「可见性
」、「原子性
」、「有序性
」 导致的问题常常会违反我们的直觉,成为并发编程 Bug 的来源。
这三者在编程领域中属于 「共性问题
」 ,所有编程语言都会遇到
。 而 Java
从诞生之初就支持多线程
,也有针对这三者的解决方案,并且在编程语言领域中处于领先地位。理解 Java
对于并发问题的解决方案,也有助于理解其他语言。 Java 通过 JMM 内存模型
来解决 「可见性」
和 「有序性」
导致的问题。
什么是 JMM 内存模型
Java 内存模型的定义: Java
内存模型是一个 规范
,可以从不同的角度来解读。从应用程序开发人员的视角来看, Java 内存模型规范了 JVM
如何提供「按需禁用
」缓存
和编译优化的方法。 其具体实现就是 volatile
、synchronized
、final
三个关键字以及 Happens-Before
规则。
-
导致
可见性
问题的原因:CPU 缓存
。 -
导致
有序性
问题的原因:编译优化——指令重排序
。
直接解决这两个问题的方法:
禁用
缓存和指令重排序。
但是这两个问题的背后都是对性能
优化产生的问题,禁用了优化,性能也会降低。
- 合理的方案
按需禁用
缓存和指令重排序。
这也就是 JMM 要做的工作,定义如何按需禁用缓存
和指令重排序
。
使用 volatile 的困惑
volatile
并不是 Java
的特产,在 C
语言中就已经存在了
volatile
最原始的意义就是 「禁用 CPU 缓存
」。
例如使用 volatile
修饰变量 int x = 0
,它的 语义
是:
- 告诉编译器,对这个变量的读写
不使用 CPU 缓存
,必须从内存中读取或写入
。
《Java 并发编程实战》
那本书中对 volatile 的描述则是:
- 当编译器读取到被 volatile 修饰的变量时,不再进行重排序
- 同时 volatile 变量不会被缓存在 寄存器或者对其他处理器不可见的地方
例如下面的例子:
-
假设
线程A
执行write()
方法,按照volatile
语义,将v = true
写入内存。 -
线程B
执行reader()
方法,同样按照volatile
语义,线程B
从内存
中 读取变量 v
,当线程B
看到v == true
时 ,线程B 看到的变量x
的值 是多少?
直觉上看,应该是42。 实际上在 Java 1.5
之前,x
可能是42,也可能是0
。 如果是在 『Java 1.5』 之后
的版本,x
一定等于42。
public class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
log.info("x = {}",x);
}
}
}
在 Java 1.5
版本之前,变量 x
可能 存储在 CPU 的寄存器
中导致可见性问题
。
在 Java 1.5 中,内存模型对 volatile
语义进行了增强 :
- 被
volatile
修饰的变量不会被缓存在 「寄存器」 或者 对其他处理器不可见的地方。
这里作者提到了 具体使用 「Happens-Before」 规则对 volatile
进行增强。 这个规则是 《jcip》 中没有提到的。 <--- 这个规则 jcip 提到了,不过是在书的最后一章中,所以是因为当时我还没有看到那个地方。
Happens-Before 规则
「Happens-Before
」 按字面意思翻译是先行发生
,但是真正的意义是 :「前一个操作的结果对后续操作是可见的
」。
Happens-Before
规则比较正式的说法
是: 约束了编译器
的优化行为
,虽然允许编译器优化,但是要求编译器优化后遵守 Happens-Before 规则
。
【而 《jcip》 中的说法则是"编译器与运行时" 会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序"。 而这里作者说的是会进行编译器优化操作,但是遵守 Happens-Before 规则,且往下看 这个规则具体是什么】
Happens-Before规则 1:程序的顺序性规则
这条规则指:「在一个线程中,按照程序顺序
,前面的操作 Happens-Before 于后续的任意操作
。」【也就是后续操作可以看到前面操作的结果。】
public class VolatileExample {
int x = 0;
// 被 volatile 修饰的变量,编译器优化后要遵守 Happens-Before 规则
volatile boolean v = false;
public void writer() {
// 根据 happens-before 原则,修改 x 在修改 被 volatile 修饰的变量 v之前,所以 reader() 中可以看到对 x 的最新操作结果 x = 42
x = 42;
v = true;
}
public void reader() {
if (v == true) {
log.info("x = {}",x);
}
}
}
Happens-Before规则 2:Volatile 变量规则
「对一个 volatile
变量的写操作
,Happens-Before
于后续对这个 volatile
变量的读操作。」
单看这个规则,理解起来就是禁用了缓存,但是这和 Java1.5
增强 volatile
语义之前好像就没有区别了?但是联合下面一个规则来看就不一样了:
Happens-Before规则 3:传递性
「如果 A Happens-Before B
,且 B Happens-Before C
,那么 A Happens-Before C
」
从图中可以看出:
x = 42
Happens-Before
写入volatile
变量v = true
,这是**「规则1
」** 的内容。- 写变量
v = true
Happens-Before
读变量v = true
,这是 「规则2
」 的内容。
再根据 『传递性规则
』,得出结论 x = 42
Happens-Before
读变量 v = true
。这意味着 如果线程B
读到了变量 "v = true
", 那么线程A
设置的 "x=42
"对线程B
是可见
的,也就是说线程B 能看到 "x==42
" 这个结果。
这就是 Java1.5
版本对 volatile
语义的增强
,这个增强意义重大,1.5 新增的 JUC
并发包就是依靠 volatile
语义来解决 「可见性」 问题的。
Happens-Before规则 4. 管程
中锁的规则
这条规则指「对一个锁的解锁
Happens-Before
于 后续对这个锁的加锁
」
「管程 」 是一种通用的**同步原语
**,在 Java
中指的就是 synchronized
关键字,synchronized
是 Java
对管程的实现
。
管程中的锁在 Java
里是**隐式实现
的,例如下面的代码,在进入同步代码块之前
,会自动加锁
,而在代码块执行完
会自动释放锁
**。 这个加锁和释放锁的操作都是 「编译器
」 帮我们实现的。
【读完这段话,理解了《jcip》 中说,内置锁 简化了锁的封装性,当时不理解这个简化的点在哪里,现在明白了 表面上只是加了一个关键字 synchronized
而背后的逻辑都是由编译器生成了加锁解锁的字节码。】
synchronized(this) { // 此处自动加锁
if(this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
这是对应方法的字节码:
所以结合**「规则4」** —— 管程中所的规则,可以这样理解:
假设 x
的初始值是 10
,线程A
执行完代码块后 x
的值会变成 12
,此时 锁被释放,线程B
进入代码块时,能够看到之前线程A
对 x
的操作,也就是 线程B 看到的 x 的值 是最终的值是 12
。【这个规则也很好理解】
Happens-Before规则 5. 线程 start() 规则
「线程A
调用 线程B
的 start()
方法,那么该 start()
操作 Happens-Before
线程B 中
的任意操作。」
Thread B = new Thread() {() -> {
// 主线程调用B.start() 之前
// 所有对共享变量的修改在这里都是可见的
// 例如,在这个例子中,在这里访问 var 得到值是 77
}}
// 此处对共享变量var激进型修改
var = 77
// 主线程调用线程B的start方法,之前的 var=77 变量修改结果可以被子线程看到
B.start();
Happens-Before规则 6. 线程 join() 规则
这是一条关于线程等待的规则。主线程A
等待子线程B
完成(主线程A
通过调用
子线程B
的 join()
方法实现) 当子线程B
完成后(主线程A
中的 join()
方法返回) 主线程
能够看到 子线程
中的共享变量的最新值。
如果在线程 A
中,调用
线程 B
的 join()
并成功返回,那么线程 B 中的任意操作 Happens-Before
于该 join()
操作的返回。
Thread B = new Thread(() -> {
// 此处对共享变量 var 进行修改
var = 66;
});
//此处对共享变量修改,则整个修改结果对线程 B可见 主线程启动子线程
B.start()
B.join()
// 子线程中所有对共享变量的修改 在主线程调用 B.join() 之后都是对主线程可见的
// 例如 此时主线程中获取 共享变量 var 的值,看到的就是被 子线程B 修改的最新值 66
【↑ 这六条 Happens-Before 规则,是对 volatile 的解释,讲的比 《jcip》 中第三章要详细一些,有收获】
被忽视的 final
【说实话,读了《jcip》 之后,才明白了封装与不可变在并发中的重要性》
final
修饰变量时最初的语义是告诉 」「编译器」,这个量生而不变,可以最大限度的优化。 但是 Java 编译器
在 1.5 之前的重排序
导致了一些并发问题的出现。
在 1.5 之后 Java
内存模型对 final
类型的变量重排序进行了约束,现在只要类的构造函数中没有 「逸出」 问题,就不会出问题。下面的例子类似之前利用双重检查创建单例时产生的错误,这里也是构造函数引用逸出导致不变量可能发生变化。
这里作者对逸出的解释是:在构造函数中将 this
赋值给了 某个全局变量 global.obj
,线程通过 global.obj
这时读取 x
可能读到 为0
的值。
因此,一定要避免 「逸出
」问题的发生。
// 类中定义的 不变量 x
final int x;
// 导致逸出问题发生的构造函数
public FinalFieldExample {
x = 3;
y = 4;
// 此处就是导致逸出问题发生的地方
global.obj = this;
}
【这里的逸出指的是,别的类可以通过这个 global.obj
公共的变量来使用这个类,但是使用的这个 this
实例可能是一个没有完全构造完成的实例,这样就会存在问题。】
个人实践与总结: 构造函数中 this 引用 隐式逸出导致并发问题的实例
于是我去找了一个还比较贴切的 「构造函数 this 引用逸出」 的例子:
【**1、
**定义一个接口 EventListener
没什么特别的,这个名字和方法都可以随便定义,我懒得改就用例子中的名字了】
public interface EventListener {
public void onEvent(Object object);
}
【2、
写一个在构造函数中使用内部类的构造方法
,由于 「内部类持有外部类的 this
引用」 所以这个方法会导致 this
的逸出。
/**
* this 隐式逸出的发生地
*/
public class ThisEscape {
public final int id;
public final String name;
public ThisEscape(EventSource<EventListener> source) {
id = 1;
// 这里创建了一个匿名内部类,隐式持有了外部类 ThisEscape 的引用
source.registerListener(new EventListener() {
public void onEvent(Object object) {
// 打印 final 值 id 和 name,按理来说此时它们应该都是已经被赋值的
System.out.println("id: " + ThisEscape.this.id);
System.out.println("name: " + ThisEscape.this.name);
}
});
try {
Thread.sleep(1000); // 调用sleep模拟其他耗时的初始化操作
} catch (InterruptedException e) {
e.printStackTrace();
}
name = "Ahri";
}
}
3、定义 相关的 EventSource
和 EventListener
对象
public class EventSource<T> {
private final List<T> eventListeners;
public EventSource() {
eventListeners = new ArrayList<>();
}
public synchronized void registerListener(T eventListener) {
this.eventListeners.add(eventListener);
// 唤醒等待重新注册监听器的线程
this.notifyAll();
}
/**
* 重新注册 Listener
*
* @return
* @throws InterruptedException
*/
public synchronized List<T> retrieveListener() throws InterruptedException {
List<T> dest = null;
if (eventListeners.size() <= 0) {
this.wait();
}
dest = new ArrayList<>(eventListeners.size());
dest.addAll(eventListeners);
return dest;
}
}
public class ListenerRunnable implements Runnable {
private EventSource<EventListener> source;
public ListenerRunnable(EventSource<EventListener> source) {
this.source = source;
}
@Override
public void run() {
List<EventListener> listeners = null;
try {
listeners = this.source.retrieveListener();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (EventListener listener : listeners) {
listener.onEvent(new Object());
}
}
}
4、测试类 ThisEscapeTest
public class ThisEscapeTest {
public static void main(String[] args) {
EventSource<EventListener> source = new EventSource<>();
ListenerRunnable listenerRunnable = new ListenerRunnable(source);
Thread thread = new Thread(listenerRunnable);
thread.start();
ThisEscape thisEscape = new ThisEscape(source);
}
}
这里如果直接测试的话,很难看到由于**线程切换
** 导致 name
还没有被赋值,构造的对象还未完成就被发布的这种情况,一般都能正确打印,而并发问题的难点就在于隐蔽
和随机出现
,所以我们增加了一个 sleep
模拟一些耗时的动作,这样问题出现的概率就很大了。
【运行结果:】
id: 1
name: null
可以看到这里当线程 sleep
之后,线程切换,导致 构造函数中的 name
还没有被赋值,该对象就被发布了。
解决方法:
这个方法也是 《jcip》 中说的,不要在构造函数中创建内部类
,而是先定义一个引用,在构造函数中对引用进行赋值,同时将构造函数设为私有,通过一个公共的静态方法对外提供构造对象的功能。
改造后的线程安全的 SafeConstruct
类:
/**
* 不会导致 this 隐式逸出的安全的类
1. 构造函数设为私有
2. 定义一个引用,将内部类对象赋值给引用
*/
public class SafeConstruct {
public final int id;
public final String name;
// 定义了一个 Listener 的引用,在构造函数中对这个引用进行赋值,而不是直接创建一个匿名内部类导致 外部类的引用逸出
EventListener listener;
private SafeConstruct() {
id = 1;
listener = new EventListener() {
public void onEvent(Object object) {
System.out.println("id: " + SafeConstruct.this.id);
System.out.println("name: " + SafeConstruct.this.name);
}
};
try {
Thread.sleep(1000); // 调用sleep模拟其他耗时的初始化操作
} catch (InterruptedException e) {
e.printStackTrace();
}
name = "Ahri";
}
public static SafeConstruct getInstance(EventSource<EventListener> source) {
SafeConstruct thisEscape = new SafeConstruct();
source.registerListener(thisEscape.listener);
return thisEscape;
}
}
测试代码:
public class ThisEscapeTest {
public static void main(String[] args) {
EventSource<EventListener> source = new EventSource<>();
ListenerRunnable listenerRunnable = new ListenerRunnable(source);
Thread thread = new Thread(listenerRunnable);
thread.start();
SafeConstruct thisEscape = SafeConstruct.getInstance(source);
}
}
/**
输出
id: 1
name: Ahri
*/
可以看到对象被正确的构造了。
当时看书的时候最大的感慨就是例子没有真实的可测试可观察的代码,补上之后才感觉理解了这个知识点。
还有另一个常见的在 「构造过程」中使 this 引用的错误是:在构造函数中启动一个线程。
这个例子还没有补充。
总结:
Java 内存模型是并发领域中的一次重要创新,再此之后 C++
,C#
,Golang
等高级语言都开始支持 内存模型。
Java 内存模型中,最晦涩的部分就是Happens-Before规则 ,它最初是在一篇 《Time,Clocks,and the Ordering of Events in a Distribute System》 的论文中被提出来的,在这篇论文中,Happens-Before
的语义是一种因果关系,在现实世界中,如果 A事件是导致B事件的起因,那么 A事件一定在B事件之前发生。
这就是 Happens-Before
语义的现实理解。
在 Java 语言中, Happens-Before 语义的本质上是一种 「可见性」, A Happens-Before B 意味着 A事件 对用户 B事件来说是可见的,无论 A事件和B事件是否发生在同一个线程中。
例如 A事件
发生在 线程1 上, B事件
发生在 线程2
, Happens-Before
规则保证了 线程2
上也能看到 A事件的发生
。
Java 内存模型主要分为两部分,一部分是面向编写并发程序的应用开发人员,另一部分面向 JVM 的实现人员。
我们重点关注前者,也就是和编写并发程序相关的部分,这部分的内容核心就是 Happens-Before 规则。
课后精华留言:
有人对 Happens-Before
做了**补充
**:
线程中断规则:对线程interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测到是否有中断发生。
对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
感觉这些规则都是对 volatile
的具体描述
Java 内存模型的底层实现:通过 内存屏障
(memory barrier
) 禁止重排序。「 即时编译器」根据具体的 「底层体系架构」 将这些内存屏障替换成具体的 CPU 指令
。 对于编译器
而言,内存屏障将限制它所能做的重排序优化。 对于处理器
而言,内存屏障会导致缓存的刷新操作。
对于 volatile
编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。 为了探究这一点 于是有了这篇文章 ↓
MacOS 环境下使用 hsdis 和 JIT Watch 查看汇编代码
Q.E.D.
Comments | 0 条评论