深入理解Java虚拟机之----垃圾收集器与内存分配策略

垃圾收集器

1、哪些内存需要回收?

前面介绍了 Java 运行时内存区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈是线程私有的,随线程而生,随线程而灭。栈中的栈桢随着方法的进入和退出而有条不絮地执行着出栈和入栈操作。每一个栈桢中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收具有确定性。当线程结束或方法结束时,内存跟着回收了。

Java 堆和方法区 则不一样,一个接口中的多个实现需要的内存可能不一样,一个方法的多个分支需要的内存可能也不一样,而且我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器关注的内存也就是这一部分内存。

2、判断哪些对象可以回收

可以回收的对象其实就是不会再被用到的对象!

那么如何判断 不会再被用到的对象 呢?有两种方法:

(1)引用计数法

引用计数法的算法很简单:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加 1 ;当引用失效时,计数器的值减 1 ;任何时候引用计数器为 0 的对象就是不可能再使用的。

客观上来说,引用计数法实现简单,效率也很高,但是有一个弊端就是:它无法处理循环引用的问题。

例如:对象 A 和 B,A 中有一个成员变量引用了 B,并且 B 中有一个成员变量引用了 A,这两个对象形成了循环引用,导致 A 和 B 的引用计数器的值永远是大于 0 的,这样,即便程序中其它地方没有使用 A 和 B 的引用,A 和 B 两个对象也不会被回收。代码如下(如果虚拟机采用的是引用计数法,那么 a 和 b 这两个对象永远也无法回收):

public class Ref {

	private Object ins = null;
	private static final int _1MB = 1024 * 1024;
	private byte[] bigSize = new byte[2 * _1MB];
		
	public static void testGC() {
		Ref a = new Ref();
		Ref b = new Ref();
		a.ins = b;
		b.ins = a;

		a = null;
		b = null;
		
		System.gc();
	}
}
(2)可达性分析法(虚拟机的主流实现)

主要思想:通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索经过的路径称为引用链,当一个对象与 “GC Roots” 没有任何引用链相连(也就是从 “GC Root” 到这个对象不可达)时,则证明此对象是不可用的。

Java 中可作为 “GC Roots” 的对象:
a)虚拟机栈(栈桢中的本地变量表)中引用的对象
b)方法区中类静态属性引用的对象
c)方法区中常量引用的对象
d)本地方法栈中 Native 方法引用的对象

3、垃圾收集算法
(1)标记-清除算法

先标记所有需要回收的对象,在标记完成后统一回收。

不足:标记和清除两个过程的效率都不高;回收完会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。如图:
垃圾-清除算法

(2)复制算法

基本思想:将内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次性清理掉,避免了内存碎片的问题。只不过这种算法的代价是将内存缩小为原来的一半。如图:
复制算法

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 都是 “朝生夕死” 的。根据这个特点,将新生代的内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1 ,所以只有 10% 的内存会被 “浪费” 。(实际上就是依据新生代中对象的特点对复制算法做了一个优化,减小了内存的浪费)

当然,98% 可回收只是一般情况,也有特殊情况,当新生代中存活的对象较多时(大于 Survivor 空间)需要依赖老年代进行分配担保。

(3)标记-整理算法

复制算法对于存活率较高的情况会进行较多的复制操作,效率不高。针对存活率较高的特点,有人提出了另外一种 “标记-整理” 算法,该算法与 “标记-清除” 算法的区别在于标记之后,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。如图:
标记-整理算法

(4)分代收集算法

分代收集就是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。新生代中对象存活率不高,采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记-整理” 或者 “标记-清除” 算法来进行回收。

4、内存分配策略
对象优先在 Eden 分配:

大多数情况下,对象在新生代 Eden 区中分配。

######大对象直接进入老年代:
大对象指的是需要大量连续内存空间的 Java 对象。 Java 虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器,对象每在 Survivor 区中 “熬过” 一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。对象晋升老年代的年龄阀值,可以通过参数 -XX:MaxTenuringThreshold 设置。

5、什么情况下会触发 GC ?

总的来说是:当空间不足,无法给新对象分配内存时触发 GC。

具体情况是这样子的:
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间分配时,虚拟机将发起一次 Minor GC 。在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果成立,代表 Minor GC 是安全的。如果不成立,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC ,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,则需要进行一次 Full GC 。

Minor GC 和 Full GC 的区别:
	新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
	老年代 GC(Major GC / Full GC):只发生在老年代的 GC ,出现了 Major GC ,经常会伴随至少一次的 Minor GC(但也不是绝对的,在 Parallel Scavenge 收集器的收集策略里面就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机提供了多种不同的收集器以及大量的调节参数,实际应用中根据具体情况选择最优的收集方式,以获取最高的性能。

参考文献:《深入理解java虚拟机》周志明 著.

发布了35 篇原创文章 · 获赞 24 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/haihui_yang/article/details/80933273
今日推荐