锁的优化
在并发的程序应用中,锁的竞争必然导致程序的性能下降。为了将这种副作用降到最低,下面提出一些建议可以帮助大家写出性能更加优越的程序。
减少锁持有的时间
对于使用锁进行并发控制的应用程序而言,在锁竞争的过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间很长,那么相对的,锁的竞争程度也就越激烈。可以想象一下,如果要求100个人各自填写自己的身份信息,但是只是给他们一支笔,那么如果每个人拿着笔的时间很长,总体所花的时间就会很长,。如果真的只能有一支笔共享给100个人用,那么最好就让每个人花尽量少的时间持笔,务必做到想好了才拿笔写,千万不可拿着笔再去想着怎么填。程序的开发也是类似的,应该尽可能的减少对某个锁的持有时间,以此来减少线程间互斥的可能。以下面的代码为例:
public synchronized void syncMethod(){ otherCode(); otherCode1(); otherCode2(); }
syncMethod()方法中,假设只有otherCode()方法需要同步,而其他两个方法是不需要的做同步控制的,这时一个较为优化的解决方案就是,只有在必要的时候才进行同步,这样就能明显减少锁的持有时间,提高系统的性能,如下所示:
public void syncMethod(){ synchronized (this){ otherCode(); } otherCode1(); otherCode2(); }
减小锁粒度
减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。对于HashMap来说,最重要的两个方法就是put()和get()。一种最自然的做法就是对整个HashMap加锁,这样必然会得到一个线程安全的对象,但是这样做,我们就认为加锁的粒度太大。对于ConcurrentHashMap,它的内部进一步细分了若干个小的HashMap,称之为段。默认的情况下,一个ConcurrentHashMap被进一步的细分为16个段。这样如果幸运的话,ConcurrentHashMap可以接收16个线程同时操作,这样相对于HashMap来说,极大的提高了吞吐量。ConcurrentHashMap的实现原理,会在以后的文章中,专门讲述,这里就不深入探究了。
所谓的减小锁粒度,就是指缩小锁定对象的范围,从而减少冲突的可能性,进而提高系统的并发能力。
读写分离锁替换独占锁
读写锁ReentrantReadWriteLock(读写锁)可以提高系统的性能,使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况,读写锁是通过对系统的功能点的分割来提高性能的。读写锁的使用有应用场景的限制,在读多写少的场合下,使用读写锁可以有效的提高系统的并发能力。由于之前的文章已经讲述了读写锁,因此就不在这里重复了。
锁分离
锁分离,就是在读写锁的思想上进一步的延伸。使用独占锁类似的分离思想,我们也可以对独占锁进行分离。一个典型的案例就是LinkedBlockingQueue,在文章数据共享通道:BlockingQueue里面有讲到。
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁,只有这样,等待在这个锁资源上的其他线程才能尽早的获得锁资源并执行任务,但是,凡是都有一个度,如果对同一个锁不停的进行请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
为此,虚拟机在遇到一连串连续的对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的同步次数,这个操作就叫做锁的粗化。比如:
public void syncMethod(){ synchronized (lock){ // do sth } synchronized (lock){ // do sth1 } }
会被JVM整合成:
public void syncMethod(){ synchronized (lock){ // do sth // do sth1 } }
注意:
性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少持有锁时间是相反的,但在不同的场合,它们的效果并不相同。所以大家需要根据实际情况,进行权衡。
Java虚拟机对锁优化所做的努力
锁偏向
锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么所就进入了偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁就有比较好的优化效果,因为连续多次极有可能是同一个线程请求的锁。而对于锁竞争比较激烈的场合,其效果不佳,因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这样,偏向模式就会失败,因此还不如不启用偏向锁。
Java虚拟机参数:-XX:+UseBiasedLocking 可以开启偏向锁模式。
轻量级锁
如果偏向锁失败,虚拟机并不会立即挂起线程。它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很轻便,它只是简单地将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区;如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。
自旋锁
锁膨胀后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还会做最后的努力—自旋锁。由于当前线程暂时无法获得锁,但是什么时候可以获得锁是一个未知数。也许在几个CPU周期后,就可以得到锁。如果这样,简单粗暴的挂起线程可能是一种得不偿失的操作。因此,系统会进行一次赌注:它假设在不久的将来,线程就可以得到这把锁。因此。虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么顺利的进入临界区。如果还不能获得锁,才会真实的将线程在操作系统层面挂起。
锁消除
锁消除是一种更彻底的锁优化。Java虚拟机在JIT(Just In Time Compiler)即时编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。
那么就有一个疑问,既然没有锁竞争,为什么程序员还要加上锁呢?
这是因为在Java开发软件的过程中,我们必然会用到一些JDK的内置API,比如StringBuffer、vetor等,我们在使用这些类的时候,也许根本不会考虑这些对象到底是如何实现的。比如,你很可能在一些不可能存在并发的场合下使用了Vetor(只是你以为存在并发),而众所周知,Vetor内部使用了synchronized请求锁,比如代码如下:
1 public String[] createString(){ 2 Vector<String> vector = new Vector<String>(); 3 for (int i = 0;i < 100 ;i++){ 4 vector.add(Integer.toString(i)); 5 } 6 return vector.toArray(new String[]{}); 7 }
注意上述代码中的Vetor,由于变量vetor只在createString()函数中使用,因此,它只是一个单纯的局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。所以,在这种情况下,Vetor内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁去掉。
锁消除涉及的一项关键技术为逃逸分析。所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。在上述的例子中,变量vetor显然没有逃出createString()方法之外,以此为基础,虚拟机才会将vetor的锁去除。如果createString()返回的不是String数组,而是vetor本身,那么就认为变量vetor逃出了当前方法,也就是说变量vetor有可能被其他线程访问,如果是这样,虚拟机就不能消除vetor的锁。
逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks 参数可以打开锁消除。
参考:《Java高并发程序设计》 葛一鸣 郭超 编著: