什么是垃圾回收

一般在内存不足的时候,JVM会发生垃圾回收释放掉一些无用的内存,那么哪些是无用的内存:

数据存放的区域来说

运行时数据区里面被分成很多部分,我们一般关注的就是堆和方法区(这里也是内存变动较大的区域),对于程序计数器、栈等区域,我们一般认为内存大小在创建的时候就已经确定了(除了编译器的一些优化,不过无关紧要)。

存放的数据来说

堆中存放着大量对象:

死亡的对象:没有被引用到的对象

而方法区常量池中存放着常量和类的元信息:

废弃的常量:没有被引用到的常量,如串池的一些字符串常量

废弃的类

  • 该类所有对象实例都被回收
  • 该类的类加载器ClassLoader被回收
  • 该类的Class对象没有被引用,比如没有用反射方法

如何判断对象死亡以及四种引用类型

如何判断对象死亡

判断死亡对象一般有两种方式:

循环计数算法:在对象内部增加一个引用计数器,当被局部变量引用一次,计数器加一;当方法结束栈销毁的时候计数器就会减一,但是可能会出现循环引用导致两个对象都无法被正确的回收

可达性分析算法:从GC Root开始遍历,无法到达的对象都被判定为死亡对象,需要被垃圾回收

可达性分析算法详解

什么是GC Root?

可以简单理解为一些不会被回收的对象。如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就可以被看作为一个根节点。

  • 虚拟机栈的栈帧内的局部变量引用的对象
  • 本地方法栈的栈帧内的局部变量引用的对象
  • 方法区的类静态属性引用的对象
  • 方法区的常量引用的对象
  • 被同步锁(synchronized)锁定的对象
  • 虚拟机的内部引用,如基本数据类型的Class对象、异常对象、系统类加载器

三色标记法

三色标记法用来解决遍历过程中对象引用关系变动导致某些对象被误会标记为死亡对象。

节点颜色含义

在节点树扫描的时候,灰色在白色和黑色之间。

有以下三种颜色:

  • 白色:垃圾收集器没有扫描到的节点,需要被清除
  • 黑色:垃圾收集器已经扫描到的节点,并且所有该黑色节点的引用也被扫描到
  • 灰色:垃圾收集器已经扫描到的节点,至少一个该灰色节点的引用没有被扫描到

对象消失

并发标记过程中,前面遍历的节点引用关系变化,建立黑色和该白色的引用关系,同时将灰色到白色之间引用删除了,那么下一步就不会遍历到白色节点,导致白色节点被误删。

解决方案

  • 增量更新(Incremental Update):针对黑-白新增,记录这些黑色建立白色引用关系,并发扫描后,重新扫描这些黑色,这些黑色就会转换成灰色,白色就转换成灰色,如CMS采用

  • 原始快照(SATB):针对灰-白删除,记录被删掉的原来这些灰色和白色引用关系,并发扫描后,重新按照原来的视图扫描一次,如G1采用

四种引用类型

强引用:类似new出来的对象

软引用:有用但是不是必须的对象,在内存不足的时候会被回收

弱引用:非必须的对象,只能存活到下次垃圾回收

虚引用:无法通过虚引用获取对象实例,只是为了在对象被回收的时候得到一个通知,可用于追踪对象的垃圾回收过程

垃圾收集算法

分代收集理论

正是由于堆被划分出那么多的区域,才有了针对某些区域的一些垃圾回收:

  • 部分收集(Partial GC):收集部分堆w
    • 新生代收集(Minor GC or Young GC):收集整个新生代
    • 老年代收集(Major GC or Old GC):收集整个老年代,只有CMS有单独收集老年代的行为
    • 混合收集(Mixed GC):收集整个新生代和部分老年代,只有G1有
  • 全面收集(Full GC):收集整个堆和方法区
    • 当方法区满了
    • 新对象担保机制触发
    • System.gc()
    • 除CMS外的垃圾收集器在老年代空间不足

记忆集与卡表

针对分代收集理论,在部分收集(Partial GC)的时候,可能会出现跨代引用的问题,在可达性算法分析死亡对象的时候,为了避免收集新生代的时候把整个老年代都加入 GC Root,或者是收集老年代把整个新生代加入 GC Root,这样会增加第一步搜寻GC Root的时间(这步需要STW),就使用了记忆集与卡表的方式,卡表是记忆集的其中一种实现方式卡表是一个数组,每个元素对应一个512MB的卡页(堆内存被分为很多卡页),如果卡页内的一个或者多个对象跨代引用,那么把这个卡页对应的卡表索引元素置为脏,也就是标识为1,这样就可以把这些变脏的元素对应的卡页的对象加入GC Root,而不用加入所有的老年代对象。

比如CMS,它的卡页在老年代,如果卡表说某个卡页是脏的,那么只需要把对应卡页的老年代对象加入GC Root,而不是所以的老年代对象,减少了STW时间。

另一种实现是Rset,被G1使用,用哈希表代替数组,每个Region都有一个Rset,key代表当前Region每个对象的起始地址,value代表引用了这个对象的对象集合,但是G1的Rset占用内存高达20%。

image-20240327200723174

标记-清除算法(一般不用)

通过可达性分析算法标记死亡对象,再清除死亡对象

  • 优点:执行较快,因为不涉及移动存活对象
  • 缺点:大量不连续的外部碎片

如果需要用这些外部碎片分配对象,就需要使用空闲列表的方式存储这些空闲内存,但是随机访问效率是不如下面两种算法的顺序访问的。

如果新对象分配不够空间,就需要对象担保机制介入了。

标记-复制算法(新生代)

堆被分为两块区域,回收只在一块区域s1进行。

通过可达性分析算法标记死亡对象,再清除死亡对象,最后把存活对象搬到另一块区域s2

  • 优点:常用于新生代,存活对象较少,执行较快,并且无外部碎片
  • 缺点:可用内存只有原来的一半

标记-整理算法(老年代)

通过可达性分析算法标记死亡对象,再清除死亡对象,最后把存活对象搬到一起

  • 优点:无外部碎片
  • 缺点:常用于老年代,因为大多数对象存活,所以执行较慢

STW和安全点

初始标记GC Root为什么要STW?

  • 一方面是为了避免浮动垃圾
  • 另一方面是避免标记过程发生引用关系的变化导致GC错误

什么是安全点?

当我们需要阻塞线程必须要到达安全点,当线程到达安全点,堆对象的状态是一致的,此时可以进行GC、偏向锁撤销等操作。

  • 循环结束的末尾段
  • 方法调用之后
  • 抛出异常的位置
  • 方法返回之前

当JVM需要发生GC、偏向锁撤销等操作时,如何才能让所有线程到达安全点阻塞或停止?

  • ①主动式中断(JVM采用的方式):不中断线程,而是设置一个标志,而后让每条线程执行时主动轮询这个标志,当一个线程到达安全点后,发现中断标志为true时就自己中断挂起。
  • ②抢断式中断:先中断所有线程,如果发现线程未执行到安全点则恢复线程让其运行到安全点位置。

安全区域

当线程处于阻塞或者挂起状态,无法响应JVM的中断请求,同时这段代码不会影响堆对象的引用,这段代码就是安全区域,当挂起的线程恢复执行,需要先判断可达性分析即初始标记是否结束才能继续执行。

垃圾回收器

垃圾回收器有几种分类方式:

  • 分代或者分区
    • 分代:Serial、ParNew、Parallel Scavenge | Serial Old、Parallel Old、CMS
    • 分区:G1、ZGC
  • 吞吐量优先或者响应时间优先
    • 吞吐量优先:吞吐量定义 = 代码运行时间 / 程序运行时长(即 代码运行时间 + 垃圾回收时间);一次GC能回收的垃圾量
    • 响应时间优先:需要回收的内存空间尽量小,就有尽量少的STW时间
    • 一般两者是成反比的

新生代

Serial(单线程)

GC过程中是需要全程发生在STW中的,并且只有一条GC线程在收集垃圾,吞吐量和响应时间都很差。

ParNew(多线程)

GC过程中是需要全程发生在STW中的,但是有多条GC线程收集垃圾,在多核条件下,吞吐量和响应时间会比Serial好一些。

Parallel Scavenge(多线程)

ParNew更关注【响应时间】,PS更关注【吞吐量】,PS收集器可以通过-XX:MaxGCPauseMillis-XX:GCTimeRatio参数精准控制GC发生时的时间以及吞吐量占比。

如果将响应时间减少和吞吐量加大也并不一定能有更好的效果,因为响应时间少了,即新生代的空间少了,每次GC的STW时间虽然少了,但是GC次数会更多,两者乘积(每次GC的STW时间 * GC次数)说不定会更大。

老年代

Serial Old(单线程)

和Serial作用一样,是Serial的老年代版本,作为CMS的备用。

CMS(多线程/并发)首个并发收集

CMS是JDK5~JDK8应用在老年代的回收器,配合的是分代理论。

CMS追求的是响应时间优先。

回收过程

  • 初始标记(STW):先标记所有直接和 GC Roots 相连的二级节点
  • 并发标记:去标记引用链的其他所有节点
  • 重新标记(STW):再标记上一步过程中用户线程新修改引用的节点
  • 并发清除:清除没有标记的节点

image-20231130164725838

  • CMS 缺点
    • CPU敏感,并发标记期间也会占用线程,如果CPU数较少,会影响程序的正常运行效率
    • 由于是独占STW清除,无法处理浮动垃圾
    • 因为采用标记-清除算法,有大量内存碎片导致老年代无法分配足够大的连续内存空间,对象担保可能导致Full GC
  • CMS 优点
    • 耗时最长的并发标记和并发清除不用暂停所有线程,暂停时间STW短

整堆

G1(多线程/并发)

G1在JDK9~JDK16被应用, 逻辑分代但是物理不分代,改为一个个的Region区,大对象有个特殊的区域(只要超过Region区的一半就直接放入大对象区)。

一般来说分区上限是2048个,假设内存是8G,那么每个分区大小是4M。

G1追求的是低停顿时间,即响应时间优先。

回收过程(Mixed GC)

  • 初始标记(stop the world忽略不计):标记所有直接和 GC Roots 相连的二级节点,并修改 TAMS 指针的值
  • 并发标记:去标记引用链的其他所有节点,并且标记完后,对变动的节点标记
  • 最终标记(stop the world):对变动的节点重新标记
  • 筛选回收(stop the world):根据每个Region的价值选择回收顺序,并且将回收的Region中的剩下对象移动到空的Region

image-20231130164643835

  • G1优点
    • 并发扫描配合Rset,避免扫描整堆
    • G1清除的时候会STW,不会产生浮动垃圾,采用局部复制、整体整理算法
    • 可预测的停顿时间模型,减少暂停时间
    • 可动态调整堆的大小,随着程序运行,逐步增加每个Region属于哪个分代
  • G1缺点
    • 如果设置的停顿时间太短,回收垃圾速度无法匹配新对象产生速度,会导致频繁Full GC,违背G1初衷

ZGC

JDK17以后被应用,逻辑和物理都不分代,有几种不同内存大小类型的Region区。

回收过程

OOM

什么情况会发生OOM?

OOM一般是由于无法分配足够大的内存给新对象的,JVM有垃圾回收机制来尽可能的保证内存空间的富足,但是如果Full GC之后还是无法提供足够的内存,就会抛出OOM异常。

image-20240415095811035

哪块区域会发生OOM?

堆OOM

-Xms -Xmx

原因:

  • 代码问题,出现死循环或者无限递归产生大量对象
  • 程序设置的堆最大内存过小
  • 出现内存泄露,逐渐蚕食内存

栈OOM

-Xss最大深度

HotSpot虚拟机将虚拟机栈和本地方法栈的溢出都归到虚拟机栈的溢出。

对于栈来说,不只有OOM还有SOF:

  • SOF:如果虚拟机栈大小固定,当前线程请求栈的深度大于虚拟机的最大值,则抛出SOF异常;一般是无限递归引起的
  • OOM:如果虚拟机栈大小可动态扩展,扩展时无法申请到足够的内存就会抛出OOM异常;可能是虚拟机栈最大值分配太小

方法区OOM

方法区保存类的元信息,还有运行时常量池会保存一些JIT热点编译后的机器码和一些代理类

原因:

  • 加载类信息过多
  • JIT热点代码过多
  • CGLIB生成的代理类过多

直接内存OOM

-XX:MaxDirectMemorySize

原因:

  • 一直用Unsafe类申请直接内存并且不释放,在Full GC之前耗尽所有的资源
  • 申请的内存超过最大值

内存泄漏

程序申请的内存空间在使用完没有被正确释放,同时还有引用链指向这块内存导致无法被GC回收,逐渐会蚕食有效内存。

如ThreadLocal、一些资源连接、static对象、字符串对象等。

ThreadLocal内存泄漏:

外部创建的ThreadLocal没有强引用指向,并且线程一直不结束如线程池核心线程,那么ThreadLocal的弱引用(这个ThreadLocalMap的key),可能就会被GC回收,但是ThreadLocalMap是和Thread相关的,线程不结束,value就一直没有办法被回收就会导致内存泄漏。

和内存溢出OOM的区别?

  • 你有一个箱子,你往里面放杂物,但是你的杂物太多放不下就会导致OOM
  • 同样的箱子,里面有你上次装的杂物,这次的杂物太多也会放不下导致内存泄漏