乐趣区

Java内存模型相关原则详解

在《Java 内存模型 (JMM) 详解》一文中我们已经讲到了 Java 内存模型的基本结构以及相关操作和规则。而 Java 内存模型又是围绕着在并发过程中如何处理原子性、可见性以及有序性这三个特征来构建的。本篇文章就带大家了解一下相关概念、原则等内容。

原子性

原子性即一个操作或一系列是不可中断的。即使是在多个线程的情况下,操作一旦开始,就不会被其他线程干扰。

比如,对于一个静态变量 int x 两条线程同时对其赋值,线程 A 赋值为 1,而线程 B 赋值为 2,不管线程如何运行,最终 x 的值要么是 1,要么是 2,线程 A 和线程 B 间的操作是没有干扰的,这就是原子性操作,不可被中断的。

Java 内存模型对以下操作保证其原子性:read,load,assign,use,store,write。我们可以大致认为基本数据类型的访问读写是具备原子性的(前面也提到了 long 和 double 类型的“半个变量”情况,不过几乎不会发生)。

从 Java 内存模型底层来看有上面的原子性操作,但针对用户来说,也就是我们编写 Java 的程序,如果需要更大范围的原子性保障,就需要同步关键字——synchronized 来保障了。也就是说 synchronized 中的操作也具有原子性。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

Java 内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,将主内存作为传递媒介。可回顾一下上篇文章的图。

无论普通变量还是 volatile 变量都是如此,只不过 volatile 变量保证新值能够立马同步到主内存,使用时也立即从主内存刷新,保证了多线程操作时变量的可见性。而普通变量不能够保证。

除了 volatile,synchronized 和 final 也能够实现可见性。

synchronized 实现的可见性是通过“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中”来保证的。

主要有两个原则:线程解锁前,必须把共享变量的最新值刷新到主内存中;线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

final 的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其他线程中就能看见 final 的值。

有序性

在 Java 内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。

有序性是指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致。

因此,上面归纳的前半句指的是线程内保证串行语义执行,后半句则指指“令重排现”象和“工作内存与主内存同步延迟”现象。

同样,Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。

指令重排

计算机执行指令经过编译之后形成指令序列。一般情况,指令序列是会输出确定的结果,且每一次的执行都有确定的结果。

CPU 和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果。

  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,重新安排语句执行顺序。
  • 指令级并行重排序:处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其的执行顺序。
  • 内存系统的重排序:处理器使用缓存和读 / 写缓冲区,使得加载和存储操作看上去可能是乱序执行。

举个例来说明一下多线程中可能出现的重排现象:

class ReOrderDemo {
    int a = 0;
    boolean flag = false;
 
    public void write() {
        a = 1;                   //1
        flag = true;             //2
    }
     
    public void read() {if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

在上面的代码中,单线程执行时,read 方法能够获得 flag 的值进行判断,获得预期结果。但在多线程的情况下就可能出现不同的结果。

比如,当线程 A 进行 write 操作时,由于指令重排,write 方法中的代码执行顺序可能会变成下面这样:

flag = true;             //2
a = 1;                   //1

也就是说可能会先对 flag 赋值,然后再对 a 赋值。这在单线程中并不影响最终输出的结果。

但如果与此同时,B 线程在调用 read 方法,那么就有可能出现 flag 为 true 但 a 还是 0,这时进入第 4 步操作的结果就为 0,而不是预期的 1 了。

请记住,指令重排只会保证单线程中串行语义执行的一致性,不会关心多线程间语义的一致性。这也是为什么在写单例模式时需要考虑添加 volatile 关键词来修饰,就是为了防止指令重排导致的问题。

JMM 提供的解决方案

在了解了原子性、可见性以及有序性问题后,看看 JMM 是提供了什么机制来保证这些特性的。

原子性问题,除了 JVM 自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用 synchronized 关键字或者重入锁 (ReentrantLock) 保证程序执行的原子性。

而工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 关键字或者 volatile 关键字解决。它们都可以使一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题和有序性问题,则可以利用 volatile 关键字解决。volatile 的另一个作用就是禁止重排序优化。

除了靠 sychronized 和 volatile 关键字之外,JMM 内部还定义一套 happens-before(先行发生)原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

先行发生原则

如果仅靠 sychronized 和 volatile 关键字来保证原子性、可见性以及有序性,那么编写并发程序会十分麻烦。为此在 Java 内存模型中,还提供了 happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题。该原则是判断数据是否存在竞争、线程是否安全的依据。

happens-before 规则:

  • 程序次序规则:在一个线程内,程序前面的操作先于后面的操作。
  • 监视器锁规则:一个 unlock 操作先于后面对同一个锁的 lock 操作发生。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,也就是说读取的值肯定是最新的。
  • 线程启动规则:Thread 对象的 start()方法调用先行发生于此线程的每一个动作。
  • 线程加入规则:Thread 对象的结束先行发生于 join()方法返回。
  • 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

还拿上面的具体代码来进行说明:

class ReOrderDemo {
    int a = 0;
    boolean flag = false;
 
    public void write() {
        a = 1;                   //1
        flag = true;             //2
    }
     
    public void read() {if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

线程 A 调用 write()方法,线程 B 调用 read()方法,线程 A 先 (时间上的先后) 于线程 B 启动,那么线程 B 读取到 a 的值是多少呢?

现在依据 8 条原则来进行对照。

两个方法分别由线程 A 和线程 B 调用,不在同一个线程中,因此程序次序原则不适用。

没有 write()方法和 read()方法都没有使用同步手段,监视器锁规则不适用。

没有使用 volatile 关键字,volatile 变量原则不适用。

与线程启动、终止、中断、对象终结规则、传递性都没有关系,不适用。

因此,线程 A 和线程 B 的启动时间虽然有先后,但线程 B 执行结果却是不确定,也是说上述代码没有适合 8 条原则中的任意一条,所以线程 B 读取的值自然也是不确定的,换句话说就是线程不安全的。

修复这个问题的方式很简单,要么给 write()方法和 read()方法添加同步手段,如 synchronized。或者给变量 flag 添加 volatile 关键字,确保线程 A 修改的值对线程 B 总是可见。

小结

在这篇文章中介绍了 Java 内存模型中一些原则,及其衍生出来保证这些原则的方式和方法。也是为我们下面学习 volatile 这个面试官最爱问的关键字的做好铺垫。欢迎关注微信公众号“程序新视界”继续学习。

原文链接:《Java 内存模型相关原则详解》

《面试官》系列文章:

  • 《JVM 之内存结构详解》
  • 《面试官,不要再问我“Java GC 垃圾回收机制”了》
  • 《面试官,Java8 JVM 内存结构变了,永久代到元空间》
  • 《面试官,不要再问我“Java 垃圾收集器”了》
  • 《Java 虚拟机类加载器及双亲委派机制》
  • 《Java 内存模型 (JMM) 详解》
  • 《Java 内存模型相关原则详解》

<center>程序新视界:精彩和成长都不容错过 </center>

退出移动版