SGI STL源码剖析——空间配置器

前言

  这段时间读了侯捷老师的STL源码剖析,有一些体会和收获,看书的过程也碰到了许多疑惑,因此将自己的理解记录下来,原书和源码 https://github.com/SilverMaple/STLSourceCodeNote

空间配置器

  配置器负责空间配置和管理,STL的所有容器都需要配置空间才能存放内容,这里所谓空间配置就是指内存配置。配置器其实就是一个实现了动态空间配置、管理和释放的类模板。
  C/C++中本就有内存申请和释放的操作,STL为什么还要单独实现空间配置器呢,主要还是为了效率问题。如果任由STL中的容器自行通过malloc分配内存,那么频繁的分配和释放内存会导致堆中有很多的外部碎片。可能堆中的所有空闲空间之和很大,但当申请新的内存的请求到来时,没有足够大的连续内存可以分配,这将导致内存分配失败。
注:
内碎片:因为内存对齐/访问效率(CPU取址次数)而产生 如 用户需要3字节,实际得到4或者8字节的问题,其中的碎片是浪费掉的。
外碎片:系统中内存总量足够,但是不连续,所以无法分配给用户使用而产生的浪费。

SGI空间配置器

  按照STL的标准规范,空间配置器是一个类模板,名称是allocator,而SGI STL的配置器有所不同。它的名称是alloc,并且实例化不接受参数,如果在声明容器时指定SGI配置器,

vector<int, std::alloc> iv;

但是实际上SGI STL已经为每一个容器都缺省了alloc为空间配置器,并不需要我们指定。
SGI也提供了标准的空间配置器allocator,但只是对new和delete的封装,所以效率不佳。因此我们重点学习的是SGI提供的特殊空间配置器std::alloc,即书中所述具备次配置力的SGI空间配置器。

内存配置和对象构造

C++中通过new申请一个对象的操作

class Foo { ... };
Foo* pf = new Foo;
delete pf;

  new一个对象(单身狗狂喜)实际上是包含了两步操作的,通过::operator new申请内存,然后再通过类的构造函数构造对象初始化内存。delete时也是分为两步,先将对象析构,再调用::operator delete释放内存。
为什么是**::operator new**呢,那就有必要看一下new和operator new的区别。
  我们都知道new和malloc是有区别的,有些地方解释说new是运算符,实际上不太准确,new本质上来说是一个关键字,那为啥说new可以重载呢,求实重载的是运算符或者说函数operator new。如上面所说,new本身就是包含两步操作的,那这第一步的申请内存就是::operator new了。
**new:*指我们在C++里通常用到的运算符,比如A a = new A; 对于new来说,有new和::new之分,
前者位于std
**operator new():指对new的重载形式,它是一个函数,并不是运算符。对于operator new来说,分为全局重载和类重载,全局重载是void ::operator new(size_t size),在类中重载形式 void A::operator new(size_t size)。还要注意的是这里的operator new()完成的操作一般只是分配内存,事实上系统默认的全局::operator new(size_t size)也只是调用malloc分配内存,并且返回一个void*指针。
operator new就像operator + 一样,是可以重载的。如果类中没有重载operator new,那么调用的就是全局的::operator new来完成堆的分配。同理,operator new[]、operator delete、operator delete[]也是可以重载的。
  也就是说如果我们在类中重载了new,那么编译器就会使用我们重载的new来完成第一步。
  我们可以做个验证,

#include<iostream>
#include<vector>
using namespace std;
class A
{  
private:
    int val;
public:
    A(int x): val(x)
    {
        cout << "构造" << endl;
    }
    ~A()
    {
        cout << "析构" << endl;
    }
    void * operator new(size_t sz){  
        A * t = (A*)malloc(sizeof(A));  
        cout << "内存分配" << endl;  
        return t;  
    }  

    void operator delete(void *p){  
        free(p);  
        cout << "内存释放" << endl;  
        return;  
    } 
};
int main()
{
    A *p = new A(3);
    delete p;
}

[pengzheng@localhost ~]$ g++ -o main main.cpp
[pengzheng@localhost ~]$ ./main
内存分配
构造
析构
内存释放
  好了,这样我们就明白了new一个对象是分为两步的,也明白了第一步调用的是::operator new,那还有一个经常提起的new是placement new,这有是啥呢?
  placement new实际上也是对::operator new的重载,
  void* operator new (std::size_t size, void* ptr) throw();
  它不分配内存,调用合适的构造函数在ptr所指的地方构造一个对象,之后返回实参指针ptr。
  简单来说,就是使用new申请空间时,是从系统的“堆”(heap)中分配空间。申请所得的空间的位置是根据当时的内存的实际使用情况决定的。但是,在某些特殊情况下,可能需要在已分配的特定内存创建对象,这就是所谓的“定位放置new”(placement new)操作。
  定位放置new操作的语法形式不同于普通的new操作。例如,一般都用A* p=new A申请空间,而定位放置new操作则使用如下语句

A* p=new (ptr)A;

申请空间,其中ptr就是指定的内存首地址。
  好了,说了这么多,主要是为了说明STL的allocator是将new一个对象的两阶段操作分开来,内存配置和释放分别由alloc:allocate()以及alloc:deallocate()负责,而对象构造和析构由::construct()和::destroy()负责。

构造和析构

  先来看构造,构造通过construct

template <class _T1, class _T2>
inline void _Construct(_T1* __p, const _T2& __value) {
	//这里就是调用placement new,在调用_T1的构造函数去将初始值设置到指定内存p所指位置
	new ((void*) __p) _T1(__value);  
}

  当然还有一个接受无参的版本

template <class _T1>
inline void _Construct(_T1* __p) {
	new ((void*) __p) _T1();
}

  构造比较简单,然后看析构,析构的复杂之处在于要判断是否需要真的去析构,因为有些析构函数实际上是什么都没有做。先看简单的版本,

template <class _Tp>
inline void _Destroy(_Tp* __pointer) {
	//这个析构就是直接调用类的析构函数去析构
	__pointer->~_Tp();  
}

  然后看第二个版本,这里传入了一个first和last两个迭代器,然后析构此范围内的所有对象

template <class _ForwardIterator>
inline void _Destroy(_ForwardIterator __first, _ForwardIterator __last) {
	//__VALUE_TYPE判断指针类型
	__destroy(__first, __last, __VALUE_TYPE(__first));
}
//__destroy干了啥呢
template <class _ForwardIterator, class _Tp>
inline void 
__destroy(_ForwardIterator __first, _ForwardIterator __last, _Tp*)
{
	typedef typename __type_traits<_Tp>::has_trivial_destructor _Trivial_destructor;    
	__destroy_aux(__first, __last, _Trivial_destructor());
}
//__destroy_aux又干了啥呢,有两个版本
template <class _ForwardIterator> 
inline void __destroy_aux(_ForwardIterator, _ForwardIterator, __true_type) {}

template <class _ForwardIterator>
void __destroy_aux(_ForwardIterator __first, _ForwardIterator __last, __false_type)
{
	for ( ; __first != __last; ++__first)
	destroy(&*__first);
}

  第二个版本的destroy()做了什么呢?在[first, last)这个范围去析构对象,每个对象都析构一次,这些析构如果没有什么实际的操作那就太没必要了,所以呢先用__VALUE_TYPE判断了迭代器所指对象的类型,再利用__type_traits<_Tp>::has_trivial_destructor判断是否是没什么操作的析构函数,是的话到__true_type里面什么都不执行,不是的话到__false_type里面虚幻调用第一个版本的destroy(&*__first)。
  这里确实很有意思,需要关注一下。

空间的配置和释放

  对象的构造和析构相对比较简单,就是调用构造函数和析构函数处理。SGI STL的内存配置使用了两层的配置器。我们知道C++申请和释放内存的基本操作是::operator new和::operator delete,其底层实现就是malloc()和free()。SGI的第一层配置器就是使用malloc()和free(),同时实现了第二层配置器来解决频繁申请内存的内存碎片和效率问题。
  当配置区块超过128 bytes 时,视之为 “足够大”,便调用第一级配置器;当配置区块小于 128 bytes 时,视之为 “过小” ,为了降低额外负担,便采用内存池管理方式。
前面说了SGI STL的配置器是alloc,这实际上是个typedef,

//直接将inst参数设置为0,所以alloc本身不支持指定任何参数了
typedef __malloc_alloc_template<0> malloc_alloc;
typedef malloc_alloc alloc;
typedef __default_alloc_template<__NODE_ALLOCATOR_THREADS, 0> alloc;

  在分析alloc之前,我们要知道SGI为alloc封装了一个接口以符合STL规范,

template<class _Tp, class _Alloc>
class simple_alloc {
public:
    static _Tp* allocate(size_t __n)
      { return 0 == __n ? 0 : (_Tp*) _Alloc::allocate(__n * sizeof (_Tp)); }
    static _Tp* allocate(void)
      { return (_Tp*) _Alloc::allocate(sizeof (_Tp)); }
    static void deallocate(_Tp* __p, size_t __n)
      { if (0 != __n) _Alloc::deallocate(__p, __n * sizeof (_Tp)); }
    static void deallocate(_Tp* __p)
      { _Alloc::deallocate(__p, sizeof (_Tp)); }
};

  这个封装的接口将配置器的配置单位从byte转为元素大小sizeof (_Tp),后面看容器代码我们会发现,所有容器都是用这个simple_alloc

第一级配置器

  第一级配置器是__malloc_alloc_template这个模板,只是对malloc和free的封装,

//注意,无「template型别参数」。至于「非型别参数」inst,完全没派上用场。
template <int __inst>
class __malloc_alloc_template {
private:
	// 函数指针,处理内存不足情况,这里需要注意_S_oom_malloc和_S_oom_realloc是在内存不足时必定
	// 调用的,然后其中又去调用__malloc_alloc_oom_handler这个由用户指定的处理函数
	static void* _S_oom_malloc(size_t);
	static void* _S_oom_realloc(void*, size_t);
	#ifndef __STL_STATIC_TEMPLATE_MEMBER_BUG
		static void (* __malloc_alloc_oom_handler)();
	#endif
public:
	static void* allocate(size_t __n)
	{
		// 第一级配置器直接使用malloc
		void* __result = malloc(__n);
		// malloc返回NULL说明内存不足,改用oom方法处理内存不足
		if (0 == __result) __result = _S_oom_malloc(__n);
		return __result;
	}
	
	static void deallocate(void* __p, size_t /* __n */)
	{
		// 第一级配置器直接使用free
		free(__p); 
	}
	
	//realloc尝试重新调整之前调用 malloc 申请的内存
	static void* reallocate(void* __p, size_t /* old_sz */, size_t __new_sz)
	{
		// 第一级配置器直接使用realloc()
		void* __result = realloc(__p, __new_sz);
		// 无法满足需求时使用oom_realloc()
		if (0 == __result) __result = _S_oom_realloc(__p, __new_sz);
		return __result;
	}

  // 以下模拟c++的set_new_handler(),可以指定自己的oom handler
	static void (* __set_malloc_handler(void (*__f)()))()
	{
		void (* __old)() = __malloc_alloc_oom_handler;
		__malloc_alloc_oom_handler = __f;
		return(__old);
	}

};

  在C++中有一个new handler机制,当内存分配无法满足时可以在抛出bad_alloc异常前调用指定的处理函数,这个处理函数就是new handler。由于这里直接采用malloc,所以就没有new handler,那就只能单独实现一个__set_malloc_handler。
  我们可以看一下这个_S_oom_malloc里面干了啥,

template <int __inst>
// 由客户端设定
void (* __malloc_alloc_template<__inst>::__malloc_alloc_oom_handler)() = 0;
template <int __inst>
void* __malloc_alloc_template<__inst>::_S_oom_malloc(size_t __n)
{
    void (* __my_malloc_handler)();
    void* __result;
    for (;;) { // 不断尝试释放、配置、再释放、再配置
        __my_malloc_handler = __malloc_alloc_oom_handler;
        // 如果没有指定,那不好意思直接抛出异常
        if (0 == __my_malloc_handler) { __THROW_BAD_ALLOC; }
        (*__my_malloc_handler)();  // 企图释放内存
        __result = malloc(__n);   // 尝试配置内存
        if (__result) return(__result);
    }
}

可以看到,第一级配置器还是比较简单的,就是malloc的操作

第二级配置器

  第二级空间配置器在申请小内存使用,即当申请的内存小于128bytes时,就采用第二级配置器,这样做的好处是避免太多小额区块造成的内存碎片。
  二级配置器维护一个内存池和16个链表,内存池是预先申请好的一大块内存。16个free-list链表,对应内存大小是8,16,…,128bytes,每个free-list链表的节点是一个内存空间,第二级配置器会将任何小额区块的内存需求量上调至8的倍数,然后从对应的free-list中取出节点即内存,释放的时候则逆之。
如上所述,free-list是一个链表,其节点定义如下,

union _Obj {
	union _Obj* _M_free_list_link;
	char _M_client_data[1];   
};

  观察这个链表会发现和我们平时使用的struct链表形式不同,它使用了一个联合体,有两个成员,union _Obj* _M_free_list_link很好理解,就是指向下一个链表节点的指针,那么char _M_client_data[1]又是为了干啥呢。
  我们知道联合体成员公用一段内存,char _M_client_data是一个数组名,也是首地址,和_M_free_list_link指向同一段内存。那为什么要连个都用呢,实际上只是为了区分开,当节点没有分配出去时,用_M_free_list_link表示指向下一段内存,而当节点分配给用户使用时,则可以通过_M_client_data来访问内存内容。
  接下来,我们来看二级配置器的代码,

enum {_ALIGN = 8};   // 最小为8
enum {_MAX_BYTES = 128};  // 最大为128
enum {_NFREELISTS = 16}; // _MAX_BYTES/_ALIGN free_list个数
// 以下为第二级配置器
// 无模板参数,且inst没用,第一个参数用于多线程情况
template <bool threads, int inst>
class __default_alloc_template {
private:
	// 向上取整至8的倍数(取反加一的逆操作)
	static size_t _S_round_up(size_t __bytes) 
	  { return (((__bytes) + (size_t) _ALIGN-1) & ~((size_t) _ALIGN - 1)); }
	// 节点构造
	union _Obj {
	      union _Obj* _M_free_list_link;
	      char _M_client_data[1];    
	};
private:
    static _Obj* __STL_VOLATILE _S_free_list[_NFREELISTS]; 
	// n从1算起,根据区块大小决定使用第n号free_list
	static  size_t _S_freelist_index(size_t __bytes) {
	     return (((__bytes) + (size_t)_ALIGN-1)/(size_t)_ALIGN - 1);
	}
	// 传回一个大小为 n的对象,并可能加入大小为n的节点块到 free_list
	static void* _S_refill(size_t __n);
	// 配置一大块空间,可容纳 nobjs个大小为 "size"的区块。
	static char* _S_chunk_alloc(size_t __size, int& __nobjs);

	static char* _S_start_free; //内存忆池起始位置。只在 chunk_alloc()变化
	static char* _S_end_free;  //内存池结束位置。只在 chunk_alloc()变化
	static size_t _S_heap_size;
public:
	static void* allocate(size_t __n);
	static void deallocate(void* __p, size_t __n);
	static void* reallocate(void* __p, size_t __old_sz, size_t __new_sz);
} ;

  这里面需要关注的就是_S_free_list这个数组,它的元素是 _Obj*,对应16个free-list的链表头。

空间配置

  空间配置通过allocate这个接口,

static void* allocate(size_t __n)
{
	void* __ret = 0;
	// 默认先调用第二级配置器,如果大于128就调用第一级配置器
	if (__n > (size_t) _MAX_BYTES) {
  		__ret = malloc_alloc::allocate(__n);
	}
 	else {
    // 寻找16个free_list中适当的一个,也就是找到对应的链表头
    _Obj* __STL_VOLATILE* __my_free_list = _S_free_list + _S_freelist_index(__n);
    // __my_free_list 是一个二级指针_Obj**,_S_free_list是数组地址,偏移后得到元素地址,
    //*__my_free_list解引用就是这个元素,而元素又是一个_Obj*
    _Obj* __RESTRICT __result = *__my_free_list;
    // 没找到可用的free_list,重新填充free_list
   if (__result == 0)
      __ret = _S_refill(_S_round_up(__n));
   else {
     // 调整free_list,__my_free_list就是链表头,将当前链表头的下一个节点作为新链表头,原链表头
     // 作为分配的内存返回出去
      *__my_free_list = __result -> _M_free_list_link;
      __ret = __result;
   }
 }
 return __ret;
};

  分配内存其实就是从free-list中找到对应的头节点分出去,也就是删除链表头结点的过程,当然这是在free-list节点充足的情况下,如果free-list没有节点了就要通过refill重新填充。
  释放内存也就比较简单了,其实就是把要释放的那段内存作为新的头结点加入到free-list中,

static void deallocate(void* __p, size_t __n)
{
	// 大于128调用第一级配置器
	if (__n > (size_t) _MAX_BYTES)
		malloc_alloc::deallocate(__p, __n);
	else {
	// 寻找对应的free_list
	_Obj* __STL_VOLATILE*  __my_free_list = _S_free_list + _S_freelist_index(__n);
	// p就是要释放的内存,q是它的副本
	_Obj* __q = (_Obj*)__p;
	//将q作为新的头结点
	__q -> _M_free_list_link = *__my_free_list;
	*__my_free_list = __q;
}
}

重新填充

  前面提到了,当从free-list中分配节点是,如果节点没有了,就要通过refill重新从内存池中分配节点,用新分配的节点填充free-list,同时返回一个给用户。因为要从内存池中取得节点,那必然分为以下几种情况,
①内存池空间充足,默认返回20个节点
②内存池空间不充足,返回的节点数小于20个
③内存池完全没空间了,一个都分配不了,那就要重新malloc一块内存了

template <bool __threads, int __inst>
void* __default_alloc_template<__threads, __inst>::_S_refill(size_t __n)
{
    int __nobjs = 20;
    //调用 chunk_alloc(),尝试取得 nobjs个节点填充free list
    //注意参数 nobjs是引用作为参数,所以值可能会改变
    char* __chunk = _S_chunk_alloc(__n, __nobjs);
    _Obj* __STL_VOLATILE* __my_free_list;
    _Obj* __result;
    _Obj* __current_obj;
    _Obj* __next_obj;
    int __i;
    // 只获得一个区块,则返回给调用者,free_list无新节点
    if (1 == __nobjs) return(__chunk);
    // 否则调整free_list,加入新节点,想取得链表头
    __my_free_list = _S_free_list + _S_freelist_index(__n);
    // 然后重新建立free_list节点,第一个节点返回出去,从第2个开始串穿起来
    __result = (_Obj*)__chunk;
    // free_list指向新配置的第二个节点,由于是从内存池中分配的,因此这些节点在内存上是连续的,+n就得
    // 到了下一个节点的地址
    *__my_free_list = __next_obj = (_Obj*)(__chunk + __n);
    // free_list节点串联,就是链表建立的过程
    for (__i = 1; ; __i++) {
        __current_obj = __next_obj;
        __next_obj = (_Obj*)((char*)__next_obj + __n);
        if (__nobjs - 1 == __i) {
        	//最后一个节点了,next指针置空
            __current_obj -> _M_free_list_link = 0;
            break;
        } else {
            __current_obj -> _M_free_list_link = __next_obj;
        }
      }
    return(__result);
}

  可以看到,refill其实就是调用chunk_alloc得到新的节点,然后把这些节点串到对应的free-list中去,实际上就是链表建立的过程。

重中之重的内存池

  chunk_alloc从内存池中分配节点,那具体是怎么做的呢,我们来看内存池的代码,

template <bool __threads, int __inst>
char* __default_alloc_template<__threads, __inst>::_S_chunk_alloc(size_t __size, int& __nobjs)                                                            
{
    char* __result;
    //要申请的总大小
    size_t __total_bytes = __size * __nobjs;
    //内存池剩余空间
    size_t __bytes_left = _S_end_free - _S_start_free;
    // 内存池空间满足需求,这种情况比较简单。我们已经知道内存池起始位置,将起始位置作为结果返回,
    //这就是新分配节点的其实位置,然后调整内存池起始位置,调整大小就是我们申请的大小
    if (__bytes_left >= __total_bytes) {
        __result = _S_start_free;
        _S_start_free += __total_bytes;
        return(__result);
    // 剩余空间不够分配20个节点了,但是够分配1个以上的节点,那就算一下当前能分配多少
    } 
    else if (__bytes_left >= __size) {
    	//当前实际能分配的节点数量
        __nobjs = (int)(__bytes_left/__size);
        __total_bytes = __size * __nobjs;
        __result = _S_start_free;
        _S_start_free += __total_bytes;
        return(__result);
    } 
    // 内存池中空间连一个节点都分配不了,这中情况稍微复杂点
    else {
    	//需要重新申请内存,重新申请的内存大大小是需要分配的2倍加上一个随着配置次数增多的量
        size_t __bytes_to_get = 2 * __total_bytes + _S_round_up(_S_heap_size >> 4);
        // 内存池中还有剩余空间,看看这个空间可以分配给那个free-list,避免浪费
        if (__bytes_left > 0) {
            _Obj* __STL_VOLATILE* __my_free_list =
                        _S_free_list + _S_freelist_index(__bytes_left);
			// 将内存池的这个起始位置作为可分配节点的新链表头
            ((_Obj*)_S_start_free) -> _M_free_list_link = *__my_free_list;
            *__my_free_list = (_Obj*)_S_start_free;
        }
        // 重新malloc内存,作为新的内存池维护的空间
        _S_start_free = (char*)malloc(__bytes_to_get);
        if (0 == _S_start_free) {
            // 堆空间都不足,malloc失败情况,这里的处理实际上并不是那么重要,我们可以先不看
            size_t __i;
            _Obj* __STL_VOLATILE* __my_free_list;
	    	_Obj* __p;
            for (__i = __size;__i <= (size_t) _MAX_BYTES; __i += (size_t) _ALIGN){             
                __my_free_list = _S_free_list + _S_freelist_index(__i);
                __p = *__my_free_list;
                if (0 != __p) {
                    *__my_free_list = __p -> _M_free_list_link;
                    _S_start_free = (char*)__p;
                    _S_end_free = _S_start_free + __i;
                    // 递归调用自己,为了修正 nobjs。
                    return(_S_chunk_alloc(__size, __nobjs));
                }
            }
        // 我们重点关注malloc以后的情况
	    _S_end_free = 0;	
        // 调用第一级空间配置器,malloc内存,_S_start_free就是内存池新的起始位置
        _S_start_free = (char*)malloc_alloc::allocate(__bytes_to_get);
    }
    	//这个heap_size是随着分配次数逐渐增大的量
        _S_heap_size += __bytes_to_get;
        //内存池新的结束位置
        _S_end_free = _S_start_free + __bytes_to_get;
        //这里的这个递归调用是干什么呢,起始也不难理解。chunk_alloc函数里面最后这种内存
        //不足情形,我们只是malloc了新的内存,然后给了内存池新的起始和结束位置,也就是
        //维护了一个新的内存池,但是我们还没处理节点分配的情况,所以我们需要再调用一次chunk_alloc
        //这次调用就会进入到第一种情形,也就是内存池剩余空间完全满足要求
        return(_S_chunk_alloc(__size, __nobjs));
    }
}

  这个内存池的维护逻辑比较清晰,注意一下_S_heap_size这个量,每次分配后它的值都变大。树上举的这个例子比较全面。
(1)一开始,用户调用chunk_alloc(32, 20),这时候内存池是空的,所以malloc 40×32 的内存,然后返回第一个区块给用户,19个交给32kb的free_list维护,剩下的20×32的空间由内存池维护。
(2)接下来设64kb的free_list已经空了,用户又调用了chunk_alloc(64, 20),这时候申请内存池只能给出 20×32 / 64 = 10 个区块,就把第一个返回给用户,然后剩下9个给64kb的free_list维护。内存池又空了。
(3)接下来假设96kb的free_list已经空了,用户allocate时又来申请节点了,这次调用chunk_alloc(96, 20),此时内存池也空了,所以重新malloc (40 * 96 + f(heap_size))kb的内存,然后第一个返回给用户,19个给96kb的free_list维护,剩下的空间给内存池。

  好了,到这里,整个空间配置器的内容就结束了。回顾一下,其实就是将内存分配和构造分开,然后内存配置设计了两层的空间配置器,接下来就是第二层空间配置器free-list和内存池的维护过程。

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值