[关闭]
@boothsun 2018-03-24T16:31:55.000000Z 字数 4399 阅读 1128

Java中锁的优化

Java多线程


参考文章
java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁
《深入理解Java虚拟机》

自旋锁

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。在大多数的应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个及以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会儿”。但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这就是自旋。

JDK1.6 默认开启自旋锁。

自旋等待不能代替阻塞,且不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能的浪费。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

适用场景:

同步操作被占用的时间很短,不会造成大量的自旋等待。

带来的问题:

  1. 过多的占据CPU时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据CPU时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞。

自适应自旋锁

自适应自旋锁的自旋时间会根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另一个方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

智能化的自旋。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

举例:

  1. // StringBuffer.append方法
  2. @Override
  3. public synchronized StringBuffer append(Object obj) {
  4. toStringCache = null;
  5. super.append(String.valueOf(obj));
  6. return this;
  7. }
  8. // 使用
  9. public static void main(String[] args) {
  10. StringBuffer sb = new StringBuffer();
  11. sb.append("a").append("b").append("c");
  12. }

每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在main方法中。也就是sb的所有引用永远不会“逃逸”到main方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

锁粗化,如果虚拟机探测到同一个线程有一连串的对同一个锁进行加锁和解锁操作,将会把加锁同步的范围扩展到整个操作序列的外部,这样就只需要加锁一次就够了

通常情况下,总是建议将同步块的作用范围限制得尽量小 —— 只在共享数据的实际作用域中才进行同步,这样是为了使执行同步代码块的时间尽可能短,锁尽可能快的释放。

大多数情况下,上面的原则都是正确的,但是如果有一系列的连续操作(在同一个线程中)都对同一个对象反复进行加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

  1. // 粗化前
  2. for( int i=0 ; i<100000 ; i++ ){
  3. synchronized(Object.class){
  4. doSomeThing();
  5. }
  6. }
  7. // 粗化后
  8. synchronized(Object.class){
  9. for( int i=0; i < 100000 ; i++ ){
  10. doSomeThing();
  11. }
  12. }

锁的优化方案

减少锁持有时间

如下面的代码:

  1. public synchronized void test() {
  2. executeMethod1();
  3. multiThreadExecute();
  4. executeMethod2();
  5. }

如果真正存在资源的竞争,需要加锁的函数是multiThreadExecute(),其他两个函数executeMethod1和executeMethod2都没有资源的竞争时,这样写只会增加线程持有锁的时间,就会导致其他线程等待这个锁的时间增长,影响性能。这种情况下,应该修改为:

  1. public void test() {
  2. executeMethod1();
  3. synchronized (this) {
  4. MultiThreadExecute();
  5. }
  6. executeMethod2();
  7. }

减小锁粒度

这个思路最典型的例子就是JDK中的重要成员ConcurrentHashMap,ConcurrentHashMap将整个区间分成若干个Segment(默认是16个),每一个Segment都是一个子map,每个Segment都拥有自己的一把锁。当需要向map中插入数据时,并不是先申请所有的锁,而是根据需要插入的数据的key的hashcode计算出应该从插入到哪一个Segment,然后再申请这个Segment的锁。所以理想情况下,ConcurrentHashMap最多可能有16个线程真正同时插入数据。

但是较小锁粒度会有一个问题:如果需要访问全局数据(这时需要取得全局锁),消耗的资源会比较多。以ConcurrentHashMap为例,put操作使用分段锁提高了并发,但是size()函数却没那么幸运,size函数返回map中所有有效的元素个数,所以需要访问所有数据,也就需要取得所有的锁,损耗的性能是比较多的。

锁分离

同样以JDK中的重要成员LinkedBlockingQueue为例,take()put()函数分别从队列中取得数据和向队列中添加元素。因为LinkedBlockingQueue是链表实现的,takeput操作分别在队头和队尾操作,互不影响,所以这两个操作就不应该公用一把锁。下面是jdk中LinkedBlockingQueue的代码的一部分:

  1. /**
  2. * Tail of linked list.
  3. * Invariant: last.next == null
  4. */
  5. private transient Node<E> last;
  6. /** Lock held by take, poll, etc */
  7. private final ReentrantLock takeLock = new ReentrantLock();
  8. /** Wait queue for waiting takes */
  9. private final Condition notEmpty = takeLock.newCondition();
  10. /** Lock held by put, offer, etc */
  11. private final ReentrantLock putLock = new ReentrantLock();
  12. /** Wait queue for waiting puts */
  13. private final Condition notFull = putLock.newCondition();

可以看到分别定义了takeLock和putLock,这两个操作不适用同一把锁,削弱了锁竞争的可能性,提高了性能。

锁粗化

所谓的锁粗化就是如果代码中有连续的对同一把锁的申请操作,则需要考虑将这些锁操作合并为一个。比如:

  1. // 粗化前
  2. public void test() {
  3. synchronized (this) {
  4. // do sth
  5. }
  6. synchronized (this) {
  7. // do sth
  8. }
  9. }
  10. // 粗化后
  11. public void test() {
  12. synchronized (this) {
  13. // do sth
  14. }
  15. }

锁粗化的思想和减少锁持有时间是相反的,但是在不同的场合下,他们的效果并不相同,需要我们权衡利弊再做决策。

使用自旋锁 + CAS的形式

如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

消除缓存行的伪共享

除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。

在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。

例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;

为了防止伪共享,不同jdk版本实现方式是不一样的:

  1. 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
  2. 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
  3. 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:
    -XX:-RestrictContended

sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注