glibc堆内存管理

概述

堆(heap)是指进程虚拟空间中由开发者动态分配的内存区域,它与栈不同,具有地址向上增长的特点,并且由开发者自己分配与释放。开发者一般通过malloc与free管理动态内存,而malloc和free其实是glibc动态内存管理的对外接口。
为了避免用户频繁使用系统调用带来的开销以及尽可能地提高内存使用率,glibc基于系统调用brk和mmap向开发者提供了一套堆内存管理机制。glibc采用的内存分配器(Memory Allocator)是ptmalloc2,其通过预留内存以及将释放的内存块通过分类有效组织起来,以此来提高内存分配速度和内存空间使用率。

1. 堆内存管理基础——mmap 和 brk

mmap和brk是Linux提供的用于动态内存分配的系统调用,也是glibc堆内存管理的基础,因此先了解这两个系统调用是如何使用的,同时需要了解这两个系统调用都是在哪分配的内存。

1.1 进程空间

32位模式下的进程默认内存布局
{F175370}

栈至顶向下扩展,并且栈是有界的。堆至底向上扩展, mmap 映射区域至顶向下扩展, mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于 C 运行时库使用 mmap 映射区域和堆进行内存分配。上图的布局形式是在内核2.6.7 以后才引入的,这是 32 位模式下进程的默认内存布局形式。本文将glibc通过brk和mmap所获取的动态内存统称为堆。

1.2 brk

lang=c
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

 
 
  • 1
  • 2
  • 3
  • 4

brk系统调用将从bss段边界向上增长,这一部分增长的内存可由开发者使用,brk调用设定了堆的边界,而sbrk是c库函数,其参数为欲申请的的新的堆空间大小。当sbrk的参数increment的大小为0是,返回的是当前堆的边界;而当inicrement的大小为负时,将对堆进行收缩。

1.3 mmap

lang=c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

 
 
  • 1
  • 2
  • 3
  • 4

mmap系统调用将一个文件或者其它对象映射进内存。这部分内存的地址和长度分别由addr和length指定,prot只这部分内存的保护标志,在glibc中,通常将新分配内存设置为可读可写的标志。而unmap的功能正好相反,将这部分内存归还给内核。

1.4 小结

可见,glibc所管理的内存实际上是由两部分内存组成的,一部分由brk申请,位于进程空间的堆区;而另一部分,则由mmap申请,位于mmap映射区域。那么,什么时候会使用brk申请内存,什么时候会使用mmap申请内存?简单来说,就是主arena会使用sbrk分配内存,子arena会使用mmap申请内存。那么什么是子arena,什么是主arena。简单来说,就是主线程的内存分配区和子线程的内存分配区。下面详细了解以下什么是分配区。

2. 堆内存管理的组织——分配区(Arena),堆(Heap)和内存块(Chunk)

下两张图清楚地显示了Arena,堆和chunk在内存空间的关系,可以在了解完三者后,再回过头来看这张图。
{F175612}

{F175613}

2.1 多线程与Arena

(1)ptmalloc的多线程支持
Linux早期版本采用dlmalloc作为它的默认分配器,但是dlmalloc并不对多线程并不友好,这是因为在dlmalloc中,线程之间共享一个堆,因此临界区的竞争将会降低内存分配的效率。而ptmalloc则友好地支持了多线程环境,其通过为线程提供各自的堆,从而各个线程可以并发地申请内存,发现没,ptmalloc就是per thread malloc的缩写。管理堆的数据结构被称为Arena。

(2)Arena和线程的对应关系
可惜的是,Arena的数量是有限的,这和CPU核数相关,因此,不一定所有的线程都独享自己的Arena,当线程数量大于Arena的数量限制时,就会出现线程之间共享Arena的情况了。主线程拥有自己的arena,该arena的堆区在glibc的代码中,其为一个静态全局变量!!main_arena!!,而其它的子线程,则根据需要动态分配得到子Arena。

(3)Arena和堆的对应关系
主Arena对应的堆通过brk申请,而子Arena对应的堆区则通过mmap申请。因此主Arena只有一个堆,而子Arena可以对应多个堆,当原来的堆不够用时,就会申请新的堆,然后和原来的堆链为一个链表。

(4)Arena的数据结构

struct malloc_state
{
  /* Serialize access.  */
  mutex_t mutex;//互斥量,用于多线程共享一个Arena

/* Flags (formerly in max_fast). */
int flags;

#if THREAD_STATS
/* Statistics for locking. Only used if THREAD_STATS is defined. */
long stat_lock_direct, stat_lock_loop, stat_lock_wait;
#endif

/* 回收箱:fastbins,bins /
/ Fastbins */
mfastbinptr fastbinsY[NFASTBINS];

/* Base of the topmost chunk – not otherwise kept in a bin */
mchunkptr top;//指向当前top chunk

/* The remainder from the most recent split of a small request */
mchunkptr last_remainder;

/* Normal bins packed as described above /
mchunkptr bins[NBINS 2 - 2];

/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];//位图,标记bins中是否存在内存块

/* Arena被连成链表 */

/* Linked list /
struct malloc_state next;

/* Linked list for free arenas. /
struct malloc_state next_free;

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

2.2 堆(Heap)

堆分为两类,第一类是主Arena的堆,第二类是子Arena的堆。主Arena的堆对应brk申请的内存,只有一个。而子Areana的堆使用mmap申请,具有多个,并使用链表进行连接。

lang=c
/* 该数据结构只在子Arena中使用,用于记录当前堆信息。 */
typedef struct _heap_info
{
  mstate ar_ptr; /* Arena for this heap. */ // 指向该堆所在的Arena
  struct _heap_info *prev; /* Previous heap. */ //由于一个子Arena管理多个堆,因此
  size_t size;   /* Current size in bytes. */ //当前堆分配给用户使用的大小,剩余部分为预留区域
  size_t mprotect_size; /* Size in bytes that has been mprotected
                           PROT_READ|PROT_WRITE.  */ //从代码来看,和size并无区别(本人意见)
  /* Make sure the following data is properly aligned, particularly
     that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
     MALLOC_ALIGNMENT. */
  char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK]; //用于对齐
} heap_info;

 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

(1)堆的申请
第一类的堆无需申请,只是调用brk进行堆边界的拓展即可。这里主要堆第二类堆的申请进行说明。

  • 堆的大小和对齐:第二类堆在申请时,总是mmap大小为HEAP_MAX_SIZE的内存,多出来的部分将作为预留空间,防止频繁申请。并且使其首地址对齐于HEAP_MAX_SIZE,这可以方便找到堆的起始地址。
  • 什么时候申请堆:在两种情况会进行第二类堆的申请,第一种情况是在创建子Arena时,会相应地进行堆的申请作为该Arena的第一个堆;第二种情况是在原来申请的堆已经分配完毕时,会重新进行堆的申请,并将该堆和原来的堆通过链表连接起来。
  • 堆的可用部分:只将用户所需要的部分分配出去,并使用size记录,剩下的部分作为预留。同时将top chunk的地址指向该堆的起始地址。(top chunk将会在下面介绍)

(2)堆的释放
这里堆的释放是指glibc将申请的堆内存归还给内核。
对于第一类堆,可以认为只有堆大小的缩减,当堆的顶部空闲的内存满足一定条件时,可以通过brk将堆的边界下移,top chunk指向地址不变,但大小变小了。
对于第二类堆,当一个堆中的内存已经完全被释放时,就会将该该堆通过munmap归还给内核,同时将top chunk重新指向上一个堆内的可用内存地址。

可以这么理解,堆由两部分组成,一部分是已经分配出去的内存,另一部分是预留的内存(top,因为它总是存在于地址最高部分),而已经分配出去的内存一部分由free释放,成为了空闲内存(内存碎片),由此除预留部分部分之外,分为两种内存,空闲内存和已使用内存。

2.3 内存块(Chunk)

glibc堆内存管理的基本单位就是chunk,对于分配得到的堆,将被分成许多个内存块(chunk)。内存块由chunk头和可用内存组成。其数据结构如下:

lang=c
struct malloc_chunk {
  INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links – used only if free. /
struct malloc_chunk
bk;

/* Only used for large blocks: pointer to next larger size. /
struct malloc_chunk
fd_nextsize; /* double links – used only if free. /
struct malloc_chunk
bk_nextsize;
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

2.3.1 chunk的状态

而当chunk存在有空闲和已使用两种状态,某些字段只有在空闲状态下才有意义,下图是两种状态下,字段使用情况。
在这里插入图片描述
在这里插入图片描述

(1)空闲内存块
chunk空闲表示该内存块未被使用,chunk头中的几个字段意义如下:

字段名意义
prev_size前一个chunk块的大小, 只有在前一个chunk空闲时才有意义,否则将会被作为数据区域被用户使用
size当前chunk块的大小
fd指向chunk所在空闲链表中的下一个空闲chunk
back指向chunk所在空闲链表中的前一个空闲chunk
fd_nextsize指向chunk所在空闲链表中的下一个不同大小(更小的)chunk
bk_nextsize指向chunk所在空闲链表中的上一个不同大小(更大的)chunk

NOTE:

  • prev_size
  • fd_nextsize 和 bk_nextsize 只在 large chunk中使用。

(2)已使用内存块

字段名意义
prev_size前一个chunk块的大小,只有在前一个chunk空闲时才有意义,否则将会被作为数据区域被用户使用
size当前chunk块的大小

2.3.2 空闲内存块的管理

在一段连续的内存(堆)上,既存在空闲的内存块,也存在已使用的内存块,这些内存块彼此相邻。其中空闲的内存块就是由用户使用完后调用free释放的,glibc不会立即将这些空闲的内存块释放还给内核,而是先将其暂时缓存起来,以便下次分配。那么如何组织这些空闲的内存块呢,有一种最简单的组织方法就是,将这些空闲的内存块统统放在一个链表里,按照从小到大的顺序排列,等到需要分配的时候,直接找到最小的满足大小块分配出去。然而glibc的组织方式基本思想也是如此,但是考虑到内存分配的速度和空间使用率,glibc的组织会稍微复杂一些。

(1)空闲内存块的分类
glibc对空闲内存块从大的角度可分类为top chunk和bin chunk,top chunk是指堆的最高地址部分的一段连续的内存,用以应对大块连续内存的分配,并且当空间不够时可扩展。而bin则是指分配出去后被回收的部分,它们是不连续的,是分散在已使用内存中的内存碎片。

(2)回收箱 Bin
再对bin chunk进行分类,按照大小分类为 small chunk 和 large chunk。而small chunk 和 large chunk 被放在 四个bin(回收箱)中,它们分别是 fast bins, small bins, large bins 和 unsorted bin。

2.3.3 Fast Bins

在所有的bin中,fast bins具有最快的内存分配速度和释放速度。
{F175375}

以32位机器举例,其特点如下:

  • 数量:fast bins的的大小为 10,即具有10个链表(fast bin),每个链表维护着一个某个大小的空闲内存块
  • chunk大小:最小为16字节,以8字节增长。
  • 来源:在fast chunk大小范围内的内存块被free函数释放
  • 释放:当开发者通过free释放内存块时,若发现内存块的大小属于fastbin的范围内,则直接将该内存块放入fast bins对应链表的头节点,注意此时如果前后存在可合并的内存块,不会进行合并,当申请和释放的内存块集中于fast bins大小时,该数据结构可以大大加快内存的分配与释放。
  • 分配:当申请的内存块大小存在于fastbin时,从fastbin的头节点取出。

注:可以将fast bins看作时部分大小small bins的缓存

2.3.4 Bins

unsorted bin,small bins 和 large bins 全部存储在一个数组 bins中,分别位于不同的位置。在malloc_state中可以看到用来表示bins的数据结构。NBINS - 1表示 三类bins的数量之和(NBINS为128),每一个BIN都是一个双向循环链表,占用bins中的两个bin(每个bin内存储一个内存块的地址),一个bin指向链表的头节点,一个bin指向链表的尾节点。其中unsorted bin只占用一个BIN,即BIN[1],也就是bins[0]和bins[1];而small bins占用63BIN,即BIN[2]到BIN[63],也就是bins[2]~bins[125];最后,large bins占用 BIN[64]到BIN[126],也就是bins[126]~bins[251].BIN[127],即bins[252]和bins[253],没有使用

  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];

 
 
  • 1
  • 2

在这里插入图片描述

(1)unsorted bin

  • 数量:1个BIN
  • chunk大小:无限制,任意大小的chunk均会被放在这里
  • 来源:①大于fast chunk大小的内存块被free释放 ②来自small/large chunk被分割后的剩余部分 ③fast bins中的内存块与邻近的空闲内存块合并
  • 特点:只有一个链表,排列没有顺序,
  • 释放:所有大于fast chunk大小的内存块不会直接存入small/large bins,而是与相邻的空闲内存块合并后,直接放入unsorted bin头部
  • 分配:遍历unsorted bin的链表,如果与所需求的大小恰好相等,则分配给开发者该块内存,结束遍历;如果不匹配,则将该块内存根据其大小放入到small bins/large bins中,继续查找。

(2)small bins

  • 数量:62个BIN
  • chunk大小:大小小于512字节的内存块
  • 来源:unsoted bins
  • 特点:每个BIN中的内存块大小均一致,相邻的small BIN中内存块大小恰为8字节
  • 释放:向small BIN的头部插入节点
  • 分配:根据需要的大小,找到对应的samll BIN,从尾部取出节点

(3)large bins

  • 数量:63个BIN
  • chunk大小:大于等于512字节的内存块
  • 来源:unsorted bin
  • 特点:每个large BIN中的内存块大小并不一致,顺序排列,头部的节点大小最大,尾部的节点大小最小;相邻的large BIN的大小差距随着索引变化,简单来说就是,large BIN内存块越大,则与相邻的large BIN的差距也越大。
  • skip list:空闲large chunk的中有特殊的字段,fd_nextsize 和 bk_nextsize。这是由于large chunk实际上使用了两个链表进行组织,第一个链表将所有大小的内存块顺序地连接起来,里面存在大小相同的内存块,fd和bk分别指向了这前后的内存块。而第二个链表中的内存块大小各不相同,在插入和搜索内存块时,可以提高查询效率。
  • 释放:首先根据大小查找large chunk应当存在的large BIN中,按照其大小存入合适的位置。
  • 分配:首先根据大小查找large chunk应当存在的large BIN中,找到最适合(最小的大于所需字节)的内存块。

2.3.5 Top Chunk

top chunk 是指一个堆最高地址部分的一段连续的内存。这一部分的内存是空闲的,当回收箱中(fast bin, small bin, large bin 和 unsorted bin)没有足够大的内存时,就会从top chunk 中分分割出一部分内存分配出去。如果top chunk 中的内存也不够大,并且该内存大小没有超过MMAP的阈值。那么就需要扩展堆了。如果是主Arena,就直接使用brk向上扩展堆的边界;如果是子Arena,就会使用mmap去申请一块新的堆,而这个堆的开头部分会作为heap_info,用于记录这个堆的信息,而剩余部分就会成为新的top chunk,也即子Arena会将top指针指向这片新的内存。堆扩展以后,top chunk的大小足以满足需求,将低地址部分分配出去,高地址部分成为新的top chunk,可以看到,top chunk总是位于一个堆的顶部(地址最大部分)。

2.4 小结

本节介绍了glibc中最主要也是最重要的三个数据结构——Arena,堆和内存块。Arena分为主Arena 和 子Arena,线程拥有自己的Arena。主Arena只有一个堆,是bss段的拓展,而子Arena则可以有多个堆,并用链表连接起来。堆内除了记录Arena信息和堆信息外的内存,分割为了大大小小的内存块,这些内存块有的已经被使用,而有的未被使用,这些未被使用的内存分为顶端的top chunk和夹在已使用内存块间的bin chunk组成。这些bin chunk被有序的组织起来,存放在四类回收箱中,方便下次分配。

3.堆内存管理的分配——malloc和free

3.1 堆内存分配

glib中堆内存分配的基本思路就是,首先找到本线程的Arena,然后优先在Arena对应的回收箱中寻找合适大小的内存,在内存箱中所有内存块均小于所需求的大小,那么就会去top chunk分割,但是如果top chunk的大小也不足够,此时不一定要拓展top,检查所需的内存是否大于128k,若大于,则直接使用系统调用mmap分配内存,如果小于,就进行top chunk的拓展,即堆的拓展,拓展完成后,从top chunk中分配内存,剩余部分成为新的top chunk。

如何分配内存?
(1)对请求的字节数转换为实际分配的内存块大小(包含chunk头并对齐)
(2)若内存块大小小于 fast chunk 的最大大小,则计算所在fast bin,从该fast bin中获取内存块,若存在,返回该内存块的可用地址,结束;否则(3)
(3)检查内存块大小是否在small chunk范围内,若是,则(4);否则,计算内存块大小所在的large bin,并且若fast bins中存在内存块,则进行合并操作,然后到(5)
(4)计算所在的 small bin,从该small bin中获取内存块,若存在,则返回该内存块可用地址,结束;否则(5)
(5)检查unsorted bin若满足条件:①当前请求为一个small request ②当前unsorted bin只有一个内存块且该内存块为 last remainder ③last remainder大小 >= MINSIZE,则进行分割并返回可用地址,将剩余部分作为新的last remainder,结束;
(6)遍历unsorted bin,若找到一个恰好大小为所需的内存块,则直接返回可用内存;否则将根据其大小,加入到small bins或large bins中
(7)检查当前所需内存块大小是否在large chunk范围内,若在,则计算所在的large bin,找到最小的满足需求的 内存块,检查是否可以分割,若可以分割,则分割后返回可用地址,剩余部分加入unsorted bin,若不可分割,则直接返回可用地址,结束;
(8)根据“small first,best fit”在binmap(其实就是small bin 和 large bin)中寻找合适大小的内存块,同样检查是否可以分割,若可以分割,则分割后返回可用地址;若不可以分割,则直接返回可用地址,结束;
(9)说明回收箱中的所有内存块均太小,尝试在top chunk中分配内存
(10)若topchunk的内存满足需求(剩余部分的大小>=MINSIZE),则直接进行分割,返回可用地址并结束;否则,检查fast bin内是否有内存块,若有,则进行合并操作并跳回(5),若没有内存块,则(11)
(11)若需要内存块大小 大于 mmap的阈值(一般为128k),则会直接调用mmap申请内存,并返回;否则就进行堆的拓展
(12)堆的拓展成功后,就可以进行分割了

注:
①small request是指实际分配的内存块大小属于small chunk范围
②fast bins合并操作是指,检查fast bins中的所有内存块是否可以和相邻内存块合并,若可以合并,则进行合并,并将合并后的内存块加入到unsorted bin中
③last remainder是一个目的为更好的利用空间局部性的优化!
④MINSIZE是指一个内存块的最小大小,即 chunk头的前两个字段所占空间
⑤遍历unsorted bin 是,并不是找到一个大于当前所需的内存块就返回,是因为遵循“small first,best fit”原则,因为可能存在内存更小,内存块用于分配
⑥找到合适的内存块后,会将内存块从当前链表中移除
⑦large chunk是否可以分割取决于剩余的大小是否大于MINSIZE
⑧binmap是一个用于记录bins中各个bin是否存在有内存块的位图,需要注意,位图中若为空,则表示一定不存在;若非空,则可能存在;
⑨注意到,内存块的分配是按照对齐来的,并且内存块的分割若不成功,则会返回整个内存块,也就是说,我们得到的内存大小实际上可能大于我们所需要的内存大小的。
⑩直接使用mmap申请的内存会被标记为M,释放时,也会直接走munp释放给内核

3.2 堆内存扩展

分配内存时,若发现当前堆的连续内存不足时,就需要进行堆的扩展。那么堆是如何扩展的?主Arena中和子Arena中的扩展方式是不同的,主Arena可以直接通过brk调用扩展堆边界即可;而对于子Arena,它的堆内实际上除了topchunk之外,留有一部分空间并未使用,因此首先它会尝试扩展这个边界,如果空间仍然不足,它会直接mmap出一个新的堆,将这个堆和原来的堆连接在一个链表内。可以看出来,子Arena的堆空间是不连续的,但是对于使用者来说,并不需要关心这些。

主Arena的空间一定会保持连续吗?
不一定。主Arena的堆空间并不是绝对保持连续的
当brk失败时,比如,brk需要扩展1M,但是剩余空间不足1M,这个时候,主Arena就会和子Arena一样,mmap出一个新的堆,并将top指向这个新的堆,此时,主Arena的堆就是不连续的。需要注意的是,当该新的堆区不够时,主Arena还是会首先尝试使用brk,如果此时未成功,将继续使用mmap申请新的堆;如果brk成功了,那么top会重新指向原来的连续区域,而这个新的堆即使空间已经全部free,也将永远留在glibc的回收箱中,而不会还给内核。(字大小为32位机器下,一个堆的大小为1M)。

3.3 堆内存释放

堆内存的释放比较简单,算法如下:

(1)根据可用地址获取该地址所在的内存块
(2)检查该内存块的大小是否属于 fast chunk范围,若是,则直接放入fast bin;否则(3)
(3)检查该内存块标志位M,若为1,则直接使用unmap释放;否则(4)
(4)检查相邻的上一个内存块是否空闲,若空闲,则合并;
(5)检查相邻的下一个内存块是否空闲,若非空闲,则直接加入unsorted bin;若空闲,检查该内存块是否为top chunk,若为top chunk,则合并并修改top chunk的地址和大小;若非top chunk,则合并并添加到unsorted bin
(6)对于主Arena,检查top chunk的区域是否超过设定的阈值,若超过,那么就适当地缩减一部分,通过brk将一部分内存还给内核;对于子Arena,则会检查,目前top chunk所在堆的内存是否已经全部释放,若已将全部释放,那就通过munmap将这片内存还给内核。

可以看出,free内存块一共有4个去向:①放入fast bins ②放入unsorted bin ③合并入top chunk ④直接通过unmap还给内核

注:主Arena在进行堆的缩减时,首先通过sbrk(0)获取当前的brk的边界,如果brk = top起始地址 + top的大小 才会进行缩减,这说明,当top chunk存在于mmap得到的堆时,将不会进行缩减,这就是3.2所说的这部分内存将永远无法返还给内核的原因。

4. 总结

glibc使用ptmallic作为内存分配器,各线程使用对应的Arena,每个Arena管理各自的堆,主Arena只有一个堆,使用brk申请内存;子Arena有多个堆,每个堆使用mmap申请内存。堆则由内存块组成,分为已使用的内存块和未使用的内存块。未使用的内存块则可以分为top chunk和bin chunk,bin chunk是指夹在已使用内存块之间内存碎片,ptmalloc将这些内存碎片按照大小分为small chunk 和 large chunk缓存在4个回收箱内,使用“small first,best fit”原则分配内存。此外,glibc源码中可以看到为提高内存利用率以及分配速度所作的设计,如chunk设计中对size字段末三位的标志和prev size字段在空闲和非空闲块的复用;算法的设计中,如内存分配对large bins中的skip list,binmap的存在;源码中充斥着大量诸如此类的设计细节,因此在了解分配器原理之余,推荐一览源码的实现。

参考
(1)glibc 2.19源码
(2)理解 glibc malloc:主流用户态内存分配器实现原理
(3)《glibc内存管理.pdf》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值