思维导图

前言

Java相对于C/C++语言来说,最明显的特点在于Java引入了自动垃圾回收。垃圾回收(Garbage Collection简称GC)可以使程序员不在需要关心JVM内存管理的问题,专注于写程序本身。平时程序员是很难感知到GC的存在,但是如果涉及到一些性能调优,线上的问题排查等等,深入地了解GC是必不可少的。往往通过一些JVM参数的设置能就使系统性能提高不少。

一、JVM内存区域

要深入了解GC,首先要明白GC会回收哪些数据,数据位于哪个区域。接着我们看一下JVM的内存区域。

从图中可以看出,内存区域分为五个:

  • 虚拟机栈:线程私有,由一个个栈帧组成,每个栈帧对应着一个调用的方法,保存有方法的局部变量等信息。方法被调用时栈帧入栈,方法结束调用时栈帧出栈。入栈出栈的时机很清楚,所以不需要进行GC。
  • 本地方法栈:与虚拟机栈非常类似,本地方法栈与虚拟机栈的区别在于,虚拟机栈执行的是Java方法,本地方法栈执行的是本地方法(Native Method)。这块区域也不需要进行GC。
  • 程序计数器:线程私有的,它的作用可以看做是当前线程所执行的字节码的行号指示器。我们知道JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会被挂起,而另一个线程获取到时间片开始执行。在JVM中,就是通过程序计数器来记录某个线程的字节码执行位置,当被挂起的线程重新获取到时间片的时候,就知道上次被挂起时执行到哪个位置了。这块区域也不需要GC。
  • 方法区:在Java8之前有永久代的概念,在堆中实现,受GC的管理,主要存储类的信息,常量,静态变量,由于永久代有 -XX:MaxPermSize 的上限,所以很容易造成 OOM。在Java8之后,永久代被移除,然后把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。所以Java8以后,方法区也不需要GC。
  • 堆:堆是Java对象的存储区域,任何new字段分配的Java对象实例和数组,都被分配在了堆上。GC主要作用于这个区域,对这两类数据进行回收。

二、如何判断对象是否可回收

上面讲了GC主要作用的区域是在堆中,那么又是怎么判断是否可以回收的呢?在GC里面有两种算法来判断,一种是引用计数,对象引用的次数为0就是垃圾,另一种是可达性算法,如果一个对象不在以GC Root根节点为起点的引用链中,则视为垃圾。

2.1 引用计数算法

首先看引用计数法,简单点说对象被引用,就会在此对象的对象头上计数器加一,每当有一个引用失效时计数器的值减一,如果没有引用(引用次数为0)则此对象可回收。但是这种算法很难解决对象之间互相循环引用的问题。

2.2 可达性算法

所谓的GC Roots就是一组必须活跃的引用,基本思路就是从一系列的GC Root一直往下搜索,通过GC Root串成的一条线称为引用链,如果有对象不在任何一条以GC Root为起点的引用链中,则此对象就会被GC回收,这就是可达性算法。

哪些对象可作为GC Root对象呢:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

三、常见的垃圾回收算法

上面已经讲了如何判断哪些对象时可回收的。那么判断完是否可回收后,GC又是使用什么算法进行回收的呢?这就要讲一讲垃圾回收的几种方式:

  • 标记清除法
  • 标记整理法
  • 复制算法
  • 分代收集算法

3.1 标记清除法

其实很简单,分为标记清除两个步骤。第一步根据可达性算法标记被回收的对象,第二步回收被标记的对象。

明显这种垃圾回收算法的缺点是很容易产生内存碎片。

3.2 标记整理法

前面两个步骤和标记清除算法一样,而不同的是在标记清除算法的基础上多了一步整理的过程。如图所示,整理步骤的时候,将所有存活的对象都往左边移动,然后清理另一端的所有区域,这样就不会产生内存碎片。

虽然不会产生内存碎片,但是由于频繁地移动存活的对象,所以效率十分低下。

3.3 复制算法

把内存分成两份,分别是A区域和B区域,第一步根据可达性算法把存活的对象标记出来,第二步把存活的对象复制到B区域,第三步把A区域全部清空。这就是复制算法。

复制算法不会产生内存碎片,并且不需要频繁移动存活的对象,而缺点就是内存利用不充分,比如一块500M的内存,要分成两份,只能利用到250M。

3.4 分代收集算法

分代搜集算法是针对对象的不同特性,而使用适合的算法,这里面并没有实际上的新算法产生。与其说分代收集算法是第四个算法,不如说它是对前三个算法的实际应用。

首先我们先探讨一下对象的不同特性,内存中的对象其实可以根据生命周期的长短大致分为三种:

  • 夭折对象(新生代):朝生夕死的对象,比如方法里的局部变量。
  • 持久对象(老年代):存活的比较久但还是要死的对象,比如缓存对象,单例对象等等。
  • 永久对象(永久代):对象生成后几乎不灭的对象,例如String池中的对象(享元模式)、加载过的类信息等等。

上述的对象对应在内存中的区域就是,夭折对象和持久对象在Java堆中,永久对象在方法区。

分代算法的原理就是根据对象的存货周期不同将堆分为年轻代和老年代。新生代又分为Eden 区,from Survivor 区(S0区),to Survivor 区(S1区),比例为8:1:1。

先看年轻代的GC,年轻代采用的回收算法是复制算法。新建的对象被创建后就会分配在Eden 区,当Eden区将满时,就会触发GC。

在这一步GC会把大部分夭折对象回收,根据可达性算法标记出存活的对象,把存活对象复制到S0区,然后清空Eden 区。

接着继续到下一次触发GC时,就会把Eden区和S0区的存活对象复制到S1区,然后清空Eden区和S0区。每次垃圾回收后S0和S1区的角色互换。每次GC后,如果对象存活下来则年龄加一。

我们知道在年轻代中存活得越久的对象,年龄会越大,如果存活对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代。由于老年代的对象一般不会经常回收,所以采用的算法是标记整理法,老年代的回收次数相对较少,每次回收时间比较长。

四、Stop the world

Java中Stop The World机制简称STW,执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集器之外),当垃圾回收完成后,再继续运行,所以尽量减少STW的时间,就是优化JVM的主要目标。

五、常见的垃圾收集器

垃圾收集器其实就是上面讲的算法的具体实现,目前没有说哪个垃圾收集器是最好的,只有根据应用的特点选择最合适的,所以说合适的才是最好的。

常见的垃圾收集器除了G1垃圾收集器外,都是只作用于一个区域,要么年轻代要么老年代,所以一般是配合使用,总共有7种,怎么配合使用,请看下面这张图,有连线的就是可以配合使用的。

5.1 Serial收集器

Serial收集器作用于年轻代,单线程的垃圾收集器,单线程意味着它只会使用一个CPU或者一个线程去完成垃圾回收的工作,当它在垃圾回收时,由于SWT机制,其他工作线程都会被暂时挂起,直到垃圾回收完成。这种垃圾收集器适用于Client模式的应用,在单CPU的环境下,由于没有和其他线程交互的开销,可以专心垃圾回收的工作,能够把单线程的优势发挥到极致,简单高效。通过-XX:+UseSerialGC可以开启这种回收模式。

5.2 ParNew收集器

ParNew 收集器是Serial收集器的多线程版本,作用于年轻代,默认开启的收集线程数和cpu数量一样,运行数量可以通过修改ParallelGCThreads设定。

5.3 Parallel Scavenge收集器

Parallel Scavenge收集器也被称为吞吐量优先收集器,作用于年轻代,多线程采用复制算法的垃圾收集器,跟ParNew 收集器有些类似。和ParNew 收集器不同的是,Parallel Scavenge收集器关注的是吞吐量,它提供了两个参数来控制吞吐量,分别是-XX:MaxGCPauseMillis(控制最大的垃圾收集停顿时间)、 -XX:GCTimeRatio(直接设置吞吐量大小)。

如果设置了-XX:+UseAdaptiveSizePolicy参数,虚拟机就会根据系统的运行情况收集监控信息,动态调整新生代的大小,Eden,Survivor比例等,以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标,这种调节方式称为GC的自适应调节策略。这也是Parallel Scavenge收集器和ParNew 收集器最大的区别。

5.4 Serial Old收集器

Serial Old 收集器是工作在老年代的单线程垃圾收集器,采用的算法是标记整理算法。在Client模式下可以和Serial收集器配合使用,如果在Server模式的应用,在JDK1.5之前可以和Parallel Scavenge收集器配合使用,另一种使用场景则是CMS垃圾收集器的后备预案,在发生Concurrent Mode Failure使用。

5.5 Parallel Old收集器

Parallel Old 收集器是Parallel Scavenge收集器的老年代版本,多线程收集,采用标记整理算法。下图是Parallel Scavenge收集器和Parallel Old 收集器配合工作的过程图。

5.6 CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,采用标记-清除算法。适用于希望系统停顿时间短,给用户更好的体验的场景。

CMS收集器运行时主要分为四个步骤:

  • 初始标记:标记GC Roots能直接关联的对象。存在Stop The World。
  • 并发标记:GC Roots Tracing,可以和用户线程并发执行。
  • 重新标记:标记期间产生的对象存活的再次判断,修正对这些对象的标记,执行时间相对并发标记短,存在Stop The World。
  • 并发清除:清除对象,可以和用户线程并发执行。

CMS收集器的缺点在于:

  • 对CPU资源比较敏感。
  • 无法处理浮动垃圾。可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生,由于在并发清理时用户线程还在运行,所以清理垃圾同时新的垃圾也会不断产生,这部分垃圾(即浮动垃圾)只能在下一次 GC 时再清理掉。
  • 采用的是标记清除算法,所以会产生内存碎片。内存碎片会导致大对象无法分配到连续的内存空间,然后会产生Full GC,影响应用的性能。

5.7 G1收集器

G1垃圾回收器主要是面向服务端的垃圾回收器,年轻代和老年代都可使用。运作时,整体上采用标记整理算法,局部上看是采用复制算法,两种算法都不会产生内存碎片,所以回收器在回收后能产生连续的内存空间。

它是专门针对以下场景设计的:

  • 像CMS收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要GC停顿时间更好预测。
  • 不希望牺牲大量的吞吐性能。
  • 不需要更大的Java Heap。

G1垃圾回收器的内存分区不再采用传统的内存分区,将新生代,老年代的物理空间划分取消了。

取而代之的是,把堆内存分成若干个Region(区域),每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生的STW。G1垃圾回收器和传统的垃圾回收器的最大区别就在于,弱化了分代概念,引入了分区的思想

G1中每代的存储地址都不是连续的,而是使用了不连续的大小相同的Region。除此之外G1中还多了一个H,H代表Humongous,用于存储巨大对象(humongous object),当对象大小大于等于region一半的对象,就直接分配到了老年代,防止了反复拷贝移动。

G1垃圾回收过程可分为四步:

  • 初始标记。收集所有GC根(对象的起源指针,根引用),STW,在年轻代完成。
  • 并发标记。标记存活对象。
  • 最终标记。是最后一个标记阶段,STW,很短,完成所有标记工作。
  • 筛选回收。回收没有存活对象的Region并加入可用Region队列。

总结

本文的简述了JVM的垃圾回收的理论知识,思路是先搞懂GC作用的区域是在堆中,然后介绍可达性算法的作用是为了标记存活的对象,知道哪些是可回收对象,接着就是使用垃圾回收算法进行回收,然后介绍了常见的几种垃圾回收算法(标记清除,复制算法,标记整理),最后再介绍常见的几种垃圾回收器。

对于垃圾回收器的介绍,这里只是简单的描述,并没有深入地讲解,因为每一个垃圾回收器如果展开细述都能讲上半天,所以有兴趣的话,可以自己再去探索一下,个人认为CMS和G1垃圾回收器是比较重要的两种。

这篇文章就讲到这里了,希望看完之后能对你有所帮助,感谢大家的阅读。

觉得有用就点个赞吧,你的点赞是我创作的最大动力~

我是一个努力让大家记住的程序员。我们下期再见!!!

能力有限,如果有什么错误或者不当之处,请大家批评指正,一起学习交流!



JVM 垃圾回收

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!