如果重排序之后的结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)既然要学习多线程,就要知道多线程因为什么而出现,出现的意义是什么,它的出现引发了什么问题。在这里,我先理一下多线程出现带来的一堆问题。
当CPU,内存,I/O飞速发展的时候,有一个矛盾一直存在,那就是这三者的运行速度,可以抽象的这样理解:
CPU运行一条指令花费时间:一天
CPU读写内存花费时间:365天
再来看内存和I/O设备的关系:
CPU读写内存需要花费时间:1天
I/O设备的花费时间:3650天
可以看到,时间的花费差异是十分巨大的,为了弥补这份巨大的时间差异,不管是内存还是CPU,都采取了相应的策略来解决这个问题。
1:CPU增加了缓存,以均衡和内存之间的速度差异;(从内存读取到数据后,将其存放在缓存中,直至处理结束,在将其写入到内存中)
2:操作系统增加了进程,线程,来分时复用CPU,进而均衡CPU和I/O设备之间的速度差异。
3:编译程序优化指令执行顺序,从而保证CPU缓存被更好的利用。
当我们做了上述那么多事情以后,多线程带来的影响也就随之而来了。
A:首先,第一个带来的就是可见性的问题。
当CPU增加缓存以后,在单核时代,这不会造成什么影响,因为只要一个CPU,当CPU从内存拷贝一份数据到缓存中,多个线程是对这一个数据进行操作的,线程a处理了数据M后,其处理结果对于线程2来说是可见的。
但是现在电脑,都是多核处理器,那么每一个CPU都有自己的缓存,这个时候问题也就来了,
可见性观察上图,CPU-1和CPU-2都含有自己的缓存,线程A和线程B分别操作两个CPU上的缓存,那么这个时候,线程A对CPU-1里面缓存数据的操作对于线程B来说,就是不可见的了。而这,就会导致最终数据操作失败。
拿一段经典代码来看:
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
6 count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束22 th1.join();
th2.join();
return count;
}
}
上面的操作按道理应该是20000的结果,但是实际上却不是这样,为什么呢,我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
B:线程切换带来了原子性问题
因为I/O读取相较于CPU实在是太慢太慢了,于是就发明了进程,多线程来分时复用CPU。
原子性线程A和线程B分时复用CPU。
举一个例子,在一个时间片内,有一个线程正在进行文件读取操作,那么它就会把自己标记为休眠状态,然后让出CPU的使用权,等到将文件读取至内存以后,操作系统会将这个线程进行唤醒,唤醒以后,这个线程就可以争夺CPU的使用权了。
在线程读取文件时,释放对CPU的使用权,这样CPU就可以做其他的事情,那么CPU的利用率也就上来了。当然,在一个线程进行文件读取的时候,如果这时候有另外一个线程也要进行文件读取,这个读文件的操作就会排队,当磁盘驱动发现一个读取完成以后,它就会启动排队中的其他读取操作。这样,I/O的利用率也上来了。
理解了分时复用,任务切换,那么为什么他会带来原子性的问题呢?
首先要说明的是:任务切换的时机大多数是在时间片结束的时候。
在这个时候,也是BUG出现的时候,十分诡异,那么举一个例子来看看吧:
count += 1
在java语言中,这个语句看似只是一条指令,在CPU中却需要3条语句才可以执行完。
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
原子性1可以看到,两个线程,当线程1正在对count+=1进行操作时,已经将count=0加载到了寄存器里面,这个时候突然进行任务切换,线程b也进行这个操作,线程b结束后,将结果1写入到了内存里面,这个时候线程1继续执行,但是此时count应该等于1,但是由于线程1已经加载过了,所以还是对count=0这个数据进行操作,最后也是将count=1写入到内存里面,所以,本来应该时count=2,现在因为分时复用的问题,就导致了结果的错误。
而上述三条指令,应当是一个原子操作。
C:最后再来看看由于编译优化带来的有序性问题。
我们以为的 new 操作应该是:
1. 分配一块内存 M;
2. 在内存 M 上初始化 Singleton 对象;
3. 然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
1. 分配一块内存 M;
2. 将 M 的地址赋值给 instance 变量;
3. 最后在内存 M 上初始化 Singleton 对象。
接下来看一下JAVA内存模型这一个概念以及它所引出来的一些东西。
首先,我们已经知道了因为缓存和指令优化而带来的多线程问题,那么看由怎么避免呢,最直接有效的方法就是禁用缓存和指令优化呗,但是这样做会给我们的效率带来极大的问题。
那么看由怎么做呢,为了同时兼顾性能和安全,我们要做的就是按需禁用缓存和指令优化,那么一个新的问题就来了,怎么拿什么来按需禁用呢?这个时候,JAVA内存模型就出来了,它规范了一些按需禁用的方法。我们直接使用它,就可以做到按需禁用。
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。
volatile不是JAVA的独特产物,在C语言中也存在,它的作用是什么呢,简单来说,就是禁用CPU缓存。
Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。
Happens-Before 规则
接下来就分析一些这个规则。
字面理解,这个规则就是保证前一个操作的结果对后一个操作时可见可知的。那么正规的说呢,就是说,此规则约束了编译器的优化行为,虽然允许优化,但是必须要遵守它的规则。
那下面就一个个来看它的具体规则:
1:程序的顺序性规则:
这个很好理解,就是说,前面的操作是优于后面的任何操作的。字面不好理解,拿代码来看:
public void writer() {
x = 42; // 1
v = true; / 2
}
在这里,x=42,就优于v=true以及它后面的任何操作,这个很好理解。
为什么会这样呢,因为操作1和操作2没有数据依赖关系,就可能被重排序,变成
v = true;
x = 42;
所以,这个规则就是允许重排序,优化,但是绝对不能搞乱了顺序,即操作1必须在操作2之前。
但是虽然这是规则保证的,但是JMM其实并没有真的保证到家,也就是说,虽然说是这样,但是当真正执行的时候,JMM依然有可能对执行顺序重排序,但是却不影响这个规则的正确性,所以这只是一个基本规则,我们还需要考虑更多的规则。
在单线程中不改变运行结果
操作不具备数据依赖性
重排序如果重排序之后的结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)
所以,就是说,只要满足在1:单线程中不改变运行结果2:操作不具备数据依赖性,JMM就会重排序,这样在单线程里面确实没问题,但是拿到多线程里面,就会出现问题了。所以这是一个坑。
2. volatile 变量规则
这个规则就可以结合上面一个规则来看了。
这个规则就是完全禁止重排序了。
这个需要拿一段代码来更好的理解:
public class ReorderExample{
private int x = 0;
private int y = 1;
private volatile boolean flag = false;
public void writer(){
x = 42; //1
y = 50; //2
flag = true; //3
}
public void reader(){
if (flag){ //4
System.out.println("x:" + x); //5
System.out.println("y:" + y); //6
}
}
}
以上面一段代码来分析,操作3必然不会被重排序到操作1和操作2之前。操作4必然不会被重排序到操作5和操作6之后。而这个时候,有序性规则也来了,这个时候,操作1和操作2因为无论是否重排序,都不会对结果造成影响,即使在多线程情况下,因为操作5判断的是flag这个加了volatile的变量,而他被限制了,所以在多线程里面,线程1write时都可以保证操作3之前的操作,对于线程2的read,都是可见的。
3:传递性
这个更好理解了。这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
传递性x =42 和 y = 50 Happens-before flag = true, 这是规则 1
写变量(代码 3) flag=true Happens-before 读变量(代码 4) if(flag),这是规则 2
根据规则 3 传递性规则,x =42 Happens-before 读变量 if(flag)
谜案要揭晓了: 如果线程 B 读到了 flag 是 true,那么 x =42 和 y = 50 对线程 B 就一定可见了,这就是 Java1.5 的增强 (之前版本是可以普通变量写和 volatile 变量写的重排序的)
这就是规则的结合。
4:监视器锁规则
这个说白了,就是synchronized 关键字。
要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
下面还是拿代码来看
public classSynchronizedExample{
private int x = 0;
public void synBlock(){
// 1.加锁
synchronized (SynchronizedExample.class){
x = 1; // 对x赋值
}
// 3.解锁
}
// 1.加锁
public synchronized void synMethod(){
x = 2; // 对x赋值
}
// 3. 解锁
}
先获取锁的线程,对 x 赋值之后释放锁,另外一个再获取锁,一定能看到对 x 赋值的改动,就是这么简单。
5:start()规则
很简单的规则,如果在线程A里面启动线程B,那么在启动线程B之前的所有操作对于线程B来说,都是可见的。
6:join()规则
在主线程A中启执行线程B的join()方法,那么线程B执行成功并且返回后,线程B的所有操作对于线程A来说,都可见。它与start()规则正好相反。
问题
有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?
答:依据Happens-Before 规则
规则2:声明共享变量abc,使用volatile关键词修饰,就可以保证。
规则4:使用synchronized锁,就可以保证线程间的可见性。
网友评论