深入理解Java虚拟机(三)

前言

       在了解了Java运行时内存区域之后,我们知道虚拟机可能造成内存溢出OOM,虽然有垃圾回收机制,但是可能也不能避免,我们现在就看看Java的垃圾收集机制为例避免内存溢出异常已经做出了哪些努力。

概述

说起GC,我们需要做下面三件事情:

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

       现在内存的动态分配、垃圾回收技术已经相当的成熟,那我们为什么还要去学习内存分配和GC呢?答案:当需要排查各种OOM问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我就需要对自动化的技术实施必要的监控和调节。

怎么判断对象已死?

引用计数法

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

       缺点:不能解决循环引用的问题。

根搜索算法

       Java和C#所采用的方式,基本原理:通过一系列名为GC Roots的对象为起点,从这些根节点向下搜索,走过的路径叫引用链,当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的。

       Java语言中可作为GC Roots的对象包括:虚拟机栈中(本地变量表)引用的对象、方法区中的类静态属性引用的对象、方法区中的常量引用对象、本地方法栈中JNI,native方法引用的对象。

引用的几个类型

       无论是哪种方式,判断存活都与引用有关。引用概念在JDK1.2之后进行了扩充,包括四种:

  1. 强引用;只要引用存在,垃圾回收器永远不会回收。
  2. 软引用;系统将要发生内存溢出之前,会把这些对象列进回收范围并进行第二次回收。如果还不够菜抛出异常。
  3. 弱引用;只能存活到下一次垃圾回收发生之前。
  4. 虚引用;一个对象是否有虚引用的存在,完全不会对其生命时间构成影响,也无法通过虚引用来获得实例,完全只是为了希望在对这个回收时收到一个系统通知。

生存还是死亡?

       根搜索算法不可达的对象也并不是一定被回收,这时候它处于缓刑阶段。至少要经历两次标记过程。如果发现没有引用链,那么标记一次并且进行一次筛选,条件是对象是否有必要执行finalize(),当对象没有覆盖finalize方法,或者已经被虚拟机调用过,都是为没有必要执行。

       如果判断为有必要执行,就会放置在一个F-Queue队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalize线程区执行。但是并不承诺等待它运行结束,原因是:如果一个对象的finalize方法执行缓慢或者死循环了,其他对象将出现永久等待。

       finalize()是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次标记,如果对象在finalize中拯救自己—只要重新与引用链上的任何一个对象相连即可。只能自救一次,因为finalize最多使用一次。

回收方法区

       主要回收内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象类似。

       判断无用的类得满足下面三个条件:

  1. 加载该类的加载器已被回收
  2. 所有类的实例已经被回收
  3. 该类的java.lang.Class对象没有任何地方引用,无法反射得到。

满足了也不一定回收,常用的参数如下:

  1. -verbose:class
  2. -xx:+TraceClassLoading
  3. -XX:+TraceClassUnLoding

       在大量使用反射、动态代理、CGLib等bytecode框架的时候,以及JSP、OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能。

垃圾收集算法

标记-清除

       首先标记出所有需要回收的对象,在标记完之后统一回收。

       缺点:效率低,产生的碎片多。

复制算法

       他将可用的内存划分为等大小的两块,每次只使用其中一块,当用完了,把存活的对象都复制到另一半上,然后把第一块的一次性清理。

缺点:内存利用率低

标记-整理算法

       让所有存活的对象都向一端移动,然后直接清楚掉端边界以外的内存。

分代收集算法

       当前商业虚拟机都常用分代收集算法,更具对象的存活周期的不同将内存划分为几块,一般是新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次都有大批对象死去,只有少量的存活,那就选用复制算法。(复制的内容比较少,复制少量存活对象成本),而老年代中因为对象存活率高,就必须使用标记-清理或者标记-整理。

垃圾收集器

       包含的收集器如下:

serial

       单线程收集器,垃圾收集时,必须暂停所有工作线程。“Stop The World”。

优势:简单而高效,没有线程切换,在客户端模式是个很好选择。

ParNew

       是Serial的多线程版本(在新生代垃圾回收的时候采用多GC线程),是服务器模式下首选的新生代收集器,目前只有它能与CMS收集器配合工作。但是作为老年代的收集器,却无法和Parallel Scavenge配合工作。

       它在单核环境中绝对不会比serial效果好,多核也不能百分之百比serial好。

Parallel Scavenge

       是一个新生代收集器,它是使用复制算法的收集器,又是并行的多线程收集器,特别之处是它注重达到一个可控制的吞吐量,可通过设置参数来完成。

serial Old

       是serial的老年代版本,采用的是标记-整理算法。主要意义是客户端模式下使用。如果使用在服务器模式下,主要有两个用途:搭配Parallel Scavenge使用,或者作为CMS收集器的后备预案。

Parallel Old

       之前新生代 Parallel Scavenge很尴尬,因为只能喝serial old组,被单线程所拖累。知道Parallel Old出现,吞吐量优先收集器终于有了比较名副其实的应用组合。

CMS

       是一种以获取最短回收停顿时间为目标的收集器。目前很多一部分的Java应用都集中在服务器上,尤其注重响应速度,希望系统停顿的时间越短越好。CMS就非常符合这个要求。

       基于标记-清除的,但是改进了分以下四步:

  1. 初识标记;仍需要暂停其他线程,仅仅是标记一下GC Roots能直接关联的对象,速度很快。
  2. 并发标记;进行GC Roots Tracing过程
  3. 重新标记;仍需要暂停其他线程,修正并发标记期间,因用户程序继续运行而导致的一部分改变,会比?????标记消耗时间长一些,比并发标记时间短。
  4. 并发清除;消耗时间也较长。

CMS 优缺点

       CMS的优点很明显:并发收集、低停顿(由于进行垃圾收集的时间主要耗在并发标记与并发清除这两个过程,虽然初始标记和重新标记仍然需要暂停用户线程,但是从总体上看,这部分占用的时间相比其他两个步骤很小,所以可以认为是低停顿的)。

       尽管如此,CMS收集器的缺点也是很明显的:

       1.对CPU资源太敏感,这点可以这么理解,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢。(增量式并发收集器i-CMS)

       2.CMS收集器无法处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”。(调高-XX:CMSInitiatingOccupancyFraction参数,太高又会造成CMF失败,后备预案就是采用Serial收集器对老年代进行,反而效率会降低)。

       3.由于CMS收集器是基于“标记-清除”算法的,前面说过这个算法会导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC(这个后面还会提到)这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。相应停顿也不得不变长。

G1收集器

       G1时当前收集器技术中最前沿的成果。相比之前的CMS收集器又两个改进:

  1. 基于标记-整理算法实现,也就是说不会产生碎片。
  2. 精确的控制停顿,在M毫秒哪,收集时间不超过N毫秒,这几乎已经时实时java(RTSJ)的垃圾收集器特征了。

       将整个Java堆划分为多个大小固定的独立区域,并且跟踪这些区域厘米的垃圾堆积程度,后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域,这就是Garbage First的由来。保证有效时间内获得更高的收集效率。

内存分配与回收策略

       Java技术体系中所提倡的自动内存管理最终归结为自动化解决了两个问题:给内存分配对象以及回收分配给对象的内存。接下来再来说说内存分配,往大方向讲,就是在堆上分配。
根据设置参数和收集器的使用组合不同,内存分配机制可能不同,但是我们讲述一般性规则,分析之前先看看两种GC:

  1. 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,比较频繁,回收速度也快。
  2. 老年代GC (Full GC/Major GC):发生在老年代的GC,经常伴随着至少一次Minor GC,但不是绝对,速度较慢,。

对象优先在Eden中分配

       大多数情况下,对象优先在新生代Eden区中分配。当Eden没有足够空间的时候,会发起一次Minor GC。

大对象直接进入老年代

       需要大量连续空间的Java对象,很长的字符串及数组等,所以程序中不要写短命大对象。(不进入Eden和Survivor时因为他们采用复制算法)。

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

       虚拟机给每一个对象一个年龄计数器,默认15岁。

动态对象年龄判断

       如果Survivor空间中相同年龄所以对象总和大于空间的一半,年龄大于等于该年龄的进入老年代。

空间分配担保

       发生 Minor GC时,虚拟机会检测晋升到老年代的空间是否够,如果不够就发起一次Full GC,如果小于,如果参数HannlePromotionFailure设置允许担保失败,就只会进行Minor GC;

Minor GC/Full GC触发条件

  1. Minor GC触发条件:当Eden区满时,触发Minor GC。

  2. Full GC触发条件:

(1)调用System.gc时,系统建议执行Full GC,但是不必然执行

(2)老年代空间不足

(3)方法区空间不足

(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

小结

       本次介绍了垃圾收集算法和多种收集器,以及自动内存分配的一些机制。内存回收与垃圾收集在很多时候都是影响系统性能、并发俄力的主要因素之一,虚拟机提供了多种不同收集器与大量调节参数。必须要了解每个具体收集器行为、优点缺点。

说明

       文中出现的图片,文字描述有些来自互联网,但是出处无法考究,如果侵犯您的相关权益,请联系我,核实后我会马上加上转载说明。谢谢!!!