线程安全的三大特性
在多线程编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题和有序性问题。首先我们看一下这三个特性概念:
原子性
原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
先来看一个例子:使用程序实现一个计数器,期望得到的结果是10000,代码如下:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class UnsafeCount {
public static volatile int count = 0; //后面会解释volatile关键字
public static void inc() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(Integer.MAX_VALUE);
for (int i = 0; i < 10000; i++) {
service.execute(new Runnable() {
@Override
public void run() {
UnsafeCount.inc();
}
});
}
service.shutdown();
//避免出现main主线程先跑完而子线程还没结束,在这里给予一个关闭时间
service.awaitTermination(3000,TimeUnit.SECONDS);
System.out.println("运行结果:UnsafeCount.count=" + UnsafeCount.count);
}
}
控制台输出:
运行结果:UnsafeCount.count=9996
最终结果是9996(count的值不是固定的),并非是我们期望的10000,这正是因为线程不安全导致的错误结果。
原因分析:
线程1 | 线程2 |
---|---|
读取 count 的值, 假设此时count = 0 | 等待 |
将"0"加1变为1 | 等待 |
时间片用完,执行线程二 | 读取 count的值 , count为0 (此时线程一还没有修改 count ) |
等待 | 将"0"加1变为1 |
等待 | 修改 count 的值 ,此时 count = 1; |
修改 count 的值 ,此时 count = 1 | 时间片用完,执行线程一 |
通过上面的分析可见虽然执行了两次count++但最终count的值为1,这就是由于count++为非原子操作引起的。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
产生这样问题的原因是由于java线程通信是通过共享内存的方式进行通信的,而为了加快执行的速度,线程一般是不会直接操作内存的,而是操作缓存。
java线程内存模型:
- Java所有变量都存储在主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程的使用到的变量副本(该副本就是主内存中该变量的一份拷贝)
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接在主内存中读写
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
线程1对共享变量的修改,要想被线程2及时看到,必须经过如下2个过程:
- 把工作内存1中更新过的共享变量刷新到主内存中
- 将主内存中最新的共享变量的值更新到工作内存2中
实际上,线程操作的是自己的工作内存,而不会直接操作主内存。如果线程对变量的操作没有刷写会主内存的话,仅仅改变了自己的工作内存的变量的副本,那么对于其他线程来说是不可见的。
下面以一个简单的例子说明:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
线程1 | 线程2 |
---|---|
读取 i 的值, 假设此时 i = 0 | 等待 |
修改 i 的值 ,此时 i = 10(假设此时i的值还没有同步到主内存中) | 等待 |
时间片用完,执行线程二 | 读取 i 的值为0( 因为i的值还没有同步到主内存中) |
等待 | 修改 j 的值 ,此时 j = 0; |
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是1。这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
有序性
有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的。
产生这样问题的原因是由于重排序的缘故。在Java内存模型中,为了加快程序的运行速度允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
举个例子:
//线程A:
context = loadContext();
inited = true;
//线程B:
while(!inited ){
sleep
}
doSomethingwithconfig(context);
如果线程A发生了重排序线程A会变成如下情况:
inited = true;
context = loadContext();
那么线程B就会拿到一个未初始化的context去使用,从而引起错误。
因为这个重排序对于线程A来说是不会影响线程A的正确性的,而如果loadContext()方法被阻塞了,为了增加Cpu的利用率,这个重排序是可能的。
保证线程安全的两个关键字
Synchronized
Synchronized能够实现原子性和可见性;在Java内存模型中,synchronized规定,线程在互斥代码时,先获得互斥锁→清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。
Volatile
Volatile能够实现可见性和有序性;Volatile实现内存可见性是通过store和load指令完成的;也就是对volatile变量执行写操作时,会在写操作后加入一条store指令,即强迫线程将最新的值刷新到主内存中;而在读操作时,会加入一条load指令,即强迫从主内存中读入变量的值。但volatile不保证volatile变量的原子性(可以看第一个例子)