深度分析:面试阿里,京东,美团99%被问到的synchronized的实现原理

988 阅读10分钟

一、synchronized介绍以及基本使用

1.1、synchronized的变动

JDK1.6之前,一般认为synchronized是重量级锁,操作系统底层的Mutex Lock来实现的,需要用户态与和心态进行切换,很耗性能。

JDK1.5之前,能够协调线程间对共享变量的访问的机制只有synchronized和volatile,但是这样存在一些局限性 JDK1.5新增了ReentrantLock,它的出现给了我们另外一个选择,当synchronized重量级锁不适用时,可以选择它 JDK1.6对synchronized进行了各种优化,有些情况就没有那么重了,整体性能与ReentrantLock持平,由于ReentrantLock是在synchronized的基础上提供了更多的功能,于是咋用synchronized能够实现需求的情况下优先考虑synchronized。

1.2、对象锁和类锁

1.2.1、获取对象锁的两种用法

同步代码块(synchronized(this),synchronized(类实例对象)),锁是括号中的实例对象

public class SynchronizedDemo1 {
    public void method() {
        synchronized (this) {
            System.out.println("方法执行");
        }
    }
}
public class SynchronizedDemo2{
     private static Object lock = new Object(); // 对象实例
    
     public void method(){
         synchronized (lock){
             System.out.println("方法执行");
         }
     }
}

同步非静态方法(synchronized method),锁是当前对象的实例对象 使用如下

public class SynchronizedDemo3 {
    public synchronized void method() {
        System.out.println("方法执行");
    }
}

1.2.2、获取类锁的两种方法

同步代码块(synchronized(类.class)),锁是小括号中的类对象(Class 对象)

public class SynchronizedDemo4{
     public void method(){
         synchronized (SynchronizedDemo4.class){
             System.out.println("方法执行");
         }
     }
}

同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)

public class SynchronizedDemo5 {
    public static synchronized void method() {
        System.out.println("方法执行");
    }
}

注意: 以上是对获取对象锁和获取类锁使用方法的介绍,想要测试锁竞争情况下的实际情况,可以在同步代码块中或者方法体重使用循环或者sleep来进行测试

对象锁和类锁的总结

有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞; 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然‘ 同一个类的不同对象的对象锁互不干扰 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的 类锁和对象锁互不干扰

二、Java对象头

在讲接下来的内容之前先说一下Java对象头相关的知识。前边说了,类锁也是特殊的对象锁,对象上锁,我们需要在对象中记录锁信息。

2.1、Java对象头的长度

对象头的长度有两种,分别是3字宽和2字宽(1字宽=4字节=32bit),3字宽的长度对应的对象类型是数组类型,非数组类型使用2字宽

2.2、Mark Word 的存储结构及状态变化

锁相关的当然是锁信息了(借图),下图是

三、锁的升级与对比

3.1、锁的状态介绍

JDK1.6中对synchronized进行了优化,这之后锁有了四种状态:

无锁 偏向锁:偏向锁的设计是为了减少同一线程获取锁的代价。大多数情况下,锁不会存在多线程竞争,总是由同一个线程多次获得。为了避免每次都要加锁,设计出了偏向锁,对象头中的mark word的线程id记录当前的线程id并且是否是偏向锁的值为1。同一线程下次再次访问时,检测线程id一样,则不需要进行同步,直接继续执行代码。 轻量级锁(乐观锁):轻量级锁是由偏向锁升级来的,偏向锁时期如果有第二个线程来竞争锁则锁会升级为轻量级锁。通常是两个线程交替执行同步块或者同步方法,有线程持有锁的时候,其他线程稍稍自旋等待一下 重量级锁(悲观锁):如果某个线程持有锁太久,即其他线程自旋太久,其他线程此时也要对该对象进行访问,此时可以说是出现了激烈的竞争,那么轻量级锁就会升级为重量级锁 锁升级的方向由上到下进行升级。

以上只是简单介绍,后边在锁升级会详细说说的

3.2、偏向锁

前边介绍了偏向锁是为了让线程获取锁的代价更低而引入的。

当一个线程访问同步块并获取锁时,会在对象头和栈帧的锁记录中存储指向当前偏向线程的线程id,那么之后该线程访问当前同步块和退出同步块时不需要进行CAS操作来进行加锁和解锁,只需要检测Mark Word中的线程id是否指向当前线程 如果id的确指向该线程,说明该线程已经获取锁,直接执行下面代码。 如果id不是指向该线程,则继续测试Mark Word中的偏向锁标识是否为1,如果不是,则使用CAS去竞争锁(JDK1.6/JDK1.7默认开启偏向锁),如果是,则说明偏向锁已经有了偏向,当前线程则尝试使用CAS去将对象头的偏向锁指向自己(这个时候发生竞争,偏向锁先升级为轻量级锁)

3.2.1、偏向锁的撤销

由于出现了另外一个线程竞争当前锁,此时的场景已经不是偏向锁能够处理的了,于是偏向锁会升级为轻量级锁。既然有锁的升级那么就有偏向锁的撤销

锁撤销的步骤大概分为如下几步

等待全局安全点(这个时间点上没有正在执行的字节码),在全局安全点上暂停当前拥有偏向锁的线程 检测当前线程是否处于活动状态,如果不处于活动状态,那么将对象头设置成无锁状态。如果线程仍然活着,则遍历它的栈中的关于该对象的锁记录,如果找到说明正在持有该锁,这时候就修改他们,让线程认为当前获取到的对象锁是轻量级锁,并且更改对象头中的Mark Word。如果没有持有该锁,那么对线头中Mark Word恢复到无锁状态或者标记对象不适合作为偏向锁 最后唤醒当前线程 以上流程可以看下图:

3.3、轻量级锁

3.3.1、轻量级锁加锁

线程执行同步块前,JVM会先在当前线程的栈帧中创建名为锁记录(Lock Record)的空间,并且将对象头中的Mark Word复制到锁记录中,并将锁记录中的Owner指向锁对象 如果拷贝成功,则线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果替换成功,则代表当前线程成功获取轻量级锁,如果替换失败,则表示其他线程竞争锁,则当前线程尝试使用自旋来获取锁(前提是当前只有一个等待线程),自旋会消耗CPU,但线程不会阻塞,响应更快

3.3.2、轻量级锁的种类

轻量级锁主要有两种:自旋锁和自适应自旋锁

自旋锁:当前轻量级锁有线程持有,如果有另外一个线程来竞争锁时,该线程会在原地进行等待,而不是将它阻塞(阻塞需要借助操作系统进行帮忙,需要内核态与用户态进行切换,太消耗性能),这样能够快速响应。但是原地自旋的时候也是会消耗CPU的,所以轻量级锁适合同步代码块或者方法执行很快的场景

自旋锁的问题:

如果持有锁的线程执行时间较长或者多个线程在自选等待,但是锁一旦释放,可能就会被其他线程夺取,而此线程还得继续自旋,这样导致自旋时间较长,白白耗费CPU 对于这个问题我们可以给定一个空循环次数,自旋锁默认情况下,自旋次数为10次,可以通过-XX:PreBlockSpin进行更改,一旦超过这个次数,那么锁会升级为重量级锁 自适应自旋锁:自适应自旋锁就是空循环次数不是固定的,而是根据实际情况改变次数。如果当前需要自旋的线程刚刚成功获取了锁,现在又来获取,虚拟机会认为该线程自旋获取锁的成功率很高,于是会延长该线程的自旋时间。如果该线程获取锁很少成功,那么有可能直接忽略掉自旋过程,直接升级成为重量级锁,避免浪费CPU资源

3.3.3、轻量级锁解锁

解锁时,会使用CAS操作将锁记录中复制的那一份Mark Word替换回对象头中,如果成功则表示没有竞争发生,如果失败则表示当前锁存竞争比较严重,那么锁就会膨胀成为重量级锁

3.4、重量级锁

重量级锁是依赖对象内部的monitor锁实现的,而monitor又是依赖操作系统的MuteLock(互斥锁) 实现的,所以重量级锁又称为互斥锁 重量级锁状态下,竞争的线程是不会进行自旋的,而是直接阻塞,且不会降级为轻量级锁,当持有锁的线程释放掉锁之后会唤醒这些线程,然后展开新一轮的锁争夺 每次阻塞或者唤醒线程都需要操作系统来进行帮忙,涉及到用户态转换为内核态,会消耗很多时间,所以重量级锁开销非常大,适用于吞吐量大的场景,同步代码块较长

3.5、三种锁的优缺点

四、其他锁操作

4.1、锁消除

JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁 StringBuffer为什么是线程安全的,比如,执行append方法,源码中,该方法是synchronized类型的同步方法,能够保证只有一个线程操作,比如:

StringBuffer s = new StringBuffer();
s.append(str1).append(str2);

s只会在append方法中使用,不可能被其他线程引用,因此s属于不可能共享的资源,JVM会自动消除内部的锁,即第二个append锁会被消除

4.2、锁粗化

锁粗化是一种极端情况:

通过扩大加锁的范围,避免反复枷锁和解锁 比如循环s.append(参数)100次,JVM能够检测到对同一个对象多次加锁,于是就会粗化该锁,粗化到整个循环操作的外部加同步锁