如果主线程是唯一可调度的线程,它决不会被排除在调度之外。从另一方面看,如果可运行的线程数大于CPU的数量,那么OS最终会强行换出正在执行的线程,从而使其他线程能够使用CPU。这会引起上下文切换,它会保存当前运行线程的执行上下文,并重建新调入线程的执行上下文。
切换上下文是要付出代价的;线程的调度需要操控OS和JVM中共享的数据结构。你的程序与OS、JVM使用相同的CPU;CPU在JVM和OS的代码花费越多时间,意味着用于你的程序的时间就越少。但是JVM和OS活动的花费并不是切换上下文开销的唯一来源。当一个新的线程被换入后,它所需要的数据可能不在当前处理器本地的缓存中,所以切换上下文会引起缓存缺失的小恐慌,因此线程在第一次调度的时候会运行得稍慢一些。即使有很多其他正在等待的线程,调度程序也会为每一个可运行的线程分配一个最小执行时间的定额。就是因为这个原因:它分期偿付切换上下文的开销,获得更多不中断的执行时间,从整体上提高了吞吐量(以损失响应性为代价)。
清单11.2 徒劳的同步(不要这样做)

// 工作代码
}
当线程因为竞争一个锁而阻塞时,JVM通常会将这个线程挂起,允许它被换出。如果线程频繁发生阻塞,那线程就不能完整使用它的调度限额了。一个程序发生越多的阻塞(阻塞I/O,等待竞争锁,或者等待条件变量),与受限于CPU的程序相比,就会造成越多的上下文切换,这增加了调度的开销,并减少了吞吐量。(无阻塞的算法同样能够帮助我们减小上下文切换;参见第15章。)
切换上下文真正的开销根据不同的平台而变化,但是一条好的经验性原则是:在大多数通用的处理器中,上下文切换的开销相当于5 000到10 000个时钟周期,或者几微秒。
Unix 系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数和内核占用的时间等信息。高内核占用率(超过10%)通常象征繁重的调度活动,这很可能是由I/O阻塞,或竞争锁引起的。
11.3.2 内存同步
性能的开销有几个来源。synchronized和volatile提供的可见性保证要求使用一个特殊的、名为存储关卡(memory barrier)的指令,来刷新缓存,使缓存无效,刷新硬件的写缓冲,并延迟执行的传递。存储关卡可能同样会对性能产生影响,因为它们抑制了其他编译器的优化;在存储关卡中,大多数操作是不能被重排序的。
在我们评估同步给性能带来影响的同时,区分竞争同步和无竞争同步也是非常重要的。synchronized机制对无竞争同步进行了优化(volatile总是非竞争的),在写作本书的时候,一个普通“fast-path”的非竞争同步,其性能开销在20至250个时钟周期。虽然这个开销不为零,但是它产生的影响已经微乎其微了。另一种选择是对安全性进行妥协,把自己陷入痛苦地搜寻bug的行动来保证你(或者你的后续事务)的安全。
现代的JVM能够通过优化,解除经确证不存在竞争的锁,从而减少额外的同步。如果一个锁对象只能由当前线程访问,那么允许JVM对其优化,去除对这个锁的请求,因为其他线程根本无法在同一个锁发生同步。例如,JVM总是能够消除类似清单11.2中对锁的请求。
更加成熟的JVM可以使用逸出分析(escape analysis)来识别本地对象的引用并没有
在堆中被暴露,并且因此成为线程本地的。在清单11.3的getStoogeNames中,对List仅有的引用是本地变量stooges,栈限制(stack-confined)的变量自动默认为线程本地的。在本地执行getStoogeNames,至少需要获取/释放Vector的锁4次,每个add一次,toString一次。然而,一个聪明的运行时编译器,能够合并这些调用,然后发现stooges和它的内部状态一直都没有逸出,因此这4次对锁的请求就可以被消除了4。
清单11.3 锁省略的候选程序
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
即使没有逸出分析,编译器同样可以进行锁的粗化(lock coarsening),把邻近的synchronized块用相同的锁合并起来。在getStoogeNames中,JVM如果进行锁的粗化,可能会把3个add调用结合起来,并对toString使用单独的锁请求和释放,在synchronized块的内部,利用启发式方法产生同步开销,而不是指令式方法5。这不仅仅减少了同步的开销,同时也给予优化者更大的代码块,很可能成就了进一步的优化。
不要过分担心非竞争的同步带来的开销。基础的机制已经足够快了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销。关注那些真正发生了锁竞争的区域中性能的优化。 |
一个线程中的同步也可能影响到其他线程的性能。同步造成了共享内存总线上的通信量;这个总线的带宽是有限的,所有的进程都共享这条总线。如果线程必须竞争同步带宽,所有使用到同步的线程都会受阻6。
11.3.3 阻塞
非竞争的同步可以由JVM完全掌控(Bacon 等,1998);而竞争的同步可能需要OS的活动,这会增大开销。当锁为竞争性的时候,失败的线程(一个或多个)必然发生阻塞。JVM既能自旋等待(spin-waiting,不断尝试获取锁,直到成功),或者在操作系统中挂起(suspending)这个被阻塞的线程。哪一个效率更高,取决于上下文切换的开销,以及成功地获取锁需要等待的时间这两者之间的关系。自旋等待更适合短期的等待,而挂起适合长时间等待。有一些JVM基于过去等待时间的数据剖析来在这两者之间进行选择,但是大多数等待锁的线程都是被挂起的。
需要挂起线程可能因为线程无法得到锁,或者因为它正在等待某个条件,亦或被I/O操作阻塞。挂起需要两次额外的上下文切换,以及OS和缓存的相关活动:阻塞的线程在它时间限额还没有到期前就被换出,稍后如果能够获得锁或者其等待的资源,又会再被换入。(阻塞归因于锁的竞争,线程持有这样的锁:当它释放该锁的时候,它必须通知OS,重新开始因该锁而阻塞的线程。)