【Java核心原理】-> JUC -> 详解 volatile

Ⅰ volatile 关键定义

首先要清楚 volatile 的定义:

volatile是Java虚拟机提供的轻量级的同步机制。

它一共有三大特性:

1. 保证可见性;
2. 不保证原子性;
3. 禁止指令重排。

这就是关于 volatile 必须必须要明确的四句话,说它轻量级,可以理解成一个乞丐版的 synchronized。 接下来的文章,我们就来通过代码的方式验证 volatile 的三大特性,明确好它定义的含义。

Ⅱ volatile 特性详解与验证

A. 可见性

在解释清楚 volatile 之前,我们有必要先明确好 JMM(Java 内存模型)是什么,它的特性又是什么。

JMM(Java Memory Module)本身是一种抽象的概念,并不真实存在。它描述的是一组规范或者规则,通过这组规则定义了程序中各个变量的访问方式。

它也有三大特性: 可见性,原子性,有序性。

并且JMM 中关于同步,有以下三个规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存;
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存;
  3. 加锁解锁是同一把锁。

这里面一共出现了两个比较麻烦的词,一个是主内存, 一个是工作内存,我们来看一下它们都是什么。

先来看一段话:

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但是线程对变量的操作(读、赋值等)都必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存, 不能直接对主内存中的变量进行操作。各个线程中的工作内存中存储着主内存中的变量拷贝副本,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

在这里插入图片描述
这点非常好理解,变量存储在主内存中,第一个线程要修改这个变量,必须把这个变量先复制出来,到自己的工作内存中,然后在工作内存中修改,最后将修改好的结果写回到主内存中,这样原来的值就被现在新写的值替代了,再有其他线程来读这个数据,得到的就是新写的这个值,这就是JMM的可见性。

接着我们来验证一下,volatile 是否真的保证了 JMM 的可见性。

先来看看没有 volatile 的情况。

我们先来写一个线程操作资源类,add() 方法会将 num 值改为 710.

在这里插入图片描述
主函数调用也很简单,直接 new 一个线程,命名为 A,它会调用资源类的 add() 方法,修改 num 值。在线程启动时会输出一个 come in,然后等待3秒,再修改num值,修改完成后再输出一个 update number
在这里插入图片描述
紧接着,我们写一个主线程也就是main线程要做的工作。

在这里插入图片描述
就是一个空循环,如果线程A在修改完num之后,主线程发现了num值被更改,就会跳出循环,输出下面的一行话。
在这里插入图片描述
结果是主线程一直结束不了,因为在循环中出不来,可见线程与线程之间的工作的独立的,A线程已经做完了工作,但是无法通知主线程,主线程就只能傻傻地等在那里。所以线程与线程之间是不可见的

好,现在我们加上 volatile 看看会怎么样。

在这里插入图片描述
注意,这里我只做了这一个修改。
在这里插入图片描述
主线程就从循环中跳出来了,这就证明了 volatile 的保证可见性,一个线程对主内存的变量做了更改,另一个线程立马就可以收到。

package com.tyz.juc.volatileDemo;

import java.util.concurrent.TimeUnit;

class MyData {
    volatile int num = 0;
    
//    没有可见性
//    int num = 0;

    public void add() {
        this.num = 710;
    }
}

/**
 * @author tyz
 */
@SuppressWarnings("AlibabaAvoidManuallyCreateThread")
public class VolatileVisibility {
    public static void main(String[] args) {
        MyData myData = new MyData();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.add();
            System.out.println(Thread.currentThread().getName() + "\t update number" + myData.num);
        }, "A").start();

        while (myData.num == 0) {
            //main 线程在这里等待,只有当 A 线程把num值修改了,主线程才会跳出循环
        }
        System.out.println(Thread.currentThread().getName() + "\t see the update number");
    }
}

B. 不保证原子性

a. 关于原子性的验证

在前面的线程操作资源类中,我再添加一个方法,就是当前值加1。
在这里插入图片描述
在主函数里,我们写二十个线程,每个线程都对调用一千次它。

在这里插入图片描述
按照我们想要的结果,应该最终num为20000,但是看最后的结果。
在这里插入图片描述
加了 volatile ,num 并没有被加两万次。所以可以知道,num++ 这个操作在多线程下是非线程安全的,也证明了 volatile 并不能保证原子性。

那为什么会发生这种事呢?

前面说JMM的时我们说了线程要操作变量,需要把主内存中的变量拷贝到自己的工作内存中,操作完再重新写回到内存中。
在这里插入图片描述
假设线程1、2、3同时拿到主内存中最初的0, 然后各自加1,这时候线程1正要写到主内存中,突然被挂起了,轮到了线程2,这时候还未来得及通知其他线程,线程1活了,直接把它加的值覆盖了上去,本来线程1和线程2应该是加了两次,但是这样就只加了一次,最后的状况就是因为发生了很多次这个事,所以最终num并没有被加两万次,有好多次增加被覆盖掉了。

再进一步,为什么线程可以在要把值写回主内存时突然被挂起呢?我们来看一下 num++ 的字节码。

在这里插入图片描述
可以看到,要执行一个一行的 num++ 操作,在字节码中需要四条语句,线程就是在最后一条 putfield,也就是将加了的数据写回给主内存时被挂起了,所以才会出现上面的情况。

不保证原子性,就可能发生写丢失的情况。

b. 原子性问题的解决

① synchronized

第一个比较简单能想到的方法当然就是加 synchronized

在这里插入图片描述
这样肯定能得到正确的数据了,但是这相当于是杀鸡用牛刀,synchronized 是一个很重的锁,没必要用来解决加1这种问题,

② AtomicInteger

AtomicInteger 是 JUC 下的一个类,我们可以扫一眼它的api

在这里插入图片描述
getAndIncrement 就当是 num++,而incrementAndGet就相当于是 ++num,它的api都比较简单,大家可以去瞄一眼。这里我们再用它写一个加1的方法。

在这里插入图片描述
AtomicInteger无参构造默认是0,所以和num是一样的。

在这里插入图片描述
用相同的流程执行这两个方法,我们来看一下输出结果。

在这里插入图片描述
可以看到 AtomicInteger 执行了正确的结果。

关于 AtomicInteger 的底层原理 CAS,我会在下一篇文章中写清楚。

C. 禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下三步。

在这里插入图片描述
处理器在重排的时候必须要考虑指令之间的数据依赖性

单线程环境中,指令重排是绝对没有问题的,程序最终执行结果和代码顺序执行结果一样。

但是多线程环境中,由于线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

我用一张图来说明一下指令重排可能会在多线程环境中导致的问题。

在这里插入图片描述
所以编译器的指令重排是要看具体情况的,有时候不该优化的优化了,就有可能导致上图中的数据不一致的问题。

比如下面这个case

在这里插入图片描述
由于method1a = 1flag = true 之间是不存在依赖关系的,指令重排后有可能先赋值的 flag,这时候有的线程还没有等到 a = 1 可能就执行了method2 ,在 a = 0的时候执行了加 5 的操作。

所以多线程环境下,指令重排后的结果是不确定的。volatile 就可以防止这种不确定性,用它修饰变量,就可以避免被指令重排导致结果不确定。

volatile 之所以能做到禁止指令重排,是因为在指令中插入了内存屏障(Memory Barrier)

内存屏障又称为内存栅栏,是一个CPU指令,它的作用一共有两个。

  1. 保证特定操作的执行顺序;
  2. 保证某些变量的内存可见性。(利用该特性实现volatile内存可见性)

如果在指令间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
在这里插入图片描述

Ⅲ 单例模式

A. 普通单例的问题所在

我们先来写一下单线程环境下经常写的一种经典的单例。


这里我在构造方法中写一句话,我们知道如,如果是单例模式的话,构造方法必然只会执行一次,这句话也就只会被打印一次。

在这里插入图片描述
在主函数中,我直接调用它六次,如果是单例的话,这三个判断一定都是true,因为得到的都是一个对象。

在这里插入图片描述
运行一下,结果并没有错,并且构造方法确实只执行了一次。

在这里插入图片描述
好,现在我们用多线程获取一下单例试试。

在这里插入图片描述
一共就十个线程,结果执行了五次单例的构造方法,这是不是问题就很严重了?

要解决这个问题,当然还是可以直接加synchronized
在这里插入图片描述
可以,但是没有必要,因为我们前面已经说过了,没必要为了一个简单的方法把整个代码块都锁上,这样并发性会大大降低。

B. 高并发下的单例——DCL

DCL(Double Check Lock),双端检锁机制。

DCL非常简单,我们说synchronized加在方法外面直接解决问题,但是会导致并发性下降,现在我们看看DCL下的synchronized该怎么用。

在这里插入图片描述
这样看貌似问题已经解决了,通过两次判断,很好地实现了单例,从运行结果上看也是对的。但是这样就可以了吗?

在前面我们说了编译器的指令重排,要知道,上面的代码是有可能被编译器优化进行重新排序的,所以DCL看上去好像非常完美,但是还是有漏洞,会造成线程安全问题。

我用一个伪代码来表示初始化对象instance = new Singleton()的过程:

1. memory = allocate(); /分配对象内存空间
2. instance(memory);    /初始化对象
3. instance = memory;   /设置instance指向刚分配的内存地址,此时instance!=null

步骤2 和 步骤3 不存在依赖关系,无论是重排前还是重排后,程序的执行结果在单线程环境下是没有变化的,所以这种重排是被允许的。

但是在多线程环境下,如果调换了 2、3 的顺序,明明instance引用的对象还没有初始化,但是instance已经指向了该对象,判断出 instance != null,这样就会造成线程安全问题,因为返回的 instance空有一个地址,但是是没有对象的。

因而,我们必须要用 volatile 修饰 instance。最终高并发下的单例模式代码就是下面这样:

	private volatile static Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值