synchronized实现原理和底层优化解读

123 阅读15分钟

文章目录


1. 线程安全问题

当多个线程对同一个共享变量执行操作时,就可能会出现线程安全问题。例如,下面的代码对共享变量count执行操作,其中线程t1执行自增,线程t2执行自减:

/**
 * @Author dyliang
 * @Date 2020/8/18 23:03
 * @Version 1.0
 */
public class Test {

    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            counter++;
        }, "t1");
        Thread t2 = new Thread(() -> {
            counter--;
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter);
    }
}

运行多次程序,发生输出的counter并不唯一,这就是多个线程对同一个共享变量执行操作导致的线程不安全问题的出现。为了理解问题的出现原因,首先使用jclasslib看一下程序所对应的字节码指令,线程t1对应的字节码指令如下:

0 getstatic #10 <StackDemo/Test.counter>
3 iconst_1
4 iadd
5 putstatic #10 <StackDemo/Test.counter>
8 retur

线程t2对应的字节码指令如下:

0 getstatic #10 <StackDemo/Test.counter>
3 iconst_1
4 isub
5 putstatic #10 <StackDemo/Test.counter>
8 return

从上面的字节码指令可以看出,对于counter的自增和自减操作不同之处在于第4条命令。因此,下面主要看一下线程t1对应的字节码指令:

  • getstatic #10:获取静态变量counter的值,这里显示的是指向常量池的地址#10


    image-20200818231407180

  • iconst_1:线程中准备常量1

  • iadd:自增操作

  • pubstatic #10:将自增结果重新存储到counter中,对应的操作是改变常量池中位置为10的数据

Java中的线程和操作系统中的线程是直接映射的,换言之,Java中的线程本质上是由本地的线程完整相应的工作。因此, Java 的内存模型完成静态变量的自增或自减需要在主存和工作内存中进行数据交换。那么,为什么会出现0之外的结果呢?我们可以通过时序图,逐步的分析线程可能的执行过程。例如,如果两个线程按照如下的顺序执行,那么结果就可能是正数:


image-20200818232658545

而如果按照下面的顺序执行,那么结果就可能为负数:


image-20200818233106718

可以看出,正是由于不同指令之间的执行顺序对于共享变量值的改变,使得最终的结果不唯一。那么,如果想要使得最终的结果为0,那么就需要对使用counter这个共享变量做出某种限制,即做到线程同步。


2. 概念

如果能够避免在临界区可能出现的竞态条件,即上述不同线程对同一共享变量的操作,那么就可以避免线程安全问题的出现。通常用于避免竞态条件出现的策略有:

  • 阻塞式:synchronized、Lock等
  • 非阻塞式:原子变量,乐观锁(CAS)

下面,首先说一下其中的synchronized,即对象锁。它采用互斥的方式让同一时刻至多只能有一个线程持有锁对象,其他想要获取同样锁对象的线程就会发生阻塞。这样可以保证,临界区在同一时刻至多只能有一个线程访问,不必担心线程上下文的切换。

2.1 基本语法

synchronized的基本语法如下:

synchronized(锁对象){
    临界区
}

只有获得了锁对象才能进入临界区。例如,为了解决上面例子中可能出现的线程安全问题,可以简单的在执行自增和自减操作前,使用synchronized加锁。如下所示:

/**
 * @Author dyliang
 * @Date 2020/8/18 23:03
 * @Version 1.0
 */
public class Test {

    static int counter = 0;
    // 创建任意对象作为锁对象使用
    public final Object lock = new Object()

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            sychronized(lock){
                counter++;
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
             sychronized(lock){
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter);
    }
}

其中作为锁的对象可以是任意对象,只要保证两个操作竞争的是同一个锁对象即可。类的方法中使用锁对象,可以使用synchronized(this)的方式,表示此时竞争的是this对象,或者像上面一样自定义一个任意的对象作为锁对象。加入了锁对象后,代码执行的时序图如下所示:


image-20200819092919704

除了可以将synchronized用于方法内部,它还可以直接用于方法上,具体使用如下:

  • 如果使用在普通方法上,下面两种方式是等价的

    public synchronizedvoid method(){}
    
    public void method(){
        sychronized(this){
            
        }
    }
    
  • 如果使用在静态方法上,下面两者是等价的

    public synchronizedvoid method(){}
    
    public void method(){
        synchronized(ClassName.class){  // 所在类的字节码文件作为锁对象
            
        }
    }
    

2.2 锁对象

synchronized中的锁对象表现为如下几种形式:

  • 普通同步方法:当前实例对象
  • 静态同步方法:当前类的Class对象
  • 同步方法块:synchronized配置的对象

3. Monitor

3.1 对象内存布局

以Java中默认的HotSpot虚拟机为例说明,对象在堆内存中的存储布局可以划分为如下三个部分:

  • 对象头(Header):保存对象自身的一些信息
  • 实例数据(Instance Data):保存代码中所定义的各种类型的字段内容
  • 对齐填充(Padding):保证对象大小为8字节的整数倍

3.2 对象头

对于32位系统来说,Java对象的对象头有如下形式,对象头包含两类信息:

  • 对象自身运行时数据(Mark Word):hashcode、GC age、锁状态标志、线程持有的锁、偏向线程的ID、偏向的时间戳等
  • 类型指针(Klass Word):指向类型元数据的指针

其中普通对象的对象头包含内容如下所示:

|---------------------------------------------|
|          Obejct Header(64 bits)             |
|---------------------------------------------|
| Mark Word (32 bits) |  Klass Word (32 bits) |
|---------------------------------------------|

数组对象还需要额外的记录数组的长度

|----------------------------------------------------------------------|
|                         Obejct Header(96 bits)                       |
|----------------------------------------------------------------------|
| Mark Word (32 bits) |  Klass Word (32 bits) | array length (32 bits) |
|----------------------------------------------------------------------|

其中Mark Word的结构如下:

|--------------------------------------------------|----------------------|
|                   Mark Word (32 bits)            |         State        |
|--------------------------------------------------|----------------------|
| hashcode:25         | age:4 | biased_lock:0 | 01 |         Normal       |
|--------------------------------------------------|----------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 |         Biased       |
|--------------------------------------------------|----------------------|
|             ptr_to_lock_record:30           | 00 |   lightweight Locked |
|--------------------------------------------------|----------------------|
|        ptr_to_heavyweight_monitor:30        | 10 |   lightweight Locked |
|--------------------------------------------------|----------------------|
|                                             | 11 |    Marked for GC     |
|--------------------------------------------------|----------------------|   

对于64位操作系统来说,Mark Word的格式如下:

|-------------------------------------------------------------|-------------------------|
|                   Mark Word (64 bits)                       |          State          |
|-------------------------------------------------------------|-------------------------|
| hashcode:25 | hashcode:31 | unused:1| age:4 | biased_lock:0 | 01 |     Normal         |
|-------------------------------------------------------------|-------------------------|
| thread:23 | epoch:2       | unused:1| age:4 | biased_lock:1 | 01 |     Biased         |
|-------------------------------------------------------------|-------------------------|
|             ptr_to_lock_record:62                           | 00 | lightweight Locked | 
|-------------------------------------------------------------|-------------------------|
|        ptr_to_heavyweight_monitor:62                        | 10 | lightweight Locked | 
|-------------------------------------------------------------|-------------------------|
|                                                             | 11 |    Marked for GC   |
|-------------------------------------------------------------|-------------------------| 

3.3 monitor

monitor直译为监视器,Java中每一个对象都可以关联一个monitor,这也是synchronized中可以使用任意创建的对象作为锁对象的底层依赖。如果使用synchronized给对象上锁,那么该对象头的Mark Word中就被设置指向Monitor对象的指针。

此时sychronized为重量级锁。

monitor结构如下所示:


image-20200826000323071

如图所示对应的场景为:

  • Thread-0和Thread-1都已经经历了获取锁和释放锁的过程,此时不满足获取锁的条件,那么处于Waiting状态
  • 当Thread-2执行synchronized(obj)时,将会将Monitor的Owner设置为Thread-2,并且Owner只能有一个
  • 在Thread-2上锁的过程中,如果Thread-3、Thread-4、Thread-5也想执行synchronized(obj)竞争锁,就会进入EntryList转换为Blocked状态。只有等Thread-2释放锁,才能继续竞争锁
  • Thread-2执行完同步块中的代码后会释放锁,唤醒EntryList中的阻塞线程,它们以非公平的方式竞争锁,重新成为Owner的拥有者执行代码

上述的线程竞争的是同一个对象的Monitor。


4 重量级锁原理

那么synchronized在程序中是如何使用Monitor来实现线程同步的呢?下面我们通过一个简单的例子看一下,代码如下所示:

/**
 * @Author dyliang
 * @Date 2020/8/26 0:12
 * @Version 1.0
 */
public class _Sychronized {
    static final Object lock = new Object();
    static int counter = 0;

    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }
}

执行程序,查询方法对应的字节码指令,如下所示:

 0 getstatic #2 <_Sychronized.lock>  // sychronzied开始
 3 dup
 4 astore_1                          // lock引用 -> slot 1
     
 5 monitorenter                      // 将lock对象Mark Word置为Monitor指针
 6 getstatic #3 <_Sychronized.counter>  // 获取counter
 9 iconst_1                             // 准备常数
10 iadd								   // +1
11 putstatic #3 <_Sychronized.counter>  // 将结果赋给counter
14 aload_1                             // <- lock引用
15 monitorexit                 //将lock对象Mark Word重置, 唤醒EntryList

16 goto 24 (+8)
19 astore_2                        // e -> slot 2
20 aload_1                         // <- lock引用
21 monitorexit              // 将lock对象Mark Word重置, 唤醒EntryList
22 aload_2                  // <- slot 2 (e)
23 athrow                   // throw e
24 return

核心部分就是中间部分的指令,可以看到main线程在操作counter时,由于使用了synchronized加锁,那么它在获取到锁之后先将创建的lock对象的Mark Word设置为monitor指针,Monitor的Owner指向此时的main线程,然后才能继续后续的操作。

当main线程执行完毕释放锁时,再将monitor对象的Mark Word重置回来,断开Owner和main线程的指向。然后去唤醒EntryList中的其他线程,让它们来竞争使用锁。

但是synchronized作为重量级锁需要依赖于操作系统底层的Monitor,而Monitor依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。因此,Jdk 6之后对于synchronized进行了改进,也就有了所谓的偏向锁、轻量级锁和偏向锁等相关的概念,继续往下吧。


5. 偏向锁原理

偏向锁是Jdk 6 之后对于synchronized优化的产物,因为Hotspot虚拟机的作者调查发现,大部分情况下,加锁的程序不仅不存在线程竞争,而且总是由同一个线程获得锁。当锁对象第一次被线程获取时,JVM把对象头中的标志位设置为01,把偏向模式设置为1,表示进入偏向状态,同时使用CAS把获取到这个锁的线程ID(Thread ID)和Mark Word进行互换。如果CAS执行成功,那么持有偏向锁的线程以后每次进入到这个锁相关的同步块时,JVM都可以不再进行任何的同步操作。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。也可以保证原子性。如果当前内存位置的值等于预期原值A的话,就将B赋值。否则,处理器不做任何操作,整个比较并替换的操作是一个原子操作。

它的执行逻辑如下:


假设,此时的代码如下所示:

static final Object obj = new Object();
public static void m1() {
    synchronized( obj ) {
        // 同步块 A
        m2();
    }
}
public static void m2() {
    synchronized( obj ) {
        // 同步块 B
        m3();
    }
}
public static void m3() {
    synchronized( obj ) {
        // 同步块c
    }
}

m1()在获取到锁之后调用了m2(),此时m1已经通过CAS操作将对象的Mark Word替换成了此时的Thread ID。接着在m1内部又调用了m2m2也需要获取锁,但是它发现此时锁对象Mark Word中的内容就是当前线程的ID,因此不需要进行CAS再尝试替换。m2内部又调用了m3m3执行前同样需要获取锁,同样检查发现锁对象的Mark Word中为线程ID,所以也不做CAS。

由于上述三个方法的执行都在同一个线程中,因此,此时synchronized使用的就是偏向锁,而且只会在第一次获取锁时进行CAS操作。那么如何判断是否使用的就是偏向锁呢?我们需要看锁对象的对象头信息,如下所示:

|-------------------------------------------------------------|-------------------------|
|                   Mark Word (64 bits)                       |          State          |
|-------------------------------------------------------------|-------------------------|
| hashcode:25 | hashcode:31 | unused:1| age:4 | biased_lock:0 | 01 |     Normal         |
|-------------------------------------------------------------|-------------------------|
| thread:23 | epoch:2       | unused:1| age:4 | biased_lock:1 | 01 |     Biased         |
|-------------------------------------------------------------|-------------------------|
|             ptr_to_lock_record:62                           | 00 | lightweight Locked | 
|-------------------------------------------------------------|-------------------------|
|        ptr_to_heavyweight_monitor:62                        | 10 | lightweight Locked | 
|-------------------------------------------------------------|-------------------------|
|                                                             | 11 |    Marked for GC   |
|-------------------------------------------------------------|-------------------------| 

当对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word值为 0x05, 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,Mark Word值为 0x01 ,即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值

对于偏向锁来说,它不存在撤销操作。如果执行CAS失败,那么会升级为轻量级锁,这个过程也被称为锁膨胀

当发生如下的情况时,偏向锁将会被撤销:

  • 调用了对象的hashcode,偏向状态撤销,锁膨胀直接为重量级锁
  • 有其他线程使用锁对象
  • 线程调用了wait和notify

对于偏向锁来说,如果对象虽然被多个线程访问,但没有竞争,这时偏向了Thread-1的对象仍有机会重新偏向Thread-2,重偏向会重置对象的Thread ID。当撤销偏向锁阈值超过 20 次后,jvm会在给这些对象加锁时重新偏向至加锁线程。当撤销偏向锁阈值超过 40 次后,jvm会让整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。


6. 轻量级锁原理

上面说到,如果某个线程在获取偏向锁时执行CAS失败,说明此时其他线程持有偏向锁,那么就会进入锁膨胀阶段,将锁升级为轻量级锁。它所使用的场景是:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即代码中表现仍然是使用synchronized来进行保证线程安全。

假设,此时的代码如下所示:

/**
 * @Author dyliang
 * @Date 2020/8/26 0:33
 * @Version 1.0
 */
public class Test2 {
    static final Object obj = new Object();

    public static void main(String[] args) {
        new Thread(() -> method1()).start();
    }

    public static void method1() {
        synchronized( obj ) {
            method2();
        }
    }
    public static void method2() {
        synchronized( obj ) {
        }
    }
}

method1执行时先要获取锁对象obj,它内部又调用了method2,而且method2又会去获取obj,这就属于同一个线程中两个同步代码块利用同一个对象加锁。下面通过图解的方式来看一下轻量级锁是如何获取和撤销的。

如果synchronized此时使用的是轻量级锁,那么它并不会在一开始就关联锁对象的Monitor,而是首先在线程自己的栈中创建一个包含锁记录(Lock Record)的栈帧,内部可以存储锁对象的Mark Word,如下所示:


image-20200826093349173

如上所示,此时锁记录最后两位为00,表示启用的是轻量级锁。如果此时没有其他线程来竞争锁,那么Thread-0就会让锁记录中的Object Reference指向锁对象,并尝试通过CAS操作替换锁对象的Mark Word,将Mark Word的值存入锁记录,便于释放锁时恢复。如果CAS执行成功,对象头中存储了锁记录地址和状态00,表示由Thread-0成功使用锁对象加锁。如下所示:


image-20200826093823122

但是,如果CAS执行失败,此时可能有两种情况:

  • 已经有其他的线程持有了该锁对象的轻量级锁,因为有了竞争,进入锁膨胀过程,将升级为重量级锁

  • 如果是当前线程中的其他方法在竞争锁,那么表示此时发生了锁重入,那么只需要在当前栈中添加一条Lock Record作为重入的计数即可,如下所示:


    image-20200826094150321

如果当前线程执行完同步块代码后释放锁,那么先看栈中有没有取值为null的锁记录,如果有,表示有锁重入。那么重置锁记录,将重入计数减一。


image-20200826094335318

当解锁时栈中已经没有取值为null的锁记录,这时需要使用CAS将锁对象的Mark Word值恢复给对象头:

  • 如果成功,那么将恢复成一开始竞争锁的情形:


    image-20200826094513069

  • 如果失败,说明此时轻量级锁已经被升级为重量级锁,进入到重量级锁的释放锁流程


7. 锁膨胀

上面说到,如果轻量级锁使用过程中有其他线程同时竞争同一锁对象,看到锁对象Mark Word已经被替换,且最后两位为00,表示已经有其他线程持有锁。那么轻量级锁就需要升级为重量级锁,进行到锁膨胀阶段。如下所示:


image-20200826095136327

锁膨胀阶段的工作如下所示:

  • 首先,为锁对象申请Monitor锁,让锁对象指向重量级锁地址,并且将Monitor锁的Owner设置为持有锁线程Thread-0

  • 然后,当前想要竞争锁的线程就进入EntryList阻塞式的等待持有锁的线程释放锁


    image-20200826095227132

当Thread-0执行完同步块代码后,使用CAS将Mark Word的值恢复给对象头。这时肯定会失败,因此,进入到重量级锁释放锁流程。按照Monitor地址找到Monitor对象,设置Owner为null,并唤醒EntryList中的阻塞线程继续竞争锁。


8. 自旋优化

如果此时锁已经升级为重量级锁,并且锁已经被当前线程持有。那么如果有其他线程现在要来竞争锁,那么之前的方式是进入到阻塞状态,等待持锁线程释放锁。Jdk 6 之后对于之一过程做了优化,即发生重量级锁竞争时,使用自旋来进行优化。如果当前线程自旋成功(持锁线程释放了锁),那么当前线程获得锁。通过自旋优化避免了线程上下文的切换,进一步的较少了开销。

另外,如果竞争锁在设置的最大自旋之前成功的获得锁,那么可以避免进入阻塞状态。但是,如果不断的自旋尝试获取锁都失败,为了减少CPU的使用,这时竞争锁进程会直接进入阻塞态。特别是Java 6 之后自旋锁是自适应的,如果对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能。


9. 锁粗化

锁粗化就是告诉我们任何事情都有个度,有些情况反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。


10. 锁消除

锁消除是发生在编译器级别的一种锁优化方式,即编译器发现当前代码块并不需要加锁进行线程同步,于是就会进行优化,将锁消除。

这部分内容可直接查看 Java锁消除和锁粗化,容易理解。