Java并发之锁优化

引语

我们在java开发中常可以听到各种各样的锁,乐观锁+悲观锁、共享锁+独占锁、公平锁+非公平锁、读写锁、可重入锁、分段锁…那么为什么会有这么多的锁的类型嘞?

其实java对锁的分类并没有一个非常严格的限制,我们常见的分类一般都是从锁的特性、锁的设计思想、锁的状态等不同角度来整理的。比如ReentrantLock既是独占锁(从锁的状态来说,阻塞),也是可重入锁(锁特性,同一个同步代码块是可重入的)。

那么java的锁确实是一种线程间共享数据的有效手段,但是有些时候它的表示可能不是那么令人满意。所以JDK1.5到JDK1.6,HotSpot团队花费了大量的精力去实现各种锁优化技术,如适应性自旋锁、锁消除、锁粗化、轻量级锁和偏向锁等,让线程之间的共享变得更高效,提高了执行效率

本文的重点是第4点到第8点讲述的是java锁优化技术的实现,前3点算是一些基础的知识。

1、CAS

CAS(Compare and Swap 比较并交换),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。

Java中的乐观锁就是通过CAS操作来实现的,当然顺便要说明一下,CAS的实现是需要硬件来支持的。

ABA问题

  • 线程1(上):获取出数据的初始值是A,后续准备进行CAS操作,期望数据仍是A的时候,修改才能成功
  • 线程2:将数据修改成B
  • 线程3:将数据修改回A
  • 线程1(下):CAS乐观锁,检测发现初始值还是A,进行数据修改

虽然可以操作成功,但是数据的版本已经发生变化了,所以常见的解决方法就是加上一个版本号,这样即使数据相同,也不会修改成功。

2、线程阻塞的代价

因为java线程的实现是映射到操作系统上完成的,所以阻塞和唤醒一个线程需要在用户态和内核态之间进行转化,这样的转换会消耗很多的系统资源,因为用户态和内核态拥有不同的内存空间,寄存器等。

  • 如果线程状态切换很频繁时会消耗很多的资源,降低程序性能
  • 如果一个线程在挂起和恢复的过程消耗的时间比同步代码执行时间更长的话,那这种同步手段是很糟糕的。

java关键字synchronized在JDK1.6之前只要发生锁的竞争,那么就会阻塞,因此被称为重量级锁,1.6以后引入了锁升级的策略,即无锁----偏向锁----轻量级锁-----重量级锁。

3、预备知识

对象头(Mark Word)的结构(32位),下面会用到
在这里插入图片描述
64位虚拟机对象头长这个样子
在这里插入图片描述
锁状态对应的锁标志位
在这里插入图片描述

4、偏向锁

目的:消除数据在无竞争情况下的同步,进一步提高程序的运行性能。
说明:“偏”就是指偏心,意思就是锁会偏向于第一个获得它的线程,如果接下来的执行过程中,没有其他线程来获取这个锁的话,那么持有偏向锁的线程将不需要再同步。

获取偏向锁的步骤

  • (1)查看Mark Word中偏向锁的标识以及锁标志位,若是否偏向锁为1且锁标志位为01,则该锁为可偏向状态。
  • (2)若为可偏向状态,则测试Mark Word中的线程ID是否与当前线程相同,若相同,则直接执行同步代码,否则进入下一步。
  • (3)当前线程通过CAS操作竞争锁,若竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行同步代码,若竞争失败,进入下一步。
  • (4)当前线程通过CAS竞争锁失败的情况下,说明有竞争。当到达全局安全点时之前获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

偏向锁的释放:

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁状态的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销需要等待全局安全点(即没有字节码正在执行),它会暂停拥有偏向锁的线程,撤销后偏向锁恢复到未锁定状态或轻量级锁状态。

具体过程:它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

为什么设计偏向锁

偏向锁的目标是为了消除昂贵的CAS操作和互斥量的开销,那么除了重偏向这种情况,那么似乎只有第一次加锁时候才有用,那么我们会对没有竞争的代码加锁吗?()

答案是会的,有下面几种情况:

  • 1、类加载其实是加锁的,我们可以尝试并发地进行类加载,尽管大多情况下这由main线程完成.
  • 2、一些旧版本的库,如使用Vector,使用HashTable,使用Collections.synchronize系列,在绝对不会出现线程逃逸的情况下使用StringBuffer拼接字符串,单线程使用了某些库中加了同步的代码等

单个偏向锁的重偏向

举例

偏向锁的一些参数

//开启偏向锁,1.6以后默认开启
-XX:+UseBiasedLocking 
//关闭偏向锁
-XX:-UseBiasedLocking 
//默认情况下jvm启动前几秒是不使用偏向锁的
-XX:BiasedLockingStartupDelay=0 //立刻启动

测试代码如下

public class Mytest {
        public static void main(String[] args) {
            long time1 = System.currentTimeMillis();
            Vector<Integer> vector = new Vector<>();
            for (int i = 0; i < 100000000; i++){
                vector.add(100);//add是synchronized操作
            }
            System.out.println(System.currentTimeMillis() - time1);
        }
}
测试环境:JDK 1.8
不使用偏向锁:6140
开启偏向锁,并立即启动:2333

开启偏向锁以后效率明显提高。

使用偏向锁注意事项

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用,高并发的应用会禁用掉偏向锁。

5、轻量级锁

说明:轻量级锁的原理就是,持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

轻量级锁是由偏向锁(开启偏向锁)升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

轻量级锁获取锁的步骤

  • 1、在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位 为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
    在这里插入图片描述
  • 2、拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果成功转向3,失败转向4
  • 3、如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
    在这里插入图片描述
  • 4、如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明锁对象被其他线程抢占了。如果两个以上线程争用同一个锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

轻量级锁释放锁的步骤

轻量级解锁时,会使用原子的CAS操作来实现,如果发现mark word依然指向线程的锁记录,那就将Displaced Mark Word替换回到对象头,如果替换成功,那么整个同步过程就完成了。如果失败,表示当前锁被其他线程试图获取过,锁就会膨胀成重量级锁。那么在释放锁的过程中,还需要唤醒等待的线程。

轻量级锁的好处

减少了互斥量的开销,对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗。

使用轻量级锁注意事项

如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用轻量级锁了,因为轻量级锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取cpu,造成cpu的浪费。

参数:

-XX:+UseSpinning //开启轻量级锁,默认

偏向锁和轻量级锁转换以及对象Mark Word的关系如图
在这里插入图片描述
以上说的是锁升级的事情,而这只是锁优化实现的一部分,下面来说明其他的几种锁优化技术

6、自旋锁和自适应自旋锁

互斥对同步最大的影响就是阻塞,挂起和唤醒的过程需要进行用户态和核心态的转变,这样会浪费很大资源。自旋锁就是说获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁 。JDK1.6以后默认开启自旋锁。

当然自旋并不能代替阻塞,虽然自旋避免了线程切换的开销,但是要占用cpu时间。所以,如果占有锁的时间很短,那么自旋等待的效果就很好,如果锁占有的时间很长,自旋就会白白浪费时间。因此自旋的次数时有限度的,如果超过限度还没有获取锁的话,就会阻塞。默认自旋次数为10,通过
-XX:PreBlockSpin来设置。

JDK1.6以后引入自适应的自旋锁。自适应就是时间不固定,而是由上一个在同一个锁上的自旋时间以及锁拥有者的状态来决定的。如果一个锁,自旋很少成功获得过的话,以后获取这个锁可能就不进行自旋了。

7、锁消除

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

举个例子:

public String concatString(String s1, String s2, String s3) {
        return s1 + s2 + s3;
    }

因为String是一个不可变的类,对于字符串的连接也是通过生成新的String对象来进行。编译器会对string连接做优化,JDK1.5以前会转化为StringBuffer的append操作,JDK1.5以后的版本中转化为StringBuilder的append操作。
那么上面的例子在编译以后就可能变成下面的样子。

public String concatString(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
   }

那么这个代码中有用到同步么?当然,StringBuffer是线程安全的,每一个方法都是一个同步块。虚拟机通过观察发现,sb的引用不会逃逸出concatString()方法,所以这里的锁会被安全消除。
注:我这里只是为了举例子,JDK1.5以后当然就是StringBuilder来作拼接啦

8、锁粗化

原则上,我们应该将同步快作用范围限制的尽可能小——只在共享数据的实际作用域中使用同步,这是为了让同步的操作数量尽可能少,这样如果存在锁竞争,等待锁的线程也能尽快拿到锁。

大部分情况这样都是正确的,但是如果一系列的连续操作都是对同一个对象反复加锁解锁,甚至加锁操作出现在循环体中,那么即使没有线程竞争,频繁的互斥行为也会造成性能损耗。

上面锁消除中的代码中连续的append()就是这种情况,如果虚拟机发现了这样一串操作都是对同一个对象加锁,那么会扩大同步范围(粗化)至整个操作序列的外部。在上述代码中就是第一个append()操作到最后一个append()操作之后,这样只需要加锁一次即可。

总结

1、java的锁升级策略,无锁->偏向锁->轻量级锁->重量级锁
2、自旋锁和自适应的自旋锁
3、锁消除,基于逃逸分析的数据结果
4、锁粗化,针对一个对象的连续加锁行为

参考文章:
《深入理解JVM》(周志明 著)
Java中的偏向锁、轻量级锁与重量级锁
偏向锁、轻量级锁、自旋锁、重量级锁
关于偏向锁,安全点,JIT的一些暗坑.

猜你喜欢

转载自blog.csdn.net/machine_Heaven/article/details/104837155