Java并发杂谈(一):volatile的底层原理,从字节码到CPU

时间:2024-10-03 07:03:25

Python微信订餐小程序课程视频

/course/detail/36074

Python实战量化交易理财系统

/course/detail/35475

volatile的特性

volatile是Java中用于修饰变量的关键字,其主要是保证了该变量的可见性以及顺序性,但是没有保证原子性;其是Java中最为轻量级的同步关键字;
接下来我将会一步步来分析volatile关键字是如何在Java代码层面、字节码层面、JVM源码层次、汇编层面、操作系统层面、CPU层面来保证可见性和顺序性的;

Java代码层面

当一个变量被定义为volatile之后,具备两项特性:

  1. 保证此变量对所有线程的可见性
  2. 禁止指令重排序优化

volatile所保证的可见性

volatile所修饰的变量在一条线程修改一个变量的值的时候,新值对于其他线程来说是可以立即知道的;
普通变量的值在线程间传递的时候都是通过主内存去完成;

根据JMM我们可以知道,每一个线程其实都有它单独的栈空间,而实际的对象其实都是存放在主内存中的,所以如果是普通对象的话,便会有一个栈空间的对象主内存中的对象存在差异的时间;而volatile所修饰的变量其保持了可见性,其会强制让栈空间所存在的对应变量失效,然后从主内存强制刷新到栈空间,如此便每次看到的都是最新的数据;

volatile所保证的禁止指令重排

Java的每一行语句其实都对应了一行或者多行字节码语句,而每一行字节码语句又对应了一行或者多行汇编语句,而每一行汇编语句又对应了一行或者多行机器指令;但是CPU的指令优化器可能会对其指令顺序进行重排,优化其运行效率,但是这样也可能会导致并发问题;而volatile便可以强制禁止优化指令重排;

volatile在字节码层面的运用

我们先看到以下代码

点击查看代码

public class Main {
        static int a ;
        static volatile int b ;
        public static synchronized void change(int num) {
                num = 0;
        }

        public static void main(String[] args) {
                a = 10;
                b = 20;
                change(a);
        }
}


  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

我们先试用javac来将java文件编译为class文件,然后通过javap -v来反编译;

点击查看代码

Classfile /opt/software/java-study/
  Last modified Mar 1, 2022; size 400 bytes
  MD5 checksum c7691713c9365588495a60da768c32a6
  Compiled from ""
public class Main
  SourceFile: ""
  minor version: 0
  major version: 51
  flags: ACC\_PUBLIC, ACC\_SUPER
Constant pool:
   #1 = Methodref #6.#20 // java/lang/Object."":()V
   #2 = Fieldref #5.#21 // :I
   #3 = Fieldref #5.#22 // :I
   #4 = Methodref #5.#23 // :(I)V
   #5 = Class #24 // Main
   #6 = Class #25 // java/lang/Object
   #7 = Utf8 a
   #8 = Utf8 I
   #9 = Utf8 b
  #10 = Utf8 
  #11 = Utf8 ()V
  #12 = Utf8 Code
  #13 = Utf8 LineNumberTable
  #14 = Utf8 change
  #15 = Utf8 (I)V
  #16 = Utf8 main
  #17 = Utf8 ([Ljava/lang/String;)V
  #18 = Utf8 SourceFile
  #19 = Utf8 
  #20 = NameAndType #10:#11 // "":()V
  #21 = NameAndType #7:#8 // a:I
  #22 = NameAndType #9:#8 // b:I
  #23 = NameAndType #14:#15 // change:(I)V
  #24 = Utf8 Main
  #25 = Utf8 java/lang/Object
{
  static int a;
    flags: ACC\_STATIC

  static volatile int b;
    flags: ACC\_STATIC, ACC\_VOLATILE

  public Main();
    flags: ACC\_PUBLIC
    Code:
      stack=1, locals=1, args\_size=1
         0: aload\_0       
         1: invokespecial #1 // Method java/lang/Object."":()V
         4: return        
      LineNumberTable:
        line 1: 0

  public static synchronized void change(int);
    flags: ACC\_PUBLIC, ACC\_STATIC, ACC\_SYNCHRONIZED
    Code:
      stack=1, locals=1, args\_size=1
         0: iconst\_0      
         1: istore\_0      
         2: return        
      LineNumberTable:
        line 5: 0
        line 6: 2

  public static void main([]);
    flags: ACC\_PUBLIC, ACC\_STATIC
    Code:
      stack=1, locals=1, args\_size=1
         0: bipush        10
         2: putstatic     #2 // Field a:I
         5: bipush        20
         7: putstatic     #3 // Field b:I
        10: getstatic     #2 // Field a:I
        13: invokestatic  #4 // Method change:(I)V
        16: return        
      LineNumberTable:
        line 9: 0
        line 10: 5
        line 11: 10
        line 12: 16
}


  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82

我们仔细观察加了volatile修饰的变量与其他变量的区别便可以看出,其主要是在flags中添加了一个ACC_VOLATILE;同时先进行putstatic指令;

volatile在JVM源码方面的运用

在JVM源码方面,我编译了OpenJDK7然后利用find与grep进行全局查找,然后进行方法追踪,由于涉及到大量C++的知识,我便跳过其C++代码追踪,而直接看最后追踪到的函数;

先来做一个总结,其实volatile的JVM源码的原理对应的是被称为内存屏障来实现的;

点击查看代码

static void loadload();
static void storestore();
static void loadstore();
static void storeload();

  • 1
  • 2
  • 3
  • 4
  • 5

这四个分别对应了经常在书中看到的JSR规范中的读写屏障

  • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

对于volatile操作而言,其操作步骤如下:

  • 每个volatile写入之前,插入一个StoreStore,写入以后插入一个StoreLoad
  • 每个volatile读取之前,插入一个LoadLoad,读取之后插入一个LoadStore

在JVM源码层次而言,内存屏障直接起到了禁止指令重排的作用,且之后与总线锁或者MESI协议配合实现了可见性;

汇编层次

在汇编层次而言,我是使用JITWatch配合hsdis进行的转汇编,可以发现在含有volatile的变量的时候,汇编指令会有一个lock前缀,而lock前缀在CPU层次中自己实现了内存屏障的功能;

CPU层次

在x86的架构中,含有lock前缀的指令拥有两种方法实现;
一种是开销很大的总线锁,它会把对应的总线直接全部锁住,如此明显是不合理的;
所以后期intel引入了缓存锁以及mesi协议,如此便可以轻量化的实现内存屏障;