赞
踩
编写并发程序是比较困难的,因为并发程序极易出现Bug,这些Bug有都是比较诡异的,很多都是没办法追踪,而且难以复现。要快速准确的发现并解决这些问题,首先就是要弄清并发编程的本质,并发编程要解决的是什么问题。并发编程要解决的三大问题:原子性、可见性、有序性。
什么是原子性
把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。在并发编程中,原子性的定义不应该和事务中的原子性(一旦代码运行异常可以回滚)一样。应该理解为:一段代码或者一个变量的操作,在一个线程没有执行完之前,不能被其他线程执行。
为什么会有原子性问题
线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。如:对于一段代码,一个线程还没执行完这段代码但是时间片耗尽,在等待CPU分配时间片,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程同时执行同一段代码,也就是原子性问题。
线程切换带来原子性问题
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
- i = 0; // 原子性操作
- j = i; // 不是原子性操作,包含了两个操作:读取i,将i值赋值给j
- i++; // 不是原子性操作,包含了三个操作:读取i值、i + 1 、将+1结果赋值给i
- i = j + 1; // 不是原子性操作,包含了三个操作:读取j值、j + 1 、将+1结果赋值给i
原子性问题举例
还是上文中的代码,10个线程将inc加到10000。假设在保证可见性的情况下,仍然会因为原子性问题导致执行结果达不到预期。为方便看,把代码贴到这里:
- public class Test {
- public int a = 0;
-
- public void increase() {
- a++;
- }
-
- public static void main(String[] args) {
- final Test test = new Test();
- for (int i = 0; i < 10; i++) {
- new Thread() {
- public void run() {
- for (int j = 0; j < 1000; j++)
- test.increase();
- }
- }.start();
- }
-
- while (Thread.activeCount() > 1) {
- // 保证前面的线程都执行完
- Thread.yield();
- }
- System.out.println(test.a);
- }
- }
目的:10个线程将inc加到10000。
结果:每次运行,得到的结果都小于10000。
原因分析:
首先来看a++操作,其实包括三个操作:
①读取a=0;
②计算0+1=1;
③将1赋值给a;
保证a++的原子性,就是保证这三个操作在一个线程没有执行完之前,不能被其他线程执行。
实际执行时序图如下:
关键一步:线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2的计算结果也是a=1。
问题在于没有保证a++操作的原子性。如果保证a++的原子性,线程1在执行完三个操作之前,线程2不能执行a++,那么就可以保证在线程2执行a++时,读取到a=1,从而得到正确的结果。
什么是可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
为什么会有可见性问题
对于如今的多核处理器,每颗CPU都有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU缓存与内存的数据不容易保证一致。为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。
缓存不能及时刷新导致了可见性问题。
可见性问题举例
- public class Test {
- public int a = 0;
-
- public void increase() {
- a++;
- }
-
- public static void main(String[] args) {
- final Test test = new Test();
- for (int i = 0; i < 10; i++) {
- new Thread() {
- public void run() {
- for (int j = 0; j < 1000; j++)
- test.increase();
- }
- }.start();
- }
-
- while (Thread.activeCount() > 1) {
- // 保证前面的线程都执行完
- Thread.yield();
- }
- System.out.println(test.a);
- }
- }
目的:10个线程将inc加到10000。
结果:每次运行,得到的结果都小于10000。
原因分析:
假设线程1和线程2同时开始执行,那么第一次都会将a=0 读到各自的CPU缓存里,线程1执行a++之后a=1,但是此时线程2是看不到线程1中a的值的,所以线程2里a=0,执行a++后a=1。
线程1和线程2各自CPU缓存里的值都是1,之后线程1和线程2都会将自己缓存中的a=1写入内存,导致内存中a=1,而不是我们期望的2。所以导致最终 a 的值都是小于 10000 的。这就是缓存的可见性问题。
什么是有序性
程序执行的顺序按照代码的先后顺序执行,称为有序性。
为什么会有有序性问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
有序性问题举例
Java中的一个经典的案例:利用双重检查创建单例对象
- public class Singleton {
- static Singleton instance;
- static Singleton getInstance(){
- if (instance == null) {
- synchronized(Singleton.class) {
- if (instance == null)
- instance = new Singleton();
- }
- }
- return instance;
- }
- }
在获取实例getInstance()的方法中,我们首先判断 instance是否为空,如果为空,则锁定 Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。看似很完美,既保证了线程完全的初始化单例,又经过判断instance为null时再用synchronized同步加锁。但是还有问题!
instance = new Singleton();
创建对象的代码,分为三步:
①分配内存空间
②初始化对象Singleton
③将内存空间的地址赋值给instance
但是这三步经过重排之后:
①分配内存空间
②将内存空间的地址赋值给instance
③初始化对象Singleton
会导致什么结果呢?
线程A先执行getInstance()方法,当执行完指令②时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
执行时序图:
并发编程的本质就是解决三大问题:原子性、可见性、有序性。
原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。由于线程的切换,导致多个线程同时执行同一段代码,带来的原子性问题。
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存不能及时刷新导致了可见性问题。
有序性:程序执行的顺序按照代码的先后顺序执行。编译器为了优化性能而改变程序中语句的先后顺序,导致有序性问题。
启发:线程的切换、缓存及编译优化都是为了提高性能,但是引发了并发编程的问题。这也告诉我们技术在解决一个问题时,必然会带来另一个问题,需要我们提前考虑新技术带来的问题以规避风险。
并发编程的三大问题:原子性、可见性、有序性。其中,缓存不能及时刷新导致了可见性问题。编译器为了优化性能而改变程序中语句的先后顺序,导致有序性问题。
而“缓存不能及时刷新“和“编译器为了优化性能而改变程序中语句的先后顺序”都是重排序的一种。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。处理器将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。
举例:如下代码执行过程中,程序不一定按照先A后B的顺序执行,经重排序之后可能按照先B后A的顺序执行。
- int a = 1; // A
- int b = 2; // B
重排序需要遵守一定规则,以保证程序正确执行。重排序遵守数据依赖性。
数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性,存在数据依赖关系的两个操作,不可以重排序。存在数据依赖性的三种情况:
- ① 写后读:a = 1;b = a; 写一个变量之后,再读这个位置。
- ② 写后写:a = 1;a = 2; 写一个变量之后,再写这个变量。
- ③ 读后写:a = b;b = 1;读一个变量之后,再写这个变量。
数据依赖性只针对单个处理器中执行的指令序列和单个线程中执行的操作。举例:
同一个线程中执行a=1;b=1; 不存在数据依赖性,可能重排序。
同一个线程中执行a=1;b=a; 存在数据依赖性,不可以重排序。
重排序遵守as-if-serial 语义
as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
举例,以计算圆的面积为例:
- double pi = 3.14; // A
- double r = 1.0; // B
- double area = pi * r * r; // C
A和B重排序之后,程序的执行结果不会改变,所以允许A、B重排序。A和C重排序之后,程序的执行结果会改变,所以不允许A、C重排序。
笔者看来,遵守数据依赖性和as-if-serial 语义实质上是一回事。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
重排序可以提高程序执行的性能,但是代码的执行顺序改变,可能会导致多线程程序出现可见性问题和有序性问题。
举例:
- 初始状态:a = b = 0;x = y = 0;
- Processor A:
- a = 1; // A1
- x = b; // A2
- Processor B:
- b = 2; // B1
- y = a; // B2
如上代码,Processor A和Processor B同时执行,最终却可能得到x = y = 0的结果。
原因分析:
第一步执行A1/B1将a=1写到缓冲区,此时写缓冲区还在等待其他写操作,不执行A3,所以内存中的a=0;
第二步执行A2/B2,处理器读取内存中的a,得到a=0;
虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了。
1)对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
2)对于处理器,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。
3)JMM根据代码中的关键字(如:synchronized、volatile)和JUC包下的一些具体类来插入内存屏障。
JMM 把内存屏障指令分为下列四类:
Store:数据对其他处理器可见(即:刷新到内存中)
Load:让缓存中的数据失效,重新从主内存加载数据
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
从Java源代码到最终实际执行,要经历三种重排序:编译器优化的重排序、指令级并行的重排序、内存系统的重排序。
as-if-serial语义要求:不管怎么重排序,程序的执行结果不能被改变。存在数据依赖关系的两个操作,不可以重排序。
重排序可能会导致多线程程序出现可见性问题和有序性问题。
JMM编译时在当位置会插入内存屏障指令来禁止特定类型的重排序。
JMM抽象结构模型
JMM定义了线程和主内存之间的抽象关系:
线程之间的共享变量存储在主内存中
每个线程都有一个私有的本地内存,本地内存中存储了该线程用以读/写共享变量的副本
共享变量:堆内存在线程之间共享,存储在堆内存中所有实例域、静态域和数组元素都是共享变量
线程之间通信
线程A与线程B通信:
线程A把本地内存A中的共享变量刷新到主内存中去。
线程B到主内存中去读取线程A之前已更新过的共享变量。
从整体来看,这个过程就是线程A在向线程B发送消息。这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。举例:
- public class JMMTest {
- static int a = 0;// 主内存中的共享变量
- public static void main(String[] args) {
- new Thread() {
- public void run() {
- a = 1;// 线程本地内存中操作共享变量a,并将a=1刷新到猪内存中
- while(true) {// 测试用,为了保持线程运行
- }
- }
- }.start();
-
- new Thread() {
- public void run() {
- System.out.println(a);// 线程到主内存中读取变量a
- while(true) {
- }
- }
- }.start();
- }
- }
两个线程之间的通信过程如下图:
要求程序员都去搞懂重排序以及JMM内存屏障再去编程是不现实的。
JMM提供了简单易懂的happens-before原则,并向程序员保证执行并发程序会遵守happens-before原则。
程序员只需理解happens-before原则,按照happens-before原则写并发代码,就能保证内存可见性和有序性。
JMM的设计
1.程序员对内存模型的使用
程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
JMM向程序员提供的happens-before规则,简单易懂且提供了足够强的内存可见性保证。程序员可以把happens-before规则当做强内存模型看待。
2.编译器和处理器对内存模型的实现
编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
例如这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
- 1.如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。
- 2.如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。
如图,程序员、happens-before、JMM之间的关系:
一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
两个操作可以是单线程或多线程,happens-before解决的就是多线程内存可见性问题。区分数据依赖性和as-if-seial针对单线程。
happens-before原则定义如下:
1)一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before原则规则:
- 1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 2)锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
- 3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
JMM与原子性问题
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,需要通过互斥加锁synchronized和Lock来实现。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。