介绍一下JVM内存结构面试回答(后续会继续补充)

发布于:2025-04-02 ⋅ 阅读:(23) ⋅ 点赞:(0)

过去我一直分不清JVM内存区域里面到底有什么 今天我们来写一篇长文来说明白

首先来一些图:图有一些是搜索网上的资料、有一些是自己画的

顺便分享一下我画图的网站:Flowchart Maker & Online Diagram Software

首先来看整体的JDK1.7 VS 1.8的对比图

JVM内存结构图

1.JDK1.7的内存结构图

自己画的

资料

2.JDK1.8的内存结构

自己画的

资料:

 图文版内存结构说明

 

内存结构细分

然后来介绍一下每一部分都有什么

1.线程私有总结图

(后续画)

2.运行时数据区

Java对于内存的管理是采用分区的方式进行管理的、不同区域的特性、存储的数据都是不同的。

根据《Java虚拟机规范》的规定、Java虚拟机所管理的内存将会包括以下几个运行时数据区域

3.程序计数器

程序计数器可以看作是当前线程所执行的字节码的行号指示器。

它通过标示下一条需要执行的字节码指令完成指令切换、可以说一个线程的运行就是在该计数器的不断变化推动下一步一步完成的。

关于程序计数器的几点总结:

  • 它是一块很小的内存空间、几乎可以忽略不计。也是运行速度最快的存储区域。

  • 在JVM规范中、每个线程都有它自己的程序计数器、是线程私有的、生命周期与线程的生命周期一致。

  • 它是程序控制流的指示器:分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

  • 任何时间一个线程都只有一个方法在执行、也就是所谓的当前方法。如果当前线程正在执行的是Java方法,程序计数器记录的是JVM字节码指令地址、如果是执行native方法、则是未指定值(undefined)。

  • 它是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

面试参考回答:

线程私有的、作为当前线程的行号指示器、用于记录当前虚拟机正在执行的线程指令地址。程序计数器主要有两个作用:

  1. 是当前线程所执行的字节码的行号指示器、通过它实现代码的流程控制、如:顺序执行、选择、循环、异常处理。

  2. 在多线程的情况下、程序计数器用于记录当前线程执行的位置、当线程被切换回来的时候能够知道它上次执行的位置。

程序计数器是唯一一个不会出现OutOfMemoryError的内存区域、它的生命周期随着线程的创建而创建、随着线程的结束而死亡。


4.虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)、早期也叫Java栈。每个线程在创建的时候都会创建一个虚拟机栈、其内部保存一个个的栈帧(Stack Frame)、对应着一次次Java方法调用、是线程私有的、生命周期和线程一致。

这里有问题就是:同一个方法调用多次、会创建多个栈帧吗==》答案是一个递归方法会创建多个栈帧。

虚拟机栈的操作只有两个、就是入栈和出栈。当调用一个新的方法时、就构建一个栈帧压入到栈中、而一个方法执行结束、就会有一个栈帧出栈、整个遵循先进后出、后进先出的原则。

每个线程在创建的时候都会创建一个虚拟机栈、其内部保存一个个的栈帧(Stack Frame)

栈帧中包含:局部变量表、操作数栈、动态链接、方法返回地址

这就是说明了:在调试程序的时候方法的调用是一层一层向下调用下去、返回的时候是从下一层层返回上来、根本就是因为底层虚拟机这里是用的栈

栈帧中主要存储了局部变量表、操作数栈、动态连接、方法出口等信息

在一条活动线程中、一个时间点上、只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧是有效的

这个栈帧被称为当前栈帧。不同线程中所包含的栈帧是不允许存在相互引用的、即不可能在一个栈帧中引用另外一个线程的栈帧。

如果当前方法调用了其他方法、方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧、接着虚拟机会丢弃当前栈帧、使得前一个栈帧重新成为当前栈帧。

Java方法有两种返回函数的方式、一种是正常的函数返回、使用return指令、另一种是抛出异常、不管用哪种方式、都会导致栈帧被弹出。

在《Java虚拟机规范》中、对这个内存区域规定了两类异常状况:

如果线程请求的栈深度大于虚拟机所允许的深度、将抛出StackOverflowError异常

如果Java虚拟机栈容量可以动态扩展、当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

栈溢出情况:1、栈帧过多 2、栈帧过大


5.本地方法栈

一个 Native Method 就是一个Java调用非Java代码的接口。

我们知道的Unsafe类就有很多本地方法。本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的、其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务、而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定、Hotspot虚拟机直接就把本地方法栈和虚拟机栈合二为一。

补充:

虚拟机栈为虚拟机执行Java方法服务、而本地方法栈则为虚拟机使用的Native方法服务。Native方法一般是用其它语言(C、C++等)编写的。

本地方法被执行的时候、在本地方法栈也会创建一个栈帧、用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

虚拟机栈为虚拟机执行Java方法服务、而本地方法栈则为虚拟机使用的Native方法服务。

6.Java堆

Java堆是所有线程共享的内存区域、几乎所有对象实例都在此分配。它也是垃圾收集器(GC)管理的主要区域。

在G1收集器出现之前、垃圾收集器主要基于分代收集理论设计

因此产生了新生代、老年代、Eden空间、Survivor空间等概念。这些分代设计的唯一目的就是优化GC性能。

Java虚拟机规范允许Java堆在物理上不连续、但逻辑上必须连续、类似于磁盘空间。主流虚拟机允许堆的大小可扩展,通过-Xmx-Xms参数控制。当堆内存不足且无法扩展时,将抛出OutOfMemoryError异常。


7.方法区

方法区(Method Area)与Java堆一样、是各个线程共享的内存区域、它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分、但是它却有一个别名叫Non-Heap(非堆)、目的应该是与Java堆区分开。

方法区的大小和堆空间一样、可以选择固定大小也可选择可扩展、方法区的大小决定了系统可以放多少个类、如果系统类太多、导致方法区溢出、虚拟机同样会抛出OutOfMemoryError错误。

JVM关闭后方法区即被释放。

《Java虚拟机规范》对方法区的约束是非常宽松的、除了和 Java堆 一样不需要连续的内存和可以选择固定大小或者可扩展外、甚至还可以选择不实现垃圾收集。

方法区(method area)只是JVM规范中定义的一个概念、不同的厂商有不同的实现。

而永久代(PermGen)是Hotspot虚拟机特有的概念、Java8的时候又被元空间取代了、永久代和元空间可以理解为方法区的落地实现。

两种实现存储内容不同、元空间存储类的元信息、而静态变量和字符串常量池等并入堆空间中、相当于永久代的数据被分到了堆空间和元空间中。

Java7中我们通过 -XX:PermSize和-XX:MaxPermSize 来设置永久代参数

Java8之后、随着永久代的取消、这些参数也就随之失效了、改为通过 -XX:MetaspaceSize

-XX:MaxMetaspaceSize 用来设置元空间参数。

所以总结方法区、Java8之后的变化:

  • 移除了永久代(PermGen)、替换为元空间(Metaspace);

  • 永久代中的class metadata转移到了native memory(本地内存、而不是虚拟机);

  • 永久代中的interned Stringsclass static variables转移到了Java

  • 永久代参数(PermSize MaxPermSize)-> 元空间参数(MetaspaceSize MaxMetaspaceSize)


8.运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

Class文件中除了有类的版本、字段、方法、接口等描述信息外、

还有一项信息是常量池表(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用、这部分内容将在类加载后进入方法区的运行时常量池中存放

JVM为每个已加载的类型(类或接口)都维护一个运行时常量池、在加载类和接口到虚拟机后创建。

所以运行时常量池相对于Class文件常量池的另一重要特性:具备动态性。

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


9.本地内存和直接内存

本地内存(Native Memory)并不是虚拟机运行时数据区的一部分\它也不是Java虚拟机规范定义的内存区域。

我们可以看到在HotSpot中、JDK1.8就将方法区移除了、用元数据区来代替、并且将元数据区从虚拟机运行时数据区移除了、转到了本地内存中、也就是说这块区域是受本机物理内存的限制、当申请的内存超过了本机物理内存、才会抛出OutOfMemoryError异常。

直接内存(Direct Memory)也是受本机物理内存的限制、在JDK1.4中新加入的NIO(new input/output)类、引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式、它可以使用Native函数库直接分配堆外内存、然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用操作、这样避免了在Java堆和Native堆中来回复制数据、显著提高性能。

  • Java程序内存 = JVM内存 + 本地内存

  • 本地内存 = 元空间 + 直接内存


面试回答介绍一下JVM的内存区域

1.分析:

首先介绍一下 程序计数器

  • 它是一块一块较小的内存空间、每个线程都有独立的程序计数器、是线程的

  • 它记录了当前线程所执行的字节码指令地址(也就是说下一条将要执行的指令)。

  • 如果执行的是 Java 方法、它记录的是正在执行的字节码地址

  • 如果执行的是 Native 方法、则为空。

  • 程序计数器是唯一一个不会出现OutOfMemoryError的内存区域、它的生命周期随着线程的创建而创建、随着线程的结束而死亡。

然后说一下虚拟机栈Java 虚拟机栈

  • 每个线程创建时都会分配一个虚拟机栈(线程私有)。

  • 虚拟机栈里面有栈帧、它是 JVM 栈的基本单位、每调用一个方法就会创建一个新的栈帧。

  • 栈帧中包含:局部变量表、操作数栈、动态链接、方法返回地址等信息

  • 若线程请求的栈深度超过虚拟机允许的深度,会抛出 StackOverflowError;若内存不足,抛出 OutOfMemoryError

然后介绍一下本地方法栈

  • 它用于执行 Native 方法也就是本地方法。

  • 是每个线程私有的

  • 本地方法被执行的时候、在本地方法栈也会创建一个栈帧、用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

  • 同样可能抛出 StackOverflowError OutOfMemoryError

然后介绍一下Java堆

  • 所有线程共享的一块内存区域、用于存储 对象实例和数组

  • 是垃圾回收器管理的主要区域、也叫GC堆

  • 堆可以细分为:新生代(Eden空间、From Survivor、To Survivor空间)和老年代。

  • 新生代又划分成 Eden 区和 Survivor 区

然后介绍一下方法区

  • 是所有线程共享的、用于存储类的结构信息

  • (如类的元数据、常量、静态变量、JIT 编译器编译后的代码等)。

  • 在 JDK 8 之前是永久代来实现

  • 在 JDK 8 之后被元空间取代来实现

  • 元空间并不在虚拟机中、而是使用本地内存。

  • 本质上使用本地内存来存储类的元数据、常量池和静态变量等信息、缓解了因固定大小的永久代带来的 OOM 问题。

然后最后说一下运行时常量池

是方法区的一部分、用于存放编译期生成的各种字面量和符号引用、在类加载后进入运行时常量池。


2.面试参考回答:

面试官您好、我来做个简单的概括:

在 JVM 的内存区域中

首先、每个线程在创建时都会有自己的线程私有区域、

主要包括程序计数器、虚拟机栈和本地方法栈。

程序计数器用于记录当前线程正在执行字节码指令的地址、生命周期与线程一致

虚拟机栈则在每次方法调用时创建栈帧、栈帧里存储局部变量、操作数栈、动态链接和返回地址等信息

本地方法栈主要是为了支持 native 方法的执行。

而对于线程共享区域、主要是 Java 堆和方法区。Java 堆是对象实例和数组的主要存储区、也是垃圾回收器管理的重点区域、其中新生代又划分成 Eden 区和 Survivor 区。而方法区则用于存放类的元数据、静态变量以及运行时常量池。这里的区别在于在 JDK1.7 中、方法区采用的是永久代实现、而且从 JDK1.7 开始、字符串常量池已经从永久代移动到堆中、而在 JDK1.8 中、永久代被移除、改为使用元空间存储

这一改进使用本地内存来存储类的元数据、常量池和静态变量、从而避免了因固定大小的永久代带来的内存溢出问题。

总体来说、不管是 JDK1.7 还是 JDK1.8、线程私有部分(程序计数器、虚拟机栈、本地方法栈)的工作原理是相似的、都是为了支撑线程的执行、而在共享区域方面、JDK1.8对方法区做了改进、更灵活地管理内存。


补充内容

补充一下关于 Java堆 内存区域的详细内容、特别是新生代的细节:

Java堆 内存区域中、新生代用于存储新创建的对象、而这些对象最初都分配在 Eden区

由于对象的生命周期往往较短、因此 Eden区的作用是存放那些刚创建的短命对象。与此同时、Survivor区(可以细分为 S0S1)用来存储那些从 Eden区 中存活下来的对象。当对象在 Eden区 被创建后、经过 垃圾回收 后、如果它依然存活、就会被移动到 Survivor区(例如从 Eden区 转移到 S0 区)。

Survivor区之间的转移就是通过 复制算法 来进行的、经过多次转移后、如果对象依然存活且达到一定年龄、它就会被晋升到 老年代。这种过程可以有效地回收大量短生命周期的对象、同时避免频繁的老年代垃圾回收。

简而言之、Eden区 存放新创建的对象,Survivor区 存放从 Eden区 存活下来的对象,随着对象“年龄”的增加,它们会在 Survivor区 之间转移,直到最终晋升到 老年代。这种机制帮助 JVM 在垃圾回收时更高效地管理内存,并通过分代收集优化了性能

这样在面试时回答,可以使面试官了解到你对 Java堆 内存结构的深入理解,尤其是新生代和老年代之间的对象生命周期转移


JVM面试题与分析

1.运行时数据区是什么

答:

虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干不同的数据区、这些区域有各自的用途、创建和销毁时间。

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

线程共享:Java 堆、方法区。

2.程序计数器是什么

答: 程序计数器是一块较小的内存空间、可以看作当前线程所执行字节码的行号指示器。

字节码解释器工作时通过改变计数器的值选取下一条执行指令。分支、循环、跳转、线程恢复等功能都需要依赖计数器完成。是唯一在虚拟机规范中没有规定内存溢出情况的区域。 如果线程正在执行 Java 方法、计数器记录正在执行的虚拟机字节码指令地址。

如果是本地方法、计数器值为 Undefine

3.直接内存是什么

Java程序内存 = JVM内存 + 本地内存

本地内存 = 元空间 + 直接内存

直接内存不属于运行时数据区、也不是虚拟机规范定义的内存区域、但这部分内存被频繁使用、而且可能导致内存溢出。 JDK1.4 中新加入了 NIO 这种基于通道与缓冲区的 IO、它可以使用 Native 函数库直接分配堆外内存

通过一个堆里的 DirectByteBuffer 对象作为内存的引用进行操作、避免了在 Java 堆和 Native 堆来回复制数据。 直接内存的分配不受 Java 堆大小的限制、但还是会受到本机总内存及处理器寻址空间限制、一般配置虚拟机参数时会根据实际内存设置 -Xmx 等参数信息、但经常忽略直接内存、使内存区域总和大于物理内存限制、导致动态扩展时出现 OOM。 由直接内存导致的内存溢出、一个明显的特征是在 Heap Dump 文件中不会看见明显的异常、如果发现内存溢出后产生的 Dump 文件很小、而程序中又直接或间接使用了直接内存(典型的间接使用就是 NIO)、那么就可以考虑检查直接内存方面的原因。


面试参考回答:

面试官您好、关于直接内存、我简要地解释一下:

直接内存是指通过 Java NIO 提供的 DirectByteBuffer 对象来访问的堆外内存、通常用于提高 I/O 操作的性能。直接内存并不属于 JVM 运行时数据区、也不是 JVM 规范定义的内存区域。它是在 JDK1.4 中引入的、主要目的是为了优化基于通道和缓冲区的 I/O 操作。

通过 NIO、ava 可以直接使用 本地操作系统的内存、避免了传统 I/O 操作中需要通过 Java 堆和本地堆之间的内存复制的开销。具体而言、直接内存的分配不受 Java 堆大小的限制、这意味着它可以比堆内存更灵活地分配内存空间、但它仍然受到本机总内存和处理器寻址空间的限制。

需要注意的是、虽然直接内存不在 JVM 内存区域内、它仍然可能导致内存溢出(OOM)。当直接内存的使用超出了物理内存的限制时、可能会发生内存溢出、并且该问题在 Heap Dump 文件中通常无法直接观察到。特别是当程序使用了大量 NIO 操作时、堆外内存的溢出可能导致异常不明显地出现在 Heap Dump 中、这时就需要检查是否存在直接内存溢出的情况。

为了避免直接内存引发的内存溢出、在配置虚拟机时、可以通过参数如 -Xmx 设置堆的最大大小、但要注意直接内存的大小并不会由这些参数直接控制、因此应当在运行时谨慎设置、避免内存总量超过物理内存。


3.内存溢出和内存泄漏的区别

分析:

1.内存溢出 OutOfMemory

定义:当程序申请内存时、系统无法提供足够的内存空间

原因:

  • 程序所需的内存超过了系统可用内存

  • 堆栈内存分配超出限制

  • 大量对象创建导致内存耗尽

常见场景:

  • 加载大量数据

  • 递归调用过深

  • 创建了过多的对象实例

2.内存泄漏(Memory Leak)

  • 定义:程序分配的内存空间使用完后未能及时释放

  • 特点:

    • 内存资源没有被正确回收

    • 随着程序运行、内存占用逐渐增加

  • 危害:

    • 长期运行会导致可用内存逐渐减少

    • 最终可能引发内存溢出

  • 常见原因:

    • 未及时关闭资源

    • 错误的对象引用

    • 存储对象的容器未清理

面试参考回答:

面试官您好、关于内存溢出内存泄漏,我做一下简要区分:

内存溢出(OutOfMemoryError)是指程序在运行过程中、申请的内存超出了系统所能提供的最大内存空间。当 JVM 无法再为程序分配足够的内存时、就会抛出 OutOfMemoryError 异常。通常这种情况是由于程序请求的内存量过大、或者大量对象的创建导致内存耗尽、或者系统资源本身不足以提供更多的内存时发生的。

内存泄漏(Memory Leak)则是指程序在申请内存后、未能及时释放这些已经不再使用的内存。虽然内存被分配了

但由于程序中的某些对象仍然被引用、垃圾回收器无法回收这些对象、导致这些内存空间不能被重新利用。随着时间的推移、内存泄漏会不断累积、最终可能导致内存溢出、即程序无法获取到足够的内存来继续运行。

简单来说、内存溢出是当内存资源耗尽时抛出的错误、内存泄漏是内存资源被占用但未能被释放、造成资源浪费。内存泄漏通常是内存溢出的前兆。


4.栈溢出的原因

由于 HotSpot 不区分虚拟机栈和本地方法栈、设置本地方法栈大小的参数没有意义、栈容量只能由 -Xss 参数来设定、存在两种异常:

  • StackOverflowError:当线程请求的栈深度超过虚拟机允许的最大深度时触发。典型场景就是如果线程请求的栈深度大于虚拟机所允许的深度、将抛出 StackOverflowError

  • 例如一个递归方法不断调用自己。该异常有明确错误堆栈可供分析、容易定位问题所在。

  • OutOfMemoryError:如果 JVM 栈可以动态扩展、当扩展无法申请到足够内存时会抛出 OutOfMemoryError。HotSpot 不支持虚拟机栈扩展、所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM

只有在创建线程时申请内存失败才会出现OOM

线程运行期间不会因为栈空间扩展导致溢出


5.运行时常量池溢出的原因

String 的 intern 方法是一个本地方法、作用是如果字符串常量池中已包含一个等于此 String 对象的字符串、则返回池中这个字符串的 String 对象的引用、否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。

在 JDK6 及之前常量池分配在永久代、因此可以通过 -XX:PermSize-XX:MaxPermSize 限制永久代大小、间接限制常量池。在 while 死循环中调用 intern 方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中。


6.方法区溢出的原因

方法区主要存放类型信息:如类名、访问修饰符、常量池、字段描述、方法描述等。只要不断在运行时产生大量类,方法区就会溢出。

例如使用 JDK 反射或 CGLib 直接操作字节码在运行时生成大量的类。很多框架如 Spring、Hibernate 等对类增强时都会使用 CGLib 这类字节码技术、增强的类越多就需更大的方法区保证动态生成的新类型可以载入内存、也就更容易导致方法区溢出。

JDK8 使用元空间取代永久代、HotSpot 提供了一些参数作为元空间防御措施、例如 -XX:MetaspaceSize 指定元空间初始大小、达到该值会触发 GC 进行类型卸载、同时收集器会对该值进行调整、如果释放大量空间就适当降低该值、如果释放很少空间就适当提高。


网站公告

今日签到

点亮在社区的每一天
去签到