1、如何判断对象为垃圾对象(能够被回收)
1.1 引用计数法
给对象添加一个引用计数器,每被引用一次,计数器的值就加1,失去引用,计数器的值减1。当计数器在一段时间内保持为0时,该对象就认为是可以被回收的(在JDK1.2之前,使用的是该算法)。但是这种算法在当前的JVM中并没有采用,原因是它不能解决对象之间循环引用的问题。
假设有A和B两个对象之间互相引用,也就是说A对象中的一个属性是B,B中的一个属性是A。
这种情况下由于他们的相互引用,导致它们的引用计数永远都不会为0,也就永远无法通知GC收集器回收它们。
举例说明,看看相互引用的情况下,对象是否会被回收,如果被回收了,则说明当前使用的不是“引用计数法”
package com.demo.other;
public class A {
private Object instance;
public A() {
byte[] m = new byte[20 * 1024 * 1024];
}
public static void main(String[] args) {
A a1 = new A();
A a2 = new A();
a1.instance = a2;
a2.instance = a1;
a1 = null;
a2 = null;
System.gc();//提醒JVM进行垃圾回收
}
}
配置运行参数:-verbose:gc -XX:+PrintGCDetails,将GC日志打印出来
运行日志表明进行了垃圾回收PSYoungGen: 46169K->1240K(75776K),说明我们的JVM使用的不是“引用计数法”
1.2 可达性分析算法
通过可达性算法,成功解决了引用计数所无法解决的循环依赖问题。可达性分析法就是从GC ROOT结点开始向下搜索,搜索走过的路径称为引用链。当所有的引用节点寻找完毕之后,剩余的节点就是到GC Roots没有任何引用链相连的节点(意味着GC Roots到这个对象不可达),就认为这个对象是垃圾,可以进行回收。
Java内存区域中可以作为GC ROOT的对象:
- 虚拟机栈(局部变量表)
- 方法区的类属性所引用的对象。
- 方法区中常量所引用的对象。
- 本地方法栈中引用的对象。
目前主流JVM采用的垃圾判定算法就是可达性分析法。
在可达性分析中,如果没有某个对象没有任何引用,它也不一定会被回收掉。
2、垃圾回收算法
在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收。但是这里面涉及到一个问题是:如何高效地进行垃圾回收。这里我们讨论几种常见的垃圾收集算法的核心思想。
2.1 标记-清除算法
标记-清除(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段;首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。可以说是最基础的收集算法,后续的回收算法都是基于这种思路并对其缺点进行改进而得到的。
标记-清除(Mark-Sweep)算法,它在效率、空间上的特点是:
- (1)效率问题:标记和清除过程的效率都不高,需要遍历一遍所有对象;
- (2)空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致大对象无法分配到足够的连续内存,从而不得不提前触发GC,甚至Stop The World。
2.2 复制算法
为解决标记-清除算法的效率问题,复制算法(Copying)出现了。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把当前这块的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
复制算法(Copying),它在效率、空间上的特点是:
- (1)效率问题:在对象存活率较高时,复制操作次数多,效率较低;在对象存活率较低时,存活的对象占比少数的情况下,却比较适合这种算法
- (2)空间问题:內存缩小了一半;需要额外空间开销做分配担保,存在空间浪费的情况,可以说是以空间交换时间的一种算法
2.3 标记-整理算法
标记整理算法与标记清除算法很相似,但显著的区别是:标记清除算法仅对不存活的对象进行处理,剩余存活对象不做任何处理,这就造成了内存碎片的问题;而标记整理算法不仅对不存活的对象进行清除,还对存活的对象进行重新整理,让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域,因此不会产生内存碎片的问题。
“标记-整理”(Mark-Compact)算法,它在效率、空间上的特点是:
- (1)效率问题:与标记-清除(Mark-Sweep)算法相同,标记和清除过程的效率都不高,需要遍历一遍所有对象;
- (2)空间问题:与标记-清除(Mark-Sweep)算法不同,通过花费额外的开销,将保留的存活对象进行移动,确保剩余的空间都是连续的。
2.4 分代收集算法
分代收集算法(Generational Collection)分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据JVM对象存活周期的不同将内存划分为几块,分别采取不同的垃圾回收机制。
在新生代中:每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
在老年代中:因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或者标记-整理算法来进行回收。
3、内存区域与回收策略
3.1 对象优先在Eden分配
大多数情况下,对象会在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机会发起一次 Minor GC。Minor GC相比Major GC更频繁,回收速度也更快。通过Minor GC之后,Eden区中绝大部分对象会被回收,而那些存活对象,将会送到Survivor的From区(若From区空间不够,则直接进入Old区) 。
3.2 Survivor区
Survivor区相当于是Eden区和Old区的一个缓冲,类似于我们交通灯中的黄灯。Survivor又分为2个区,一个是From区,一个是To区。每次执行Minor GC,会将Eden区中存活的对象放到Survivor的From区,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。(From Survivor和To Survivor的逻辑关系会发生颠倒: From变To , To变From,目的是保证有连续的空间存放对方,避免碎片化的发生)
Survivor区存在的意义
如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次Minor GC没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生。Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
3.3 大对象直接进入老年代
所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。
虚拟机提供了一个XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用的是复制算法)。
3.4 长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中(正常情况下对象会不断的在Survivor的From与To区之间移动),并且对象年龄设为1。对象在Survivor区中每经历一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 XX:MaxPretenuringThreshold 设置。
3.5 动态对象年龄判定
为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到 MaxPretenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于改年龄的对象就可以直接进入老年代,无需等到MaxPretenuringThreshold中要求的年龄。
4、垃圾收集器
4.1 新生代收集器
4.1.1 Serial
Serial收集器是一个新生代收集器,单线程执行,使用复制算法。当Serial进行垃圾收集时,必须要暂停其他所有线程的工作,直到垃圾收集完成,这个动作叫STW(Stop The World) 。而且这个暂停动作对用户来说是不可见的,用户可能只会知道某个请求执行了很久,没有经验的话是很难跟GC挂上钩的。
但是从某些方面来看,如果你的系统就只有单核,那么Serial就不会存在线程之间的交互的开销,可以提高GC的效率。这也是为什么Serial仍然是JVM Client模式下的默认新生代收集器。
4.1.2 ParNew
ParNew相当于是Serial收集器的多线程版本。除此之外,其使用的垃圾收集算法和收集行为完全一样。单CPU不如Serial,因为单核无法发挥多线程的优势。存在线程交互的开销。
-XX:+UseParNewGC 新生代并行(ParNew),老年代串行(Serial Old)
-XX:ParallelGCThreads=N 设置并行收集器收集时使用的CPU数。并行收集线程数。一般最好和计算机的CPU相当
4.1.3 Parallel Scavenge
-XX:+UseParallelGC 新生代使用并行回收收集器,老年代使用串行收集器
Parallel Scavenge是一个多线程的收集器,也是在Server模式下的默认垃圾收集器。上面的两种收集器关注的重点是如何减少STW的时间,而Parallel Scavenge则更加关注于系统的吞吐量。
吞吐量 = (执行用户代码消耗的时间)/(执行用户代码的时间 + 垃圾回收时所占用的时间)
例如JVM已经运行了100分钟,而GC了1分钟,那么此时系统的吞吐量为(100 - 1)/100 = 99%。
##两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis 垃圾收集器的停顿时间
-XX:GCTimeRatio 直接设置吞吐量大小
4.2 老年代收集器
4.2.1 Serial Old
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用的标记-整理算法。
Serial Old也是给JVM Client模式下使用的。
4.2.2 Parallel Old
Parallel Old是Parallel Scavenge的老年代版本,也是一个多线程的、采用标记-整理算法的收集器,刚刚讨论过了系统吞吐量,那么在对CPU的资源十分敏感的情况下, 可以考虑Parallel Scavenge和Parallel Old这个新生代-老年代的垃圾收集器组合。
4.2.2 CMS
CMS全称(Concurrent Mark Sweep),使用的是标记-清除算法。重点关注于最低的STW时间的收集器,如果你的应用非常注重响应时间,那么就可以考虑使用CMS。
4.2.3 G1
G1全称Garbage First,业界目前对其评价很高,G1是一款面向服务端应用的垃圾收集器。JDK9中甚至提议将其设置为默认的垃圾收集器。我们前面讲过,Parallel Scavenge更加关注于吞吐量,而CMS更加关注于更短的STW时间,那么G1就是在实现高吞吐的同时,尽可能的减少STW的时间。
4.2 总结
各收集器间的搭配关系,分为两块,上面为新生代收集器,下面是老年代收集器,有连线则说明可以一起使用。
一张表格做总结
收集器 | 串行or并行or并发 | 新生代or老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU,JVM Client模式 |
Serial Old(MSC) | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU,JVM Client模式,CMS的后备方案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU,JVM Server模式,与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算且不需要太多交互的场景 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算且不需要太多交互的场景 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统,服务端上的场景 |
G1 | 并发 | 新生代&老年代 | 标记-整理&复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |