Java垃圾回收机制

1.前言

  本文概括一下对Java内存管理机制的理解,尽量通俗易懂,知识来自于深刻理解Java虚拟机一书。html

2.起源

  计算机简单理解就是根据执行计划,经过参数获得结果。执行计划就是程序了,参数就是实际变量,最终运行获得咱们要的结果。磁盘因为其廉价且持久化,用于保存程序和数据,可是受制于执行速度,内存的做用就显现出来了。内存运行快,可是昂贵,易失数据(断电),容量远不及存储介质,可是快就是硬道理,计算无非就是要快出结果。因为容量的限制,致使不可能全部数据和程序代码都被加载到内存,又因为操做系统演化到了分时系统,对于内存的管理也就更显突出了。算法

  有些语言须要代码自己完成内存的分配和释放,典型的就是C++了。这种由开发人员决定如何利用内存无疑是高效、清楚的,可是对于大型项目开发带来了灾难,内存的分配不当,不释放,协做开发都会致使一系列问题。并且内存问题又是难以发现,测试,对程序编写人员我的能力提出了极高的要求,但显然不能期望全部人都能作到。缓存

  为了解决上诉问题,有人就想出由另外一个程序来管理内存,对其进行释放,这个典型的就是JVM了。其让开发人员无需过于关注内存的分配和使用,所有搞定,固然彻底不关心也是不可能的。这就是单独写一个程序进行内存分配和回收的起源。tomcat

  能够想一下,要写一个管理内存的程序,要考虑哪些问题:安全

    1.如何管理内存,让内存使用更高效数据结构

    2.什么状况下对象能够被回收多线程

    3.什么时间,如何进行回收并发

  这篇文章将对上述问题进行梳理。性能

3.探究

3.1 内存管理

  管理内存是个技术活,由于涉及到分配、回收和再分配的问题,好的设计才能提高效率。测试

  有一个基本的概念须要理解,那就是顺序读写确定比离散读写要快,因此为了效率,须要提高的就是内存的连续性了。首先就是开辟一大块内存,而后一块块的进行分配,初次是没有什么问题的,可是回收后就会出现大量的内存碎片,这个确定是不利于后续使用的。下面对几种算法进行讨论:

3.1.1 标记-清除算法

  这个就是上面讲的最简单的算法了。标记出全部须要回收的对象,在标记完成后统一回收被标记的对象。这就会形成内存产生大量的片断,不够连续了,还会妨碍分配大对象。

3.1.2 复制算法

  这个方法就是解决上诉碎片化的一种方案了,简单暴力。将内存分配成两块等大的区域,清理其中一块时,所有按顺序将存活的对象移动到另外一边。这样总能保证内存中的数据是连续的。

  可是这个方法存在两个问题,一个是太耗费内存,另外一个就是复制太多。这里先扩展一下虚拟机对内存的一个基本定义,新生代和老年代。这么划分是有缘由的,虚拟机中大部分的对象都是临时的,处于朝生夕死的状态(98%),但又有些对象会持久存活,甚至贯穿整个运行周期。对于这些状况,虚拟机定义了这么两个区域,能够针对其特性采起不一样的算法策略加速回收。

  复制算法就很适合新生代的朝生夕死的状况,这也意味着其不须要将内存等分。现代JVM将新生代划分红一块Eden空间(伊甸园新生),2块Survivor(幸存者,Eden幸存对象)。默认的大小比例是8:1:1。也就是Eden占用80%的空间,Survivor各占10%。具体操做就是:清理的时候,将当前使用的Eden和Survivor中幸存下来的对象,移动到另外一块当前没有使用的Survivor区域中,这样浪费的空间也就是一块survivor,10%而已,比一半节省了大量的空间。

  这里还有一些其余细节:10%的survivor不必定彻底够用,这个时候就会将一些内容刷新到老年代了。老年代不够用那就是真的要抛出异常了。这里面还涉及的概念有full gc和通常gc,后续进行说明。

3.1.3 标记-整理算法

  上面复制算法的两个问题,第一个内存虽然经过特性解决了,只浪费了10%的内存,可是存活对象不少的时候,复制的效率低下问题却没有获得解决。上面所说的另外一个定义老年代,这个区域的对象大几率都会存活到下一次gc,因此显然使用复制算法不太划算了。这个时候采起的一般就是标记整理算法。通俗的将就是将存活的对象向内存一端靠拢,最后统一清理掉存活对象后面的内存。

3.1.4 分代收集算法

  这个并非新的算法,而是上面所提到的思路:根据对象存活时间不一样,尽可能减小操做,采起合适的回收算法。因此将内存分为了新生代、老年代,而且新生代常采起复制算法,老年代常采起标记整理算法。固然,这个不是绝对的,要根据实际使用的回收器。

3.2 什么对象须要回收

  这个问题很好解答:不可能被再次使用的对象,便是无用对象,能够被回收。问题就在于如何判断一个对象再也不被使用呢?

3.2.1 引用计数法

  这种方法很好理解:每一个对象有一个计数器,若是有一个地方引用了它,计数器加1,引用失效就减一。对象建立的时候引用固然不会为0,为0后再也找不到这个对象了,天然就能够被回收了。这个方法实现简单,判断效率很高,Redis就是采用了这种算法,有一些语言好比Python也是使用这种方法,可是至少是主流的JVM没有使用这种方法。

  缘由在于循环引用:A引用了B,B引用了A,这两者都不为0,可是其余任何地方都与这两个地方不要紧。这种状况下也是应该被清理的对象,可是实际上引用计数法没法处理该状况。

3.2.2 可达性分析

  为了解决上诉的问题,JVM采起了可达性分析的方法。从一系列称为“GC Roots”的对象做为起始点,向下搜索全部能够被访问到的对象,若是有对象不能被搜索到,那么其必定就不可用。AB对象互相引用,可是GC Roots没法搜索到,这样循环引用的无效对象问题就解决了。

  GC Roots对象在我看来就是当前绝对不能对清理的对象,知足这一条件的对象有如下几种:

    1.虚拟机栈(栈帧中的本地变量表)中引用的对象。  马上就要用了,怎么能被清理

    2.方法区中类静态属性引用对象,常量引用对象。    方法区的static和final基本上和类加载通生命周期了,妥妥的命久对象,不能清理

    3.本地方法栈中JNI(Native方法)引用的对象。    举个不知道对不对的例子,线程开启完不保存就没法被引用了,可是在本地方法栈中仍是被线程管理器持有,这个固然不能清理。

3.2.3 再谈引用

  引用若是只能被标记为被引用和不被引用就比较狭窄,对于一些无关紧要的就难以抉择了。好比设计一个缓存服务,有内存的时候固然好,缓存保留。可是内存不够的时候,就会但愿可以行之有效的减小这些缓存。由于缓存确定是要被使用的,确定回收算法没法对其回收,这个时候想要丢弃都没有办法。JDK1.2以后提出了四种引用:强引用,软引用,弱引用,虚引用。网上有不少对这些引用的使用的讲解,这里推荐篇文章,不作过多描述:这里

  强引用:广泛存在,new之类的。强引用存在,就不会被回收。

  软引用:用于有做用,可是非必须的对象。发生内存溢出以前,会进行回收,若是回收后还不够,会抛出异常。SoftReference类。(用来作缓存)

  弱引用:非必需的对象。比软引用更弱,只能生存到下一次垃圾收集发生以前,当垃圾回收时都会清除掉。WeakReference类来实现。(一次性使用,自动回收,WeakHashMap实现,tomcat中用来作分代缓存:这里

  虚引用:幽灵引用或者是幻影做用,最弱的关系。不会对该对象生存时间构成影响,惟一的做用就是在清除后会收到一个通知。PhantomReference来实现。(用来作对象销毁后的一些操做,finalize也能作到相同的效果,可是因为其相关的一些问题,很差使用:这里

3.2.4 回收过程

  不可达的对象并非必定会被回收,主要是由于要进行两次标记。

  若是不可达,首先进行第一次标记,并判断是否须要执行finalize方法。没有覆写finalize方法,或者这个方法被执行过了,就不须要再次执行。这种状况会被直接清理掉。

  若是须要执行finalize方法,就会被放在F-Queue队列中,由Finalizer线程执行,执行的含义是会触发,但不必定会等运行结束:由于执行缓慢或者死循环会致使F-Queue中的其余对象等待,形成回收系统崩溃。finalize方法中,使得这个对象被其余对象引用,就能够逃脱回收的命运。由于以后GC会对F-Queue进行第二次标记,若是被其余对象持有,就会移除被回收的集合,若是没有逃脱,这个对象就会真正被回收。

3.2.5 回收方法区

  不少人认为方法区(永久代)是不须要进行回收的,虚拟机规范中虽说了不要求在方法区回收,性价比也比较低,可是也有回收的。

  回收的主要内容有两部分:废弃常量和无用类。JDK7将字符常量移除了永久代PermGen(很容易溢出),JDK8甚至移除了永久代,采用元数据Metaspace:这里

  字符常量很容易判断是否须要回收,可是类就比较麻烦了,须要同时知足如下3个条件:

    1.全部该类的实例被回收了(确保没有对象须要使用类的字节码、方法等信息)

    2.加载类的ClassLoader被回收了(确保再也不能经过new建立对象)

    3.该类对象的Class对象没有被引用,没法经过反射访问该类的方法。(补充2的反射状况)

  即使都知足了,也不必定会被回收。hotspot提供了-Xnoclassgc参数进行控制,或者使用-verbose:class和-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载卸载信息。前两个能够在Product版使用,UnLoading参数要在FastDebug版才支持。

3.3 HotSpot实现

  分析可达性的时候,系统必须保证冻结,即在这段时间内没有新的对象产生等。这就是所说的stop the world,会暂停全部的操做,保持不变。目前也是在极力减小停顿时间。

  HotSpot中使用OopMap的数据结构来达到直接得知哪些地方存放着对象引用的目的。在类加载完时,HotSpot就会将对象内什么偏移量上是什么数据类型计算出来,在JIT编译过程当中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC扫描的时候就能够直接获得这些信息了。

  在OopMap的帮助下,能够很快完成GC Roots的枚举。可是另一个问题是致使引用变化的指令不少,若是每条指令都生成OopMap开销就很高了。实际上,也并无为每条指令都生成OopMap,只有在特定的位置记录了信息,这些位置被称为安全点,只有在到达安全点时才能暂停,开始GC。

  安全点的选定基本上是以程序"是否具备让程序长时间执行的特制"为标准进行选定的——由于每条指令执行的时间都很是短暂,程序不太可能由于指令流长度太长这个缘由而过长时间运行,”长时间执行“的最明显的特制就是指令序列复用,如方法调用,循环跳转,异常跳转等,因此具备这些功能的指令才会产生Safepoint。

  另外一个问题就是如何让全部线程(不包括JNI调用的线程)都跑到安全点上再停顿下来。这里就有两种方式:

    1.抢先式中断:抢先式中断不须要线程的执行代码主动配合,在GC发生时,首先把全部线程所有中断,若是发现有线程中断的地方不在安全点上,就恢复,让它跑到安全点。该方法再也不采用。

    2.主动式中断:GC须要中断线程时,不直接对线程操做,仅仅简单地设置一个标志,各个线程执行时会主动轮询这个标志,发现为真时,就本身中断挂起。轮询标志的地方和安全点是重合的,另外加上建立对象须要分配内存的地方。test指令是生成的轮询指令,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待。

  安全点看似解决了如何进入GC的问题,但实际上却不必定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。可是,程序不执行的时候呢?不执行的时候就是没有分配CPU时间,典型的例子就是Sleep或者Blocked状态,线程没法响应JVM的中断请求,到安全的地方去中断挂起,JVM显然也不可能等待线程被从新分配CPU时间,这种状况就须要采起另外一种手段——安全区域safe region来解决了。

  安全区域指的就是在一段代码中,引用关系不会发生变化。在这个区域中任意地方GC都是安全的。线程执行到安全区域的时候,就会标识本身已经到了safe region,那么GC的时候就不会管线程状态了。在线程离开安全区域的时候,要检查系统是否已经完成了根节点枚举,若是完成了,线程继续执行,不然必须等到收到能够离开安全区域的信号才行。

4. 垃圾收集器

  上面说了那么多,这里看下JDK7的Hotspot虚拟机对于垃圾回收的实现。以前提到过JVM将内存人为的分为了年轻代和老年代,这也是为了针对不一样生命周期对象使用不一样算法提高效率的策略,因此存在使用多种垃圾回收器的状况,下图是回收器所处的代及其能够组合的回收器。

  能够看到年轻代的有:serial、ParNew、Parallel Scavenge

  老年代的有:CMS、Serial Old、Parallel Old

  G1通用于年轻代和老年代,另外连线就是能够进行组合使用的意思了,可是年轻代只能选一个,对应选择一个能够组合的老年代。G1通用,因此选了它就不能选其余的。另外,这个是早期版本的JVM提供的收集器了,近些年又有了极大的发展,好比JDK11提供的ZGC,号称很强大。JDK9改进了G1收集器,并废弃了几种组合DefNew+CMS、ParNew+SerialOld、递增的CMS。

4.1 Serial收集器

  这个是最基础的收集器,历史悠久,单线程(不只仅是只会使用一个线程,并且要暂停其余全部的线程)。这就是经典的stop the world了,十分糟糕。试想一下运行1小时,忽然中止5分钟,对程序而言很是不利。下图是serial/serial old处理示意图。

 

  可是这个收集器在1.7版本仍就是运行在client模式下的默认新生代收集器。优势在于简单高效。

4.2 ParNew收集器

   这个就是serial进化版本,采起的是多线程的方式,其他的都和serial的同样,好比控制参数:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure。工做示意图以下:

  这个收集器除了多线程以外,其余的与serial并无太多创新之处,可是它倒是许多运行在Server模式下的虚拟机首选的新生代收集器,由于除了Serial收集器外,只有它可以与CMS收集器(这个是第一个真正意义的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工做)配合工做。

  不幸的是,CMS做为老年代收集器,没法与Parallel Scavenge配合工做。因此只能选择ParNew或者Serial中的一个。若是使用参数-XX:+UseConcMarkSweepGC后,默认使用的就是ParNew,也可使用-XX:+UseParNewGC选项强制指定。

  在单CPU上,ParNew因为线程切换,效果确定不如Serial,通常线程数与CPU核数相关,能够经过-XX:ParallelGCThreads来限制线程数。

4.3 Parallel Scavenge收集器

  这是一个新生代收集器,使用的也是复制算法,并行多线程,与ParNew有什么区别呢?特色就是关注目标和其余收集器不一样。

  收集器的通常目标是尽快的完成清理动做,停顿时间越短越好。可是Parallel不一样,它关注的是吞吐量,即用户代码运行时间占总运行时间的比例。计算公式是:运行用户代码时间/(运行用户代码时间+垃圾收集时间),好比若是总共运行了10分钟,垃圾回收用了1分钟,吞吐量就是90%。

  强交互性的任务就须要停顿时间越短越好,好比一个鼠标点击事件,遇到垃圾回收等了1分钟,那就受不了了。而高吞吐量就不适合交互性的任务了,可是其反应的是更高效的运用CPU,因此运算效率会高。

  -XX:MaxGCPauseMillis设置最大垃圾回收的停顿时间,设置短是会牺牲吞吐量和新生代,会致使收集频繁

  -XX:GCTimeRatio设置吞吐量大小,这个值大于0小于100的整数。好比设置19,就是GC占时是5%. (1/(1+19)),默认值是99,就是1%的GC时间。

  -XX:+UseAdaptiveSizePolicy,设置这个就不须要设置-Xmn -XX:SurvivorRatio、-XX:PretenureSizeThreshold等细节参数了,会自适应。只须要设置Xmx最大堆,和上面两个参数便可。

4.4 Serial Old收集器

  这是一个老年代收集器,Serial的Old版本。一样的单线程,采用标记-整理算法,给Client模式下使用。

  还有两个用途:

    1.与Parallel Scavenge收集器搭配使用

    2.做为CMS的后备方案,发生Concurrent Mode Failure时使用。

4.5 Parallel Old收集器

  这个是用于解救以前新生代使用Parallel Scavenge时,老年代只能选择Serial Old这个性能不佳的收集器的困境。

  Parallel Scavenge + Serial Old的吞吐量不必定比ParNew+ CMS组合强,这个收集器弥补了这个尴尬之处,更适合用于注重吞吐量的场合。

4.6 CMS收集器

   Concurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的收集器。目前不少Java程序集中在互联网站或者B/S系统的服务端上,这类服务尤为注重响应速度,但愿系统停顿时间最短,以给用户带来较好的体验。

  CMS采起的不像以前的老年代收集器采用的标记整理算法,其使用的是标记-清除算法,整个过程分为4个步骤:

    1.初始标记:暂停全部线程,标记一下GC Roots能直接关联到的对象,速度很快

    2.并发标记:不须要暂停全部线程,是进行GC Roots Tracing的过程

    3.从新标记:暂停线程,修正并发标记期间因用户程序继续运做而致使标记产生变更的一部分对象的标记记录,耗时比初始标记长,并发标记短

    4.并发清除:不须要暂停线程,清除标记的对象

  CMS有3个显著的缺点:

    1.对CPU资源敏感。并发阶段虽然不会致使用户线程停顿,可是会占用一部分资源致使应用程序变慢,总吞吐量下降。默认的回收线程数是(CPU+3)/4,很多于25%的CPU资源。开发了一种增量式并发收集器,CMS的变种,在并发阶段让GC线程和用户线程交替运行,减小GC独占时间,效果很差。以前也提到了JDK9中废弃了。

    2.没法处理浮动垃圾,可能出现Concurrent Mode Failure,而致使另外一次Full GC的产生。并发清理阶段用户线程还在产生垃圾,这部分会被留到下一次GC处理,被称为浮动垃圾。因此CMS不能像其余收集器那样等待老年代几乎彻底被填满了再进行收集,须要留一部分空间应对这种状况。JDK5中,老年代使用了68%会被触发,能够经过参数-XX:CMSInitiatingOccupancyFraction的值提升触发百分比。若是预留空间不够,就会触发Concurrent Mode Failure,会使用预备方案,以前说的Serial Old收集器进行清理,停顿时间就更长了。

    3.标记-清除会产生大量空间碎片,对大对象分配带来很大的麻烦,会提早触发Full GC。-XX:+UseCMSCompactAtFullCollection开关参数(默认开启)用于在CMS收集器要Full GC时进行内存碎片的合并整理,碎片整理是没办法并发进行的,因此停顿时间更长了。-XX:CMSFullGCsBeforeCompaction,这个参数用于设置进行多少次不压缩的Full GC后进行一次压缩的,默认0表示每次都压缩。

4.7 G1收集器

  G1收集器在JDK9被设置成默认使用的收集器了,这几年有了更好的发展。这是一款面向服务端应用的垃圾收集器。赋予它的使命就是在替换掉JDK1.5发布的CMS收集器。也能够看出经历的时间很长才完成了这款收集器。

  G1有如下特色:

    1.并发与并行:充分利用多CPU、多核环境,缩短Stop-The-World时间

    2.分代收集:分代概念仍保留在G1中,虽然它一个就管理了新生代和老年代。

    3.空间整合:从总体上是基于标记-整理算法实现的收集器,局部上是基于复制算法实现的。不会产生大量碎片。

    4.可预测的停顿:除了追求停顿外,还创建可预测的停顿时间模型,让使用者指定一个长度为M毫秒的时间片断内,消耗在垃圾收集上的时间不超过N毫秒,几乎是实时Java(RTSJ)的垃圾回收器的特制了。

  虽然保留了分代概念,可是其是将堆划分红多个大小相等的独立区域,再也不是物理隔离了。之因此可以创建停顿时间模型,也是由于它能够有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个区域的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据容许的收集时间,优先收回价值最大的区域,保证在有限时间内达到效率最大。思路虽然简单,实现起来很是复杂,由于区域并不是孤立,不可能只扫描一个区域,否则其余区域引用了如何判断?从04年G1的理论到如今才被设置成默认的收集器,可见其困难。

  在G1中,使用Remembered Set来避免全堆扫描,每一个区域都有一个这个Set,虚拟机发现程序对Reference类型的数据进行写操做时,会产生一个Write Barrier暂时中断写操做,检查Reference引用的对象是否处于不一样的Region之中,若是是,便经过CardTable将相关信息记录到被引用对象所属的区域的Remembered Set中。回收时,经过这个就能保证不进行全堆扫描也能知道该区域对象有没有被其余区域引用。

  G1回收步骤与CMS类似,分为如下阶段:

    1.初始标记:标记一下GC Roots能直接关联到的对象,修改TAMS的值,让下一阶段用户程序并发运行时,能在正确可用的区域建立新对象

    2.并发标记:从堆中对象进行可达性分析,找出存活的对象,可并发执行

    3.最终标记:修正并发标记过程当中因为用户程序执行致使标记产生变化的记录,变化记录在线程的Remembered Set Logs里面,会将这部分数据合并到Remembered Set中。

    4.筛选回收:对各个区域进行排序,根据用户指望的GC停顿时间制定回收计划。

 

5 GC日志

  时间:【GC类型【发生区域:GC前使用容量->GC后使用容量(该区域总容量),GC耗时】GC前Java堆使用容量->GC后Java堆使用容量(堆总容量),总GC耗时】

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

     发生区域根据不一样的收集器,不一样代命名不一样。

     DefNew就是serial的新生代

     ParNew:是ParNew的新生代

     PSYoungGen:是Parallel Scavenge的新生代

  还有另外一种格式,例如:

    100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456), [Perm: 2999K -> 2999K(21248K)],0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

  时间上user就是用户态消耗的CPU时间,sys是内核态消耗的CPU时间,real指从开始到结束所通过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间区别在于:墙钟时间包含各类非运算的等待耗时,例如磁盘IO等,CPU不包含。可是若是是多核CPU,多线程操做会叠加这些时间,因此会看到user或sys时间超过real时间。

   经过参数-XX:+PrintGCDetails这个参数打印内存回收日志。

6 内存分配过程

  大部分状况下,对象在新生代的Eden区进行分配,没有足够空间的时候,触发一次Minor GC,内存依旧不够,将对象移动到老年代这个担保区域。

  大对象须要大量的连续空间,好比byte[],会致使触发垃圾回收,更糟糕的状况是遇到一群大对象。虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个设置值的对象直接在老年代进行分配。避免在Eden和两个Survivor区之间发生大量的内存复制。这个参数必须写成字节数,不能直接写MB。

  另外一种进入老年代的方法就是知足了年龄阈值,每进行一次Minor GC后对象仍或者,其年龄就会加1,到达阈值就会进入老年代。经过-XX:MaxTenuringThreshold设置。

  为了更好的适用内存情况,不必定必须达到年龄才能晋升老年代,若是survivor空间中相同年龄全部对象大小总和大于survivor空间的一半,年龄大于等于这个年龄的对象就能够直接进入老年代。

  在执行Minor GC以前,会检查老年代的最大可用连续空间是否大于新生代全部对象总和,成立那么Minor GC就是安全的。由于老年代是在新生代空间不足时的担保方,最差的状况就是全部对象都进入了老年代,因此只要老年代的空间足够,那么Minor GC必定安全。若是不安全,就会查看HandlePromotionFailure设置值是否容许担保失败,若是容许,就会检查老年代的连续最大可用空间是否大于历次晋升到老年代对象的平均大小,若是大于,会尝试进行Minor GC,尽管这次也有风险,小于或者不容许失败,会改为进行Full GC。JDK6 Update24后,这个参数没有实际做用了,大于平均晋级大小,就会进行Minor GC,不然就是Full GC。

相关文章
相关标签/搜索