第02篇:JMM-Java内存模型

在上一篇提到了并发带来的一些风险,尤其是数据安全性的问题.那为什么在多线程的环境下,会出现这样不可思议的结果?这其实和我们的硬件(CPU,内存)和Java内存模型(JMM)息息相关的.同样的, 希望完本篇博客, 能够回答以下几个问题:

  • 什么是JMM? 要它何用?
  • 在JMM的控制下, 线程是如何进行通信的?
  • 除了控制线程通信, JMM还有什么重要的意义?
  • 什么是重排序? 为什么会重排序?
  • JMM对重排序如何约束?

1.JMM概述

很多初学者没有区分开JVM内存结构和JMM的概念. 前者是说JVM在工作的时候, 内存结构是如何划分的, 以及每个区域存放的数据, 就比如我们常说的堆,栈,方法区(JDK8之后的元空间)等. 而JMM(Java Memory Model)Java内存模型, 主要是用来控制Java线程之间如何进行通信的一种语言级的模型.他定义了线程和主内存之间的一种抽象关系.我们先来看图.
在这里插入图片描述
说明:

  1. 线程之间的共享变量存储在主内存中. 什么是共享变量? 例如实例的成员变量, 静态变量或者数组元素等, 这些信息都是存储在堆内存中.
  2. 每个线程都有自己的工作空间, 即本地内存. 在本地内存里面, 存放着共享变量的副本. 特别需要注意的是, 这个本地内存是JMM中抽象出来的一个概念, 并不真实存在(主内存可以看成是计算机的内存条). 本地内存其实涵盖的区域比较杂, 包括了: 缓存, CPU写缓冲区, 寄存器等.

现在有这样一个场景 : 线程A想要跟线程B进行通信. 说得直白一点, 比如内存中有一个值x = 1,线程A把x的值修改为了2,然后要让线程B知道.在JMM的控制下,就得这样来干:

  1. 首先线程A要把本地内存中的x=2, 写到主内存中.
  2. 然后线程B从主内存中, 重新获取x的值, 更新本地内存中x的值.
    在这里插入图片描述
    整个过程画一个流程图的话, 大致是这样:
Created with Raphaël 2.2.0 开始 主内存x=1 线程A,B读取x=1到本地内存 线程A在本地内存修改x=2 线程A将本地内存的x=2刷到主内存 线程B从主内存重新获取x的值 线程B将本地内存x的值修改为2 结束

总结起来, 就是两个线程之间的通信, 必须要经过主内存. 这点很重要, 后面在说明volatile的时候就很容易理解了. JMM通过控制主内存与每个线程的本地内存之间的交互, 来为我们提供了内存的可见性保证.

2.关于重排序

上面只是对JMM最基本的一个描述, 包括他的定义和主要作用. 在上面一点的结尾处, 提到了内存可见性. 即一个线程, 能够感知到另一个线程对共享变量的修改. 但是这里有一个关键的问题: 什么时候回写内存? 什么时候从内存中去重新获取共享变量的值? 正是因为这是个不能确定的因素, 所以执行的时机或者说顺序, 如果无法控制, 那么这种可见性还是无法保证.

另一方面,从硬件层面来说,现代CPU都会使用写缓冲来临时保存将要写往内存的数据,这样的好处主要有两点:

  1. 通过写缓冲,可以避免cpu频繁停顿下来往内存写数据的延迟。
  2. 可以批量的从cpu刷数据到内存,合并对同一内存地址的写操作
    但正是因为使用了写缓冲,且它只对当前cpu可见,这样就不会受其他cpu影响,当执行到某条写指令的时候,如果发现缓存区块被其他CPU占用,为了提高CPU处理性能, 可能将后面的读缓存命令优先执行.

比如下面这段代码.

    public static int a = 0;
    public static int b = 0;
    public static int x = 0;
    public static int y = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            a = 1;
            x = b;
        }).start();
        new Thread(() -> {
            b = 2;
            y = a;
        }).start();
        TimeUnit.MILLISECONDS.sleep(100);
        System.out.println("x=" + x + "\ny=" + y);
    }
}

执行的结果如下:

x=0
y=1

从理论上分析,x和y的值最终可能都是0. 这里两个线程,(至于为什么这段代码运行的结果,绝大部分时候都是0和1. 笔者认为是这两个线程无法做到真正的同时启动运行。 )其实都做了相同的三个步骤,以第一个线程为例:

  • 在本地内存,写a=1
  • 读取共享内存的值b,赋值给x
  • 将a更新后的值刷到主内存

如果程序以我们编码的步骤一步一步执行,且2个线程同时启动运行。 那么最终将会得到 x=y=0的结果。从内存操作发生的实际顺序来看,虽然代码的顺序是先执行写a=1,再读取b的值,但是真正执行的顺序却是先读取b的值,然后在执行写。此时内存操作顺序被重排序了。

我们的代码在最终被执行的过程中, 其实经历了3个重排序阶段:编译器优化重排序 → 指令级并行重排序 → 内存系统重排序。其中指令重排和内存重排属于处理器重排序。有以下主要几种规则类型:

  • Load-Load
  • Load-Store
  • Store-Store
  • Store-Load
  • 数据依赖

这些重排序在不同的处理器平台允许的规则是不同的,比如我们常用的x86处理器,他仅允许Store-Load(写读操作)重排序,因为他用到了写缓冲,也就是在写自己缓冲区的时候,可以先执行后面的读操作。

对于这么如此多的重排序规则,包括前面提到的编译器优化重排序。为了保证数据的可见性,JMM的编译器重排序规则会禁止特定类型的编译器重排,而对于处理器指令重排,JMM会通过插入不同的内存屏障(Memory Barriers)来禁止特定类型的处理器重排,从而保证共享数据的可见性。

JMM把内存屏障指令分为了4类:

  • LoadLoad Barriers:确保先加载一个数据,再加载另一个数据
  • StoreStore Barriers:确保先写入一个数据到内存, 在进行另一个写入以及后续操作
  • LoadStore Barriers:确保先加载一个数据, 在写入一个数据到内存
  • StoreLoad Barriers: 确保先把数据刷到内存, 在进行后续的读操作。他要求屏障之前所有读写完成后,再进行后续的读操作。、

回顾以上内容,JMM的核心点是着怎么保证共享数据的可见性。他定义了Java线程之间的交互模型,通过编译器重排规则和内存屏障指令,来保证共享数据的可见性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值