了解下Java中的Synchronized锁

Synchronized是Java中的一个关键字,可以作用于普通方法,静态方法,代码块,使得被修饰的资源在同一时间只能由一个线程来访问。是Java语言用来保证线程安全的一种锁。可是它仅仅知识一种锁吗?它和别的锁都有什么关系呢?今天让我们来聊聊Synchronized的底层原理。

Synchronized的理解我我准备从三个层面开始说起,java语言层面,字节码层面,以及jvm层面:

Java语言层面:

Synchronized会应用在一下三种场景当中:

  • 普通方法:在普通方法中存在共享变量的操作,而该方法又存在多线程场景,那么我们一般会对该方法加上Synchronized关键字,这边举个例子
    public class Test {
        public static void main(String[] args) {
            new Test().hello();
        }
        //锁方法
        public synchronized void hello(){
            System.out.println("hello world");
        }
    }
    
    

    我们使用javap-c可以查看Test类所对应的字节码文件如下:

    //其余部分省略
    public synchronized void hello();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=2, locals=1, args_size=1
             0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
             3: ldc           #6                  // String hello world
             5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
             8: return
          LineNumberTable:
            line 16: 0
            line 17: 8
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0       9     0  this   LTest;
    

    可以看到:flags中出现了两个属性,一个是ACC_PUNLIC,另外一个是ACC_SYNCHRONIZED。第一个属性是表示该类的访问类型为public,如果为ACC_PRIVATE就表示访问类型为private。第二个属性就表示该方法为同步方法,同一时间只允许一个线程对其进行操作。

  • 代码块:在代码块中存在共享变量的操作,而该方法又存在多线程场景,那么我们一般会对该方法加上Synchronized关键字,这边举个例:

    public class Test {
        public static void main(String[] args) {
            new Test().hello();
        }
    
        public void hello(){
            //锁同步代码块
            synchronized (this){
                System.out.println("hello world");
            }
        }
    }
    
    

    用javap-c可以查看Test类所对应的字节码文件如下:

    //其余部分省略
    //这是加了锁的字节码
    public void hello();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=3, args_size=1
             0: aload_0
             1: dup
             2: astore_1
             3: monitorenter
             4: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
             7: ldc           #6                  // String hello world
             9: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
            12: aload_1
            13: monitorexit
            14: goto          22
            17: astore_2
            18: aload_1
            19: monitorexit
            20: aload_2
            21: athrow
            22: return

    可以看到,在指令当中多了monitorenter,和 monitorexit指令,这两个指令的意思就是进入同步代码块,和出同步代码块的意思,中间的内容同一时间只能由一个线程访问。

  • 静态方法:在静态方法中存在共享变量的操作,而该方法又存在多线程场景,那么我们一般会对该方法加上Synchronized关键字,这边举个例:

    public class Test {
        public static void main(String[] args) {
            new Test().hello();
        }
    
        public synchronized static void hello(){
            System.out.println("hello world");
        }
    }
    
    

    用javap-c可以查看Test类所对应的字节码文件如下:

    //其余部分省略
    public static synchronized void hello();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
        Code:
          stack=2, locals=0, args_size=0
             0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
             3: ldc           #6                  // String hello world
             5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
             8: return
          LineNumberTable:
            line 16: 0
            line 17: 8
    
    

    和普通方法相同,只不过多了static字段而已,但是其实还是有所不同,因为对于static的方法不是作用于对象头的,这一点后面我们再讲。

字节码层面:

其实字节码层面是基于两种命令来实现的,因为我们知道,jvm会将Java文件编译为自己可以识别的字节码文件,也就是class文件。可以理解为Java文件的执行,其实就是字节码指令的执行。除了几个比较特殊的存在,几乎所有Java当中的功能的最终实现,其实都是jvm对字节码指令的执行得到的结果。这里举几个例子,大家看看就好:

  1. 本地方法调用(Native Method Invocation): Java 允许通过使用 native 关键字声明本地方法,这些方法的实现是用其他语言(如 C 或 C++)编写的。这些本地方法可以直接调用底层操作系统的功能或库。虽然本地方法使用字节码调用的方式,但它们涉及到与 Java 虚拟机外部环境的交互,因此不完全属于字节码执行范畴。

  2. JNI(Java Native Interface): JNI 是一种允许 Java 代码与本地代码(如 C 或 C++)交互的机制。通过 JNI,Java 代码可以调用本地方法,并且本地代码也可以回调 Java 方法。这涉及到更多的底层交互,不仅仅是字节码指令的执行。

  3. Java 虚拟机内部机制: Java 虚拟机实现了许多内部机制,如垃圾回收、类加载、字节码解释、即时编译等。这些机制在一定程度上超出了纯粹的字节码指令执行,涉及到虚拟机的内部逻辑和管理。

  4. Java 标准库之外的外部库和框架: Java 生态系统包含许多外部的库和框架,例如 Spring、Hibernate、Netty 等。这些库和框架提供了更高级别的抽象和功能,它们的实现可能涉及多种技术和机制,不仅仅局限于字节码执行。

当然Synchronized是属于字节码指令执行的结果,对应的两个指令分别为:monitorenter,monitorexit。当带有Synchronized关键字的文件反编译后我们会发现存在一个monitorenter指令和两个monitorexit指令。当执行到monitorenter指令后程序进入同步状态,此时只允许一个线程进入执行该代码块。等到monitorexit执行以后,才允许别的线程来执行。我们可以看到一般一个monitorenter后面会跟着两个monitorexit,第一个monitorexit是结束同步,第二个monitorexit的意思是保证程序可以退出,可以参考finally()。

jvm层面:

好了,到了今天的重点了,Synchronized它在底层究竟是怎么实现的呢。他其实是通过mark word(对象头)中的地址去找到一个叫做monitor的东西来实现的,要搞清楚这些东西,我们需要了解下mark word的结构,还有monitor这个东西具体是什么。

在我们聊对象头的结构之前,我们需要知道一个类的对象当中都包含了什么信息

在一个类被加载时,会为这个类生成一个专属的class对象。而这个类每次被实例化以后,都会为这个类生成一个实例对象,我们这里说的对象指的是后者。一个类的对象分为三个部分:对象头,实例变量,对其填充。

对象头:Mark word和一个指向类对象的指针。

实例变量:存放一些对象的基本信息,如果是普通类型数据的话就是一些值,如果是引用类型的话就是一个指向内存的指针。

对其填充:没有什么实际意义,因为jvm对象必须是8的整数倍,如果不满足这个条件的话这个字段会对其进行填补。

 

了解下Java中的Synchronized锁_第1张图片 

 

 那Mark word又是个什么东西呢?

 

了解下Java中的Synchronized锁_第2张图片 Mark word是jvm实现的一种可变的数据结构,里面包含了一个对象的hash码,gc年龄以及锁状态信息。为什么说它可变的,因为这个结构的前30位都可以用来表示不同的信息,后两位都是用来表示锁状态相关信息的。

 

 因为Synchronized在jdk6以前,对于互斥量会直接加一个重量级的锁,即通过Mark word中monitor的地址去访问monitor,而monitor是由ObjectMonitor实现的,源码由c++实现,里面主要的属性如下图了解下Java中的Synchronized锁_第3张图片

 这些属性我们在这里不做过多注释,因为已经超出了Java语言的范畴,我们主要研究下jdk6以后的一些变动。

虽然Synchronized可以保证数据的安全性,可是当锁住整个共享资源以后,其他访问共享资源的线程再次访问会进入阻塞状态,而阻塞的操作是非常耗费系统资源的。

因为Java的线程是映射到操作系统的原生线程之上的,一个线程的阻塞和唤醒是需要操作系统介入完成的,这就牵扯到了用户态向内核态的转换,这种操作是非常耗费系统资源的。因为用户态和内核态都有自己专用的内存空间,和专属的寄存器等,用户态切换内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。了解

  1. 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  2. 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

由此可以大致推测出Synchronized实现的锁是一个极其笨重的锁,一点也不灵活。其实实际上也是这样的。不过在jdk1.6以后,Synchronized加入了一个锁升级,锁粗化,锁消除等几个过程,这解决了在简单的同步代码块中,Synchronized过于笨重的问题,使Synchronized变成了一个很“灵活”的锁。至于锁升级的过程下面我们继续研究。

 了解下Java中的Synchronized锁_第4张图片

这是mark word的结构,可以看到,其中不仅仅有有关monitor的重量级锁,还包含了偏向锁,轻量级锁这两个字段。没错,这就是jdk6以后对于Synchronized进行的优化操作,具体的优化过程我们开始解密:

偏向锁

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

轻量级锁

  1. 先会访问mark word的锁标志位,如果为01,那么就会在当前线程的栈帧当中简历一个名为锁记录的空间,如图所示SouthEast
  2.  然后回拷贝mark word的信息到这个锁记录中。
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示:SouthEast

  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程

至于为什么要复制一份Mark word的信息到Lock Record,我在刚刚看的时候是有疑惑的,最后明白了是要和原Mark word中数据对比,因为期间如果有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。

上面在介绍Synchronized作用于静态方法的时候,锁的是类,就不是对象头了。对于类级别的锁(静态synchronized方法或代码块),情况略有不同。在Java虚拟机中,并没有为类的元数据(Class对象)分配对象头。因此,JVM不会直接使用对象头来实现对整个类的锁。相反,JVM在内部维护了一个用于类级别锁的数据结构。当一个线程尝试进入一个类级别的synchronized代码块或方法时,JVM会使用该类的元数据(Class对象)作为锁标识,而不是使用对象头。这个锁标识实际上是一个指向内部数据结构的引用,而不是实际的对象头。这使得可以在没有类实例的情况下锁定整个类,也就是类级别的锁。

这就是Synchronized的锁升级的过程

 总结:本文我们从Java语言,字节码,jvm三个层面介绍了Synchronized的功能,作用场景,原理等一些内容,如有问题欢迎指正。

 

 

 

你可能感兴趣的:(jvm,java)