深入理解volatile关键字
# 如何保证变量的可见性
# 基于JMM模型理解可见性的原理
从JMM
提供的抽象模型上来说,它保证可见性的方式如下,假设我们有一个volatile
共享变量a
,值为1,线程1和线程2协作的操作如下
- 由于是
volatile
变量,所以线程1就不会对应将本地内存设置为无效,直接从主存中获取,并加载到主存中。 - 然后线程1将值修改为2,由于需要保证可见性,所以
JMM
会把这个变量写如主存中。 - 线程2读取,同样因为
volatile
修饰的原因,不走本地内存,直接从主存中获取,从而保证缓存一致性。
# volatile保证可见性代码示例
代码如下所示,分别给num
变量加上volatile
和删除volatile
public class VolatileModify {
private volatile static int num = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (num == 0) {
}
System.out.println("num已被修改为:1" );
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
System.out.println("t2修改num为1" );
});
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
输出结果
/**
* 加volatile关键字
* t2修改num为1
* num已被修改为:1
*/
2
3
4
5
而不加volatile
,则t1无法感知改变就会一直走CPU Cache
中的值,导致死循环。
/**
* 不加volatile
*t2修改num为1
*/
2
3
4
# volatile可以禁止指令重排序
volatile
不仅可以保证可见性,还可以避免指令重排序。如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
双重校验锁实现对象单例(线程安全):
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
uniqueInstance
采用 volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
但是由于 JVM
具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 1 执行了 1 和 3,此时 线程2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
# volatile无法保证原子性
volatile
关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
# 无法保证原子性代码示例
public class VolatoleAtomicityDemo {
public volatile static int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatoleAtomicityDemo.increase();
}
});
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
正常情况下,运行上面的代码理应输出 2500
。但真正运行了上面的代码之后,每次输出结果都小于 2500
。也就是说,如果 volatile
能保证 inc++
操作的原子性的话。每个线程中对 inc
变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500
实际上,inc++
其实是一个复合操作,包括三步:
- 读取 inc 的值。
- 对 inc 加 1。
- 将 inc 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
inc
进行读取操作之后,还未对其进行修改。线程 2 又读取了inc
的值并对其进行修改(+1),再将inc
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
inc
的值进行修改(+1),再将inc
的值写回内存。
这也就导致两个线程分别对 inc
进行了一次自增操作后,inc
实际上只增加了 1。
其实,如果想要上面的代码运行正确,可以利用 synchronized
、Lock
或者AtomicInteger
。
使用 synchronized
改进:
public synchronized void increase() {
inc++;
}
2
3
使用 AtomicInteger
改进:
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
2
3
4
5
使用 ReentrantLock
改进:
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}
2
3
4
5
6
7
8
9