自适应的自旋锁
什么是自旋?字面意思是 "自我旋转" 。在 Java 中也就是循环的意思,比如 for 循环,while 循环等等。那自旋锁顾名思义就是「线程不放过 CPU,一直循环地去获取锁,直至获取到才去执行任务,否则一直在自旋」。
上一章聊自旋锁的时候,我们知道 AtomicXxx 类都是 java 自旋锁的体现。通过 AtomicInteger 源码来回忆一下,自旋锁的原理:
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while (!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
代码中使用一个 do-while 循环来一直尝试修改 int 的值。自旋的缺点在于如果自旋时间过长,那么性能开销是很大的,浪费了 CPU 资源。
JDK1.6 之前可以使用 「-XX:+UseSpinning」 来开启自旋锁,在 JDK1.6 之后默认开启。同时自旋的默认次数为 10 次,可以通过参数 「-XX:PreBlockSpin」 来调整次数,但会带来诸多的不便。
比如:我设置为 10 次,但系统中很多线程都是等自旋线程刚退出的时候就释放锁(加入自旋线程多旋 1、2 次就能成功几个获取锁),这个时候就很尴尬了。首先我没办法判断我要自旋多少次。
所以,在 「JDK 1.6」 中引入了自适应的自旋锁来解决这个问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的「 成功率、失败率,以及当前锁的拥有者的状态」等多种因素来共同决定。具体规则如下:
- 自旋次数通常由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。如果「线程 A」 自旋成功,自旋次数为 17 次,那么等到下一个「 线程 B」 自旋时,也会默认认为「 线程 B」 自旋 17 次成功,
- 如果「线程 B」 自旋了 5 次就成功了,那么此时这个自旋次数就会缩减到 5 次。
自适应自旋锁随着程序运行和性能监控信息,从而「使得虚拟机可以预判出每个线程大约需要的自旋次数」
锁消除
在聊锁消除之前,可能得先聊两个概念,一个叫 「JIT」,一个叫「 逃逸分析」。本文只简单介绍下他们的概念,具体的原理,篇幅原因就不说了。以后单独写一篇来聊。
- JIT(Just In Time)即时编译器,是一种优化手段。「JVM 在编译时会发现某个方法或代码块运行特别频繁的时候,就会认为这是 “热点代码”(Hot Spot Code)。然后 JIT 会把部分 "热点代码" 翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用」。HotSpot 虚拟机中内置了两个 JIT 编译器:Client Complier 和 Server Complier。
- 逃逸分析:一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
简单来说,它的基本行为就是:「当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸」。我们最常见的就是静态变量 (类变量) 赋值,称为线程逃逸
在 Java 代码运行时,通过 JVM 参数可指定是否开启逃逸分析:
- -XX:+DoEscapeAnalysis :表示开启逃逸分析
- -XX:-DoEscapeAnalysis :表示关闭逃逸分析 从 jdk 1.7 开始已经默认开始逃逸分析,如需关闭,需要指定 - XX:-DoEscapeAnalysis
举个栗子:
/** * 方法一 **/ public static StringBuffer craeteStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb; } /** * 方法二 **/ public static String createStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }
如上,方法一中的 sb 就逃逸了,而方法二中的 sb 并没有逃逸,因为方法二的 sb 对象的作用域只在方法内,而法一直接把对象返回去调用方了。
逃逸分析可以对代码做 3 个优化:同步省略、将堆分配转化为栈分配以及分离变量或标量替换。「同步省略就是我们常说的锁消除」。
举个例子:下面方法,我想打印一个对象,我担心出现线程安全问题,加了个锁。
public void print() { Object dog = new Object(); synchronized(dog) { System.out.println(dog); } }
但是 JIT 编译时借助逃逸分析发现 dog 对象的生命周期只在 print 方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉。优化成:
public void print() { Object dog = new Object(); System.out.println(dog); }
「所以,在使用 synchronized 的时候,如果 JIT 经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除」。
锁粗化
锁粗化,「如果我们释放了锁,紧接着什么都没做又或者做一些不需要同步且耗时很短的操作,又重新获取锁」。代码如下:
public void lockCoarsening() { synchronized(this) { //do something } synchronized(this) { //do something } synchronized(this) { //do something } }
你也发现了,其实这种释放和重新获取锁是完全没有必要的。JVM 会这么优化:把同步的区域扩大,尽量避免不必要的加解锁操作。
public void lockCoarsening() { synchronized(this) { //do something //do something //do something } }
有经验的朋友可能会想到在循环的场景下,有如下代码:第一段代码会被优化成第二段代码的样子。这时就要注意了,这个循环的耗时是非常长的吗?如果是,这就会导致其他线程长时间无法获得锁。「所以,这里的锁粗化不适用于循环的场景,仅适用于非循环的场景」。
for (int i = 0; i < 1000; i++) { synchronized(this) { //do something } } //优化 synchronized(this) { for (int i = 0; i < 1000; i++) { //do something } }
偏向锁 / 轻量级锁 / 重量级锁
介绍一下偏向锁、轻量级锁和重量级锁,这三是指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。
- 偏向锁
❝一个对象在被初始化后,如果还没有任何线程来获取它的锁时,它就是可偏向的,当有第一个线程来访问它尝试获取锁的时候,它就记录下来这个线程,如果后面尝试获取锁的线程正是这个偏向锁的拥有者,就可以直接获取锁,开销很小。
- 轻量级锁
❝synchronized 中的代码块是被多个线程交替执行的,也就是说,并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决。这种情况下,重量级锁是没必要的。轻量级锁指当锁原来是偏向锁的时候,被另一个线程所访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的方式尝试获取锁,不会阻塞。
❞
- 重量级锁
❝利用操作系统的同步机制实现,所以开销比较大。当多个线程直接有实际竞争,并且锁竞争时间比较长的时候,此时偏向锁和轻量级锁都不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
❞
锁升级的路径
根据前面的描述,我们知道:「偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差」。所以,JVM 默认优先使用偏向锁,有必要才会逐步。