若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
- 参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
参考文章
- jmap 命令详解:https://zhuanlan.zhihu.com/p/475571429
- JVM 垃圾回收 超详细学习笔记(二):https://blog.csdn.net/weixin_53142722/article/details/125418216
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
下图为 Java 虚拟机运行时数据区

在 Java 内存运行时区中的三个区域:程序计数器、虚拟机栈、本地方法栈
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的
而 Java 堆和方法区这两个区域则有着很显著的不确定性
只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
垃圾收集器所关注的正是这部分内存该如何管理,后续讨论中的 “内存” 分配与回收也仅仅特指这一部分内存。
在堆里面存放着 Java 世界中几乎所有的对象实例,
垃圾收集器在对堆进行回收前,
第一件事情就是要确定这些对象之中哪些还 “存活” 着,哪些已经“死去”(“死去” 即不可能再被任何途径使用的对象)了。

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
很多教科书判断对象是否存活的算法是这样的:
客观地说,引用计数算法(Reference Counting) 虽然占用了一些额外的内存空间来进行计数。
但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
有一些比较著名的应用案例都使用到了引用计数算法进行内存管理
但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存。
主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。
譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
举个简单的例子:具体见下面的代码块中的 testGC() 方法
objA.instance=objB 及 objB.instance=objA。代码块:引用计数算法的缺陷
/**
* testGC()方法执行后,objA 和 objB 会不会被 GC 呢?
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA 和 objB 是否能被回收?
System.gc();
}
}
运行结果
[Full GC (System)
[Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K),
[Perm : 2999K->2999K(21248K)],
0.0150007 secs]
[Times: user=0.01 sys=0.00, real=0.02 secs] Heap
def new generation total 9216K, used 82K [0x00000000055e0000, 0x0000000005fe0000, 0x0000000005fe0000)
Eden space 8192K, 1% used [0x00000000055e0000, 0x00000000055f4850, 0x0000000005de0000)
from space 1024K, 0% used [0x0000000005de0000, 0x0000000005de0000, 0x0000000005ee0000)
to space 1024K, 0% used [0x0000000005ee0000, 0x0000000005ee0000, 0x0000000005fe0000)
tenured generation total 10240K, used 210K [0x0000000005fe0000, 0x00000000069e0000, 0x00000000069e0000)
the space 10240K, 2% used [0x0000000005fe0000, 0x0000000006014a18, 0x0000000006014c00, 0x00000000069e0000)
compacting perm gen total 21248K, used 3016K [0x00000000069e0000, 0x0000000007ea0000, 0x000000000bde0000)
the space 21248K, 14% used [0x00000000069e0000, 0x0000000006cd2398, 0x0000000006cd2400, 0x0000000007ea0000)
No shared spaces configured.
从运行结果中可以清楚看到内存回收日志中包含 “4603K->210K”
这意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们
这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。
可达性分析(Reachability Analysis)
GC Root 对象为起点的引用链找到该对象。找不到,表示可以回收。
GC Root 呢?这里作一下相关的演示来了解这些情况 /**
* 演示 GC Roots
*/
public class Demo2_1 {
public static void main(String[] args) throws InterruptedException, IOException {
List<Object> list1 = new ArrayList<>();
list1.add("a");
list1.add("b");
System.out.println(1);
System.in.read();
list1 = null;
System.out.println(2);
System.in.read();
System.out.println("end...");
}
}
jps 命令来查看进程 id
jmap 工具来转存文件。(之前介绍过其可以查看堆内存占用情况:jmap -heap 进程id)
jmap能够打印给定 Java 进程、核心文件或远程 DEBUG 服务器的共享对象内存映射或堆内存的详细信息。- 如果给定的进程运行在 64 位虚拟机上,则必须指定
-J-d64选项,例如jmap -J-d64 -heap pid。jmap可能在未来的 JDK 版本中删除。
jmap -dump:format=b,live,file=1.bin 进程id
-dump:[live,]format=b,file=
- 将 Java 堆以 hprof 二进制格式转储到 filename 文件中。
- live 是可选参数,如果指定,则只转储堆中的活动对象。
- 可以使用 MAT(Memory Analyzer) 工具来分析内容。

Eclipse Memory Analyzer(该工具可单独使用,无需下载 Eclipse)对使用 jmap 工具生成的文件来进行分析。Eclipse Memory Analyzer
gc roots
在上面的软件分析中出现了四个对象,这里先稍稍解释以下对象在软件中的情况。
java.lang.Class 类的实例对象List
list1 只是一个局部变量,它是存在于 活动 栈帧 里的。new ArrayList<>(); 则是存储在堆中的(通过 new 关键字,创建对象都会使用堆内存)list1 = null;
System.out.println(2); 这里时,我们再次使用了 jmap 工具来转储文件,live 参数主动执行了一次垃圾回收
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
除了这些固定的 GC Roots 集合以外
根据用户所选用的垃圾收集器以及当前回收的内存区域不同
还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。
譬如后文将会提到的分代收集和局部回收(Partial GC)就会 “临时性” 地加入 GC Roots 集合。
如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),其更不是孤立封闭的
所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用。
这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器无一例外都具备了局部回收的特征。
为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。
关于这些概念、优化技巧以及各种不同收集器实现等内容,都将在本章后续内容中一一介绍。
视频中认为有五种引用

只有所有 GC Roots 对象都不通过 强引用 引用该对象,该对象才能被垃圾回收
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
可以配合引用队列来释放软引用自身
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
可以配合引用队列来释放弱引用自身
必须配合引用队列使用,主要配合 ByteBuffer 使用。
被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
例如:由 Reference Handler 线程通过 Cleaner 的 clean 方法调用 Unsafe.freeMemory 来释放直接内存
无需手动编码
但其内部必须配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收)
再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象
Finalizer 线程的优先级很低,可能导致终结器引用指向的对象迟迟不被回收
故在实际开发中不推荐使用 Finalizer 来回收 GC
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,
判定对象是否存活都和 “引用” 离不开关系。
在 JDK 1.2 版之前,Java 里面的引用是很传统的定义:
这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有 “被引用” 或者 “未被引用” 两种状态,
对于描述一些 “食之无味,弃之可惜” 的对象就显得无能为力。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,
如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。
很多系统的缓存功能都符合这样的应用场景。
在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,
将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)
这 4 种引用强度依次逐渐减弱。
强引用是最传统的 “引用” 的定义,是指在程序代码之中普遍存在的引用赋值
Object obj=new Object() 这种引用关系。软引用是用来描述一些还有用,但非必须的对象。
弱引用也是用来描述那些非必须对象。
虚引用也称为 “幽灵引用” 或者 “幻影引用”,它是最弱的一种引用关系。
其实这里可以对应视频中认为的第五种引用:终结器引用
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的。
这时候它们暂时还处于 “缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果这个对象被判定为确有必要执行 finalize() 方法
这里所说的 “执行” 是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束
这样做的原因是:
finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,
如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,
譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出 “即将回收” 的集合;
如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

从下面的代码清单中我们可以看到一个对象的 finalize() 被执行,但是它仍然可以存活。
代码清单
/**
* 此代码演示了两点:
* * 1.对象可以在被 GC 时自我拯救。
* * 2.这种自救的机会只有一次,因为一个对象的 finalize() 方法最多只会被系统自动调用一次
*
* @author zzm
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
运行结果
finalize method executed!
yes, i am still alive :)
no, i am dead :(
从上面的运行结果可以看到,SAVE_HOOK 对象的 finalize() 方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。
另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。
这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次。
如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。
还有一点需要特别说明,上面关于对象死亡时 finalize() 方法的描述可能带点悲情的艺术加工,笔者并不鼓励大家使用这个方法来拯救对象。
相反,笔者建议大家尽量避免使用它,因为它并不能等同于 C 和 C++ 语言中的析构函数,
而是 Java 刚诞生时为了使传统 C、C++ 程序员更容易接受 Java 所做出的一项妥协。
它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
有些教材中描述它适合做 “关闭外部资源” 之类的清理性工作,这完全是对 finalize() 方法用途的一种自我安慰。
finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,
所以笔者建议大家完全可以忘掉 Java 语言里面的这个方法。
代码部分
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;
/**
* 演示软引用
* * -Xmx20m
* * * -XX:+PrintGCDetails -verbose:gc
*/
public class Demo2_1_4_1 {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) throws IOException {
//strong();
soft();
}
//演示硬引用
public static void strong() throws IOException {
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
/* system.in.read()方法的作用:
* 是从键盘读出一个字符,然后返回它的 Unicode 码。
* 按下 Enter 结束输入*/
System.in.read();
}
//演示软引用
public static void soft() {
// list --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println("ref.get():" + ref.get());
list.add(ref);
System.out.println("list.size():" + list.size());
}
System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println("ref.get():" + ref.get());
}
}
}
控制台输出的结果
main() 方法调用 strong() 方法时(VM options:-Xmx20m)Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at aTest.Demo2_1_4_1.strong(Demo2_1_4_1.java:21)
at aTest.Demo2_1_4_1.main(Demo2_1_4_1.java:15)
main() 方法调用 soft() 方法时(VM options:-Xmx20m)ref.get():[B@6d6f6e28
list.size():1
ref.get():[B@135fbaa4
list.size():2
ref.get():[B@45ee12a7
list.size():3
ref.get():[B@330bedb4
list.size():4
ref.get():[B@2503dbd3
list.size():5
循环结束:5
ref.get():null
ref.get():null
ref.get():null
ref.get():null
ref.get():[B@2503dbd3
ref.get():[B@6d6f6e28
list.size():1
ref.get():[B@135fbaa4
list.size():2
ref.get():[B@45ee12a7
list.size():3
[GC (Allocation Failure) [PSYoungGen: 2251K->488K(6144K)] 14539K->13056K(19968K), 0.0009551 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
# 第四次循环
ref.get():[B@330bedb4
list.size():4
[GC (Allocation Failure)
--[PSYoungGen: 4696K->4696K(6144K)]
17264K->17280K(19968K), 0.0010460 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics)
[PSYoungGen: 4696K->4526K(6144K)]
[ParOldGen: 12584K->12547K(13824K)]
17280K->17074K(19968K),
[Metaspace: 3469K->3469K(1056768K)], 0.0046119 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
# 触发了二次回收
[GC (Allocation Failure)
--[PSYoungGen: 4526K->4526K(6144K)]
17074K->17090K(19968K), 0.0008202 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure)
[PSYoungGen: 4526K->0K(6144K)]
[ParOldGen: 12563K->671K(8704K)]
17090K->671K(14848K),
[Metaspace: 3469K->3469K(1056768K)], 0.0065348 secs]
[Times: user=0.09 sys=0.00, real=0.01 secs]
ref.get():[B@2503dbd3
list.size():5
循环结束:5
ref.get():null
ref.get():null
ref.get():null
ref.get():null
ref.get():[B@2503dbd3
# 程序运行结束时,内存的占用情况
Heap
PSYoungGen total 6144K, used 4264K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
eden space 5632K, 75% used [0x00000000ff980000,0x00000000ffdaa080,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen total 8704K, used 671K [0x00000000fec00000, 0x00000000ff480000, 0x00000000ff980000)
object space 8704K, 7% used [0x00000000fec00000,0x00000000feca7fe8,0x00000000ff480000)
Metaspace used 3476K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
代码
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;
/**
* 演示软引用, 配合引用队列
* -Xmx20m
*/
public class Demo2_1_4_2 {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i++) {
// 关联了引用队列
// 当软引用所关联的 byte[] 被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println("ref.get():" + ref.get());
list.add(ref);
System.out.println("list.size():" + list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println("ref.get():" + reference.get());
}
}
}
控制台输出
ref.get():[B@6d6f6e28
list.size():1
ref.get():[B@135fbaa4
list.size():2
ref.get():[B@45ee12a7
list.size():3
ref.get():[B@330bedb4
list.size():4
ref.get():[B@2503dbd3
list.size():5
===========================
# list 中只存在一个元素
ref.get():[B@2503dbd3
# 显然配合引用队列成功清理了软引用
代码部分
/**
* 演示弱引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo2_1_4_3 {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
// list --> WeakReference --> byte[]
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> w : list) {
System.out.print(w.get()+" ");
}
System.out.println();
}
System.out.println("循环结束:" + list.size());
}
}
控制台输出结果
[B@6d6f6e28
[B@6d6f6e28 [B@135fbaa4
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7
[GC (Allocation Failure) [PSYoungGen: 2251K->488K(6144K)] 14539K->13056K(19968K), 0.0011199 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 [B@330bedb4
[GC (Allocation Failure) [PSYoungGen: 4696K->488K(6144K)] 17264K->13064K(19968K), 0.0008881 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null [B@2503dbd3
[GC (Allocation Failure) [PSYoungGen: 4695K->504K(6144K)] 17271K->13096K(19968K), 0.0004643 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null [B@4b67cf4d
[GC (Allocation Failure) [PSYoungGen: 4710K->488K(6144K)] 17302K->13096K(19968K), 0.0004630 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null [B@7ea987ac
[GC (Allocation Failure) [PSYoungGen: 4694K->496K(6144K)] 17302K->13136K(19968K), 0.0004816 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null null [B@12a3a380
[GC (Allocation Failure) [PSYoungGen: 4702K->488K(5120K)] 17342K->13128K(18944K), 0.0004228 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@6d6f6e28 [B@135fbaa4 [B@45ee12a7 null null null null null [B@29453f44
[GC (Allocation Failure) [PSYoungGen: 4674K->32K(5632K)] 17314K->13104K(19456K), 0.0005544 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 32K->0K(5632K)] [ParOldGen: 13072K->689K(8704K)] 13104K->689K(14336K), [Metaspace: 3466K->3466K(1056768K)], 0.0060263 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
null null null null null null null null null [B@5cad8086
循环结束:10
Heap
PSYoungGen total 5632K, used 4278K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
eden space 4608K, 92% used [0x00000000ff980000,0x00000000ffdadab8,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 8704K, used 689K [0x00000000fec00000, 0x00000000ff480000, 0x00000000ff980000)
object space 8704K, 7% used [0x00000000fec00000,0x00000000fecac7e8,0x00000000ff480000)
Metaspace used 3473K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
显然,仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
但若要释放弱引用自身,则需要配合引用队列来释放(具体操作与软引用清理无异)

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
最早出现也是最基础的垃圾收集算法是 “标记-清除”(Mark-Sweep)算法
该算法分为 “标记” 和 “清除” 两个阶段:
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以 标记-清除 算法为基础,对其缺点进行改进而得到的。
它的主要缺点有两个:


参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
标记-复制算法常被简称为复制算法。
为了解决 标记-清除 算法面对大量可回收对象时执行效率低的问题
1969 年 Fenichel 提出了一种称为 “半区复制”(Semispace Copying)的垃圾收集算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
这样实现简单,运行高效,不过其缺陷也显而易见。
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

这里还请结合下面的 “分代垃圾回收” 和 “垃圾回收器” 的内容来看,书中的编排顺序与视频中稍有所不同
现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代。
IBM 公司曾有一项专门研究对新生代 “朝生夕灭” 的特点做了更量化的诠释:新生代中的对象有 98% 熬不过第一轮收集。
因此并不需要按照 1∶1 的比例来划分新生代的内存空间。
在 1989 年,Andrew Appel 针对具备 “朝生夕灭” 特点的对象,
提出了一种更优化的半区复制分代策略,现在称为 “Appel 式回收”。
HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计 新生代 的内存布局。
Appel 式回收 的具体做法是
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1,
当然,98% 的对象可被回收仅仅是 “普通场景” 下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活。
因此 Appel 式回收还有一个充当罕见情况的 “逃生门” 的安全设计
内存的分配担保好比我们去银行借款。
如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,
只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。
内存的分配担保也一样。
如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,
这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
关于对新生代进行分配担保的内容,在稍后的章节中介绍垃圾收集器执行规则的时候还会再进行讲解。

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。
更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,
以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的 “标记-整理”(Mark-Compact)算法,
其中的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,
而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,

这里还请结合下面的 “分代垃圾回收” 的内容来看,书中的编排顺序与视频中稍有所不同
标记-清除 算法与 标记-整理 算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
是否移动回收后的存活对象是一项优缺点并存的风险决策。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,
移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,
而且这种对象移动操作必须全程暂停用户应用程序才能进行,
这就更加让使用者不得不小心翼翼地权衡其弊端了,
像这样的停顿被最初的虚拟机设计者形象地描述为 “Stop The World”。
(通常 标记-清除 算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已)
但如果跟 标记-清除 算法那样完全不考虑移动和整理存活对象的话,
弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
譬如通过 “分区空闲分配链表” 来解决内存分配问题
(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。
内存的访问是用户程序最频繁的操作,甚至都没有之一,
假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,
但是从整个程序的吞吐量来看,移动对象会更划算。
此语境中,吞吐量的实质是 赋值器 与 收集器 的效率总和。
即使不移动对象会使得收集器的效率提升一些,
但因内存分配和访问相比垃圾收集频率要高得多,因为这部分的耗时增加,总吞吐量仍然是下降的。
HotSpot 虚拟机里面关注 吞吐量 的 Parallel Scavenge 收集器是基于 标记-整理 算法的,
而关注 延迟 的 CMS 收集器则是基于 标记-清除 算法的,这也从侧面印证这点。
另外,还有一种 “和稀泥式” 解决方案可以不在内存分配和访问上增加太大额外负担,
做法是让虚拟机平时多数时间都采用 标记-清除 算法,
暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,
再采用 标记-整理 算法收集一次,以获得规整的内存空间。
前面提到的基于 标记-清除算法 的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。
把分代收集理论具体放到现在的商用 Java 虚拟机里
设计者一般至少会把 Java 堆划分为 新生代(Young Generation) 和 老年代(Old Generation) 两个区域。
在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

提示:新生代分为一块较大的 Eden 和两块较小的 Survivor,这里面的 from 和 to 都是 Survivor
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
| 含义 | 参数 |
|---|---|
| 堆初始大小 | -Xms |
| 堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size |
| 新生代大小 | -Xmn 或 ( -XX:NewSize=size + -XX:MaxNewSize=size) |
| 幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy |
| 幸存区比例 | -XX:SurvivorRatio=ratio |
| 晋升阈值 | -XX:MaxTenuringThreshold=threshold |
| 晋升详情 | -XX:+PrintTenuringDistribution |
| 打印 GC 详情 | -XX:+PrintGCDetails -verbose:gc |
| FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC |
参考博客:JVM 垃圾回收 超详细学习笔记(二)
VM options:-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
代码部分
import java.util.ArrayList;
public class Demo10_1 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
// -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_1MB]);
}
}
运行结果
# 显然,这里触发了两次内存回收
[GC (Allocation Failure)
# 新生代进行垃圾回收前后的所占用的空间
[DefNew: 2190K->661K(9216K), 0.0012653 secs]
# 堆进行垃圾回收前后的所占用的空间
2190K->661K(19456K), 0.0013016 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure)
[DefNew: 8157K->28K(9216K), 0.0043199 secs]
8157K->7846K(19456K), 0.0043384 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 1134K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 13% used [0x00000000fec00000, 0x00000000fed14930, 0x00000000ff400000)
from space 1024K, 2% used [0x00000000ff400000, 0x00000000ff4072a8, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 7818K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 76% used [0x00000000ff600000, 0x00000000ffda2818, 0x00000000ffda2a00, 0x0000000100000000)
# 元空间位于本地内存,其并不属于堆,此处只是打印出来了而已
Metaspace used 3470K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
若是直接一次加满 list.add(new byte[_8MB]);
即一个对象的大小直接超过了新生代的伊甸园的剩余容量,from 中也放不下它
其进行垃圾回收也不能将该对象放进去时,则此时新生代不会进行垃圾回收,而是直接将对象晋升到老年代。
就大对象而言,在老年代空间足够,而新生代空间不足时,其必不会在新生代进行垃圾回收,而是直接将对象晋升到老年代
Heap
def new generation total 9216K, used 2354K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
# [28%]是因为 java 程序运行时,必须要加载一些类,创建一些对象,这些对象使用的仍是[伊甸园]的区域
eden space 8192K, 28% used [0x00000000fec00000, 0x00000000fee4cbd0, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 80% used [0x00000000ff600000, 0x00000000ffe00010, 0x00000000ffe00200, 0x0000000100000000)
Metaspace used 3469K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
若是进行了 gc,但是内存还是不够,这时就会导致内存溢出。不过在抛出 OOM 异常之前 JVM 还是会进行一次自救
比如这里我加了 16MB,此时是老年代(10M)也放不下,新生代也放不下
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
运行结果
[GC (Allocation Failure) [DefNew: 2190K->689K(9216K), 0.0015390 secs][Tenured: 8192K->8880K(10240K), 0.0018898 secs] 10382K->8880K(19456K), [Metaspace: 3463K->3463K(1056768K)], 0.0034957 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 8880K->8862K(10240K), 0.0014772 secs] 8880K->8862K(19456K), [Metaspace: 3463K->3463K(1056768K)], 0.0014970 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 246K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 3% used [0x00000000fec00000, 0x00000000fec3d890, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 8862K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 86% used [0x00000000ff600000, 0x00000000ffea7b58, 0x00000000ffea7c00, 0x0000000100000000)
Metaspace used 3494K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 384K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at cTest.Demo10_1.main(Demo10_1.java:17)
一个线程的
OutOfMemoryError是不会导致整个程序都停止的
代码部分
import java.util.ArrayList;
public class Demo10_1S {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();
System.out.println("sleep....");
Thread.sleep(1000L);
}
}
运行结果
sleep....
[GC (Allocation Failure)
[DefNew: 4365K->892K(9216K), 0.0025394 secs]
[Tenured: 8192K->9082K(10240K), 0.0034657 secs] 12557K->9082K(19456K),
[Metaspace: 4332K->4332K(1056768K)], 0.0060740 secs]
[Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC (Allocation Failure)
[Tenured: 9082K->9027K(10240K), 0.0029306 secs] 9082K->9027K(19456K),
[Metaspace: 4332K->4332K(1056768K)], 0.0029713 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
# 该线程(Thread-0)内存溢出,但是主线程并未抛出异常
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
at cTest.Demo10_1.lambda$main$0(Demo10_1.java:17)
at cTest.Demo10_1$$Lambda$1/1747585824.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
Heap
def new generation total 9216K, used 1329K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 16% used [0x00000000fec00000, 0x00000000fed4c770, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 9027K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 88% used [0x00000000ff600000, 0x00000000ffed0c30, 0x00000000ffed0e00, 0x0000000100000000)
# 元空间位于本地内存,其并不属于堆
Metaspace used 4857K, capacity 4978K, committed 5248K, reserved 1056768K
class space used 544K, capacity 591K, committed 640K, reserved 1048576K
底层是一个单线程的垃圾回收器
应用场景:堆内存较小时,适合个人电脑
底层是多线程的垃圾回收区
应用场景:堆内存较大,需要多核 cpu 来支持,适合在服务器上工作
目标:让单位时间内,STW 的时间最短(如:0.2 秒、0.2 秒 ,垃圾回收时间共 0.4 秒),垃圾回收时间占比最低,这样就称吞吐量高
底层是多线程的垃圾回收区
应用场景:堆内存较大,需要多核 cpu 来支持,适合在服务器上工作
目标:尽可能让单次 STW 的时间最短(如:0.1 秒、0.1 秒、0.1 秒、0.1 秒、0.1 秒,垃圾回收总时间为 0.5 秒)
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
从 ParNew 收集器 开始,后面还将会接触到若干款涉及 “并发” 和 “并行” 概念的收集器。
在大家可能产生疑惑之前,有必要先解释清楚这两个名词。
并行 和 并发 都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:
并行(Parallel)
并发(Concurrent)
吞吐量(Throughput)
垃圾收集时用户线程的停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
我们以可达性分析算法中从 GC Roots 集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。
固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,
尽管目标明确,但查找过程要做到高效并非一件容易的事情。
现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,
若要逐个检查以这里为起源的引用肯定得消耗不少时间。
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,
因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的 “Stop The World” 的困扰。
现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,
但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行
若这点不能满足的话,分析结果准确性也就无法保证。
这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,
即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、ZGC 等收集器,枚举根节点时也是必须要停顿的。
由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,
所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,
虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构来达到这个目的。
一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,
在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。
这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。
安全点(Safe Point)
在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举。
但一个很现实的问题随之而来:
实际上 HotSpot 也的确没有为每条指令都生成 OopMap。
前面已经提到,只是在 “特定的位置” 记录了这些信息,这些位置被称为安全点(Safepoint)。
有了安全点的设定,
也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,
而是强制要求必须执行到达安全点后才能够暂停。
因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
安全点位置的选取基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,
因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,
“长时间执行” 的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,
所以只有具有这些功能的指令才会产生安全点。
对于安全点,另外一个需要考虑的问题是:
如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。
这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
抢先式中断
现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
而主动式中断的思想是:
轮询标志的地方和安全点是重合的,
另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。
HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。
但是,程序 “不执行” 的时候呢?
所谓的程序不执行就是没有分配处理器时间,
典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,
这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,
虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入 安全区域(Safe Region) 来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。
因此,在这个区域中任意地方开始垃圾收集都是安全的。
我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,
首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),
如果完成了,那线程就当作没事发生过,继续执行;
否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
| 分代收集理论 |
第三条假说其实是可根据前两条假说逻辑推理得出的隐含推论
依据第三条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用
只需在新生代上建立一个全局的数据结构(该结构被称为 “记忆集”,Remembered Set)
| 记忆集 |
讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题
事实上并不只是新生代、老年代之间才有跨代引用的问题
所有涉及部分区域收集(Partial GC)行为的垃圾收集器
典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临相同的问题
因此我们有必要进一步理清记忆集的原理和实现方式,以便理解。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
如下面的代码块(以对象指针来实现记忆集的伪代码)
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。
而在垃圾收集的场景中,
收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。
那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本
下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
| 卡表 |
其中,第三种 “卡精度” 所指的是用一种称为 “卡表”(Card Table)的方式去实现记忆集
这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。
前面定义中提到记忆集其实是一种 “抽象” 的数据结构
抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
关于卡表与记忆集的关系,读者不妨按照 Java 语言中 HashMap 与 Map 的关系来类比理解。
卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。
以下这行代码是 HotSpot 默认的卡表标记逻辑
CARD_TABLE [this address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块
这个内存块被称作 “卡页”(Card Page)。
一般来说,卡页大小都是以 2 的 N 次幂的字节数
通过上面代码可以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。
那如果卡表标识内存区域的起始地址是 0x0000 的话
数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF 的卡页内存块
具体情况如下图

一个卡页的内存中通常包含不止一个对象
只要卡页内有一个(或更多)对象的字段存在着跨代指针
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
我们已经解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题
但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏的答案是很明确的
但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?
在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
注意将这里提到的 “写屏障”,以及在低延迟收集器中提到的 “读屏障” 与解决并发乱序执行问题中的 “内存屏障” 区分开来,避免混淆。
写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面
AOP 为 Aspect Oriented Programming 的缩写,意为 面向切面编程
- 通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。
- 上面提到的 “环形通知” 也是 AOP 中的概念,使用过 Spring 的读者应该都了解这些基础概念。
在赋值前的部分的写屏障叫作写 前屏障(Pre-Write Barrier),在赋值后的则叫作写 后屏障(Post-Write Barrier)。
HotSpot 虚拟机的许多收集器中都有使用到写屏障
但直至 G1 收集器出现之前,其他收集器都只用到了写后屏障。
下面这段代码清单是一段更新卡表状态的简化逻辑
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令
一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用
每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。
除了写屏障的开销外,卡表在高并发场景下还面临着 “伪共享”(False Sharing)问题。
伪共享是处理并发底层细节时一种经常需要考虑的问题
现代中央处理器的缓存系统中是以 缓存行(Cache Line)为单位存储的
当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低
这就是伪共享问题。
假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行。
这 64 个卡表元素对应的卡页总的内存为 32KB(64×512 字节)
也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了避免伪共享问题,一种简单的解决方案是
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
在 JDK 7 之后
HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
开启串行回收器的语句:-XX:+UseSerialGC = Serial + SerialOld

参考博客:JVM 垃圾回收 超详细学习笔记(二)
进行垃圾回收的时候为什么需要让其他线程停下?
在垃圾回收线程运行的时候其他线程都需要阻塞,等垃圾线程运行结束后,其他用户线程才能恢复运行。
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
Serial 收集器是一个单线程工作的收集器。
在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,
收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),
垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,
只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。
所以,Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器。
java 8 后 JDK 默认使用该回收器
ParNew 收集器实质上是 Serial 收集器的多线程并行版本
除了同时使用多条线程进行垃圾收集之外
其余的行为包括 Serial 收集器可用的所有控制参数
-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等以及 Serial 收集器的收集算法、Stop The World、对象分配规则、回收策略等
这些都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
Parallel Scavenge 收集器是一款新生代收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。

-XX:+UseParallelGC ~ -XX:+UseParallelOldGC-XX:+UseAdaptiveSizePolicy-XX:GCTimeRatio=ratio-XX:MaxGCPauseMillis=ms-XX:ParallelGCThreads=n-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量
-XX:MaxGCPauseMillis 参数:控制最大垃圾收集停顿时间
-XX:GCTimeRatio 参数:直接设置吞吐量大小
显然,这两个参数是互相冲突的
-XX:+UseAdaptiveSizePolicy 参数
-Xmn)-XX:SurvivorRatio)-XX:PretenureSizeThreshold)等细节参数了。自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。
-XX:ParallelGCThreads 参数
该收集器与 ParNew 收集器类似,都默认开启的收集线程数与处理器核心数量相同
在处理器核心非常多的环境下,可以使用上面的参数来限制垃圾收集的线程数。

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads-XX:CMSInitiatingOccupancyFraction=percent-XX:+CMSScavengeBeforeRemark-XX:ParallelGCThreads=n
-XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
+ 为开启,- 为禁用参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,
这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。
CMS 收集器就非常符合这类应用的需求。
从名字(包含 “Mark Sweep”)上就可以看出 CMS 收集器是基于 标记-清除 算法实现的,
它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
其中 初始标记 和 重新标记 这两个步骤仍然需要 “Stop The World”。
由于在整个过程中耗时最长的 并发标记 和 并发清除 阶段中,垃圾收集器线程都可以与用户线程一起工作
所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起 并发 执行的。
通过下图可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的阶段。

CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿
一些官方公开文档里面也称之为 “并发低停顿收集器”(Concurrent Low Pause Collector)
CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点。
首先,CMS 收集器对处理器资源非常敏感。
事实上,面向并发设计的程序都对处理器资源比较敏感。
在并发阶段,它虽然不会导致用户线程停顿
但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
CMS 默认启动的回收线程数是: 处理器核心数量 + 3 4 \frac{处理器核心数量 + 3}{4} 4处理器核心数量+3
为了缓解这种情况,虚拟机提供了一种称为 “增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的 CMS 收集器变种
实践证明增量式的 CMS 收集器效果很一般
然后,由于 CMS 收集器无法处理 “浮动垃圾”(Floating Garbage)
有可能出现 “Con-current Mode Failure” 失败进而导致另一次完全 “Stop The World” 的 Full GC 的产生。
同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用
因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
在 JDK 5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置
-XX:CMSInitiatingOccu-pancyFraction到了JDK 6 时,CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:
所以参数 -XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低
用户应在生产环境中根据实际应用情况来权衡设置。
还有最后一个缺点,在本节的开头曾提到,CMS 是一款基于 “标记-清除” 算法实现的收集器,
如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。
空间碎片过多时,将会给大对象分配带来很大麻烦,
往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMS-CompactAtFullCollection 开关参数
这样空间碎片问题是解决了,但停顿时间又会变长,
因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction(此参数从 JDK 9 开始废弃)
Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果
它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
JDK 9 发布之日
相关时间:2004 论文发布、2009 JDK 6u14 体验、2012 JDK 7u4 官方支持、2017 JDK 9 默认
适用场景
相关 JVM 参数
-XX:+UseG1GC
-XX:G1HeapRegionSize=size-XX:MaxGCPauseMillis=time上述的三个阶段是一个循环过程

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起 “停顿时间模型”(Pause Prediction Model)的收集器
停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标
这几乎已经是 实时 Java(RTSJ) 的中软实时垃圾收集器特征了。
那具体要怎么做才能实现这个目标呢?
首先要有一个思想上的改变,在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,
而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,
衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。
G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。
每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1 MB~32 MB ,且应为 2 的 N 次幂。
而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中
G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。
G1 仍然保留新生代和老年代的概念
G1 收集器之所以能建立可预测的停顿时间模型
更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的 “价值” 大小
-XX:MaxGCPauseMillis 指定,默认值是 200 毫秒)这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
E:伊甸园、S:幸存区、O:老年代、空白:空闲区

类加载时新创建的对象,开始时都会分配到 伊甸园区
当 伊甸园 的区域被占满时(可以自己设置阈值),触发 minor gc,也就同时触发 Stop The World,当然这个时间相对而言是比较短的。
此时会使用 复制 的算法将 幸存对象 放入 幸存区
再工作一段时间后,当 幸存区 的对象也比较多的时候,也会触发 minor gc
当 幸存区 的对象寿命超过阈值时,会晋升至 老年代;幸存区 的对象寿命未达到阈值时会放到新的 幸存区。
大体上与之前的分代垃圾回收无异。
Young Collection + Concurrent Mark
XX:InitiatingHeapOccupancyPercent=percent(默认 45%)
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
初始标记(Initial Marking)
并发标记(Concurrent Marking)
TAMS(Top at Mark Start)
下面的内容只是摘抄了书中的部分片段,详情还请看书:3.4.6.并发的可达性分析
| 并发的可达性分析(部分内容) |
用户线程与收集器并发工作的时候
收集器在对象图上标记,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。
解决并发扫描时的对象消失问题有两种解决方案
增量更新 可以简化理解为:黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照 可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
会对 E(Eden )、S(Survivor)、O(Old Generation) 进行全面的垃圾回收
-XX:MaxGCPauseMillis=ms:设置最大暂停时间目标,默认值是 200 毫秒
问:为什么图中有的老年代被拷贝了,有的没拷贝?
优先处理回收价值收益最大的那些 Region,这也是 “Garbage First” 名字的由来。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
最终标记(Final Marking)
筛选回收(Live Data Counting and Evacuation)
通过 初始标记、并发标记、最终标记、筛选回收 的和阶段描述可以看出
G1 收集器除了 并发标记 外,其余阶段也是要完全暂停用户线程的
换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量
所以才能担当起 “全功能收集器” 的重任与期望
从 Oracle 官方透露出来的信息可获知,回收阶段( Evacuation)其实本也有想过设计成与用户程序一起并发执行
但这件事情做起来比较复杂
考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现
而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。
另外,还考虑到 G1 不是仅仅面向低延迟,
停顿用户线程能够最大幅度提高垃圾收集效率,
为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
通过下图可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段。

SerialGC
ParallelGC
CMS
G1
先回顾一下新生代的垃圾回收的过程
假设现在要进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的
问题:遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。
解决办法:我们就可以使用 记忆集 避免全堆作为 GC Roots 扫描
而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

下图中的粉红色区域即为脏卡区

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
每个 Region 都维护有自己的记忆集
这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
G1 的记忆集(Remembered Set)在存储结构的本质上是一种哈希表
根据经验,G1 至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。
使用 pre-write barrier + satb_mark_queue 来完成重新标记阶段
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
| 并发的可达性分析 |
当前主流编程语言的垃圾收集器基本上都是依靠 可达性分析 算法来判定对象是否存活的
可达性分析算法理论上要求全过程都基于一个能 保障一致性的快照 中才能够进行分析
这意味着必须全程冻结用户线程的运行。
在 根节点枚举 这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数
且在各种优化技巧(如 OopMap)的加持下
它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。
可从 GC Roots 再继续往下遍历对象图
这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了
这听起来是理所当然的事情。
要知道包含 “标记” 阶段是所有追踪式垃圾收集算法的共同特征
如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器
同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
为了能解释清楚这个问题,此处借助 三色标记(Tri-color Marking) 工具来辅助推导
把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色
白色对象:表示对象尚未被垃圾收集器访问过。
黑色对象:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
灰色对象:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。


Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生 “对象消失” 的问题
即原本应该是黑色的对象被误标为白色:
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。
由此分别产生了两种解决方案:增量更新(Incremental Update)和 原始快照(Snapshot At The Beginning,SATB)。
增量更新 要破坏的是第一个条件
原始快照 要破坏的是第二个条件
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用
譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
| 问题:在并发标记阶段如何保证收集线程与用户线程互不干扰地运行? |
这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误
该问题的解决办法是
此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上
与 CMS 中的 “Concurrent Mode Failure” 失败会导致 Full GC 类似
如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 “Stop The World”。
去重操作
-XX:+UseStringDeduplication
String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
JDK 8 Update 40 的时候,G1 提供并发的类卸载的支持补全了其计划功能的最后一块拼图。
这个版本以后的 G1 收集器才被 Oracle 官方称为 “全功能的垃圾收集器”(Fully-Featured Garbage Collector)。
在并发标记阶段结束以后,就能知道哪些类不再被使用。
如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
-XX:+ClassUnloadingWithConcurrentMark 默认启用

Region 中一类特殊的 Humongous 区域就是专门用来存储大对象的。上图中的 H 即为 Humongous 区域
图中两个 H 的 incoming 分别为 2 和 3
-XX:InitiatingHeapOccupancyPercent 来设置栈参数
-XX:InitiatingHeapOccupancyPercent 用来设置初始值文档链接:https://docs.oracle.com/en/java/javase/12/gctuning/ (版本自己选啰)
配置好了 jdk 的环境变量后,便可以使用该命令来查看当前环境的虚拟机参数
java -XX:+PrintFlagsFinal -version | findstr "GC"
首先排除减少因为自身编写的代码而引发的内存问题
之后再来查看 FullGC 前后的内存占用,考虑下面几个问题
resultSet = statement.executeQuery("select * from 大表 limit n")(使用 limit 来限制行数)| 数据类型 | 内存占用字节数 |
|---|---|
| byte | 1 |
| short | 2 |
| int | 4 |
| long | 8 |
| float | 4 |
| double | 8 |
| boolean | 1 |
| char | 2 |
新生代的特点
新生代内存越大越好么?当然不是。
# -Xmn
Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery).
GC is performed in this region more often than in other regions.
If the size for the young generation istoo small, then a lot of minor garbage collections are performed.
If the size is too large, then only full garbage collections are performed, which can take a long time to complete.
Oracle recommends that you keep the size for the young generation greater than 25% and less than 50% of the overall heap size.
-XX:MaxTenuringThreshold=threshold:调整最大晋升阈值-XX:+PrintTenuringDistribution:打印晋升的详细信息以 CMS 为例
-XX:CMSInitiatingOccupancyFraction=percent:设置阈值:老年代内存 占 堆内存 的比例JVM 垃圾回收 超详细学习笔记(二):https://blog.csdn.net/weixin_53142722/article/details/125418216
案例 1:Full GC 和 Minor GC 频繁
分析:
解决方法:先使用监测工具查看各个区域的内存使用情况,然后尝试提高新生代的内存空间,以及调高晋升老年代的阈值
案例 2:请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
分析
解决办法:我们可以尝试在重新标记之前进行一次新生代的垃圾回收,减少重新标记阶段的时间
-XX:+CMSScavengeBeforeRemark案例 3:老年代内存充裕情况下,发生 Full GC (jdk1.7 环境下的 CMS)
分析
解决办法:通过参数设置永久代的初始值和最大值
-XX:PermSize、-XX:MaxPermSize