本文 首发于 🍀 永浩转载 请注明 来源

29、【对线面试官】垃圾回收机制

聊聊Java的垃圾回收机制?

  • 我们使用Java的时候,会创建很多对象,但我们未曾「手动」将这些对象进行清除,而如果用C++语言的时候,用完是需要自己free(释放)掉的
  • 写Java的时候不用自己手动释放”垃圾"呢?原因很简单,JVM帮我们做了(自动回收垃圾)
  • 垃圾的定义:只要对象不再被使用了,那我们就认为该对象就是垃圾,对象所占用的空间就可以被回收·

是怎么判断对象不再被使用的呢?

  • 常用的算法有两个「引用计数法」和「可达性分析法」

    • 引用计数法思路很简单:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收

    • 缺点就是:如果对象存在循环依赖,那就无法定位该对象,是否应该被回收(A依赖B,B依赖A)

    • 是可达性分析法:它从「GC Roots」开始向下搜索,当对象到「GC Roots」都没有任何引用相连时,说明对象是不可用的,可以被回收

      • 「 GC Roots」是一组必须「活跃」的引用

      • 从「 GC Root」出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象

        • 比如:JVM内存结构中的虚拟机栈,虚拟机栈里的栈帧,栈帧中的局部变量,局部变量就存储着引用。
        • 那如果栈帧位于虚拟机栈的栈顶,是不是说明这个栈帧是活跃的(换言之,是线程正在被调用的)
        • 既然是线程正在调用的,那栈帧里的指向「堆」的对象引用,就一定是「活跃」的引用
        • 所以,当前活跃的栈帧指向堆里的对象引用就可以是「 GC Roots」
      • 当然了,能作为「 GC Roots」也不单单只有上面那一块

        • 比如类的静态变量引用是「 GC Roots」,被「Java本地方法」所引用的对象也是「 GC Roots」等等
      • 「 GC Roots」是一组必须「活跃」的「引用」,只要跟「GC Roots」没有直接或者间接引用相连,那就是垃圾。JVM用的就是「可达性分析算法」来判断对象是否为垃圾

标记完,怎么删除的(垃圾回收算法)

  • 标记清除
    • 缺点:直接清除会有「内存碎片」的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)
  • 标记复制
    • 「标记」存活的对象「复制」到另一块空间,复制完了之后,直接把原有的整块空间给干掉!这样就没有内存碎片的问题了
    • 缺点:内存利用率低,得有一块新的区域给我复制(移动)过去
  • 标记整理
    • 当前区域内进行移动,存活对象一到一边,垃圾移到一边,再统一删除,就不会有内存碎片了

老年代、年轻代

  • 大部分对象的生命周期都很短,而只有少部分对象可能会存活很长时间
  • 回收垃圾的时候,程序是有短暂的时间不能正常继续运作啊。(JVM在回收的时候,用户线程不能继续分配修改引用),为了使「 stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率
  • 所以很多的垃圾收集器上都会在「物理」或者「逻辑」上,把这两类对象进行区分
    • 死得快的对象所占的区域叫做「年轻代」,活得久的对象所占的区域叫做「老年代」
    • 但也不是所有的「垃圾收集器」都会有,只不过我们现在线上用的可能都是JDK8,JDK8及以下所使用到的垃圾收集器都是有「分代」概念的

垃圾收集器

  • 垃圾回收的过程,其实就对应着几种「垃圾回收算法」分别是

    • 标记清除算法、标记复制算法和标记整理算法【「标记」「复制」「整理」】
  • 「年轻代」的垃圾收集器有: Seria、Parallel Scavenge、 Pardew

    • 年轻代的垃圾回收器使用的都是「标记复制算法」
    • 所以在「堆内存」划分中,将年轻代划分出 Survivor区( Survivor From和 ourvor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动)
    • 新对象则放入Eden区
    • 堆内存大小默认比例:

  • 「老年代」的垃圾收集器有: Serial Old、 Parallel Old、CMS

  • Serial是单线程的, Parallel是多线程。这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)

  • CMS是「JDK8之前」是比较新的垃圾收集器,它的特点是能够尽可能减少「stop the word」时间。在垃圾回收时让用户线程和GC线程能够并发执行」

新创建的对象一般是在「新生代」嘛,那在什么时候会到「老年代」中呢?

  • 两种情况
    • 如果对象太大了,就会直接进入老年代(对象创建时就很大 或者 Survivor区没办法存下该对象)
    • 如果对象太老了,那就会晋升至老年代(每发生一次 Monor GC,存活的对象年龄+1,达到默认值15则晋升老年代)或者(动态对象年龄判定可以进入老年代)

那 Monor GC什么时候会触发呢?

  • 当Eden区空间不足时,就会触发 Monor GC

那在「年轻代」GC的时候,从 GC Roots出发,那不也会扫描到「老年代」的对象吗?那那那.不就相当于全堆扫描吗?那这分代还有意义吗?

  • JVM解决方案

    • Hotspot虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上
    • 所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上、
    • 当做 Monor GCI的时候,从 GC Roots出发,如果发现「老年代」的对象,那就不往下走了( Monor GC对老年代的区域毫无兴趣)

但又有个问题,那如果「年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),那时候肯定是不能回收掉「年轻代」的对象的?

  • 解决方案
    • Hotspot虚拟机下有「 card table」(卡表)来避免全局扫描「老年代」对象
    • 「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」
    • 那知道了「卡表」之后,就很好办了。每次 Monor GC的时候只需要去「卡表找到「脏页」,找到后加入至 GC Root,而不用去遍历整个「老年代」的对象了。

总结

什么是垃圾:只要对象不再被使用,那即是垃圾

如何判断为垃圾:可达性分析算法和引用计算算法,JVM使用的是可达性分析算法

什么是GC Roots:GC Roots是一组必须活跃的引用,跟GC Roots无关联的引用即是垃圾,可被回收

常见的垃圾回收算法:标记清除、标记复制、标记整理

为什么需要分代:大部分对象都死得早,只有少部分对象会存活很长时间。在堆内存上都会在物理或逻辑上进行分代,为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率。

Minor GC:当Eden区满了则触发,从GC Roots往下遍历,年轻代GC不关心老年代对象

什么是card table【卡表】:空间换时间(类似bitmap),能够避免扫描老年代的所有对象,进而顺利进行Minor GC (案例:老年代对象持有年轻代对象引用)

堆内存占比:年轻代占堆内存1/3,老年代占堆内存2/3。Eden区占年轻代8/10,Survivor区占年轻代2/10(其中From 和To 各站1/10)