一、理解什么是JVM?
JVM是Java虚拟机,即在操作系统中虚构出来的一个计算机,JVM可以不与硬件直接交互,而是与操作系统进行交互,再通过操作系统完成与硬件的交互
好处:
- 可以实现Java的跨平台特性(一次编译,到处运行),Java代码可以被编译成对应的.class文件(字节码文件),JVM可以将该文件翻译成不同操作系统都能理解的指令,即不同操作系统只需要按照对应的JVM就可以运行这个文件了,不需要再为不同的操作系统编写不同的代码了
- 可以隔离危险操作,JVM只能与操作系统进行交互,不能与硬件直接交互,这可以有效避免恶意代码直接破坏硬件,系统会提供对应的安全接口访问资源,JVM只能使用这些资源
- 更好的管理资源,操作系统会负责对应的硬件管理,JVM只需要负责对象生命周期等功能即可,避免了重复造轮子,简化了JVM设计
- 可以不用在意硬件的变化,即使在不同的硬件配置下也一样运行(硬件由操作系统管理)
理解JVM的五大块
线程共享区
- **方法区:**存储类的元数据(例如类名、方法名、字段名、常量池等),以及静态变量和常量
因为处于线程共享区,可能存在线程安全问题(多个线程同时访问一个类的数据)
- **堆:**存储所有的对象实例和数组,是JVM中最大的内存区域(像一个大仓库,存放所有的货物(对象))
也因为处于线程共享区,可能存在线程安全问题(多个线程同时访问一个类的数据),所以需要使用同步机制(例如:锁)来保证线程安全
线程独享区
- **栈:**存储方法调用的局部变量、操作数栈、动态链接和方法返回地址(就像一个工作台,每个线程都有自己的工作台,独立完成当前的任务
因为在线程独享区,不存在线程安全问题
-
本地方法栈:为 JVM 调用本地方法(Native Method,如 C/C++ 代码)提供服务(像一个翻译官,专门处理JVM与非Java代码的交互)
-
程序计数器:记录当前线程执行的字节码指令地址(类似于书签,标记当前执行到哪里)
调优重点——堆和栈
JVM 调优主要围绕 堆 和 栈 进行,因为它们是内存占用最大、最容易出问题的区域。
- 堆调优:
- 调整堆大小(
-Xmx
、-Xms
)。 - 选择合适的垃圾回收器(如 G1、ZGC)。
- 监控堆内存使用情况,避免内存泄漏或频繁 Full GC。
- 调整堆大小(
- 栈调优:
- 调整栈大小(
-Xss
)。 - 避免过深的递归调用或过大的局部变量。
- 调整栈大小(
拓展:什么是堆?
概念:堆是满足以下条件的树:堆中的每一个节点值都大于等于(或小于等于)子树中所有节点的值。(可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作)
堆不一定是完全二叉树,只是为了方便存储和索引,通常用完全二叉树来表示堆,事实上,广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树。
堆的用途
当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。
分类
堆分为最大堆和最小堆,二者区别在于节点的排序方式
最大堆:堆中每个节点的值都大于等于子树中所有节点的值
最小堆:堆中每个节点的值都小于等于子树中每个节点的值
堆的操作
插入元素:
1、将要插入的元素放到最后(就像一个新来的员工,总是要从基层做起的)
2、从底向上,如果父节点比该元素小,那么交换位置,直到无法交换
删除堆顶元素
当我们需要找寻最大或最小元素时,可以利用堆的性质——最大堆的堆顶元素是所有元素中最大的,最小堆的堆顶元素是所有元素中最小的。
在删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,这个过程称之为“堆化”,堆化分为两种:
- 自底向上的堆化,插入元素所使用的方法就是自底向上的堆化,元素从最底部开始移动
- 自顶向下的堆化,元素从最顶部向下移动
自底向上堆化
删除堆顶元素后,数组中下标为1的位置空出,那么他的位置由谁来顶替呢?谁能力强谁来呗(数值最大的顶替)
由图中的元素可知,原下标为3的元素最大,将其移到1处。
然后在比较位置3的左右子节点,谁最大谁来顶替,直到堆的最底部
自顶向下堆化(同理)
将最后的一个元素移到堆顶,然后跟自底向上一样从堆顶开始换位置,直到无法交换位置
堆操作总结
插入元素:先将元素放至数组末尾,再自底向上堆化,将末尾元素上浮
删除堆顶元素:删除堆顶元素,将末尾元素放至堆顶,再自顶向下堆化,将堆顶元素下沉。也可以自底向上堆化,只是会产生“气泡”,浪费存储空间。最好采用自顶向下堆化的方式。
堆排序
分为两步:
第一步建堆,将一个无序的数组建立为一个堆
第二步排序,将堆顶元素取出,然后堆剩下的元素进行堆化,反复迭代,直到所有元素被取出为止
建堆:建堆的过程就是对一个所有非叶子节点的自顶向下堆化的过程(非叶子节点:最后一个节点的父节点及它之前的元素),也就是说,如果节点数为n,我们需要对n/2到1的节点进行自顶向下的堆化
排序:如果是最大堆,就取出堆顶元素放到数组最后(如图),再对剩下元素进行堆化,再去堆顶元素放到最后,如此反复,直至最后一个节点,即完成排序。
取第一个元素进行堆化:
取第二个元素进行堆化:
……
最终结果:
Java main方法的执行过程
现在有一个Java类,里面有一个main方法
public class Car {
public static void main(String[] args) {
System.out.println("引擎启动!");
drive();
}
static void drive() {
System.out.println("汽车开始行驶...");
}
}
main
方法的作用:是程序的唯一入口,就像汽车的点火开关,没有它,程序无法启动
接下来用Javac Car.java编译代码,生成Car.class
文件(字节码文件)
通过Java Car命令运行程序,JVM开始工作:
加载类,类加载器会找到Car.class
文件,把他加载到内存的方法区(就像工厂收到生产汽车的指令,从仓库搬出Car的零件图纸)
找到main方法,JVM检查Car类是否有合法的main方法:
public static void main(String[] args)
public
:谁都能按这个点火开关。static
:不需要先造一辆车,直接就能点火。void
:点火后没有返回值(点火要么成功,要么失败)。String[] args
:点火时可以传递参数(比如选择燃油类型)。
启动主线程,JVM创建一个主线程,这个线程会执行main方法
执行main方法,主线程开始执行main方法
System.out.println("引擎启动!");
drive();
/*关键细节:
方法调用会生成 栈帧(Stack Frame),存放在 虚拟机栈 中(类似司机记住当前操作步骤)。
局部变量(如 args 参数)也存储在栈中。
*/
程序结束,main方法执行完毕后,主线程结束,JVM退出
为什么需要main方法?
- 唯一入口:JVM 需要知道从哪里开始执行程序,就像汽车必须有一个统一设计的点火开关。
- 标准化设计:所有 Java 程序都从
main
启动,保证一致性。
类加载器(Java世界的“快递员”)
简述:类加载器的工作简单来说就是找到类文件(.class文件)并送到JVM内存中
类加载器的作用
- 找类:根据类名,找到对应的
.class
文件 - 加载类:把
.class
文件的内容读入内存 - 验证类:检查类文件是否合法(是否被篡改)
- 准备内存:为类的静态变量分配内存空间
- 解析符号:把符合引用(例如方法名)转为直接引用(内存地址)
- 初始化类:执行静态代码块(static{})和静态变量的赋值
类加载器的类型
JVM中有三类核心“快递员”,他们分工明确,形成“父子关系”:
启动类加载器(Bootstrap ClassLoader):
作用:配送JDK核心包裹(例如java.lang
包下的类)
特点:1.用C/C++作为底层实现,是JVM的一部分
2.没有”上级领导“,是最顶层的快递员
路径:加载JRE/lib目录下的核心类库
扩展类加载器(Extension ClassLoader):
作用:配送JDK扩展包裹(如Javax包下的类)
领导:上级是启动类加载器
路径:加载JRE/lib/ext目录下的扩展类库
应用类加载器(Application ClassLoader):
作用:配送程序员自己写的包裹(项目中的类)
领导:上级是扩展类加载器
路径:加载classpath下的类(即程序员自己写的代码和第三方库)
类加载的“双亲委派”机制
什么是“双亲委派机制”?
这个机制是类加载的核心规则,可以近似理解为“有事先找领导”的工作流程
流程步骤
- 当应用类收到一个配送任务时(加载类请求):
- 先问自己的上级领导(扩展类加载器):”你可以送这个包裹吗?“
- 扩展类加载器再问自己的上级领导(启动类加载器):“你能送吗?”(感觉像是甩锅,不过是下层甩给上层的)
- 启动类加载器尝试配送,发现自己送不了,向下退回给扩展类加载器
- 扩展类加载器也尝试配送,发现也送不了,向下退回给应用类加载器自己送
为什么要这样进行?目的是什么?
这样进行的目的是为了安全性:
- 可以防止程序员自定义的类冒充JDK核心类(比如程序员自己写了一个
java.lang.String
,会被启动类加载器优先加载,而核心类已经存在,所以程序员自定义的类不会被加载) - 可以保证一个类只被加载一次
自定义类加载器
除了上述三类类加载器,还可以自己雇佣一个快递员(自定义一个类加载器),应用于特殊场景:
- 应用场景
- 热部署:不重启程序,动态替换类(如开发工具调试时)。
- 模块化加载:不同模块用不同类加载器,防止类冲突(如 Tomcat 为每个 Web 应用分配独立类加载器)。
- 加密类文件:加载加密的
.class
文件(先解密再加载)。
- 实现方式
- 继承
ClassLoader
类,重写findClass
方法。 - 例如:从网络下载
.class
文件并加载。
- 继承
类加载器实例
以加载com.example.MyClass
文件为例:
- 应用类加载器接收到请求,上交给扩展类加载器;
- 扩展类加载器交给启动类加载器;
- 启动类加载器发现这个类不在JDK核心类(rt.jar)中,无法加载,退回给扩展类加载器;
- 扩展类加载器发现这个类不在ext目录中,退回给应用类加载器;
- 应用类加载器在classpath中找到com/example/Myclass.class,加载进内存;
常见问题
为什么需要类加载器?
分工明确,确保核心类优先被加载,避免恶意代码替换JDK核心类
类加载器会导致内存泄漏吗?
会!如果自定义类加载器加载的类长时间不卸载,可能导致内存泄漏(尤其在热部署场景中)。
运行时数据区
了解数据区之前,可以先看一下JVM的整体结构,以便我们更好的了解数据区
由上面的图片可以看出,JVM的运行时数据区分为五大板块,分别是方法区、栈、本地方法栈、堆、程序计数器。这些内容我们在本文刚开始就已经简单介绍过他们的作用,现在我们会详细讲解其中重要部分的作用(堆和栈)
本地方法栈
本地方法栈主要是为JVM调用本地方法提供服务,因为他位于线程独享区,所以不需要考虑线程安全问题
一个简单的例子:
我们现在点开 Thread 类的源码,会看到它的 start0 方法带有一个 native 关键字修饰,而且不存在方法体,这种用 native 修饰的方法就是本地方法,这是使用 C 来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。
程序计数器
用于记录当前线程执行的字节码指令地址。
程序计数器其实就是一个指针,指向了程序中下一句所需要执行的指令,是内存区域中唯一不会出现OutOfMemoryError的区域,并且占用内存空间极小(指针几乎不占用内存)。这个内存代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
方法区
存储类的元数据(例如类名、方法名、字段名、常量池等),以及静态变量和常量
当他存储的信息过大时,会在无法满足内存分配时报错
虚拟机栈
虚拟机栈负责运行代码,虚拟机堆负责存储数据
概念:虚拟机栈是线程私有的内存区域,用于支持Java方法的执行。每个方法在执行时,JVM都会为其创建一个栈帧(Stack Frame)(在Java中也叫方法),用于存储以下内容:
- 局部变量表:存放方法参数,方法内定义的局部变量(例如
int a = 10;
) - 操作数栈:用于计算中间结果(如加减乘除的临时值)
- 动态链接:指向方法所属类的运行时常量池的引用(用于多态)
- 方法返回地址:记录方法执行完毕后应返回的位置(如main方法内调用其他方法后的返回点)
核心作用:
- 管理方法调用与返回(类似于“任务清单”)
- 存储方法执行过程中的临时数据
生命周期与特点
生命周期:
- 栈帧随方法的开始而创建,随方法的结束而销毁
- 线程结束时,整个栈内存被回收
特点:
- 高效:内存分配与释放由 JVM 自动完成,速度极快(直接移动栈顶指针)。
- 容量有限:默认大小 1MB,可通过
-Xss
参数调整(如-Xss2m
)。 - 线程隔离:每个线程独立操作自己的栈,天然线程安全。
虚拟机栈的执行
在栈中的数据都是以栈帧的格式存储,栈帧是一个关于方法和运行时数据的数据集。例如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1被压入栈中。同理,方法b会产生B1,方法c会产生C1,都会被压入栈中,最后遵循栈的先入后出,后进先出的原则进出栈
实际场景中的问题
实际场景中经常出现栈溢出(StackOverflowError),其原理是递归调用过深或方法内定义了超大局部变量
解决方法一般是将递归改用循环,或增大栈空间(-Xss
参数)
虚拟机堆(Heap)
概念:堆是JVM中线程共享的最大区域,用于存储所有对象实例和数组
核心分区:
-
新生代:存放新创建的对象(分为Eden、Survivor区)
-
老年代:存放长期存活的对象
-
元空间(JDK8+、用来取代永久代):存放类元信息,与永久代的不同是元空间是不存在于JVM中的,它使用的是本地内存。并且有两个参数:
MetaspaceSize:初始化元空间大小,控制发生GC MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。
核心作用:
- 管理对象的生命周期(从创建到垃圾回收)
- 支持动态内存分配(如使用
new
关键字创建对象)
内存分配与回收
分配机制:
- 对象优先在Eden区分配,若Eden区满则触发Minor GC
- 大对象(如超大数组)则可能直接进入老年代(避免Eden复制开销)
垃圾回收(GC):
- Minor GC:清理新生代,存活对象从Eden复制到Survivor区
- Major GC/Full GC:清理整个堆(包括老年代),通常伴随着程序停顿(STW)
Eden年轻代详细介绍
在我们new了一个对象后,会将对象放在Eden区,但我们直到堆内存是线程共享的,所有有可能出现两个对象共用一个内存的情况。
JVM对此的处理是,为每个线程都预先申请好一块连续的内存空间并规定对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作称之为TLAB
在Eden空间满了之后,会触发Minor GC操作,存活下来的对象移动到Survivor0区,若Eden区再次满了,则再次触发Minor GC,会将Eden和Survivor0区存活的对象都移动到Survivor1区,此时还会发生from和to指针交换(Survivor0区改名叫 to,Survivor1区改名叫 from(交换标签,保证下次腾挪时总有一个空房间))
当经过多次Minor GC后仍然存活的对象(具体为15次,对应的虚拟机参数为-XX:MaxTenuringThreshold。至于为什么为15次,因为HotSpot中记录年龄的空间就只有四位,最大为1111,即15)会被移入老年代
举例理解
通俗版解释:垃圾回收的“教室腾挪大法”
场景设定
想象 JVM 的年轻代内存是一个 教室(Eden区) 和两个 备用小房间(Survivor0 和 Survivor1 区),学生(对象)在教室里上课,年龄大的学生会被送到 老年代(VIP自习室)。
第一步:教室满了,开始考试(Minor GC)
- 触发条件:当教室(Eden区)坐满学生(对象)时,触发一次 小考(Minor GC)。
- 考试规则:
- 存活学生:被考官(GC)标记为“存活”的学生(被引用的对象),搬到备用小房间(Survivor0区)。
- 淘汰学生:未被标记的学生(垃圾对象)直接离开教室(内存被回收)。
- 结果:教室(Eden区)被清空,Survivor0区有了第一批学生。
第二步:第二次考试,房间不够怎么办?
- 再次触发:当教室(Eden区)再次坐满,触发第二次小考(Minor GC)。
- 考试范围扩大:
- 这次考试不仅检查教室(Eden区)的学生,还检查 Survivor0区的学生。
- 腾挪规则:
- 存活学生:从教室(Eden)和 Survivor0区中标记存活的学生,全部搬到 另一个空房间(Survivor1区)。
- 房间交换:考完后,Survivor0区改名叫 to,Survivor1区改名叫 from(交换标签,保证下次腾挪时总有一个空房间)。
- 结果:
- Eden区和 Survivor0区被清空,存活学生集中在 Survivor1区。
第三步:学生年龄大了,升入VIP自习室(老年代)
- 年龄记录:
- 每次小考(Minor GC)后,存活学生的 年龄+1(类似“升级”)。
- 年龄上限是 15岁(因为记录年龄的“档案”只有4位,最大能存 1111,即15)。
- 升学规则:
- 当学生年龄达到 15岁(通过参数
-XX:MaxTenuringThreshold
可调整),就会被送到 老年代(VIP自习室)。
- 当学生年龄达到 15岁(通过参数
总结流程
- 教室(Eden)满了 → 小考(Minor GC) → 存活学生搬到备用房(Survivor)。
- 下次考试时,教室和备用房的学生一起考 → 存活者搬到另一个备用房。
- 反复考试(GC)后,老学生(年龄达标)进VIP自习室(老年代)。
关键点解释
- 为什么 Survivor区满不会触发 Minor GC?
只有教室(Eden区)满才会触发考试(GC),备用房(Survivor区)的学生只有在教室满时才会被一起检查。 - 为什么年龄最多15岁?
学生的“年龄档案”只有4个格子(二进制4位),最大能写15(1111)。 - 交换 Survivor区的意义:
保证总有一个空房间备用,避免内存碎片(像轮流使用两个仓库,方便快速清理)。
类比记忆:
- 教室(Eden) → 临时上课点(新对象)。
- 备用房(Survivor) → 临时座位(存活对象)。
- VIP自习室(老年代) → 长期座位(老对象)。
- 考试(Minor GC) → 定期淘汰差生(垃圾回收)。
老年代详细介绍
老年代中存储的是长期存活下来的对象,在老年代被占满就会触发Full GC,在此期间会停止所有线程等待GC的完成。所以响应要求高的应用应该尽量减少Full GC发生的概率,从而避免响应超时问题
而且当老年区执行了 full gc 之后仍然无法进行对象保存的操作,就会产生 OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xmx 来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。
Minor GC是怎么判断对象是否存活的?
可以把 Minor GC 想象成一次 “大扫除”,目标是清理年轻代(Eden + Survivor 区)中的“垃圾对象”,只留下有用的对象。判断对象是否存活的核心逻辑是 “谁还有用,谁该丢掉”,具体分为两步:
第一步:标记存活对象
核心方法:可达性分析(根搜索算法)
JVM会从一组”根对象(GC Roots)“出发,沿着引用链顺藤摸瓜,所有能被根对象直接或间接访问到的对象,就是根对象。
根对象包括:
- 虚拟机栈中的局部变量(如
main
方法中的对象引用)。 - 方法区中的静态变量(如
static User admin;
)。 - 本地方法栈中 JNI 引用的对象。
- 正在运行的线程对象。
第二步:复制存活的对象到Survivor区
- 复制算法:
- Minor GC 会 将 Eden 区和当前 Survivor 区(From 区)的存活对象,一次性复制到另一个 Survivor 区(To 区)。
- 复制完成后,清空 Eden 区和 From 区,并将 From 和 To 区的标签交换(下次 GC 时,原来的 To 区变成新的 From 区)。
- 年龄增长:
- 每熬过一次 Minor GC,对象的年龄(Age)会 +1(年龄记录在对象头中)。
- 当年龄达到阈值(默认 15,可通过
-XX:MaxTenuringThreshold
调整),对象会被晋升到老年代。
为什么用两个 Survivor 区?
- 保证始终有一个空的 Survivor 区用于接收存活对象,避免内存碎片(类似“腾笼换鸟”策略)。
如何判断一个对象已经死亡?
判断一个对象死亡至少需要两次标记:
- 对对象进行可达性分析后发现其没有与根对象(GC Roots)相连的引用链,那么它将会被第一次标记,并且进行一次筛选。筛选判断的条件是决定这个对象是否执行finalize()方法,如果有必要,那么该对象被放入F-Queue队列中
- GC对F-Queue队列中的对象进行二次标记,如果对象再finalize()方法中重新与引用链上的任何一个对象建立了联系,那么二次标记则会将他移出”即将回收“集合,如果此时对象还没有成功逃脱,那么就会被回收(宣判死亡)
JVM垃圾回收机制
内存分配
JVM的内存分配主要发生在年轻代(Young Generation) 和 老年代(Old Generation)
对象优先在Eden区进行分配
大多数新创建的对象会优先分配到Eden区(成为年轻代的一部分)
原因:Eden区是年轻代的主要区域,分配速度快
Object obj = new Object(); // 新对象分配到 Eden 区
大对象(占用内存大)直接进入老年代
如果对象非常大(如超大数组),Eden区无法容纳,会直接分配到老年代
原因:避免大对象在Eden区和Survivor区之间频繁复制,降低GC效率
可以提供-XX:PretenureSizeThreshold
设置大对象阈值
将长期存活的对象放入老年代
在年轻代经过多次Minor GC仍然存活的对象(默认15次),会被晋升到老年代
在老年代中存放长期存活的对象,降低年轻代的GC压力
可以通过-XX:MaxTenuringThreshold
设置晋升年龄阈值
动态对象年龄判定
若Survivor区中相同年龄的对象总大小超过Survivor区的一半,年龄大于等于该年龄的对象将会直接升入老年代
原因:避免Survivor区被大量同年龄对象占满,影响新对象分配
垃圾回收原则
JVM的垃圾回收主要分为Minor GC和Full GC
分代收集(Generational Collection)
JVM将堆内存分为年轻代和老年代,分别采用不同的回收策略
- 年轻代:使用复制算法(Minor GC),回收频繁但速度快
- 老年代:使用标记-清除或标记-整理算法(Full GC),回收慢但频率低
原因:根据对象的生命周期特点,优化回收效率
复制算法(年轻代)
-
将 Eden 区和 Survivor 区(From 区)的存活对象复制到另一个 Survivor 区(To 区)。
-
清空 Eden 区和 From 区,交换 From 和 To 区的标签。
-
优点:
- 内存连续,避免碎片化。
- 回收速度快(只处理存活对象)。
-
缺点:
- 需要预留一半内存(Survivor 区)作为复制空间。
标记-清除算法(老年代)
-
标记阶段:从根对象出发,标记所有可达对象。
-
清除阶段:回收未被标记的对象(垃圾)。
-
优点:
- 不需要额外内存空间。
-
缺点:
- 产生内存碎片(回收后内存不连续)。
标记-整理算法(老年代)
-
标记阶段:与标记-清除相同,标记所有可达对象
-
**整理阶段:**将存活对象向一端移动,清理边界外的内存
-
优点:
- 避免内存碎片,适合老年代的大对象分配。
-
缺点:
- 移动对象需要额外开销。
空间分配担保
在Minor GC之前,JVM会检查老年代是否有足够空间容纳年轻代的所有对象,如果不够,会先触发Full GC
原因:避免Minor GC后存放对象无法晋升到老年代,导致内存溢出
参数:通过 -XX:HandlePromotionFailure
控制是否启用空间分配担保(JDK 6u24 后默认启用)。
调优原则
- 年轻代调优:
- 增大 Eden 区(
-Xmn
)可减少 Minor GC 频率,但会增加单次 GC 时间。 - 调整 Survivor 区比例(
-XX:SurvivorRatio
)可优化对象晋升策略。
- 增大 Eden 区(
- 老年代调优:
- 增大堆内存(
-Xmx
)可减少 Full GC 频率,但会增加 GC 停顿时间。 - 选择合适的 GC 算法(如 G1、ZGC)可优化老年代回收效率。
- 增大堆内存(
- 避免内存泄漏:
- 及时释放无用对象的引用(如静态集合、缓存)。
- 使用工具(如 VisualVM、MAT)分析堆转储文件,定位泄漏点。
空间分配担保
空间分配担保是为了确保在Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间
在《深入理解Java虚拟机》中对于空间分配担保的描述如下:
JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看
`-XX:HandlePromotionFailure` 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 `-XX: HandlePromotionFailure` 设置不允许冒险,那这时就要改为进行一次 Full GC。
JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
举例解释:搬家之前看仓库够不够大
假设你正在 搬家:
- 年轻代(Eden区) 是你的 临时储物间(放短期物品)。
- 老年代 是 长期仓库(放需要保留很久的物品)。
- Minor GC 就是 清理临时储物间,把有用的东西搬到仓库。
问题:
如果临时储物间满了,你要清理(Minor GC),但搬进仓库前,得先确认仓库有没有足够空间。否则,东西搬过去仓库放不下,就会引发大问题(内存溢出)!
JDK6之前的规则(严格检查)
第一步:检查仓库容量
如果仓库最大的连续空间>临时储物间所有物品的总大小→安全,直接清理临时储物间(Minor GC)
如果仓库空间不足→看是否允许冒险(-XX:HandlePromotionFailure
参数)
第二步:检查是否允许冒险?
允许冒险:检查仓库空间是否>以前每次搬到仓库的物品平均大小
- 如果平均大小足够→冒险清理临时储物间(可能会成功,也可能会失败)
- 如果平均大小不够→必须彻底清理整个仓库(Full GC)
不允许冒险:直接彻底清理仓库(Full GC)
总结:该模式规则严格,容易触发Full GC,但更加安全
JDK 6 Update 24 之后的规则(简化检查)
- 直接检查两个条件:
- 仓库的连续空间 > 临时储物间所有物品的总大小,或者
- 仓库的连续空间 > 以前搬到仓库的物品平均大小。
- 满足任一条件 → 直接清理临时储物间(Minor GC)。
- 都不满足 → 必须彻底清理仓库(Full GC)。
结果:
- 规则更宽松,减少 Full GC 次数(性能更好),但略微增加风险。
为什么要更改规则?
在安全的情况下,尽量减少Full GC(因为Full GC会暂停整个程序,影响性能)
- 类比:
- 旧规则:搬家前必须确保仓库能放下所有东西,否则宁可大扫除(Full GC)。
- 新规则:只要仓库大概率能放下,就冒险先清理临时储物间(Minor GC),即使偶尔需要补救。
实际意义
- 调优建议:
- 增大老年代空间(
-Xmx
)或优化对象生命周期,减少晋升到老年代的对象数量。 - 观察 GC 日志,确保老年代空间足够,避免频繁 Full GC。
- 增大老年代空间(
- 参数变化:
- JDK 6u24 后
-XX:HandlePromotionFailure
参数失效,规则自动优化。
- JDK 6u24 后
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现
直到现在还没有一个可以称得上是最好的收集器出现,更没有一个万能的收集器,我们能做的就是更加具体应用场景选择适合自己的垃圾收集器
Serial收集器
Serial(串行)收集器死最基本、历史最悠久的垃圾收集器。看名字就可以得知其是一个单线程收集器,它的单线程不仅指它只会用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作时必须暂停其他所有工作线程,直到它收集结束
该收集器新生代采用标记-复制算法,老年代采用标记-整理算法
优点:实现简单,内存开销较小
ParNew收集器
ParNew收集器其实是Serial的多线程版本,除了使用多线程进行垃圾收集外,其余行为和Serial收集器完全一样
Parallel Scavenge收集器
Parallel Scavenge收集器也是使用标记-复制算法的多线程收集器,看上去似乎和ParNew收集器一样,那么他有什么不一样的地方呢?
特点:
- 该收集器目标是最大化吞吐量,即让应用花更少的时间再GC上,更多的时间在执行业务上
- 默认搭配Parallel Old(老年代并行回收),适用于大数据吞吐量的场景
- 允许设定最大 GC 停顿时间(
-XX:MaxGCPauseMillis
)和GC 时间比例(-XX:GCTimeRatio
)
适用场景:
- 吞吐量优先的应用,如批量任务、大数据计算、后台服务,允许 GC 发生较长的 STW 停顿。
- CPU 资源充足,能利用多线程并行处理垃圾回收。
- 不适合低延迟要求的应用(如 Web 服务器)。
常见的JVM参数:
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:MaxGCPauseMillis=200
-XX:GCTimeRatio=99
对比总结:
维度 | ParNew | Parallel Scavenge |
---|---|---|
目标优化方向 | 低延迟 | 高吞吐量 |
适用场景 | Web服务器、低延迟交互应用 | 大数据、批处理任务 |
默认搭配的老年收集器 | CMS(不支持Parallel Old) | Parallel Old(默认搭配) |
可配置性 | 配合CMS进行优化 | 具备自适应调优 |
GC停顿 | STW低,吞吐量可能较低 | STW可能较长、但吞吐量更高 |
Parallel Old收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS(Concurrent Mark-Sweep)收集器
核心特点
- 设计目标:以最短停顿时间(低延迟)为核心,适合对响应速度敏感的应用(如Web服务)。
- 工作范围:老年代收集器,需与新生代收集器(如ParNew)配合使用。
- 算法:基于标记-清除(Mark-Sweep),分阶段并发执行。
CMS收集器是HotSpot虚拟机第一个真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
工作流程
CMS的回收过程分为4个阶段,其中2个阶段是并发执行的(不暂停用户线程)
初始标记(Initial Mark)
暂停用户线程(STW),标记与GC Roots直接关联的对象(速度很快)
并发标记(Concurrent Mark)
并发执行,遍历老年代,标记所有存活对象
重新标记(Remark)
暂停用户线程(STW),修正并发标记期间因为用户线程运行导致的标记变动
并发清除(Concurrent Sweep)
并发执行,清理未标记的垃圾对象
适用场景
- 中小型堆内存(如 4GB 以下)。
- 对延迟敏感的应用(如在线交易系统)。
- 需避免长时间 Full GC 停顿的场景。
G1(Garbage-First)收集器
G1收集器是一个面向服务器的垃圾收集器
核心特点
- 设计目标:兼顾吞吐量和低延迟,适合大内存、多核 CPU 的场景。
- 工作范围:全堆收集器(新生代 + 老年代),无需与其他收集器配合。
- 算法:基于 Region 分区的标记-整理(Mark-Compact)。
核心机制
- 堆内存分区:
- 将堆划分为多个大小相等的 Region(默认 2048 个),每个 Region 可以是 Eden、Survivor 或 Old 区。
- 回收优先级:
- 跟踪每个 Region 的垃圾比例(即“垃圾优先”),优先回收垃圾最多的 Region(最大化回收效率)。
- 可预测停顿模型:
- 通过参数
-XX:MaxGCPauseMillis
(默认 200ms)设定目标停顿时间,动态调整回收范围。
- 通过参数
工作流程
- 初始标记(Initial Mark)
- STW,标记 GC Roots 直接关联的对象(类似 CMS)。
- 并发标记(Concurrent Mark)
- 并发执行,标记全堆存活对象。
- 最终标记(Final Mark)
- STW,处理并发阶段的变化(类似 CMS 的重新标记)。
- 筛选回收(Evacuation)
- STW,根据优先级选择 Region 进行回收,存活对象复制到空 Region(整理内存)。
ZGC收集器
与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。
核心特点
- 设计目标:实现超低停顿时间(10ms 以内),适合超大堆内存(TB 级别)和实时性要求极高的场景。
- 工作范围:全堆收集器(新生代 + 老年代)。
- 算法:基于 Region 分区的并发标记-整理(Concurrent Mark-Compact)。
核心机制
- 染色指针(Colored Pointers)
- 在指针中嵌入元数据(如标记位、引用状态),避免额外的内存开销。
- 通过硬件支持(如多级页表)快速访问指针信息。
- 并发执行
- 几乎所有阶段(标记、转移、重定位)都并发执行,极大减少 STW 时间。
- Region 分区
- 将堆划分为多个大小不等的 Region(动态调整),支持灵活的内存管理。
- 可扩展性
- 设计上支持 TB 级别的堆内存,适合现代大数据和云计算场景。
工作流程
- 初始标记(Initial Mark)
- STW,标记 GC Roots 直接关联的对象(时间极短)。
- 并发标记(Concurrent Mark)
- 并发执行,遍历堆内存,标记所有存活对象。
- 再标记(Remark)
- STW,修正并发标记期间的变化(时间极短)。
- 并发转移准备(Concurrent Relocate Preparation)
- 并发执行,选择需要回收的 Region,准备转移存活对象。
- 初始转移(Initial Relocate)
- STW,转移少量对象(时间极短)。
- 并发转移(Concurrent Relocate)
- 并发执行,将存活对象复制到新 Region,并更新引用。
适用场景
- 超大堆内存(如 100GB 以上)。
- 实时性要求极高(如金融交易、实时推荐系统)。
- 未来趋势:JDK15 后 ZGC 逐渐成熟,成为超低延迟场景的首选。
Concurrent Mark-Compact)。
核心机制
- 染色指针(Colored Pointers)
- 在指针中嵌入元数据(如标记位、引用状态),避免额外的内存开销。
- 通过硬件支持(如多级页表)快速访问指针信息。
- 并发执行
- 几乎所有阶段(标记、转移、重定位)都并发执行,极大减少 STW 时间。
- Region 分区
- 将堆划分为多个大小不等的 Region(动态调整),支持灵活的内存管理。
- 可扩展性
- 设计上支持 TB 级别的堆内存,适合现代大数据和云计算场景。
工作流程
- 初始标记(Initial Mark)
- STW,标记 GC Roots 直接关联的对象(时间极短)。
- 并发标记(Concurrent Mark)
- 并发执行,遍历堆内存,标记所有存活对象。
- 再标记(Remark)
- STW,修正并发标记期间的变化(时间极短)。
- 并发转移准备(Concurrent Relocate Preparation)
- 并发执行,选择需要回收的 Region,准备转移存活对象。
- 初始转移(Initial Relocate)
- STW,转移少量对象(时间极短)。
- 并发转移(Concurrent Relocate)
- 并发执行,将存活对象复制到新 Region,并更新引用。
适用场景
- 超大堆内存(如 100GB 以上)。
- 实时性要求极高(如金融交易、实时推荐系统)。
- 未来趋势:JDK15 后 ZGC 逐渐成熟,成为超低延迟场景的首选。