JVM内存结构

JVM 的结构体系

JVM 可以大致划分为 类加载器、运行时数据区、执行引擎

  • 类加载器负责负责从文件系统、网络或其他来源加载 Class 文件,将
    Class 文件中的二进制数据读入到内存当中。

  • 运行时数据区,JVM 在执行 Java 程序时,需要在内存中分配空间来处
    理各种数据,这些内存区域按照 Java 虚拟机规范可以划分为方法区、堆、
    虚拟机栈、程序计数器和本地方法栈。

  • 执行引擎负责执行字节码。它包括一个虚拟处理
    器、即时编译器 JIT 和垃圾回收器。

JVM 运行时数据区详细内容

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

线程共享的部分:方法区、堆、直接内存(非运行时数据区的一部分)

程序计数器

程序计数器也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当
前线程所执行的字节码行号指示器。

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

虚拟机栈

它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

除了一些 Native 方法调用是通过本地方法栈实现的,其他所
有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域
比如程序计数器配合)。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、
动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的
数据结构,只支持出栈和入栈两种操作。

局部变量表主要存放了编译期可知的各种数据类型、对象引用。

提问:一个什么都没有的空方法,空的参数都没有,那局部变量表里有没有变

量?

  • 对于静态方法,由于不需要访问实例对象 this,因此在局部变量表中不会有
    任何变量。

  • 对于非静态方法,即使是一个完全空的方法,局部变量表中也会有一个用
    于存储 this 引用的变量。this 引用指向当前实例对象,在方法调用时被隐式
    传入。

操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产
生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池
里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,
需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。
动态链接的作用就是为了将符号引用转换为调用方法的直接引用,
这个过程也被称为 动态连接 。

程序运行中栈可能出现的两种错误:
StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈
的最大深度的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,
则抛出OutOfMemoryError异常。

本地方法栈

本地方法栈与虚拟机栈相似,区别在于虚拟机栈是为 JVM 执行 Java 编写的
方法服务的,而本地方法栈是为 Java 调用本地 native 方法服务的,通常由
C/C++ 编写。在本地方法栈中,主要存放了 native 方法的局部变量、动态链接和方法出
口等信息。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。也会出现
StackOverFlowErrorOutOfMemoryError 两种错误。

堆是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,
所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。
进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,
比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且
只能回收很少的堆空间时,就会发生此错误。

java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放
新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值。)

提问: 堆和栈的区别是什么?

堆属于线程共享的内存区域,几乎所有 new 出来的对象都会堆上分配,生命
周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不再
被任何变量引用,最后被垃圾收集器回收。

栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用
等,通常随着方法调用的结束而自动释放,不需要垃圾收集器处理。

提问: 堆内存是如何分配的?

在堆中为对象分配内存时,主要使用两种策略:指针碰撞和空闲列表。

指针碰撞适用于管理简单、碎片化较少的内存区域,如年轻代;而空闲列
表适用于内存碎片化较严重或对象大小差异较大的场景如老年代。

  • 什么是指针碰撞?

假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内
存,另一部分是未被使用的内存。
在分配内存时,Java 虚拟机会维护一个指针,指向下一个可用的内存地
址,每次分配内存时,只需要将指针向后移动一段距离,如果没有发生碰
撞,就将这段内存分配给对象实例。

  • 什么是空闲列表?

JVM 维护一个列表,记录堆中所有未占用的内存块,每个内存块都记录有
大小和地址信息。
当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来
存放新对象。
分配后,如果选中的内存块未被完全利用,剩余的部分会作为一个新的内
存块加入到空闲列表中

提问: new对象时,堆会发生内存抢占吗? JVM如何解决堆内存分配竞争问题?

new 对象时,指针会向右移动一个对象大小的距离,假如一个线程 A 正在
给字符串对象 s 分配内存,另外一个线程 B 同时为 ArrayList 对象 l 分配内
存,两个线程就发生了抢占。

为了解决堆内存分配的竞争问题,JVM 为每个线程保留了一小块内存空
间,被称为 TLAB,也就是线程本地分配缓冲区,用于存放该线程分配的对
象。
当线程需要分配对象时,直接从 TLAB 中分配。只有当 TLAB 用尽或对
象太大需要直接在堆中分配时,才会使用全局分配指针。

提问: 内存溢出与内存泄漏的关系?

内存溢出,俗称 OOM,是指当程序请求分配内存时,由于没有足够的内存空间,从而抛出 OutOfMemoryError

内存泄漏是指程序在使用完内存后,未能及时释放,导致占用的内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终导致内存溢出。

内存泄漏的可能原因:

  • 静态的集合中添加的对象越来越多,但却没有及时清理;静态变量的生命周期与应用程序相同,如果静态变量持有对象的引用,这些对象将无法被 GC 回收。

  • 单例模式下对象持有的外部引用无法及时释放;单例对象在整个应用程序的生命周期中存活,如果单例对象持有其他对象的引用,这些对象将无法被回收。

  • 数据库、IO、Socket 等连接资源没有及时关闭;

  • ThreadLocal 的引用未被清理,线程退出后仍然持有对象引用;在线程执行完后,要调用 ThreadLocal 的 remove 方法进行清理。

提问: 内存泄漏处理:

可视化的监控工具 VisualVM,配合 JDK 自带的 jstack 等命令行工具进行了排查。

第一步,使用 jps -l 查看运行的 Java 进程 ID。

第二步,使用top -p [pid] 查看进程使用 CPU 和内存占用情况。

第三步,使用 top -Hp [pid] 查看进程下的所有线程占用 CPU 和内存情况。

第四步,抓取线程栈:jstack -F 29452 > 29452.txt,可以多抓几次做个对比。看看有没有线程死锁、死循环或长时间等待这些问题。

第五步,可以使用jstat -gcutil [pid] 5000 10 每隔 5 秒输出 GC信息,输出 10 次,查看 YGC 和 Full GC 次数。通常会出现 YGC 不增加或增加缓慢,而 Full GC 增加很快。或使用 jstat -gccause [pid] 5000 输出 GC 摘要信息。或使用 jmap -heap [pid] 查看堆的摘要信息,关注老年代内存使用是否达到阀值,若达到阀值就会执行 Full GC。如果发现 Full GC 次数太多,就很大概率存在内存泄漏了。

第六步,生成 dump 文件,然后借助可视化工具分析哪个对象非常多,基本就能定位到问题根源了。
执行命令 jmap -dump:format=b,file=heap.hprof 10025 会输出进程 10025 的堆快照信息,保存到文件 heap.hprof 中。

第七步,使用图形化工具分析,如 JDK 自带的 VisualVM,从菜单 > 文件 > 装入 dump 文件。

提问:内存溢出处理:

通过导出堆转储文件进行分析发现的。

首先使用 jmap 命令手动生成 Heap Dump 文件:

1
jmap -dump:format=b,file=heap.hprof <pid>

然后使用 MAT、JProfiler 等工具进行分析,查看内存中的对象占用情况。
一般来说:
如果生产环境的内存还有很多空余,可以适当增大堆内存大小来解决,例如 -Xmx4g 参数。或者检查代码中是否存在内存泄漏,如未关闭的资源、长生命周期的对象等。
之后,在本地进行压力测试,模拟高负载情况下的内存表现,确保修改有效,且没有引入新的问题。

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域并不真实存在,是各个线程共享的内存区域。
用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存
等。

运行时常量池
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,
当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,
主要目的是为了避免字符串的重复创建。
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,
StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize
(可以通过 -XX:StringTableSize 参数来设置),
保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动到了 Java 堆中。

提问: 方法区和永久代以及元空间是什么关系呢?

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,
这里的类就可以看作是永久代和元空间,接口可以看作是方法区,
也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。
并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

提问: 为什么要将永久代替换为元空间?

  • 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),
    而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了,
    而由系统的实际可用空间来控制,这样能加载的类就更多了。

  • 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西,
    合并之后就没有必要额外的设置这么一个永久代的地方了。

  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

提问: 变量存在 堆栈 的哪些区域?

  • 对于局部变量,它存储在当前方法栈帧中的局部变量表中。当方法执行完
    毕,栈帧被回收,局部变量也会被释放。

  • 对于静态变量来说,它存储在 Java 虚拟机规范中的方法区中,在 Java 7
    中是永久代,在 Java8 及以后 是元空间。

提问: 对象创建至销毁的流程?

使用 new 关键字创建一个对象时,JVM 首先会检查 new 指令的参数
是否能在常量池中定位到类的符号引用,然后检查这个符号引用代表的类是
否已被加载、解析和初始化。如果没有,就先执行类加载。

如果已经加载,JVM 会为对象分配内存完成初始化,比如数值类型的成
员变量初始值是 0,布尔类型是 false,对象类型是 null。

接下来会设置对象头,里面包含了对象是哪个类的实例、对象的哈希码、
对象的 GC 分代年龄等信息。

最后,JVM 会执行构造方法 完成赋值操作,将成员变量赋值为
预期的值,比如 int age = 18,这样一个对象就创建完成了。

当对象不再被任何引用指向时,就会变成垃圾。垃圾收集器会通过可达性分
析算法判断对象是否存活,如果对象不可达,就会被回收。

垃圾收集器通过标记清除、标记复制、标记整理等算法来回收内存,将对
象占用的内存空间释放出来。


JVM内存结构
http://bloomivy.github.io/2025/02/05/JVM相关/
作者
Bloom
发布于
2025年2月5日
许可协议