深入分析synchronized 原理和锁膨胀过程

前言

上一篇文章介绍了多线程的概念及synchronized的使用方法《synchronized 的使用(一)》,但是仅仅会用还是不够的,只有了解其底层实现才能在开发过程中运筹帷幄,所以本篇探讨synchronized的实现原理及锁升级 (膨胀) 的过程。

synchronized 实现原理

synchronized是依赖于JVM来实现同步的,在同步方法和代码块的原理有点区别。

同步代码块

我们在代码块加上synchronized关键字

public void synSay() {    synchronized (object) {
        System.out.println("synSay----" + Thread.currentThread().getName());
    }
}

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。

  public void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0         1: getfield      #2                  // Field object:Ljava/lang/String;
         4: dup         5: astore_1         6: monitorenter         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: new           #4                  // class java/lang/StringBuilder
        13: dup        14: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        17: ldc           #6                  // String synSay----
        19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        22: invokestatic  #8                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        25: invokevirtual #9                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        37: aload_1        38: monitorexit        39: goto          47
        42: astore_2        43: aload_1        44: monitorexit        45: aload_2        46: athrow        47: return
      Exception table:
         from    to  target type             7    39    42   any            42    45    42   any
      LineNumberTable:
        line 21: 0
        line 22: 7
        line 23: 37
        line 24: 47
      LocalVariableTable:
        Start  Length  Slot  Name   Signature            0      48     0  this   Lcn/T1;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 42
          locals = [ class cn/T1, class java/lang/Object ]          stack = [ class java/lang/Throwable ]        frame_type = 250 /* chop */
          offset_delta = 4

可以发现synchronized同步代码块是通过加monitorentermonitorexit指令实现的。 每个对象都有个 ** 监视器锁 (monitor) **,当monitor被占用的时候就代表对象处于锁定状态,而monitorenter指令的作用就是获取monitor的所有权,monitorexit的作用是释放monitor的所有权,这两者的工作流程如下:monitorenter

  1. 如果monitor的进入数为 0,则线程进入到monitor,然后将进入数设置为1,该线程称为monitor的所有者。

  2. 如果是线程已经拥有此monitor(即monitor进入数不为 0),然后该线程又重新进入monitor,则将monitor的进入数+1,这个即为锁的重入

  3. 如果其他线程已经占用了monitor,则该线程进入到阻塞状态,知道monitor的进入数为 0,该线程再去重新尝试获取monitor的所有权

monitorexit:执行该指令的线程必须是monitor的所有者,指令执行时,monitor进入数-1,如果-1后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。这个时候其它阻塞的线程可以尝试获取monitor的所有权。

同步方法

在方法上加上synchronized关键字

synchronized public void synSay() {
    System.out.println("synSay----" + Thread.currentThread().getName());
}
复制代码

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。

  public synchronized void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #3                  // class java/lang/StringBuilder
         6: dup         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #5                  // String synSay----
        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: invokestatic  #7                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        18: invokevirtual #8                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30: return
      LineNumberTable:
        line 20: 0
        line 21: 30
      LocalVariableTable:
        Start  Length  Slot  Name   Signature            0      31     0  this   Lcn/T1;

从字节码上看,加有synchronized关键字的方法,常量池中比普通的方法多了个ACC_SYNCHRONIZED标识,JVM就是根据这个标识来实现方法的同步。 当调用方法的时候,调用指令会检查方法是否有ACC_SYNCHRONIZED标识,有的话线程需要先获取monitor,获取成功才能继续执行方法,方法执行完毕之后,线程再释放monitor,同一个monitor同一时刻只能被一个线程拥有。

两种同步方式区别

synchronized同步代码块的时候通过加入字节码monitorentermonitorexit指令来实现monitor的获取和释放,也就是需要 JVM 通过字节码显式的去获取和释放 monitor 实现同步,而 synchronized 同步方法的时候,没有使用这两个指令,而是检查方法的ACC_SYNCHRONIZED标志是否被设置,如果设置了则线程需要先去获取 monitor,执行完毕了线程再释放 monitor,也就是不需要 JVM 去显式的实现。**这两个同步方式实际都是通过获取 monitor 和释放 monitor 来实现同步的,而 monitor 的实现依赖于底层操作系统的mutex互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。**线程获取、释放monitor的过程如下:

线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到的同步队列中,等待其他线程释放monitor当其他线程释放monitor后,有可能刚好有线程来获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平锁。如果线程获取monitor成功则进入到monitor中,并且将其进入数+1

关于什么是公平锁、非公平锁可以参考一下美团技术团队写的《不可不说的 Java“锁” 事》

到这里我们也清楚了synchronized的语义底层是通过一个monitor的对象完成,其实waitnotiyfnotifyAll等方法也是依赖于monitor对象来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因 (需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常

Java 对象的组成

我们知道了线程要访问同步方法、代码块的时候,首先需要取得锁,在退出或者抛出异常的时候又必须释放锁,那么锁到底是什么?又储存在哪里? 为了解开这个疑问,我们需要进入 Java 虚拟机 (JVM) 的世界。在HotSpot虚拟机中,Java对象在内存中储存的布局可以分为3块区域:对象头实例数据对齐填充synchronized 使用的锁对象储存在对象头中

对象头

对象头的数据长度在32位和64位 (未开启压缩指针) 的虚拟机中分别为32bit64bit。对象头由以下三个部分组成:

  • Mark Word:记录了对象和锁的有关信息,储存对象自身的运行时数据,如哈希码 (HashCode)、GC分代年龄、锁标志位、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄等。注意这个 Mark Word 结构并不是固定的,它会随着锁状态标志的变化而变化,而且里面的数据也会随着锁状态标志的变化而变化,这样做的目的是为了节省空间

  • 类型指针:指向对象的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 数组长度:这个属性只有数组对象才有,储存着数组对象的长度。

32位虚拟机下,Mark Word的结构和数据可能为以下5种中的一种。

64位虚拟机下,Mark Word的结构和数据可能为以下2种中的一种。

这里重点注意是否偏向锁锁标志位,这两个标识和synchronized的锁膨胀息息相关。

实例数据

储存着对象的实际数据,也就是我们在程序中定义的各种类型的字段内容。

对齐填充

HotSpot虚拟机的对齐方式为8字节对齐,即一个对象必须为8字节的整数倍,如果不是,则通过这个对齐填充来占位填充。

synchronized 锁膨胀过程

上文介绍的 "synchronized实现原理 " 实际是 synchronized 实现重量级锁的原理,那么上文频繁提到monitor对象和对象又存在什么关系呢,或者说monitor对象储存在对象的哪个地方呢?在对象的对象头中,当锁的状态为重量级锁的时候,它的指针即指向monitor对象,如图:

那锁的状态为其它状态的时候是不是就没用上monitor对象?答案: 是的。 这也是JVMsynchronized的优化,我们知道重量级锁的实现是基于底层操作系统的mutex互斥原语的,这个开销是很大的。所以JVMsynchronized做了优化,JVM先利用对象头实现锁的功能,如果线程的竞争过大则会将锁升级 (膨胀) 为重量级锁,也就是使用monitor对象。当然JVM对锁的优化不仅仅只有这个,还有引入适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等。

那么锁的是怎么进行膨胀的或者依据什么来膨胀,这也就是本篇需要介绍的重点,首先我们需要了解几个概念。

锁的优化
自旋锁和自适应性自旋锁

自旋:当有个线程A去请求某个锁的时候,这个锁正在被其它线程占用,但是线程A并不会马上进入阻塞状态,而是循环请求锁 (自旋)。这样做的目的是因为很多时候持有锁的线程会很快释放锁的,线程A可以尝试一直请求锁,没必要被挂起放弃CPU时间片,因为线程被挂起然后到唤醒这个过程开销很大, 当然如果线程A自旋指定的时间还没有获得锁,仍然会被挂起。

自适应性自旋:自适应性自旋是自旋的升级、优化,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。例如线程如果自旋成功了,那么下次自旋的次数会增多,因为JVM认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果对于某个锁,自旋很少成功,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费处理器资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善,JVM对程序锁的状况预测就会变得越来越准确,JVM也就变得越来越聪明。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除

锁粗化

在使用锁的时候,需要让同步块的作用范围尽可能小,这样做的目的是为了使需要同步的操作数量尽可能小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁

轻量级锁

所谓轻量级锁是相对于使用底层操作系统mutex互斥原语实现同步的重量级锁而言的,因为轻量级锁同步的实现是基于对象头的 Mark Word。那么轻量级锁是怎么使用对象头来实现同步的呢,我们看看具体实现过程。

获取锁过程

  1. 在线程进入同步方法、同步块的时候,如果同步对象锁状态为无锁状态 (锁标志位为 “01” 状态,是否为偏向锁为 “0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Recored) 的空间,用于储存锁对象目前的 Mark Word 的拷贝 (官方把这份拷贝加了个 Displaced 前缀,即 Displaced Mark Word)。

  1. 将对象头的Mark Word拷贝到线程的锁记录 (Lock Recored) 中。

  2. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,则执行步骤4,否则执行步骤5

  3. 更新成功,这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位将转变为 “00”,即表示此对象处于轻量级锁的状态。

  1. 更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其其它线程抢占了。进行自旋执行步骤3,如果自旋结束仍然没有获得锁,轻量级锁就需要膨胀为重量级锁,锁标志位状态值变为 “10”,Mark Word 中储存就是指向monitor对象的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

释放锁的过程

  1. 使用CAS操作将对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来 (依据Mark Word中锁记录指针是否还指向本线程的锁记录),如果替换成功,则执行步骤2,否则执行步骤3

  2. 如果替换成功,整个同步过程就完成了,恢复到无锁的状态 (01)。

  3. 如果替换失败,说明有其他线程尝试获取该锁 (此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作区消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不用做了。偏向锁默认是开启的,也可以关闭。 偏向锁 “偏”,就是 “偏心” 的 “偏”,它的意思是这个锁会偏向于第一个获得它的程序,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

获取锁的过程

  1. 检查Mark Word是否为可偏向锁的状态,即是否偏向锁即为 1 即表示支持可偏向锁,否则为 0 表示不支持可偏向锁。

  2. 如果是可偏向锁,则检查Mark Word储存的线程ID是否为当前线程ID,如果是则执行同步块,否则执行步骤3

  3. 如果检查到Mark WordID不是本线程的ID,则通过CAS操作去修改线程ID修改成本线程的ID,如果修改成功则执行同步代码块,否则执行步骤4

  4. 当拥有该锁的线程到达安全点之后,挂起这个线程,升级为轻量级锁。

锁释放的过程

  1. 有其他线程来获取这个锁,偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。

  2. 等待全局安全点 (在这个是时间点上没有字节码正在执行)。

  3. 暂停拥有偏向锁的线程,检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态,否则设置为被锁定状态。如果锁对象处于无锁状态,则恢复到无锁状态 (01),以允许其他线程竞争,如果锁对象处于锁定状态,则挂起持有偏向锁的线程,并将对象头Mark Word的锁记录指针改成当前线程的锁记录,锁升级为轻量级锁状态 (00)

锁的转换过程

锁主要存在4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争的情况逐渐升级,这几个锁只有重量级锁是需要使用操作系统底层mutex互斥原语来实现,其他的锁都是使用对象头来实现的。需要注意锁可以升级,但是不可以降级。

![](https://img-blog.csdnimg.cn/img_convert/7011ba883d2ac3689477c1b9cd56328d.png

这里盗个图,这个图总结的挺好的!(图被压缩过了 看不清,可以打开这个地址查看高清图 >> 高清图 <<)

三种锁的优缺点比较

参考

深入理解 Java 虚拟机

Java 的对象头和对象组成详解 JVM(三)JVM 中对象的内存布局详解 JVM——深入分析对象的内存布局啃碎并发(七):深入分析 Synchronized 原理 Java Synchronized 实现原理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值