在上一篇文章中,你可能已经知道了,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
volatile 解决可见性、有序性问题
以我零散的知识可以知道,被 volatile 修饰的共享变量,读写操作是直接操作内存,相当于禁用了CPU缓存,可以解决可见性问题。很多时候,扯到 volatile ,就得扯一下 Java 的内存模型,我这里就不详细展开。
另外,volatile 还可以解决有序性问题比如上一章中提到的那个创建单例的例子,实际上,只要在 static Singleton instance;
前加个 volatile
关键字,那么这个单例就是安全的了。
final 可定义安全对象,禁止指令重排
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java 编译器在 1.5 以前的版本的确优化得很努力,以至于都优化错了。
public class Singleton {
final static finalSingleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
问题类似于利用双重检查方法创建单例的例子,构造函数的错误重排导致线程可能看到 final 变量的值会变化(之前 1.5 )
在 1.5 以后,对 final 类型变量的重排进行了约束(禁止指令重排)。所以上面这个例子是安全的。
再举个例子:
public class CommonExample{
String x;
public CommonExample(String p) {
x = p;
}
}
这样的代码在编译的时候,也会被重排序,将赋值操作重排序到构造方法之外,那么也就是,构造方法结束了,初始化完成了,这时候,别的线程先后可能会读取到俩个不同的值,一个没初始化的,一个初始化后的值。要避免这种情况的发生,就要用 fianl。
public class CommonExample{
final String x;
public CommonExample(String p) {
x = p;
}
}
final修饰的变量,可以保证,不会被抽排序到构造方法之外,那么就保证了,只要别的线程拿到该对象CommonExample
的引用,那么该对象 fianl 修饰的变量,一定是初始化完成了,避免了上面同一变量,读取值俩次不一样的问题。
使用final需要注意溢出的问题。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this
赋值给了全局变量 global.obj
,这就是“逸出”,线程通过 global.obj
读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
在构造方法中将 this
复制给别的变量,意味着,构造方法还没结束,对象 this
就给别人使用了。对象还没初始化完成,别人就使用,这个有点类似于双层校验创建单例的例子。
所以,我们平时使用final关键字时,不要溢出,就不会出问题了。也就是说,所有线程读到的值都是一致的。
Happens-Before 规则解决可见性问题
如何理解呢?它要表达的是:前面一个操作的结果对后续操作是可见的。比如 A Happens-Before B
,意思就是 A 发生在 B 之前,A 的结果,B 可见。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。
比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
首先看个例子(很好的一个例子):
1.5 后,假设线程 A 执行 writer()
方法,按照 volatile 语义,会把变量 v=true
写入内存;假设线程 B 执行 reader()
方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v
,如果线程 B 看到 v == true
时,那么线程 B 看到的变量 x
是多少呢?
class VolatileExample {
int x = 0;
// 注意:v已经被volatile修饰
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 注意:这里的x只是普通变量
// 这里x会是多少呢?
}
}
}
// 答案:x 就是等于 42
在了解 Happens-Before 规则 之前,我的 Java 基础告诉我,变量 x
没有被 volatile 修饰,只是写在了缓存,可能还没 fresh 到内存。所以这个时候 有两个可能,就是线程 B 看到的变量 x = 0 或者 x = 42。
其实,在 1.5 之前确实是这样子的。这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案 Happens-Before 规则。
1. 程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。
//(为方便你查看,我将那段示例代码在这儿再呈现一遍)
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢?
}
}
}
2. volatile 变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
感觉没啥不一样,但是如果我们关联一下规则 3,就有点不一样的感觉了。
3. 传递性
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
再看上面那个例子,1.5之后。为什么线程 B读到 x 变量
的值一定是 42。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2DlgbTa0-1637076804039)(C:\Users\pengyu\Desktop\我的\b1fa541e98c74bc2a033d9ac5ae7fbe1.jpg)]
从图中,我们可以看到:
x = 42
Happens-Before 写变量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
,有没有一种恍然大悟的感觉?
这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent) 就是靠 volatile 语义来搞定可见性的。
4. 锁的规则
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12
。这个也是符合我们直觉的,应该不难理解。
5. 线程 start() 规则
这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
Thread B = new Thread(()->{
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
6. 线程 join() 规则
如果在线程 A 中,调用线程 B 的 join()
并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join()
操作的返回
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66
互斥锁 解决原子性问题
你已经知道,原子性问题的源头是线程切换。那禁止线程切换?
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。(我觉得这样是不现实的,单核 CPU 的多线程在某些场景还是非常的有意义。所以,如果是单核CPU的多线程,没有禁止线程切换,也还是存在原子性问题的)
多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行
“同一时刻只有一个线程执行” 这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
在 Java 中,synchronized 和并发包中的 Lock 都是互斥锁的实现。
比如下面这个例子:count+=1
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
//受保护资源value
value += 1;
}
}
无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下前面提到的管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,所以前一个线程的解锁操作对后一个线程的加锁操作可见。
所以一个锁保护一个受保护资源是可行的。
如何理解这个模型呢?
比如现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是 Java 类里的方法,而门票就是用来保护资源的“锁”,Java 里的检票工作是由 synchronized 解决的。
那两个锁保护一个资源可行吗?
其实上面的例子还存在一个问题,get() 方法时,value的可读性问题。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证
看一段代码:
//两个锁保护一个资源value
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
两个锁分别是 this 和 SafeCalc.class。临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
这就类似于一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了,所以是不可能的。
一个锁保护两个资源呢?
答案是可以,这个对应到现实世界就是我们所谓的“包场”了。也就是一张包场的凭证,保护球赛里面的所有座位(多个资源)
所以我们可以得到一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。
例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,所有的方法都增加同步关键字 synchronized 就可以了,实例代码如下:
class Account {
// 锁:保护账户余额
private final Object balLock
= new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock
= new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
但是用一把锁锁多个资源有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
在实际工作编程中,是否通过一个锁来保护多个资源,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源