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

| 分类 Java  | 标签 GC 

###前言

最近项目上线不长时间LOAD突然报警,查看了一下发现平时在3-4的LOAD,一下子提高到7-8。于是使用jstack分析占用CPU最高的线程,发现Java进程中占用CPU最高的线程有3个都是GC收集线程。因为临近周末,就简单调高了JVM的内存,这样就会减少GC的回收次数,达到减少CPU调用的结果。之后观察了几个小时发现LOAD恢复正常。但这个做法毕竟不是长久之计,因为以前对JVM不怎么熟悉,于是趁着周末学习一下JVM的GC原理,简单做个笔记备忘:)

###1. 为什么需要学习GC?

首先我们得知道:

垃圾回收早于Java,我一直以为GC是Java的产物,其实不然。早在1960年,诞生在MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。而当时设计是围绕三个问题进行的:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

经过半个世纪的发展,内存的动态分配和回收技术已经日益成熟。Java语言使用了GC机制,而C++因为效率问题选择了放弃。固然Java程序员不用担心内存回收的问题,但在一些情况下,我们还是要学习GC。比如:当需要排查各种内存溢出、内存泄露问题时、当垃圾回收成为系统达到更高性能的瓶颈时,我们就需要看看当前的GC机制是否是最合适的,并且对这些“自动化”的技术实施必要的监控和调优。

###2. 对象已死?

在《Java编程思想》中我们了解到,几乎所有对象对象实例都是存放在堆中的(除了基本类型),那么垃圾回收器在对堆进行回收前,第一件事就是判断哪些对象还存活着,哪些对象已经死去(即不可能再被任何其他途径使用的对象。比如循环中创建的局部变量,在循环结束以后就不会再使用了)。

那么,在Java中使用那种方法来判断对象是否存活呢?

首先我们可能会想到最简单的引用计数法,但是这种计数法无法解决循环引用的问题(java -XX:+printGCDetails TestGC):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TestGC {
	private Object instance = null;
	private static final int SIZE = 1024 * 1024;
	
	//仅仅为了看GC是否回收这个对象
	private byte[] bigSize = new byte[2 * SIZE];
	
	public static void main(String[] args) {
		TestGC test1 = new TestGC();
		TestGC test2 = new TestGC();
		
		test1.instance = test2;
		test2.instance = test1;
		
		test1 = null;
		test2 = null;
		
		//强制系统进行GC,看内存是否被回收
		System.gc();
	}
}
/**ouput: 
[GC [PSYoungGen: 5447K->384K(38912K)] 5447K->384K(125952K), 0.0020290 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC [PSYoungGen: 384K->0K(38912K)] [ParOldGen: 0K->290K(87040K)] 384K->290K(125952K) [PSPermGen: 2544K->2543K(21504K)], 0.0078670 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
ok
Heap
 PSYoungGen      total 38912K, used 2703K [0x000000016c400000, 0x000000016ef00000, 0x0000000196f00000)
  eden space 33792K, 8% used [0x000000016c400000,0x000000016c6a3ec0,0x000000016e500000)
  from space 5120K, 0% used [0x000000016e500000,0x000000016e500000,0x000000016ea00000)
  to   space 5120K, 0% used [0x000000016ea00000,0x000000016ea00000,0x000000016ef00000)
 ParOldGen       total 87040K, used 290K [0x0000000116f00000, 0x000000011c400000, 0x000000016c400000)
  object space 87040K, 0% used [0x0000000116f00000,0x0000000116f48b88,0x000000011c400000)
 PSPermGen       total 21504K, used 2553K [0x0000000111d00000, 0x0000000113200000, 0x0000000116f00000)
  object space 21504K, 11% used [0x0000000111d00000,0x0000000111f7e410,0x0000000113200000)
*/

很囧的说,我不太会用JVM参数。。。所以结果跟作者的结果不一样,他那个明显看出来JVM是把这个相互引用回收了的。。。T_T

其实,在Java中使用的是根搜索算法来实现判断对象存活与否的。这个算法其实也比较简单啦,就是判断是不是同根,使用并查集的路径压缩可以轻松搞定。

###3. 再谈引用

我们知道,引用其实就是C/C++中和指针相似的东西,它保存的是一个地址,更确认的是说:是一个内存起始地址。在Java1.2之前,引用的定义是这样的:

如果reference类型的数据中存储的数值代表的是另外一块内存中的起始地址,就称这块内存代表着一个引用。

我们可以看到,这个引用的定义是非常狭隘的,只有引用、非引用区分。(我感觉不就应该这样吗?要么是引用,要么不是引用,对垃圾回收来说判断也更简单呀。。太弱了)所以,在JAVA1.2以后提出了新的引用定义:

  • 强引用:在代码中普遍存在的,类似Object obj = new Object();。只要强引用还存在,垃圾回收期就永远不会回收被引用的对象
  • 软引用:用来描述一些还有用,但并非必须的对象。这样当系统要发生内存溢出异常之前,就会把软引用列进第二次垃圾回收的计划中
  • 弱引用:比软引用还弱的引用,被弱引用的对象只能存活到下一次垃圾回收之前
  • 虚引用:最弱的一种引用关系了。使用虚引用的唯一目的就是在这个对象回收前收到一个系统回收通知

当然,上面的东西以我现在的水平完全不知道在说啥= =。。。先记下来再说,哼!

###4. 生存还是死亡?

上面说到,垃圾回收器通过根搜索算法确定对象是否存活。但是,即使是不可达对象,也并非是Facebook(非死不可)的,这时候它们暂时处于”缓刑“阶段,真正宣告一个对象死亡,至少要经过两次标记过程

如果对象在进行根搜索后发现跟root不同根,就被标记一次,同时进行筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()已经被JVM调用过(说明一个对象的finalize()方法只能执行一次),JVM会将这两种情况视为“没有必要执行”。如果这个对象有必要执行finalize()方法,JVM就会把它放在F-Queue中,稍后JVM会触发一个低优先级的线程去执行。但是去执行并并不承诺会等待它运行结束,因为如果一个对象在finalize()方法中执行缓慢,甚至发生了死循环,就会导致F-Queue其他对象永久处于等待状态,更严重的话可能会拖垮整个内存回收系统。finalize()是对象逃脱死亡命运的最后一次机会,稍后GC将会对F-Queue进行第二次小规模的标记,如果在finalize()中将自己和root挂在一个根上(比如把自己赋值给某个类变量或者对象的成员变量),那么在这第二次标记将会被移除出“即将回收的集合”:如果没抓住这次机会,呵呵,受死吧!骚年!

show code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK = null;
	
	public void isAlive() {
		System.out.println("haha, i'm 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 Throwable {
		SAVE_HOOK = new FinalizeEscapeGC();
		
		//对象第一次拯救自己
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(500);
		if(SAVE_HOOK != null) {
			SAVE_HOOK.isAlive();
		} else {
			System.out.println("5555, i'm dead!");
		}
		
		//对象第二次拯救自己,但是却跪了。因为finalize只能执行一次呀,亲!!
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(500);
		if(SAVE_HOOK != null) {
			SAVE_HOOK.isAlive();
		} else {
			System.out.println("5555, i'm dead!");
		}
	}
}
/**output: 
finalize method executed!
haha, i'm still alive!
5555, i'm dead!
*/

我们可以清楚的看到,第一次在finalize()中赋值给类变量,所以和root同根自救了一次,但是因为finalize()只会执行一次,所以第二次标记时,JVM发现已经调用这个对象的finalize(),就知道没必要再执行finalize了,然后就被回收了。

Tips:

作者特别说明了一点东西,感觉有点意思。他说非常不推荐使用finalize()方法自救对象,因为这是Java刚诞生为了使C/C++程序员更容易接受它作的一个妥协。它的运行带价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中提到它使用“关闭外部资源”之类的工作,这完全是对这种方法的用途的一种自我安慰(《head first java》和《TIJ》也躺枪。。。)finalize()能做的所有工作,使用try-finally或其他方法都可以做的更好、更及时,完全可以忘掉Java有finalize()。

###5. 方法回收区

在《Java虚拟机规范》中确实说过可以不要求虚拟机在方法区实现垃圾回收,主要是因为在方法区进行垃圾回收的“性价比”很低:在堆中,尤其是在新生代中,常规应用进行一次垃圾回收一般可以回收70%-95%的空间,而永久代的垃圾回收效率也远低于此。

永久代的垃圾收集主要回收两部分内容:

  • 废弃常量:以字符串常量池为例,假如字符串常量池中有一个字符串“abc“,但是当前系统没有任何一个String对象是叫做”abc“的,那么在这时候发生GC,而且必要的话,”abc“会被JVM”请“出常量池。同理,常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
  • 无用的类:判断一个类是无用的类,条件比废弃变量要苛刻的多,要同时满足下面3个条件才能算是”无用的类“:
    1. 该类所有的实例都已经被回收,意思是堆上没有该对象的实例了
    2. 加载该类的ClassLoader已经被回收
    3. 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法(因为通过反射,就一定要加载该类)

所以,在大量使用反射、动态代理的应用中,都必须要求JVM具有类卸载的功能,以保证永久代不会溢出

###6. 垃圾收集算法

  • 标记-清除算法:最简单的一种,先遍历出可以回收的,再扫一遍,把能回收的都回收了。不做任何整理,这样就容易产生过多的内存碎片。当需要大的连续内存空间时,即使碎片内存总和远大于需求,也会触发第二次GC,直到有一个内存碎片的空间大于需求为止
  • 复制算法:将整个内存分成两块(称为1和2),只使用1,当需求的空间大于1剩下的空间,就将1的存活对象复制到2(当然是连续的哟),然后把1给回收。这种方法很巧妙,但是只能使用一半空间着实浪费啊。现在的商业JVM采用这种算法来回收新生代,IBM经过调研发现,新生代的对象98%都是朝生夕死的,所有并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor还存活着的对象一次性拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个内存空间的9/10,只有10%的内存是用来浪费的。当然了,我们无法保证每次回收只有少于10%的对象存活,当存活对象大于10%,就会借用其他内存(这里指老年代)进行分配担保。分配担保就相当于现实生活中的担保。
  • 标记-整理算法:和第一种方法类似,先遍历一遍,得到需要回收的对象,但第二步不是直接扫一遍回收,而是扫一遍,将存活的对象连续的放在前面,最后直接回收最后一个存活对象后面的所有内存。(老年代就是采用这个方法)
  • 分代收集算法:当前商业虚拟机的垃圾回收都采用分代收集算法,这种算法没有啥特别的,就是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据每个代不同的特点采用最适当的回收算法。比如新生代存活对象少,就采用Eden-Survivor复制算法;老年代存活对象少,复制的话代价太大,就可以采用标记-整理算法。

###7. 垃圾收集器

上面说完常用的垃圾收集算法,下面就讲一下整个垃圾收集器的工作流程吧。需要说明的是:作者讨论的是Sun HotSpot虚拟机1.6版本Update22

####1. Serial收集器

Serial收集器是最基本、历史最悠久的收集器,曾经(在JDK 1.3.1.之前)是虚拟机新生代收集的唯一选择。大家看名字就能知道,这个收集器是一个单线程的收集器,但它的单线程的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾回收工作,更重要的是:它工作时,必须暂停其他所有的工作线程(被Sun称为stop the world),直到它收集结束。这听起来挺悲剧吧?意思就好像你这个Java应用运行1个小时,其中5分钟你完全不能进行任何操作。但是Sun人家的SDE也不容易啊,你妈妈打扫房间时应该也会让你原地不动或者出去吧,绝对不会容忍她一边打扫,你一边扔吧?况且这玩意比打扫房间要复杂的多的多(当然薪水也要多的多^_^)。在实际应用中,Serial还是灰常流弊的。。它依然是虚拟机运行在Client端的默认新生代收集器。因为它不需要考虑线程切换,只专注一次把收集工作搞定,而且在Client端,新生代的内存一般只有几十M或者一两百M的样子,完成一次收集工作完全可以控制在几十毫秒或者一百毫秒左右,不会有很大的停顿感。

####2. ParNew收集器

这个本质上就是Serial收集器的多线程版本。但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中还有一个与性能无关但很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。不幸的是,CMS作为老年代的收集器,却无法和JDK 1.4.0中已经存在的Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择Serial收集器或者ParNew中的一个(原因是Parallel Scavenge收集器和后面的G1收集器都没有使用传统的GC收集器代码框架,而是另外独立实现的,其余几种收集器则共用了框架代码)。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,当然也可以使用-XX:+UseParNewGC选项来显式指定使用

至于使用Serial还是ParNew取决于你的使用环境,假如你的机器是单CPU的话,使用超线程技术实现的伪多CPU由于线程切换,ParNew收集器说不定效率还会低于Serial收集器。当然,随着计算机的发展,多CPU已经普及,这种情况下使用ParNew才会发挥它多线程的优势,它默认开启的收集器线程数和CPU核数相同,当你想控制的时候,可以使用```-XX:ParallelGCThreads参数来限制收集器的线程数。

然后提前解释一下并行并发的概念,因为后面会有几个并发和并行的收集器:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。所以,遇到 Parallel 关键字的话,都是并行。所以当它们工作的时候,用户线程是阻塞的。所以也是 stop the world
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会根据时间片轮转交替进行),用户程序继续运行,而垃圾收集程序运行在另外一个CPU上。所以遇到 concurrent 关键字就是 GC 线程和用户线程在一段时间内交叉运行,不会将用户线程阻塞,不是 stop the world

####3. Parallel Scavenge收集器

Parallel Scavenge也是一个新生代收集器,它也是使用复制算法的收集器,同时也是并行的多线程收集器。看上去是不是和ParNew一样?那它有什么feature呢?精确的说就是:

控制CPU的吞吐量

我们知道,Stop The World到现在还是没有办法消除的,只是一直在缩减停顿时间。CMS等一众优秀的收集器关注点都是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的就是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间)。比如虚拟机运行了100分钟,垃圾回收使用了1分钟,那么吞吐量就是99%。

这就说说一下应用场景了。

  • 停顿时间(垃圾回收时间): 停顿时间越短越适合于用户交互的程序,良好的响应速度能提升用户体验
  • 高吞吐量: 可以最高效率的利用CPU时间,尽快的完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

为了这两个目的,Parallel Scavenge收集器提供了2个参数:

  • -XX:MaxGCPauseMillis:大于0的毫秒数,收集器将尽力保证内存回收时间不超过这个值。不过不要异想天开认为把这个值设的特别小,就能使系统垃圾收集速度更快,GC停顿时间缩短肯定是有代价的,它会牺牲吞吐量和新生代空间来实现。比如原来新生代空间为500M,10s收集一次,100ms的停顿收集时间;现在设定70ms的停顿收集时间,那么就会把新生代空间设为300M,5s收集一次(更小的空间肯定收集更快)。现在算算,原来10s的吞吐量是10s-100ms/10s=0.99,现在10s的吞吐量是10s-140ms/10s=0.986。
  • -XX:GCTimeRatio:大于0小于100的整数.假如设为N,那么垃圾收集时间占总时间的比率就是1/(1+N),比如设置为19,占比就是1/(1+19)=5%,默认值是99,即1%。
  • -XX:+UseAdaptiveSizePolicy:这也是一个有用的参数,放在这里说一下。它是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden、Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以一同最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。所以,当我们不懂收集器的原理时,就只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后设置最大停顿时间或者吞吐量,给虚拟机设置一个优化目标,剩下的参数细节虚拟机就会自动调整了。自适应调节策略也是Parallel Scavenge收集器和ParNew收集器的一个重要区别

####4. Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。这个收集器的主要意义就是被Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用;另外一个就是CMS的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。

####5. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。是在JDK 1.6之后才提供的。前面说过,Parallel Scavenge收集器采用了独立的架构,无法和CMS配合使用。那么,在JDK 1.6以前,Parallel Scavenge只能和Serial Old配合使用。因为Serial Old是单线程的,所以在多CPU情况下无法发挥性能,所以根本实现不了高吞吐量的需求,直到JDK 1.6推出了Parallel Old之后,Parallel Scavenge收集器和Parallel Old搭配,才真正实现了对吞吐量优先的控制。所以,在注重吞吐量及CPU资源敏感的场合,都可以考虑Parallel Scavenge和Parallel Old组合

####6. CMS(Comcurrent Mark Sweep)收集器

CMS收集器是以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或者B/S系统上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,给用户最佳的用户体验。而CMS收集器就非常符合这类应用的需求

从名字上可以看出,”Mark Sweep“是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤:

  1. 初始标记(stop the world):初始标记仅仅只是标记一下GC roots能直接关联到的对象,速度很快
  2. 并发标记:并发标记就是进行GC Roots Tracing的过程
  3. 重新标记(stop the world):重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变化的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍微长一些,但远比并发标记的时间短
  4. 并发清除:垃圾清除

GC ROOTS Tracing过程如图所示:

img

其中,可作为GC ROOTS的节点有:

  • JavaStack中的引用的对象
  • 方法区中静态引用指向的对象
  • 方法区中常量引用指向的对象
  • Native方法中JNI引用的对象

初始标记就是找出GC ROOTS能直接关联到的对象,上图有Object A;然后并发标记就是找出其他所有Object(B、C、D、F、G、H、I)的过程,在这个过程就完成了标记,哪些是不需要回收的,哪些是需要回收的,CMS已经知道了;重新标记是因为并发标记时,用户线程还会产生垃圾,这时候再Stop the world进行一次标记,因为数量不多,所以时间很快;然后进行清理工作。

由于整个过程中,并发标记和并发清除时间最长,收集器线程可以和用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的优点在于并发收集、低停顿,但是也不是完美的,主要有3个显著的缺点:

  1. CMS收集器对CPU资源非常敏感。默认情况下,CMS的收集线程数=(CPU数目+3)/4,当CPU个数大于4的时候,CMS的收集线程不会超过整个CPU占用率的25%。但是在CPU个数比较小的情况下,CPU占用就会突然增大,这样对于初始标记和并发标记这样”Stop The World”的过程来说,用户就会明显感觉到停顿。虽然有了解决方法,但已经废除了,就不多说了。
  2. CMS收集器无法处理浮动垃圾,可能出现”Concurrent Mode Failure”失败而导致另一次Full GC的产生。这个结合CMS的原理很容易理解,因为只有初始标记和重新标记是”Stop The World”,所以其他两个步骤运行的时候用户线程也可以运行,就会产生垃圾。但并发标记过程中生成的垃圾可以被重新标记所捕获,而并发清除阶段产生的垃圾只有在下次GC才能清理,这一部分垃圾才被称为浮动垃圾。另外,因为垃圾收集和用户线程是并发(虽然初始标记和重新标记是stop the world,但是时间很短。可以近似认为CMS是和用户线程并发执行)的,所以CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,它需要预留一部分空间提供并发收集时的线程使用。在默认设置下,CMS收集器在老年代使用了68%的空间会被激活,这是一个偏保守的设置。如果在应用中,老年代增长不是太快,可以适当调高这个参数-XX:CMSInitiatingOccupancyFraction。要是CMS运行期间预留的内存无法满足程序的需要,就会出现”Concurrent Mode Failure”失败,这时候JVM会启动后备方案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,因为是单线程,停顿时间就会更长了。所以如果大量出现”Concurrent Mode Failure”,就可以将这个值调低
  3. CMS是基于标记-清除算法实现的收集器,所以会产生内存碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦:老年代还有空间但是没有连续的足够大的空间,于是不得不触发一次Full GC。为了解决这个问题,有一个开关叫做-XX:+UseCMSCompactAtFullCollection,用于在享受过Full GC之后免费赠送一次碎片整理过程。当然,这个内存整理没法并发,只有”Stop The World”了。另外,虚拟机还设计了一个参数-XX:CMSFullGCsBeforeCompaction,用于指定在多少次不压缩的Full GC后,跟着来一次带压缩的。

####7. G1收集器

这个是最前沿的东西,据说会随着JDK 1.7的发布出现一个成熟稳定的版本。CMS已经很流弊了,但是它用的标记-清除算法,长时间运行后会导致内存碎片越来越多,那么Full GC是不可避免的。而G1的全称为(Garbage First),它比CMS有2个显著的改进:

  1. 采用标记-整理算法。所以它不会产生内存碎片,这对于长时间运行的系统是非常有用的
  2. 可以非常精确的控制停顿时间,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java的垃圾收集器的特征了。G1收集器没有分代的概念,而是将整个Java堆划分为多个大小固定的独立区域,然后跟踪这些区域内面的垃圾堆积程度,在后台维护一个优先队列,每次回收根据优先算法优先回收垃圾最多的区域。区域划分和优先级的设定,保证了G1收集器在有限的时间内”智能“的获得最高的收集效率。

###8. 内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以总结为两个点:

  1. 给对象分配内存
  2. 回收分配给对象的内存

上面的篇幅中,我们主要讨论了JVM中的垃圾收集器如何回收分配给对象的内存,下面我们来谈谈第一条:如何给对象分配内存。。

当然,有几个常用的JVM参数查看我们必须知道,因为有些操作是针对特定收集器,如果你的JVM使用了其他收集器,那么你的程序会有所不同。

  • -XX:+PrintFlagsInitial:JVM的默认参数配置
  • -XX:+PrintFlagsFinal:经过修改的JVM参数配置
  • -XX:+PrintCommandLineFlags:显示和默认配置不同的JVM选项以及参数

使用上面几个参数,结合grep就可以得到所有变量的配置,比如查看使用的是哪种收集器组合,哪个参数的值是多少等等。

首先,我们要查看一下JVM的配置,使用java -X,就可以查看每个选项是做什么的。这里比较重要的3个参数为:

  1. -Xms:设置初始Java堆大小
  2. -Xmx:设置最大Java堆大小
  3. -Xss:设置Java线程堆栈大小

下面我们根据这3个参数来跑一个程序测试一下。

命令为:java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+PrintGCDetails JVMPara

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class JVMPara {
	
	private static final int SIZE = 1024 * 1024;
	
	public static void testAllocation() {
		byte[] a1, a2, a3, a4;
		a1 = new byte[2 * SIZE];
		a2 = new byte[2 * SIZE];
		a3 = new byte[2 * SIZE];
		a4 = new byte[4 * SIZE];
	}
	public static void main(String[] args) {
		testAllocation();
	}
}
/**output:
Heap
 PSYoungGen      total 9216K, used 7143K [0x0000000118a00000, 0x0000000119400000, 0x0000000119400000)
  eden space 8192K, 87% used [0x0000000118a00000,0x00000001190f9f10,0x0000000119200000)
  from space 1024K, 0% used [0x0000000119300000,0x0000000119300000,0x0000000119400000)
  to   space 1024K, 0% used [0x0000000119200000,0x0000000119200000,0x0000000119300000)
 ParOldGen       total 10240K, used 4096K [0x0000000118000000, 0x0000000118a00000, 0x0000000118a00000)
  object space 10240K, 40% used [0x0000000118000000,0x0000000118400010,0x0000000118a00000)
 PSPermGen       total 21504K, used 2550K [0x0000000112e00000, 0x0000000114300000, 0x0000000118000000)
  object space 21504K, 11% used [0x0000000112e00000,0x000000011307db70,0x0000000114300000)
*/

从log中我们可以发现,新生代中Eden是8M,Survivor是1M+1M,分别为from和to。所以,新生代总大小(PSYoungGen:9M),因为不包含to的Survivor。当分配了a1,a2,a3之后,发现新生代只剩下3M了,所以有两种选择:

1.新生代垃圾收集,很不幸,发现a1,a2,a3都无法回收,于是会将a1,a2,a3复制到老年代,然后对新生代垃圾收集 2.分配担保,使用老年代

结果我们可以看出,JVM使用了第二种方法,于是我们看到,ParOldGen中有4M被a4占用了。但是为什么JVM会使用第二种呢?原来这里有一个参数:-XX:PretenureSizeThreshold,我们使用java -XX:+PrintFlagsInitial | grep 'PretenureSizeThreshold'查看,会发现值为0,说明当新生代空间不够时,只要大于0,就会直接在老年代分配。我们可以试验一下,将这个值设为5M的大小(这个参数不能直接写5M,要写字节5*1024*1024B(1Byte=8bit)),就会发现JVM按照第一种方法执行了。我修改以后运行,发现出现错误,原来作者提到了,PretenureSizeThreshold变量只对Serial和ParNew两款收集器有效,而我查看JVM发现我使用的是Parallel Scavenge和Parallel Old收集器,所以就没法搞了- -,如果想试验这个,可以改为ParNew和CMS组合。

下面我们来说下分代的情况。因为收集器分为新生代和老年代,那么,在分配内存的时候,JVM是怎样判断一个对象是属于哪个generation呢?原来JVM使用了一个简单的对象年龄计数器来完成的:

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1.对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄超过阈值(默认是15岁),就会被晋升到老年代中。对象晋升老年代的阈值,可以通过参数-XX:MaxTenuringThreshold设置。我们可以用java -XX:+PrintFlagsFinal | grep 'MaxTenuringThreshold'查看。

但是实际情况可能不是酱紫滴,因为JVM会采用一种更smart的方法——动态对象年龄判定

为了更好地适应不同程序的内存状况,JVM不是在达到年龄阈值才会将对象晋升到老年代,如果在Survivor空间中,相同年龄所有对象的大小总和 > Survivor空间一半,那么,年龄大于等于该年龄的对象就可以直接进入老年代,而不必等待达到年龄阈值

而所谓的Minor GC和Full GC就是这样区分的:

  • 年轻代Young GC(Minor GC):指发生在新生代的垃圾收集,因为Java对象98%情况都是朝生夕死,所以Minor非常频繁,一般回收速度也比较快
  • 老年代Full GC:指老生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC(也不是绝对的,PS收集器可以选择),Full GC的速度一般会比Young GC慢10倍以上

前面说到了分配担保,这里解释一下:

分配担保:在发生Young GC时,JVM会检测之前每次晋升老年代的对象平均大小和老年代剩余空间的大小。如果大于,直接进行Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败:如果允许,只会进行Young GC;如果不允许,则也要改为进行一次Full GC。一般情况下是把这个开关打开,要不然会出现频繁Full GC的情况。


上一篇     下一篇