虽然synchronized可以解决原子性和内存可见性问题,但 在解决内存可见性时并没有真正通过线程间通信解决,同时 也没有解决有序性问题。所以来看一个新的关键字
volatile
。
内存不可见的现象
先重现一个内存可见性问题。创建两个线程,一个线程不断的循环判断标识决定是否退出,另外一个线程来修改标识位。
public class Demo01_Volatile {
private static int flag = 0;
public static void main(String[] args) throws InterruptedException {
//定义一个线程
Thread t1 = new Thread(() -> {
System.out.println("t1线程已启动..");
//循环判断标识位
while (flag == 0){
//TODO:
}
System.out.println("t1线程已退出..");
});
//启动线程
t1.start();
//定义第二个线程,来修改flag的值
Thread t2 = new Thread(() -> {
System.out.println("t2线程已启动..");
System.out.println("请输入一个整数:");
Scanner scanner = new Scanner(System.in);
//接收用户输入并修改flag的值
flag = scanner.nextInt();
System.out.println("t2线程已退出..");
});
//确保t1先启动
TimeUnit.SECONDS.sleep(1);
//启动线程
t2.start();
}
}
现象是当用户输入了一个非零值时,线程1并没有正确退出,出现线程不安全现象。先来刨析一下这个现象是如何产生的。
首先线程1在执行的过程中并没有对flag修改;在执行时,线程1先从主内存中把flag加载到自己的工作内存,也就是寄存器和缓存中;CPU对执行的过程做了一定的优化:既然当前线程没有修改变量的值,而工作内存读取速度是主内存的几万倍以上,所以每次判断flag时就从工作内存中读取;而线程2修改flag之后并没有通知线程1获取最新的值,从而导致了线程不安全现象。所以需要一种工作线程之间能相互通知的机制。
volatile
内存可见性和有序性
在上述的情况下,当为变量flag加入volatile修饰之后,就解决了内存可见性问题,程序可以正常退出。
public class Demo01_Volatile {
//注意观看volatile修饰后的现象
private static volatile int flag = 0;
public static void main(String[] args) throws InterruptedException {
//定义一个线程
Thread t1 = new Thread(() -> {
System.out.println("t1线程已启动..");
//循环判断标识位
while (flag == 0){
//TODO:
}
System.out.println("t1线程已退出..");
});
//启动线程
t1.start();
//定义第二个线程,来修改flag的值
Thread t2 = new Thread(() -> {
System.out.println("t2线程已启动..");
System.out.println("请输入一个整数:");
Scanner scanner = new Scanner(System.in);
//接收用户输入并修改flag的值
flag = scanner.nextInt();
System.out.println("t2线程已退出..");
});
//确保t1先启动
TimeUnit.SECONDS.sleep(1);
//启动线程
t2.start();
}
}
⚠️ 注意:在while循环中写上sleep语句也可以达到预期效果,但是存在很大的不确定性,所以在程序中不能依赖这种存在不确定性的写法。
缓存一致性协议(MESI)
先来看一下内存可见性在CPU层面是如何实现的:
缓存一致性协议:当某个线程修改了一个共享变量之后,通知其他CPU中该变量的缓存值为失效状态,当其他CPU中执行的指令再获取缓存变量的值时,发现这个值的状态被置为失效状态,那么就需要从主内存中重新加载最新的值。
内存屏障
对变量加了volatile之后,编译成指令的前后加了如下的四个屏障,其中Load表示读,Store表示写。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证load1的读操作优先于load2 |
StoreStore | Store1;StoreStore;Store2 | 保证store1的写操作先于store2执行,并刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 保证load1的读操作结束先于store2的写操作 |
StoreLoad | Store1;StoreLoad;Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的操作才能执行 |
①在每个volatile读操作之前插入LoadLoad屏障,这样就能让当前线程获取A变量的时候,保证其他线程也能获取到相同的值,这样所有线程读取到的数据就一样了。
②在每个volatile写操作之前插入StoreStore屏障,这样就能让其他线程修改A变量之后,把修改的值对当前线程可见。
③在读操作之后插入LoadStore屏障;这样就能让当前线程在其他线程修改A变量之前,获取到主内存里面A变量的值。
④在写操作之后插入StoreLoad屏障;这样就能让其他线程在获取A变量的时候,能够获取到已经被当前线程修改的值。
所以volatile可以真真正正的解决内存可见性问题,不像synchronized通过串行的方式。前面介绍过有序性是指在保证程序执行正确的前提下,编译器、CPU对指令的优化过程。用volatile修饰的变量就是要告诉编译器,不需要对这个变量涉及的操作进行优化,从而实现有序性。 所以volatile可以解决有序性问题。
原子性
public class Demo02_Volatile {
// 定义自增操作的对象
private static Counter2 counter = new Counter2();
public static void main(String[] args) throws InterruptedException {
// 定义两个线程,分别自增5万次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 调用加锁的方法
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 调用没有加锁的方法
counter.increment();
}
});
// 启动线程
t1.start();
t2.start();
// 等待自增完成
t1.join();
t2.join();
// 打印结果
System.out.println("count = " + counter.count);
}
}
class Counter2 {
public static volatile int count = 0;
// 修饰静态方法
public static void increment() {
// 要执行的修改逻辑
count++;
}
}
上面的程序得到的结果并不如预期,原因在于:变量count被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是count++,count++的过程可以分为三步,首先获取count的值,其次对count的值进行加1,最后将得到的新值写会到缓存中。假设线程1首先得到了count的初始值100,但是还没来得及修改,就阻塞了,这时线程2开始了,它也得到了count的值,由于count的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程2得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。问题来了,线程1已经读取到了count的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程1阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存。
所以即便volatile具有可见性,但volatile也不能保证原子性。
⚠️注意:volatile只用来修饰变量,做法比较单一。
继续加油。