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

来源:互联网 发布:淘宝发物流怎么填单号 编辑:程序博客网 时间:2024/06/05 08:27

深入理解Java虚拟机第二版学习笔记。

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

       第二章java内存运行时区域的各个部分,启动程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本是类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟着回收了。而java堆和方法区则不一样,一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配、回收时动态的,GC关注的是这部分内存。

 

       3.2 对象是死了,还是活着?

       堆中存着Java中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就要确定这些对象哪些还活着,哪些已经死了(不可能在被任何途径使用的对象)。

      

3.2.2 可达性分析算法

多数商用程序语言(Java,C#)的实现中,是通过可达性分析(Reachability Analysis)来判定对象是否存活。算法的基本思想是通过一系列的成为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(用图论的话说,就是从GC Roots到这个对象不可达),则证明这个对象是不可用的。




Java语言中,可以作为GCRoots的对象包括下面几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。

方法区中类静态属性引用的对象。

方法区中常量引用的对象。

本地方法栈中JNI(即一般说的native方法)引用的对象。

 

       3.2.3 再谈引用

       在Jdk1.2前,Java中引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就成这块内存代表着一个引用。这种定义太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

       Jdk1.2后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)4种,引用强度依次减弱。

       强引用,指在程序中普遍存在的,类似“Object obj =new Object()”这类的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。

       软引用,用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常前,将会把这些对象列进回收范围中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出。

       弱引用,也是描述非必需的对象,但是他的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象。

       虚引用,也成幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

 

       3.2.4 生存还是死亡

       如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

       如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程区执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但不承诺会等待它运行结束,这样做的原因是:如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

       Finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己---只要重新与引用链上的任何一个对象建立关联即可,比如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合;如果对象这个时候还没逃脱,那它就真的被回收了。

一次对象自我拯救的代码清单:


public class FinalizeEscapeGC {public static FinalizeEscapeGC SAVE_HOOK= null;public void isAlive(){System.out.println("yes ,I am still alive");}@Overrideprotected void finalize() throws Throwable{super.finalize();System.out.println("finalize method executed");FinalizeEscapeGC.SAVE_HOOK = this;}public FinalizeEscapeGC() {}public static void main(String[] args) throws Throwable {SAVE_HOOK= new FinalizeEscapeGC();//对象第一次成功拯救自己SAVE_HOOK= null;System.gc();//因为finalize方法优先级低,暂停0.5秒等待他Thread.sleep(500);if(SAVE_HOOK != null){SAVE_HOOK.isAlive();}else{System.out.println("no,I am dead");}//下面这段代码,自救就失败了。SAVE_HOOK= null;System.gc();Thread.sleep(500);if(SAVE_HOOK != null){SAVE_HOOK.isAlive();}else{System.out.println("no,I am dead");}}}

运行结果:

finalize method executed

yes ,I am still alive

no,I am dead


代码中有两段完全一样的代码片段,执行结果是第一次逃脱成功,第二次失败,这是因为任何一个对象的finalize()方法多只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。

    Finalize()能做的工作,比如关闭外部资源等,使用try-finally或者其他方式都可以做的更好、更及时,所以不太建议使用finalize()。

 

       3.2.5 回收方法区

       很多人认为方法区(或者Hotspot虚拟机中的永久代)是没有垃圾收集的。

       永久代的垃圾收集“性价比”很低,主要回收废弃常量和无用的类。回收废弃常量与回收Java堆中的对象类似,以常量池中字面量的回收为例,加入一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫“abc”,换句话说,就是没有任何String对象引用常量池中的“abc”常量,有没有其他地方引用了这个字面量,如果这时间发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也类似。

       判断一个类是“无用的类”条件相对苛刻。类需要同时满足下面三个条件才算是“无用的类”:

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

2,加载这个类的ClassLoader已经被回收。

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

       虚拟机对满足上述3个条件的无用类,有可能进行回收。是否对类进行回收,Hotspot虚拟机提供了 –Xnoclassgc参数进行控制,还可以使用 –verbose:class 及 –XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息。

 

       3.3 垃圾收集算法

       主要介绍算法的思想。

       3.3.1 标记-清除算法

       最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同他的名字一样,算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

       它的主要不足:一是效率问题,标记、清除连个过程的效率都不高;另一个是空间问题,标记清除后产生大量不连续的内存碎片,空间碎片太多可能导致以后在需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。

      

       3.3.2 复制算法

       为了解决效率问题,一种称为“复制”(Coping)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配即可。只是这种算法的代价是将内存缩小为原来的一半。

 

       3.3.3 标记-整理算法

       复制收集算法在对象存活率较高时就要进行较多的赋值操作,效率将会变低。

       根据老年代的特点,有人提出一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行整理,而是让所有存活对象都向另一端移动,然后直接清理掉端边界以外的内存。

 

       3.3.4 分代收集算法

       当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法。

       一般吧java堆分为新生代和老年代,这样就可以根据各个年底的特点采用最适当的手机算法。在新生代,每次垃圾收集都会有大批对象死去,只有少量存活,那就选择赋值算法,只要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对他进行分配担保,必须使用“标记-清理”或“标记-整理”算法进行回收。

 

       3.4 HotSpot 算法实现

       3.4.1 枚举根节点

       从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,必然会消耗很多时间。

       可达性分析对执行时间的敏感还体现在GC停顿上,因为这项工作必须在一个能确保一致性的快照中进行,这里“一致性”的意思是指在整个分析期间整个执行系统看起来像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Stop theworld)的其中一个重要原因,即使是号称不会发生停顿的CMS收集器,枚举根节点时也是必须要停顿的。

       目前主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当时有办法直接得知那些地方存放着对象引用。

       Hotspot实现中,使用一组称为Oopmap的数据结构来达到这个目的,在类加载完成后,Hotspot把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中那些位置是引用。这样,GC在扫描时就可以直接得知这些信息。

 

       3.4.2 安全点

       在OopMap的协助下,Hotspot可以快速准确的完成GC Roots枚举,但是导致引用关系变化或者OopMap内容变化的指令很多,如果每条执行都生成对应的OopMap,那会需要大量空间。

       实际上,Hotspot没有为每条指令都生成OopMap,只是在特定位置记录了这些信息,这些位置成为安全点(safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。

       安全点的选定基本上是以程序“是否具有让程序长时间运行的特征”为标准进行选定的,长时间执行最明显的特征就是指令序列复用,如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生safepoint。

       对于Safepoint,另一个需要考虑的问题是如何在GC发生时所有线程(不包括执行JNI调用的线程)都跑到最近的安全点上再停顿下来。Hotspot采用主动式中断,思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点时重合的,另外加上创建对象需要分配内存的地方。

 

       3.4.3 安全区域

       Safepoint机制保证了程序执行时,在不太长的时间就会遇到可以进入GC的safepoint,但是程序“不执行”时,就是没有分配cpu时间时,比如线程处于sleep或blocked状态,这时无法响应JVM的中断请求,走到安全点挂起。这种情况,需要安全区域来解决。

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

       在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,当这段时间里JVM要发起GC,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,要检查系统是否已经完成了根节点枚举(或整个GC过程),如果完成了,那线程就继续执行,否则他必须等待直到收到可以安全离开SafeRegion的信号为止。

 

       3.5 垃圾收集器

       收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。



如果两个收集器之间存在连线,说明他们可以搭配使用。所处的区域表示它是属于新生代收集器还是老年代收集器。

 

       3.5.1 Serial 收集器

       这是个单线程收集器,“单线程”的意义不仅仅说明他只会使用一个cpu或一条收集线程去完成垃圾收集工作,重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。




直到现在,它依然是虚拟机运行在Client模式下的默认新生代收集器。它有优于其他收集器的地方:简单高效(与其他收集器的单线程比),对于限定单个cpu的环境来说,Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

 

       3.5.2 ParNew 收集器

       ParNew收集器其实是Serial收集器的多线程版本。




ParNew收集器除了多线程收集外,其他与Serial收集器比并没有太多创新,但它是许多运行在Server模式下的虚拟机中首选的新生代收集器,目前只有它能与CMS收集器配合工作。

       Jdk1.5时,推出的CMS收集器(Concurrent Mark Sweep),是Hotspot虚拟机中第一款真正意义的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

       ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

       它默认开启的收集线程数与cpu的数量相同,在cpu非常多的环境下,可以使用-XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

      

       并发编程中有并发、并行两个概念:

       并行(Parallel):值多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

       并发(Concurrent):值用户线程和垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个cpu上。


3.5.3Parallel Scavenge收集器

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

       CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是cpu用于运行用户代码的时间与cpu总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花1分钟,吞吐量就是99%。

       停顿时间越短越适合需要与用户交互的程序,而高吞吐量则可以高效率的利用cpu时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

       Parallel Scavenge收集器使用参数 –XX:MaxGCPauseMillis 参数控制最大垃圾收集停顿时间,使用 –XX:GCTimeRatio参数设置吞吐量大小。

       Parallel Scavenge收集器还有一个参数-XX:UseAdaptiveSizePolicy,这个参数打开后,不需要手工指定新生代的大小(-Xmn),Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。


3.5.4Serial Old收集器

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




3.5.5Parallel Old收集器

       Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及cpu资源敏感的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器的组合。




3.5.6CMS收集器

       CMS(Concurrent mark sweep)收集器是一种以获取最短回收停顿时间目标的收集器。基于“标记-清除”算法实现。运作过程分为4个步骤:

       初始标记(CMS initial mark)

       并发标记(CMS concurrent mark)

       重新标记(CMS remark)

       并发清除(CMS concurrent sweep)

       其中初始标记、重新标记两个步骤仍然需要“stop the world”。初始标记仅仅是标记下GC Roots能直接关联到的对象,速度很快,并发标记阶段是进行GC Roots Tracing的过程,而重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长,但远比并发标记的时间短。

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



CMS收集器的优点:并发收集,低停顿,也称为并发低停顿收集器(Concurrent low pausecollector)。但是也有3个缺点:

1, CMS收集器对cpu资源非常敏感。其实,面向并发设计的程序都对cpu资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说cpu资源)而导致应用程序变慢,总吞吐量降低。CMS默认启动的回收线程是(cpu数量+3)/4。

2, CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full Gc的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程勋运行自然就会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留待下一次GC时清理掉。这一部分垃圾成为“浮动垃圾”。

3, CMS基于“标记-清除”算法,这就意味着收集结束时会有大量空间碎片产生。空间碎片过多,会给大对象分配带来麻烦,往往出现老年代还有大量空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前出发一次Full GC。


3.5.7 G1收集器

G1是面向服务端应用的垃圾收集器。有如下特点:

并行和并发:G1能充分利用多cpu、多核环境的硬件优势,使用多个cpu来缩短

stop-the-world停顿的时间。

分代收集:分代概念在G1中依然保留。虽然G1可以不需要其他收集器配合就能独立

管理整个GC堆,但它能采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象。

       空间整合:G1从整体看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)看是基于“复制”算法实现的,这两种算法都意味着G1运行期间不会产生内存空间碎片,手机后能提供规整的可用内存。

       可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型,让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

 

       G1将整个java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代、老年代的概念,但是新生代、老年代不再是物理隔离了,他们都是一部分Region(不需要连续)的集合。

       G1之所以能建立可预测的停顿时间模型,是因为他可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面垃圾堆积的价值大小(回收 所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这就是Garbage-First名称的来由)。这种使用Region划分内存空间及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

       G1把内存“化整为零”的思路,理解容易,实现没想象那么简单,以一个细节为例:

把Java堆分为多个Region后,垃圾收集是否真的能以Region为单位进行?仔细想想很容易发现问题所在:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定对象是否存活的时候,岂不是还要扫描整个Java堆才能保证准确性?

       在G1收集器中,Region之间的对象引用以及其他收集器中新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。

       如果不计算维护Remembered Set的操作,G1的运作分以下步骤:

       初始标记(Initial Marking)

       并发标记(Concurrent Marking)

       最终标记(Final Marking)

       筛选回收(Live Data Counting and Evacuation)

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

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

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

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




3.5.8理解GC日志

       两段经典的GC日志:

第一段:

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]

10 0.6 6 7:[F u l l G C[T e n u r e d:0 K->2 1 0 K(1 0 2 4 0 K),0.0 1 4 9 1 4 2 s e c s]4603K->210K(19456K),[Perm:2999K->

2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java

虚拟机启动以来经过的秒数。

 

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新

生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面

这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之

类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将

显示“[Full GC(System)”。

第二段:

[FullGC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]

 

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与

使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->

GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->

152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。


3.5.9垃圾收集参数总结



3.6内存分配与回收策略

       对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的。

      

       3.6.1 对象优先在Eden分配

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

       虚拟机提供 –XX:+PrintGCDetail 这个收集器日志参数,告诉虚拟机发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前的内存各区域分配情况。

       新生代Minor GC:

VMargs:

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

前三个参数限制了Java堆大小是20M,不可扩展,其中10M分给新生代,剩下10M分给老年代。

-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的比例是8:1。

 

新生代Minor GC 代码清单:

public class TestAllocation {private static final int _1MB = 1024*1024;private TestAllocation mTestAllocation = new TestAllocation();public TestAllocation() {}public static void alloctaion_1(){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];//出现一次Minor GC。}public static void main(String[] args) {alloctaion_1();}}

新生代的可用空间total 9216K(eden space 8192K + Survivor 1024K)。

分配allocation4时,发生一次Minor GCGC的结果是:新生代区从6963K变为480K,新生代区总容量9216K,Java堆的已使用容量6963K变为6624K,Java堆的总容量19456K。

GC的原因:给allocation4分配内存时,发现Eden区(8192K)已经被占用了6M,剩余空间不足以分配allocation4所需的4M内存,因此发生Minor GCGC期间,虚拟机发现已有的32M的对象无法放入Survivor区(空间只有1M),所以通过分配担保提前转移到老年代区(tenured generation   total 10240K, used 6144K)。

GC结束后,allocation4分配到Eden区,所以程序执行完的结果是Eden占了4Meden space 8192K,  52% used),老年代区占用6Mtenured generation   total 10240K, used 6144K

运行结果:

[GC(Allocation Failure) [DefNew: 6963K->480K(9216K), 0.0039700 secs]6963K->6624K(19456K), 0.0040197 secs] [Times: user=0.00 sys=0.00, real=0.00secs]

Heap

 def new generation   total 9216K, used 4740K [0x03e00000,0x04800000, 0x04800000)

  eden space 8192K,  52% used [0x03e00000, 0x042290e8, 0x04600000)

  from space 1024K,  46% used [0x04700000, 0x04778180, 0x04800000)

  to  space 1024K,   0% used[0x04600000, 0x04600000, 0x04700000)

 tenured generation   total 10240K, used 6144K [0x04800000,0x05200000, 0x05200000)

   the space 10240K,  60% used [0x04800000, 0x04e00030, 0x04e00200,0x05200000)

 Metaspace      used 82K, capacity 2242K, committed 2368K, reserved 4480K


新生代GC(Minor Gc):指发生在新生代的垃圾收集动作,因为java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度比较快。

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

 

       3.6.2 大对象直接进入老年代

       大对象指需要大量连续内存空间的java对象,典型的是很长的字符串及数组。经常出现大对象容易导致内存还有不少空间时就提前出发垃圾收集以获取足够的连续空间来安置他们。

       虚拟机提供了 –XX:PretenureSizeThreshold 参数,让大于这个值的对象直接在老年代分配。这样做的目的是避免在Eden去及两个Survivor区之间发生大量的内存复制(新生代采用内存复制算法收集内存)。

大对象直接进入老年代:

       VM args:

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

 

代码清单:

public class TestAllocation {private static final int _1MB = 1024*1024;//private TestAllocation mTestAllocation = new TestAllocation();public TestAllocation() {}private static void testPretenureSizeThreshold(){byte[] allocation;allocation = new byte[4*_1MB];}public static void main(String[] args) {testPretenureSizeThreshold();}}

运行结果:

Heap

 def new generation   total 9216K, used 984K [0x03e00000,0x04800000, 0x04800000)

 eden space 8192K,  12% used[0x03e00000, 0x03ef6058, 0x04600000)

 from space 1024K,   0% used[0x04600000, 0x04600000, 0x04700000)

 to   space 1024K,   0% used [0x04700000, 0x04700000, 0x04800000)

 tenured generation   total 10240K, used 4096K [0x04800000,0x05200000, 0x05200000)

  the space 10240K,  40% used[0x04800000, 0x04c00010, 0x04c00200, 0x05200000)

 Metaspace      used 82K, capacity 2242K, committed 2368K, reserved 4480K

可以看到eden区几乎没有使用,老年代的10M被使用40%,也即是4M的allocation对象直接分配在老年代中。因为-XX:PretenureSizeThreshold=3145728设置为3M。


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

虚拟机给每个对象定义了一个对象年龄(age)计数器,如果对象在eden出生并经过第一次Mimor Gc后仍然存活,并且能被Survivor容纳,将被移动到survivor空间,并且对象年龄设为1,对象在survivor中每熬过一次Minor GC,年龄就加1,当他的年龄增加到一定程度(默认15),就会晋升到老年代。可以通过参数 –XX:MaxTenuringThreshold设置年龄阀值。

    在如下的测试中,不管MaxTenuringThreshold设置为1还是15,第二次GC时,新生代内存都是0kb(4832K->0K(9216K)),也就是说allocation1被晋升到了老年代。为什么没有像前面说的把MaxTenuringThreshold设置15,第二次GC后,allocation1对象应该还留在新生代Survivor空间,原因参考下一节3.6.4


public class TestAllocation {private static final int _1MB = 1024*1024;public TestAllocation() {}private static void testPretenureSizeThreshold(){byte[] allocation1,allocation2,allocation3,allocation4;allocation1 = new byte[_1MB/4];//什么时候进入老年代,取决于MaxTenuringThreshold的设置allocation2 = new byte[4*_1MB];allocation3 = new byte[4*_1MB];allocation3 =null;allocation3 = new byte[4*_1MB];}public static void main(String[] args) {testPretenureSizeThreshold();}}

运行结果:

[GC (Allocation Failure) [DefNew

Desired survivor size 524288 bytes,new threshold 1 (max 15)

- age   1:    753896 bytes,     753896 total

: 5171K->736K(9216K), 0.0030497secs] 5171K->4832K(19456K), 0.0031001 secs] [Times: user=0.00 sys=0.00,real=0.00 secs]

[GC (Allocation Failure) [DefNew

Desired survivor size 524288 bytes,new threshold 15 (max 15)

: 4832K->0K(9216K), 0.0008951secs] 8928K->4831K(19456K), 0.0009249 secs] [Times: user=0.00 sys=0.00,real=0.00 secs]

Heap

 def new generation   total 9216K, used 4260K [0x03e00000,0x04800000, 0x04800000)

 eden space 8192K,  52% used[0x03e00000, 0x042290e8, 0x04600000)

 from space 1024K,   0% used[0x04600000, 0x04600000, 0x04700000)

 to   space 1024K,   0% used [0x04700000, 0x04700000, 0x04800000)

 tenured generation   total 10240K, used 4831K [0x04800000,0x05200000, 0x05200000)

  the space 10240K,  47% used[0x04800000, 0x04cb7df0, 0x04cb7e00, 0x05200000)

 Metaspace      used 82K, capacity 2242K, committed 2368K, reserved 4480K


3.6.4动态对象年龄判定

       虚拟机不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无序等到MaxTenuringThreshold中要求的年龄。

       



阅读全文
0 0