JVM系列(二)JAVA内存模型

Java与C++之间有一堵由内存动态分配和垃圾回收技术所围成的高墙,墙外面的人想进来,墙里面的人却想出来。

一、Java内存模型概述

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

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

其中,JVM 内存结构是底层实现,也是理解和认识 JMM 的基础。 大家熟知的堆内存、栈内存等运行时数据区的划分就可以归为 JVM 内存结构,本篇文章也是主要讲这一块的内容。

如果把Java的内存结构做了简化定义,可以称为一个基于堆栈的内存结构,如下所示
在这里插入图片描述
之所以要分为堆区和栈区有如下的两个原因。

  1. 对象是有生命周期的,生命周期一般比较长;线程的方法执行完就立马消失了。
  2. 对象占用多大的内存,很可能不知道;但是方法栈占用多大的内存是知道的。

JVM 中,每个正在运行的线程,都有自己的线程栈。

线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息。所以方法栈又被称为“调用栈”(call stack)。 线程在执行代码时,调用栈中的信息会一直在变化。

堆内存又称为“共享堆”,堆中的所有对象,可以被所有线程访问, 只要他们能拿到对象的引用地址。

总结来说:

原始数据类型和对象引用地址在栈上;对象、对象成员与类定义、静态变量在堆上
示意图如下所示:
5eb89250-e803-44bb-8553-a2ae74fd01ba.jpg

二、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域

三、Java虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
在这里插入图片描述
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

四、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

五、Java堆

在十年之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代”来设计,需要新生代、老年代收集器搭配才能工作。到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器。不过本文章还是采用分代模型进行描述。
在这里插入图片描述

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

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

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

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

六、运行时常量池/非堆

6.1 概念

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

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

  • Metaspace, 以前叫持久代(永久代, Permanent generation), Java8 换了个名字叫 Metaspace. Java8 将方法区移动到了 Meta 区里面,而方法又是class的一部分和 CCS 交叉了
  • CCS, Compressed Class Space, 存放 class 信息的,和 Metaspace 有交叉。
  • Code Cache, 存放 JIT 编译器编译后的本地机器代码。

6.2 和永久代的关系

垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,很难被收集。

而永久代,仅仅是很早之前的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

七、直接内存/堆外内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

比如,在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

八、CPU指令

CPU 的实现都是采用流水线的方式。如果 CPU 一条指令一条指令地执行,那么很多流水线实际上是闲置的。硬件设计人员就想出了一个好办法: “指令乱序”。 CPU 完全可以根据需要,通过内部调度把这些指令打乱了执行,充分利用流水线资源,只要最终结果是等价的,那么程序的正确性就没有问题。但这在如今多 CPU 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。

CPU 是多个核心一起执行,同时 JVM 中还有多个线程在并发执行,这种多对多让局面变得异常复杂,稍微控制不好,程序的执行结果可能就是错误的。
在这里插入图片描述
JVM 支持程序多线程执行,每个线程是一个 Thread,如果不指定明确的同步措施,那么多个线程在访问同一个共享变量时,就看会发生出问题。

九、内存屏障

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 内核与自己的缓存交互时,就知道这个缓存不是最新的,需要从主内存重新进行加载处理。

十、小结

  1. JVM 的内存区域分为: 堆内存 和 栈内存,栈内存包含了当前正在执行的方法链/调用链上的所有方法的状态信息。
  2. 堆内存的实现可分为两部分: 堆(Heap) 和 非堆(Non-Heap);
  3. 堆主要由 GC 负责管理,按分代的方式一般分为: 老年代+年轻代;年轻代=新生代+存活区;
  4. CPU 有一个性能提升的利器: 指令重排序;
  5. 内存屏障的分类与作用。

十一、参考链接

  1. 深入理解java虚拟机 第三版
  2. Java 内存模型:海不辞水,故能成其深.
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值