Java内存模型(JMM)详解

#头条创作挑战赛#

什么是 JMM?

在上一篇文章https://mp.weixin.qq.com/s/ZiuZHQ8OMFudzr0jCqn1dQ中,我们了解了计算机由于各个硬件的读取速度之间的巨大差距,和充分利用CPU的性能的手段方法,及其所带来的一系列问题:

  1. 为了充分压榨CPU的性能,CPU 会对指令乱序执行或者语言的编译器会指令重排,让CPU一直工作不停歇,但同时会导致有序性问题。
  2. 为了平衡CPU的寄存器和内存的速度差异,计算机的CPU 增加了高速缓存,但同时导致了 可见性问题
  3. 为了平衡CPU 与 I/O 设备的速度差异,操作系统增加了进程、线程概念,以分时复用 CPU,但同时导致了原子性问题。

Java 是最早尝试提供内存模型的编程语言。由于Java 语言是跨平台的,另外各个操作系统总存在一些差异,Java在物理机器的基础上抽象出一个"内存模型(JMM)"

JMM 可以看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,这样就可以屏蔽各个操作系统的差异,简化多线程编程。

Java 运行时内存区域与硬件内存的关系

Java 内存区域和Java内存模型有何区别?

这是一个非常容易让人混淆的问题,Java 内存区域和内存模型完全是不一样的东西,

  1. Java 内存区域, 也叫内存区域、JVM内存模型,和 Java 虚拟机(JVM)的运行时区域相关,是指 JVM运行时将数据分区域存储,强调对内存空间的划分。


  1. Java内存模型,也叫内存模型(JMM),是Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,屏蔽各个操作系统的差异。


  1. 通俗点说:JMM规范了程序中变量的访问规则,保证了操作的原子性、可见性、有序性,我们下文慢慢道来。

我们知道JVM 运行时内存区域是分区域的,分为栈、堆等,其实这些都是 JVM 定义的逻辑概念。但在传统的硬件内存架构中是没有栈和堆这种概念。

其中:

  1. 图中栈可以细分为:虚拟机栈(JVM Stacks)和本地方法栈(Native Method Stack)


  1. 堆(Heap)

虚拟机堆是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。

更多精彩文章在公众号「小牛呼噜噜」

Java中栈和堆既存在于计算机的高速缓存中,又存在于主存中,所以两者并没有很直接的关系。

Java 线程与主内存的关系

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。 在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。

而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

什么是主内存?什么是本地内存?

Java 内存模型其实是一种规范,定义了很多东西:

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

线程间通信

线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,JMM 为共享变量提供了线程间的保障。如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作。我们直接来看下图:

在多线程的情况下,对主内存中的共享变量进行操作可能发生线程安全问题,比如:线程 1 和线程 2 同时对同一个共享变量进行操作,执行+1操作,线程 1 、线程2 读取的共享变量是否是彼此修改前还是修改后的值呢,这个是无法确定的,这种情况和CPU的高速缓存与内存之间的问题非常相似

如何实现主内存与工作内存的变量同步,为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:

重温Java 并发三大特性

原子性

原子性:即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch) 比如:

int i = 0;   //语句1,原子性

i++;         //语句2,非原子性

语句1大家一幕了然,语句2却许多人容易犯迷糊,i++其实可以分为3步:

  1. i 被从局部变量表(内存)取出,
  2. 压入操作栈(寄存器),操作栈中自增
  3. 使用栈顶值更新局部变量表(寄存器更新写入内存)

执行上述3个步骤的时候是可以进行线程切换的,或者说是可以被另其他线程的 这3 步打断的,因此语句2不是一个原子性操作

更多精彩文章在公众号「小牛呼噜噜」

在 Java 中,可以借助synchronized 、各种 Lock以及各种原子类实现原子性。 synchronized和各种Lock是通过保证任一时刻只有一个线程访问该代码块,因此可以保证其原子性。各种原子类是利用CAS (compare and swap)操作(可能也会用到 volatile或者final关键字)来保证原子操作。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。 我们来看一个例子:

public class VisibilityTest {
    private boolean flag = true;

    public void change() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + ",已修改flag=false");
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + ",开始执行.....");
        int i = 0;
        while (flag) {
            i++;
        }
        System.out.println(Thread.currentThread().getName() + ",结束循环");
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();

        // 线程threadA模拟数据加载场景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 让threadA执行一会儿
        Thread.sleep(1000);
        // 线程threadB 修改 共享变量flag
        Thread threadB = new Thread(() -> test.change(), "threadB");
        threadB.start();

    }
}

threadA 负责循环,threadB负责修改 共享变量flag,如果flag=false时,threadA 会结束循环,但是上面的例子会死循环。原因是threadA无法立即读取到共享变量flag修改后的值。我们只需 private volatile boolean flag = true;加上volatile关键字threadA就可以立即退出循环了。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。

因此,可以使用volatile来保证多线程操作时变量的可见性。除了volatile,Java中的synchronized和final两个关键字 以及各种 Lock也可以实现可见性。

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

int i = 0;
int j = 0;
i = 10;   //语句1
j = 1;    //语句2

但由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。语句可能的执行顺序如下:

  1. 语句1 语句2
  2. 语句2 语句1

指令重排对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性。 在Java 中,可以通过volatile关键字来禁止指令进行重排序优化,

详情可见:https://mp.weixin.qq.com/s/6GGV0YDwTFW_4VcLRYfBAg

也可以使用synchronized关键字保证同一时刻只允许一条线程访问程序块。


参考资料:

《java并发编程实战》

https://www.cnblogs.com/czwbig/p/11127124.html



本篇文章到这里就结束啦,如果我的文章对你有所帮助,还请帮忙一键三连:点赞、关注、收藏,你的支持会激励我输出更高质量的文章,感谢!

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!

展开阅读全文

页面更新:2024-03-12

标签:模型   内存   线程   原子   变量   语句   详解   虚拟机   操作   方法   工作

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top