zoukankan      html  css  js  c++  java
  • linux源码解读(十):内存管理——内存分配和释放关键函数分析&ZGC垃圾回收

      上文介绍了buddy和slab内存管理的思路,本文看看这些算法的关键代码都是怎么写的,这里用的是4.9版本的源码;重新把这个图贴出来,方便后续理解代码!

            

      1、如上图所示,slab算法的入口就是kmem_cache结构体了,和其他重要结构体管理的方式类似,这里也统一采用数组的形式管理所有的kmem_cache结构体,所以也会根据用户输入的参数从数组里面找到合适的kmem_cache结构体,如下:

    /*
     * Conversion table for small slabs sizes / 8 to the index in the
     * kmalloc array. This is necessary for slabs < 192 since we have non power
     * of two cache sizes there. The size of larger slabs can be determined using
     * fls.
     * 通过size_index_elem求出8的倍数后,继续查找kmem_cache的index;
     * 从这里可以直观看到用户申请不同size内存对应的kmem_cache的index
     */
    static s8 size_index[24] = {
        3,    /* 8 */
        4,    /* 16 */
        5,    /* 24 */
        5,    /* 32 */
        6,    /* 40 */
        6,    /* 48 */
        6,    /* 56 */
        6,    /* 64 */
        1,    /* 72 */
        1,    /* 80 */
        1,    /* 88 */
        1,    /* 96 */
        7,    /* 104 */
        7,    /* 112 */
        7,    /* 120 */
        7,    /* 128 */
        2,    /* 136 */
        2,    /* 144 */
        2,    /* 152 */
        2,    /* 160 */
        2,    /* 168 */
        2,    /* 176 */
        2,    /* 184 */
        2    /* 192 */
    };
    /*
    8字节对齐,也就是8的多少倍?
    */
    static inline int size_index_elem(size_t bytes)
    {
        return (bytes - 1) / 8;
    }
    
    /*
     * Find the kmem_cache structure that serves a given size of
     * allocation
     */
    struct kmem_cache *kmalloc_slab(size_t size, gfp_t flags)
    {
        int index;
    
        if (unlikely(size > KMALLOC_MAX_SIZE)) {
            WARN_ON_ONCE(!(flags & __GFP_NOWARN));
            return NULL;
        }
        /*如果用户申请的size小于192byte*/
        if (size <= 192) {
            if (!size)
                return ZERO_SIZE_PTR;
    
            index = size_index[size_index_elem(size)];
        } else
        /*如果用户申请的size大于192byte*/
            index = fls(size - 1);
    
    #ifdef CONFIG_ZONE_DMA
        if (unlikely((flags & GFP_DMA)))
            return kmalloc_dma_caches[index];
    
    #endif
        return kmalloc_caches[index];
    }

      上面的size_index数组看起来可能还有些抽象,这里有个更直接的映射表:object的size和kmem_cache的index、名字之间的映射关系,比如用户申请96byte的内存,那么这个kmem_cache的index就是1,和size_index数组刚好吻合;

    /*
     * kmalloc_info[] is to make slub_debug=,kmalloc-xx option work at boot time.
     * kmalloc_index() supports up to 2^26=64MB, so the final entry of the table is
     * kmalloc-67108864.
     */
    static struct {
        const char *name;
        unsigned long size;
    } const kmalloc_info[] __initconst = {
        {NULL,                      0},        {"kmalloc-96",             96},
        {"kmalloc-192",           192},        {"kmalloc-8",               8},
        {"kmalloc-16",             16},        {"kmalloc-32",             32},
        {"kmalloc-64",             64},        {"kmalloc-128",           128},
        {"kmalloc-256",           256},        {"kmalloc-512",           512},
        {"kmalloc-1024",         1024},        {"kmalloc-2048",         2048},
        {"kmalloc-4096",         4096},        {"kmalloc-8192",         8192},
        {"kmalloc-16384",       16384},        {"kmalloc-32768",       32768},
        {"kmalloc-65536",       65536},        {"kmalloc-131072",     131072},
        {"kmalloc-262144",     262144},        {"kmalloc-524288",     524288},
        {"kmalloc-1048576",   1048576},        {"kmalloc-2097152",   2097152},
        {"kmalloc-4194304",   4194304},        {"kmalloc-8388608",   8388608},
        {"kmalloc-16777216", 16777216},        {"kmalloc-33554432", 33554432},
        {"kmalloc-67108864", 67108864}
    };

      最关键的就是index的计算方法了;小于192byte直接查表,大于192byte调用fls,find last bit set函数查找index,代码如下:

    /*
     * fls = Find Last Set in word
     * @result: [1-32]
     * fls(1) = 1, fls(0x80000000) = 32, fls(0) = 0
     */
    static inline __attribute__ ((const)) int fls(unsigned long x)
    {
        if (__builtin_constant_p(x))
               return constant_fls(x);
    
        return 32 - clz(x);
    }
    
    /*
     * Count the number of zeros, starting from MSB
     * Helper for fls( ) friends
     * This is a pure count, so (1-32) or (0-31) doesn't apply
     * It could be 0 to 32, based on num of 0's in there
     * clz(0x8000_0000) = 0, clz(0xFFFF_FFFF)=0, clz(0) = 32, clz(1) = 31
     */
    static inline __attribute__ ((const)) int clz(unsigned int x)
    {
        unsigned int res;
    
        __asm__ __volatile__(
        "    norm.f  %0, %1        \n"
        "    mov.n   %0, 0        \n"
        "    add.p   %0, %0, 1    \n"
        : "=r"(res)
        : "r"(x)
        : "cc");
    
        return res;
    }

      核心作用是返回输入参数的最高有效bit位(从低位往左数最后的有效bit位)的序号,该序号与常规0起始序号不同,它是1起始的(当没有有效位时返回0);

      要点:

    •   kmem_cachess数组最多13个元素,也就是说最多有13种不同size的object;
    •        如果用户申请的size小于192byte,直接查size_index数组就可以找到kmem_cache的index
    •        如果用户申请的size大于192byte,返回输入参数的最高有效bit位(从低位往左数最后的有效bit位)的序号

      2、kmem_cache得到后,下一步就是找到slab了,linux是这样干的:

    static __always_inline void *
    slab_alloc(struct kmem_cache *cachep, gfp_t flags, unsigned long caller)
    {
        unsigned long save_flags;
        void *objp;
    
        flags &= gfp_allowed_mask;
        cachep = slab_pre_alloc_hook(cachep, flags);
        if (unlikely(!cachep))
            return NULL;
    
        cache_alloc_debugcheck_before(cachep, flags);
        /*保存flag寄存器的状态,然后关中断,避免被中断切换进程*/
        local_irq_save(save_flags);
        /*得到了返回值*/
        objp = __do_cache_alloc(cachep, flags);
        /*重新打开中断*/
        local_irq_restore(save_flags);
        objp = cache_alloc_debugcheck_after(cachep, flags, objp, caller);
        prefetchw(objp);
    
        if (unlikely(flags & __GFP_ZERO) && objp)
            memset(objp, 0, cachep->object_size);
    
        slab_post_alloc_hook(cachep, flags, 1, &objp);
        return objp;
    }

      函数的返回值objp是调用_do_cache_alloc返回的,重点继续分析这个函数(其他函数都是陪衬┭┮﹏┭┮);经过一系列的调用链,最终来到了这里:返回值是objp,来自两个地方:先检查cpu的缓存(也就是array_cache数组),如果还有就从缓存数组分配;如果没有就继续调用cache_alloc_refill从 3 个 slabs_(free/partial/full)中寻找可用的object,并将其填充到 array_cache 中;

    static inline void *____cache_alloc(struct kmem_cache *cachep, gfp_t flags)
    {
        void *objp;
        struct array_cache *ac;
    
        check_irq_off();
        /*先看array_cache缓存是不是还有*/
        ac = cpu_cache_get(cachep);
        if (likely(ac->avail)) {
            ac->touched = 1;
            objp = ac->entry[--ac->avail];
    
            STATS_INC_ALLOCHIT(cachep);
            goto out;
        }
    
        STATS_INC_ALLOCMISS(cachep);
        /*array_cache缓存没了再重新寻找可用的object*/
        objp = cache_alloc_refill(cachep, flags);
        /*
         * the 'ac' may be updated by cache_alloc_refill(),
         * and kmemleak_erase() requires its correct value.
         */
        ac = cpu_cache_get(cachep);
    
    out:
        /*
         * To avoid a false negative, if an object that is in one of the
         * per-CPU caches is leaked, we need to make sure kmemleak doesn't
         * treat the array pointers as a reference to the object.
         */
        if (objp)
            kmemleak_erase(&ac->entry[ac->avail]);
        return objp;
    }

       和objp相关的两个函数都操作了arry_cache,这究竟是个啥结构体了?如下:

    /*
     * struct array_cache
     *
     * Purpose:
     * - LIFO ordering, to hand out cache-warm objects from _alloc
     * - reduce the number of linked list operations
     * - reduce spinlock operations
     *
     * The limit is stored in the per-cpu structure to reduce the data cache
     * footprint.
     * 给每个cpu设置的对象缓存池
     */
    struct array_cache {
        unsigned int avail;/*缓存池中可用的object数量*/
        unsigned int limit;/*最大object数量*/
        unsigned int batchcount;/*要从 slab_list 转移进本地高速缓存对象的数量,或从本地高速缓存中转移出去的 obj 数量*/
        unsigned int touched;/*从缓存池移除对象时设置为1,否则设置为0*/
        void *entry[];    /* 保存释放的object实例指针
                 * Must have this definition in here for the proper
                 * alignment of array_cache. Also simplifies accessing
                 * the entries.
                 */
    };

       这个结构体带来的好处:

    • 提高cpu L1/L2/L3 cache的命中率:当此CPU上释放object后,如果再次申请一个相同大小的object时,原object很可能还在这个CPU的 L1/L2/L3 cache中,所以为每个CPU维护一个这样的链表,当需要新的object时,会优先尝试从当前CPU的本地CPU空闲object链表获取相应大小的object,此时很有可能命中cpu的L1/L2/L3 cache,提升分配效率
    • 减少锁的竞争:假设多个CPU同时申请同样大小的object,为了互相同步,需要暂时多个cpu之间暂时互斥,导致分配效率降低;有个array_cache后,申请object时会先从cpu自己的array_cache查找,减少了cpu之间互斥的概率,提升分配效率

       结构体的字段确定后,就要填充了,在cache_alloc_refill函数种完成,代码如下:

    static void *cache_alloc_refill(struct kmem_cache *cachep, gfp_t flags)
    {
        int batchcount;
        struct kmem_cache_node *n;
        struct array_cache *ac, *shared;
        int node;
        void *list = NULL;
        struct page *page;
        /*确保中断已经关闭*/
        check_irq_off();
        node = numa_mem_id();
    
        ac = cpu_cache_get(cachep);
        batchcount = ac->batchcount;
        if (!ac->touched && batchcount > BATCHREFILL_LIMIT) {
            /*
             * If there was little recent activity on this cache, then
             * perform only a partial refill.  Otherwise we could generate
             * refill bouncing.
             */
            batchcount = BATCHREFILL_LIMIT;
        }
        n = get_node(cachep, node);
    
        BUG_ON(ac->avail > 0 || !n);
        shared = READ_ONCE(n->shared);
        if (!n->free_objects && (!shared || !shared->avail))
            goto direct_grow;
    
        spin_lock(&n->list_lock);
        shared = READ_ONCE(n->shared);
    
        /* See if we can refill from the shared array */
        //从全局共享空闲对象中转移空闲对象到本地 CPU 空闲对象链表上
        if (shared && transfer_objects(ac, shared, batchcount)) {
            shared->touched = 1;
            goto alloc_done;
        }
    
        while (batchcount > 0) {
            /* Get slab alloc is to come from. */
            page = get_first_slab(n, false);//从 slab 中获取空闲 slab
            if (!page)
                goto must_grow;
    
            check_spinlock_acquired(cachep);
            /*从free slab中获取空闲object,转移到本地CPU空闲object链表上*/
            batchcount = alloc_block(cachep, ac, page, batchcount);
            fixup_slab_list(cachep, n, page, &list);
        }
    
    must_grow:
        n->free_objects -= ac->avail;
    alloc_done:
        spin_unlock(&n->list_lock);
        fixup_objfreelist_debug(cachep, &list);
    
    direct_grow:
        if (unlikely(!ac->avail)) {
            /* Check if we can use obj in pfmemalloc slab */
            if (sk_memalloc_socks()) {
                void *obj = cache_alloc_pfmemalloc(cachep, n, flags);
    
                if (obj)
                    return obj;
            }
            /*当slab中都没有空闲object时,就从buddy系统中获取内存*/
            page = cache_grow_begin(cachep, gfp_exact_node(flags), node);
    
            /*
             * cache_grow_begin() can reenable interrupts,
             * then ac could change.
             */
            ac = cpu_cache_get(cachep);
            if (!ac->avail && page)
                alloc_block(cachep, ac, page, batchcount);
            cache_grow_end(cachep, page);
    
            if (!ac->avail)
                return NULL;
        }
        ac->touched = 1;
    
        return ac->entry[--ac->avail];
    }

      整个array_cache填充过程总结如下:

    • 首先会从本地 CPU 空闲object链表中尝试获取一个object用于分配:如果失败,则检查所有CPU共享的空闲object链表链表中是否存在,并且空闲链表中是否存在空闲object,若有就转移 batchcount 个空闲object到本地 CPU空闲object链表中;
    • 如果上述失败,就尝试从 SLAB中分配;这时如果还失败,kmem_cache会尝试从页框分配器中获取一组连续的页框建立一个新的SLAB,然后从新的SLAB中获取一个对象。

         相应的object释放流程(没在cache_alloc_refill):

    • 首先会先将object释放到本地CPU空闲object链表中,如果本地CPU空闲object链表中对象过多,kmem_cache 会将本地CPU空闲object链表中的batchcount个对象移动到所有CPU共享的空闲object链表链表中
    • 如果所有CPU共享的空闲object链表链表的object也太多了,kmem_cache也会把所有CPU共享的空闲object链表链表中 batchcount 个数的object移回它们自己所属的SLAB中,
    • 这时如果SLAB中空闲对象太多,kmem_cache会整理出一些空闲的SLAB,将这些SLAB所占用的页框释放回页框分配器中。

      cache_alloc_refill中比较关键的函数:从slab_partial和slab_free队列找空闲的object:

    static struct page *get_first_slab(struct kmem_cache_node *n, bool pfmemalloc)
    {
        struct page *page;
    
        page = list_first_entry_or_null(&n->slabs_partial,
                struct page, lru);
        if (!page) {
            n->free_touched = 1;
            page = list_first_entry_or_null(&n->slabs_free,
                    struct page, lru);
        }
    
        if (sk_memalloc_socks())
            return get_valid_first_slab(n, page, pfmemalloc);
    
        return page;
    }

      最后就是_kmalloc函数了:

    static __always_inline void *kmalloc(size_t size, gfp_t flags)
    {
        if (__builtin_constant_p(size)) {
            if (size > KMALLOC_MAX_CACHE_SIZE)
                return kmalloc_large(size, flags);
    #ifndef CONFIG_SLOB
            if (!(flags & GFP_DMA)) {
                int index = kmalloc_index(size);
    
                if (!index)
                    return ZERO_SIZE_PTR;
    
                return kmem_cache_alloc_trace(kmalloc_caches[index],
                        flags, size);
            }
    #endif
        }
        return __kmalloc(size, flags);
    }

      _kmalloc继续调用_do_kmalloc函数,核心调用的正式上面分析的kmalloc_slab函数和slab_alloc函数!

    /**
     * __do_kmalloc - allocate memory
     * @size: how many bytes of memory are required.
     * @flags: the type of memory to allocate (see kmalloc).
     * @caller: function caller for debug tracking of the caller
     */
    static __always_inline void *__do_kmalloc(size_t size, gfp_t flags,
                          unsigned long caller)
    {
        struct kmem_cache *cachep;
        void *ret;
        /*根据用户参数找到高速缓存块*/
        cachep = kmalloc_slab(size, flags);
        if (unlikely(ZERO_OR_NULL_PTR(cachep)))
            return cachep;
        /*根据kmem_cache结构体和用户参数找到object,并返回地址*/
        ret = slab_alloc(cachep, flags, caller);
    
        kasan_kmalloc(cachep, ret, size, flags);
        trace_kmalloc(caller, ret,
                  size, cachep->size, flags);
    
        return ret;
    }

    总结:

    •       对于用户来说,需要用内存时直接调用malloc就行了,但是操作系统找空闲内存区域的时候可谓是大费周折(甚至还要关闭中断,避免分配的过程被打断;同时加锁,和其他cpu核之间互斥保持同步),这是malloc函数效率低的根本原因;如果客观条件允许,调用malloc函数后申请的内存建议反复使用(但是可能带来部分安全问题,这里需要平衡两者的权衡利弊),不要频繁地malloc和free
    •      slab机制比buddy复杂,各种概念伴随着层出不穷的结构体,但是万变不离其宗:(1)将内存进一步分割成更小的块,从8byte起步,尽可能避免碎片   (2)不同size的内存块分开管理,提高分配和回收合并的效率; 以上所有的结构体都是围绕着这两点目的展开的,比如缓存array_cache、多级链式查找等!
    •      多级链式建立和维护的时候比较复杂,比如下图的从kmem_cache_node→partital中分配object,最多需要经历4次链式跳转查询object,为啥不直接用bitmap管理,而是要搞得这么复杂了?注意观察:每次链式跳转查询都有不同的结构体承载,每个结构体都有自己特定的字段属性,由此达到分层解耦的目的

               

    •    内存操作函数概览:

      

    •    栈和堆的区别

      写过代码的人都知道,代码运行的时候数据主要存储在两块人为划分的区域:栈和堆;栈一般用来存参数、返回地址、局部变量;堆用来存new出来的对象实例、全局变量、静态变量等;碎片化问题主要集中在堆区域,重来没听过栈内存有碎片化问题的,这是为啥了?

      栈是通过sp指针管理的,sp减少或增加,意味着栈增加或减少,一般是函数调用前和函数调用完毕移动sp;栈内部存放的函数frame的大小就是函数参数、返回地址、局部变量的大小总和;编译器在编译阶段就能够精准计算函数frame的大小,所以函数调用前和调用后移动sp的距离也是能提前精准计算的,所以sp移动时给函数frame分配的空间也是精准的,这就是栈上没有碎片的根本原因!相比之下堆就不一样了: 堆要承担给用户app存放对象实例、全局变量、静态变量的重任,全局变量和静态变量还好,编译时就能确定大小,但是对象实例就不行了,这个完全依赖于运行时的情况确定,尤其时代码各种分支比较多的情况,所以堆内存的使用是没法提前预判的;为了增加内存分配的灵活性,就诞生了各种内存分配、GC的算法

      3、尽管linux操作系统采取了buddy和slab等算法,但还是无法完全避免内存碎片;并且由于c/c++自生的限制,没有jvm这种自动垃圾回收的机制,导致了malloc或new出来的内存需要程序员人工手动释放;一旦忘记释放,就会一直占用着内存,直到程序运行结束,操作系统全部回收整个程序的内存才会释放。如果某些应用程序因为业务需要无法停止(比如服务器上跑的应用),必须一直运行,那么这块“被遗忘”的内存会一直被占用,可用内存会越来越少,最终可能导致内存严重不够,整个应用崩盘重来(这也是服务端开发java越来越流行的原因之一)!目前业界公认垃圾回收算法最牛的就属ZGC和shenandoah了,这里简单介绍一下ZGC算法的过程和牛逼之处!

      (1)jvm也会把内存按照不同大小分块(官方给的称呼是分页,但是个cpu硬件对内存的分页不同),jvm分页的大小如下:

    •   小页面2MB,用于存储小于256KB的对象
    •        中页面32MB,用于存储256KN~4MB的对象
    •        大页面大小不固定,用于存储大于4MB的对象

      (2)要回收垃圾,就要正确地识别垃圾,避免误伤正常的对象,ZGC是这样做的:找到GC root(线程栈变量、static变量、常量池、jni指针等),挨个查找这些对象内部的fileds,看看有没有引用其他的变量,由此找到直接的对象引用;

         

         这个阶段称之为“初始标记”。为了确保标记的准确性,需要暂停所有线程的工作,官方称之为stop the world,简称STW;由于不需要像传统的GC算法那样扫描堆区域,所以再大的内存都不怕。从实测的数据看,此阶段耗时小于1ms;找到直接引用的对象后(比如下面的A对象),会把GC root指针的M0位置1,标明A对象正在经历GC流程

      

        (3)经过初始标记,找到了GC root直连的对象,很明显此刻并不能回收,因为还有很多对象可能并不是CG  root节点直接引用的,比如B、C节点(有可能是A节点用了链表、数组、new Object等方式生成了B、C节点);为了提高效率,需要多个GC线程同时来标记所有的间接引用对象,此阶段称为“并发标记”;此阶段并不需要STW,所以并不影响业务线程的正常运行,时间长也不影响!B、C找到后,A->B和B->C引用指针的M0位同样标绿;此阶段有点像是多叉树或图的遍历!

         

       这一步并发标记方法称为“三色标记”法;整个阶段由于没有STW,并且是多个GC线程和业务线程一起协作,可能出现漏标的情况:GC1线程没有标记C节点,以为C是垃圾,要回收C节点的内存;但是GC2线程还未完成,此时业务线程执行了B.c=null,把B->C的引用去掉,CG2线程执行后也会人为C节点没有引用;实际上由于业务线程执行了A.c=C,导致C节点被A引用,本身并不是垃圾,明显被误伤了! 

            

       为了避免C被误伤,jvm执行到A.c的时候需要把C先记录在remember set;为下一步的再标记做准备!使用的算法或技术叫snapshot at the begining,简称SATB,也就是在开始的时候拍快照,把代码中的引用关系保存下来,用作后续备查

           (4)上一步的并发标记由于没有STW,存在对象漏标的情况,可能导致正常引用的节点被当成垃圾回收;为了避免误伤,采用了remembor set的方式,一旦遇到A.c这种情况马上记录到set,然后通过set找到还在正常被使用的对象标记;为了精准标记、避免误伤,此阶段需要STW,让业务线程先暂停;由于只处理漏标的对象,实际测试看耗时小于1ms;此阶段称为再标记

        (5)并发转移准备:经过前面3个阶段找到了垃圾,现在要着手准备删除垃圾、回收内存了;ZGC采用了copying算法:找到相同大小、还未使用过的页面,然后把正常引用的对象复制到新页面,旧页面存放的数据全部擦除,思路就是这么简单粗暴;此阶段没有STW,不影响正常的业务线程;

             

       (6)初始转移:

    •      由于需要移动对象,并且这些对象还有GC root的直接引用(没有引用的对象就是垃圾了,还需要转移么?),此阶段肯定要STW,耗时小于1ms(大部分是垃圾,需要转移的很少;并且此阶段只移动和GC root直连的对象,间接连接的对象此阶段部考虑)
    •      把A复制到新页面,更改GC root的指针指向,同时更改颜色指针为蓝色,表示remapped移动阶段结束!

      

       (7)并发转移

    •      B和C转移到新页面,但是B和C的引用怎么转移了?在旧页面维护了转发表Forward table(和GC root不直连的对象都会在转发表),类似hashtable,把实例的新旧地址做了对应
    •      此阶段没有STW的,如果有线程正在需要读B和C怎么办了?此时就需要读屏障了:等B和C搬迁到新地址后再读(通过转发表找到),这个有点类似自旋,汇编层面通过jmp slow_path实现;额外开销约4%;那么问题又来了:业务线程怎么知道B和C线程有没有搬迁到新地址了?还是通过对象引用指针的着色确定的;如果4个bit都是0,说明改引用是good color,对象可以直接读写;如果4bit中有任何bit是1,就是bad color,说明正处于GC流程,这种对象是不能马上使用的

       

           (8)还剩最后一步了:B和C复制到新页面后,原有的引用关系还没恢复了,这个问题什么时候解决了?这就要等到下一次GC启动后的第二阶段:对象重定位阶段进行!这个阶段A->B和B->C的指针都是绿色的,表示上次GC遗留下来没有更新的指针!最终的结果如下:A、B和C在新页面建立引用关系,指针改成红色!

        B和C这种间接引用,为啥要放到第二次GC才做了?为啥不第一次就完结了?因为这种可达性分析没必要做两次,可以直接复用第一次的forward table!所以也不需要STW业务线程!

         

        最后通过实测,每个阶段耗时如下:需要STW的是初始标记、再标记和初始转移阶段,3个阶段加起来耗时约0.7ms,这效率杠杠滴!

       

      

    参考:

    1、https://cloud.tencent.com/developer/article/1622989  slab分配一个object的流程分析

    2、https://www.dingmos.com/index.php/archives/23/ slab分配器

    3、https://www.bilibili.com/video/BV1cb4y117Vg?p=6&spm_id_from=pageDriver  ZGC垃圾回收过程

  • 相关阅读:
    PHP AES256加密算法
    PHP字符串比较
    linux常用命令
    播放音乐方法(兼容IE FF Chrome Opera Safari)
    JS小游戏象棋暗棋
    Sublime Text 2 介紹
    php生成QRcode
    几种极其隐蔽的XSS注入的防护
    JS判断碰撞方法
    php 发送带附件邮件
  • 原文地址:https://www.cnblogs.com/theseventhson/p/15712375.html
Copyright © 2011-2022 走看看