zl程序教程

您现在的位置是:首页 >  后端

当前栏目

浅谈JVM内存模型

JVM内存 模型 浅谈
2023-09-11 14:22:57 时间

前言

关于 JVM 内存模型,Java 程序员对此定不陌生,不管是入行五年八年的老码农,还是刚入行一两年的新码农,我们这些工程师对此定然都能说上那么三五句,或者三五分钟。尤其是面试的时候,这个问题更是面试的重灾区,不管是初级工程师,还是中高级工程师,哪怕是资深的专家,这个问题估计都跑不了,各个阶段有各个阶段的答案。

但是,即使是入行几年,花了那么一点时间去了解、理解、记忆这个内存模型的程序员,对此的回答都未必能人真正满意,或者我们换一句话说,很多人都未必能达到优秀的阶段,如果评分算的话,可能一百分有不少人都得不到八十分。如果得不到八十分,那在面试官的心里,这一道题你也只是勉强合格,不能成为你的加分项,更不会因此弥补你其他地方不懂、不会的失分项。

但是,我们都知道这个是面试重灾区,我们更要认真地去理解它,只有深入地理解了它,弄懂了它,你记忆起来才会更容易,也更不容易忘记。

JVM 内存模型图

这是一张 JVM 内存模型的示意图,下面我们依据此图来进行一个讲解。

首先,从大方面讲,JVM 内存模型分为了五部分,分别是:

堆、元空间(方法区)、栈(JVM 栈、方法栈)、本地方法栈、程序计数器。

  • Java源代码编译成Java Class文件后通过类加载器ClassLoader加载到JVM中
  • 类存放在方法区中
  • 类创建的对象存放在堆中
  • 堆中对象的调用方法时会使用到虚拟机栈,本地方法栈,程序计数器
  • 方法执行时每行代码由解释器逐行执行
  • 热点代码由JIT编译器即时编译
  • 垃圾回收机制回收堆中资源
  • 和操作系统打交道需要调用本地方法接口

其次,我们细致区分每个部分。

1. 堆

堆空间是用来存放对象和数组的地方,是整个内存中占比最大的,所以我们这张图中堆占的比例也是最大的。在堆中,老年代比新生代占用的内存空间更大,我们这张图为了细分新生代,所以占用的比例比老年大还大,实际上是老年代的内存占比更大。

上面说的对象就是我们 new 出来的对象,还有 Spring 实例化的时候创建的对象,以及具有引用性质的对象,比如 String 字符串的值。当然还有一个情况,就是对象里面的属性是 Sting 类型,那么会在该对象上存放该属性的 String 字符串的引用,这个引用指向另一块 String 字符串的空间。

我们只需要记住,就是存放对象的地方即可。

堆分为两部分,一部分是老年代,一部分是新生代。其中新生代又分为了伊甸区和幸存者区,而幸存者区又分为了S0和S1。老年代我们也称之为Old区,新生代我们也称之为Young区。S0和S1分别被成为 From Survior 和 To Survior。

伊甸区的英文是 Eden,就是伊甸园的意思。我们知道在西方的故事中,伊甸园是亚当和夏娃生活的地方,最终孕育了人类,所以说伊甸园是人类出生的地方。那么在我们内存中分配对象的时候,一般情况下(二般的情况我们后面再讲)新对象都要先在伊甸区进行内存空间的分配,也就是把对象先放到伊甸区。

幸存者区我们也称之为 Survior 区,它的英文意思就是幸存者的意思。这里一共有两部分,分别是S0 和 S1。

我们创建一个对象后,会给对象分配内存,一般情况下,我们新建的对象会先放到新生代的伊甸区。在对象的创建和使用过程中会不断地进行新生代垃圾回收(young gc)。在进行新生代垃圾回收(young gc)清除掉一部分对象之后,会把依然存活的对象复制(复制算法,这是垃圾回收器的算法,我们在另外的文章中再讲)到 S0 区。如此反复,直到 S0 区满了之后,再进行垃圾回收的时候就把 Eden 区和 S0 区继续存活的对象复制到 S1 区。然后 S0 和 S1 交换位置。

当对象在新生代存活到一定的年限(默认是15年,可以通过虚拟机参数修改),也就是经历了一定次数(15次)的垃圾回收之后依然存活,此时对象就会被移动到老年代。还有一些极少数情况,没有达到指定的年限也会被移动到老年代。还有我们上面说的二般情况,就是新创建的对象占用的内存空间比较大,在伊甸区和幸存者区都放不下,这个时候就会把这个新创建的对象直接放到老年代,也就是 old 区。

2. 方法区

我们在方法区后面加了括弧,标注了永久代和元空间。关于这一点我们有两点说明:

根据《JVM虚拟机规范》方法区是一个逻辑上的抽象概念,并非真实存在的,它只是一个规范、一个标准而已,至于如何实现就交由各大虚拟机厂商自行决定。我们提到的永久代和元空间就是由 Hotspot 团队开发而成,也就是我们常说的 Hotspot 虚拟机。因为 Hotspot 是目前市面上比较常用的一款虚拟机,所以就有了永久代和元空间。实际上,在有些虚拟机上就没有元空间这个概念。

我们在讨论或者面试的时候回答永久代、元空间的前提是,我们心里已经默认我们讨论的是 Hotspot 的虚拟机。

在 JDK8 以前没有元空间的概念,都是永久代。在 JDK8 之后 Hotspot 团队对 JVM 内存模型进行了修改,取消了永久代,改成了元空间。

我们看一张图。

JDK版本逻辑概念HotSpot虚拟机的具体实现说明
JDK1.2~JDK6方法区永久代
JDK7方法区永久代+部分堆字符串常量池+静态变量存放在 Java 堆中
JDK8+方法区元空间+部分堆字符串常量池+静态变量存放在 Java 堆中

由此可知:

  • 方法区是一个逻辑概念,需要有具体实现。
  • 通俗意义上讲,方法区的具体实现分别是 JDK8 以前的永久代和 JDK8(含)以后的元空间。
  • 堆中存放的不止是对象和数组,还有字符串常量和静态变量。

永久代和元空间最大的区别是:元空间并不在 JVM 虚拟机中,而是使用的本地内存。

永久代的来源是基于 Hotspot 虚拟机的分代回收机制,将堆分为了老年代、新生代,也就有了永久代。

为什么取消永久代?

因为永久代的里面的对象存活周期特别长,很难进行垃圾回收,就容易导致内存溢出。

永久代和元空间分别存放什么?

在 JDK7 的时候将字符串常量池放到了堆中,JDK8 (含)以后没有再做出改变,遵循了 JDK7 的做法,同样是将字符串常量池放到了堆中。

方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

什么是运行时常量池?
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的常量池表(Constant Pool Table)。常量池表会在类加载后存放到方法区的运行时常量池中。

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

JDK1.7 之前,运行时常量池包含的字符串常量池和静态变量存放在方法区, 此时 HotSpot 虚拟机对方法区的实现为永久代。

JDK1.7 字符串常量池和静态变量被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 HotSpot 中的永久代 。

JDK1.8 HotSpot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池和静态变量还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
 

 

3. 栈(JVM 虚拟机栈、方法栈)

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

4. 本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间 的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚 拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式 与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法 栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

5.程序计数器

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

程序计数器是线程私有的空间,也是唯一一个不会发生内存溢出的区域。

参考链接:

JVM 方法区和元空间什么关系?为什么要将永久代替换为元空间?

JVM内存结构和Java内存模型别再傻傻分不清了