从零开始的JVM学习--GC

 2023-01-06
原文作者:pixel_revolve 原文地址:https://juejin.cn/post/7153141395958530056

简单介绍

什么是垃圾回收机制?

什么是垃圾回收机制?

Java语言的一个显著的特点就是引入了「垃圾回收机制」,使C++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候 不再需要考虑内存管理。

由于有个「垃圾回收机制」,Java中的对象不再有“作用域的概念,只有对象的引用才有“作用域”。

「垃圾回收器」通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用「垃圾回收器」对某个对象或所有对象进行垃圾回收(自动执行,且不可控)。

「回收机制」有分代复制垃圾回收和标记垃圾回收,增量垃圾回收。「垃圾回收器」通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用「垃圾回收器」对某个对象或所有对象进行垃圾回收(自动执行,且不可控)。

「回收机制」有分代复制垃圾回收和标记垃圾回收,增量垃圾回收。

「垃圾回收」可以有效的防止内存泄露,有效的使用可以使用的内存。

Java 堆的内存结构

关于Java 堆的内存结构的具体内容可以看我的这篇博客:从零开始的JVM学习--Java运行时数据区域

理解堆内存结构对垃圾回收机制有什么用?

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。并且我们知道Java对象是存储在「堆」中的,所以Java 自动内存管理最核心的功能是「堆」内存中对象的分配与回收。

「堆」是「垃圾收集器」管理的主要区域,因此也被称为“垃圾堆”(GC堆)。

程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。 这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

而对于「堆」和「方法区」,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的 ,「垃圾收集器」所关注的正是这部分内存(也就是线程共享的几个区域)。

JDK1.7 及以前的堆结构

202301011503302921.png

  • 新生代

    • Eden
    • 两个 Survivor
  • 老年代

  • 永久代

JDK1.8 的堆结构

202301011503308052.png

JDK1.8 开始,PermGen(永久代)被 MetaSpace(元空间)替代,元空间使用的是 直接内存

垃圾收集

哪些内存需要回收?

判断对象是否可以被回收

引用计数法

什么是引用计数法?

在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了可以回收。

引用计数算法的缺点

  • 需要额外的内存来计数
  • 运行期间需要维护计数器,带来额外的开销
  • 无法解决循环引用的问题

主流 JVM 为什么不用引用计数算法?

「引用计数算法」的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的JVM里没有选用「引用计数算法」来管理内存,主要是 因为它很难解决对象之间「循环引用」的问题

循环引用问题

在两个对象出现「循环引用」的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为「循环引用」的存在,JVM不使用「引用计数算法」。

     public class Test {
     ​
         public Object instance = null;
     ​
         public static void main(String[] args) {
             Test a = new Test();// 引用+1
             Test b = new Test();// 引用+1
             a.instance = b;// 引用+1
             b.instance = a;// 引用+1
             a = null;// 引用-1
             b = null;// 引用-1
             doSomething();
             // a、b均不可能再被访问到,但是引用计数器为1,无法被回收。
         }
     }

a 与 b 引用的对象实例互相持有了对方的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。这样就造成了内存的泄漏了。

可达性分析法

主流JVM都是通过可达性分析来判断对象是否可以被回收的。

什么是可达性分析算法?

「可达性分析算法」 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

搜索走过的路径称为「引用链」,所以算法也可以总结为:如果某个对象到GC Roots没有任何「引用链」相连,就说明该对象不可达,即可以被回收。

JVM 使用该算法来判断对象是否可被回收。

什么是 GC Roots?

GC Roots就是对象,而且是JVM确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 )。

只有找到这种对象,后面的搜索过程才有意义, 不能被回收的对象所依赖的其他对象肯定也不能回收。

可达性算法搜索的过程

202301011503316943.png

JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞(暂停用户线程),也就是STW(stop the world)。

然后JVM要找到所有的GC Roots,这个过程也称作「枚举根节点」。

然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。

GC Roots一般可以是什么样的对象?

GC Roots 是一种特殊的对象,是Java程序在运行过程中所必须的对象,而且是根对象。

作为根肯定是满足一定条件的,使它向上查找不到结果,也就是 具备最大性GC Roots一般是以下对象:

  • 「虚拟机栈」中「局部变量表」中引用的对象

    属于执行上下文中的对象。线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的「局部变量表」中。

    只要方法还在运行,还没出栈,就意味这「局部变量表」的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots

  • 「本地方法栈」中 JNI 中引用的对象

    和上一条本质相同,无非是一个是Java虚拟机栈中的变量引用,一个是「本地方法栈」中的变量引用。

  • 「方法区」中类静态属性引用的对象

    属于全局对象。Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收。

  • 「方法区」中的常量池引用的对象

    属于全局对象。比如字符串常量池,常量本身初始化后就不再改变,因此作为GC Roots也是合理的。

  • 被同步锁持有的对象

    synchronized 锁住的对象不能被回收的。当前有线程持有对象锁,如果GC回收了对象,锁就失效了。

GC Roots 并不包括「堆」中对象所引用的对象,这样就不会有循环引用的问题。

引用类型

什么是引用类型?

在 JDK 1.2 以前,Java 中的引用定义很传统,一个对象只有被引用或者没有被引用两种状态。

但是我们希望能描述这一类对象:

当内存空间还足够时,则保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。 (很多系统的缓存功能都符合这样的应用场景)

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

这也就是我们这里所要介绍的「引用类型」了。

引用类型有什么用?

判定对象是否「存活」和「引用」有关

无论是通过「引用计数算法」判断对象的引用数量,还是通过「可达性分析算法」判断对象是否可达,判定对象是否可被回收都与「引用」有关。

以上所列举的不同的「引用类型」, 主要体现的是对象不同的「可达性状态」和「垃圾收集」的影响

不同引用类型详细分析

  • 强引用

    什么是强引用

    最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似0bject obj=new object()这种引用关系。

    「强引用」的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。

    对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看「垃圾收集策略」。

    强引用是造成Java内存泄漏的主要原因之一。

    强引用关联对象和垃圾回收的关系

    被「强引用」关联的对象 不会被回收。

    创建方式

    new 一个新对象就可以创建「强引用」。

         Object obj = new Object();
  • 软引用

    什么是软引用

    「软引用」用来描述一些还有用,但非必须的对象。

    在系统将要发生内存溢出之前,将会把「软引用」关联对象列入回收范围之中进行第二次回收 。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

    「软引用」通常用来实现内存敏感的缓存。比如: 高速缓存就有用到「软引用」。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

    软引用关联对象和垃圾回收的关系

    被「软引用」关联的对象只有 在内存不够的情况下才会被回收。

    创建方式

    使用 SoftReference 类来创建「软引用」。

         Object obj = new Object();
         SoftReference<Object> sf = new SoftReference<Object>(obj);
         obj = null;  // 消除掉强引用,使obj只被软引用关联
  • 弱引用

    什么弱引用?

    弱引用也是描述那些非必需对象。

    被「弱引用」关联的对象只能生存到下一次垃圾收集之前。 当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被「弱引用」关联的对象。

    由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有「弱引用」的对象。在这种情况下,「弱引用」对象可以存在较长的时间。

    软引用、「弱引用」都非常适合来保存那些可有可无的缓存数据。 当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

    弱引用关联对象和垃圾回收的关系

    被「弱引用」关联的对象 一定会被回收 ,也就是说它只能存活到下一次垃圾回收发生之前。

    创建方式

    使用 WeakReference 类来创建「弱引用」。

         Object obj = new Object();
         WeakReference<Object> wf = new WeakReference<Object>(obj);
         obj = null; // 消除掉强引用,使obj只被弱引用关联
  • 虚引用

    什么是虚引用?

    「虚引用」又称为幽灵引用或者幻影引用。一个对象是否有「虚引用」的存在,不会对其生存时间造成影响,也无法通过「虚引用」得到一个对象。

    虚引用关联对象和垃圾回收的关系

    为一个对象设置「虚引用」的唯一目的是能在这个对象被回收时收到一个系统通知。

    创建方式

    使用 PhantomReference 来创建「虚引用」。

         Object obj = new Object();
         PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
         obj = null; // 消除掉强引用,使obj只被虚引用关联

何时回收?

什么时候触发GC?

什么时候触发GC,以及触发什么类型的GC

不同的垃圾收集器实现不一样,你还可以通过设置参数来影响JVM的决策。

  • 新生代会在Eden区用尽后才会触发GC

  • 老年代不能等Old区用尽才触发GC

    因为有的并发收集器在清理过程中,用户线程可以继续运行,这意味着程序仍然在创建对象、分配内存,这就需要老年代进行「空间分配担保」(新生代放不下的对象会被放入老年代)

    如果老年代的回收速度比对象的创建速度慢,就会导致「分配担保失败」,这时JVM不得不触发Full GC,以此来获取更多的可用内存。

    Full GC的执行效率是比较差的,我们应该尽量的避免它的出现。

这里的介绍我们引入了GC的类型的概念,并且不同的GC的触发机制也不同,于是本章我们将「GC类型」的部分和与GC类型相关度比较高的「内存分配与回收策略」归档在本章。

各种GC

JVM在进行GC时,不是每次都是新生代、老年代、永久代一起回收的,大部分时候回收的都是指新生代。这里的GC的类型实际是针对回收堆内存区域的不同进行的抽象划分,具体的实现还是得看「如何回收」章节。本章先以简单的GC分类开头:

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

  • 部分收集(Partial GC)

    不是完整收集整个Java堆的垃圾收集。其中又分为:

    • 新生代收集(Young GC):

      只是新生代的垃圾收集。

      一般来说就是Minor GC

    • 老年代收集(Old GC):

      只是老年代的垃圾收集。

      只有CMS GC会有单独收集老年代的行为。

      很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。

    • 混合收集(Mixed GC)

      收集整个新生代以及部分老年代的垃圾收集。

      目前,只有G1 GC会有这种行为

  • 整堆收集(Fu1l GC)

    收集整个Java堆的垃圾收集。

Minor GC

什么是Minor GC?

回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

触发条件

Eden 区的空间耗尽了。这个时候 JVM 便会触发一次 Minor GC 来收集新生代的垃圾,存活下来的对象,则会被送到 Survivor 区。

Minor GC 的过程

新生代有两个 Survivor 区,我们分别用 from 和 to来指代。其中 to 指向的 Survivor 区是空的。

当发生 Minor GC时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制(「复制算法」)到 to 指向的 Survivor 区中,然后 交换 from 和 to指针,以保证下一次 Minor GC时,to 指向的 Survivor区还是空的

对象在经过一次复制以后年龄要+1。

Survivor 区对象晋升到老年代对象

JVM会记录 Survivor区中的对象一共被来回复制了几次。 如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升为至老年代 ,(至于为什么是 15次,原因是 HotSpot 会在对象头的中的标记字段里记录年龄,分配到的空间只有4位,所以最多只能记录到15)。另外, 如果单个 Survivor 区已经被占用了 50% (对应虚拟机参数: -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代

Survivor 区对象晋升到老年代对象以后,会导致老年代的占用率升高。

Minor GC过程中,Survivor 可能不足以容纳Eden和另一个Survivor中的存活对象。如果Survivor中的存活对象溢出,多余的对象将被移到老年代,这称为 过早提升(Premature Promotion) 也可以被称为「分配担保机制」。

  • 过早提升的问题

    这会导致老年代中短期存活对象的增长,可能会引发严重的性能问题。

    Minor GC过程中,如果老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,这将导致遍历整个Java堆,这称为 提升失败(Promotion Failure)

Full GC

什么是Full GC?

Full GC就是收集整个堆,包括新生代,老年代,永久代(JDK1.8以前)。

老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

触发条件

  • 调用 System.gc()

    只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

  • 老年代空间不足

    老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

    为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

  • 空间分配担保失败

    使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC

  • JDK 1.7 及以前的永久代空间不足

    在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

    当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError

    为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC

  • Concurrent Mode Failure

    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC

Major GC

什么是Major GC?

还有一个名词是所谓的 Major GC,这个其实一般用的比较少,他是一个非常容易混淆的概念。

Full GC定义是相对明确的。而Major GC是俗称。Major GC通常是跟Full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“Major GC”的时候一定要问清楚他想要指的是上面的Full GC还是Old GC

内存分配与回收策略

  • 对象优先在Eden分配

    大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

  • 大对象直接进入老年代

    大对象是指需要大量连续内存空间的 Java 对象,如字符串或数据。

    一个大对象能够存入 Eden 区的概率比较小,发生「分配担保」的概率比较大,而「分配担保」需要涉及大量的复制,就会造成效率低下。

    JVM提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复制算法)。

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

    JVM 采用了「分代收集」的思想来管理内存,因此内存回收时需要能识别出哪些对象应该放在新生代,哪些对象应该放到老年代中。

    因此JVM 给每个对象定义了一个对象年龄计数器。当新生代发生一次 Minor GC 后,存活下来的对象年龄 +1,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

    使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。

  • 动态对象年龄判定

    如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

  • 空间分配担保

    「空间分配担保」是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

    JDK 1.6 Update 24 之前的规则是这样的:

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

    JDK 1.6 Update 24 之后的规则变为:( 主要还是看这里

    只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小(老年代空间完全可以容纳新生代对象的晋升),就会进行 Minor GC,否则将进行 Full GC

    通过清除老年代中的废弃数据来扩大老年代空闲空间,以便给新生代作担保。

    这个过程就是分配担保。

如何回收?

垃圾收集算法

本章将从分代收集理论开始,以此介绍垃圾收集相关算法,这些算法的执行过程都是GC具体实现

分代收集理论

什么是分代收集理论?

JVM将「堆」划分成不同的代,不同的代中存放的对象特点不一样, 针对不同的代使用不同的GC回收算法进行回收可以提升GC的效率。

目前大多数JVM的「垃圾收集器」都遵循「分代收集」理论,「分代收集理论」建立在三个假说之上:

  • 弱分代假说

    绝大多数对象都是朝生夕死的。

    绝大多数时候,我们创建一个对象,只是为了进行一些业务计算,得到计算结果后这个对象也就没什么用了,即可以被回收了。 再例如:客户端要求返回一个列表数据,服务端从数据库查询后转换成JSON响应给前端后,这个列表的数据就可以被回收了。 诸如此类,都可以被称为「朝生夕死」的对象。

  • 强分代假说

    熬过越多次GC的对象就越难以回收。

    这个假说完全是基于概率学统计来的,经历过多次GC都无法被回收的对象,可以假定它下次GC时仍然无法被回收,因此就没必要高频率的对其进行回收,将其挪到「老年代」,减少回收的频率,让GC去回收效益更高的「新生代」。

  • 跨代引用假说

    「跨代引用」相对于同代引用是极少的。

    这是根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,应该倾向于同时生存或者同时消亡的。

    如果某个新生代对象存在「跨代引用」,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时「跨代引用」也随即被消除了。

解决跨代引用

「跨代引用」虽然极少,但是它还是可能存在的。如果为了极少的「跨代引用」而去扫描整个老年代,那每次GC的开销就太大了,GC的暂停时间会变得难以接受。如果忽略「跨代引用」,会导致新生代的对象被错误的回收,导致程序错误。

一般的解决方式有以下几种:

  • Remembered Set (记忆集)

    JVM通过「记忆集」(Remembered Set)来解决。通过在新生代建立「记忆集」的数据结构,来避免回收新生代时把整个老年代也加进GC Roots的扫描范围,减少GC的开销。

    什么是记忆集?

    「记忆集」是一种由非收集区域指向收集区域的指针集合的抽象数据结构,也就是把「年轻代中被老年代引用的对象」给标记起来。「记忆集」可以有以下三种记录精度:

    • 字长精度:记录精确到一个机器字长,也就是处理器的寻址位数。
    • 对象精度:精确到对象,对象的字段是否存在跨代引用指针。
    • 卡精度:精确到一块内存区域,该区域内的对象是否存在跨代引用。

    什么是卡表、卡页?

    202301011503324024.png

    字长精度和对象精度太精细化了,需要花费大量的内存来维护「记忆集」,因此许多JVM都是采用的「卡精度」,也被称作“卡表”(Card Table)。

    「卡表」是「记忆集」的一种实现,也是目前最常用的一种形式,它定义了记忆集的记录精度、与对内存的映射关系等。

    HotSpot使用一个字节数组来实现「卡表」 ,它将堆空间划分成一系列2次幂大小的内存区域,这个内存区域就被称作「卡页」(Card Page)。

    「卡页」的大小一般都是2的幂次方数,HotSpot采用2的9次幂,即512字节。

    字节数组的每一个元素都对应着一个「卡页」 ,如果某个「卡页」内的对象存在「跨代引用」,JVM就会将这个「卡页」标记为「Dirty」脏的, GC时只需要扫描「脏页」对应的内存区域即可,避免扫描整个堆。

  • 写屏障

    请将这里的「写屏障」和并发编程中内存指令重排序的「写屏障」区分开,避免混淆。

    卡表只是用来标记哪一块内存区域存在跨代引用的数据结构,JVM如何来维护卡表呢?

    HotSpot是通过「写屏障」(Write Barrier)来维护「卡表」的,JVM拦截了「对象属性赋值」这个动作,类似于AOP的切面编程。

    JVM可以在对象属性赋值前后介入处理,赋值前的处理叫作「写前屏障」,赋值后的处理叫作「写后屏障」,伪代码如下:

         void setField(Object o){
             before();//写前屏障
             this.field = o;
             after();//写后屏障
         }
> 什么时候将卡页变脏呢?

开启「写屏障」后,`JVM`会为所有的赋值操作生成相应的指令。

一旦出现老年代对象的引用指向了年轻代的对象,`HotSpot`就会将对应的「卡表」元素置为脏的。

> 伪共享

除了「写屏障」本身的开销外,「卡表」在高并发场景下还面临着「伪共享」的问题。

 *  > 现代计算机CPU缓存和内存的关系
    
    ![202301011503330985.png][]
 *  > Cache Line
    
    现代CPU的缓存系统是以「缓存行」(`Cache Line`)为单位存储的,Intel 的 CPU「缓存行」的大小一般是64字节。
    
    当一行 `Cache Line` 被从内存拷贝到 Cache 里,Cache 里会为这个 `Cache Line` 创建一个条目。这个 Cache 条目里既包含了拷贝的内存数据,即 `Cache Line`,又包含了这行数据在内存里的位置等元数据信息。
    
    **当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主存里面获取该变量,然后把该变量所在内存区域的一个 `Cache Line` 大小的内存复制到 Cache 中。**
    
    由于存放到 `Cache Line` 的是内存块而不是单个变量,所以可能会把多个连续的变量存放到一个 `Cache Line` 中。
    
    **所以通常情况下访问连续存储的数据会比随机访问要快。** (毕竟只需要读集中的内存,块数少)
 *  > 什么是伪共享?
    
    **多线程修改互相独立的变量时,如果这些变量在同一个缓存行中,就会导致彼此的缓存行无故失效** ,线程不得不频繁发起`load`指令重新加载数据,而导致性能降低。
    
     *  > 具体的例子:
        
        一个`Cache Line`是64字节,每个「卡页」是512字节,64\*512字节就是 32KB。
        
        如果不同的线程更新的对象处在这 32KB 之内,就会导致更新「卡表」时正好写入同一个「缓存行」而影响性能。
        
        ![202301011503338816.png][]
        
        如上图,变量 X 和 Y 同时被放到了 CPU 的 L1、L2 以及 L3,一个 Core 一次只能运行一条线程。
        
        当 线程1 使用 Core 1 对变量 X 进行更新时,首先会修改 Core 1 的一级缓存变量 X 所在的「缓存行」,这时候在缓存一致性协议(`MESI`)下,Core 2 中变量 X 对应的「缓存行」失效。那么 线程2 在写入变量 Y 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的 CPU 中相同「缓存行」里面的变量。更坏的情况是,如果 CPU 只有一级缓存,则会导致频繁地访问主内存。
        
        因为缓存与内存交换数据的单位就是「缓存行」,这就造成了造成多个变量被放入了一个「缓存行」中,为了保证缓存数据一致性,根据`MESI`协议,会使其他 Core 相同「缓存行」数据过期,如果多个线程同时去写入缓存行中不同的变量,虽然明面上不同线程读写不同的数据,但是由于数据在同一「缓存行」上,造成缓存频繁失效,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现「伪共享」,有人将其描述成无声的性能杀手。
 *  > 如何解决?
    
    为了避免这个问题,`HotSpot`支持只有当元素未被标记时,才将其置为脏的,这样会增加一次判断,但是可以避免「伪共享」的问题,设置-`XX:+UseCondCardMark`来开启这个判断。

标记-清除算法

什么是标记-清除算法?

202301011503349857.png

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),进行「标记-清除算法」。

「标记清除算法」分成两个过程 :标记、清除:

  • 标记

    遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象「标记」为存活对象(一般是再对象的Header中记录为可达对象)。

  • 清除

    遍历堆中所有的对象,将没有标记的对象全部「清除」掉。与此同时,「清除」那些被标记过的对象的标记(对象Header中没有标记为可达对象),以便下次的垃圾回收。

    同时「清除」不是真的置空,而是把需要清除的对象地址保存在「空闲地址列表」中。下次有新对象需要加载时,判断垃圾对象的位置空间是否够,如果够,就存放。

    • 空闲列表

      202301011503356118.png

      虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式称为:空闲列表(Free List)。

缺点

  • 效率问题( 执行效率不稳定

    标记和清除两个过程的效率都不高。标记和清除的时间消耗随着Java堆中的对象不断增加而增加。

    在进行GC的时候要停止整个应用程序,导致用户体验差。

  • 空间问题( 内存碎片

    标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

什么是复制算法?

202301011503367549.png

为了解决标记-清除算法产生的内存碎片问题,「复制算法」被提出。

在新生代,对常规应用的GC,一次通常可以回收70%~99%的内存空间,回收性价比很高。现在的商业JVM都是用复制算法来回收新生代的。

  • 解决空间问题(复制操作)

    为了解决空间利用率问题,「复制算法」将内存分为三块: EdenFrom SurvivorTo Survivor,比例是 8:1:1。

    每次使用 Eden 和其中一块 Survivor。回收时,将 EdenSurvivor 中还存活的对象一次性「复制」到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。 这样只有 10% 的内存被浪费。

    大部分对象都会再第一次GC时被回收,需要被「复制」的往往是极少数对象。但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行「分配担保」。

    • 分配担保

      「分配担保」可以说是复制操作的保险了。

      为对象分配内存空间时,如果 Eden+Survivor 中空闲区域无法装下该对象,会触发 MinorGC 进行垃圾收集。但如果 Minor GC 过后依然有超过 10% 的对象存活(一个Survivor区装不下),这样存活的对象直接通过「分配担保机制」进入老年代,然后再将新对象存入 Eden 区。

优点

  • 没有标记和清除过程,实现简单,运行高效(复制算法的高效性是建立在存活对象少、垃圾对象多的前提下)
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。
  • 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大(复制较少对象,STW更短)。

缺点

  • 需要两倍的内存空间。(对于存活对象来说)
  • 对于G1这种分拆成为大量regionGC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。

标记-整理算法

什么是标记-整理算法?

复制算法除了在对象大量存活时需要进行较多的复制操作外,还需要额外的内存空间老年代进行分配担保。其次复制算法的高效性是建立在存活对象少、垃圾对象多的前提下。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。

所以在老年代中一般不采用复制算法。

基于老年代垃圾回收的特性,JVM设计者在标记-清除算法的基础上改进并发明了「标记-整理算法」。

2023010115033772610.png

  • 整理操作(压缩操作)

    被标记存活的对象会被移动,将所有存活对象「压缩」到内存的一端,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表少了很多开销。

「标记-整理算法」的最终效果等同于标记-清理算法执行完成后,再进行一次内存碎片整理。但是这两个算法是有本质区别的,标记-清除算法是一种非移动式的算法,「标记-整理算法」是移动式的。

优点

  • 消除了「标记-清除算法」当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,「标记-整理算法」要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。移动过程中,需要全程暂停用户应用程序(STW

三种算法比较

指标 标记-清除(Mark-Sweep) 复制算法(Copying) 标记-整理(Mark-Compact)
速度 中等 最快 最慢
空间开销 少(但会堆积碎片) 通常需要活对象的2倍大小(不堆积碎片) 少(不堆积碎片)
移动对象

分代收集算法

根据「分代收集理论」,几乎所有的JVM都会采用「分代收集算法」——根据对象存活周期将内存划分为几块,不同快采用适当的收集算法。

一般把Java堆分成新生代和老年代:

  • 新生代

    特点

    区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

    使用的垃圾回收算法

    复制算法

    这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于新生代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

  • 老年代

    特点

    区域较大,对象生命周期长、存活率高,回收不及新生代频繁。

    使用的垃圾回收算法

    标记-清除 或者 标记-整理 算法

    这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

    • Mark(标记)阶段的开销与存活对象的数量成正比。
    • Sweep(清除)阶段的开销与所管理区域的大小成正相关。
    • Compact(整理)阶段的开销与存活对象的数据成正比。

增量收集算法

什么是增量收集算法?

在垃圾回收过程中,应用软件将进入STW的状态。在STW状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。

为了解决这个问题, 「增量收集算法」让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。

「增量收集算法」通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码, 所以能减少系统的停顿时间。 但是因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

小结

本篇文章我们介绍了垃圾回收机制的相关概念,和这个知识点关联比较紧密的是垃圾收集器,关于垃圾收集器的内容我将专门分出一篇博客来介绍。

本文参考: