JVM垃圾回收

前述

  • 目前主流虚拟机使用准确式内存管理(Exact Memory Management)。准确式内存管理是指虚拟机可以知道内存中某个位 置的数据具体是什么类型。譬如内存中有一个32bit的整数123456,虚拟机可以分辨出它到底是一 个指向了123456的内存地址的引用类型还是一个数值为123456的整数。准确式内存管理的虚拟机(Exact VM)访问对象可以使用直接指针的方式而不用使用基于句柄的二次转发,提高用户程序访问对象的性能。因为垃圾回收时如果移动对象,Exact VM可以在内存中找到引用这个对象的所有引用类型,然后修改它们指向对象新的地址。不使用准确式内存管理的虚拟机无法这么做,因此只能使用基于句柄的的方式。
  • 程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。
  • 需要注意堆内存太大,回收一次的时间就会变长,导致用户线程的停顿时间变长,因此并不是堆越大越好。

如何判断对象已死

引用计数法

Redis就是采用这种方法(RedisObject有个ref_count成员)。在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的。引用计数法很难解决对象之间相互循环引用的问题。

可达性分析算法

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

Java中可固定可作为GC Roots的对象包括以下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
    • 方法区中的常量、静态变量和类都可能被卸载,所以这些并不一定总是GC Roots
  4. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
    • 如果某些对象(特别是Class对象)根本不会引用其他对象,那也可以不用作为GC Roots
  5. 所有被同步锁(synchronized关键字)持有的对象。
    • 如果一个对象被synchronized锁住而没有释放,则这个对象一定不会被回收

需要注意的是,根据当前回收的内存区域不同,可以有其他对象“临时性”地加入GC Roots集合。比如只针对新生代进行垃圾回收,需要把堆中新生代以外区域的对象(比如老年代的对象)加入到GC Roots中。

可达性分析性能分析

可达性分析算法分为两步

  1. 根节点枚举
    根结点(GC Roots)枚举目前需要在一致性的环境中进行,因此需要停顿。包括CMS,G1,ZGC目前都需要STW(stop the world)。

    • 固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文中(例如 栈帧中的本地变量表)。为了快速找到这些内存区域内的引用,加快根节点枚举的速度,HotSpot 采取了空间换时间的方法,使用 OopMap 来存储对象引用的信息。OopMap 会记录栈和寄存器里哪些位置是引用,还会记录对象的引用类型成员变量的内存地址。在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap,将其中的引用类型指向的对象加入GC Roots。所以目前根节点枚举很快且几乎与堆容量无关。参考 https://zhuanlan.zhihu.com/p/441867302
    • 但是在方法执行的过程中,对象随时可以生成,对象之间的引用关系随时会发生改变,目前并不是同步维护OopMap 里面的数据的,而是到安全点(safe point) 才会维护和更新OopMap。当需要中断用户线程做垃圾收集时,JVM不能随意中断用户线程,而是设置一个标识位,用户线程执行时会主动轮询这个标志位,发现标志为真时就主动挂起。等所有用户线程都在安全点后,垃圾收集器才开始工作,并维护OopMap。安全点位置的选取原则是避免用户程序长时间的执行而让垃圾收集器等待时间过长,比如安全点会放置在长循环回跳前,方法返回前。
    • 当线程处于 Sleep 状态或者 Blocked 状态,线程没有分配到 CPU,就无法通过轮询标志位达到 Safe Point。因此引入了安全区域 Safe Region,Safe Region 是一片区域,在这个区域的代码片段,引用关系不会发生变化,在 Safe Region 中任意地方开始垃圾收集都是安全的。Safe Region 就是 Safe Point 的扩展。当用户线程执行到安全区域时,首先会标识自己进入了安全区域,当虚拟机要发起垃圾收集时就不必管已声明在安全区域内的用户线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。如果线程block时不在安全区域,则需要垃圾收集时,其他已经停止的用户线程和虚拟机线程都要等待这个线程进入安全区域,这会导致垃圾收集停顿时间较长。
  2. 可达性分析找到存活对象。
    这个过程很耗时且和堆容量大小正相关,理论上需要STW,不能在分析过程中还修改对象之间的引用关系。目前JVM实现了可达性分析线程可以和用户线程并发执行,方法是在可达性分析过程中使用写屏障,将用户线程对引用关系的修改记录下来,等分析完成后再对修改的部分重新扫描处理一遍。

Java引用类型

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只有当内存空间不足了,才会回收软引用对象。当内存不足,会触发JVM的GC,如果GC后,内存还是不足,就会把软引用的包裹的对象给干掉,也就是只有在内存不足,JVM才会回收该对象。JVM保证会在虚拟机抛出OutOfMemoryError之前回收软引用对象。
    • All soft references to softly-reachable objects are guaranteed to have been cleared before the virtual machine throws an OutOfMemoryError. Otherwise no constraints are placed upon the time at which a soft reference will be cleared or the order in which a set of such references to different objects will be cleared. Virtual machine implementations are, however, encouraged to bias against clearing recently-created or recently-used soft references.
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK提供WeakReference类来实现弱引用。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,JDK提供的 PhantomReference 的get方法总是返回null。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

参考 https://www.cnblogs.com/CodeBear/p/12447554.html

SoftReference<byte[]> softReference = new SoftReference<byte[]>(new byte[1024*1024*10]);
System.out.println(softReference.get());
System.gc();
System.out.println(softReference.get());

byte[] bytes = new byte[1024 * 1024 * 10];
System.out.println(softReference.get());
/* 定义了一个软引用对象,里面包裹了byte[],byte[]占用了10M,然后又创建了10Mbyte[]。
运行程序,需要带上一个参数:-Xmx20M  代表最大堆内存是20M。
运行结果:
[B@11d7fff
[B@11d7fff
null  

可以很清楚的看到手动完成GC后,软引用对象包裹的byte[]还活的好好的,但是当我们创建了一个10M的byte[]后,
最大堆内存不够了,所以把软引用对象包裹的byte[]给干掉了,如果不干掉,就会抛出OOM。
软引用到底有什么用呢?比较适合用作缓存,当内存足够,可以正常的拿到缓存,当内存不够,就会先干掉缓存,不至于马上抛出OOM。
*/
WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1]);
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());

/*
运行结果:
[B@11d7fff
null

可以很清楚的看到明明内存还很充足,但是触发了GC,资源还是被回收了。
弱引用的特点是不管内存是否足够,只要发生GC,都会被回收
弱引用在很多地方都有用到,比如ThreadLocal、WeakHashMap。
*/

Object::finalize()方法

当一个对象被可达性分析算法判定为GC Roots不可达后,如果这个对象没有覆盖Object::finalize()方法,则这个对象会被直接回收。
如果一个对象覆盖了Object::finalize()方法,则JVM会调用一次对象的finalize()方法,并在调用后再判断该对象是否可达:如果还是不可达,则回收该对象。如果可达,则本次不回收该对象,但下次该对象不可达时则不再调用该对象的finalize方法。

虚拟机保证会调用对象的finalize方法有且仅有一次,但并没有承诺一定会等待该方法运行结束。因此该方法不推荐使用。

垃圾收集算法

分代收集理论

将Java堆划分出不同的区域,一般是新生代(Young Generation)和老年代(Old Generation)两个区域。然后将对象依据其年龄(年龄即对象熬过垃圾收集的次数)分配到不同的区域之中集中存储。这样垃圾收集器可以每次只回收其中部分区域,因而才有了“Minor GC”“Full GC”这样的回收类型的划分;也能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制”“标记-清除”“标记-整理”等针对性的垃圾收集算法。

术语定义:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。 Young GC的频率较高。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。“Major GC”这个说法现在有点混淆,需要区分是指老年代的收集还是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收 集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。目前没有单独针对永久代或方法区的垃圾收集,当永久代满了会触发Full GC。

新生代默认占总空间1/3,老生代默认占 2/3。 新生代使用的是标记复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。新对象是在Eden区分配的(因此线程的本地线程分配缓冲TLAB也在Eden区),但大对象会直接进入老年代,避免在Eden与Survivor区之间来回复制大对象产生高额复制开销。

当Eden区满了会触发Young GC,采用标记复制算法清理:

  • 把 Eden + From Survivor 的存活对象放入 To Survivor(如果 To Survivor区放不下会将多余的对象直接加入老年代),并将这些存活对象的年龄 +1,年龄超过阈值的存活对象移入老生代。如果老年代放不下,触发Old GC(很多资料是直接触发Full GC)。触发Old GC后老年代还是放不下,触发Full GC。
    Full GC可以清理掉存在跨代引用关系的对象,比如对象A、B分别在新生代和老年代且相互引用,当单独清理新生代或者老年代时,对象A、B都无法被清楚,但Full GC可以清除对象A、B。但是一般存在跨代引用关系的对象最终都会进入老年代,然后在Old GC时一起被清理掉。
  • 清空 Eden 和 From Survivor;
  • 交换 From Survivor 和 To Survivor;

基本的垃圾收集算法

标记清除算法(不适合新生代,适合老年代)

标记可回收对象,清除可回收的对象。也可以反过来,标记存活对象,清除可回收对象。

优点:不移动元素
缺点:容易产生不连续的内存碎片,提高了垃圾回收的频率,需要使用空闲链表记录可用空间,新对象的分配比较耗时;如果可回收的对象太多,这个算法很耗时。

标记复制算法(适合新生代,不适合老年代)

标记存活对象,复制存活对象。
将内存分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将存活对象复制到另外一块,然后把已使用过的内存空间清理掉。

优点:没有内存碎片。新对象的分配简单
缺点:1、需要移动元素,移动元素需要解决指针的重定向问题(直接指针 VS 句柄二次转发); 2、可用内存缩小为原来的一半,存在空间浪费问题(可以不按照1:1分配空间)。

标记整理算法(理论上适合新生代,不适合老年代,但老年代常采用)

标记可回收对象,整理存活对象
让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

优点:没有内存碎片。新对象的分配简单
缺点:1、需要移动元素,移动元素需要解决指针的重定向问题(直接指针 VS 句柄二次转发);

标记整理与标记复制相似,但标记整理不浪费内存空间,感觉可以替代标记复制。

新生代回收器:Serial、ParNew、Parallel Scavenge,都采用标记复制算法。
老年代回收器:Serial Old、Parallel Old、CMS、G1,除了CMS采用标记清除,其余采用标记整理。

新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

HotSpot的算法细节

根节点枚举
安全点
安全区域

记忆集与卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在针对部分区域做垃圾收集时,记忆集可以避免将太多对象加入GC Roots而导致可达性分析耗时太久。记忆集有多种精度,最常用的是卡精度:只需判断出某一块非收集区域是否存在指向了收集区域的指针,并不需要了解这些跨区域或跨代指针的详细信息。一般用卡表来实现卡精度。HotSpot虚拟机默认的卡表实现如下:

// CARD_TABLE 是字节数组,之所以使用byte数组而不是bit数组主要是速度上的考量
// 现代计算机最小是按字节寻址的,没有直接存储一个bit的指令,所以要用bit的话就不得不多消耗几条shift+mask指令
CARD_TABLE [address >> 9] = 0;
// 2的9次幂即512字节,一个卡表元素对应非收集区域512字节的内存空间

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能得出哪些卡页内存块中包含跨代指针,虚拟机再借助OopMap快速找到这个区域内的引用指向的对象,把它 们加入GC Roots中一并扫描。

写屏障

写屏障用在两个地方,实时更新卡表和并发的可达性分析中记录用户线程改变对象引用关系情况。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面 ,在对“引用类型字段赋值”时会产生一个环形(Around)通知,供程序执行额外的动作。写屏障可以分为写前屏障和写后屏障,虚拟机(包括CMS和G1)主要使用写后屏障,通过指令实时更新卡表,这会有一定的开销。

并发的可达性分析

经典垃圾收集器

使用最多的是 CMS 和 G1 收集器,二者都有分代的概念。

CMS

CMS是老年代收集器,需要和新生代收集器ParNew收集器配合。ParNew收集器采用多个线程,使用标记-复制算法清理新生代,清理过程中需要STW。

CMS收集器是以牺牲吞吐量为代价,获取最短回收停顿时间为目标的低延迟收集器。CMS收集器是基于标记-清除算法实现的,运作过程可分为四个步骤:

  1. 初始标记(STW)
    初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

  2. 并发标记
    并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发运行;

  3. 重新标记(STW)
    重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短

  4. 并发清除
    并发清除阶段会删除标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发的。这个阶段用户线程产生的垃圾(浮动垃圾)只能留到下一次垃圾回收时清理

CMS使用卡表记录老年代中的哪些区域有跨代指针指向新生代,在回收新生代时会使用卡表信息。但回收老年代没有使用卡表,而是把整个新生代都加入GC Roots,因为老年代内存区域一般比新生代大,一般是2倍,所以在老年代收集时使用卡表收益不明显。

整个过程中耗时最长的并发标记和并发清除阶段,垃圾收集器线程都可以与用户线程一起工作,所以总体可以说,CMS收集器的内存回收过程是与用户线程一起并发执行的。但CMS仍存在以下问题:

  1. 由于垃圾收集线程和用户线程同时工作,垃圾收集线程会占用CPU资源影响用户线程
    • 其实这是不可避免的,也是其他垃圾收集器的通病。
  2. 并发标记和并发清除阶段用户线程还在运行,需要预留足够内存空间供用户线程使用,因此CMS不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,而需要提前开始垃圾收集,这样可以预留空间。如果预留的内存无法满足用户程序分配新对象的需要,这时虚拟机会冻结用户线程的执行,启用Serial Old收集器来重新进行老年代的垃圾收集, 导致出现“Stop The World”的Full GC。
    • Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法清理老年代,收集过程中需要STW。
    • 其实这是不可避免的,也是其他并发收集垃圾收集器的通病。
  3. CMS使用的标记清除算法会导致内存空间碎片,空间碎片过多时,会出现老年代有很多剩余空间,但无法找到足够大的连续空间来分配当前的大对象,导致需要提前触发一次Full GC。因此CMS在Full GC时会进行内存碎片的合并整理过程,由于内存整理需要移动存活对象,(在Shenandoah和ZGC出现前)是无法和用户线程并发的,需要暂停用户线程,导致停顿时间变长。
    • 为了分配得更快,CMS设计了一个叫作Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块缓冲区后,在缓冲区内可以使用指针碰撞方式来将大片内存通过多次分配给若干个小对象。
G1

G1开创了面向局部收集和基于Region的堆内存布局形式。G1建立起"停顿时间模型",意思是在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒,用以控制垃圾收集器占用的时间和引起的停顿。在G1之前的收集器,包括CMS在内,垃圾收集的目标范围是整个新生代或老年代,或整个Java堆(Full GC),且新生代和老年代的大小是固定的,不会针对部分区域进行收集。

虽然G1也仍是遵循分代收集理论设计的,仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的,G1不再坚持固定大小的分代区域划分,每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。Region作为单次回收的最小单元,收集器能够对扮演不同角色的 Region采用不同的策略去处理。而G1可以对任意Region组成的回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这样可避免对Java堆进行全区域的垃圾收集,这就是G1的Mixed GC模式。

G1会跟踪各个Region的价值,价值即回收所获得的空间以及回收所需的时间,然后在后台维护一个优先级列表,每次根据用户设定的最大收集停顿时间(参数-XX:MaxGCPauseMillis指定,默认200毫秒),优先处理回收价值最大的那些Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1能在不超过期望停顿时间的约束下获得尽可能高的收集收益。

Region中有一类特殊的Humongous区域用来存储大对象。大小超过Region容量一半的对象是大对象。对于超过整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来处理。

G1实现
G1的每个Region(不管是新生代还是老年代)都维护有自己的记忆集,这个记忆集会记录下别的Region 是否包含指向自己的指针,并标记这些跨Region指针分别在这个Region下的哪些卡页(一个Region分为多个卡页)。记忆集是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡页的索引号。由于Region数量比传统收集器的分代数量更多,因此G1更消耗内存。G1一般要消耗堆容量的10%至20%来维持收集器工作。G1垃圾收集器的内存占用和CPU消耗逗比CMS要高。

G1与CMS一样,在并发的可达性分析过程中需要记录对象引用关系的变化,在并发收集过程中需要预留内存空间供用户线程使用。G1的每个Region内都要预留出一部分空间用于并发回收过程中新对象的分配。与CMS一样,如果预留空间不够,或者内存回收的速度赶不上内存分配的速度,会出现“Stop The World”的Full GC。

与CMS使用的新生代收集器ParNew一样, G1的young GC需要STW,然后使用标记复制算法清理新生代的Region。

G1运作过程可分为四个步骤:

  1. 初始标记(STW)
    仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  2. 并发标记
    并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图做可达性分析,这个过程耗时较长但是不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发运行;
  3. 最终标记(STW)
    重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
  4. 筛选回收(部分STW)
    负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,计算由哪些Region组成回收集才可以在不超过用户期望的停顿时间的约束下获得最高的收益。然后类似标记整理算法,把回收集Region中的存活对象复制到空的Region中(这里需要有空Region),再清理回收集的全部空间。这里需要移动存活对象,必须暂停用户线程,移动对象意味着G1不会产生内存碎片,能提供一片规整的可用内存,这种特性有利于程序长时间运行和提高吞吐量。
    最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了对象移动与用户线程的并发执行,因此其内存整理过程不需要STW。

G1其余步骤与CMS类似,但最后一步G1是标记复制,需要移动对象,所以会stop the world,但CMS最后一步是标记清除,不需要移动对象,所以CMS最后一个不需要stop the world。G1收集器除了并发标记阶段外,其余阶段是要完全暂停用户线程的, 因此G1并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量(吞吐量 = 运行用户代码时间/ (运行用户代码时间 + 运行垃圾收集时间),一般停顿时间越低,吞吐量也低)。考虑到G1只是回收一部分Region,停顿时间是用户可控的,停顿用户线程能够最大幅度提高垃圾收集效率,为了提高吞吐量才选择完全暂停用户线程的实现方案。

图中浅色表示必须挂起用户线程,深色表示垃圾收集器线程与用户线程是并发工作。从图中可以看出:
1、G1最终标记阶段的耗时和停顿相比CMS很少。G1的Compact阶段大部分是可以和用户线程并发的,但内存整理涉及到对象移动,是需要少量的停顿的。
2、young GC都是要stop the world the world
3、Shenandoah和ZGC,几乎整个工作过程都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。

posted @ 2023-02-11 23:31  zoo-keeper  阅读(44)  评论(0)    收藏  举报