一、什么是线程安全问题
定义:指当多个线程同时访问共享资源(如变量、对象、文件等)时,由于线程执行顺序的不确定性或资源竞争,可能导致数据不一致、程序崩溃或逻辑错误的现象。
现代计算机体系下的CPU、内存、I/O设备的速度是有极大差异的,程序运行时,内存读写和IO设备的读写速度远远跟不上CPU计算的速度,这导致CPU性能过剩,或者说,无法完全发挥CPU性能的问题。
当线程因为等待IO读写而陷入阻塞时,该线程是不需要CPU的,因为此时是由其他硬件设备进行读写操作的。
为了尽量减少这种速度差距带来的CPU浪费,现代计算机体系中的CPU硬件、操作系统、编译器都提出了一系列方法来试图缓和这个矛盾。包括:
- CPU增加了高速缓存,以均衡与内存的速度差异。
- 编译器和CPU优化了指令执行次序,让指令尽量并行执行,从而提高高速缓存的利用率。
- 操作系统增加了进程、线程,更好地分时复用CPU来提高并发效率,进而均衡CPU与I/O设备的速度差异。
上述这些优化在提高了CPU利用效率的同时,也带来了可见性,有序性和原子性这三个问题。在单线程模式下,它们不会出现或者不会带来问题,但在多线程模式下,它们可能会导致线程出现各种不安全的问题。它们是Java多线程开发必须要解决的三个问题,而这三个问题也就是我们常说的线程安全问题。
二、并发编程的三个特性
原⼦性
⼀个操作或者多个操作,要么全部执⾏并且执⾏的过程不会被任何因素打断,要么就都不执⾏。
原⼦性是拒绝多线程操作的,不论是多核还是单核,具有原⼦性的量,同⼀时刻只能有⼀个线程来对它进⾏操作。 简⽽⾔之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原⼦性。例如 a=1
是原⼦性操作,但是 a++和
a +=1
就不是原⼦性操作。
Java
中的原⼦性操作包括:
基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原⼦性操作;
所有引⽤reference
的赋值操作;
java.concurrent.Atomic.* 包中所有类的⼀切操作。
可⻅性
指当多个线程访问同⼀个变量时,⼀个线程修改了这个变量的值,其他线程能够⽴即看得到修改的值。
在多线程环境下,⼀个线程对共享变量的操作对其他线程是不可⻅的。Java
提供了
volatile
来保证可⻅性,当⼀个变量被volatile
修饰后,表示着线程本地内存⽆效,当⼀个线程修改共享变量后他会⽴即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。synchronize
和
Lock
都可以保证可⻅性。
synchronized
和
Lock 能保证同⼀时刻只有⼀个线程获取锁然后执⾏同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因 此可以保证可⻅性。
有序性
即程序执⾏的顺序按照代码的先后顺序执⾏。
Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在⼀个线程中观察另⼀个线程,所有操作都是⽆序的。前半句是指“
线程内表现为串⾏语义
”
,后半句是指
“
指令重排序
”
现象和
“
⼯作内存主主 内存同步延迟”
现象。在Java
内存模型中,为了效率是允许编译器和处理器对指令进⾏重排序,当然重排序不会影响单线程的运⾏结果,但是对多线程会有影响。Java
提供
volatile
来保证⼀定的有序性。最著名的例⼦就是单例模式⾥⾯的
DCL
(双重检查锁)。另外,可以通过synchronized
和
Lock
来保证有序性,
synchronized
和
Lock
保证每个时刻是有⼀个线程 执⾏同步代码,相当于是让线程顺序执⾏同步代码,⾃然就保证了有序性。
为了让⼤家更好理解可⻅性和有序性,这个就不得不了解
“
内存模型
”
、
“
重排序
”
和
“
内存屏障
”
,因为这三个概 念和他们关系⾮常密切。
内存模型
JMM
决定⼀个线程对共享变量的写⼊何时对另⼀个线程可⻅,
JMM
定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)
中,每个线程都有⼀个私有的本地内存(
Local Memory
),本地内存保存了被该线 程使⽤到的主内存的副本拷⻉,线程对变量的所有操作都必须在⼯作内存中进⾏,⽽不能直接读写主内存中的变量。
对于普通的共享变量来讲,线程
A
将其修改为某个值发⽣在线程
A
的本地内存中,此时还未同步到主内存中去;⽽线 程B
已经缓存了该变量的旧值,所以就导致了共享变量值的不⼀致。解决这种共享变量在多线程模型中的不可⻅性 问题,可以使⽤volatile
、
synchronized
、
final
等,
此时
A
、
B
的通信过程如下:
⾸先,线程
A
把本地内存
A
中更新过的共享变量刷新到主内存中去;
然后,线程
B
到主内存中去读取线程
A
之前已更新过的共享变量。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可⻅性保证,需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不⼀定就真实的对应cpu缓存和物理内存。总结⼀句话,内存模型JMM控制多线程对共享变量的可⻅性!!!
重排序
重排序是指编译器和处理器为了优化程序性能⽽对指令序列进⾏排序的⼀种⼿段。
重排序需要遵守⼀定规则:
重排序操作不会对存在数据依赖关系的操作进⾏重排序。⽐如:
a=1;b=a;
这个指令序列,由于第⼆个操作依赖于第⼀个操作,所以在编译时和处理器运⾏时这两个操作不会被重排序。
重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执⾏结果不能被改变。 ⽐如:
a=1;b=2;c=a+b
这三个操作,第⼀步(
a=1)
和第⼆步
(b=2)
由于不存在数据依赖关系, 所以可能会发⽣重排 序,但是c=a+b
这个操作是不会被重排序的,因为需要保证最终的结果⼀定是
c=a+b=3
。
重排序在单线程下⼀定能保证结果的正确性,但是在多线程环境下,可能发⽣重排序,影响结果,请看下⾯的示例
代码:
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
System.out.println(i);
}
}
}
内存屏障
为了保证内存可⻅性,可以通过
volatile
、
final
等修饰变量,
java
编译器在⽣成指令序列的适当位置会插⼊内存屏障 指令来禁⽌特定类型的处理器重排序。内存屏障主要有3
个功能: 它确保指令重排序时不会把其后⾯的指令排到内存屏障之前的位置,也不会把前⾯的指令排到内存屏障的后
⾯;即在执⾏到内存屏障这句指令时,在它前⾯的操作已经全部完成;
它会强制将对缓存的修改操作⽴即写⼊主存;
如果是写操作,它会导致其他
CPU
中对应的缓存⾏⽆效。
volatile boolean flag = false;
int a= 0;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
System.out.println(i);
}
}