Java 内存模型解析

Java 内存模型

了解计算机历史的同学应该知道,计算机刚刚发明的时候,是没有内存这个概念的,速度慢到无法忍受。知道冯诺依曼提出了一个天才的设计才解决了这个问题,没错,这个设计就是加了内存,所以现代的电子计算机又叫做 “冯诺依曼机”。

JVM是一个完整的计算机模型,所以自然就需要有对应的内存模型,这个模型被称为 “Java内存模型” ,对应的英文是 “Java Memory Model” ,简称JMM。

Java内存模型规定了JVM应该如何使用计算机内存(RAM)。广义来讲,Java内存模型分为两个部分:

  • JVM 内存结构
  • JMM 与线程规范

其中,JVM内存结构是底层实现,也是我们理解和认识JMM的基础。大家熟知的堆内存、栈内存等运行时数据区的划分就可以归为JVM内存结构。

JVM内存结构

JVM整体内存概念图:

内部使用的Java内存模式,在逻辑上将内存划分为线程栈(thread stacks)和堆内存(heap)两个部分。如下图所示:

6f0f8921-0768-4d1d-8811-f27a8a6608a8.jpg

JVM中,每个正在运行的线程,都有自己的线程栈。线程栈包括了当前正在执行的方法、调用链上的所有方法的状态信息。

所以线程栈又被称为 “方法栈” 或 “调用栈(call stack)”。线程在执行代码时,调用栈中的信息会一直在变化。

线程栈里面保存了调用链上正在执行的所有方法中的局部变量。

  • 每个线程都只能访问自己的线程栈
  • 每个线程都不能访问其他线程的局部变量

即使两个线程正在执行完全相同的代码,但每个线程都会在自己的线程栈内创建对应代码中声明的局部变量。所以每个线程都有一份自己的局部变量副本。

  1. 所有原生类型的局部变量都存储在线程栈中,因此对其他线程是不可见的
  2. 线程可以将一个原生变量值的副本传给另一个线程,但不能共享原生局部变量本身
  3. 堆内存中包含了Java代码中创建的所有对象,不管是哪个线程创建的,其中也涵盖了包装类型
  4. 不管是创建一个对象并将其赋值给局部变量,还是赋值给另一个对象的成员变量,创建的对象都会被保存到堆内存中

img

总结一下:原始数据类型和对象引用地址在栈上;对象、对象成员与类定义、静态变量在堆上;堆是线程共享的,只要他们能拿到对象的引用地址。

栈内存的结构

栈内存的大体结构:

img

每启动一个线程,JVM就会在栈空间分配对应的线程栈,比如1MB的空间(-Xss1m)。线程栈也叫做Java方法栈。如果使用了JNI方法,则会分配一个单独的本地方法栈(Native Stack)。

线程执行过程中,一般会有多个方法组成调用栈,每执行到一个方法,就会创建对应的栈帧(Frame)。

堆内存的结构

Java程序除了栈内存之外,最主要的内存区域就是堆内存了。

706185c0-d264-4a7c-b0c3-e23184ab20b7.jpg

堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。

但 JVM 的具体实现一般会有各种优化,比如将逻辑上的Java堆,划分为堆(Heap)和非堆(Non-Heap)两个部分。这种划分的依据在于,我们编写的Java代码,基本上只能使用Heap这部分空间,所以也有一种说法,这里的Heap也叫GC管理的堆。

GC理论中有一个重要的思想,叫做分代。经过研究发现,程序中分配的对象,要么用过就扔,要么就能存活很久。

因此,JVM 将Heap 内存分为年轻代(Young generation)和 老年代(Old generation)两部分。

年轻代还划分为了3个内存池,新生代(Eden space)和存活区(Survivor space),在大部分GC算法中有2个存活区(S0,S1),在我们可以观察到的任何时刻,S0和S1总有一个是空的,但一般较小,也不浪费多少空间。

具体实现对新生代还有优化,那就是TLAB(Thread Local Allocation Buffer),给每个线程先划定一小片空间,你创建的对象先在这里分配,满了再换。这能极大降低并发资源锁定的开销。

Non-Heap本质上还是Heap,只是一般不归GC管理,里面划分3个内存池。

  • Metaspace,以前叫永久代,Java8换了个名字,Java8中将方法区移动到了Meta区里面,二方法有事class的一部分和CSS交叉了?
  • CSS,Compressed Class Space,存放class信息的,和Metaspace有交叉。
  • Code Cache,存放JIT编译器编译后的本地机器代码。
JMM简介

JVM 支持程序多线程执行,每个线程是一个Thread,如果不指定明确的同步措施,那么多个线程访问同一个共享变量时,就会发生一些奇怪的问题,比如A线程读取了一个变量a=10,想要做一个只要大于9就减2的操作,同时B线程现在A线程前设置a=8,其实这时候已经不满足A线程的操作条件了,但是A线程不知道,依然执行了a-2,最终a=6;实际上a的正确值应该是8,这个没有同步的机制在多线程下导致了错误的最终结果。

由此 JMM规定了线程间的操作。

  1. 能被多个线程共享使用的内存称为 “共享内存” 或 “堆内存”
  2. 所有的对象(包括内部的实例成员变量),static变量,以及数组,都必须存放在堆内存中
  3. 局部变量,方法的形参/入参,异常处理语句的入参不允许在线程之间共享,所以不受内存模型的影响
  4. 多个线程同时对一个变量访问时,这时候只要有某个线程执行的是写操作,那么这种现象就称之为 “冲突”
  5. 可以被其他线程影响或感知的操作,称为线程间的交互行为,可分为:读取、写入、同步操作、外部操作等等。其中同步操作包括:对volatile变量的读写,对管程(monitor)的锁定与解锁,线程的起始操作与结尾操作,线程启动和结束等等。外部操作则是指对线程执行环境之外的操作,比如停止其他线程等等。

总结:JMM 规范的是线程间的交互操作,而不管线程内部对局部变量进行的操作。

感兴趣可以参阅:ifeve 翻译的: JSR133 中文版.pdf

内存屏障

前面提到了CPU会在合适的时机,按需要对将要进行的操作重新排序,但是有时候这个重排机会导致我们的代码跟预期不一致。

怎么办呢?JMM 引入了内存屏障机制。

内存屏障可分为读屏障写屏障,用于控制可见性。 常见的 内存屏障 包括:

#LoadLoad
#StoreStore
#LoadStore
#StoreLoad
复制

这些屏障的主要目的,是用来短暂屏蔽 CPU 的指令重排序功能。 和 CPU 约定好,看见这些指令时,就要保证这个指令前后的相应操作不会被打乱。

  • 比如看见 #LoadLoad, 那么屏障前面的 Load 指令就一定要先执行完,才能执行屏障后面的 Load 指令。
  • 比如我要先把 a 值写到 A 字段中,然后再将 b 值写到 B 字段对应的内存地址。如果要严格保障这个顺序,那么就可以在这两个 Store 指令之间加入一个 #StoreStore 屏障。
  • 遇到 #LoadStore 屏障时, CPU 自废武功,短暂屏蔽掉指令重排序功能。
  • #StoreLoad 屏障, 能确保屏障之前执行的所有 store 操作,都对其他处理器可见; 在屏障后面执行的 load 指令, 都能取得到最新的值。换句话说, 有效阻止屏障之前的 store 指令,与屏障之后的 load 指令乱序 、即使是多核心处理器,在执行这些操作时的顺序也是一致的。

代价最高的是 #StoreLoad 屏障, 它同时具有其他几类屏障的效果,可以用来代替另外三种内存屏障。

如何理解呢?

就是只要有一个 CPU 内核收到这类指令,就会做一些操作,同时发出一条广播, 给某个内存地址打个标记,其他 CPU 内核与自己的缓存交互时,就知道这个缓存不是最新的,需要从主内存重新进行加载处理。

小结

本节我们讲解了JMM的一系列知识,让大家能够了解Java的内存模型,包括:

  1. JVM 的内存区域分为: 堆内存栈内存
  2. 堆内存的实现可分为两部分: 堆(Heap)非堆(Non-Heap);
  3. 堆主要由 GC 负责管理,按分代的方式一般分为: 老年代+年轻代;年轻代=新生代+存活区;
  4. CPU 有一个性能提升的利器: 指令重排序
  5. JMM 规范对应的是 JSR133, 现在由 Java 语言规范和 JVM 规范来维护;
  6. 内存屏障的分类与作用。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值