JVM 理论
本篇内容由网上资料、书籍、个人理解编成,存在一定时效性;
本篇主要针对HotSpot虚拟机以及Graal VM 虚拟机进行知识分享、测试和运用。
# 初识JVM
# 什么是JVM
JVM 全称是 **J**ava Virtual Machine,译为 Java虚拟机,是Java平台的核心组件之一,JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件,为Java程序提供了一个跨平台运行的执行环境,亦是Java号称“Write Once,Run Anywhere”(一次编写,到处运行)的原因。

JVM在Java技术体系中的位置
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——**字节码(Byte Code)**是构成平台无关性的基石,但本节标题中笔者刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性——语言无关性正在越来越被开发者所重视。直到今天,或许还有相当一部分程序员认为Java虚拟机执行Java程序是一件理所当然和天经地义的事情。
但在Java技术发展之初,设计者们就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性,他们在发布规范文档的时候,也刻意把Java的规范拆分成了《Java语言规范》(The Java Language Specification)及《Java虚拟机规范》 (opens new window)(The Java Virtual M achine Specification)两部分。并且早在1997年发表的第一版《Java虚拟机规范》中就曾经承诺过:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”(In the future,we will consider bounded extensions to the Java virtual machine to provide better support for other languages)。Java虚拟机发展到今天,尤其是在2018年,基于HotSpot扩展而来的GraalVM 公开之后,当年的虚拟机设计者们已经基本兑现了这个承诺。
-- 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
# 常见的JVM
| 名称 | 作者 | 支持版本 | 社区活跃度(github star) | 特性 | 适用场景 |
|---|---|---|---|---|---|
| HotSpot (Oracle JDK版) | Oracle | 所有版本 | 高(闭源) | 使用最广泛,稳定可靠,社区活跃JIT支持Oracle JDK默认虚拟机 | 默认 |
| HotSpot (Open JDK版) | Oracle | 所有版本 | 中(16.1k) | 同上开源,Open JDK默认虚拟机 | 默认对JDK有二次开发需求 |
| GraalVM | Oracle | 11, 17,19,21企业版支持8 | 高(18.7k) | 多语言支持高性能、JIT、AOT支持 | 微服务、云原生架构需要多语言混合编程 |
| Dragonwell JDK龙井 | Alibaba | 标准版 8,11,17扩展版11,17 | 低(3.9k) | 基于OpenJDK的增强高性能、bug修复、安全性提升JWarmup、ElasticHeap、Wisp特性支持 | 电商、物流、金融领域对性能要求比较高 |
| Eclipse OpenJ9 (原 IBM J9) | IBM | 8,11,17,19,20 | 低(3.1k) | 高性能、可扩展JIT、AOT特性支持 | 微服务、云原生架构 |
# HotSpot的发展历程
# 初出茅庐 - 1999年4月
源自1997年收购的SmallTalk语言的虚拟机,HotSpot虚拟机初次在JDK中使用。在JDK1.2中作为附加功能存在,
JDK1.3之后作为默认的虚拟机。
# 野蛮生长 - 2006年12月
JDK 6发布,并在虚拟机层面做了大量的优化,这些优化对后续虚拟机的发展产生了深远的影响。
# 稳步前进 - 2009-2013
JDK7中首次推出了G1垃圾收集器。收购了Sun公司之后,吸纳了JRockIt虚拟机的一些设计思想,JDK8中引入了JMC等工具,去除了永久代。
# 百家争鸣 - 2018-2019
JDK11优化了G1垃圾收集器的性能,同时推出了ZGC新一代的垃圾回收器,JDK12推出Shenan-doah垃圾回收器。
# 拥抱云原生 - 2019-至今
以Hotspot为基础的GraalVM虚拟机诞生,不仅让解决了单体应用中多语言整合的难题,同时也提升了这些语言运行时的效率。极高的性能、极快的启动速度也更适用于当下的云原生架构。
# JVM的功能
# 解释和运行
对字节码文件中的指令,实时的解释成机器码,让计算机执行。
字节码文件中包含了字节码指令,计算器无法直接执行,Java虚拟机会将字节码文件中的字节码指令实时地解释成机器码,机器码是计算机可以运行的指令。

# 内存管理
- 自动为对象、方法等分配内存
- 自动的垃圾回收机制,回收不再使用的对象
Java虚拟机会帮助程序员为对象分配内存,同时将不用的对象使用垃圾回收器回收掉,这是对比C和C++这些语言的一个优势。在C/C++语言中,对象的回收需要程序员手动去编写代码完成,如果遗漏了这段删除对象的代码,这个对象就会永远占用内存空间,不会再回收。所以JVM的这个功能降低了程序员编写代码的难度。
# 即时编译
JVM提供了即时编译JIT(Just-In-Time) 进行性能的优化,最终能达到接近C、C++语言的运行性能。即时编译可以说是提升Java程序性能最核心的手段。
# Java性能低的主要原因和跨平台特性
Java语言如果不做任何的优化,性能其实是不如C和C++语言的。主要原因是:在程序运行过程中,Java虚拟机需要将字节码指令**实时地**解释成计算机能识别的机器码,这个过程在运行时可能会反复地执行,所以效率较低。

C和C++语言在执行过程中,只需要将源代码编译成可执行文件,就包含了计算机能识别的机器码,无需在运行过程中再实时地解释,所以性能较高。

Java为什么要选择一条执行效率比较低的方式呢?主要是为了实现跨平台的特性。Java的字节码指令,如果希望在不同平台(操作系统+硬件架构),比如在windows或者linux上运行。可以使用同一份字节码指令,交给windows和linux上的Java虚拟机进行解释,这样就可以获得不同平台上的机器码了。这样就实现了Write Once,Run Anywhere 编写一次,到处运行的目标。

但是C/C++语言,如果要让程序在不同平台上运行,就需要将一份源代码在不同平台上分别进行编译,相对来说比较麻烦。
再回到即时编译,在JDK1.1的版本中就推出了即时编译去优化对应的性能。

虚拟机在运行过程中如果发现某一个方法甚至是循环是热点代码(被非常高频调用),即时编译器会优化这段代码并将优化后的机器码保存在内存中,如果第二次再去执行这段代码。Java虚拟机会将机器码从内存中取出来直接进行调用。这样节省了一次解释的步骤,同时执行的是优化后的代码,效率较高。
Java通过即时编译器获得了接近C/C++语言的性能,在某些特定的场景下甚至可以实现超越。
# JVM的组成💡💡💡

- 类加载子系统:核心组件类加载器,负责将字节码文件中的内容加载到内存中。
- 运行时数据区:JVM管理的内存,创建出来的对象、类的信息等等内容都会放在这块区域中。
- 方法区:存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 堆:存放对象,几乎所有的对象实例都在此分配,也是Java垃圾回收的主要区域。
- 程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- 虚拟机栈:虚拟机栈是线程私有的内存区域,生命周期与线程相同。它描述的是Java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈:本地方法栈(Native Method Stacks)与虚拟机栈作用相似,也会抛出StackOverflowError和OutOfMemoryError异常。区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈是为虚拟机使用到的Native方法服务。
- 执行引擎:包含了即时编译器、解释器、垃圾回收器,执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
- 解释器:负责读取Java程序编译后的字节码(.class文件中的内容),并逐条将这些字节码指令转换为对应平台的机器码,然后执行。这个过程是按需进行的,即每读取一条字节码指令就翻译并执行它,因此解释执行相对较慢。
- 即时编译器:在程序运行初期,解释器快速启动并执行代码,而JIT编译器则在后台分析热点代码(即经常执行的代码段),并将这些代码编译成本地机器码进行缓存以提高执行效率。
- 垃圾回收器:动态管理内存,GC自动监测和回收不再使用的对象所占用的内存空间,从而减轻了程序员手动管理内存的责任。能够重新组织和整理内存空间,确保内存碎片化最小化,从而更高效地利用可用内存。
- 本地接口:调用本地库使用C/C++编译好的方法,本地方法在Java中声明时,都会带上native关键字,如下图所示。

# 字节码文件
# 以正确的姿势打开文件
字节码文件中保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读。
通过NotePad++使用十六进制插件查看class文件:
十六进制插件安装及使用方式:插件 → 安装

使用:选中文本 → 插件 → HEX-Editor → View in HEX
查看文件

无法解读出文件里包含的内容,推荐使用 jclasslib工具查看字节码文件。
Github地址: https://github.com/ingokegel/jclasslib (opens new window)

# 字节码文件的组成
字节码文件总共可以分为以下几个部分:
- 基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
- 常量池: 保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
- 字段: 当前类或接口声明的字段信息
- 方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
- 属性: 类的属性,比如源码的文件名、内部类的列表等
以该Class作为示例:
public class ClassDemo {
private static final int staticVar;
private int var = 2;
private String str;
static {
staticVar = 1;
}
public static void main(String[] args) {
System.out.println(staticVar);
ClassDemo classDemo = new ClassDemo();
classDemo.setStr("str");
InnerClass innerClass = new InnerClass();
System.out.println("方法执行完毕");
}
public void setStr(String str) {
this.str = str;
}
static class InnerClass {
private int innerVar = 0;
public void setInnerVar(int innerVar) {
this.innerVar = innerVar;
}
public int getInnerVar() {
return innerVar;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 基础信息
# Magic魔数
每个Java字节码文件的前四个字节是固定的,用16进制表示就是0xcafebabe。文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改不影响文件的内容。软件会使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错。
比如常见的文件格式校验方式如下:

java字节码文件中,将文件头称为magic魔数。Java虚拟机会校验字节码文件的前四个字节是不是0xcafebabe,如果不是,该字节码文件就无法正常使用,Java虚拟机会抛出对应的错误。
观察示例Class的字节码文件:

# 主副版本号
主副版本号指的是编译字节码文件时使用的JDK版本号,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
1.2之后大版本号计算方法就是 : 主版本号 – 44,比如主版本号52就是JDK8。

版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。如果使用较低版本的JDK去运行较高版本JDK的字节码文件,无法使用会显示如下错误:

有两种方案:
- 升级JDK版本,将图中使用的JDK6升级至JDK8即可正常运行,容易引发其他的兼容性问题,并且需要大量的测试。
- 将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求。建议使用这种方案
# 其他基础信息
其他基础信息包括访问标识、类和接口索引,如下:

# 常量池
字节码文件中常量池的作用:避免相同的内容重复定义,节省空间。如下图,常量池中定义了一个字符串,即示例Class14行的资产,字符串索引为21,字符串的字面量值为"str"。

常量池中字符串索引和字符串的字面值索引是不同的,注意区分。是先找到字符串索引,再找到其字面值
比如在代码中,编写了两个相同的字符串“我爱北京天安门”,字节码文件甚至将来在内存中使用时其实只需要保存一份,此时就可以将这个字符串以及字符串里边包含的字面量,放入常量池中以达到节省空间的作用。
String str1 = "我爱北京天安门";
String str2 = "我爱北京天安门";
2
常量池中的数据都有一个编号,编号从1开始。比如“我爱北京天安门”这个字符串,在常量池中的编号就是7。在字段或者字节码指令中通过编号7可以快速的找到这个字符串。
字节码指令中通过编号引用到常量池的过程称之为符号引用。

# 字段
字段中存放的是当前类或接口声明的字段信息。
如下图中,定义了两个字段a1和a2,这两个字段就会出现在字段这部分内容中。同时还包含字段的名字、描述符(字段的类型)、访问标识(public/private static final等)。

# 方法
字节码中的方法区域是存放字节码指令的核心位置,字节码指令的内容存放在方法的Code属性中。

通过分析方法的字节码指令,可以清楚地了解一个方法到底是如何执行的。先来看如下案例:
int i = 0;
int j = i + 1;
2
这段代码编译成字节码指令之后是如下内容:

要理解这段字节码指令是如何执行的,我们需要先理解两块内存区域:操作数栈和局部变量表。
操作数栈是用来存放临时数据的内容,是一个栈式的结构,先进后出。
局部变量表是存放方法中的局部变量,包含方法的参数、方法中定义的局部变量,在编译期就已经可以确定方法有多少个局部变量。
- iconst_0,将常量0放入操作数栈。此时栈上只有0。

- istore_1会从操作数栈中,将栈顶的元素弹出来,此时0会被弹出,放入局部变量表的1号位置。局部变量表中的1号位置,在编译时就已经确定是局部变量i使用的位置。完成了对局部变量i的赋值操作。

- iload_1将局部变量表1号位置的数据放入操作数栈中,此时栈中会放入0。

- iconst_1会将常量1放入操作数栈中。

- iadd会将操作数栈顶部的两个数据相加,现在操作数栈上有两个数0和1,相加之后结果为1放入操作数栈中,此时栈上只有一个数也就是相加的结果1。

- istore_2从操作数栈中将1弹出,并放入局部变量表的2号位置,2号位置是j在使用。完成了对局部变量j的赋值操作。

- return语句执行,方法结束并返回。

i++的字节码指令如下,其中iinc 1 by 1指令指的是将局部变量表1号位置增加1,其实就实现了i++的操作。

而++i只是对两个字节码指令的顺序进行了更改:

从字节码就能很清晰的看出i++和++i的区别
我们再来看看示例Class的main方法:

依行解释:
0 从java.lang.System类中获取静态字段out,该字段是一个java.io.PrintStream类型的对象引用
3 从com.ydhl.ClassDemo类中获取静态整型字段staticVar的值
6 调用PrintStream对象的println方法,传入从ClassDemo.staticVar获取的整型参数,并打印它
9 创建一个新的com.ydhl.ClassDemo类的实例
12 将新创建的ClassDemo实例的引用复制到操作数栈顶,以便后续的初始化
13 调用ClassDemo的构造函数<init>,初始化新创建的实例
16 将初始化后的ClassDemo实例存储到局部变量表的索引1中
17 从局部变量表的索引1加载ClassDemo实例的引用
18 将常量字符串str加载到操作数栈顶
20 调用ClassDemo实例的setStr方法,传入从ldc指令加载的字符串参数
23 创建一个新的com.ydhl.ClassDemo$InnerClass(ClassDemo的内部类)实例
26 复制新创建的InnerClass实例的引用到操作数栈顶
27 调用InnerClass的构造函数<init>,初始化新创建的实例
30 将初始化后的InnerClass实例存储到局部变量表的索引2中
31 再次获取System.out的引用
34 加载一个常量字符串“方法执行完毕”
36 调用PrintStream的println方法,打印从ldc指令加载的字符串
39 结束方法返回
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 属性
属性主要指的是类的属性,比如源码的文件名、内部类的列表等。

# 玩转字节码常用工具
# 2.2.3.1 javap
javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。
直接输入javap查看所有参数。输入javap -v 字节码文件名称 查看具体的字节码信息。如果jar包需要先使用 jar –xvf 命令解压。
# jclasslib插件
jclasslib也有Idea插件版本,建议开发时使用Idea插件版本,可以在代码编译之后实时看到字节码文件内容。
安装方式:
- 打开idea的插件页面,搜索jclasslib
- 选中要查看的源代码文件,选择 视图(View) - Show Bytecode With Jclasslib
tips:
1、一定要选择文件再点击视图(view)菜单,否则菜单项不会出现。
2、文件修改后一定要重新编译之后,再点击刷新按钮。
# Arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。
官网:https://arthas.aliyun.com/doc/ (opens new window)
Arthas的功能列表如下:

dump
命令详解:https://arthas.aliyun.com/doc/dump.html (opens new window)
dump命令可以将字节码文件保存到本地,如下将java.lang.String 的字节码文件保存到了/tmp/output目录下:
$ dump -d /tmp/output java.lang.String
HASHCODE CLASSLOADER LOCATION
null /tmp/output/java/lang/String.class
Affect(row-cnt:1) cost in 138 ms.
2
3
4
5
jad
命令详解:https://arthas.aliyun.com/doc/jad.html (opens new window)
jad命令可以将类的字节码文件进行反编译成源代码,用于确认服务器上的字节码文件是否是最新的,如下将demo.MathGame的源代码进行了显示。
$ jad --source-only demo.MathGame
/*
* Decompiled with CFR 0_132.
*/
package demo;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class MathGame {
private static Random random = new Random();
public int illegalArgumentCount = 0;
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:
- 加载
- 连接,其中又分为验证、准备、解析三个子阶段
- 初始化
- 使用
- 卸载

# 加载阶段
主要做的事:
- 加载该类的.class字节码文件读取进运行时数据区
- 在方法区的元空间中存储类信息
- 在堆中开辟空间存储当前类的Class对象,方便后续创建对象去使用
1、加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。
- 从本地磁盘上获取文件
- 运行时通过动态代理生成,比如Spring框架
- Applet技术通过网络获取字节码文件
2、类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中,方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。

3、Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。
生成这个对象的原因是为了安全考虑,堆区中对象存储的数据是比方法区少的,堆区只存放程序员可以用到的数据。

# 连接阶段
连接阶段分为三个子阶段:
- 验证,验证内容是否满足《Java虚拟机规范》。
- 准备,给静态变量赋初始值。
- 解析,将常量池中的符号引用替换成指向内存的直接引用。
# 验证
验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:
1、文件格式验证:比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。

2、元信息验证,例如类必须有父类(super不能为空)。
3、验证程序执行指令的语义:比如方法内的指令执行中跳转到不正确的位置。
4、符号引用验证:例如是否访问了其他类中private的方法等。
对版本号的验证,在JDK8的源码中如下:

编译文件的主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过。
# 准备
准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。
| 数据类型 | 初始值 |
|---|---|
| int | 0 |
| long | 0L |
| short | 0 |
| char | ‘\u0000’ |
| byte | 0 |
| boolean | false |
| double | 0.0 |
| 引用****数据类型 | null |
如下代码:
public class Student{
public static int value = 1;
}
2
3
4
5
在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
如下例子中,变量加上final进行修饰,在准备阶段value值就直接变成1了,因为final修饰的变量后续不会发生值的变更。
# 解析
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。

直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。

# 初始化阶段
初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。
如下代码编译成字节码文件之后,会生成三个方法:
public class Demo1 {
public static int value = 1;
static {
value = 2;
}
public static void main(String[] args) {
}
}
2
3
4
5
6
7
8
9
10
11

- init方法,会在对象初始化时执行
- main方法,主方法
- clinit方法,类的初始化阶段执行
继续来看clinit方法中的字节码指令:
1、iconst_1,将常量1放入操作数栈。此时栈中只有1这个数。

2、putstatic指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中#2指向了常量池中的静态变量value,在解析阶段会被替换成变量的地址。

3、后两步操作类似,执行value=2,将堆上的value赋值为2。
如果将代码的位置互换:
public class Demo1 {
static {
value = 2;
}
public static int value = 1;
public static void main(String[] args) {
}
}
2
3
4
5
6
7
8
9
10
11
字节码指令的位置也会发生变化:

这样初始化结束之后,最终value的值就变成了1而不是2。
以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
- 调用Class.forName(String className)。
- new一个该类的对象时。
- 执行main方法的当前类。
添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类
# 初始化阶段触发场景
来看一个经典笔试题:
public class Test1 {
public static void main(String[] args) {
System.out.println("A");
new Test1();
new Test1();
}
public Test1(){
System.out.println("B");
}
{
System.out.println("C");
}
static {
System.out.println("D");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
从字节码的角度分析执行顺序:

- 字节码包含三个方法,类加载阶段执行的类初始化方法<clinit>、对象初始化方法<init>、main方法
- 首先类加载阶段执行类初始化<clinit>

输出:D - 然后是main方法,先执行1-3行字节码,<font style="background-color:#FBDE28;">输出:A</font>

- 执行main方法4-7行字节码,即执行<init>方法,<font style="background-color:#FBDE28;">输出:CB</font>

tips
- 在类中没与任何的前缀或后缀,并使用"{}"括起来的代码片段称为构造代码块。
- 每次调用构造函数时,都会先执行构造代码块,然后执行相应构造函数的其他代码。
- 构造代码块可以提取构造函数的共同量,减少各个构造函数的重复代码。
- 执行main方法8-11行字节码,即执行<init>方法,<font style="background-color:#FBDE28;">输出:CB</font>
最终结果:DACBCB
笔试示例二:
public class Demo01 {
public static void main(String[] args) {
System.out.println(B02.a);
}
}
class A02{
static int a = 0;
static {
a = 1;
}
}
class B02 extends A02 {
static {
a = 2;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
分析步骤:
- 访问父类的静态变量,只初始化父类。

- 执行A02的初始化代码,将a赋值为1。
最终结果,输出:1
**疑惑?**为什么a=2没执行呢?加上类加载打印参数-XX:+TraceClassLoading康康?
[Loaded com.ydhl.Demo01 from file:/.../]
[Loaded sun.launcher.LauncherHelper$FXHelper from D:\Develop\jdk1.8\jre\lib\rt.jar]
[Loaded com.ydhl.A02 from file:/.../]
[Loaded com.ydhl.B02 from file:/.../]
1
[Loaded java.lang.Shutdown from D:\Develop\jdk1.8\jre\lib\rt.jar]
[Loaded java.lang.Shutdown$Lock from D:\Develop\jdk1.8\jre\lib\rt.jar]
2
3
4
5
6
7
这不是加载了吗?static代码块执行是在初始化阶段会执行的,也就是B02没有被初始化。
为了验证我们修改一下main方法,强制进行一次初始化:
public static void main(String[] args) {
new B02();
System.out.println(B02.a); // 2
}
2
3
4
输出结果是2,那么跟我们分析的一致,问题又回到为什么子类引用父类的静态字段不会触发子类的clinit方法呢?
这是因为类的初始化遵循一定的层次结构和顺序:
- 超类优先原则:当一个子类引用其超类的静态字段时,JVM首先确保超类已经被初始化。这意味着超类的clinit方法(如果有的话)会被执行,以确保静态字段的正确初始化。
- 静态字段的继承:子类可以继承超类的静态字段。当子类引用这些字段时,实际上是在引用超类中定义的字段,而不是子类中重新定义的同名字段(除非子类中确实重新定义了同名的静态字段)。
根据上述理论我们修改一下代码加以验证:
public class Demo01 {
public static void main(String[] args) {
System.out.println(B02.a); // 1
System.out.println(B02.b); // 1
System.out.println(B02.a); // 2
}
}
class A02{
static int a = 0;
static {
a = 1;
}
}
class B02 extends A02 {
static int b = 0;
static {
a = 2;
b = 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
分析步骤:
- 直接访问A02静态字段,即超类静态字段,初始化超类,不初始化子类
- 直接访问B02静态字段,触发子类初始化
- 再次访问A02静态字段,a已经B02初始化而改变
# <clinit>不会执行的几种情况
如下几种情况是不会进行初始化指令执行的:
- 无静态代码块且无静态变量赋值语句。
- 直接访问子类引用父类的静态字段时。
B extend A; System.out.println(B.A_CONSTANT); //不会触发 B 的初始化 - 有静态变量的声明,但是没有赋值语句,这类变量会在连接阶段中的准备阶段直接进行初始化默认值。
public static int a; - 静态变量的定义使用final关键字且为常量引用,这类变量会在连接阶段中的准备阶段直接进行初始化。
public static final int a = 10; - 数组的创建不会导致数组元素的初始化。
public class Test2 {
public static void main(String[] args) {
Test2_A[] arr = new Test2_A[10];
}
}
class Test2_A {
static {
System.out.println("Test2 A的静态代码块运行"); // 不会执行
}
}
2
3
4
5
6
7
8
9
10
11
# 类加载器 💡💡💡
# 什么是类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分。

类加载器会通过二进制流的方式获取到字节码文件的内容,接下来将获取到的数据交给Java虚拟机,虚拟机会在方法区和堆上生成对应的对象保存字节码信息。
# 类加载器应用场景
- SPI机制
- 类的热部署
- Tomact类的隔离
- 使用Arthas不停机修复线上故障
# 类加载器的分类
类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。
- 虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言与虚拟机底层语言一致,比如Hotspot使用C++。主要目的是保证Java程序运行中基础类被正确地加载,比如java.lang.String,Java虚拟机需要确保其可靠性。
- JDK中默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。
类加载器的设计JDK8和8之后的版本差别较大,首先来看JDK8及之前的版本,这些版本中默认的类加载器有如下几种:

类加载器的详细信息可以通过Arthas的classloader命令查看:
classloader- 查看 classloader 的继承树,urls,类加载信息,使用 classloader 去 getResource

- BootstrapClassLoader是启动类加载器,numberOfInstances是类加载器的数量只有1个,loadedCountTotal是加载类的数量1861个。
- ExtClassLoader是扩展类加载器
- AppClassLoader是应用程序类加载器
# 启动类加载器
- 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
- 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
运行如下代码:
/**
* 启动程序类加载器案例
*/
public class BootstrapClassLoaderDemo {
public static void main(String[] args) throws IOException {
ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader);// null
System.in.read();
}
}
2
3
4
5
6
7
8
9
10
11
这段代码通过String类获取到它的类加载器并且打印,结果是null。这是因为启动类加载器在JDK8中是由C++语言来编写的,在Java代码中去获取既不适合也不安全,所以才返回null
在Arthas中可以通过sc -d 类名的方式查看加载这个类的类加载器详细的信息,比如:

通过上图可以看到,java.lang.String类的类加载器是空的,Hash值也是null。
# 用户扩展基础jar包
如果用户想扩展一些比较基础的jar包,让启动类加载器加载,有两种途径:
- 放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。
- 使用参数进行扩展。推荐,使用
-Xbootclasspath/a:{jar包目录}/{jar包名}进行扩展,参数中的/a代表新增。
如下图,在IDEA配置中添加虚拟机参数,就可以加载D:/jvm/jar/classloader-test.jar这个jar包了。

# 扩展类加载器和应用程序类加载器
- 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
- 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
继承关系图如下:

- ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。
- SecureClassLoader提供了证书机制,提升了安全性。
- URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据。
- 扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。
# 扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。

如下代码会打印ScriptEnvironment类的类加载器。ScriptEnvironment是nashorn框架中用来运行javascript语言代码的环境类,他位于nashorn.jar包中被扩展类加载器加载。
/**
* 扩展类加载器
*/
public class ExtClassLoaderDemo {
public static void main(String[] args) throws IOException {
ClassLoader classLoader = ScriptEnvironment.class.getClassLoader();
System.out.println(classLoader);
}
}
2
3
4
5
6
7
8
9
打印结果如下:
sun.misc.Launcher$ExtClassLoader@77fbd92c
通过扩展类加载器去加载用户jar包:
- 放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容。
- 使用参数进行扩展使用参数进行扩展。推荐,使用
-Djava.ext.dirs={jar包}目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录
如下图中:

# 应用程序加载器
应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。
如下案例中,打印出Student和FileUtils的类加载器:
/**
* 应用程序类加载器案例
*/
public class AppClassLoaderDemo {
public static void main(String[] args) throws IOException, InterruptedException {
//当前项目中创建的Student类
Student student = new Student();
ClassLoader classLoader = Student.class.getClassLoader();
System.out.println(classLoader);
//maven依赖中包含的类
ClassLoader classLoader1 = FileUtils.class.getClassLoader();
System.out.println(classLoader1);
Thread.sleep(1000);
System.in.read();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
输出结果如下:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
2
这两个类均由应用程序类加载器加载。
类加载器的加载路径可以通过classloader –c hash值 查看:

# 双亲委派机制 💡
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载。

详细流程:
每个类加载器都有一个父类加载器。父类加载器的关系如下,启动类加载器没有父类加载器:

在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。
类的双亲委派机制是什么?
- 当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。
- 应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
- 双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。
案例1:
比如com.itheima.my.A假设在启动类加载器的加载目录中,而应用程序类加载器接到了加载类的任务。
1、应用程序类加载器首先判断自己加载过没有,没有加载过就交给父类加载器 - 扩展类加载器。

2、扩展类加载器也没加载过,交给他的父类加载器 - 启动类加载器。

3、启动类加载器发现已经加载过,直接返回。

案例2:
B类在扩展类加载器加载路径中,同样应用程序类加载器接到了加载任务,按照案例1中的方式一层一层向上查找,发现都没有加载过。那么启动类加载器会首先尝试加载。它发现这类不在它的加载目录中,向下传递给扩展类加载器。

扩展类加载器发现这个类在它加载路径中,加载成功并返回。

如果第二次再接收到加载任务,同样地向上查找。扩展类加载器发现已经加载过,就可以返回了。

# 双亲委派机制的作用
- 保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
- 避免重复加载。双亲委派机制可以避免同一个类被多次加载。
# 如何指定加载类的类加载器?
在Java中如何使用代码的方式去主动加载一个类呢?
方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。
方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载。
例如:
public class Test1 {
public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
ClassLoader classLoader = Test1.class.getClassLoader();
System.out.println(classLoader);
// 指定使用application加载器加载
Class<?> aClass = classLoader.loadClass("com.ydhl.Test1");
System.out.println(aClass);
}
}
2
3
4
5
6
7
8
9
10
# 打破双亲委派机制
打破双亲委派机制历史上有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:
- 自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离。
- 线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
- Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
# 自定义类加载器
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。

Tomcat使用了自定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类。

那么自定义加载器是如何能做到的呢?首先我们需要先了解,双亲委派机制的代码到底在哪里,接下来只需要把这段代码消除即可。
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。
// 类加载的入口,提供了双亲委派机制。内部会调用findClass 重要
public Class<?> loadClass(String name)
// 由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。重要
protected Class<?> findClass(String name)
// 做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
// 执行类生命周期中的连接阶段
protected final void resolveClass(Class<?> c)
2
3
4
5
6
7
8
9
10
11
自定义类加载器可以重写findClass方法来自定义获取字节码信息的途径。
如果想通过自定义类加载器来打破双亲委派机制则需要重写loadClass方法,不再实现双亲委派机制。

1、入口方法:

2、再进入看下:

如果查找都失败,进入加载阶段,首先会由启动类加载器加载,这段代码在findBootstrapClassOrNull中。如果失败会抛出异常,接下来执行下面这段代码:

父类加载器加载失败就会抛出异常,回到子类加载器的这段代码,这样就实现了加载并向下传递。
3、最后根据传入的参数判断是否进入连接阶段:

接下来实现打破双亲委派机制:
import org.apache.commons.io.IOUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
import java.util.regex.Matcher;
/**
* 打破双亲委派机制 - 自定义类加载器
*/
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
//设置加载目录
public void setBasePath(String basePath) {
this.basePath = basePath;
}
//使用commons io 从指定目录下加载文件
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
return null;
}
}
//重写loadClass方法
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
//如果是java包下,还是走双亲委派机制
if(name.startsWith("java.")){
// 这是自定义类加载器的父类ClassLoader,原本有实现双亲委派的机制
return super.loadClass(name);
}
//从磁盘中指定目录下加载
byte[] data = loadClassData(name);
//调用虚拟机底层方法,方法区和堆区创建对象
return defineClass(name, data, 0, data.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
//第一个自定义类加载器对象
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
//第二个自定义类加载器对象
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2);
Thread.currentThread().setContextClassLoader(classLoader1);
System.out.println(Thread.currentThread().getContextClassLoader());
System.in.read();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 自定义类加载器父类怎么是AppClassLoader呢?
默认情况下自定义类加载器的父类加载器是应用程序类加载器:

以Jdk8为例,ClassLoader类中提供了构造方法设置parent的内容:

这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,该方法返回的是AppClassLoader。

# 两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。
在Arthas中使用sc –d 类名的方式查看具体的情况。
如下代码:
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
//第一个自定义类加载器对象
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
//第二个自定义类加载器对象
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
打印的应该是false,因为两个类加载器不同,尽管加载的是同一个类名,最终Class对象也不是相同的。
通过Arthas看:

也会出现两个不同的A类。
# 正确实现自定义类加载器
正确的去实现一个自定义类加载器的方式应该是重写findClass方法,这样不会破坏双亲委派机制。

# 线程上下文类加载器
利用上下文类加载器加载类,比如JDBC和JNDI等。
我们来看下JDBC的案例:
1、JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
import com.mysql.cj.jdbc.Driver;
import java.sql.*;
/**
* 打破双亲委派机制 - JDBC案例
*/
public class JDBCExample {
// JDBC driver name and database URL
static final String JDBC_DRIVER = "com.mysql.cj.jdbc.Driver";
static final String DB_URL = "jdbc:mysql:///bank1";
// Database credentials
static final String USER = "root";
static final String PASS = "123456";
public static void main(String[] args) {
Connection conn = null;
Statement stmt = null;
try {
conn = DriverManager.getConnection(DB_URL, USER, PASS);
stmt = conn.createStatement();
String sql;
sql = "SELECT id, account_name FROM account_info";
ResultSet rs = stmt.executeQuery(sql);
//STEP 4: Extract data from result set
while (rs.next()) {
//Retrieve by column name
int id = rs.getInt("id");
String name = rs.getString("account_name");
//Display values
System.out.print("ID: " + id);
System.out.print(", Name: " + name + "\n");
}
//STEP 5: Clean-up environment
rs.close();
stmt.close();
conn.close();
} catch (SQLException se) {
//Handle errors for JDBC
se.printStackTrace();
} catch (Exception e) {
//Handle errors for Class.forName
e.printStackTrace();
} finally {
//finally block used to close resources
try {
if (stmt != null)
stmt.close();
} catch (SQLException se2) {
}// nothing we can do
try {
if (conn != null)
conn.close();
} catch (SQLException se) {
se.printStackTrace();
}//end finally try
}//end try
}//end main
}//end FirstExample
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2、DriverManager类位于rt.jar包中,由启动类加载器加载。

3、依赖中的mysql驱动对应的类,由应用程序类加载器来加载。

在类中有初始化代码:

DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。(这点存疑,一会儿再讨论)
那么问题来了,DriverManager怎么知道jar包中要加载的驱动在哪儿?
1、在类的初始化代码中有这么一个方法LoadInitialDrivers:

2、这里使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类。

3、SPI机制就是在这个位置下存放了一个文件,文件名是接口名,文件里包含了实现类的类名。这样SPI机制就可以找到实现类了。


4、SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。

总结:

# JDBC案例中真的打破了双亲委派机制吗?
最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。
但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。
所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。
# JDK9之后的类加载器
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。

可以看到扩展类加载器和应用程序类加载器都继承了URLClassLoader,也就是说JDK8 本质上是按照jar包的位置去加载字节码文件的。由于JDK9引入了module的概念,类加载器在设计上发生了很多变化,由从jar包中加载,转变为了从jmod文件加载。
- 启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。 - 扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
# 使用阿里arthas不停机解决线上问题 💡
背景:
小李的团队将代码上线之后,发现存在一个小bug,但是用户急着使用,如果重新打包再发布需要一个多小时的时间,所以希望能使用arthas尽快的将这个问题修复。
思路:
- 在出问题的服务器上部署一个 arthas,并启动。
- jad --source-only 类全限定名 > 目录/文件名.java jad
命令反编译,然后可以用其它编译器,比如 vim 来修改源码 - sc -d 类全限定名
查看加载这个类的类加载器详细的信息,获取类加载器hashcode - mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
mc 命令用来编译修改过的代码 - retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
详细流程:
1、这段代码编写有误,在枚举中的类型判断上使用了== 而不是equals。

2、枚举中是这样定义的,1001是普通用户,1002是VIP用户:

3、由于代码有误,导致传递1001参数时,返回的是收费用户的内容。

4、jad --source-only 类全限定名 > 目录/文件名.java 使用 jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码

这里直接双击文件使用finalShell编辑:

5、mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录 使用mc 命令用来编译修改过的代码

这里缺失hashcode,需要通过sc -d指令查询
6、retransform class文件所在目录/xxx.class 用 retransform 命令加载新的字节码

7、测试:

注意事项:
1、程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新。
2、使用retransform不能添加方法或者字段,也不能更新正在执行中的方法。
# 运行时数据区 💡💡💡
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用。

# 独占 - 程序计数器
程序计数器**(Program Counter Register)也叫PC**寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。

一个程序计数器的具体案例:
在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址。

在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。这里为了简单起见,使用偏移量代替,真实内存中执行时保存的应该是地址。
比如当前执行的是偏移量为0的指令,那么程序计数器中保存的就是下一条的地址(偏移量1)。

一路向下执行

一直执行到方法的最后一行指令,此时方法执行return语句,当前方法执行结束,程序计数器中会放入方法出口的地址(栈中讲解,简单来说就是这个B方法结束了,A调用了B,那么要回到A方法)

所以,程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。不管是分支、跳转、异常,只需要在程序计数器中放入下一行要执行的指令地址即可。
在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。

程序计数器会出现内存溢出吗?
内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。由于每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的。程序员无需对程序计数器做任何处理。
# 独占 - Java虚拟机栈 💡
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
public class MethodDemo {
public static void main(String[] args) {
study();
}
public static void study(){
eat();
sleep();
}
public static void eat(){
System.out.println("吃饭");
}
public static void sleep(){
System.out.println("睡觉");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
main方法执行时,会创建main方法的栈帧:

接下来执行study方法,会创建study方法的栈帧

进入eat方法,创建eat方法的栈帧

eat方法执行完之后,会弹出它的栈帧:

然后调用sleep方法,创建sleep方法栈帧

最后study方法结束之后弹出栈帧,main方法结束之后弹出main的栈帧。
在IDEA中也可以看到对应的栈帧:
package chapter03.frame;
/**
* 栈帧测试1
*/
public class FrameDemo {
public static void main(String[] args) {
A();
}
public static void A() {
System.out.println("A执行了...");
B();
}
public static void B() {
System.out.println("B执行了...");
C();
}
public static void C() {
System.out.println("C执行了...");
throw new RuntimeException("测试");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
打上断点debug之后会出现栈帧内容:

Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈,main线程和线程A。

Java虚拟机栈的栈帧中主要包含三方面的内容:
- 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
# 局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。
我们先来看下字节码文件中的局部变量表:编译成字节码文件时就可以确定局部变量表的内容。
public static void test1(){
int i = 0;
long j = 1;
}
2
3
4
test1方法的局部变量表如下:

可以在jclasslib 方法标签下查看到
局部变量表中保存了字节码指令生效的偏移量:

比如i这个变量,它的起始PC是2,代表从lconst_1这句指令开始才能使用i,长度为3,也就是2-4这三句指令都可以使用i。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j = 1代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用。
JVM通过局部变量表来控制局部变量的能被访问的范围,如果在超过局部变量的生效范围的字节码指令中访问了该局部变量,则会拒绝。
接下来看下栈帧中的局部变量表,栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。

i占用数组下标为0的位置,j占用数组下标1-2的位置。
刚才看到的是静态方法,实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。

方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。

test3方法中包含两个参数k,m,这两个参数也会被加入到局部变量表中。
以下代码的局部变量表中会占用几个槽?
public void test4(int k,int m){
{
int a = 1;
int b = 2;
}
{
int c = 1;
}
int i = 0;
long j = 1;
}
2
3
4
5
6
7
8
9
10
11
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
1、方法执行时,实例对象this、k、m 会被放入局部变量表中,占用3个槽

2、将1的值放入局部变量表下标为3的位置上,相当于给a进行赋值。

3、将2放入局部变量表下标为4的位置,给b赋值为2。

4、ab已经脱离了生效范围,所以下标为3和4的这两个位置可以复用。此时c的值1就可以放入下标为3的位置。

4、脱离c的生效范围之后,给i赋值就可以复用c的位置。

5、最后放入j,j是一个long类型,占用两个槽。但是可以复用b所在的位置,所以占用4和5这两个位置

所以,局部变量表数值的长度为6。这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可。

# 操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。

比如之前的相加案例中,操作数栈最大的深入会出现在这个时刻:

所以操作数栈的深度会定义为2。
# 帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
# 动态链接
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。

# 方法出口
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址,即上一个栈帧中当前方法的下一条指令地址。

# 异常表
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

如下案例:i=1这行源代码编译成字节码指令之后,会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作,4的没有异常就跳转到10方法结束。如果出现异常的情况下,继续执行到7这行指令,7会将异常对象放入操作数栈中,这样在catch代码块中就可以使用异常对象了。接下来执行8-9,对i进行赋值为2的操作。

所以异常表中,异常捕获的起始偏移量就是2,结束偏移量是4,在2-4执行过程中抛出了java.lang.Exception对象或者子类对象,就会将其捕获,然后跳转到偏移量为7的指令。
# 栈内存溢出
Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。

如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。

我们来模拟下栈内存的溢出情况:
public static int count = 0;
//递归方法调用自己
public static void recursion(){
System.out.println(++count);
recursion();
}
2
3
4
5
6
使用递归让方法调用自身,但是不设置退出条件。定义调用次数的变量,每一次调用让变量加1。查看错误发生时总调用的次数。

执行之后可以打印出溢出时总栈帧的数量,并且发现虚拟机已经抛出了StackOverflow的错误。
要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。
- 语法:-Xss栈大小
- 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
例如:
-Xss1048576
-Xss1024K
-Xss1m
-Xss1g
2
3
4
操作步骤如下,不同IDEA版本的设置方式会略有不同:
1、点击修改配置Modify options
2、点击Add VM options
3、添加参数

调成512k之后,明显发现最大栈帧数量减少了:

注意事项:
1、与-Xss类似,也可以使用 -XX:ThreadStackSize 调整标志来配置堆栈大小。
格式为: -XX:ThreadStackSize=1024
2、HotSpot JVM对栈大小的最大值和最小值有要求:
比如测试如下两个参数,会直接报错:
-Xss1k
-Xss1025m
Windows(64位)下的JDK8测试最小值为180k,最大值为1024m。
3、局部变量过多、操作数栈深度过大也会影响栈内存的大小。我们在这段代码中添加一些局部变量。
//递归方法调用自己
public static void recursion() {
long a,b,c,d,f,g,h,i,j,k;
System.out.println(++count);
recursion();
}
2
3
4
5
6
使用默认大小来测试之后,发现栈帧数量从10000+减少了到8000+

一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存。
# 独占 - 本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在 Java 中,本地方法栈帧(Native Method Stack Frame)是当线程调用本地方法(即非 Java 语言实现的方法,通常是 C 或 C++编写的)时,用于存储该本地方法相关信息的栈帧。
当 Java 线程执行到一个本地方法时,会创建一个对应的本地方法栈帧,并将其压入本地方法栈中。本地方法栈帧中包含了本地方法的参数、局部变量、返回值等信息。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

比如测试下这段代码:
/**
* 本地方法栈
*/
public class NativeDemo1 {
public static void main(String[] args) {
try {
FileOutputStream fileOutputStream = new FileOutputStream("E:\\123.txt");
fileOutputStream.write(1);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
执行之后发生异常,会打印出所有栈帧的名字:

open0是一个本地方法,所以创建了本地方法的栈帧。本地方法和Java虚拟机方法的栈帧在一个栈上。
# 共享 - 堆内存 💡
一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
public class Test {
public static void main(String[] args) {
Student s1 = new Student();
s1.name = "张三";
s1.age = 18;
s1.id = 1;
s1.printTotalScore();
s1.printAverageScore();
Student s2 = new Student();
s2.name = "李四";
s2.age = 19;
s2.id= 2;
s2.printTotalScore();
s2.printAverageScore();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这段代码中通过new关键字创建了两个Student类的对象,这两个对象会被存放在堆上。在栈上通过s1和s2两个局部变量保存堆上两个对象的地址,从而实现了引用关系的建立。

# 堆内存的溢出
通过new关键字不停创建对象,放入集合中,模拟堆内存的溢出,观察堆溢出之后的异常信息。
package chapter03.heap;
import java.io.IOException;
import java.util.ArrayList;
/**
* 堆内存的使用和回收
*/
public class Demo1 {
public static void main(String[] args) throws InterruptedException, IOException {
ArrayList<Object> objects = new ArrayList<Object>();
System.in.read();
while (true){
objects.add(new byte[1024 * 1024 * 100]);
Thread.sleep(1000);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出OutOfMemory错误。在这段代码中,不停创建100M大小的字节数组并放入ArrayList集合中,最终超过了堆内存的上限。抛出如下错误:

# 三个重要的值
堆空间有三个需要关注的值,used、total、max。used指的是当前已使用的堆内存,total是java虚拟机已经分配的可用堆内存,max是java虚拟机可以分配的最大堆内存。

堆内存used total max三个值可以通过arthas的dashboard命令看到。
手动指定刷新频率(不指定默认5秒一次):dashboard –i 刷新频率(毫秒)

随着堆中的对象增多,当total可以使用的内存即将不足时,java虚拟机会继续分配内存给堆。

此时used达到了total的大小,Java虚拟机会向操作系统申请更大的内存。

但是这个申请过程不是无限的,total最多只能与max相等。

那么是不是当used = max = total的时候,堆内存就溢出了呢?
不是,堆内存溢出的判断条件比较复杂,在垃圾回收器章节中会详细介绍。
如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。 Oracle官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html (opens new window)
# 设置堆的大小
要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)。
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于 2 MB,Xms必须大于1MB
-Xms6291456
-Xms6144k
-Xms6m
-Xmx83886080
-Xmx81920k
-Xmx80m
2
3
4
5
6
这样可以将max和初始的total都设置为4g,在启动后就已经获得了最大的堆内存大小。运行过程中不需要向操作系统申请。

使用arthas的memory命令同样可以看到:

为什么arthas中显示的heap堆大小与设置的值不一样呢?
arthas中的heap堆内存使用了JMX技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。
建议:
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。-Xmx具体设置的值与实际的应用程序运行环境有关,后续章节会给出设置方案。
# 共享 - 方法区 💡
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息,保存了所有类的基本信息,静态变量
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量(JDK7 之前)
# 类的元信息
方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,这些内容更多是存放了引用,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。

静态变量存放位置
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。
# 运行时常量池
方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。

# 字符串常量池
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。
字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。
如下代码执行时,代码中包含abc字符串,就会被直接放入字符串常量池。在堆上创建String对象,并通过局部变量s1引用堆上的对象。

接下来通过s2局部变量引用字符串常量池的abc。

所以s1和s2指向的不是同一个对象,打印出false。
# 字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分:
- JDK7 之前,运行时常量池包含字符串常量池,位于方法区的永久代中
- JDK7,字符串常量池从方法区中割离,置于堆中
- JDK8后,字符串常量池仍然位于堆中,而方法区由永久代(JVM内存)变为了元空间(直接内存)

# StringTable的案例1
/**
* 字符串常量池案例
*/
public class Demo2 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = a + b;
System.out.println(c == d);
}
}
2
3
4
5
6
7
8
9
10
11
12
1、首先将1放入字符串常量池,通过局部变量a引用字符串常量池中的1字符串。

2、同理处理b和c:

3、将a和b指向的字符串进行连接,本质上就是使用StringBuilder进行连接,最后**创建了一个新的字符串**放入堆中。然后将局部变量d指向堆上的对象。

4、所以c和d指向的不是同一个对象,打印出的结果就是false。
# StringTable的案例2
package chapter03.stringtable;
/**
* 字符串常量池案例
*/
public class Demo3 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = "1" + "2";
System.out.println(c == d);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
编译之后的字节码指令如下:

说明在编译阶段,已经将1和2进行连接,最终生成12的字符串常量池中的结果。所以返回结果就是true,c和d都指向字符串常量池中的对象。
总结一下:

# 神奇的intern
String.intern()方法是可以手动将字符串放入字符串常量池中。
分别在JDK6 JDK8下执行代码,JDK6 中结果是false false ,JDK8中是true false
package chapter03.stringtable;
/**
* intern案例
*/
public class Demo4 {
public static void main(String[] args) {
String s1 = new StringBuilder().append("think").append("123").toString();
System.out.println(s1.intern() == s1);
String s2 = new StringBuilder().append("ja").append("va").toString();
System.out.println(s2.intern() == s2);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
先来分析JDK6中,代码执行步骤如下:
1、使用StringBuilder的将think和123拼接成think123,转换成字符串,在堆上创建一个字符串对象。局部变量s1指向堆上的对象。这里注意字符串常量池中本来就有一个java字符串对象,这是java虚拟机自身使用的所以启动时就会创建出来。

2、调用s1.intern方法,会在字符串常量池中创建think123的对象,最后将对象引用返回。所以s1.intern和s1指向的不是同一个对象。打印出false。

3、同理,通过StringBuilder在堆上创建java字符串对象。

4、调用s2.intern发现字符串常量池中已经有java字符串对象了,就将引用返回。所以s2.intern指向的是字符串常量池中的对象,而s2指向的是堆中的对象。打印结果为false。

接下来分析JDK7中,JDK7及之后版本中由于字符串常量池在堆上,所以intern () 方法会把第一次遇到的字符串的**引用**放入字符串常量池。
代码执行步骤如下:
1、执行第二句代码时,由于字符串常量池中没有think123的字符串,所以直接创建一个引用,指向堆中的think123对象。所以s1.intern和s1指向的都是堆上的对象,打印结果为true。

2、s2.intern方法调用时,字符串常量池中已经有java字符串了,所以将引用返回。这样打印出来的结果就是false。

后续JDK版本中,如果Java虚拟机不需要使用java字符串,那么字符串常量池中就不会存放java。打印结果有可能会出现两个true。
# 方法区的实现
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的**直接内存**中,这片内存的是独立于虚拟机内存之外的,默认情况下只要不超过操作系统承受的上限,可以一直分配。

可以通过arthas的memory命令看到方法区的名称以及大小:
- JDK7及之前的版本查看ps_perm_gen属性。

- JDK8及之后的版本查看metaspace属性,max是 -1。

# 方法区的溢出
通过ByteBuddy框架,动态创建类并将字节码数据加载到内存中。通过死循环不停地加载到方法区,观察方法区是否会出现内存溢出的情况。分别在JDK7和JDK8上运行上述代码。
ByteBuddy是一个基于Java的开源库,用于生成和操作Java字节码。
1.引入依赖
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.12.23</version>
</dependency>
2
3
4
5
2.创建ClassWriter对象
ClassWriter classWriter = new ClassWriter(0);
3.调用visit方法,创建字节码数据。
classWriter.visit(Opcodes.V1_7,Opcodes.ACC_PUBLIC,name,null ,"java/lang/Object",null);
byte[] bytes = classWriter.toByteArray();
2
代码:
package chapter03.methodarea;
import net.bytebuddy.jar.asm.ClassWriter;
import net.bytebuddy.jar.asm.Opcodes;
import java.io.IOException;
/**
* 方法区的溢出测试
*/
public class Demo1 extends ClassLoader {
public static void main(String[] args) throws IOException {
System.in.read();
Demo1 demo1 = new Demo1();
int count = 0;
while (true) {
String name = "Class" + count;
ClassWriter classWriter = new ClassWriter(0);
classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null
, "java/lang/Object", null);
byte[] bytes = classWriter.toByteArray();
demo1.defineClass(name, bytes, 0, bytes.length);
System.out.println(++count);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
实验发现,JDK7上运行大概十几万次,就出现了错误。

在JDK8上运行百万次,程序都没有出现任何错误,但是内存会直线升高。这说明JDK7和JDK8在方法区的存放上,采用了不同的设计。
- JDK7将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数
-XX:MaxPermSize=值来控制。 - JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用
-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。
在JDK8中将最大元空间内存设置为256m,再次测试

这次就出现了MetaSpace溢出的错误:

建议:
由于JDK8 开始的元空间使用的是操作系统的直接内存,若不经设置则有可能到达操作系统内存最大值,从而占用其他进程内存,影响其他进程,所以一般建议设置元空间最大内存,一般设置-XX:MaxMetaspaceSize=256m。
# 直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
- Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
使用堆(JVM内存) 创建对象的过程:

使用直接内存创建对象的过程,不需要进行复制对象,减少了一次拷贝,数据直接存放在直接内存中:

# 使用方法
要创建直接内存上的数据,可以使用ByteBuffer。
语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
注意事项: arthas的memory命令可以查看直接内存大小,属性名direct。

代码:
package chapter03.direct;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 直接内存的使用和回收
*/
public class Demo1 {
public static int size = 1024 * 1024 * 100; //100mb
public static List<ByteBuffer> list = new ArrayList<ByteBuffer>();
public static int count = 0;
public static void main(String[] args) throws IOException, InterruptedException {
System.in.read();
while (true) {
//1.创建DirectByteBuffer对象并返回
//2.在DirectByteBuffer构造方法中,向操作系统申请直接内存空间
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
//directBuffer = null;
list.add(directBuffer);
System.out.println(++count);
Thread.sleep(5000);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
运行之后,用arthas监控发现,每隔5秒直接内存会增长100M:

如果将Thread.``_sleep_``(5000);注释掉,让直接内存快速大量分配。操作系统内存不足时就会报错:

但是工作中服务器上有可能部署了其他应用,为了避免将内存耗尽,需要设置直接内存的最大值。如果需要手动调整直接内存的大小,可以使用XX:MaxDirectMemorySize=大小
单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。默认不设置该参数情况下,JVM 自动选择 最大分配的大小。
以下示例以不同的单位说明如何将 直接内存大小设置为 1024 KB:
-XX:MaxDirectMemorySize=1m
-XX:MaxDirectMemorySize=1024k
-XX:MaxDirectMemorySize=1048576
2
3
在Idea中设置直接内存最大值为1g:

直接循环11次之后,超过最大值就会报错:

建议:
如果项目的逻辑代码中没有使用到直接内存,是无需设置这个值的,但如果使用的框架或者底层使用到了nio的技术,则最好设置一下这个值,具体应该设置多少合适,得具体看使用场景,压测取所需容量大一些。
#
# GC 垃圾回收💡💡💡
在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
在这段代码中,通过死循环不停创建Test类的对象,每一轮循环结束之后,这次创建的对象就不再使用了。但是没有手动调用删除对象的方法,此时对象就会出现内存泄漏。

这段代码中,手动调用delete删除对象,就不会出现内存泄漏。

我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象。
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。
垃圾回收器如果发现某个对象不再使用,就可以回收该对象。


自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象
- 优点:降低程序员实现难度、降低对象回收bug的可能性
- 缺点:程序员无法控制内存回收的及时性
手动垃圾回收,由程序员编程实现对象的删除
- 优点:回收及时性高,由程序员把控回收的时机
- 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题
那么垃圾回收器需要负责对哪些部分的内存进行回收呢?
首先是线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以这一部分不需要垃圾回收器负责回收。

# 方法区的回收
方法区中能回收的内容主要就是不再使用的类。判定一个类可以被卸载。需要同时满足下面三个条件:
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用。
1、此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
这段代码中就将局部变量对堆上实例对象的引用去除了,所以对象就可以被回收。

2、加载该类的类加载器已经被回收。
这段代码让局部变量对类加载器的引用去除,类加载器就可以回收。

3、该类对应的 java.lang.Class 对象没有在任何地方被引用。

代码:
package chapter04.gc;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
/**
* 类的卸载
*/
public class ClassUnload {
public static void main(String[] args) throws InterruptedException {
try {
ArrayList<Class<?>> classes = new ArrayList<>();
ArrayList<URLClassLoader> loaders = new ArrayList<>();
ArrayList<Object> objs = new ArrayList<>();
while (true) {
URLClassLoader loader = new URLClassLoader(
new URL[]{new URL("file:D:\\lib\\")});
Class<?> clazz = loader.loadClass("com.itheima.my.A");
Object o = clazz.newInstance();
// objs.add(o);
// classes.add(clazz);
// loaders.add(loader);
System.gc();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
添加这两个虚拟机参数进行测试:
-XX:+TraceClassLoading -XX:+TraceClassUnloading

如果注释掉代码中三句add调用,就可以同时满足3个条件。但是需要手动调用System.gc()方法,让垃圾回收器进行回收。
如果需要手动触发垃圾回收,可以调用System.gc()方法。
语法: System.gc()
注意事项:
调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
执行之后,日志中就会打印出类卸载的内容:

那么类卸载主要用在什么场景下呢?
开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。
每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
# 如何判断对象可以回收 💡
垃圾回收器要回收对象的第一步就是判断哪些对象可以回收。Java中的对象是否能被回收,是根据对象是否被**引用**来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
比如下面代码的内存结构图:
第一行代码执行之后,堆上创建了Demo类的实例对象,同时栈上保存局部变量引用堆上的对象。

第二行代码执行之后,局部变量对堆上的对象引用去掉,那么堆上的对象就可以被回收了。

一个更复杂的案例:

这个案例中,如果要让对象a和b回收,必须将局部变量到堆上的引用去除。

假设我们在main方法的最后加入a1 = null, b1 = null;,是否就能回收A B对象了呢?
也就是说,A和B互相之间的引用需要去除吗?答案是不需要,因为局部变量都没引用这两个对象了,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象的回收。
判断对象是否可以回收,主要有两种方式:引用计数法和可达性分析法。
# 引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
比如下图中,对象A的计数器初始为0,局部变量a1对它引用之后,计数器加1就变成了1。同样A对B产生了引用,B的计数器也是1。

引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:
- 每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响。
- 存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收,导致内存泄漏。
这张图上,由于A和B之间存在互相引用,所以计数器都为1,两个对象都不能被回收。但是由于没有局部变量对这两个代码产生引用,代码中已经无法访问到这两个对象,理应可以被回收。

我们来做一个实验,验证下Java中循环引用不会导致内存泄漏,因为Java虚拟机根本没有使用引用计数法。首先我们要学会去看一个对象有没有被回收,可以通过垃圾回收日志来查看。
如果想要查看垃圾回收的信息,可以使用-verbose:gc参数。
语法: -verbose:gc

加上这个参数之后执行代码,发现对象确实被回收了:

通过不同的死循环创建对象,内存并没有上升,一直维持在1000K,说明每轮循环创建的两个对象在垃圾回收之后都被回收了。
# 可达性分析法
Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个对象到GC Root对象是可达的,对象就不可被回收。

哪些对象被称之为GC Root对象呢?
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量。

- 监视器对象,用来保存同步锁synchronized关键字持有的对象。

- 本地方法调用时使用的全局对象。
通过arthas和eclipse Memory Analyzer (MAT) 工具可以查看GC Root,MAT工具是eclipse推出的Java堆内存检测工具。具体操作步骤如下:
1、使用arthas的heapdump命令将堆内存快照保存到本地磁盘中。
2、使用MAT工具打开堆内存快照文件。
3、选择GC Roots功能查看所有的GC Root。

步骤详解:
1、代码如下:
package com.itheima.jvm.chapter04;
import java.io.IOException;
public class ReferenceCounting {
public static A a2 = null;
public static void main(String[] args) throws IOException {
// while (true){
A a1 = new A();
B b1 = new B();
a1.b = b1;
b1.a = a1;
a2 = a1;
System.in.read();
// a1 = null;
// b1 = null;
// }
}
}
class A {
B b;
// byte[] t = new byte[1024 * 1024 * 10];
}
class B {
A a;
// byte[] t = new byte[1024 * 1024 * 10];
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2、使用arthas连接到程序,输入如下命令:
heapdump 目录/test2.hprof

这样就生成了一个堆内存快照(后面介绍,简单来说就是包含了所有堆中的对象信息)。
3、打开MAT工具,如果出现如下错误,请将环境变量中的JDK版本升级到17以上。

4、选择菜单中的打开堆内存快照功能,并选择刚才生成的文件。

5、选择内存泄漏检测报告,并确定。

6、通过菜单找到GC Roots。

7、MAT对4类GC Root对象做了分类。

8、找到静态变量。

9、找到局部变量

# 常见的引用对象 💡
可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:
- 软引用
- 弱引用
- 虚引用
- 终结器引用
# 软引用 💡
软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。
如下图中,对象A被GC Root对象强引用了,同时我们创建了一个软引用SoftReference对象(它本身也是一个对象),软引用对象中引用了对象A。

接下来强引用被去掉之后,对象A暂时还是处于不可回收状态,因为有软引用存在并且内存还够用。

如果内存出现不够用的情况,对象A就处于可回收状态,可以被垃圾回收器回收。

这样做有什么好处?如果对象A是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问。但是如果内存不够用了,我们就可以将这部分缓存清理掉释放内存。即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性。
特别注意:
软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉。

# 软引用的使用方法
软引用的执行过程如下:
- 将对象使用软引用包装起来,
new SoftReference<对象类型>(对象)。 - 内存不足时,虚拟机尝试进行垃圾回收。
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
- 如果依然内存不足,抛出OutOfMemory异常。
代码:
/**
* 软引用案例2 - 基本使用
*/
public class SoftReferenceDemo2 {
public static void main(String[] args) throws IOException {
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
bytes = null;
System.out.println(softReference.get());
byte[] bytes2 = new byte[1024 * 1024 * 100];
System.out.println(softReference.get());
//
// byte[] bytes3 = new byte[1024 * 1024 * 100];
// softReference = null;
// System.gc();
//
// System.in.read();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
添加虚拟机参数,限制最大堆内存大小为200m:

执行后发现,第二个100m对象创建之后需,软引用中包含的对象已经被回收了。

# 软引用对象本身怎么回收呢?
如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也可以被回收了。
SoftReference提供了一套队列机制:
1、软引用创建时,通过构造器传入引用队列

2、在软引用中包含的对象被回收时,该软引用对象会被放入引用队列

3、通过代码遍历引用队列,将SoftReference的强引用删除
代码
/**
* 软引用案例3 - 引用队列使用
*/
public class SoftReferenceDemo3 {
public static void main(String[] args) throws IOException {
ArrayList<SoftReference> softReferences = new ArrayList<>();
// 软引用队列,存放已经被回收的软引用对象
ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);
softReferences.add(studentRef);
}
SoftReference<byte[]> ref = null;
int count = 0;
// 遍历队列
while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
count++;
}
System.out.println(count);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
设置堆内存最大200M-Xmm200m,最终展示的结果是:

这9个软引用对象中包含的数据已经被回收掉,所以可以手动从ArrayList中去掉,这样就可以释放这9个对象。
# 软引用的缓存案例
使用软引用实现学生信息的缓存,能支持内存不足时清理缓存。

代码:
package chapter04.soft;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.HashMap;
import java.util.Map;
/**
* 软引用案例4 - 学生信息的缓存
*/
public class StudentCache {
private static StudentCache cache = new StudentCache();
public static void main(String[] args) {
for (int i = 0; ; i++) {
StudentCache.getInstance().cacheStudent(new Student(i, String.valueOf(i)));
}
}
private Map<Integer, StudentRef> StudentRefs;// 用于Cache内容的存储
private ReferenceQueue<Student> q;// 垃圾Reference的队列
// 继承SoftReference,使得每一个实例都具有可识别的标识。
// 并且该标识与其在HashMap内的key相同。
private class StudentRef extends SoftReference<Student> {
private Integer _key = null;
public StudentRef(Student em, ReferenceQueue<Student> q) {
super(em, q);
_key = em.getId();
}
}
// 构建一个缓存器实例
private StudentCache() {
StudentRefs = new HashMap<Integer, StudentRef>();
q = new ReferenceQueue<Student>();
}
// 取得缓存器实例
public static StudentCache getInstance() {
return cache;
}
// 以软引用的方式对一个Student对象的实例进行引用并保存该引用
private void cacheStudent(Student em) {
cleanCache();// 清除垃圾引用
StudentRef ref = new StudentRef(em, q);
StudentRefs.put(em.getId(), ref);
System.out.println(StudentRefs.size());
}
// 依据所指定的ID号,重新获取相应Student对象的实例
public Student getStudent(Integer id) {
Student em = null;
// 缓存中是否有该Student实例的软引用,如果有,从软引用中取得。
if (StudentRefs.containsKey(id)) {
StudentRef ref = StudentRefs.get(id);
em = ref.get();
}
// 如果没有软引用,或者从软引用中得到的实例是null,重新构建一个实例,
// 并保存对这个新建实例的软引用
if (em == null) {
em = new Student(id, String.valueOf(id));
System.out.println("Retrieve From StudentInfoCenter. ID=" + id);
this.cacheStudent(em);
}
return em;
}
// 清除那些所软引用的Student对象已经被回收的StudentRef对象
private void cleanCache() {
StudentRef ref = null;
while ((ref = (StudentRef) q.poll()) != null) {
StudentRefs.remove(ref._key);
}
}
// // 清除Cache内的全部内容
// public void clearCache() {
// cleanCache();
// StudentRefs.clear();
// //System.gc();
// //System.runFinalization();
// }
}
class Student {
int id;
String name;
public Student(int id, String name) {
this.id = id;
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# 弱引用
弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。
弱引用对象本身也可以使用引用队列进行回收。
package chapter04.weak;
import java.io.IOException;
import java.lang.ref.WeakReference;
/**
* 弱引用案例 - 基本使用
*/
public class WeakReferenceDemo2 {
public static void main(String[] args) throws IOException {
byte[] bytes = new byte[1024 * 1024 * 100];
WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
bytes = null;
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
执行之后发现gc执行之后,对象已经被回收了。

# 虚引用和终结器引用
这两种引用在常规开发中是不会使用的。
- 虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现,如DirectByteBuffer。
- 终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。
package chapter04.finalreference;
/**
* 终结器引用案例
*/
public class FinalizeReferenceDemo {
public static FinalizeReferenceDemo reference = null;
public void alive() {
System.out.println("当前对象还存活");
}
@Override
protected void finalize() throws Throwable {
try{
System.out.println("finalize()执行了...");
//设置强引用自救
reference = this;
}finally {
super.finalize();
}
}
public static void main(String[] args) throws Throwable {
reference = new FinalizeReferenceDemo();
test();
test();
}
private static void test() throws InterruptedException {
reference = null;
//回收对象
System.gc();
//执行finalize方法的优先级比较低,休眠500ms等待一下
Thread.sleep(500);
if (reference != null) {
reference.alive();
} else {
System.out.println("对象已被回收");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 垃圾回收算法 💡
Java是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:
- 找到内存中存活的对象
- 释放不再存活对象的内存,使得程序能再次利用这部分空间
# 垃圾回收算法的历史和分类
1960年John McCarthy发布了第一个GC算法:标记-清除算法。
1963年Marvin L. Minsky 发布了复制算法。
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。

# 垃圾回收算法的评价标准 💡
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。
如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的。

所以判断GC算法是否优秀,可以从三个方面来考虑:
1.吞吐量
吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。

2.最大暂停时间
最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中,黄色部分的STW就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。

3.堆使用效率
不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。

没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景。
# 标记清除算法 💡
标记清除算法的核心思想分为两个阶段:
- 标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段:从内存中删除没有被标记也就是非存活对象。
第一个阶段,从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来:

第二个阶段,将没有标记的对象清理掉,所以对象D就被清理掉了。

优缺点
优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
缺点:
- 碎片化问题
- 分配速度慢
碎片化问题
由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
如下图,红色部分已经被清理掉了,总共回收了9个字节,但是每个都是一个小碎片,无法为5个字节的对象分配空间。

分配速度慢
由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间。

# 复制算法 💡
复制算法的核心思想是:
- 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
对象A首先分配在From空间:

- 在垃圾回收GC阶段,将From中存活对象复制到To空间。
在垃圾回收阶段,如果对象A存活,就将其复制到To空间。然后将From空间直接清空。

- 将两块空间的From和To名字互换。
接下来将两块空间的名称互换,下次依然在From空间上创建对象。

完整的复制算法的例子:
- 将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。

- GC阶段开始,将GC Root搬运到To空间

- 将GC Root关联的对象,搬运到To空间

- 清理From空间,并把名称互换

优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
- 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
**缺点:**内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
# 标记整理算法 💡
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
- 标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段:将存活对象移动到堆的一端。清理掉存活对象的内存空间。

优点:
- 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
**缺点:**整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
# 分代垃圾回收算法 💡
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代:

我们通过arthas来验证下内存划分的情况:
在JDK8中,添加-XX:+UseSerialGC参数使用分代回收的垃圾回收器,运行程序。
在arthas中使用memory命令查看内存,显示出三个区域的内存情况。

- Eden + survivor 这两块区域组成了年轻代。
- tenured_gen指的是晋升区域,其实就是老年代。
另外还可以选择的虚拟机参数如下,注意需要开启支持分代回收的GC回收器
| 参数名 | 参数含义 | 示例 |
|---|---|---|
| -Xms | 设置堆的最小和初始大小,必须是1024倍数且大于1MB | 比如初始大小6MB的写法: -Xms6291456 -Xms6144k ****-Xms6m |
| -Xmx | 设置最大堆的大小,必须是1024倍数且大于2MB | 比如最大堆80 MB的写法: -Xmx83886080 -Xmx81920k ****-Xmx80m |
| -Xmn | 新生代的大小 如果 -Xms 和 -Xmx 的值相同,那么 -Xmn 的默认值大约是 -Xms 的1/3。 如果 -Xms 和 -Xmx 的值不同,那么 -Xmn 的默认值大约是 -Xmx 的1/4。 | 新生代256 MB的写法: -Xmn256m -Xmn262144k ****-Xmn268435456 |
| -XX:SurvivorRatio | 伊甸园区和幸存区单区的比例,默认为8:1 ****新生代1g内存,伊甸园区800MB,S0和S1各100MB | 比例调整为4的写法: -XX:SurvivorRatio=4 |
| -XX:+PrintGCDetails 或verbose:gc | 打印GC日志 | 无 |
代码:
package chapter04.gc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 垃圾回收器案例1
*/
//-XX:+UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3 -XX:+PrintGCDetails
// Eden:12M; S1:4M; S0:4M; old:40M
public class GcDemo0 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
int count = 0;
while (true){
System.in.read();
System.out.println(++count);
//每次添加1m的数据
list.add(new byte[1024 * 1024 * 1]);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
使用arthas的memory展示出来的效果:

heap展示的是可用堆,survivor区每次只有一块能使用,所以60 - 4 = 56m。
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。

2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。

3、接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。

此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入S0,也就是存活的对象会在S0和S1之间来回移动。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。

4、如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。


当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。

下图中的程序为什么会出现OutOfMemory?

从上图可以看到,Full GC无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
继续使用这段代码测试:
//-XX:+UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3 -XX:+PrintGCDetails
public class GcDemo0 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
int count = 0;
while (true){
System.in.read();
System.out.println(++count);
//每次添加1m的数据
list.add(new byte[1024 * 1024 * 1]);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
结果如下:

老年代已经满了,而且垃圾回收无法回收掉对象,如果还想往里面放就发生了OutOfMemoryError。
# 垃圾回收器 💡
为什么分代GC算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代GC算法将堆分成年轻代和老年代主要原因有:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
垃圾回收器是垃圾回收算法的具体实现。由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。具体的关系图如下:

常用组合:
- minor(Serial)+ old(Serial Old)
- minor(PreNew)+ old(CMS)
- minor(Parallel Scavenge)+ old(Parallel Old)
- G1
- Shenandoah
- ZGC
- minor(Serial)+ old(CMS)【JDK9 废弃】
- minor(PreNew)+ old(Serial Old)【JDK9 废弃】
- minor(Parallel Scavenge)+ old(Serial Old)【JDK14 废弃】
- old(CMS)【JDK14废弃】
# 年轻代-Serial垃圾回收器 💡
**Serial**是是一种单线程串行回收年轻代的垃圾回收器。

**回收年代和算法:【**年轻代】复制算法
**优点:**单CPU处理器下吞吐量非常出色
**缺点:**多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
**适用场景:**Java编写的客户端程序或者硬件配置有限的场景
参数:-XX:+UseSerialGC
# 老年代-SerialOld垃圾回收器 💡
**SerialOld**是Serial垃圾回收器的老年代版本,采用单线程串行回收
-XX:+UseSerialGC 新生代、老年代都使用串行回收器。

**回收年代和算法:【**老年代】标记-整理算法
**优点:**单CPU处理器下吞吐量非常出色
**缺点:**多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
**适用场景:**与Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用
参数:-XX:+UseSerialGC
# 年轻代-ParNew垃圾回收器
**ParNew**垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收
新生代使用ParNew回收器, 老年代使用串行回收器

回收年代和算法:【年轻代】复制算法
**优点:**多CPU处理器下停顿时间较短
**缺点:**吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
**适用场景:**JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
参数:-XX:+UseParNewGC
# 老年代- CMS(Concurrent Mark Sweep)垃圾回收器
**CMS**垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
回收年代和算法:【老年代】标记清除算法
**优点:**系统由于垃圾回收出现的停顿时间较短,用户体验好
缺点
- 内存碎片问题
- 退化问题
- 浮动垃圾问题
**适用场景:**大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等
参数:XX:+UseConcMarkSweepGC
CMS执行步骤:

- 初始标记,用极短的时间标记出GC Roots能直接关联到的对象,STW。
- 并发标记, 标记所有的对象,用户线程不需要暂停。
- 重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记,STW。
- 并发清理,清理死亡的对象,用户线程不需要暂停。
缺点:
- CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用
-XX:CMSFullGCsBeforeCompaction=N参数(默认0)调整N次Full GC之后再整理。 - 无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。
- 如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
并发线程数:
在CMS中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置,默认值为0,由系统计算得出。
计算公式为(-XX:ParallelGCThreads定义的线程数 + 3) / 4, ParallelGCThreads是STW停顿之后的并行线程数
ParallelGCThreads是由处理器核数决定的:
- 当cpu核数小于8时,ParallelGCThreads = CPU核数
- 否则 ParallelGCThreads = 8 + (CPU核数 – 8 )*5/8
我的电脑上逻辑处理器有12个,所以:
ParallelGCThreads = 8 + (12 - 8)* 5/8 = 10;
ConcGCThreads = (-XX:ParallelGCThreads定义的线程数 + 3) / 4 = (10 + 3) / 4 = 3;

最终可以得到这张图:

并发标记和并发清理阶段,会使用3个线程并行处理。重新标记阶段会使用10个线程处理。由于CPU的核心数有限,并发阶段会影响用户线程执行的性能。

# 年轻代-Parallel Scavenge垃圾回收器
**Parallel Scavenge**是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。

回收年代和算法:【年轻代】复制算法
**优点:**吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
**缺点:**不能保证单次的停顿时间
**适用场景:**不需要与用户交互的后台任务,并且容易产生大量的对象的场景。比如:大数据的处理,大文件导出
参数:-XX:+UseParallelGC
常用参数:
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在内存宽裕的条件下使用这个组合时(PS + PO),不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
- 最大暂停时间,
-XX:MaxGCPauseMillis=n设置每次垃圾回收时的最大停顿毫秒数 - 吞吐量,
-XX:GCTimeRatio=n设置吞吐量为n(用户线程执行时间 = n/n + 1) - 自动调整内存大小,
-XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小,默认开启
# 老年代-Parallel Old垃圾回收器
**Parallel Old**是为Parallel Scavenge收集器设计的老年代版本,是JDK8默认的老年代垃圾回收器,利用多线程并发收集。

回收年代和算法:【老年代】标记-整理算法
**优点:**并发收集,在多核CPU下效率较高
**缺点:**暂停时间会比较长
**适用场景:**与Parallel Scavenge配套使用
参数:-XX:+UseParallelGC 或 -XX:+UseParallelOldGC
# G1垃圾回收器 💡💡💡
JDK9之后默认的垃圾回收器是**G1(Garbage First)**垃圾回收器。Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合,兼顾吞吐量和暂停时间:
- 支持巨大的堆空间回收(6G以上),并有较高的吞吐量。
- 支持多CPU并行垃圾回收。
- 允许用户设置最大暂停时间。
JDK9之后强烈建议使用G1垃圾回收器。
回收年代和算法:【年轻代+老年代】复制算法
优点:
- 对比较大的堆如超过6G的堆回收时,延迟可控
- 不会产生内存碎片
- 并发标记的SATB算法效率高
**缺点:**JDK8之前还不够成熟
**适用场景:**JDK8最新版本、JDK9之后建议默认使用
参数1: -XX:+UseG1GC 打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值 最大暂停的时
G1出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图:

G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。

G1垃圾回收有两种方式:
- 年轻代回收(Young GC)
- 混合回收(Mixed GC)
# 年轻代回收
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数
-XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
执行流程:
1、新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC。

2、标记出Eden和Survivor区域中的存活对象,
3、根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。


G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region。
4、后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。


5、当某个存活对象的年龄到达阈值(默认15),将被放入老年代。

6、部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。

7、多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeapOccupancyPercent默认45%)会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。


# 混合回收
混合回收阶段分为:
- 初始标记(initial mark)STW
- 并发标记(concurrent mark)
- 最终标记(remark或者Finalize Marking)STW
- 并发清理(cleanup)
G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。

G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。最后清理阶段使用复制算法,不会产生内存碎片。

**注意:**如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。

使用以下代码测试g1垃圾回收器,打印出每个阶段的时间:
package chapter04.gc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 垃圾回收器案例3
*/
//-XX:+UseG1GC -Xmn8g -Xmx16g -XX:SurvivorRatio=8 -XX:+PrintGCDetails -verbose:gc
public class GcDemo2 {
public static void main(String[] args) throws IOException {
int count = 0;
List<Object> list = new ArrayList<>();
while (true){
//System.out.println(++count);
if(count++ % 10240 == 0){
list.clear();
}
// byte[] bytes = new byte[1024 * 1024 * 1];
list.add(new byte[1024 * 1024 * 1 / 2]);
// System.gc();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
每个region大小为2m,一共有84个young区,26个幸存者区。

初始标记花了0.0478秒。

并发标记总共耗时10ms,不会产生STW。

# 新世代GC 💡
不同的垃圾回收器设计的目标是不同的,如下图所示:

# Shenandoah GC
Shenandoah 是由Red Hat开发的一款低延迟的垃圾收集器,Shenandoah 并发执行大部分 GC 工作,包括并发的整理,堆大小对STW的时间基本没有影响。
1、下载。Shenandoah只包含在OpenJDK中,默认不包含在内需要单独构建,可以直接下载构建好的。
下载地址:https://builds.shipilev.net/openjdk-jdk-shenandoah/ (opens new window)
选择方式如下:
- {aarch64, arm32-hflt, mipsel, mips64el, ppc64le, s390x, x86_32, x86_64}:架构,使用arch命令选择对应的的架构。
- {server,zero}:虚拟机类型,选择server,包含所有GC的功能。
- {release, fastdebug, Slowdebug, optimization}:不同的优化级别,选择release,性能最高。
- {gcc*-glibc*, msvc*}:编译器的版本,选择较高的版本性能好一些,如果兼容性有问题(无法启动),选择较低的版本。

2、配置。将OpenJDK配置到环境变量中,使用java –version进行测试。打印出如下内容代表成功。

3、添加参数,运行Java程序。
-XX:+UseShenandoahGC 开启Shenandoah GC
-Xlog:gc 打印GC日志

/*
* Copyright (c) 2005, 2014, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.sample;
import com.sun.management.OperatingSystemMXBean;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
//执行5轮预热,每次持续2秒
@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
//输出毫秒单位
@OutputTimeUnit(TimeUnit.MILLISECONDS)
//统计方法执行的平均耗时
@BenchmarkMode(Mode.AverageTime)
//java -jar benchmarks.jar -rf json
@State(Scope.Benchmark)
public class MyBenchmark {
//每次测试对象大小 4KB和4MB
@Param({"4","4096"})
int perSize;
private void test(Blackhole blackhole){
//每次循环创建堆内存60%对象 JMX获取到Java运行中的实时数据
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
//获取堆内存大小
MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage();
//获取到剩余的堆内存大小
long heapSize = (long) ((heapMemoryUsage.getMax() - heapMemoryUsage.getUsed()) * 0.6);
//计算循环次数(堆大小/单次循环消耗的内存)
long size = heapSize / (1024 * perSize);
for (int i = 0; i < 4; i++) {
List<byte[]> objects = new ArrayList<>((int)size);
for (int j = 0; j < size; j++) {
objects.add(new byte[1024 * perSize]);
}
blackhole.consume(objects);
}
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g","-XX:+UseSerialGC"})
public void serialGC(Blackhole blackhole){
test(blackhole);
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g","-XX:+UseParallelGC"})
public void parallelGC(Blackhole blackhole){
test(blackhole);
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g"})
public void g1(Blackhole blackhole){
test(blackhole);
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g","-XX:+UseShenandoahGC"})
public void shenandoahGC(Blackhole blackhole){
test(blackhole);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
测试结果:

Shenandoah GC对小对象的GC停顿很短,但是大对象效果不佳。
# ZGC 💡💡💡
ZGC 是一种可扩展的低延迟垃圾回收器。ZGC 在垃圾回收过程中,STW的时间不会超过一毫秒,适合需要低延迟的应用。支持8MB到16TB 的堆大小,堆大小对STW的时间基本没有影响。
ZGC降低了停顿时间,能降低接口的最大耗时,提升用户体验。但是吞吐量不佳,所以如果Java服务比较关注QPS(每秒的查询次数)那么G1是比较不错的选择。

# ZGC版本更迭

# ZGC的使用
OracleJDK和OpenJDK中都支持ZGC,阿里的DragonWell龙井JDK也支持ZGC但属于其自行对OpenJDK 11的ZGC进行优化的版本。建议使用JDK17之后的版本,延迟较低同时无需手动配置并行线程数。
分代 ZGC添加如下参数启用: -XX:+UseZGC -XX:+ZGenerational
非分代 ZGC通过命令行选项启用: -XX:+UseZGC

# ZGC的环境搭建
ZGC在设计上做到了自适应,根据运行情况自动调整参数,让用户手动配置的参数最少化。
- 自动设置年轻代大小,无需设置
-Xmn参数。 - 自动晋升阈值(复制中存活多少次才搬运到老年代),无需设置
-XX:TenuringThreshold。 - JDK17之后支持自动的并行线程数,无需设置
-XX:ConcGCThreads。
必须设置的参数:
-Xmx值 最大堆内存大小
这是ZGC最重要的一个参数,必须设置。ZGC在运行过程中会使用一部分内存用来处理垃圾回收,所以尽量保证堆中有足够的空间。设置多少值取决于对象分配的速度,根据测试情况来决定。
可以设置的参数:
-XX:SoftMaxHeapSize=值
ZGC会尽量保证堆内存小于该值,这样在内存靠近这个值时会尽早地进行垃圾回收,但是依然有可能会超过该值。例如,-Xmx5g -XX:SoftMaxHeapSize=4g 这个参数设置,ZGC会尽量保证堆内存小于4GB,最多不会超过5GB。
在Shenandoah GC的测试用例中添加以下两项进行测试:
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g","-XX:+UseZGC","-XX:+UseLargePages"})
public void zGC(Blackhole blackhole){
test(blackhole);
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = {"-Xms4g","-Xmx4g","-XX:+UseZGC","-XX:+ZGenerational","-XX:+UseLargePages"})
public void zGCGenerational(Blackhole blackhole){
test(blackhole);
}
2
3
4
5
6
7
8
9
10
11

ZGC整体表现还是非常不错的,分代也让ZGC的停顿时间有更好的表现。
# ZGC调优
ZGC 中可以使用Linux的Huge Page大页技术优化性能,提升吞吐量、降低延迟。
注意:安装过程需要 root 权限,所以ZGC默认没有开启此功能。
操作步骤:
- 计算所需页数,Linux x86架构中大页大小为2MB,根据所需堆内存的大小估算大页数量。比如堆空间需要16G,预留2G(JVM需要额外的一些非堆空间),那么页数就是18G / 2MB = 9216。
- 配置系统的大页池以具有所需的页数(需要root权限):
$ echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages- 添加参数
-XX:+UseLargePages启动程序进行测试
如果不需要大页则再次执行第3步的指令,将页大小置为0,使大页失效掉。
$ echo 0 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 实战案例
需求:
Java服务中存在大量软引用的缓存导致内存不足,测试下g1、Shenandoah、ZGC这三种垃圾回收器在这种场景下的回收情况。
步骤:
测试代码:
package com.itheima.jvmoptimize.fullgcdemo;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.SneakyThrows;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
@RestController
@RequestMapping("/fullgc")
public class Demo2Controller {
private Cache cache = Caffeine.newBuilder().weakKeys().softValues().build();
private List<Object> objs = new ArrayList<>();
private static final int _1MB = 1024 * 1024;
//FULLGC测试
//-Xms8g -Xmx8g -Xss256k -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:/test.hprof -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
//ps + po 50并发 260ms 100并发 474 200并发 930
//cms -XX:+UseParNewGC -XX:+UseConcMarkSweepGC 50并发 157ms 200并发 833
//g1 JDK11 并发200 248
@GetMapping("/1")
public void test() throws InterruptedException {
cache.put(RandomStringUtils.randomAlphabetic(8),new byte[10 * _1MB]);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
1、启动程序,添加不同的虚拟机参数进行测试。

2、使用Apache Benchmark测试工具对本机进行压测。

3、生成GC日志,使用GcEasy进行分析。
4、对比压测之后的结果。
两种垃圾回收器在并行回收时都会使用垃圾回收线程占用CPU资源
- 在内存足够的情况下,ZGC垃圾回收表现的效果会更好,停顿时间更短,但在内存是特别充足的条件下不如Shenandoah GC,ZGC在并行回收执行耗时较长,会与用户请求抢占CPU。
- 在内存不是特别充足的情况下, Shenandoah GC表现更好,并行垃圾回收的时间较短,用户请求的执行效率比较高。
# 垃圾回收器总结
垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
JDK8及之前:
- 关注暂停时间:ParNew + CMS
- 关注吞吐量:Parallel Scavenge + Parallel Old
- 兼顾吞吐量和暂停时间:G1(JDK8最新版之前不建议)
JDK9之后:G1(默认)
# 扩展内容
# JVM家族史
家族史内容摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
# 虚拟机始祖:Sun Classic/Exact VM
1996年1月23日,Sun发布JDK 1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的虚拟机就是Sun Classic VM 。这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。在JDK 1.2及之前,用户用Classic虚拟机执行java-version命令,将会看到类似下面这行的输出:
java version "1.2.2"
Classic VM (build JDK-1.2.2-001, green threads, sunwjit)
2
其中的“sunwjit”(Sun Workshop JIT)就是Sun提供的外挂编译器,其他类似的外挂编译器还有 Symantec IT和shuJIT等。由于解释器和编译器不能配合工作,这就意味着如果要使用编译执行,编译器就不得不对每一个方法、每一行代码都进行编译,而无论它们执行的频率是否具有编译的价值。基于程序响应时间的压力,这些编译器根本不敢应用编译耗时稍高的优化技术,因此这个阶段的虚拟机虽然用了即时编译器输出本地代码,其执行效率也和传统的C/C++程序有很大差距,“Java语言很慢”的印象就是在这阶段开始在用户心中树立起来的。
Sun的虚拟机团队努力去解决Classic虚拟机所面临的各种问题,提升运行效率,在JDK 1.2时,曾 在Solaris平台上发布过一款名为Exact VM的虚拟机,它的编译执行系统已经具备现代高性能虚拟机雏 形,如热点探测、两级即时编译器、编译器与解释器混合工作模式等。
Exact VM因它使用准确式内存管理(Exact Memory Management,也可以叫Non-Con- servative/Accurate Memory Management)而得名。准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。
虽然Exact VM的技术相对Classic VM来说先进了许多,但是它的命运显得十分英雄气短,在商业 应用上只存在了很短暂的时间就被外部引进的HotSpot VM所取代,甚至还没有来得及发布Windows和 Linux平台下的商用版本。
# 武林盟主:HotSpot VM
相信所有Java程序员都听说过HotSpot虚拟机,它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。但不一定所有人都知道的是,这个在今天看起来“血统纯正”的虚拟机在最初并非由Sun公司所开发,而是由一家名为“Longview Technologies”的小公司设计;甚至这个虚拟机最初并非是为Java语言而研发的,它来源于Strongtalk虚拟机,而这款虚拟机中相当多的 技术又是来源于一款为支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的Self虚拟机, 最终甚至可以追溯到20世纪80年代中期开发的Berkeley Smalltalk上。Sun公司注意到这款虚拟机在即时 编译等多个方面有着优秀的理念和实际成果,在1997年收购了Longview Technologies公司,从而获得了HotSpot虚拟机。
HotSpot既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,如它名称中的HotSpot指的就是它的**热点代码探测技术**(这里的描写带有“历史由胜 利者书写”的味道,其实HotSpot与Exact虚拟机基本上是同时期的独立产品,HotSpot出现得还稍早一些,一开始HotSpot就是基于准确式内存管理的,而Exact VM之中也有与HotSpot几乎一样的热点探测技术,为了Exact VM和HotSpot VM哪个该成为Sun主要支持的虚拟机,在Sun公司内部还争吵过一场, HotSpot击败Exact并不能算技术上的胜利),HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器_以方法为单位_进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译(On-Stack Replacement,OSR)行为。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。
# 天下第二:BEA JRockit/IBM J9 VM
前面三节介绍的都是由Sun/Oracle公司研发的Java虚拟机,历史上除了Sun/Oracle公司以外,也有其 他组织、公司开发过虚拟机的实现。如果说HotSpot是天下第一的武林盟主,那曾经与HotSpot并称“三 大商业Java虚拟机”的另外两位,毫无疑问就该是天下第二了,它们分别是BEA System公司的JRockit与IBM公司的IBM J9。
JRockit虚拟机曾经号称是“世界上速度最快的Java虚拟机”(广告词,IBM J9虚拟机也这样宣传过,总体上三大虚拟机的性能是交替上升的),它是BEA在2002年从Appeal Virtual Machines公司收购 获得的Java虚拟机。BEA将其发展为一款专门为服务器硬件和服务端应用场景高度优化的虚拟机,由 于专注于服务端应用,它可以不太关注于程序启动速度,因此JRockit内部不包含解释器实现,全部代 码都靠即时编译器编译后执行。除此之外,JRockit的垃圾收集器和Java Mission Control故障处理套件 等部分的实现,在当时众多的Java虚拟机中也处于领先水平。JRockit随着BEA被Oracle收购,现已不再继续发展,永远停留在R28版本,这是JDK 6版JRockit的代号。
IBM J9虚拟机并不是IBM公司唯一的Java虚拟机,不过目前IBM主力发展无疑就是J9。J9这个名字最初只是内部开发代号而已,开始选定的正式名称是“IBM Technology for Java Virtual Machine”,简称IT4J,但这个名字太拗口,接受程度远不如J9。J9虚拟机最初是由IBM Ottawa实验室的一个 SmallTalk虚拟机项目扩展而来,当时这个虚拟机有一个Bug是因为8KB常量值定义错误引起,工程师们 花了很长时间终于发现并解决了这个错误,此后这个版本的虚拟机就被称为K8,后来由其扩展而来、支持Java语言的虚拟机就被命名为J9。与BEA JRockit只专注于服务端应用不同,IBM J9虚拟机的市场 定位与HotSpot比较接近,它是一款在设计上全面考虑服务端、桌面应用,再到嵌入式的多用途虚 拟机,开发J9的目的是作为IBM公司各种Java产品的执行平台,在和IBM产品(如IBM WebSphere等) 搭配以及在IBM AIX和z/OS这些平台上部署Java应用。
IBM J9直至今天仍旧非常活跃,IBM J9虚拟机的职责分离与模块化做得比HotSpot更优秀,由J9 虚拟机中抽象封装出来的核心组件库(包括垃圾收集器、即时编译器、诊断监控子系统等)就单独构成了IBM OMR项目,可以在其他语言平台如Ruby、Python中快速组装成相应的功能。从2016年起, IBM逐步将OMR项目和J9虚拟机进行开源,完全开源后便将它们捐献给了Eclipse基金会管理,并重新 命名为Eclipse OMR和OpenJ9[2]。如果为了学习虚拟机技术而去阅读源码,更加模块化的OpenJ9代码 其实是比HotSpot更好的选择。如果为了使用Java虚拟机时多一种选择,那可以通过AdoptOpenJDK来获得采用OpenJ9搭配上OpenJDK其他类库组成的完整JDK。
# 小家碧玉:Mobile/Embedded VM
Sun/Oracle公司所研发的虚拟机可不仅包含前面介绍到的服务器、桌面领域的商用虚拟机,面对移 动和嵌入式市场,也有专门的Java虚拟机产品。
由于Java ME产品线的发展相对Java SE来说并不那么成功,所以Java ME中的Java虚拟机相比HotSpot要低调得多。Oracle公司在Java ME这条产品线上的虚拟机名为CDC-HI(C Virtual Machine, CVM)和CLDC-HI(Monty VM)。其中CDC/CLDC全称是Connected(Limited)Device Configuration,这是一组在JSR-139及JSR-218规范中进行定义的Java API子集,这组规范希望能够在手机、电子书、PDA等移动设备上建立统一的Java编程接口,CDC-HI VM和CLDC-HI VM就是JSR-139 及JSR-218规范的参考实现,后面的HI则是HotSpot Implementation的缩写,但它们并不是由HotSpot直接裁剪而来,只是借鉴过其中一些技术,并没有血缘关系,充其量能叫有所渊源。
Java ME中的Java虚拟机现在处于比较尴尬的位置,所面临的局面远不如服务器和桌面领域乐观, 它最大的一块市场——智能手机已被Android和iOS二分天下[1],现在CDC在智能手机上略微有点声音的产品是Oracle ADF Mobile,原本它提出的卖点是智能手机上的跨平台(“Developing with Java on iOS and Android”),不过用Java在Android上开发应用还要再安装个CDC虚拟机,这事情听着就觉得别扭,有多此一举的嫌疑,在iOS上倒确实还有一些人在用。
而在嵌入式设备上,Java ME Embedded又面临着自家Java SE Embedded(eJDK)的直接竞争和侵蚀,主打高端的CDC-HI经过多年来的扩充,在核心部分其实已经跟Java SE非常接近,能用Java SE的 地方大家自然就不愿意用Java ME,所以市场在快速萎缩,Oracle也基本上砍掉了CDC-HI的所有项目,把它们都划归到了Java SE Embedded下。Java SE Embedded里带的Java虚拟机当然还是HotSpot,但这是为了适应嵌入式环境专门定制裁剪的版本,尽可能在支持完整的Java SE功能的前提下向着减少内 存消耗的方向优化,譬如只留下了客户端编译器(C1),去掉了服务端编译器(C2);只保留Serial/Serial Old垃圾收集器,去掉了其他收集器等。
面向更低端设备的CLDC-HI倒是在智能控制器、传感器等领域还算能维持自己的一片市场,现在也还在继续发展,但前途并不乐观。目前CLDC中活得最好的产品反而是原本早该被CLDC-HI淘汰的KVM,国内的老人手机和出口到经济欠发达国家的功能手机(Feature Phone)还在广泛使用这种更加 简单、资源消耗也更小的上一代Java ME虚拟机。
# 软硬合璧:BEA Liquid VM/Azul VM
我们平时所提及的“高性能Java虚拟机”一般是指HotSpot、JRockit、J9这类在通用硬件平台上运行 的商用虚拟机,但其实还有一类与特定硬件平台绑定、软硬件配合工作的专有虚拟机,往往能够实现更高的执行性能,或提供某些特殊的功能特性。这类专有虚拟机的代表是BEA Liquid VM和Azul VM。
Liquid VM也被称为JRockit VE(Virtual Edition,VE),它是BEA公司开发的可以直接运行在自家
Hypervisor系统上的JRockit虚拟机的虚拟化版本,Liquid VM不需要操作系统的支持,或者说它自己本 身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等。由虚拟机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时,不需要再进行内核态/用户态的切换,这样可以最大限度地发挥硬件的能力,提升Java程序的执行性能。随着JRockit虚拟机终止开发,Liquid VM 项目也已经停止了。
Azul VM是Azul Systems公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机,每个Azul VM实例都可以管理至少数十个CPU和数百GB的内存的硬件资 源,并提供在巨大内存范围内停顿时间可控的垃圾收集器(即业内赫赫有名的PGC和C4收集器),为专有硬件优化的线程调度等优秀特性。2010年起,Azul公司的重心逐渐开始从硬件转向软件,发布了 自己的Zing虚拟机,可以在通用x86平台上提供接近于Vega系统的性能和一致的功能特性。
# 挑战者:Apache Harmony/Google Android Dalvik VM
这节介绍的Harmony虚拟机(准确地说是Harmony里的DRLVM)和Dalvik虚拟机只能称作“虚拟 机”,而不能称作“Java虚拟机”,但是这两款虚拟机以及背后所代表的技术体系曾经对Java世界产生了 非常大的影响和挑战,当时甚至有悲观的人认为成熟的Java生态系统都有分裂和崩溃的可能。
Apache Harmony是一个Apache软件基金会旗下以Apache License协议开源的实际兼容于JDK 5和JDK 6的Java程序运行平台,它含有自己的虚拟机和Java类库API,用户可以在上面运行Eclipse、 Tomcat、Maven等常用的Java程序。但是,它并没有通过TCK认证,所以我们不得不用一长串冗长拗口的语言来介绍它,而不能用一句“Apache的JDK”或者“Apache的Java虚拟机”来直接代指。
如果一个公司要宣称自己的运行平台“兼容于Java技术体系”,那该运行平台就必须要通过TCK(Technology Compatibility Kit)的兼容性测试,Apache基金会曾要求当时的Sun公司提供TCK的使用授权,但是一直遭到各种理由的拖延和搪塞,直到Oracle收购了Sun公司之后,双方关系越闹越僵,最终导致Apache基金会愤然退出JCP组织,这是Java社区有史以来最严重的分裂事件之一。
当Sun公司把自家的JDK开源形成OpenJDK项目之后,Apache Harmony开源的优势被极大地抵消,以至于连Harmony项目的最大参与者IBM公司也宣布辞去Harmony项目管理主席的职位,转而参 与OpenJDK的开发。虽然Harmony没有真正地被大规模商业运用过,但是它的许多代码(主要是Java类库部分的代码)被吸纳进IBM的JDK 7实现以及Google Android SDK之中,尤其是对Android的发展起了很大推动作用。
说到Android,这个时下最热门的移动数码设备平台在最近十年所取得的成果已经远远超越了Java ME在过去二十多年所获得的成果,Android让Java语言真正走进了移动数码设备领域,只是走得并非 Sun公司原本想象的那一条路。
Dalvik虚拟机曾经是Android平台的核心组成部分之一,它的名字来源于冰岛一个名为Dalvik的小渔村。Dalvik虚拟机并不是一个Java虚拟机,它没有遵循《Java虚拟机规范》,不能直接执行Java的 Class文件,使用寄存器架构而不是Java虚拟机中常见的栈架构。但是它与Java却又有着千丝万缕的联系,它执行的DEX(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写应用程序,可以直接使用绝大部分的Java API等。在Android发展的早期,Dalvik虚拟机随着Android的成功迅速流行,在Android 2.2中开始提供即时编译器实现,执行性能又有了进一步提高。不过到了Android 4.4时代,支持提前编译(Ahead of Time Compilation,AOT)的ART虚拟机迅速崛起,在当时性能还不算特别强大的移动设备上,提前编译要比即时编译更容易获得高性能,所以在Android 5.0里ART就 全面代替了Dalvik虚拟机。
# 没有成功,但并非失败:Microsoft JVM及其他
在Java虚拟机二十几年的发展历程中,除去上面介绍的那些被大规模商业应用过的Java虚拟机外, 还有许多虚拟机是不为人知地默默沉寂,或者曾经绚丽过但最终夭折湮灭的。我们以其中Microsoft公 司的Java虚拟机为代表来介绍一下。
在Java语言诞生的初期(1996年~1998年,以JDK1.2发布之前为分界),它的主要应用之一是在浏览器中运行Java Applets程序,微软为了在Internet Explorer 3浏览器中支持Java Applets应用而开发了自己的Java虚拟机,虽然这款虚拟机只有Windows平台的版本,“一次编译,到处运行”根本无从谈起,但却是当时Windows系统下性能最好的Java虚拟机,它在1997年和1998年连续获得了《PC Magazine》杂志的“编辑选择奖”。但是好景不长,在1997年10月,Sun公司正式以侵犯商标、不正当竞争等罪名控 告微软,在随后对微软公司的垄断调查之中,这款虚拟机也曾作为证据之一被呈送法庭。官司的结果是微软向Sun公司(最终微软因垄断赔偿给Sun公司的总金额高达10亿美元)赔偿2000万美金,承诺终 止其Java虚拟机的发展,并逐步在产品中移除Java虚拟机相关功能。而最令人感到讽刺的是,到后来在 Windows XP SP3中Java虚拟机被完全抹去的时候,Sun公司却又到处登报希望微软不要这样做[1]。 Windows XP高级产品经理Jim Cullinan称:“我们花费了三年的时间和Sun公司打官司,当时他们试图阻 止我们在Windows中支持Java,现在我们这样做了,可他们又在抱怨,这太具有讽刺意味了。”