Java-第十四部分-JVM-垃圾回收概述、标记阶段和清除阶段

JVM全文

概述

  • Lisp,第一门开始使用内存动态分配和垃圾收集技术的语言
  • 垃圾,在运行程序中没有任何指针指向的对象,这个对象就是要被回收的垃圾

如果不及时清理内存中的垃圾,这些垃圾对象所占的内存空间一直保存在应用程序结束,无法被其他对象使用,导致内存溢出

  • 为什么需要GC
  1. 如果不进行垃圾回收,内存迟早都会消耗完;
  2. 除了释放没用的对象,垃圾回收也可以清楚内存里的记录碎片,碎片整理将占用的内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
  3. 应付越来越庞大、复杂的业务需求,没有GC就不能保证应用程序的正常进行,但是经常造成STW的GC跟不上实际需求,需要不断堆GC优化
  • 内存泄漏,逻辑上并不需要这个对象了,但是这个对象根据可达性分析,仍在引用链中,被其他对象引用着

早期垃圾回收

  • 手工进行
  • new内存申请
  • delete内存释放
  • 灵活控制内存释放的时间,但存在频繁申请和释放内存的管理负担,容易由于个人原因,导致内存泄漏(对象没有被引用,但是没有被回收),垃圾对象就永远无法被清除,随着系统运行时间,垃圾对象所耗内存持续上升,直到内存溢出并造成程序崩溃

Java回收机制

  • 自动内存管理,降低内存泄漏和内存溢出的风险

没有垃圾回收,会出现悬垂指针、野指针、泄漏等问题

  • 针对方法区和堆空间
  1. 频繁收集年轻代
  2. 较少收集老年代
  3. 基本不动方法区

标记阶段算法

  • 对象存活判断
  1. 在GC执行垃圾之前,首先需要区分出内存中哪些是存活对象,哪些事已经死亡的对象
  2. 只有被标记为已经死亡的对象,GC才会执行垃圾回收,释放掉其所占空间
  • 一般有两种方式,引用计数算法和可达性分析算法

引用计数算法

  • Java没有选择这个算法
  • 对每一个对象都保存一个整型的引用计数器属性,用于记录对象被引用的情况
  • 具体过程
  1. 对于一个对象A,只要有任何一个对象引用了A,A的引用计数器+1
  2. 当引用失效,引用计数器-1
  3. 只要对象A的引用计数器值==0,表示A不可能再被使用,可进行回收,提高吞吐量
  • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟
  • 缺点
  1. 需要单独的字段存储计数器,增加了存储空间的开销
  2. 每次赋值都需要更新计数器,伴随加法和减法,增加时间开销
  3. 无法处理循环引用的情况
  • python/OC 使用了引用计数器的标记算法
  1. 手动解除
  2. 使用弱引用,weakref/weakSelf

可达性分析算法

  • java/C#
  • 根搜索算法、追踪性垃圾收集
  • 解决引用计数器中循环引用的问题,防止内存泄漏
  1. 以根对象集合(一组必须活跃的引用),按照从上至下的方式搜索根对象集合所连接的目标对象是否可达
  2. 内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称为引用链
  3. 如果目标对象没有任何引用链相连,则不可达,意味着该对象已经死亡,标记为垃圾对象
  4. 只有能够被根对象集合直接或者间接连接的对象才是存活对象
  • GC Roots,采用栈方式存放变量和指针,一个指针保存对内存里面的对象,但又不存放在堆内存中,就是一个root
  1. 虚拟机栈中引用对象,局部变量表中的引用,各个线程被调用的方法中使用到的参数、局部变量
  2. 本地方法栈内JNI Java Native Interface(本地方法)引用对象
  3. 堆空间的类静态属性引用的对象,引用类型静态变量
  4. 字符串常量池中的引用
  5. 被同步锁持有的对象
  6. 虚拟机内部引用,基本数据类型对应的包装类,一些常驻的异常对象(NullPointerException、OutOfMemoryError),系统类加载器
  7. 反映java虚拟机内部情况的JMXBean(java程序管理)、JVMTI(虚拟机管理)中的注册回调、本地代码缓存
  8. 针对回收的区域不同,有其他对象临时性的加入,当对新生代进行局部回收Partial GC,老年代的一些对象,也会指向新生代的对象,将这个关联的区域计入GC Roots集合中
  • 分析工作必须在能保障一致性的快照中进行,也就导致java进行GC必须发生Stop The World 停下用户线程;CMS收集器(第一个低延迟垃圾收集器,并发执行),枚举根节点是也必须要停顿

对象finalization机制

  • 允许开发人员提供对象被销毁之前的自定义处理逻辑
  • 当垃圾回收器发现没有引用指向一个对象时,在回收这个对象时,会调用finalize(),jdk9之后被弃用,不推荐手动实现,出现java.lang.ref.Cleaner和java.lang.ref.PhantomReference,但是底层还是会出现finalizer
  • finalize()运行被子类重写,用于对象被回收时进行资源释放,如关闭文件、套接字和数据库连接等
  1. 永远不要主动调用某个对象的finalize(),完全交给垃圾回收机制调用
  2. 可能会导致对象复活
  3. 执行的时间没有保障,完全由GC线程决定,极端条件下,不发生GC,finalize()没有执行机会,可能出现的情况是在我们耗尽资源之前,gc却仍未触发
  4. 实现的糟糕,严重影响GC性能
  5. 与析构函数比较相似,但是java中采用的基于垃圾回收器的自动内存管理机制,本质上不同
  • 如果finalize()存在,对象可能有三种状态
  1. 可触及的,从根节点,可以到达这个对象
  2. 可复活的,对象的所有引用都被释放,有可能在finalize()中复活
  3. 不可触及的,对象的finalize()被调用,且没有被复活;不可能被复活,只能被调用一次
  • 存在finalize()的对象判定至少要经历两次标记过程
  1. 对象obj到GCRoots没有引用链,进行第一次标记
  2. 进行筛选,判断obj是否有必要执行finalize()
  3. 如果没有重写finalize(),或者已经被虚拟机调用过,则为不可触及的
  4. 如果重写finalize(),且还未执行,obj就会被插入到F-Queue(java.lang.ref.Finalizer.ReferenceQueue)队列中,由一个虚拟机自动创建的、低优先级的Finalize线程触发发其finalize()执行
  5. GC会对F-Queue中的对象进行第二次标记,如果obj在finalize()与引用链上任何一个对象建立了联系,那么第二次标记时,obj就会被移出即将回收集合。当对象会再次出现没有引用存在的情况,而这个情况下,finalize()不会被调用,对象直接变成不可触及的状态
  6. 一个对象的finalize()只会被调用一次
  • finalize()的缺点
  1. finalize机制本身就是存在问题的。
  2. finalize机制可能会导致性能问题,死锁和线程挂起。
  3. finalize中的错误可能导致内存泄漏;如果不在需要时,也没有办法取消垃圾回收;并且没有指定不同执行finalize对象的执行顺序。此外,没有办法保证finlize的执行时间。
  • cleaner,有自己的线程,在所有清理操作完成后,自己会被GC
  1. 需要实现AutoCloseable接口,这样在重写的close()方法中释放资源会被自动调用回收。
  2. cleaner机制需要创建单独的线程去执行逻辑,这与finalize机制不同。
  3. 执行finalize机制的线程不可控,所以cleaner机制不存在类似于先执行finalize逻辑在回收对象的问题,即只要执行了cleaner机制就不会降低垃圾回收效率。
  4. 但是前提是执行了cleaner机制,因为它的clean()方法还是写在重写的close()方法中等待被自动调用,所以无法保证保证被及时执行。
  • cleaner实现
  1. 被监听的类需要实现Runnable接口
  2. 创建一个Cleaning,将类注册到里面去
  3. 主线程中需要初始化 Cleaning类
//类中 
public class Person implements Runnable{
    public Person() {
        System.out.println("Person was Born");
    }
    @Override
    public void run() {
        //释放资源...
        System.out.println("Person is Dead");
    }
}
class PersonCleaning implements AutoCloseable {
    //创建清洁处理
    private static final Cleaner cleaner  = Cleaner.create();
    privete Person person;
    private Cleaner.Cleanable cleanable;

    public PersonCleaning() {
        this.person = new Person();
        //注册使用的对象
        this.cleanable = this.cleaner.register(this, this.person);
    }
    @Override
    public void close() throws Exception {
        //启动多线程
        this.cleanable.clean();
    }
}

//调用
public class CleanerTest {
    public static void main(String[] args) {
        try (PersonCleaning p = new PersonCleaning()){
            System.out.println("start");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
复制代码

MAT和JProfiler查看GCRoots

  • dump文件,进程的内存镜像,可以把程序的执行状态通过调试器保存到dump文件中
  • visual vm生成dump文件
  • mat查看

image.png

  • 底层还是有 finalizer

image.png

  • GCRoot溯源,找到导致内存泄漏的对象的GCRoot
  • JProfiler进行GC溯源
  1. live memory->选中要查的类型右键show selection in heap walker
  2. Reference->Incoming references被引用的引用链->Show Pahts To GC Root

image.png

  • JProfile分析OOM
  • -XX:+HeapDumpOnOutOfMemoryError 当出现内存错误的时候,输出堆空间的dump文件
  • 出现的超大对象

image.png

  • 出现问题的线程和代码

image.png

清除阶段算法

  • 实践上,前沿GC都是复合算法,并行和并发兼备
  • 当成功区分出存活对象和死亡对象后,接下来GC要执行垃圾回收,释放掉无用对象所占用的空间
  1. 标记-清除算法 Mark-Sweep
  2. 复制算法 Copying
  3. 标记-压缩算法 Mark-Compact

标记-清除算法

  • 最开始被应用于lisp语言,比较基础和常见
  • 执行过程
  1. 当堆中有效空间被耗尽,会停止整个程序STW Stop The World
  2. 标记,Collector从引用根节点开始便利,标记所有被引用的对象,在对象的Header中记录为可达对象
  3. 清除,Collector对堆内存从头到尾进行线性便利,如果发现某个对象的Header没有标记为可达对象,将其回收
  • 缺点
  1. 用递归的方式遍历,标记节点,遍历在引用链中的对象;清除阶段,需要将全部的对象都遍历一边,效率不算高
  2. 在GC时,停止整个应用程序STW,用户体验差
  3. 清除出来的空闲区域是不连续的,产生内存碎片,需要维护一个空闲列表
  • 清除,并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新的对象需要加载时,判断垃圾的位置是否够,如果够,那么就存放,覆盖掉垃圾对象的数据

复制算法

  • 可以使用指针碰撞来为对象分配内存
  • 使用双存储区的lisp语言垃圾收集器
  • 将内存空间分成两块,每一次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象交换两个内存的角色from区和to区,解决碎片化
  • 优点
  1. 没有标记和清除过程,实现简单,运行高效
  2. 复制过去以后保证空间连续性,不会出现碎片化的问题
  • 缺点
  1. 需要两倍的内存空间
  2. 对于G1这种分拆成大量region(对象的区域)的GC,复制而不是移动,需要调整对象引用的引用地址,意味着GC需要维护region之间的对象引用关系,内存占用和时间开销不小
  3. 系统中存活对象很多,复制算法会提高内存占用和时间开销,复制算法需要复制的存活对象并不会太多;对应的,新生代的对象大部分都是朝生夕死的,回收性价比很高,避免了存活对象很多的极端情况,因此在新生代中采用复制算法,s0/s1中的对象不断在GC的过程中交换,并增加对象的年龄计数

标记-压缩算法

  • 标记-整理,内存碎片化的整理
  • 针对大部分对象是存活对象的情况,符合老年代垃圾回收的特性
  • 执行过程
  1. 与标记-清除算法一致,从根节点开始标记所有被引用对象
  2. 将所有的存货对象压缩到内存的一段,按顺序排放,清理边界外的所有空间
  3. 标记的存货对象被整理之后,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址(分割已存和未存的空间)
  • 与标记-清除算法的差别
  1. 清除算法是一种非移动的回收算法
  2. 压缩算法是移动式的,是否移动回收后的存货对象是一项优缺点并存的风险策略
  • 指针碰撞,如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配其实点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量,将新对象分配在第一个空闲内存位置上即可
  • 优点
  1. 消除了标记-清除算法中,内存区域碎片化
  2. 消除了复制算法中,内存减半的高额代价
  • 缺点
  1. 效率低于前两个
  2. 移动对象的同时,如果对象被其他对象引用,还需要调整引用地址
  3. 移动过程中,需要全程暂停用户应用程序 STW

三个算法比较

image.png

  • 执行效率来说,复制算法>清除>压缩
  • 压缩算法,比复制算法多了标记的阶段,比清除算法多了整理内存的阶段

分代收集算法

  • 不同的对象的生命周期是不一样的,采取不同的手机方式,以便提高回收效率
  1. 根据各个年代的特点使用不同的回收算法
  2. Http请求中的Session对象、线程、Socket连接,生命周期比较长
  3. String对象,生命周期比较短
  • 年轻代,区域相对老年代比较小,生命周期短、存活率低、回收频繁

用复制算法的回收整理,速度最快,复制算法的效率只和当前存活对象大小有关

  • 老年代,区域较大,对象生命周期长、存活率高;一般是由标记-清除或者标记-清除与标记-整理混合实现
  1. Mark阶段的开销与存活对象数量成正比
  2. Sweep阶段的开销与所管理区域的大小成正相关
  3. Compact阶段的开销与存活对象的数量成正比
  • CMS针对老年代的垃圾回收器
  1. 基于标记清除算法实现的,对对象的回收效率很高
  2. 对碎片化问题,CMS采用基于压缩整理算法的Serial Old 串行垃圾回收器回收器最为补偿
  3. 当回收内存不佳,碎片化导致Concurrent Mode Failure(在年老代被用完之前不能完成对无引用对象的回收;当新空间分配请求在年老代的剩余空间中得到满足),将采用Serial Old

执行Full GC达到对老年代内存的整理

增量收集算法

  • 实时垃圾收集算法
  • 让垃圾收集线程和应用程序线程交替执行,垃圾收集线程只收集一小片的内存空间,接着切换到应用程序线程,反复切换
  • 基础仍是传统的标记清除和复制算法,通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
  • 由于在垃圾回收过程中,间断性执行了应用程序代码,减少系统的停顿时间
  • 由于线程切换和上下文转换的消耗,会导致垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法

  • 针对G1垃圾收集器
  • 为了控制GC产生的停顿时间,将一块大的区域分割成多个小块,根据目标停顿时间,每次合理地回收若干个小区间,减少一次GC所产生的停顿
  • 将整个堆空间划分成连续的不同小区间,有的放eden,有的放s0/s1,有的放old,有的放humongous 超大对象
  • 每一个小区间都独立使用,独立回收,可以控制一次回收多少个小区间

猜你喜欢

转载自juejin.im/post/7018370204941942821
今日推荐