Java 多线程并发【6】volatile

并发基础中我们分析了并发问题的根源是三个问题:原子性、可见性和有序性问题。简单回顾一下这三个特性:

  • 原子性:一个具有原子性的操作应该是不可以被打断的,要么全部不执行,要么全部执行,并且中途不会被打断。
  • 可见性:CPU 中会有缓存空间,在缓存空间中执行计算时,并没有立刻把计算结果同步到内存中。
  • 有序性:编译器为了提高性能,会对代码在编译期间进行优化,对指令进行重排序。生成的指令可能会在执行顺序上与我们代码想要的顺序不同。

Java 中,为了解决上述的并发问题,提供了 volatile 关键字。

volatile 的作用

原子性问题

volatile 关键字修饰的变量与普通变量的原子性操作一样,只能保证单独的读或单独的写操作的原子性。而类似 i++ 这种操作,是多个操作合并的运算,无法保证原子性。

有序性问题

保证有序性的手段是防止指令重排,Happens-Before 规则中的 volatile 变量规则规定:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

可见性问题

底层通过内存屏障解决可见性问题。

底层实现原理

回顾计算机的内存模型:

Java 多线程并发【6】volatile
epub_603120_566.jpeg

因为硬件设备不同部分的读写速度并不相同,所以实际上计算机从硬件架构上设计了三级缓存:

  • CPU 中的缓存
  • 高速缓存
  • 主内存

当多个处理器的计算都涉及同一块内存区域中的内容时,会导致高速内存中的数据不一致,为了解决这个问题,在高速缓存与主内存之间多了一层缓存一致性协议,在读写时要根据协议来进行操作。

关于缓存一致性协议,涉及知识内容比较多,后续再单独讲。

很显然,缓存一致性协议能够解决计算机在硬件层面的可见性问题,除了缓存一致性协议,一般还会做其他的优化方案。而内存屏障就是一种关于内存读写排序的一部分,在不同体系结构下变化很大而不适合推广。

内存屏障

大多数处理器提供了内存屏障指令:

  • 完全内存屏障(full memory barrier)确保内存读和写操作;保障了内存屏障前的读写操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的读写操作。
  • 内存读屏障(read memory barrier)仅确保了内存读操作;保障了内存屏障前的读操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的读操作。
  • 内存写屏障 (write memory barrier) 仅保证了内存写操作。保障了内存屏障前的写操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的写操作。

简单来说,就是在确保内存屏障前后读写操作都写回主内存后再执行后续读写。

内存屏障可简单分为读屏障和写屏障。两两组合就会有四种情况:

  • read_read

    read1 操作
    read_read 屏障
    read2 操作

    在 read2 操作及读取操作读取的数据前, read1 读操作一定执行完成。

  • read_write

    read 操作
    read_write 屏障
    write 操作

    在 write 及后续写入操作执行前, read 操作一定执行完成。

  • write_write

    write1 操作
    write_write 屏障
    write2 操作

    在 write2 及后续写入操作执行前,保证 write1 的写入操作对其它处理器可见。

  • write_read

    write 操作
    write_read 屏障
    read 操作

    在 read 及后续所有读取操作执行前,保证 write 的写入对所有处理器可见。

volatile 关键字底层执行了悲观的内存屏障读写策略:

  • 在每个 volatile 写操作前插入 write_write 屏障,在写操作后插入 write_read 屏障;
  • 在每个 volatile 读操作前插入 read_read 屏障,在读操作后插入 read_write 屏障;

这里实际上应该是 read 对应 Load;write 对应 Store ,但从个人角度讲,用 read 和 write 更方便理解:

内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。
// JDK 底层代码中的内存屏障
  static void     loadload();
  static void     storestore();
  static void     loadstore();
  static void     storeload();

内存屏障通过在操作之间添加屏障,从而实现了提供可见性和防止指令重排的效果。

可见性

write_write 和 write_read 保证了在写操作执行后,立刻对外部可见。

防止指令重排

happen-before 规则中的 volatile 规则,保证了代码的有序性。而 volatile 关键字底层通过内存屏障确保了读写按顺序执行。

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

代码层面分析

反编译一个 volatile 关键字修饰的属性:

  // 代码
  volatile boolean x = false;
  // 字节码
  volatile boolean x;
    descriptor: Z
    flags: (0x0040) ACC_VOLATILE

该属性配置了一个 ACC_VOLATILE 的 flag ,通过 OpenJDK 源码去查找它的底层实现,在文件 src/hotspot/share/utilities/accessFlags.hpp 中发现了相关逻辑:

// JVM_ACC_VOLATILE      = 0x0040
 
// Java access flags
bool is_volatile    () const         return (_flags & JVM_ACC_VOLATILE    ) != 0; }

is_volatile() 方法的处理逻辑在 src/hotspot/share/interpreter/zero/bytecodeInterpreter.cpp 中:

          if (cache->is_volatile()) {
            switch (tos_type) {
              case itos:
                obj->release_int_field_put(field_offset, STACK_INT(-1));
                break;
              // ...
              case atos: {
                oop val = STACK_OBJECT(-1);
                VERIFY_OOP(val);
                obj->release_obj_field_put(field_offset, val);
                break;
              }
              default:
                ShouldNotReachHere();
            }
            OrderAccess::storeload();
          } else {
            switch (tos_type) {
              case itos:
                obj->int_field_put(field_offset, STACK_INT(-1));
                break;
              // ...
              case atos: {
                oop val = STACK_OBJECT(-1);
                VERIFY_OOP(val);
                obj->obj_field_put(field_offset, val);
                break;
              }
              default:
                ShouldNotReachHere();
            }
          }

这里省略了一些分支,都是不同类型类似的处理。通过对比发现,如果是 volatile 变量,调用了 release_XXX_field_put, 而不是 volatile 变量,则调用 XXX_field_put 。引用类型额外处理,但本质上也是一样的。

release_int_field_put 方法定义在 src/hotspot/share/oops/oop.cpp 中发现:

void oopDesc::release_int_field_put(int offset, jint value) {       
  Atomic::release_store(field_addr<jint>(offset), value); 
}

int_field_put 方法定义则是:

inline void oopDesc::int_field_put(int offset, jint value) 
  *field_addr<jint>(offset) = value;    
}

release_int_field_put 通过 Atomic 类的 release_store 方法来进行指针赋值;int_field_put 则是直接赋值。区别也就是 Atomic::release_store(...),它的定义在 src/hotspot/share/runtime/atomic.hpp中:

template <typename D, typename T>
inline void Atomic::release_store(volatile D* p, T v) {
  StoreImpl<D, T, PlatformOrderedStore<sizeof(D), RELEASE_X> >()(p, v);
}

release_store 方法中的第一个参数声明了 volatile ,所以 Java 的 volatile 关键字本质上是使用了 C/C++ 中的 volatile 关键字的特性。

C 语言中的 volatile 原理

这是一个简单的 C 语言 demo :

#include <stdio.h>

int fn(int num) {
    return num + 3;
}
 
int main() {
    int a = 5;
    int b = 10;
    int c = 20;
    scanf("%d", &c);
    
    a = fn(c);
    b = a + 1;
    printf("%dn", b);
}

通过 gcc -S -masm=intel -O4 xxx.c 将 C 语言代码转换成汇编代码,其中会直接进行 a + 4

  call  _scanf
  mov esi, dword ptr [rbp - 4]
  add esi, 4 // 【*】这里 add 指令直接进行了 +4 操作
  lea rdi, [rip + L_.str.1]

如果没有 scanf 这一行,编译结果直接是:

  mov esi, 24

相当于 c + 3 + 1 直接进行了合并。

而如果给 a 加上 volatile 关键字,汇编结果是:

  mov eax, dword ptr [rbp - 4]
  add eax, 3                      // add 加法操作
  mov dword ptr [rbp - 8], eax
  mov esi, dword ptr [rbp - 8]    // mov 传送字或字节.
  inc esi                         // inc 指令 加 1 操作
  lea rdi, [rip + L_.str.1]       // lea 装入有效地址
  xor eax, eax                    // xor 异或运算
  call  _printf
  xor eax, eax
  add rsp, 16
  pop rbp
  ret

可以看出,fn 方法中的 + 3 操作,单独执行,b = a + 1 也是单独执行。

从这个 demo 中能够分析出一个结论,C 语言的 volatile 关键字的作用告诉编译器,不进行优化处理。也就是防止指令重排的效果。


原文始发于微信公众号(八千里路山与海):Java 多线程并发【6】volatile

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由半码博客整理,本文链接:https://www.bmabk.com/index.php/post/85120.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
半码博客——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!