Java中的volatile和DCL记录

70 阅读6分钟

为什么会突然记录,这个两个玩意,事情起源于探索 SynchronousQueue 的简单使用。对SynchronousQueue的探索又来自线程池的使用理解。

至此,在看到网上很好的博客,记录下来。

Java中的volatile

1.volatile的内存语义

  • 内存可见性

volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销。

为了能比较清晰彻底的理解volatile,我们一步一步来分析。首先来看看如下代码


static boolean flag = true;

public static void testVolatile() {
    new Thread(()->{
        System.out.println(Thread.currentThread().getName()+"\t -----come in");
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = false;
    },"t1").start();

    new Thread(()->{
        System.out.println(Thread.currentThread().getName()+"\t -----come in");
        while (flag) {

        }
        System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
    },"t2").start();
}

单元测试结果:

@Test
public void testVolatile() {
    CountDownLatch latch = new CountDownLatch(1);
    System.out.println("start");
    ThreadPoolDemo.testVolatile();
    try {
         latch.await(1,TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

image.png

上面这个例子,模拟在多线程环境里,t1线程对flag共享变量修改的值能否被t2可见,是否输出 “-----flag被设置为false,程序停止” 这句话?

结果显而易见.线程2并不知道flag被改了.

这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,因为先行发生原则之happens-before,自然是可以正确保证输出的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程t1的修改,对于线程t2来讲,是"不可见"的。也就是说,线程t2此时可能无法观测到flage已被修改为false。那么什么是可见性呢?

  • 所谓可见性,是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。很显然,上述的例子中是没有办法做到内存可见性的。

  • volatile的内存语义 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取,从而保证了可见性。

volatile变量有2大特点,分别是:

可见性

有序性:禁重排!

重排序是指编译器和处理器为了优化程序性能面对指令序列进行重新排序的一种手段,有时候会改变程序予以的先后顺序。

不存在数据以来关系,可以重排序; 存在数据依赖关系,禁止重排序。 但重排后的指令绝对不能改变原有串行语义!

那么volatile凭什么可以保证可见性和有序性呢??--->内存屏障Memory Barrier

2.内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。(看不懂不要紧,以后看,知道那个意思就行)

  • Java中的内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。

内存屏障之前 的所有 写操作 都要 回写到主内存, 内存屏障之后 的所有 读操作 都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。 粗分主要是以下两种屏障:

  • 读屏障(Load Memory Barrier) :在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据。
  • 写屏障(Store Memory Barrier) :在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中。

image.png

PS:测试的时候发现,线程Sleep后能读到,目前还未深究。可能Sleep之后内存存在刷新机制吧。

因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。 来看看源码:sun.misc.Unsafe.java

image.png 主要包括以上三个方法,接着在对应的Unsafe.cpp 源码中查看:

image.png 在底层C++代码中发现其底层调用的是OrderAccess类中的方法

image.png 我们发现其又细分了四种屏障,四大屏障分别是什么意思呢?

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证Load的读取操作在load2及后续读取操作之前执行
StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStoreLoad1;LoadLoad;Store2在Store2及其后的写操作执行前,保证Load1的读操作已读取结束
StoreLoadStore1;StoreStore;Load2保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行

接下来结合底层linux_86代码来分析--我是没看懂,先留着

image.png

3.happens-before 之 volatile 变量规则

image.png

给大家讲解一下上表,主要有以下三种情况不允许重拍~

  1. 蓝色:当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。

  2. 红色:当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会重排序到volatile写之后。

  3. 绿色:当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

其他情况都允许被重排。

4.Demo

将上述代码中的falg加上修饰符 volatile后再次进行测试

static volatile boolean flag = true;

测试结果:

image.png

若不加volatile修饰为何t2 看不到被 t1线程修改为 false的flag的值?

  • t1线程修改了flag之后没有将其刷新回主内存,所以t2线程获取不到。
  • t1线程将flag刷新到了主内存,但是t2一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。

使用volatile修饰共享变量后,被volatile修饰的变量有以下特点:

  • 线程中读取时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存;
  • 线程中修改了工作内存中变量的副本,修改之后回立即刷新到主内存。

5.无原子性

Demo 编写一个带同步锁的函数

public int number = 0;
public synchronized void addPlusPlus() {
    number++;
}

在我们的单元方法中开启一个线程执行 number++的方法,然后等待2秒,大家的预期值是不是10000呢? 单元测试代码:

@Test
public void testSync() {
    ThreadPoolDemo demo = new ThreadPoolDemo();
    for (int i = 0; i < 10; i++) {
        new Thread(()->{
            for (int i1 = 0; i1 < 1000; i1++) {
                demo.addPlusPlus();
            }
        }).start();
    }
    try {
        TimeUnit.SECONDS.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("final Num:"+demo.number);
}

测试结果:

image.png

接下来 使用 volatile修饰number,并去除函数的同步锁修饰符

public volatile int number;
public void addPlusPlus() {
   number++;
}

测试结果:

image.png

那为什么会出现不预期的结果呢?

​ 对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是在多线程环境下,“数据计算” 和 “数据赋值” 操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存的最新值,操作出现丢失问题。即 各线程工作内存和主内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。

禁止重排案例

    int i = 0;
    volatile boolean flag = false;
    public void write() {
        if (flag) {
            System.out.println("---i=" + i);
        }
    }

在本案例中 变量i 和 flag 语句的执行顺序如果被重排的话就会影响结果,存在数据依赖关系,禁止重排序。 解读一下逻辑:首先读取volatile变量,然后读取普通变量。属于上述禁止重排的蓝色情况。 说了这么多,那么在什么时候使用 volatile 呢?

  1. 以,但含复合运算赋值不可以
  2. 状态标志,判断业务是否结束
  3. 开销较低的读,写锁策略
  4. DCL双端锁的发布

这里要提提 DCL双端锁---继续写!!!

DCL双端锁

未使用volatile下的双重检查锁

public class DoubleCheckedLocking{
    private static Instance instance;
    public static Instance getInstance() {
        // 第一次检查 如果有多个线程到达而还没初始化完毕 显然都可以通过第一次检查
        // 其实去掉第一重检查对于多线程的线程安全不影响 也可以实现单例模式
        // 好处是性能问题
        // 如果没有第一次检查 每次有线程调用getinstance,都会执行同步锁操作很耗费性能
        // 加上第一次检查的话 只有第一次未初始化的时候会执行一次同步锁
        // 初始化之后就直接return
        // 以后就不用进入同步代码块了 性能提升
        if (instance == null) {
            // 要保证是单例,加锁 保证只有第一个线程创建完第二个线程才能获取instance
            // 此时 有一个线程进入同步代码块 其他线程等待
            synchronized (DoubleCheckedLocking.class) {
                // 第二次检查
                // 如果没有第二次检查的话 那么其他没抢到等待锁线程还是可以调用new Instance语句
                // 这样就违背了单例模式
                if (instance == null) {
                    // 问题的根源就出在这里
                    instance = new Instance;
                }
            }
        }
        return instance;
    }
}
  • 双重检查锁定了第九行,创建了一个对象,这一行可以分解为以下三行伪代码
// 分配对象的内存空间
memory = allocate();
// 初始化对象到内存空间
ctorInstance(memory);
// 设置instance指向刚才分配的内存地址
instance = memory;

因为在单线程中重排序不会影响执行结果,所以第二步和第三步可能会被重排序

重排序后:

memory = allocate();
// 有指向了
instance = memory;
// 此时对象还没有被初始化!
// 在这个时候 第二个线程来了 instance已经不为null了(指向了内存地址),但是实际上是空的
// 对于 == 比较的是内存地址 b线程以为初始化完成了 直接return instance了,但是没完成初始化
// 出事了
ctorInstance(memory);

两种解决方式:

1、将instance改为volatile变量

public class SafeDoubleCheckedLocking {
    private volatile Instance instance;
    public Instance getInstance() {
        if (instance == null) {
        synchronized(SafeDoubleCheckedLocking.class) {
            instance = new Instance;    
        }
    }
    return instance;
}

volatile修饰之后:

  • 大众式的讲解:(绝大多数面试官都认为是volatile禁止了new对象里面三行代码的重排序):

    • 第二行代码和第三行代码不会重排序
  • 真实的情况:

    • 因为new instance是一个jvm指令码,对应的是 new 指令,volatile能够保障单个jvm指令的原子性,所以此处,new instance 相当于是volatile写,会在 new instance 前加 storestore屏障,后加storeload屏障,然后b线程就必须在 storeload 屏障后面读取,实际上new对象三个指令的重排序依然可以发生,真正的重排序是在外层的内存屏障控制

2、基于类初始化的解决方案

	public class InstanceFactory {
        private static class InstanceHolder { 
            // new了一个静态对象 没有使用
            public static Instance instance = new Instance();
        }

        public static Instance getInstance() {
            // 这里将导致InstanceHolder类被初始化
            return InstanceHolder.instance;
    }

它是如何保证对象只创建一个的?

  • 在执行类的初始化的时候,JVM会获取一个初始化的锁
  • 这个锁可以同步多个线程对同一个类的初始化

image.png

  • 也就是真的给了一个锁,锁能保证初始化过程的原子性
  • 锁能保证任何复合操作的原子性,也就是这三步,哪怕他们指令重排序
  • new 几个都能保证
  • 但是它是一个初始化对象的锁
  • 在这时候B如果想要访问,就要等待A释放锁,但是释放的时候锁的内存语义已经保证了类初始化完毕

Volatile 原文链:blog.csdn.net/m0_49183244…

DCL原文链接: www.cnblogs.com/coolzqy/p/d…