Unity3D托管堆BoehmGC算法学习-内存分配篇

字节跳动

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

引言

Unity3D引擎在管理托管堆内存时,没有直接使用操作系统自带的内存分配函数例如malloc和free来直接分配和释放每一个对象,而是采用一套内存分配GC算法来管理托管堆上所有内存对象的分配和回收。在Unity的1.X到2.X的版本中,基于开源的C++BoehmGC算法,而在3.X或更高版本中,开始启用SGEN算法。采用il2Cpp生成的Unity工程中,使用的是BoehmGC算法。

BoehmGC算法思路

BoehmGC是一种mark-sweep(标记-清扫)算法,大致思路和Java的GC算法类似,主要包含4个阶段:

  • 准备阶段:每个托管堆内存对象在创建出来的时候都有一个关联的标记位,来表示当前对象是否被引用,在标记之前,该标记位是0。

  • 标记阶段(Mark phase):从根部内存节点(静态变量、栈、寄存器)出发,遍历扫描托管堆的内存节点,将被引用的内存节点标记为1。

  • 清扫阶段(Sweep phase):遍历所有节点,将没有被标记的节点的内存数据清空,并且基于一定条件释放。

  • 结束阶段(Finalization phase):触发GC_register_finalizer等注册的回调逻辑。

BoehmGC算法特点

BoehmGC是一个保守式GC算法,与之对应的是准确式GC。所谓保守式GC算法就是其无法分辨一个内存值是一个指针还是非指针,所以会对所有内存当做一个指针做尝试,所以无法使用Copying算法。而准确式GC就是GC可以直接分辨出这是个指针还是非指针。而且可以使用Copying算法优化内存的使用。

本篇主要介绍BoehmGC算法关于内存分配的实现逻辑,下一篇主要介绍该算法关于垃圾回收的实现逻辑。

内存分配流程

内存类型

首先GC_malloc是BoehmGC中用来替换malloc()函数的API,接收一个参数lb,即需要分配的内存大小。 GC_malloc_kind(size_t lb, int k) 接收2个参数,包括需要分配的内存大小,以及类型。

void * GC_malloc(size_t lb)
{
    return GC_malloc_kind(lb, NORMAL);
}

void * GC_malloc_kind(size_t lb, int k)
{
    return GC_malloc_kind_global(lb, k);
}
复制代码

首先这里的参数k,用来区分不同的分配内存类型,k的情况可以有三种类型,分别是PTRFREE,NORMAL,UNCOLLECTABLE等。说明如下:

  • NORMAL:无类型的内存分配,对于GC而言,因为无法得到对象的类型元数据,所以在做GC时会按照指针对齐的方式扫描该内存块,只要发现类似通过指针校验的地址,都会认为该对象引用了该指针地址指向的对象。

  • PTRFREE:无指针内存分配,明确告知GC,该对象内无任何指针信息,即在GC时无需查找该对象内是否引用其他对象。在mono/il2cpp中的int型的数组,byte型的数组,字符串等使用该类型的内存分配。

  • UNCOLLECTABLE:属于BOEHM自己为了辅助实现内存管理而分配的内存,这些内存不需要标记和回收的。

大内存or小内存

首先判断是否大于2048字节,如果小于2048字节,则有一套分配小型内存的流程来处理,否则走大内存分配的流程。

void * GC_malloc_kind_global(size_t lb, int k)
{
    if (SMALL_OBJ(lb)) {
        //小内存分配流程
        ...
    } else {
        //大内存分配流程
    }
}
复制代码

小对象内存分配

尺寸对齐-GRANULE

针对小内存分配,并不是直接分配实际的内存大小,而是分配16字节倍数的内存,保证按照指针对齐的方式管理内存。因此,第一步需要计算出实际需要分配的字节大小。实现思路是,提出一个“粒度(GRANULES)”的概念,即一个GRANULE的大小是16字节。实际分配内存的时候按照GRANULE为基本单位来分配,例如分配2个GRANULE,即分配32字节。分配过程中,按照原始需要的大小,计算并映射得到实际需要分配的GRANULE个数,代码如下:

//lb是原始的分配大小,lg是GRANULE(1~128)。
size_t lg = GC_size_map[lb];
复制代码

例如需要18字节的内存,则lg=2,即实际分配2个GRANULE(32字节),如果需要1字节的内存,则lg=1,即实际分配1个GRANULE(16字节)。

GC_size_map[] 是一个 “尺寸表”, 维护lb->lg的映射关系。GC_size_map最多可以返回128个GRANULE,即小内存的大小上限(128 * 16 = 2048)。GC_size_map数组本身通过懒加载的方式不断扩充。下图反应GRANULE和内存大小的对应关系:

image.png

空闲链表-ok_freeList

决定了GRANULE的大小之后,首先会从ok_freelist链表中查看对应的空闲内存块,如果发现有,则直接返回这块内存,完成分配。算法维护了一个数据结构obj_kind,如下:

struct obj_kind {
    void **ok_freelist;
    struct hblk **ok_reclaim_list;
    ...
} GC_obj_kinds[3];
复制代码

GC_obj_kinds[3]对应上文提到的3种内存类型,分别是PTRFREE,NORMAL,UNCOLLECTABLE,即每种类型都有一个obj_kind结构体信息。

每一个obj_kind结构体都维护了一个ok_freelist二维指针链表,用来存放空闲的内存块。ok_freelist维护128个链表,即链表0~链表127。其中,链表N中的每个内存块的大小是(N+1) * 16,即这里的N等价于之前的GRANULE个数。ok_freelist的数据结构如图:

image.png

首先,根据计算得到的GRANULE=N个数,查找对应大小的链表ok_freelist[N],如果存在且元素个数大于0,取出ok_freelist[N]中第一个内存块地址ok_freelist[N][0]返回,并且将其从链表中移除。操作过程如图:

image.png

对应的逻辑如下:

GC_INNER void * GC_generic_malloc_inner(size_t lb, int k)
{
    struct obj_kind * kind = GC_obj_kinds + k;
    size_t lg = GC_size_map[lb];
    void ** opp = &(kind -> ok_freelist[lg]);
    ...
    lg = GC_size_map[lb];
    //查看freelist的内存块
    opp = &(kind -> ok_freelist[lg]);
    op = *opp;
    //未找到相应的内存块
    if (0 == op) {
        ...
        //从底层内存块链表中查找或创建
        op = GC_allocobj(lg, k);
        if (0 == op)
            returnNULL;
        }
    }
    //将找到的内存块从空闲链表中移除
    *opp = obj_link(op);
    obj_link(op) = 0;
    ...
    //返回内存块地址
    return op;
}
复制代码

ok_freelist链表最初为空,即一开始ok_freelist可用的空闲内存块。如果ok_freelist中没有相应的空闲内存块,则调用GC_allocobj(lg, k)去底层查找可用的内存,代码逻辑如下:

GC_INNER ptr_t GC_allocobj(size_t gran, int kind)
{
    void ** flh = &(GC_obj_kinds[kind].ok_freelist[gran]);
    while (*flh == 0) {
        ENTER_GC();
        GC_collect_a_little_inner(1);
        EXIT_GC();
        if (*flh == 0) {
            GC_new_hblk(gran, kind);
            if (*flh == 0) {
                ENTER_GC();
                    ...
                    GC_collect_a_little_inner(1);
                    ...
                EXIT_GC();
            }
        }
    }
    return (ptr_t)(*flh);
}
复制代码

GC_allocobj的核心逻辑是调用GC_new_hblk(gran, kind)去底层内存池获取内存,并且查看底层内存池中是否分配了空闲的内存块,如果没有则通过系统函数例如malloc分配内存给底层内存池,如果内存迟有,直接取出一块返回。GC_new_hblk的代码逻辑如下:

GC_INNER void GC_new_hblk(size_t gran, int kind)
{
    struct hblk *h; /* the new heap block */
    GC_bool clear = GC_obj_kinds[kind].ok_init;

    /* Allocate a new heap block */
    h = GC_allochblk(GRANULES_TO_BYTES(gran), kind, 0);
    if (h == 0) return;

    /* Build the free list */
    GC_obj_kinds[kind].ok_freelist[gran] =
    GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]);
}
复制代码

可以发现GC_new_hblk的主要逻辑有2步,其一,调用GC_allochblk方法进一步获取内存池中可用的内存块;其二,调用GC_build_fl方法,利用内存池中返回的内存块构建ok_freelist,供上层使用。

核心内存块链表-GC_hblkfreelist

底层内存池的实现逻辑类似ok_freelist,维护了一个空闲内存块链表的指针链表GC_hblkfreelist,和freeList不同的是,这个链表中的内存块的基本单位是4K(4096)字节,即一个page_size的大小。

GC_hblkfreelist共有60个元素,每个元素都是一个链表,例如,GC_hblkfreelist[N]是一个链表,链表中的每个内存块的大小是4K的倍数,但是链表[N]中的每个内存块不一定是等大小的,如下图:

image.png

内存块-hblk、头信息-hblkhdr

首先,链表中每一个内存块,以大小为4096(4KB)的内存块为基本单位,一个4096大小的内存块称为hblk,数据定义如下:

struct hblk {
    char hb_body[HBLKSIZE]; //HBLKSIZE=4096
};
复制代码

每个hblk都有相应的一个header信息,用于描述这个内存块的状况,数据的定义如下:

//头部信息
struct hblkhdr {
    struct hblk * hb_next; //指向下一个hblk
    struct hblk * hb_prev; //指向上一个hblk
    struct hblk * hb_block; //对应的hblk
    unsigned char hb_obj_kind; //kink类型
    unsigned char hb_flags; //标记位
    word hb_sz; //如果给上层使用,则表示实际分配的单位,如果空闲,则表示内存块的大小
    word hb_descr; 
    size_t hb_n_marks;//标记位个数,用于GC
    word hb_marks[MARK_BITS_SZ]; //标记为,用于GC
}
复制代码

当每个内存块创建出来的时候,都会生成一个相应的header信息,并且header信息以某种hash算法存储在一个全局队列中,header信息是关键的数据结构,GC能否正常运行全靠这个块信息描述。header的存取逻辑将在后文中讲解。

hb_next表示hblkfreelist链表中下一个内存块,hb_block指向对应的内存块,hb_obj_kind表示内存块类型,hb_flags是标记位,用于记录一些信息。hb_sz表示这个内存块本身的大小。

内存块和header的信息如下图:

image.png

hblk内存块查找

了解了数据结构之后,查看GC_allochblk方法的代码,逻辑如下:

structh blk *GC_allochblk(size_t sz, int kind, unsigned flags/* IGNORE_OFF_PAGE or 0 */)
{
    ...
    //1.计算需要的内存块大小
    blocks_needed = OBJ_SZ_TO_BLOCKS_CHECKED(sz);
    start_list = GC_hblk_fl_from_blocks(blocks_needed);

    //2.查找精确的hblk内存块
    result = GC_allochblk_nth(sz, kind, flags, start_list, FALSE);
    if (0 != result) return result;

    may_split = TRUE;
    ...
    if (start_list < UNIQUE_THRESHOLD) {
        ++start_list;
    }
    //3.从更大的内存块链表中找
    for (; start_list <= split_limit; ++start_list) {
        result = GC_allochblk_nth(sz, kind, flags, start_list, may_split);
        if (0 != result) break;
    }
    return result;
}

STATIC int GC_hblk_fl_from_blocks(word blocks_needed)
{
    if (blocks_needed <= 32) return blocks_needed;
    if (blocks_needed >= 256) return (256-32)/8+32;
    return (blocks_needed-32)/8+32;
}
复制代码

步骤如下:

  1. 首先根据上层需要分配的内存大小,计算需要的内存块大小,例如上层根据GC_size_map计算出,需要分配16字节,则sz=16,OBJ_SZ_TO_BLOCKS_CHECKED的计算公式是:

    OBJ_SZ_TO_BLOCKS_CHECKED(lb) = divHBLKSZ(lb + HBLKSIZE - 1)
                             = divHBLKSZ(lb + 4096 - 1) / 4096
    复制代码

    结果是需要分配的HBLK内存块个数,因为GC_hblkfreelist链表是以4096的大小作为基本单位。例如小于4096字节,结果是1。对于小对象(小于2048字节),内存块个数是1。

  2. GC_hblk_fl_from_blocks进一步处理,GC_hblk_fl_from_blocks是一个关键的方法,根据实际需要的内存块数(blocks_needed),判断并决定从哪一个GC_hblkfreelist链表查找,start_list是开始查找的链表index,即从GC_hblkfreelist[start_list]开始查找。因为并不是需要blocks,就一定会从GC_hblkfreelist[blocks]的链表中查找,而是遵循一定的转换规则,具体如下:

    1. 如果blocks_needed小于32,则startlist=blocks_needed,直接去GC_hblkfreelist[blocks_needed]中查找。

    2. 如果blocks_needed位于32~256,则startlist=(blocks_needed-32)/8+32,即blocks_needed每增加8个,对应GC_hblkfreelist[index]的index增加1。

    3. 如果blocks_needed大于256,则都从GC_hblkfreelist[60]链表中查找。

    之所以采取这样规则,是因为链表的总个数有限,一共只有60个。与之相对应的,在创建内存块构建链表时,也会按照GC_hblk_fl_from_blocks的规则将对应大小的内存块加入相应级别的GC_hblkfreelist链表中。链表结构如下图:

    image.png

    左边的数字是链表的级别,可以看到从GC_hblkfreelist[32]开始,存储的内存块大小不等相差,例如GC_hblkfreelist[32]存储的内存块大小范围是128KB~156KB。

  3. 决定从哪个链表开始查找之后,首先进行精确查找,如果直接找到,则直接返回找到的内存块。

  4. 如果精准查找失败,则逐渐增大start_list,从更大的内存块链表中查找。

    GC_allochblk_nth方法是核心的内存块查找方法,代码较复杂,主要逻辑如下:

    STATIC struct hblk *GC_allochblk_nth(size_t sz, int kind, unsigned flags, int n, int may_split)
    {
        struct hblk *hbp;
        hdr * hhdr;
        struct hblk *thishbp;
        hdr * thishdr;/* Header corr. to thishbp */
        //计算需要分配的内存块大小
        signed_word size_needed = HBLKSIZE * OBJ_SZ_TO_BLOCKS_CHECKED(sz);
    
        //从链表中查找合适的内存块
        for (hbp = GC_hblkfreelist[n];; hbp = hhdr -> hb_next) {
            signed_word size_avail;
            if (NULL == hbp) return NULL;
            //获取内存块的header信息
            GET_HDR(hbp, hhdr);
            //内存块大小
            size_avail = (signed_word)hhdr->hb_sz;
            if (size_avail < size_needed) continue;
            //可用内存大于需要的分配的大小
            if (size_avail != size_needed) {
                //要求精准不分割,退出循环,返回空
                if (!may_split) continue;
                ...
                if( size_avail >= size_needed ) {
                    ...
                    //分割内存块,修改链表
                    hbp = GC_get_first_part(hbp, hhdr, size_needed, n);
                    break;
                }
            }
        }
        if (0 == hbp) return 0;
        ...
        //修改header信息
        setup_header(hhdr, hbp, sz, kind, flags)
        ...
        return hbp;
    }
    复制代码

    主要逻辑分为以下几步:

    1. 入参sz是上层需要分配的大小,例如16字节,king是内存类型。例如NORMAL,n是链表GC_hblkfreelist的index,即之前计算出来的内存块index,may_split表示是否允许拆分,如果不允许拆分,则不允许查找更大的内存块。

    2. 计算需要分配的内存,这里重新计算一次,例如小内存块sz=16字节,则size_needed=4096。

    3. 遍历查找GC_hblkfreelist[n]的链表中的内存块,由GC_allochblk方法可知,第一次查找是精确查找,例如sz=16,则start_list计算是1,即size_needed=4096,则遍历GC_hblkfreelist[1]的链表,链表中的内存块的大小是4096。如果发现有,停止遍历。

    4. 如果GC_hblkfreelist[1]的链表中不存在空闲的4KB内存块,逐步从更大的链表中查找,如果发现有,则停止遍历,并且将这个大内存块拆分成2份,取走前半部分想要的内存块。例如找了一个8KB的内存块,拆分这个内存块,过程如图: image.png 找到8KB的内存块,首先将其从8KB链表中删除。因为只需要4KB的内存块,所以将8KB内存块拆分成2半,前半部分用于返回,并且通过修改flag标记~FREE_BLK,标记为非空闲状态。后半部分是新生成的,同时会生成相应的header信息,并加入4KB内存块链表,即GC_hblkfreelist[1],代码逻辑如下:

      STATIC struct hblk *GC_get_first_part(struct hblk *h, hdr *hhdr, size_t bytes, int index) {
          word total_size = hhdr -> hb_sz;
          struct hblk * rest;
          hdr * rest_hdr;
          //从空闲链表删除
          GC_remove_from_fl_at(hhdr, index);
          if (total_size == bytes) return h;
          //后半部分
          rest = (struct hblk *)((word)h + bytes);
          //生成header信息
          rest_hdr = GC_install_header(rest);
          //内存块大小
          rest_hdr -> hb_sz = total_size - bytes;
          rest_hdr -> hb_flags = 0;
          ...
          //加入相应的空闲链表
          GC_add_to_fl(rest, rest_hdr);
      }
      复制代码
    5. 修改这个内存块对应的header信息,通过修改header重置信息,用于后续的判断。例如修改hb_sz字段,因为实际使用为16字节,则hb_sz改为16,hb_block字段设置为对应的内存块地址,同时清空hb_marks和hb_n_marks字段,用于后续的GC标记。

      static GC_bool setup_header(hdr * hhdr, struct hblk *block, size_t byte_sz,
      int kind, unsigned flags) {
          hhdr -> hb_sz = byte_sz;
          hhdr -> hb_obj_kind = (unsignedchar)kind;
          hhdr -> hb_flags = (unsignedchar)flags;
          hhdr -> hb_block = block;
          descr = GC_obj_kinds[kind].ok_descriptor;
          if (GC_obj_kinds[kind].ok_relocate_descr) descr += byte_sz;
          hhdr -> hb_descr = descr;
          ...
          GC_clear_hdr_marks(hhdr);
          hhdr -> hb_last_reclaimed = (unsignedshort)GC_gc_no;
          return(TRUE);
      }
      复制代码

内存块分配

如果GC_hblkfreelist空闲链表中找不到合适的内存块,则考虑从系统开辟一段新的内存,并添加到GC_hblkfreelist链表中。在GC_expand_hp_inner方法中实现:

GC_INNER GC_bool GC_expand_hp_inner(word n)
{
    ...
    //调用系统方式开辟内存
    space = GET_MEM(bytes);
    //记录内存地址和大小
    GC_add_to_our_memory((ptr_t)space, bytes);
    ...
    //添加到GC_hblkfreelist链表中
    GC_add_to_heap(space, bytes);
    ...
}
复制代码
  1. GET_MEM是一个宏,对于不同的操作系统,分配内存的接口不一样,该方法会根据操作系统平台调用相应的系统接口,例如malloc分配内存。

  2. GC_add_to_our_memory方法将这次分配的内存放到一个全局数组中维护,通过这个数组,可以查询到分配的内存块信息。

    # define GC_our_memory GC_arrays._our_memory
    复制代码
  3. GC_add_to_heap方法将创建出来的内存块加入相应的GC_hblkfreelist链表中。同时加入一个全局的存放堆内存信息的数组中。

    GC_INNER void GC_add_to_heap(struct hblk *p, size_t bytes)
    {
        ...
        //生成header信息
        phdr = GC_install_header(p);
        //堆内存信息记录
        GC_heap_sects[GC_n_heap_sects].hs_start = (ptr_t)p;
        GC_heap_sects[GC_n_heap_sects].hs_bytes = bytes;
        GC_n_heap_sects++;
        //更新hb_sz和hb_flags
        phdr -> hb_sz = bytes;
        phdr -> hb_flags = 0;
        //加入链表中
        GC_freehblk(p);
        ...
        //更新堆内存地址的范围,用于后续GC
        GC_collect_at_heapsize += bytes;
        GC_least_plausible_heap_addr = (void *)((ptr_t)p - sizeof(word));
        GC_greatest_plausible_heap_addr = (void *)endp;
    }
    复制代码

    其中GC_freehblk方法有一些特殊的逻辑,发现内存连续的前后内存块是否存在且空闲,则合并前后的内存块,生成一个更大的内存块。

    GC_INNER void GC_freehblk(struct hblk *hbp)
    {
        struct hblk *next, *prev;
        hdr *hhdr, *prevhdr, *nexthdr;
        word size;
    
        GET_HDR(hbp, hhdr);
        size = HBLKSIZE * OBJ_SZ_TO_BLOCKS(hhdr->hb_sz);
        hhdr->hb_sz = size;
        hhdr -> hb_flags |= FREE_BLK;
        next = (struct hblk *)((ptr_t)hbp + size);
        GET_HDR(next, nexthdr);
        prev = GC_free_block_ending_at(hbp);
        //合并next
        if (0 != nexthdr && HBLK_IS_FREE(nexthdr)) {
            GC_remove_from_fl(nexthdr);
            hhdr -> hb_sz += nexthdr -> hb_sz;
            GC_remove_header(next);
        }
        //合并prev
        if (0 != prev) {
            prevhdr = HDR(prev);
            GC_remove_from_fl(prevhdr);
            prevhdr -> hb_sz += hhdr -> hb_sz;
            GC_remove_header(hbp);
            hbp = prev;
            hhdr = prevhdr;
        }
        ...
        //加入空闲链表
        GC_add_to_fl(hbp, hhdr);
    }
    复制代码

    其中GC_add_to_fl方法负责将新分配的内存块添加到对应的空闲链表GC_hblkfreelist,代码如下:

    STATIC void GC_add_to_fl(struct hblk *h, hdr *hhdr)
    {
        int index = GC_hblk_fl_from_blocks(divHBLKSZ(hhdr -> hb_sz));
        ...
        GC_hblkfreelist[index] = h;
        ...
    }
    复制代码

    GC_hblk_fl_from_blocks方法如上文所述,决定创建的内存块放到哪一个GC_hblkfreelist链表中管理。

hblk内存块->ok_freeList

得到了合适的内存块并返回给上层,需要利用这个内存块构建上层的空闲链表ok_freelist。在GC_new_hblk中调用GC_build_fl方法构建链表。

//构建ok_freelist[gran]
GC_obj_kinds[kind].ok_freelist[gran] = GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]);

GC_INNER ptr_t GC_build_fl(struct hblk *h, size_t sz, GC_bool clear,
ptr_t list) {
    word *p, *prev;
    word *last_object;/* points to last object in new hblk*/
    ...
    //构建链表
    p = (word *)(h -> hb_body) + sz;/* second object in *h*/
    prev = (word *)(h -> hb_body);/* One object behind p*/
    last_object = (word *)((char *)h + HBLKSIZE);
    last_object -= sz;
    while ((word)p <= (word)last_object) {
        /* current object's link points to last object */
        obj_link(p) = (ptr_t)prev;
        prev = p;
        p += sz;
    }
    p -= sz;

    //拼接之前的链表
    *(ptr_t *)h = list;
    //返回入口地址
    return ((ptr_t)p);
}
复制代码

划分构建ok_freelist的流程如图所示:

image.png

以4096字节的内存块划分为16字节单元的freeList为例,步骤如下:

  1. 4096字节按照16字节分给,划分为256个小内存块,编号是0~255,将最后一个内存块(255)作为新链表的首节点。

  2. 内存地址向前遍历,建立链表,即255的下一个节点是254,尾节点是0。

  3. 将尾节点的下一个节点指向原链表的首地址。

  4. 将新链表的首节点地址作为ok_freelist[N],N是上文提到的GRANULE,例如16字节对应1。

重建好的freeList,并将首节点提供给上层使用。

大对象内存分配

不同于分配小内存对象(2048字节以内),分配大内存对象是指分配的内存大于2048字节,回到GC_generic_malloc方法:

GC_API GC_ATTR_MALLOC void * GC_CALLGC_generic_malloc(size_t lb, int k)
{
    ...
    if (SMALL_OBJ(lb)) {
    }
    else {
        lg = ROUNDED_UP_GRANULES(lb);
        lb_rounded = GRANULES_TO_BYTES(lg);
        //计算hblk内存块的个数
        n_blocks = OBJ_SZ_TO_BLOCKS(lb_rounded);
        result = (ptr_t)GC_alloc_large(lb_rounded, k, 0);
        ...
    }
    retrun op;
}
复制代码

OBJ_SZ_TO_BLOCKS用于计算需要的hblk内存块的个数,对于大内存,需要的个数大于等于1。例如需要分配9000字节的内存,则需要3个hblk内存块,然后调用GC_alloc_large分配内存。

GC_alloc_large方法和注释如下:

GC_INNER ptr_t GC_alloc_large(size_t lb, int k, unsigned flags)
{
    struct hblk * h;
    word n_blocks;
    ptr_t result;
    ...
    n_blocks = OBJ_SZ_TO_BLOCKS_CHECKED(lb);
    ...
    //分配内存
    h = GC_allochblk(lb, k, flags);
    ...
    //分配失败,系统分配内存块后继续尝试分配
    while (0 == h && GC_collect_or_expand(n_blocks, flags != 0, retry)) {
        h = GC_allochblk(lb, k, flags);
        retry = TRUE;
    }
    //记录大内存创建大小
    size_t total_bytes = n_blocks * HBLKSIZE;
    ...
    GC_large_allocd_bytes += total_bytes;
    ...
    result = h -> hb_body;
    //返回内存地址
    return result;
}
复制代码

lb是需要分配的内存大小,然后回到了GC_allochblk核心方法的调用。

查找空闲内存快

重新看一下GC_allochblk方法的实现。

struct hblk * GC_allochblk(size_t sz, int kind, unsigned flags)
{
    word blocks;
    int start_list;
    struct hblk *result;
    int may_split;
    ...
    //计算需要的HBLK内存块个数
    blocks = OBJ_SZ_TO_BLOCKS_CHECKED(sz);
    ...
    //处理个数
    start_list = GC_hblk_fl_from_blocks(blocks);
    //精确分配
    result = GC_allochblk_nth(sz, kind, flags, start_list, FALSE);
    if (0 != result) return result;
    ...
    //从更大的内存中查找
    for (; start_list <= split_limit; ++start_list) {
        result = GC_allochblk_nth(sz, kind, flags, start_list, may_split);
        if (0 != result) break;
    }
    return result;
}
复制代码

和小内存对象查找内存块的逻辑一样,首先通过GC_hblk_fl_from_blocks计算得到start_list,即从链表GC_hblkfreelist[start_list]开始查找,如果找不到,则不断增加start_list,从更大的链表中查找。

GC_allochblk_nth的逻辑和小内存查找一样,不同的是,大内存块分配好后,不会进一步用于构建ok_freeList链表,而是直接返回大内存块的地址。

内存分配总体流程

综上,内存分配的流程图如下:

image.png

Header信息存储逻辑

如上文所述,对于每一个从GC_hblkfreelist中的内存块,都有一个与之对应的Header数据,用于描述这个内存块的信息,这个header信息也是GC算法逻辑实现的关键。分配了的内存块和Header之后,通过SET_HDR方法存储header信息,相应的GET_HDR方法可以找到header信息。

二维结构

Header信息存放在一个全局的二级数组中,全局信息的数据结构如下:

#define GC_top_index GC_arrays._top_index

#ifndef HASH_TL
    # define LOG_TOP_SZ (WORDSZ - LOG_BOTTOM_SZ - LOG_HBLKSIZE)
#else
    # define LOG_TOP_SZ 11
#endif

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

typedef struct bi {
    hdr * index[BOTTOM_SZ];
    structbi * asc_link; /* All indices are linked in*/
    /* ascending order... */
    structbi * desc_link;/* ... and in descending order. */
    wordkey; /* high order address bits. */
    # ifdef HASH_TL
        structbi * hash_link;/* Hash chain link. */
    # endif
} bottom_index;
复制代码

全局对象GC_arrays维护了数组_top_index,包含一组bottom_index指针,每个bottom_index维护了一个数组index,里面存放各个Header数据。

image.png

计算规则

对于一个内存块地址p,通过hash算法,计算出存储header的位置。流程如下:

  1. 已知一个64位的内存块地址p,将其分为3段,利用每一个段的信息确定其位置。如图所示: image.png 内存地址从高到低分别是,高42位,22~63位,用于计算top_index数组中的下标index。

    确定了bottom_index之后,内存地址12~22位,用于计算bottom_index中index数组的下标。

    内存地址0~12,用于计算具体的对象内存地址在这个内存块中的索引。

    此外,算法设置了一个宏HASH_TL,开启和关闭这个宏,算法逻辑略有差异,以开启HASH_TL宏为例,逻辑如下:

    #ifndef HASH_TL
        # define LOG_TOP_SZ (WORDSZ - LOG_BOTTOM_SZ - LOG_HBLKSIZE)
    #else
        # define LOG_TOP_SZ 11
    #define TOP_SZ (1 << LOG_TOP_SZ)
    
    #define HDR_FROM_BI(bi, p) ((bi)->index[((word)(p) >> LOG_HBLKSIZE) & (BOTTOM_SZ - 1)])
    
    # define TL_HASH(hi) ((hi) & (TOP_SZ - 1))
    
    # define GET_BI(p, bottom_indx) \
    do { \
        REGISTER word hi = (word)(p) >> (LOG_BOTTOM_SZ + LOG_HBLKSIZE); \
        REGISTER bottom_index * _bi = GC_top_index[TL_HASH(hi)]; \
        while (_bi -> key != hi && _bi != GC_all_nils) \
            _bi = _bi -> hash_link; \
            (bottom_indx) = _bi; \
        } while (0)
    
    # define GET_HDR_ADDR(p, ha) \
        do { \
            REGISTER bottom_index * bi; \
            GET_BI(p, bi); \
            (ha) = &HDR_FROM_BI(bi, p); \
        } while (0)
    
    # define GET_HDR(p, hhdr) \
        do { \
            REGISTER hdr ** _ha; \
            GET_HDR_ADDR(p, _ha); \
            (hhdr) = *_ha; \
        } while (0)
    
    # define SET_HDR(p, hhdr) \
        do { \
            REGISTER hdr ** _ha; \
            GET_HDR_ADDR(p, _ha); \
            *_ha = (hhdr); \
        } while (0)
    复制代码
    1. 其中GET_BI (p, bi) 用于计算topIndex数组中的下标,即计算得到对应的bottom_indx对象,结合上图可以看到,通过 (p) >> (LOG_BOTTOM_SZ + LOG_HBLKSIZE) 向右偏移22位,得到高42位,然后通过TL_HASH(hi),取其中的11位,作为hash值,定位到GC_top_index[hash],由于不同内存地址的中间11位可能相同,因此维护一个hash链表,从GC_top_index[hash]作为链表的入口,然后遍历匹配bi->key,key存放的是TL_HASH之前的42位信息。匹配到之后,定位具体的bottom_indx信息。

    2. 调用HDR_FROM_BI(bi, p) 方法,取内存地址12~22位,作为hash值,定位bi->index[hash]作为header信息的存放地址。

    3. 同时还提供了2个方法,

      # define HBLKSIZE 4096
      # define HBLKPTR(objptr) ((struct hblk *)(((word)(objptr)) & ~(word)(HBLKSIZE-1)))
      # define HBLKDISPL(objptr) (((size_t) (objptr)) & (HBLKSIZE-1))
      复制代码
      1. HBLKPTR用于返回当前对象内存地址对应的hblk内存块的首地址,因为根据上文,分配内存的基本单位都是hblk,大小是4096字节,即分配的内存一定是4096的倍数,因此和“~(HBLKSIZE-1)”进行“按位与操作”可以得到内存对象属于的那个hblk地址,对象p的地址位于hblk中。

      2. HBLKDISPL用于返回当前对象内存地址在hblk内存块中的偏移量。因为有效的对象内存地址一定在一个hblk中,一个hblk的内存大小是0~4096,12位,因此和“HBLKSIZE-1”进行“按位与操作”可以得到偏移量。

以16字节的小内存对象为例,删除一个4KB的hblk可以分配256个这样的对象,则对象N的内存地址是HBLK的首地址+16*N。

辅助链表

另外还维护了一个一维双向链表来辅助管理所有的bottom_index数据,在分配内存块,生成header信息时,将新的bottom_index节点插入链表中合适的位置。按照链表是有序链表,bottom_index节点按照bottom_index->key字段值递增,key字段存的是前42位地址,因此内存地址越大,链表中的位置越靠后。如图所示:

image.png

插入新的bottom_index节点到链表的过程如下:

static GC_bool get_index(word addr)
{
    word hi = (word)(addr) >> (LOG_BOTTOM_SZ + LOG_HBLKSIZE);
    bottom_index * r;
    bottom_index * p;
    bottom_index ** prev;
    bottom_index *pi; /* old_p */
    word i;
    # ifdef HASH_TL
        i = TL_HASH(hi);
        pi = p = GC_top_index[i];
        while(p != GC_all_nils) {
            if (p -> key == hi) return(TRUE);
            p = p -> hash_link;
        }
    # else
        if (GC_top_index[hi] != GC_all_nils)
            return TRUE;
        i = hi;
    # endif
    //创建bottom_index节点
    r = (bottom_index *)GC_scratch_alloc(sizeof(bottom_index));
    if (EXPECT(NULL == r, FALSE))
        return FALSE;
    //初始化清空数据
    BZERO(r, sizeof(bottom_index));
    r -> key = hi;
    # ifdef HASH_TL
    //r放置在GC_top_index[i]中第一个位置
        r -> hash_link = pi;
    # endif

    prev = &GC_all_bottom_indices;
    pi = 0; 
    //查找第一个大于hi的节点p
    while ((p = *prev) != 0 && p -> key < hi) {
        pi = p;
        prev = &(p -> asc_link);
    }
    //将r插入链表中
    r -> desc_link = pi;
    if (0 == p) {
        GC_all_bottom_indices_end = r;
    } else {
        p -> desc_link = r;
    }
    r -> asc_link = p;
    *prev = r;
    //r放置在GC_top_index[i]中第一个位置
    GC_top_index[i] = r;
    return(TRUE);
}
复制代码

缓存结构

维护了一个缓存结构,存储一个内存块及其对应的header信息,数据结构如下:

typedef struct hce {
    word block_addr; //内存块地址
    hdr * hce_hdr; //header地址
} hdr_cache_entry;
复制代码

查找header信息时,也可以通过先查找缓存的方式增加效率。通过宏HC_GET_HDR封装。

#define HC_GET_HDR(p, hhdr, source) \
{ /* cannot use do-while(0) here */ \
    hdr_cache_entry * hce = HCE(p); \
    if (EXPECT(HCE_VALID_FOR(hce, p), TRUE)) { \
        HC_HIT(); \
        hhdr = hce -> hce_hdr; \
    } else { \
        hhdr = HEADER_CACHE_MISS(p, hce, source); \
    if (NULL == hhdr) break; /* go to the enclosing loop end */ \
    } \
}
复制代码

如果找到,直接返回,找不到再通过GC_header_cache_miss方法查找。GC_header_cache_miss方法通过HDR(p)方法查找header信息。

总结

这篇文章大致分析了Unity中BoehmGC算法关于对象内存分配的实现思路,下一篇文章将学习和分析GC的实现。其中的许多细节还有待进一步学习和挖掘。另外,文章中理解有误之处,希望大家多多指正。

文章分类
iOS