synchronized实现与优化

本文主要讲述了synchronized的实现原理,还有synchronized的优化。其中主要包括了自适应自旋,锁消除、锁粗化、以及偏向锁和轻量级锁。另外阐述了锁的内存语义和对三种锁的总结。其实工程学科就是不断解决实际问题才能得以发展,synchronized从早期的一上来就直接使用Mutex逐步优化到现在的程度,mutex互斥量是最重要的同步原语,但是我们去使用mutex的时候却会出现诸多问题(比如销毁了已加锁的互斥量、死锁问题)Monitor机制是编程语言在语法上提供的语法糖,假设我们用的是C语言,那么很明显无法使用Monitor机制。

synchronized实现原理

实现synchronized的关键是两个东西,Java对象头和Monitor,在HotShot虚拟机中,对象在内存中的布局分为三块:对象头、实例数据、对齐填充。我们需要重点关注的东西是对象头!

mark

下图即是32位的JVM Mark Word的结构:

mark

Monitor:每个Java对象天生自带了一把看不见的锁,也成为管程和监视器锁,如何看到它的结构呢?

通过这个地址可以看到 Monitor的源码

mark

当一个线扯获取到对象锁时,_owner就会指向当前线程,并把计数器(_count)+1,如果调用wait方法,就会释放当前线程持有的Monitor,_owner 会变成NULL,计数器(_count)-1, 同时该线程实例会进入等待池。

Monitor锁的竞争、获取与释放

mark

这也就是为什么Java的任意对象都可以作为锁的原因。

接下来我们可以看看synchronized在字节码层面的实现:

 1public class SyncBlockAndMethod {
 2
 3    public void syncsTask() {
 4        synchronized (this){
 5            System.out.println("Hello");
 6        }
 7    }
 8
 9    public synchronized void syncTask(){
10        System.out.println("Hello Again");
11    }
12}

编译后再通过javac -verbose 指令查看字节码:

mark

执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。

是否注意到了上述字节码中包含一个monitorenter指令以及多个monitorexit指令。这是因为Java虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。

那么对于同步方法呢?又是如何实现的?

mark

当用synchronized标记方法时,字节码中方法的访问标记包括ACC_ SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行monitorenter操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java虚拟机均需要进行monitorexit操作。

这里monitorenter和monitorexit操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是this;对于静态方法来说,这两个操作对应的锁对象则是所在类的Class 实例。关于monitorenter和monitorexit的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一 个指向持有该锁的线程的指针。其实看ObjectMonitor的源码,也就是ObjectMonitor.hpp也就能明白这一点。

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。

1public void syncsTask() {
2       synchronized (this){
3           System.out.println("Hello");
4           synchronized (this){
5               System.out.println("World");
6           }
7       }
8   }

比如一个线程获取到了锁,执行System.out.println("Hello"); 这条语句时,可以再次获得锁去执行System.out.println("World"); ,这就是可重入的情况!

synchronized的优化

在JDK的早期版本中,synchronized属于重量级锁,依赖于Mutex Lock实现,因为监视器锁(也就是Monitor)是基于互斥锁Mutex来实现的,线程之间的切换需要从用户态转换到内核态,开销较大。但是从JDK6以来,HotSpot团队对synchronized做了很多优化,现在已经优化得相当不错了。主要的优化方式分为有轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)、自适应自旋、锁消除、锁粗化等等。

自适应自旋 Adaptive Spinning

许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。通过让线程执行忙循环等待锁的释放,不让出CPU。自旋在JDK1.4就被引入了,默认是关闭状态,JDK1.6默认开启。缺点就是若锁被其他线程长时间占用,会带来许多性能上的开销,在JDK中用户可以根据一个叫做PerBlockSpin的参数来控制自旋时间。

什么是自适应自旋呢?即自旋的次数不再固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。打个比方:红绿灯代表是否获取到了锁,如果之前不熄火等到了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等到绿灯,那么这次不熄火的时间就短一点。

锁消除 Lock Eliminate

锁消除是一种更彻底的优化,JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:

1public class StringBufferWithoutSynchronized {
2    public void add(String str1, String str2){
3        //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
4        //因此sb属于不可能共享的资源, JVM会自动消除内部的锁
5        StringBuffer stringBuffer = new StringBuffer();
6        stringBuffer.append(str1).append(str2);
7    }
8}

锁粗化 Lock Coarsening

通过扩大加锁的范围,避免反复加锁和解锁

1public class StringBufferWithoutSynchronized {
2    public static String copyString(String target){
3        StringBuffer stringBuffer = new StringBuffer();
4        for (int i = 0; i < 100; i++) {
5            stringBuffer.append(target);
6        }
7        return stringBuffer.toString();
8    }
9}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)后面会讲。

轻量级锁和偏向锁

synchronized的四种状态:无锁、偏向锁、轻量级锁、重量级锁 锁膨胀方向:无锁→偏向锁→轻量级锁→重量级锁,很多观点认为锁不会发生降级,这是不对的,当出现闲置的Monitor的时候可能会发生锁降级。

偏向锁

减少同一线程获取锁的代价,大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得

偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。偏向锁不适用于锁竞争比较激烈的多线程场合,这种场合下偏向锁就失效了。

偏向锁的获取比较简单:线程在获取锁时,检测对象头的Mark Word里是否存在指向当前线程的指针,如果存在则获取所成功;如果测试失败,检查Mark Word偏向锁标识是否设置为1(也就是判断当前是否还是偏向锁),如果还是偏向锁,就通过CAS操作把Mark Word中的指针指向为当前线程。如果Mark Word偏向锁标识没有设置为1(则说明已经不是偏向锁),此时CAS尝试去竞争锁。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

mark

mark

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。轻量级锁的适应的场景是线程交替执行同步块。若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁的加锁过程:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁过程:轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁, 导致锁膨胀的流程图。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后 会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁的内存语义

当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中; 而当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

关于Java内存模型,可以参考我之前写的一篇博客《重新认识volatile》和我转载的一篇博客《理解Java内存模型》中来了解Java内存模型和缓存一致性协议。

总结

mark