Java基础篇——JVM之GC原理(干货满满)

13次阅读

共计 10467 个字符,预计需要花费 27 分钟才能阅读完成。

原创不易,如需转载,请注明出处 https://www.cnblogs.com/baixianlong/p/10697554.html,多多支持哈!
一、什么是 GC?
GC 是垃圾收集的意思,内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。Java 程序员不用担心内存管理,因为垃圾收集器会自动进行管理。要请求垃圾收集,可以调用下面的方法之一:System.gc() 或 Runtime.getRuntime().gc()。
二、哪些内存需要回收?
哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无非就是那些不可能再被任何途径使用的对象。那么如何找到这些对象?

引用计数法:这种算法不能解决对象之间相互引用的情况,所以这种方法不靠谱
<font color=red> 可达性分析法:这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链(即 GC Roots 到对象不可达)时,则证明此对象是不可用的。</font>

那么问题又来了,如何选取 GCRoots 对象呢?在 Java 语言中,可以作为 GCRoots 的对象包括下面几种:

虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
方法区中的类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI(Native 方法)引用的对象。

下面给出一个 GCRoots 的例子,如下图,为 GCRoots 的引用链,obj8、obj9、obj10 都没有到 GCRoots 对象的引用链,所以会进行回收。

三、四种引用状以及基于可达性分析的内存回收原理

对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。

如果对象在进行可达性分析后发现没有与 GCRoots 相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的 finalize 方法,若对象没有覆盖 finalize 方法或者该 finalize 方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的 finalize 方法,即该对象将会被回收。反之,若对象覆盖了 finalize 方法并且该 finalize 方法并没有被执行过,那么,这个对象会被放置在一个叫 F -Queue 的队列中,之后会由虚拟机自动建立的、优先级低的 Finalizer 线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。

对 F -Queue 中对象进行第二次标记,如果对象在 finalize 方法中拯救了自己,即关联上了 GCRoots 引用链,如把 this 关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那就会被回收。如下代码演示了一个对象如何在 finalize 方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。具体代码如下:
public class GC {
public static GC SAVE_HOOK = null;

public static void main(String[] args) throws InterruptedException {
// 新建对象,因为 SAVE_HOOK 指向这个对象,对象此时的状态是(reachable,unfinalized)
SAVE_HOOK = new GC();
// 将 SAVE_HOOK 设置成 null,此时刚才创建的对象就不可达了,因为没有句柄再指向它了,对象此时状态是(unreachable,unfinalized)
SAVE_HOOK = null;
// 强制系统执行垃圾回收,系统发现刚才创建的对象处于 unreachable 状态,并检测到这个对象的类覆盖了 finalize 方法,因此把这个对象放入 F -Queue 队列,由低优先级线程执行它的 finalize 方法,此时对象的状态变成 (unreachable, finalizable) 或者是(finalizer-reachable,finalizable)
System.gc();
// sleep,目的是给低优先级线程从 F -Queue 队列取出对象并执行其 finalize 方法提供机会。在执行完对象的 finalize 方法中的 super.finalize()时,对象的状态变成 (unreachable,finalized) 状态,但接下来在 finalize 方法中又执行了 SAVE_HOOK = this; 这句话,又有句柄指向这个对象了,对象又可达了。因此对象的状态又变成了 (reachable, finalized) 状态。
Thread.sleep(500);
// 这里楼主说对象处于 (reachable,finalized) 状态应该是合理的。对象的 finalized 方法被执行了,因此是 finalized 状态。又因为在 finalize 方法是执行了 SAVE_HOOK=this 这句话,本来是 unreachable 的对象,又变成 reachable 了。
if (null != SAVE_HOOK) {// 此时对象应该处于 (reachable, finalized) 状态
// 这句话会输出,注意对象由 unreachable,经过 finalize 复活了。
System.out.println(“Yes , I am still alive”);
} else {
System.out.println(“No , I am dead”);
}
// 再一次将 SAVE_HOOK 放空,此时刚才复活的对象,状态变成(unreachable,finalized)
SAVE_HOOK = null;
// 再一次强制系统回收垃圾,此时系统发现对象不可达,虽然覆盖了 finalize 方法,但已经执行过了,因此直接回收。
System.gc();
// 为系统回收垃圾提供机会
Thread.sleep(500);
if (null != SAVE_HOOK) {
// 这句话不会输出,因为对象已经彻底消失了。
System.out.println(“Yes , I am still alive”);
} else {
System.out.println(“No , I am dead”);
}
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println(“execute method finalize()”);
// 这句话让对象的状态由 unreachable 变成 reachable,就是对象复活
SAVE_HOOK = this;
}
}

运行结果如下:
leesf
null
finalize method executed!
leesf
yes, i am still alive :)
no, i am dead : (

由结果可知,该对象拯救了自己一次,第二次没有拯救成功,因为对象的 finalize 方法最多被虚拟机调用一次。此外,从结果我们可以得知,一个堆对象的 this(放在局部变量表中的第一项)引用会永远存在,在方法体内可以将 this 引用赋值给其他变量,这样堆中对象就可以被其他变量所引用,即不会被回收。
四、方法区的垃圾回收
1、方法区的垃圾回收主要回收两部分内容:

废弃常量
无用的类

2、既然进行垃圾回收,就需要判断哪些是废弃常量,哪些是无用的类?

如何判断废弃常量呢?以字面量回收为例,如果一个字符串“abc”已经进入常量池,但是当前系统没有任何一个 String 对象引用了叫做“abc”的字面量,那么,如果发生垃圾回收并且有必要时,“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

如何判断无用的类呢?需要满足以下三个条件:

该类的所有实例都已经被回收,即 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

五、垃圾收集算法(垃圾回收器都是基于这些算法来实现)
1、标记 - 清除(Mark-Sweep)算法
这是最基础的算法,标记 - 清除算法就如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。这种算法的不足主要体现在效率和空间,从效率的角度讲,标记和清除两个过程的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片,内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。标记 - 清除算法执行过程如图:

2、复制(Copying)算法
复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。复制算法的执行过程如图:

不过这种算法有个缺点,内存缩小为了原来的一半,这样代价太高了。现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明 1:1 的比例非常不科学,因此新生代的内存被划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。每次回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 区和 Survivor 区的比例为 8:1,意思是每次新生代中可用内存空间为整个新生代容量的 90%。当然,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。
3、标记 - 整理(Mark-Compact)算法
复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象 100% 存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记 - 整理算法,过程与标记 - 清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。标记 - 整理算法的工作过程如图:

六、垃圾收集器
垃圾收集器就是上面讲的理论知识的具体实现了。不同虚拟机所提供的垃圾收集器可能会有很大差别,我们使用的是 HotSpot,HotSpot 这个虚拟机所包含的所有收集器如图:

上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,那说明它们可以搭配使用。虚拟机所处的区域说明它是属于新生代收集器还是老年代收集器。多说一句,我们必须明确一个观点:没有最好的垃圾收集器,更加没有万能的收集器,只能选择对具体应用最合适的收集器。这也是 HotSpot 为什么要实现这么多收集器的原因。OK,下面一个一个看一下收集器。
Serial 收集器
最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程的收集器,单线程一方面意味着它只会使用一个 CPU 或一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。后者意味着,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。不过实际上到目前为止,Serial 收集器依然是虚拟机运行在 Client 模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。Serial 收集器运行过程如下图所示:

说明:1. 需要 STW(Stop The World),停顿时间长。2. 简单高效,对于单个 CPU 环境而言,Serial 收集器由于没有线程交互开销,可以获取最高的单线程收集效率。
ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和 Serial 收集器完全一样,包括使用的也是复制算法。ParNew 收集器除了多线程以外和 Serial 收集器并没有太多创新的地方,但是它却是 Server 模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作(看图)。CMS 收集器是一款几乎可以认为有划时代意义的垃圾收集器,因为它第一次实现了让垃圾收集线程与用户线程基本上同时工作。ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于线程交互的开销,该收集器在两个 CPU 的环境中都不能百分之百保证可以超越 Serial 收集器。当然,随着可用 CPU 数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 数量相同,在 CPU 数量非常多的情况下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。ParNew 收集器运行过程如下图所示:

Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一个新生代收集器,也是用复制算法的收集器,也是并行的多线程收集器,但是它的特点是它的关注点和其他收集器不同。介绍这个收集器主要还是介绍吞吐量的概念。CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是打到一个可控制的吞吐量。所谓吞吐量的意思就是 CPU 用于运行用户代码时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总运行 100 分钟,垃圾收集 1 分钟,那吞吐量就是 99%。另外,<font color=red>Parallel Scavenge 收集器是虚拟机运行在 Server 模式下的默认垃圾收集器 </font>。
停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率利用 CPU 时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。
虚拟机提供了 -XX:MaxGCPauseMillis 和 -XX:GCTimeRatio 两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。不过不要以为前者越小越好,GC 停顿时间的缩短是以牺牲吞吐量和新生代空间换取的。由于与吞吐量关系密切,Parallel Scavenge 收集器也被称为“吞吐量优先收集器”。<font color=red>Parallel Scavenge 收集器有一个 -XX:+UseAdaptiveSizePolicy 参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden 区和 Survivor 参数等细节参数了,虚拟机会根据当前系统的运行情况以及性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。</font> 如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。
Serial Old 收集器
Serial 收集器的老年代版本,同样是一个单线程收集器,使用“标记 - 整理算法”,这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本,使用多线程和“标记 - 整理”算法。这个收集器在 JDK 1.6 之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 收集器 +Parallel Old 收集器的组合。运行过程如下图所示:

CMS 收集器
<font color=red>CMS(Conrrurent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器 </font>。使用标记 – 清除算法,收集过程分为如下四步:

初始标记,标记 GCRoots 能直接关联到的对象,时间很短。
并发标记,进行 GCRoots Tracing(可达性分析)过程,时间很长。
重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。
并发清除,回收内存空间,时间很长。

其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。运行过程如下图所示:

说明:

对 CPU 资源非常敏感,可能会导致应用程序变慢,吞吐率下降。
无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾,同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。
由于采用的标记 – 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次 Full GC。虚拟机提供了 -XX:+UseCMSCompactAtFullCollection 参数来进行碎片的合并整理过程,这样会使得停顿时间变长,虚拟机还提供了一个参数配置,-XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的 Full GC 后,接着来一次带压缩的 GC。

G1 收集器
G1 算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者 Survivor 空间。老年代也分成很多区域,G1 收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1 完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 cms 内存碎片问题的存在了。
在 G1 中,还有一种特殊的区域,叫 Humongous 区域。如果一个对象占用的空间超过了分区容量 50% 以上,G1 收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。

G1 主要有以下特点:

并行和并发。使用多个 CPU 来缩短 Stop The World 停顿时间,与用户线程并发执行。
分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次 GC 的旧对象,以获取更好的收集效果。
空间整合。基于标记 – 整理算法,无内存碎片产生。
可预测的停顿。能简历可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

在 G1 之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,<font color=red>Java 堆的内存布局与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region 的集合 </font>。
七、CMS 和 G1 对比(过去 vs 未来)
CMS 垃圾回收器
CMS 堆内存结构划分:

新生代:eden space + 2 个 survivor
老年代:old space
持久代:1.8 之前的 perm space
元空间:1.8 之后的 metaspace

<font color=”red”> 注意:这些 space 必须是地址连续的空间 </font>
CMS 中垃圾回收模式

对象分配

优先在 Eden 区分配
在 JVM 内存模型一文中, 我们大致了解了 VM 年轻代堆内存可以划分为一块 Eden 区和两块 Survivor 区. 在大多数情况下, 对象在新生代 Eden 区中分配, 当 Eden 区没有足够空间分配时, VM 发起一次 Minor GC, 将 Eden 区和其中一块 Survivor 区内尚存活的对象放入另一块 Survivor 区域, 如果在 Minor GC 期间发现新生代存活对象无法放入空闲的 Survivor 区, 则会通过空间分配担保机制使对象提前进入老年代(空间分配担保见下).

大对象直接进入老年代
Serial 和 ParNew 两款收集器提供了 -XX:PretenureSizeThreshold 的参数, 令大于该值的大对象直接在老年代分配, 这样做的目的是避免在 Eden 区和 Survivor 区之间产生大量的内存复制(大对象一般指 需要大量连续内存的 Java 对象, 如很长的字符串和数组), 因此大对象容易导致还有不少空闲内存就提前触发 GC 以获取足够的连续空间.

然而取历次晋升的对象的平均大小也是有一定风险的, 如果某次 Minor GC 存活后的对象突增, 远远高于平均值的话, 依然可能导致担保失败(Handle Promotion Failure, 老年代也无法存放这些对象了), 此时就只好在失败后重新发起一次 Full GC(让老年代腾出更多空间).

空间分配担保

在执行 Minor GC 前, VM 会首先检查老年代是否有足够的空间存放新生代尚存活对象, 由于新生代使用复制收集算法, 为了提升内存利用率, 只使用了其中一个 Survivor 作为轮换备份, 因此当出现大量对象在 Minor GC 后仍然存活的情况时, 就需要老年代进行分配担保, 让 Survivor 无法容纳的对象直接进入老年代, 但前提是老年代需要有足够的空间容纳这些存活对象. 但存活对象的大小在实际完成 GC 前是无法明确知道的, 因此 Minor GC 前, VM 会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小, 如果条件成立, 则进行 Minor GC, 否则进行 Full GC(让老年代腾出更多空间).

对象晋升

年龄阈值
VM 为每个对象定义了一个对象年龄 (Age) 计数器, 对象在 Eden 出生如果经第一次 Minor GC 后仍然存活, 且能被 Survivor 容纳的话, 将被移动到 Survivor 空间中, 并将年龄设为 1. 以后对象在 Survivor 区中每熬过一次 Minor GC 年龄就 +1. 当增加到一定程度(-XX:MaxTenuringThreshold, 默认 15), 将会晋升到老年代.

提前晋升: 动态年龄判定
然而 VM 并不总是要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代: 如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 而无须等到晋升年龄.

G1 垃圾回收器
G1 堆内存结构划分(它将整个 Java 堆划分为多个大小相等的独立区域 Region)

G1 中提供了三种垃圾回收模式:young gc、mixed gc 和 full gc

Young GC 发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 eden region 中分配内存,当所有 eden region 被耗尽无法申请内存时,就会触发一次 young gc,这种触发机制和之前的 young gc 差不多,执行完一次 young gc,活跃对象会被拷贝到 survivor region 或者晋升到 old region 中,空闲的 region 会被放入空闲列表中,等待下次被使用。

Mixed GC 当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 mixed gc,该算法并不是一个 old gc,除了回收整个 young region,还会回收一部分的 old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 old region 进行收集,从而可以对垃圾回收的耗时时间进行控制。

Full GC 如果对象内存分配速度过快,mixed gc 来不及回收,导致老年代被填满,就会触发一次 full gc,G1 的 full gc 算法就是单线程执行的 serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 full gc.

八、各种垃圾收集器的选用

首先查看你使用的垃圾回收器是什么?
java -XX:+PrintCommandLineFlags -version

根据自身系统需求选择最合适的垃圾回收器(没有最好的,只有最是适合的)

九、总结

到此 GC 的内存就差不多了,其中不免有些错误的地方,或者理解有偏颇的地方欢迎大家提出来!
关于 GC 更细粒度的调优,没敢妄言,今后有了实战事例在补上!!!

个人博客地址:
csdn:https://blog.csdn.net/tiantuo6513 cnblogs:https://www.cnblogs.com/baixianlong
segmentfault:https://segmentfault.com/u/baixianlong
github:https://github.com/xianlongbai

本文参考:
<font color=”gray”>https://www.cnblogs.com/xiaox…;/font>
<font color=”gray”>https://zhuanlan.zhihu.com/p/…;/font>

正文完
 0