JVM之垃圾回收

垃圾回收

在前面有讲到,通过JVM,让Java可以一次编写,到处运行。那么,它是怎么进行内存管理,让程序员不需要手动的分配内存、释放内存的呢?

​ 垃圾回收GC,全称Garbage Collection。实际上,GC并不是Java这门语言的产物,GC的历史比Java久远的多。

JVM内存结构中,哪些需要进行垃圾回收

​ JVM内存结构主要分为程序计数器、虚拟机栈、本地方法栈、堆、方法区几个部分。其中,程序计数器、虚拟机栈、本地方法栈都是随线程而生,随线程而灭。并且这几个区域的内存分配和回收都具备确定性,也就不需要考虑垃圾回收。当方法结束或者线程结束时,这几个区域占用的内存就会被自动的回收掉。

堆跟方法区就有着显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法执行的不同分支需要的内存也不一样。只有在运行期间,我们才知道这些程序到底会创建哪些对象,创建多少个对象。堆跟方法区分配和回收的内存都是动态的,也是垃圾收集器所关注的。

如何判断一个对象是否需要垃圾回收

​ 判断一个对象是否需要被垃圾回收的标准就是查看这个对象是否还有途径被引用。

​ 有两种算法来判断:引用计数法和可达性分析算法

引用计数法Reference Counting

​ 引用计数法简单来说,就是在每个对象中都添加一个引用计数器,有一个地方引用,计数器就+1;当引用失效不再引用了,计数器就-1,当计数器为0,就说明这个对象没有地方再引用,就可以被回收掉。

​ 但是大部分虚拟机都没有采用这种算法进行内存管理。主要原因是这种算法虽然原理简单,但是需要额外的处理工作才能正确的工作,比如对象的循环引用。

可达性分析算法Reachability Analysis

​ 可达性分析算法的原理就是,在Java的技术体系中,有些对象可以作为GC Roots对象。通过这些GC Roots作为根对象,从这些节点开始根据引用关系开始搜索,(搜过过程走过的路径称为引用链),如果某个对象到GC Roots间没有任何引用链相连(从图论的角度,就是从GC Roots到这个对象不可达),就说明这个对象是不被使用的。

GC Roots

​ 在Java的技术体系中,可以作为GC Roots的对象有以下几种:

​ 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等

​ 在方法区中类静态属性所引用的对象

​ 在方法区中常量引用的对象,比如字符串常量池中的引用

​ 在本地方法栈中JNI(Native方法)中引用到的对象

​ Java虚拟机内部引用到的对象,如基本数据类型对应的Class对象,常用的异常对象(OutOfMemoryError、NullPointException)等

​ 被同步锁(synchronized关键字)持有的对象

​ 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

​ 根据使用的垃圾收集器以及回收的内存区域不同,其余被临时加入的对象。如:在后续讲到的垃圾回收算法中,某个区域的对象被其他的区域的对象引用,当对这个区域进行垃圾回收时,就需要将这些关联区域的对象也加入到GC Roots集合中


引用

在JDK1.2之前,对引用到定义是:如果reference类型的数据存储的事另外一块内存对象的起始地址(也就是reference指向了某个对象),那么就代表某块内存、某个对象被引用了

​ 这样定义并没有什么不对,但是如果想继续划分,比如我们希望有一类对象:如果内存空间足够,就可以继续存活,如果在进行完一次垃圾回收后,内存空间依旧十分紧张,就回收掉这些对象

强引用
Object obj=new Object();

​ 这种引用赋值就是强引用。任何情况下,只要强引用关系还存在,垃圾回收器就不能回收掉被引用的对象

软引用

​ 软引用用来描述一些还有用,但是非必须的对象。被软引用关联的对象,在系统将要发生内存溢出前,会对这些对象进行回收,如果回收后内存还是不够,才会抛出内存溢出异常。

​ 可以配合引用队列来释放软引用对象自身。

弱引用

​ 弱引用也是用来描述那些非必须对象,被弱引用的对象,在垃圾回收时,无论内存是否充足,都会被回收。

​ 可以配合引用队列来释放弱引用对象自身。

虚引用

​ 虚引用是最弱的一种引用关系,一个对象是否有虚引用存在,完全不会对它的生存时间构成影响,也无法通过虚引用来获取对象实例。虚引用的目的是在这个对象被垃圾收集器回收时,收到一个系统通知。

​ 虚引用必须配合引用队列使用。虚引用主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由ReferenceHandler线程调用虚引用相关方法来释放内存。

Finalize方法

​ 被可达性算法判定死亡的对象也并不是一定要被回收。如果这个对象实现了finalize()方法,并且虚拟机还没有执行过,那么这个对象会被放置到一个F-Queue队列中,由虚拟机自动建立的、低优先级的Finalizer线程去执行他们的方法,但是虚拟机只保证会执行这个方法,却不承诺一定会等它执行结束(如果一个对象的finalize方法执行太慢或者发生了死循环,那么会导致其他队列中的对象finalize无法被执行,严重会导致内存回收子系统崩溃)。

​ 任何对象的finalize方法只会被系统自动掉用一次。

​ 这个方法是Java刚诞生时为其他语言做出的一种妥协,现在已经被官方明确声明为不推荐使用的方法。


垃圾回收算法

分代收集理论

收集器将Java堆划分为不同的区域,然后将回收对象按照其年龄(对象熬过垃圾收集过程的次数)分配到不同的区域去存储,这样就可以按照每个区域不同的特征,安排与里面存储对象存亡特征相匹配的垃圾收集算法——“标记-清除算法”,“标记-复制算法”,“标记-整理算法”,也可以每次只回收一个或者某几个区域,这样就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

弱分代假说

​ 绝大部分对象都是朝生夕灭的

强分代假说

​ 熬过越多次垃圾收集过程的对象就越难消亡

​ Java虚拟机一般会把Java堆划分为新生代和老年代,那么如果新生代的对象被老年代的对象引用呢?

跨代引用假说

​ 跨代引用相对于同代引用来说仅占少数

​ 根据这条假说,如果为了少量的跨代引用扫描整个老年代,就很浪费资源。为了解决跨代引用,使用记忆集这种结构。

记忆集RememberedSet

​ 在新生代上建立一个全局的数据结构,这个结构把老年代划分为若干小块,标识老年代的哪一块内存被跨代引用。之后发生MinorGC时,只有包含了跨代引用内存中的对象才会被加入到GCRoots中进行扫描。

​ 卡表就是记忆集的一种具体实现。

回收类型

​ 在Java堆按照分代假说划分为不同的区域后,垃圾收集器就可以每次只回收某个或者某部分的区域。因此就有了Minor GC,Major GC,Full GC回收类型的划分。

Partial GC

​ 不是完整收集整个Java堆堆垃圾收集

Minor GC/Young GC

​ 目标只是新生代的垃圾收集

Major GC/Old GC

​ 目标是老年代的垃圾收集(目前只有CMS垃圾收集器会有单独收集老年代的行为)

Mixed GC

​ 目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为

Full GC

​ 收集整个Java堆和方法区的垃圾收集


垃圾收集算法

标记

​ 沿着GC Root对象的引用链进行遍历,标记出需要被回收掉对象

标记清除算法
清除

​ 把这个对象占用的空间释放掉,只需要将这块空间的起始地址放到空闲地址列表中,有新对象来的时候再进行分配

优缺点
优点

​ 简单

缺点

​ 执行效率不太稳定。如果堆中包含大量的对象,并且大部分需要被回收,那么需要进行大量的标记和清除,导致标记和清除这两个过程的执行效率随着对象数量增长变低

​ 这种算法会导致内存碎片。标记、清除后会有大量不连续的内存碎片,可能会导致下次分配大对象时没有足够的连续空间而不得不触发另一次垃圾收集

标记复制算法
复制

​ 将可用的内存按照容量划分为大小相等的两块,每次只使用其中的一块。垃圾回收时,将存活的对象复制到另一块上,然后将使用过的空间清理掉。这种算法适合少数存活对象。

现在Java虚拟机大多使用这种收集算法去回收新生代

缺点

​ 这种算法将内存缩小为原来的一半,浪费了一半的空间

半区复制分代策略

​ 因为标记复制算法的缺点,在1989年出现了更优化的半区复制分代策略。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代。

​ 这种策略的做法是将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden空间和其中一个Survivor空间。垃圾收集时,将Eden和Survivor中仍然存活的对象一次复制到另外一块Survivor空间上,然后清理掉Eden和使用过的Survivor空间。

​ Eden和Survivor占比默认是8:1,也可以通过参数去配置。

​ 但是这样也会有Survivor空间无法容纳依次Minor GC之后存活的对象的场景(假设最坏情况下,新生代的所有对象都存活了),这时就需要老年代来进行担保。(是否允许老年代进行担保也可以由参数进行配置)即在MinorGC前,判断老年代的可用空间是否大于新生代所有对象的大小,如果老年代的空间也不允许,就会触发Full GC。

在这里插入图片描述

对象首先都会被分配在Eden区

新生代空间不足时,会触发Minor GC,Eden和Survivor From中存活的对象会复制到Survivor To区,存活对象的年龄+1

交换Survivor From和Survivor To

当对象的寿命超过一定的阈值,会晋升到老年代。不同的虚拟机如果空间紧张,那么也会提前晋升到老年代

当老年代空间不足,会先触发MinorGC,如果还不足,就会触发FullGC,如果空间还不够,就会抛出OutOfMemoryError

标记整理算法

​ 标记复制算法在对象存活率较高时就要进行很多的复制操作,效率会降低,而且需要额外的空间来进行担保,并不适合老年代。

​ 针对老年代对象的特征,出现了标记整理算法。标记整理算法与标记清除算法一样,但后续的步骤不是直接清理可回收的对象,而是让存活的对象都向空间的一端移动,然后直接清理掉边界的内存。

STW

​ 对于老年代这种每次回收都有大量对象存活,需要移动并更新对象引用,将是一种负重的操作。因为移动对象后,对象的内存地址就会发生变化,需要暂停用户应用程序才可以进行。这种停顿被称为Stop The World。

​ Minor GC和Full GC都会触发STW。

小结

​ 不管那种垃圾回收算法,都会存在弊端。在垃圾收集器中,根据不同的情况和关注点来使用不同的垃圾回收算法。

​ 标记清除算法导致的空间碎片,需要通过更复杂的内存分配器和内存访问器来解决,如通过“分区空闲分配链表”来解决内存分配。但是内存分配和访问,比垃圾回收的频率要高很多,所以总吞吐量是下降的。但是因为它运行速度较快,关注延迟的CMS收集器就是采用了标记清除算法。

​ 标记复制和标记整理需要移动对象,造成停顿,但是总得吞吐量的较大的,关注吞吐量的Parallel Old收集器就是基于标记整理算法。

​ 还有一种解决方案,让虚拟机在大部分时间采用标记清除算法,当内存空间碎片大到影响对象分配时,再使用标记整理算法收集一次来获得规整的内存空间。CMS收集器在空间碎片过多时,就是这么处理的。


hotspot的算法细节实现

根节点枚举

​ 可达性分析算法需要从GC Roots集合中遍历每个GC Root对象,查找他们的引用链。在根节点枚举这个过程中,是需要暂停用户线程的,不然对象引用关系就会不断变化。

​ 目前Java虚拟机都使用了准确式垃圾收集,用户线程停顿时,并不需要遍历所有的执行上下文。在HotSpot虚拟机中,通过OopMap这种数据结构来指明对象引用。在类加载完成后,HotSpot就把对象在哪些偏移量上是什么类型的数据计算出来,也会在特定位置记录栈和寄存器中哪些位置是引用。

安全点

​ HotSpot并不是在每个指令都生成OopMap,只在某些特定的位置记录,这些位置被称为安全点。所以用户程序只有在代码指令到达安全点后才能暂停。

​ 安全点选择的标准:是否让程序长时间的执行,如方法调用、循环跳转

​ 如何在垃圾收集时,让所有线程都跑到最近的安全点进行停顿?这里有两种方式:抢先式中断和主动式中断。

抢先式中断

​ 垃圾收集时,系统首先把所有的线程全部中断,如果发现有用户线程没停在安全点上,就恢复这条线程,让它跑到安全点再重新中断。(基本没有虚拟机使用这种方法)

主动式中断

​ 垃圾收集需要中断线程时,不直接对线程操作,而是设置一个标志位,各个线程执行的时候去轮询这个标识,一旦发现这个标识为真,就跑到最近的安全点主动挂起线程

安全区

​ 安全点并不是完美的。当程序不执行的时候(没有分配处理时间,如用户线程处于Sleep状态),线程就没办法响应虚拟机的中断请求,这时就需要安全区来解决。

​ 安全区指的是在某一段代码中,引用关系不会发生变化,因此在这个区域中任何地方进行垃圾收集都是安全的。

​ 用户线程进入到安全区后,会标识自己进入安全区,这样虚拟机在发生垃圾收集就不需要管这些线程。线程离开安全区后,会检查虚拟机是否已经完成了根节点枚举,如果没有就一直等待。

写屏障

​ 前面讲过了通过记忆集(卡表)来缩减GC Root的扫描范围,写屏障就是用来维护记忆集中的元素的。

​ 当其他分代区域有对象引用了这个区域的对象,就需要对卡表进行维护。HotSpot中,使用了写屏障来维护卡表状态。写屏障可以理解为改变引用对象赋值时,程序需要额外执行的动作(可以简单理解为引用对象赋值这个动作的aop切面)

伪共享

​ 除了写屏障的开销外,卡表在高并发情况下面临伪共享的问题。现代处理器的缓存系统中,是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰巧处于同一个缓存行,就会彼此影响,性能降低。

​ 为了解决这个问题,可以在写屏障前先检查卡表标记。只有当卡表没有被标记过,才把它标记为脏。(JDK7后,可以通过参数判断是否开启卡表更新的判断)


垃圾收集器

​ 我们可以把垃圾收集器划分为几种类型:

​ 串行

​ 吞吐量优先

​ 响应时间优先


串行垃圾收集器

可以通过参数-XX:+UseSerialGC指定Serial(新生代)+Serial Old(老年代)两种收集器

串行不仅仅是只使用一个回收线程去进行垃圾收集,更重要的是在垃圾收集时,其余用户线程必须暂停,直到它收集结束

Serial/Serial Old收集器运行示意图如下:

在这里插入图片描述

优缺点
优点

​ 简单高效,额外内存消耗最小

​ 适用于单核处理器或者处理器核心数较少的环境

缺点

​ 垃圾回收期间会造成较长时间的STW


吞吐量优先垃圾收集器

可以通过参数-XX:+UseParallelGC -XX:+UseParallelOldGC开启(这两个开关开启任意一个,另外一个也会自动开启)

Parallel Scavenge

​ 基于标记-复制算法的收集器,能够并行收集

Parallel Old

​ 基于标记-整理算法实现的收集器,能够并行收集

Parallel Scavenge/Parallel Old收集器运行示意图如下:

在这里插入图片描述

吞吐量

​ 吞吐量是指处理器运行用户代码的时间与处理器总消耗时间的比值

垃圾收集器中的并行与并发
并行

​ 同一时间内有多条垃圾收集器线程在工作,用户线程处于等待状态

并发

​ 同一时间内,垃圾收集器线程和用户线程都在工作。但是因为垃圾收集器 线程占用了一部分系统资源,吞吐量会受到影响

参数
-XX:MaxGCPauseMillis

​ 控制最大垃圾收集停顿时间

​ 说明:XX:MaxGCPauseMillis这个参数并不是越小越好。因为垃圾收集停顿时间缩短是牺牲吞吐量和新生代空间为代价换来的,系统将新生代空间调小,那么垃圾收集停顿时间自然就会缩短,但是垃圾收集会变得更频繁,吞吐量也就降下来了。

-XX:GCTimeRatio

​ 吞吐量

-XX:UseAdaptiveSizePolicy

​ 垃圾收集的自适应调节策略,这个开关开启后,就不需要人工指定新生代的大小、Eden与Survivor的比例、晋升老年代对象的大小等参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量

-XX:ParallelGCThread

​ 设置并行收集器收集时使用的线程数

响应时间优先的垃圾收集器

开启参数:-XX:+UseConcMarkSweepGC指定CMS作为老年代的垃圾收集器,开关打开后,新生代默认使用ParNew垃圾收集器

-XX:+UseParNewGC开关可以强制指定ParNew垃圾收集器。但是自从G1收集器作为CMS的替代者登场,JDK9以后,-XX:+UseParNewGC这个命令已经被取消了

ParNew

​ 可以简单的认为是Serial收集器的多线程并行版本,也是使用标记-复制算法

ParNew收集器运行示意图如下:

在这里插入图片描述

CMS(ConcurrentMarkSweep)

​ 以获取最短回收停顿时间为目标的收集器。从名字就可以看出,CMS是基于标记-清除算法的并发收集器。

CMS收集器运行示意图如下:

在这里插入图片描述

CMS工作过程

CMS收集器工作主要分为四步:

初始标记

​ 需要STW,但是速度会很快。这一步主要是标记下GC Roots能直接关联到的对象

并发标记

​ 从GC Roots直接关联对象开始遍历整个对象图,可以与用户线程一起执行

重新标记

​ 修正并发标记期间,因用户线程继续运行而导致标记变动的那一部分对象的标记记录修正

并发清理

​ 清理掉标记阶段判断死亡的对象

优缺点
缺点

​ 1.对处理器资源非常敏感。因为在并发阶段,会占用一部分线程导致应用程序变慢,吞吐量降低。CMS默认启动的回收线程是(处理器核心数量+3)/4,那么当CPU数量不足4个时,CMS对用户程序的影响就会变大。

​ 2.CMS无法处理浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程还是在继续运行的,程序就会产生新的垃圾对象,这部分垃圾对象只能在下次垃圾收集的时候再处理。这部分垃圾就成为“浮动垃圾”。这就导致了CMS不能像其他垃圾收集器那样等老年代几乎填满了再收集,必须留一部分空间给浮动垃圾,可以通过参数:-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的出发比例。

​ 这时就会面临一种风险:CMS运行期间如果预留的内存无法满足程序分配新对象的需要,就会导致“并发失败”,虚拟机就会启动SerialOld收集器来进行老年代的垃圾回收。

​ 所以,-XX:+CMSInitiatingOccu-pancyFraction参数如果设置的太高,很容易就会导致大量的并发失败,性能反而会降低。

​ 3.空间碎片。因为CMS才用了标记清理算法,所以会造成大量的空间碎片,空间碎片过多时,就无法找到足够大的连续内存来分配当前对象,就会提前触发FullGC。

参数:
-XX:+UseCMS-CompactAtFullCollection

​ 让CMS收集器不得不进行FullGC时开启内存碎片的合并整理,但是这样又会因为移动对象造成STW。

-XX:+CMSFullGCsBefore-Compaction(JDK9废弃)

​ 要求CMS收集器在执行若干次不整理空间的FullGC后,下次FullGC前先进行碎片整理

-XX:CMSScavengeBeforeRemark

​ 为了解决重新标记过程中,如果在并发标记阶段产生的新生代对象引用了老年代对象,如果这时对整个新生代对象都进行一遍可达性扫描,那么性能就太低了。开启这个开关,在重新标记前,重新对新生代进行一次垃圾回收,这样新生代就只留下了存活对象,老年代的重新标记工作量就降低了。

GarbageFirst(G1)

​ G1是面向局部收集的设计思路与基于Region的内存布局形式,也是JDK9默认的垃圾收集器。官方给G1收集器设定的目标是在延迟可控的情况下,获得尽可能高的吞吐量。

​ 默认的用户期待停顿目标是200ms,适合大内存应用。

G1的回收阶段

​ 新生代垃圾收集->新生代垃圾收集+并发的标记->混合收集,这三个阶段是个循环的过程。

​ 一开始是新生代垃圾收集,如果老年代的内存超过一个阈值,就会在新生代垃圾收集的同时进行并发标记,然后进行混合收集。混合收集结束,内存足够,又开始新生代的垃圾收集。

在这里插入图片描述

概念介绍
Region

​ G1将连续的Java堆划分为多个大小相等的独立区域Region,每个Region都可以根据需要,扮演Eden、Survivor或者老年代空间。

​ 需要特殊说明的是,G1有一类特殊的区域专门用来存放大对象。G1对大对象的定义是:如果一个对象的大小超过了Region容量的一半就认为它是大对象。G1不会对大对象进行拷贝,回收的时候也会优先考虑。

面向局部收集

​ G1建立了可预测的停顿时间模型,每次回收,它会将Region作为单次回收的最小单元,并且G1会跟踪各个Region中垃圾堆积的“价值”,即回收会获得的空间大小以及回收需要的空间,在后台维护一个优先级列表,根据用户设定的允许的垃圾收集停顿时间,优先处理回收价值最大的Region。这也就是G1,Grabage First名字的由来。

G1收集器的运行示意图如下:
在这里插入图片描述

G1收集器工作过程
初始标记

​ 标记GC Roots能直接关联到的对象,需要用户停顿线程,借用MinorGC的时候同步完成

并发标记

​ 从GC Root开始对堆中的对象进行可达性分析,找出要回收的对象,并重新处理并发时有引用变动的对象

最终标记

​ 对用户线程做短暂的暂停,处理并发阶段结束后遗留下来的引用有变化的对象

筛选回收

​ 更新Region统计的数据,对各个Region回收价值与成本进行排序,根据用户期望的停顿时间来定制回收计划,然后把需要回收的Region中存活的对象复制到空的Region中,再清理掉旧的Region空间。会造成用户线程停顿。

优缺点
优点

​ G1在整体上是基于标记-整理算法,但是在Region之间又是基于标记-复制算法。这就意味G1收集器运行期间不会产生内存碎片,垃圾收集完成后可以提供规整的可用内存。

​ G1对字符串去重复、类卸载都做了优化。

​ G1收集器将所有新分配的字符串都放入一个队列,在新生代回收时,G1收集器会检查是否有字符串重复,如果有重复,就让他们指向同一个char[]的引用。(与intern不同,intern关注的是字符串对象,而去重关注的是char[])

​ G1收集器在并发标记阶段,就知道哪些类不再使用。当一个类加载的所有类都不在使用,就卸载它加载的所有类,可以使用参数-XX:+ClassUnloadingWithConcurrentMark开关来关闭,默认启用。

缺点

​ 相比于CMS收集器,G1收集器为了解决跨代引用使用的卡表更为复杂,而且无论新生代还是老年代,都必须维护一份卡表,这就造成了更多的内存开销

​ 更多的执行负载。CMS收集器采用增量更新算法来解决并发标记阶段对象引用关系的改变,使用写后屏障来维护卡表。而G1使用原始快照算法,除了使用写后屏障维护卡表,还使用了写前屏障来跟踪并发时的指针变化,实现原始快照算法。

​ CMS的写屏障实现是同步的操作,而G1则用一个类似于队列的结构,将写前屏障和写后屏障中做的事情放入队列,使用异步处理。

​ 如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程,导致FullGC产生长时间的STW。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值