一、相关概念
线程安全
当多个线程访问某个类时,不管运行环境采用
何种调度方式
或者这些线程如何交替执行,并且在主调代码不需要额外的协同或者同步,这个类都能表现出正确的行为
,那么可以说这个类是线程安全的
概念有点绕,下面会具体说明,继续看其他概念说明
并发和并行
- 并发:指两个或多个事件在同一个时间段内发生
- 并行:指两个或多个事件在同一时刻发生
那么对于我们Java里面的多线程是并发还是并行呢?答案是都有可能,需求看操作系统的CPU类型
通过线程的概念我们知道:线程是CPU调度和分配的基本单位
。
根据CPU是单核还是多核,区分了线程是可以并发还是并行执行
单核多线程
单核多线程指的是单核CPU轮流执行多个线程,通过给每个线程分配CPU时间片来实现,只是因为这个时间片非常短(几十毫秒),所以在用户的角度上感觉上多个线程同时执行
多核多线程
可以把多线程分配给不同的核心处理,其他的线程依旧等待,相当于多个线程并行的执行(当然多核肯定也能并发执行多线程)
线程调度
线程同时运行是一个宏观的概念,如果是单CPU单核的情况,从微观上来说线程只能串行一个个执行的,多个线程按照某种顺序执行,我们把这种情况称之为线程调度。
为什么多线程会有有线程安全问题?
这就涉及到另一个概念:Java内存模型(JMM)
二、Java内存模型
共享变量
Java中,所有实例变量、静态变量和数组元素(这些统称为共享变量)都存储在堆内存中,堆内存在多线程之间共享。而局部变量、方法参数,异常处理器参数是线程私有的。
JMM概念
JMM仅仅是JVM对内存访问的一种规范,是独立于物理机器的一种内存存取模型。(用于屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果)
Java内存模型描述了Java程序中共享变量的访问规则,以及JVM中将变量存储到内存和从内存中读取变量这样的细节。
具体规则如下:
- 所有的共享变量都存储主内存(堆内存)中
- 每个线程都有一个私有的本地内存,里面保存了该线程使用到的共享变量的副本
- 线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存读取
- 不同线程之间无法直接访问其他线程的本地内存,线程中共享变量的传递需要通过主内存实现
内存模型的示意图:
线程工作时,先把共享变量拷贝到自己的本地内存中,线程操作结束再将本地内存中修改后的共享变量值写回到主内存中。此时其他线程就可以到主内存中读取到更新后的共享变量值。
Java内存模型通过控制主内存与每个线程本地内存的交互,来为Java程序提供内存可见性保证(注意仅仅是提供
不是保证
)。
三、线程安全
问题产生
为什么会有线程安全问题?
是因为对共享变量
的竞争访问
因为共享变量无论是读还是写操作都不是直接操作主内存,都需要分成两步。例如:写操作需求先写入线程的本地内存(写1),再由内存模型从本地内存中同步到主内存中(写2);读操作也是类似。
而且在多线程中这两步是可以穿插进行,意思就是例如写1,写2虽然是顺序执行,但是中间可以穿插其他线程的写或者读操作,当然读操作也是一样的,这就产生了数据的竞争。
所以多线程操作共享变量时,可能本地内存中数据没有及时刷新到主内存中,我们读到的还是没修改之前的数据,这就发生了线程安全问题。
问题解决
如何解决线程安全问题?这就需要保证JMM具备如下三种特性,这也是JMM的核心
原子性
原子 Atomic 意指不可分割,也就是作为一个整体,要么全部执行,要么不会执行
对于共享变量的访问操作,如果对除了当前线程以外的如何线程来说,都是不可分割的,那么就是具有原子性。
如何保证原子性?
- 使用
锁机制
,锁具有排他性,它能够保证一个共享变量在任意一个时刻仅仅被一个线程访问,这就消除了竞争 - 使用CAS指令(compare-and-swap)
(Java种基本类型以及引用类型的写操作都是原子的,除了long和double,因为其64位长度,如果是在32位机器上,写操作可能分成两个步骤,分别处理高低32位,这就打破了原子性,可能出现数据安全问题)
可见性
一个线程对共享变量的修改之后,能及时被另一个线程读取到该修改结果,那么就称这个线程对该共享变量的更新对其他线程可见
如果一个线程对共享变量做出了修改,而另外的线程却并没有读取到最新的结果,这是有问题的,这就需要保证可见性
如何保证内存可见性?
-
使用volatile关键字保证共享变量的新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。
-
使用
锁机制
,通过如下两条规则保证可见性:- 如果对变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load伙assign操作初始化变量的值
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)
有序性
关于有序性,首先要说下重排序的概念,如果没有重排序,那也没有有序性的问题
重排序是JVM对内存访问的一种优化,可以在不影响单线程正确性的前提下进行一定的调整,进入提高程序的性能。但是多线程场景下就可能出现问题
重排序对多线程的影响 举例说明:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 2; //1
flag = true;//2
}
public void reader() {
if (flag) { //3
int i = 10 / a;//4
}
}
}
如果现在现在有两个线程,线程A和线程B。线程A执行writer方法,线程B执行reader方法。假如现在执行操作3时,flag已经被标记为true了,执行操作4时a是否一定已经赋值了呢?
答案是不一定
因为操作1和操作2没有依赖关系,虽然源码的顺序是先1后2,但是编译器和处理器可以对这两操作重排序。线程1先将flag设置为true,这时候处理器切换到线程2判断flag是true,开始执行操作4,这时候程序就会发生错误,破坏了多线程程序的语义。
如何保证有序性?
- 使用volatile关键字来保证
一定的
有序性 - 使用
锁机制
,保证每个时刻只有一个线程执行同步代码,相当于让线程顺序执行同步代码,自如就保证了有序性。
如何界定指令会不会重排序:Java内存模型通过
happens-before
原则来确定两个操作的顺序。如果两个操作次序无法从happens-before
原则推导出来,那么它们就不能保证按照代码顺序有序执行,虚拟机可以随意对他进行重排序。
四、总结
总结来说多线程的线程安全问题通过Java的volatile
关键字(一定程度上上解决)或者锁机制
来解决。
关于volatile
关键字和锁机制
(synchronized和lock)的使用和原理详解下篇文章