上一节我们总结了java多线程,这一节我们看看多线程安全问题。
一. 内存模型
1. 现代计算机内存模型
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。也就是当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。这是计算机硬件对于主存数据的访问方式。
2. Java内存模型(JMM)
前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。众所周知,Java程序是需要运行在Java虚拟机上面的,Java内存模型即Java Memory Model,简称JMM。Java内存模型(JMM)定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作规范。在硬件内存模型中,各种CPU架构的实现是不尽相同的,Java作为跨平台的语言,JMM用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能够达到一致的内存访问效果。JMM是一个抽象的概念,并不是物理上的内存划分。
- 所有的变量都存储在主内存中。
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)。
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
二、多线程导致的安全问题
1. 产生多线程安全问题的原因。
要产生多线程安全问题需要同时满足以下两个条件:
- 多个线程在操作共享的数据。
- 操作共享数据的线程代码有多条。
当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行。导致共享数据的错误。
线程安全问题体现在三个方面:原子性、可见性、有序性
三、原子性、可见性、有序性
1. 原子性(Atomic):
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。对于别的线程而言,他要么看到的是该线程还没有执行的情况,要么就是看到了线程执行后的情况,不会出现执行一半的场景,简言之,其他线程永远不会看到中间结果。
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
- 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。是保证原子性的。
- 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
- 语句3 x++和 语句4 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。因此不是原子性操作。
只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2. 可见性:
是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
例如:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
println(j);//输出 0
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
Java提供了volatile关键字来保证可见性。。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,会去主存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
下面用一段代码来做测试:
public class VisibilityTest extends Thread{
//如果没有关键字volatile,会发现这个线程停不掉,可自行测试。
private volatile boolean stop;
public void run() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("finish loop,i=" + i);
}
public void stopIt() {
stop = true;
}
public boolean getStop() {
return stop;
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest visibilityTest = new VisibilityTest();
visibilityTest.start();
visibilityTest.sleep(1000);
visibilityTest.stopIt();
System.out.println(visibilityTest.getStop());
}
}
除了volatile还有两种解决可见性问题的方法:
final关键字,能够解决的原因:final常量会进常量区(以前的方法区,现在的Metaspace),常量区是线程安全的,线程安全就是一个线程占有着此资源的时候,其他线程不能占有,所以可想而知,只要A线程在对此资源做任何操作,B线程都是等待着的,当A释放了,B才去jvm拿值,这样也算保证了可见性。
synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3. 有序性:
按照我们日常的思维,程序的执行过程是从上至下一行一行执行的,就是说按照代码的顺序来执行。那么JVM在实际中一定会这样吗? 答案是否定的。
a = 5; //1
b = 20; //2
c = a + b; //3
编译器优化后可能变成
b = 20; //1
a = 5; //2
c = a + b; //3
在这个例子中,编译器调整了语句的顺序(指令重排序(Instruction Reorder)),但是不影响程序的最终结果。
指令重排序
Java语言是运行在 Java 自带的 JVM(Java Virtual Machine) 环境中,在JVM环境中源代码(.class)的执行顺序与程序的执行顺序(runtime)不一致,或者程序执行顺序与编译器执行顺序不一致的情况下,我们就称程序执行过程中发生了重排序。
处理器为了提高程序运行效率,可能会对输入代码进行优化。它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的(单线程情况下)。
指令重排序导致出现有序性问题
编译器的指令重排序,这种修改可以优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,但最终结果看起来没什么变化。在单线程程序里面没问题。但是在多多线程环境下就可能会出现有序性问题。有序性问题,指的是在多线程环境下,由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题。
例如:
单例模式的双重检查锁
public class SingletonDemo {
private /** volatile*/ static SingletonDemo instance;
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (null == instance) {
instance = new SingletonDemo();
}
}
}
return instance;
}
}
这里为什么不能缺少volatile关键字呢?主要在于instance = new Instance()这个语句实际上包含了三个操作:
1)分配对象内存空间;
2)初始化对象;
3)设置instance指向刚分配的内存地址
前文中提到一个线程内看其他线程中的指令执行顺序可能是乱序的,有可能是如下顺序:
1)分配对象内存;
2)设置instance指向刚分配的内存;
3)初始化对象
那么其他线程可能取得的是没有初始化的对象,出现诡异的并发bug。
在java里可以通过volatile来保证一定的有序性,另外也可以通过synchroized和lock来保证有序性。synchroized和lock是保证每个时刻是只有一个线程执行同步代码,相当于是让线程顺序执行代码从而保证有序性。Java具备一些先天的有序性(不需要任何手段就能保证有序性),即happens-before原则(先行发生原则)。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证有序性。虚拟机可以随意的对它们进行重排序。总结起来:在JMM中,提供了以下三种方式来保证有序性:volatile机制,synchronized机制,happens-before原则
happens-before原则
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。 准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、 循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、 Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
这边举个列子来帮助理解happens-before原则:
private int value=0;
pubilc void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}
假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用getValue方法,那么B的返回值是多少?
对照着hp原则,上面的操作不满下面的任何条件:
- 不是同一个线程,所以不涉及:程序次序规则;
- 不涉及同步,所以不涉及:管程锁定规则;
- 没有volatile关键字,所以不涉及:volatile变量规则
- 没有线程的启动,中断,终止,所以不涉及:线程启动规则,线程终止规则,线程中断规则
- 没有对象的创建于终结,所以不涉及:对象终结规则
- 更没有涉及到传递性
所以一条规则都不满足,尽管线程A在时间上与线程B具有先后顺序,但是,却并不满足happens-before原则,也就是有序性并不会保障,所以线程B的数据获取是不安全的!!
时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。只有真正满足了happens-before原则,才能保障安全。
如果不能满足happens-before原则,就需要使用下面的synchronized机制和volatile机制机制来保证有序性。
volatile解决有序性问题
通过volatile关键字来保证一定的“有序性”,volatile的底层是使用内存屏障来保证有序性的。写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
内存屏障有两个能力:
- 就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。
在Java并发编程中,如果要保证代码的安全性,则必须保证代码的原子性、可见性和有序性。