还在无脑使用synchronized?volitale或许能更优雅的帮到你

开发 前端
volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

面试的时候是否被问过volitale关键字?多线程并发编程时是否直接怼synchronized?volitale到底有什么用?volitale和synchronized又有什么区别?可见性,指令重排,原子性又是怎么回事?volitale原理是什么?如果你有这样的疑问,那么先恭喜你看到了这篇宝藏文章!

在本合集的《Java线程安全问题和解决方案》一文中,指出Java多线程在操作共享数据时会有线程安全问题,解决线程安全问题通常手段是加锁,通过 synchronized 关键字或者通过Lock接口实现。使用锁之后线程在执行程序时会去获取锁,在执行效率上会降低,所以在一些简单场景下,我们可以使用volatile关键字来代替,注意不是所有的场景都可以使用,文中会根据理论和代码逐一介绍volatile的相关特性,在文章末尾总结了使用场景。

如果想了解 volatile 需要从Java内存模型【JMM】以及并发编程中的可见性、有序性、原子性入手

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种类型:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

指令重排图解

我们知道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指令,多少还是影响性能,不建议此种写法

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(() -> {
// 线程中累加数据
for (int j = 0; j < 10000; j++) {
// 加锁
synchronized (VolatileAtomic.class) {
count++;
System.out.println(Thread.currentThread().getName() + "count====>" + count);
}
}
}, "第" + i + "个线程").start();
}
}
}

执行结果:

加锁之后,就可以保障线程安全,可以获取到正确的结果,当然我们也可以使用Lock加锁在本合集的《Java线程安全问题和解决方案》一文中有介绍,在这就不多赘述!

原子类解决线程安全

Java5开始提供了java.util.concurrent.atomic简称【Atomic包】,这个包中的原子操作类提供了一种用法简单,性能高效,线程安全的操作变量的方式

AtomicInteger

原子型Integer,可以实现整型原子修改操作

方法

作用

AtomicInteger()

构造方法,初始化一个默认值为0的AtomicInteger

AtomicInteger(int initialValue)

构造方法,初始化一个指定值的AtomicInteger

final int get()

获取值

final void set(int newValue)

设置值

final int getAndSet(int newValue)

设置值,并返回旧值

final int getAndIncrement()

加1,并返回旧值

final int getAndDecrement()

减1,并返回旧值

final int incrementAndGet()

加1,并返回加1后的值

final int decrementAndGet()

减1,并返回减-后的值

final int addAndGet(int delta)

将输入的值与原来的值相加

通过原子类改造:

  • 声明 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修饰变量可见性的原因

单例模式

单例模式大家应该熟悉不过了吧,就是保障程序中的某实例只存在一个,单例模式一般有8种写法,大概分为懒汉式、饿汉式、静态内部类和枚举,其中懒汉式的常见四种写法中有部分写法存在多线程安全问题,这里借此演示一下多线程懒汉式的安全问题,并使用volatile解决安全问题。

饿汉式

在真正需要单例对象的时候才创建对象,在Java程序中,有时候可能需要延迟一些高开销的对象创建操作,以提升性能,这时就可以采用饿汉式实现单例对象的创建。

判空创建【线程不安全】

这种方式是先判断是否为null,之后创建对象,再返回对象,这种方式是线程不安全的

单例类:

public class SingleDemo1 {
// 定义实例对象
private static SingleDemo1 INSTANCE;

// 私有构造器
private SingleDemo1(){}

// 定义获取单例对象的方法
public static SingleDemo1 getInstance() {
// 判断实例对象为null,则创建对象
// 注意:多线程情况下,可能两个线程获取单例对象,判断为空之后,发生线程切换,当线程继续执行时就会创建多个对象
if(INSTANCE == null) {
// 创建对象
INSTANCE = new SingleDemo1();
}
// 返回对象
return INSTANCE;
}
}

测试类:

  • 开启10个线程获取单例对象
  • 在线程中输出对象的哈希值,默认的toString方法就是返回对象的哈希值
  • 如果哈希值一样则是同一个对象,如果不一样就是不同的对象
public class SingleMain {
public static void main(String[] args) {
// 循环开启 10 个线程
for (int i = 0; i < 10; i++) {
new Thread(() -> {
// 线程内获取单例对象
SingleDemo1 instance = SingleDemo1.getInstance();
// 输出单例对象,默认输出的是哈希值
System.out.println(Thread.currentThread().getName() + "instance===>" + instance);
},"线程" + i).start();
}
}
}

运行结果:

发现结果中有8条线程的哈希值一样,说明多线程下并非仅创建了一个对象,线程不安全

加锁解决【线程安全】

同步方法:可以使用 synchronized 修饰获取单例对象的方法,线程调用方法时就会去获取锁

public class SingleDemo1 {
// 定义实例对象
private static SingleDemo1 INSTANCE;

// 私有构造器
private SingleDemo1(){}

// 定义获取单例对象的方法,加synchronized同步
public synchronized static SingleDemo1 getInstance() {
// 判断实例对象为null,则创建对象
if(INSTANCE == null) {
// 创建对象
INSTANCE = new SingleDemo1();
}
// 返回对象
return INSTANCE;
}
}

同步代码块:缩小锁范围,将方法中共享数据【单例对象】锁住

public class SingleDemo1 {
// 定义实例对象
private static SingleDemo1 INSTANCE;

// 私有构造器
private SingleDemo1(){}

// 定义获取单例对象的方法
public static SingleDemo1 getInstance() {
// 同步代码块
synchronized(SingleDemo1.class) {
// 判断实例对象为null,则创建对象
if(INSTANCE == null) {
// 创建对象
INSTANCE = new SingleDemo1();
}
}
// 返回对象
return INSTANCE;
}
}

运行结果:

加锁之后,就可以解决线程安全问题

问题:

代码中发现将if判断也锁起来了,理想的情况是只有对象是null的时候才去竞争锁,不是null的话就直接返回就行,显然上边的加锁方式太简单粗暴,影响性能

加锁线程不安全

有的小机灵鬼就是想到那我先判断是否为空,再加锁不就行啦,其实下边的这个代码也是线程不安全的,因为线程可能判断为null之后在加锁之前发生线程切换

public class SingleDemo1 {
// 定义实例对象
private static SingleDemo1 INSTANCE;

// 私有构造器
private SingleDemo1(){}

// 定义获取单例对象的方法
public static SingleDemo1 getInstance() {
// 判断实例对象为null,则创建对象
if(INSTANCE == null) {
// 同步代码块,在此可能发生线程切换,即使加锁也是在判空之后
synchronized(SingleDemo1.class) {
// 创建对象
INSTANCE = new SingleDemo1();
}
}
// 返回对象
return INSTANCE;
}
}

运行结果:

volatile双重验证【推荐使用】

在面试时可以直接甩出,面试官必然满意,通过双重检查机制,并且使用volatile修饰单例对象,最好最安全的方式,强烈建议使用

public class SingleDemo1 {
// 定义实例对象
private volatile static SingleDemo1 INSTANCE;

// 私有构造器
private SingleDemo1(){}

// 定义获取单例对象的方法
public static SingleDemo1 getInstance() {
// 第一次验证:判断实例对象是否为null,不是null就不需要竞争锁,直接返回,提高性能
if(INSTANCE == null) {
// 同步代码块
synchronized(SingleDemo1.class) {
// 第二次验证:再次判空,防止加锁之前发生线程切换
if(INSTANCE == null) {
// 创建对象,非原子操作
INSTANCE = new SingleDemo1();
}
}
}
// 返回对象
return INSTANCE;
}
}

思考:为什么要加上 volatile 修饰实例呢?因为创建对象并不是一个原子操作,而是分为了以下三步:

  • 分配内存空间
  • 调用构造方法,初始化实例
  • 返回内存地址给引用变量

指令重排:

其中第二步和第三步有可能发生指令重排,即先将地址返回,再初始化实例,此时引用就不是null了,但是对象内部的属性,初始值等还没有完成赋值,对象内部的数据可能还是null,此时如果发生线程切换,线程2进来判断 INSTANCE 引用其实已经不为null,此时就会直接返回对象,但是该对象是一个残疾,在使用对象内部数据时就可能发生NEP即空指针异常

可见性:

  • 由于可见性问题,线程1在自己的工作内存中创建了实例,但此时还未同步到主存中;此时线程2在主存中判断instance还是null,那么线程2又将在自己的工作内存中创建一个实例,这样就创建了多个实例。
  • 如果加上了volatile修饰INSTANCE之后,保证了可见性,一旦线程1返回了实例,线程2可以立即发现Instance不为null。

由此可见:使用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 ,或者监视数据变化,实现轻量级同步
  • volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性,因为无锁不需要花费时间在获取锁和释放锁上,所以说它是低成本的
  • volatile只能作用于变量上,我们用volatile修饰实例变量和类变量,这样编译器就不会对这个属性做指令重排序
  • volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取
  • volatile提供了happens-before保证,对volatile变量的写入,happens-before保障所有其他线程对变量的读操作都是可知的
  • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性​

文章出自:​​石添的编程哲学​​,如有转载本文请联系【石添的编程哲学】今日头条号。

责任编辑:武晓燕 来源: 今日头条
相关推荐

2020-02-03 09:15:18

工具代码开发

2020-06-03 10:35:20

测试工具APP

2023-12-21 10:26:30

​​Prettier

2023-07-10 09:39:02

lambdaPython语言

2022-05-13 08:48:50

React组件TypeScrip

2021-08-16 15:18:20

机器学习人工智能计算机

2021-06-25 15:53:25

Kubernetes程序技巧

2022-03-11 12:14:43

CSS代码前端

2018-07-12 14:20:33

SQLSQL查询编写

2021-12-29 17:24:16

Kubernetes集群事件

2024-02-23 08:57:42

Python设计模式编程语言

2022-06-28 08:01:26

hook状态管理state

2022-03-08 06:41:35

css代码

2020-04-03 14:55:39

Python 代码编程

2012-02-29 13:39:18

AndroidGoogle

2018-07-11 20:29:19

数据库MySQLroot密码

2017-05-18 13:04:55

智能

2019-09-02 16:21:41

2022-09-08 07:32:56

JDK7程序管理

2020-02-05 14:30:36

MacWindows 10Linux
点赞
收藏

51CTO技术栈公众号