这段笔记是参照b站教程BV1Rv411y7MU整理而来的,用于个人备忘以便复习,需要的朋友可以自取。
线程安全问题
- 非线程安全主要指多个线程对一个对象的实例变量进行操作的时候,会出现值被更改,值不同步得问题。
- 线程安全表现为三个方面:原子性、可见性和有序性。
1. 原子性
原子性(Atomic)就是不可分割得意思。
原子操作的不可分割有两层含义:
- 访问 (读,写)某个共享变量的操作从其他线程来看,这个操作要么已经执行完毕,要么尚未发生。
- 访问同一组共享变量的原子操作是不能够交错的。
Java实现原子性的两种方式:
- 利用锁
- 利用处理器的CAS(Compare and Swap)指令
锁具有排他性,保证共享变量在某一时刻只能被一个线程访问。
CAS指令直接在硬件(处理器和内存)层次上实现,看作是一种硬件锁。
public class main {
public static void main(String[]args){
//启动两个线程,不断调用getNum()方法
MyInt myInt = new MyInt();
for(int i=1;i<=2;i++){
new Thread(new Runnable(){
@Override
public void run() {
while(true){
System.out.println(Thread.currentThread().getName()+" -> "+myInt.getNum());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
public static class MyInt{
int num;
public int getNum(){
return num++;
//自增操作流程:读取数据num -> 数据改变+1 -> 返回新数据num
}
}
}
上述代码中由于两个线程不断访问myInt.getNum()
,导致线程安全问题。由于线程启动时并不保证相同,所以导致线程随意修改num值,打印的值会出现相等。
在Java中提供了一个线程安全的AtomicInteger类,保证了线程的原子操作。
只需对MyInt进行一些修改:
public static class MyInt{
AtomicInteger num = new AtomicInteger();
public int getNum(){
return num.getAndIncrement();//类似num++
}
}
2. 可见性
在多线程环境中,一个线程对某个变量进行了更新之后,后续其他进程可能无法立刻读到这个结果,这既是线程安全问题的另一种形式:可见性(visibility)。
如果一个线程对共享变量更新后,后续访问该变量的其他线程可以读到更新的结果,就称这个线程对共享变量的更新对其他线程可见,反之称这个线程对共享变量的更新对其他线程不可见。
多线程程序因为可见性问题可能会导致其他线程读取到旧数据(脏数据)。
public class main {
public static void main(String[]args) throws InterruptedException {
MyTask myTask = new MyTask();
new Thread(myTask).start();
//暂停一秒
Thread.sleep(1000);
//主线程1秒后取消此线程
myTask.cancel();
/**
* 可能在main线程中调用cancel()方法,把task对象的toCancel变量修改为true
* 可能存在子线程看不见main线程对toCancel的修改,在子线程中toCancel变量一直为false
*/
}
public static class MyTask implements Runnable{
private boolean toCancel = false;
private boolean doSomething() throws InterruptedException {
System.out.println("Do something...");
Thread.sleep((long) (Math.random()*1000));
return true;
}
public void cancel(){
this.toCancel = true;
System.out.println("收到 取消线程的消息");
}
@Override
public void run() {
int num=0;
while (!toCancel){
try {
if(doSomething()){
num++;
System.out.println(num);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(toCancel){
System.out.println("任务被取消");
}else {
System.out.println("任务正常结束");
}
}
}
}
导致子线程看不见主线程main对数据修改的原因可能有以下几点:
- JIT即时编译器 ***可能***会对run()方法中的while循环进行优化:
if(toCancel){
while(true){
if(doSomething){
...
}
}
}
- 可能和计算机存储系统有关,假设有两个CPU内核运行main与子线程,一个CPU内核无法立刻读取另一个CPU内核的数据。
3. 有序性
有序性(Ordering) 是指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另一个处理器运行的其他线程看来是乱序的(Out of Order)。
乱序是指内存访问操作的顺序看起来发生了变化。
在多核处理器的环境下,编写的顺序结构,这种操作执行的顺序可能是没有保障的:
- 编译器可能会改变两个操作的先后顺序。
- 处理器可能不会按照目标代码的顺序执行。
这个处理器上执行的多个操作在其他处理器上来看,他的顺序与目标代码指定的顺序可能不一样,这种现象称之为重排序。
重排序是对内存访问操作的一种优化,可以在不影响单线程程序正确的情况下提升程序的性能。但是可能会对多线程程序的正确性产生影响,即可能导致线程安全问题。
重排序与可见性问题类似,不是必然出现的。
与内存操作顺序有关的几个概念:
- 源代码顺序,就是源码中指定的内存访问顺序。
- 程序顺序,处理器上运行的目标代码所指定的内存访问顺序。
- 执行顺序,内存访问操作在处理器上的实际执行顺序。
- 感知顺序,给定处理器所感知到的该处理器和其他处理器的内存访问操作顺序。
可以把重排序分为指令重排序和存储子系统
- 指令重排序主要是由JIT、处理器引起的,指程序顺序与执行顺序不一样。
- 存储子系统重排序是由高速缓存、写缓冲器引起的,感知顺序与执行顺序不一致。
3.1 指令重排序
源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序(Instruction Reorder)。
指令重排是一种动作,确实对指令的顺序做了调整,重排序的对象指令。
Java编译器一般不会对执行指令进行重排序,而JIT及时编译器可能会发生指令重排序。
处理器也可能会执行指令重排序,使得执行顺序和程序顺序不一致。
指令重排不会对单线程程序的结果正确性产生影响,但是可能会导致多线程程序出现非预期结果。
3.2 存储系统重排序
存储子系统是指写缓冲器与高速缓存。
高速缓存(Cache)是CPU中为了匹配与主内存处理速度不匹配而设计的一个高速缓存。
写缓冲器(Store buffer,Write buffer)用来提高写高速缓存操作的效率。
即使处理器严格按照程序顺序执行两个内存访问操作,在存储子系统的作用下,其他处理器对这两个操作的感知顺序与程序顺序不一致,即这两个操作的顺序看起来像是发生了变化,这种现象称为存储子系统重排序。
存储子系统重排序并没有真正的对指令执行顺序进行调整,而是造成了一种指令顺序被调整的现象。
存储子系统重排序对象是内存操作的结果。
从处理器角度来看,读内存就是从指定的RAM地址中加载数据到寄存器,成为Load操作;写内存就是把数据存储到指定的地址表示的RAM存储单元,成为Store操作。
内存重排序可能有以下四种可能:
- LoadLoad重排序,一个处理器先后执行L1和L2,其他处理器对两个内存操作的感知顺序可能是L2 -> L1。
- StoreStore重排序,一个处理器先后执行W1和W2,其他处理器对两个内存操作的感知顺序可能是W2 -> W1。
- LoadStore重排序,一个处理器先后执行L1和W1,其他处理器对两个内存操作的感知顺序可能是W1 -> L1。
- StoreLoad重排序,一个处理器先后执行W1和L1,其他处理器对两个内存操作的感知顺序可能是L1 -> W1。
内存重排序与具体的处理器微架构有关,不同架构的处理器所允许的内存重排序不同。
内存重排序可能会导致线程安全问题。假设有两个共享变量
int data = 0; boolean ready = false;
处理器1 | 处理器2 |
---|---|
data = 1;//S1 ready = true;//S2 | |
while(!ready){} //L3 sout(data);//L4 |
上述表格中可能处理器1对ready的操作重排序后处理器2没有读到,所以导致while一直循环所以不会打印data。
貌似串行语义
JIT及时编译器、处理器和存储子系统是按照一定规则对指令、内存操作的结果进行重排序的,给单线程程序造成一种假象----指令是按照源码顺序执行的。这种假象成为貌似串行语义 。
貌似串行语义并不能保证多线程环境程序的准确性。
为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。如果两个操作或者指令访问同一个变量,且其中一个操作或指令为写操作,那么这两个操作之间就存在数据依赖关系(Data dependency)。
如:
/*存在数据依赖的语句不可能发生重排序*/
x = 1;
y = x + 1;//后一条语句的操作数包含前一条语句的执行结果
y = x;
x = 1;//先读取x变量在更新x的值,也存在数据依赖关系。
x = 1;
x = 2;//两个语句同时对一个变量进行写操作,存在数据依赖关系。
如果不存在数据依赖关系则可能发生重排序,如:
double price = 45.8;
int quantity = 10;//前两条语句不存在数据依赖关系,则可能出现重排序
double sum = price * quantity;//但是前两条语句和本条语句之间存在数据依赖关系,不可能发生重排序,即强两条语句一定在本语句之前。
存在控制依赖关系的语句允许重排序,一条语句的执行结果可以决定另一条语句是否执行,则存在控制依赖关系(Control Dependency)。如在if语句中允许重排就可能存在处理器先执行if代码块,再判断if条件是否成立。
保证内存访问顺序
可以使用volatile关键字、synchronized关键字来实现有序性。
Java内存模型
![](https://i-blog.csdnimg.cn/blog_migrate/6092c874fd557caf7d80c59f88dc2acb.png)
- 每个线程都有独立的栈空间
- 每个线程都可以独立访问内存
- 计算机的CPU不直接从主内存中读取数据,CPU读取数据时,先把主内存的数据读取到Cache缓存中,把Cache中的数据读取到Register寄存器中。
- JVM中的共享的数据可能会被分配到Register寄存器中,每个CPU都有自己的Register寄存器,一个CPU不能读取其他CPU寄存器中的内容。如果两个线程分别运行在不同的CPU上,而共享数据被分配到不同寄存器上,就会产生可见性问题。
- 即使JVM中的共享数据分配到主内存中,也不能保证数据的可见性,CPU不直接对内存进行访问,而是通过Cache高速缓存进行的,一个处理器上运行的线程对数据的更新可能只是更新到处理器的写缓冲器(Store Buffer),还没有到达Cache中,更不用说主内存。另外一个处理器不能读取到改处理器写缓冲器上的内容,会产生运行在另外一个处理器上的线程无法看到该处理器对共享数据的更新。
- 一个处理器的高速缓存不能直接读取另外一个处理器的Cache,但是一个处理器可以直接通过缓存一致性协议(Cache Coherence Protocol)来读取其他处理器缓存中的数据,并将读取到的数据更新到该处理器的Cache中。这个过程称之为缓存同步。缓存同步使得一个处理器上的线程可以读取到另外一个处理器上运行的线程对共享数据所作的操作,即保障了可见性,为了保障可见性,必须使一个处理器对共享数据的更新最终被写入该处理器的Cache中,这个过程称为冲刷处理器缓存。
Java内存模型可以抽象为:
规定:
- 每个线程之间的共享数据都存储在主内存之中。
- 每个线程都有一个私有的本地内存(工作内存),线程的工作内存是抽象概念,不是真实存在的,它覆盖写缓冲器、寄存器和其他硬件的优化。
- 每个线程从主内存中把数据读取到本地工作内存中,在工作内存中保存共享数据的副本线程在自己的工作内存中处理数据,仅对当前线程可见,对其他线程不可见。