JVM垃圾收集器与内存分配策略(总结自《深入理解Java虚拟机》)

版权声明:转载请注明出处 https://blog.csdn.net/abc123lzf/article/details/83097885

1、对象可用性判断

垃圾收集器在回收对象前,需要判断哪些对象没有被废弃,哪些对象已经废弃了(即无法通过任何途径使用的对象)。所以,垃圾收集器需要一种算法来判定这个对象是否需要回收。

(1)引用计数算法

引用计数算法的基本思想是给一个对象添加一个引用计数器,每当有一个地方引用它时,这个计数器的值就加1,引用失效时,计数器就减去1,当引用计数器的值为0时,就代表这个对象不再可用了。

引用计数算法实现简单,判断效率也比较高,但是它存在一个问题就是无法判断互相引用问题
比如两个对象a和b,a.instance = b,b.instance = a,这个时候两个对象的引用计数器的值都为2,当我们设置 a = null,b = null后,程序已经无法获取到这个对象了,但是此时这两个对象的引用计数器的值为1,意味着它无法被垃圾回收。所以,Java虚拟机没有采用这种垃圾回收算法。

(2)可达性分析算法

很多主流的语言都是通过可达性分析来判断是否存活。这个算法的基本思路就是通过一个称为“GC Root”的对象作为起始点,从这个对象持有的引用开始搜索,所走过的路径称为引用链,当一个对象到GC Root不存在任何引用链,就说明这个对象不可用。

在Java中,可作为GC Root的对象有
1、虚拟机栈帧中的局部变量表中引用的对象。
2、方法区中类的静态属性所持有的变量(即一个类的静态变量)。
3、方法区中常量引用的对象。
4、本地方法栈中native方法引用的对象。

在一个对象被标记为不可达对象后,垃圾收集器并不会尝试去回收它,而是将这个对象作一个标记并进行一次筛选,筛选的条件是该对象所属的类有没有重写finalize方法,如果没有重写finalize方法或者finalize方法已被调用过,那么不会进行筛选。任何一个对象的finalize方法只能执行一次,即使在finalize方法中尝试恢复引用。

(3)Java支持的引用类型

Java支持四种引用类型:强引用、软引用、弱引用和虚引用。

强引用在Java代码中普遍存在,比如 Object a = new Object()中的a就是强引用类型,只要有强引用引用这个对象,那么垃圾收集器不可能回收掉它,即使发生了内存溢出。

软引用用来表示一些有用但是非必要的对象。当JVM管理的内存即将溢出时,才会尝试将仅被软引用关联到的对象进行回收。Java提供了java.lang.ref.SoftReference来支持软引用。

弱引用同样也是用来描述非必须对象的,无论内存是否充裕,仅被弱引用关联的对象只能生存到下次垃圾收集发生之前。Java提供了java.lang.ref.WeakReference,弱引用在JDK代码中普遍存在,比如WeakHashMap,ObjectOutputStream中的序列化缓存,ThreadLocalMap中ThreadLocal类型的键,采用弱引用的目的大多数是为了防止内存泄漏。

虚引用又称幽灵引用,是最弱的一种引用关系,Java提供了java.lang.ref.PhantomReference来支持虚引用。无论是否有强引用关联到这个对象,虚引用对象的get方法都是恒返回null的。它的唯一目的就是当这个对象被收集器回收时得到一个系统通知(即加入到引用队列),所以,在构造PhantomReference必须指定一个ReferenceQueue。


2、垃圾收集算法

(1)标记-清除算法

标记-清除算法很简单,首先标记出所有失去引用的对象,标记结束后再统一释放掉这些内存。
在这里插入图片描述
图片转自:http://www.imooc.com/article/72218?block_id=tuijian_wz

标记-清除算法的不足点有两个:一是效率问题,它的标记和清除两个过程效率不高。另外一个是空间问题,在清除后容易产生很多内存碎片,当程序需要分配一个连续的较大的内存区域存放对象时,就不得不再次进行垃圾回收。

(2)复制算法

为了解决效率问题,复制算法随即出现了,它将可用内存分为大小相等的两块,每次只使用一块内存。当这一块内存使用完后,就启动垃圾回收,将仍然存活的对象复制到另外一块,然后将原来的那一块内存全部回收。这样,内存碎片的问题解决了。当然,如果采用这种1:1的比例来划分内存的缺陷显而易见,就是可用的内存缩小为原来的一半。
在这里插入图片描述
图片转自:http://www.bubuko.com/infodetail-2316435.html

现在很多虚拟机都采用这个算法来回收新生代。对于一般的Java程序来说绝大部分对象都是"朝生夕死"的,即对象构造后很快就失去了引用。所以无需按照1:1的比例来划分内存,只需要将内存划分为一个较大的Eden空间和两块较小的Survivor空间即可,在运行期间每次使用Eden空间和其中一块Survivor空间。垃圾回收时,将Eden和Survivor空间仍然还存活的对象复制到另外一个Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。

Eden空间和Survivor空间都用于存储新生代,Eden空间存放刚刚构造的对象,而Survivor空间存放那些在Eden区中经历了一次垃圾回收后但仍旧存活的对象

HotSpot虚拟机默认采用Eden区和Survivor区8:1的比例来划分内存,这样只有10%的内存会被浪费。如果Survivor空间不够用,那么就需要依赖其它内存进行分配担保

(3)标记-整理算法

根据老年代的特点,有人提出了标记-整理算法。标记过程和标记-收集算法一样,但是后续操作不是对可回收对象直接进行清理,而是让存活对象都向一端移动,然后清理掉边界之外的内存。

在这里插入图片描述
图片转自:https://my.oschina.net/winHerson/blog/114391

(4)分代收集算法

很多商用虚拟机都采用了分代收集算法,这种算法只是根据对象的存活周期将内存划分为几块。一般划分为新生代和老年代。在新生代中,采用复制算法,在老年代中,采用标记-清理算法或者标记-整理算法。


3、HotSpot的垃圾收集策略

(1)枚举根节点

可达性的分析必须需要确保一致性:在整个分析过程中,看起来像是被冻结在一个时间点上,如果在对象的引用状况不断地变动的情况下进行分析,其准确性无法得到保证。所以,GC在进行过程中必须先暂停所有的Java执行线程(称为Stop the world)。

目前Java虚拟机使用的都是准确式GC,所谓准确式GC,就是让虚拟机知道内存中某位置数据的类型什么。比如当前内存位置中的数据究竟是一个整型变量还是一个引用类型。这样虚拟机可以很快确定所有引用类型的位置,从而更有针对性的进行枚举根节点操作。当执行系统停顿后,无需检查所有的执行上下文和全局引用位置,HotSpot通过一种称为OopMap的数据结构来达到这种目的,类加载完成后,HotSpot就会把内存偏移量计算出来,GC在扫描时就可以直接得知这些信息。
在OopMap协助下,HotSpot虚拟机可以快速完成GC Roots枚举。

(2)安全点

如果引用关系变化,或者OopMap变化的指令非常多,如果为每一条指令都生成对应的OopMap会需要大量的空间。实际上HotSpot并没有为每条指令都生成OopMap,只是在特定位置记录这些信息,这个位置成为安全点。程序执行时只有在达到安全点时才会暂停。安全点的选定是以“是否具有让程序长时间执行的特征”为标准进行制定的,例如方法调用、循环跳转、异常跳转等。

还有一个问题就是在GC时如何让所有的线程跑在最近的安全点上停下来,HotSpot采用了主动式中断的策略,其思想是:当GC需要中断线程时,不直接对线程进行操作,仅仅是设置一个标记,各个线程执行时主动轮询这个标志,发现中断标记为真时就将自己暂停。

(3)安全区域

如果有线程处于阻塞或等待状态,就无法相应JVM中断请求,对于这种情况就需要安全区域来解决。安全区域是指一段代码片段中,引用关系不会发生变化,在这个地方开始GC是安全的。

当线程执行到安全区域时,会标记自己进入了安全区域,当JVM需要GC时,就无需理会那些进入安全区域的线程了。当线程离开安全区域时,会检查JVM是否完成了根节点枚举,如果完成了那么线程会继续执行,如果尚未完成那么会将线程暂停直到可以离开安全区域为止。

4、垃圾收集器

GC可以分为两种类型:Minor GC和Major GC/Full GC
Minor GC即新生代GC,指发生在新生代的垃圾收集动作,一般回收速度较快。
Major GC/Full GC为老年代GC,指发生在老年代的GC,出现Major GC之前一般发生了至少一次Minor GC,Major GC一般会比Minor GC 慢10倍以上。

HotSpot一共实现了7种垃圾收集器:Serial收集器、ParNew收集器、Parallel Scavenge收集器、Serial Old收集器、Parallel Old收集器、CMS收集器、G1收集器。

其中,针对新生代垃圾回收的收集器为Serial、ParNew和Parallel Scavenge收集器,针对老年代的收集器为Serial Old、Parallel Old和CMS收集器,G1收集器两者兼具。

这些收集器的搭配关系和适用场景如下:
在这里插入图片描述

(1)新生代收集器:Serial

Serial收集器是最基本的、发展历史最悠久的垃圾收集器,在JDK1.3以前是新生代收集的唯一选择。
Serial收集器是单线程收集器,在它进行垃圾收集时必须暂停所有工作线程直到它运行结束,所以将它使用在服务端可能不是一个好的选择。但是对于桌面应用,一般内存占用不会很大,Serial收集器在管理这些内存时的暂停时间可以控制到几十毫秒,所以对于Client模式下的虚拟机是一个比较好的选择。

(2)新生代收集器:ParNew

ParNew收集器可以看成是Serial收集器的多线程版本,其收集算法、对象分配规则、回收策略和Serial收集器一样。除了Serial收集器,ParNew是唯一一个能和CMS收集器共同使用的垃圾收集器。

(3)新生代收集器:Parallel Scavenge

Parallel Scavenge也是一款使用了复制算法的收集器,也同样采用了多线程回收,它的特别之处它可以根据实际需求控制吞吐量(吞吐量即实际代码执行时间/总执行时间,总执行时间包含了垃圾回收时间)。停顿时间短则可以给用户带来更好的体验,高吞吐量则可以高效率地利用CPU时间,尽快完成运算任务,适合用于后台运算。

Parallel Scavenge提供了两个参数控制吞吐量:控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数和直接设置吞吐量大小的-XX:GCTimeRatio。

GC停顿时间缩短是以牺牲吞吐量和新生代空间换取的。

(4)老年代收集器:Serial Old

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,它主要是用于Client模式下的虚拟机。

(5)老年代收集器:Parallel Old

Parallel收集器就是Parallel Scavenge的老年代版本,使用多线程及其标记-整理算法,同样可以控制吞吐量。它只能和Parallel Scavenge搭配使用。

(6)老年代收集器:CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,它比较适合基于B/S的Java服务端应用这种注重响应速度的程序。CMS收集器基于标记-清除算法,它的运作分为四个部分:
在这里插入图片描述
1、初始标记:初始标记会标记GC Root能关联到的对象,速度很快,但是需要Stop the world
2、并发标记:并发标记就是进行GC Tracing的过程
3、重新标记:重新标记阶段则是为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这一部分停顿时间会稍比初始标记阶段长,需要Stop the world
4、并发清除:清除标记的对象。

CMS收集器的优点在于:可以并发收集并且低停顿。
CMS收集器有3个较为明显的缺点:
1、CMS收集器对CPU资源敏感,虽然它在并发阶段不会导致收集器停顿,但是因为占用了一部分线程而导致应用程序变慢,总吞吐量降低。
2、CMS无法处理浮动垃圾。浮动垃圾是出现在标记过程之后的废弃对象。
3、CMS是基于标记-清除算法的垃圾收集器,在收集结束后会有大量的内存碎片,当无法找到足够大的空间分配大对象时,就会触发Full GC。

关于CMS收集器更多知识点,可以参考这篇博客:
https://blog.csdn.net/zqz_zqz/article/details/70568819

(7)G1收集器

G1收集器是当今收集器最前沿的成果,它是一款面向服务端应用的垃圾收集器,它可以处理新生代和老年代的对象,G1收集器包含以下特点:
1、并行与并发:G1收集器可以充分利用多核CPU,来缩短stop the world的时间
2、分代收集:分代收集概念仍然在G1收集器中得到了保留,但它能够采用不同的方式去处理新生代和老年代对象。
3、空间整合:G1整体来看是基于标记-整理算法实现的收集器,从局部来看是基于复制算法实现的,所以,G1收集器不会产生内存碎片。
4、可预测的停顿:G1相对CMS除了能够很好地降低停顿时间外,还能建立可预测的停顿时间模型。

G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。

G1之所以能够建立可预测的停顿时间模型,在于它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1追踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先队列,每次根据允许的收集时间,优先回收价值最大的Region,这就是G1(Garbage-First)收集器名字的来源。这样有效地保证了G1收集器在有限的时间能够获得尽可能高的收集效率。

G1收集器运作可以分为以下四个步骤:
1、初始标记
2、并发标记
3、最终标记
4、筛选回收

在这里插入图片描述

5、内存分配、回收策略

对象的内存分配简单来说就是在Java堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也直接会在老年代分配。

(1)对象优先在Eden区分配
大多数情况下对象在新生代Eden区分配,当Eden区没有多余的空间时,将发起Minor GC(新生代GC)。

(2)大对象直接进入老年代
所谓大对象是指需要大量连续内存空间的Java对象,典型的大对象就是很长的字符串和数组,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集来获取足够的连续内存空间,所以应当尽量避免使用大对象。

(3)长期存活的对象进入老年代
虚拟机采用分代收集的思想管理内存,那么在内存回收时必须要区分哪些对象在新生代,哪些对象在老年代。如果对象在Eden区域分配后并经历一次Minor GC后仍旧存活,并且Survivor空间能够容纳的话,将被移动在Survivor空间中,并且对象年龄加1,每经历一次Minor GC后仍旧存活,年龄就会增加1,当它的年龄超过一定数值后(默认15),就会变为老年代对象,年龄阈值可以通过参数-XX:MaxTenuringThreshold调整。

为了能够更好地适应不同程序的内存状况,虚拟机并不是一定要到达指定的年龄才能晋升为老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

(4)空间分配担保
在Minor GC前,JVM会检查老年代最大可用的连续空间是否大于新生代所有对象占用的总空间,如果这个条件满足那么Minor GC就是安全的。如果不满足并且HandlePromotionFailure参数允许担保失败,那么继续检查老年代最大可用的连续空间是否大于晋升到老年代对象的平均大小,如果大于则尝试进行Minor GC。如果小于或者HandlePromotionFailure设置不允许担保失败,那么进行Full GC。

猜你喜欢

转载自blog.csdn.net/abc123lzf/article/details/83097885