Synchronized原理、对象头、锁升级都是怎么一回事?

412 阅读9分钟

参考视频

www.bilibili.com/video/BV16J…

疑问

我们都是知道synchronized保证了临界区代码的安全性。那么我有以下几个疑问。

  • Synchronized为什么要绑定一个对象作为锁呢?

  • Synchronized他底层是怎么来保证一次只能有一个线程访问的呢?

  • JDK对Synchronized做了一个锁升级的优化,那这个优化的过程又是怎么样的呢?

Synchronized的简单介绍,加锁方式

image.png

上面我们不难看出,我们使用synchronized就必须要有一个被锁的对象。那么我们就来看下这个锁对象到底起到什么作用?

对象头信息

普通对象头信息

image.png

数组对象

image.png

Mark Word 信息

image.png 这张图很好的展示了不同锁状态的取值。

在大概知道对象头与锁的关系后,我们在来介绍下什么是Monitor 管程。

Monitor

在早期的时候,synchronized是一把很重的锁。这也是一部分同学知道的,synchronized是一把重量级的锁。那么这是为什么呢?

这里我们就要介绍一下Monitor了。

在jdk1.5之后对synchronized进行了改进升级。使得synchronized的锁根据线程的并发激烈程度的变化自动对锁进行一个升级的过程(锁升级不可逆)。而不是一上来就给锁对象分配一个Monitor管程。1.5之前的时候就是不管三七二十一,只要使用了synchronized关键字,就给当前锁对象分配一个Monitor对象。

Monitor 对象内部结构

底层是基于JVM内部的C++语言实现的。

ObjectMonitor() {

     _header = NULL; //对象头 markOop

     _count = 0;

     _waiters = 0,

     _recursions = 0; // 锁的重入次数

     _object = NULL; //存储锁对象

     _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)8 _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点

     _WaitSetLock = 0 ;

     _Responsible = NULL ;

     _succ = NULL ;

     _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)

     FreeNext = NULL ;

     _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)

     _SpinFreq = 0 ;

     _SpinClock = 0 ;

     OwnerIsThread = 0 ;

     _previous_owner_tid = 0;

 }

我们再用一张更简单的图来描述,线程、对象锁于Monitor的关系

image.png

1.WaitSet

这就是持有锁的对象调用wait()方法后,Monitor给Waiting的线程提供的一个等待的休息室。等待被唤醒。(Look锁中有Condition多个等待休息室)

2.EntryList

并发情况下,没有争抢到锁而进行阻塞的线程,存放的地方。

3.Owner

当前持有锁的线程。(后面进行比对,CAS等操作)

Monitor 流程简介(重量级锁)

简单的熟悉了线程、对象锁于Monitor的关系后,我们在来一个三者代码层面的程序运行流程。

工作流程(简图,这里介绍的就是重量级锁的加锁过程):

image.png

  • 开始时 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj),先进行Owner 的CAS 比对操作,如果是null, 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中。
  • 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),但是此时的Owner已经存放了Thread-2。他们就会进入 EntryList BLOCKED(双向链表)。
  • Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord。
  • 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞。(非公平锁设计)
  • WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

以上我们算是对,synchronized底层有了一个大概的认识了,接下来就是 锁升级、锁优化是怎么一回事了。

轻量级锁

虽然是多个线程在使用同一把锁对象来上锁,但是上锁的时机是错开的。比如 Thread1 使用期间没有其他线程来上锁,另一个线程 Thread2 来上锁也不需要等待。这种场景下就不需要申请Monitor对象来进行多个线程间的协调(也就是不需要使用重量级锁)。有什么好的方法来进行优化呢?这时候Synchronized就提供了一种优化后的锁 轻量级锁

我们来举例,说明下轻量级锁的加锁、解锁、和升级的过程。

static final Object obj = new Object();

    public static void method1() {

        synchronized( obj ) {

        // 同步块 A

        method2();

       }
}

public static void method2() {

    synchronized( obj ) {

    // 同步块 B

    }

}
  • 同一个线程加锁两次的情况。Thread0 调用method1上了一把锁,method2又上了一把锁。
  • JVM会给线程开辟一块栈内存,给线程调用的方法在栈内存中又分配一个栈帧。如图:

image.png

  • 创建锁记录(Lock Record)对象,每个线程都为栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。(我们可是上了两次锁的哦,别忘记了。后面还有图,慢慢看)
  • 第一次上锁的过程是这样的。让锁记录中 Object reference 指向锁对象,并尝试用 cas (由于这里比较耗费性能,后面会经过偏向锁优化)替换 Object 的 Mark Word,将 Mark Word 的值存起来。如图:

image.png

  • (只考虑交换成功,如果失败就是重量级锁了。后面讲)这个时候你就应该回忆起来,上面我们介绍的对象头信息了。交换完之后,对象头重的锁标志为 00,好了第一次上锁成功,且是轻量级锁。
  • 我们再来上第二次锁,看看会怎么样。

image.png

  • 只有第一次的锁记录,记录了对象头重的Mark Work 信息。不用重复记录。
  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
  • 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头

思考问题:上述的流程中肯定会有失败的,我们不可能保证在Thread0 执行过程中没有别的线程来对同一个锁对象加锁。那么会发生什么呢?

  • 第一处失败:Thread1来上锁,发现此时对象头已经是轻量级锁了。那么这时候Thread1会尝试自璇CAS上锁(这里是出于性能优化,不要一下子就升级重量级锁),几次失败后。会申请为重量级锁,这时候我们的Monitor就会登场了,把锁对象头Mark Word设置为重量级锁10,且重新指向Monitor。 并且把Thread1 存放到EntryList集合中等待唤醒,此时的Owner 为Thread0
  • 第二处失败:Thread0释放锁的时候,想通过CAS交换Mark Wokd信息,这时候发现。Mark Word 已经指向 Monitor了,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。

image.png

锁膨胀

思考问题就是锁碰撞啊兄弟!

再总结一下锁膨胀吧:当前是轻量级锁情况下,一旦发生锁竞争了。就会进入到锁膨胀过程,将轻量级锁升级为重量级锁。

自旋优化

兄弟啊,这个在上面也讲过了哦。

再总结一下吧:重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。避免上下文切换,优先自旋几次尝试上锁。上锁失败后,在入队列。提升性能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • Java 7 之后不能控制是否开启自旋功能

偏向锁

其实在上述的轻量级锁中,还是有一部分性能问题的,就是每一次轻量级锁的上锁和解锁都是需要进行CAS操作的,这个也是比较消耗CPU性能的。且经过大量的官方实践,大部分的项目也都是单线程就可以解决的。所以就有了更加优化的手段 偏向锁

偏向锁是如何做的呢?

偏向锁是直接将Thread ID 设置到Mark Work中,避免每次都上锁了。直接比对锁对象头中的线程ID与当前线程ID就可以了。

我们来对比下 轻量级锁与偏向锁

static final Object obj = new Object();

    public static void method1() {

        synchronized( obj ) {

        // 同步块 A

        method2();

       }
}

public static void method2() {

    synchronized( obj ) {

    // 同步块 B

    }

}

image.png

image.png

image.png

偏向锁之锁撤销

偏向锁是撤销是什么情况呢?就是JVM判断当前的锁不适合做偏向锁,因此将对象头中Thread Id 和偏向锁状态取消升级成轻量级锁或重量级锁。

那么哪些情况会出现锁撤销呢?

  • 调用了锁对象的HashCode方法,因为锁对象中存储了HashCode值就无法存储线程ID了。其他两种锁都有地方存的。(轻量级锁在锁记录中存放HashCode,重量级锁在Monitor中存HashCode)
  • 当另外一个线程使用同一把锁的时候(如果是发生竞争的话就是重量级锁了),这时候会使用轻量级锁。
  • 调用 wait/notify 方法。这两个方法 是要绑定Monitor对象的,升级成重量级锁。

偏向锁之批量重偏向

什么情况下会发生批量重偏向呢?我们来看下代码吧。

 Vector<Dog> list = new Vector<>();
    Thread t1 = new Thread(() -> {

    for (int i = 0; i < 30; i++) {//线程1 连续给三十个对象上了偏向锁

        Dog d = new Dog();

        list.add(d);

        synchronized (d) {

        log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));

}
for (int i = 0; i < 30; i++) {//给上面30个已经上了偏向锁 t1的对象,由t2重新上了轻量级锁

    Dog d = list.get(i);

   log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));

    synchronized (d) {

    log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));

}
}

}, "t2");

t2.start();

第二次循环三十次,重新给锁对象升级为轻量级锁。但是一旦超过20次,JVM就会怀疑是偏向错误,这时候从第21个对象开始,又重新偏向t2.这就是批量重偏向。

偏向锁之批量撤销

当上述的撤销次数达到40次的时候,JVM就会认为。根本就不该偏向,于是整个类的所有对象都不会偏向。

锁消除

public void b() throws Exception {

Object o = new Object();

synchronized (o) {

    x++;
    }
}

上述中的这种加锁,是无意义的。在JIT 阶段会做优化处理。不加锁处理,这就是锁消除。

锁升级总结

锁的升级过程是这样的:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 //过程不可逆

image.png