继上一篇 JVM学习之路2-对象内存布局及逃逸分析 介绍完jvm相关对象在内存中如何布局、如何进行访问以及jvm进行逃逸分析并做优化之后,本篇准备聊一下jvm最关键的一个点(也是现实中遇到问题最多的点)垃圾回收,本篇相对比较长,大家可以只看自己需要看的部分。
知识点
1、垃圾回收机制
2、垃圾收集算法及常见的垃圾收集器

垃圾回收机制

顾名思义,垃圾回收就是对垃圾进行回收,但是jvm中哪些属于"垃圾"呢?无用的对象。大概整理了下目前主流的gc流程:
image.png
jvm判断对象是否可以被回收主要有两种算法,引用计数法和可达性分析。下面分别来介绍一下。

引用计数法

在对象中添加一个计数器,用来统计对象被引用的次数,每次对象被引用一次就加1,引用减少一次就减1,当引用次数为0的时候就说明对象没有被使用,那就可以销毁了。

可达性分析

可达性分析也比较简单,就是有一系列作为"gc root"的对象作为起始点,由该节点开始向下查找,当一个对象到gc root没有任何引用链链接时就认为该对象可以被回收了,目前主流的虚拟机都是采用这种方式进行回收判定。以下对象可以作为gc root:
1、虚拟机栈中引用的对象;
2、方法区中类静态属性引用的对象;
3、方法区中常量引用的对象;
4、本地方法栈中引用的对象;
要注意一点,正常来说对象不可达都会被回收,但是对象也并非"非死不可",我们可以通过重载finalize()对对象再引用一次,即可阻止对象被回收(不建议这么做)。

四种引用类型

在谈到对象是否被引用的时候,我们要讲一下java中的4种引用类型:强引用、弱引用、软引用、虚引用;

强引用

只要对象还被引用,那就不会被gc所回收。这个是我们大多数时候在用的,举个例子:

A a = new A();

这个a就是一个强引用。

弱引用

如果一个对象只有弱引用在引用它,那么下次gc的时候就会被回收。这个平时我们代码中用得不多,但是在看源码的时候会发现用得还是比较多的(ThreadLocal就是用得弱引用key的管理),这也是一种内存对象的管理方式。举个例子:

ReferenceQueue<Employee> referenceQueue = new ReferenceQueue<>();
WeakReference<A> weakA = new WeakReference<>(new A());
WeakReference<A> weakA = new WeakReference<>(new A(), referenceQueue);

上述代码中的weakA就是一个弱引用,在下次gc的时候就会被回收。

软引用

如果一个对象只有软引用,在内存即将不够的时候,会将这些对象进行回收。举个例子:

ReferenceQueue<Employee> referenceQueue = new ReferenceQueue<>();
SoftReference<A> softEmployee = new SoftReference<>(new A());
SoftReference<A> softEmployee = new SoftReference<>(new A(),referenceQueue);
虚引用

该引用可以认为没有被引用,也无法通过该引用获取到对象实例,总得来说该引用只有一种作用,就是在对象被回收的时候收到系统通知。举个例子:

PhantomReference<Employee> phantomEmployee = new PhantomReference<>(new Employee(), referenceQueue);

可以看出虚引用只有一个构造函数,并且需要传递引用队列,引用队列的作用就是在对象被回收之后能在引用队列里找到该引用(不是该对象)。当然弱引用、软引用都可以用到引用队列。

分代收集

在介绍内存模型中堆的概念的时候,说到堆上的空间分为新生代、老年代(默认1:2),新生代又分为eden区、to区、from区(默认8:1:1),有一张图已经比较清晰的展现了不同代的情况,这里把相关的概念介绍下,后面分析gc日志的时候还是比较有用的。
新生代收集(Minor GC/Young GC):对于新生代空间上的垃圾进行回收。
老年代收集(Major GC/Old GC):对于老年代空间上的垃圾进行回收。Major GC有些资料也指整堆收集。
整堆收集(Full GC):收集所有堆和方法区。
对于大对象,我们知道是直接进入老年代的,所以代码中尽量避免大对象:
image.png

收集算法

针对不同的分代,有不同的收集算法适配,目前常见的垃圾收集算法有三种:标记-清除、标记-复制、标记-整理。

标记-清除

最早最基础的算法,非常简单,对于满足回收条件的对象进行标记,然后在回收的时候对带标记的对象进行回收。
缺点:
1、效率低,存在大量对象时就会有大量的标记和清除动作;
2、内存碎片,会产生大量不连续的内存碎片,导致后面分配内存可能因为连续空间不够导致需要full gc;
image.png

标记-复制

该算法也比较简单,就是将可用内存分为相等的两块,一块为在用的内存,一块为空闲内存,当在用内存空间不够分配的时候,就会将该内存中存活的对象复制一份到空闲内存中,然后对在用内存进行清理。
缺点:
1、内存浪费,由于存在空闲内存,最多可能会浪费50%内存空间;
2、对象存活率较高时会有大量复制,存在效率问题;
image.png
目前主流的虚拟机对于新生代的回收都是采用该算法。

标记-整理

和标记-清除很像,只不过是在清除完后会对空间进行整理,以保证内存空间的连续,减少内存碎片。
缺点:
1、存活对象较多时候,内存空间整理会比较耗性能,造成卡顿;
image.png

垃圾收集器

垃圾收集器就是垃圾收集算法的实践者。直接引用书里的一张图,大概了解一下对于不同区目前有哪些收集器以及如何搭配使用。
image.png
存在连线的说明是可以搭配使用的收集器。下面我们逐一介绍这些收集器。
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、Parallel Old、CMS
整堆收集器:G1

Serial收集器

顾名思义,单线程收集器,会有一个额外线程对垃圾进行收集,在收集的过程中会暂停其他所有工作线程。
缺点:体验差,会造成程序卡顿;
优点:简单高效、内存消耗少;
算法:标记-复制;
使用场景:对于新生代只需要几百兆内存的应用,卡顿时间差不多在几十上百毫秒,非频繁收集下是可以接受的。应用于客户端应用。
设置参数:-XX:+UseSerialGC

ParNew收集器

其实就是Serial的多线程版本,在单核系统下相比Serial没有优势,多核下就有优势,会有多个线程同时进行垃圾回收,优缺点和Serial一样。
使用场景:配合CMS进行使用,应用于服务端应用。
设置参数:
"-XX:+UseParNewGC":强制指定使用ParNew;
"-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
"-XX:ParallelGCThreads":指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;

Parallel Scavenge收集器

与ParNew类似,但是关注点在吞吐量而不在卡顿,该收集器与其老年代版本为jdk8默认收集器。
吞吐量=处理器用于运行用户代码的时间/处理器总消耗时间的比值,也就是尽量让处理器运行用户代码。该收集器支持通过参数设置自适应策略,虚拟机会自动进行调优。
算法:标记-复制。
使用场景:后台计算类应用。
设置参数:
"-XX:MaxGCPauseMillis":控制最大垃圾收集停顿时间,大于0的毫秒数,MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降,因为可能导致垃圾收集发生得更频繁;
"-XX:GCTimeRatio":设置垃圾收集时间占总时间的比率,0<n<100的整数,GCTimeRatio相当于设置吞吐量大小,计算方法是1 / (1 + n)
,默认值99;
"-XX:+UseAdptiveSizePolicy":开启Jvm自适应策略,JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量;

Serial Old收集器

Serial收集器的老年代版本,没什么好多说的。
算法:标记-整理。

Parallel Old收集器

Parallel Scavenge收集器的老年代版本,传说中的吞吐量组合,没什么好多说的。
算法:标记-整理。

CMS收集器

全称Concurrent Mark Sweep,以低停顿作为目标的收集器,基于标记-清除算法,整体分为4个过程:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记是需要停止所有用户线程的,但是耗时较短。默认收集线程数为(cpu核数+3)/4个。
优点:收集效率高,低停顿。
缺点:内存碎片,cpu数量少于4个情况下对应用影响较大,无法处理浮动垃圾(并发标记和清除过程中用户线程在运行又产生的垃圾)。
应用场景:对于响应性能要求高的系统。
设置参数:
"-XX:+UseConcMarkSweepGC":指定使用CMS收集器;

G1收集器

全称Garbage-First,该收集器的使命是比较重的,一个里程碑的收集器,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,不再区分新生代和老年代。什么是Region?可以理解为一块连续的内存区域,一个堆分为大小相等的N个Region。G1就是基于Region进行垃圾回收,Region既会扮演新生代eden、survivor区,又会扮演老年代区。用Humongous区存放大对象(对象大小超过Region一半,一个不够则用多个Region放)。
大致堆分布变成这样(引用自参考资料第四个博客):
image.png
基本上和上面提到的垃圾收集器实现完全不一样了,不需要回收新生代整个区或者老年代整个区,G1内部会维护一个回收的优先级列表,在目标时间周期内只要把列表中"价值"(可回收空间以及所需时间)最大的Region回收过来就行。
回收步骤:
1、初始标记,需要停顿,耗时短。
2、并发标记,无需停顿。
3、最终标记,需要停顿,耗时短。
4、筛选回收,对各个Region的回收价值和成本进行排序,制定具体的回收计划,无需停顿。
G1虽然好,但是我们也不是一定要用G1,还是得结合具体场景和实际情况来选择;
优点:延迟可控,高吞吐,没有内存碎片,不需要和其他收集器搭配使用。
缺点:为了解决对象跨Region引用问题,需要记忆集进行记录引用关系,需要额外内存开销。
算法:整体看集于标记-整理算法进行收集,局部看基于标记-复制算法进行收集。
应用场景:面向服务端应用,针对具有大内存、多处理器的机器。
设置参数:
"-XX:+UseG1GC":指定使用G1收集器。
"-XX:InitiatingHeapOccupancyPercent":当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45。
"-XX:MaxGCPauseMillis":为G1设置暂停时间目标,默认值为200毫秒。
"-XX:G1HeapRegionSize":设置每个Region大小,范围1MB到32MB,要为2的N次幂;目标是在最小Java堆时可以拥有约2048个Region。
"-XX:ConcGCThreads":并发GC使用的线程数。

总结

本篇内容比较多又比较长,花了不少时间来写,基本上说清了jvm的垃圾回收机制,包括对4种引用类型也做了相关使用介绍,对于垃圾收集的算法以及主流的7种收集器也做了较为详细的说明,当然现在还有ZGC、Shenandoah这些更牛逼的收集器,但是用的不多,就不花时间整理介绍了,有需要大家可以再自己找一下资料学习。后面有新的收获会再进行补充,下一篇会介绍Jvm的类加载机制,在系列文最后会写调优案例,到时候会结合相关案例来实践前面的理论。

参考资料:
周志明《深入理解Java虚拟机》
https://www.cnblogs.com/jswan...
https://www.cnblogs.com/cxxjo...
https://blog.csdn.net/pedro7k...


爱炒股的程序猿
50 声望4 粉丝

每天进步一点点