同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(也就是我们前面文章介绍过的内存屏障)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数的操作都是不能被重排序的。
在评估同步操作带来的性能影响时,需要区分有竞争的同步和无竞争的同步。现代的JVM可以优化一些不会发生竞争的锁,从而减少不必要的同步开销。
synchronized(new Object()){...}JVM会通过逃逸分析优化掉以上的加锁。
所以,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
阻塞
非竞争的同步可以完全在JVM中处理,而竞争的同步可能需要操作系统的介入,从而增加系统的开销。在锁上发生竞争时,竞争失败的线程会被阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waitiin,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间短,就采用自旋等待方式;如果等待时间长,则适合采用线程挂起的方式。JVM会分析历史等待时间做选择,不过,大多数JVM在等待锁时都只是将线程挂起。
线程被阻塞挂起时,会包含两次的上下文切换,以及所有必要的操作系统操作和缓存操作。
减少锁的竞争串行操作会降低可伸缩性,并且上下文切换也会降低性能。当在锁上发生竞争时会同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。如果在锁上发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率和每次持有该锁的时间。(Little定律)
如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。
下面介绍降低锁的竞争程度的方案。
缩小锁的范围
降低发生竞争的可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移除代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作(I/O操作)。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能太小,因为会有一些复合操作需要以原子操作的方式进行,这时就必须在同一同步块中。
减小锁的粒度
另一种减少锁的持有时间的方式便是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,这些技术中将采用多个相互独立的锁来保护相互独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能缩小锁操作的粒度,并能实现更高的可伸缩性。但是需要注意,使用的锁越多,也就越容易发生死锁。
锁分解如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
例如,如下的程序我们便可以进行锁分解。(例子来自《Java并发编程实践》)
@ThreadSafe // 该注解表示该类是线程安全的 public class ServerStatus { // @GuardedBy(xxx)表示该状态变量是由xxx锁保护 @GuardedBy("this") public final Set<String> users; @GuardedBy("this") public final Set<String> queries; public ServerStatusBeforeSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } public synchronized void addUser(String u) { users.add(u); } public synchronized void addQuery(String q) { queries.add(q); } public synchronized void removeUser(String u) { users.remove(u); } public synchronized void removeQuery(String q) { queries.remove(q); } }