内存模型
JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

- 程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,生命周期与线程相同。
- Java 虚拟机栈:每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。
- 本地方法栈:与 Java 虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。
- Java 堆:是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。
- 方法区(元空间):在 JDK 1.8 及以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有 “非堆” 的别名。方法区可以选择不实现垃圾收集,内存不足时会抛出 OutOfMemoryError 异常。
- 运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出 OutOfMemoryError 异常。
- 直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常
JVM 内存模型里的堆和栈有什么区别?
- 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用
new关键字创建一个对象时,对象的实例就会在堆上分配空间。 - 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收。
- 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
- 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由 JVM 管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
栈中存的到底是指针还是对象?
在 JVM 内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。
当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如 int, double 等)和对象的引用,而不是对象本身。
这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如 MyObject obj = new MyObject();,这里的 obj 实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在 64 位系统上是 8 字节),它指向堆中分配给对象的内存区域。
堆分为几部分

- 新生代(Young Generation): 新生代分为 Eden Space 和 Survivor Space。在 Eden Space 中, 大多数新创建的对象首先存放在这里。Eden 区相对较小,当 Eden 区满时,会触发一次 Minor GC(新生代垃圾回收)。在 Survivor Spaces 中,通常分为两个相等大小的区域,称为 S0(Survivor 0)和 S1(Survivor 1)。在每次 Minor GC 后,存活下来的对象会被移动到其中一个 Survivor 空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(Old Generation/Tenured Generation): 存放过一次或多次 Minor GC 仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此 Major GC(也称为 Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比 Minor GC 长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 元空间(Metaspace): 从 Java 8 开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在 Java 堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
- 大对象区(Large Object Space / Humongous Objects): 在某些 JVM 实现中(如 G1 垃圾收集器),为大对象分配了专门的区域,称为大对象区或 Humongous Objects 区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
程序计数器的作用,为什么是私有的?
Java 程序是支持多线程一起运行的,多个线程一起运行的时候 cpu 会有一个调动器组件给它们分配时间片,比如说会给线程 1 分给一个时间片,它在时间片内如果它的代码没有执行完,它就会把线程 1 的状态执行一个暂存,切换到线程 2 去,执行线程 2 的代码,等线程 2 的代码执行到了一定程度,线程 2 的时间片用完了,再切换回来,再继续执行线程 1 剩余部分的代码。
我们考虑一下,如果在线程切换的过程中,下一条指令执行到哪里了,是不是还是会用到我们的程序计数器啊。每个线程都有自己的程序计数器,因为它们各自执行的代码的指令地址是不一样的呀,所以每个线程都应该有自己的程序计数器。
方法区中的方法的执行过程?
当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:
- 解析方法调用:JVM 会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
- 栈帧创建:在调用一个方法前,JVM 会在当前线程的 Java 虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
String s = new String(“abc”)执行过程中分别对应哪些内存区域?
如果 abc 这个字符串常量不存在,则创建两个对象,分别是 abc 这个字符串常量,以及 new String 这个实例对象。如果 abc 这字符串常量存在,则只会创建一个对象
引用类型
- 强引用指的就是代码中普遍存在的赋值方式,比如 A a = new A() 这种。强引用关联的对象,永远不会被 GC 回收。
- 软引用可以用 SoftReference 来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
- 弱引用可以用 WeakReference 来描述,他的强度比软引用更低一点,弱引用的对象下一次 GC 的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用 PhantomReference 来描述,他必须和 ReferenceQueue 一起使用,同样的当发生 GC 的时候,虚引用也会被回收。可以用虚引用来管理堆外内存
内存泄露和内存溢出
内存泄露
内存泄漏是指程序在运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,从而导致可用内存逐渐减少。虽然在 Java 中,垃圾回收机制会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,垃圾收集器无法回收这些内存,最终可能导致程序的内存使用不断增加。
常见原因:
- 静态集合:使用静态数据结构(如
HashMap或ArrayList)存储对象,且未清理。 - 事件监听:未取消对事件源的监听,导致对象持续被引用。
- 线程:未停止的线程可能持有对象引用,无法被回收。
内存溢出
内存溢出是指 Java 虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发 OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。
常见原因:
- 大量对象创建:程序中不断创建大量对象,超出 JVM 堆的限制。
- 持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用或者未关闭的资源,导致内存累积。如何进行处理呢?第一,始终记得在 finally 中进行资源的关闭;第二,关闭连接的自身代码不能发生异常;第三,Java7 以上版本可使用 try-with-resources 代码方式进行资源关闭。
- 递归调用:深度递归导致栈溢出。
内存溢出种类
- 堆内存溢出:当出现 Java.lang.OutOfMemoryError:Java heap space 异常时,就是堆内存溢出了。原因是代码中可能存在大对象分配,或者发生了内存泄露,导致在多次 GC 之后,还是无法找到一块足够大的内存容纳当前对象。
- 栈溢出:如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。
- 元空间溢出:元空间的溢出,系统会抛出 Java.lang.OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
- 直接内存内存溢出:在使用 ByteBuffer 中的 allocateDirect() 的时候会用到,很多 JavaNIO(像 netty) 的框架中被封装为其他的方法,出现该问题时会抛出 Java.lang.OutOfMemoryError: Direct buffer memory 异常。
堆溢出
通常发生在程序持续创建对象且无法被 GC 及时回收的场景下。
问题定位:
- 捕获内存快照:通过 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof,让程序在发生 OOM 时自动生成堆快照文件。 - 分析快照文件:使用 MAT(Memory Analyzer Tool)或 JProfiler 等工具分析快照,重点看哪些对象占用了大量内存、是否存在内存泄漏(如对象长期被无用引用持有,无法回收)。 解决思路:
- 如果是内存泄漏:比如静态集合无意识地缓存了大量对象、长生命周期对象持有短生命周期对象的引用(如单例类持有业务对象)等。这时候需要梳理对象引用链,找到未释放的根源,比如清理静态集合中不再使用的元素、解除不必要的对象关联。
- 如果是内存不足:即程序确实需要大量内存(如处理大文件、加载大量数据到内存),但当前堆配置太小。这种情况下可以通过调整 JVM 参数扩大堆内存,比如
-Xms2g -Xmx4g(初始堆 2G,最大堆 4G),但需注意不能超过物理内存限制,避免频繁 swap。 - 代码优化:避免一次性加载全部数据(改用分批处理)、使用缓存时设置合理的过期策略、及时释放资源(如 IO 流、数据库连接)等,从源头减少内存占用
栈溢出
发生在 Java 虚拟机栈(或本地方法栈)的内存空间被耗尽时,通常与方法调用的深度直接相关。
解决思路:
- 排查递归逻辑:检查是否存在无限递归或递归层级过深的问题,添加正确的终止条件,或减少递归深度。必要时可将递归改写为迭代(如用循环替代),因为迭代不会持续创建新栈帧。
- 调整栈内存大小:通过 JVM 参数
-Xss(如-Xss256k)增大栈内存容量。但这种方式要谨慎,栈内存过大会导致线程可创建数量减少(总内存固定时,单个线程栈越大,能创建的线程数越少)。 - 优化方法栈帧:减少方法内局部变量的数量,避免在方法中创建过大的对象或数组,将大对象的创建移到堆中(通过 new 关键字),降低单个栈帧的内存占用。
类初始化和类加载
创建对象的过程
在 Java 中创建对象的过程包括以下几个步骤:
- 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数,即 class 文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
类加载器

- 启动类加载器(Bootstrap Class Loader):这是最顶层的类加载器,负责加载 Java 的核心库(如位于 jre/lib/rt.jar 中的类),它是用 C++ 编写的,是 JVM 的一部分。启动类加载器无法被 Java 程序直接引用。
- 扩展类加载器(Extension Class Loader):它是 Java 语言实现的,继承自 ClassLoader 类,负责加载 Java 扩展目录(jre/lib/ext 或由系统变量 Java.ext.dirs 指定的目录)下的 jar 包和类库。扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。
- 系统类加载器(System Class Loader)/ 应用程序类加载器(Application Class Loader):这也是 Java 语言实现的,负责加载用户类路径(ClassPath)上的指定类库,是我们平时编写 Java 程序时默认使用的类加载器。系统类加载器的父加载器是扩展类加载器。它可以通过 ClassLoader.getSystemClassLoader() 方法获取到。
- 自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络加载 class 文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展 Java 应用程序的灵活性和安全性,是 Java 动态性的一个重要体现。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器要加载类时,先让父加载器去尝试加载,只有父加载器加载不了,自己才会去加载。
举个例子:我们自己写了一个
java.lang.String类,当AppClassLoader要加载它时,会先委派给Extension ClassLoader,再委派给Bootstrap ClassLoader。而启动类加载器发现自己已经加载过 JDK 自带的String类了,就直接返回这个类,不会去加载我们自定义的String类。 这种机制的核心作用有两个:
- 保证类的唯一性和安全性:避免同一个类被不同加载器重复加载,确保核心类(如 JDK 的
String、Integer)不会被篡改。比如上面的例子,防止我们自定义的String类替换掉 JDK 的核心类,否则可能引发安全问题(比如修改String的底层实现导致系统混乱)。 - 实现类的复用:核心类只需要被顶层加载器加载一次,所有子加载器都能共享这个类,减少内存消耗。
类加载过程
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:

- 加载:通过类的全限定名(包名 + 类名),获取到该类的.class 文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的 Java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
- 连接:验证、准备、解析 3 个阶段统称为连接。
- 验证:确保 class 文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的 class 类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如 int 类型初始值是 0。被 final 修饰的 static 字段不会设置,因为 final 在编译的时候就分配了
- 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
- 初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(() ),要注意的是这里的构造器方法 () 并不是开发者写的,而是编译器自动生成的。
- 使用:使用类或者创建对象
- 卸载:一个类要被 JVM 卸载,条件非常苛刻,需要同时满足以下三点:
- 该类所有的实例都已经被回收:这是最显而易见的前提。如果堆中还存在这个类的任何一个实例对象,那么定义这个对象的 Class 对象肯定不能被卸载。
- 加载该类的 ClassLoader 已经被回收:这是最关键也是最难满足的条件。类与其加载器是双向绑定的共生关系。一个类由哪个类加载器加载,这个信息是存储在 Class 对象里的。要卸载一个类,必须先卸载加载它的类加载器。
- 类对应的 Java.lang.Class 对象没有任何地方被引用:不能在任何地方通过反射(如静态字段、全局变量)、静态变量、JNI 等途径引用到这个 Class 对象。一旦这个 Class 对象还存在强引用,GC 就不会回收它,那么这个类也就不会被卸载。
垃圾回收
如何触发
- 内存不足时:当 JVM 检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。
- 手动请求:虽然垃圾回收是自动的,开发者可以通过调用
System.gc()或Runtime.getRuntime().gc()建议 JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。 - JVM 参数:启动 Java 应用时可以通过 JVM 参数来调整垃圾回收的行为,比如:
-Xmx(最大堆大小)、-Xms(初始堆大小)等。 - 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收
判断垃圾的方法
引用计数法
- 原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加 1;当引用失效时,计数器减 1。当计数器为 0 时,表示对象不再被任何变量引用,可以被回收。
- 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为 0,导致对象无法被回收。
可达性分析算法
Java 虚拟机主要采用此算法来判断对象是否为垃圾。
- 原理:从一组称为 GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots 对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、本地方法栈中 JNI(Java Native Interface)引用的对象、活跃线程的引用等
GC 机制
- Serial GC 是单线程回收,简单但效率低,适合小内存应用;
- Parallel GC 多线程回收,注重吞吐量(单位时间内处理任务多);
- CMS 追求低延迟,回收时大部分工作和用户线程并发执行,减少停顿时间,但会占用更多 CPU;
- G1 则把内存分成多个区域,优先回收垃圾多的区域,平衡吞吐量和延迟,适合大内存场景。
- 现在新出的 ZGC、Shenandoah 更是追求微秒级停顿,适合高并发低延迟的应用。 总的来说,GC 机制的核心是 「自动管理内存」,通过可达性分析判断垃圾,结合分代思想和不同算法,再由具体的回收器实现,最终目的是在保证程序正常运行的前提下,高效释放内存,减少对用户线程的影响
垃圾回收算法
- 标记 - 清除算法:标记 - 清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记 - 清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
- 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
- 标记 - 整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记 - 整理算法”。标记 - 整理算法的“标记”过程与“标记 - 清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值 (默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定) 后,如果对象还存活,那么该对象会进入老年代
垃圾回收器

- Serial 收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
- ParNew 收集器 (复制算法): 新生代收并行集器,实际上是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现;
- Parallel Scavenge 收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间 +GC 线程时间),高吞吐量可以高效率的利用 CPU 时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
- Serial Old 收集器 (标记 - 整理算法): 老年代单线程收集器,Serial 收集器的老年代版本;
- Parallel Old 收集器 (标记 - 整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge 收集器的老年代版本;
- CMS(Concurrent Mark Sweep) 收集器(标记 - 清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短 GC 回收停顿时间。
- G1(Garbage First) 收集器 (标记 - 整理算法): Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新收集器,G1 收集器基于“标记 - 整理”算法实现,也就是说不会产生内存碎片。此外,G1 收集器不同于之前的收集器的一个重要特点是:G1 回收的范围是整个 Java 堆 (包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代
垃圾回收算法哪些阶段会 stop the world?
标记 - 复制算法应用在 CMS 新生代(ParNew 是 CMS 默认的新生代垃圾回收器)和 G1 垃圾回收器中。标记 - 复制算法可以分为三个阶段:
- 标记阶段,即从 GC Roots 集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以 G1 为例,通过 G1 中标记 - 复制算法过程(G1 的 Young GC 和 Mixed GC 均采用该算法),分析 G1 停顿耗时的主要瓶颈。G1 垃圾回收周期如下图所示:
G1 的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
标记阶段停顿分析 - 初始标记阶段:初始标记阶段是指从 GC Roots 出发标记全部直接子节点的过程,该阶段是 STW 的。由于 GC Roots 数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和 GC 线程可以同时活动。并发标记耗时相对长很多,但因为不是 STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是 STW 的。 清理阶段停顿分析
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是 STW 的。 复制阶段停顿分析
- 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是 STW 的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。 四个 STW 过程中,初始标记因为只标记 GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1 停顿时间的瓶颈主要是标记 - 复制中的转移阶段 STW。
minorGC、majorGC、fullGC 的区别,什么场景
Minor GC (Young GC)
- 作用范围:只针对年轻代进行回收,包括 Eden 区和两个 Survivor 区(S0 和 S1)。
- 触发条件:当 Eden 区空间不足时,JVM 会触发一次 Minor GC,将 Eden 区和一个 Survivor 区中的存活对象移动到另一个 Survivor 区或老年代(Old Generation)。
- 特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。 Major GC
- 作用范围:主要针对老年代进行回收,但不一定只回收老年代。
- 触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发 Major GC。
- 特点:相比 Minor GC,Major GC 发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。 Full GC
- 作用范围:对整个堆内存(包括年轻代、老年代以及永久代/元空间进行回收。
- 触发条件:
- 直接调用
System.gc()或Runtime.getRuntime().gc()方法时,虽然不能保证立即执行,但 JVM 会尝试执行 Full GC。 - Minor GC(新生代垃圾回收)时,如果存活的对象无法全部放入老年代,或者老年代空间不足以容纳存活的对象,则会触发 Full GC,对整个堆内存进行回收。
- 当永久代(Java 8 之前的版本)或元空间(Java 8 及以后的版本)空间不足时。
- 直接调用
- 特点:Full GC 是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少 Full GC 的触发
GMS VS G1
CMS 适用场景:
- 低延迟需求:适用于对停顿时间要求敏感的应用程序。
- 老生代收集:主要针对老年代的垃圾回收。
- 碎片化管理:容易出现内存碎片,可能需要定期进行 Full GC 来压缩内存空间。 G1 适用场景:
- 大堆内存:适用于需要管理大内存堆的场景,能够有效处理数 GB 以上的堆内存。
- 对内存碎片敏感:G1 通过紧凑整理来减少内存碎片,降低了碎片化对性能的影响。
- 比较平衡的性能:G1 在提供较低停顿时间的同时,也保持了相对较高的吞吐量。
区别:
区别一:使用的范围不一样:
- CMS 收集器是老年代的收集器,可以配合新生代的 Serial 和 ParNew 收集器一起使用
- G1 收集器收集范围是老年代和新生代。不需要结合其他收集器使用
区别二:STW 的时间:
- CMS 收集器以最小的停顿时间为目标的收集器。
- G1 收集器可预测 垃圾回收 (opens new window) 的停顿时间(建立可预测的停顿时间模型)
区别三: 垃圾碎片
- CMS 收集器是使用“标记 - 清除”算法进行的垃圾回收,容易产生内存碎片
- G1 收集器使用的是“标记 - 整理”算法,进行了空间整合,没有内存空间碎片。
区别四: 垃圾回收的过程不一样

注意这两个收集器第四阶段得不同
区别五: CMS 会产生浮动垃圾
- CMS 产生浮动垃圾过多时会退化为 serial old,效率低,因为在上图的第四阶段,CMS 清除垃圾时是并发清除的,这个时候,垃圾回收线程和用户线程同时工作会产生浮动垃圾,也就意味着 CMS 垃圾回收器必须预留一部分内存空间用于存放浮动垃圾
- 而 G1 没有浮动垃圾,G1 的筛选回收是多个垃圾回收线程并行 gc 的,没有浮动垃圾的回收,在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现‘Concurrent Mode Failure’,一旦出现此错误时便会切换到 SerialOld 收集方式
G1 回收器的特色是什么?
开发实践
JVM dump
JVM Dump = 给 Java 虚拟机拍 “体检快照”。当 Java 应用卡死、OOM、CPU 100%、内存泄漏、死锁、响应极慢时,用它来定位问题。JVM Dump 一般分 3 种,用途完全不一样:
1️⃣ core dump /gcore → 系统级完整镜像
- 分析问题:JVM 崩溃、crash、段错误
- 整个进程内存 + 寄存器 + 栈全量导出
- 一般 JVM 崩溃时自动生成,或用
gcore手动生成
2️⃣ thread dump /jstack → 线程快照
- 分析问题:CPU 飙高、死锁、线程卡住、请求不响应
- 记录所有线程当前在执行哪行代码
- 能看到死锁、死循环、阻塞、大量等待线程
- 常用命令
jstack <pid> > thread.log
- Dump 分析:
- jstack 或在线工具查看死锁。
- 用
grep搜BLOCKED、WAITING、死锁
3️⃣ heap dump /jmap → 堆内存快照
- 分析问题:OOM、内存泄漏、对象太多、内存占用异常
- 把整个 Java 堆完整导出成文件(
.hprof) - 能看到:谁创建了大量对象、谁没释放、占了多少内存
- 把整个 Java 堆完整导出成文件(
- 常用命令
jmap -dump:format=b,file=heap.hprof <pid>
# live 只导出存活对象,文件小很多。
jmap -dump:live,format=b,file=heap.hprof 12345
# 自动 OOM 时导出 Dump,线上必配,JVM 启动参数加上:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
- Dump 分析:用 Eclipse MAT / jhat / VisualVM 等分析内存占用。
MAT 工具
MAT 是 Memory Analyzer tool 的缩写,是一种快速,功能丰富的 Java 堆分析工具,能帮助你查找内存泄漏和减少内存消耗。很多情况下,我们需要处理测试提供的 hprof 文件,分析内存相关问题,那么 MAT 也绝对是不二之选。
Overview 功能模块

- Actions:
- Histogram 列出每个类所对应的对象个数,以及所占用的内存大小;
- Dominator Tree 以占用总内存的百分比的方式来列举出所有的实例对象,可以用来发现大内存对象;注意这个地方是直接列举出的对应的对象而不是类
- Top Consumers:按照类和包分组的方式展示出占用内存最大的一个对象
- Duplicate Classes:检测由多个类加载器所加载的类信息(用来查找重复的类)
- Reports:
- Leak Suspects:通过 MAT 自动分析当前内存泄露的主要原因
- Top Components:Top 组件,列出大于总堆 1% 的组件的报告
- Step By Step:
- Component Report:组件报告,分析属于公共根包或类加载器的对象; 上述所有被标注加粗的部分,是内存溢出 dump 分析时较为常用的功能点
相关概念
- Shallow(浅) Heap:表示对象本身占用内存的大小,不包含对其他对象的引用,也就是对象头加成员变量(不是成员变量的值)的总和。
- Retained(保留) Heap:该对象自己的 Shallow Heap,并加上当前对象可直接或间接引用到的对象的 Shallow Heap 之和,并且排除被 GC Roots 直接或者间接引用的对象;换句话说,Retained Heap 是该对象 GC 之后所能回收到内存的总和。
- 把内存中的对象看成下图中的节点,并且对象和对象之间互相引用。这里有一个特殊的节点 GC Roots,这就是 reference chain 的起点。

OOM 常用分析思路
- Dominator Tree 查看大内存对象(占用百分比最高的),第一名占用堆内存异常高(如 98%)
- 分析该大内存对象都引用了那些数据,以及当前该对象是被那几个对象所引用的。功能:List Objects
- with outgoing references(查看当前该对象的所有的引用信息)
- with incoming references(查看当前该对象是被那几个对象所引用的)
- 看 Leak Suspects 自动分析,猜测基本一致
假 OOM 分析思路
-
真 OOM・典型内存泄漏:
- 内存曲线:平滑、持续、匀速上涨
- GC 表现:Young GC 越来越频繁 → 随后频繁 Full GC → Full GC 后内存不掉 → 最终 OOM
- 本质:对象每时每刻都在疯狂创建,且都活过 Young GC,源源不断进入老年代。老年代被快速填满 → Full GC 救不回来 → 真 OOM。
-
假 OOM・典型场景:
- 内存占用阶梯式上升,频繁触发 YoungGC,不触发 FullGC。➔ 内存看似在涨但是没有触发 FullGC,是因为老年代区内存空间大,达到 FullGC 条件过程比较久。
-
OOM 核心判断逻辑:这些占据大量堆内存的对象,到底是“本该被回收却被错误强引用的僵尸对象”,还是“纯粹等待垃圾回收器来清理的普通垃圾”。
通过 MAT 导出的 Dump 文件,主要通过以下三个步骤得出了“非内存泄漏”的结论:
- 自动分析:Leak Suspects (泄露嫌疑报告) 未指向全局集合
- 动作:首先查看了 MAT 自动生成的 Leak Suspects 报告。
- 分析:如果是真正的内存泄漏(例如忘记清理的静态
HashMap、错误使用的ThreadLocal导致越攒越大),报告通常会明确指出某一个具体的全局大对象占用了绝大部分的 Retained Heap(保留内存)。 - 结论:在我们的 Dump 报告中,并没有发现异常膨胀的全局容器或静态结构。内存是被海量散落的“发送任务实体”、“消息缓存”等正常业务对象占据的。
- Dominator Tree 定位大对象 + Path to GC Roots 追溯引用链
- 动作:
- 打开 Dominator Tree,按 Retained Size 倒序排列,找到内存占用 Top N 的对象;
- 对每个大对象,执行「Path to GC Roots」操作,查看其完整引用链。
- 分析:典型内存泄漏中,会出现 单个 GC Root(如静态 Map、ThreadLocalMap) 作为根节点,通过引用链支配了整个巨大的对象图。此时 Dominator Tree 会呈现出一个异常大的 “树根节点”,而 Path to GC Roots 会明确显示该节点的引用链来自全局静态结构。
- 分析:
- 如果是泄露:会发现这个已经执行完的任务对象,依然被某个存活的后台线程、长生命周期的 Spring Bean 或静态变量强引用着,导致垃圾回收器对它无能为力。
- 实际情况:溯源发现,这些大量堆积的老年代对象,根本已经没有强引用到达 GC Roots 了(或者仅仅是被当时极少数还在执行的短时线程引用)。
- 结论:这意味着它们在逻辑上已经“死亡”,已经是纯粹的垃圾。它们之所以还在内存里,仅仅是因为我们给老年代分配了较大的内存空间,而 Java 11 默认要达到 92% 才会触发 Full GC,当时堆积量还没达到阈值时,GC 还没启动清除。
- 动作:
典型内存泄漏场景
- 静态 Map / 静态 List 里塞了大量对象
- ThreadLocal 没清理
- 连接池、缓存没释放 这些都是 GC Roots 强引用一直持有对象,导致内存越来越大,最终 OOM。