提示:阅读这篇文章的时候最好先掌握Java内存模型(JMM)的相关内容,不然可能会感到不适。
大多数人接触到这个关键字都是在学习单例模式的时候,他可以保证在并发的场景下不会产生多个实例对象的情况。
通常volatile用来修饰成员变量的时候,
- 可以保证该成员变量在不同线程之间的可见性;
- 可以防止编译器和处理器对该成员变量进行重新排序,保证有序性;
- 无法保证该成员变量的原子性,并发场景下线程不安全。
1 可见性
我们用两个线程来模拟一下,不同线程的工作内存之间的可见性。
public class VolatileDemo {
public static int INIT = 0;
public static int MAX = 6;
public static void main(String[] args) {
ExecutorService pool = Executors.newCachedThreadPool();
// 线程1
pool.execute(() -> {
int v = INIT;
while (v < MAX) {
if (v != INIT) {
System.out.println(Thread.currentThread().getName() + " >>> 获取更新后的值:" + INIT);
v = INIT;
}
}
});
// 线程2
pool.execute(() -> {
int v = INIT;
while (INIT < MAX) {
System.out.println(Thread.currentThread().getName() + " >>> 将值更新为:" + ++v);
INIT = v;
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
先简单说明一下,线程1始终运行,检查自身工作内存中INIT的值是否有变化。线程2会修改自身工作内存中的INIT值,并同步到主内存中。所以,程序的输出应该是:
pool-1-thread-2 >>> 将值更新为:1
pool-1-thread-1 >>> 获取更新后的值:1
pool-1-thread-2 >>> 将值更新为:2
pool-1-thread-2 >>> 将值更新为:3
pool-1-thread-2 >>> 将值更新为:4
pool-1-thread-2 >>> 将值更新为:5
pool-1-thread-2 >>> 将值更新为:6
所以,线程2的工作内存中的INIT对于线程1来说是不可见的,线程1无法感知到线程2对于INIT的修改。
如果对INIT使用volatile关键字,那么任何线程修改了INIT,都要立即将他写回到主内存中,并且这会使其他线程中的INIT数据失效,想要继续使用他的时候,都必须要从主内存中重新获取。
INIT加上volatile关键字后并运行输出
public volatile static int INIT = 0;
pool-1-thread-2 >>> 将值更新为:1
pool-1-thread-1 >>> 获取更新后的值:1
pool-1-thread-2 >>> 将值更新为:2
pool-1-thread-1 >>> 获取更新后的值:2
pool-1-thread-2 >>> 将值更新为:3
pool-1-thread-1 >>> 获取更新后的值:3
pool-1-thread-2 >>> 将值更新为:4
pool-1-thread-1 >>> 获取更新后的值:4
pool-1-thread-2 >>> 将值更新为:5
pool-1-thread-1 >>> 获取更新后的值:5
pool-1-thread-2 >>> 将值更新为:6
pool-1-thread-1 >>> 获取更新后的值:6
可以看到,线程1时刻都感知到了INIT值的变化。
注意
有一种说法是volatile修饰对象或数组的时候,针对的是引用,数组或对象中的成员变量不具备可见性。
我在做这种测试的时候并没有产生这种结果,我在做如下示例的时候volatile依然对对象中的成员变量产生影响了。不知道是我的代码有问题还是这种说法是错误的。
public class VolatileDemo {
public static InitObj initObj = new InitObj(0);
public static int MAX = 6;
public static void main(String[] args) {
ExecutorService pool = Executors.newCachedThreadPool();
// 线程1
pool.execute(() -> {
int v = initObj.getInit();
while (v < MAX) {
if (v != initObj.getInit()) {
System.out.println(Thread.currentThread().getName() + " >>> 获取更新后的值:" + initObj.getInit());
v = initObj.getInit();
}
}
});
// 线程2
pool.execute(() -> {
int v = initObj.getInit();
while (initObj.getInit() < MAX) {
System.out.println(Thread.currentThread().getName() + " >>> 将值更新为:" + ++v);
initObj.setInit(v);
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
@Data
static
class InitObj {
private int init;
public InitObj(int i) {
this.init = i;
}
}
}
2 有序性
验证有序性有个很好的例子是单例模式:
public class LazySingleton {
private static volatile LazySingleton instance = null;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
}
上面是双重检查锁机制的单例模式,我们都知道synchronized关键字可以保证有序性,那么为什么还要加上volatile关键字呢?
原因就是,两者保证有序性的方式不一样。synchronized无法禁止指令重排,被synchronized包裹的代码块就算发生指令重排,由于同一时间内只有一个线程执行逻辑,所以就算是指令重排也可以保证有序性。
而volatile则是使用内存屏障的方式禁止指令重排,从而保证有序性。内存屏障是个很底层的概念大概的作用就是重排序时不能把后面的指令重排序到内存屏障之前。
我们看一下上述代码的部分字节码
Code:
stack=2, locals=2, args_size=0
0: getstatic #2 // Field instance:Lcom/spheign/szjx/designModel/singleton/LazySingleton;
3: ifnonnull 37
6: ldc #3 // class com/spheign/szjx/designModel/singleton/LazySingleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field instance:Lcom/spheign/szjx/designModel/singleton/LazySingleton;
14: ifnonnull 27
17: new #3 // class com/spheign/szjx/designModel/singleton/LazySingleton
20: dup
21: invokespecial #4 // Method "":()V
24: putstatic #2 // Field instance:Lcom/spheign/szjx/designModel/singleton/LazySingleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field instance:Lcom/spheign/szjx/designModel/singleton/LazySingleton;
40: areturn
其中17、20、21、24为instance = new LazySingleton();
的主要操作。我们也可以拆分成下面三个步骤:
- 分配存储LazySingleton对象的内存空间;
- 初始化LazySingleton对象;
- 将instance指向刚刚分配的内存空间。
以上是正常的顺序,但是编译器为了优化程序的性能,有可能的执行顺序是:
- 分配存储LazySingleton对象的内存空间;
- 将instance指向刚刚分配的内存空间;
- 初始化LazySingleton对象。
这时问题就来了,线程1先进来执行,并且已经将instance指向LazySingleton对象的内存空间,但还没有初始化LazySingleton对象。与此同时,线程2执行到了判断if (instance == null)
,由于instance指向LazySingleton对象的内存空间,所以判断false,直接返回instance对象。这样线程2使用instance对象的时候就会发生空指针异常。
3 原子性
我们先来看一个例子:
public class VolatileDemo {
public volatile int i = 0;
public void increase() {
i++;
}
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
for (int j = 0; j < 100; j++) {
volatileDemo.increase();
}
});
}
pool.shutdown();
System.out.println(volatileDemo.i);
}
}
如果你用的IDE是idea的话,那么你会发现一个提示
这是由于i++
不是一个原子性的操作,我们拆分一下就是两步操作,先执行i+1
,再将i+1
赋值给i
。
所以程序的运行结果肯定不会是我们预期的1000,而是小于1000的某个值。稍加分析我们就能理解为什么是这种结果,线程1和线程2都同时获取了i的值,比如是10,线程1执行了+1操作,将11写回到主内存中,线程2工作内存中的i将失效。在线程1向主内存中回写数据之前线程2也完成了+1的操作,所以就算是工作内存中的i失效了也不会影响线程2再将11写回到主内存中。
解决办法有两个,加锁或者使用atomic类。
synchroinzed:
下例中也可以不加volatile关键字
public class VolatileDemo {
public volatile int i = 0;
private final Object lock = new Object();
public void increase() {
synchronized (lock){
i++;
}
}
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
for (int j = 0; j < 100; j++) {
volatileDemo.increase();
}
});
}
pool.shutdown();
System.out.println(volatileDemo.i);
}
}
AtomicInteger:
public class VolatileDemo {
public AtomicInteger i = new AtomicInteger(0);
public void increase() {
i.incrementAndGet();
}
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
for (int j = 0; j < 100; j++) {
volatileDemo.increase();
}
});
}
pool.shutdown();
System.out.println(volatileDemo.i);
}
}