高并发编程之线程安全与内存模型
微信公众号:Java修炼指南
关注可与各位开发者共同探讨学习经验,以及进阶经验。如果有什么问题或建议,请在公众号留言。
博客:https://home.cnblogs.com/u/wuyx/
前几期简单介绍了一些线程方面的基础知识,以及一些线程的一些基础用法(想看往期文章的小伙伴可以直接拉到文章最下方飞速前往)。本文通过java内存模型来介绍线程之间不可见的原因。
原子性
原子性对于我们开发者来说应该算是比较熟悉的了,通俗点说就是执行一个操作要么成功,要么失败,不会出现成功一半的情况。而在这里是指即使多个线程一起操作执行时,一旦一个操作开始就不会被其他线程干扰。而在我们开发中所写的程序几乎都不是原子操作,只有系统级别的指令被认为是原子操作。还有一种情况也不是原子操作,就是32位的操作系统去读64位的数据时,也是非原子操作。
就连我们经常使用的 ‘i++’ 也不时原子操作,它其中也至少包含三个操作,首先将i的值读出来,然后进行+1操作,最后将值写如i中。我们看下面例子:
public class ThreadTest { public static int i; public static void add() { i++; } public static void main(String[] args) { Thread t1 = new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 100000; j++) { add(); } } }, "t1"); Thread t2 = new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 100000; j++) { add(); } } }, "t2"); t1.start(); t2.start(); while(t1.isAlive() || t2.isAlive()) { System.out.println(false); } System.out.println(i); } }
如果i++为原子操作的话最后输出的结果应该为200000,然而输出的值并不是200000而是小于200000,这是其中一个在线程在执行i++时先读到了i的值,然后被另一个线程打断执行之后继续执行第一个线程,此时会继续在读到的i上面加1再写回到i中去,所以覆盖了线程2中堆i的操作,所以i的值会比200000小。
有序性
在并发编程时,程序执行并不是有序的,它有可能会出现乱序的情况。我们看下面例子:
static int num = 0; static boolean token = false; private static void writer () { num = 1; token = true; } private static void reader () { if (token) { num += 1; } System.out.println(num); } public static void main(String[] args) { Thread t1 = new Thread() { @Override public void run() { writer(); } }; Thread t2 = new Thread() { @Override public void run() { reader(); } }; t1.start(); t2.start(); }
我们写一个写操作,写一个读操作,分别让两个线程去执行一下,我们理想中得到的值应该是2,但是当你执行多次之后发现,得到的值有3个,0,1,2.得到2我们可以理解,因为我们理想中的值就是2,得到0,我们也勉强可以理解,有可能cpu在分配调度时线程t2首先被执行。但是得到1是什么情况呢?其实这里涉及到2个问题,一个是jvm优化策略指令重排序,另外一个是可见性的问题,在下面我们会说到。
指令重排序
学过汇编语言的小伙伴应该知道,java中的每一条语句其实在jvm的底层,其实都是被转换成一条条的汇编指令去执行,而每一条指令又分为几个步骤(不同cpu可能步骤不同),我们假设每一条汇编指令需要以下5个步骤:
- 取值 IF
- 译码和取寄存器操作数 ID
- 执行或有效地址计算 EX
- 存储器访问 MEM
- 写回 WB
而且不同的步骤执行过程中,使用了硬件系统的不同的单元模块,所以在同一个cpu时钟周期内不会出现相同的两个步骤,比如A指令的 ID 操作与B指令的 ID 操作不会在同一时钟周期同时执行。
我们看下面例子:
a = b + c;
jvm在执行上面java代码时会将java代码转换成下面得汇编语言:
LW R1,b
LW R2,c
ADD R3,R1,R2
SW a,R3
上面汇编语言得意思是,首先将b的值写入到R1寄存器中,然后将c的值写入到R2寄存器中,然后将R1寄存器的值和R2寄存器的值相加写入到R3寄存器中,最后将R3寄存器的值写入到a中。汇编语言是这样执行,那么具体cpu是怎么执行得呢,我们继续往下看:
LW R1,b IF ID EX MEM WB
LW R2,c IF ID EX MEM WB
ADD R3,R1,R2 IF ID × EX MEM WB
SW a,R3 IF × ID EX MEM WB
cpu在执行汇编语言时不会一条语句一条语句慢慢执行,而是当执行结束一条指令得第一个阶段IF时,执行IF的硬件模块就处于空闲状态,它立刻会去执行第二条指令得IF阶段,而第一条指令则继续去执行ID阶段,这样上述汇编语言执行结束就只需要9个cpu时钟周期。如果cpu是一条条去执行指令的话就需要用到20个cpu时钟周期,这样效率就会减少一倍不止。但是也有小伙伴发现在执行ADD指令时,并不是等第二条指令得EX阶段执行结束就立刻去执行ADD指令得EX阶段,这是为什么呢?这是因为ADD指令需要使用到第二条指令写入的R2寄存器,可是此时还未进行寄存器访问,所以R2寄存器中还没有c的值,所以此时出现了一个空闲的时钟周期,我们也叫做出现了一个气泡。当第二条指令执行寄存器访问之后,并不需要写回,硬件就可以直接读取,所以ADD指令继续执行,而在SW指令中为什么也多了一个气泡呢,如果在SW中没有这个气泡的话,它得EX阶段就会和ADD指令得EX阶段冲突,因为硬件执行在同一单元模块中,所以这里不可以冲突,所以上面指令得气泡将被继承下来。
我们再看下面一个例子:
a = b + c;
d = e + f;
我们直接来看cpu执行汇编指令的结果:
LW R1,b IF ID EX MEM WB
LW R2,c IF ID EX MEM WB
ADD R3,R1,R2 IF ID × EX MEM WB
SW a,R3 IF × ID EX MEM WB
LW R4,e × IF ID EX MEM WB
LW R5,f IF ID EX MEM WB
ADD R6,R1,R2 IF ID × EX MEM WB
SW d,R6 IF × ID EX MEM WB
我们看到执行上述得汇编语言使用了14个cpu时钟周期,但是cpu在真实操作时并不是这样去执行的,它会对指令进行优化,在不影响程序顺序执行得情况下使得气泡尽可能的少。我们看下面例子:
LW R1,b IF ID EX MEM WB
LW R2,c IF ID EX MEM WB
LW R4,e IF ID EX MEM WB
ADD R3,R1,R2 IF ID EX MEM WB
LW R5,f IF ID EX MEM WB
SW a,R3 IF ID EX MEM WB
ADD R6,R1,R2 IF ID EX MEM WB
SW d,R6 IF ID EX MEM WB
我们看到cpu将LW R4,e指令放在了ADD R3,R1,R2之前,因为LW R4,e的指令执行并不会影响到整个程序的顺序执行,而且也将气泡消除,这样执行下来就会消除3个气泡,下面又将LW R5,f得指令向上移动,又消除另外2个气泡,这样下来,整个程序运行速度提高了2个cpu时钟周期,大家可能觉得这样的提升并不大,但是这只是2行代码,如果代码有几十万行甚至几百万行,那么对于效率的提升可想而知。
我们对于上面cpu对于指令的优化就叫做“cpu指令重排序”。
可见性
可见性问题也是在多线程开发中比较常见的一个问题,他是说一个线程将一个变量的值进行修改,其他线程是否能够知道的一个问题。这个问题的成因特别复杂,他可能是系统各个层面得优化而产生的问题,比如缓存,硬件优化,编译器优化,批处理优化还有上面提到的cpu指令重排序等等。
下面来看一个比较经典的一个例子:
两个线程 a == b == 0;
Thread1 | Thread2 |
---|---|
r2 = a | r1 = b |
b = 1 | a = 2 |
但是这段代码执行结果r2有可能0,也有可能等于2,r1有可能等于0也有可能等于1,因为发生指令重排之后会产生这样的结果。
我们再看下面例子:
两个线程 p == q; p.x == 0;
Thread1 | Thread2 |
---|---|
r1 = p; | r6 = p |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r1.x |
但是这段代码在编译阶段就会被大部分jvm优化成以下代码:
Thread1 | Thread2 |
---|---|
r1 = p; | r6 = p |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r2 |
这段代码在编译时,编译器发现r1.x的值与r2的值是同一个,所以就会将r2直接赋值给r5,所以这段代码并不会像程序员想象中将3的值赋值给r5.
这是在多线程开发中很常见得可见性问题,解决方案也比较简单,就是在变量前面加上“volatile”关键字,这将会牺牲一部分效率作为代价,在每次使用此变量时都会去内存中拿。但是有一点需要注意,“volatile”关键字只是用于解决可见性问题,它并不能保证原子操作。
Happen-Before规则
在指令重排序中由于cpu去除气泡导致某些指令被重新排序,但是在指令重排时也是需要遵从一定得规则,这个规则就是Happen-Before规则:
- 程序顺序规则:一个线程内保证语义的串行性。
- volatile规则:volatile变量的写,先发生于读,这保证volatile变量的可见性。
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
- 传递性规则:A先于B,B先于C,那么A一定先于C。
- 线程中start()方法先于线程得每一个动作。
- 线程中每一个动作都先于线程得结束join()。
- 线程中断(interrupt())先于要中断的代码。
- 对象的构造函数执行结束先于finalize()方法。
往期内容
高并发编程之基础概念
高并发编程之基础概念(二)
高并发编程之 synchronized