java内存模型-内存间交互操作

前言

本文是阅读周志明大佬的《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》第12章,12.3节Java内存模型得来的读书笔记。

阅读告警😂😂😂,本文可能会有点枯燥,大部分内容都是对书中内容做一记录。示例代码可能会有不同。

一、内存间交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了8中操作,每一种操作都是原子的、不可再分的

  • lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占的状态
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放,释放后的变量才可以被其他线程访问
  • read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便load操作使用
  • load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

如图所示,变量从主内存到工作内存,需要按顺序执行read和load,变量从工作内存回到主内存,按照store和write操作。java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。

java内存模型还规定了执行上述8中基本操作时需要满足以下规则:

  • 不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施user、store操作之前,必须先执行assign和load操作
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值
  • 如果一个变量没有被执行lock操作,那么不允许对它执行unlock操作,也不允许unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
二、volatile变量的特殊规则
2.1 volatile变量的可见性

当变量被定义为volatile之后, 保证此变量对所有线程的可见性。这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量并不能做到这一点,因为普通变量的值在线程间传递时均需要通过主内存来完成。

那么是否可以理解为volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下都是线程安全的???

首先基于我们的经验,上述话的前半部分是对的,但是结论是不对的。为什么呢?我们可以先看一段例子:

public class VolatileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }
    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);
        ExecutorService service = new ThreadPoolExecutor(5, 8,
                1000, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(1024), new ThreadPoolExecutor.AbortPolicy());
        for (int i = 0; i < THREADS_COUNT; i++) {
            service.execute(() -> {
                for (int i1 = 0; i1 < 100; i1++) {
                    increase();
                }
                countDownLatch.countDown();
            });
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        service.shutdown();
        System.out.println(race);
    }
}

这段代码发起了20个线程,每个线程对变量进行100次自增操作,如果代码能正确并发的话,最后输出的结果是2000。但是我们运行后,并不会获得期望的结果,而且每次运行的程序输出的结构都不一样,是一个小于或者等于2000的数字,这是为什么呢?

问题在于race++操作,我们使用javap -v反编译这段代码后,发现race++是由4条字节码构成,如下图所示

这样,从字节码层面就比较容易分析出并发失败的原因了:当getstatic指令把race读取到栈顶操作时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令时,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race同步回主内存中。

2.2 禁止指令重排序优化

普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能得到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

Java内存模型中对volatile变量定义的特殊规则如下:

  • 线程对变量的load、read操作需要连续并且一起出现。即要求在工作内存中,每次使用变量时都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量所做的修改。
  • 线程对变量的store、write操作需要连续并且一起出现。即要求在工作内存中,每次修改变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
  • 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q。(这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章