《深入理解JAVA虚拟机》详细解读(第三章 ):垃圾收集器与内存分配策略

 

目录

一、垃圾收集器与内存分配策略

1 概述

2 对象已经死亡?

2.1引用计数法(未使用)

2.2可达性分析算法

2.3 再谈引用

2.4 生存还是死亡

2.5 回收方法区

3 垃圾收集算法

3.1 复制算法(Copy)

3.2 标记-清除算法(Mark-Sweep)

3.3 标记-整理算法(Mark-Compact)

3.4分代收集算法

4 Hotspot的GC算法实现

4.1枚举根节点

4.2安全点(SafePoint)

4.3安全区域(Safe Region)

5 垃圾收集器

5.1 Serial串行收集器(新生代)

5.2 ParNew收集器(新生代)

5.3 Parallel Scavenge收集器(新生代)

5.4 Serial Old收集器(老生代)

5.5 Parallel Old收集器(老生代)

5.6 CMS收集器(老生代)

5.7 G1收集器

6 理解GC日志

7 垃圾收集器的参数总结

8 内存分配与回收策略

8.1对象优先在Eden区分配

8.2 大对象直接进入老年代

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

8.4 动态对象年龄判定

总结:

PS:博文仅作为个人学习笔记,如有错误欢迎指正,转载请注明出处~


一、垃圾收集器与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

本节常见面试题(推荐带着问题阅读,问题答案在文中都有提到):

  • 如何判断对象是否死亡(两种方法)。
  • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
  • 垃圾收集有哪些算法,各自的特点?
  • HotSpot为什么要分为新生代和老年代?
  • 常见的垃圾回收器有那些?
  • 介绍一下CMS,G1收集器。
  • Minor Gc和Full GC 有什么不同呢?

1 概述

说到垃圾收集(Garbage Collection,GC),大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史比Java更久远。说到GC,我们首先必须考虑以下三个问题:

  • 哪些垃圾需要回收?

  • 什么时候回收?

  • 如何回收?

目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那么为什么我们还要去了解GC和内存分配呢?

答案很简单:当需要排查各种 内存溢出问题、当垃圾收集称为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

看过上一篇博客《深入理解JAVA虚拟机》详细解读(第二章 ):JAVA内存区域与内存溢出异常

的朋友相信已经了解,Java的内存分为程序计数器栈,元空间(JDK8)和堆。

在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的code属性中,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现,不需要GC关注。

而一个程序只要还在运行,他的程序计数器就一直处在被使用的状态,也不需要GC关注。

Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。既然对象是在运行时创建的,由于继承,接口等原因,同一个类型的对象所需要的内存可能不同,这部分的内存分配也将是动态的,所以GC主要关注的就是这一部分的内存。

2 对象已经死亡?

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断那些对象已经死亡(即不能再被任何途径使用的对象)

2.1引用计数法(未使用)

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

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。A和B互相持有对方对象的引用,这个计数器永远无法为0。

2.2可达性分析算法

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的(换句话说,就是从GC Roots到这个对象不可达)。如图,Object 5,6,7虽然互相关联,但他们到GC Roots是不可达的,被判定回收。

在Java语言中,可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈桢中的本地变量表)中引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

2.3 再谈引用

如果判断对象是否存活仅仅依靠对象是否被引用,而被分成已引用未引用两部分,未免太过狭隘。如何描述一个“食之无味,弃之可惜”的对象在这种情况下就显得无能为力。

我们希望能够描述这样一类对象:当内存空间足够时,能正常保存在内存中;如果GC回收后内存依然紧张,则可以抛弃这类对象,很多系统的缓存功能都符合这样的应用场景,这里仅做部分引申不具体讨论。

引申:缓存的维护策略

LRU(Least Recently Used):最近最少使用 
LFU(Least Frequently Uesd):最不经常使用 
FIFO(First in First Out):先进先出 当然缓存的更新策略会更加复杂,这里不作更多讨论。

JDK1.2以后,Java对引用的感念进行了扩充,将引用分为强引用软引用弱引用虚引用四种(引用强度逐渐减弱)

1.强引用(StrongReference)

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用,类似“Object A = new Object()”。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

2.软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。在JDK1.2之后,提供了SoftRefrence类来实现软引用。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

3.弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可无的生活用品弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。在JDK1.2之后,提供了WeakReference类来实现软引用。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动

虚引用与软引用和弱引用的一个区别在于 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用对象的内存被回收之前采取必要的行动。在JDK1.2之后提供了PhantomReference类来实现软引用。

特别注意:

在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

2.4 生存还是死亡

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”。要真正宣告一个对象死亡,至少要经历两次标记过程

第一次筛选

可达性分析法中不可达的对象被第一次标记并且进行一次赛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行直接回收。

第二次筛选

被判定为需要执行的对象将会被放在一个叫做F-Queue(ReferenceQueue的队列中,并稍后由一个虚拟机自动建立的低优先级的Finalizer线程去执行他,这就是第二次筛选。这里所谓的“执行”,是指虚拟机仅会触发这个方法而不会等待,因为如果这个方法执行的时间很长或者发生死循环以及更加极端的情况,将很可能使得F-Queue中的所有对象永远处于等待状态,导致整个回收系统的崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会(finalize()方法仅会被虚拟机调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,除非这个对象与引用链上的任何一个对象建立关联从而移出F-Queue,否则就会被真的回收。

2.5 回收方法区

虽然虚拟机规范不要求虚拟机在方法区实现垃圾收集,而且在方法区的垃圾收集效率远低于堆中。尤其是新生代中的垃圾收集,一次往往可以回收70%-95%的空间,然尔在特定情况下,方法区的垃圾回收也是必要的。

方法区(或者说永生代/元空间)的垃圾收集主要回收两部分内容:废弃常量无用的类

废弃常量

废弃常量的回收和堆中对象的回收有点类似,如果字符串常量池中的字符串没有被任何地方引用,而且必要的话,就会被回收。

无用的类

判定一个常量是否是“废弃常量”比较简单,而要判定一个无用的类”需要同时满足下面3个条件 :

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。

  • 加载该类的ClassLoader已经被回收。

  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机对以上无用的类仅仅是可以回收,而不是必须回收,具体还要看虚拟机的设置。在大量使用反射、动态代理、CGli'b等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景,都需要虚拟机具备卸载类的能力,以保证永生代不会发生内存溢出的现象。

3 垃圾收集算法

3.1 复制算法(Copy)

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

为了方便理解,我画了示意图:

IBM公司的专门研究表明,新生代中的对象98%是“朝生昔死”的,所以通常from-survivor,eden和to-survivor的比例为1:8:1

3.2 标记-清除算法(Mark-Sweep)

在对象存活率较高时使用复制算法会浪费大量内存,所以GC规定经历过16次minorGC还没销毁的对象会被放入老生代来回收。

算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,会带来两个明显的问题;

1:效率问题

2:空间问题(标记清除后会产生大量不连续的碎片)

为了方便理解,我画了示意图:

 

3.3 标记-整理算法(Mark-Compact)

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移动,然后直接清理掉端边界以外的内存。很显然,整理这一下需要时间,所以与标记清除算法相比,这一步花费了不少时间,但从长远来看,这一步还是很有必要的。该算法可谓“道德高尚,自己栽树,后人乘凉”

 

3.4分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的所以我们可以选择“标记-清理”或“标记-整理”算法进行垃圾收集。

延伸面试问题: HotSpot为什么要分为新生代和老年代?

根据上面的对分代收集算法的介绍回答。

引申:FullGC

4 Hotspot的GC算法实现

4.1枚举根节点

首先,由上文我们了解到GC通过可达性分析来判断对象是否应该被回收,并且GC Roots的节点通常为对象的静态成员栈桢的局部变量表,而现在的很多应用仅仅方法区就有几百兆,如果逐个检查引用那么必然会消耗很多时间

其次可达性分析对执行时间的敏感性还体现在GC停顿上,如果在分析期间,对象的引用关系还在变化的话,那么分析的准确性就无法得到保证。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是需要停顿的。

我们把这种GC的停顿,称为STW(Stop the World)

目前主流的java虚拟机使用的都是准确式GC(对应的还有保守式GC,感兴趣的可以去看文末的参考资料5,当执行停顿时,虚拟机并不需要逐一去扫描引用,而是应当知道哪些地方存放着这些引用。在Hotspot的实现中,使用了一组称为OopMap的数据结构来达到这个目的,你可以把他理解成一个类型的映射表,或者是一个调试信息。不同的虚拟机对这个数据结构有不同的明明,殊途同归。在类加载完成的时候,虚拟机就把对象内什么偏移量(内存地址)对应什么类型的数据计算出来,在JIT编译(just-in-time compilation,代码第一次被编译时,又称即时编译,是特殊情况下的动态编译,详见文末的参考资料3过程中也会在特定的位置记录下栈和寄存器中哪些位置是引用,这样GC扫描时就可以直接得知这些信息了。

4.2安全点(SafePoint)

OopMap的协助下,Hotspot可以快速完成GC Roots枚举,但是如果引用关系发生变化,或者说OopMap的变化指令非常多,如果为每一条指令都生成对应的OopMap,那么将会需要大量的额外空间,GC的空间成本会变得非常高。

实际上,Hotspot也并没有为每条指令都生成对应的OopMap,而是在特定的位置记录了这些信息,这些位置我们称之为安全点(SafePoint)。既程序并非在所有地方都能停下来开始GC,而是只有到达安全点时才能暂停。安全点的选择既不能太少以致让GC等待太长时间,也不能太多从而过分增大运行时的负荷。所以,安全点的位置选点基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。什么意思呢?就是说当程序需要长时间执行的时候,设置安全点,从而使得GC对程序的影响降低到最小。这些特定的位置主要在指令序列复用的时候,例如: 

  1. 循环的末尾 
  2. 方法临返回前 / 调用方法的call指令后 
  3. 可能抛异常的位置

对于SafePoint来说,另一需要考虑的问题就是多线程问题,既如何让所有的线程都“跑”到最近的安全点上再停顿下来(这里不包括执行JNI调用的线程 : Java Native Interface。这里有两种中断方式可供选择,抢先式中断主动式中断

抢先式中断:

强先式中断不需要线程代码主动配合,而是强制将所有线程中断。如果发现有线程中断的地方不在安全点上,就恢复线程让他跑到安全点上。现在几乎没有虚拟机使用强先式中断来暂停线程执行GC。

主动式中断:

主动式中断的思想是让GC需要中断线程的时候,不直接对线程进行操作,而是仅仅设置一个标志,标志的位置和安全点重合。各个线程执行时会去主动轮询这个标志,当发现标志为真时,就自己将线程挂起。

4.3安全区域(Safe Region)

使用SafePoint似乎已经完美解决了如何进入GC的问题,但是实际情况却不一定。SafePoint保证了程序执行时进入GC的问题,但是当程序不执行时,或者说线程处于Sleep或者Brocked状态下,线程无法走到安全点,也将无法进入GC流程。JVM显然不可能等待这个线程被重新分配CPU时间片,对于这种情况就需要安全区域(Safe Region)来解决。换句话说,在这种情况下,代码会进入安全区域。

安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域中的任何地方开始GC都是安全的。我们可以把Safe Region看成扩展版的Safe Point。线程在执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那么此时如果JVM发起GC回收,就不用管拥有Safe Region标识的线程了。在线程要离开Safe Region时,他要等待GC完成了GC Roots枚举才能继续执行,否则就必须等待GC枚举。

总结:

到此为止,我们仅仅知道了Hotspot虚拟机如何去发起GC回收的问题,但是JVM(Java Virtual Machine)具体如何如何进行内存回收动作仍未可知。由于虚拟机回收内存的方式主要由具体的GC收集器来决定,而且往往虚拟机中不止有一种垃圾收集器,所以下一步我们来了解下Hotspot中具体有哪些垃圾收集器。

5 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器如何实现并没有任何规定,所以不同版本的JVM中所提供的垃圾收集器可能会有很大的差别,而且一般都会提供参数供用户根据自己应用的特点和要求组合出各个年代所使用的收集器。下面讨论的是JDK1.7以后的Hotspot虚拟机中垃圾收集器的实现。

上图展示了7种垃圾收集器,如果两个垃圾收集器之间存在连线,就说明他们可以搭配使用他们所处的区域代表了不同的分代,如新生代老生代。虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的HotSpot虚拟机就不会实现那么多不同的垃圾收集器了。

5.1 Serial串行收集器(新生代)

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程"Stop The World" 了解一下),直到它收集结束。

虚拟机的设计者们当然知道Stop The World(STW)带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。

但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择,他是Client模式下默认的新生代垃圾收集器

引申:虚拟机的server和client模式

hotspot包括server和client两种模式的实现

Java HotSpot Client VM(-client),为在客户端环境中减少启动时间而优化;

Java HotSpot Server VM(-server),为在服务器环境中最大化程序执行速度而设计。

比较:Server VM启动比Client VM慢,运行比Client VM快。

server模式的运行中,垃圾回收处理做的比较好一些。

5.2 ParNew收集器(新生代)

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样。

它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

从ParNew收集器开始,后面还会接触到几款并行和并发的收集器,这里有必要对并行和并发概念进行补充:

  • 并行(Parallel) :指多条垃圾收集线程并行工作,用户线程处于等待状态。(妈妈打扫垃圾的时候乖乖站着)

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个CPU上。(妈妈打扫垃圾的时候,你还能扔纸屑)

5.3 Parallel Scavenge并发收集器(新生代)

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的的多线程收集器。。。那么它有什么特别之处呢?

Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值,公式为吞吐量=代码运行时间/(代码运行时间+GC时间)。例如程序运行了100分钟,GC收集花了1分钟,那么吞吐量就是99%。逻辑 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在的话可以选择把内存管理优化交给虚拟机去完成(GC的自适应调节策略 Ergonomics也是一个不错的选择。

5.4 Serial Old串行收集器(老生代)

Serial收集器的老年代版本,它同样是一个单线程收集器,主要供Client模式的虚拟机使用。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与新生代Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案,使用“标记-整理”算法

5.5 Parallel Old并发收集器(老生代)

Parallel Scavenge收集器的老年代版本使用多线程“标记-整理算法。解决了Serial Old在老生代垃圾收集中性能上的拖累,在注重吞吐量以及CPU资源的场合中,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。

5.6 CMS并发标记收集器(老生代)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它而非常符合在注重用户体验的应用上使用。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记(ini-mark): 暂停所有的其他线程,并记录下直接与root相连的对象,速度很快 ;

  • 并发标记(concurrent-mark): 同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方

  • 重新标记(remark): 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短

  • 并发清除(concurrent-sweep): 开启用户线程,同时GC线程开始对为标记的区域做清扫

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对CPU资源敏感

面向并发的concurrent程序设计都会对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是由于他占用了一部分CPU资源,如果CPU资源紧张的话会导致用户进程响应变慢。CMS的默认回收线程数量是(CPU数量+3)/4,CPU数量越小GC线程所占的CPU资源越多,如果只有2个CPU就会占用50%的CPU,让人无法接受。针对这种情况,提出了增量式并发收集器(Incremental Concurrent Mark Sweep),既i-CMS它采用的解决策略是让用户进程和GC进程交替进行,减少用户响应时间,然而实际表现的性能并不理想,已经被申明不建议使用(deprecated)

  • 无法处理浮动垃圾(Float Garbage)

在上图的并发清理阶段,用户线程其实还在不断的制造垃圾,我们称之为浮动垃圾。这一部分垃圾出现在标记过程之后,CMS无法对它们进行即时清理,而是留到下一次GC处理。也是由于在垃圾清理阶段用户进程还在运行,所以CMS不能等到老生代都满了以后再来对内存进行清理,需要预留一部分内存供用户使用,在JDK1.6以后,这个百分比被设置成8%,既内存使用率达到92%以后,会出现一次“Concurrent Mode Failure”,JVM启动后备方案,使用Seral Old收集器来重新进行老年代的垃圾收集,这样停顿的时间就很长了,所以这部分预留内存不能设置太少。

  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生

由于使用了“标记-清除”算法,在回收过程中容易产生大量的空间碎片,所以CMS设置了一个参数在必要的时候启动FullGC进行内存整理,这回造成一次较长的停顿效果。CMS也提供了参数设置在固定次数的FullGC后来一次带压缩的GC。

5.7 G1收集器

上一代的垃圾收集器(串行serial, 并行parallel, 以及CMS)都把堆内存划分为固定大小的三个部分:    年轻代(young generation), 老年代(old generation), JDK1.8以前的持久代(permanent generation)和1.8以后的元空间,这些space必须是地址连续的空间。

G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。它被开发的目的,就被用来替代现有的CMS收集器。

被视为JDK1.7中HotSpot虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行

  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念并重新设计分代。他将内存划分为不同的Region,新生代和老生代不再是物理隔离的,而是一系列Region的集合。

  • 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。这两种算法都不会产生内存碎片,有利于程序长时间的有效运行,减少GC和Full GC的触发。

  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内垃圾收集用时不超过N秒

G1收集器跟踪各个Region里的垃圾堆价值大小,在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了GF收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

每个Region被标记了E(eden)S(survivor)O(old)H(humongous),说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

我们可以思考一下,将内存划分为Region来进行优先级管理真的能简单地做到更加快速么?

答案是否定的。由于新生代比老生代中内存更新更快,而且不同的Region内的对象有可能存在互相引用的关系,如果回收新生代的内存时不得不同时扫描老生代的话,G1的效率可能要下降不少。实际上,G1采用了Remenbered Set集来解决这个问题。G1规定虚拟机在对Refrence对象进行写操作的时候,产生一个Write Barrier栅栏暂时中断写操作,检查Refrence的对象是否处于不同的Region中(主要检查是否老年代的对象引用了新生代的对象),如果是便把相关的引用信息记录到写对象所处Region的Remenbered Set中,这样当G1在回收内存时,在GC根节点的枚举范围中加入Remenbered Set即可避免全堆扫描检索引用。

G1为这种新的内存结构提供了三种模式垃圾回收模式,young gcmixed gc full gc,在不同的条件下被触发。

young gc

发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

mixed gc

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。由于G1是并行垃圾收集器,所以为了保证用户进程有内存可以使用,当老年代的使用率达到阀值时,启动mixed gc回收内存,可以通过参数设置。

mixed gc的垃圾回收和CMS有点类似,大致可以分为以下四部分

  • 初始标记(initial-mark)

初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS的值,让下一个阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这一阶段需要停顿线程,但是耗时很短,

  • 并发标记(concurrent-mark)

并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段时耗时较长,但可与用户程序并发执行。

  • 最终标记(final-mark)

而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remenbered Set Logs里面,最终标记阶段需要把Remembered Set Logs里的数据合并到Remembered Set中,这一阶段需要停顿线程,但是可以和记录变化并行执行。

  • 筛选回收(livadata count and evacuation)

最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

full gc

如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的Serial Old GC,这是个单线程的垃圾收集器,会导致异常且长时间的暂停时间,因此在使用G1时应尽可能地避免出现full gc。

6 理解GC日志

每一种收集器的日志实现格式都是由他们自身的实现所决定的,但是为了方便阅读,各个收集器的日志都维持着一定的共性,例如下面这一段日志:

2018-06-15T10:44:26.630-0800: [GC (System.gc()) [PSYoungGen: 2673K->496K(38400K)] 2673K->504K(125952K), 0.0010649 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

上方的[GC (System.gc()),表示

这次垃圾收集的停顿类型,如果有FULL GC说明这次垃圾收集出现了STW(Stop-The-World)

上面方括号内部的[PSYoungGen: 2673K->496K(38400K)],表示

GC前该内存区域已使用容量->GC后该内存区域已使用容量,后面圆括号里面的38400K为该内存区域的总容量。

方括号外面的 2673K->504K(125952K), 0.0010649 secs],表示

GC前Java堆已使用容量->GC后Java堆已使用容量,后面圆括号里面的125952K为Java堆总容量。   PSYoungGen耗时

[Times: user=0.00 sys=0.00, real=0.00 secs]分别表示

户消耗的CPU时间 ,内核态消耗的CPU时间 ,操作从开始到结束所经过的墙钟时间(Wall Clock Time)

user是用户态耗费的时间,sys是内核态耗费的时间,real是整个过程实际花费的时间。user+sys是CPU时间,每个CPU core单独计算,所以这个时间可能会是real的好几倍。

CPU时间和墙钟时间的差别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但是多核CPU会叠加user或sys的时间,所以在多核环境下可能出现user+sys>real的情况
 

7 垃圾收集器的参数总结

-XX:+UseSerialGC : Jvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收

-XX:+UseParNewGC : 打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收

-XX:+UseConcMarkSweepGC : 使用ParNew + CMS +  Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用

-XX:+UseParallelGC : Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge +  Serial Old的收集器组合进行回收

-XX:+UseParallelOldGC : 使用Parallel Scavenge +  Parallel Old的收集器组合进行回收

-XX:SurvivorRatio : 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1

-XX:PretenureSizeThreshold : 直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配

-XX:MaxTenuringThreshold : 晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代

-XX:UseAdaptiveSizePolicy : 动态调整java堆中各个区域的大小以及进入老年代的年龄

-XX:+HandlePromotionFailure : 是否允许新生代收集担保,进行一次minor gc后, 另一块Survivor空间不足时,将直接会在老年代中保留

-XX:ParallelGCThreads : 设置并行GC进行内存回收的线程数

-XX:GCTimeRatio : GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效

-XX:MaxGCPauseMillis : 设置GC的最大停顿时间,在Parallel Scavenge 收集器下有效

-XX:CMSInitiatingOccupancyFraction : 设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70

-XX:+UseCMSCompactAtFullCollection : 由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效

-XX:+CMSFullGCBeforeCompaction : 设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用

-XX:+UseFastAccessorMethods : 原始类型优化

-XX:+DisableExplicitGC : 是否关闭手动System.gc()

-XX:+CMSParallelRemarkEnabled : 降低标记停顿

-XX:LargePageSizeInBytes : 内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m

–XX:+UseG1GC : 打开此开关后,使用G1垃圾收集器

 

8 内存分配与回收策略

8.1对象优先在Eden区分配

大多数情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC.

Minor Gc和Full GC 有什么不同呢?

新生代GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC(并非绝对),Major GC的速度一般会比Minor GC的慢10倍以上。

8.2 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别那些对象应放在新生代,那些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器,默认16代后转入老年代。

8.4 动态对象年龄判定

为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果Survivor 空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。

总结:

本节介绍了垃圾收集算法,几款JDK1.7中提供的垃圾收集器特点以及运作原理。 内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及大量调节参数,是因为只有根据实际应用的需求、实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合、也没有最优的调优方法,那么必须了解每一个具体收集器的行为、优势和劣势、调节参数。

PS:博文仅作为个人学习笔记,如有错误欢迎指正,转载请注明出处~

其他博文详见:《笔记导航目录》 不定期更新~

参考文档:

1.《深入理解Java虚拟机》密码:8jz3 

2. 面试中关于Java虚拟机(jvm)的问题看这篇就够了

3. JVM即时编译(JIT)

4. 汇编中偏移地址的理解

5. 我爱学Java之JVM中的OopMap

6. hotspot 虚拟机的server和client模式

7. G1垃圾收集器介绍

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值