写点什么

Java 并发 --synchronized 原子性的底层机制剖析

用户头像
林昱榕
关注
发布于: 2021 年 08 月 05 日

Java 并发机制采用的是线程模型,在并发场景下可能存在线程安全问题,Java 为此提供了 synchronized 关键字。


本文深入剖析它的底层实现机制。

线程安全问题

定义

多线程场景下修改共享变量导致的非预期结果的问题,称为“线程安全问题”。

实例

package craft.lock;
public class ThreadTest { public static void main(String[] args) { Num num = new Num();
for (int i = 0; i < 2; i++) { new NewThread(num).start(); } }}
class NewThread extends Thread { private Num num;
public NewThread(Num num) { this.num = num; }
@Override public void run() { for (int i = 0; i < 1000; i++) { num.add(); } System.out.println("num: " + num.value); }}
class Num { public int value;
public void add() { value += 1; }}
复制代码


Num 的 add 方法的字节码如下:

 0 aload_0 1 dup 2 getfield #7 <craft/lock/Num.value> 5 iconst_1 6 iadd 7 putfield #7 <craft/lock/Num.value>10 return
复制代码

从 add 方法的字节码可看出,value 的加 1 操作包含了多个字节指令执行过程,并不是原子操作(当然通过字节码判断并不严谨,准确应该根据更底层的汇编代码)。因此当多个线程同时执行 getfield 指令时,它们拿的是同一个值,然后各自加 1 写回,此时 value 变量只加了 1,产生线程安全问题。


解决该问题的关键是:同一个时刻只允许一个线程访问 value 变量(即互斥)。


我们在 add 方法里面添加 synchronized 关键字:

class Num {    public int value;
public void add() { synchronized (this) { value += 1; } }}
复制代码


此时字节码如下:

0 aload_0 1 dup 2 astore_1 3 monitorenter 4 aload_0 5 dup 6 getfield #7 <craft/lock/Num.value> 9 iconst_110 iadd11 putfield #7 <craft/lock/Num.value>14 aload_115 monitorexit16 goto 24 (+8)19 astore_220 aload_121 monitorexit22 aload_223 athrow24 return
复制代码

我们注意到字节码多了两个指令:monitorenter monitorexit。monitorenter 指令的作用是试图获取某个对象的“监视器”(即:锁)的所有权,在我们例子里,就是要获取 Num 对象的锁的所有权。


monitorenter 的过程如下:

  • 当对象的监视器的计数为 0,则线程进入监视器,并将进入计数+1,该线程拿锁成功;

  • 当该线程已经拥有了该监视器,所以重新进入,并将进入计数+1;

  • 如果此时其他线程已经占有了该监视器,则当前线程被阻塞住,直到该监视器的计数重新变为 0,被唤醒后重新获取锁的所有权。


monitorexit 指令很简单,就是将监视器的进入计数减 1,它和 monitorenter 总是成对出现的,进入多少次,最终也会退出多少次,最后进入计数变为 0,即线程释放了锁。


至此,我们似乎已经清楚了 synchronized 的底层机制,即通过 monitorenter 和 monitorexit 指令对将共享资源“锁”起来,进而同一时刻只让一个线程访问(互斥)


但是,当两个线程同时看到对象的监视器进入计数为 0,具体是怎么保证只会有 1 个线程进入监视器呢


我们还需继续深挖,从更底层的汇编指令寻找答案。


为了查看汇编指令,我们需要用到 2 个工具:Hsdis(反汇编工具)和 JitWatch

工具

1)Hsdis

  1. 下载 hsdis-amd64.dylib(mac 版);

  2. 将该文件放到 jdk 的 lib 目录下:/Library/Java/JavaVirtualMachines/jdk-13.0.2.jdk/Contents/Home/lib/hsdis-amd64.dylib

2)JitWatch

  1. 地址:https://github.com/AdoptOpenJDK/jitwatch

  2. 按如下操作:

a)git clone https://github.com/AdoptOpenJDK/jitwatch.git

b)mvn clean compile test exec:java

弹出界面如下:

  1. 点击 SandBox:

  1. 点击 Configure Sandbox,添加一个 VM 参数:-Xcomp(强制直接编译成本地代码执行)。

  1. 点击 open 打开自己的源文件,然后点击 run 即可:

  1. 弹出以下界面,可在 Class 输入框手动输入要查看的类:

从汇编代码来看,monitorenter 字节码指令会首先跳到一段代码去获取监视器,获取成功则继续往下执行。


我们来看看获取监视器的逻辑:

我们重点关注两个指令:lock cmpxchg。这两个指令就是锁机制最底层的秘密了。


cmpxchg 指令通过一条指令完成数据的比较和交换操作(CAS),这条指令保证了程序更新监视器进入计数的原子性。


但这还不够,因为这条指令可能会被多个 cpu 核心执行,仅仅保证一个核心下的原子性依然无法满足只有一个线程获得锁的需求。


lock 前缀的作用是:同一时刻,只能有 1 个核心执行该指令


至此,synchronized 的原子性底层机制才算是清楚了。可见要保证原子性真的不容易,需要软件和 cpu 硬件同时支持


拓展

  • 这里还需要注意 cpu 本地缓存的问题,一般来说,cpu 更新数据时可能只写到本地缓存,而不是写到主存。这会导致主存可见性问题。当然,synchronized 会强制刷新 cpu 本地缓存到主存的(JMM 定义的 happens-before 规则)。

  • 监视器锁对象为什么采用计数的方式呢:可以实现锁重入特性(拥有锁的线程每次进入计数加 1,退出计数减 1)。

  • synchronized 锁对象的隐性规则:修饰静态方法时,锁的是当前类的 Class 对象;修饰实例方法时,锁的是当前实例对象 this。

  • wait/notify 依赖 monitor 对象,因此需要在同步方法或同步块中才能调用;


参考资料:

1. 极客时间宫老师的专栏《编译原理实战课》第 33 讲


也欢迎关注我的公众号(搜索:Make IT Simple),一起学习交流。


 欢迎关注“Make IT Simple”,一起搞定底层原理


发布于: 2021 年 08 月 05 日阅读数: 5
用户头像

林昱榕

关注

开心生活,努力工作。 2018.02.13 加入

还未添加个人简介

评论

发布
暂无评论
Java并发--synchronized原子性的底层机制剖析