Bootstrap

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

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 
 5 iconst_1
 6 iadd
 7 putfield #7 
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 
 9 iconst_1
10 iadd
11 putfield #7 
14 aload_1
15 monitorexit
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

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

monitorenter的过程如下:

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

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

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

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

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

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

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

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

工具

1)Hsdis

2)JitWatch

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

b)mvn clean compile test exec:java

弹出界面如下:

从汇编代码来看,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”,一起搞定底层原理