前言
这一篇主要介绍了volatile关键字的基本用法,使用场景等等。在讲述volatile之前,还得补充一些预备知识,比如Java内存模型,并发编程中常见的问题。这些应该如何解决,volatile会解决哪些问题,如何解决等等。
内存模型
程序数据的临时数据存放在内存中,但由于CPU执行速度很快,使得从内存读写数据的效率成为了瓶颈,因而CPU当中还有一个高速缓存Cache(程序运行时会从主存复制一份数据到Cache中)对于单线程程序,这种模式不会出错。线程先从主存读取值,复制一份到Cache,CPU指令进行操作,将新值写入Cache,最后将Cache新值刷新到主存当中。
但对于多线程程序,两个线程可能同时读取值到各自所在CPU的Cache,然后进行操作。因为Cache的值更新并不会立刻通知其他CPU的Cache失效,所以就会出现缓存不一致的错误。
一般的解决方法:synchronized,Lock,volatile。
并发编程常见问题
①原子性:一系列的操作,要么都执行,要么都不执行。
②可见性:一个线程修改了某一遍历的值,其他线程能否立刻看到修改的值。如果不能,那么就会出现操作丢失的问题。
③有序性:JVM执行代码时不一定按顺序执行,会发生指令重排序(Instruction Reorder),处理器为了提高效率,会对代码的执行顺序进行优化,但会保证结果一致。(只能保证单线程下是一致,多线程下可能出现错误)
Java内存模型
概念:所有变量都存在于主存中,每个线程都有自己的工作内存(相当于前面的高速缓存Cache)。线程对变量的所有操作都必须在工作内存中进行,不能直接对主存进行操作。每个线程不能访问其他线程的工作内存。
①Java的原子性:
在Java中,很多操作都不是原子性操作,即使是简单的一个语句也很有可能是多个操作。
int x = 10; // statement 1
int y = x; // statment 2
x++; // statement 3
x = x + 1; // statement 4
上述语句,只有语句1是原子性操作,其他都不是。
语句2包含两个操作,先去读取x的值,然后再将x的值写入工作内存。
语句3,4包含三个操作,先读取x的值,进行加1操作,然后写入新值到工作内存。
因此:Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。
②Java的可见性:可以使用 volatile 关键字来保证可见性。当一个共享变量被volatile修饰,它会保证修改的值会立即被更新到主存,并且此时其他线程的Cache都会失效。如果其他线程需要读取该值,那么就要重新到内存中读取,此时读取到的会是新值。而普通的共享变量,在被修改之后,什么时候被写入主存是不确定的,比如A线程对a变量进行了修改,然后后面还有其他逻辑,这段时间内B线程再去读取a,因为A线程对a的修改还没有写入主存,因而B线程读取到的仍然是旧值。
通过synchronized和Lock也能保证可见性,但实际上volatile也能实现。
③Java的有序性:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。重排序有一个 happens-before 原则,只有当两个操作之间没有必然的执行顺序,JVM才可对其进行重排序。
例子:
int a = 10;
int r = 2;
a = a + 3;
r = a * a;
执行顺序不一定是1→2→3→4,也可能是2→1→3→4,但不可能是2→1→4→3。在单线程下不会出现问题,但后面我们会看到,在多线程下会因为重排序而可能出错。
volatile
作用
保证了可见性,有序性,但不能保证原子性。它的效率比synchronized高,所以特定情况下优于synchronized,比如通过volatile+CAS实现无锁并发。
Question:volatile如何保证可见性和有序性?
Ans:对于有序性,volatile使得修改的值会立刻更新到主存,其他线程的Cache立即失效,如果需要读取该变量的值,那么需要从主存读取更新后的值。
而对于有序性,volatile变量前的语句不会重排到volatile变量之后,反之同理。而且对于单一的一个语句,写操作一定会发生在读操作之前。虽然语句顺序依然可能出现重排,但不会影响到volatile变量,因而在多线程环境下也不会出错。
举例子说明:
// Thread1
context = loadConext(); // 语句1
init = true; // 语句2
// Thread2
while (!init) {
sleep(10000);
}
doSthWithContext(context);
上述例子并不完整,但大概是这个意思:init值默认是false,此时线程2一直在进行等待其他线程把init值更改。因此,当线程1执行完loadConext操作,在这里是加载配置文件,将属性赋值到context,接着就把init更改为true。这表示context已经加载完毕,其他线程可以使用这个context了。于是此时线程2结束了while循环,进行了它的doSthWithContext方法,传入的是已经被初始化的context变量。这个看起来一定不会出错,实际上并不是。
由于重排序,显然,语句1和语句2是没有任何关联的。在单线程环境下,先执行loadContext,还是先执行init的赋值,都不会对后续的操作有任何影响。因此执行顺序有可能是:先执行语句2,再执行语句1。那么多线程下,这个重排序会导致什么问题?先执行了init的赋值,此时线程2就跳出了while循环,继续执行它的doSth方法。然而此时线程1还没有完成loadContext方法,即context还没有被初始化。那么在线程2的doSth方法就会出错。
解决方法:使用volatile修饰init变量,那么语句1,语句2的顺序就不会改变。因为:volatile变量前的语句不会重排到volatile变量之后。
Question:为什么volatile不能保证原子性?
Ans:以最简单的 i++为例:
public class Test {
public volatile int inc = 0;
public void incre() {
inc++;
}
}
incre方法的字节码:
public void incre();
Code:
0: aload_0
1: dup
2: getfield #2 // Field inc:I
5: iconst_1
6: iadd
7: putfield #2 // Field inc:I
10: return
volatile只能保证,在getfield时,inc的值是正确的。但后续执行iconst,iadd操作时,其他线程可能已经把inc的值改变。比如线程1从内存读取了inc的值,然后阻塞。接着线程2对inc的值进行修改,它会使得线程1的缓存(工作内存)失效。但线程1唤醒后是继续执行后面的操作,而不会重新进行getfield,因而就出现了更新丢失。(根本原因:Java操作的非原子性)
volatile常用场景:
①标记状态量。可见性使得flag可以尽早更新,生效并作用于其他线程。同时又防止了指令重排序导致标记量的值在并发时出错。
②double check。双重检测,这里以实现单例模式为例:
代码一(有误):
public class Singleton {
public static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized(Singleton.class) {
if (singleton == null) {
// double check是必须的,否则假设Thread1正在等待锁
// 当Thread2生成了对象后,Thread1会再次创建
singleton = new Singleton();
}
}
}
return singleton;
}
}
看起来可以在多线程下也能高效运作(只有为null时才进入synchronized体),但由于重排序,而new这一个步骤实际上有3步:①分配内存空间→②初始化对象→③对象引用指向刚刚分配完的空间。而重排序下,有可能变成了:①→③→②,在多线程环境下,可能会出错,如下:
可以看到,Thread B可能会获得一个初始化不完整的对象,导致出错。
因此更正确的做法是:将单例变量声明为volatile,使得写操作会发生在读操作之前,所以一定是①→②→③。
正确代码:public volatile static Singleton singleton;
,其他都不变。