JVM
JVM
基础
什么是 Java 虚拟机?为什么 Java 被称作是“平台无关的编程语言”?
Java 虚拟机是一个可以执行 Java 字节码的虚拟机进程。
Java 源文件被编译成能被 Java 虚拟机执行的字节码文件,Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。
怎样通过 Java 程序来判断 JVM 是 32 位 还是 64 位?
你可以检查某些系统属性如 sun.arch.data.model 或 os.arch 来获取该信息。
32 位 JVM 和 64 位 JVM 的最大堆内存分别是多少?
理论上说上 32 位的 JVM 堆内存可以到达 2^32, 即 4GB,但实际上会比这个小很多。不同操作系统之间不同,如 Windows 系统大约 1.5GB,Solaris 大约3GB。64 位 JVM 允许指定最大的堆内存,理论上可以达到 2^64,这是一个非常大的数字,实际上你可以指定堆内存大小到 100GB。甚至有的 JVM,如 Azul,堆内存到 1000G 都是可能的。
内存模型
问题集锦-引用类型
引用类型有哪些?有什么区别?
- 强引用:保证核心对象存活。
- 软引用:平衡内存与性能(如缓存)。
- 弱引用:避免内存泄漏(如监听器)。
- 虚引用:实现资源精准释放(如 JNI 本地内存)。
详见 再谈引用(引用的分类)。
弱引用了解吗?举例说明在哪里可以用?
弱引用的核心价值在于平衡内存与功能需求,典型应用包括。
- 缓存系统:允许内存敏感的自动释放(如 WeakHashMap)。 * 内存泄漏防护:解耦长生命周期对象与短生命周期对象(如监听器)。 * 资源管理:跟踪需释放的外部资源(如文件句柄)。
- 通过合理使用弱引用,可以在不牺牲功能的前提下优化内存使用,提升应用稳定性。
问题集锦-内存模型
详解 JVM 内存模型-JVM 的主要组成部分及其作用
JVM 包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、Execution engine (执行引擎);两个组件为 Runtime data area(运行时数据区)、Native Interface (本地接口)。
- Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载 class 文件到 Runtime data area 中的 method area。
- Execution engine(执行引擎):执行 classes 中的指令。
- Native Interface(本地接口):与 native libraries 交互,是其它编程语言交互的接口。
- Runtime data area(运行时数据区域):这就是我们常说的 JVM 的内存。
作用:首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
JVM 内存模型里的堆和栈有什么区别?
可见性
- 堆:线程共享,所有线程均可访问堆中的对象。
- 栈:线程私有,仅当前线程可访问自己的栈帧(局部变量、方法参数等)。
定位
- 堆:存储对象实例和数组。
- 栈:存储方法调用帧(局部变量、返回地址等)。
内存分配
- 堆:动态分配(运行时决定大小),需垃圾回收(GC)。
- 栈:静态分配(线程创建时固定大小),方法结束自动释放。
生命周期
- 堆:对象存活时间由 GC 决定(可达性分析)。
- 栈:栈帧随方法调用结束立即销毁。
性能
- 堆:分配/回收速度慢(可能触发STW)。
- 栈:分配/回收极快(栈帧压栈/弹栈操作)。
异常类型
- 堆:OutOfMemoryError(无法分配对象)。
- 栈:StackOverflowError(递归过深或栈帧溢出)。
设计差异
- 堆:复杂(分代/分区、GC 策略)。
- 栈:简单(线性分配,无内存碎片)。
总结
- 堆关注对象生命周期和 GC 效率;栈关注方法执行效率和线程隔离性。
栈中存的到底是指针还是对象?
存储位置 | 内容 | 示例 |
---|---|---|
栈 | 对象引用(指针) | Person person = new Person() 中的 person 变量 |
堆 | 对象实例和数组 | new Person() 创建的对象 |
栈 | 基本数据类型的值 | int age = 25; 中的 age |
堆分为哪几部分呢?
堆的核心分区为新生代(Eden + Survivor)和 老年代,结合分代收集策略,针对不同生命周期对象优化 GC 效率。元数据区独立管理类元信息,不属于堆范畴。
注意:某些 JVM 为大对象分配了专门的区域,如 G1 的 Humongous 区域是专为超大对象设计的特殊分区,通过连续分配减少跨 Region 引用,但可能引发内存碎片和 Full GC 风险。
程序计数器的作用,为什么是私有的?
- 多线程并发执行的隔离性
线程切换的上下文保存
多个线程可能同时执行不同的代码路径(如不同的方法或循环)。
如果共享一个 PC 寄存器,线程切换时会覆盖彼此的执行位置,导致数据混乱。独立执行位置的维护
每个线程必须记录自己的执行进度(下一条指令地址),私有 PC 寄存器确保线程间互不干扰。
- 硬件层面的映射
物理 CPU 的寄存器模拟
在物理 CPU 中,每个线程的上下文(包括 PC)需要独立保存到寄存器或栈中。
JVM 模拟这一行为,为每个线程分配独立的 PC 寄存器,以匹配底层硬件的执行模式。指令流水线的依赖
现代 CPU 依赖 PC 寄存器驱动指令流水线,私有化设计避免了多线程竞争导致的流水线冲突。
- 简化虚拟机设计
无锁同步的需求
若 PC 是共享的,需要复杂的同步机制(如锁)维护其值,这会降低性能并增加复杂性。
私有 PC 寄存器天然支持线程独立执行,无需额外同步。单步执行的语义保证
PC 寄存器是单步执行的基石,私有化确保每条指令按顺序执行(除非遇到跳转指令),避免状态共享引发的逻辑错误。
方法区中还有哪些东西?
类的元数据
- 类名、父类、实现的接口、修饰符(
public
/final
等) - 字段信息(名称、类型、修饰符)
- 方法信息(名称、参数类型、返回值类型、字节码、异常表)
- 注解信息(类、方法或字段上的注解元数据)
- 类名、父类、实现的接口、修饰符(
运行时常量池
- 字面量(如字符串、数值)
- 符号引用(类名、方法名、字段名)
- 动态链接所需信息(方法调用的解析依据)
静态变量(Static Variables)
- 类级别的变量(
static
修饰) - 基本类型(如
int
)和引用类型(如对象指针)
- 类级别的变量(
JIT 编译后的代码
- 热点代码的本地机器码(如 HotSpot 的 C1/C2 编译器生成)
类加载器元信息
- 类加载器(ClassLoader)的上下文
- 类隔离信息(如 Tomcat 自定义类加载器的 Web 应用类)
其他运行时数据
- 异常表(
try-catch
块范围及处理代码地址) - 方法区锁信息(类初始化锁,防止重复初始化)
- 异常表(
Java 版本差异
- Java 7 及之前:永久代(PermGen)存储类元数据,易引发内存溢出(
java.lang.OutOfMemoryError: PermGen
)。 - Java 8 及之后:元空间(Metaspace)替代永久代,存储在本地内存,避免固定大小限制。
- Java 9+:进一步优化元空间管理,默认移除永久代相关参数(如
-XX:MaxPermSize
)。
- Java 7 及之前:永久代(PermGen)存储类元数据,易引发内存溢出(
动态链接支持
- 方法区中的符号引用在运行时解析为直接引用(如方法调用
invokevirtual
的目标地址)。
- 方法区中的符号引用在运行时解析为直接引用(如方法调用
方法区锁
- 用于多线程环境下类初始化的同步控制(如双重检查锁定中的类加载锁)。
方法字节码
方法的字节码(Code 属性)存储在方法区的
method_info
结构中,包含操作码(Opcode)、操作数、异常表等。字节码指令格式由
def()
函数定义,例如_iconst_0
表示将常量 0 压栈,invokevirtual
表示动态方法调用。字节码是平台无关的中间表示,需通过 JVM 解释执行或 JIT 编译为本地机器码。
符号引用
- 符号引用存储在方法区的常量池中,包括类/接口全限定名、字段名、方法名及描述符(如
Ljava/lang/String;
、()V
)。 - 符号引用在类加载的 解析阶段 转换为直接引用(如内存地址),例如:
- 类/接口解析:通过类加载器加载符号引用的类。
- 字段/方法解析:递归搜索父类或接口,匹配字段或方法签名。
- 符号引用存储在方法区的常量池中,包括类/接口全限定名、字段名、方法名及描述符(如
常量池缓存
- 静态常量池:编译期生成的
.class
文件常量池,包含字面量(如字符串、数值)和符号引用。 - 运行时常量池:类加载后,将静态常量池加载到方法区,并动态解析符号引用为直接引用。
- 字符串常量池:
- 存储字符串字面量的唯一引用(JDK 7 前在方法区,JDK 7+ 移至堆)。
- 通过
intern()
方法将运行时生成的字符串加入池中,避免重复创建。
- 动态常量缓存:运行时生成的常量(如
String.valueOf()
结果)可能被 JVM 缓存优化。
- 静态常量池:编译期生成的
问题集锦-分派
静态分派与动态分派
静态分派。
- 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用是方法重载(根据参数的静态类型来定位目标方法)。
- 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。
动态分派。
- 在运行期根据实际类型确定方法执行版本。
对象分配规则
- 对象优先分配在 Eden 区,如果 Eden 区没有足够的空间时,虚拟机执行一次 Minor GC。
- 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了 1 次 Minor GC 那么对象会进入 Survivor 区,之后每经过一次 Minor GC 那么对象的年龄加 1,知道达到阀值对象进入老年区。
- 动态判断对象的年龄。如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保。每次进行 Minor GC 时,JVM 会计算 Survivor 区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次 Full GC,如果小于检查 HandlePromotionFailure 设置,如果true 则只进行 Monitor GC,如果 false 则进行 Full GC。
对象分配内存
类加载完成后,接着会在 Java 堆中划分一块内存分配给对象。内存分配根据 Java 堆是否规整,有两种方式: * 指针碰撞:如果 Java 堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。 * 空闲列表:如果 Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。 选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
什么是直接内存?
Java 直接内存(Direct Memory)是 JVM 堆外的内存区域,由操作系统直接分配和管理,不属于 JVM 运行时数据区的一部分。
详见 直接内存
如果有个大对象一般是在哪个区域?
一般都是在老年代,具体如下。
垃圾回收器 | 大对象分配位置 | 潜在问题 |
---|---|---|
G1 | Humongous Region | 内存碎片、Full GC 风险 |
CMS | 老年代 | 碎片化、Full GC 频繁 |
Parallel Old | 老年代连续空间 | 碎片化、Full GC 停顿时间长 |
方法区中的方法的执行过程?
方法区(Method Area)是 JVM 内存模型的一部分(Java 8 之前),用于存储类的元数据、常量、静态变量、方法字节码等。方法区本身不直接执行方法,而是为方法的执行提供必要的元数据支持。方法的真正执行发生在 虚拟机栈(JVM Stack) 和 执行引擎 中。以下是方法从加载到执行的完整流程:
1. 类加载阶段
加载(Loading)
- 类加载器(ClassLoader)将类的字节码文件(
.class
)加载到方法区。 - 方法区存储类的元信息:
- 方法的字节码(Code 属性)
- 方法签名(参数类型、返回值类型)
- 访问修饰符(
public
/static
等) - 常量池(Constant Pool,包含字面量和符号引用)
- 类加载器(ClassLoader)将类的字节码文件(
链接(Linking)
- 验证(Verification):确保字节码符合 JVM 规范。
- 准备(Preparation):为静态变量分配内存并赋默认值(如
int
初始化为0
)。 - 解析(Resolution):将符号引用转换为直接引用(例如方法名的内存地址)。
初始化(Initialization)
- 执行静态代码块(
static{}
)和静态变量的显式赋值。 - 此时方法区中的方法字节码已准备好,但尚未执行。
- 执行静态代码块(
2. 方法调用阶段
当调用一个方法时(如 obj.method()
):
解析方法符号引用
- JVM 根据方法名和描述符,在方法区的常量池中查找方法的字节码地址。
创建栈帧(Stack Frame)
- 在虚拟机栈中为当前方法分配一个栈帧,包含:
- 局部变量表:存储方法参数和局部变量。
- 操作数栈:执行字节码指令的临时数据存储区。
- 动态链接:指向方法区中该方法的符号引用。
- 返回地址:方法执行完毕后返回的位置。
- 在虚拟机栈中为当前方法分配一个栈帧,包含:
执行字节码指令
- 解释执行:解释器逐条读取方法区中的字节码,翻译为机器码执行。
- 即时编译(JIT):热点代码(频繁执行的方法)会被 JIT 编译器编译为本地机器码,直接由 CPU 执行。
3. 方法执行中的关键依赖
动态链接(Dynamic Linking)
方法区中的常量池存储方法的符号引用(如invokevirtual
指令),在运行时通过动态链接解析为实际的方法地址。静态变量与常量
静态变量(static
)和类常量(final static
)存储在方法区,方法执行时可直接访问。异常表(Exception Table)
方法区的字节码中包含异常表,记录try-catch
块的范围和异常处理代码地址。
4. 方法执行结束
正常返回
- 执行引擎将操作数栈顶的值作为返回值,返回到调用者的栈帧。
- 虚拟机栈弹出当前栈帧,继续执行后续代码。
异常返回
- 若发生未捕获的异常,虚拟机会查找异常表,跳转到对应的异常处理代码。
- 若无处理逻辑,线程终止,栈帧被销毁。
5. 方法区与执行过程的关系总结
阶段 | 方法区的角色 | 执行引擎的角色 |
---|---|---|
类加载 | 存储类的元数据(方法字节码、常量池等) | 无 |
方法调用 | 提供方法的符号引用和字节码 | 解释/编译字节码,操作栈帧执行指令 |
静态变量 | 存储静态变量的内存地址和值 | 通过局部变量表或操作数栈访问 |
6. 关键区别
- 方法区:存储类的静态信息(方法字节码、常量、静态变量),是线程共享的内存区域。
- 虚拟机栈:存储方法调用的栈帧(局部变量、操作数栈),是线程私有的,直接支持方法执行。
- 执行引擎:负责解析和执行字节码,依赖方法区的元数据和栈帧的运行时状态。
7. 简而言之:方法区提供方法的“设计图纸”(元数据),虚拟机栈和执行引擎根据图纸“施工”(执行)。
JVM 内存为什么要分成新生代,老年代,持久代?
JVM 将内存划分为新生代、老年代和持久代(现为元空间),核心目的是基于对象生命周期的差异,采用分代垃圾回收策略以提升内存管理效率。具体作用如下:
- 新生代:存放生命周期极短的临时对象(如局部变量),采用复制算法快速回收(Minor GC),避免内存碎片。
- 老年代:存储长期存活对象(如缓存),使用标记-清除/整理算法(Full GC),减少高频回收的性能损耗。
- 持久代/元空间:管理类元数据、常量池等(Java 8 后由元空间替代,使用本地内存),与对象回收解耦以降低堆压力。
这种分代设计通过 差异化回收策略(高频小范围回收 vs 低频大范围回收)和对象生命周期隔离,显著减少垃圾回收的停顿时间,并优化内存利用。
新生代中为什么要分为 Eden 和 Survivor?
Eden 区负责快速分配新对象(多数短命对象在此消亡),两个 Survivor 区通过交替复制存活对象(避免内存碎片)并筛选年龄达标者晋升老年代,从而降低 Full GC 频率。
堆里面的分区:Eden、survival to、from to,老年代,各自的特点。
- JVM 中堆空间可以分成三个大区,新生代、老年代、永久代。
- 新生代可以划分为三个区,Eden 区,两个幸存区。
在 JVM 运行时,可以通过配置以下参数改变整个 JVM 堆的配置比例。
1. JVM 运行时堆的大小
-Xms 堆的最小值
-Xmx 堆空间的最大值
2. 新生代堆空间大小调整
-XX:NewSize 新生代的最小值
-XX:MaxNewSize 新生代的最大值
-XX:NewRatio 设置新生代与老年代在堆空间的大小
-XX:SurvivorRatio 新生代中 Eden 所占区域的大小
3. 永久代大小调整 -XX:MaxPermSize
4. 其他
-XX:MaxTenuringThreshold,设置将新生代对象转到老年代时需要经过多少次垃圾回收,但是仍然没有被回收
问题集锦-内存泄漏、内存溢出
内存泄漏和内存溢出的理解?
定义和核心区别如下。
概念 | 内存泄漏(Memory Leak) | 内存溢出(Memory Overflow) |
---|---|---|
定义 | 程序分配内存后未正确释放,导致内存无法被回收,长期累积后可能耗尽资源。 | 程序申请内存时,系统无法满足请求,因可用内存不足而抛出异常。 |
直接原因 | 对象生命周期管理不当(如未释放引用、循环引用等)。 | 短期内内存需求超过系统限制(如大对象分配、递归过深等)。 |
表现形式 | 内存使用量随时间持续增长,但程序可能仍正常运行。 | 程序立即崩溃或抛出 OutOfMemoryError 。 |
关联性 | 内存泄漏是内存溢出的常见原因之一,但非唯一原因。 | 可能由内存泄漏引发,也可能由一次性大内存申请直接导致。 |
Java 会存在内存泄漏吗?
尽管 Java 具备自动垃圾回收机制(GC),但内存泄漏仍然可能发生。其本质是 无用对象因错误的引用关系无法被 GC 回收,导致内存占用持续增加,最终可能引发 OutOfMemoryError。
以下是内存泄漏的一些常见原因。
长生命周期对象持有短生命周期对象的引用
- 场景:静态集合类(如
static List
、static Map
)持续添加对象而未移除。 - 示例:
private static List<Object> staticList = new ArrayList<>(); staticList.add(new Object()); // 对象被静态集合长期持有
- 场景:静态集合类(如
未关闭的资源
- 场景:数据库连接、文件流、网络连接等未显式调用
close()
方法。 - 解决方案:使用
try-with-resources
语法自动关闭资源。
- 场景:数据库连接、文件流、网络连接等未显式调用
监听器或回调未注销
- 场景:注册的事件监听器或回调未在对象销毁时移除。
- 示例:GUI 组件销毁后未移除事件监听器。
线程泄漏
- 场景:线程池中未正确终止的线程或未关闭的线程池。
内部类引用外部类
- 场景:非静态内部类隐式持有外部类实例的引用,导致外部类无法被回收。
JVM 内存结构有哪几种内存溢出的情况
溢出错误类型 | 常见原因 | 调整参数 | |
---|---|---|---|
堆内存 | java.lang.OutOfMemoryError: Java heap space | 对象过多、内存泄漏 | -Xmx (最大堆大小)、-Xms (初始堆大小) |
方法区 | PermGen space (JDK 1.7及之前)Metaspace (JDK 1.8+) | 类过多、常量池过大 | -XX:MaxPermSize (JDK 1.7)-XX:MaxMetaspaceSize (JDK 1.8+) |
虚拟机栈 | StackOverflowError | 递归过深、方法参数或局部变量占用过大 | -Xss (线程栈大小) |
本地方法栈 | StackOverflowError | JNI 递归调用过深 | -Xss (线程栈大小) |
直接内存 | Direct buffer memory | NIO 直接缓冲区分配过多 | -XX:MaxDirectMemorySize |
什么情况下会发生栈内存溢出
触发场景 | 预防建议 |
---|---|
递归调用深度过大 | 改用迭代算法替代递归,或设置递归深度上限(如计数器控制递归层数)。 |
函数调用层次过深 | 重构代码逻辑,减少嵌套层级;避免链式回调过度嵌套。 |
局部变量占用栈内存过大 | 将大对象(如大数组)分配至堆内存(Java 使用 new ,C/C++ 使用 malloc )。 |
线程栈容量不足或线程数量过多 | 调整 JVM 参数 -Xss 增大单线程栈容量;减少线程数或改用线程池管理线程资源。 |
栈内存分配机制限制(固定容量) | 合理设置栈初始大小(如 C/C++ 编译时指定 -Wl,--stack=size )。 |
有具体的内存泄漏和内存溢出的例子么请举例及解决方案?
实际中遇到的有下面两个内存泄露场景,典型的场内见示例。
- JPA 内存泄露。JPA 的二级缓存或查询缓存默认将查询结果对象存储在堆内存中。若缓存未配置过期策略(如
hibernate.cache.region.factory_class
未限制大小),或 IN 子句参数频繁变化导致缓存计划失效,会持续积累无用对象。 - ThreadLocal 泄露。未正确使用 remove() 方法,则会出现内存泄露。线程池中的线程长期存活,若未调用 remove(),已回收的 ThreadLocal 实例对应的 Entry 会持续积累,导致堆内存泄漏。
问题类型 | 典型场景 | 解决方案 |
---|---|---|
内存泄漏 | 静态集合持有对象、未关闭资源 | 使用弱引用、资源自动关闭 |
堆内存溢出 | 无限循环创建对象 | 调整 -Xmx、优化对象生命周期 |
栈内存溢出 | 递归无终止条件 | 修复递归逻辑、调整 -Xss |
元空间溢出 | 动态生成过多类 | 限制 -XX:MaxMetaspaceSize |
直接内存溢出 | NIO 直接缓冲区滥用 | 调整 -XX:MaxDirectMemorySize |
问题集锦-String
String 保存在哪里呢?
创建方式 | 对象存储位置 | 字面量存储位置 | 引用比较结果 |
---|---|---|---|
直接赋值(字面量) | 字符串常量池(堆中) | 字符串常量池(堆中) | s1 == s2 → true |
new String() | 堆内存(独立对象) | 字符串常量池(堆中) | s3 == s4 → false |
字符串拼接(编译期优化) | 字符串常量池(堆中) | 字符串常量池(堆中) | s5 == "ab" → true |
字符串拼接(运行时) | 堆内存(新对象) | 无新字面量加入 | s6 == s1 → false |
注意常量池位置随版本有变化:
- JDK 6 及之前:字符串常量池位于 方法区(永久代)。 * JDK 7+:字符串常量池迁移到 堆内存 中。 * JDK 8+:方法区由元空间(Metaspace)实现,但字符串常量池仍保留在堆中。
String s = new String(“abc”)执行过程中分别对应哪些内存区域?
- 默认情况:创建 2 个对象(常量池的
"abc"
+ 堆中的String
实例)。 - 特殊情况:若常量池已存在
"abc"
,则仅创建 1 个对象(堆中的String
实例)。
String.intern()
我们用一个示例理解,示例代码如下。
String str2 = new StringBuilder("计算机").append("技术").toString();
System.out.println(str2 == str2.intern()); // 1.7、1.8 true 1.6 false
String s2 = new StringBuilder("计算机技术").toString();
System.out.println(s2 == s2.intern()); // 1.7、1.8 false 1.6 false
该问题要点是要理解 3 个区域,栈、堆,字符串常量池,字符串常量池在不同版本的 JDK 中变化如下。
- Jdk1.6 及之前:JVM 存在永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池。
- Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里。
- Jdk1.8 及之后:无永久代,变成了元空间,运行时常量池在元空间,字符串常量池里依然在堆里。
String 中的 intern 方法是一个 native 的方法,我们忽略运行时常量池,只关心字符串常量池就行了,字符串常量池在 JDK1.7 后都在堆中。
- JDK1.7(含) + ,当调用 intern 方法时,如果字符串常量池已经包含一个等于此 String 对象的字符串(用 equals 方法确定),则返回池中的字符串, 否则,将 intern 返回的引用指向当前字符串。
- Jdk1.6 版本需要将 s2 复制到字符串常量池里。
我们分析如下。
- JDK 1.6 的 str2.intern() 和 s2.intern() 指向永久代的字符串常量池,str2 和 s2 指向堆中两个不同的引用,所以两个都是 false。
- JDK 1.7 的 str2.intern(),字符串常量池中无该字符串,此时执行堆中的 str2,因此第一个是 true,第二个 s2 堆中的引用不等于 str2.intern() 引用,自然就是 false 了。
处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
- 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过
-XX:+/-UserTLAB1
参数来设定虚拟机是否使用 TLAB。
类初始化和加载
创建对象的过程?
Java 对象创建的核心流程为:类加载检查 → 内存分配 → 初始化零值 → 设置对象头 → 执行构造方法。内存分配策略和并发控制机制(如 TLAB(Thread-Local Allocation Buffer))直接影响性能,而对象内存布局的设计优化了 GC 效率与访问速度。
对象的生命周期
Java 对象的生命周期可概括为:创建阶段、应用阶段(被强引用持有)、不可达阶段(GC Roots 无法访问)、垃圾回收阶段(可能触发 finalize() 方法)及内存回收阶段。
类加载器有哪些?
- 启动类加载器:Bootstrap ClassLoader,用来加载 Java 核心类库,无法被 Java 程序直接引用,如加载存放在 JDK\jre\lib(JDK 代表 JDK 的安装目录,下同)下,或被 -Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库。
- 扩展类加载器:Extension ClassLoader,该加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 JDK\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如 javax.* 开头的类),开发者可以直接使用扩展类加载器。
- 应用程序类加载器:Application ClassLoader,该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器
- 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。
双亲委派模型的作用
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
使用双亲委派模型的作用如下。
- 避免类的重复加载:通过父类加载器优先加载类,确保同一类在JVM中仅被加载一次,减少内存占用和版本冲突风险。
- 保证核心类库的安全性:防止用户自定义类覆盖JVM核心类(如
java.lang.String
),通过父类加载器优先加载核心类库实现安全隔离。 - 提高类加载效率:父类加载器已加载的类可直接复用,减少重复加载的开销。
- 隔离类加载器的命名空间:不同类加载器加载的类处于独立命名空间,避免类冲突(如Tomcat多Web应用场景)。
- 维护类的统一行为:确保核心类(如
Object
)在所有加载器中行为一致,避免因类版本不同导致的逻辑异常。
讲一下类加载过程?
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、 初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)。
详见 类加载过程。
讲一下类的加载和双亲委派原则
就是把类加载过程和双亲委派机制加载步骤说明下就行了。
垃圾回收
问题集锦-垃圾回收理论
判断垃圾的方法有哪些?
如下 3 种。
- 引用计数法:个对象维护一个引用计数器,记录当前被引用的次数。
- 可达性分析算法:从 GC Roots 出发,遍历所有可达对象,未被遍历到的对象视为不可达(即垃圾)。
- 引用类型判定法:根据引用类型的不同,决定对象回收的优先级和条件。
详见 对象已死?。
问题集锦-垃圾回收算法
标记清除算法的缺点是什么?
- 内存碎片化问题。
- 执行效率低。标记和清除阶段均需遍历堆中的所有对象,时间复杂度为 O(n)。
- 无法处理循环引用。
- 不可预测的回收时机。被动触发:标记清除算法通常在全堆内存不足时触发回收,导致回收时机不可预测,可能在高负载时引发性能抖动。
- 内存访问冲突风险。多线程问题:在标记和清除过程中,若与程序线程并发访问内存,可能引发数据不一致或崩溃。
问题集锦-垃圾回收器
垃圾回收器有哪些?
经典垃圾回收器
Serial 收集器
- 特点:单线程串行回收,采用 "Stop-The-World" 机制;新生代使用复制算法,老年代(Serial Old)使用标记-整理算法。
- 适用场景:单核 CPU 或小内存客户端应用(如早期桌面程序)。
- 参数:
-XX:+UseSerialGC
ParNew 收集器
- 特点:Serial 的多线程版本,新生代并行回收(复制算法),需与 CMS 搭配使用。
- 适用场景:JDK8 及之前版本的服务器端低延迟场景。
- 参数:
-XX:+UseParNewGC
Parallel Scavenge/Old(PS+PO)
- 特点:多线程并行回收,以吞吐量优先(JDK8 默认组合),支持自适应调节堆大小。
- 适用场景:后台批处理、大数据计算等高吞吐场景。
- 参数:
-XX:+UseParallelGC
CMS(Concurrent Mark Sweep)
- 特点:并发标记清除(减少停顿时间),老年代使用标记-清除算法;存在内存碎片和浮动垃圾问题。
- 适用场景:Web 服务、订单系统等低延迟场景(JDK14 后已移除)。
- 参数:
-XX:+UseConcMarkSweepGC
现代垃圾回收器
G1(Garbage-First)
- 特点:分区式(Region)、并行与并发结合;采用复制算法和预测性停顿模型,支持大堆内存(6GB+)。
- 优势:平衡吞吐量与延迟,JDK9 后成为默认回收器。
- 参数:
-XX:+UseG1GC
ZGC(Z Garbage Collector)
- 特点:超低延迟(停顿 <10ms),支持 TB 级堆内存;通过读屏障和并发压缩实现。
- 适用场景:金融交易、实时系统等对延迟敏感的场景(JDK11+ 支持)。
- 参数:
-XX:+UseZGC
Shenandoah
- 特点:通过“颜色指针”和并发整理减少停顿时间,适合大堆且低延迟需求。
- 适用场景:大堆内存、低延迟场景(需手动启用)。
- 参数:
-XX:+UseShenandoahGC
Epsilon GC
- 特点:无操作的回收器,仅分配内存不回收;用于性能测试或内存管理完全可控的场景。
- 适用场景:调试、短期任务或内存泄漏检测。
- 参数:
-XX:+UseEpsilonGC
详见 经典的垃圾收集器。
G1 回收器的特色是什么?
基于分区的内存管理机制
- 将堆内存划分为多个等大小的 Region(默认约 2048 个),每个 Region 可动态切换为 Eden、Survivor、Old 或 Humongous 区域。
- 支持动态分代调整:年轻代占比通过
-XX:G1NewSizePercent
和-XX:G1MaxNewSizePercent
在 5%~60% 间自动伸缩。 - 大对象优化:超过单个 Region 50% 大小的对象分配至 Humongous 区域,避免内存碎片。
可预测的停顿时间模型
- 通过
-XX:MaxGCPauseMillis
设定目标停顿时间(如 200ms),实现软实时回收。 - 优先级回收策略:按 Region 垃圾价值(回收空间/耗时)排序,优先处理高收益 Region(Garbage-First 设计理念)。
- 衰减标准差算法:根据历史 GC 数据动态预测回收能力,确保目标时间内完成回收。
- 通过
并行与并发混合执行
- 并行回收:利用多核 CPU 并行执行 Young GC 和 Mixed GC,缩短 STW 时间。
- 并发标记:后台线程与用户线程并发运行,仅初始标记/最终标记需短暂 STW。
- 增量式回收:将老年代回收拆分为多次 Mixed GC,避免单次长时间停顿。
高效空间整合与低碎片
- 全局使用标记-整理算法(Mark-Compact),局部使用标记-复制算法(Mark-Copy)。
- 零内存碎片:支持长期运行服务稳定分配大对象。
- 连续内存分配:通过指针碰撞(Bump-the-Pointer)直接分配,无需空闲链表管理。
智能化数据结构支持
- 记忆集(RSet):每个 Region 维护跨 Region 引用记录,避免全堆扫描。
- 全局卡片表(Card Table):标记 512B 卡片单元,精准追踪对象引用变化。
- 并发标记快照(SATB):记录标记开始时的对象引用关系,防止漏标。
适用场景对比
- 最大堆内存
- CMS:≤4GB
- G1:≥8GB(支持 TB 级堆)
- 停顿时间控制
- CMS:无明确预测模型
- G1:可设定目标停顿时间
- 内存碎片问题
- CMS:需定期 Full GC 整理
- G1:自动整合(零碎片)
- JDK 版本兼容性
- CMS:JDK8 及以下主流
- G1:JDK7u4+ 可用,JDK9+ 默认
- 最大堆内存
调优建议
- 关键参数:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4M -XX:InitiatingHeapOccupancyPercent=45
- 监控工具:使用 JMX 或
jstat -gcutil
观察 Region 分布与回收效率。
- 关键参数:
垃圾回收器 CMS 和 G1 的比较?
分代与分区设计
- CMS:物理分代,严格划分新生代和老年代,需配合其他新生代收集器(如 ParNew)使用。
- G1:逻辑分代 + 物理分区,堆内存划分为多个等大小 Region(默认约 2048 个),支持动态调整分代。
回收算法与内存碎片
- CMS:基于标记-清除算法,老年代回收后产生内存碎片,可能触发 Full GC。
- G1:基于标记-整理算法,通过 Region 间对象移动避免内存碎片。
回收阶段差异
- CMS 四阶段:
- 初始标记(STW 短暂) → 并发标记 → 重新标记(STW) → 并发清除。
- G1 四阶段:
- 初始标记(STW) → 并发标记 → 最终标记(STW) → 筛选回收(按 Region 价值排序回收)。
- CMS 四阶段:
停顿时间控制
- CMS:以最小停顿时间为目标,但无法预测具体停顿时间。
- G1:支持可预测停顿模型(如
-XX:MaxGCPauseMillis=200ms
),适合大堆场景。
内存碎片与浮动垃圾
- CMS:存在内存碎片和浮动垃圾,可能因并发模式失败触发 Full GC。
- G1:无内存碎片,通过 SATB 机制避免浮动垃圾导致漏标。
大对象处理
- CMS:大对象直接进入老年代,加剧碎片问题。
- G1:大对象分配至 Humongous 区域(跨多个 Region),避免过早晋升。
辅助数据结构
- CMS:依赖 Card Table 记录跨代引用。
- G1:额外使用 Remembered Set(RSet)记录跨 Region 引用,占用约 20% Region 内存。
执行负载与读写屏障
- CMS:
- 仅需写后屏障维护卡表(Card Table),记录跨代引用,同步操作开销低。
- 并发标记阶段使用增量更新策略,重新标记阶段计算量较小。
- G1:
- 需写前屏障(支持 SATB 快照)和写后屏障(维护 RSet),异步队列处理开销高。
- 维护 Remembered Set(RSet)占用约 20% Region 内存,CPU 资源竞争更激烈。
- CMS:
适用场景
- CMS:中小型堆(≤4GB)、低延迟敏感场景(如 Web 服务),JDK8 及以下版本。
- G1:大型堆(≥8GB)、需平衡吞吐量与延迟的场景(如实时系统),JDK9+ 默认回收器。
什么情况下使用 CMS,什么情况使用 G1?
CMS 适用场景
- 低延迟需求:适用于对响应时间敏感的应用(如 Web 服务、实时交易系统),需最小化 STW 停顿。
- 中小型堆内存(≤4GB):内存碎片风险可控,需预留 20% 空间存放浮动垃圾。
- 老年代为主的回收:适合对象晋升缓慢、老年代占用率高的场景(需配合 ParNew 收集器)。
- JDK 版本限制:推荐在 JDK 8 及以下版本使用。
G1 适用场景
- 大堆内存(≥8GB):高效管理数十 GB 至数百 GB 堆内存,避免传统分代模型碎片问题。
- 可预测停顿时间:通过
-XX:MaxGCPauseMillis
设置目标停顿(如 200ms),适合金融交易等实时系统。 - 内存碎片敏感场景:长期运行服务(如云原生应用),依赖标记-整理算法避免 Full GC。
- 混合代际回收:支持同时回收新生代和老年代,适合对象生命周期复杂的应用。
- JDK 版本兼容性:JDK 7u4+ 可用,JDK 9+ 默认推荐。
什么是 Java 里的垃圾回收?为什么要 GC ?如何触发垃圾回收?
Java 垃圾回收(Garbage Collection, GC)是 JVM 提供的自动内存管理机制,用于回收程序中不再使用的对象所占用的内存空间。
其核心目标是如下 3 点。
- 内存泄漏:防止因对象长期未被释放导致的内存耗尽(如未关闭的文件句柄或数据库连接)。
- 手动管理复杂性:避免开发者手动分配/释放内存时可能出现的错误(如野指针、双重释放)。
- 内存碎片化:通过特定策略减少内存碎片,提升内存分配效率。
垃圾回收的触发条件如下。
内存分配失败
- Minor GC(新生代回收):当 Eden 区满时触发,通过复制算法快速回收存活率低的对象。
- Major GC(老年代回收):老年代空间不足时触发,通常伴随 Full GC。
显式调用
- 通过
System.gc()
或Runtime.getRuntime().gc()
建议 JVM 执行垃圾回收,但 JVM 可能忽略该请求。
- 通过
阈值触发
a. 堆内存使用率:当堆内存使用超过预设阈值(如 70%)时触发 GC。
b. 永久代/元空间满:Java 8 之前 PermGen 区满触发;Java 8+ Metaspace 区满触发。时间间隔
- 部分 JVM 实现基于固定时间间隔(如每小时)触发 GC,无论内存使用情况。
并发标记清除(CMS/G1)
- 低延迟回收器(如 CMS、G1)可能在并发标记阶段因内存碎片等问题触发 Full GC。
垃圾回收算法哪些阶段会 stop the world?
简述
STW 的触发与算法设计强相关。
- 传统算法(如标记-清除、复制)的标记和转移阶段需全局暂停。
- 并发算法(如 CMS、G1)通过并发标记减少 STW,但关键阶段仍无法避免。
- 现代算法(如 ZGC)通过硬件优化(指针染色、读屏障)将 STW 压缩至极限。
实际应用中,需根据业务需求(低延迟或高吞吐)选择合适的回收器,并通过调整堆大小、分代策略等参数优化 STW 时间。
详细介绍
标记阶段
- 初始标记(Initial Mark)
- 触发原因:需暂停应用线程以确保根对象(GC Roots)的一致性,标记直接引用的存活对象。
- 适用算法:CMS、G1(混合回收的初始阶段)。
- 耗时:毫秒级(仅处理根对象)。
- 重新标记(Remark)
- 触发原因:修正并发标记期间因应用线程修改引用导致的标记错误(漏标/误标)。
- 适用算法:CMS、G1(混合回收的最终标记阶段)。
- 耗时:毫秒级(通过写屏障优化缩短)。
- 初始标记(Initial Mark)
清除与复制阶段
- 复制/转移阶段(Evacuation)
- 触发原因:复制存活对象时需暂停线程,防止引用被修改。
- 适用算法:复制算法(年轻代回收)、G1(混合回收的复制阶段)。
- 耗时:与存活对象数量成正比(STW 主要瓶颈)。
- 清理阶段(Sweep)
- 触发原因:统计分区存活情况时需短暂暂停线程。
- 适用算法:G1。
- 耗时:微秒级(仅处理分区元数据)。
- 复制/转移阶段(Evacuation)
分代回收的 STW 场景
- 年轻代回收(Minor GC)
- 触发原因:年轻代(如 Eden 区)空间不足时复制存活对象。
- 适用算法:所有分代算法(Serial、Parallel、G1 等)。
- 耗时:与年轻代存活对象数量相关(通常较短)。
- 老年代回收(Full GC)
- 触发原因:老年代空间不足时触发全堆回收。
- 适用算法:Serial Old、Parallel Old、CMS(并发模式失败时退化)。
- 耗时:秒级(遍历全堆对象)。
- 年轻代回收(Minor GC)
现代算法的优化与例外
- 并发标记与增量回收
- CMS/G1:并发标记阶段无 STW,但初始/最终标记仍需暂停。
- ZGC/Shenandoah:通过读屏障和并发压缩,仅根扫描阶段需亚毫秒级 STW。
- Epsilon GC
- 特点:无回收操作,完全避免 STW。
- 适用场景:特殊用途(如性能测试)。
- 并发标记与增量回收
minorGC、majorGC、fullGC 的区别,什么场景触发 full GC
- 核心区别
类型 | 作用区域 | 触发条件 | 算法特点 | 耗时与影响 |
---|---|---|---|---|
Minor GC | 新生代(Eden + Survivor 区) | Eden 区空间不足时触发。 | 复制算法(存活对象复制到 Survivor 或老年代) | 耗时短(毫秒级),但频繁触发可能影响吞吐量。 |
Major GC | 老年代 | 老年代空间不足,或 Minor GC 后存活对象无法晋升至老年代时触发。 | 标记-清除或标记-整理(CMS、Parallel Old) | 耗时长(秒级),通常伴随 Minor GC,暂停时间长。 |
Full GC | 全堆(新生代+老年代)+ 方法区/元空间 | 老年代/元空间不足、显式调用 System.gc() 、晋升失败、空间分配担保失败等。 | 全局标记-整理(如 Serial Old) | 耗时最长(秒级甚至分钟级),完全暂停应用线程(STW),对性能影响最大。 |
- Full GC 的触发场景
- 老年代空间不足
- 对象晋升或大对象直接分配至老年代失败(通过
-XX:PretenureSizeThreshold
控制大对象阈值)。
- 对象晋升或大对象直接分配至老年代失败(通过
- 显式调用
System.gc()
- 开发者主动触发(需未禁用
-XX:+DisableExplicitGC
)。
- 开发者主动触发(需未禁用
- 元空间/永久代不足
- 类/方法元数据加载过多(需合理设置
-XX:MaxMetaspaceSize
)。
- 类/方法元数据加载过多(需合理设置
- 晋升失败(Promotion Failure)
- Survivor 区空间不足,且老年代无法提供担保空间。
- CMS 并发模式失败
- CMS 回收器无法及时回收足够空间,退化为 Serial Old 触发 Full GC。
- 分配大对象失败
- Eden 区无法分配大对象,且老年代空间不足。
- 空间分配担保失败
- 老年代连续空间 < 新生代存活对象总大小(未开启
HandlePromotionFailure
时触发)。
- 老年代连续空间 < 新生代存活对象总大小(未开启
- 堆转储或诊断操作
- 使用
jmap -dump
等工具强制触发。
- 使用
- 老年代空间不足
Minor GC 和 Major GC:详见 分代收集理论。
回收方法区
主要回收两部分内容:废弃的常量和不再使用的类型。
见 回收方法区。
分代垃圾回收器是怎么工作的?
以 CMS 收集器为例说明。
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
JVM 中一次完整的 GC 流程是怎样的,对象如何晋升到老年代?
GC 流程全解析
标记阶段
- 可达性分析:从 GC Roots(虚拟机栈局部变量、静态属性、常量等)出发标记存活对象。
- 两次标记机制:首次标记不可达对象,二次筛选未覆盖
finalize()
或已执行过的方法的对象直接回收。
垃圾回收
- 新生代回收(Minor GC)
- 触发条件:Eden 区空间不足时自动触发。
- 执行步骤:
- 存活对象从 Eden 和 Survivor From 区复制到 Survivor To 区(年龄 +1)。
- 清空 Eden 和 Survivor From 区,交换 From/To 区角色。
- Survivor 空间不足时,存活对象通过分配担保机制直接进入老年代。
- 老年代回收(Full GC/Major GC)
- 触发条件:老年代空间不足、大对象分配失败、显式调用
System.gc()
等。 - 执行步骤:
- 采用标记-整理算法清理老年代内存碎片。
- 回收后仍内存不足则抛出
OutOfMemoryError
。
- 触发条件:老年代空间不足、大对象分配失败、显式调用
- 新生代回收(Minor GC)
内存整理与分配
- 碎片整理:将存活对象紧凑排列(复制或移动)确保连续内存空间。
- 指针碰撞分配:直接分配连续内存,避免空闲链表管理开销。
对象晋升到老年代的条件
- 年龄阈值机制
- 对象每经历一次 Minor GC 且存活,年龄 +1,达到阈值(默认 15)时晋升。
- 调整参数:
-XX:MaxTenuringThreshold
。
- 动态年龄判定
- Survivor 区中相同年龄对象总大小 > Survivor 空间的 50% 时,年龄 ≥ 该值的对象直接晋升。
- 大对象直存老年代
- 对象大小超过
-XX:PretenureSizeThreshold
设定值(如 30MB)时直接分配至老年代。
- 对象大小超过
- Survivor 区空间不足
- Minor GC 后存活对象无法全部放入 Survivor 区时提前晋升。
- 分配担保策略
- Minor GC 前检查老年代剩余空间:
- 剩余空间 < 历次晋升对象的平均大小时触发 Full GC。
- Full GC 后仍不足则部分存活对象强制晋升。
- Minor GC 前检查老年代剩余空间:
- 年龄阈值机制
垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?
- 对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当 GC 确定一些对象为"不可达"时,GC 就有责任回收这些内存空间。
- 可以。
- 程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。
JVM 的元空间(永久代)中会发生垃圾回收么?
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免 Full GC 是非常重要的原因。
注意下 Java8 中已经移除了永久代,新加了一个叫做元空间的 native 内存区。
新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。
虚拟机执行子系统
类的生命周期
类的生命周期是 Java 虚拟机(JVM)管理类从加载到卸载的全过程,共分为加载(Loading)→ 连接(Linking)→ 初始化(Initialization)→ 使用(Using)→ 卸载(Unloading)五个阶段。
详见 类加载的过程
类装载的执行过程
类装载是 Java 虚拟机(JVM)将类的字节码文件(.class)加载到内存并转化为可执行代码的过程,其执行流程可概括为加载、链接、初始化三个阶段,其中链接阶段进一步分为验证、准备、解析 三个子步。
详见 类加载的过程
JVM 加载 class 文件的原理机制?
类的加载是将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。通常是创建一个字节数组读入 .class 文件,然后产生与所加载类对应的 Class 对象。
加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对类进行初始化,包括:
- 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类。
- 如果类中存在初始化语句,就依次执行这些初始化语句。
什么是类加载器,类加载器有哪些?
类加载器:通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”。
类加载器有如下类型。
启动类加载器(Bootstrap Class Loader)
- 由 JVM 自身用 C/C++ 实现,不继承
java.lang.ClassLoader
,无法在 Java 代码中直接访问。 - 功能:加载核心 Java 类库(如
rt.jar
、resources.jar
),路径为<JAVA_HOME>/jre/lib
或-Xbootclasspath
指定的目录。
- 由 JVM 自身用 C/C++ 实现,不继承
扩展类加载器(Extension Class Loader)
- 由
sun.misc.Launcher$ExtClassLoader
实现,父加载器为启动类加载器。 - 功能:加载扩展目录(
<JAVA_HOME>/jre/lib/ext
或java.ext.dirs
指定路径)中的 JAR 文件。
- 由
应用程序类加载器(Application Class Loader/System Class Loader)
- 由
sun.misc.Launcher$AppClassLoader
实现,父加载器为扩展类加载器。 - 功能:默认加载用户类路径(ClassPath)下的类,是程序中默认的类加载器。
- 由
自定义类加载器(User-Defined Class Loader)
- 开发者继承
ClassLoader
类实现,需重写findClass()
方法。 - 用途:动态加载网络资源、加密字节码、模块化隔离(如 Tomcat 热部署)等。
- 开发者继承
详见 双亲委派模型。
双亲委派模型
双亲委派模型是 Java 类加载器的层级委托机制,子加载器在加载类时会优先委派给父加载器处理,依次递归至最顶层的启动类加载器(Bootstrap ClassLoader),若父加载器无法加载,子加载器才会自行加载,以此保证核心类库的安全性(如防止用户篡改 java.lang.String)并避免重复加载。
详见 双亲委派模型。
Java 程序运行机制
- 首先利用 IDE 集成开发工具编写 Java 源代码,源文件的后缀为 .java;
- 再利用编译器(javac 命令)将源代码编译成字节码文件,字节码文件的后缀名为 .class;
- 运行字节码的工作是由解释器(java 命令)来完成的。

从上图可以看,java 文件通过编译器变成了 .class 文件,接下来类加载器又将这些 .class 文件加载到 JVM 中。
其实可以一句话来解释:类的加载指的是将类的 .class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。
Java 对象创建过程
首先让我们看看 Java 中提供的几种对象创建方式:
创建方式 | 描述 |
---|---|
使用 new 关键字 | 调用了构造函数 |
使用 Class 的 newInstance 方法 | 调用了构造函数 |
使用 Constructor 类的 newInstance 方法 | 调用了构造函数 |
使用 clone 方法 | 没有调用构造函数 |
使用反序列化 | 没有调用构造函数 |
下面是对象创建的主要流程:
虚拟机遇到一条 new 指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。若 Java 堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发,也有两种方式: CAS 同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行 init
方法。
Java 对象结构
Java 对象由三个部分组成:对象头、实例数据、对齐填充。
对象头由两部分组成,第一部分存储对象自身的运行时数据:对象的哈希码、GC 分代年龄、锁标识状态、线程持有的锁、偏向线程 ID(一般占 32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)。
对齐填充:JVM 要求对象起始地址必须是 8 字节的整数倍(8 字节对齐)。
JVM 实战
GC 日志分析
摘录 GC 日志一部分(前部分为年轻代 gc 回收;后部分为 full gc 回收):
2016-07-05T10:43:18.093+0800: 25.395: [GC [PSYoungGen: 274931K->10738K(274944K)] 371093K->147186K(450048K), 0.0668480 secs] [Times: user=0.17 sys=0.08, real=0.07 secs]
2016-07-05T10:43:18.160+0800: 25.462: [Full GC [PSYoungGen: 10738K->0K(274944K)] [ParOldGen: 136447K->140379K(302592K)] 147186K->140379K(577536K) [PSPermGen: 85411K->85376K(171008K)], 0.6763541 secs] [Times: user=1.75 sys=0.02, real=0.68 secs]
通过上面日志分析得出,PSYoungGen、ParOldGen、PSPermGen 属于 Parallel 收集器。其中 PSYoungGen 表示 gc 回收前后年轻代的内存变化;ParOldGen 表示 gc 回收前后老年代的内存变化;PSPermGen 表示 gc 回收前后永久区的内存变化。young gc 主要是针对年轻代进行内存回收比较频繁,耗时短;full gc 会对整个堆内存进行回城,耗时长,因此一般尽量减少 full gc 的次数。
调优命令、调优工具
- 1 基础故障处理工具
- 1.1 jps: 虚拟机进程状态工具
- 1.2 jstat: 虚拟机统计信息监视工具
- 1.3 jinfo: Java 配置信息工具
- 1.4 jmap: Java 内存映像工具
- 1.5 jhat: 虚拟机堆转储快照分析工具
- 1.6 jstack: Java 堆栈跟踪工具
- 1.7 基础工具总结
- 2 可视化故障处理工具
- 2.1 JHSDB: 基于服务性代理的调试工具
- 2.2 JConsole: Java 监视与管理控制台
- 2.3 VisualVM: 多合-故障处理工具
- 2.4 Java Mission Control: 可持续在线的监控工具
- 3 HotSpot 虚拟机插件及工具
JVM 性能调优
1)堆栈配置相关
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:MaxPermSize=16m -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxTenuringThreshold=0
-Xmx3550m: 最大堆大小为 3550m。
-Xms3550m: 设置初始堆大小为 3550m。
-Xmn2g: 设置年轻代大小为 2g。
-Xss128k: 每个线程的堆栈大小为 128k。
-XX:MaxPermSize: 设置持久代大小为 16m
-XX:NewRatio=4: 设置年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值(除去持久代)。
-XX:SurvivorRatio=4: 设置年轻代中 Eden 区与 Survivor 区的大小比值。设置为 4,则两个 Survivor 区与一个 Eden 区的比值为 2:4,一个 Survivor 区占整个年轻代的 1/6
-XX:MaxTenuringThreshold=0: 设置垃圾最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。
2)垃圾收集器相关
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseParallelGC-XX:ParallelGCThreads=20-XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=5-XX:+UseCMSCompactAtFullCollection:
-XX:+UseParallelGC: 选择垃圾收集器为并行收集器。
-XX:ParallelGCThreads=20: 配置并行收集器的线程数
-XX:+UseConcMarkSweepGC: 设置年老代为并发收集。
-XX:CMSFullGCsBeforeCompaction:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次 GC 以后对内存空间进行压缩、整理。
-XX:+UseCMSCompactAtFullCollection: 打开对年老代的压缩。可能会影响性能,但是可以消除碎片
3)辅助信息相关
-XX:+PrintGC-XX:+PrintGCDetails
-XX:+PrintGC 输出形式:
[GC 118250K->113543K(130112K), 0.0094143 secs] [Full GC 121376K->10414K(130112K), 0.0650971 secs]
-XX:+PrintGCDetails 输出形式:
[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs] [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs
高并发 JVM 调优
内存管理优化
- 固定堆大小:
-Xms
与-Xmx
设为相同值(如-Xms4g -Xmx4g
)。 - 年轻代占比 40%~50%:
-XX:NewRatio=2
,Survivor 区比例-XX:SurvivorRatio=8
。 - 限制元空间:
-XX:MaxMetaspaceSize=512m
,大对象优先使用堆外内存(DirectByteBuffer
)。
- 固定堆大小:
垃圾回收器选型
- G1:默认选择,平衡吞吐与延迟
- 参数:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
- 参数:
- ZGC:超低延迟(TB 级堆)
- 参数:
-XX:+UseZGC -Xms16g -Xmx16g
- 参数:
- Shenandoah:高并发内存压缩
- 参数:
-XX:+UseShenandoahGC
- 参数:
- G1:默认选择,平衡吞吐与延迟
线程与并发优化
- 线程池大小:
线程数 = CPU 核心数 * (1 + 平均等待时间/计算时间)
。 - 队列类型:使用有界队列(如
LinkedBlockingQueue
)并设置拒绝策略。 - 减少锁竞争:用
ReentrantLock
或ThreadLocal
替代synchronized
。
- 线程池大小:
监控与诊断工具
- 实时监控:
jstat -gcutil <pid> 1000
、Arthas(trace
/watch
命令)。 - 堆分析:
jmap -dump:format=b,file=heap.hprof <pid>
+ MAT 工具。 - GC 日志:
-Xlog:gc*:file=gc.log
,用 GCViewer 分析停顿原因。
- 实时监控:
核心避坑指南
- 避免动态堆扩容:固定
-Xms=-Xmx
防抖动。 - 禁用 CMS:内存碎片问题,高并发场景不稳定。
- 警惕无界队列:导致 OOM,需设置合理队列容量。
- 避免动态堆扩容:固定
JVM GC 响应优先与吞吐优先的区别是什么?
比如原来一个程序,10 秒收集一次,停顿时间 100ms,通过调整参数,变为 5 秒收集一次,停顿时间 70ms。很明显吞吐量下来了,但响应速度上去了。 具体 JVM 猜应该是以老年代是并发还是并行为主的配置(待验证),可参考理解。
-XX:+UseParallelGC 与 -XX:+UseParNewGC 区别
- -XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。可以同时并行多个垃圾收集线程,但此时用户线程必须停止。
- -XX:+UseParNewGC:设置年轻代为多线程收集。可与 CMS 收集同时使用。在 serial 基础上实现的多线程收集器。
特性 | Parallel GC | ParNew GC |
---|---|---|
设计目标 | 高吞吐量 | 低延迟(需搭配 CMS) |
老年代搭配回收器 | Serial Old / Parallel Old(需显式启用) | CMS(强制依赖) |
是否支持自适应调节 | 支持(-XX:+UseAdaptiveSizePolicy ) | 不支持 |
JDK 默认版本 | JDK8 及之前(新生代默认) | 需显式启用(JDK9+ CMS 已废弃) |
典型应用场景 | 离线计算、大数据处理 | Web 服务(JDK8 及之前低延迟场景) |
场景题
常量存放的地方,在 JVM 的哪个部分
JVM 中常量根据类型存储在不同区域:字符串字面量在堆的字符串常量池,类常量池在元空间,final 静态常量内联到代码,基本字面量嵌入字节码指令。
Java 代码运行在哪边,线程运行在哪边
结论如下。
- 代码运行位置:JVM 进程 → 操作系统 → CPU 物理核心。
- 线程运行位置:OS 线程 → CPU 逻辑核心(物理核心或超线程)。
- 核心依赖:JVM 是代码和线程的运行时容器,但最终依赖底层 CPU 资源。
补充说明。
- Java 代码(.java文件)编译为 字节码(.class文件),字节码运行在 JVM(Java 虚拟机) 中。JVM 本身是一个 本地进程(如 java.exe 或 javaw.exe),其进程由操作系统调度到 CPU 物理核心 上运行。
- Java 线程本质是操作系统原生线程的封装。
- 在大多数现代 JVM 实现(如 HotSpot)中,java.lang.Thread 实例直接映射到 操作系统线程(如 Linux 的 pthread、Windows 的线程 API)。
- 线程调度由操作系统内核管理,具体运行在哪个 CPU 核心上由操作系统动态分配。
Java 对象的定位方式(对象怎么访问)
Java 程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有句柄和直接指针两种方式。
- 指针: 指向对象,代表一个对象在内存中的起始地址。
- 句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。
直接指针。
如果使用直接指针访问,引用中存储的直接就是对象地址,那么 Java 堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。其优点如下。
- 速度更快,节省了一次指针定位的时间开销。由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。
句柄访问。
Java 堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,其优点如下。
- 引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
Java 中使用句柄的有如下场景。
- Java 通过 FileInputStream、FileOutputStream 等类操作文件时,底层会使用操作系统的文件句柄(File Handle)。如果未正确关闭资源(如调用 close()),可能导致句柄泄漏。
- 在调用本地代码(C/C++)时,JNI 通过句柄(如 jobject、jclass)间接引用 Java 对象,避免直接暴露内存地址。
具体构造如下图所示。
垃圾回收会回收 JVM 的哪些区域
内存区域 | 是否由 GC 管理 | 存储内容 | 回收条件 |
---|---|---|---|
堆(Heap) | ✅ 是 | 对象实例、数组 | 对象无引用时回收(Minor GC/Full GC) |
新生代(Young Gen) | ✅ 是 | 新创建的对象 | Eden 区满触发 Minor GC,存活对象复制到 Survivor 或晋升老年代 |
- Eden 区 | ✅ 是 | 新对象分配区 | |
- Survivor 区(From/To) | ✅ 是 | 经历 Minor GC 后存活的对象 | |
老年代(Old Gen) | ✅ 是 | 长期存活的对象 | 空间不足时触发 Full GC(标记-清除/标记-整理) |
方法区(Metaspace) | ✅ 是(部分) | 类信息、常量池、静态变量 | 类卸载时回收(Full GC 触发,条件苛刻) |
运行时常量池 | ✅ 是(部分) | 字符串常量、符号引用 | 常量无引用时回收(Full GC 触发) |
直接内存(Direct Memory) | ❌ 否(需手动) | 堆外内存(NIO Buffer 等) | 依赖 Unsafe 或 ByteBuffer 的 cleaner 机制,或 Full GC 触发 Cleaner |
虚拟机栈(VM Stack) | ❌ 否 | 栈帧(局部变量、操作数栈) | 方法执行结束自动弹出栈帧 |
本地方法栈(Native Stack) | ❌ 否 | Native 方法调用 | 线程结束时回收 |
程序计数器(PC Register) | ❌ 否 | 当前线程执行的字节码行号 | 线程结束时回收 |
补充说明如下。
直接内存(Direct Memory):
- 不属于 JVM 运行时数据区,但被 JVM 间接管理(如 NIO 的
ByteBuffer.allocateDirect
)。 - 回收依赖
Cleaner
机制(PhantomReference
),Full GC 时可能触发,但建议手动调用System.gc()
或显式释放(((DirectBuffer) buffer).cleaner().clean()
)。
- 不属于 JVM 运行时数据区,但被 JVM 间接管理(如 NIO 的
其他特殊区域:
- Code Cache:JIT 编译后的本地代码,由 JVM 单独管理,不依赖 GC。
- 压缩类空间(Compressed Class Space):在启用压缩指针时存储类元数据,属于 Metaspace 的一部分。
GC 不管理的区域:
- 栈、程序计数器等线程私有区域,生命周期与线程绑定。
- 直接内存需结合手动释放或
Cleaner
机制。
TIP
Full GC(Major GC)属于内存回收,它是 JVM 垃圾回收(Garbage Collection, GC)中最彻底的一种回收机制,会清理整个堆内存(包括新生代和老年代)以及方法区(Metaspace)的无效对象。
垃圾回收算法中的标记怎么实现、Java 垃圾回收算法使用的标记
垃圾回收算法中的标记阶段是实现内存管理的关键步骤,主要用于识别哪些对象是存活的,哪些是可以回收的。以下是几种常见的标记实现方式。
- 标记-清除算法中的标记
基本实现步骤:
- 从根对象(全局变量、栈变量等)开始遍历
- 对每个访问到的对象设置一个标记位(通常在对象头中)
- 递归或迭代地标记所有可达对象
- 三色标记法
更高效的标记方式,将对象分为三种颜色:
- 白色:未访问(初始状态)
- 灰色:已访问但引用的对象未完全处理
- 黑色:已访问且引用的对象已完全处理
实现过程:
- 所有对象初始为白色
- 根对象标记为灰色并入队
- 从队列取出灰色对象,将其引用的白色对象标记为灰色
- 处理完所有引用后,该对象标记为黑色
- 重复直到灰色队列为空
- 位图标记
对于大规模堆内存的高效标记:
- 使用独立的位图来记录标记状态
- 每个位对应堆中的一个对象或内存块
- 减少对对象本身的修改,提高缓存效率
- 写屏障与并发标记
现代 GC(如 G1、ZGC)使用写屏障技术支持并发标记:
- 在对象引用修改时记录变化
- 维护标记的一致性
- 允许标记阶段与应用线程并发执行
优化技术
- 并行标记:多线程同时进行标记
- 增量标记:将标记过程分成小步骤执行,减少停顿
- 卡表:记录堆中修改过的区域,缩小标记范围
标记算法的选择取决于垃圾回收器的设计目标和应用程序的特性,注意以下一些说明。
- 卡表是位图的变种(粗粒度,记录内存区域而非单个对象)
- ZGC 的染色指针本质上是一种"无位图"设计,将标记信息嵌入指针
- Shenandoah 结合位图与 Brooks 指针实现并发移动
垃圾回收算法 | 标记-清除算法中的标记 | 三色标记法 | 位图标记应用场景 | 写屏障与并发标记技术 |
---|---|---|---|---|
Serial GC | 递归标记对象头 | 未使用 | 卡表(记录跨代引用) | 无(全程 STW) |
Parallel GC | 多线程并行标记 | 未使用 | 卡表优化年轻代回收 | 无(全程 STW) |
CMS | 并发标记(减少 STW) | 是(老年代并发标记) | 卡表 + 标记位图 | 写屏障维护卡表 |
G1 GC | 分区标记(Region 级) | 是(SATB 快照) | 标记位图 + 记忆集 | 写屏障维护记忆集 |
Shenandoah | Brooks 指针辅助标记 | 是(并发标记) | 位图标记存活对象 | 写屏障实现并发移动 |
ZGC | 染色指针直接标记 | 是(指针元数据位标记) | 无(染色指针替代位图) | 读屏障+染色指针 |
垃圾回收机制整体介绍
1 基本工作原理
垃圾回收主要解决两个问题:
- 识别垃圾对象: 找出不再被程序使用的对象
- 回收内存空间: 释放这些对象占用的内存
2 垃圾识别算法
2.1 引用计数法
- 每个对象维护一个引用计数器
- 引用增加时计数器加 1, 减少时减 1
- 计数器为 0 时立即回收
- 缺点: 无法处理循环引用
2.2 可达性分析
- 从 GC Roots(栈、 静态变量等) 出发
- 遍历对象引用链
- 不可达的对象即为垃圾
- 主流 JVM 采用此方法
3 垃圾回收算法
3.1 标记-清除(Mark-Sweep)
- 标记阶段: 标记所有可达对象
- 清除阶段: 回收未标记对象
- 缺点: 产生内存碎片
3.2 标记-整理(Mark-Compact)
- 标记后, 将存活对象向一端移动
- 解决碎片问题但增加开销
3.3 复制算法(Copying)
- 将内存分为两块
- 只使用一块, 存活对象复制到另一块
- 优点: 无碎片, 简单高效
- 缺点: 内存利用率低
3.4 分代收集(Generational)
- 基于对象生命周期特点
- 新生代(Young Generation): 频繁 GC, 使用复制算法
- 老年代(Old Generation): 较少 GC, 使用标记-清除/整理
4 JVM 中的垃圾回收器
4.1 Serial GC
- 单线程, 简单高效
- 适合客户端应用
4.2 Parallel GC
- 多线程并行回收
- 注重吞吐量
4.3 CMS(Concurrent Mark-Sweep)
- 并发标记清除
- 减少停顿时间
- JDK9 后废弃
4.4 G1(Garbage-First)
- 分区收集, 可预测停顿
- JDK9+ 默认
4.5 ZGC
- 亚毫秒级停顿
- 支持 TB 级堆内存
- JDK15+ 生产可用
5 性能考量
5.1 衡量指标
- 吞吐量: GC 时间占总运行时间的比例
- 停顿时间: 单次 GC 暂停应用的时间
- 内存占用: GC 所需额外内存
5.2 调优方向
- 合理设置堆大小
- 选择适合场景的 GC 算法
- 调整新生代/老年代比例
- 监控 GC 日志分析瓶颈
6 发展趋势
- 向低延迟方向发展(ZGC, Shenandoah)
- 适应大内存和云原生环境
- 更智能的自适应调优
引用计数有什么缺点
循环引用导致内存泄漏
- 对象间相互引用时,引用计数无法归零,即使失去外部引用也无法释放。
性能开销
- 频繁的引用增减操作需更新计数器,高频率场景下可能成为性能瓶颈。
并发环境下的同步问题
- 多线程修改引用计数需依赖锁或原子操作,增加竞争和性能损耗。
内存释放的不确定性
- 存在循环引用时,内存释放需等待引用关系解除,可能延迟到程序结束。
无法处理复杂数据结构
- 树、图等复杂结构易产生循环引用,需额外设计(如弱引用)避免泄漏。
内存碎片化
- 按需回收对象可能导致内存空间不连续,影响大块内存分配效率。
对开发者透明性较低
- 需手动管理部分引用(如弱引用),否则易引入内存泄漏问题。
可达性分析从哪边开始
GC Roots 是可达性分析的起点,它们是程序运行中始终活跃的引用。Java 体系中,固定可作为 GC Roots 的对象包括以下几种。
- 在虚拟机栈中(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池里的引用。
- 在本地方法栈中 JNI(即 Native 方法) 引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整“GC Roots”集合。
三色回收算法
三色回收算法(Tri-color Marking)是一种高效的垃圾回收算法,核心思想是通过颜色标记跟踪对象存活状态,支持并发执行以减少停顿时间。以下是其核心内容整理:
核心思想
颜色定义
- 白色:未被访问的对象(可能为垃圾)。
- 灰色:已被访问但未完全扫描其引用的对象。
- 黑色:已被访问且所有引用均被扫描的对象(存活对象)。
目标
从GC Roots出发,遍历所有可达对象并标记为黑色,未被标记的白色对象视为可回收。
算法流程
初始标记(Initial Marking)
- 短暂 STW:标记所有直接从 GC Roots 可达的对象为灰色。
- 耗时短,仅需记录直接关联的根对象。
并发标记(Concurrent Marking)
- 并发执行:从灰色对象出发,递归遍历其引用的对象。
- 若对象未被标记,标记为灰色并加入队列。
- 已标记为灰色或黑色的对象跳过。
- 可能漏标:并发期间新创建的对象可能未被标记(需后续处理)。
- 并发执行:从灰色对象出发,递归遍历其引用的对象。
重新标记(Remark)
- 短暂 STW:修正并发标记期间因用户程序修改引用关系导致的漏标对象。
- 常用策略:增量更新(记录引用变化)或原始快照(SATB)。
并发清除(Concurrent Sweep)
- 并发执行:回收所有白色对象(不可达对象),并整理内存。
三色定理与正确性
- 定理:当且仅当所有可达对象均为黑色,不可达对象为白色时,标记结果正确。
- 关键点:并发标记阶段需通过 SATB 或增量更新保证引用关系修改的记录,避免漏标。
优缺点
优点
- 支持并发:大部分工作与用户程序并发执行,减少停顿时间(仅初始标记和重新标记需短暂 STW)。
- 处理循环引用:通过可达性分析自动解决循环引用问题。
- 低误判率:结合 SATB 或增量更新保证标记正确性。
缺点
- 浮动垃圾:并发标记期间新创建的对象可能未被回收(需下一轮 GC 处理)。
- 内存占用:需维护对象颜色标记表,增加内存开销。
- 复杂度高:需处理并发冲突和状态同步问题。
应用场景
- CMS(Concurrent Mark-Sweep)收集器:通过三色标记实现低停顿的垃圾回收。
- G1(Garbage-First)收集器:在混合回收阶段使用三色标记划分 Region 优先级。
- 适用于多核、大内存场景:需平衡吞吐量和停顿时间。
与引用计数法对比
特性 | 三色标记法 | 引用计数法 |
---|---|---|
循环引用 | 自动处理 | 无法处理 |
实时性 | 延迟回收(并发+标记阶段) | 立即回收 |
性能开销 | 并发阶段低,但需 STW | 每次引用操作均需更新计数器 |
适用场景 | 复杂引用关系、大内存堆 | 简单对象、实时性要求高 |
总结:三色标记法通过颜色划分和并发执行,在保证正确性的前提下显著降低GC停顿时间,是现代高性能垃圾回收器的核心算法。但其实现复杂度较高,需结合 SATB、增量更新等机制解决并发修改问题。
CMS 垃圾回收器的实现机制
1. 核心目标
- 低停顿时间:专为减少老年代垃圾回收的 STW(Stop-The-World)时间设计,适用于对响应速度敏感的应用(如 Web 服务)。
2. 核心阶段
CMS 基于标记-清除算法,分为四个阶段,其中两个阶段需 STW,两个阶段并发执行:
初始标记(Initial Mark)
- STW:短暂暂停应用线程,仅标记直接关联 GC Roots 的对象(如栈中局部变量引用的对象)。
- 特点:耗时极短,通常与 Minor GC 配合触发。
并发标记(Concurrent Mark)
- 并发执行:与应用线程并行遍历对象图,递归标记所有可达对象。
- 挑战:用户线程可能修改引用关系,导致漏标(需后续阶段修正)。
重新标记(Remark)
- STW:修正并发标记阶段的漏标对象。
- 策略:
- 增量更新(Incremental Update):记录引用变化,重新处理被修改的引用链。
- 原始快照(SATB, Snapshot-At-The-Beginning):基于并发标记开始时的对象快照,确保漏标对象被扫描。
并发清除(Concurrent Sweep)
- 并发执行:回收不可达对象(白色对象),整理内存碎片。
- 特点:与应用线程并发,但可能因内存碎片导致后续分配失败。
3. 关键实现机制
三色标记法:
- 白色:未被访问的对象(待回收)。
- 灰色:已访问但未完全扫描其引用的对象。
- 黑色:已访问且所有引用均被扫描的对象(存活)。
- 流程:从 GC Roots 出发,灰色对象逐步扩散至黑色,最终白色对象被回收。
并发与并行:
- 并发:与用户线程同时运行(如并发标记、清除)。
- 并行:多线程协同完成某一阶段(如初始标记和重新标记)。
内存碎片处理:
- CMS 不压缩内存,长期运行可能导致碎片化,触发 Full GC(使用 Serial Old 收集器)。
4. 缺点与局限性
- 浮动垃圾:并发标记期间产生的新垃圾无法立即回收,需等待下一轮 GC。
- 内存碎片:标记-清除算法导致碎片,可能引发 Full GC(STW 时间较长)。
- 并发模式失败:若并发阶段无法在限定时间内完成,会退化为Full GC。
- CPU资源竞争:并发阶段占用 CPU 资源,可能影响应用吞吐量。
5. 适用场景
- 对延迟敏感:需快速响应用户请求(如电商交易系统)。
- 中小内存堆:适合堆内存较小(如几十 GB)且碎片问题可控的场景。
- JDK 8 及之前版本:JDK 9+ 中已被 G1 替代,但部分旧项目仍需维护。
6. 对比其他收集器
特性 | CMS | Parallel Old | G1 |
---|---|---|---|
目标 | 低停顿 | 高吞吐 | 低停顿 + 高吞吐 |
算法 | 标记-清除 | 标记-整理 | 标记-整理(分 Region) |
碎片问题 | 严重 | 无 | 通过 Compaction 缓解 |
适用堆大小 | 中小(<32GB) | 大 | 大(多 Region 管理) |
STW 时间 | 短 | 长 | 可控(优于 CMS) |
7. 总结
- 优势:通过并发执行显著降低停顿时间,适合对延迟敏感的应用。
- 劣势:内存碎片、并发失败风险及 CPU 资源消耗。
- 面试要点:
- 明确阶段划分及 STW 环节。
- 结合三色标记法解释漏标修正(SATB/增量更新)。
- 对比 G1 的适用场景差异(碎片处理、分区设计)。
详见 CMS 垃圾回收器。
G1 垃圾回收器的实现机制
1. 核心目标
- 平衡吞吐量与延迟:在保证高吞吐量的同时,尽可能减少停顿时间(通常控制在几百毫秒内),适用于大内存、多核 CPU 且对延迟敏感的应用(如在线事务处理系统)。
2. 核心设计
分代与分区结合:
- 分代:保留年轻代(Young Generation)和老年代(Old Generation)的概念。
- 分区:将整个堆划分为多个大小相等的 Region(默认 1~32MB,可通过
-XX:G1HeapRegionSize
调整),每个 Region 独立管理,可以是 Eden、Survivor、Old 或 Humongous(存储超大对象)。
Humongous 区域:
- 存储超过 Region 50% 大小的对象,连续分配在多个 Region 中,回收时优先处理。
3. 垃圾回收过程
G1 的回收分为 Young GC 和 Mixed GC,均包含 STW 和 并发 阶段:
Young GC
- 触发条件:Eden 区满时触发。
- 流程:
- STW 初始标记:标记 GC Roots 直接关联的对象。
- 并发标记:遍历存活对象,记录引用关系。
- 复制存活对象:将存活对象从 Eden/Survivor 区复制到新的 Survivor 区或老年代 Region。
- 特点:年轻代 Region 回收后变为 Survivor 或老年代 Region。
Mixed GC
- 触发条件:老年代占用达到阈值(默认 45%)时触发。
- 流程:
- 全局并发标记(与 Young GC 共享):
- 初始标记(STW):标记直接关联对象。
- 根区域扫描:扫描 Survivor 区的引用。
- 并发标记:遍历全堆,标记存活对象。
- 最终标记(STW):处理并发阶段的引用变化(SATB 算法)。
- 筛选回收:根据 Region 的垃圾比例,选择回收价值高的 Region(优先回收年轻代 Region)。
- 并发清理:回收选定 Region 的垃圾,整理内存。
- 全局并发标记(与 Young GC 共享):
4. 关键技术点
Remembered Set(RSet):
- 每个 Region 维护一个 RSet,记录其他 Region 对该 Region 的引用(跨 Region 引用)。
- 作用:避免全堆扫描,提升回收效率。例如,仅扫描 RSet 中记录的引用链。
SATB(Snapshot-At-The-Beginning)算法:
- 在并发标记开始时生成对象快照,确保标记过程中新增的引用关系被记录(通过写屏障实现)。
- 用途:修正并发标记阶段的漏标对象。
混合回收策略:
- 根据 Region 的垃圾占比(存活对象比例)动态选择回收目标,优先回收垃圾最多的 Region。
- 通过
-XX:InitiatingHeapOccupancyPercent
控制触发 Mixed GC 的老年代占用阈值。
5. 优势与局限优势
- 可预测的停顿时间:通过筛选回收价值最高的 Region,控制单次 GC 停顿时间。
- 分代与分区结合:灵活管理不同生命周期的对象,减少碎片化。
- 高效处理大对象:Humongous 区域专门存储超大对象,避免内存浪费。
局限
- 内存占用:RSet 和 SATB 的写屏障增加内存和 CPU 开销。
- 内存碎片:虽通过复制算法减少碎片,但 Humongous 对象可能导致 Region 利用率低。
- 并发模式失败:若并发标记无法在停顿时间内完成,会触发 Full GC(使用 Serial Old 收集器)。
6. 参数调优
- 目标停顿时间:
-XX:MaxGCPauseMillis
(默认 200ms,需根据业务调整)。 - Region大小:
-XX:G1HeapRegionSize
(建议根据堆大小选择,如 4GB 堆可设为 16MB)。 - 并发线程数:
-XX:ConcGCThreads
(控制并发标记阶段的线程数)。
7. 与其他收集器对比
特性 | G1 | CMS | ZGC/Shenandoah |
---|---|---|---|
算法 | 标记-整理 | 标记-清除 | 读屏障+染色指针 |
停顿时间 | 可控(毫秒级) | 低(但浮动垃圾多) | 极低(亚毫秒级) |
内存碎片 | 无 | 严重 | 无 |
适用堆大小 | 中到大(几十GB) | 中等(<32GB) | 超大(TB 级) |
适用场景 | 延迟敏感型应用 | 响应时间要求不高 | 超低延迟需求(如金融交易) |
8. 面试高频问题
G1 如何实现低停顿?
- 通过分区回收、筛选高垃圾比例 Region,以及并发标记与混合回收结合。
G1的 RSet 有什么作用?
- 减少跨 Region 引用扫描,提升回收效率。
G1 的 SATB 算法如何工作?
- 在并发标记开始时生成对象快照,通过写屏障记录引用变化,确保标记准确性。
G1 与 CMS 的适用场景差异?
- G1 适合大堆且需平衡吞吐量与延迟的场景;CMS 适合中小堆且延迟敏感但对碎片不敏感的场景。
G1 的 Full GC 触发条件?
- 并发标记失败(如超时)、Humongous 对象分配失败、显式调用
System.gc()
。
- 并发标记失败(如超时)、Humongous 对象分配失败、显式调用
9. 总结
- 核心价值:G1 通过分区管理、并发标记和混合回收,在可控停顿时间内高效回收垃圾,是 JDK 9+ 的默认收集器。
- 面试重点:分代分区设计、SATB 与 RSet 机制、参数调优(如停顿时间与 Region 大小)。
- 避坑指南:避免过度调小
MaxGCPauseMillis
导致GC频繁,合理设置 Region 大小以适配应用对象分布。
详见 G1 垃圾回收器。
高优先级
JVM 的内存模型(运行时数据区)及每个模块的作用
- 栈:存放局部变量表、操作数栈、动态连接、方法出口等,线程私有。局部变量表存放了基本数据类型、对象引用类型、指向对象起始地址的引用指针、指向一个对象的句柄和 returnAddress 类型(指向了一条字节码指令的地址)。
- 本地方法栈:和虚拟栈相似,只不过它服务于 Native 方法,线程私有。
- 程序计数器:是一个较小的内存空间,当前线程所执行的行号指示器,用于记录正在执行的虚拟机字节指令地址,线程私有。唯一一块 Java 虚拟机没有规定任何 OutofMemoryError 的区块。
- 堆:Java 内存中最大的一块,所有对象实例、数组都存放在 Java 堆,GC 回收的地方,线程共享。
- 方法区:即非堆(元空间),存放已被加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。各线程共享。回收目标主要是常量池的回收和类型的卸载。
详见 运行时数据区域。
线上内存溢出分析
- top → 定位高内存进程 (PID)。
top -Hp <PID>
→ 定位高内存线程 (TID)。printf "%x\n" <TID>
→ 转为十六进制。jstack <PID> | grep '0x4295' -C10 --color
→ 查看线程堆栈,关联业务代码。jmap -dump:format=b,file=my.dump <PID>
→ 生成 Heap Dump。- MAT/VisualVM → 分析 Heap Dump 找到泄漏对象。
- 修复代码 → 优化内存使用。
- 监控验证 → 确保内存回归正常。
附录一、参考文献
附录一、参考文献
- 深入理解Java虚拟机JVM高级特性与最佳实践(第3版).pdf
- 其他
附录二、典型问题记录
附录二、典型问题记录
- IDEA 插件
- BinEd 文件二进制编码查看
- jclasslib bytecode viewer