多线程
2 线程安全问题
非线程安全主要是指多个线程对同一个对象的实例变量进行操作时,会出现值被更改,但是值不同步的情况.
线程安全问题表现为三个方面: 原子性,可见性和有序性。
2.1 原子性
原子(Atomic)就是不可分割的意思. 原子操作的不可分割有两层含义:
-
某个访问(读,写)某个共享变量的操作,从其他线程来看,该操作要么已经执行完毕,要么尚未发生, 即其他线程看不到当前操作的中间结果。
-
访问同一组共享变量的原子操作是不能够交叉的。
比如现实生活中从 ATM 机取款, 对于用户来说,要么操作成功,用户拿到钱, 余额减少了,增加了一条交易记录; 要么没拿到钱,相当于取款操作没有发生。
Java有两种方式实现原子性: 一种是使用锁; 另一种利用处理器的CAS(Compare and Swap)指令.
锁具有排它性,保证共享变量在某一时刻只能被一个线程访问.
CAS 指令直接在硬件(处理器和内存)层次上实现,可看作是硬件锁。
package com.yupeng.threadsafe;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 线程的原子性问题
* @author Yupeng
* @create 2020-12-05 18:12
*/
public class Test01 {
public static void main(String[] args) {
// 启动两个线程,都调用getNum()方法
//MyInt myInt = new MyInt();
MyInt1 myInt = new MyInt1();
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName() + "-->" + myInt.getNum());
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
static class MyInt{
int num;
public int getNum(){
return num++;
}
}
// 线程安全的AtomicInteger类,保证操作的原子性
static class MyInt1{
AtomicInteger num = new AtomicInteger();
public int getNum(){
return num.getAndIncrement();
}
}
}
2.2 可见性
在多线程环境中, 一个线程对某个共享变量进行更新之后 , 后续其他的线程可能无法立即读到这个更新的结果, 这就是线程安全问题的另外一种形式: 可见性(visibility).
如果一个线程对共享变量更新后, 后续访问该变量的其他线程可以读到更新的结果, 称这个线程对共享变量的更新对其他线程可见,否则称这个线程对共享变量的更新对其他线程不可见.
多线程程序中,由于可见性问题,可能会导致其他线程读取到了旧数据(脏数据)。
package com.yupeng.threadsafe;
import java.util.Random;
/**
* 测试线程的可见性
* @author Yupeng
* @create 2020-12-05 18:33
*/
public class Test02 {
public static void main(String[] args) throws InterruptedException {
MyTask myTask = new MyTask();
new Thread(myTask).start();
Thread.sleep(2000);
// 主线程睡眠2秒后,取消子线程
myTask.cancel();
/*
* 可能会出现以下情况:
* 在main线程中调用myTask.cancel()方法后,把myTask对象的toCancel变量修改为true
* 但是子线程看不到main线程对toCancel变量做的修改,导致子线程中的toCancel变量一直为false
* 这样就出现多线程的可见性问题*/
}
static class MyTask implements Runnable{
private boolean toCancel = false;
@Override
public void run() {
while (!toCancel){
if(doSomething()){
}
}
if (toCancel) {
System.out.println("任务被取消");
}else {
System.out.println("任务正常结束");
}
}
private boolean doSomething() {
System.out.println("执行某项任务...");
try {
Thread.sleep(new Random().nextInt(500)); // 模拟执行任务的事件,0-500ms的随机值
} catch (InterruptedException e) {
e.printStackTrace();
}
return true;
}
public void cancel() {
toCancel = true;
System.out.println("收到取消线程的消息");
}
}
}
2.3 有序性
有序性(Ordering)是指在一些情况下,一个处理器上运行的一个线程所执行的内存访问操作,在另外一个处理器运行的其他线程看来是乱序的(Out of Order)。乱序是指内存访问操作的顺序看起来发生了变化 。
2.3.1 重排序
在多核处理器的环境下,编写的顺序结构,实际执行的顺序可能是没有保障的:
编译器可能会改变两个操作的先后顺序;
处理器也可能不会按照目标代码的顺序执行;
一个处理器上执行的多个操作,在其他处理器来看它的顺序与目标代码指定的顺序可能不一样,这种现象称为重排序.
重排序是对内存访问有序操作的一种优化,可以在不影响单线程程序正确的情况下,提升程序的性能.但是,对于多线程程序,其正确性可能受影响,即可能导致线程安全问题。重排序与可见性问题类似,不是必然出现的。
与内存操作顺序有关的几个概念:
- 源代码顺序, 就是源码中指定的内存访问顺序.
- 程序顺序, 处理器上运行的目标代码所指定的内存访问顺序
- 执行顺序,内存访问操作在处理器上的实际执行顺序
- 感知顺序,给定处理器所感知到的该处理器及其他处理器的内存访问操作的顺序
重排序可分为指令重排序与存储子系统重排序两种:
指令重排序主要是由 JIT 编译器和处理器引起的, 程序顺序与执行顺序不一样
存储子系统重排序是由高速缓存和写缓冲器引起的,感知顺序与执行顺序不一致
2.3.2 指令重排序
在源码顺序与程序顺序不一致,或者 程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序(Instruction Reorder).
指令重排是一种动作,确实对指令的顺序做了调整, 重排序的对象指令.
javac 编译器一般不会执行指令重排序, 而 JIT 编译器可能执行指令重排序.
处理器也可能执行指令重排序, 使得执行顺序与程序顺序不一致.
指令重排不会对单线程程序的结果正确性产生影响,但是可能导致多线程程序出现非预期的结果.
2.3.3 存储子系统重排序
存储子系统是指写缓冲器与高速缓存。
高速缓存(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; //W1 ready = true; //W2 | |
while( !ready){} //L3 sout( data ); //L4 |
对于处理器1中的两个写内存操作,处理器2的L3读操作可能没有读到W2的写操作,导致循环不能结束,无法执行L4。
2.3.4 貌似串行语义
JIT 编译器,处理器,存储子系统是按照一定的规则对指令,内存操作的结果进行重排序, 给单线程程序造成一种假象----指令是按照源码的顺序执行的.这种假象称为貌似串行语义,但并不能保证多线程环境程序的正确性。
为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序.如果两个操作(指令)访问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系(Data dependency)。
为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序.如果两个操作(指令)访问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系(Data dependency).
如:
x = 1; y = x + 1;// 后一条语句的操作数包含前一条语句的执行结果
y = x; x = 1;// 先读取 x 变量,再更新 x 变量的值
x = 1; x = 2;// 两条语句同时对一个变量进行写操作
如果不存在数据依赖关系则可能重排序,如:
double price = 45.8;// 第1行代码和第2行代码可能重排序
int quantity = 10;
double sum = price * quantity;
存在控制依赖关系的语句允许重排.一条语句(指令)的执行结果会决定另一条语句(指令)能否被执行,这两条语句(指令)存在控制依赖关系(Control Dependency). 如在 if 语句中允许重排,可能存在处理器先执行 if 代码块,再判断 if 条件是否成立。
2.3.5 保证内存访问的顺序性
可以使用 volatile关键字, synchronized关键字实现有序性。