虚拟机学习之二:垃圾收集器和内存分配策略

1.对象是否可回收

1.1引用计数算法

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

客观来说,引用计数算法的实现简单,判定效率高,在大部分情况下都是不错的算法,但是在主流的java虚拟机里面都没有选用该算法进行内存管理,主要原因是它很难解决对象之间相互循环引用的情况。如下面代码例子:

配置:输出垃圾回收日志

-XX:+PrintGC

代码: 

public class ReferenceCountingGC {

	private ReferenceCountingGC instance = null;

	private static final int _1M = 1024 * 1024;

	private byte[] bsize = new byte[2 * _1M];

	public static void testGC() {
		ReferenceCountingGC rc1 = new ReferenceCountingGC();
		ReferenceCountingGC rc2 = new ReferenceCountingGC();
		
		//两个对象互相引用
		rc1.instance = rc2;
		rc2.instance = rc1;
		
		rc1 = null;
		rc2 = null;
		//提醒虚拟机执行垃圾回收
		System.gc();
	}
	
	public static void main(String[] args) {
		testGC();
	}

}

运行结果:

[GC (System.gc())  6092K->736K(125952K), 0.0009621 secs]
[Full GC (System.gc())  736K->612K(125952K), 0.0068694 secs]

 从运行结果中可以清楚看到,GC日志中包含6092K->736K,意味着虚拟机并没有因为这两个对象相互引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否或者。(配置-XX:+PrintGC或者-verbose:gc输出基本回收信息,配置-XX:+PrintGCDetails可以输出详细的GC信息)。

1.2可达性分析算法

在虚拟机的主流实现中,都是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路就是:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(也就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在java语言中可以作为“GC Roots”的对象包括以下几种:

  • 虚拟机栈中引用的对象,也就是栈帧中本地变量表中的对象。
  • 方法区中静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(一般说的是Native方法)引用的对象。

如下面图中所示:虽然obj5、obj6、obj7之间相互引用但是它们到GC Roots没有可达的调用链,所以他们将会被判定为可回收的对象。

1.3对象引用类别

在JDK1.2之前定义引用:如果reference类型的数据中存储的数值代表另一块内存的起始地址,就称这块内存代表着一个引用。这种定义虽然比较纯粹但是太过狭隘,我们实际中更希望能代表一种情况:当内存空间足够时,则保留在内存之中,当内存空间在进行垃圾回收之后依然比较紧张,则可以抛弃这些对象。所以在JDK1.2之后java就对引用概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。这四种引用强度依次减弱。

  • 强引用:这种引用在代码中普遍存在,类似“Object obj = new Object()”这类的引用只要引用还在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用:这种引用用来描述一些“还有用但并非必须”的对象,对于软引用关联的对象,在系统将要发生内存溢出之前,会将这些对象列入回收对象之中进行二次回收,如果回收之后依然没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:是用来描述非必须对象的,但它的强度比软引用更弱一些,被若引用关联的对象只能生存到下一次垃圾回收之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉被弱引用关联的对象。
  • 虚引用:也被成为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用存在完全不会对其生存时间产生影响,也不能通过虚引用取得一个对象实例。为对象设置虚引用的唯一目的就是能够在对象被回收时收到一个通知。

1.4finalize方法

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于“缓刑”阶段,要真正宣布一个对象死亡,至少要经理两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的调用链,那么这个对象将会被进行第一次标记并且进行一次筛选,筛选的条件就是是否有必要执行finalize方法,如果没有覆盖该方法或者已经被虚拟机调用过,就会被认为“没有必要执行”。如果被判定为有必要执行finalize方法,就会将对象放置在一个叫做“F-Queue”的队列中,并由一个虚拟机自建的优先级低的线程去执行它(虚拟机只是触发调用,并不保证执行成功或完成)。如果在执行finalize方法的过程中对象重新与引用链上的任何一个对象建立关联则在稍后的第二次标记中该对象就会被移除“即将回收队列”,如果这时候依然没有和引用链上的对象建立关联,则该对象就会被回收。虚拟机调用对象finalize方法只有一次,不会进行第二次调用。如下代码实例:

代码:

public class FinalizeEscapeGC {

	public static FinalizeEscapeGC SAVE_HOOK = null;
	
	public void isAlive(){
		System.out.println("yes,I am still alive!");
	}
	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		//与成员变量建立关联
		System.out.println("finalize method executed!");
		FinalizeEscapeGC.SAVE_HOOK = this;
	}
	
	public static void main(String[] args) throws Exception {
		SAVE_HOOK = new FinalizeEscapeGC();
		//对象第一次拯救自己
		SAVE_HOOK = null;
		System.gc();
		//因为虚拟机调用finalize方法优先级比较低,暂停1s等待执行。
		Thread.sleep(1000);
		if(SAVE_HOOK != null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("I am dead!");
		}
		
		//第二次时不会再调用finalize方法
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK != null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("I am dead!");
		}
		
		
	}
}

执行结果:

finalize method executed!
yes,I am still alive!
I am dead!

可以看到回收的第一次执行了finalize方法然后对象没有被回收,第二次时没有调用finalize方法,对象被回收掉了。

这种方法虽然能在对象被回收时自救一次,但在编写代码时不建议使用此种操作。

1.5回收方法区(JDK8中是回收元空间)

按照JDK7介绍,永久代中的垃圾收集主要回收两部分内容:废弃常量和无用的类。

废弃常量:废弃常量的回收和java堆中对象的回收非常类似。以常量池为例,如果一个字符串“abc”已经进入常量池中,但是当前系统中没有任何一个String字符串对象叫做“abc”,也就是没有任何一个对象引用这个“abc”常量,也没有任何一个地方引用这个字面量。这个时候发生内存回收,有必要的话常量池中的“abc”常量会被系统清理出常量池。

无用的类:类的回收判定比较严格要满足一下三个条件才可以会被回收。

  • 该类所有的实例都已经被回收。
  • 加载该类的ClassLoader也已经被回收。
  • 该类对应的class对象也没有在任何地方被引用,也就是不能通过反射访问该类。

2.垃圾收集算法

2.1标记-清除算法

标记-清除算法:最基础的收集算法,主要分为“标记”和“清除”两个阶段完成,首先标记出所有需要回收的对象,在标记完成之后进行统一回收。之所以说它时最基础的收集算法是因为后续的收集算法都是基于这种思路进行对其不足进行改进而得到的。

这种算法有两种不足:第一个就是这两个阶段的效率都不高;第二个是在标记清除之后会产生大量的不连续的内存碎片,空间碎片太多可能会导致在后面程序运行过程中如果分配较大对象时,无法找到足够的连续内存空间,而不得不提前进行下一次垃圾回收。

2.2复制算法

复制算法:将可用内存分为大小相等的两部分,每次只使用其中的一块,当这一块内存用完,就进行垃圾回收将还存活的对象复制到另一块上面,然后清除掉刚才使用的内存空间。这样做的好处就是每次对整个板块内存进行回收,不用考虑内存碎片等复杂问题。但是这种算法的代价就是讲内存可用空间直接缩小了一半。

在商业虚拟机中都使用这种算法来回收新生代。IBM研究表明大部分情况新生代中有98%的对象会被第一次收集时被回收掉,所以在实现中把内存分为较大的一块Eden空间和两个较小的Survivor空间,比例是8:1:1.每次使用时将新创建的对象分配到Eden区其中一个Surivivor区保存上次回收存活下来的对象,当进行垃圾收集时将Eden和使用中的Survivor中的存活对象复制到另一个Survivor中,然后清空Eden和使用过的Survivor空间,依次循环使用。当然并不是每次存活的对象都不足10%,当存活对象大于10%时Surivivor中的空间就不够使用,就需要依赖其他内存进行分配担保(老年代)。也就是当另一块Surivivor内存不够时就会将存活的对象分配到老年代中。

2.3标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,从而降低效率。还要预留担保空间,以应对存活对象较多时新生代内存不够分配的情况。所以在老年代提出了“标记-整理”算法,标记同样跟前面的“标记-清除”算法中标记操作一样,但是标记之后不会将对象清除掉,而是将对象移动到整块内存空间的一端,然后直接清理掉边界以外的内存。

2.4分代收集算法

当前商业虚拟机都采用“分代收集算法”,这种算法只是将整块内存按照对象存活周期分为几个块,一般把java堆分为新生代、老年代。这样就可以根据各个年代特点使用不同算法进行收集。例如在新生代每次回收时都有少量对象可以存活,就是用复制算法,将少量存活对象复制到Survivor区。而老年代对象存活率比较高只有少量对象会被清除掉,就选用“标记-清除”或者“标记-整理”算法。

3.HotSpot算法实现

3.1枚举根节点

在可达性分析算法中可以作为GC Roots节点的主要在全局性引用(例如常量、静态属性)或者执行上下文(栈中本地变量表)中。在查找调用链的时候并不会这个检查这里面的引用,因为这样会消耗很多时间。

HotSopt实现中,使用一组称为OopMap的数据结构,在类加载完成的时候就已经计算出来对象“哪些”偏移量上面存储“哪些”数据类型。例如:在JIT编译过程中会在特定位置记录栈和寄存器中哪些位置是引用。这样在GC扫描的时候可以直接引用。

另外在执行GC 的时候所有java线程都必须停顿下来(Stop The World),因为在执行可达性分析算法的时候对象的引用关系不能发生变化。

3.2安全点

上面提到记录引用的特定位置称为“安全点”,线程在GC的时候需要暂停执行,但并不是在任何地方都可以停下来的,需要线程跑到“安全点”上时如果这时候有GC标识就暂停执行,这样可以保证在GC时引用不会发生变化。

3.3安全区域

安全区域:指在一段代码片段之中,引用关系不会发生变化。这个区域中的任何位置开始GC 都是安全的。

4.垃圾收集器

4.1Serial收集器

新生代收集器,复制算法。

Serial收集器是最基本、发展历史最悠久的收集器。这个收集器是一个单线程的收集器,它有一条专门的线程负责垃圾收集工作,更重要的是它在垃圾回收的时候要停止所有其他线程。主要用在Client模式下(用户的桌面场景中)。

4.2ParNew收集器

新生代收集器,复制算法。

ParNew收集器是Serial收集器的多线程版本。除了使用多线程进行垃圾收集之外其他的所有包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial完全一样。

4.3Paralle Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,也是使用复制算法的收集器,有事并行的多线程收集器。

Paralle Scavenge收集器的特点就是关注吞吐量:运行用户代码时间 / CPU总运行时间(用户代码时间+垃圾收集时间)。

Paralle Scavenge收集器提供了可以配置精准控制吞吐量的参数。所以又称为“吞吐量优先”收集器。

4.4Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。

主要给client模式下的虚拟机使用。

4.5Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。和Paralle Scavenge收集器搭配实现名副其实的“吞吐量优先”收集器。

4.6CMS收集器

CMS收集器(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器。CMS收集器主要分四个步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记、重新标记:这两个步骤虽然很快但是还是需要“Stop The World”。

并发标记:进行GC Roots tracing,在这个阶段jvm收集线程会和用户线程并行执行。(时间较长,降低用户系统信息)。

缺点:

  • 占用用户CPU资源,4核以上服务器至少占用1/4CPU资源。
  • 产生“浮动垃圾”由于CMS收集器和用户线程并发执行,在收集过程中用户线程可能产生新的垃圾对象。
  • 标记清除算法产生碎片内存空间,多次执行标记清除回收之后要进行一次内存压缩。

4.7G1收集器

G1收集器是当今收集技术最前沿成果之一。

特点:

  • 并行和并发,缩短“Stop The World”时间,让用户线程和收集线程并发执行。
  • 分代收集
  • 空间整合,不会产生内存碎片。
  • 可预测停顿时间,可以让使用者明确指定在一个长度为M毫米的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1之前的收集器收集范围都是整个新生代或者老年代。而G1收集器将整个java堆划分为多个大小相等的独立区域(Region),虽然还保留着新生代和老年代,但新生代和老年代不再是物理隔离的了,他们都是有一部分Region的集合组成。G1维护一个优先列表记录每个Region回收的价值大小,每次根据允许收集时间,首先回收价值最大的Region。

4.8理解GC日志

[GC (System.gc()) [PSYoungGen: 1331K->32K(38400K)] 1943K->644K(125952K), 0.0004471 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 612K->611K(87552K)] 644K->611K(125952K), [Metaspace: 2662K->2662K(1056768K)], 0.0076396 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 

[GC 和[Full GC 代表收集停顿来下,Full表示有停顿及“Stop The World”。

[PSYongGen 和 [ParOldGen、[Metaspace表示GC发生的区域,[PSYongGen新生代;[ParOldGen老年代;[Metaspace元空间。

区域后面“[ ]”之内的32K->0K(38400K) 表示:该区域回收之前占用容量->回收之后占用容量(该区域总容量)。

"[ ]"之外的644K->611K(125952K)表示:java堆GC之前的占用容量->GC之后占用容量(java堆总用量)。

5内存分配与回收策略

5.1对象优先在Eden分配

通过例子讲解:首先创建4个数组对象allocation1、allocation2、allocation3、allocation4,占用空间分别为2M、2M、2M、4M,然后指定虚拟机堆内存20M,新生代内存10M,新生代中Eden区域Survivor区域占比为8:1。

public class EdenTest {

	private static final int _1MB = 1024 * 1024;

	private 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) {
		testAllocation();
	}

}

使用Serial+Serial Old收集器组合进行内存回收(UserSerialGC配置指定收集器)

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+UseSerialGC

运行日志:

[GC (Allocation Failure) [DefNew: 7292K->613K(9216K), 0.0050167 secs] 7292K->6757K(19456K), 0.0050693 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 def new generation   total 9216K, used 4791K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,  59% used [0x00000000ff500000, 0x00000000ff599460, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
 Metaspace       used 2668K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 288K, capacity 386K, committed 512K, reserved 1048576K

日志解读

GC (Allocation Failure):表示向young generation(eden)给新对象申请空间,但是young generation(eden)剩余的合适空间不够所需的大小导致的minor gc。

DefNew:表示新生代使用Serial串行GC垃圾收集器,defNew提供新生代空间信息。

7292K->613K(9216K):新生代占用内存7292K -> 收集器回收之后占用内存613K(新生代可用内存9216K)。

7292K->6757K(19456K):java堆被占用内存7292K -> 收集器回收之后占用内存6757K (堆内存总空间19456K)。

堆内存的使用分配情况看

猜你喜欢

转载自my.oschina.net/u/3100849/blog/2250969
今日推荐