Unity3D托管堆BoehmGC算法学习-垃圾回收篇

avatar
@字节跳动

作者:字节游戏中台客户端团队 - 潘风

引言

前文Unity3D托管堆BoehmGC算法学习-内存分配篇论述了GC内存分配的相关机制,本文主要分析一下BoehmGC算法中垃圾回收的具体实现机制。

流程概述

首先BoehmGC算法提供了垃圾回收的入口API,GC_gcollect,GC_gcollect会调用GC_try_to_collect_general,GC_try_to_collect_general再调用GC_try_to_collect_inner开始,如下:

void GC_CALL GC_gcollect(void)
{
    (void)GC_try_to_collect_general(0, FALSE);
    ...
}

GC_bool GC_try_to_collect_general(GC_stop_func stop_func, GC_bool force_unmap GC_ATTR_UNUSED) 
{
    ...
    ENTER_GC();
    //调用GC_try_to_collect_inner开始GC
    result = GC_try_to_collect_inner(stop_func != 0?stop_func:GC_default_stop_func);
    EXIT_GC();
    ...
}

GC_try_to_collect_inner方法开始真正的GC操作,下面是代码和主要步骤:

GC_INNER GC_bool GC_try_to_collect_inner(GC_stop_func stop_func)
{
    ...
    //控制停止
    if (GC_dont_gc || (*stop_func)()) return FALSE;
    //抛出事件
    if (GC_on_collection_event) GC_on_collection_event(GC_EVENT_START);
    ...
    //清空标记位
    GC_clear_marks();
    ...
    //开始追踪内存节点,标记“引用”标记位
    if (!GC_stopped_mark(stop_func)) {
        ...
    }
    //结束标记,开始回收没有被引用的内存节点
    GC_finish_collection();
    ...
    //抛出事件
    if (GC_on_collection_event) GC_on_collection_event(GC_EVENT_END);
}

首先,解释一下“对象内存节点” 的概念,即前文介绍的分配给上层的对象内存块。例如,一个对象节点分配16字节,一个hblk内存块的大小是4096字节,可以等分成256个对象节点,提供给上层使用。对于小内存(小于2048字节),内存节点从ok_freelist中取出,按照16字节内存对齐,大小分类共有128种。对于大内存(大于2048字节),直接从hblkfreelist中取出。对象内存节点的大小记录在Header数据结构的hb_sz字段中。

GC主要分为两个阶段:

  • 第一阶段:标记(Mark)阶段,这个阶段首先重置内存节点的标记位,然后从内存的根节点开始遍历扫描内存空间,将托管堆上被引用的内存节点标记为“引用”。

  • 第二阶段:回收(Reclaim)阶段,这个阶段遍历托管堆上的所有内存节点,然后将未标记为“引用”的内存节点进行回收。

除此之外,GC还提供了一个事件机制,用于通知外界GC的阶段和状态。状态定义如下:

typedef enum {
    GC_EVENT_START, //开始GC
    GC_EVENT_MARK_START, //开始标记
    GC_EVENT_MARK_END,  //结束标记
    GC_EVENT_RECLAIM_START, //开始回收
    GC_EVENT_RECLAIM_END, //回收结束
    GC_EVENT_END //GC结束
    ...
}

外界可以设置事件的回调方法,如下:

void GC_CALL GC_set_on_collection_event(GC_on_collection_event_proc fn)
{
    ...
    GC_on_collection_event = fn;
    ...
}

GC的过程中,会在各个阶段调用GC_on_collection_event方法通知。

托管堆内存布局

在介绍垃圾回收流程之前,首先介绍一下托管堆内存的布局和结构。

如前文所述,hblk内存块是Boehm算法在托管堆中进行内存分配的基础单位,无论大内存还是小内存,所有对象节点都是在hblk的基础上分配的。在分配一个hblk内存块的时候,会创建相应的header信息,header信息创建之后,被加入到全局数组_top_index中:

struct GC_arrays {
    ...
    bottom_index *_top_index[TOP_SZ];
};

typedef struct bi {
    hdr * index[BOTTOM_SZ]; //存储Header信息的数据结构
    structbi * asc_link; //升序排列
    structbi * desc_link; //降序排列
    word key; //key,前42位高地址
    # ifdef HASH_TL
        structbi * hash_link;
    # endif
} bottom_index;

其中bottomIdex结构是数组的元素,里面存放了具体的header信息,关于存储header信息的逻辑,步骤如下:

  1. 根据header对应的hblk的内存块地址,得到一个hash值,hash值是_top_index的下标index,如果计算出的hash值相同,则进一步比较0~42位的bit值,如果相同,则认为header找到了对应的bottomIndex块,直接返回这个bottomIndex块。

  2. 如果不同,则新建一个bottomIndex块,并将其插入二级链表中。所有bottomIndex块的引用关系如下图所示,hash值相同的块用hash_link链表维护。
    image.png

  3. 同时会构建一个辅助的有序链表,存储所有的bottomIndex块。这个链表是一维的,且按照一定的规则计算出hash值,根据hash值进行有序构建。这个链表把全局数组_top_index的二维结构转化为一维有序链表,方便后续的遍历。如下图:

    image.png

  4. 确定了bottomIndex块之后,进一步根据hblk地址的42~64位,计算出hash值,作为bottomIndex中index数组的下标,将header信息存入存储对应位置。

综上,遍历这个链表可以找到所有hblk内存块的地址和header信息,进一步遍历hblk内存块中所有的内存节点,遍历托管堆中所有hblk内存块的逻辑如下:

void GC_apply_to_all_blocks(void (*fn)(struct hblk *h, word client_data),
              word client_data)
{
  signed_word j;
  bottom_index * index_p;
  for (index_p = GC_all_bottom_indices; index_p != 0;
     index_p = index_p -> asc_link) {
    for (j = BOTTOM_SZ-1; j >= 0;) {
      if (!IS_FORWARDING_ADDR_OR_NIL(index_p->index[j])) {
        if (!HBLK_IS_FREE(index_p->index[j])) {
          (*fn)((struct hblk *)(((index_p->key << LOG_BOTTOM_SZ) + (word)j)
                << LOG_HBLKSIZE))
        }
        j--;
       } else if (index_p->index[j] == 0) {
        j--;
       } else {
        j -= (signed_word)(index_p->index[j]);
       }
     }
   }
}

从GC_all_bottom_indices开始遍历有序链表,得到bottomIndex块,然后进一步遍历里层的index数组中的header信息,如果这个header不是空闲状态,则参与了内存分配,将header对应的hblk地址返回给上层进一步处理。了解了这个核心结构和逻辑之后,接着分析后续的操作。

Mark阶段

Mark阶段的目的是将托管堆内存上的对象,标记为“引用”或“非引用”,便于后续回收阶段的处理。

标记位重置

首先header信息记录了对应内存块中所有“对象内存节点”是否被引用,结构如下:

//头部信息
struct hblkhdr {
    ...
    size_t hb_n_marks;//标记位个数,用于GC
    word hb_marks[MARK_BITS_SZ]; //标记为,用于GC
}

其中,hb_n_marks表示这个hblk内存块中被“引用”的对象个数,hb_marks数组维护所有对象的引用标记位,用0和1表示“引用”和“非引用”,MARK_BITS_SZ是一个足够大的数字。例如第16个对象节点被引用,则hb_marks[15]的值是1,第17节点没有被引用,则hb_marks[16]的值是0。

首先调用GC_clear_marks方法重置所有标记位。 GC_apply_to_all_blocks如上文所述,遍历得到托管堆中所有hblk内存块的地址,找到对应的header信息。GC_clear_hdr_marks方法负责将header中关于内存节点的标记位和标记个数清0。

GC_INNER void GC_clear_marks(void)
{
    GC_apply_to_all_blocks(clear_marks_for_block, (word)0);
    ...
}

static void clear_marks_for_block(struct hblk *h, word dummy GC_ATTR_UNUSED)
{

    hdr * hhdr = HDR(h);
    ...
    GC_clear_hdr_marks(hhdr);
}

GC_INNER void GC_clear_hdr_marks(hdr *hhdr)
{
    BZERO(hhdr -> hb_marks, sizeof(hhdr->hb_marks));
    ...
    hhdr -> hb_n_marks = 0;
}

Mark主要流程

重置标记信息之后,开始正式进入扫描标记的流程,GC_stopped_mark方法实现了主要逻辑:

  1. 准备工作,暂停线程,

  2. 从根节点开始扫描,包括栈内存、寄存器变量、静态数据区的内存开始遍历,判断根节点内存地址指向的内存空间是否位于一个有效的hblk内存块中。将这些有效的内存空间标记起来,并暂存到一个临时数组中。

  3. 进一步扫描临时数组中的所有对象的内存空间,判断其指向的内存空间是否位于有效的hblk内存块中,并进行标记。直到全部遍历结束。

  4. 结束标记流程,恢复线程。

GC_stopped_mark方法的主要逻辑如下:

STATIC GC_bool GC_stopped_mark(GC_stop_func stop_func)
{
    //事件通知,暂停线程
    if (GC_on_collection_event)
        GC_on_collection_event(GC_EVENT_POST_STOP_WORLD);    
    ...
    //暂停线程
    STOP_WORLD();
    //事件通知,暂停线程
    if (GC_on_collection_event)
        GC_on_collection_event(GC_EVENT_POST_STOP_WORLD);
    ...
    //事件通知,开始扫描
    if (GC_on_collection_event)
        GC_on_collection_event(GC_EVENT_MARK_START);
    ...
    //判断gc初始化状态
    GC_initiate_gc();   
    ...
    //从根节点开始扫描追踪
    if (GC_mark_some(GC_approx_sp())) break;
    ...
    //事件通知,结束扫描
    if (GC_on_collection_event)
       GC_on_collection_event(GC_EVENT_MARK_END);
    ...
    //事件通知,开始恢复线程
    if (GC_on_collection_event)
        GC_on_collection_event(GC_EVENT_PRE_START_WORLD);
    ...
    //恢复线程
    START_WORLD();
    ...
    //事件通知,恢复线程完成
    if (GC_on_collection_event)
        GC_on_collection_event(GC_EVENT_POST_START_WORLD);
    ...
}

下面详细介绍子阶段的实现逻辑:

线程暂停

为了准确的采集跟踪托管堆内存的瞬时状态,需要先对当前线程进行挂起。

GCThread

首先GC算法为每个线程封装了一个GC_THREAD结构,这个结构存储了线程相关的信息,包括当前的线程id、状态、栈(底部指针、顶部指针)的信息,结构如下:

 typedef struct GC_Thread_Rep {
     ...
     struct GC_Thread_Rep * next;
     pthread_t id; //线程id
     struct thread_stop_info stop_info; //线程暂停信息
     ...
     ptr_t stack_end; 
     word stack_size;
     ...
 } * GC_thread;

 struct thread_stop_info {
     ...
     ptr_t stack_ptr;  
     ...
 }

在创建每个线程时会获取栈底部指针,而线程在处理暂停SUSPEND信号时,会将当前栈顶指针保存到GC_THREAD结构中。stop_info记录了线程暂停时刻的相关信息。全局数组GC_threads用于维护所有线程信息。

suspendAll

线程暂停的宏定义是STOP_WORLD,方法实现是GC_stop_world,内部调用GC_suspend_all暂停所有线程(除当前线程之外),主要逻辑如下

GC_INNER void GC_stop_world(void)
{
    ...
    (void)GC_suspend_all();
    ...
}

STATIC int GC_suspend_all(void)
{
    int n_live_threads = 0;
    int i;
    pthread_t self = pthread_self();
    //遍历所有线程信息
    for (i = 0; i < THREAD_TABLE_SZ; i++) {
        for (p = GC_threads[i]; p != 0; p = p -> next) {
            //不是当前线程
            if (!THREAD_EQUAL(p -> id, self)) {
                 //过滤结束和阻塞的线程
                 if ((p -> flags & FINISHED) != 0) continue;
                 if (p -> thread_blocked) /* Will wait */ continue;
                 ...
                 n_live_threads++;
                 ...
                 //发送suspend信号,暂停系统线程
                 result = RAISE_SIGNAL(p, GC_sig_suspend);
                 ...
            }
        }
    }
}

因为需要当前线程来进行后续逻辑,因此会暂停当前线程之外的所有线程。同时也会过滤结束和阻塞的线程。然后发起线程中断的信号。

suspend_handler

当接收到中断信号之后,会触发GC_suspend_handler回调函数处理,再触发GC_suspend_handler_inner方法:

STATIC void GC_suspend_handler_inner(ptr_t dummy GC_ATTR_UNUSED,
                   void * context GC_ATTR_UNUSED)
{
    pthread_t self = pthread_self();
    GC_thread me;
    ...
    //找到当前线程
    me = GC_lookup_thread_async(self);
    ...
    //存储栈顶指针
    GC_store_stack_ptr(me);
    ...
 }

暂停了线程之后,开始从根节点扫描。

内存扫描

执行GC_mark_some方法开始从根节点扫描,如下

GC_INNER GC_bool GC_mark_some(ptr_t cold_gc_frame)
{
    switch(GC_mark_state) {
    case MS_NONE:
        break;
    case MS_PUSH_UNCOLLECTABLE: {
        ...
        //
        GC_push_roots(FALSE, cold_gc_frame);
        GC_objects_are_marked = TRUE;
        if (GC_mark_state != MS_INVALID) {
            GC_mark_state = MS_ROOTS_PUSHED;
        }
    }
    case MS_ROOTS_PUSHED: {
        ...
        MARK_FROM_MARK_STACK();
        break;
    }
    ...
    case MS_INVALID: {
        ...
    }
    break;
}

GC_mark_state表示当前扫描的状态,主要分为4个状态,算法根据不同状态,进行相应处理。 MS_INVALID:表示内存空间未标记,此时状态会切换到MS_PUSH_UNCOLLECTABLE状态。如图所示: image.png

图中绿色方块表示未开始扫描的根节点内存,例如栈区内存空间。然后会从低地址向高地址,偏移1字节的方式扫描。

MS_PUSH_UNCOLLECTABLE:开始遍历内存根节点,并对根节点指向的托管堆内存节点标记为“引用”,因为这些内存节点被根节点引用,并且将这个节点存入数组。完成标记之后,此时切换到MS_ROOTS_PUSHED状态,如图所示:

image.png

从根节点内存的起始地址开始,逐渐偏移字节,判断地址指向的值是否是托管堆内存地址。例如地址是ptr,如 *ptr是托管堆内存的一个有效地址,这个托管堆内存的节点被根节点“引用”了。图中绿色表示未扫描的内存空间,蓝色表示已扫描的内存空间。全部扫描结束之后,被引用的堆内存节点全部标记为蓝色(ABCDE),未指向的全部标记为白色(将被回收的节点)。

MS_ROOTS_PUSHED:遍历之前被“引用”的托管堆内存节点,按照偏移地址的方式,判断指向的地址是否也是托管堆内存,例如节点A的地址是ptr,则判断*ptr是否是一个有效的托管堆内存节点地址,然后ptr++,扫描完A,然后将BCDE…全部扫描判断完成。

image.png

扫描完成时,托管堆内存上所有被“引用”的内存节点已经被标记出来,所有未标记的节点最终会被GC回收。完毕之后,切换到MS_NONE状态。

MS_NONE:表示扫描的全部结束,全部标记好。

将上述四个状态归为2个基本逻辑,根节点扫描和托管堆内存递归扫描,探究具体实现逻辑:

根节点扫描

根节点扫对应MS_PUSH_UNCOLLECTABLE的GC_push_roots方法,如下:

GC_INNER void GC_push_roots(GC_bool all, ptr_t cold_gc_frame GC_ATTR_UNUSED)
{
    ...
    //扫描寄存器、线程栈地址空间
    GC_push_regs_and_stack(cold_gc_frame);
    //扫描其他根节点空间
    if (GC_push_other_roots != 0) (*GC_push_other_roots)();
}

STATIC void GC_push_regs_and_stack(ptr_t cold_gc_frame)
{
    GC_with_callee_saves_pushed(GC_push_current_stack, cold_gc_frame);
}

GC_push_all_stack_sections和GC_push_all_register_sections方法分别扫描栈和寄存器,这里就不具体展开。最终会调用GC_push_all_eager方法扫描。

GC_API void GC_CALL GC_push_all_eager(void *bottom, void *top) {
    ...
    lim = t - 1

    for (p = b; (word)p <= (word)lim;
        p = (word *)(((ptr_t)p) + ALIGNMENT)) {
    REGISTER word q = *p;
    GC_PUSH_ONE_STACK(q, p);
   }
}

lim表示空间的最大值,例如从栈底到栈顶,逐渐累加ALIGNMENT,p是当前地址,q是*p,即p指向的地址。然后调用GC_PUSH_ONE_STACK检测。

# define GC_PUSH_ONE_STACK(p, source) \
do { \
   if ((word)(p) >= (word)GC_least_plausible_heap_addr \
     && (word)(p) < (word)GC_greatest_plausible_heap_addr) { \
     PUSH_ONE_CHECKED_STACK(p, source); \
   } \
} while (0)

GC_least_plausible_heap_addr和GC_greatest_plausible_heap_addr记录了托管堆内存空间的最低和最高地址,在上文系统分配堆内存的时候会更新这两个值。这是一个大致的范围,如果在这个范围内,再调用GC_mark_and_push_stack方法进一步判断。

GC_INNER void GC_mark_and_push_stack(ptr_t p, ptr_t source) {
    hdr * hhdr;
    ptr_t r = p;

    PREFETCH(p);
    GET_HDR(p, hhdr);

    //能取到header信息
    if (NULL == hhdr|| (r = (ptr_t)GC_base(p)) == NULL|| (hhdr = HDR(r)) == NULL)) {
        return;
    }
    //判断空闲状态
    if (HBLK_IS_FREE(hhdr) {
        return;
    }
    ...
    //标记并加入临时数组
    PUSH_CONTENTS_HDR(r, GC_mark_stack_top, GC_mark_stack_limit,
           source, hhdr, FALSE);
}

source对应之前根节点内存地址,变量r对应根节点指向的地址值,通过GET_HDR方法取出r对应的Header信息,如果能取到,说明r位于一个有效的hblk内存块中,进一步判断这个内存块是否空闲,如果不空闲,说明r被分配给上层作为一个有效的对象内存节点来使用,r是一个能被根节点“引用”的托管堆内存节点。

判断逻辑

由此可以总结出判断一个内存地址是否是一个有效的,被引用的托管堆内存节点的逻辑:

  1. 是否在大致范围内GC_least_plausible_heap_addr~ GC_greatest_plausible_heap_addr。

  2. 判断是否能取到header信息,因为每个hblk内存块都对应一个header信息,能取到说明地址位于hblk内存块中。

  3. 判断位于的hblk内存块是否空闲,不空闲说明这个内存块被分配给上层使用。

操作标记位

判定为能被“引用”节点之后,调用PUSH_CONTENTS_HDR方法处理这个节点。

# define PUSH_CONTENTS_HDR(current, mark_stack_top, mark_stack_limit, source, hhdr, do_offset_check) { \
    //计算内存节点位于的hblk内存块地址的偏移值
    size_t displ = HBLKDISPL(current); \
    size_t gran_displ = BYTES_TO_GRANULES(displ); \
    ...
    //将header中对应的标记位设置为1,表示这个节点被“引用”
    SET_MARK_BIT_EXIT_IF_SET(hhdr, gran_displ); \
    ...
    //增加引用个数hb_n_marks
    INCR_MARKS(hhdr); \
    ...
    //将内存节点存入临时数组中
    PUSH_OBJ(base, hhdr, mark_stack_top, mark_stack_limit); \
    ...
}
  1. 计算内存节点地址在hblk内存块中的偏移值,因为按照其上文所述,这个偏移值是Header信息中标记位数组hb_marks[]的下标,Header按照偏移值存储每个内存节点的标记状态信息。例如hblk内存块的地址是0x122c86900,hb_sz=16(节点大小16字节),内存节点A的地址是0x122c86920,则偏移32字节(2个gran),Header->hb_marks[1]存储A的“引用”状态标记位的值0或1。调用INCR_MARKS方法hb_n_marks加1,表示这个hblk内存块中被“引用”的节点总数。如图所示。蓝色表示被“引用”的2个内存节点,相应的hb_marks[1]和hb_marks[2]标记为1,hb_n_marks=2。

    image.png

  2. 调用PUSH_OBJ,将“引用”的内存节点地址加入一个临时数组中,作为接下来的递归扫描的起点。mark_stack_top是一个数组,存放的obj是刚才扫描出的被“引用”的托管堆内存节点地址。

    #define PUSH_OBJ(obj, hhdr, mark_stack_top, mark_stack_limit) \
     do { \
         ...
         mark_stack_top++; \
         ...
         mark_stack_top = GC_signal_mark_stack_overflow(mark_stack_top); \
         mark_stack_top -> mse_start = (obj); \
         mark_stack_top -> mse_descr.w = _descr; \
         } \
     } while (0)
    

堆内存递归扫描

接下来基于遍历mark_stack_top,扫描其内存空间指向的地址值。首先通过MARK_FROM_MARK_STACK宏调用GC_mark_from方法,如下:

GC_INNER mse * GC_mark_from(mse *mark_stack_top, mse *mark_stack, mse *mark_stack_limit) 
{
    current_p = mark_stack_top -> mse_start;
    descr = mark_stack_top -> mse_descr.w;
    ...
    while ((word)current_p <= (word)limit) {
        current = *(word *)current_p;
        ...
        if (current >= (word)least_ha && current < (word)greatest_ha) {
            PREFETCH((ptr_t)current);
            ...
            PUSH_CONTENTS((ptr_t)current, mark_stack_top, mark_stack_limit, current_p);
        }
        current_p += ALIGNMENT;
    }
    ...
}

current_p是当前内存地址的指针,current是current_p指向的内存地址,判断current是否是一个托管堆内存节点,current_p+= ALIGNMENT,逐渐偏移指针,直到地址空间被扫描完。current的判断逻辑是:

  1. 是否在大致范围内GC_least_plausible_heap_addr~ GC_greatest_plausible_heap_addr。

  2. 调用PUSH_CONTENTS方法进一步判断,逻辑如下:

    #define PUSH_CONTENTS(current, mark_stack_top, mark_stack_limit, source) \
     do { \
         hdr * my_hhdr; \
         HC_GET_HDR(current, my_hhdr, source); /* contains "break" */ \
         PUSH_CONTENTS_HDR(current, mark_stack_top, mark_stack_limit, \
            source, my_hhdr, TRUE); \
     } while (0)
    
    • 调用HC_GET_HDR方法获取current所在的hblk内存块对应的header信息。前文提到,和GET_HDR方法不同的是,HC_GET_HDR方法会借助缓存获取header信息,如果有直接返回header,如果没有,调用HEADER_CACHE_MISS方法获取header信息,然后存入缓存,这样做,在频繁获取header信息的时候可以缩短计算时间,提升性能。如果获取不到header信息,则退出本次判断。

      #define HC_GET_HDR(p, hhdr, source) \
      { \
       //获取缓存信息
       hdr_cache_entry * hce = HCE(p); \
       if (EXPECT(HCE_VALID_FOR(hce, p), TRUE)) { \
        HC_HIT(); \
        hhdr = hce -> hce_hdr; \
       } else { \
        //没有缓存,直接获取header,存入缓存
        hhdr = HEADER_CACHE_MISS(p, hce, source); \
        //获取不到header
        if (NULL == hhdr) break; \
       } \
      }
      
    • 如果获取到header信息,则认为指向的地址current也是一个托管堆内存节点,调用PUSH_CONTENTS_HDR方法对齐操作。

    • PUSH_CONTENTS_HDR方法如上文所述,设置了这个节点的标记位,header-> hb_marks[index],并且递增hb_n_marks。最后将其加入临时数组mark_stack_top中,作为下一次递归扫描的对象。

完成了一轮扫描之后,针对当前mark_stack_top数组中收集到的内存节点,按照同样的方式扫描内存地址,对其指向的地址空间进行判断。

当所有扫描结束,托管堆上的被“引用”的内存节点已经在hb_marks全部被标记为1,未“引用”的节点在之前标记位重置的时候,设置为0。GC接下来就是根据这些标记来判断进行垃圾回收的。

线程恢复

当扫描完之后,调用START_WORLD重新恢复所有线程,具体实现在GC_start_world方法中,如下:

GC_INNER void GC_start_world(void)
{
    ...
    n_live_threads = GC_restart_all();
}

STATIC int GC_restart_all(void)
{
    ...
    pthread_t self = pthread_self();
    for (i = 0; i < THREAD_TABLE_SZ; i++) {
    for (p = GC_threads[i]; p != NULL; p = p -> next) {
        if (!THREAD_EQUAL(p -> id, self)) {
            if ((p -> flags & FINISHED) != 0) continue;
            if (p -> thread_blocked) continue;
            ...
            result = RAISE_SIGNAL(p, GC_sig_thr_restart);
            ...
        }
    }
}

和GC_suspend_all方法对应,除了当前线程(以及已结束、阻塞)之外的线程进行恢复,调用RAISE_SIGNAL发送一个恢复的信号。

Reclaim阶段

扫描和标记完成后,开始对未“引用的”托管堆内存节点进行回收。逻辑在GC_finish_collection方法中触发,如下:

STATIC void GC_finish_collection(void)
{
    ...
    //检测内存泄漏逻辑
    if (GC_find_leak) {
        for (kind = 0; kind < GC_n_kinds; kind++) {
            for (size = 1; size <= MAXOBJGRANULES; size++) {
                q = (ptr_t)GC_obj_kinds[kind].ok_freelist[size];
                if (q != NULL)
                GC_set_fl_marks(q);
            }
        }
    //不实际回收
    GC_start_reclaim(TRUE);
    }
    ...
    //清空ok_freelist链表
    for (kind = 0; kind < GC_n_kinds; kind++) {
        for (size = 1; size <= MAXOBJGRANULES; size++) {
            q = (ptr_t)GC_obj_kinds[kind].ok_freelist[size];
            if (q != NULL)
            GC_clear_fl_marks(q);
        }
    }
    //开始回收
    GC_start_reclaim(FALSE);
    ...
}

该方法首先判断,本次操作是否需要检测内存泄漏,如果是,则触发检测泄漏逻辑,检测出的泄漏内存,不实际触发回收逻辑,这块在之后分析。通过GC_clear_fl_marks将ok_freelist链表中的内存节点的标记位设置为0。接下来调用GC_start_reclaim方法开始回收。

回收逻辑

GC_start_reclaim方法的主要逻辑如下:

GC_INNER void GC_start_reclaim(GC_bool report_if_found)
{
    for (kind = 0; kind < GC_n_kinds; kind++) {
        struct hblk ** rlist = GC_obj_kinds[kind].ok_reclaim_list;
        ...
        //清空ok_freelist和ok_reclaim_list
        void **lim = &(GC_obj_kinds[kind].ok_freelist[MAXOBJGRANULES+1]);
        for (fop = GC_obj_kinds[kind].ok_freelist; (word)fop < (word)lim; (*(word **)&fop)++) {
            ...
            GC_clear_fl_links(fop);
        }
        ...
        BZERO(rlist, (MAXOBJGRANULES + 1) * sizeof(void *));
    }

    ...
    //加入ok_reclaim_list链表
    GC_apply_to_all_blocks(GC_reclaim_block, (word)report_if_found);

    ...
    //回收内存
    GC_reclaim_all((GC_stop_func)0, FALSE);
    ...
}
  1. 在回收之前,先将ok_freelist链表中的内存节点清空,因为针对未“引用”的小内存节点(小于2048字节),回收时会将其加入ok_freelist链表中,满足后续的内存分配,因此先清空一下。

  2. 然后将ok_reclaim_list链表也清空。ok_reclaim_list是Header中的字段,是专门针对小内存节点回收设置的,回收的对象位于ok_reclaim_list中,因此需要先清空一下这个链表。

  3. GC_apply_to_all_blocks方法遍历所有托管堆内存的hblk内存块,针对每个内存块,使用GC_reclaim_block方法预处理要回收的内存块,对于大内存直接回收,对于小内存暂时加入ok_reclaim_list链表。

  4. GC_reclaim_all方法实际回收小内存对象。

下面对2~4分别分析。

GC_reclaim_block

GC_reclaim_block方法如下:

STATIC void GC_reclaim_block(struct hblk *hbp, word report_if_found)
{
    hdr * hhdr = HDR(hbp);
    word sz = hhdr -> hb_sz; /* size of objects in current block */
    struct obj_kind * ok = &GC_obj_kinds[hhdr -> hb_obj_kind];
    if( sz > MAXOBJBYTES ) { /* 1 big object */
    if( !mark_bit_from_hdr(hhdr, 0) ) {
        ...
        //直接释放hbp到GC_hblkfreelist链表中
        GC_freehblk(hbp);
    }
    else {
        ...
        GC_bool empty = GC_block_empty(hhdr);
        if (empty) {
            GC_freehblk(hbp);
        } else if (!GC_block_nearly_full(hhdr)) {
            struct hblk **rlh = ok -> ok_reclaim_list;
            if (rlh != NULL) {
                rlh += BYTES_TO_GRANULES(sz);
                hhdr -> hb_next = *rlh;
                *rlh = hbp;
            }
        }
    }
    ...
}

分别对大内存和小内存块进行不同处理:

大内存

大内存比较特殊,因为一个大内存节点一般是由1~N个hblk内存块组成,不像小内存是由一个hblk等分成若干个小内存节点。因此,Header信息的hb_marks数组只存储一个标记位,即hb_marks[0]。

mark_bit_from_hdr(header, bit_no)用于获取header->hb_marks[bit_no]的值,如果是0,则这个大内存节点未被“引用”,调用GC_freehblk方法直接将其释放加入到GC_hblkfreelist链表中,同时把内存中的数据清空。供下次大内存分配使用。

小内存

对于小内存节点,因为提个hblk内存块上可能由若干个节点未“引用”,此时采用分情况处理的策略,有以下3种情况:

  1. 如果整个hblk为empty,即header->hb_n_marks=0,说明这个hblk内存块中没有被“引用”的内存节点,则直接释放整个内存块至GC_hblkfreelist,清空数据。

  2. 调用GC_block_nearly_full方法,判断这个hblk内存块中的节点是否大多数被“引用”,如果不是,则将这个hblk内存块加入待回收链表ok_reclaim_list,ok_reclaim_list和ok_freelist一样,提供128个链表,如图所示。根据内部等分的内存节点的大小,将hblk内存块加入相应的ok_reclaim_list中。例如hblk内部是16字节内存节点,则加入ok_reclaim_list[0]。

    image.png

  3. 如果GC_block_nearly_full,则hblk内存块中大多数内存节点被“引用”,不将这个内存块加入ok_reclaim_list中。

GC_reclaim_all

GC_reclaim_all方法针对小内存对象,将刚才加入ok_reclaim_list中的所有hblk内存块进行处理,回收没有被“引用”的内存节点至ok_freelist链表中。代码逻辑如下:

GC_INNER GC_bool GC_reclaim_all(GC_stop_func stop_func, GC_bool ignore_old)
{
    ...
    for (kind = 0; kind < GC_n_kinds; kind++) {
    ok = &(GC_obj_kinds[kind]);
    rlp = ok -> ok_reclaim_list;
    if (rlp == 0) continue;
    for (sz = 1; sz <= MAXOBJGRANULES; sz++) {
      rlh = rlp + sz;
      while ((hbp = *rlh) != 0) {
        ...
        hhdr = HDR(hbp);
        *rlh = hhdr -> hb_next;
        GC_reclaim_small_nonempty_block(hbp, FALSE);
      }
    }
  }
    ...
}

遍历所有的ok_reclaim_list链表,取出hblk内存块,以及对应的header信息,调用GC_reclaim_small_nonempty_block方法处理,这里有两个参数,第一个参数传hblk内存块的地址,第二个参数report_if_found表示是否仅用于发现,不回收,这里传false,表示需要回收。方法如下:

STATIC void GC_reclaim_small_nonempty_block(struct hblk *hbp, GC_bool report_if_found)
{
  hdr *hhdr = HDR(hbp);
  word sz = hhdr -> hb_sz;
  struct obj_kind * ok = &GC_obj_kinds[hhdr -> hb_obj_kind];
  //ok_freelist链表
  void **flh = &(ok -> ok_freelist[BYTES_TO_GRANULES(sz)]);
  ...

  //发现不回收
  if (report_if_found) {
    GC_reclaim_check(hbp, hhdr, sz);
  } else {
    //回收
    *flh = GC_reclaim_generic(hbp, hhdr, sz, ok -> ok_init,
                 (ptr_t)(*flh), &GC_bytes_found);
  }
}

进入GC_reclaim_generic方法,再调用GC_reclaim_clear方法回收内存,主要逻辑如下:

STATIC ptr_t GC_reclaim_clear(struct hblk *hbp, hdr *hhdr, word sz,
               ptr_t list, signed_word *count)
{
    word bit_no = 0;
    word *p, *q, *plim;
    p = (word *)(hbp->hb_body);
    plim = (word *)(hbp->hb_body + HBLKSIZE - sz);

    while ((word)p <= (word)plim) {
      if (mark_bit_from_hdr(hhdr, bit_no)) {
        p = (word *)((ptr_t)p + sz);
      } else {
          obj_link(p) = list;
          list = ((ptr_t)p);
          q = (word *)((ptr_t)p + sz);
          p++; /* Skip link field */
          while ((word)p < (word)q) {
              *p++ = 0;
          }
     }
     bit_no += MARK_BIT_OFFSET(sz);
   }
   ...
}

遍历hblk内存中的每个内存节点,如果mark_bit_from_hdr为1,表示被标记“引用”,不需要回收,否则将其加入list中,即ok_freelist链表。如图所示:

image.png

到此完成了所有内存节点的回收。

内存泄漏检测

除了回收逻辑,算法还可以检测内存泄漏,泄漏的对象即未被“引用”的托管堆内存节点。在GC_finish_collection方法中,还有一个检测内存泄漏的逻辑,最终也会调用GC_reclaim_small_nonempty_block方法,report_if_found传true,该逻辑只用于标记出那些未“引用”的内存节点,不实际回收他们,加入GC_leaked全局数组中。

总结

这篇文章大致分析了Unity中BoehmGC算法关于对象垃圾回收的实现思路,部分概念在前一篇文章中有所阐述,其中的许多细节还有待进一步学习和挖掘。另外,文章中理解有误之处,希望大家多多指正。