一、处理器实现原子操作
原子操作(atomic operation)意为“不可被中断的一个或一系列操作”
由于CPU的高速发展,CPU的处理速度和读写内存的速度的脱节。所以出现了存在于内存和处理器之间的高速缓存。每一个核都会去维护其自己的高速缓存,而每个核的高速缓存是互相不可见的。进而就产生了缓存一致性问题。
例:i = i +1 ;
如果同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
为了解决缓存一致性问题,通常有两种解决办法:
- 通过在总线加LOCK#锁的方式
- 通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
总线锁
前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。在某个CPU要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。
总线锁开销较大,因为总线锁将CPU和内存之间的通信锁住了,导致锁定期间,其他处理器不能操作其他内存地址的数据。
缓存一致性协议
这里以在Intel系列中广泛使用的MESI协议详细阐述下其原理。
MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
MESI 协议是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
- M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
- E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
- S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
- L:无效的。本CPU中的这份缓存已经无效。
当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
所以如果一个变量在某段时间只被一个线程频繁地修改,则使用其内部缓存就完全可以办到,不涉及到总线事务,如果缓存一会被这个CPU独占、一会被那个CPU 独占,这时才会不断产生RFO指令影响到并发性能。这里说的缓存频繁被独占并不是指线程越多越容易触发,而是这里的CPU协调机制,这有点类似于有时多线程并不一定提高效率,原因是线程挂起、调度的开销比执行任务的开销还要大,这里的多CPU也是一样,如果在CPU间调度不合理,也会形成RFO指令的开销比任务开销还要大。当然,这不是编程者需要考虑的事,操作系统会有相应的内存地址的相关判断,这不在本文的讨论范围之内。
并非所有情况都会使用缓存一致性的,如被操作的数据不能被缓存在CPU内部或操作数据跨越多个缓存行(状态无法标识),则处理器会调用总线锁定;另外当CPU不支持缓存锁定时,自然也只能用总线锁定了,比如说奔腾486以及更老的CPU。
二、Java内存模型
在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念以及研究Java内存模型为我们提供了哪些保证以及在java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性问题示例
例如从账户A转出100元到账户B,可以分解为以下操作:
1. 读取账户A的余额,300
2. 计算账户A余额减去100后的余额,得到200
3. 更新账户A的余额为200
4. 读取账户B的余额,500
5. 计算账户B的余额加上100后的余额,得到600
6. 更新账户B的余额为600
以上操作要么全部成功,要么全部失败,不能出现执行到某一个步骤然后停止的情况,例如执行完第3步后忽然停止,那么账户A已经减少了100元,而账户B却没有任何增加。
在Java中典型的原子性问题就是变量的自加自减操作,例如count++
操作看起来只是一个操作,但其实包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。
例如我们编写一个计数器,用来记录当日登录系统的用户数量,如程序清单所示:
public class NonAtomicityDemo {
public static void main(String[] args) {
int i = 10;
while (i-- > 0) {
new Thread(() -> UnSafeCounter.addCount()).start();
}
}
}
class UnSafeCounter {
private static int counter = 0;
public static void addCount() {
counter++;
System.out.println(counter);
}
}
/* 输出 4 10 8 9 8 6 7 4 4 5 */
我们可以观察到,开启10个线程调用UnSafeCounter的addCount()方法,输出的计数是错误的,这是因为count++操作并不是原子操作的原因,不同的线程可能获取count值时,count已经被另一个线程进行了赋值,而当前线程输出的仍然是未被改变的值。这种多个线程多次调用中返回错误的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现的不正确结果情况有一个正式的名字:竟态条件(Race Condition)。
原子性问题解决
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,一般我们可以通过以下几种方法来保证更大范围操作的原子性:
- 通过synchronized关键字来保证同步
- 通过原子类来保证数据同步: concurrent包下提供了一些原子类,如:AtomicInteger、AtomicLong、AtomicReference等这些类提供了原子操作。
- Lock显式锁来进行同步
后续文章将对此进行详述。
可见性
线程之间的可见性是指当一个线程修改一个变量,另外一个线程可以马上得到这个修改值。
可见性问题示例:
假设我们有2个线程:A为读线程,读取一个共享变量的值,并根据读取到的值来判断下一步执行逻辑;B为写线程,对一个共享变量进行写入。很有可能B线程写入的值对于A线程是不可见的。
我们用一个例子来表示这种线程间变量不可见的情况。NonVisibilityDemo中的示例包含两个共享数据的线程。写线程将更新标志,读线程将等待直到设置标志:
public class NonVisibilityDemo {
public static void main(String[] args) throws InterruptedException {
new ReadThread().start();
Thread.sleep(1000);
new WriteThread().start();
}
}
class ReadThread extends Thread {
@Override
public void run() {
System.out.println("read-thread start");
while (true) {
if (ShareData.flag == 1) {
System.out.println("read-thread end");
break;
}
}
}
}
class ShareData {
public static int flag = -1;
}
class WriteThread extends Thread {
@Override
public void run() {
ShareData.flag = 1;
System.out.println("write-thread:flag=" + ShareData.flag);
}
}
这个程序可能会一直循环下去,因为读线程可能读取不到写线程对于flag的写入而永远等待。
可见性问题解决
为了保证线程间可见性我们一般有3种选择:
volatile:只保证可见性
- 在上述程序中,如果我们使用
volatile
关键字对flag进行修饰,读线程可以正常的访问到写线程对共享数据flag的修改从而正常结束。
- 在上述程序中,如果我们使用
Atomic相关类:保证可见性和原子性
- 在上述程序中,使用
AtomicInteger
类来包装共享数据ShareData.flag。ShareData.flag == 1
改为ShareData.flag.get() == 1
;ShareData.flag = 1
改为ShareData.flag.set(1)
- 在上述程序中,使用
Lock: 保证可见性和原子性
- 使用Lock或
synchronized
关键字对操作加锁能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以原子性和可见性。如下代码
- 使用Lock或
public class VisibilityBySynchronizedDemo {
public static void main(String[] args) throws InterruptedException {
new WorkThread().start();
Thread.sleep(1000);
new CancelThread().start();
}
}
class WorkThread extends Thread {
@Override
public void run() {
System.out.println("WorkThread start");
while (true) {
if (ShareData.getFlag() == 1) {
break;
}
}
System.out.println("WorkThread end");
}
}
class ShareData {
private static int flag = -1;
public static synchronized int getFlag() {
return flag;
}
public static synchronized void setFlag(int value) {
flag = value;
}
}
class CancelThread extends Thread {
@Override
public void run() {
ShareData.setFlag(1);
System.out.println("CancelThread setFlag flag=" + ShareData.getFlag());
}
}
其实无论对于上述的何种方式,其本质都是会使相应的CPU进行刷新处理器缓存动作,来保证共享变量的可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
有序性问题示例:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
重排序:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:语句2--->语句1--->语句3--->语句4
那么可不可能是这个执行顺序呢: 语句2--->语句1--->语句4--->语句3。答案是不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
有序性问题解决:
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在后续文章详述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条原则摘自《深入理解Java虚拟机》。前4条规则是比较重要的,后4条规则都是显而易见的。下面我们来解释一下前4条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性。