非线程安全主要是指多个线程对同一个对象的实例进行操作时,会出现值被更改,值不同步的情况。
线程安全问题主要表现在三个方面:原子性,可见性和有序性。
原子(Atomic)就是不可分割的意思。原子操作的不可分割有两层含义:
1)访问(读,写)某个共享变量的操作从其他线程来看,该操作要么已经执行完毕,要么尚未发生,即其他线程显示到当前操作中间结果;
2)访问同一组共享变量的原子操作是不能够交错的,如现实生活中从ATM机取款,对于用户来说,要么操作成功,用户拿到钱,余额减少了,增加了一条交易记录;要么没拿到钱,相当于取款操作没有发生。
Java有两种方式实现原子性:一种是使用锁;另一种利用处理器的CAS(Compare and Swap)指令。
锁具有排它性,保证共享变量在某一时刻只能被一个线程访问;
CAS指令直接在硬件(处理器和内存)层次上实现,看作是硬件锁。
创建两个线程共享一个资源,这里是num,没取一次num会+1,那么看运行结果


不难看出,在一个资源获取num的同时,num还没执行++的时候,另一个线程也获取了num,因此出现了两个线程都获取了统一num的现象。


通过使用java中提供了一个线程安全的AtomicInteger类,保证了操作的原子性,从上述运行结果看出,不再出现相同的数
在多线程环境中,一个线程对某个共享变量进行更新之后,后续其他的线程可能无法立即读到这个更新的结果,这就是线程安全问题的另外一个形式:可见性(visibility)。
如果一个线程对共享变量更新后,后续访问该变量的其他线程可以读到更新的结果,称这个线程对共享变量的更新对其线程可见,否则称这个线程对共享变量的更新对其他线程不可见。
多线程程序因为可见性问题可能会导致其他线程读取到了旧数据(脏数据)。
public class TestVisibility {
public static void main(String[] args) {
MyTask task = new MyTask();
new Thread(task).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//主线程1秒后取消子线程
task.cancel();
/**
* 可能会出现一下情况:
* 在main线程中调用了task.cancel()方法,把task对象的toCancel变量修改为true
* 可能存在子线程看不到main线程对toCancel做的修改,在子线程中toCancel变量一直为false
* 导致子线程看不到main线程对toCancel变量更新的原因,可能:
* 1)JIT即时编译器可能会对run方法中while循环进行优化为:
* if (!toCancel) {
* while (true) {
* break;
* }
* }
* 2)可能与计算机的存储系统有关,假设分别有两个cpu内核运行main线程与子线程,一个cpu内核无法立即读取另外一个cpu内核中的数据
*/
}
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(1000)); //模拟执行任务的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
return true;
}
public void cancel() {
toCancel = true;
System.out.println("收到取消线程的消息");
}
}
}
只是可能是编译器的问题,并没有出现我们想要的结果。按理说会出现在收到取消线程的消息后,子线程仍然在执行某个任务。
有序性(Ordering)是指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另一一处理器运行的其他线程看来是乱序的(Out of Order)。
乱序是指内存访问操作的顺序看起来发生了变化。
在多核处理器的环境下,编写的顺序结构,这种操作执行的顺序可能没有保障的:
1)处理器可能会改变两个操作的先后顺序;
2)处理器可能不会按照目标代码的顺序执行;
这种一个处理器上执行的多个操作,在其他处理器来看它的顺序与目标代码指定的顺序可能不一样,这种现象称为重排序。
重排序是对内存访问有序操作的一种优化,可以在不影响单线程正确的情况下提升程序的性能。但是,可能对多线程的正确性产生影响,即可能导致线程安全问题
重排序与可见性问题类似,不是必然出现的。
与内存操作顺序有关的几个概念:
1)源代码顺序,就是源代码中指定的内存访问顺序;
2)程序顺序,处理器上运行的目标代码所指定的内存访问顺序;
3)执行顺序,内存访问操作在处理器上的实际执行顺序;
4)感知顺序,给定处理器所感知到的该处理器及其他处理器的内存访问操作的顺序;
可以把重排序分为指令重排序与存储子系统重排序两种:
1)指令重排序主要是由JIT编译器,处理器引起的,指程序顺序与执行顺序不一致;
2)存储子系统重排序是由高速缓存,写缓冲器引起的,感知顺序与执行顺序不一致。
在源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序(Instruction Reorder)。
指令重排是一种动作,确实对指令的顺序做了调整,重排序的对象指令。
javac编译器一般不会执行指令重排序,而JIT编译器可能执行指令重排序。
处理器也可能执行指令重排序,使得执行顺序与程序顺序不一致。
指令重排不会对单线程程序的结果正确性产生影响,可能导致多线程程序出现非预期的结果。
存储子系统是指写缓冲器与高速缓存。
高速缓存(Cache)是CPU中为了匹配与主内存处理速度不匹配而设计的一个高速缓存,
写缓冲(Store buffer, Write buffer)用来提高写高速缓存操作的效率,
即使处理器严格按照程序顺序执行两个内存访问操作,在存储子系统的作用下,其他处理器对这两个操作的感知顺序与程序顺序不一致,即这两个操作的顺序看起来像是发生了变化,这种现象称为存储子系统重排序。
存储子系统重排序并没有真正的对指令执行顺序进行调整,而是造成一种指令执行顺序被调整的现象。
存储子系统重排序对象是内存操作的结果。
从处理器角度来看,读内存就是从指定的RAM地址中加载数据到寄存器,称为Load操作;写内存就是把数据存储到指定的地址表示RAM存储单元中,称为Store操作,内存重排序有一下四种可能:
1)LoadLoad重排序,一个处理器先后执行两个读操作L1和L1,其他处理器对两个内存操作的感知顺序可能是L2->L1
2)StoreStore重排序,一个处理器先后执行两个写操作w1和w2,其他处理器对两个内存操作的感知顺序可能是w2->w1
3)LoadStore重排序,一个处理器先执行读内存操作L1再执行写内存操作W1,其他处理器对两个内存操作的感知顺序可能是W1->L1
4)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 |
JIT处理器,处理器,存储子系统是按照一定的规则对指令,内存操作的结果进行重排序,给单线程程序造成一种假象-----指令是按照源码的顺序执行的,这种假象称为貌似串行语义。并不能保证多线程环境下程序的正确性。
为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。如果两个操作(指令)访问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系,如: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
存在控制依赖关系的语句允许重排,一条语句(指令)的执行结果会决定另一条语句(指令)能否被执行,这两条语句(指令)存在控制依赖关系。如在if语句中允许重排,可能存在处理器先执行if代码块,再判断if条件是否成立。
可以使用volatile关键字,synchronized关键字实现有序性。

