还记得很久以前看HashTable的源码的时候,因为老早就听说HashTable已经被取代了——在多线程并发的情况下性能是在太差,因此看到HashTable几乎每个方法都加上Synchronized关键字之后,对这个Synchronized关键字的印象就是:锁粒度太大、开销高……等等。虽然ConcurrentHashMap性能能够提升的关键还是在于内部各种灵活的CAS方法的使用,但是这也并不能说明Synchronized锁一定比其他的锁比如ReentrantLock来得慢,因为JDK一直再为Synchronized关键字进行种种优化……

接下来就揭露一下JDK中锁优化的点点滴滴,参考文献:《深入理解JAVA虚拟机》

1. 自适应自旋锁

在使用锁完成互斥同步之时,我们知道对性能最大的影响就是阻塞的实现。因为挂起线程和恢复线程都需要交给操作系统,进入内核态来完成,而且每一次基于阻塞的线程调度都需要保存上下文等等,这给并发编程性能带来了很大的压力

如果我们临界区的数据锁定的状态只有很短的一瞬间的话,这时候还要去阻塞和恢复线程很显然是不值当的。让当前线程“原地稍等一会”再访问临界区资源时,很有可能就能访问到而不需要被真正阻塞,这就是自旋锁的思想。在JDK1.4.2时,自旋锁就已经被引入了(虽然是默认关闭状态的)。

在JDK1.4.2中,自旋锁默认次数是10次,用户可以指定参数-XX:PreBlockSpin来自行更改

到了JDK6之后,引入了自适应自旋锁。自适应代表着:自选的时间不再是固定的了,而是由JVM来监控各个锁每次获取的状态来自适应调整了。例如如果上一次自旋获取锁成功了,那么则会认为这一次自旋很大几率也能获取到锁;如果某个锁很难通过自旋获取的话,那么在以后就不会再尝试自旋获取了,因为自旋也是需要白白损失CPU时间开销的。

2. 锁消除

锁消除是指JVM在编译器运行时,会对一些虽然加上了锁但被锁的内容不存在数据竞争这种情况进行锁消除操作

编译器判断是否存在竞争是通过一个叫做逃逸分析技术来实现的:如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当做栈上的数据对待,即认为他们是线程私有的,自然就不再需要加锁操作了。

例如:

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

如果在JDK5之前,也就是锁消除没引进之前,上面这段代码经过编译器后会被优化成

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

StringBuffer内部使用了Synchronized关键字来实现线程安全

而在JDK5之后,则会变为使用不保证线程安全的StringBuilder

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

3. 锁粗化

当我们写代码时,如果将锁作用的对象范围弄得越小,看上去性能效果也应该是最好的

大多数情况下上面的操作是正确的,但是还存在一种例外情况,例如如果一系列的连续操作都对同一个对象进行反复加锁解锁的话,这会增加不必要的性能开销

JVM在探测到上面描述的——对同一个对象反复加锁解锁操作时,会将锁的范围进行放大(粗化)到整个操作序列的外部。

例如上面的SpringBuffer的例子,通常来说每一个append操作都会加锁,但是JVM优化过后会将锁的范围拓展到从第一个append开始到最后一个append结束,这样就只需要加锁/解锁一次了

4. 轻量级锁、偏向锁与锁膨胀

说到这三个内容,首先不得不提一下JVM中对象的内存布局:

  • 对象头
  • 对象数据部分
  • 对其填充(对齐到8字节的整数倍)

其中对象头又包括:

  • Mark word
  • 指向对象对应Class文件的指针
  • 数组长度标识(如果是数组的话)

其中Mark word又包括

  • 。。。还是直接看下面的图吧

轻量级锁

轻量级锁是JDK1.6时加入的新型锁机制,对比与使用操作系统Mutex互斥量来说开销十分轻量,因此得名于轻量级锁。

当然轻量级锁这个名字并不是表示他是一个能代替重量级锁的新锁,它的设计依据就是:对于绝大部分的锁,在整个同步周期内是不存在竞争的。也就是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。

工作机制

如上图所示,在代码进入同步块之前,如果同步对象没有被锁定(即Mark word最后一部分字段为01),此时JVM会

  • 在当前线程的栈帧中新建立一个锁记录,然后将当前Mark word拷贝进去
  • 使用CAS操作尝试把对象的Mark word更新为指向锁记录的指针

    • 如果CAS成功:则认为轻量级锁加锁成功,此时Mark word最后两位变为00
    • 如果CAS失败:则意味着存在多线程的竞争,此时进入下面流程

      • 检查锁对象的Mark word是否指向当前线程的栈帧

        • 是,则说明该线程已经拥有锁,可以继续执行
        • 否,则需要进行锁膨胀,记录变为10

后续的线程如果发现锁记录标志位10时,则不触发轻量级锁,直接进入阻塞状态。

可以发现,如果存在锁竞争的话,除了互斥量原本的开销之外,还额外发生了CAS操作,因此会比传统重量级锁还要慢

偏向锁

偏向锁是JDK6引入的,目的消除数据在无竞争环境下的同步原语,他与轻量级锁的区别是:

  • 轻量级锁在没竞争的情况下使用CAS来完成
  • 偏向锁在没有竞争的情况下把整个同步都消除掉(包括CAS)

其工作的过程和轻量级锁是同步进行的:

  • 锁第一次被获取时,会把上图中偏向模式从0设置为1,标志位为01
  • 继续轻量级锁的CAS操作

如果CAS成功了,持有偏向锁的线程以后每次进入这个锁相关的同步块时,JVM都不会在进行任何同步操作

一旦另一个线程去尝试获取锁的话,偏向模式马上宣告结束,根据所对象目前是否处于被锁定的状态来决定是否撤销偏向,撤销后标志位恢复到01或者00,之后的同步操作就和上面的轻量级锁一样

Last modification:July 12th, 2021 at 06:49 pm