22. 再论线程安全(volatile)
1. volatile的作用
1.禁止进行指令重排。
2.保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
线程工作时会将数据加载到工作内存操作,对于不加volatile关键字修饰的变量,线程A修改后,线程B不会立即到主存中读取。
以下三种情况线程B才会去刷新主存数据
- 线程中释放锁时
- 线程切换时
- CPU有空闲时间时(比如线程休眠,IO操作)
2. 对volatile的误解
很多人以为volatile是线程安全的,但实际上volatile只能保证可见性,A线程写入后,B线程能立即看到,并不能保证线程安全。
2.1 错误例子
public class ErrorVolatileDemo {
private static class Worker implements Runnable {
private static volatile int count = 0;
@Override
public void run() {
//线程安全的话,最终数字应该是40000,实际却不是
for (int i = 0; i < 20000; i++) {
count = count + 1; // 非原子操作,导致结果不正确
}
System.out.println("count: " + count);
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new ErrorVolatileDemo.Worker());
Thread t2 = new Thread(new ErrorVolatileDemo.Worker());
t1.start();
t2.start();
}
}
两个线程循环20000次,线程安全情况下,期望值为40000,而实际打印日志:
count: 22701
count: 30116
可见volatile关键字只能保证可见性,不能保证线程安全。
2.2 线程安全例子
public class CorrectVolatileDemo {
private static class Worker implements Runnable {
public static volatile int count = 0;
@Override
public void run() {
for (int i = 0; i < 20000; i++) {
synchronized (Worker.class) { // 同步加锁保证原子性,且java对象为类,而不是类实例
count = count + 1;
}
}
System.out.println("count: " + Worker.count);
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new CorrectVolatileDemo.Worker());
Thread t2 = new Thread(new CorrectVolatileDemo.Worker());
t1.start();
t2.start();
}
}
打印日志:
count: 39258
count: 40000
可以看到使用synchronized加锁后,最终打印结果为4000,前面一个不为4000是因为第1个线程执行完后退出,第2个线程还未执行完。
对于这个例子而言,实际不需要使用volatile修饰count变量能保证线程安全。(可以去掉volatile再跑一次试试)
2.3 可见例子
那么volatile的可见性怎么体现,再来看一个例子:
public class VolatileVisibilityDemo {
private static class Worker implements Runnable {
private boolean stop = false; // 不使用volatile关键字时,在线程内修改取值,主线程看不到,因此即使取值变化,主线程也不会停下来
@Override
public void run() {
quietlySleep();
stop = true;
System.out.println("stop =" + stop);
}
public boolean isStop() {
return stop;
}
private void quietlySleep() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
// nothing to do
}
}
}
public static void main(String[] args) {
Worker td = new Worker();
new Thread(td).start();
while (true) { // 没有volatile修饰时,且cpu没有空闲,不会重新加载子线程更新到主内存的变量,无法读取到修改后的取值
if (td.isStop()) {
System.out.println("exit");
break;
}
}
}
}
打印日志:
stop =true
可以看到,虽然日志中stop取值已经是true,但是主线程并没有退出,是因为主线程中的while(true)一直在占用CPU,导致没有空闲时间刷新主内存数据获得Worker更新的最新值。如果要想刷新主内存数据有几种方式: 1.stop变量增加volatile关键字修饰,那么每一次都会到主内存中读取数据。 2.在while(true)代码块中释放CPU,例如sleep,通过System.out打印日志,文件操作等等。
当然通常情况下不会写个while条件始终为true,而在其代码块中又不做休眠处理,因此这类问题一般不会出现,但是为了避免出现类似问题,凡是跨线程获取变量取值的操作最好都通过volatile关键字修饰。
接着用volatile修饰stop再跑一次,打印日志:
exit
stop =true
此时主线程可以看到Worker更新到主内存中的变量值,因此主线程退出,这就是可见性。
3. volatile的其他使用场景
3.1. 单例模式
除了上面的可见性例子外,volatile使用的经典场景是单例模式,如下是单例模式的其中一种标准写法:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (null == singleton) { // 通过volatile的可见性提高性能
synchronized (Singleton.class) { // 减小锁的范围
if (null == singleton) { // 通过volatile的可见性保证不会重复实例化
singleton = new Singleton();
}
}
}
return singleton;
}
}
4. 小结
1.单独使用volatile关键字只能保证可见性,不能保证原子性,因此不能确保线程安全。
2.在某些场景下,只需要满足线程间的可见性要求,此时不需要通过加锁来实现原子性以确保线程安全,这种场景下只需要读取到其他线程更新后的最新值则可。
3.加锁也能做到可见性,但是加锁有性能损耗,因此仅通过volatile来做到可见性是有意义的。
4.并非所有场景下volatile和加锁要同时使用,两者均有单独使用的场景,也有一起使用的场景。