Java synchronized 同步机制

synchronized 是 Java 中的关键字,用来实现多线程同步,是一种非公平的同步锁。当线程访问同步代码或同步方法时,必须先获得对象的监视器,退出或抛出异常时必须释放对象的监视器。

任意一个对象都拥有自己的监视器,当这个对象由同步代码块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步代码块或者同步方法,而没有获取到监视器的线程将会被阻塞在同步代码块和同步方法的入口处,并进入同步队列,状态变为 BLOCKED。当成功获取监视器的线程释放了锁后,会唤醒阻塞在同步队列的线程,使其重新尝试对监视器的获取。

基本使用

修饰非静态方法:

1
private synchronized void method() {}

作用域是整个方法,要获取调用此方法的对象的锁。

修饰静态方法:

1
private static synchronized void method() {}

作用域是整个方法,要获取此方法所在的类锁,只有一把。

修饰 this:

1
2
3
4
private void method() {
synchronized (this) {
}
}

作用域是代码块,要获取调用此方法的对象的锁。

修饰自定义非静态对象:

1
2
3
4
5
private final Object lock = new Object();
private void method() {
synchronized (lock) {
}
}

作用域是代码块,要获取 lock 对象的锁。

修饰自定义静态对象:

1
2
3
4
5
private static final Object lock = new Object();
private void method() {
synchronized (lock) {
}
}

作用域是代码块,要获取 lock 对象的锁,由于对象是静态的,属于类,只有一个对象,相当于类锁。

修饰类:

1
2
3
4
private void method() {
synchronized (Class.class) {
}
}

作用域是代码块,要获取类锁。

同步原理

JVM 代码块同步和方法同步的实现细节不同。

monitorenter 和 monitorexit 指令

使用 monitorenter 指令插入到同步代码块的开始处,将 monitorexit 指令插入到方法结束处和异常处。

示例代码如下:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
synchronized (Main.class){
}
method();
}
}
public static synchronized void method() {
}

经过 javac 编译后,通过 javap -v 命令查看 class 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/smartisan/Synchronized
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: invokestatic #3 // Method m:()V
18: return

public static synchronized void m();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 14: 0

SourceFile: "Synchronized.java"

monitorenter 指令:

每个对象都与一个监视器相关联。只有当监视器有所有者时,它才会被锁定。
执行 monitorenter 的线程尝试获得与锁对象关联的监视器的所有权时:

  1. 如果与锁对象关联的监视器的条目计数为零,则线程将进入监视器并将其条目计数设置为 1。此时这个线程是监视器的所有者。
  2. 如果线程已经拥有与锁对象关联的监视器,它将重新进入监视器,并增加其条目计数。
  3. 如果一个线程已经拥有与锁对象关联的监视器,则其他线程将一直阻塞,直到监视器的条目计数为零,然后再次尝试获得所有权。

monitorexit 指令:

执行 monitorexit 的线程必须是与锁对象关联的监视器的所有者。
线程会减少与锁对象关联的监视器的条目计数。如果结果是条目计数的值为零,则线程将不再是监视器的所有者。之前被阻止进入监视器的其他线程可尝试去拥有监视器。

为什么会有两个 monitorexit?

  1. 一个 monitorexit 是正常退出同步时执行,另一个 monitorexit 是抛出异常时 monitorenter 和 monitorexit 指令依然可以正确配对执行。
  2. 编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

ACC_SYNCHRONIZED

synchronized 方法在 method_info 结构有 ACC_SYNCHRONIZED 标记,线程执行时会识别该标记,获取对应的锁,实现方法同步。

方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标记得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有监视器后才能执行方法,当方法完成(无论是正常完成还是非正常完成)时释放监视器。在方法执行期间,执行线程持有了监视器,其他任何线程都无法再获取到同一个监视器。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的监视器将在异常抛到同步方法之外时自动释放。

可重入性依赖的是加锁次数计算器。

monitor 监视器

Java-Monitor-Principle.png

  1. 想要获取 monitor 的线程,首先会进入 EntrySet 队列。
  2. 当某个线程获取到对象的 monitor 后,进入 Owner 区域,设置为当前线程,同时计数器 count 加 1。
  3. 如果线程调用了 wait()方法,则会进入 WaitSet 队列。它会释放 monitor 锁,即将 owner 赋值为 null,count 自减 1,进入 WaitSet 队列阻塞等待。
  4. 如果其他线程调用 notify() / notifyAll() ,会唤醒 WaitSet 中的某个线程,该线程再次尝试获取 monitor 锁,成功即进入 Owner 区域。
  5. 同步方法执行完毕了,线程退出临界区,会将 monitor 的 owner 设为 null,并释放监视锁。

Java-Monitor-Mark-Word

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)。

  • 对象头:Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
  • 实例数据:对象真正存储的有效信息,存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

总结

  1. 无论 synchronized 关键字是修饰方法还是对象,如果它作用的对象是非静态的,则它取得的是对象锁;如果作用的对象是一个静态方法或一个类,则它取得的是类锁,该类所有的对象同一把锁。
  2. 每个对象只有一个锁与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
  3. 无论你是方法正常执行完毕或者方法抛出异常,都会释放锁。
  4. 实现同步需要很大的系统开销,甚至可能造成死锁,所以尽量避免无谓的同步控制。
  5. syschronized 保证了原子性、可见性和有序性。

参考

[1] Synchronization - Oracle
[2] monitorenter - Oracle
[3] Java 并发之 synchronized 深度解析
[4] Synchronized 解析
[5] synchronized 底层原理