synchronized 与高性能并发

May 2018 · 1 minute read

自Java 1.5推出java.util.concurrent包以后,其中ReentrantLock以其高效的性能,灵活的控制权得到了大家的喜爱。相比笨重的synchronized则成为低效的代名词,被人逐渐放弃。直到今天依然有很多程序员认为Lock比synchroized性能更高。

实际上作为亲儿子,synchronized自Java 1.6后得到了大量的优化,引入了“锁消除”、“锁粗化”、“偏向锁”、“轻量级锁”、“适应性自旋”等技术减少锁操作的开销。

锁消除(Lock elision)

HotSpot通过逃逸分析检测到一个对象不存在外部竞争,会通过锁消除来节省无意义的锁请求时间,以提高性能。如:

<code class="java">public String hello() {
   StringBuffer sb = new StringBuffer();
   sb.append("Hello");
   sb.append("World");
   return sb.toString();
} 
</code>

在此代码中,HotSpot可以明显分析出sb对象并未逃逸出hello方法外,因此可放心大胆的将synchroized锁同步操作移除。

锁粗化(Lock coarsening)

某些情况下,我们需要循环多次调用如Vector.add StringBuffer.append等方法,若每次均申请、释放锁,无疑是很大的资源浪费。因此HotSpot若检测到对同一个对象连续的加解锁动作,会将其合并为一个更大的锁。如:

<code class="java">public void addStooges(Vector v) {
    v.add("Hello");
    v.add("word");
    v.add("!");
}
</code>

在此代码中,HotSpot会将锁的申请与释放粗化至整个方法体,一次完成锁的申请与释放。

偏向锁(Biased Locking)

锁存在4种状态,依次为:无锁、偏向锁、轻量级锁、重量级锁。随着竞争激烈逐渐升级,锁只可升级不可降级。

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。

获取锁

  1. 检测Mark Word(Java对象头,4字节32位)是否为可偏向状态,即是否为偏向锁1,锁标识位为01。
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3)。
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4)。
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。

释放锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态。
  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态。

轻量级锁(Lightweight Locking)

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3)。
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3)。
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

释放锁

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3)。
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
  4. 对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

重量级锁

重量级锁本质是依赖于底层操作系统的互斥锁实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

自旋与适应性自旋

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

所谓自旋就是执行一段代码,避免进入挂起状态,看持有锁的线程是否会释放锁。最简单的自旋代码就像:

<code class="java">while (checkLock);
</code>

自旋锁于Java 1.4.2中引入,默认关闭,使用-XX:+UseSpinning开启。Java 1.6默认开启,并可使用-XX:preBlockSpin设置自旋次数,默认为10次。

虽然可以通过-XX:preBlockSpin设置自旋次数,但设置过大则将消耗过多的CPU,过小则可能刚退出即释放了锁。所以 Java 1.6新增加了适应性自旋。

所谓适应性自旋即自旋次数不固定,它是由同一个锁上一次自旋结果来决定。若上一次执行成功,则本次允许更多的自旋次数。反之若某锁很少会自旋成功,则会减少自旋次数甚至取消自旋,以节省CPU时间。

总结

在竞争不激烈的情况下,synchronized关键字可提供更高的性能。但若竞争激烈,ReentrantLock可提供更灵活的锁策略,实际项目中要根据实际情况进行选择。