美文网首页
深入理解Java虚拟机 之 垃圾回收

深入理解Java虚拟机 之 垃圾回收

作者: 4e70992f13e7 | 来源:发表于2018-09-05 14:45 被阅读34次

    哪些内存需要回收

    猿们都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

    引用计数算法

    算法分析

    引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

    优缺点

    优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
    缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

    示例:

    public class ReferenceFindTest {
        public static void main(String[] args) {
            MyObject object1 = new MyObject();
            MyObject object2 = new MyObject();
              
            object1.object = object2;
            object2.object = object1;
              
            object1 = null;
            object2 = null;
        }
    }
    

    这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

    可达性分析算法

    可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。


    image.png

    在Java语言中,可作为GC Roots的对象包括下面几种:

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

    垃圾收集算法

    标记-清除算法(Mark-Sweep)

    image.png

    首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它就是最基础的垃圾回收算法。但是这么做存在两个问题:
    (1)效率问题:标记过程与清除过程采用轮询一样的机制的话,效率太低;
    (2)空间问题:需要回收的对象内存空间并不一定是连续的,如果每隔一个单位内存空间就有一个对象需要回收,那么会出现 太多的控件碎片。导致后面的操作在申请较大的内存空间时,会出现实际内存容量足够,但是没有连续的内存空间去分配给这个对象的情况。导致不得不去触发另一次的垃圾收集动作。

    标记-整理算法(Mark-Compact)

    image.png

    标记-整理算法已经把“清除”步骤变成了“整理”。在同样进行标记完成后,让所有存活的对象向内存的一端移动,然后直接清除掉存活对象区边界之外的内存。其实这个算法应用大部分在于分代收集中的老年代中,下面一步一步介绍分代收集。

    复制算法(Copying)

    image.png

    把内存空间分为大小相等的两份,每次只使用其中的一块。当发现正在使用的一块的内存空间用完了以后,把还存活的对象复制到另一块内存空间上,再去清除上一块内存空间中需要清除的对象。每次操作对半个区域,没有了大量的内存碎片,通过堆指针的移动,效率也得到了提高。
    重要的来了:这种算法存在一个缺陷是把可用内存缩小为了原来的一半。当对象大部分在下一次GC时都会被回收的情况下,50%的比例太大了。所以大部分时候是按照一种实践后的分配方式:
    将(新生代)内存空间分为一块80%空间的 Eden 空间和两块10%的 Survivor 空间,在GC时,将Eden与一块Survivor中的存活对象复制到另一块Survivor空间中。
    但是实际上,我们无法保证那10%就足以存储还存活的对象,所以我们还要依赖其他的内存空间,就是“老年代”,顺理成章地,上面的空间变成了“新生代”。

    分代收集算法

    当前的JVM基本都是使用分代收集算法。针对不同的情况,在不同的“代”中执行不同的垃圾回收算法。一般把 JVM堆内存 分为新生代与老年代。新生代对象死亡率高,使用复制算法。老年代中的对象存活率高,没有额外空间对它们进行分配担保,就必须使用Mark-Sweep、Mark-Compact.
    对于不同的垃圾收集器,会在不同的代使用大致相同的垃圾回收算法,但是有的是双阶段多线程,有的只有一个阶段使用。稍后,我会分析最近比较热门的CMS收集器与G1收集器中更加智能的过程。

    垃圾回收器

    Serial收集器

    Serial收集器是最古老的收集器,它的缺点是当Serial收集器想进行垃圾回收的时候,必须暂停用户的所有进程,即stop the world。到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器,与其他收集器相比,对于限定在单个CPU的运行环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾回收自然可以获得最高的单线程收集效率。
    Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。这个收集器的主要意义也是被Client模式下的虚拟机使用。在Server模式下,它主要还有两大用途:一个是在JDK1.5及以前的版本中与Parallel Scanvenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。
    通过指定-UseSerialGC参数,使用Serial + Serial Old的串行收集器组合进行内存回收。

    ParNew收集器

    ParNew收集器是Serial收集器新生代的多线程实现,注意在进行垃圾回收的时候依然会stop the world,只是相比较Serial收集器而言它会运行多条进程进行垃圾回收。
    ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百的保证能超越Serial收集器。当然,随着可以使用的CPU的数量增加,它对于GC时系统资源的利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多(譬如32个,现在CPU动辄4核加超线程,服务器超过32个逻辑CPU的情况越来越多了)的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
    -UseParNewGC: 打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收,这样新生代使用并行收集器,老年代使用串行收集器。

    Parallel Scavenge收集器

    Parallel是采用复制算法的多线程新生代垃圾回收器,似乎和ParNew收集器有很多的相似的地方。但是Parallel Scanvenge收集器的一个特点是它所关注的目标是吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能够提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
    Parallel Old收集器是Parallel Scavenge收集器的老年代版本,采用多线程和”标记-整理”算法。这个收集器是在jdk1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是如果新生代Parallel Scavenge收集器,那么老年代除了Serial Old(PS MarkSweep)收集器外别无选择。由于单线程的老年代Serial Old收集器在服务端应用性能上的”拖累“,即使使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,又因为老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合”给力“。直到Parallel Old收集器出现后,”吞吐量优先“收集器终于有了比较名副其实的应用祝贺,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
    -UseParallelGC: 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行内存回收。-UseParallelOldGC: 打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行垃圾回收

    CMS收集器

    CMS(Concurrent Mark Swep)收集器是一个比较重要的回收器,现在应用非常广泛,我们重点来看一下,CMS一种获取最短回收停顿时间为目标的收集器,这使得它很适合用于和用户交互的业务。从名字(Mark Swep)就可以看出,CMS收集器是基于标记清除算法实现的。它的收集过程分为四个步骤:

    1. 初始标记(initial mark)
    2. 并发标记(concurrent mark)
    3. 重新标记(remark)
    4. 并发清除(concurrent sweep)

    注意初始标记和重新标记还是会stop the world,但是在耗费时间更长的并发标记和并发清除两个阶段都可以和用户进程同时工作。
    不过由于CMS收集器是基于标记清除算法实现的,会导致有大量的空间碎片产生,在为大对象分配内存的时候,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前开启一次Full GC。为了解决这个问题,CMS收集器默认提供了一个-XX:+UseCMSCompactAtFullCollection收集开关参数(默认就是开启的),用于在CMS收集器进行FullGC完开启内存碎片的合并整理过程,内存整理的过程是无法并发的,这样内存碎片问题倒是没有了,不过停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction参数用于设置执行多少次不压缩的FULL GC后跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。
    不幸的是,它作为老年代的收集器,却无法与jdk1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在jdk1.5中使用cms来收集老年代的时候,新生代只能选择ParNew或Serial收集器中的一个。ParNew收集器是使用-XX:+UseConcMarkSweepGC选项启用CMS收集器之后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。
    由于CMS收集器现在比较常用,下面我们再额外了解一下CMS算法的几个常用参数:
    UseCMSInitatingOccupancyOnly:表示只在到达阈值的时候,才进行 CMS 回收。
    为了减少第二次暂停的时间,通过-XX:+CMSParallelRemarkEnabled开启并行remark。如果ramark时间还是过长的话,可以开启-XX:+CMSScavengeBeforeRemark选项,强制remark之前开启一次minor gc,减少remark的暂停时间,但是在remark之后也立即开始一次minor gc。
    CMS默认启动的回收线程数目是(ParallelGCThreads + 3)/4,如果你需要明确设定,可以通过-XX:+ParallelCMSThreads来设定,其中-XX:+ParallelGCThreads代表的年轻代的并发收集线程数目。
    CMSClassUnloadingEnabled: 允许对类元数据进行回收。
    CMSInitatingPermOccupancyFraction:当永久区占用率达到这一百分比后,启动 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。
    CMSIncrementalMode:使用增量模式,比较适合单 CPU。
    UseCMSCompactAtFullCollection参数可以使 CMS 在垃圾收集完成后,进行一次内存碎片整理。内存碎片的整理并不是并发进行的。
    UseFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。
    一些建议
    对于Native Memory:

    • 使用了NIO或者NIO框架(Mina/Netty)
    • 使用了DirectByteBuffer分配字节缓冲区
    • 使用了MappedByteBuffer做内存映射

    由于Native Memory只能通过FullGC回收,所以除非你非常清楚这时真的有必要,否则不要轻易调用System.gc()。
    另外为了防止某些框架中的System.gc调用(例如NIO框架、Java RMI),建议在启动参数中加上-XX:+DisableExplicitGC来禁用显式GC。这个参数有个巨大的坑,如果你禁用了System.gc(),那么上面的3种场景下的内存就无法回收,可能造成OOM,如果你使用了CMS GC,那么可以用这个参数替代:-XX:+ExplicitGCInvokesConcurrent。
    此外除了CMS的GC,其实其他针对old gen的回收器都会在对old gen回收的同时回收young gen。

    G1收集器

    G1收集器是一款面向服务端应用的垃圾收集器。HotSpot团队赋予它的使命是在未来替换掉JDK1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:

    1. 并行与并发:G1能更充分的利用CPU,多核环境下的硬件优势来缩短stop the world的停顿时间。
    2. 分代收集:和其他收集器一样,分代的概念在G1中依然存在,不过G1不需要其他的垃圾回收器的配合就可以独自管理整个GC堆。
    3. 空间整合:G1收集器有利于程序长时间运行,分配大对象时不会无法得到连续的空间而提前触发一次GC。
    4. 可预测的非停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

    在使用G1收集器时,Java堆的内存布局和其他收集器有很大的差别,它将这个Java堆分为多个大小相等的独立区域,虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
    虽然G1看起来有很多优点,实际上CMS还是主流。

    相关文章

      网友评论

          本文标题:深入理解Java虚拟机 之 垃圾回收

          本文链接:https://www.haomeiwen.com/subject/hiyawftx.html