文章目录
1、Volatile 是什么
前提,要掌握 Volatile 关键字,就得掌握 JMM。
JMM 学习链接:Java 内存模型
volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。
volatile 的特性:
1、保证了不同线程对这个变量进行操作时的可见性;
2、不保证原子性;
3、禁止指令重排。
2、保证可见性
当写一个volatile变量时,JMM会把该线程对应的本地中的共享变量值刷新到主内存。
2.1 没有可见性的案例
下面下一个测试代码:
public class JMMTest {
private static int num = 0;
public static void main(String[] args)throws Exception {
Thread MyThread = new Thread(() -> {
while (num == 0) {
}
});
MyThread.start();
TimeUnit.SECONDS.sleep(1);
num = 2;
System.out.println(num);
}
}
程序运行结果如下:
可见,main 线程修改了 num 的值并刷新回给了主内存,但是 myThread 线程使用的还是它工作内存中的 num。
2.2 实现可见性
改进:使用关键字 Volatile 修饰 num:
public class JMMTest {
private volatile static int num = 0;
public static void main(String[] args)throws Exception {
Thread MyThread = new Thread(() -> {
while (num == 0) {
}
});
MyThread.start();
TimeUnit.SECONDS.sleep(1);
num = 2;
System.out.println(num);
}
}
运行结果如下:
可见,main 线程对 num 的修改可以被 myThread 线程实时感知。
3、不保证原子性
3.1 不保证原子性的案例
下面写一个测试代码:
public class VolatileTest {
private volatile static int num = 0;
public static void add() {
num++;
}
public static void main(String[] args) {
//理论上num结果应该为 2 万
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}
while (Thread.activeCount() > 2) {
// 至少存在 main gc 两个线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
运行结果如下:
按道理来说,20 个线程,每个线程 执行 1000 次add()
方法,最后的 num 应该为 20 × 1000 = 20000 才对,可是实际结果为不足 2 万。
我们来查看add()
方法的字节码:
可见,我们以为只是执行了一行num++;
,其实在字节码文件中执行了 5 行。
1、拿到 num 的值;
2、加载到操作数栈;
3、取出操作数栈进行加一操作,接着放回操作数栈;
4、操作数栈出栈赋值给 num;
5、return。
当线程 A 拿到 num 的值为 10,然后执行到第二步时,线程 B 把 1-5 都执行了一遍,接着线程 A 继续往下执行时就已经不对了,因为此时 num 已经是 11 了,但是线程 A 还是在 10 的基础上加一。
因此,我们可以分析出,num 不足 20000,就是因为很多线程拿到的值都是旧值,做了很多重复的工作。
3.2 实现原子性
如果我们既要 Volatile 的可见性,还得保证原子性,怎么办呢?
第一个想法肯定就是对add()
方法进行加锁。那么有没有另外的办法,既不加锁,也能原子性呢?当然可以,这就是java.util.concurrent.atomic
包的作用了:
可见,它有很多类,都是和数据类型相关。
把 int
改成 AtomicInteger
,并且把add()
函数进行修改:
public class VolatileTest {
private volatile static AtomicInteger num = new AtomicInteger();
public static void add() {
num.getAndIncrement();
}
public static void main(String[] args) {
//理论上num结果应该为 2 万
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}
while (Thread.activeCount() > 2) {
// 至少存在 main gc 两个线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
运行结果:
查看getAndIncrement()
源码:
调用了UnSafe
类型对象的方法,继续挖掘:
这些类的底层都直接和操作系统挂钩!在内存中修改值!Unsafe类是一个很特殊的存在!
4、禁止指令重排
4.1 指令重排现象
什么是指令重排:你写的程序,计算机并不是按照你写的那样去执行的。
源代码–>编译器优化的重排–> 指令并行也可能会重排–> 内存系统也会重排—> 执行
处理器在进行指令重排的时候,考虑:数据之间的依赖性!
比如一段代码如下:
int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4
我们以为会按照 1、2、3、4 的顺序执行,但是它也可能按照 2134 或1324 的顺序执行,但是不可能是 4123 的顺序,因为第 4 行依赖于第 1 行。
假设有两个线程如下:
线程A | 线程B |
---|---|
x = a | y = b |
b = 2 | a = 3 |
a 和 b 的初始值为 0,在正常情况下,线程 A 的执行结果就是:x = 0;线程 B 的执行结果就是 y = 0;
如果发生了指令重排:
线程A | 线程B |
---|---|
b = 2 | a = 3 |
x = a | y = b |
于是,线程 A 的结果就是 x = 3;线程 B 的结果就是 y = 2。
指令重排机制判断线程 A 和 B 的语句没有上下依赖,因此进行性能优化,进行了指令重排,导致了结果异常。
我们知道,在单线程中指令重排是只有好处没有坏处,但是在多线程环境下,指令重排就容易搞事情了。
4.1 Volatile 禁止指令重排原理
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
1、保证特定操作的顺序;
2、保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
也就是说,在对 Volatile 修饰的变量的写和读的时候,加入屏障,防止出现指令重排。
简单来说,如下图: