1、基本介绍

synchronized 关键字用于实现多线程之间的同步操作。
synchronized 可用于修饰 类方法, 对象方法, 代码块
使用 syncronized 时,需要有监视器。当修饰 类方法时, 监视器为该类; 当修饰 对象方法, 监视器为该对象; 当修饰 代码块, 需要手动传入监视器

1.1、类方法

示例代码
public class SynchronizedTest {
    public synchronized static void classMethod() {
    }

    public static void commonClassMethod() {
    }
}
反编译
  public static synchronized void classMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 12: 0

  public static void commonClassMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 15: 0

从反编译角度来看,加了 synchronized 关键字的,flags 多了 ACC_SYNCHRONIZED

1.2、对象方法

示例代码
public synchronized void objMethod() {
    }

    public void commonObjMethod() {
    }
}
反编译
  public synchronized void objMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 12: 0

  public void commonObjMethod();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 15: 0

同类方法,被 synchronized 修饰的对象方法,flags 多了 ACC_SYNCHRONIZED 标识

1.3、代码块

示例代码
public class SynchronizedTest {

    public void codeBlock() {
        synchronized ("monitor") {
            System.out.println("codeBlock");
        }
    }
}
反编译
  public void codeBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // String monitor
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                  // String codeBlock
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return
      Exception table:
         from    to  target type
             5    15    18   any
            18    21    18   any
      LineNumberTable:
        line 10: 0
        line 11: 5
        line 12: 13
        line 13: 23
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 18
          locals = [ class com/csp/boot/SynchronizedTest, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

可以看到在指令层面多加了 monitorenter, monitorexit。有一点需要注意:上面有 2个 monitorexit

为什么 1 个 synchronized 会产生 2 个 monitorexit 指令?

这是为了保证,方法在发生异常时,也能正常释放锁。

1.4、ACC_SYNCHRONIZED、monitorenter、monitorexit

monitorenter

每个对象都关联一个 monitor,当线程执行 monitorenter 时,monitor 会被锁定;
monitor 内部维护着一个被锁次数的计数器,对象未被锁定时,次数为0。线程执行 monitorenter 计数器会 +1。当同一个线程再次获得该对象的锁时,计数器会再加1。其他线程想获得该 monitor时,就会阻塞,直到计数器为0才能成功。

monitorexit

只有持有 monitor 的线程,才能执行该指令。
每次执行 monitorexit monitor 内部的计算器就会 -1。

ACC_SYNCHRONIZED

方法级别的锁是隐式的,当方法被 synchronized 标记时,在方法的常量池中会有 ACC_SYNCHRONIZED 标记。
一个方法在被调用时,需要先判断是否有 ACC_SYNCHRONIZED 标记;如果有则线程需要先持有 monitor 锁。当方法执行完毕时,会自动释放锁,不管方法执行成功,还是失败。

2、对象头

在对象的内存布局中,包含对象头,对象实例数据,对齐填充。其中,对象头的 mark word 记录了锁的信息。

mark word

image.png

3、锁升级

在早期,synchronized 是一个重量级锁,自从 jdk 1.6 版本之后,对 synchronized 锁进行了优化,引入了 偏向锁, 轻量锁

3.1、偏向锁

偏向锁是一种加锁,解锁都非常快速的锁。
大部分情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,偏向锁因此被引入

3.1.1、偏向锁加锁
场景一:锁对象未被线程持有

线程发现锁是匿名偏向状态(threadId 未指向任何线程),会使用 CASmark wordthreadId 指向本身。如果成功,则获取锁;否则加锁失败,升级为轻量锁。

场景二:获取偏向锁线程再次进入同步块

线程进入同步块时,发现 threadId 指向的就是自身。此时会往自身线程栈中添加一条 Displaced Mark Word 为空的 Lock Record。因为操作的是线程的私有变量,因此无需 CAS

注:Displaced Mark Word 是 JVM 为线程在栈帧中创建用于存储锁记录的空间

从场景一,场景二 来看,偏向锁的加锁效率是非常高的。

对于偏向锁而言,只要发生锁竞争,那么会立马升级为轻量锁

3.1.2、偏向锁解锁
解锁过程

偏向锁线程,在解锁时,仅仅是将自身线程栈中最后一条 Lock Recordobj 设置为 null

目的

未去修改 Mark WordthreadId,仅仅是为了同一个线程再次进入同步代码块时,无需使用 CAS。(偏向锁被引入的原因是:锁竞争情况少,且总是由同一个线程多次获得)

偏向锁解锁效率非常高

3.1.3、撤销偏向锁
未获取偏向锁线程进入同步块

未获取偏向锁线程进入同步块,发现 threadId 指向的不是自己,且是偏向状态,那么此时会进入到 撤销偏向锁的流程中。一般会在 safepoint 时,查看偏向锁线程是否存活。

  1. 偏向锁线程死亡,或不在同步代码块中,则将 mark word 设置为无锁状态。
  2. 偏向锁线程未死亡,且在同步代码块中。则将锁升级为轻量级锁,原偏向锁线程,继续持有锁。偏向锁线程释放锁时,按照轻量级锁方式释放。其他线程则自旋尝试获取轻量锁。
为什么在撤销偏向锁时,需要查看偏向锁线程是否死亡,或者在不在同步代码块中

因为,偏向锁在解锁时,仅仅是修改了自身栈中的值。

3.2、轻量锁

3.2.1、轻量锁加锁
流程

先将 Mark Word 复制一份到 Displaced Mark Word,然后用 CASMark Word 替换为自己栈中锁记录的指针。如果成功,则持有锁;失败则,自旋继续尝试获取锁。

自旋获取锁

轻量锁,不会无限次的自旋获取锁,而是在自旋一定次数后(自适应),如果还是未获取到锁,则将锁升级为重量锁

3.2.2、轻量锁解锁

解锁则是用 CASDisplaced Mark Word 复制回锁对象的 Mark Word。如果成功则表示没有竞争。如果失败,表示当前锁存在竞争,锁会膨胀为重量级锁

3.3、重量锁

当多个线程同时访问重量锁时,重量锁会设置几种状态区分线程(线程在请求重量锁失败后,会进入阻塞状态)
Contention List: 所有请求锁的线程,在请求锁失败后,会被放入该集合中。该集合的数据结构是先进后出
Entry List: Contention List 会让有机会成为候选人的线程放入到此集合中,主要是为了减少对 Contention List 的并发访问。
OnDeck: 任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Wait Set: 线程调用 wait 之后会被放入该队列中
Owner: 获得锁的线程被称为 Owner
!Owner: 释放锁的线程

3.3.1、执行流程

image.png

步骤1:线程在进入 Contention List 之前,线程会 CAS 尝试获取锁,获取不到才会进入 Contention List 尾部
步骤2:Owner 线程在解锁时,如果 Entry List 为空,那么会优先将 Contention List 部分线程移入 Entry List
步骤3:Owner 线程在解锁时,如果 Entry List 为空,那么会从 Contention List 队尾取一个线程成为 OnDeck。如果 Entry List 不为空,则从 Entry List 中选一个成为 OnDeckOnDeck 需要与还未进入 Contention List,还在自旋获取锁的线程进行竞争
步骤4:OnDeck 线程获得锁,成为 Owner 线程
步骤5:调用 wait 释放锁,进入 Wait Set 队列
步骤6:Onwer 线程执行 notifynotifyAllWait Set 中的某个线程或所有线程被唤醒

4、参考

Java面试之Synchronized解析
【大厂面试07期】说一说你对synchronized锁的理解?


心无私天地宽
513 声望22 粉丝