1.概述
在这篇文章中,我主要会讲到以下几个内容:
(1)“垃圾”主要存在于什么位置?
(2)什么是“垃圾”?
(3)如何去清理“垃圾”?
(4)扩展:内存的分类策略
2.“垃圾”主要存在于什么位置?
要进行清理之前,Java虚拟机需要知道哪些空间是需要进行自动清理回收的,所以,我们首先大致了解一下Java虚拟机的内存空间布局。
Java内存主要分为五个部分:
(1)程序计数器:用于存储当前指令所在的地址,一旦当前指令执行结束,程序计数器自动加1或者根据转移地址跳转到下一条指令。对于单核CPU来说,线程之间是通过不断切换获得CPU资源来实现宏观上的并行,那么就需要每一个线程有自己单独的程序计数器,以便在获得CPU资源后可以从断点的位置继续执行指令。所以,程序计数器是线程私有的内存区域。
(2)虚拟机栈:堆和栈的概念相信大家已经很熟悉了,简单来说,栈中存放的是一个又一个的栈帧,当一个方法被调用时,虚拟机就会创建一个栈帧并将其压入栈。栈帧主要是用于存放局部变量、操作数栈、动态链接以及方法出口等等信息。而且,栈内存也是线程私有的,每一个线程会在栈内存内有一块自己私有的区域。
(3)本地方法栈:本地方法栈存储的内容和虚拟机栈很类似,唯一的区别是本地方法栈是为虚拟机执行Native方法而服务,虚拟机栈是为了虚拟机执行一般的Java方法而服务,本地方法栈也是线程私有的内存区。
(4)Java堆:堆中主要存放的是对象的实体以及数组等内容(对象的引用被存放在了栈中),Java堆是被所有线程所共享的一块区域,在虚拟机启动时就会被创建。
(5)方法区:方法区和堆一样,是线程共享的内存区域,方法区主要用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译器编译后的代码等数据。
大致讲了一下Java内存的五个部分,主要是想说明,这五个部分中,程序计数器、虚拟机栈、本地方法栈都是线程私有的内存区域,其存储的是数据会随着线程的消亡而消亡,所以不需要虚拟机为其清理垃圾,也就是说,Java堆和方法区是垃圾回收机制(后面我们会称其为GC机制)的“主要照顾对象”。
2.什么是“垃圾”?
上面我们说到,虚拟机主要为我们清理的是堆和方法区两个区域,那么主要清理的内容就是无用的对象、常量、静态变量和类信息。
那么,如何判断一个对象、常量、静态变量或者类是无用的呢?
这里我们将对象、常量、静态变量放在一起来说,因为它们的判断方式是一致的。下面我们以对象为例,具体谈一下这种判断机制。
最初,虚拟机是使用引用计数算法来判断一个对象是否有reference指向它。引用计数算法的原理是,每一个对象都有一个计数器,当有一个reference指向该对象是,计算器便+1,当减少一个reference时,计数器便-1,一旦计数器值为零,恰好这时候虚拟机进行了一次GC,那么该对象就会被清除。
这种算法虽然实现简单,而且效率高,但是其有一个很大的缺点,如果两个对象互相引用,即使两个对象在程序中不会再被使用到,但是两个对象的计算器一直保持着1,虚拟机便永远不会清除这两个对象。也就是说,这种算法无法识别出相互引用的垃圾。
为了解决这个问题,可达性分析算法诞生。这个算法的基本思想是通过一系列的被称之为“GC Roots”的对象作为起始点,从这些对象开始搜索,找到其引用的下一个对象,直到最后的对象没有下一个引用。这样搜寻出的对象形成一个“引用链”,我们认为,在“引用链”上的对象是不能被清理的,不在“引用链”上的对象,进入到被清理的“边缘”。(请注意,我这里说的是在边缘,也就是说这些对象还有一次自救的机会。)
如上图所示,对象6和对象7虽然互相引用,但是因为不在引用链上,也会被看做是垃圾。
在Java中,可以作为GC Roots的对象包括以下几种:
(1)虚拟机栈中引用的对象;
(2)方法区中静态属性引用的对象;
(3)方法区中常量引用的对象;
(4)本地方法栈中本地方法引用的对象;
但是,一个对象不在引用链上,就一定会被看做成垃圾直接清除掉了吗?
其实虚拟机的判断没有这样草率,当进行一次可达性分析之后,虚拟机会将不在引用链上的对象添加一个标记(他们已经处于很危险的状态),这时,虚拟机会去判断这些很危险的对象有没有必要执行finalize()方法(继承自Object类),如果该对象重写了finalize()方法而且该finalize()方法从未被执行过,那么虚拟机会给这个对象一个执行finalize()方法的机会,如果执行完该方法后,这个对象被引用链上的某个对象所重新引用,那么,恭喜这个对象,它不会被清除了。如果执行完finalize()方法后,这个对象还是没有进入引用链或者虚拟机认为这个对象没有必要执行finalize()方法,那么,对不起,这时这个对象就真的被认为是“垃圾”需要清理掉。
刚刚说到的是如何判断对象是不是需要被清理,判断常量、静态变量也是如此,那么,一个类该不该被清理应如何判断呢?
判断一个类是无用的类必须满足以下三个条件:
(1)该类的所有实例均已被回收,Java堆中不存在该类的实例;
(2)该类的ClassLoader已经被回收;
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
3.如何清理“垃圾”?
当虚拟机知道了垃圾的位置,也知道了什么时垃圾,下一步我们需要为其指定清除垃圾的方法。
垃圾收集算法
标记—清除算法:这是一种最基础的收集算法,它的工作流程就如同它的名字一样,分为标记和清除两个过程。首先标记出所有需要回收的对象(就是在判断对象是否是垃圾),在标记结束后统一清除所有被标记的对象。但是几乎没有虚拟机直接采用了这种这种收集算法,首先,标记和清除两个过程效率均比较低,而且更重要的是,清除过后,内存空间会留下很多不连续的碎片,导致后续需要存储大对象时无法找到足够的连续内存,不得不提前触发GC动作。
标记—整理算法:为了解决标记—清除算法造成内存空间碎片较多的问题,诞生了标记—整理算法。顾名思义,该算法也分为两个部分,标记和整理。标记过程仍然和“标记—清除算法”相同,但是标记过后,没有直接进行清理,而是把所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,这样一来,经过一次清理,内存空间便不存在碎片,一端是存活的对象,另一端是未使用的空间。
复制算法:也是一种比较主流的收集算法,将内存区域平均分成两部分,每次只使用其中的一块,当这一块空间填满之后,就将这一块内存中存活对象复制到另外一块中,接下来将使用过的这一块内存空间清空,然后开始使用另外一块内存空间。这样在分配内存上只需要按顺序分配即可,效率得到了提高,而且也没有碎片的问题,但是,每次只有一半的空间可供使用,代价未免太高了一点。
分代回收算法:上面提到的几种算法各有各的好处,复制算法在需要清除的对象比较多的时候会有很高的运行效率(因为只需要将一小部分存活的对象复制到新的内存空间),标记—整理算法在要清除的对象比较少的时候效率比较高(几乎不需要移动和清除),所以能不能将内存空间按照存活对象的多少来进一步划分呢?这就是分代回收算法的基本思想。这种回收算法将Java堆内存划分成新生代和老年代,新生代里的对象一般存活周期比较短,老年代中的对象存活周期比较长。那么在新生代中就可以采用复制算法,老年代中采用标记—整理算法。而且,研究表明,新生代中每次GC,大约98%的对象都是需要被清除的,这样一来,我们就不需要将新生代划分为大小相等的两份进行复制。而是按照8:1:1的比例划分为三份,空间较大的一份被称为Eden区,空间较小的两份被称为Survivor区(一个被称为from区,一个被称为to区)。使用的时候将对象存进Eden区和From区,回收时,将Eden区和From区的存活对象复制到To区中,然后将From区和To区进行对换。这就完成了一次新生代的垃圾回收。如果To区装载不下存活的对象。需要依赖老年代进行分配担保(将存活的对象存进老年代)。一旦老年代也无法存满了之后,将会触发一次Full GC,将老年代和永久代(方法区)使用标记—整理算法进行一次清理,这一次的清理时间会很长,所以,应尽可能少的触发Full GC。
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
在JDK1.7之后的HotSpot虚拟机上,包含有如下这么多的收集器的实现。
上半部分是用在新生代的垃圾收集器,下半部分是用在老年代的垃圾收集器。用线连接起来说明这两种收集器可以配合使用。
下面简要说明每一种垃圾收集器的特性。但是要明确一点,没有最好的收集器,只有更合适收集器。否则HotSpot虚拟机就没有必要实现这么多不同的收集器了。
Serial收集器是一个用在新生代上的垃圾收集器,所以使用的是复制算法,而且它是一个单线程的收集器,这不仅仅说明它只会使用一条线程去完成垃圾收集工作,更重要的是,它在进行垃圾收集工作时,必须暂停其他所有的工作线程,直到它收集结束。这个工作机制被称为“Stop The World”,对于用户来说是很难接受的(用户的其他所有工作都必须要停顿一段时间)。所以,目前大部分垃圾收集器都在致力于如何缩短这一停顿时间。尽管Serial收集器的用户体验不是很好,但是它也有着很明显的优势:简单而高效,在单CPU的工作环境下,由于没有线程之间的交互开销,效率要比多线程的收集器高许多。所以它现在仍然是虚拟机运行在Client模式下的默认新生代收集器。(Client模式下,新生代空间一般不会很大,收集一次所停顿的时间也不是很长)
Serial / Serial Old收集器运行示意图Serial Old收集器是Serial收集器的老年代版本,同样使用着单线程收集器,但是用的是“标记—整理”算法,这个收集器主要意义在于给Client模式下的虚拟机在老年代使用。在Server模式下,它也可以与 Parallel Scavenge搭配使用,或者作为CMS收集器的后备预案。
ParNew收集器其实就是Serial收集器的多线程版本,也是用在新生代上,也是用的复制算法,在垃圾回收时,仍然要“Stop The World”,只不过就是可以用多个线程共同完成垃圾清理工作。目前,ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,原因是这样的,在jdk1.5之后,HotSpot退出了一款有划时代意义的垃圾收集器——CMS(Concurrent Mark Sweep)收集器,这是一款用于老年代垃圾收集的收集器,不幸的是,能够与其配合的用在新生代的收集器只有Serial和ParNew收集器,在Server模式下,随着CPU数量的增加,ParNew收集器(并行进行垃圾清除)要比Serial收集器(单线程垃圾清除)能更好地利用CPU资源。
Parallel Scavenge收集器也是一个新生代收集器,也使用了复制算法,也是并行的多线程收集器,乍一看,和ParNew收集器是一样的。但是它的关注点不同,其他的收集器都在致力于缩短用户线程的停顿时间,但是Parallel Scavenge收集器致力于达到一个可控制的吞吐量。Parallel Scavenge收集器提供了两个参数,-XX:MaxGCPauseMillis 和 -XX:GCTimeRatio,第一个参数设定的是每一次停顿的最长时间,第二个参数设定的是垃圾收集时间占总时间的比率。
这里提一下,不一定是停顿时间越短,垃圾收集时间占的比例就小,举个例子,当 -XX:MaxGCPauseMillis 参数设置的比较小时,虽然每次停顿时间变短,但是代价是新生代的容量减小,随之而来的后果便是,GC变得更加频繁,垃圾收集时间占的比例增大,吞吐量降低。
同时,Parallel Scavenge收集器还有一个优点是,可以根据当前系统的运行情况动态调节与GC相关的参数以提供最合适的停顿时间或者最大的吞吐量。
Parallel Scavenge / Parallel Old 收集器运行示意图Parallel Old收集器是 Parallel Scavenge收集器的老年代版本,使用多线程和“标记—整理”算法、这个收集器在jdk1.6之后才开始提供,在此之前,如果新生代选择的是Parallel Scavenge收集器,那么老年代只有Serial Old收集器与之对应,但是Serial Old收集器会严重拖累Parallel Scavenge收集器获取最大吞吐量的效果,直到Parallel Old收集器的出现,“吞吐量优先”的收集器组合才名副其实,所以,在注重吞吐量以及CPU资源的场合,可以选择Parallel Scavenge和Parallel Old组合。
CMS收集器运行示意图CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是基于“标记—清除”算法实现的。它的运作过程分为以下四个步骤:
初始标记、并发标记、重新标记、并发清除
在进行初始标记和重新标记时,仍需要“Stop The World”,在并发标记和并发清除两个过程,用户的进程可以和GC进程并发执行。
初始标记仅仅是标记一下“GC Roots”可以直接关联到的对象,速度很快;并发标记便是寻找整个引用链的过程,但是这个阶段,用户的进程有可能会改变一些对象的引用;那么重新标记就是为了标记那些有变动的对象,这个阶段耗时会比初始标记稍长,但是远远小于并发标记过程。也就是说,耗时最长的并发标记和并发清除两个过程是不需要用户线程停顿的,停顿的阶段只是初始标记和重新标记两个短暂的过程,所以号称是目前最短回收停顿时间的收集器。
以上便介绍完了目前常用的几种收集器,还是那句话,没有最好的收集器,我们需要依据系统的运行情况选择最合适的收集器组合方式。
4.内存的分配策略
上面说过,创建一个新的对象后,这个对象会被存进新生代的Eden区中,如果没有足够的空间存放,将会触发一次新生代的GC,被称之为Minor GC(老年代的GC被称为Full GC或者Major GC)。但是,这个说法并不准确,如果新创建的对象非常大,比如说是一个很长的字符串或者很长的数组,那么这个对象很有可能被直接放进老年代。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接存进老年代,这样就可以避免在Eden区以及两个Servivor区来回复制。
那么,除了大对象可以直接放进老年代,还有什么情况下,新生代的对象可以被存进老年代中呢?
(1)长期存活的对象将进入老年代
虚拟机为每一个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC仍然存活,年龄计数器便会置为1,后来每经过一次Minor GC,年龄计数器就会增加1,当对象的年龄增加到一定程度(默认15岁),就会晋升到年老代。这个年龄阈值是可以通过-XX:MaxTenuringThreshold设置。
(2)动态对象年龄判定
为了能更好地适应不同程序的内存情况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄对象大小的总和大于Survivor空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代。
(3)空间分配担保
空间分配担保在分代回收算法中提到过一次,不过说的比较粗略。下面详细说一下这个概念中的一些细节。
当新生代需要进行一次Minor GC时,虚拟机会先去检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的(因为即使Survivor区的To区无法存放得下存活的对象,这些对象也完全可以由年老区分配空间存放,这就是分配担保的意思)。但是,如果这个条件不成立,那么虚拟机会去查看handlePromotionFailure设置值是否允许担保失败(因为这时,如果新生代全都是存活的对象,老年代就没有足够的空间容纳这么多对象,所以现在是需要承担担保失败的风险的),如果允许,虚拟机会继续查看老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则开始Minor GC,如果不大于或者handlePromotionFailure设置的是不允许承担担保失败,那么将会提前触发一次Full GC。
下面解释一下这里承担的是什么风险,现在已经确定老年代中最大可用的连续空间小于新生代的所有对象总空间,我们也是通过以往的经验值(历次晋升到老年代的对象大小的平均值)认为这一次老年代仍然有足够的空间容纳新生代中存活的对象,但是,一旦这一次Minor GC比较特殊,绝大多数对象都存活了下来,其大小远远超过经验值,老年代没有足够空间存放,那么相当于绕了个大圈,最后还是要触发Full GC,这便是需要承担的风险。
下面这张图表示如何为一个对象分配内存空间。
以上内容便是关于Java 垃圾回收部分的全部基础知识,要想更近一步地了解JVM(Java虚拟机)在底层是如何工作,请大家前往阅读
《深入了解Java虚拟机》这本书,该书中提供了大量代码,大家可以自己动手,通过查看GC日志的方式了解JVM垃圾收集是如何工作的。
本文大部分也参照了这本书上垃圾回收器这一章节的内容。
网友评论