天天看点

我与GC不得不说的故事——《我的Java打怪日记》

GC作为Java知识体系里的一个面试热点,经常是众多程序猿常常需要复习的内容,借助这次活动,特将我总结的GC知识点分享给大家,大家共同进步!

GC(垃圾回收)

虚拟机栈是线程独占的,也就是说随着线程初始而初始,消亡而消亡,当线程被销毁后,虚拟机栈上的内存自然会被回收,即虚拟机栈上的内存空间不在GC范围。

GC的主要作用是回收程序中(主要是堆中)不再使用的内存。对对象而言,如果没有任何变量去引用它,那么该对象就不可能被程序访问,因此可以认为它是垃圾信息,可以被回收。垃圾回收器使用有向图来记录和管理堆内存中的所有对象,通过该有向图可以识别哪些变量是可达的,哪些是不可达的(没有引用变量引用即为不可达的),所有不可达的均要被回收。

检查对象是否存活

  1. 引用计数法:引用计数作为一种简单但是效率较低的方法,其实现原理如下:在堆中每个对象都有一个引用计数器,当对象被引用过时,引用计数器加1,当引用失效时减1,由于这种方法无法解决相互引用的问题,因此JVM没有采用这个算法。分析下面这段代码使用引用计数法可能出现的问题。
我与GC不得不说的故事——《我的Java打怪日记》

当采用引用计数算法时:

    • 第一步:GcObject实例1被obj1引用,所以它的引用数加1,为1;
    • 第二步:GcObject实例2被obj2引用,所以它的引用数加1,为1;
    • 第三步:obj1的instance属性指向obj2,而obj2指向GcObject实例2,故GcObject实例2引用加1,为2;
    • 第四步:obj2的instance属性指向obj1,而obj1指向GcOjbect实例1,故GcObject实例1引用加1,为2;
    • 到此前4步,GcOjbect实例1和GcOjbect实例2的引用数量均为2,此时结果图如下:
我与GC不得不说的故事——《我的Java打怪日记》
    • 第五步:obj1不再指向GcOjbect实例1,其引用计数减1,结果为1;
    • 第六步:obj2不再指向GcOjbect实例2,其引用计数减1,结果为1。

到此,可以发现GcObject实例1和实例2的计数引用都不为0,如果采用引用计数算法的话,这两个实例所占的内存将得不到释放,这便产生了内存泄露。

  1. 可达性分析法:通过一系列的称为“GC roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC root没有任何引用链相连(用图论的话来说,就是从GC roots到这个对象不可达),则证明此对象是不可用的。
我与GC不得不说的故事——《我的Java打怪日记》

GC主要对堆中的对象进行回收,方法区、栈不被GC所管理,因而选择这些区域内的对象作为GCroots。GC会收集那些不是GC roots且没有被GC roots引用的对象。

常用的垃圾回收算法

  1. 标记清除:利用JVM维护的对象引用图,从根节点开始遍历对象的引用图,同时标记遍历到的对象,当遍历结束时,未被标记的对象就是目前已不被使用的对象,可以被回收。如下例子:
我与GC不得不说的故事——《我的Java打怪日记》
我与GC不得不说的故事——《我的Java打怪日记》

假设运行到step3后运行step4之前,进行了一次垃圾回收。首先:找出所有的根对象,标志其是否可回收,这个是通过将同步块索引的一位设为0来完成的,这个阶段为标记阶段。在运行到step3后,obj1和obj2和obj3就是这里的3个根,其中根obj2在栈中已经被pop出去了,因此栈中只有两个根对象obj1和obj3,如图所示:

我与GC不得不说的故事——《我的Java打怪日记》

因为根obj1均引用了object对象1,所以object对象1的同步块索引的一位被置为1,以标记其不是垃圾,然后检查根obj3的对象引用情况,发现它也引用了object对象1,当它刚要标志其同步索引块的一位时,发现object对象1已经被标记了,则不重新标记它。需要注意的是,在标记object对象1时,发现它引用了其他的对象(假设为a),那么对象a也对被标记。标记过程会持续,依次检查完所有的根。若对象在进行可达性分析后发现没有与GC roots相连接的引用链,那么它将会被第一次标记并进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法,当对象没有重写finalize()方法或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没必要执行。若该对象被判定为有必要执行finalize方法,则这个对象会被放在一个F-Queue队列,finalize方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-queue中的对象进行第二次小规模的标记,若对象要在finalize中成功拯救自己(只要重新与引用链上的任何一个对象建立关联即可),那么在第二次标记时他们将会被移出即将回收集合。

如果在被标记后直接对对象进行清除,会带来另一个新的问题——内存碎片化。如果下次有比较大的对象实例需要在堆上分配较大的内存空间时,可能会出现无法找到足够的连续内存而不得不再次触发垃圾回收。

  1. 标记整理:标记过程同上,整理:把堆中活动的对象移到堆中一端,这样就会在堆中另外一端留出很大的一块空闲区域,相当于对堆中的碎片进行了处理,虽然这种方式会大大简化消除堆碎片的工作,但是每次处理都会带来性能的损失。在完成这一步后,有两个问题亟待解决,第一个是内存压缩后,其在堆中的实际位置变化了,而引用的位置还是原来的,将会访问旧的内存地址而造成内存损坏。所以,还要将根中引用的地址减去在压缩中偏移的字节数,这样就能保证每个根引用的还是原来的c,只不过对象在内存中变换了位置。第二个问题是指向下一个对象在堆中的分配位置指针也要进行偏移字节数的计算,这样才能保证新对象分配的内存与原有的堆内存是连续的。
  1. 复制回收算法:把堆分成两个大小相同的区域,在任何时刻。只有其中的一个区域被使用,当这个区域被消耗完,垃圾回收器会中断程序的执行,通过遍历的方式把所有活动的对象复制到另外一个区域中,在复制的过程中,它们是紧挨着布置的,从而可以消除内存碎片。当复制过程结束后程序接着运行,直到这块区域被使用完,然后再采用上面的方法进行垃圾回收。
  1. 按代回收算法:复制回收算法有一个缺点,每次算法执行时,所有处于活动状态的对象都要被复制,这样效率很低。由于程序有“大部分对象的生命周期都很短,只有一部分对象有较长的生命周期的”特点。因此可以根据这个缺点对算法进行优化。按代回收算法的主要思路如下:把堆分成两个或者多个子堆,每一个子堆被视为一代。算法在运行的过程中优先收集那些年幼的对象,如果一个对象经过多次收集仍然存活,那么就可以把这个对象转移到高一级的堆里,减少对其的扫描次数。开发人员可以通过System.gc()方法来通知垃圾回收器运行,当然,JVM也不会保证垃圾回收器马上就会运行,由于该方法的执行会停止所有的响应去检查内存中是否有可回收的对象,这会对程序的运行及性能造成很大的威胁。

分代收集:

  • 新生代:复制回收算法;
  • 老年代:标记整理或标记清除。

垃圾收集器

这里讨论的虚拟机是基于Jdk1.7之后的HotSpot虚拟机。下图展示了jdk7中作用于不同分代的收集器,如果两个收集器之间存在连线就代表它们可以搭配使用。

我与GC不得不说的故事——《我的Java打怪日记》

新生代垃圾收集器

  1. serial收集器:单线程收集器,单线程的意义不仅仅说明它只会使用一个cpu或者一条垃圾收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到它收集结束。Stop The World这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉。下图是Serial收集器运行示意图。直到现在Serial仍然是虚拟机运行在client模式下默认新生代收集器,它简单而高效,单个CPU环境下没有线程交互的开销,采用复制回收算法。
我与GC不得不说的故事——《我的Java打怪日记》
  1. parNew搜集器:serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数,但在单CPU的环境中,它不会有比Serial收集器更好的效果,是许多运行在server模式下的虚拟机首选的新生代收集器,其中有一个与性能无关但很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。工作过程如下图,复制回收算法。
我与GC不得不说的故事——《我的Java打怪日记》
  1. parallel scaverge:并行的多线程收集器,目标是达到一个可控制的吞吐量(运行代码时间/(运行代码时间+垃圾收集时间)),而其他收集器的目标是尽可能的缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量可以高效的利用CPU时间,尽快完成程序的运行。适合在后台运算,没有太多的交互。复制回收算法。

老年代垃圾收集器

  1. serial old:serial收集器的老年代版本,单线程,标记整理算法,这个收集器的主要意义也是在于给Client模式下的虚拟机使用,如果在Server模式下,它主要还有两大用途:一种用途是在jdk1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS的后备预案。工作过程如下图:
我与GC不得不说的故事——《我的Java打怪日记》
  1. parallel old:parallel scaverge的老年代版本,多线程,标记整理,工作过程如下:
我与GC不得不说的故事——《我的Java打怪日记》
  1. cms(Concurrent Mark Sweep)收集器:该收集器是Hotspot虚拟机中第一款具有真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,但该收集器无法与Paraller Scavenge配合使用,它是一种以获取最短回收停顿时间为目标的收集器,是基于标记清除算法实现的运作过程较为复杂,整个过程可分为4个过程:初始标记(仅仅只是标记一下GC roots能直接关联到的对象,速度很快)、并发标记(进行GC Roots tracing的过程)、重新标记(为了修正并发标记期间因并用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长,但远比并发标记的时间短) 并发清除。其中初始标记和重新标记阶段要stop the world(停止工作线程),整个过程中耗时最长的并发标记和并发清除过程收集器都可以与用户线程一起工作,总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。优点:并发收集、低停顿;缺点:不能处理浮动垃圾、对cpu资源敏感、产生大量内存碎片(标记清除算法)。运行过程如下:
我与GC不得不说的故事——《我的Java打怪日记》

浮动垃圾:并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。

G1收集器

优点:

  1. 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  2. 分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、 熬过多次GC的旧对象以获取更好的收集效果。
  3. 空间整合:与CMS的标记清理算法不同,G1从整体来看是基于标记整理算法实现的收集器,从局部(两个Region之间)上来看是基于复制算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。 这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  4. 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1收集器的运作大致可划分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收(首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划)。

我与GC不得不说的故事——《我的Java打怪日记》
我与GC不得不说的故事——《我的Java打怪日记》
我与GC不得不说的故事——《我的Java打怪日记》

每个region的大小都是2的倍数,通过设置堆的大小和region的个数(默认2048)计算得出,每个region可能属于eden,也可能属于old,且每类区域空间是不连续的,这种将O区划分成多块的理念源于:当并发后台线程寻找可回收的对象时、有些region区包含可回收的对象要比其他区块多很多。虽然在清理这些区块时G1仍然需要暂停应用线程、但可以用相对较少的时间优先回收包含垃圾最多区块。这也是为什么G1命名为Garbage First的原因:第一时间处理垃圾最多的区块。

垃圾收集模式

G1中提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。

  1. young gc:发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。
参数 含义
-XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
  1. mixed gc:当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制,mixed gc的触发时机:

在cms中,如果添加了以下参数:

-XX:CMSInitiatingOccupancyFraction=80 
-XX:+UseCMSInitiatingOccupancyOnly      

当老年代的使用率达到80%时,就会触发一次cms gc。相对的,mixed gc中也有一个阈值参数 -XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc。mixed gc的执行过程有点类似cms,主要分为以下几个步骤:

    • initial mark:初始标记过程,标记了从GC Root直接可达的对象;
    • concurrent marking:并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息;
    • remark:最终标记过程,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象;
    • clean up:垃圾清除过程,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中。
  1. full gc:如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc。

内存分配

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合还有虚拟机中与内存相关参数的设置。下面是最普遍的内存分配规则:

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

-XX:+PrintGCDetails

​ :告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

我与GC不得不说的故事——《我的Java打怪日记》
  1. 大对象直接进入老年代:大对象是指需要大量连续内存空间的java对象,最典型的大对象就是那种很长的字符串以及数组,大对象对虚拟机的内存分配来说就是一个坏消息,尤其是朝生夕灭的短命大对象,写程序的时候应当避免,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来存放它们。

-XX:PretenureSizeThreshold

​,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制

  1. 长期存活的对象进入老年代:每个对象有一个对象年龄Age计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。 对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数​

    -XX:MaxTenuringThreshold

    ​设置。
  1. 动态对象年龄判断:若在survivor空间中相同年龄所有对象大小的总和>survivor 空间的一半,则年龄大于等于该年龄的对象直接进入老年代,无须等到MaxTeuringThreshold(默认为15)中的要求。

分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。 如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

内存泄露

内存泄漏大家都不陌生了,简单粗俗的讲,就是该被释放的对象没有释放,一直被某个或某些实例所持有却不再被使用导致 GC 不能回收。内存泄露是指一个不再被程序使用的对象或者变量还在内存中占有存储空间。一般来说,内存泄露主要有两种情况,一是堆中申请的空间没有被释放,二是对象已不再被使用,但仍然在内存中保留着。垃圾回收机制的引入可以有效的解决第一种情况,而对于第二种情况,垃圾回收机制无法保证不再使用的对象会被释放。Java中引起内存泄露的原因主要有以下几个方面的内容:

  1. 静态集合类:例如HashMap和Vector,如果这些容器是静态的,由于它们的生命周期与程序一致,那么容器中的对象在结束之前将不会被释放,从而造成内存泄露。如下例:
我与GC不得不说的故事——《我的Java打怪日记》
  1. 各种连接,比如数据库连接、网络连接以及IO连接,若不关闭相应的连接,会造成内存泄露。
  2. 监听器:通常一个应用中会用到多个监听器,但在释放对象的同时往往没有相应的删除监听器,从而造成内存泄露。
  3. 变量不合理的作用域:变量定义的作用范围大于其使用范围,很有可能造成内存泄露。
  4. 单例模式:若单例模式中包含对对象的引用。

由于单例对象以静态变量的方式存储,因此它在JVM的整个生命周期中都存在,若它含有对其他对象的引用,则会造成其他对象的类不能被回收。

minor GC、major GC、full GC区别

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

老年代 GC(Major GC):指发生在老年代的GC,。MajorGC的速度一般会比Minor GC慢10倍以上。

Full GC是针对整个堆来说的,出现full gc的时候经常伴随着至少一次的minor gc,但并非绝对的,

我与GC不得不说的故事——《我的Java打怪日记》

堆内存划分为Eden、Survivor和Tenured/Old 空间。虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被 Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为 1。对象在Survivor区中每熬过一次Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代中。

商业虚拟机:将内存分为一块较大的eden空间和两块较小的survivor空间,默认比例是8:1:1,即每次新生代中可用内存空间为整个新生代容量的90%,每次使用eden和其中一个survivour。当回收时,将eden和survivor中还存活的对象一次性复制到另外一块survivor上,最后清理掉eden和刚才用过的 survivor,若另外一块survivor空间没有足够内存空间存放上次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

触发full gc的情况:

  • System.gc()方法的调用(此方法会建议jvm进行full gc);
  • 老年代空间不足:老年代空间只有在新生代对象转入或创建大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误: ​

    java.lang.OutOfMemoryError: Java heap space

    ​,为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组;
  • 永久代空间不足:当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息: ​

    java.lang.OutOfMemoryError: PermGen space

    ​。为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC;
  • 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间;
  • Full gc还会回收方法区和堆外内存。

查看gc情况

jstat通常用来分析系统的垃圾回收情况。

jstat -gcutil pid 2000(每隔2秒输出一次结果)      
我与GC不得不说的故事——《我的Java打怪日记》

其中S0、S1 代表两个Survivor区;E 代表 Eden 区;O(Old)代表老年代;P(Permanent)代表永久代;YGC(Young GC)代表Minor GC;YGCT代表Minor GC耗时;FGC(Full GC)代表Full GC耗时;GCT代表Minor & Full GC共计耗时。