写点什么

Java 虚拟机内存模型

用户头像
xcbeyond
关注
发布于: 2021 年 01 月 16 日

一、前言

Java 虚拟机,简称 JVM(Java Virtual Machine),是 Java 语言中最为核心的一个东西,Java 程序运行离不开它,因为它的存在,使得 Java 拥有“一次编译,多次运行”的特点。任何平台只要装有针对于该平台的 Java 虚拟机,字节码文件(.class)就可以在该平台上运行。


JVM 是 Java 中最难以理解、而且非常重要的知识点,也常常用来衡量一个人 Java 基本功是否牢靠,更是在面试中被问及最多、最频繁的知识点之一。本文将从 Java 虚拟机内存模型开始入手,一步步来了解它。

Java 虚拟机内存模型是 Java 程序运行的基础,为了使 Java 应用程序正常运行,JVM 将其内存数据分为程序计数器、虚拟机栈、本地方法栈、堆和方法区,如下图所示:


(在 JDK1.8 开始,已经去掉了方法区的概念,用元空间(Metaspace)进行了代替.)


程序计数器用于存放下一条运行的指令;虚拟机栈和本地方法栈用于存放函数方法调用堆栈信息;Java 堆用于存放 Java 程序运行时所需的对象等数据;方法区用于存放程序的元数据信息。

    其中,一部分是线程私有的,而另一部分却是线程共享的。

  • 线程私有:程序计数器、虚拟机栈、本地方法栈

  • 线程共享:堆、方法区


二、程序计数器

程序计数器是一块很小的内存空间用于存放下一条运行的指令,它是线程私有的,可以认作为当前线程的行号指示器。


由于 Java 是支持线程的语言,当线程数量超过 CPU 数量时,线程之间根据时间片轮询抢夺 CPU 资源。对于单核 CPU 而言,每一时刻,只能有一个线程在运行,而其他线程必须被切换出去。为此,每一个线程都必须用一个独立的程序计数器,来记录下一条要运行的指令。各个线程之间的计数器互不影响,独立工作。

如果当线程正在执行一个 Java 方法,则程序计数器记录正在执行的 Java 字节码地址,如果当前线程正在执行一个 Native 方法,则程序计数器为空。

三、虚拟机栈(栈)

栈保存的是方法的局部变量、部分结果,并参与方法的调用和返回,即:栈帧数据。

1.栈帧

每个方法被执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接方法、返回地址等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈(方法调用)到出栈(方法返回)的过程。


栈帧结构如下图所示:


如果方法调用时,方法的参数和局部变量相对较多,那么栈帧中局部变量表就会比较大,栈帧就很很大,因此,单个方法调用所需的栈空间大小也会很大。(在程序开发时,尽量避免这种情况,尤其是递归方法中要避免递归调用的深度)


以下代码片段中,通过逐步设置递归方法调用的深度,将会抛出栈溢出异常(StackOverflowError)。

public class StackTest {    // 递归次数    private final int count = 100000;
/** * 递归方法 * @param num */ public void recursionMethod(int num) { num++; if (num < count) { recursionMethod(num); } }

@Test public void stackDepthTest() { recursionMethod(0); }}
复制代码


2.栈溢出、内存溢出

Java 虚拟机规范中允许栈的大小是动态的或者是固定的,定义了两种异常与栈空间相关:StackOverflowError 和 OutOfMemoryError。如果线程在计算过程中,请求的栈深度大于最大可用的栈深度,则会抛出 StackOverflowError 异常,如果栈能够动态扩展,而在扩展过程中,没有足够的内存空间来支持栈的扩展,则会抛出 OutOfMemoryError 异常。


其中,可以使用 JVM 参数-Xss 来调整设置栈的大小,从而决定了方法调用可以达到的深度。


3.jclasslib 工具

篇外话,但觉得还是有必要提出来,在研究 JVM 时,总是会去研究一些字节码指令、Class 类文件结构、大小等数据,而 jclasslib 工具恰恰满足这些,有了它更有助于我们对 Java、JVM 有更深入的了解。


大家可根据自己的喜好,选择安装,有单机软件版、IDE 插件可供使用,在此,我选择的是在 idea 中安装了 jclasslib 插件,方便使用。此工具将伴随着你在 JVM 的世界里翱翔,一探 JVM 究竟。


以上述代码为例进行说明,如下图所示,在 idea 中通过 jclasslib 插件查看 StackTest.class 文件,展开方法 recursionMethod 后,查看 Code 属性的 Misc 页签中,当前方法的最大局部变量表的容量为 2。因为在该方法中只有一个 int 类型的参数,所以共占 2 字。


关于 jclasslib 工具的更多使用技巧,在不断的使用中去摸索吧。


四、本地方法栈

本地方法栈和虚拟机栈的功能很相似,虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。


本地方法并不是用 Java 实现的,而是使用 C 实现的。本地方法栈保存的是 native 方法的信息,当一个 JVM 创建的线程调用 native 方法后,JVM 不再为其在虚拟机栈中创建栈帧,JVM 只是简单地动态链接并直接调用 native 方法。


在 Hot Spot 虚拟机中,是不区分本地方法栈和虚拟机栈的。因此,本地方法栈一样也会抛出异常 StackOverflowError 和 OutOfMemoryError。


五、堆

堆可以说是 Java 运行时内存中最为重要的部分,几乎所有的对象和数组都是在堆中分配空间的。堆分为新生代和老年代两部分,新生代用于存放刚刚产生的对象和年轻的对象,如果对象一直没有被收回,生存得足够长,老年对象就会被移入老年代。


新生代又可以进一步细分为 eden、survivor space0(s0 或者 from space)和 survivor space1(s1 或者 to space)。eden 称之为伊甸园,即对象的出生地,大部分对象刚刚创建时,通常会存放在这里。s0 和 s1 为 survivor 空间,直译为幸存者,就是指存放其中的对象至少经历了一次垃圾回收,并得以幸存。如果在幸存区的对象到了指定年龄仍未被回收,则有机会进入老年代。


换言之,堆空间简单分为新生代和老年代,新生代用于存放刚产生的新对象,老年代则存放年长的对象(存放时间较长,经过垃圾回收次数较多的对象)。


堆空间结构如下图所示:


六、方法区

方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。主要保存的信息是类的元数据,即类的类型信息、常量池、域信息、方法信息,如 static 修饰的变量加载类的时候就被加载到方法区中。


类型信息包括类的完整名称、父类的完整名称、类型修饰符(public/protected/private)和类型的直接接口类表;常量池包括这个类方法、域等信息所引用的常量信息;域信息包括域名称、域类型和域修饰符;方法信息包括方法名称、返回类型、方法参数、方法修饰符、方法字节码、操作数栈和方法帧栈的局部变量区大小以及异常表。总之,方法区内保存的信息,大部分都来自于 class 文件。


在 Hot Spot 虚拟机中,方法区也成为永久区,是一块独立于 Java 堆的内存空间。虽然叫做永久区,但是永久区中的对象,同样也是可以被 GC 回收的。只是对于 GC 的表现和 Java 堆空间略不相同。对永久区 GC 的回收,通常主要从两个方面分析:一是 GC 对永久区常量池的回收,二是永久区对类元数据的回收。

方法区也成为永久区,主要存放常量和类的定义信息。


(在 JDK1.8 的 HotSpot 虚拟机中,已经去掉了方法区的概念,用 Metaspace 代替,并且将其移到了本地内存来规划了。)


发布于: 2021 年 01 月 16 日阅读数: 44
用户头像

xcbeyond

关注

不为别的,只为技术沉淀、分享。 2019.06.20 加入

公众号:程序猿技术大咖,专注于技术输出、分享。

评论

发布
暂无评论
Java虚拟机内存模型