当前位置: 首页 > 科技观察

JVM的垃圾回收算法和垃圾回收器详解

时间:2023-03-12 12:53:31 科技观察

开始我们知道JVM的垃圾回收机制其实就是对JVM内存的操作。回收的目的是为了避免内存溢出和内存泄漏。JVM内存由五个区域组成:方法区、堆、虚拟机栈、本地方法栈、程序计数器。虚拟机栈、本地方法栈、程序计数器是随着Java线程的建立而建立起来的。这部分内存将被释放。方法区和堆属于共享线程,随着JVM的启动而建立,这两个区域也不同于其他三个区域。一个接口中有多少个实现类(方法区),创建了多少个对象(堆)是动态的,也就是说只有在程序运行的时候才能知道。为了让这部分动态内存分配能够得到合理的回收,就需要垃圾回收算法和垃圾收集器来帮忙。让我们进入今天的主题。如何判断对象“存活”?JVM垃圾回收机制是回收堆中未使用的对象,因此判断对象是否“存活”非常重要。在判断一个对象是否“活着”的方法中,我们会介绍引用计数算法和可达性分析方法。引用计数算法为Java堆中的每个对象设置一个引用计数器。当一个对象被创建并初始赋值时,变量count被设置为1。每在某处被引用一次,计数器值就加1。当引用过期时,也就是当一个引用被引用时,计数器值就减1。对象超过生命周期(超出范围后)或设置为新值。任何引用计数为0的对象都可以被垃圾回收。当一个对象被垃圾回收时,它所引用的任何对象的计数都会减1。这种方法的优点是显而易见的。引用计数收集器实现简单,判断效率高,更有利于程序长时间不中断的实时环境。但是,缺点也很明显。对象循环引用的场景很难判断,引用计数器增加了程序执行的开销。Java语言并没有选择这种算法进行垃圾回收。可达性分析方法也称为根搜索算法,它以称为GCRoots的对象为起点,从上到下搜索。搜索所经过的路径称为引用链(ReferenceChain)。当发现一个对象与GCRoots之间不存在引用链时,则认为该对象不可达,该对象成为垃圾回收的目标。如图1所示,没有从GCRoots连接到Object5、Object6和Object7的引用链,所以这三个对象是GCRoots无法访问的,即使它们之间有引用也会被垃圾回收。图1Reachability分析方法在Java中,可以作为GCRoots的对象包括以下四种:虚拟机栈中引用的对象(栈帧中的局部变量表)局部方法中JNI(Native方法)引用的变量stack类静态属性在方法区引用的变量方法区常量引用的变量上面说的可达性其实就是判断对象是否被引用。如果没有,垃圾收集器将回收它。但是,我们希望有这样的对象,尽量在内存空间足够的时候将它们保存在内存中,在内存不够的时候回收这些对象。下面看看如何处理以下对象:强引用(StrongReference):比如Objectobj=newObject(),只要强引用存在,垃圾回收器就永远不会回收被引用的对象。软引用:在系统内存即将溢出之前,将软引用对象纳入回收范围进行二次回收。如果本次回收内存不足,则会抛出内存溢出异常。弱引用:与弱引用关联的对象只能存活到下一次垃圾回收发生。不管当前内存是否足够,软引用关联的对象都会被回收。幻影参考(PhantomReference):幻影参考也称为幽灵参考或幻影参考。到系统通知。上面的垃圾回收算法解释了如何找到“生存”对象。JVM中使用了可达性分析方法。说白了就是看引用链中是否引用了GCRoots对应的对象。接下来,让我们看看这个上下文中的垃圾收集算法。这里我们列出一些常见的:mark-and-sweep算法。该算法分为标记和清除两个阶段。对象,也就是没有被引用的对象,被标记,然后对象被清除,也就是被回收。如图2所示,算法扫描内存空间,发现GCRoots引用了Object1和Object2,但是没有引用Object2。首先将Object2标记为未引用。如图2和图3所示,算法再次扫描内存,清除Object2对象占用的空间,并设置为空闲空间。图3标记清除算法该算法的优点是简单粗暴,没有引用的对象会被清除,缺点是效率高。标记和清除操作会扫描整个空间两次(第一次:标记活动对象;第二次:清除未标记的对象)以完成清洁工作。同时清理过程容易产生内存碎片。这些空闲空间不能容纳大的物体。如果此时有比较大的对象进入内存,由于内存中没有大对象的连续空间,就会提前触发垃圾回收。为了解决mark-and-clear方法带来的问题,复制算法将内存分成大小相等的两块,每次使用其中一块。当这块内存用完后,将对象复制到另一块,然后清理已用内存空间。这样每次都回收一半的内存区域,没有考虑内存碎片的问题。如图4所示,上方区域是垃圾回收前的内存空间,我们用黑色虚线将内存分成两部分。左边部分是正在使用的空间,右边部分是预留空间。左边区域红色部分为不可回收内存,即有被GCRoots引用的对象,灰色部分为可回收区域,即没有被GCRoots引用的对象,白色区域为未分配的。如果是通过复制算法进行垃圾回收,沿着绿色箭头向下,在回收的内存区可以看到左边红色的内存对象被移动到右边的保留区,并按顺序排出。然后清理左边运行的内存区域,成为保留区域,等待第二次垃圾回收的执行。图4复制算法复制算法的优点是简单高效,不会出现内存碎片。缺点也很明显,内存利用率低,只用了一半的内存。尤其是当存活对象较多时,效率会明显降低,因为每一个不可回收数据的实际内存位置都需要移动。标记排序算法该算法类似于标记清除算法,但是后面的步骤并不直接清理可回收对象,而是将所有存活的对象移到内存前面,然后清除其他可回收对象占用的内存空间。如图5所示,回收前的内存中,红色为不可回收内存空间,灰色为可回收空间,白色为未分配空间。执行标记排序算法的垃圾回收后,将不可回收的内存空间排序到内存的最前面,同时清除可回收的内存空间。此时白色的未分配空间存放在新对象的不可回收空间后面。订金。图5.标记清理算法标记清理算法的优点是解决了标记清理算法中的内存碎片问题。缺点也很明显,需要局部物体移动,一定程度上降低了效率。分代收集算法分代收集算法,顾名思义,就是根据对象的生命周期,将内存分成若干块,然后定义回收规则。如图6所示,从左到右分别是YoungGeneration、OldGeneration和PermanentGeneration。此外,新生代还分为伊甸园空间(EdenSpace)、幸存者空间(SurvivorSpace)。目前商业化的虚拟机算法中广泛使用分代收集算法。图6分代收集方式分代收集方式上面按字面解释,算法执行过程描述如下:1)新产生的对象先分配到Eden区(除非配置了-XX:PretenureSizeThreshold,大于具有此值的对象直接进入老年代)。有这样一种情况,当对象刚在新生代创建后被回收,对象从该区域消失的过程称为minorGC。2)当Eden区已满或放不下时,将存活的对象复制到from区。如果此时存活的对象from区放不下,就会放到老年代,然后回收Eden区的所有内存。3)之后产生的对象继续分配在Eden区。当Eden区已满或放不下时,会将Eden区和from区存活的对象复制到to区。这时候如果存活的对象到Eden区和From区的内存会同时被回收。4)如果按上述操作在多个区域移动对象,则对象将被复制多次。如果对象被复制一次,对象的年龄将+1。默认情况下,当对象被复制15次时(配置为:-XX:MaxTenuringThreshold),对象将进入老年代。5)当老年代满时,就会发生FullGC。备注:MinorGC是指发生在新生代的垃圾回收动作。因为大多数Java对象都具有永恒的特性,所以MinorGC非常频繁,回收速度一般也比较快。FullGC是指发生在老年代的GC。FullGC的出现,往往伴随着至少一次FullGC。FullGC的速度一般比MinorGC慢10倍以上。垃圾收集器如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下面将介绍JDK1.7Update14之后的HotSpot虚拟机,如图7所示,内存分为新生代和老年代,其中分布着7个不同的垃圾收集器。垃圾回收器之间是有联系的,说明可以一起使用。虚拟机所在的区域表示它属于新生代收集器还是老年代收集器。Hotspot实现这么多的收集器正是因为目前没有完美的收集器,只是针对具体的应用选择最合适的收集器。图7垃圾收集器的分类下面介绍一下这些垃圾收集器:串行收集器串行(serial)收集器是使用复制算法的新一代收集器。它是一种单线程收集器,针对一个CPU或一个收集线程完成垃圾收集工作。当它执行垃圾收集时,它必须挂起所有其他工作线程,直到Serial收集器收集结束。这种方法也称为“停止世界”。如图8所示,左侧有多个应用程序线程正在执行。当Serial收集器的GC线程(虚线)执行时,应用线程(左边多条实线)会被挂起。只有当收集器线程执行完后,应用程序线程(右边多条实线)才会继续执行。图8SerialGarbageCollector这个收集器的问题是其他工作线程在垃圾收集过程中必须暂停,但是它可以在运行在HotSpot虚拟机上的Client模式下为新一代收集器服务。它简单高效对于仅限于单个CPU的环境,Serial收集器没有线程交互开销。在用户的桌面应用场景下,分配给虚拟机管理的内存并不大,停顿时间可以控制在几十毫秒以内,还是可以接受的。对于以Client模式运行的虚拟机来说,这是一个不错的选择。ParNew收集器ParNew收集器是Serial收集器的多线程版本,也是新一代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、StopTheWorld、对象分配规则、回收策略等。如图9所示,不同于Serial,ParNew使用多线程(中间多条虚线)进行垃圾回收。图9ParNew并行收集器ParNew收集器将显着提高多CPU环境下的垃圾收集效率。默认启用的收集线程数与CPU数相同,当CPU数过多时可以通过-XX:ParallerGCThreads参数设置。相反,如果您针对单个CPU环境,ParNew和Serial回收器的效果是无法区分的。SerialOldCollectorSerialOld是SerialCollector的旧版本。它是一个单线程收集器并使用Mark-Compact算法。它可以用于客户端模式下的虚拟机。如果是Server模式,它还有两大用途:在JDK1.5及之前的版本(ParallelOld诞生之前)与ParallelScavenge收集器配合使用。作为CMS收集器的备份计划,在并发收集出现ConcurrentModeFailure时使用。ParallelOldCollectorParallelOldCollector是ParallelScavenge的老一代版本,它使用多线程标记算法。它仅在JDK1.6中可用。如果新生代选择Para??llelScavenge收集器,那么老年代就只能选择SerialOld了。ParallelOldrecycler的工作流程与ParallelScavenge相同。ParallelScavenge收集器ParallelScavenge收集器是一种使用复制算法的并行多线程年轻代收集器。ParallelScavenge收集器的目标是实现一个可控的吞吐量(Throughput)。这里稍微解释一下,吞吐量是CPU运行用户代码的时间占CPU总消耗时间的比值,表示为工作时间:吞吐量=用户代码运行时间/(用户代码运行时间+垃圾回收时间)。用户代码运行95分钟,垃圾回收耗时5分钟,所以吞吐量为95/(95+5)=95%。高吞吐量表明垃圾收集器有效地使用CPU时间并尽快完成程序的计算任务。这样可以缩短垃圾回收带来的停顿时间,适合与用户交互的程序可以提高用户体验。ParallelScavenge将提供用于精确控制吞吐量的参数。另外会自动调整新生代的大小(-Xmn),Eden区和Survivor区的比例(-XX:SurvivorRatio),通过设置参数-XX:+UseAdaptiveSizePolicy提升老年代的对象年龄(-XX:PretenureSizeThreshold)等信息。此外,ParallelScavenge收集器的另一个特点是它会根据当前系统运行状态收集性能监控信息,并动态调整这些参数以提供最合适的停顿时间或最大吞吐量。我们称这种GC自适应调整策略(GCErgonomics)。CMS收集器CMS(ConcurrentMarkSweep)收集器是一种旨在获得最短恢复停顿时间的收集器。适用于重视响应速度的应用场景。它是基于mark-sweep算法实现的。如图10所示,CMS工作流程从左到右分为以下4个步骤:初始标记(CMSinitialmark):标记与GCRoots直接相关的对象,需要执行“StopTheWorld”,即,让工作线程暂停。并发标记(CMSconcurrentmark):从GCRoots中找到所有可达的对象。这个过程耗时较长,此时用户线程还在运行。重标记(CMSremark):在并发标记校正过程中,用户程序继续操作导致被标记对象被标记,调整标记记录。这个阶段也需要“StopTheWorld”,因为如果工作线程没有被挂起,可能会有标记不准确的情况发生。并发扫描(CMSconcurrentsweep):对标记为不可用的对象进行并发扫描操作。这个过程会花费很长时间,此时工作线程仍然可以运行。因此,一般情况下,CMS收集器的内存回收过程是与用户线程并发执行的。从下图中可以清楚的看到CMS收集器运行步骤中的并发和停顿时间:图10CMSGarbageCollectorCMS的优点是显而易见的,比如并发收集和低停顿。但是,它对CPU资源非常敏感。虽然在并发阶段不会导致用户线程挂起,但会因为占用部分线程(或CPU资源)而拖慢应用程序并降低总吞吐量。CMS默认启动的回收线程数为(CPU数+3)/4,即当CPU数超过4个时,并发回收时垃圾回收线程占用CPU资源不低于25%,垃圾收集线程的数量随着CPU数量的增加而增加。衰退。但是当CPU少于4个时(比如2个),CMS对用户程序的影响可能会变得非常大。如果原来的CPU负载比较大,必须分配一半的算力来执行收集器线程。结果,用户程序的执行速度突然降低了50%。无法处理浮动垃圾(FloatingGarbage)可能存在“ConcurrentModeFailure”故障,导致另一次FullGC发生。在垃圾回收阶段,用户线程还在运行,需要预留足够的内存空间给用户线程使用。因此,CMS在并发收集时需要预留一部分空间供程序运行。mark-and-sweep算法本身也会造成大量的空间碎片。G1收集器G1(Garbage-First)收集器是服务器端应用程序的垃圾收集器。它具有以下特点:可以充分利用多个CPU来缩短“StopTheWorld”的停顿时间,可以并发方式继续执行Java程序。整个GC堆可以独立管理,不需要其他收集器的配合,对新创建的对象和存活了一段时间的对象采用不同的处理方式。对于经历过多次GC的老对象,会有更好的回收效果。G1基本是基于marking算法实现的,本地(两个Region之间)是基于replication算法实现的。这意味着G1在运行过程中不会产生内存空间碎片,回收后可以提供规律的可用内存。这个特性有利于程序的长时间运行。在分配大对象时,不会因为找不到连续的内存空间而提前触发下一次GC。可预测暂停时间模型允许用户明确指定在M毫秒的时间段内,GC花费的时间不应超过N毫秒。与其他垃圾收集器不同,G1回收整个堆。如图11所示,G1将堆分成多个大小相等的区域(Regions)。虽然仍然保留了新生代和老年代的概念,但是新生代和老年代不再是物理上孤立的,而是属于Region的。收集。图11G1将堆划分为Region。前面提到,G1收集器能够预测的停顿时间是因为它避免了在整个Java堆中进行垃圾收集。G1会跟踪每个Region的垃圾堆积值(回收空间的大小和回收所需时间的经验值),在后台维护一个优先级列表,每次都会优先选择值最高的Region根据允许的恢复时间。虽然G1将Java堆划分为多个区域,但是一个区域中的对象可以与另一个区域中的任何对象具有引用关系。做可达性分析时还是需要扫描整个堆,显然效率不高。为了避免全堆扫描,G1为每个区域维护一个RememberedSet。当发现程序正在写入引用(Reference)类型的数据时,会产生一个WriteBarrier,暂时中断写入操作。然后检查引用(Reference)对象是否在不同的Region,如果是,则通过CardTable将相关引用信息记录到被引用对象所属Region的RememberedSet中。在进行内存回收时,将RememberedSet加入到GC根节点的枚举范围内,可以保证不会扫描到整个堆,不会有遗漏。说白了就是通过RememberedSet来记录跨Region引用的对象,这样做的目的是为了避免全堆扫描。如图12所示,Region2中分配的两个对象分别被Region1和Region3中的对象引用,所以Region2中的rememberedset会记录这两个引用的信息,只需要通过收集memoryset的信息,每个Region中对象的引用关系是已知的,不需要扫描堆的所有region。图12跨Region对象引用描述了G1的特点和机制。我们通过图13来看一下它的执行过程:初始标记(InitialMarking):标记可以被GCRoots直接引用的对象,这样下一阶段的用户程序就可以在并发运行时创建对象正确的区域。这个阶段需要暂停线程,但是时间很短。ConcurrentMarking:从GCRoot开始,对堆中的对象进行可达性分析,寻找存活对象。这个阶段耗时较长,但可以与用户程序并发执行。FinalMarking:为了更正标记记录中由于用户程序在并发标记期间继续运行而导致标记发生变化的部分,虚拟机将这段时间的对象变化记录在RememberedSetLogs中线程。最后的标记阶段需要将RememberedSetLogs的数据合并到RememberedSet中。这个阶段需要暂停线程,但是可以并行执行。筛选恢复(LiveDataCountingandEvacuation):首先对每个Region中的恢复值和代价进行排序,根据用户预期的GC停顿时间制定恢复计划。其实这个阶段也可以和用户程序并发执行,但是因为只是回收了一部分Region,所以时间是用户可控的,暂停用户线程会大大提高回收效率。小结今天给大家介绍一下垃圾回收算法和JVM垃圾回收器。算法是思想和方法论的指南,而垃圾收集器是方法论的最佳实践。下面用一张表格来总结一下两者:作者介绍崔浩,社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年的分布式架构经验。他曾经是惠普的技术专家。乐于分享,撰写了多篇阅读量超过60万的热门技术文章。《分布式架构原理与实践》作者。