第三章——垃圾收集器与内存分配策略

概述

GC需要回答下面这三个问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

想来本章是要解决这三个问题的。

对象已死?

1、谁是垃圾

  垃圾回收,得是垃圾才能回收,怎么判断一个对象是不是垃圾呢?有两种办法:

  • 引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是垃圾。
      但是这个东西有缺点:它很难解决对象之间相互引用的问题。也就是两块垃圾待在内存里互相引用,他们的计数器都不为0,但是外部已经无法访问他们了……
  • 根搜索算法:主流商用语言都是用这个的。通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象不可用。
      什么意思呢?Java指定了一些大哥,所有的对象要么自己就是大哥,要么是某个大哥的小弟,如果某个对象没有大哥,那它就要上名单了。

Java语言里,可作为GC Roots的对象包括下面几种(都是些不上名单的大哥):

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

2、再谈引用

  在JDK 1.2之前,Java中的引用定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就成这块内存代表着一个引用。
  这就很极端,搞得矛盾很尖锐。要么认大哥,要么上名单,影响很不好。所以后来Java对引用的概念进行了扩充,将引用分了四个级别,强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。

  • 强引用:在程序代码中普遍存在,类似“Object obj = new Object()”这类引用,只要强引用还存在,垃圾收集器就不会收集这个被引用的对象(这些对象就是大哥)。
  • 软引用:还有用,但不是必需的对象,在内存溢出之前会把这些对象清除一下,要是还不够才会抛出异常。
  • 弱引用:只能生存到下一次垃圾收集发生之前。
  • 虚引用:一个对象是否有虚引用存在完全不会对生存时间构成影响,也无法通过虚引用取得对象的实例,给一个对象设置虚引用唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。

3、生存还是死亡

  一个对象变成垃圾之后,也不是非死不可的。上了名单之后还要审问至少两次。第一次,刚上名单之后的审问,这次没过那这个对象就完蛋了。具体审问什么呢?判断这个对象是否有必要执行finalize()方法。怎么判断它有没有必要呢?这个对象没有覆盖finalize方法或者虚拟机已经调用过这个方法了,那就说这个对象没有必要执行。如果这个对象没有必要执行finalize方法,那就相当于没过审问。
  第二次就是它过了第一次审问(也就是“finalize方法被覆盖了,而且虚拟机也没有调用过这个方法。”),那这个对象就会进入一个F-Queue队列,虚拟机会自动建立一个低优先级的Finalizer线程去调用队列里面的这些对象的finalize方法(注意了,调用这个方法不是要等这个方法执行完),调用完之后GC会对队列中的对象进行第二次检测,看看队列里的对象有没有认个大哥,在finalize里面认了大哥的就放他回家,还没认大哥的,统统上枪毙名单等待枪毙。

这里我有个疑问,书上说至少要经历两次标记过程,但是如果第一次审问没过的话那这个对象就直接等待枪毙了,岂不是只有一次标记过程。只能等新知识补充进来之后再看看了……

4、回收方法区

  上面讲得都是Java堆里面对象的回收,方法区也是要垃圾回收的,但是性价比要比Java堆里面低,也就是吭哧吭哧收集半天发现没空出来多少……
  方法区的垃圾收集主要是针对:废弃常量和无用的类。

  • 废弃常量:与Java堆中的对象类似。
  • 无用的类:判断条件苛刻:该类所有的实例都已经被回收;加载该类的ClassLoader已经被回收;该类对应的Class对象没有在任何地方引用;无法在任何地方通过反射访问该类的方法;满足这三个条件就可以回收了,可以回收不一定回收,HotSpot可以用-Xnoclassgc参数控制。

在大量使用反射、动态代理、CGLib等字节码技术的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,使方法区不会溢出。

垃圾收集算法

主要有三种思路来收集垃圾。

  • 标记-清除算法
  • 复制算法
  • 标记-整理算法
1、标记-清除算法

  这是最基础的收集算法,顾名思义,先把所有的垃圾标记出来(检测一下谁是垃圾),然后统一回收被标记的对象。
当然,缺点也很明显:

  1. 效率问题:标记垃圾和清除垃圾的效率都不高。
  2. 空间问题:标记清除之后会产生大量的不连续空间,当程序运行过程中有较大的对象没有合适的地方放的时候就会频繁的触发不必要的GC。
2、复制算法

  复制算法是把内存空间分成两份,每次只使用一半,当一半用完的时候,触发GC,将还存活的对象复制到另一半空间,原来用过的空间清空即可。每次只考虑一块内存,也没有内存碎片的问题。实现简单,运行高效,但是代价是一半的内存!
  现代的商业虚拟机都是用这种算法来收集新生代内存(因为新生代中的对象绝大多数是朝生夕死的),HotSpot默认将新生代分为三部分,80%的Eden空间,2个10%的survivor空间。每次使用Eden空间和1个survivor空间,也就是只使用90%的空间,等GC时将存活的对象复制到空余的survivor空间中,如果空余的10%不够,要向老生代借空间。这样可以最大限度的利用新生代的特点。

3、标记-整理算法

  和“标记-清除”算法差在后面两个字上,可以猜到,第一步都要标记出谁是垃圾,第二步不删除垃圾,而是将存活对象向一端移动,最后将端边界以外的空间清空即可。

4、分代收集算法

  这不算一种算法吧,算是一种策略,要根据对象生存周期划分成不同的区域(新生代和老生代),再按照不同区域的特点采用合适的算法,比如:新生代用复制算法,老生代用标记-清除、或整理算法。

垃圾收集器

  垃圾收集器是垃圾收集算法的具体实现,有很多,HotSpot现在应该有7个垃圾收集器,3个新生代,3个老生代,还有一个G1收集器。每个垃圾收集器都有自己的特点,应该根据不同的应用环境合理的搭配使用。

1、Serial收集器

  这是个新生代的(意味着实现的是复制算法)单线程收集器,是最基础、最原始的收集器。会“stop the world”(垃圾收集的时候会停下用户线程来收集垃圾),与其它收集器的单线程比起来简单高效。在桌面应用环境中,因为分配给虚拟机管理的内存不会很大,所以停顿时间可以控制在几十毫秒或者一百对毫秒以内,可以接受。

【此处应该有图】(Serial垃圾收集器线程示意图)
2、ParNew收集器

  这也是个新生代的收集器,与Serial的区别是,这是个多线程的垃圾收集器。其它很多方面都与Serial收集器一样,共用了很多代码,在服务端非常常用。不光是因为它在多线程条件下效率高,而且因为多线程收集器只有它能和老年代的CMS收集器配合(这个CMS收集器可以做到一边收集垃圾,一边运行用户线程,在JDK1.5时代,用CMS作为老年代垃圾收集器的时候,不能和Parallel Scavenge配合使用,只能用ParNew收集器。当你使用CMS作为老年代的收集器的时候,默认会使用ParNew作为新生代的垃圾收集器)。
  ParNew默认开启的线程数和CPU的数量一致。

【此处应该有图】(ParNew多线程收集器)
3、Parallel Scavenge

  这也是个新生代的多线程收集器,它与其它的多线程收集器不同的地方在于,这个收集器的目标是达到一个可控的吞吐量(CPU用于运行用户代码的时间与CPU总消耗时间的比值,即:吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集的时间),虚拟机运行100分钟,其中垃圾处理花了一分钟,那么吞吐量就是99%)。
  停顿时间越短,越适合与用户交互的程序;而高吞吐量可以尽快的完成计算任务,适合在后台运算,不需要太多交互的任务。有两个参数可以控制吞吐量,经常被称为吞吐量优先收集器:

  • 控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis,值是一个大于0的毫秒数,收集器会尽可能的控制停顿时间不超过设置的值,但是这个控制是牺牲吞吐量和新生代大小换来的。收集器把新生代调小,那垃圾收集起来自然更快,停顿时间更短,但是相应的也会更加频繁,牺牲了吞吐量!
  • 直接控制吞吐量:-XX:GCTimeRatio,值是0-100的整数,表示垃圾收集时间占总时间的比率(吞吐量的倒数),这个我没懂,书上举了两个例子:如果把这个值设为19,那么允许的最大GC时间占总时间的5%(1/(1+19));默认值为99,那么允许的最大GC时间占总时间的1%(1/(1+99))。
  • 还有一个开关参数:-XX:+UseAdaptiveSizePolicy,打开开关之后,不需要手工指定新生代大小(-Xmn),Eden和Survivor空间的比例(-XX:SurvivorRatio),晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据允许情况自动的调整,只需要设置好最大堆(-Xmx),然后用MaxGCPauseMillis(关注停顿时间)或GCTimeRatio(关注吞吐量)参数给虚拟机设置好优化目标就行了,自适应优化也是Parallel Scavenge收集器与ParNew收集器的重要区别。
4、Serial Old收集器

  这是Serial收集器的老年代版本,实现了“标记-整理”算法,这个收集器的主要意义也是在Client模式下运行。还有两个用处是:在JDK1.5及之前版本与Parallel Scavenge收集器配合使用;或者作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5、Parallel Old收集器

  这是和Parallel Scavenge收集器配合使用的多线程老生代收集器,因为Parallel Scavenge不能和CMS收集器配合,所以在这个收集器发布之前只能配合Serial Old单线程收集器使用,无法发挥吞吐量收集器的优势。

6、CMS(Concurrent Mark Sweep)收集器

  以获取最短的收集停顿为目标的收集器,可以实现“你妈妈一边打扫,你一边扔垃圾”的操作。基于“标记-清除”算法实现,整个过程分为四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

  初始标记和重新标记会“Stop The World”,但是都很快。初始标记会标记出GC Roots直接关联的对象,很快;然后和用户线程并发做Roots Trancing;接下来的重新标记阶段会把并发标记过程当中因为用户程序运行而使标记发生变化的对象重新标记出来;最后和用户线程并发做清除工作。因为耗时最长的并发标记和并发清除都是和用户线程共同进行的,停顿时间很低。
  但是有三个显著的缺点:

  • CMS收集器对CPU资源非常敏感,CMS默认启动的回收线程数是(CPU数量+3)/4,当CPU超过四个的时候,垃圾收集线程最多占用25%的CPU资源,但是当CPU少于四个的时候,影响就比较大了,本来程序已经占满CPU了,但是还要分出相当一部分CPU的算力去做垃圾回收,会让用户忽然感觉到程序运行慢了很多。为了应对这种情况,提出过一种方案,现在已经不提倡用户使用了:“增量式并发收集器”,在并发标记和并发清除的时候让GC线程和用户线程交替运行,使得程序运行速度下降的没有那么明显。
  • CMS收集器无法处理浮动垃圾,最后的并发清除阶段产生的垃圾只能等下次垃圾回收才能处理,而且并发清除阶段必须给运行的程序留有足够的内存空间,否则就会出现,用户线程觉得没内存了,要收集垃圾了,垃圾收集线程觉得,我已经正在收集了,你还想怎么样,这就是Concurrent Mode Failure错误,出现这个错误的时候会使用Serial Old收集器收集垃圾,使得程序停顿时间延长。默认情况下,老年代被使用68%的时候就会触发CMS的垃圾收集动作,如果设置的太高,就会频繁的发生Concurrent Mode Failure错误,拖慢垃圾收集效率,需要根据自己应用老年代增长的特点设置。
  • 空间碎片多,这个问题是标记清除算法无法避免的,CMS提供了一个:-XX:+UseCMSCompactAtFullCollection开关参数,打开这个开关之后,会在你享受完“Full GC”之后会免费附赠一个的碎片整理服务,当然内存整理是单线程的,会延长停顿时间。CMS还提供了一个:-XX:CMSFullGCsBeforeCompaction,参数,可以指定多少次不压缩服务之后获得一次压缩服务?。
7、G1(Garbage First)收集器

  这个是基于“标记-整理”算法的收集器,不会产生空间碎片。而且能非常精确的控制停顿,也就是可以让使用者明确的指定在一个长度为M的时间片段内用于垃圾收集上的时间不超过N毫秒。
  这个可以收集器可以同时管理新生代和老生代,G1将整个Java堆划分为多个大小固定的独立区域(Region),并且监控这些区域的垃圾堆积程度,在后台维护一个优先列表,根据设置的收集时间限制,优先收集垃圾多的区域。区域划分和有优先级的回收,保证了G1收集器在有限的时间内获得最大的回收效率。

8、每个垃圾收集器都有一些参数,可以控制调优,要根据使用的哪个垃圾收集器来具体情况具体设置。

内存分配与回收策略

  不同的收集器组合策略是不同的,大致上内存分配有下面五个原则:这里涉及到各种机制的执行顺序书上没说明白,不知道是不是安装书上讲的顺序执行的。

  1. 对象优先在Eden分配
      分配内存时先把实例放在新生代,当新生代的可用空间不足的时候,会触发一次GC,新生代的GC是复制算法,survivor空间不足以存放新生代占用的空间时,会触发分配担保机制,将新生代的占用内存提前放入老年代。
  2. 大对象直接进入老年代
      虚拟机有个参数可以控制哪些对象算是大对象,这些大对象会被直接分配到老年代。-XX:PretenureSizeThreshold参数可以设置。
  3. 长期存活的对象将进入老年代
      分代收集的机制,就肯定有哪些对象算新哪些对象算老,虚拟机给每个对象定义了一个年龄,熬过一次Minor GC就涨一岁,等到了默认的十五岁的时候就从新生代晋升到老年代了。-XX:MaxTenuringThreshold参数用来设置对象年龄。
  4. 动态对象年龄判定
      在survivor空间中,如果相同年龄的对象总大小占了survivor空间的一半以上(注意,在survivor空间说明是新生代垃圾收集的时候),那么就把这个年龄和这个年龄以上的对象全部放入老年代。
  5. 空间分配担保
      发生Minor GC的时候(一般会有从新生代到老年代的晋升)会判断之前每次晋升到老年代的对象平均大小是否大于老年代的剩余空间大小,根据结果是否进行Full GC,如果平均的比剩余的多,就会Full GC,如果平均的比剩余的少(意味着这次晋升应该也没什么问题),就会查询一个HandlePromotionFailure设置,看它是否允许担保失败,如果允许(当然要允许了,不然回回都是Full GC可咋整),那就只进行Minor GC,否则也要进行Full GC。当然,如果真的担保失败了,那还是要进行Full GC的,但是一般不会?。

小结

这章大致介绍了垃圾收集算法、几种垃圾收集器和内存分配的几个原则,没有涉及到更多的细节,主要是他们都有什么特点,有什么缺点,没有一个通用的办法,需要根据自己的业务情况定制化调整,那么具体怎么来做这个定制化调整的工作呢?下面两章会介绍内存分析的工具和调优的一些案例。我个人打算先跳过后面两章,抓紧先把虚拟机是怎么个东西搞懂了,至于怎么用这个东西的内容可以滞后一点,等有需要的时候再来看后面两章。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值