什么是线程安全问题

线程安全问题不是说线程不安全,而是多个线程之间交错操作有可能导致数据异常。就比如两个线程同时对一个数据进行操作,不能保证最后得到是数据是正确的,这就出现了线程安全问题。

什么是Java内存模型

在这之前,先了解下Java内存模型是什么,这能帮助我们更好的理解线程的安全性问题。

669ee2aec526

Java内存模型

其实线程每次对数据操作,这些数据都是当前线程工作内存中的共享变量副本,并不是直接在主内存操作。每条线程都有自己的工作内存。

如果线程对变量的操作没有刷回主内存的话,仅仅改变了自己的工作内存的变量副本,那么对其他线程来说是不可见的,不知道这个变量发生变化。而如果一个变量没有读取内存中新值,而是使用旧的值去做后续操作的话,会得到一个错误的结果,这里体现出线程安全问题 -- 可见性

什么是Java线程调度

在任意时刻,CPU 只能执行一条机器指令,每个线程只有获取到 CPU 的使用权后,才可以执行指令。也就是在任意时刻,只有一个线程占用 CPU,处于运行的状态。

多线程并发运行实际上是指多个线程轮流获取 CPU 使用权,分别执行各自的任务。线程的调度由 JVM 负责,线程的调度是按照特定的机制为多个线程分配 CPU 的使用权。线程调度模型分为两类:分时调度模型和抢占式调度模型。

1、分时调度模型:让所有线程轮流获取 CPU 使用权,并且平均分配每个线程占用 CPU 的时间片。

2、抢占式调度模型:JVM 采用的是抢占式调度模型,也就是先让优先级高的线程占用 CPU,如果线程的优先级都一样,那就随机选择一个线程,并让该线程占用 CPU。也就是如果我们同时启动多个线程,并不能保证它们能轮流获取到均等的时间片。如果我们的程序想干预线程的调度过程,最简单的办法就是给每个线程设定一个优先级。

什么是数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:

1、写后读(a = 1;b = a;),写一个变量之后,再读这个位置。

2、写后写 (a = 1;a = 2;),写一个变量之后,再写这个变量。

3、读后写(a = b;b = 1;),读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。

如何保证线程安全

1、原子性

2、可见性

3、有序性

要实现线程安全就要保证上面说到的原子性、可见性和有序性。

原子性

在讲原子性之前先来看看这个例子:

a++,对于共享变量a的操作,实际上会执行三个步骤,

1.读取变量a的值

2.a的值+1

3.将值赋予变量a

这三个操作中任何一个操作过程中,a的值被人篡改,那么都会出现我们不希望出现的结果。在多线程中,a的值可能被其他线程修改,导致线程不安全。为了保证线程安全,必须把这三个步骤当成不可分割的一个整体操作,在其他线程看来,该操作只有未开始和结束的两种状态,不知道中间发生什么。这就体现了原子性。

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的。

注:基本数据类型的变量和赋值操作类似 i = 0 的操作

可见性

刚才讲到Java内存模型时,有提及到可见性。可见性是指一个线程对共享变量的更新,对于其他读取该变量的线程是否可见。怎么让修改完的数据可见呢?举个例子

① 将工作内存1中的共享变量的改变更新到主内存中

② 将主内存中最新的共享变量的变化更新到工作内存2中

Java提供了volatile关键字来保证可见性。通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。举个例子

class ReorderExample {

int a = 0;

boolean flag = false;

public void writer() {

a = 1; // 1

flag = true; // 2

}

public void reader() {

if (flag) { // 3

int i = a * a; // 4

}

}

}

思考一下,flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?

执行顺序是:2 -> 3 -> 4 -> 1 (这是完全存在并且合理的一种顺序,如果你不能理解,请先了解CPU是如何对多个线程进行时间分配的)

操作3和操作4重排序后,因为操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。

happens-before原则(先行发生原则)

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作。

volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。

传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。

对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

总结

要实现线程安全就要保证上面说到的原子性、可见性和有序性。

注:提及到的volatile、synchronized和Lock后续写一篇关于锁的文章Android多线程之锁

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐