面霸的自我修养:volatile专题
阅读原文时间:2023年09月05日阅读:2

王有志,一个分享硬核Java技术的互金摸鱼侠

加入Java人的提桶跑路群:共同富裕的Java人

今天是《面霸的自我修养》第4篇文章,我们一起来看看面试中会问到哪些关于volatile的问题吧。

数据来源:

  • 大部分来自于各机构(Java之父,Java继父,某灵,某泡,某客)以及各博主整理文档;
  • 小部分来自于我以及身边朋友的实际经理,题目上会做出标识,并注明面试公司。

叠“BUFF”:

  • 八股文通常出现在面试的第一二轮,是“敲门砖”,但仅仅掌握八股文并不能帮助你拿下Offer;
  • 由于本人水平有限,文中难免出现错误,还请大家以批评指正为主,尽量不要喷~~
  • 本文及历史文章已经完成PDF文档的制作,提取关键字【面霸的自我修养】。

指令重排

难易程度

重要程度

面试公司:无

指令重排是一种优化技术,通过指令乱序执行(Out Of Order Execution,简称OoOE或OOE)提高处理器的执行效率和性能

以下内容摘自维基百科:

在计算机工程领域,乱序执行错序执行,英语:out-of-order execution,简称OoOEOOE)是一种应用在高性能微处理器中来利用指令周期以避免特定类型的延迟消耗的范式。在这种范式中,处理器根据输入数据的可用性确定执行指令的顺序,而不是根据程序的原始数据决定。在这种方式下,可以避免因为获取下一条程序指令所引起的处理器等待,取而代之的处理下一条可以立即执行的指令。

指令重排的基础建立在保证当线程环境下语义准确性的前提下,而不能保证多线程环境下的语义。


内存屏障

难易程度

重要程度

面试公司:无

内存屏障(Memory barrier),也称内存栅栏内存栅障屏障指令等,是一类同步指令,它使CPU或编译器进行操作时严格按照一定的顺序执行,即保证内存屏障前后的指令不会因为指令重排而导致乱序执行。

JVM中定义了7种屏障:

class OrderAccess : private Atomic {
public:
    static void     loadload();
    static void     storestore();
    static void     loadstore();
    static void     storeload();

    static void     acquire();
    static void     release();
    static void     fence();
}

其中最重要的是4种基本的内存屏障:

  • LoadLoad,指令:Load1; LoadLoad; Load2。确保Load1在Load2及之后的读操作前完成读操作,Load1前的Load指令不能重排序到Load2及之后的读操作后;
  • StoreStore,指令:Store1; StoreStore; Store2。确保Store1在Store2及之后的写操作前完成写操作,且Stroe1写操作的结果对Store2可见,Store1前的Store指令不能重排序到Store2及之后的写操作后;
  • LoadStore,指令:Load1; LoadStore; Store2。确保Load1在Store2及之后的写操作前完成读操作,Load1前的Load指令不能重排序到Store2及之后的写操作后;
  • StoreLoad:指令:Store1; StoreLoad; Load2。确保Store1在Load2及之后的Load指令前完成写操作,Store1前的Store指令不能重排序到Load2及之后的Load指令后。

至于acquire,release和fence,我们通过一张表格来表示它们与4种基本内存屏障的对应关系:

Tips

  • 内存屏障的定义位于orderAccess.hpp中,强烈建议阅读注释中的“Memory Access Ordering Model”
  • 重点理解4种基本内存屏障实现的功能即可,JVM源码对的部分了解即可,也别往下卷了,啥时候是个头啊;
  • Java中定义的内存屏障屏蔽不同操作系统间内存屏障的差异,使得不同的操作系统表现出一致的语义。

volatile是什么?

难易程度

重要程度

面试公司:腾讯

volatile是Java提供的关键字,可以用来修饰成员变量。volatile提供了两个能力:

  • 保证被修饰变量在多线程环境下的可见性
  • 禁止被修饰变量的指令重排

volatile保证可见性的例子:

private static volatile boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (flag) {
        }
        System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
    }, "block_thread").start();
    TimeUnit.MICROSECONDS.sleep(500);
    new Thread(() -> {
        flag = false;
        System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
    }, "change_thread").start();
}

删除修饰flagvolatile后,block_thread无法“察觉”到change_thread对flag的修改,因此会“沉迷”wile循环无法自拔。

**volatile**禁止指令重排的例子:

经典的双检锁单例模式,在下一题中解释不使用volatile带来的有序性问题。

public static class Singleton {
    private volatile Singleton instance;
    public Singleton getInstance() {
        if (instance == null) {
            synchronized(this) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile的实现原理

难易程度

重要程度

面试公司:百度,OPPO,丰巢,美团,乐信

volatile修饰的变量在生成字节码时会被标记上ACC_VOLATILE,当JVM读取到该标记时会按照JMM中定义的volatile语义处理。

以经典的双检锁单例模式为例:

public class Singleton {

    static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

编译后的部分字节码如下:

public class com.wyz.keyword.keyword_volatile.Singleton
static volatile com.wyz.keyword.keyword_volatile.Singleton instance;
flags:(0x0048) ACC_STATIC, ACC_VOLATILE
public static com.wyz.keyword.keyword_volatile.Singleton getInstance();
Code:
stack=2, locals=2, args_size=0
24: putstatic     #7      // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;
37: getstatic     #7      // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;

字节码中第7行和第8行的两个指令:putstaticgetstatic(非静态变量对应putfieldgettfield)用于操作静态变量instance,这两条指令的源码位于bytecodeInterpreter中,以下仅截取关键部分源码。

volatile变量的写操作

putstaticputfield指令:

CASE(_putfield):
CASE(_putstatic):
{
    if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {
        // static的处理方式
    } else {
        // 非static的处理方式
    }

    // ACC_VOLATILE -> JVM_ACC_VOLATILE -> is_volatile()
    if (cache->is_volatile()) {
        // volatile变量的处理方式
        if (tos_type == itos) {
            obj->release_int_field_put(field_offset, STACK_INT(-1));
        }else {
            // 省略了超多的类型判断
        }
        OrderAccess::storeload();
    } else {
        // 非volatile变量的处理方式
    }
}

JVM在处理完volatile类型变量的写操作后,加入OrderAccess::storeload保证volatile变量的写操作对所有后续的读操作可见

volatile变量的读操作

getstaticgettfield指令:

CASE(_getfield):
CASE(_getstatic):
oop obj;
if ((Bytecodes::Code)opcode == Bytecodes::_getstatic) {
    // static变量的处理
} else {
    // 非static变量的处理
}
if (cache->is_volatile()) {
    // volatile变量的处理方式
    //
    if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
        OrderAccess::fence();
    }
    if (tos_type == atos) {
        VERIFY_OOP(obj->obj_field_acquire(field_offset));
        SET_STACK_OBJECT(obj->obj_field_acquire(field_offset), -1);
    } else {
        // 省略了超多的类型判断
    }
} else {
    // 非volatile变量的处理
}

JVM在处理volatile变量的读操作前,加入OrderAccess::fence保证了volatile变量的读操作前所有对volatile变量的写操作已经对其它处理器可见

是否使用OrderAccess::fence,由常量support_IRIW_for_not_multiple_copy_atomic_cpu决定,该常量定义在globalDefinitions.hpp文件中:

#ifdef CPU_NOT_MULTIPLE_COPY_ATOMIC
const bool support_IRIW_for_not_multiple_copy_atomic_cpu = true;
#else
const bool support_IRIW_for_not_multiple_copy_atomic_cpu = false;
#endif

该常量指的是支持IRIW但不支持Mutiple Copy Atomic(MCA模型,Multi-copy Atomicity)的CPU,在这类CPU中volatile变量的getstaticgettfield指令需要使用OrderAccess::fence来保证语义的正确性,否则不需要使用。

Tips:文末参考资料中提供了关于IRIW和MCA模型的部分资料,感兴趣的可以自行阅读。


synchronized和volatile有哪些区别?

难易程度

重要程度

面试公司:无

synchronizedvolatile都是Java中的关键字,但它们能够修饰的范围不同:

  • synchronized用来修饰方法和代码块;
  • volatile用来修饰变量。

另外它们的作用也并不是完全相同:

  • synchronized对可见性,有序性和原子性都做出了保证;
  • volatile保证了被修饰变量的可见性,禁止被修饰变量的指令重排。

举个指令重排的例子:

int a, b, c, d;

int count;

public static void main(String[] args) {
    a += 1;
    b += 1;

    count += 1;

    c += 1;
    d += 1;
}

这段代码中,可能发生的顺序是:

当我们使用volatile修饰count后,count += 1;一定是发生在a += 1;b += 1;之后,发生在c += 1;d += 1;之前的。也就是说,即便不存在数据依赖,对变量a,b,c或d的操作也不能与对变量count的操作发生指令重排。

至于a += 1;b += 1;c += 1;d += 1;之间的指令重排,被volatile修饰的count并不关心。

d += 1;
c += 1;
b += 1;
a += 1;
count += 1;

Tips:synchronized与volatile在保证有序性上的原理是不同的。synchronized限制了同一时间只有一个线程可以执行被修饰的代码,因此能够保证有序性(虽然指令可能发生了重排序);volatile则是禁止了指令重排,来保证程序的有序性。


使用volatile变量就一定是并发安全的吗?

难易程度

重要程度

面试公司:美团

并不是的,并发编程中有3个问题:

  • 可见性问题
  • 有序性问题
  • 原子性问题

volatile关键字通过JVM实现的内存屏障保证了可见性和有序性,但没有对运算操作原子性做出任何保证

比如最常见的自增操作的例子:

private volatile static int count = 0;

public static void main(String[] args) {
    new Thread(() -> {
        for(int i = 0; i < 300000; i++) {
            count++;
            System.out.println("T1:" + count);
        }
    }).start();

    new Thread(() -> {
        for(int i = 0; i < 300000; i++) {
            count++;
            System.out.println("T2:" + count);
        }
    }).start();
}

执行上面的程序,最后的结果可能并不是预期的600000,而是一个小于600000的数字(如果电脑的CPU非常“屌”,可以试试调大循环的数字来复现这个问题),这是因为count++操作包含了3个动作,而这3个动作并不是原子性执行的:

  • 读取变量count
  • count进行自增操作
  • 将count写入工作内存

以上的操作可能被分开执行,导致出现如下情况:

简单解释下第7步操作,线程T1重新开始执行,发现缓存已经失效,此时线程T1重新读取内存中的数据,但由于T1已经执行过自增操作,因此不会重新执行自增操作,所以此时写入内存的仍然是线程T1阻塞前计算的结果。

Tips


如果本文对你有帮助的话,还请多多点赞支持。如果文章中出现任何错误,还请批评指正。最后欢迎大家关注分享硬核Java技术的金融摸鱼侠王有志,我们下次再见!