认识和理解JVM

因为这里是 JVM 这部分的第一块,从这里,我得回顾一下 JVM 的一些基础内容,作为基础了

JVM全称为Java Virtual Machine(Java虚拟机)。它是运行Java字节码(.class文件)的虚拟计算机,它是一个虚构出来的计算机。

它屏蔽了底层操作系统和硬件的差异,实现了“一次编写,到处运行”(Write once,Run Anywhere)

可见,JVM是 Java 天天吹的一次编写,处处运行(WORA)的关键内容,而且可知,我们写的Java代码会先被编译成一种中间语言(字节码, .class文件),那么之后 JVM 的关键工作就是:

  • 加载:找到这些.class文件
  • 翻译&执行:把字节码指令一条条翻译成特定操作系统的机器码,并指挥CPU执行
  • 管理资源:给程序运行过程中需要的各种“东西”(对象、信息)分配内存空间(堆、栈等),并在不需要时清理掉(垃圾回收)

因此,按照JVM的工作内容,JVM 主要由三个子系统组成(怎么又有四个):

  • 类加载器:负责从文件系统或网络中加载 .class 文件。它不负责运行,只负责将类信息加载进内存。
  • 运行时数据区:这部分就是这部分要讲到的 JVM 内存模型
  • 执行引擎:负责执行字节码。它包含
    • 解释器: 逐行解释执行字节码(启动快,执行慢)。
    • JIT 编译器 (Just-In-Time): 把热点代码(经常运行的代码)编译成机器码缓存起来(编译慢,执行超快)。
    • 垃圾回收器 (GC): 自动回收不再使用的对象。把内存释放
  • 本地方法接口:当Java代码需要调用操作系统底层机制(比如读写文件、网络处理)或者用C/C++写的库时,就利用它来沟通
image-20260122111114289

最后嗦一下3J的关系,JDK 是开发工具包,JRE 是运行环境,JVM 只是 JRE 的一部分,负责执行字节码。

前言

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++ 程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。

但正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

运行时数据区

运行时数据库区结构

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域,而且 JVM 运行时数据区的核心划分逻辑是为了支撑 Java 程序的执行,整体可分为两类:

  • 线程私有区域:每个线程独立拥有,随线程创建 / 销毁而生命周期同步
  • 线程共享区域:所有线程共用,随 JVM 启动 / 关闭而创建 / 销毁

在网上借鉴几张图片

JDK 1.8 和之前的版本略有不同

JDK1.7

image-20260122111503674

JDK 1.8以后:JDK8 是里程碑式的调整,方法区带着它的运行时常量池来到了本地内存中,不再属于堆,而是直接使用操作系统的内存,并且改名成了元空间

image-20260122112104429

首先,总结一下,具体它们是什么,下面再说

  • 线程私有的:
    • 虚拟机栈
    • 本地方法栈
    • 程序计数器
  • 线程共享的
    • 方法区(JDK8之前)
    • 直接内存 (非运行时数据区的一部分)

程序计数器

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

JVM 中的程序计数器和操作系统的 PC寄存器 类似,可以看作是线程的 “执行进度条”

  • 对 CPU 来说,它就像程序员写代码时的 “行号标记”,记录着当前线程 “读到哪一行字节码指令了”;
  • 对 JVM 来说,它是字节码解释器的导航,解释器通过这个计数器的值,精准找到下一条要执行的字节码指令。

字节码解释器工作时通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

而且它也是唯一不会抛出 OOM 的区域(JVM 规范中都是唯一没有规定任何 OutOfMemoryError 情况的内存区域),因为占用内存极小且固定,不会溢出

虽然它小,但是为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,这才能让各线程之间计数器互不影响,独立存储,所以我们称这类内存区域为“线程私有”的

  • JVM 的多线程是通过 “CPU 时间片轮转” 实现的,同一时间 CPU 只执行一个线程的指令,当线程切换时,CPU 需要记住每个线程上次执行到哪条指令,所以必须为每个线程分配独立的程序计数器。

而且程序计数器的生命周期与线程完全同步,线程创建时生成,线程销毁时释放,无需 GC 管理:

  • 创建:随着线程的创建而创建。
  • 销毁:随着线程的结束而销毁。

而且程序计数器只存 字节码指令地址,有明确的边界

执行的方法类型 程序计数器存储内容 原因
Java 方法(非 native) 当前正在执行的 JVM 字节码指令的内存地址 Java 方法编译后生成字节码,JVM 通过地址就能找到对应的指令。
Native 方法(本地方法) 值为 Undefined(未定义) Native 方法是 C/C++ 等底层代码,不经过 JVM 字节码解释,而是通过 JNI 调用本地平台的底层代码,JVM 无需跟踪其执行位置

补充:这里的 “地址” 本质是字节码指令在方法区中的偏移量,不是物理内存地址,其实本质上认识它还是一个能定位指令的标识 即可。

image-20260122112626949

那么,总结一下就是,程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了

虽然日常开发不会直接操作程序计数器,但它的设计影响着你能感知的功能:

  1. 调试功能:IDE的 “断点调试” “单步执行”这种操作,底层就是通过修改 / 读取程序计数器的值实现的 —— 断点本质是让计数器停在指定指令地址,单步执行就是让计数器每次只走一条指令。
  2. 异常堆栈:当程序抛出异常(如 NullPointerException),堆栈信息里的 “行号”,就是 JVM 通过程序计数器的地址映射回源码行号的结果。
  3. 线程切换性能:程序计数器的 “轻量级” 设计(内存小、操作快),让线程切换的开销极低,这也是 JVM 多线程高效的原因之一。

Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的

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

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

image-20260122114324383

可以把 Java 虚拟机栈看作是线程的 “方法调用账本”

  • 每个线程有且仅有一个专属的虚拟机栈,线程创建时栈就被创建,线程销毁时栈也随之释放(线程私有、生命周期与线程一致);
  • 所有 Java 方法(非 Native 方法)的调用、执行、返回全靠这个栈来管理,方法调用的所有数据(包括参数、局部变量、执行状态等)都存在栈里;
  • 和栈的数据结构特性一样,FILO先进后出,方法调用时 栈帧入栈,方法结束时 栈帧出栈。

关于栈帧,栈由一个个栈帧组成,栈帧是方法调用的最小单位,每调用一个 Java 方法,就会创建一个栈帧并压入栈;每结束一个方法,就会弹出对应的栈帧(无论正常结束还是异常结束)。Java虚拟机栈拥有和栈一样的性质(先进后出,只支持出栈和入栈两种操作)

而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址

image-20260122115546534

局部变量表

  • 核心作用:存储方法执行过程中用到的所有局部变量,是方法的 “临时变量存储空间”;
  • 存储内容:
    • 存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double 这种基本数据类型在编译期就能确定类型)
    • 对象引用,reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针/句柄,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。比如String str = new String("test")中,str就是引用,存在局部变量表,而new String("test")的对象实例存在堆里);
  • 关键特性
    • 大小在编译期就确定(方法运行时不会动态扩容),每个变量占用的内存空间(槽位)是固定的(long/double 占 2 个槽位,其他基本类型和引用占 1 个);
    • 局部变量表是栈帧中占用内存最大的部分之一,也是方法执行的核心依赖。

操作数栈

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

    • 例如:执行int a = 1 + 2;时,JVM 会先把12压入操作数栈,执行加法运算后,把结果3弹出,存入局部变量表的a位置;

      执行方法调用时,也会先把参数压入操作数栈,再传递给被调用方法;

  • 特性:

    • 和栈一样,既然叫栈,也是先进后出结构
    • 其深度(能存放的元素数量)在编译期就确定,方法运行时不会动态改变。

动态链接(Dynamic Linking)

这是支撑 Java 多态的核心机制,是 Java 虚拟机实现方法调用的关键机制之一

在 Class 字节码文件中,方法调用以符号引用的形式存在于常量池。为了执行调用,这些符号引用必须被转换为内存中的直接引用

这个转换过程分为两种情况

  • 静态解析:对于静态方法、私有方法、构造方法(编译期就能确定调用哪个版本),在类加载的 “解析阶段” 就完成转换(比如调用Math.abs(),编译期就知道调用哪个方法);

  • 动态链接:对于虚方法(比如Object.toString()、子类重写父类的方法等),编译期无法确定具体调用哪个版本(多态),这个转换过程则被推迟到程序运行期间,由动态链接来完成,在运行时根据对象的实际类型,把符号引用转成正确的直接引用;

    • 例如:

      1
      2
      Animal animal = new Cat(); 
      animal.eat();

      编译期只知道animalAnimal类型,但运行时实际是Cat对象,动态链接会在运行时把eat()的符号引用,链接到Cat.eat()的内存地址,这就是多态的底层实现。

因此,动态链接的核心作用是在运行时解析虚方法的调用点,将其链接到正确的方法版本上

image-20260122120338031

Java虚拟机栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。

不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。这也是搓 DFS 时候经典的问题了。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出熟悉的 StackOverFlowError栈溢出 错误。

方法返回地址

  • 核心作用:记录方法执行完后,要返回给调用者的 “位置”(即程序计数器的地址),方便后续操作;
    • 例如:方法 A 调用方法 B,方法 B 的栈帧中会记录 “方法 A 执行到哪条指令时调用的 B”;当 B 执行完(正常 return 或抛异常),JVM 会根据这个返回地址,把程序计数器跳回方法 A 的对应位置,继续执行 A 的后续代码;

Java 方法有两种返回方式

  • 正常返回:执行return语句,返回值传递给调用者。
  • 异常返回:方法执行过程中抛出异常且未被捕获。

不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 那么当虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

简单总结一下程序运行中栈可能会出现两种错误:

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

虚拟机栈的关键参数

日常开发 和 调优中,最常用的栈相关参数是:

  • -Xss:设置每个线程的虚拟机栈大小,比如-Xss1024k(1MB)、-Xss2m(2MB);
  • 调整存在原则:如果程序需要深层递归(比如算法类程序),可以适当调大-Xss;如果需要创建大量线程(比如高并发服务),可以适当调小-Xss(减少每个线程的栈内存占用,避免系统内存不足)。

本地方法栈

image-20260122120855981

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 (Native 方法是用 C/C++ 等非 Java 语言编写、通过 JNI(Java Native Interface)调用的方法,比如System.currentTimeMillis()Object.hashCode()等底层方法);

在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

  • JVM 规范对本地方法栈的约束很宽松,不同虚拟机实现差异很大。其中最常用的 HotSpot 虚拟机,并没有单独划分 “虚拟机栈” 和 “本地方法栈” 的物理内存区域,而是将两者合并实现,也就是说,在 HotSpot 中,Java 方法和 Native 方法的调用,共用同一块栈内存空间(只是逻辑上区分服务对象)。

数据结构同样遵循先进后出(FILO) 的栈结构,和虚拟机栈的操作逻辑一致,调用 Native 方法时压入对应的栈帧,方法执行结束后弹出栈帧。

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

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误

v-3

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

Java 的堆所以就是 JVM 管理的对象仓库

  • 线程共享:堆是所有线程共用的内存区域(和程序计数器、虚拟机栈、本地方法栈的 “线程私有” 形成核心区别),虚拟机启动时就创建,直到 JVM 退出才释放;
  • 核心用途:存放几乎所有的对象实例和数组(比如new User()int[] arr = new int[10],对象 / 数组本身都存在堆里,引用存在虚拟机栈的局部变量表中);
  • GC 核心区域:堆是垃圾收集器(GC)的主要 “工作场地”,因此也被称为GC 堆,堆的分区设计、对象晋升规则都是为了让 GC 更高效;
    • 从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存
  • 特殊优化:“几乎”所有的对象都在堆中分配,所以说肯定出现例外,随着 JIT 编译器和逃逸分析技术的成熟,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。(栈上分配),打破了 “所有对象都在堆上” 的绝对规则,JDK7 默认开启逃逸分析。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

image-20260122122722494
  • 新生代:存放刚创建的、存活时间短的对象,采用 “复制算法” GC(效率高),GC 频率高(Minor GC);
  • 老年代:存放存活时间长的对象,采用 “标记 - 清除 / 标记 - 整理” 算法 GC,GC 频率低(Major GC/Full GC);
  • Survivor 区的作用:作为新生代 GC 的 “临时中转站”,避免新生代对象直接进入老年代,减少老年代 GC 压力。

可以看到 JDK 1.8 及之后,取消了永久代,被 Metaspace(元空间) 取代,元空间使用的是本地内存。

对象在堆中的分配与晋升规则是堆最核心的运行逻辑:

graph TD
A[创建新对象] --> B{Eden区是否有空间?};
B -- 有 --> C[对象分配到Eden区];
B -- 无 --> D[触发Minor GC(新生代GC)];
D --> E{Eden区存活对象};
E --> F[存活对象进入S0/S1区,年龄+1];
F --> G{对象年龄≥阈值(默认15)?};
G -- 是 --> H[晋升到老年代];
G -- 否 --> I[留在Survivor区];
H --> J[老年代满则触发Major GC/Full GC];

大部分情况,对象都会首先在 Eden 区域分配(除了大对象,如超大数组)

当 Eden 区满时,触发Minor GC(新生代 GC),回收 Eden 区中不再被引用的对象,如果对象还存活,则会进入 S0 或者 S1 的 Survivor 区,每次 Minor GC 后,Survivor 区的存活对象年龄 + 1,且会在 S0 和 S1 之间 “复制流转”,其中对象从 Eden 区 进入到 Survivor 区后对象的初始年龄是 1

当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:

1
MaxTenuringThreshold of 20 is invalid; must be between 0 and 15
  • 为什么年龄只能是 0-15?

    因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:

  • 对象头(Header):包括标记字段(Mark Word)和类型指针(Klass Word)两部分
  • 实例数据(Instance Data)
  • 对齐填充(Padding)。

这个年龄信息就是在对象头中的标记字段中存放的,markOop.hpp定义了标记字(mark word)的结构,可以看到对象年龄占用的大小确实是 4 位。

image-20260122123456211

而且,Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半(TargetSurvivorRatio=50),则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。

例如,若MaxTenuringThreshold=15,但年龄 1-4 的对象总大小已超过 Survivor 区的 50%,则实际阈值为 4,年龄≥4 的对象直接晋升老年代(无需等到 15);

这样会避免 Survivor 区被占满,导致对象提前进入老年代。

而且有一些特殊情况,除了年龄达标,以下情况对象会直接晋升老年代:

  • 大对象:可通过-XX:PretenureSizeThreshold设置阈值,超过该大小的对象(如超大数组)直接分配到老年代(避免在 Eden 和 Survivor 之间频繁复制);
  • Survivor 区空间不足:Minor GC 后,Survivor 区无法容纳所有存活对象,多余对象会直接进入老年代(这是一个空间分配担保机制)。

堆是 OOM 异常的 “高发区”

  • java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
    • 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。(好像是JVM 在最近 98% 的时间都在执行 GC,但每次 GC 仅回收了不到 2% 的堆空间,且连续多次就出现这种情况)
    • 存在这种情况的原因无非就是堆内存严重不足,GC 频繁执行但几乎回收不到内存,程序无法正常运行;这时候就要调大堆内存,如果不行,查看是不是有无限循环创建对象
  • java.lang.OutOfMemoryError: Java heap space
    • 创建新对象时,堆(新生代 / 老年代)没有足够的空间分配,且 GC 后仍无法释放足够内存,就会触发这样的异常。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size
    • 这种情况无非两种,程序创建大量大对象(如超大集合、数组),超出堆的最大配置;内存泄漏(如静态集合持有大量对象引用,GC 无法回收);需要鼓捣你的JProfiler了看看什么情况了

日常开发 / 调优中,堆的关键配置参数如下(HotSpot 虚拟机):

参数 作用
-Xms 堆的初始大小(如-Xms1g),建议和-Xmx设置相同,避免 JVM 动态调整堆大小
-Xmx 堆的最大大小(如-Xmx2g),直接决定堆的上限
-XX:NewRatio 新生代与老年代的比例(如-XX:NewRatio=2,表示老年代:新生代 = 2:1)
-XX:SurvivorRatio Eden 区与 Survivor 区的比例(如-XX:SurvivorRatio=8,表示 Eden:S0:S1=8:1:1)
-XX:MaxTenuringThreshold 对象晋升老年代的最大年龄(0-15,默认 15)
-XX:PretenureSizeThreshold 大对象直接进入老年代的阈值(单位字节,如-XX:PretenureSizeThreshold=1048576表示 1MB)
-XX:+PrintGCDetails 打印 GC 详细日志,便于分析堆的 GC 情况

补充一些栈上分配的内容:

  • JVM 分析对象的引用范围,来判断对象是否 “逃逸”
  • 若对象未逃逸(仅在方法内部使用,如局部变量),则直接在虚拟机栈的栈帧中分配内存,无需进入堆;栈上分配的对象随栈帧弹出而销毁,无需 GC,提升性能。
  • 而且栈上分配会标量替换来处理对象,这是进一步的优化,将对象拆解为基本类型(标量),直接存储在局部变量表中,彻底避免对象创建;

方法区

v-4

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

Java 虚拟机规范其实只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。

在不同的虚拟机实现上,方法区的实现是不同的。

当虚拟机加载一个类时,它会从 Class 字节码文件中解析出相应的信息,并将这些元数据存入方法区。

具体来说,方法区主要存储以下核心数据:

  • 类的元数据:包括类的完整结构,如类名、父类、实现的接口、访问修饰符,以及字段和方法的详细信息(名称、类型、修饰符等)。
  • 方法的字节码:每个方法的原始指令序列。
  • 运行时常量池(每个类的常量池):每个类独有的,由 Class 文件中的常量池转换而来,用于存放编译期生成的各种字面量和对类型、字段、方法的符号引用。运行时常量池 JDK8 前在永久代(方法区实现),JDK8 + 在元空间(方法区实现);

元空间并非单一内存块,而是分为两部分

  • Klass Metaspace:存储类的元数据(类名、方法信息、字段信息等),这部分是可回收的(类卸载时释放);
  • NoKlass Metaspace:存储全局的元数据(如字符串常量池的符号表、模块信息),这部分基本不回收

很多人误以为方法区(永久代 / 元空间)不参与 GC,这是错误的:

  • 回收内容:主要回收两部分:
    • 废弃的常量(如运行时常量池中不再被引用的字面量);
    • 无用的类(满足 3 个条件:该类所有实例已被回收 + 加载该类的 ClassLoader 已被回收 + 该类的Class对象无任何引用);
  • 常量回收简单,但类的回收条件严苛,且永久代的类回收效率远低于元空间;对于动态生成类的场景(如 Spring/CGLIB、动态代理、热部署),方法区 GC 至关重要,否则会导致永久代 / 元空间溢出。

需要特别注意的是,以下几类数据虽然在逻辑上与类相关,但在 HotSpot 虚拟机中,它们并不存储在方法区内:

  • 静态变量(Static Variables):自 JDK 7 起,静态变量已从方法区(永久代)移至 Java 堆(Heap)中,与该类的 java.lang.Class 对象一起存放。
  • 字符串常量池(String Pool):同样自 JDK 7 起,字符串常量池也移至 Java 堆中
  • 即时编译器编译后的代码缓存(JIT Code Cache):JIT 编译器将热点方法的字节码编译成的本地机器码,存放在一个独立的、名为“Code Cache”的内存区域,而不是方法区本身。这样做是为了实现更高效的执行和内存管理

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

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

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

img

整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,经常溢出,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是你电脑本地内存大得多了就,比原来出现的几率会小很多

1
当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制

-XX:MetaspaceSize 调整标志定义元空间的初始大小,如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

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

而且在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西,它们直接用本地内存实现方法区, 合并之后就没有必要额外的设置这么一个永久代的地方了

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

实际开发中,元空间 OOM 的常见原因如下

  • 第三方框架动态生成大量代理类(如 MyBatis、Spring AOP),且 ClassLoader 未正确释放(如线程池持有 ClassLoader 引用);
    • 使用-XX:+TraceClassLoading/-XX:+TraceClassUnloading打印类加载 / 卸载日志,排查未卸载的类;
  • MetaspaceSize设置过小,导致元空间 GC 频繁(日志中出现大量Metaspace GC),最终触发 OOM;
    • 调大MetaspaceSize,减少 GC 次数;

永久代 OOM 的经典场景

  • Tomcat 热部署次数过多
  • 使用 CGLIB 动态生成大量子类

运行时常量池

v-5

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table)

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。

字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。

符号引用和直接引用

常量池表会在类加载后存放到方法区的运行时常量池中。

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

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

字符串常量池

v-6

字符串常量池 是 JVM 为了提升性能和减少内存消耗,针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

1
2
3
4
5
6
// 1.在字符串常量池中查询字符串对象 "ab",如果没有则创建"ab"并放入字符串常量池
// 2.将字符串对象 "ab" 的引用赋值给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象 "ab",赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true

在 HotSpot 虚拟机中,字符串常量池的底层是StringTable(一个 HashTable),存储 “字符串字面量(key)与堆中字符串对象引用(value)” 的映射关系

那么,同一个字面量的字符串,在常量池中只会保留一个引用,所有指向该字面量的变量都共享这个引用(这也是aa==bb返回 true 的原因)。

那么假如,我改一下代码

1
2
3
4
String aa = "ab";
String cc = new String("ab");
System.out.println(aa==cc); // 是什么结果呢
System.out.println(aa.equals(cc)); // 是什么结果呢

那么此时,JVM 会至少创建一个堆对象,最多两个,首先,JVM 会先去字符串常量池中查找是否存在内容为 "ab" 的对象。如果没有,则在常量池中创建一个 "ab" 对象;如果有,则直接返回引用。那么,变量 aa 存储在中,它直接指向常量池里的那个 "ab"。而下一行的 new 意味着强制在堆中开辟一块新的内存空间。JVM 会在中创建一个新的 String 对象。虽然这个对象内部引用的字符数组也指向 "ab",但 cc 本身指向的是堆中的这块新地址。变量 cc 存储在中,它指向的是里的新对象。所以说,比较内存地址(==)肯定是不一样的,aa 指向的是常量池地址,cc 指向的是堆地址。比较内容肯定是一样的。

那么类似的题还有几个,我再放几个

1
2
3
String s1 = "a" + "b";
String s2 = "ab";
System.out.println(s1 == s2); // 结果:true
  • 编译期优化。编译器发现 "a" + "b" 都是常量,直接合成为 "ab"。所以 s1 也是指向常量池。
1
2
3
4
String s1 = "a";
String s2 = s1 + "b";
String s3 = "ab";
System.out.println(s2 == s3); // 结果:false
  • 底层使用了 StringBuilder.append()。只要拼接中包含变量,生成的新字符串对象都会在中,而不是常量池。

String.intern()是手动将字符串对象加入常量池的核心方法,它检查常量池是否有当前字符串的字面量,若有,返回常量池中的引用;若无,将当前字符串的引用存入常量池,再返回该引用;

1
2
3
4
5
String s1 = new String("abc");
String s2 = s1.intern();
String s3 = "abc";
System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // true

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动到了 Java 堆中。

image-20260122131638916

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存

JVM 常量池中存储的是对象还是引用呢?

  • 对于运行时常量池:存引用,引用的具体内容也大都是常量池中的字面常量;
  • 对于字符串常量池:JDK1.8版本的字符串常量池中存的是字符串对象,以及字符串常量值。

直接内存

v-7

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现(电脑内存爆了或者分配不够)。

JDK1.4 中新加入的 NIO,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块对内存的引用来进行操作。这样就能在一些场景中显著提高性能,因为这样的引用避免了在 Java 堆和 Native 堆之间来回复制数据

直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

类似的概念还有 堆外内存 。在一些文章中将直接内存等价于堆外内存,个人觉得不是特别准确。

堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。