深刻分析synchronized原理和锁膨胀过程(二)

image

前言

上一篇文章介绍了多线程的概念及synchronized的使用方法《synchronized的使用(一)》,可是仅仅会用仍是不够的,只有了解其底层实现才能在开发过程当中指挥若定,因此本篇探讨synchronized的实现原理及锁升级(膨胀)的过程。html

synchronized实现原理

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

同步代码块

咱们在代码块加上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的全部权,这二者的工做流程以下:
monitorenterbash

  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查看对应的字节码,这里为了减小篇幅,我就只粘贴对应的方法的字节码。app

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的过程以下:post

线程尝试获取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互斥原语来实现,其余的锁都是使用对象头来实现的。须要注意锁能够升级,可是不能够降级。

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

三种锁的优缺点比较

image

参考

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

原文地址:ddnd.cn/2019/03/22/…

相关文章
相关标签/搜索