Java的垃圾回收机制详解——从入门到出土,学不会接着来砍我!

首先我们要知道哪些内存需要被回收?

哪些内存需要回收

在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

垃圾收集器所关注的正是堆和方法区的内存该如何管理的问题,我们平时所说的内存分配与回收也仅仅特指这一部分内存

回收堆:垃圾的定义

引用计数算法:

在对象中添加一个引用计数器:

  • 每当有一个地方引用它时,计数器值就加一;
  • 当引用失效时,计数器值就减一;

任何时刻计数器为零的对象就是不可能再被使用的

但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及
objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

可达性分析算法:

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象:

在这里插入图片描述

GC Roots的对象

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

回收方法区:垃圾的定义

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型

回收废弃常量与回收Java堆中的对象非常类似

举个常量池中字面量回收的例子:
假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,看还有没有地方引用这个常量就OK了,而判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。

需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

如何回收垃圾

关于分代回收理论与垃圾回收算法的推演可以参考的我的另一篇博文:https://yangyongli.blog.csdn.net/article/details/126473167

经过上面的文章中gc的不断发展,逐渐到了我们现在用的gc的样子:

垃圾回收算法总结

标记—清除算法(适用老年代,但是基本废弃了)

这是最早最基础的算法,首先标记出要回收的对象,标记完成后,统一回收被标记的对象

缺点:
1、执行效率不稳定,随着对象数量的增多,标记次数增多,清楚也变多,执行效率下降2、内存空间碎片化,假如说被清除和保留的对象内存交错在一起,就会导致内存空间碎片化,即使空间充足,由于碎片化也没法新建对象,导致空间浪费。

标记—复制算法(现在新生代普遍用的)

他实现原理是把内存空间分成两块空间,每次只使用其中一块,将存活的对象复制到另一块空间,假如说大量的对象被回收,只需要复制一小部分即可,所以适用于新生代。(速度快,效率高)

缺点:代价是明显的需要浪费一半的空间

但是后面针对 对象朝生熄灭的特点衍生更好的半区复制策略——“Appel”式回收,具体的策略为,将新生代空间分为一块较大Eden的空间和两块较小Survivor,其中HotSpot虚拟机默认的比例是Eden:Survivor=8:1,也就是说每次新生代拥有90%的空间(Survivor80+Eden10),大大减少了空间浪费,同样也会有这样的问题,假如说总共100MB空间 ,此次回收对象为20MB,很显然,剩下的10MB无法占下,谁都不能保证这种情况不会发生,这时Appel采用了“逃生门”设计——内存分配担保

内存分配担保就是将Survivor无法站下的那部分直接放入老年代。就是相当于把上面例子中的20MB直接放入老年代(也就是晋升老年代的特殊情况)

标记—整理算法(现在老年代普遍用的)

与标记—清除不同的是,将存活的对象标记后,没有进行直接清除而是将存活的对象向一端整理到一起,这样就解决了空间碎片化问题。

因为老年代的特点所以只有很少一部分被清除,在老年代会有大量的活对象,并且需要更新对象索引,这就导致了系统的负重,并且这种对象一定操作必须在系统停止的情况下进行,这是就出现了我们经常听到的““Stop The World”。

另外还有一种和稀泥走一步看一步的方法,就是平常的时候采用标记清除法,产生碎片化后,直到影响到对象分配时,再采用标记整理法,以获得完整的空间。基于标记—清除的算法的CMS收集器就是采用的这种处理方法


下面我们来看一下JVM 是怎么进行回收的,其中JVM中有三种GC:

JVM GC的种类

JVM常见的GC包括三种:Minor GC,Major GC与Full GC

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:

  • 一种是部分收集(Partial GC)
  • 一种是整堆收集(Full GC)

部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集,目前,只有G1 GC会有这种行为

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

注意:JVM在进行GC时,并非每次都对所有区域(新生代,老年代,方法区)一起回收的,大部分时候回收的都是指新生代

GC的触发机制

年轻代GC(Minor GC)触发机制

触发机制:

  • 当年轻代空间不足时,就会触发Minor GC这里的年轻代空间不足指的是Eden区满,Survivor区满不会触发GC(每次Minor GC 会清理年轻代的内存)

因为Java对象大多具备朝生夕死的特新,所以Minor GC非常频繁,一般回收速度也比较快.
Minor GC会引发STW(stop to world),暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行

老年代GC(Major GC/Full GC)触发机制

指发生在老年代的GC,对象从老年代消失时,我们说"Major GC或Full GC"发生了、

一般出现Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)

触发机制:

  • 也就是老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC

PS:
Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
如果Major GC后,内存还不足,就报OOM了
Major GC的速度一般会比Minor GC慢10倍以上

Full GC触发机制

触发Full GC执行的情况有如下五种:

  • 调用System.gc(),系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区,from区向to区复制时,对象大小大于to区可用内存,则把对象转存到老年代,并且老年代的可用内存小于该对象大小(那要是GC之后还不够呢?那还用说:OOM异常送上)

另外要特别注意: full GC是开发或调优中尽量要避免的

为什么需要把Java堆分代?

经研究,不同对象的生命周期不同,70%-99%的对象是临时对象

其实不分代完全可以,分代的唯一理由就是优化GC性能,如果没有分代,那所有的对象都在一个区域,当需要进行GC的时候就需要把所有的对象都进行遍历,GC的时候会暂停用户线程,那么这样的话,就非常消耗性能,然而大部分对象都是朝生夕死的,何不把活得久的朝生夕死的对象进行分代呢,这样的话,只需要对这些朝生夕死的对象进行回收就行了.总之,容易死的区域频繁回收,不容易死的区域减少回收.

这个其实也是基于分代回收理论的,详情可以参考我的另一篇博文:https://blog.csdn.net/weixin_45525272/article/details/126473167

JVM中一次完整的GC流程是怎样的?

新创建的对象一般会被分配在新生代中,常用的新生代的垃圾回收器是 ParNew 垃圾回收器,它按照8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区。某一时刻,我们创建的对象将 Eden 区全部挤满,这个对象就是挤满新生代的最后一个对象。此时,Minor GC 就触发了

在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小

为什么要做这样的检查呢?
原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。

这样就有两种情况:

  1. 老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也绝对够放

  2. 老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了“老年代空间分配担保规则”,具体来说就是看 -XX:-HandlePromotionFailure 参数是否设置了。

    老年代空间分配担保规则是这样的,如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。
    因为从概率上来说,以前的放的下,这次的也应该放的下。

  • 分配担保规则此时也有两种情况:
    • 老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 Minor GC
    • 老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查

开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:

  1. Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束
  2. Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC结束;
  3. Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC

前面都是成功 GC 的例子,还有 3 中情况,会导致 GC 失败,报 OOM:

  1. 紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM
  2. 未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM
  3. 开启老年代分配担保机制,但是担保不通过,一次 Full GC 后,老年代任然放不下剩余对象,也是能 OOM

JVM GC注意点:

Full GC会导致什么?

Full GC会“Stop The World”,即在GC期间全程暂停用户的应用程序。所以说我们开发的时候尽量少让他Full GC。

JVM什么时候触发GC?

我们对上面的流程进行总结,也就是说:
当 Eden 区的空间耗尽时 Java 虚拟机便会触发一次 Minor GC 来收集新生代的垃圾,存活下来的对象,则会被送到 Survivor 区,简单说就是当新生代的Eden区满的时候触发 Minor GC

serial GC 中,老年代内存剩余已经小于之前年轻代晋升老年代的平均大小,则进行 Full GC。而在 CMS 等并发收集器中则是每隔一段时间检查一下老年代内存的使用量,超过一定比例时进行 Full GC 回收。

如何减少FullGC的次数?

可以采用以下措施来减少Full GC的次数:

  1. 增加方法区的空间;
  2. 增加老年代的空间;
  3. 减少新生代的空间;
  4. 禁止使用System.gc()方法;
  5. 使用标记-整理算法,尽量保持较大的连续内存空间;
  6. 排查代码中无用的大对象。

为什么老年代不能使用标记复制?

因为老年代保留的对象都是难以消亡的,而标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,所以在老年代一般不能直接选用这种算法。

新生代为什么要分为Eden和Survivor?

现在的商用Java虚拟机大多都优先采用了“标记-复制算法”去回收新生代,该算法早期采用“半区复制”的机制进行垃圾回收。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

为什么要设置两个Survivor区域?

设置两个 Survivor 区最大的好处就是解决内存碎片化

我们先假设一下,Survivor 只有一个区域会怎样。Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?

在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化

但是我们现在设置两个Survivor区,因为Survivor有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To区域,然后就可以清空 Eden 区和 From 区。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivorspace 是无碎片的。那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案

为什么新生代和老年代要采用不同的回收算法?

如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。

如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

新生代正是符合第一种情况,所以使用标记——复制算法可以更好的清除大量的垃圾和保留少量的存活对象;而老年代中只是回收少部分对象,为了避免空间碎片问题,采用标记——整理算法是最优的。

猜你喜欢

转载自blog.csdn.net/weixin_45525272/article/details/126469398
今日推荐