06、多线程的代价

线程的简单回顾

在操作系统中引入多线程的原因是进程切换的开销太大,进程在进行上下文切换时由于要切换页表,往往伴随者页调度,因此开销比较大,而线程在进行上下文切换时,由于仅涉及与自身相关的寄存器状态和栈的信息(线程的上下文环境主要包含寄存器的值、程序计数器、栈指针),因此开销比较小。所以,充分利用多线程可以提供系统的执行效率以及充分利用资源。

在多个线程进行协作完成任务时,由于涉及到资源的共享与数据的同步,所以在多线程程序中需要使用额外的线程同步机制保证程序执行的正确性。正确性是性能优化的前提,在保证正确运行的前提下,考虑如何充分利用现有资源提升性能,多线程程序因为会发生线程切换和重新调度的开销,所以性能会收到影响。

对于单线程程序而言,既不需要线程调度,又不需要同步的开销,而且不需要使用锁来保证数据结构的一致性,所以单线程在一定情况下表现更好。但是对于相互独立且同构的任务来说使用单线程不仅不能充分利用多核处理器的优势,而且程序的性能的可伸缩性也是非常差的。所以,使用多线程带来的性能提升肯定是大于并发导致的开销的。

多线程的代价

总结起来,使用多线程代价有三个方面:

  • 设计更加复杂

因为在并发编程中需要保证数据的完整性和运行的正确性,需要使用同步机制来保证这一点。比如在并发编程中使用的锁,锁本省也是一种开销,而且还需要维护锁对程序的同步,自然设计就更加复杂了。另外,因为多线程运行出现bug,往往具有不可恢复性

  • 上下文切换的开销

CPU运行是有时钟周期的,一个时钟周期只能运行一个线程,所以对于多线程程序,需要进行线程的切换和调度。这个过程CPU需要保存现场信息,然后调度新的线程执行任务。这个过程称为上下文切换。

  • 导致更多的资源消耗

在多线程编程中,线程需要一些内存在维持线程本地栈,每个线程都有本地独立的栈用以存储线程专用数据,需要内存开销自然就更大了。同步操作导致的开销也是一个重要方面。

切换上下文需要在线程调度过程中访问由操作系统和JVM共享的数据结构,而应用程序、操作系统和JVM都是同一组相同的CPU,在操作系统和JVM中消耗的CPU时钟越多,应用程序可用的CPU时钟就越少。另外,如果新调度进来的线程需要访问的数据不再处理器的高速缓存中,那么会产生缺页异常,操作系统会发生中断(陷阱),操作系统需要从其他地方加载缺失的数据,一旦发生缺页中断,执行速度更加缓慢。

对于因为同步操作增加的性能开销包括多个方面,以synchronized和volatile提供的可见性为例,JMM(Java内存模型)会使用一些特殊的指令保证缓存被更新(具体是使用缓存失效机制,其他处理器通过嗅探技术来判断自己所缓存的数据是否失效),这些内存指令也称为“内存栅栏”。但是“内存栅栏”可能会对编译器的优化产生影响。

进一步分析内存同步

“内存栅栏”对性能的影响还是有限的,JMM的设计足够友好,可能不用多这方面担心太多。对于同步,可以分为有竞争的同步和无竞争的同步。无竞争的同步对开销足够小,所以对应用程序的性能影响也是有限的。主要导致性能瓶颈的一般都是有竞争的锁,而且竞争越激烈,对程序性能的影响也越大,整个程序可能完全无法继续执行。

在之前的文章提到过,锁的范围过大或者一次持有多个锁都是不正确的。虽然现代JVM对锁进行一些优化,比如可以判断出当前使用的锁如果不存在竞争,那么就会执行“锁消除”。锁消除最简单的解释就是,如果程序使用的锁不存在竞争,那么就不使用锁。而且如果JVM检测到临近的代码块如果使用了不同的锁,但是这些不同的锁除了锁不同外其他并没有什么不同,那么会执行“锁粗化”——将多个锁的操作合并为一个锁的操作,去除不必要的同步。