Java5、Java9内存模型
序
为了充分利用CPU多核心,我们要编写多线程的程序。大部分场景下,线程间交互会造成很大的开销,充分理解内存模型有利于编写最高性能的多线程程序。
Java5的内存模型资料网上很多,而Java9的修订就很少有资料。此外,很多资料的描述非常晦涩难懂,我想用自己的方式重新描述一下。
问题列表
- 如果A线程传递一个对象给B线程(比如上面的User对象),这个对象内部的每个字段都要标记为volatile或者访问方法加synchronized吗?
Java5的内存模型
本文会从另一个角度来描述Java5的内存模型,不需要了解晦涩的happens before原则,只需要知道一点简单的概念,就能开发在任何情况下都正确的程序。其实对于有一点基础的开发者来说,只要理解了volatile和final的含义即可(其实也就是JSR-133进行的最重要的两个修订),下面就详细说明。
首先我们假设一个简单的硬件结构,每个CPU核心有自己的Cache,然后共享主内存(实际结构比这要复杂的多)。此外,程序指令会乱序执行,这个乱序可能是编译器(包括javac编译器和JIT编译器),也可能是硬件导致的。本文举的例子实际并不严谨,但足够应用开发人员以此开发出正确的程序。
volatile写保证会刷到主内存中去,而volatile读一定会从主内存读取,这是volatile最原始的语义,它提供一种比加锁更加轻量的线程数据同步方式。
我们先看下面这个程序:
private User user;
public synchronized void setUser(User user) {
this.user = user;
}
public User getUser() {
return user;
}
写方法是同步的,而读方法不是,那么在多线程场景下,线程A调用了写方法以后,线程B调用getUser()仍然可能会返回null。解决办法有两个,第一是把getUser()方法也标记为同步,第二是将user字段标记为volatile。
这个时候突然有一个可怕的推论,按上面这个例子,如果A线程传递一个对象给B线程(比如上面的User对象),这个对象内部的每个字段都要标记为volatile或者访问方法加synchronized吗?
我们知道实际情况是不需要,这得益于JSR-133对volatile的修订。假设线程A有写入了10个字段要传递给线程B,前面9个字段都可以是普通变量,只要最后一个字段是volatile就可以,volatile写入的时候,除了保证这个字段自己会写入主内存,它会让前面的9个字段也被刷到主内存,同时它会禁用重排序,也就是前面9个字段的写在执行过程中不会排到volatile写后面去。线程B读的时候要先读第10个字段,volatile读会让CPU Cache(按前面我们假设的模型)失效,所以它就能读到线程A写入的前面9个字段,volatile读也会禁用重排序,这是为了保证后面9个字段的读不会重排到volatile读前面去。这是不是有点难懂,读的时候顺序怎么还要反过来呢?实际上我们日常就是这么做的:
private volatile User user;
public void setUser(User user) {
this.user = user;
}
public User getUser() {
return user;
}
上面代码假设User类里面有9个普通字段,线程A先设置了这9个字段,然后调用setUser(volatile写);线程B调用getUser是先做volatile读,然后才能访问User类里面的9个字段。synchronized(或Lock)也有类似这样的效果,进入synchronized代码块(加锁)相当于volatile读,失效Cache,这样同步代码块里面能读到最新的数据,离开synchronized代码块(解锁)相当于volatile写,把Cache中的数据都刷到主内存中去。
线程之间还有别的通信方式,比如通过一个并发Queue来传递数据,写入Queue的操作,不管具体实现是加锁还是不加锁(synchronized、CAS、volatile、Lock等),一定具有volatile写的内存语义,放入队列的数据,会被刷进主内存;同样,读取Queue的操作一定具有volatile读的内存语义,失效Cache,从主内存中读。
上面这些内容的严谨表述就是happens before原则,它定义的更加晦涩一些,也更加全面。比如happens before会保证,新启动的线程一定能看见启动它的线程,在启动它之前写入的变量,这和我们的常识一致,所以本文就没有必要一一列出了。
需要注意的是,上面的这些因果顺序保证,要求的是读写同一个volatile变量,或者访问同一个锁。如果锁里套锁,并且还有很复杂的因果关系的话。。。你最好不要这样做。
到目前为止,我们了解了一些知识,但好像又没有什么用途,因为即使不知道上面这些知识,我们日常开发的程序仍然是有效的。
接下来我们看下关于JSR-133对于final的修订,这个知识就有点冷。下面的这个程序是有问题的:
public class User {
private long bornTime;
private static User instance;
private User(long bornTime) {
this.bornTime = bornTime;
}
public static User getInstance() {
if (instance != null) {
return instance;
}
synchronized (User.class) {
if (instance != null) {
return instance;
}
instance = new User(System.currentTimeMillis());
return instance;
}
}
public long getBornTime() {
return bornTime;
}
}
instance只需要初始化一次,这个程序本意是希望除了第一次,每次调用getInstance的时候都不要加锁,由于对instance字段的写是一个原子操作,因此它期望别的线程看到instance字段的值不为null以后,直接返回,进而可以访问instance.getBornTime()。遗憾的是,由于重排序(构造函数内对bornTime的赋值被重排序到构造函数之外),一个线程可能会拿到一个bornTime还未赋值的User对象。
一个解决办法是将bornTime标记为volatile,上面我们讲过volatile能禁止重排序。
除此之外更加轻量的方法是将bornTime标记为final,它能阻止构造函数中对final变量的赋值被重排序到构造函数之外。使用final需要注意两点:
- 只能保证被final标记的字段不被重排序,如果构造的时候还写了其它没有final标记的字段,其它这些字段没有保证
- 在构造函数中不要将this外溢并且让其它线程看到,否则其它线程看到一个没有构造完成的对象,就没有保证了(当然你还可以用别的方式保证比如volatile)
Java5之后 Java9之前
前面的内容对于开发普通的程序已经足够了,Java9关于内存模型的修订只是补充,前面的知识仍然都是适用的。但对于dongting这样的项目开发来说有更高的要求,所以我们还要继续探讨。
Java5的内存模型看起来已经没有什么问题了,还想进一步改进的动力在于,对一个高性能程序来说volatile的读写开销太大了。比如现在有一个volatile变量,对它进行读操作必然伴随失效缓存和禁用重排序等开销,而在某些要求不严格的场景下,我就想看看它大致的值,但是却没有办法像读普通变量一样去读它。
重大的修订发生在Java9,不过我们先看看这之前有什么改变,毕竟还有很多项目仍然用Java8。
Java6给AtomicInteger等引入了lazySet方法,这名字起的好奇怪,文档也很含糊,大概的意思是最终会设置上,我们先按下,等下文还会提到它。
Java8给Unsafe增加了loadFence / storeFence / fullFence方法,因为前面我们没有提到内存屏障(基于标准的Java5内存模型开发,并不需要手工使用屏障),再加上它又在Unsafe里面,同样先按下。
总的来说,Java6、7、8在这方面改动极小。
Java9的修订
受到Java5内存模型的启发,C++11开始也定义了统一的、更强大的内存模型,这样,使用C/C++开发的高性能并发程序,也能够跨平台了。2017年发布的Java9修订了内存模型,又基本上对齐了C++。
Java9内存模型相关的修改,核心的类是VarHandle,不过这个类的使用比较复杂。这里还是用比较简单的AtomicInteger举例。它增加了以下方法:getPlain()、setPlain(int)、getOpaque()、setOpaque(int)、getAcquire()、setRelease(int)等。
AtomicInteger内部的value是个volatile int,所以它的get()、set(int)方法具有volatile读写的内存语义。而getPlain()、setPlain(int),顾名思义,我们可以像读写普通变量一样读写一个volatile了!注意并不需要将volatile int声明为AtomicInteger才能实现这一点,VarHandle类可以实现对volatile int进行任何方式的读写,具体的使用限于篇幅本文就不描述了,我们还是用AtomicInteger来举例。
getOpaque()、setOpaque(int)提供的是opaque模式的读写。这种模式很少用,它只能提供非常弱的保证。它只保证对以opaque模式访问的这个变量,操作是部分有序的,多次写会以顺序的方式被看见。对opaque变量的写最终会对其它线程可见(plain写不能保证最终可见,如果一个线程循环访问一个plain stop变量决定是否停止,理论上可能永远不会停止)。最后opaque读写具有原子性,一个64bit的变量不会被拆成两个。总的来说,opaque模式不是特别有用。(TODO 这里我没有完全理解Per-variable antecedence acyclicity和Coherence的区别)
getAcquire()、setRelease()是一对,一个用于读,一个用于写,名称和功能和C++的memory_order_acquire/memory_order_release一致。在release写操作之前的读写操作一定不会被重排到release写操作的后面去,而在acquire读之后的读写操作不会被重排到acquire读之前去,这可以用来建立因果关系:
private final AtomicReference<User> userRef = new AtomicReference<>();
public void setUser(User user) { userRef.setRelease(user);}
public User getUser() { return userRef.getAcquire();}
还是类似上面的例子,假设User类有9个字段,线程A(生产者)先创建User类设置9个字段(使用plain模式设置),然后调用setUser方法(release写),如果线程B调用getUser方法(acquire读)发现返回的值不为null,那么它一定能看见线程A设置在User类上面的9个字段。
setRelease和前面讲的Java6引入的lazySet具有相同的效果,但是Java6里面没有办法进行acquire读。
Release/Acquire提供的一致性保证比volatile读写要弱。见下面的例子:
private AtomicInteger int x = new AtomicInteger(0);
private AtomicInteger int y = new AtomicInteger(0);
public int invokeByThread1() {
x.setRelease(1);
return y.getAcquire();
}
public void invokeByThread2() {
y.setRelease(1);
return x.getAcquire();
}
上面代码两个线程可能会都读到0。但是如果访问模式都是volatile,至少有一个会返回1。有点难理解?我们先看看下一部分:内存屏障。
关于内存屏障
不同的CPU体系架构下有不同的屏障实现,它们可能非常不同。
Doug Lea在“The JSR-133 Cookbook for Compiler Writers”一文中提出了4种抽象的内存屏障(实现的时候再映射到具体CPU的屏障):
- LoadLoad屏障。假设3条指令分别是Load1、LoadLoad屏障、Load2,那么Load1一定不会排到Load2后面去,也就是说Load1会先执行。
- StoreStore屏障。以此类推,Store1、StoreStore、Store2。Store1会先于Store2完成,而且会保证Store1先写到主内存里面去。
- LoadStore屏障。以此类推,Load1、LoadStore、Store2。Load1会先于Store2完成。
- StoreLoad屏障。以此类推,Store1、StoreLoad、Load2。保证Store1先于Load2完成,并且Store1会先写到主内存里面去。这是开销最昂贵的一个屏障。
到具体硬件架构上实现这4种屏障的时候,有的屏障的实现可能是no-op,比如x86是强内存模型,就不需要实现部分屏障。
Java9的VarHandle类上提供了全面的设置屏障的静态方法(终于不是在Unsafe里面了):
屏障方法 | 别名 | 效果 |
---|---|---|
acquireFence | loadFence | LoadLoad + LoadStore |
releaseFence | storeFence | StoreStore + LoadStore |
fullFence | loadFence() + storeFence()+ StoreLoad | |
loadLoadFence | 当前实现等于loadFence() | |
storeStoreFence | 当前实现等于storeFence() |
Release/Acquire和Volatile的内存语义,都可以用普通读写(64bit变量注意使用opaque保证原子性)加上内存屏障来实现(这可能不是最高效的实现,实际实现未必是这样):
操作 | 等效实现 |
---|---|
release write | releaseFence(); plain write |
acquire read | plain read; acquireFence() |
volatile write | releaseFence(); plain write; fullFence() |
volatile read | plain read; fullFence() |
这样我们再回过来看刚才那个代码例子,如果使用Release/Acquire模式,它的等效实现是这样的:
private int x;private int y;
public int invokeByThread1() {
VarHandle.releaseFence();
x = 1;
int ry = y;
VarHandle.acquireFence();
return ry;
}
public void invokeByThread2() {
VarHandle.releaseFence();
y = 1;
int rx = x;
VarHandle.acquireFence();
return rx;
}
注意到没有,写操作和读操作之间是没有屏障的,所以可能会被调换顺序,导致两个线程都返回0的问题。换成volatile的等效实现看一下:
private int x;
private int y;
public int invokeByThread1() {
VarHandle.releaseFence();
x = 1;
VarHandle.fullFence();
int ry = y;
VarHandle.fullFence();
return ry;
}
public void invokeByThread2() {
VarHandle.releaseFence();
y = 1;
VarHandle.fullFence();
int rx = x;
VarHandle.fullFence();
return rx;
}
显然不会有问题了。
建议
- 封装在一个类中,不要外溢
- 尽量不要涉及多个变量以不同的混合方式访问
- 反复review,如果有复杂的因果关系,从理论上推导一下正确性
- 用单元测试尽可能的模拟一下各种场景
内容来源
原文章链接 (尊重大佬的原创) github.com/dtprj/dongt…
后续
会开一个专栏,下一节会对 java的并发知识 结合 文章内容进行细致说明,不一定都对,如有错误可以指出,我会修订。
但是因为 我有2个 方面(算上这个) 同时在进行,估计不会很频繁的更新
转载自:https://juejin.cn/post/7170150026520297502