java中多线程之volatile详解
什么是volatile
volatile是JVM提供的轻量级同步机制
好,开始讲大家看不懂的东西了!
volatile有三大特性:
- 保证可见性
- 不保证原子性
- 有序性
傻了吧,这他妈都是些什么jb东西啊?别着急,我们一个一个来。
在学习volatile之前,我们先了解一下JMM。什么又是JMM?我只知道JVM。这他妈是啥东西啊?
JMM:java内存模型。jmm是一种抽象的概念,并不真实存在,它描述的是一种规范,通过这种规范定义了程序中的各个变量的访问形式。(仔细读,还是能读懂的)
JMM关于同步的规定(仔细读):
1.线程解锁前,必须把共享变量的值刷新回主内存
2.线程加锁钱,必须读取主内存的最新值到自己的工作内存
3.加锁解锁是同一把锁
知道看不懂,开始白话文解释!
JVM我们的java虚拟机运行程序的时候,是以线程为最小刻度的。而每个线程创建的时候,jvm就会为这个线程创建一个工作内存,该工作内存是私有的,只能被当前线程所访问。
而JMM内存模型中规定:所有的变量都储存在主内存中,所有线程都能访问,但线程对变量的任何操作(读取赋值等)都必须在工作内存中进行,首先要将主内存中的变量拷贝到自己的工作内存中,然后才能对变量进行操作,操作完成后再讲变量写会主内存中。
这里我们发现了一个问题:
先试想这样一个场景:现在有一个商品只剩下最后一个,如果两个线程同时进来抢,拿到了一个变量:int a = 1;(商品的数量) 这时候这个int a = 1;会拷贝出两份,分别存在于线程1的工作内存和线程2的工作内存。 我们知道,不同线程间是无法访问对方的工作内存的。
这个时候线程1 跑得快一点抢到了最后一个商品,把int a 的值-1了,然后通知快递部门上门来取货,把这最后一个商品拿走发货,然后把最新的a的值返回给主内存。现在主内存int a 的值等于0。
但对于线程2来说,它现在只看自己的工作内存,不看主内存,对于线程2来说,int a 的值现在还是1。所以它就觉得它也抢到了商品,其实这时主内存中的int a已经是0了,已经没有商品了。这时线程2把自己工作内存的int a 的值-1,然后通知快递部门来取货,快递来了发现你他妈的商品都卖完了我来取个啥?
上面就出现了超卖的情况,其根本原因就是:多个线程之间不能知道对方的对共享变量的执行情况,大家都是盯着自己的东西在做事。就像两个施工队在山的两边一起往中间打隧道,互相不知道对方的情况,最后两个隧道在山的中间完美错过。
好!那么有没有一个办法,只要有一个线程修改了主内存的变量的值以后,其他的线程能马上知道并获取到最新的值呢?
volatile的可见性
先看看没有使用volatile关键字的情况:
1.编写一个类,模拟售卖商品的过程,商品数量我们初始化为 Int a = 1;
class Shop{
int a = 1;
public void saleOne(){
this.a = a-1;
}
}
2.测试类
public static void main(String[] args) {
Shop shop = new Shop();
new Thread(()->{
System.out.println("线程A初始化");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
shop.saleOne();
System.out.println("线程A购买商品完成,剩余商品量:"+shop.a);
},"线程A").start();
while (shop.a == 1){
}
System.out.println("主线程,剩余商品量:"+shop.a);
}
这里有两个线程,线程A和主线程。 程序启动的时候:
- 线程A和主线程分别把shop 对象拷贝一份到自己的工作内存,对于这两个线程来说,shop 对象里的int a 属性的值都是1;
- 启动线程A
- 线程A等待3秒,再此3秒期间,主线程已经运行到while (shop.a == 1)这行代码,因为判断为true所以一直阻塞。
- 3秒过后,线程A调用方法,把自己工作内存中的int a 减 1,并把最新的int a = 0发送到主内存。
- 但我们发现,主线程还是一直处于阻塞的状态,对于主线程来说,它不知道int a 的值已经变为0,对主线程来说现在int a 的值还是自己工作内存中的1,所以 while (shop.a == 1)的判断永远为True。不会执行最后一行代码System.out.println(“主线程,剩余商品量:”+shop.a);
我们加上volatile关键字
class Shop{
volatile int a = 1;
public void saleOne(){
this.a =a-1;
}
}
测试代码不变
结果:
- 线程A和主线程分别把shop 对象拷贝一份到自己的工作内存,对于这两个线程来说,shop 对象里的int a 属性的值都是1;
- 启动线程A
- 线程A等待3秒,再此3秒期间,主线程已经运行到while (shop.a == 1)这行代码,因为判断为true所以一直阻塞。
- 3秒过后,线程A调用方法,把自己工作内存中的int a 减 1,并把最新的int a = 0发送到主内存。
- 由于volatile的可见性,此时对于主内存来说 int a的值已经由1变为了0, while (shop.a == 1)判断为False。程序就继续往下走,打印出了最新的int a的值:0。这时候商品数量为0之后,我们就不会再出现超卖的情况了
volatile的原子性(不保证)
原子性什么意思呢?
也就是完整性,比如一个线程在做一件事的时候,期间不能被加塞或分割,需要整体完整,要么同时成功要么同时失败。
大白话翻译:同一个方法,在一个线程没有执行完之前,其他线程必须给我等着。等我执行完了再放第二个线程进来。以免线程1的操作被线程2给覆盖了。比如synchronized,就保证了原子性。
给我们的Shop类创建一个增加商品库存的方法(每调一次addGoods方法,Int a就+1):
class Shop{
volatile int a = 1;
public void addGoods(){
a++;
}
public void saleOne(){
this.a =a-1;
}
}
此时int a商品数量是加了volatile 修饰的,保证了不同线程之间的可见性!
测试:
public static void main(String[] args) {
Shop shop = new Shop();
for(int i = 0; i < 20;i++){
new Thread(()->{
shop.addGoods();
}).start();
}
//保证所有20个线程都跑完,只剩下2个线程(主线程和GC线程)的时候代码才继续往下走
//其中 Thread.yield() 方法表示主线程不执行,让给其他线程执行
while (Thread.activeCount() >2){
Thread.yield();
}
System.out.println("如果保证了原子性,应该的结果是本来的1+20 = 21,但实际的值:"+shop.a);
}
- 开20个线程去执行addGoods()方法
- 最后主线程把int a 的数值打印出来
结果让我们大失所望,每次执行程序得到的结果都不一样
这里我们知道,volatile不能保证程序的原子性。那为什么呢?
首先明确一点 a++操作不是原子性,它有三步:
- 获取主内存的当前值到自己的工作内存
- 进行+1操作
- 把最新值写回到主内存
尚且a++都不是原子操作,那我们平时的业务代码是不是更长,花的时间也更多?被其他线程覆盖的机会是不是也更大?
好,现在我们来看看上面的20个线程的例子怎么来分析!
- 加入现在有l两个线程几乎同时进入到addGoods()方法里面。
- 对于A,B两个线程而言,现在int a 的值都拷贝到各自的工作内存中,值都=1。
- 现在线程A开始执行a++操作,底层获取到当前值,然后+1,得到值为2,准备把最新值写入到主内存
- 这个时候由于多线程的机制,线程A在写入主内存之前被挂起了!
- 线程B开始执行了,成功的把int a 从加到2,写入主内存,现在主内存的值是2
- 线程A现在又被唤起,完成第3步没有完成的操作,把线程A自己工作内存中的2写入到主内存。
- 但现在主内存本来就是2,线程A由于在执行底层的++操作,没有机会去读取到最新的值。
以上!就是整个代码运行流程,解释了volatile为什么不能保证原子性。我知道很多同学还是没看懂,别急,文章最后会有更直观的例子(单例模式中的线程安全问题),一看就明白了
现在我们想一想,怎么解决volatile这个缺点呢?怎么实现原子性?
- 1,在addGoods方法加同步锁synchronized
- 2, AtomicInteger原子类
我们讲第二种:
修改我们的Shop类
class Shop{
AtomicInteger atomicInteger = new AtomicInteger(1);
public void addGoodsByAtomic(){
atomicInteger.getAndIncrement();
}
- 初始化原子类值为1
- 创建新方法,方法体让原子类自增1,整个过程是原子性的。
测试:
public static void main(String[] args) {
Shop shop = new Shop();
for(int i = 0; i < 20;i++){
new Thread(()->{
shop.addGoodsByAtomic();
}).start();
}
while (Thread.activeCount() >2){
Thread.yield();
}
System.out.println("如果保证了原子性,应该的结果是本来的1+20 = 21,但实际的值:"+shop.atomicInteger);
}
结果正确:
为什么原子类保证了原子性?这个设计到CAS锁。看我关于CAS的博客就懂了哈!
volatile的有序性(禁止指令重排)(了解)
这是什么鸡巴东西?
我们写的java代码,为了提高性能,在编译器和处理器中往往会进行指令重排,例如我写的某一行代码在23行,当经过编译过后这行代码在150行。
多线程环境中,由于编译器重排的原因,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
简单来说volatile避免了指令重排,也就避免了多线程中可能产生的问题。
volatile的运用场景(重点)
单例模式:
public class type3 {
private static type3 type;
private type3(){}
private static type3 getInstance(){
if(type == null){
type = new type3();
}
return type;
}
}
上面是一个线程不安全的单例模式,我们可以加上一个synchronized :
public class type4 {
private static type4 type;
private type4(){}
private static synchronized type4 getInstance(){
if(type == null){
type = new type4();
}
return type;
}
}
但synchronized把整个方法都锁了,在高并发的情况下,太重了。并发性下降了,吞吐量下降了。
所以出现了效率最高,也安全的单例模式写法:双重检查!
public class type5 {
private static type5 type;
private type5(){}
private static type5 getInstance(){
if(type == null){
synchronized(type5.class){
if(type == null){
type = new type5();
}
}
}
return type;
}
}
大家觉得上面的代码有没有什么问题?
我来梳理一下。
- 现在有A B两个线程同时进来,都通过了第一次检查。现在到达了synchronized同步锁外面
- A线程运气好,被先放进去了,再次检查发现type确实为null,好,放行
- A线程new了一个实例出来,这是把这个最新的实例返回给主内存,主内存的对象变量从Null变为有值
- A线程完成,B线程被放synchronized开始进行B线程的第二次检查
- 但由于type5 变量没有volatile修饰,所以线程B不能马上获取到最新的值,它不知道现在对象已经被new出来了,在线程B自己的工作内存了对象依然为null。
- B线程通过第二次检查,又new了一个对象出来。单例的目标没有达成,上面的代码失败。
所以我们要给变量加上volatile关键字:
private static volatile type5 type;
好了 基本已经讲完,欢迎大家评论区指出不足,一起学习进步!
大家看完了点个赞,码字不容易啊。。。