NingG +

Understanding the JVM:垃圾回收与内存分配策略

1. 为什么需要学习GC?

首先我们得知道:

垃圾回收早于Java,我一直以为GC是Java的产物,其实不然。早在1960年,诞生在MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。

而当时设计是围绕三个问题进行的:

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

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

2. 对象已死?

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

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

什么对象才算作存活呢?

  1. 会被使用的对象
  2. 被需要的对象

如何找出会被使用的对象?

  1. 引用计数:其他对象使用这个对象,则,对象的引用计数加一,其他对象释放这个对象的引用计数减一
  2. 跟搜索:找到一批一定会被使用的对象,这些对象所需要的对象,就都是被需要的

首先,我们说一下最简单的引用计数法:为对象设置一个引用计数器,每一个地方引用对象,计数器+1,引用失效,计数器-1,当引用计数器为0时,对象已死。引用计数法无法解决循环引用的问题(java -XX:+printGCDetails TestGC):

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)]
*/

其实,在Java中使用的是根搜索算法来实现判断对象存活与否的。具体:

3. 再谈引用

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

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

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

上面采用分级的思想,对不同情况牺牲不同的引用对象,以此尽可能保证程序正常运行。

4. 生存还是死亡?

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

特别说明:

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

注:finalize()方法只能执行一次,并且,不推荐使用。

5. 方法回收区

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

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

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

备注:

关于永久代的 GC,有几个情况:

  1. JDK 1.7 开始,常量池,从永久代中移除,存放在直接内存中;
  2. JDK 1.8 开始,彻底放弃永久代概念,字节码 class 文件,存放在直接内存中;

JVM 类卸载,几个问题:

  1. 什么时候类卸载?
    • 类的对象实例,不存在
    • Class 的 ClassLoader 已经被回收:因为 ClassLoader 保存有 Class 的引用
    • 不存在 Class 的任何引用
  2. 是否可以主动卸载类?
    • ClassLoader 只感知,Class 在方法区的位置,不感知哪些对象引用了 Class
    • 由于 ClassLoader 不知道 Class 被那些对象引用,因此,无法主动卸载

6. 垃圾收集算法

几种垃圾回收算法:

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参数来限制收集器的线程数。

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

3. Parallel Scavenge收集器

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

控制CPU的吞吐量

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

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

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

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个步骤:

GC ROOTS Tracing过程如图所示:

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

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

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

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

7. G1收集器

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

8. 内存分配与回收策略

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

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

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

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

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

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

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

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,要写字节510241024B(1Byte=8bit)),就会发现JVM按照第一种方法执行了。我修改以后运行,发现出现错误,原来作者提到了,PretenureSizeThreshold变量只对Serial和ParNew两款收集器有效,而我查看JVM发现我使用的是Parallel Scavenge和Parallel Old收集器,所以就没法搞了- -,如果想试验这个,可以改为ParNew和CMS组合。

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

对象晋升老年代的阈值,可以通过参数-XX:MaxTenuringThreshold设置。我们可以用java -XX:+PrintFlagsFinal | grep 'MaxTenuringThreshold'查看。 但是实际情况可能不是酱紫滴,因为JVM会采用一种更smart的方法——动态对象年龄判定

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

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

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

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

原文地址:https://ningg.top/understanding-jvm-chapter-3/
微信公众号 ningg, 联系我

同类文章:

微信搜索: 公众号 ningg, 联系我, 交个朋友.

Top