Java高并发程序设计(三)—— java内存模型和线程安全

一、原子性

原子性是指一个操作是不可中断的。即使是在多线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。

原子的含义本身就是不可再分的,对于一个不可再分的操作要么就完成,要么就完不成,不会说做一半被另外一个线程给干扰。

一般认为CPU的指令都是一些原子操作,但是像程序代码里的东西就不是原子操作了,比如常见的i++,就不是原子操作,因为包含三个操作,读,加,读。在多个线程中,如果有多个线程同时做i++,i是一个全局变量,这个时候就会有冲突。比如线程1读取到i的值为1,线程2在线程1做加法之前也读取到i的值为1,两个线程同时做自己的加法,然后i变成2,线程1把结果2写到结果中去,线程2也把2写到结果中去,所以最终i是2,而事实上,两个线程同时对它做++操作,它应该变成3,这就说明i++并不是一个原子操作,不满足原子的特性。另外一种情况就是在32位机器上对64位数据进行读写,比如在32位java虚拟机上去读写64位long型。这时候会发现long型的读取和写入也不是一个原子操作,但是32位机器去读取32位整数它就是一个原子操作。

二、有序性

在并发时,程序的执行可能出现乱序。

计算机在执行代码的时候不一定按照程序的语序来执行的。

 

 如果writer()和reader()在两个线程中执行,writer线程中,有可能先执行flag等于true,后执行a=1。

一条指令的执行是分很多步骤的:(简化)

1. 取指IF

2. 译码和取寄存器操作数ID

3. 执行或者有效地址计算EX

4. 存储器访问MEM

5. 写回WB

一条汇编指令的执行分为很多步骤。不同的硬件,实现是不一样的。根据现在的CPU,基本上一条指令它分为十几个阶段去执行。

目前的虚拟机,解释执行的时候也是把它变成机器码执行的,但是如果是编译执行,它会把整个函数变成机器码去执行,执行的时候都是机器码。

第一步,一条指令要执行了,我先把这条指令取出来,用IF代替取指操作。

第二步,取出操作数,也就是要拿出参数来,用ID代替译码和取寄存器操作数操作。

第三步,执行和有效地址计算,用EX代替执行和有效地址计算操作。

第四步,存储器访问,用MEM代替存储器访问操作。

第五步,要把数据写回到寄存器当中去,用WB代替写回操作。

简化后,把一条指令的执行分为五个部分,IF,ID,EX,MEM,WB分别代表指令执行的从先到后的五个部分,每一部分可能会用到不同的硬件。

 如果一个指令我们把它分解到几个不同的阶段,那么我们就不一定让指令一条接着一条执行,这里有两条指令,一条指令1,一条指令2,一般呢我们认为指令1和指令2依次执行。但是实际上指令并不是这么做的,因为这样做太慢了,我们假设每一个环节都要消耗1个时钟周期,一个指令就要消耗5个CPU时钟周期,两条指令如果串行执行,就要消耗10个CPU时钟周期。所以为了以性能为优先,所以不能以串行的方式一条条去执行。所以高效的执行方式是,第一条指令执行IF的时候,第二条指令不能执行IF的,因为两条指令不能同时用同样的硬件,但是当第一条指令执行到ID的时候,指令1的IF指令用到的硬件空出来了,第二条指令可以执行IF操作。所以第一条指令执行的第一个步骤执行之后,第二条指令就可以紧跟而上。同理,后面指令都可以跟在后面依次执行。这种工作方式成为流水性。本例中是五级流水线如果跑满的话,相当于每过一个时钟周期就可以跑一条指令,与之前5个时钟周期出一条指令,这种工作方式快了5倍,加速比就是5,前提是流水线跑满。

如果执行A=B+C的操作。

第一步,读取B的值到寄存器R1中,使用LW(load)指令。

第二步,读取C的值到寄存器R2中,使用LW(load)指令。

第三步,做一个ADD操作,把寄存器R1和R2的值读取出来,放到寄存器R3中。

第四步,把R3寄存器的值保存到A变量当中去。

以上是从上到下的执行步骤。

注意第三步的时候,空出来一位,是因为这时候还没有拿到R2的值,需要等到MEM访问之后才可以参与运算,这时候R2的值已经从内存当中读取出来了。不用等到WB回写回去就执行运算,那是因为硬件电路当中,只要读了内存当中的数据,在内存当中处理数据冲突的时候会使用一种旁路的机制,直接把数据从硬件当中给读取出来,所以不需要等到第二条指令完全执行完,才可以做运算,实际上等待第二条指令将数据从内存中读取出来之后就可以做运算了,但是在内存操作之前还是不能做运算的。叉号代表气泡,在这个节拍中什么都没有做,所以在第三条指令中浪费了一个时钟周期,因为这个时钟周期什么都做不了。第四条指令还需要一个气泡是因为同一个操作在同一时间两个不同指令当中是不能一起做的,因为它们会使用同一个硬件设备。

有序性案例2:

 

从上图可以看到,为了使得代码正常执行,我们插了5个气泡。能不能对代码进行优化,使得插入的气泡尽可能少,每多一个气泡,性能下降十个百分点。如果一直插入气泡,性能下降百分之五十。像奔腾系列的CPU,有两条流水线,意思是如果在完全理想的情况下,一个时钟周期出来两条指令,就因为插了一个气泡,两个时钟周期出来一条指令,就非常不合适了。

 

因为在ADD之前,我们加了一个气泡进去,所以可以考虑在ADD之前做一些额外的事情,把气泡给填充了,因为ADD产生气泡的原因并不是因为硬件上的冲突,而仅仅是因为ADD和LOAD产生了数据依赖关系,因为数据依赖的产生使得ADD没有办法做,所以我不得不插入一个气泡进去,所以我能不能在ADD之前做另外的操作和LOAD没有数据依赖,把这条操作插入到这里来,把气泡抵消掉。所以把读取E和读取F的操作往前提。

 

在新的调整之后,整个过程没有气泡,程序可以执行的更快。不太好的结果是A很晚赋值,E和F很早就被读取值了,这就是指令重排。

指令重排的目的是可以使得流水线更加顺畅。

指令重排的原则是不可以破坏串行语义的一致性。

指令重排只是编译器或CPU优化代码的一种方式,这种优化可能最终在一个线程去看另一个线程的时候就会出现乱序的现象。

三、可见性

可见性是指当一个线程修改了某一个共享变量的值,其它线程是否能立即知道这个修改。

- 编译器优化

- 硬件优化(如写吸收、批操作)

 

可见性问题更像是一个系统性问题,它可能由各个环节产生,并不是我由某一项优化技术产生可见性问题,在各个级别上的优化都有可能产生可见性问题,比如说在CPU指令执行时的指令重排,也会产生可见性问题,因为不知道谁先谁后,所以说没有办法从一个线程中去看另一个线程变量变到了什么程度,可以推测某个变量一定是某个值。因为不知道,所以没有办法做这么一个推测。另外在编译器优化的时候也可能会产生这种现象,比如说一个编译程序,我们在编译这个代码的时候,有可能在一个线程当中把一个变量的值优化到了某一个寄存器当中去,然后对另外一个线程当中对这个值来讲把这个变量的值放到高速缓存cache当中去,这时候这两个值它们就未必是能够在同一时间发生对方修改了同一变量的值。因为毕竟是在多核CPU上面,因为每一个CPU都有自己的一套寄存器和Cache,而一个变量有可能被不同CPU的不同寄存器和Cache给缓存住,这时候你不能保证他们之间一定是一致的,这就是可见性问题的一个原因。再有硬件优化的原因,比如说我们的CPU想把一个数据写到内存里去的时候,其实这个时候它很有可能并不是把这个数据直接写到内存里去的,因为这样会很慢,它为了优化,它有一个硬件的队列,它会把数据往硬件队列里面写,然后通过批量操作的方法把硬件队列中的数据批量的写入到内存里去,这样批操作会比较快一些。在批操作过程中还会做一些优化,比如说你对同一个内存地址做了多次不同的读写,它认为你这是没有必要的,因为一定是以最后一次读写为准,所以把老的读写从队列中丢掉,不在内存中写入,然后把最后的结果写到内存里面去,这样的后果是之前写的数据在另外的线程当中是看不见的。

可见性问题的成因也是因为优化,不同级别产生的优化。

如图,同样一个变量t,有一份保存在cache中,另一份保存在内存内,CPU2修改内存中的t,CPU1还在读cache中的t,所以不一定能读到。

多核CPU之间会有一些数据一致性的协议,但是这种一致性协议是一种相对比较松散的一致性协议,还是没有办法保证立即看见对方对变量的修改,因为要保证立即可见性是要有一些性能的代价的。所有的问题都是由于优化导致的,如果没有问题,性能则会变得很差。

虚拟机层面的可见性问题成因举例:

 

 

 使用-server模式对代码进行足够的优化。虚拟机执行有两种方式:-client方式,客户端模式不会对代码进行足够的优化,更多着重于系统启动要快,对用户反映要快。还有一种使-server模式,会对代码进行足够的优化,系统会尽量做一些优化,启动会慢一些。事实上,根据打印运行程序的汇编代码。

可见性问题的成因比较复杂,可能是各个层面的优化产生的。可见性问题在一个线程当中可能看不到另外线程对变量的修改。解决这个问题使用关键字volatile。从编译出的代码也可以看出,加了是volatile之后,编译出来的代码可以看出每次循环都会把stop的值get一下。

 

四、Happen-Before

Happen-Before规则:

1. 程序顺序原则:一个线程内保证语义的串行性   a=1;  b=a+1; 

2. volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性。

3. 锁规则:解锁(unlock)必然发生于在随后的加锁(lock)前。

4. 传递性:A先于B,B先于C,那么A必然先于C。

5. 线程的start()方法先于它的每一个动作。

6. 线程的所有操作先于线程的终结( Thread.join() )。

7. 线程的中断( interrupt() )先于被中断线程的代码。

8. 对象的构造函数执行结束先于finalize()方法。

Happen-Before是为了保证多线程中语义是一致的,

五、线程安全的概念

指某个函数、函数库在多线程环境中被调用时,能够正确地处理各个线程的局部变量,使程序功能正确完成。

 

如果i++中i是一个static变量,它不是一个线程安全的,线程1和线程2同时读取i的值,并执行i++操作,然后写入1。所以导致一个线程的值被另一个线程覆盖掉。

 

 使用synchronized可以有效保证线程安全。这种方式很简单,因为临界区中只有一个线程工作,如果允许多个线程在临界区中工作,那么自己要处理很多复杂的情况。

 

posted @ 2020-02-13 18:30  海边拾贝seebit  阅读(215)  评论(0编辑  收藏  举报