对于 volatile 型变量的特殊规则
关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制.
在处理多线程数据竞争问题时, 不仅仅是可以使用 synchronized 关键字来实现, 使用 volatile 也可以实现.
Java 内存模型对 volatitle 专门定义了一些特殊的访问规则, 当一个变量被定义为 volatile 时, 它将具备以下两个特性:
第一个是保证此变量对所有线程的可见性, 这里的 "可见性" 是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知的. 而普通变量不能做到这一点, 普通变量的值, 在线程之间的传递都是需要通过主内存的的 stroe 和 write 操作以及 read 和 load 操作来实现的. volatile 只保证了变量的可见性, 但并不能保证变量运算的原子性.
第二个是禁止指令重排序优化, 普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致.
由于 volatile 变量只能保证可见性, 在不符合以下两条规则的运算场景中, 我们仍然要通过枷锁 (使用 synchronized 或 java.util.concurrent 中的原子类) 来保证原子性.
运算结果并不依赖变量的当前值, 或者能够确保只有单一的线程修改变量的值.
变量不需要与其他的状态变量共同参与不变约束.
如下代码就很适合使用 volatile 变量来控制并发:
- volatile boolean shutdownRequested;
- public void shutdown(){
- shutdownRequested = true;
- }
- public void doWork(){
- while (!shutdownRequested){
- //do something;
- }
- }
原子性, 可见性与有序性
Java 内存模型是围绕着在并发过程中如何处理原子性, 可见性, 和有序性这 3 个特征来建立的, 下面来介绍一下这 3 个特征:
原子性(Atomicity):
由 Java 内存模型来直接保证的原子性变量操作包括 read,load,assign,use,store, 和 write, 我基本可以认为基本数据类型的访问读写是具备原子性的. 如果需要一个更大范围的原子性保证, Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求, 尽管虚拟机未把这两个操作直接开发给用户使用, 但却提供了更高层次的字节码指令来隐式地使用这两个操作, 这两个字节码指令反应到 Java 代码中就是同步块 ----synchronized 关键字, 因此 synchronized 块之间的操作也具备原子性.
可见性(Visibility):
可见性是指当一个线程修改了共享变量的值, 其他线程能够立即得知这个修改. Java 内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的, 无论是普通变量还是 volatile 变量和是普通变量都是如此, 普通变量与 volatile 变量的区别是, volatile 的特殊规则保证了新值能立即同步到主内存, 以及每次使用前立即从主内存刷新.
除了 volatile 之外, synchronized 和 final 也可以实现可见性, synchronized 的可见性是由 "对一个变量执行 unlock 操作之前, 必须先把此变量同步回主内存中" 这条规则获得的, final 是因为被 final 修饰的字段在构造器中一旦完成, 并且构造器没有吧 "this" 的引用传递出去, 在其他线程中就能看见 final 字段值.
有序性(Ordering):
Java 程序中天然的有序性可以总结为一句话: 如果在本线程内观察, 所以的操作都是有序的; 如果在一个线程中观察另一个线程, 所有的操作都是无序的. 前半句指 "线程内表现为串行语义", 后半句指 "指令重排序" 现象和 "工作内存与主内存同步延迟" 现象.
Java 提供了 volatile 和 synchronized 两个关键字来保证线程之间的操作时有序的, volatile 包含了精致指令重排序的语义, 而 synchronized 是由 "一个变量在同一时刻只允许一个条线程对其进行 lock 操作" 这条规则获得的, 这条规则决定了持有同一个锁的两个同步块只能串行的进入.
先行发生原则(appens-before)
若 Java 内存模型中所有的有序性都仅依靠 volatile 和 synchronized 来完成, 那么有一些操作会变得很繁琐, 但是我们在写 java 代码中并没有感知这一点, 因为 Java 语言中有一个 "先行发生" 原则.
先行发生是 Java 内存模型中定义的两项操作之间的偏序关系, 若操作 A 先行发生于操作 B, 操作 A 产生的影响能被操作 B 观察到,"影响" 是指修改了内存中共享变量的值, 发送了消息, 调用了方法等.
如下例子:
- // 以下操作在线程 A 中执行
- i = 7;
- // 以下操作在线程 B 中执行
- j = i;
- // 以下操作在线程 C 中执行
- i = 9;
如果线程 A 先行发生于线程 B 那么变量 j 一定是 7, 因为线程 A 的操作会被线程 B 观察到进而被影响, 而此时线程 C 还没有发生, 所以 j 一定是 7. 但如果线程 C 出现在线程 A 和线程 B 之间, 线程 C 没有与线程 B 不存在先行发生, 那么此时线程 C 对变量 i 的影响可能会被线程 B 观察到, 也可能不会, 这时线程 B 读取到的数据就存在过期风险, 不具备多线程安全性.
Java 内存模型中存在一些 "天然的" 先行发生关系, 这些先行发生关系无须任何同步器协助就已经存在. 如果两个操作之间的关系不在如下规则中, 并且无法从下列规则中推导出来, 那它们就没有顺序性保障, 虚拟机可以对它们随意地进行重排序.
如下:
程序次序规则: 在一个线程内按照程序代码顺序, 书写在前面的操作先行发生于书写在后面的操作. 准确的地应该是控制流顺序而不是程序代码顺序, 因为要考虑分支, 循环等结构.
管程锁定规则: 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作. 这里指的是同一个锁, 而 "后面" 是指时间上的先后顺序.
volatile 变量规则: 对于一个变量的写操作先行发生于后面对这个变量的读操作, 这里的 "后面" 同样指时间上的先后顺序.
线程启动规则: Thread 对象的 start()方法先行发生于此线程的每一个动作.
线程终止规则: 线程中的所有操作都先行发生于对此线程的终止检测, 我们可以通过 Thread.join()方法结束, Thread.isAlive()的返回值等手段检测到线程已经终止执行.
线程中断规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过 Thread.interrupted()方法检测到是否有中断发生.
对象终结规则: 一个对象的初始化完成 (构造函数执行结束) 先行发生于它的 finalize()方法的开始.
传递性: 如果操作 A 先行发生于操作 B, 操作 B 先行发生于操作 C, 那就可以得出结论: 操作 A 优先发生于操作 C.
如何判断操作是否有顺序性呢? 代码示例如下:
- private int value = 9;
- public static int getValue() {
- return value;
- }
- public void setValue(int value){
- this.value = value;
- }
一组普通的 getter/setter 方法, 若线程 A 先调用了 "setValue(10)", 然后线程 B 调用了同一个对象的 "getValue()", 那么线程 B 得到的返回值是什么?
分析: 由于线程 A 和线程 B 时两个线程所以程序次序规则不适用, 由于没有同步块, 也不会发生 lock 和 unlock, 所以管程锁定规则也不适用, 没有 volatile 关键字, 所以 volatile 变量规则也不适用, 后面的线程启动规则, 线程终止规则, 线程中断规则, 对象终结规则也和这里没关系. 因为没有一个适用的先行发生规则, 所以传递性也不存在, 因此虽然线程 A 在时间上先与线程 B, 但是无法确定线程 B 中 "getValue()" 的返回值, 也就是说, 这个操作不具备多线程安全性.
那么怎么修复这个问题, 让这个操作编程线程安全的呢? 有两种方式:
把 getter/setter 方法都定义为 synchronized 方法.
把 value 定义为 volatile 变量.
虽然时间上的先行执行不代表就会先行发生, 那如果先行发生是不是就一定会是时间上的先行执行呢?
举例说明:
- // 以下操作在同一个线程中执行
- int i = 10;
- int j = 20;
由于两条赋值语句, 在同一个线程中执行, 根据程序次序规则, 第一条语句先行发生于第二条语句, 但是第二条语句的代码完全可能先被处理器执行, 这并不影响先行发生原则的正确性, 因为在这条线程之中没有办法感知到这一点.
通过两个例子总结出, 时间先后顺序与先行发生原则之间基本没什么太大的关系, 所以并发问题不要受时间顺序的干扰, 要以先行发生原则为准.
Java 内存模型这部分也就完结了, 虽然说都记录下来, 但是有的地方还是有些云里雾里, 看来以后要回顾自己的知识, 并且要根据这一部分的知识找到一两个的面试题或者是, 自己提出疑惑然后通过查资料把自己的疑惑解开, 这样能加深印象, 毕竟这些理论性的知识如果不是理解透了, 是很容易忘记得.
马上就要裸辞出去找工作面试了, 希望通过自己这段时间的努力, 在面试过程中不会被打击的很惨.
来源: https://www.cnblogs.com/jimoer/p/9109781.html