面试的时候是否被问过volitale关键字?多线程并发编程时是否直接怼synchronized?volitale到底有什么用?volitale和synchronized又有什么区别?可见性,指令重排,原子性又是怎么回事?volitale原理是什么?如果你有这样的疑问,那么先恭喜你看到了这篇宝藏文章!
在本合集的《Java线程安全问题和解决方案》一文中,指出Java多线程在操作共享数据时会有线程安全问题,解决线程安全问题通常手段是加锁,通过 synchronized 关键字或者通过Lock接口实现。使用锁之后线程在执行程序时会去获取锁,在执行效率上会降低,所以在一些简单场景下,我们可以使用volatile关键字来代替,注意不是所有的场景都可以使用,文中会根据理论和代码逐一介绍volatile的相关特性,在文章末尾总结了使用场景。
如果想了解 volatile 需要从Java内存模型【JMM】以及并发编程中的可见性、有序性、原子性入手
注意:判断是否支持并发的三要素:可见性、有序性、原子性,其实我们都知道volatile无法保证原子性,所以很多情况无法使用的,只有某些场景可以使用,所以开头就说了,某些简单的场景就可以使用这个volatile,毕竟volatile是轻量级的synchronized
Java内存模型
《Java虚拟机规范》中定义Java内存模型来屏蔽各个硬件和操作系统的内存访问差异。Java的内存模型(Java Memory Mode, JMM)指定了Java虚拟机如何与计算机的主存(RAM)进行工作。如下图所示:
Java内存模型规定了一个线程对共享变量的写入何时对其他线程可见,定义了线程和内存之间的抽象关系。具体如下:
- 共享变量存储于主内存之中,每个线程都可以访问
- 每个线程都有私有的工作内存或称为本地内存
- 工作内存只存储该线程对共享变量的副本
- 线程不能直接操作主内存,只有先操作了工作内存数据副本之后,才能将操作后的数据再写入主内存。
- 工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在,根据不同的Java虚拟机实现具体的数据存储位置,如Hotspot虚拟机,根据寄存器、方法区、堆内存以及硬件等存储数据
可见性
比如下方代码:
- 声明一个flag变量,在线程1中将其修改为false
- 主线程中一个while循环,当flag为true时一直循环,当为false时跳出循环,执行结束语句
public class VolatileMain {
// 运行标记
private static boolean flag = true;
public static void main(String[] args) {
//创建第一个线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
// 睡眠3秒
try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
// 将运行标记设置为false
flag = false;
System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
},"线程1").start();
//第二个是main线程
while (true){
// 如果第二个main线程 可以监测到flag值的改变,就会跳出当前循环,执行后续程序。
if(!flag) {
break;
}
}
System.out.println(Thread.currentThread().getName()+"程序结束!");
}
}
运行结果:
当子线程修改flag值为false后,主线程的while循环并未停止,说明主线程并没有发现flag值被另外的线程修改
分析:
- 基于Java8使用的HotSpot虚拟机实现来说,静态变量 flag 存储于方法区中,被所有线程共享
- 当线程1启动时需要使用flag变量就会将其拷贝进线程1的工作内存,并且修改值为false
- 主线程使用该变量也是拷贝进自己的工作内存,当拷贝进去时flag变量值都为true,线程1睡眠3秒之后修改了值,并将值刷新进主内存
- 但是此时主线程循环使用的flag值并不是主内存中最新的,而是线程启动时就拷贝进来的值,所以循环并没有停止,也就是主线程并没有发现值被修改了,因为他没有去获取最新的值
想要解决这个问题有两种方案
- 加锁
- 保障变量修改后被其他线程可见
加锁
我们对flag的判断进行加锁处理
public class VolatileMain {
private static boolean flag = true;
public static void main(String[] args) {
//创建第一个线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
flag = false;
System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
},"线程1").start();
//第二个是main线程
while (true){
// 加锁
synchronized (VolatileMain.class) {
if(!flag) {
break;
}
}
}
System.out.println(Thread.currentThread().getName()+"程序结束!");
}
}
运行结果:
**分析:**为什么加了锁就能获取到最新的值了呢?
因为线程进入 synchronized 代码块之后,它的执行过程如下:
- 线程获取锁
- 从主内存拷贝共享数据的最新值到工作内存中
- 执行代码
- 将修改后的值刷新到主内存
- 线程释放锁
volatile实现可见性
加了volatile关键字修饰的变量,只要有一个线程将主内存中的变量值做了修改,其他线程都将马上收到通知,立即获得最新值。当写线程修改一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存。当读线程获取一个volatile变量时,JMM会把该线程对应的本地工作内存置为无效,线程将到主内存中重新读取共享变量
解决方案:我们对变量flag使用 volatile 修饰,就可以保障线程在使用该变量时会从主内存获取最新值
public class VolatileMain {
private volatile static boolean flag = true;
public static void main(String[] args) {
//创建第一个线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
flag = false;
System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
},"线程1").start();
//第二个是main线程
while (true){
if(!flag) {
break;
}
}
System.out.println(Thread.currentThread().getName()+"程序结束!");
}
}
运行结果: 发现当修改了flag值之后,main线程也跳出了while循环
分析:
- 线程1从主内存读取共享数据到工作内存
- 睡眠3秒后,将值修改为false,此时共享数据被volatile修饰,就会强制将最新的值刷新回主内存
- 当线程1将值刷新到主内存之后,其他线程的共享变量就会作废
- 再次对共享变量操作时,就会读最新的值,放到工作内存中
volatile修饰的变量可以在多线程情况下,修改数据可以实现线程之间的可见性
有序性(禁止指令重排序)
**指令重排:**在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,软件和硬件都会为了:在不改变程序执行结果的前提下,尽可能提高执行效率,JMM对底层尽量减少束缚,使其能够发挥自身优势。因此:在程序运行时,为了提高性能,编译器和处理器常常会对指令进行重排。重排序一般分3种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
指令重排图解
我们知道Java执行会将代码转换为指令集,变量a和变量b并没有直接的依赖关系,左边为重排之前代码,需要加载和保存a变量两次,右侧为重排之后的代码,只需要加载和保存a变量一次,提高了执行效率
指令重排问题
根据以下来需求证明存在指令重排:
- a,b,i,j四个变量初始值为0
- 开启两个线程,分别对a和b赋值为1,再将b的值赋值给i,将a的值赋值给j,
- 因为指令重排问题,i和j的值有四种情况
- 分别输出第count次,i和j的值分别为多少,为了控制输出条数,声明result1,result2,result3,result4四个变量记录每一种情况
- 当所有情况都出现之后跳出while循环,结束程序
public class OrderResetMain {
// 共享变量a,b,i,j初始值都为0
private static int a,b = 0;
private static int i,j = 0;
public static void main(String[] args) throws InterruptedException {
// 记录每种情况是否已经出现
boolean result1 = false;
boolean result2 = false;
boolean result3 = false;
boolean result4 = false;
// 执行次数
int count = 0;
// 不断循环开启线程
while(true) {
// 每次执行次数+1
count++;
// 每次重置四个变量
a = 0;
b = 0;
i = 0;
j = 0;
Thread t1 = new Thread(() -> {
// 交替赋值
a = 1;
i = b;
}, "线程1");
Thread t2 = new Thread(() -> {
// 交替赋值
b = 1;
j = a;
}, "线程2");
// 启动线程
t1.start();
t2.start();
// 插入线程,需要等待t1和t2线程运行结束才会执行main线程
t1.join();
t2.join();
// 分别针对四种情况只做一次输出
if(!result1 && i == 0 && j == 0) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result1 = true;
}
if(!result2 && i == 0 && j == 1) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result2 = true;
}
if(!result3 && i == 1 && j == 0) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result3 = true;
}
if(!result4 && i == 1 && j == 1) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result4 = true;
}
// 当所有情况都出现之后结束循环
if(result1 && result2 && result3 && result4) {
break;
}
}
}
}
运行结果:这段程序的i和j的值有四种情况
分析:
出现这四中情况的原因是程序并没有同步加锁,导致线程切换执行,同时因为指令重排,同一个线程内部的程序在执行时调换了代码的顺序,按照之前的认识,线程内代码执行的顺序是不变的,也就是线程1的a = 1肯定在 i = b之前执行,第二个线程的b = 1在j = a之前执行
但是实际上线程1和线程2内部的两行代码的执行顺序和源代码中写的不一致,因为虚拟机在执行代码时发现i = b这行代码与上边的a = 1这行之间没有必然联系,它认为重排不会影响执行结果,线程1对线程2的代码是无知的,线程之间的代码是独立的,所以就出现了i = 0,j = 0 的情况。
如果没有指令重排,即保障线程中两行代码的执行顺序和编写顺序一样,那么就不会出现i = 0,j = 0的情况
public class OrderResetMain {
// 使用 volatile修饰变量,保障用到该变量的代码是有序的
private volatile static int a,b = 0;
private volatile static int i,j = 0;
public static void main(String[] args) throws InterruptedException {
boolean result1 = false;
boolean result2 = false;
boolean result3 = false;
boolean result4 = false;
int count = 0;
while(true) {
count++;
a = 0;
b = 0;
i = 0;
j = 0;
Thread t1 = new Thread(() -> {
// 交替赋值
a = 1;
i = b;
}, "线程1");
Thread t2 = new Thread(() -> {
// 交替赋值
b = 1;
j = a;
}, "线程1");
t1.start();
t2.start();
t1.join();
t2.join();
if(!result1 && i == 0 && j == 0) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result1 = true;
}
if(!result2 && i == 0 && j == 1) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result2 = true;
}
if(!result3 && i == 1 && j == 0) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result3 = true;
}
if(!result4 && i == 1 && j == 1) {
System.out.println("第" + count + "次,i = " + i + ",j = " + j);
result4 = true;
}
if(result1 && result2 && result3 && result4) {
break;
}
}
}
}
原子性
所谓原子性是指在一次操作或多次操作中,要么所有的操作全部得到执行并且不受任何因素干扰而中断,要么所有的操作都不执行,而volatile不保障原子性
**案例:**开启100个线程每个线程对count值累加10000次,那么最后的正确结果应该是 1000000。
public class VolatileAtomic {
// 共享变量
private static int count = 0;
public static void main(String[] args) {
// 开启100个线程
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 线程中累加数据
for (int j = 0; j < 10000; j++) {
count++;
System.out.println(Thread.currentThread().getName() + "count====>" + count);
}
},"第" + i + "个线程").start();
}
}
}
运行结果:发现多次运行结果并没有累加到1000000,当然也可能加到正确的结果,这里我的运气比较差
结果分析:
以上的问题出现在count++上,这个操作其实是分为了三个步骤:
- 从主内存中将count变量加载工作内存,可以记作iload
- 在工作内存中对数据进行累加,可以记作iadd
- 再将累加后的值写回到主内存,可以记作istore
count++不是一个原子操作,也就是在某一个时刻对某一个指令操作时,可能被其他线程打断
- 如果此时count的值为100,线程1需要对变量自增,首先需要从主内存中将变量count读取到线程的工作内存,此时因为不是原子操作,CPU时间片发生切换,线程2运行,此时线程1变为就绪状态,线程2变为运行状态
- 线程2也需要对count进行自增操作,同样的将count的值从主内存读取到线程2的工作内存,此时count值还未被修改,仍然为100
- 线程2对count进行了+1操作,线程2的工作内存的值变为了101,但是没有被刷新到主内存
- 此时,CPU放弃执行线程2,转而执行线程1,由于此时线程2的并未被刷新进主内存,因此线程1工作内存的count值仍然为100,线程1进行了+1操作,工作内存中的值变为101
- 然后线程2执行,将101刷新会主内存,线程1也执行,也是将101刷新进主内存,此时就会出现两个线程累加,但是只对值修改了一次
volatile原子性测试:
如下图,通过对变量count添加volatile发现并没有解决多线程count的累加问题,多次运行仍然累加不到1000000。
那是因为volatile不保障原子性,也就是count++还是被分割为三个操作,iload,iadd和istore。只保障线程使用值时获取到的是别的线程修改后的最新值,并不能保障一个操作的原子性,如下图:
- iload数据时并没有任何一个线程修改数据,所以获取到的还是100
- 因为被volatile修饰,所以当线程执行add之后,就会将最新的值刷新进主内存,并将其他线程获取到的旧值作废
- 如果CPU是单核,此时其实可以解决累加问题,但是此时,我们CPU是多核,可以同时执行多个线程,线程1和 线程2如果同时执行add,就不会获取最新的值,仍然会出现少加情况
小结
volatile关键字可以保证可见性和禁止指令重排,但是不能保证对数据操作的原子性,所以在多线程并发编程的情况下如果对共享数据进行计算,使用volatile仍然是不安全的,我们可以通过加锁或者使用原子类保障线程安全
加锁保障线程安全
通过synchronized将操作count共享变量的代码同步起来
加锁方式1:
这里我将线程中的for循环直接同步了,锁的范围有点大,但是可以减少获取锁的次数,如果在线程的10000循环内加锁的话,线程内部每次循环都需要重新获取锁,反而影响性能
public class VolatileAtomic {
private volatile static int count = 0;
public static void main(String[] args) {
// 开启100个线程
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 加锁
synchronized (VolatileAtomic.class) {
// 线程中累加数据
for (int j = 0; j < 10000; j++) {
count++;
System.out.println(Thread.currentThread().getName() + "count====>" + count);
}
}
},"第" + i + "个线程").start();
}
}
}
加锁方式2:
一般都是建议减小锁粒度,即只锁住操作共享数据的代码,也就是只锁住count++就行了,但是线程内有循环,这样每次循环都需要再获取一次锁,虽然synchronized是可重入锁,虽然不用判断是否被占用,可以直接获取到锁,但是还是仍然会执行 monitorenter 和 monitorexit指令,多少还是影响性能,不建议此种写法
执行结果:
加锁之后,就可以保障线程安全,可以获取到正确的结果,当然我们也可以使用Lock加锁在本合集的《Java线程安全问题和解决方案》一文中有介绍,在这就不多赘述
原子类解决线程安全
Java5开始提供了
java.util.concurrent.atomic简称【Atomic包】,这个包中的原子操作类提供了一种用法简单,性能高效,线程安全的操作变量的方式
AtomicInteger
原子型Integer,可以实现整型原子修改操作
通过原子类改造:
- 声明 AtomicInteger 类型的原子类共享数据
- 通过incrementAndGet方法自增后返回新值
- 线程中没有加锁,仍然实现了线程安全
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileAtomic {
// 声明原子类整型,不传参默认为0
private static AtomicInteger atomicInteger = new AtomicInteger();
public static void main(String[] args) {
// 开启100个线程
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 线程中累加数据
for (int j = 0; j < 10000; j++) {
// 累加1并返回新值
System.out.println(Thread.currentThread().getName() + "atomicInteger====>" + atomicInteger.incrementAndGet());
}
}, "第" + i + "个线程").start();
}
}
}
运行结果:
多次运行发现都是正确的结果,实现了线程安全
原子类源码
原子类中的值通过volatile修饰,保障数据可见性
incrementAndGet方法源码:
- 累加后获取值的方法调用了 unsafe 对象的getAndAddInt方法
- 在getAndAddInt方法中有一个do…while循环,判断条件调用了 this.compareAndSwapInt()方法
- 其实是通过CAS实现的原子操作
public final int incrementAndGet() {
// 调用方法
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
// 循环判断和比较值是否是上一次修改的结果
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
volatile写读建立的happens-before关系
上边我们说为了提高运算速度,JVM会编译优化,也就是进行指令重排,并发编程下指令重排会带来安全隐患:如指令重排导致的多个线程之间的不可见性,如果让程序员再去了解这些底层的实现规则,那就太难太卷了,严重影响并发编程效率
从Java5开始,提出了happens-before【发生之前】的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作既可以是在一个线程之内,也可以是在不同线程之间。 所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它瞎优化!
简单来说: happens-before 应该翻译成: 前一个操作的结果可以被后续的操作获取。就是前面一个操作变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。这就是volatile修饰变量可见性的原因
由此可见:使用volatile修饰绝不是花活而是科学的必要
volatile应用场景
因为volatile并不能保障原子性,所以如果多线程对共享数据进行计算的场景下还是需要加锁,使用volatile并不能保障线程安全,volatile适用于:
- 单纯的赋值,比如将flag的值改为true或者false,不适用于count++这样的非原子操作
- 监视数据变化,比如检测到flag的值变为true,就退出循环等操作,当温度超过40度就报警等
比如:我们上边的可见性问题的案例
所以volatile应用场景并不是非常广泛,主要是为了解决同步加锁太重的问题,在某些场景下可以使用volatile解决部分线程安全问题
volatile与synchronized
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
- volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他 (互斥) 的机制。
- volatile用于禁止指令重排序: 可以解决单例双重检查对象初始化代码执行乱序问题
- volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
总结
- volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。比如boolean flag ,或者监视数据变化,实现轻量级同步
单纯的赋值,比如将flag的值改为true或者false,不适用于count++这样的非原子操作
监视数据变化,比如检测到flag的值变为true,就退出循环等操作,当温度超过40度就报警等
- volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性,因为无锁不需要花费时间在获取锁和释放锁上,所以说它是低成本的
- volatile只能作用于变量上,我们用volatile修饰实例变量和类变量,这样编译器就不会对这个属性做指令重排序
- volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取
- volatile提供了happens-before保证,对volatile变量的写入,happens-before保障所有其他线程对变量的读操作都是可知的
- volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性