谈到垃圾回收(Carbage Collection,简称GC),GC中的垃圾,特指存于内存中、不会再被使用的对象,而回收就是把这么垃圾清除。垃圾回收有很多算法,如引用计数法,标记整理法,复制算法,分代,分区等。
3.1 判断对象是否被引用
3.1.1 引用计数算法
给对象添加一个引用的计数器,每当有一个地方引用它时,计数器值就+1,当引用失效时,计数器-1,任何时刻计数器为0的对象就是不可能再被使用的。
客观的说,引用计数算法(Reference Counting)的实现很简单,判定效率也高,在大部分情况下它都是不错的算法,也有一些比较著名的应用案例。但是,至少主流的 java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的因是它很难解决对象之间相互循环引用的问题。
3.1.2 可达性分析算法
在主流的商用程序语言(java、C#、Lisp)的主流实现中,都是通过可达性分析来判定对象是否存活的。
这个算法的基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始想下搜索,搜索所走过的路径称为引用连(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明对象不可用。
在java语言中,可作为GC Roots 的对象包括下面几种:
- 虚拟机栈中的引用对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
3.2 垃圾回收算法
3.2.1 标记-清除算法
这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。然而,这种方法存在最大的弊端,就是空间碎片问题,垃圾回收后的空间不是连续的,不连续的内存空间的工作效率要低于连续的内存空间。
3.2.2 复制算法 (新生代使用)
为解决效率问题,复制算法出现了,它将可用内存按容量划分为大小相等的两块(s0和s1区)。当这一块内存用完了,就将还存活的对象复制到另外块上面,然后再把已使用过的内存空间一次清理掉。这样子使得每次都是对整个半区进行垃圾回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是这种算法对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
Hotspot 虚拟机新生代将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
Hotspot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费。
3.2.3 标记-整理法(老年代使用)
为了解决复制算法的缺陷,充分利用内存空间,提出了标记-整理算法。该算法标记阶段和标记-清除法一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
3.2.4 分代算法
分代算法就是根据对象的特点把内存分为N块,然后根据没个内存的特点使用不同的算法。
例如:java堆中分为新生代和老年代,这样子可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时发现有大批量的对象死去,只有少量的存活,那就选用复制算法,只需付出少量的存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就使用标记-整理法来回收。
对于新生代和老年代来说,新生代回收频率较高,但是每次回收耗时很短,而老年代回收频率较低,但是耗时相对较长,所以应该尽量减少老年代的GC。
3.3 垃圾回收时的停顿现象
垃圾回收器的任务是识别和回收垃圾对象进行内存清理,为了让垃圾回收器可以高效运行,大部分情况下,会要求系统进入一个停顿的状态。停顿的目的是终止所有应用线程,只有这样系统才不会产生新的垃圾,同时停顿保证了系统状态在某一瞬间的一致性,也有益于更好地标记垃圾对象。因此在垃圾回收时,都会产生应用程序的停顿。
3.4 内存分配与回收策略
java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:
- 给对象分配内存
- 回收分配给对象的内存
对象的内存分配,往大方向上讲,就是在堆上分配,对象主要分配在新生代的 Eden 上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分百的固定的,其细节取决于当前用的是哪一种垃圾收集器组合,还有虚拟机中内存相关的参数配置。
3.4.1 对象优先在 Eden 分配
一般情况下对象都是优先分配在Eden上,当Eden没有足够的空间进行分配时,jvm会发起一次Minor GC。如果还是没有足够的空间分配,后面还有另外的措施。
public class Test01 {
private static final int _1MB = 1024*1024;
public static void testAllocation(){
byte[] allocation1 , allocation2 , allocation3 , allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB];
}
public static void main(String[] args) {
Test01.testAllocation();
}
}
运行参数:-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC
上面的代码执行,allocation1、allocation2、allocation3 实力化都是分配到 Eden 区,而在分配 allocation4 的时候,由于Eden已经没有足够的内存,所以进行了一次 Minor GC,但是在 Minor GC 后,依然没有足够的空间,并且 Survivor 也没有足够的空间。allocation1、allocation2、allocation3 这个三个对象进入老年代。这时候 Eden 区有足够的空间,allocation4 放入 Eden 区中。所以在GC日志中可以看到,Eden used 4723k(4M),而老年代 used 6144k(6M)
3.4.2 对象直接进入老年代上
大对象是指需要大量连续内存空间去存放的对象,类似于那种很长的字符串和数组。大对象对于虚拟机的内存分布来讲并不是好事,当遇到很多存活仅一轮的大对象jvm更加难处理,写代码的时候应该避免这样的问题。虚拟机中提供了-XX:PretenureSizeThreshold参数,令大于这个值的对象直接分配到老年代,这样做的目的是为了避免在Eden区和Survivor区之间发生大量的内存复制。
public class Test02 {
public static void main(String[] args) {
byte[] buf = new byte[4 * 1024 * 1024];
}
}
-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
-XX:PretenureSizeThreshold=3145728 不能写成 -XX:PretenureSizeThreshold=3m
3.4.3 长期存活的对象将进入老年代
虚拟机既然采用了分带收集的思想来管理内存,那内存回收就必须识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了打到目的,jvm给每个对象定义了一个年龄计数器(Age)。如果对象在Eden出生并且能过第一次Minor GC后仍然存活,并且可以在Survivor存放的话,将被移动到Survivor中,并将对象的年龄设为1。对象每躲过一次Minor GC,年龄就会加1,当他的年龄超过一年的阈值的时候,该对象就会晋升到老年代。这个阈值jvm默认是15,可以通过-XX:MaxTenuringThreshold来设置。
public class Test03 {
static int m = 1024 * 1024;
public static void main(String[] args) {
byte[] a1 = new byte[1 * m / 4];
byte[] a2 = new byte[4 * m];
byte[] a3 = new byte[4 * m];
a3 = null;
a3 = new byte[4 * m];
}
}
设置参数:-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
3.4.4 动态对象年龄判定
为了能更好的适应不同程序的内存状态,虚拟机并不总是要求对象的年龄必须达到-XX:MaxTenuringThreshold所设置的值才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年区,无须达到-XX:MaxTenuringThreshold中的设置值。
3.4.5 空间分配担保
在发生Minor GC的时候,虚拟机会检测每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则直接进行一次FUll GC。如果小于,则查看HandlerPromotionFailyre设置是否允许担保失败,如果允许那就只进行Minor GC,如果不允许则也要改进一次FUll GC。也就是说新生代Eden存不下改对象的时候就会将该对象存放在老年代。
3.5 补充
Minor GC和FUll GC的区别:
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为java对象大对数都是逃不过第一轮的GC,所以Minor GC使用很频繁,一般回收速度也比较快。
老年代GC(FULL GC/Major GC) :指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对,在ParallelScavenge收集器的收集策略中就有直接进行Major GC的选择过程 )。Major GC的速度一般会比Minor GC慢10倍以上。