美文网首页
从线程安全到 JMM(Java Memory Model)

从线程安全到 JMM(Java Memory Model)

作者: 小道萧兮 | 来源:发表于2020-03-09 19:57 被阅读0次

    一、什么是线程安全

    多个线程不管以何种方式访问共享变量,并且不需要进行同步,都能表现正确的行为,就是线程安全。

    呃,这和 JMM(Java Memory Model)有什么联系呢?

    这里就需要知道为什么会产生线程不安全。

    发生线程不安全的本质实际上是主内存和工作内存中的数据不一致,或者发生了重排序所导致

    什么是主内存,什么是工作内存,什么又是重排序呢?这就牵涉到 JMM 了。

    二、Java 内存模型——JMM

    Java 内存模型(Java Memory Model,JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例变量、静态变量和构成数组对象的元素)的访问方式。

    需要注意的是别把 JMM 和 JVM 搞混了,JVM 是 Java 虚拟机(Java Virtual Machine)。

    JMM

    JMM 定义了每个线程和主内存之间的抽象关系:线程之间的共享变量存储在“主内存”中,而每个线程都有一个私有的“工作内存”,工作内存中存储了该线程读/写共享变量的副本。

    上图描述了一个多线程执行场景。

    线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。

    JMM 规定:线程不能直接读写主内存的共享变量,每个线程都有自己私有的工作内存,线程需要读写主内存的共享变量时,首先需要将该变量拷贝一份副本到自己工作内存,然后在自己的工作内存中对该变量进行操作,完成操作之后再将结果同步至主内存。

    这么说起来有点抽象,下面举一个例子。

    例如 2 条线程,循环 1000 次,分别给 x 值加 1,若不加任何处理,其最终结果很可能会小于 2000。

    A、B 线程分别读取主内存中 x 的值,并把 x 的值复制到自己的工作内存中,此时,A、B 线程的工作内存的值都是 0。

    然后,每条线程对自己工作内存中的值进行自加操作,操作完成之后再写回主内存,这时候就出问题了。A、B 线程只把自己的值写回主内存,而没有考虑到其他线程在该期间内也对主内存中的值做了修改。这就是线程不安全的原因之一。

    三、volatile

    为了解决上述问题,volatile 关键字就闪亮登场了。

    被 volatile 关键字描述变量的操作具有可见性有序性(禁止指令重排)。

    1、可见性

    针对 volatile 修饰的变量 Java 虚拟机有特殊的约定:

    当一条线程读取被 volatile 修饰的变量时,JMM 会把该线程工作内存中对应的变量值置为无效,必须从主内存中读取;

    当一条线程写入被 volatile 修饰的变量时,JMM 会把该线程的工作内存中对应的值立即刷新到主内存;

    从而避免出现数据脏读的现象,保证数据的“可见性”。

    2、有序性

    在执行程序时,为了提高性能,编译器和处理器会对指令进行重排序。例如下面代码:

    double pi = 3.14             // A
    double r = 1.0               // B
    double area = pi * r * r     // C
    

    这是一个计算圆面积的代码,由于 A、B 两句之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。

    因此可以执行顺序可以是 A->B->C 或者 B->A->C 执行最终结果都是 3.14,即 A 和 B 之间没有数据依赖性。但是 C 一定在 A、B 执行完之后才能执行。因此,重排序有以下两个特点:

    1. 重排序操作不会对存在数据依赖关系的操作进行重排序。

    2. 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。

    例如下面的代码,A 线程执行changeStatus(),B 线程执行run(),能保证输出一定等于 3 吗?

    public class TestVolatile {
        int a = 1;
        boolean status = false;
    
        public void changeStatus() {
            a = 2;          // 1
            status = true;  // 2
        }
    
        public void run() {
            while (true) {
                if (status) {        // 3
                    int b = a + 1;   // 4
                    System.out.println(b);
                    break;
                }
            }    
        }
    }
    

    不一定,因为 1 和 2 之间不存在数据依赖关系,因此编译器和处理器可能会对指令进行重排序,若 A 线程先执行了 2,还未执行 1,此时 B 线程执行了 3 和 4,这就会时输出的值等于 2。

    将变量 a 使用 volatile 关键字修饰共享变量便可以禁止这种重排序。若用 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

    3、不保证原子性

    在访问 volatile 变量时不会执行加锁操作,也就不会使执行线程阻塞,因此 volatile 是一种比 sychronized 更轻量级的同步机制,可以保证可见性,保证不被重排序,但是,不能保证线程安全,也不保证原子性。

    为什么 volatile 不能保证原子性?

    以 i++ 为例,其包括读取、加操作、赋值三个操作,下面是两个线程的操作顺序。

    假如线程 A 在做了 i+1,但未赋值的时候,线程 B 就开始读取 i,当线程 A 赋值 i=1,并回写到主内存,而此时线程 B 已经不再需要读取 i 的值了,而是正在做 +1 操作,于是当线程 B 执行完并回写到主内存,i 的值仍然是 1,而不是预期的 2。

    也就是说,volatile 缩短了普通变量在不同线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。

    四、总结

    所以,结论是 volatile 是一种轻量级的同步机制,可以保证共享变量对所有线程的可见性,禁止指令重排序优化,但不保证原子性,像 num++ 这种复合操作,volatile 无法保证其原子性

    当然,像 num++ 这种操作可以通过 CAS 的方式来保证原子性。

    如想保证线程安全和原子性,还是需要使用锁。锁可以保证可见性、有序性、原子性

    相关文章

      网友评论

          本文标题:从线程安全到 JMM(Java Memory Model)

          本文链接:https://www.haomeiwen.com/subject/rfopdhtx.html