Java架构师学习之路之并发编程二: volatile关键字

本文深入探讨了Java中的volatile关键字,详细解释了其作用、原理及带来的总线风暴问题。volatile确保线程间变量的可见性,防止指令重排,但在高并发场景可能导致总线风暴。解决方案包括合理使用锁。此外,文中还讨论了volatile在单例模式中的应用及其避免指令重排的重要性。

经过上一章的学习,我们知道了JMM是围绕原子性、可见性、有序性进行设计的,但是JMM本身并不是一个实际存在的东西,他只是一种规范。那么Java种是如何实现原子性、可见性和有序性的呢?本章将会和大家一起学习volatile关键字,实现可见性和有序性。

volatile关键字、指令重排和内存屏障

volatile关键字

1. 什么是volatile关键字

volatile关键字用于解决java各个线程间数据不共享的问题,即:线程1和线程2同时对同一个值进行操作,则两个线程都会基于初始值进行操作,而不会将两个操作结果叠加。这在并发环境下是一个致命的问题,因此我们需要使用volatile关键将各个线程副本的值变成线程间可见。或者我们也可以使用锁进行串行化操作。

2. volatile关键字的作用
  1. 实现线程间的变量可见性。
  2. 阻止上下文的指令重排(懵比…)
3. volatile关键字的原理

可见性:volatile算是缓存一致性协议的具体实现,加上volatile则表示对改变量的操作遵从缓存一致性协议。线程1和线程2将flag = false读取到工作副本中,此时线程1进行flag = true赋值操作,同时线程2将会一致监听(嗅探)缓存一致性协议。在线程1中,该变量将会被标记“已修改”,并将该标记通过缓存一致性协议通知给线程2,线程2则会舍弃旧值,直接从主内存读取。但是,线程1修改了值以后只会先写回三级缓存,而不会及时写回主内存,因此线程2即便从主内存重新读取也会发现值仍然是旧值。此时线程2也不会一直等待线程1写回主内存,而是会进行指令重排,优先执行后续的指令。

验证可见性的代码如下:

private boolean flag = false;

    public void load() {
        System.out.println("load: 准备尝试获取更新后的flag...");
        while (!flag) {
        }
        System.out.println("load: 成功嗅探到更新后的flag...");
    }

    public void refresh() {
        System.out.println("refresh: 开始更新flag");
        flag = true;
        System.out.println("refresh: 更新flag完毕: " + flag);
    }

    public static void main(String[] args) throws InterruptedException {
        TestController test = new TestController();
        Thread tLoad = new Thread(() -> {
            test.load();
        });
        Thread tRefresh = new Thread(() -> {
            test.refresh();
        });
        tLoad.start();
        Thread.sleep(2000);
        tRefresh.start();
    }

线程1执行load方法,线程2执行refresh方法。先启动线程1,线程1会循环进行!flag的判断,如果我们不使用volatile,则此时不会遵循缓存一致性协议,因此将load方法将一直循环下去。

如果我们使用volatile定义flag:

private volatile boolean flag = false;

此时,由于使用了volatile,所以会遵守缓存一致性协议,线程1会舍弃工作副本的值,转而从主内存中查询最新值。在示例代码中,在循环中判断!flag后没有其他的操作,因此不会产生指令重排。而是此时会一直等待线程1将最新的值同步回主内存。
最终,将会输出load: 成功嗅探到更新后的flag...

4. volatile关键字引发的问题——总线风暴

volatile最关键的特性就是,会一直通过缓存一致性协议进行监听或嗅探,只要其他线程没有将最新值同步回主内存,嗅探就会一直占用总线。而我们上一章中讲述了一个知识:总线是有带宽限制的 (详情请参见上一章:Java架构师学习之路之并发编程一: 缓存一致性协议和JMM)
因此如果在高并发场景下,滥用volatile关键字则会出现总线被占满的问题。这个问题叫做——总线风暴

5. 如何解决总线风暴
  1. 不滥用volatile关键字
  2. 在某些业务场景下选择使用同步锁

拓展:单例模式与volatile

大家可能会奇怪,单例模式和volatile有什么关系。别着急,一起来看看下面的代码:

class TestSingleton {

    private static Object object;

    public static Object getObject() {
        if (object == null) {
            synchronized (TestSingleton.class) {
                if (object == null) {
                    object = new Object();
                }
            }
        }
        return object;
    }
}

从平常咱们的视野来看,这个懒汉单例模式是没问题的。
但是仔细考虑一下object = new Object()这行代码的执行原理就能发现问题:
执行原理如下:

// 1. 申请内存
allocate();
// 2. 创建对象
new Object();
// 3. 赋值
assign();

如果是超高并发场景下,new Object()assign()可能会发生指令重排问题,因此执行顺序可能会颠倒,导致申请完内存后直接赋值。如果此时有别的线程进行if (object == null)的判断,则此时由于allocate()已经申请了内存地址,并且内存地址通过assign()操作赋值给了object,所以object不为null,直接返回该object,如果紧接着调用object中的方法,则会抛出空指针问题,因为这个object指向的是空地址。

解决方法:

  1. 禁止指令重排。使用volatile修饰object变量则可以禁止指令重排
  2. 或者在2和3两步操作之间调用Unsafe类里面的这三个方法,手动添加内存屏障,以禁止指令重排(本例不适用)
    public native void loadFence();
    public native void storeFence();
    public native void fullFence();

下一章:Java架构师学习之路之并发编程三: Java同步器之Syncronized&Lock&AQS,让我们一起朝着架构师努力吧~~

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值