Synchronized 底层原理的 JDK 1.6的锁优化

2,379 阅读9分钟

Synchronized 底层原理

synchronized用的锁是存在Java对象头里的。

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。代码块同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。根据虚拟机规范的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;相应地,在执行monitorexit指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

注意两点:

1、synchronized同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

2、同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

对象头

Java 的对象头由以下三部分组成:

1,Mark Word

2,指向类的指针

该指针在32位 JVM 中的长度是32 bit,在64位 JVM 中长度是64 bit,Java对象的类数据保存在方法区。

3,数组长度(只有数组对象才有)

只有数组对象保存了这部分数据,该数据在32位和64位JVM中长度都是32 bit。

Mark Word

Mark Word
Mark Word

锁优化

JDK1.6 中synchronized的实现进行了各种优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁,主要解决三种场景:

  • 只有一个线程进入临界区,偏向锁
  • 多线程未竞争,轻量级锁
  • 多线程竞争,重量级锁

偏向锁→轻量级锁→重量级锁过程,锁可以升级但不能降级,这种策略是为了提高获得锁和释放锁的效率

偏向锁

引入偏向锁的目的是:在没有多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。相对于轻量级锁,偏向锁只依赖一次 CAS 原子指令置换 ThreadID,不过一旦出现多个线程竞争时必须撤销偏向锁,主要校验是否为偏向锁、锁标识位以及ThreadID。

加锁

①.获取对象的对象头里的Mark Word

②.检测Mark Word是否为可偏向状态,即mark的偏向锁标志位为1,锁标识位为01

③.若为可偏向状态,判断Mark Word中的线程ID是否为当前线程ID,如果指向当前线程执行⑥,否则执行④

④.通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行⑤

⑤.通过CAS竞争锁失败,证明当前存在多线程竞争,当到达safepoint全局安全点(这个时间点是上没有正在执行的代码),获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块

⑥.执行同步代码块

解锁

线程是不会主动去释放偏向锁,只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,释放锁需要等待全局安全点。步骤如下:

①.暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态

②.撤销偏向锁,恢复到无锁状态(01)或者轻量级锁(00)的状态

偏向锁
偏向锁

轻量级锁

引入轻量级锁的主要目的是在多线程没有竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁,在有多线程竞争的情况下,轻量级锁比重量级锁更慢

加锁

①.获取对象的对象头里的Mark Word

②.判断当前对象是否处于无锁状态,即mark的偏向锁标志位为0,锁标志位为 01

③.若是,JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后执行④;若不是执行⑤

④.JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行⑤

⑤.判断当前对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经持有这个对象的锁,则直接执行同步代码块;否则说明该锁对象已经被其他线程抢占了,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态

解锁

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

①.如果对象的Mark Word仍然指向着线程的锁记录,执行②

②.用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果成功,则说明释放锁成功,否则执行③

③.如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程

轻量级锁
轻量级锁

重量级锁

monitorenter

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:

  • 若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。
  • 当前线程成为monitor的owner(所有者)若线程已拥有monitor的所有权,允许它重入monitor,并递增monitor的进入数
  • 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。

monitorexit

能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。

执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

当JVM执行引擎执行某一个方法时,其会从方法区中获取该方法的access_flags,检查其是否有ACC_SYNCRHONIZED标识符,若是有该标识符,则说明当前方法是同步方法,需要先获取当前对象的monitor,再来执行方法。

自旋锁和自适应自旋

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。

锁消除

锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

// 虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该
// 方法中逃逸出去(即StringBuffer sb的引用没有传递到该方法外,不可能被其他线程拿到该引用),所以其实这过
// 程是线程安全的,可以将锁消除。
public class SynchronizedTest {

    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();

        for (int i = 0; i < 100000000; i++) {
            test.append("abc""def");
        }
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。