本篇章是对 Java 并发中的 synchronized 关键字进行深入探索。🚀本篇章代码 Demo

# 序言

  • 线程安全是并发编程中的重要关注点,应该注意的是,造成线程安全问题的主要原因有以下两点:
    • 1️⃣ 是存在共享数据 (临界资源)。
    • 2️⃣ 存在多条线程共同操作共享数据。
  • 为了解决这个问题,我们需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一个时刻有且只有一个线程在操作共享数据,其它线程必须等到该线程处理完数据后再进行。
  • 这种方式的名称叫 互斥锁 ,即能达到互斥访问目的的锁,也就说当一个共享数据被当前正在访问的线程加上 互斥锁 后,在同一个时刻,其它线程只能处于等待状态,直到当前线程处理完数据释放该锁。
  • Java 中关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块 (主要是对方法或者代码块中存在共享数据的操作)。
  • 同时我们还应该注意到 synchronized 另外一个重要的作用,那就是 synchronized 可以保证一个线程的变化 (共享数据的变化) 被其它线程所看到 (保证可见性,完全可以替代 volatile 关键字),这点也是很重要。

# synchronized 的应用

  • synchronized 关键字主要有以下 3 种应用方式:
    • 修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁。
    • 修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。
    • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

# synchronized 修饰实例方法

所谓实例对象锁就是用 synchronized 修饰实例对象中的实例方法 (实例方法不包括静态方法),代码如下:

package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 修饰实例方法
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedData implements Runnable {
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    @Override
    public synchronized void run() {
        for (int j = 0; j < 1000000; j++) {
            incremental();
        }
    }
    /**
     * 递增方法
     */
    private synchronized void incremental() {
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        SynchronizedData instance = new SynchronizedData();
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        // 开始
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
        /*
           执行结果: 2000000
         */
    }
}

在上述代码中,我们开启了两个线程操作同一个共享资源 (即:变量 i ), 由于 i++ 并不是原子性操作,该操作是先读取值然后写回一个新值,相当于原来的值加上 1 ,分两步完成。如果第二个线程在第一个线程读取旧值和写会新值期间读取 i 的阈值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败。因此对于 incremental() 方法必须使用 synchronized 来修饰,以便保证线程安全。此时我们应该注意到 synchronized 修饰的是实例方法 incremental() , 在这样的情况下,当前线程的锁便是实例对象 incremental

注意:在 Java 中线程同步锁可以是任意对象,从代码执行结果来看确实是正确的,倘若我们没有使用 synchronized 关键字,其最终执行结果大概率是小于 2000000 的,这便是 synchronized 关键字的作用,这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法时,那么其它线程不能访问该对象的其它 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其它线程将无法再次获取该对象的锁,所以无法访问该对象的其它 synchronized 实例方法,但是其它线程还是可以访问该实例对象的其它非 synchronized 方法。

如果:一个线程 A 需要访问实例对象 object1synchronizeda1 方法 (当前对象锁是 object1 ),另一个线程 B 需要访问实例对象 object2synchronizeda2 方法 (当前对象锁是 object2 ),这样是允许的,以为两个实例对象的锁并不相同,此时人工两个线程操作数据并非共享数据,此时线程是安全有保障的,遗憾的是如果两个线程操作的是共享数据那么线程安全就很有可能无法保证,下面是代码示例:

SynchronizedDataError.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: 非线程安全使用共享数据
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedDataError implements Runnable {
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    
    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            incremental();
        }
    }
    /**
     * 递增方法
     */
    private synchronized void incremental() {
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        // 创建新实例
        Thread t1=new Thread(new SynchronizedDataError());
        // 创建新实例
        Thread t2=new Thread(new SynchronizedDataError());
        // 开始线程
        t1.start();
        t2.start();
        //join 含义:当前线程 A 等待 thread 线程终止之后才能从 thread.join () 返回
        t1.join();
        t2.join();
        System.out.println(i);
        /*
           执行结果: 1658464
           每次执行的数值是随机的并不是固定的数值,这就是非线程安全操作共享数据
         */
    }
}

上述代码与前面的不同的是我们创建了两个新实例的 SynchronizedDataError 线程对象,然后启动两个不同的线程对共享数据变量 i 进行操作,遗憾的是执行结果不是期望的 2000000 ,因此上述代码犯了严重的错误,虽然我们使用 synchronized 修饰了 incremental() 方法,但是创建的是两个不同的实例对象,这也就以为着存在着两个不同实例的对象锁,因此 t1t2 都会进入各自的对象锁中,就是说 t1t2 线程使用的是不同的锁,因而无法保证线程安全,解决这个问题的方式是将 synchronized 作用于静态的 incremental() 方法上,这样一来的话,对象锁就是当前类对象,由于无论创建多少个实例对象,但对于类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的,修改后的代码如下:

SynchronizedDataError.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: 线程安全使用共享数据
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedDataError implements Runnable {
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    
    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            incremental();
        }
    }
    /**
     * 递增方法
     */
    private synchronized static void incremental() {
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        // 创建新实例
        Thread t1=new Thread(new SynchronizedDataError());
        // 创建新实例
        Thread t2=new Thread(new SynchronizedDataError());
        // 开始线程
        t1.start();
        t2.start();
        //join 含义:当前线程 A 等待 thread 线程终止之后才能从 thread.join () 返回
        t1.join();
        t2.join();
        System.out.println(i);
        /*
           执行结果: 2000000
           将 incremental () 方法修饰为 static 后就可以做到线程安全的了
         */
    }
}

# synchronized 修饰静态方法

synchronized 作用于静态方法时,其锁就是当前类的 class 对象锁,由于静态成员不专属于任何一个实例对象,属于类成员,因此通过 class 对象锁就可以控制静态成员的并发操作。需要注意的是如果:一个线程 A 调用一个实例对象的非 static synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,这样是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的 class 对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,示例代码如下:

SynchronizedDataClass.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 修饰静态方法
 * @DateTime: 2023-12-16 12:33
 * @Version:1.0
 **/
public class SynchronizedDataClass implements Runnable {
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            staticIncremental();
        }
    }
    /**
     * 作用于静态方法,锁是当前 class 对象,也就是
     * SynchronizedDataClass 类对应的 class 对象
     */
    public static synchronized void staticIncremental() {
        i++;
    }
    /**
     * 非静态,访问时锁不一样不会发生互斥
     */
    public synchronized void incremental() {
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        //new 新实例
        Thread t1 = new Thread(new SynchronizedDataClass());
        Thread t2 = new Thread(new SynchronizedDataClass());
        // 启动线程
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
        /*
            执行结果:2000000
         */
    }
}

由于 synchronized 关键字修饰的是静态 staticIncremental() 方法,与修饰实例方法不同的是,其锁对象是当前类的 class 对象,注意代码中的 incremental() 方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,但我们应该意识到这种情况下可能会发现线程安全问题 (操作共享资源)。

# synchronized 同步代码块

除了使用 synchronized 关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,就会非常耗时,这种情况我们就可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,示例代码如下:

SynchronizedCodeBlock.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 同步代码块
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedCodeBlock implements Runnable {
    private static final SynchronizedCodeBlock INSTANCE = new SynchronizedCodeBlock();
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    /**
     * synchronized 同步代码块,锁对象是 INSTANCE
     */
    @Override
    public void run() {
        synchronized (INSTANCE){
            for (int j = 0; j < 1000000; j++) {
                i++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(INSTANCE);
        Thread t2 = new Thread(INSTANCE);
        // 开始
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
        /*
           执行结果: 2000000
         */
    }
}

从上面代码中可以看出,将 synchronized 作用于一个给定的实例对象 INSTANCE ,即当前实例对象就是锁对象,,每次当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 INSTANCE 实例的对象锁,如果当前有其它线程持有该对象锁,那么新到的线程就必须等待,这样就保证了每次只有一个线程执行 i++ 操作。当然除了 INSTANCE 作为对象外,我们还可以使用 this 对象 (代表当前实例) 或者当前类的 class 对象作为锁,示例代码如下:

SynchronizedCodeBlockThis.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 同步代码块 this
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedCodeBlockThis implements Runnable {
    /**
     * 共享资源 (临界资源)
     */
    private static int i = 0;
    @Override
    public void run() {
        //this: 当前实例对象锁
        synchronized (this){
            for (int j = 0; j < 1000000; j++) {
                i++;
            }
        }
        //class 对象锁
        synchronized (SynchronizedCodeBlockThis.class){
            for (int j = 0; j < 1000000; j++) {
                i++;
            }
        }
    }
}

了解完了 synchronized 的基本含义和使用方式后,下面将进一步深入 synchronized 的底层实现原理。

# synchronized 底层语义

  • Java 虚拟机中的同步 ( Synchronization ) 基于进入和退出管程 ( Monitor ) 对象实现,无论是显示同步还是隐式同步都是如此。
    • 显示同步是指有明确的 monitorentermonitorexit 指令,即:同步代码块。
  • Java 中, synchronized 用的最多的地方就是被 synchronized 修饰的同步方法。
  • 同步方法并不是由 monitorentermonitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中的 ACC_SYNCHRONIZED 标志来隐式实现的。
  • 下面先来了解一下 Java 对象头概念,这对理解 synchronized 实现原理很重要。

# Java 对象头与 Monitor

  • JVM 中对象在内存里分布为三块区域:对象头、实例数据、对齐填充。
    • 对象头对象头重点分析
    • 实例数据 :这部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
    • 对齐填充 :由于虚拟机要求对象起始地址必须是 8 字节的整数倍,对齐填充并不是必须存在的,仅仅是为了字节对齐。

# 对象头重点分析

对象头它是实现 synchronized 锁对象的基础,一般而言 synchronized 使用的锁对象是存储在 Java 对象头中的, JVM 采用两个字来存储对象头,其主要结构是由 Mark WordClass Metadata Address 组成,如果对象是数组则会分配三个字,多出来的一个字用于记录数组长度,结构说明请看下表:

虚拟机位数对象头结构描述
32/64bitMark Word存储对象的 hashCode 、锁信息或分代年龄或 GC 标志等信息
32/64bitClass Metadata Address类型指针指向对象的类元数据, JVM 通过这个指针确定该对象是哪个类的实例

Mark Word 在默认情况下存储着对象的 hashCode 分代年龄 锁状态标记 等,下表是 32JVMMark Word 默认存储结构。

锁状态25bit4bit1bit 是否是偏向锁2bit 锁标志位
无锁状态对象 hashCode对象分代年龄001

由于对象头信息是对象自身定义的数据没有关系的额外储存成本,因此考虑到 JVM 的空间效率, Mark Word 被设计成一个非固定的数据结构,以便存储更多有效数据,它会根据对象本身状态来复用自己的存储空间,如: 32 位的 JVM 除了上述列出的 Mark Word 默认存储结构外,还有可能变为如下结构:

pi4XAqP.png

轻量级锁和偏向锁是 Java 6synchronized 进行优化后新增的,在这我们先分析重量级锁也就是 synchronized 的对象锁,锁状态标记为 10 其中指针指向的是 monitor 对象 (管程或监视器锁) 的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系存在多种实现方式。

如: monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机 ( HotSpot ) 中, monitor 是由 ObjectMonitor 来实现的,其主要数据结构如下,位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,由 C++ 实现。

ObjectMonitor.hpp
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;     // 记录个数
    _waiters      = 0,
    _recursions   = 0;     // 线程的重入次数
    _object       = NULL;
    _owner        = NULL;  // 标识拥有该 monitor 的线程
    _WaitSet      = NULL;  // 处于 wait 状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

ObjectMonitor 中有两个队列分别是 _WaitSet_EntryList , 用于保存 ObjectWaiter 对象列表,而每个等待锁的线程都会被封装成 ObjectWaiter 对象, _owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后将进入 _owner 区域并把 monitor 中的 _owner 变量设置为当前线程,同时 monitor 中的计数器 count 将会 +1 ,若线程调用 wait() 方法,将释放当前持有的 monitor , _owner 变量恢复为 Null 然后 count-1 ,该线程将会进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕将释放 monitor 并复位变量的值,以便其它线程进入获取 monitor 锁。

pi5ASmD.png

由此可以看出 monitor 对象存在于每个 Java 对象的对象头中 (存储的指针指向), synchronized 便是通过这种方式获取锁的,这也是为什么 Java 中任意对象可以作为锁的原因,同时也是 noifynotifyAllwait 等方法存在于顶级对象 Object 中的原因,在了解了上面的知识基础后,将进一步来分析 synchronized 在字节码层面的具体语义实现。

# synchronized 代码块底层原理

现在重新来定义一个 synchronized 同步代码块,在代码块中操作共享变量,代码示例如下:

SyncCodeBlock.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 同步代码块用于 javap 反编译
 * @DateTime: 2023-12-16 22:30
 * @Version:1.0
 **/
public class SyncCodeBlock {
    private static int i;
    public void syncTask(){
        // 同步代码块
        synchronized (this){
            i++;
        }
    }
}

编译上述代码并使用 javap 命令来反编译得到字节码,反编译命令如下:

命令行提示符
javap -p -s -l -v -constants -c -sysinfo SyncCodeBlock.class
SyncCodeBlock.class
Classfile /D:/项目/gitee/synchronized-example/target/classes/top/rem/rain/synchronized_example/SyncCodeBlock.class
  Last modified 20231217; size 544 bytes
  SHA-256 checksum 448b5a7d5bcb275bb0612dbae81d517f7fa275735caecef1ace76e30f695a070
  Compiled from "SyncCodeBlock.java"
public class top.rem.rain.synchronized_example.SyncCodeBlock
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // top/rem/rain/synchronized_example/SyncCodeBlock
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // top/rem/rain/synchronized_example/SyncCodeBlock.i:I
   #8 = Class              #10            // top/rem/rain/synchronized_example/SyncCodeBlock
   #9 = NameAndType        #11:#12        // i:I
  #10 = Utf8               top/rem/rain/synchronized_example/SyncCodeBlock
  #11 = Utf8               i
  #12 = Utf8               I
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               LocalVariableTable
  #16 = Utf8               this
  #17 = Utf8               Ltop/rem/rain/synchronized_example/SyncCodeBlock;
  #18 = Utf8               syncTask
  #19 = Utf8               StackMapTable
  #20 = Class              #21            // java/lang/Throwable
  #21 = Utf8               java/lang/Throwable
  #22 = Utf8               SourceFile
  #23 = Utf8               SyncCodeBlock.java
{
  private static int i;
    descriptor: I
    flags: (0x000a) ACC_PRIVATE, ACC_STATIC
    // 构造函数
  public top.rem.rain.synchronized_example.SyncCodeBlock();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 9: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ltop/rem/rain/synchronized_example/SyncCodeBlock;
  // 主要来看 syncTask 方法实现
  public void syncTask();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  // 此处进入同步方法
         4: getstatic     #7                  // Field i:I
         7: iconst_1
         8: iadd
         9: putstatic     #7                  // Field i:I
        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
      LineNumberTable:
        line 14: 0
        line 15: 4
        line 16: 12
        line 17: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Ltop/rem/rain/synchronized_example/SyncCodeBlock;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class top/rem/rain/synchronized_example/SyncCodeBlock, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "SyncCodeBlock.java"

从字节码中可以看出同步代码块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,而 monitorexit 指令则指明同步代码块的结束位置,当执行 monitorenter 指令时,当前线程将试图获取 objectref (对象锁) 所对应的 monitor 的持有权,当 objectormonitor 进入时计数器为 0 ,那么线程就可以成功获取到 monitor ,并将计数器设置为 1 则代表取锁成功。

如果当前线程已持有 objectrefmonitor 持有权,那它可以重入这个 monitor (重入将在后面介绍),重入时计数器的值也会 +1 。倘若其它线程已拥有 objectrefmonitor 持有权,那么当前线程将被阻塞,直到正在执行的线程执行完毕 (即: monitorexit 指令被执行),执行完 monitorexit 指令后线程将释放 monitor 锁并设置计数器为 0 ,其他线程将有机会持有 monitor

这里需要注意的是编译器将会确保,无论方法通过任何一种方式完成, 方法中调用过的每条 monitorenter 指令都有其对应的 monitorexit 指令执行,而无论这个方法是否正常结束还是异常结束,为了确保在方法异常完成时 monitorentermonitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令,从字节码中可以看出多了一个 monitorexit 指令,它就是异常结束时被执行释放 monitor 的指令。

# synchronized 方法底层原理

synchronized 方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作中, JVM 可以从常量池中的方法结构表中访问 ACC_SYNCHRONIZED 标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了 ACC_SYNCHRONIZED 访问标志,执行线程将先持有 monitor (在虚拟机规范中代表管程),然后再执行方法,最后在方法完成时无论是否正常完成都会释放 monitor 。在方法执行期间,执行线程持有了 monitor 其它线程都将无法再获取到同一个 monitor

如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常时,那么这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放,字节码层如下:

SyncMethod.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 同步方法用于 javap 反编译
 * @DateTime: 2023-12-17 10:46
 * @Version:1.0
 **/
public class SyncMethod {
    public int i;
    public synchronized void syncTask(){
        i++;
    }
}

编译上述代码并使用 javap 命令来反编译得到字节码,反编译命令如下:

命令行提示符
javap -p -s -l -v -constants -c -sysinfo SyncMethod.class
SyncMethod.class
Classfile /D:/项目/gitee/synchronized-example/target/classes/top/rem/rain/synchronized_example/SyncMethod.class
        Last modified 20231217; size 431 bytes
        SHA-256 checksum 6b6d0142250883ab1592853d0f9ef17372bb1fb40c43ab9b5dd2a512653b0bc9
        Compiled from "SyncMethod.java"
public class top.rem.rain.synchronized_example.SyncMethod
        minor version: 0
        major version: 61
        flags: (0x0021) ACC_PUBLIC, ACC_SUPER
        this_class: #8                          // top/rem/rain/synchronized_example/SyncMethod
        super_class: #2                         // java/lang/Object
        interfaces: 0, fields: 1, methods: 2, attributes: 1
        Constant pool:
        #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
        #2 = Class              #4             // java/lang/Object
        #3 = NameAndType        #5:#6          // "<init>":()V
        #4 = Utf8               java/lang/Object
        #5 = Utf8               <init>
   #6 = Utf8               ()V
           #7 = Fieldref           #8.#9          // top/rem/rain/synchronized_example/SyncMethod.i:I
           #8 = Class              #10            // top/rem/rain/synchronized_example/SyncMethod
           #9 = NameAndType        #11:#12        // i:I
           #10 = Utf8               top/rem/rain/synchronized_example/SyncMethod
           #11 = Utf8               i
           #12 = Utf8               I
           #13 = Utf8               Code
           #14 = Utf8               LineNumberTable
           #15 = Utf8               LocalVariableTable
           #16 = Utf8               this
           #17 = Utf8               Ltop/rem/rain/synchronized_example/SyncMethod;
           #18 = Utf8               syncTask
           #19 = Utf8               SourceFile
           #20 = Utf8               SyncMethod.java
{
public int i;
        descriptor: I
        flags: (0x0001) ACC_PUBLIC
public top.rem.rain.synchronized_example.SyncMethod();
        descriptor: ()V
        flags: (0x0001) ACC_PUBLIC
        Code:
          stack=1, locals=1, args_size=1
            0: aload_0
            1: invokespecial #1                  // Method java/lang/Object."<init>":()V
            4: return
        LineNumberTable:
          line 9: 0
        LocalVariableTable:
          Start  Length  Slot  Name   Signature
            0       5     0  this   Ltop/rem/rain/synchronized_example/SyncMethod;
    // 主要来看 syncTask 方法
public synchronized void syncTask();
        descriptor: ()V
        // 方法标识 ACC_PUBLIC 代表 public 修饰,ACC_SYNCHRONIZED 指明该方法为同步方法
        flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=3, locals=1, args_size=1
            0: aload_0
            1: dup
            2: getfield      #7                  // Field i:I
            5: iconst_1
            6: iadd
            7: putfield      #7                  // Field i:I
            10: return
        LineNumberTable:
          line 13: 0
          line 14: 10
        LocalVariableTable:
          Start  Length  Slot  Name   Signature
            0      11     0  this   Ltop/rem/rain/synchronized_example/SyncMethod;
}
SourceFile: "SyncMethod.java"

在字节码中可以看出, synchronized 修饰的方法并没有 monitorentermonitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,此标识指明了该方法是一个同步方法, JVM 通过该 ACC_SYNCHRONIZED 标识来分辨一个方法是否被声明为同步方法,从而执行相应的同步调用,这便是 synchronized 锁在同步代码块和同步方法上的基本实现原理。

我们需要必须注意的是在 Java 早期版本中, synchronized 属于重量级锁,由此效率很低,因为监视器锁 ( monitor ) 是依赖于底层操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对较长的时间,时间成本消耗会很高,这也是为什么早期的 synchronized 效率很低的原因。

好在 Java 6 之后官方从 JVM 层面对 synchronized 做了较大的优化,所以现在的 synchronized 锁效率优化得很不错了, Java 6 之后为了减少获得锁和释放锁时带来的性能消耗,便引入了轻量级锁和偏向锁,下面接着将来了解一下 JavaJVM 层对 synchronized 的优化。

# JVM 对 synchronized 的优化

  • 锁的状态一共有四种分别是:
  • 锁的状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,就是说只能从低到高,不会出现锁的降级。
  • 下面将介绍偏向锁和轻量级锁以及 JVM 的其它优化手段,如需详细阅读请查阅 《深入理解java虚拟机》

# 偏向锁

偏向锁是在 Java 6 之后新增的锁,它是一种针对加锁操作的优化手段,经过研究发现在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,因此为了减少同一个线程获取锁 (涉及 CAS 操作) 的代价而引入的偏向锁。

偏向锁核心思想是,如果一个线程获得了锁,那么锁就会进入偏向模式,此时 Mark Word 的结构也将变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作 (即获取锁的过程),这样就省去了大量有关 “锁” 申请的操作,从而提高了程序的性能。

对于没有锁竞争的场合,偏向锁有很好得优化效果,毕竟极有可能连续多次是同一个线程申请相同锁。但是对于锁竞争激烈的场合,偏向锁就将失效了,因为这种场合极有可能每次申请锁的线程都是不同的,因此在这种情况下就不应该使用偏向锁,偏向锁失败后并不会立即升级到重量级锁,而是先将锁升级为轻量级锁。

# 轻量级锁

偏向锁失败后, JVM 并不会立即升级为重量级锁,它首先会尝试一种称为轻量级锁的优化手段 (自 1.6 之后新增的一种锁),此时 Mark Word 的结构将变为轻量级锁结构。轻量级锁能够提升程序性能的依据是:对绝大部分的锁,在整个同步周期内都不存在竞争。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一个锁的场景,将会导致轻量级锁升级为重量级锁。

# 自旋锁

轻量级锁失败后, JVM 为了避免线程真实地在操作系统层面被挂起,将会进行一种称为自旋锁的优化手段。这是基于大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程将会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前线程可以获得锁,虚拟机会让当前想要获得锁的线程做几个空循环 (这也是被称为自旋的原因),一般不会太久,大概是在 50~100 个循环,经过若干次循环后,如果得到锁就顺利进入临界区。

如果:经过若干次循环后还不能获得锁,那么线程将会在操作系统层面被挂起,这就是自旋锁的优化方式,这种方式确实可以提升效率,在最后没办法的情况时也就只能升级为重量级锁了。

# 锁消除

锁消除是虚拟机的另外一种对锁的优化,这种优化更彻底, Java 虚拟机在 JIT 编译时 (即时编译,可以理解为当某段代码即将第一次被执行时进行编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式来消除没有必要的锁,可以省去毫无意义的加锁时间,示例代码如下:

StringBufferRemoveSynchronized.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: 锁消除示例:消除 StringBuffer 同步锁
 * @DateTime: 2023-12-17 15:55
 * @Version:1.0
 **/
public class StringBufferRemoveSynchronized {
    public void add(String str1, String str2) {
        // StringBuffer 是线程安全的,由于 sb 只会在 append 方法中使用,不可能被其他线程引用
        // 因此 sb 属于不可能共享的资源,JVM 会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
    public static void main(String[] args) {
        StringBufferRemoveSynchronized rsync = new StringBufferRemoveSynchronized();
        for (int i = 0; i < 10000000; i++) {
            rsync.add("abc", "123");
        }
    }
}

上述代码中 StringBufferappend() 方法是一个同步方法,但是在 add() 方法中的 StringBuffer 属于局部变量,并且不会被其它线程所使用,因此 StringBuffer 不会存在共享资源被竞争的情景, JVM 会自动将其内部的锁进行消除。

# synchronized 的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其它线程持有对象锁的临界资源时将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况就属于 “重入锁” 请求将会成功,在 Javasynchronized 是基于原子性内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其它方法体内调用该对象的另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁这是允许的,这就是 synchronized 的可重入性,示例代码如下:

SynchronizedData2.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: synchronized 的可重入性
 * @DateTime: 2023-12-15 23:02
 * @Version:1.0
 **/
public class SynchronizedData2 implements Runnable {
  private static final SynchronizedData2 INSTANCE = new SynchronizedData2();
  /**
   * 共享资源 (临界资源)
   */
  private static int i = 0;
  private static int ii = 0;
  @Override
  public  void run() {
    synchronized (INSTANCE){
      for (int j = 0; j < 1000000; j++) {
        i++;
        //synchronized 的可重入性
        incremental();
      }
    }
  }
  /**
   * 递增方法
   */
  private synchronized void incremental() {
    ii++;
  }
  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(INSTANCE);
    Thread t2 = new Thread(INSTANCE);
    // 开始
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
    System.out.println(ii);
  }
}

在获取到当前实例对象锁后进入 synchronized 代码块执行同步代码,并在代码块中调用当前实例对象的另一个 synchronized 方法,再次请求当前实例锁时将被允许,进而执行方法体中的代码,这就是重入锁最直接的提现。需要注意另外一种情况,当子类继承父类是,子类也是可以通过可重入锁调用父类中的同步方法,注意:由于 synchronized 是基于 monitor 实现的,因此每次重入时 monitor 中的计数器仍会 +1

# synchronized 与线程中断

# 线程中断

线程中断就是在线程运行 ( run() 方法) 期间打断它,在 Java 中,提供了三种有关线程中断的方法。

// 中断线程 (实例方法)
public void Thread.interrupt();
// 判断线程是否被中断 (实例方法)
public boolean Thread.isInterrupted();
// 判断是否被中断并清除当前中断状态 (实例方法)
public static boolean Thread.interrupted();

下面是这三种方法的源码,如下:

Thread.java
package java.lang;
public class Thread implements Runnable {
  // 中断线程 (实例方法)
  public void interrupt() {
    if (this != Thread.currentThread()) {
      checkAccess();
      // thread may be blocked in an I/O operation
      synchronized (blockerLock) {
        Interruptible b = blocker;
        if (b != null) {
          interrupted = true;
          interrupt0();  // inform VM of interrupt
          b.interrupt(this);
          return;
        }
      }
    }
    interrupted = true;
    // inform VM of interrupt
    interrupt0();
  }
  // 判断线程是否被中断 (实例方法)
  public boolean isInterrupted() {
    return interrupted;
  }
  // 判断是否被中断并清除当前中断状态 (实例方法)
  public static boolean interrupted() {
    Thread t = currentThread();
    boolean interrupted = t.interrupted;
    // We may have been interrupted the moment after we read the field,
    // so only clear the field if we saw that it was set and will return
    // true; otherwise we could lose an interrupt.
    if (interrupted) {
      t.interrupted = false;
      clearInterruptEvent();
    }
    return interrupted;
  }
  
}

当一个线程处于被阻塞状态或试图执行一个阻塞操作时,使用 Thread.interrupt() 方法可中断该线程,注意:此时将会抛出一个 InterruptedException(中断异常) 的异常,同时中断状态将会被复位 (由中断状态改为非中断状态),示例代码如下:

InterruptedSleepThread.java
package top.rem.rain.synchronized_example;
import java.util.concurrent.TimeUnit;
/**
 * @Author: LightRain
 * @Description: 线程中断示例
 * @DateTime: 2023-12-17 21:30
 * @Version:1.0
 **/
public class InterruptSleepThread {
  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(){
      @Override
      public void run() {
        //while 在 try 中,通过异常中断就可以退出 run 循环
        try {
          while (true){
            // 当前线程处于阻塞状态,异常必须捕捉处理,无法往外抛出
            TimeUnit.SECONDS.sleep(2);
          }
        } catch (InterruptedException e) {
          System.out.println("Interrupt When Sleep");
          boolean interrupt = this.isInterrupted();
          // 中断状态被复位
          System.out.println("interrupt:"+interrupt);
        }
      }
    };
    t1.start();
    TimeUnit.SECONDS.sleep(2);
    // 中断处于阻塞状态的线程
    t1.interrupt();
        /*
            执行结果:
                Interrupt When Sleep
                interrupt:false
         */
  }
}

如上述代码所示,创建一个线程并在线程中调用了 sleep() 方法从而使线程进入阻塞状态,启动线程后调用线程实例对象的 interrupt() 方法来中断线程阻塞异常,并抛出 InterruptedException 异常,此时中断状态也将被复位。为了编码清晰建议使用 TimeUnit.SECONDS.sleep() , 此方法内部实现最终还是调用了 Thread.sleep() 方法。

除了阻塞中断的情景,我们还可能会遇到处于运行期间且非阻塞状态的线程,在这种情况下,直接调用 Thread.interrupt() 方法中断线程是不会得到任何响应的,如下代码将无法中断非阻塞状态下的线程:

InterruptThread.java
package top.rem.rain.synchronized_example;
import java.util.concurrent.TimeUnit;
/**
 * @Author: LightRain
 * @Description: interrupt () 方法示例
 * @DateTime: 2023-12-17 21:51
 * @Version:1.0
 **/
public class InterruptThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("未被中断");
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();
        /*
          执行结果 - 无限执行:
              未被中断
              未被中断
              未被中断
               ......
         */
    }
}

虽然我们在代码中调用了 interrupt() 方法,但线程并未被中断,因为当前线程处于非阻塞状态下,这种情况需要我们手动去进行中断检测并结束程序,修改后的代码如下:

InterruptThread2.java
package top.rem.rain.synchronized_example;
import java.util.concurrent.TimeUnit;
/**
 * @Author: LightRain
 * @Description: interrupt () 方法示例
 * @DateTime: 2023-12-17 21:51
 * @Version:1.0
 **/
public class InterruptThread2 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(){
            @Override
            public void run(){
                while(true){
                    // 判断当前线程是否被中断
                    if (this.isInterrupted()){
                        System.out.println("线程中断");
                        break;
                    }
                }
                System.out.println("已跳出循环,线程中断!");
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();
        /*
          执行结果:
           线程中断
           已跳出循环,线程中断!
         */
    }
}

我们在上述代码中使用了实例方法 isInterrupted() 来判断线程是否已被中断,如果被中断则跳出循环以此来结束当前线程,注意:非阻塞状态下调用 interrupt() 方法并不会导致中断状态重置。

总结:中断线程有两种情况,第一种是当线程处于阻塞状态或试图执行一个阻塞操作时,我们可以使用实例方法 interrupt() 进行线程中断,执行中断操作后将会抛出 InterruptedException 异常 (该异常必须进行捕捉无法抛出) 并将中断状态复位。第二种是当线程处于运行状态时,我们也可以调用实例方法 interrupt() 进行线程中断,但同时必须手动去判断中断状态,并编写中断线程的代码 (其就是来结束 run() 方法体的代码)。有时我们在编写代码时可能需要兼顾以上两种情况,那么就可以如下编写:

public void run(){
    try {
    // 判断当前线程是否已中断,注意 interrupted 方法是静态的,执行后会对中断状态进行复位,Thread.interrupted () 默认为 false
    while (!Thread.interrupted()) {
        TimeUnit.SECONDS.sleep(2);
    }
    } catch (InterruptedException e) {
    }
}

# 中断与 synchronized

事实上线程的中断操作对于正在等待获取锁对象的 synchronized 方法或代码块并不起作用,也就是说对于 synchronized 来说,如果一个线程正在等待锁,那么结果就只有两种可能,第一要么它获得这把锁继续执行,第二要么它就保持等待,即使调用了中断线程的方法也并不会生效。示例代码如下:

SynchronizedBlocked.java
package top.rem.rain.synchronized_example;
import java.util.concurrent.TimeUnit;
/**
 * @Author: LightRain
 * @Description: 中断与 synchronized
 * @DateTime: 2023-12-17 22:30
 * @Version:1.0
 **/
public class SynchronizedBlocked implements Runnable{
    public synchronized void f() {
        System.out.println("Trying to call f()");
        // Never releases lock
        while(true)
            Thread.yield();
    }
    /**
     * 在构造器中创建新线程并启动获取对象锁
     */
    public SynchronizedBlocked() {
        // 该线程已持有当前实例锁
        new Thread() {
            public void run() {
                // Lock acquired by this thread
                f();
            }
        }.start();
    }
    public void run() {
        // 中断判断
        while (true) {
            if (Thread.interrupted()) {
                System.out.println("中断线程!!");
                break;
            } else {
                f();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlocked sync = new SynchronizedBlocked();
        Thread thread = new Thread(sync);
        // 启动后调用 f () 方法,无法获取当前实例锁处于等待状态
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        // 中断线程,无法生效
        thread.interrupt();
        /*
            执行结果:
            Trying to call f ()
                ......
                当前程序并不会自动停止
         */
    }
}

我们在 SynchronizedBlocked 构造函数中创建了一个新线程并启动调用 f() 方法获取到哦当前实例锁,由于 SynchronizedBlocked 自身也是线程,启动后在其 run() 方法中也调用了 f() 方法,但是由于对象锁被其它线程所占用,导致 thread 线程只能等待锁,此时我们调用了 thread.interrupt() 方法,但并不能中断线程。

# synchronized 与等待唤醒机制

所谓的等待唤醒机制主要指的是 notifynotifyAllwait 方法,在使用这三个方法时,必须处于 synchronized 代码块或 synchronized 方法中,否则将会抛出 IllegalMonitorStateException 异常,这是因为调用者几个方法前必须拿到当前对象的监视器也就是 monitor 对象,就是说 notifynotifyAllwait 方法依赖于 monitor 对象,我们知道 monitor 存在于对象头的 Mark Word 中 (存储 monitor 引用指针),而 synchronized 关键字可以获取 monitor 对象,这也就是为什么 notifynotifyAllwait 方法必须在 synchronized 代码块或 synchronized 方法中调用的原因。

synchronized (object){
   object.notify();
   object.notifyAll();
   object.wait();
}

最后需要特别注意的是,与 sleep() 方法不同的是 wait() 方法调用完成后,线程将被暂停,但 wait() 方法将会释放当前持有的监视器锁 ( monitor ), 直到有新线程调用了 notifynotifyAll 方法后才能继续执行,而 sleep() 方法只是让线程休眠并不释放锁。同时 notifynotifyAll 方法调用后,并不会马上释放监视器锁,而是在相应的 synchronized 代码块或 synchronized 方法执行结束后才会自动将锁释放。

# synchronized 原理图

piIluCT.png