企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
本课时我们主要讲解 JVM 的内存划分以及栈上的执行过程。这块内容在面试中主要涉及以下这 3 个面试题: * JVM 是如何进行内存区域划分的? * JVM 如何高效进行内存管理? * 为什么需要有元空间,它又涉及什么问题? 带着这 3 个问题,我们开始今天的学习,关于内存划分的知识我希望在本课时你能够理解就可以,不需要死记硬背,因为在后面的课时我们会经常使用到本课时学习的内容,也会结合工作中的场景具体问题具体分析,这样你可以对 JVM 的内存获得更深刻的认识。 首先,第一个问题:**JVM的内存区域是怎么高效划分的**?这也是一个高频的面试题。很多同学可能通过死记硬背的方式来应对这个问题,这样不仅对知识没有融会贯通在面试中还很容易忘记答案。 为什么要问到 JVM 的内存区域划分呢?因为 Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。 然而这种呼之即来挥之即去的内存申请和释放方式,自然也有它的代价。为了管理这些快速的内存申请释放操作,就必须引入一个池子来延迟这些内存区域的回收操作。 我们常说的内存回收,就是针对这个池子的操作。我们把上面说的这个池子,叫作堆,可以暂时把它看成一个整体。 ### JVM 内存布局 程序想要运行,就需要数据。有了数据,就需要在内存上存储。那你可以回想一下,我们的 C++ 程序是怎么运行的?是不是也是这样? Java 程序的数据结构是非常丰富的。其中的内容,举一些例子: * 静态成员变量 * 动态成员变量 * 区域变量 * 短小紧凑的对象声明 * 庞大复杂的内存申请 这么多不同的数据结构,到底是在什么地方存储的,它们之间又是怎么进行交互的呢?是不是经常在面试的时候被问到这些问题? 我们先看一下 JVM 的内存布局。随着 Java 的发展,内存布局一直在调整之中。比如,Java 8 及之后的版本,彻底移除了持久代,而使用 Metaspace 来进行替代。这也表示着 -XX:PermSize 和 -XX:MaxPermSize 等参数调优,已经没有了意义。但大体上,比较重要的内存区域是固定的。 ![](https://img.kancloud.cn/c1/f6/c1f6bc34c67b51bff124af8bb18c5c79_990x540.png) JVM 内存区域划分如图所示,从图中我们可以看出: * JVM 堆中的数据是共享的,是占用内存最大的一块区域。 * 可以执行字节码的模块叫作执行引擎。 * 执行引擎在线程切换时怎么恢复?依靠的就是程序计数器。 * JVM 的内存划分与多线程是息息相关的。像我们程序中运行时用到的栈,以及本地方法栈,它们的维度都是线程。 * 本地内存包含元数据区和一些直接内存。 一般情况下,只要你能答出上面这些主要的区域,面试官都会满意的点头。但如果深挖下去,可能就有同学就比较头疼了。下面我们就详细看下这个过程。 ### 虚拟机栈 ![](https://img.kancloud.cn/35/9e/359e0deebec8c64120659dfabe0cd5fb_640x250.gif) 栈是什么样的数据结构?你可以想象一下子弹上膛的这个过程,后进的子弹最先射出,最上面的子弹就相当于栈顶。 我们在上面提到,Java 虚拟机栈是基于线程的。哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。 栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。每个栈帧,都包含四个区域: * 局部变量表 * 操作数栈 * 动态连接 * 返回地址 我们的应用程序,就是在不断操作这些内存空间中完成的。 ![](https://img.kancloud.cn/f2/2f/f22f0102ab867d81fdcf7ed8560532c8_922x535.png) 本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,这并不影响我们对 JVM 的了解。 这里有一个比较特殊的数据类型叫作 returnAdress。因为这种类型只存在于字节码层面,所以我们平常打交道的比较少。对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针。 ![](https://img.kancloud.cn/2b/99/2b99aefb9ed48d7fc5addbcc241a19ee_1156x546.png) 这部分有两个比较有意思的内容,面试中说出来会让面试官眼前一亮。 * 这里有一个两层的栈。第一层是栈帧,对应着方法;第二层是方法的执行,对应着操作数。注意千万不要搞混了。 * 你可以看到,所有的字节码指令,其实都会抽象成对栈的入栈出栈操作。执行引擎只需要傻瓜式的按顺序执行,就可以保证它的正确性。 这一点很神奇,也是基础。我们接下来从线程角度看一下里面的内容。 ### 程序计数器 那么你设想一下,如果我们的程序在线程之间进行切换,凭什么能够知道这个线程已经执行到什么地方呢? 既然是线程,就代表它在获取 CPU 时间片上,是不可预知的,需要有一个地方,对线程正在运行的点位进行缓冲记录,以便在获取 CPU 时间片时能够快速恢复。 就好比你停下手中的工作,倒了杯茶,然后如何继续之前的工作? 程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。下面这张图,能够加深大家对这个过程的理解。 ![](https://img.kancloud.cn/80/f8/80f88df62b8079de3d80793395da37ba_1106x547.png) 可以看到,程序计数器也是因为线程而产生的,与虚拟机栈配合完成计算操作。程序计数器还存储了当前正在运行的流程,包括正在执行的指令、跳转、分支、循环、异常处理等。 我们可以看一下程序计数器里面的具体内容。下面这张图,就是使用 javap 命令输出的字节码。大家可以看到在每个 opcode 前面,都有一个序号。就是图中红框中的偏移地址,你可以认为它们是程序计数器的内容。 ![](https://img.kancloud.cn/e8/14/e8148d47d2c24135ecf98c7e58fad842_774x441.jpg) ### 堆 ![](https://img.kancloud.cn/81/91/8191cc710bc84fc69c5aa97daed94106_938x529.png) 堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。 堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。 随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。 由于对象的大小不一,在长时间运行后,堆空间会被许多细小的碎片占满,造成空间浪费。所以,仅仅销毁对象是不够的,还需要堆空间整理。这个过程非常的复杂,我们会在后面有专门的课时进行介绍。 那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。 Java 的对象可以分为基本数据类型和普通对象。 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。 注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。 ![](https://img.kancloud.cn/81/91/8191cc710bc84fc69c5aa97daed94106_938x529.png) 这就是 JVM 的基本的内存分配策略。而堆是所有线程共享的,如果是多个线程访问,会涉及数据同步问题。这同样是个大话题,我们在这里先留下一个悬念。 ### 元空间 关于元空间,我们还是以一个非常高频的面试题开始:“为什么有 Metaspace 区域?它有什么问题?” 说到这里,你应该回想一下类与对象的区别。对象是一个活生生的个体,可以参与到程序的运行中;类更像是一个模版,定义了一系列属性和操作。那么你可以设想一下。我们前面生成的 A.class,是放在 JVM 的哪个区域的? 想要问答这个问题,就不得不提下 Java 的历史。在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern 相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。 Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。关于它们的对比,可以看下这张图。 ![](https://img.kancloud.cn/9c/96/9c964deb6a504d054af217dd1a723970_884x521.png) 然后,元空间的好处也是它的坏处。使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。 方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。我们将在后面的课时中,再次遇到它。现在,你只需要了解到,这个区域存储的内容,包括:类的信息、常量池、方法数据、方法代码就可以了。 ### 小结 好了,到这里本课时的基本内容就讲完了,针对这块的内容在面试中还经常会遇到下面这两个问题。 * 我们常说的字符串常量,存放在哪呢? 由于常量池,在 Java 7 之后,放到了堆中,我们创建的字符串,将会在堆上分配。 >[info]备注:JVM中存在多个常量池。把第一个改成字符串常量池就比较好理解了。 1、字符串常量池,已经移动到堆上(jdk8之前是perm区),也就是执行intern方法后存的地方。 2、类文件常量池,constant_pool,是每个类每个接口所拥有的,第四节字节码中“#n”的那些都是。这部分数据在方法区,也就是元数据区。而运行时常量池是在类加载后的一个内存区域,它们都在元空间。 * 堆、非堆、本地内存,有什么关系? 关于它们的关系,我们可以看一张图。在我的感觉里,堆是软绵绵的,松散而有弹性;而非堆是冰冷生硬的,内存非常紧凑。 ![](https://img.kancloud.cn/5b/0a/5b0a01755c647d2b60acb23e11c9924b_1000x398.png) 大家都知道,JVM 在运行时,会从操作系统申请大块的堆内内存,进行数据的存储。但是,堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理。 在 Linux 机器上,使用 top 或者 ps 命令,在大多数情况下,能够看到 RSS 段(实际的内存占用),是大于给 JVM 分配的堆内存的。 如果你申请了一台系统内存为 2GB 的主机,可能 JVM 能用的就只有 1GB,这便是一个限制。 ### 总结 JVM 的运行时区域是栈,而存储区域是堆。很多变量,其实在编译期就已经固定了。.class 文件的字节码,由于助记符的作用,理解起来并不是那么吃力,我们将在课程最后几个课时,从字节码层面看一下多线程的特性。 JVM 的运行时特性,以及字节码,是比较偏底层的知识。本课时属于初步介绍,有些部分并未深入讲解。希望你应该能够在脑海里建立一个 Java 程序怎么运行的概念,以便我们在后面的课时中,提到相应的内存区域时,有个整体的印象。 #### 课后问答 * 1、被final修饰的成员变量,会被gc吗? 答:对象是否被GC,和是否是final变量没有关系。 * 2、类加载器是加载字节码变成机器码给执行引擎去执行的,那么类加载器是谁来加载的? 答:启动类加载器,就是最上面那一个,是c代码实现的,没有继承classloader类。它就是一段native逻辑,所以没有加载这种概念。它的实现参考${openjdk}\hotspot\src\share\vm\classfile 目录下的 classLoader.cpp 与classLoader.hpp * 3、JMM 保证了 read、load、assign、use、store 和 write 六个操作具有原子性,可以认为除了 long 和 double 类型以外,对其他基本数据类型所对应的内存单元的访问读写都是原子的。long和double没有原子性? 答:目前大多数机器是64位的,你可以认为是原子的。这是因为,在32位操作系统上对64位的数据的读写要分两步完成,每一步取32位数据。随着时间推移,这种知识点会越来越冷。 * 4、字节码的执行流程可以这样理解吗?字节码在Java虚拟机栈中被执行,每一项内容都可以看作是一个栈帧,栈帧的结构包括局部变量表、操作数栈、链接、返回地址。这时候就很明了了,栈帧的执行流程就是字节码的执行流程了。类中变量会被解析到局部变量表,然后对操作数栈进行入栈出栈的操作,在此期间有可能引用到动态或静态链接,最后把计算结果的引用地址返回。不知道这个理解是否正确,麻烦老师指正,谢谢? 答:正确 * 5、“它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。”这句话是什么意思呢?为什么自己写的ArrayList不会被加载? 答:loadClass的逻辑是可以非常灵活的,以下代码来自tomcat-9.0.30。 ``` // (0.2) Try loading the class with the system class loader, to prevent // the webapp from overriding Java SE classes. This implements // SRV.10.7.2 String resourceName = binaryNameToPath(name, false); ClassLoader javaseLoader = getJavaseClassLoader(); boolean tryLoadingFromJavaseLoader; ``` 第一步就是尝试从javabase加载哦,加载不到才走其他逻辑。感兴趣可以参照WebappClassLoaderBase.java文件。 * 6、这里有一个两层的栈。第一层是栈帧,对应着方法;第二层是方法的执行,对应着操作数栈;这里是说栈帧是说具体的Java方法,而真正的调用,是在栈帧中里面还建了一个操作数栈对吗?为什么要这么做呀? 答:线程方法栈(栈)->栈帧(元素)=>方法级别的操作。 栈帧里的操作数栈(栈)->操作数(元素)=> 字节码指令级的操作。 主管的功能不同,层次也不同。 * 7、“由于常量池,在 Java 7 之后,放到了堆中,我们创建的字符串,将会在堆上分配”。但是您上文也说了,JAVA8开始,metasapce是非堆区域,而且文中也提到了该区域包含的内容是类的信息、常量池、方法数据、方法代码。那么java字符串常量,就不应该在堆上创建了啊。劳烦您解释下,添麻烦了,谢谢。 答案:JVM中存在多个常量池。把第一个改成字符串常量池就比较好理解了。 1、字符串常量池,已经移动到堆上(jdk8之前是perm区),也就是执行intern方法后存的地方。 2、类文件常量池,constant_pool,是每个类每个接口所拥有的,第四节字节码中“#n”的那些都是。这部分数据在方法区,也就是元数据区。而运行时常量池是在类加载后的一个内存区域,它们都在元空间。 * 8、大家都知道,JVM 在运行时,会从操作系统申请大块的堆内内存,进行数据的存储。但是,堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理 这句话是jvm去申请了一块操作系统的堆内内存,那图上怎么jvm申请的内存包括了堆内存和非堆内存,有点疑惑。就是jvm申请的内存其实有这两个? 答案:可以这样理解: 操作系统有8G。-Xmx分配了4G(堆内内存),Metaspace使用了256M(堆外内存) 剩下的 8G-4G-256M ,就是操作系统剩下的本地内存。具体有没有可能变成堆外内存,要看情况。 比如: (1)netty的direct buffer使用了额外的120MB内存,那么现在JVM占用的堆外内存就有 256M+120M (2)使用了jni或者jna,直接申请了内存2GB,那么现在JVM占用的堆外内存就有256M+120M+2GB (3)网络socket连接等,占用了操作系统的50MB内存 这个时候,留给操作系统的就只剩下了:8GB-4GB-256M-120M-2GB-50M。具体“堆和堆外”一共用了多少,可以top命令,看RSS段。 * 9、字符串常量池,已经移动到堆上(jdk8之前是perm区),jdk8之前 的 perm区 不就是Perm Gen所在的位置吗?图上Perm Gen在堆内部,那不就是【字符串常量池一直位于堆上吗?】难道perm区不在堆内部吗? 答案:你这么说也没错。我觉得你只需要记忆jdk8之后的就可以了,因为这个常量池经过了多次变更。 <jdk7: 处于perm区,属于堆,但空间单独管理>=jdk7 处于堆,此堆非彼堆,空间上限也不同 :)>jdk8: perm区没了,它又不在元空间,也只能说堆了。 * 10、对于基本类型的包装类也是在栈上分配吗? 答案:包装类属于引用类型,它们不属于基本类型,所以是在堆上分配的。 * 11、请问虚拟机栈的数据一定是线程安全的吗? 答案:答案是肯定的,在同一虚拟机栈的数据不需要做同步。建议了解一下“线程封闭”这个概念。主要提到了Ad-hoc、栈封闭、ThreadLocal等。 * 12、上文中提到jdk8之后的版本,废弃了perm 取而代之的是产生了非堆区matespace,这个区是方法区的物理存储形式,常量池也存在其中,,下文又说常量池是在堆中创建,请问常量池的存储形式具体是在哪个区,还是我的理解有误呢, 答案:JVM中存在多个常量池。把第一个改成字符串常量池就比较好理解了。 1、字符串常量池,已经移动到堆上(jdk8之前是perm区),也就是执行intern方法后存的地方。 2、类文件常量池,constant\_pool,是每个类每个接口所拥有的,第四节字节码中“#n”的那些都是。这部分数据在方法区,也就是元数据区。而运行时常量池是在类加载后的一个内存区域,它们都在元空间。 * 13、非堆指的就是方法区吧,看您图中在堆中还画了一个永久带(Perm),非堆和永久带是不是有点重复?在我理解是一回事吧? 答案:你概念好像搞混了。图中有两种情况,Java8之前的Perm是属于堆的,包括里面的方法区;课程默认是Java8及其以后,没有Perm,此时方法区是在metaspace非堆。另外,非堆也不仅仅只是方法区,更详细的排查可以参考第13课时。 * 14、运行时常量池和字符串常量池什么区别?string常量池是在堆中还是元空间? 答案:JVM中存在多个常量池。1、字符串常量池,已经移动到堆上(jdk8之前是perm区),也就是执行intern方法后存的地方。2、类文件常量池,constant_pool,是每个类每个接口所拥有的,第四节字节码中“#n”的那些都是。这部分数据在方法区,也就是元数据区。而运行时常量池是在类加载后的一个内存区域,它们都在元空间。 * 15、请教一个问题,就是我在main主线程中新建了两个线程,其中一个线程发生了oom,但是我发现发生oom后,有问题的那个线程把占用的内存都释放了,其他的线程也没受到影响,继续运行。我的疑惑是:我们分配的堆空间不是对整个进程有效吗?为什么其中一个线程发生了oom,内存会释放掉呢?且不影响其他线程呢? 答案:你说的这种情况确实存在,少量线程模拟下可以复现,因为GC线程和用户线程是并行执行的,线程溢出的空间能够被及时释放。但是系统一般都是高速运行,有很多线程在运行和并行申请内存,在实际中很难复现,都是雪崩式直接退出。还有很多情况是GC线程疯狂运转,直到系统异常,也就是我们后面说的GC线程占用cpu 100% * 16、请教一个问题,应该是道面试题:创建一个100M的数组,程序OOM,但是分析日志发现 堆内存还大于100M,造成这个问题有哪些情况?这个问题和咱们这篇文章的最后有点像,老师说用top命令观察 RSS段(实际占用内存)一般是大于分配的堆内存的,文中说“堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理”,这块没怎么看明白,自己也解释不通上面的那道题,希望老师帮忙指点下。 答案:这个OOM描述不太清楚,OOM在会发生在很多区域。第101、13小节具体讲解了堆外内存的排查,希望对你有所帮助。另外,一些JVM配置参数也会造成此种状况,比如CMS的预留空间大小。 * 17、文中说除了基本类型,其他都是在堆上分配的。之前粗略在哪个地方看到过jvm会判断对象是否存在线程逃逸,如果不存在就直接在栈上分配对象。这种在栈上创建对象的情况是怎样的呢。 答案:你说的没错,这种情况在第6小节已聊到了。不过它是分层编译的优化手段,所以我们在后面JIT小节还会碰到它。 * 18、大家都知道,JVM 在运行时,会从操作系统申请大块的堆内内存,进行数据的存储。但是,堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理 这句话是jvm去申请了一块操作系统的堆内内存,那图上怎么jvm申请的内存包括了堆内存和非堆内存,有点疑惑。就是jvm申请的内存其实有这两个? 答案:可以这样理解: 操作系统有8G。-Xmx分配了4G(堆内内存),Metaspace使用了256M(堆外内存) 剩下的 8G-4G-256M ,就是操作系统剩下的本地内存。具体有没有可能变成堆外内存,要看情况。 比如: (1)netty的direct buffer使用了额外的120MB内存,那么现在JVM占用的堆外内存就有 256M+120M (2)使用了jni或者jna,直接申请了内存2GB,那么现在JVM占用的堆外内存就有256M+120M+2GB (3)网络socket连接等,占用了操作系统的50MB内存 这个时候,留给操作系统的就只剩下了:8GB-4GB-256M-120M-2GB-50M。具体“堆和堆外”一共用了多少,可以top命令,看RSS段。 * 19、老师,枚举类的内存模型是啥? 答案:java类和字节码,没有内存模型这个概念。Java的内存模型,指的是JMM,与多线程协作有关。像堆、虚拟机栈这些划分,也不叫内存模型,叫内存布局。这个千万别搞混了。 你应该是说enum的字节码表现形式。可以使用javac E.java && javap -v -p E看一下输出。 ``` public enum E{ A, B, C, D } ``` javap输出: ``` public final class E extends java.lang.Enum<E> ``` 可以看到enum只是一种语法上的便捷方式,继承的是Enum类。 * 20、完整的项目代码 答案:https://gitee.com/xjjdog/jvm-lagou-res * 21、“如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代”应该为小于等于某一年龄的对象大小总和? 答案:感谢提醒,参考代码share/gc/shared/ageTable.cpp中的compute_tenuring_threshold函数,重新表述如下:从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则讲当前的对象age将作为新的阈值,年龄大于此阈值的对象直接进入老年代。 * 22、另外“幸存区的一半”最好提一下 TargetSurvivorRatio 这个参数。 答案:值的注意的是。使用“grep -rn -i --color TargetSurvivorRatio .”搜索(jdk13),可以看到这个参数只影响serial和G1收集器,还稍微影响PLAB缓冲区的大小。