前景提要
并发是什么?
在了解并发的三大特征之前,我们需要先知道并发是什么。
并发是CPU处理线程任务的一种特点。在CPU的一个核心中,可以调度多个线程在这个核心中快速交替的执行。这种交替执行的速率对于我们来说是肉眼不可见的。
总的来说,并发是基于CPU的一个核心观察到的一种任务执行的特点。
线程安全是什么?
接着我们来了解下线程安全。
线程安全是指,当有多个线程在对某个共享资源进行操作时,比如读和写的操作,这个共享资源如果依然能保持正确的状态,那么就可以说明这个共享变量是线程安全的。举例来说,比如有2个线程对某个共享变量进行累加,其中有一个线程对这个共享变量累加了1,那么另一个线程如果能够在这个基础上进行累加,就说明这个共享资源是线程安全的。因为线程的执行都是基于正确状态的变量上进行的。
总的来说,线程安全就是对共享资源的一种评价,评价这个共享资源在被多个线程操作时是否依然能够保持正确的状态。
并发的三大特征意味着什么?
搞明白了并发和线程安全的概念后,接下来我们就可以开始认识并发的三大特征了。
并发的三大特征是一种能够保证在并发情况下,共享资源依然能够保持线程安全的措施。这些措施分别为:
- 操作原子性。
- 内存可见性。
- 指令有序性。
接下来一个个解释下。
原子性
什么是原子性?
原子性是指,线程在操作某个共享资源时,这个操作不可中断。要么一次性被全部执行成功,要么就全部都执行失败。放到硬件层面上来说的话,就是如果一个线程在CPU的一个核心中被处理,那么根据原子性的要求,这个线程的运行就不应该被暂停。它应该一直被执行直到整个操作都执行结束。
注意这里说的操作可能是由多个子操作合成的,比如i++,它就含有3个操作:
- 线程从主内存中读取i的值,然后把它存入到当前线程的工作内存中;
- 线程把工作内存中的i执行加1操作;
- 线程再把i的值更新到主内存中。
出于在并发环境下对共享资源线程安全的要求,则基于操作的原子性,这3个操作应该是一个不可切割的整体。它们要么全部都执行成功,要么就全部都执行失败,从而避免i++这个操作存在竞态条件。
总的来说,如果想要保证某个操作的线程安全,那么就应该保证这个操作的原子性,从而避免竞态条件的存在,使线程基于错误状态下的变量执行操作,导致错误的计算结果。
保证原子性的措施
接下来说明一下有哪些方式可以保证线程操作的原子性。
以下是一些可用的工具:
- concurrent.atomic包下的工具类。如果是对基本数据类型做累加操作,比如i的数据类型是Long,然后i++,那么就可以使用这个工具类。它内部通过一种无锁的机制,从而保证操作的原子性。
- Lock相关的工具类。
- synchronized关键字。
内存可见性
什么是内存可见性?
在了解内存可见性之前,需要先知道一下JVM内存模型中主内存和工作内存的概念。
工作内存是每个线程所独有的,而主内存则是被所有线程共享的。线程对共享资源的操作,都是在自己的工作内存上进行的,包括读和写。
线程与工作内存的协作流程是,它会先把共享变量从主内存中加载到自己的工作内存上,然后之后线程的读和写就都会是基于工作内存上的这个变量副本进行的。示意图如下:
举一个例子,假如有一个变量X,初始值为1,现在有两个线程的任务都是对这个变量X进行累加,那么这时候,这两个线程首先会分别从主内存中加载这个共享变量X到工作内存中,然后再分别在自己的工作内存上对这个变量X的副本进行累加。
假设现在线程1先对X执行了一次累加,线程1工作内存上的这个X变成了2,接着,由于并发,线程2被CPU调度去执行变量X的累加,那么此时如果这个变量X是线程安全的,则线程2的操作就应该是建立在线程1的基础上的,但由于X的更新没有通知线程2,那么线程2的操作就会基于一个无效状态的变量上进行。即线程2得到的结果将也会是1,但正确的结果应该是1+1=2。
看到这里我们可以知道,当共享资源的状态发生变化时,如果没有一个合适的同步机制来通知线程共享资源的状态发生了变化,那么在这种缺乏同步机制的情况下,由于数据的不一致,线程基于无效状态的资源上进行操作,显然就会导致程序的计算出现偏差。
保证内存可见性的措施
接下来说明一下有哪些方式可以保证内存的可见性。
以下是一些可用的工具:
- volatile关键字。
- synchronized关键字。
- Lock相关的工具类。
有序性
什么是有序性?
在介绍有序性之前,我们需要先知道一下指令重排序的概念。指令重排序是JVM对程序执行性能的一个优化。它通过调整指令之间的顺序,从而保证程序的执行可以更加高效。对于重排序具体背后的机制,可以查阅资料了解下,这里就简单的做个介绍。
总的来说,重排序就是将多个指令之间的顺序做了个调换。
下面用一个例子来举例说明一下重排序是如何破坏线程安全的:
class SharedResource {
private int sharedValue = 0;
private boolean flag = false;
public void writeData() {
// 原有的逻辑是先赋值,后操作flag = true,线程基于flag的状态判断是否可以对变量进行读取
// 但由于重排序,则会导致先操作flag = true,然后再赋值变量,则此时其它线程就会读取到一个异常的变量值,
sharedValue = 42;
flag = true;
//重排序后的代码如下:
//flag = true;
//sharedValue = 42;
}
public int readData() {
if (flag) {
// 由于重排序,此时flag的状态是无效的
// 所以会导致这块代码的执行是基于一个无效状态的共享变量进行的
return sharedValue;
}
return 0;
}
}
上面的代码表明,writeData()原本的逻辑是先对变量sharedValue赋值,然后设置flag为true使readData()可以通过变量flag来判断是否可以读取变量sharedValue。
但由于重排序,使writeData()原本的逻辑发生了颠倒,变成了先设置flag为true,然后再赋值。这就有可能会导致当writeData()方法刚执行完flag = true操作后,在正要执行sharedValue = 42时,另一些线程紧接着跑去执行了readData()方法,那么由于flag = true这个无效的状态,于是读取了一个错误的变量值,也就是0,但实际上正确结果应该是42。
所以看到这里我们可以知道,由于重排序,在并发情况下会使共享资源出现线程不安全的情况,即线程会基于无效状态的共享变量进行操作,从而产生不正确的行为。
保证有序性的措施
接下来说明一下有哪些方式可以保证指令的有序性。
以下是一些可用的工具:
- volatile关键字。
- synchronized关键字。
- Lock相关的工具类。