Java Unsafe CAS、volatile与可见性

看JUC工具源码突然发现没用volatile关键字修饰这些变量,哼哼,有点意思。你是在诱惑我小叮当。
在这里插入图片描述
为什么count不需要使用volatile修饰,如何保证其可见性?

我们知道AQS实现的锁可见性依赖于对volatile state变量的读写触发内存屏障来保证。
在这里插入图片描述

究其原因,是count变量由default修饰,count只对当前类或package中子类方法可见。即,对count的读写方法中都触发了内存屏障操作,也因此得以保证count变量对当前线程可见。
在这里插入图片描述

究其原理请听我娓娓道来。

试一试

我们根据上述的理论来编写代码,以验证我们的猜想。

在此之前,你应该知道:

  1. 以下示例代码都可运行。
  2. Java内存模型的规则。
例1,volatile变量具有可见性!!!
	public static volatile boolean b = true;

	new Thread(() -> {
	    try {Thread.sleep(100);} catch (InterruptedException e) {}
	    b = false;
	}, "线程2").start();
	new Thread(() ->{
	    while (b) {     // 可见线程1对b的修改
	    }
	}, "线程2").start();

线程对volatile修饰的变量进行写:
(1)将工作内存修改了的缓存(不仅仅是该变量的缓存)都强制刷新回主内存。
(2)把其他CPU对应缓存行标记为invalid状态,那么在读取这一部分缓存时,必须回主内存读取。这样也
就保证了线程间的可见性。

例2,lock与unlock之间的变量被修改后,仍旧对其它线程不可见!!!(文末解释)

例如:

	public static boolean b = true;
	
	new Thread(() -> {
	    try {Thread.sleep(100);} catch (InterruptedException e) {}  // 先让线程2启动并缓存变量b
	    lock.lock();
	    b = false;  // 如果变量b不是volatile,这里修改解锁后,仍然对其它线程("线程2")不可见
	    lock.unlock();     // 解锁之后,线程2仍然会继续运行
	}, "线程1").start();
	new Thread(() ->{
	    while (b) {         // 线程2人就使用缓存中的b变量
	    }
	}, "线程2").start();     // 线程死循环

使用JUC的锁(ReentrantLock)、synchronized都不能保证加解锁范围内的非volatile变量对其它线程可见,需要显示地将变量声明为volatile,或其它线程显示的使用锁或CAS强制读取最新值。

例3,lock与unlock之间的变量被修改后,对有lock与unlock操作的线程可见!!!

例如:

	public static boolean b = true;
	
	new Thread(() -> {
	    try {Thread.sleep(100);} catch (InterruptedException e) {}
	    lock.lock();
	    b = false;  // 如果变量b不是volatile,这里修改解锁后,仍然对其它线程("线程2")不可见
	    lock.unlock();
	}, "线程1").start();
	new Thread(() ->{
	    while (b) {
		    lock.lock();         // 单纯的加锁与解锁,就能保证变量b的可见性
			lock.unlock();       // 解锁后,在下一次循环当前线程能读到b的最新值
	    }
	}, "线程2").start();

A类的成员变量b,线程1在lock块中修改A.b,在lock块离开前触发volatile的修改,会把修改的值从工作内存flush到主内存中,然后当线程2在lock块中读取A.b,工作内存会被设置无效,所以从主内存中读取它的实际值,这样完成了A.b的可见性。

例4,变量被修改后,对有lock与unlock操作的线程可见!!!
	public static boolean b = true;
	
	ReentrantLock lock = new ReentrantLock();
	new Thread(() -> {
	    try {Thread.sleep(100);} catch (InterruptedException e) {}
	    b = false;  // 未使用锁对b进行修改
	}).start();
	new Thread(() ->{
	    while (b) {              // 脏读,b=true
	        lock.lock();         // 从主存最新读
	        lock.unlock();
	    }
	}).start();

在进入锁时,会去主存中读取此时的最新数据,退出锁时将当前更新刷新到主存中。

Java volatile

字节码层面如何处理volatile?

编译源码(javac xxx):

    public volatile int a = 0;         // Java API volatile
    public int b = 0;

反编译(javap -v xxx):

  public volatile int a;
    descriptor: I
    flags: (0x0041) ACC_PUBLIC, ACC_VOLATILE

  public int b;
    descriptor: I
    flags: (0x0001) ACC_PUBLIC

注意:若需要反编译查看ACC_VOLATILE,必须使用非private修饰变量,不然反编译信息会忽略该部分字节码信息。

因此,我们可以得出,字节码层面使用ACC_VOLATILE来表示volatile变量。

JVM层面如何处理volatile?

我们知道 volatile 提供可见性与有序性支持,即内存读和内存写与禁止编译重排序。

可见性,有硬件提供的内存读写屏障:

  • Load:内存读,也就是加载操作(load),从内存读到寄存器。
  • Store:到内存写,也就是存储操作(store),直接从寄存器写入到内存。

JVM 为了避免编译器和处理器对代码或指令进行重排序,保证 as-if-serial 语义,于是使用了内存屏障。为了保证并发,在简单的硬件屏障读写上进行了细化:

  • LoadLoad:操作序列 Load1, LoadLoad, Load2,保证 1、2 顺序读如缓存。
  • StoreStore:操作序列 Store1, StoreStore, Store2,保证 1、2 顺序写到内存。
  • LoadStore:操作序列 Load1, LoadStore, Store2,保证 1、2 顺序读入缓存再写入内存。
  • StoreLoad:操作序列 Store1, StoreLoad, Load2,保证 1、2 顺序写入内存再内存读入缓存。

接着看看 JVM 层面是如何实现 Load Barrier 和 Store Barrier 的:

OpenJDK的/src/hotspot/share/interpreter/bytecodeInterpreter.cpp

读指令的实现(getfield、getstatic)

...
CASE(_getfield):
CASE(_getstatic):
{
	u2 index;
    ConstantPoolCacheEntry* cache;
    index = Bytes::get_native_u2(pc+1);
    cache = cp->entry_at(index);
	...
	if (cache->is_volatile()) {
		if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
	        OrderAccess::fence();       // 读之前插入万能内初屏障
	    }
	    switch (tos_type) {	            // 根据字段类型进行读	    	
	    	...
	    }
	}
	...
}

写指令的实现(putfield、putstatic)

CASE(_putfield):
CASE(_putstatic):
{
	u2 index = Bytes::get_native_u2(pc+1);
	ConstantPoolCacheEntry* cache = cp->entry_at(index);
	...
	if (cache->is_volatile()) {
		if (cache->is_volatile()) {
			switch (tos_type) {            // 根据字段类型进行写
				...
			}
		}
		...
		OrderAccess::storeload();          // 写之后插入万能内存屏障
	}
	...
}

根据OrderAccess::fence()的实现,我们可以看到:针对不同架构的处理器,OpenJDK 中提供了不同平台对 OrderAccess::fence() 的代码实现,屏蔽了底层硬件的差异,为 JVM 提供了统一的 CPP 层面的 API。

我们任意找一个 OrderAccess 的实现,比如来看Linux_x86上的实现

#ifndef OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP
#define OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP

static inline void compiler_barrier() {
  // 内嵌汇编,格式:__asm__ (汇编语句模板: 输出部分: 输入部分: 破坏描述部分)
  __asm__ volatile ("" : : : "memory");     // 编译屏障
  // volatile :告诉GCC编译器,禁止重排序
  // ("" : : : "memory"):告诉GCC编译器,禁止"memory"前后代码重排序、缓存作废,需要时再内存读
}

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

inline void OrderAccess::acquire()    { compiler_barrier(); }
inline void OrderAccess::release()    { compiler_barrier(); }

inline void OrderAccess::fence() {
   // always use locked addl since mfence is sometimes expensive 内存屏障消耗的资源大于locked指令
#ifdef AMD64  
  __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
  // StoreLoad 屏障
  // 对指定寄存器+0,空操作,为了使用lock而使用
  // x84平台,基于MESI,致使该缓存行中数据在其他CPU中失效
  __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  compiler_barrier();
}

inline void OrderAccess::cross_modify_fence_impl() {
  if (VM_Version::supports_serialize()) {
    __asm__ volatile (".byte 0x0f, 0x01, 0xe8\n\t" : : :); //serialize
  } else {
    int idx = 0;
#ifdef AMD64
    __asm__ volatile ("cpuid " : "+a" (idx) : : "ebx", "ecx", "edx", "memory");
#else
    // On some x86 systems EBX is a reserved register that cannot be
    // clobbered, so we must protect it around the CPUID.
    __asm__ volatile ("xchg %%esi, %%ebx; cpuid; xchg %%esi, %%ebx " : "+a" (idx) : : "esi", "ecx", "edx", "memory");
#endif
  }
}

#endif // OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP

综上,我们便知道 storeload 开销是四种屏障中最大的,且在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能(见代码)。

Java CAS

Java层面CAS API,Unsafe#CAS

在这里插入图片描述

JVM层面CAS

Java Unsafe#CAS对应的C++接口

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))

JVM中CAS对应的C++接口

Atomic::cmpxchg(x, addr, e)

Linux_x86_JVM平台中对CAS接口C++的实现:

#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
	 int mp = os::is_MP(); // multi processor的缩写,mp核心数
	 // 内嵌汇编
	 // volatile:禁用GCC编译优化
	 // LOCK_IF_MP:判断当前系统是否为多核处理器决定是否为cmpxchg指令添加lock前缀
	 // cmpxchgl:CPU原语,实现原子CAS功能
	 asm volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
		 : "=a" (exchange_value)
		 : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
		 : "cc", "memory");       // 同上内存屏障的实现
	 return exchange_value;
 }

可以得出结论,x86中Java CAS是通过lock+cmpxchg两条指令实现的。使得CAS兼具volatile读写的内存语义与原子性,即我们在前文中所说的CAS保证可见性与原子性

Java CAS的缺陷

  • ABA问题

    介绍的文章较多,也比较基础,不再赘述。

  • 没有编译支持(对例2的解释)

    我们观察C++中Atomic::cmpxchg实现便可知,多核CPU中光用lock+cmpxchg指令来保证原子、可见和禁止重排序是不够的,还需要C++在asm后紧跟volatile提供禁止GCC重排序功能。而Java volatile与Java CAS相比,Java CAS还缺失了Java volatile具有的禁止重排序功能,为什么???Java CAS用到了C++的volatile吗?没错,至少在JVM的C++层面实现是相同的,但是Java属于编译+解释型语言,Java在编译具有volatile的属性get/set时会自动添加能触发内存屏障的指令!!!而对于Java CAS操作的变量,你就只能每次都手动触发内存屏障!!!使用CAS操作属性,稍有疏漏,必将埋下BUG,如例2。因此,我们Java CAS总是和volatile搭配使用,以获得Java层面的编译支持,提供完整的JMM happens-before语义。

    如果你运行过例2的代码后,想必你会发现一些问题:例2中"线程2"一直死循环。

    疑问:聪明的你应该会想起,ReentrantLock加解锁中涉及了CAS操作,而CAS操作会清除工作区域的缓存,然而为什么对"线程2"来说却无效呢?

    动手试一试:将"线程1"中换成CAS来修改变量b,"线程2"同样不可见b。

    主要原因:"线程2"中并没有涉及到任何内存屏障操作,于是"线程2"对变量b便一直缓存读,这也就是导致"线程2"一直循环的原因。也就是说,被CAS操作的变量,以及lock~unlock之间的变量,编译器在编译时是没有额外增加内存屏障操作的。如果读取被CAS修改的变量或lock~unlock之间的变量,且读取线程也没有触发对内存屏障的操作,那么这部分被读取的变量对该线程来说是不具备可见性的。

    解决方法:volatile具有编译支持,编译器会对volatile变量的读写自动添加内存屏障,对这部分变量我们对齐添加volatile修饰,也就是CAS要与volatile结合使用

    或者通过其它方式在读取线程手动触发一次内存屏障的操作。比如,lock|unlock、synchronized、CAS等。

——附——

C++ volatile
  • 与外部硬件交流(比如 DMA 或 MMIO)——引用设备寄存器的变量时使用,避免编译优化器无意删除重要的访问。

  • 禁止编译重排序。

    参考:https://docs.oracle.com/cd/E36784_01/html/E36860/codingpractices-1.html

asm volatile (“” : : : “memory”)

给 GCC 内嵌汇编加上一个内存破坏性描述符,禁止优化、禁止缓存读。

  • 编译内存屏障:这条语句后面的语句不能被编译器优化到前面去,前面的不能被优化到后面去——这是所有内嵌汇编都具有的效果。

  • 禁止所在函数被inline:不管开多高的优化等级, 这也是所有内嵌汇编都具有的效果。但是这一点目前也渐渐被gcc新版本打脸了。。

  • 强制内存读:重点,这条语句之后,函数返回前的所有流程(也就是包括了循环语句 跳转到这条语句前面去的场景),对所有地址的访问(包括局部变量地址)都必须去内存里去 load,而不能使用这条语句前寄存器缓存的结果(当然,某些寄存器的结果编译器还是认为还是有效的,例如堆栈寄存器,不然代码没法走下去了)。

    参考:https://www.zhihu.com/question/66896665/answer/249586507

lock指令

Intel手册——LOCK前缀使得cache写入内存,同时使得其它核的cache无效化。

  • 当前处理器缓存行的数据写回主存
  • 缓存一致性协议生效:在其他处理器缓存了该数据的缓存行的无效化

参考:https://stackoverflow.com/questions/27837731/is-x86-cmpxchg-atomic-if-so-why-does-it-need-lock/44273130#44273130

Java volatile

在Java中的语义是:变量读写原子性(例:64 Long)、禁止指令重排序(编译与处理)、跨线程内存可见性(Happens-Before)。

由 C++ volatile、asm volatile (“” : : : “memory”) 和 lock 联合实现,因此 Java volatile 也就实现了:

  • (弱)原子性(lock:锁总线),32 位机器上的 long | double 读写原子(64 无需),且不能保证 Java 层面的原子性。观察 x84_64 的实现可以发现,volatile 甚至连单条字节码的读写原子都不能保证(即,用 i++ 生成 3 条字节码举例是错误的❌),只能保证底层代码执行时的单次读写可见性。例如字节码 putfield | getfield 操作 volatile 变量中,C++用了大段落代码来实现,且并未加锁!这期间完全有可能与其它线程并发执行,因此 volatile 想要得到完整的原子性支持必须搭配 synchronized 使用!synchronized 基于互斥锁思想,提供线程安全的临界点。且 synchronized 与 volatile 互补,synchronized 不能为临界区中非 volatile 变量提供编译支持,volatile 能提供编译支持,保证线程之间对该变量的读写可见。
  • 禁止重排序(fence:lock+compiler_barrier、compiler_barrier:asm、volatile)。
  • 可见性(lock + 【MESI,MOSI,Synapse,Firefly 及 DragonProtocol等】),通过总线锁 + 缓存一致性协议实现。
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值