ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

垃圾回收机制具体是如何执行的?

2022-07-06 20:05:31  阅读:104  来源: 互联网

标签:Java 对象 虚拟机 回收 GC 垃圾 机制 内存


Java虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。不过既然是自动机制,肯定没法做到像手动回收那般精准高效,而且还会带来不少与垃圾回收实现相关的问题。

引用计数法与可达性分析

在Java虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?

引用计数法(reference counting):它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了

缺点:

  • 需要额外的空间来存储计数器,以及繁琐的更新操作

  • 无法处理循环引用对象

MyObject myObject1 = newMyObject();  //object1为 MyObject1的第一次引用 ,引用+1
MyObject myObject2 = newMyObject();  //object2为 MyObject2的第一次引用,引用+1

myObject1.ref = myObject2;  //object1.ref 为 MyObject2的第二次引用,引用+1
myObject2.ref = myObject1;  //object2.ref 为 MyObject1的第二次引用,引用+1

myObject1 = null;  //MyObject1对象的引用-1
myObject2 = null;  //MyObject2对象的引用-1

但是myObject1和 myObject2这俩个对象仍然还有一次引用(引用不是0)垃圾回收器就无法回收他们,就出现了循环引用而导致的内存泄漏问题

可达性分析算法:目前Java虚拟机的主流垃圾回收器,通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象不可用

img

上图中reference1 reference2 reference3都属于GC Roots,堆中对象实例3和5虽然互相引用但是没有任何GC Roots引用链 也就造成了不可达最终就会当做垃圾回收掉

GC Roots我们可以暂时理解为由堆外指向堆内的引用,GC Roots包括(但不限于)如下几种:

  • Java方法栈桢中的局部变量;

  • 已加载类的静态变量;

  • JNI handles;

  • 已启动且未停止的Java线程。

Stop-the-world以及安全点

在Java虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

Java虚拟机中的Stop-the-world是通过安全点(safepoint)机制来实现的。当Java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作

安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析

举个例子,当Java程序通过JNI执行本地代码时,如果这段代码不访问Java对象、调用Java方法或者返回至原Java方法,那么Java虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。

只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于Java虚拟机线程调度器的掌控之下,因此属于安全点。

其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间

垃圾回收的三种方式

第一种标记清除法:

标记:垃圾回收器此时会找出内存哪些在使用中,哪些不是。垃圾回收器要检查完所有的对象,才能知道哪些有被引用,哪些没。如果系统里所有的对象都要检查,那这一步可能会相当耗时间。

清除:会把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象

图片

  • 优点:标记清除算法的特点就是简单直接,速度也非常块,特别适合可回收对象不多的场景

  • 缺点:

    • 会造成不连续的内存空间,空间碎片会导致后面的GC频率增加

    • 性能不稳定,内存中需要回收的对象,当内存中大量对象都是需要回收的时候,通常这些对象可能比较分散,所以清除的过程会比较耗时,这个时候清理的速度就会比较慢了。

第二种标记压缩法:同样分为两个阶段,第一阶段标记,第二阶段即把存活的对象聚集到内存区域的起始位置,再清理掉边界以外的死亡对象,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

图片

  • 优点:解决内存碎片化的问题 适合存活对象多的场景

  • 缺点:是三种之中性能最低的一种,因为标记压缩法在移动对象的时候不仅需要移动对象,还要额外的维护对象的引用的地址,这个过程可能要对内存经过几次的扫描定位才能完成,做的事情越多那么必然消耗的时间也越多

第三种复制法:即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题。(下面的文章有详细介绍)

  • 优点:每次清除针对的都是一整块内存,所以清除可回收对象的效率也比较高。解决内存碎片化

  • 缺点:

    • 堆空间的使用效率极其低下

    • 存活对象多会非常耗时,因为复制移动对象的过程比较耗时 + 对象复制移动后需要维护对象的引用地址

    • 需要担保机制,因为复制区总会有一块空的空间浪费而为了减少浪费空间太多,所以我们会把复制区的空间分配控制在很小的区间,但是空间太小又会产生一个问题,就是在存活的对象比较多的时候,这时复制区的空间可能不够容纳这些对象,这时就需要借一些空间来保证容纳这些对象,这种从其他地方借内存的方式我们称它为担保机制

当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点

对象存活时间统计

图片

(pmd中Java对象生命周期的直方图,红色的表示被逃逸分析优化掉的对象)

许多研究人员的假设:即大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。

之所以要提到这个假设,是因为它造就了Java虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

Java虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。

这时候,Java虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)

Java虚拟机的堆划分

Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。

默认情况下,Java虚拟机采取的是一种动态分配的策略(对应Java虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。

当然,你也可以通过参数-XX:SurvivorRatio来固定这个比例。但是需要注意的是,其中一个Survivor区会一直为空,因此比例越低浪费的堆空间将越高。

通常来说,当我们调用new指令时,它会在Eden区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的,否则,将有可能出现两个对象共用一段内存的事故。

  • 新生代:一旦新生代内存满了,就会开始对死掉的对象,进行所谓的小型垃圾回收(Minor GC)过程。一片新生代内存里,死掉的越多,回收过程就越快;至于那些还活着的对象,根据所设置的阈值,并最终老到进入老年代内存。

  • 老年代:用来保存长时间存活的对象。通常,设置一个阈值,当达到该年龄时,年轻代对象会被移动到老年代。最终老年代也会被回收。这个事件为 Major GC。

  • 永久代:JDK8 的时候已经被彻底移除,取而代之的是元空间。永久代包含JVM用于描述应用程序中类和方法的元数据。永久代是由JVM在运行时根据应用程序使用的类来填充的。此外,Java SE类库和方法也存储在这里。

Major GC 也会触发STW(Stop the World)。通常,Major GC会慢很多,因为它涉及到所有存活对象。所以,对于响应性的应用程序,应该尽量避免Major GC。还要注意,Major GC的STW的时长受年老代垃圾回收器类型的影响。

MinorGC过程

首先,将任何新对象分配给 eden 空间。两个 survivor 空间都是空的。

当 eden 空间填满时,会触发轻微的垃圾收集

引用的对象被移动到第一个 survivor 空间。清除 eden 空间时,将删除未引用的对象

在下一次Minor GC中,Eden区也会做同样的操作。删除未被引用的对象,并将被引用的对象移动到Survivor区。然而,这里,他们被移动到了第二个Survivor区(S1)。

此外,第一个Survivor区(S0)中,在上一次Minor GC幸存的对象,会增加年龄,并被移动到S1中。待所有幸存对象都被移动到S1后,S0和Eden区都会被清空。注意,Survivor区中有了不同年龄的对象。

在下一次Minor GC中,会重复同样的操作。不过,这一次Survivor区会交换。被引用的对象移动到S0,。幸存的对象增加年龄。Eden区和S1被清空。

此幻灯片演示了 promotion。在较小的GC之后,当老化的物体达到一定的年龄阈值(在该示例中为8)时,它们从年轻一代晋升到老一代。

随着较小的GC持续发生,物体将继续被推广到老一代空间。

所以这几乎涵盖了年轻一代的整个过程。最终,将主要对老一代进行GC,清理并最终压缩该空间。

知识拓展

为什么需要Survivor区

Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生

Survivor为什么设置为两个

如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案

老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代

  • 大对象,指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。

  • 长期存活对象,虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。当然,这里的 15,JVM 也支持进行特殊设置。

  • 动态对象年龄,虚拟机并不重视要求对象年龄必须到 15 岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等你“成年”。

标签:Java,对象,虚拟机,回收,GC,垃圾,机制,内存
来源: https://www.cnblogs.com/caomaoi/p/16452292.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有