深入分析Synchronized原理

165 阅读9分钟

Synchronized是Java中解决并发问题的一种最常用方法,Synchronized的作用主要有三个:

1. 原子性:确保线程互斥访问同步代码;
2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的"对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值"来保证的;
3. 有序性:有效解决重排序问题。

从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在Hotspot JVM实现中,锁有个专门的名字:对象监视器 monitor。

当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;
当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);

注意,synchronized 内置锁是一种对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对临界资源的同步互斥访问是可重入的。其可重入最大作用是避免死锁,如: 子类同步方法调用了父类同步方法,如没有可重入的特性则会发生死锁

同步原理

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。 当一个线程访问同步代码块时,首先需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

查看反编译后结果: image.png

1.monitorenter:每个对象都是一个监视器锁。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor所有权,过程如下:

如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

2.monitorexit: 执行monitorexit的线程必须时object所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

通过上面两段描述,我们也应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法才能调用wait/notify等方法,否则会抛出异常java.lang.illegalMonitorStateException的异常的原因

再来看一下同步方法:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

查看反编译后结果:

image.png

反编译结果

从编译结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成,不过相对于普通方法其常量池中多了ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:

当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完成之后再释放monitor。在方法执行期间,其他任何线程都无法在获得同一个monitor对象。

同步概念

在JVM中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充:

image.png

Mark Word存储结构 Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:

image.png

64位Mark Word存储结构 对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的Hash码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级锁则存储的指向线程锁记录的指针. image.png

锁的优化

从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

锁主要存在四种状态,依次是**:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态**,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

自旋锁

线程的阻塞和唤醒需要从CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。对象锁的状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。 所以引入自旋锁。所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话 锁占用的时间就很短。自旋等待不能替代阻塞,虽然可以避免线程切换带来的开销,但是他占用了CPU的处理时间。自旋锁在JDK1.6中默认开启

适应性自旋锁

1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁消除

为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。

锁消除的依据是逃逸分析的数据支持

如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于程序员来说这还不清楚么?在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?虽然没有显示使用锁,但是在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i \+ "");
    }
    System.out.println(vector);
}

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进偏向锁。 引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。

轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用 当关闭偏向锁或者线程竞争偏向锁升级为轻量级锁。轻量级锁若自旋结束仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志位状态变为10,当前线程以及后面等待锁的线程便进入阻塞状态。 对于轻量级锁,其性能提升的依据是"对于绝大部分锁,在整个生命周期内都是不会存在竞争的",如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢

重量级锁

Synchronized是通过对象内部的一个叫做Monitor 来实现的。而操作系统实现线程之间的切换就需要从用户态转为核心态,这个成本非常高,状态之间转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。