synchronized 核心原理

目录

    • 同步实例方法
    • 同步静态方法
    • 同步代码块
    • Java 对象结构
      • 对象结构总览
      • 对象头
      • 实例数据
      • 对齐补充
    • Java 对象头
      • Mark Word
      • 类型指针
      • 数组长度
    • 使用 JOL 查看对象信息
      • 引入 JOL 环境依赖
      • 打印对象信息
      • 打印对象锁状态
    • synchronized 核心原理
      • synchronized 底层原理
        • 修饰方法
        • 修饰代码块
      • Monitor 锁原理
      • 反编译 synchronized 方法
      • 反编译 synchronized 代码块

synchronized 的用法可以分为三种,分别为同步实例方法、同步静态方法和同步代码块。
synchronized 核心原理_第1张图片

同步实例方法

当一个类中的普通方法被 synchronized 修饰时,相当于对 this 对象加锁,这个方法被声明为同步方法。此时,多个线程并发调用同一个对象实例中被 synchronized 修饰的方法是线程安全的。

    public synchronized void testMethod(){
        // 业务逻辑
    }

同步静态方法

可以在 Java 的静态方法上添加 synchronized 关键字来对其进行修饰,当一个类的某个静态方法被 synchronized 修饰时,相当于对这个类的 Class 对象加锁,而一个类只对应一个 Class 对象。此时,无论创建多少个当前类的对象调用被 synchronized 修饰的静态方法,这个方法都是线程安全的。

    public static synchronized void testMethod(){
        // 业务逻辑
    }

同步代码块

synchronized 关键字修饰方法可以保证当前方法是线程安全的,但如果修饰的方法临界区较大,或者方法的业务逻辑过多,则可能影响程序的执行效率。
synchronized 修饰代码块可以分为两种情况,一种情况是对某个对象加锁,另一种情况是对类的 Class 对象加锁。

    public void testMethod(){
        synchronized(obj) {
        	// 业务逻辑
        }
    }
  public void testMethod(){
        synchronized(Test.class) {
        	// 业务逻辑
        }
    }

Java 对象结构

对象结构总览

synchronized 核心原理_第2张图片

从图中可以看出,一个类生成的对象实例会存储在 JVM 的堆区。一个完整的 Java 对象除了包括在类中定义的成员变量和方法等信息,还会包括对象头,对象头又包括 Mark Word、类型指针和数组长度,而数组长度只在当前对象是数组时才存在。同时为了满足 JVM 中对象的起始地址必须是 8 的整数倍的要求,对象在 JVM 堆区中的存储结构还会有一部分对齐填充位。
一个类的类元信息会存储在 JVM 的方法区中,对象头的类型指针会指向存储方法区中的类元信息。

类元信息:类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)。

对象头

Java 中的对象头一般占用 2 个机器码的存储空间。在 32 位 JVM 中,1 个机器码占用 4 字节的存储空间,也就是 32 位;而在 64 位 JVM 中,1 个机器码占用 8 字节的存储空间,也就是 64 位。
对象头中存储了对象的 Hash 码、对象所属的分代年龄、对象锁、锁状态、偏向锁的 ID(获得锁的线程 ID)、获得偏向锁的时间戳等,如果当前对象是数组对象,则对象头中还会存储数组的长度信息
所以,如果当前对象是数组对象,则对象头会占用 3 个机器码空间,多出来的一个机器码空间用于存储数组的长度。

实例数据

实例数据部分主要存储的是对象的成员变量信息,例如,存储了类的成员变量的具体值,也包括父类的成员变量值,在 JVM 中,这部分的内存会按照 4 字节进行对齐。

对齐补充

在 HotSpot JVM 中,对象的起始位置地址必须是 8 的整数倍。对象头占用的存储空间已经是 8 的整数倍,所以如果当前对象的实例变量占用的存储空间不是 8 的整数倍,则需要使用填充数据来保证 8 字节的对齐。

如果当前对象的实例变量占用的存储空间是 8 的整数倍,则不需要使用填充数据来保证字节对齐,也就是说,填充数据不是必须存在的,它的存在仅仅是为了进行 8 字节的对齐。

Java 对象头

Java 中的对象头可以进一步分为 Mark Word、类型指针和数字长度三部分。

Mark Word

Mark Word 主要用来存储对象自身的运行时数据,例如:对象的 Hash 码、GC(垃圾回收)的分代年龄,锁的状态标志、对象的线程锁状态信息、偏向线程 ID、获得偏向锁的时间戳等。在 Java 中,Mark Word 是实现偏向锁和轻量级锁的关键。
Mark Word 字段的长度与 JVM 的位数相关,在 32 位 JVM 中,Mark Word 占用 32 位存储空间;在 64 位 JVM 中,Mark Word 占用 64 位存储空间。
由于对象头所占用的存储空间与对象自身存储的数据无关,所以对象头占用的是额外的空间, JVM 为了提高存储效率,将 Mark Word 设计成一个非固定的数据结构,以便能够在 Mark Word 中存储更多信息。同时,Mark Word 会随着程序的运行发生一系列的变化。
32 位 JVM 中 Mark Word 的结构如图所示
synchronized 核心原理_第3张图片

64 位 JVM 中 Mark Word 的结构如图所示
synchronized 核心原理_第4张图片

我们重点看一下 64 位 JVM 中 Mark Word 的结构:

  1. 锁标志位:占用 2 位存储空间,锁标志位的值不同,所代表的整个 Mark Word 的含义不同。
  2. 是否偏向锁标记:占用 1 位存储空间,标记对象是否开启了偏向锁。当值为 0 时,表示没有开启偏向锁;当值为 1 时,表示开启了偏向锁。
  3. 分代年龄:占用 4 位存储空间,表示 Java 对象的分代年龄。在 JVM 中,当发生 GC 垃圾回收时,年轻代未被回收的对象会在 Survivor 区被复制一次,对象的分代年龄加 1。如果被复制的次数超过了一定的值,那么当前对象会被移动到老年代。在默认情况下,当分代年龄达到 15 时,对象会被移动到老年代。
  4. 对象 HashCode:占用 31 位存储空间,主要存储对象的 HashCode值。
  5. 线程 ID:占用 54 位存储空间,表示持有偏向锁的线程 ID。
  6. 时间戳:占用 2 位存储空间,表示偏向时间戳。
  7. 指向栈中锁记录的指针:占用 62 位存储空间,表示在轻量级锁的状态下,指向栈中锁记录的指针。
  8. 指向重量级锁的指针:占用 62 位存储空间,表示在重量级锁的状态下,指向对象监视器的指针。

类型指针

不同位数的 JVM 的类型指针所占用的位数也不同。在 32 位 JVM 中,类型指针占用 32 位存储空间;而在 64 位 JVM 中,类型指针占用 64 位存储空间。

数组长度

如果当前对象是数组类型的,则在对象头中还需要额外的空间存储数组的长度信息。数组的长度纤细在不同位数的 JVM 所占用的存储空间是不同的。在 32 位 JVM 中,数组长度占用 32 位存储空间;而在 64 位 JVM 中,数组长度占用 64 位存储空间。

使用 JOL 查看对象信息

JOL 工具包能够比较精确地分析对象在 JVM 中的结构,也可以计算某个对象的大小。

引入 JOL 环境依赖

        <dependency>
            <groupId>org.openjdk.jolgroupId>
            <artifactId>jol-coreartifactId>
            <version>0.11version>
        dependency>

打印对象信息

新建 MyObject 测试类

public class MyObject {

    private int count = 0;

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }
}

接下来,创建 ObjectSizeAnalysis 类

public class ObjectSizeAnalysis {

    public static void main(String[] args) {
        MyObject obj = new MyObject();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

运行 ObjectSizeAnalysis 中的 main 方法,结果如下:
synchronized 核心原理_第5张图片
synchronized 核心原理_第6张图片
synchronized 核心原理_第7张图片

打印对象锁状态

测试代码如下:

public class ObjectLockAnalysis {

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

    /**
     * 打印锁信息
     */
    private static void printNormalLock() throws InterruptedException {
        //创建测试类对象
        MyObject obj = new MyObject();
        //打印对象信息,此时对象处于无锁状态
        System.out.println("打印对象信息,此时对象处于无锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
        synchronized (obj) {
            //打印对象信息,此时对象处于轻量级锁状态
            System.out.println("打印对象信息,此时对象处于轻量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
            //计算对象的Hashcode值
            System.out.println("计算对象的Hashcode值=====" + obj.hashCode());
            //计算处于轻量级状态的对象的HashCode值,轻量级锁会膨胀为重量级锁
            System.out.println("计算处于轻量级状态的对象的HashCode值,轻量级锁会膨胀为重量级锁=====" + ClassLayout.parseInstance(obj).toPrintable());
        }
        synchronized (obj) {
            //打印对象信息,此时对象处于重量级锁状态
            System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
        }
        //打印对象信息,此时对象处于重量级锁状态
        System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
    }
}

通过 printNormalLock() 方法的输出结果,我们可以看出如下信息

  1. 创建对象后输出的偏向锁标志位为 0,锁标志位为 01,此时处于无锁状态。synchronized 核心原理_第8张图片
  2. 第一次使用 synchronized 关键字对创建的 MyObject 对象加锁后,再次打印的结果信息中锁标志位为 00,此时处于轻量级锁状态。synchronized 核心原理_第9张图片
  3. 对于轻量级锁状态的对象计算其 HashCode 值,再次打印对象信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。说明计算处于轻量级锁状态对象的 HashCode 值,轻量级锁会膨胀为重量级锁。synchronized 核心原理_第10张图片
  4. 释放 MyObject 对象的 synchronized 锁后,再次对其添加 synchronized 锁,并打印 MyObject 对象的信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。synchronized 核心原理_第11张图片
  5. 释放第二次添加的 synchronized 锁,再次打印 MyObject 对象的信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。synchronized 核心原理_第12张图片

上面的打印对象锁状态中没有输出偏向锁的状态,这是由于 Java 中的偏向锁默认在 JVM 启动几秒之后才会激活。在 ObjectLockAnalysis 类中新增 printBiasLock() 方法来打印偏向锁信息,代码如下:

public class ObjectLockAnalysis {

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

    /**
     * 打印偏向锁信息
     */
    private static void printBiasLock() throws InterruptedException {
        //Java中的偏向锁在JVM启动几秒之后才会被激活
        //所以程序启动时先休眠5秒钟,等待激活偏向锁
        //否则会出现一些没必要的锁撤销
        Thread.sleep(5000);
        //创建测试类对象
        MyObject obj = new MyObject();
        //打印对象信息,此时对象处于偏向锁状态
        System.out.println("打印对象信息,此时对象处于偏向锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
        synchronized (obj) {
            //打印对象信息,此时对象处于偏向锁状态
            System.out.println("打印对象信息,此时对象处于偏向锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
            //计算对象的Hashcode值
            System.out.println("计算对象的Hashcode值=====" + obj.hashCode());
            //计算处于偏向锁状态的对象的HashCode值,偏向锁会膨胀为重量级锁
            System.out.println("计算处于偏向锁状态的对象的HashCode值,偏向锁会膨胀为重量级锁=====" + ClassLayout.parseInstance(obj).toPrintable());
        }
        synchronized (obj) {
            //打印对象信息,此时对象处于重量级锁状态
            System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
        }
        //打印对象信息,此时对象处于重量级锁状态
        System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
    }
}

通过 printBiasLock() 方法的输出结果,我们可以看出如下信息

  1. 程序休眠 5s 后创建 MyObject 对象,打印对象信息,在输出的结果中,我们可以看到偏向标志位为 1,锁标志位为 01,此时处于偏向锁状态,说明程序已经激活偏向锁。synchronized 核心原理_第13张图片
  2. 第一次使用 synchronized 关键字对创建的 MyObject 对象加锁后,再次打印的结果信息中锁标志位为 01,偏向锁标记为 1,此时处于偏向锁状态。synchronized 核心原理_第14张图片
  3. 对于偏向锁状态的对象计算其 HashCode 值,再次打印对象信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。synchronized 核心原理_第15张图片
  4. 释放 MyObject 对象的 synchronized 锁后,再次对其添加 synchronized 锁,并打印 MyObject 对象的信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。synchronized 核心原理_第16张图片
  5. 释放第二次添加的 synchronized 锁,再次打印 MyObject 对象的信息,输出的结果中锁标志位为 10,此时处于重量级锁状态。synchronized 核心原理_第17张图片

对比上面两个方法的打印信息,我们可以发现如下特点:

  • Java 中的偏向锁默认需要在 JVM 启动几秒之后才会被激活,如果想打印对象的偏向锁状态,那么需要在 JVM 启动后,让方法休眠几秒再执行。
  • 无论当前对象的对象头中的锁标志位和偏向标志位,是处于偏向锁状态还是处于轻量级锁状态,只要计算了当前对象的 HashCode 值,当前对象所处的锁状态就都会膨胀为重量级锁状态。也就是说,偏向锁和轻量级锁会膨胀为重量级锁。

synchronized 核心原理

synchronized 底层原理

synchronized 是基于 JVM 中的 Monitor 锁实现的,从 Java 1.6 版本开始,对 synchronized 锁进行了大量的优化,引入了锁粗化、锁消除、偏向锁、轻量级锁、适应性自旋等技术来提升 synchronized 锁的性能。

修饰方法

当 synchronized 修饰方法时,当前方法会比普通方法在常量池中多一个「ACC_SYNCHRONIZED」标识符
synchronized 核心原理_第18张图片

该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,则当前线程将先获取 monitor 对象。同一时刻,只会有一个线程获取 monitor 对象成功,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。从而保证了同一时刻只能有一个线程进入被 synchronized 修饰的方法中执行方法体的逻辑。

修饰代码块

当 synchronized 修饰方法时,synchronized 关键字会被编译成「monitorenter」和「monitorexit」两条指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
synchronized 核心原理_第19张图片

从图中可以看出,当源码中使用了 synchronized 修饰代码块,源码被编译成字节码后,同步代码块的逻辑前后会分别被添加 monitorenter 和 monitorexit 两条指令,使得同一时刻只能有一个线程进入 monitorenter 和monitorexit 两条指令中间的同步代码块。

Monitor 锁原理

synchronized 底层是基于 Monitor 锁实现的,而 Monitor 锁是基于操作系统的 Mutex 锁实现的,Mutex 锁是操作系统级别的重量级锁,其性能较低。
在 Java 中,创建出来的任何一个对象在 JVM 中都会关联一个 Monitor 对象,当 Monitor 对象被一个 Java 对象持有之后,这个 Monitor 对象处于锁定状态,synchronized 在 JVM 底层本质上都是基于进入和退出 Monitor 对象来实现同步方法和同步代码块的。

反编译 synchronized 方法

测试代码如下:

public class SynchronizedDecompileTest {

    public synchronized void syncMethod(){
        System.out.println("hello synchronized method");
    }

    public void synCodeBlock(){
        synchronized (this){
            System.out.println("hello synchronized code block");
        }
    }
}

使用「javac -g」将源代码编译成 .class 文件。
使用「javap -v」将 .class 文件进行反编译,代码如下:
synchronized 核心原理_第20张图片

 public synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello synchronized method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 4: 0
        line 5: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   LSynchronizedDecompileTest;

从输出结果我们可以看到,syncMethod 方法在反编译后的 flags 中会有一个 ACC_SYNCHRONIZED 标识符。当调用 syncMethod 方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 Monitor 锁对象,获取成功之后才能执行方法体,方法执行完后再释放 Monitor。在方法执行期间,其他任何线程都无法再获得同一个 Monitor 对象,从而保证了被 synchronized 修饰的方法同一时刻只能被一个线程执行。

反编译 synchronized 代码块

测试代码如下:

public class SynchronizedDecompileTest {

    public void synCodeBlock(){
        synchronized (this){
            System.out.println("hello synchronized code block");
        }
    }
}

进行反编译,代码如下:
synchronized 核心原理_第21张图片

 public void synCodeBlock();
    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     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello synchronized code block
         9: invokevirtual #4                  // 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
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any

从输出就可以看出,当 synchronized 修饰代码块时,会在编译出的字节码中插入 monitorenter 指令和 monitorexit 指令,如下所示

3: monitorenter
13: monitorexit
19: monitorexit

在 JVM 的规范中也有对上面两个指令 monitorenter、monitorexit 的描述,我简单总结一下对 monitorenter 的描述:
每个对象都有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时首先会尝试获取 monitor 的所有权,整个流程如下:

  1. 如果 monitor 计数为零,则线程进入 monitor 并将 monitor 计数设置为 1。当前线程就是 monitor 的所有者。
  2. 如果线程已经获取到 monitor,此时只是重新进入 monitor,则只是将进入 monitor 的计数加 1。
  3. 如果另一个线程已经占用了 monitor,则当前线程将阻塞,直到 monitor 的计数为零,当前线程将再次尝试获取 monitor。

JVM 规范中对于 monitorexit 指令的描述原文如下:
在执行 monitorexit 指令时,monitor 的计数会减 1。如果减 1 后 monitor 的计数为 0,则当前线程将退出 monitor,不再是当前 monitor 的所有者。其他被阻止进入当前 monitor 的线程可以尝试再次获取当前 monitor 的所有权。

参考: 《深入理解高并发编程》

你可能感兴趣的:(Java并发,java,并发编程,synchronized)