深入理解Java虚拟机--垃圾收集器与内存分配策略

垃圾收集器与内存分配策略

Java运行时的各部分中,程序计数器、虚拟机栈、本地方法栈这三个区域随线程的产生而产生,而线程的消失而消失;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会有JIT编译器进行一些优化,但基于概念模型的讨论中,大体上可以认为是编译器可知的),因此这几个区域的内存分配和回收具备确定性,不需要过多考虑回收的问题,方法结束或者线程结束时,内存自然就随着回收了。
而Java堆和方法区(metadata space元数据区域)则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行时才知道会创建哪些对象,这部分内存的分配和回收都是动态的,而垃圾回收器所关注的就是这部分内存(即Java堆和方法区)。

如何判断对象已没用了?

垃圾回收器所关注的第一件事就是来确定这些共享内存中所产生的对象实例哪些还“活着”,哪些已经“死去”了(即不可能再被任何途径使用的对象)。在这里垃圾回收器有两种判断的方法:一、引用计数法;二、可达性分析法。

引用计数法

这种方法是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。但是主流的虚拟机都没有使用这种方式,因为他解决不了对象的循环引用问题

可达性分析法

主流的虚拟机都采用的可达性分析来判断对象是否存活的。它的基本思想是通过一系列的称为“GC Roots”的对象作为起始点,从这些起点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)当一个对象到GC Roots没有任何引用链相连时(就是从GC Roots到这个对象不可达时)则证明此对象是不可用的。在Java语言中,可作为GC Roots的对象包括以下几种:

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

即使在可达性分析算法中不可达的对象,也并非是“死亡”的对象,要真正的宣布“死亡”,至少还要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Root向连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法当对象没有覆盖finalize()方法时或者方法已经被虚拟机调用过时,虚拟机将这两种情况都视为“没必要执行”
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里的“执行”是指虚拟机会查发这个方法,但并不承诺会等待它运行结束(因为如果一个对象在执行时运行缓慢,或者发生了死循环,将很可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个虚拟机崩溃)。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC对F-Queue中的对象进行第二次标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那么它就会在第二次标记的时候被移除“即将回收”的集合;如果这时还没逃脱,它就要真的被回收了。

引用

无论是通过引用计数法判断对象的引用数量,还是可达性分析法来判断对象引用链是否可达,判断对象是否存活都与对象的“引用”有关。引用可以分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Plantom Reference)这四种,下边我们来具体介绍一下这几种引用。

  • 强引用就是指在程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。对一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

方法区的回收

Java虚拟机规范中确实说过可以不要求虚拟机在方法区(或者叫做永久代)实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低。永久代的垃圾收集主要回收两部分内容:废弃的常量和无用的类
废弃常量的回收
回收废弃常量与回收Java堆中的对象非常类似,如果没有任何String对象引用常量池中的常量,也没有其他地方引用了这个字符串常量的字面量,,如果发生内存回收,这个字符串常量就会被清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似。
无用的类的回收
类需要满足下面三个条件才算是“无用的类”

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述3个条件的无用类进行回收,但它并不是和对象一样,不使用了就必然回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading需要在FastDebug版本的虚拟机中使用。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

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

该算法分"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(标记过程一般是使用可达性分析法来标记)。
算法缺点

  1. 标记和清除两个过程的效率都不高
  2. 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要分配大对象时,无法找到足够连续的内存而不得不提前触发另一个垃圾收集动作。

复制算法(Coping)

为了解决效率问题,一种称为"复制"的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块儿上面,然后再把自己使用过的内存空间一次清理掉。每次都是对整个内存的一半进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只要一动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
算法缺点

  1. 算法的代价是内存缩小为原来的一半,浪费了内存空间。
  2. 它适合对象存活率低的情况,当对象存活率高时,就需要进行较多对象的复制操作,垃圾回收效率就会变低。

现在的商业虚拟机都采用复制算法来回收新生代,新生代中的对象98%都是"朝生夕死",所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块儿较大的Enden空间和两块较小的Survivor空间(S0和S1),每次使用Eden和其中一块Survivor。当回收时,将Enden和Survivor空间中还存活的对象一次性地复制到另外一块儿survivor空间上,最后清理掉Enden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例都是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%是用来复制存活对象的。当survivor1的空间不够用来复制存活的对象时,需要依赖其他内存(老年代)进行分配担保。也就是说,如果另外的Survivor1内存不够存放存活对象时,这些对象将直接通过担保机制进入老年代。

标记-整理算法(mark-compact)

标记整理算法,是先对需要回收的对象进行标记,然后让所有存活对象都向一端移动,直接清理掉端边界意外的内存。标记-整理方法一般在老年代进行使用。

分代收集算法(Generational Collection)

当代商业虚拟机的垃圾收集都采用"分代收集(Generational Collection)"算法,它根据对象存活周期的不同将堆内存划分为新生代(Young Generation)老年代(Old Generation)永久代(Permanent Generation)。其中,新生代又可以分为EdenSurvivor0(S0)Survivor1(S1)在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以适合复制算法;而老年代对象存活率较高、没有额外空间对它进行分配担保,则使用"标记-清理"或者"标记-整理算法"进行回收。

垃圾收集器

垃圾收集器目前分为7种作用于不同分代的收集器:Serial(串行收集器)、ParNew()、Parallel Scavenge、CMS、Serial Old、Parallel Old、G1。这七种收集器中,Serial(串行收集器)、ParNew()、Parallel Scavenge会在新生代使用;CMS、Serial Old、Parallel Old会在老年代使用;G1可以使用在新生代和老年代。下面我们具体来讲一下每种收集器的特点。

Serial收集器(串行收集器)

串行收集器是一个单线程的收集器,但它的"单线程"的意义并不仅仅说明只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是它在垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。也就是说它在垃圾收集时会引起"stop-the-world"。它是虚拟机运行在Client模式下的默认新生代收集器。

ParNew收集器(并行收集器)

ParNew收集器是串行收集器的多线程版本,除了使用多线程进行垃圾收集之外,其他行为包括串行收集器可用的所有控制参数、收集算法、stop-the-world、对象分配规则、回收策略等都与Serial收集器完全一样。它是许多运行在Server模式下的虚拟机中首选的新生代收集器,它还可以与CMS收集器配合工作。
JVM参数配置:

//使用CMS垃圾收集器对老年代进行回收,则默认使用并行收集器作为新生代的收集器
-XX:+UseConcMarkSweepGC  

//或者强制指定
-XX:+UseParNewGC

注意:并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上

Parallel Scavenge收集器

它是一个新生代收集器,同样使用复制算法进行垃圾收集。它的目标是达到一个可控制的吞吐量,吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间),这个是JDK1.7-1.8默认的年轻代收集器。JDK1.7-1.8默认的使用-XX:+UseParallelGC参数。
跟吞吐量有关的配置参数:

//控制最大垃圾收集停顿时间(不太建议设置)
-XX:MaxGCPauseMillis  

//设置吞吐量大小(不太建议设置)
-XX:GCTimeRatio

//采用自适应调节,不需要配置-Xmn、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代年龄(-XX:PretenureSizeThreadhold)
-XX:+UseAdaptiveSizePolicy

Serial Old收集器

它是Serial收集器的老年代版本,同时是一个线程进行垃圾收集,使用"标记-整理"算法。通常在Client模式下的虚拟机使用,在server模式下,一种用途是在JDK1.5以及之前版本与Parallel Scavenge收集器搭配使用;另一种用途是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

它是Parallel Scavenge收集器的老年代版本,使用多线程和"标记-整理"算法进行垃圾收集,在注重吞吐量以及CPU资源敏感的场合,与新生代的Parallel Scavenge搭配使用。JDK1.7-1.8默认的老年代收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS是基于"标记-清除"算法实现的,整个过程分为4步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除
    初始标记、重新标记这两步仍然需要"stop-the-world"。初始标记仅仅只是记录一下GC Roots能直接关联到的对象,速度很快,并发标记就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户继续运作而导致标记产生变动的那一部分对象的标记记录,这个时段的停顿时间一般比初始标记阶段稍长,但远比并发标记的时间短。

由于在并发清除阶段,用户程序也在运行,所以还会产生新的垃圾,这部分垃圾只好留着等待下一GC的到来,这一部分垃圾被称为"浮动垃圾"。因此CMS收集器不能等老年代几乎被填满了再进行垃圾收集,需要预留一部分空间提升并发收集时的程序运作使用。如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比;但是如果百分比太高,CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure",这时虚拟机将启动后预案,临时使用Serial Old收集器来进行老年代的收集,这样就会停顿时间加长,降低性能。

由于CMS是基于"标记-清除"算法的,因此垃圾收集结束后会产生大量的空间碎片。CMS提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,但是这样又会增加停顿时间。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值时0,表示每次Full GC时都进行碎片整理)。

G1收集器

从JDK1.7之后,G1收集器就开始正式被HotSpot虚拟机采用,它是一款面向服务端应用的垃圾收集器。它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region的集合。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先收集价值最大的Region。

它有以下几个特点:

  • 并行与并发:利用多CPU、多核环境下,使用多个CPU来缩短stop-the-world的停顿时间,可以通过并发方式让Java程序继续执行。
  • 分代收集:分代概念依然保留,不需要其他收集器的配合就能管理整个GC堆。但它采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:G1从整体来看是基于"标记-整理"算法实现的收集器,从局部(两个region之间)来看是基于"复制"算法实现的,无论如何,这两种算法都不会产生内存空间碎片,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下次GC.
  • 可预测的停顿:G1同样追求低停顿,同时建立可预测的停顿时间模型,能让使用者明确指定在长度为M毫秒的时间片内,消耗在垃圾手机上的时间不得超过N毫秒

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的G1中每个region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属Region的Remembered Set之中。

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记
    标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记
    该阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户并发执行。
  • 最终标记
    该阶段是为了修正在并发标记期间因用户程序继续工作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收
    该阶段对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控的,而且停顿用户线程将大幅提高收集效率。

内存分配与回收策略

对象的内存分配,往大方向讲就是在堆上分配内存,对象主要分配在新生代的Eden区域上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节主要取决于当前使用的哪一种垃圾收集器以及虚拟机中与内存相关的参数配置。接下来我们将会讲几条最普遍的内存分配规则

  • 对象优先在Eden区分配

    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将会发起一次Minor GC。
    虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。(在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析)

  • 大对象直接进入老年代

    所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机分配来说就是一个坏消息,尤其是那种“朝生夕灭”“短命大对象”,写程序时应当避免。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来存放他们。

    虚拟机提供了一个-XX:PretenurseSizeThreshold参数(该参数只对Serial和ParNew两款收集器有效),大于这个阈值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

  • 长期存活的对象将进入老年代

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

    对象晋升老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold设置,当对象年龄超过该阈值时,则进入老年代。

  • 动态对象年龄判断

    为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

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

参考文献

《深入理解Java虚拟机》

posted @ 2020-10-14 11:14  爪哇洋  阅读(129)  评论(0编辑  收藏  举报