之前的篇幅聊了些并发基础概念和线程在Java中的实现与用法,今天就写相关我们在并发编程中涉及到的一些理论概念,包括JMM理论模型,一些问题(原子性,可见性,有序性),重排序,happens-before原则等。
内存模型
计算机的并发模型
在聊Java内存模型之前,我们和之前一样,我们先看看计算机是怎么处理并发问题的,在现代计算机处理器CPU的计算效能越来越高的情况下,内存的读写速度明显已经跟不上CPU的计算速度了,为了能够匹配CPU的计算力,计算机不得不加入一层读写速度尽可能接近CPU的运算速度的高速缓存Cache来作为内存与CPU之间的缓冲:将运算需要的数据先从内存复制到缓存,在进行计算,最后再将缓存里计算后的数据复制到内存,这样子CPU就无需等待读写速度很慢的内存了。
虽说加入了高速缓存解决了速度不匹配的问题,但是也由此增加了计算机系统实现的复杂度,(PS:这几乎是计算机或者说是程序世界中必现的问题,在提出更好更快更优的解决方案的背后,往往实现的复杂度是几何倍的增加),由此引入了一个新的问题:缓存一致性。比如在一个多处理器的计算机系统中,每个处理器都有自己的高速缓存,在发生并发的时候,他们都要同时访问一个资源进行数据的更新操作,那各自处理器必然都要从内存复制一份数据,在计算后在更新到内存,那这时应该是谁覆盖谁的数据呢。
那计算机是如何处理这个问题的呢?如下图所示,计算机一般会有两种解决方案:
- 总线加锁(此方法性能较低,现在已经不会再使用);
- MESI协议(缓存一致性协议),这是Intel提出的,MESI协议也是相当复杂,在这里我就简单的说下:当一个CPU修改了Cache中的数据,会通知其他缓存了这个数据的CPU,其他CPU会把Cache中这份数据的Cache Line置为无效,要读取数据的话,直接去内存中获取,不会再从Cache中获取了。
Java内存模型
看完了计算机的并发解决方案,我们再来看看今天的主角,Java内存模型JMM,首先先上图
比较下前面的计算机的模型,你会有种似曾相识的感觉,没错,Java为了能够更好的发挥出计算机硬件的各种优势特性,经过漫长的优化和验证,Java逐渐形成上图所示的内存模型:
- 本地内存(工作内存):存放的是私有变量和主内存数据的副本。如果私有变量是基本数据类型,则直接存放在本地内存,如果是引用类型变量,存放的是引用(指针),实际的数据存放在主内存。
- 主内存:存放的是共享的数据,所有线程都可以访问。 Java内存模型规定了所有线程对共享变量的读写操作都必须在自己的本地内存中进行,需要先从主内存中拿到数据,复制到本地内存,然后在本地内存中对数据进行修改,再刷新回主内存。线程间变量值的传递都需要通过主内存来完成。 此外,其实这里的内存模型和JVM规范中定义的内存划分是没有什么关系的,但是如果硬要和JVM的内存划分来类同的话,主内存就相当于堆区,本地内存相当于每个线程自己的虚拟机栈。
其实在以上的Java内存模型中,其实也存在着缓存一致性等其他一些问题,那下面我们看看,在并发编程中,我们通常会遇到三个典型的问题:原子性问题,可见性问题,有序性问题以及JMM是如何解决这些问题的
三大问题,JMM如何解决
原子性
定义:一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
举个经典的转账例子,比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
我们再来看看在Java的内存模型中定义哪些操作是具备原子性的,看下图:
- lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程访问。
- read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入到工作内存变量副本中。
- use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时会执行这个操作。
- assign:作用于工作内存的变量,它把一个从执行引擎收到的值赋给工作内存的变量。每当虚拟机遇到给变量赋值的字节码指令时会执行这个操作。
- store:作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中。以便随后的write操作。
- write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值,放入主内存的变量中。
我们再看下面的一段程序
//语句1
a = 10;
//语句2
b=a;
//语句3
a++;
//语句4
a=a+1;
- 语句1是直接将数值10赋值给a,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
- 语句2实际上包含2个操作,它先要去读取a的值,再将a的值写入工作内存,虽然读取a的值以及将a的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
- 语句3和4,a++和 a=a+1包括3个操作:读取a的值,进行加1操作,写入新的值。
- 所以上面4个语句只有语句1的操作具备原子性。
保证原子性
只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个例子:
//线程1执行
int a = 0;
a = 10;
//线程2执行
int b = a;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 a=10这句时,会先把a的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中a的值变为10了,却没有立即写入到主存当中。此时线程2执行 b = a,它会先去主存读取a的值并加载到CPU2的缓存当中,注意此时内存当中a的值还是0,那么就会使得b的值为0,而不是10. 这就是典型的可见性问题,线程1对变量a修改了之后,线程2没有立即看到线程1修改的值。
保证可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
使用方法,举例子
class Demo {
public static volatite boolean flag = true;
public static void main(String[] args) throws Exception{
Thread th = new Thread(){
public void run(){
while(flag){
System.out.printf("运行中..");
Thread.sleep(1000);
}
}
};
th.start();
Thread.sleep(10000);
//也算是优雅停止线程的例子了
stopThread();
}
public static void stopThread(){
flag = false;
}
}
下面我们再看一个例子:
public class VolatiteDemo {
public static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
add();
}
}
};
thread.start();
}
Thread.sleep(2000);//保证上面的计算完成
System.out.println("count最后的结果是:" + count);
}
public static void add() {
count++;
}
}
各位看客猜下最后的结果是多少呢?10000吗? 分析程序,每个线程都自增了1000次,一共10个线程,显而易见的最后的结果是10*1000 = 10000。可是结果是这样吗?看下截图
我运行了四次,没有一次是我想要的结果,而且每次都不一样(当然也可能会出现一样的结果,也有可能是10000),这是为什么呢? 我们看看在add方法中是什么操作呢,count++,我们之前的篇幅中说过,++操作包含了3个操作:读取count的值,进行加1操作,写入新的值。所以count++并非是原子性操作。假如某个时刻变量的值为10,线程1对变量进行自增操作,线程1先读取了变量count的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2先去读取变量count的原始值,由于线程1此时只是对变量count进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量count的缓存行无效,所以线程2会直接去主存读取count的值,发现count的值是10,然后进行加1操作,并把11写入工作内存,最后写入主存。然后线程1接着进行加1操作,由于已经读取了count的值,注意此时在线程1的工作内存中count的值仍然为10,所以线程1对count进行加1操作后count的值为11,然后将11写入工作内存,最后写入主存。也就是两个线程对变量并发了更新主存的操作,导致最后那么两个线程分别进行了一次自增操作后,count只增加了1。
那我们怎么办呢?很简单,保证原子性的方法是什么来着,对,我们只要在add方法加个synchronized关键字即可。如下
所以:volatile并不能保证原子性
volatile的实现原理在以后的篇幅在做说明
有序性
定义:程序执行的顺序按照代码的先后顺序执行。
重排序
在看有序性之前,我们先来看看重排序的概念
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。重排序分三种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
重排序-数据依赖性
如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:
这三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。
注意:这里所说的数据依赖性仅针对单个处理器中执行的指令顺序和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
重排序-as-if-serial语义
定义:不管(编译器和处理器)怎么重排序,(单线程)程序的执行结果不能被改变。也就是说编译器和处理器在处理指令集的时候,必须遵守as-if-serial。
示例代码:
int a = 1;//操作A
int b = 2;//操作B
int c = a+b*a;//操作C
按照上面示例来讲,根据数据依赖性和as-if-serial两大原则语义来说,A和C存在数据依赖性,B和C存在数据依赖性。因此C操作肯定是不会在A或者B前面执行的,而编译器和处理器可以对A和B操作进行重排序。我们也可以看到,AB两个操作只是赋值,谁先谁后,都不会影响最后C操作的结果。
再回头来看看有序性问题,由于编译器和处理器都可能发生指令重排序,在单线程中,是不会出现问题,但是若是出现在并发环境中,重排序之后就说不定不能保证最后的结果能够达到你想要的效果了。
保证有序性
在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()方法的开始
这八条规则,前四条是比较重要的,后四条规则是显而易见的 第一条:对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条:意思就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条:是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性
以上便是本篇所有内容了
参考文献
《java并发编程的艺术》
《深入理解Java虚拟机》