面试的时候是否被问过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时跳出循环,执行结束语句
运行结果:
当子线程修改flag值为false后,主线程的while循环并未停止,说明主线程并没有发现flag值被另外的线程修改
分析:
- 基于Java8使用的HotSpot虚拟机实现来说,静态变量 flag 存储于方法区中,被所有线程共享
- 当线程1启动时需要使用flag变量就会将其拷贝进线程1的工作内存,并且修改值为false
- 主线程使用该变量也是拷贝进自己的工作内存,当拷贝进去时flag变量值都为true,线程1睡眠3秒之后修改了值,并将值刷新进主内存
- 但是此时主线程循环使用的flag值并不是主内存中最新的,而是线程启动时就拷贝进来的值,所以循环并没有停止,也就是主线程并没有发现值被修改了,因为他没有去获取最新的值。
想要解决这个问题有两种方案
- 加锁
- 保障变量修改后被其他线程可见
加锁
我们对flag的判断进行加锁处理
运行结果:
分析:为什么加了锁就能获取到最新的值了呢?
因为线程进入 synchronized 代码块之后,它的执行过程如下:
- 线程获取锁
- 从主内存拷贝共享数据的最新值到工作内存中
- 执行代码
- 将修改后的值刷新到主内存
- 线程释放锁
volatile实现可见性
加了volatile关键字修饰的变量,只要有一个线程将主内存中的变量值做了修改,其他线程都将马上收到通知,立即获得最新值。当写线程修改一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存。当读线程获取一个volatile变量时,JMM会把该线程对应的本地工作内存置为无效,线程将到主内存中重新读取共享变量
解决方案:我们对变量flag使用 volatile 修饰,就可以保障线程在使用该变量时会从主内存获取最新值
运行结果:
发现当修改了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循环,结束程序
运行结果:这段程序的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的情况
原子性
所谓原子性是指在一次操作或多次操作中,要么所有的操作全部得到执行并且不受任何因素干扰而中断,要么所有的操作都不执行,而volatile不保障原子性
案例:开启100个线程每个线程对count值累加10000次,那么最后的正确结果应该是 1000000。
运行结果:发现多次运行结果并没有累加到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循环内加锁的话,线程内部每次循环都需要重新获取锁,反而影响性能
加锁方式2:
一般都是建议减小锁粒度,即只锁住操作共享数据的代码,也就是只锁住count++就行了,但是线程内有循环,这样每次循环都需要再获取一次锁,虽然synchronized是可重入锁,虽然不用判断是否被占用,可以直接获取到锁,但是还是仍然会执行 monitorenter 和 monitorexit指令,多少还是影响性能,不建议此种写法
执行结果:
加锁之后,就可以保障线程安全,可以获取到正确的结果,当然我们也可以使用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方法自增后返回新值
- 线程中没有加锁,仍然实现了线程安全
运行结果:
多次运行发现都是正确的结果,实现了线程安全
原子类源码
原子类中的值通过volatile修饰,保障数据可见性
incrementAndGet方法源码:
- 累加后获取值的方法调用了 unsafe 对象的getAndAddInt方法
- 在getAndAddInt方法中有一个do......while循环,判断条件调用了 this.compareAndSwapInt()方法
- 其实是通过CAS实现的原子操作
volatile写读建立的happens-before关系
上边我们说为了提高运算速度,JVM会编译优化,也就是进行指令重排,并发编程下指令重排会带来安全隐患:如指令重排导致的多个线程之间的不可见性,如果让程序员再去了解这些底层的实现规则,那就太难太卷了,严重影响并发编程效率
从Java5开始,提出了happens-before【发生之前】的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作既可以是在一个线程之内,也可以是在不同线程之间。 所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它瞎优化!
简单来说: happens-before 应该翻译成: 前一个操作的结果可以被后续的操作获取。就是前面一个操作变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。这就是volatile修饰变量可见性的原因
单例模式
单例模式大家应该熟悉不过了吧,就是保障程序中的某实例只存在一个,单例模式一般有8种写法,大概分为懒汉式、饿汉式、静态内部类和枚举,其中懒汉式的常见四种写法中有部分写法存在多线程安全问题,这里借此演示一下多线程懒汉式的安全问题,并使用volatile解决安全问题。
饿汉式
在真正需要单例对象的时候才创建对象,在Java程序中,有时候可能需要延迟一些高开销的对象创建操作,以提升性能,这时就可以采用饿汉式实现单例对象的创建。
判空创建【线程不安全】
这种方式是先判断是否为null,之后创建对象,再返回对象,这种方式是线程不安全的
单例类:
测试类:
- 开启10个线程获取单例对象
- 在线程中输出对象的哈希值,默认的toString方法就是返回对象的哈希值
- 如果哈希值一样则是同一个对象,如果不一样就是不同的对象
运行结果:
发现结果中有8条线程的哈希值一样,说明多线程下并非仅创建了一个对象,线程不安全
加锁解决【线程安全】
同步方法:可以使用 synchronized 修饰获取单例对象的方法,线程调用方法时就会去获取锁
同步代码块:缩小锁范围,将方法中共享数据【单例对象】锁住
运行结果:
加锁之后,就可以解决线程安全问题
问题:
代码中发现将if判断也锁起来了,理想的情况是只有对象是null的时候才去竞争锁,不是null的话就直接返回就行,显然上边的加锁方式太简单粗暴,影响性能
加锁线程不安全
有的小机灵鬼就是想到那我先判断是否为空,再加锁不就行啦,其实下边的这个代码也是线程不安全的,因为线程可能判断为null之后在加锁之前发生线程切换
运行结果:
volatile双重验证【推荐使用】
在面试时可以直接甩出,面试官必然满意,通过双重检查机制,并且使用volatile修饰单例对象,最好最安全的方式,强烈建议使用
思考:为什么要加上 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可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性
文章出自:石添的编程哲学,如有转载本文请联系【石添的编程哲学】今日头条号。