谈垃圾回收机制

Posted by WGrape的博客 on October 10, 2020

文章内容更新请以 WGrape GitHub博客 : 谈垃圾回收机制 为准

前言

本文原创,著作权归WGrape所有,未经授权,严禁转载

一、背景

1. 程序载入

程序被载入内存时,OS会为其生成一个描述符task_struct,属性mm_struct指向所对应的内存区域 (包括代码段即指令区、堆栈段即数据区 )。当时间片分配给此程序时,CPU便会读取其内存区域中的指令并执行,这时程序不再是一个静态的数据结构,而是一个运行的实体,即进程( 进行的程序 )。

image

2. 程序执行

指令是汇编代码,汇编代码可以直接操作内存。所以在CPU读取程序指令执行的过程中,涉及内存操作的指令会使用掉部分的堆栈空间

image

3. 堆栈增长

随着程序的执行,使用的堆栈空间会朝着相反的方向不断增长

生长方向相反可以最大化的利用内存

4. 内存记忆

1) 物理层

内存存储器主要由锁存器 ( Latch ) 组成,锁存器内部是晶体管和逻辑门电路所制,高低电平信号在其电路上传输时会被锁住,通过这种方式实现信号存储,得名为锁存器

image

当很多种这样的锁存器组成时,就组成了一个内存存储器

image

因此,对于内存而言,其内部的锁存器存储的高低电平信息即是用户的数据。一旦锁存器存储的电平改变,上次存储的信息就会消失,这种消失从用户角度来看就是数据被删除。从计算机角度看,只是锁存器内部不再存有信号,或者虽然存储信号,但是其所存储的数据已被OS标识为空闲。

理论上,所有存储的数据,无论其抽象层如何标识数据的存在与否,只要在物理层还存在,数据就可以被恢复

2) OS层

OS会维护空闲的内存链表,这些都是可以作为再分配的内存资源。释放堆内存,本质是OS通过将其连接在空闲内存链表而实现。所以回收的堆内存中的保留的数据可能并未被删除。

image

3) 编程语言层

一般编程语言对堆内存的管理,是通过OS提供的接口实现

image

5. 栈的管理

计算机科学《函数调用约定》中规定了不同的CPU架构,在子程序调用后返回到调用方程序时,应该如何需要清理掉栈上的空间。所以无论C、JAVA、Go等任何语言,只是其在汇编层额外多了些栈操作,才得以实现栈空间在调用后的自动释放。

6. 堆的管理

1) 分配

创建新对象时,会在堆空间分配内存后,返回这段内存空间的地址,即指针 ( 参照malloc原理 ) 。

2) 释放

堆空间的生命周期会持续到手动释放后才结束。在C语言中,会提供free释放堆内存的函数,其本质也是通过空闲内存链表实现。

3) 问题

因为内存的申请和释放完全由开发者管理,经常出现未手动释放堆空间而造成内存泄露,为了解决人为管理内存的不便,便开始研究新机制:垃圾回收 / Garbage Collection

二、认识垃圾回收

1. 基本原理

通过回收算法识别出其中不会再被引用的对象,从而进行系统级别的对象自动释放。

2. 回收算法

根据回收时机 ( 回收周期 ) 的不同,可分为即刻回收和延迟回收两种

1) 即刻回收

① 引用计数法

在运行时,记录每个对象被引用的次数,每次创建新对象、赋值引用、删除引用的同时,GC会立即更新计数器,如果计数器为0则立即进行内存回收。

image

2) 延迟回收

由于延迟回收策略中的回收周期时间不定,无法跟踪堆对象。此时GC会面临 when 和 how 问题 。how 是指如何找到目标堆对象 ,when 是指什么时候去找。

  • 解决 How :【根搜索法】在每一次创建对象的时候,都会根据引用及其指向对象的信息,生成对应的 GC Root 结构体,并存储在一个类似映射表的数据结构中 ( 如 OopMap ),通过遍历 GC Roots 解决 find 问题

  • 解决 When :一般可以把 GC Roots 大小、堆空间的使用大小,定期等作为是否触发GC的因素

① 标记清除法 / Mark Sweep

第一步 :遍历所有 Roots ,并标记所有可达的 Object 第二步 :遍历堆 ,清除所有未被标记的 Object

image

清理后的内存使用情况如下图所示

image

② 标记压缩法 / Mark Compact

第一步 :遍历所有 Roots ,并标记所有可达的 Object 第二步 :遍历堆 ,清除所有未被标记的 Object 第三步 :重新排列所有未被清除的Object

压缩法相比清除法,多了一步整理移动堆空间的操作,减少了内存碎片

image

清理后的内存使用情况如下图所示

image

③ 内存复制 / Copying

前提条件 :需要把内存等分为 空闲区和使用区 两个区

第一步 :当使用区用完后,标记所有可达对象,并删除不可达对象 第二步 :GC将其中还存活的对象复制到空闲区中 第三步 :清除使用区内的所有对象 第四步 :把两个区块的身份对调 ( 即空闲区变成使用区,使用区变成空闲区 )

image

清理后的内存使用情况如下图所示

image

相比压缩算法,复制算法解决了标记压缩算法中 内存的移动整理 **较复杂且效率低 **的问题。

3.回收优化

在基本的回收算法中,还存在一些可以优化的地方。

1) 内存分代

在遍历GC Roots时,由于有些对象的生命周期较长,就会造成无用遍历的情况,即某个对象被遍历了多次,但是一直是alive状态导致其引用的对象不能被回收。既不能强制回收,又需要减少遍历的代价提高效率,便提出了内存分代的概念。

image

一般可分为年轻代和老年代,年轻代存储存活周期较短的对象,老年代存储存活周期较长的对象。年轻代意味着更容易被释放,老年代意味着需要很久才会被释放。所以通过这种方式明显提高遍历效率 :

第一步 :创建的新生对象进入Young区 第二步 :在GC遍历Map时,会把所有可达对象的“年龄”+1 第三步 :当对象的年龄超过限制后,对象及其GC Root会被移入老年区

2) 解决引用计数中的循环引用问题

传统的引用计数中存在缺陷,当对象循环引用时会存在无法清理对象的情况。即本质上对象已经是可以回收的了,但是因为引用计数非0,导致无法释放内存。

为解决问题,可以通过算法识别出循环引用的对象,然后加到缓冲中,当缓冲容量超过限制后便进行回收。

三、相关问题解释

1. 垃圾的定义

当对象不再被任何变量引用时,这个对象就是GC应该回收的垃圾

2. 垃圾的生命周期

原则上GC每回收一次垃圾都会降低效率,所以对象被标识为垃圾后,不同的GC机制都会有对应的优化策略,比如当存储垃圾对象的某个缓冲区满之后才会回收。

3. 理解 GC Root

GC Root 概念比较难理解,不过其本质是描述GC把一切堆对象视为回收目标,各个堆对象之间的引用关系可能错综复杂,所以需要有一个类似起点的节点对象,这样GC每次只需要从起点出发,就能遍历到所有与之相关的所有堆对象

image

4. 引用计数的循环引用

① 定义对象a、b

image

② 对象a、b循环引用

对象a、b的引用次数都+1

a.ref = b;
b.ref = a;

image

③ 删除对象a、b

对象a、b的引用次数都-1

unset(a);
unset(b);

image

④ 无法清理

因为对象a、b的引用计数都不为0,导致堆对象无法被回收

四、不同语言的垃圾回收

1. JAVA

1) 回收机制 - 根搜索法 + 内存分代

① Java把内存分为新生代、老年代 ( 新老内存大小比例 1:2 ) 和 永久代,其中新生代分为Eden、From、To三个区域 ( 内存大小比例默认8:1:1 )

  • Eden (伊甸园,象征起源) :所有新创建的对象会在分配在此区域中
  • From、To区域就是对内存复制方式的实现
  • 永久代用于存储如静态类的对象,一般不会进行垃圾回收

② Java早期版本如果对象不被引用会被GC直接回收,后续需要只能由JVM重新创建,降低了JVM性能。因此后续版本Java把引用分为强引用、软引用、弱引用、虚引用,一般很少用后两种。

  • 强引用 :宁愿抛出OOM错误,GC也不会回收强引用的对象
    // 强引用
    Object strongReference = new Object();
    
  • 软引用 :如果内存空间不足时,会回收这些软引用的对象。适合缓存场景
    // 软引用: Java中用java.lang.ref.SoftReference类来表示
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);
    

    区分不同引用类型的本质 :优化GC回收策略,解决对象无引用时会被GC立即回收 而引起的 JVM 性能下降问题

    2. PHP

    1) 变量容器 ZVal

    ZVal 全称 Zend Value,是Zend引擎中描述PHP变量的容器 (结构体) 。

typedef struct _zval_struct {
    zvalue_value value; // 变量的值
    zend_uint refcount__gc; // 引用计数器, 记录引用次数
    zend_uchar type; // 变量的类型
    zend_uchar is_ref__gc; // 是否是引用变量, 1是, 0不是
}zval;

typedef union _zvalue_value {
    long lval; 
    double dval;
    struct { // 如字符串变量
        char *val;
        int len;
    } str;
    HashTable *ht; // 如数组变量
    zend_object_value obj; // 如对象变量
} zvalue_value;

// zend_uchar 定义了8种变量类型
#define IS_NULL     0
#define IS_LONG     1
#define IS_DOUBLE   2
#define IS_BOOL     3
#define IS_ARRAY    4
#define IS_OBJECT   5
#define IS_STRING   6
#define IS_RESOURCE 7
#define IS_CONSTANT 8
#define IS_CONSTANT_ARRAY   9
#define IS_CALLABLE 10

2) 回收机制 - 引用计数

在PHP5.3以前,使用的是最简单引用计数方式,不过从5.3开始,使用了改进版的引用计数算法,来解决内存泄露问题。 当对象的refcount减一操作导致结果为0时,表示其引用已全部不存在可以清理。反之如果减一操作执行后其结果仍然大于0,表示还存在其他引用,这个对象可能是垃圾。这时垃圾回收器会把这些垃圾收集起来,存在缓冲区中,当缓冲区满时,识别出其中的循环引用的对象和无引用对象并进行清理。

3) 相关知识点

1. unset函数

unset只是断开一个变量到其引用的对象之间的连接,同时将该对象的引用计数-1,对象是否回收主要还是根据refcount是否为0,以及GC策略。

2. = null 操作

a=null是直接将a指向的数据结构置空,同时将其引用计数归0。

3. 脚本执行结束

脚本执行结束,该脚本中使用的所有堆内存都会被释放,不论是否有引用。

4. xdebug调试

通过Xdebug查看变量容器,观察refcount的引用变化 ## 3. JavaScript ### 1) 回收机制 - 根搜索法 + 内存分代 最初级的JavaScript使用引用计数法,但是循环引用问题无法解决。大概从2012年起JavaScript GC开始使用根搜索法,并且将内存分为年轻代和老年代 ### 2) 相关知识点 #### 1. JS堆内存分析 需要分析内存的时候,可以使用Chrome工具调试

2. 闭包和GC

只要是处于被引用状态的堆内存数据,都不会被回收掉。闭包机制中,在函数结束时,由于其内部变量仍然被引用,所以即使是局部变量,但是其仍然可用。这也是闭包为什么会造成内存泄露的原因。

// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures
function makeFunc() {
    var name = "Mozilla"; // 变量name
    function displayName() {
        alert(name);
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();

五、总结

垃圾回收本质最重要的还是回收算法和回收时机这两方法,对于回收算法而言,目前常用算法基本都大同小异,或是上述算法中的变种。对于回收时机来说,也是基本都采用缓冲区容量限制等作为是否触发的标准。