linux cache 机制探究,Runtime原理探究(三)—— OC Class的方法缓存cache_t

Runtime系列文章

☕️☕️本文篇幅比较长,创作的目的并不是为了在简书上刷赞和阅读量,而是为了自己日后温习知识所用。如果有幸被你发现这篇文章,并且引起了你的阅读兴趣,请休息充分,静下心来,精力充足地开始阅读,希望这篇文章能对你有所帮助。如发现任何有误之处,肯请留言纠正,谢谢。☕️☕️

承接上一篇的内容,我们回过头去看Class的定义

struct objc_class : objc_object {

// Class ISA;

Class superclass;

cache_t cache; // formerly cache pointer and vtable 方法缓存

class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags 用于获取具体的类信息

};

这里面还有一个cache_t cache没有解读过,一起来看一看这个东西。看名字很好理解,就是缓存的意思,缓存什么呢?——缓存方法。

它的底层是通过散列表(哈希表)的数据结构来实现的,用于缓存曾经调用过的方法,可以提高方法的查找速度。

首先,回顾一下正常情况下方法调用的流程。假设我们调用一个实例方法[bj XXXX];

obj -> isa -> obj的Class对象 -> method_array_t methods -> 对该表进行遍历查找,找到就调用,没找到继续往下走

obj -> superclass -> obj的父类 -> isa -> method_array_t methods -> 对父类的方法列表进行遍历查找,找到就调用,没找到就重复本步骤

找到就调用,没找到重复流程

找到就调用,没找到重复流程

找到就调用,没找到重复流程

直到NSObject -> isa -> NSObject的Class对象 -> method_array_t methods ......

如果XXXX方法在程序内会被频繁的调用,那么这种逐层便利查找的方式肯定是效率低下的,因此苹果设计了cache_t cache,当XXXX第一次被调用的时候,会按照常规流程查找,找到之后,就会被加入到cache_t cache中,当再次被调用的时候,系统就会直接现到cache_t cache来查找,找到就直接调用,这样便大大提升了查找的效率。

刚才介绍了cache_t cache是通过散列表来实现的,下面就来着重分析一下,方法是如何被缓存的。散列/哈希表,想必大部分iOS开发这至少应该听过,而我们常用的NSDictionary其实就是一种散列表数据结构。来看一下cache_t cache的定义

struct cache_t {

struct bucket_t *_buckets;

mask_t _mask;

mask_t _occupied;

}

struct bucket_t *_buckets; —— 用来缓存方法的散列/哈希表

mask_t _mask; —— 这个值 = 散列表长度 - 1

mask_t _occupied; —— 表示已经缓存的方法的数量

上面介绍的_buckets散列表里面的存储单元是bucket_t,来看看它包含了方法的什么信息

struct bucket_t {

private:

cache_key_t _key;

IMP _imp;

}

cache_key_t _key; —— 这个key实际上就是方法的SEL,也就是方法名

IMP _imp; —— 这个就是方法对应的函数的内存地址

想一想我们平时是怎么使用NSDictionary的,通过一堆Key-Value键值对来进行存储的,NSDictionary的底层就是散列表,这个刚才说过。方法缓存的时候,key就是上面的cache_key_t _key;,value就是上面的bucket_t结构体对象。

但是散列表的运作原理到底如何呢,这个属于数据结构问题,这里简要介绍一下。首先散列表本质上就是一个数组

a1a91d91641b在往散列表里面添加成员的时候,首先需要借助key计算出一个index,然后再将元素插入散列表的index位置

a1a91d91641b

往散列表插值

那么从散列表里面取值就显而易见了,根据一个key,计算出index,然后到散列表对应位置将值取出

a1a91d91641b

根据key从散列表取值

这里的查询方法的时候(也就是取值操作),时间复杂度为O(1), 对比我们一开始从方法列表的遍历查询,它的时间复杂度为O(n),因此通过缓存方法,可以极大的提高方法查询的效率,从而提高了方法调用机制的效率。

根据key计算出index值的这个算法称作散列算法,这个算法可以由你自己设计,总之目的就是尽可能减少不同的key得出相同index的情况出现,这种情况被称作哈希碰撞,同时还要保证得出的index值在合理的范围。index越大,意味着对应的散列表的长度越长,这是需要占用实际物理空间的,而我们的内存是有限的。散列表是一种通过牺牲一定空间,来换取时间效率的设计思想。

我们通过key计算出的index大小是随机的,无顺序的,因此在方法缓存的过程中,插入的顺序也是无顺序的

a1a91d91641b

而且可以预见的是,散列表里面再实际使用中会有很多位置是空着的,比如散列表长度为16,最终值存储了10个方法,散列表长度为64,最终可能只会存储40个方法,有一部分空间终究是要被浪费的。但是却提高查找的效率。这既是所谓的空间换时间。

再介绍一下苹果这里所采用的散列算法,其实很简单,如下

index = @selector(XXXX) & mask 根据&运算的特点,可以得知最终index <= mask,而mask = 散列表长度 - 1,也就是说 0 <= index <= 散列表长度 - 1,这实际上覆盖了散列表的索引范围。而刚刚我们还提到过一个问题——哈希碰撞,也就是不同的key得到相同的index,该怎么处理呢?我们看一下源码,在objc源码里面搜索cache_t,可以发现一个跟查找相关的方法

a1a91d91641b

bucket_t * cache_t::find(cache_key_t k, id receiver) //根据key值 k 进行查找

{

assert(k != 0);

bucket_t *b = buckets();

mask_t m = mask();

mask_t begin = cache_hash(k, m); //通过cache_hash函数【begin = k & m】计算出key值 k 对应的 index值 begin,用来记录查询起始索引

mask_t i = begin; // begin 赋值给 i,用于切换索引

do {

if (b[i].key() == 0 || b[i].key() == k) {

//用这个i从散列表取值,如果取出来的bucket_t的 key = k,则查询成功,返回该bucket_t,

//如果key = 0,说明在索引i的位置上还没有缓存过方法,同样需要返回该bucket_t,用于中止缓存查询。

return &b[i];

}

} while ((i = cache_next(i, m)) != begin);

// 这一步其实相当于 i = i-1,回到上面do循环里面,相当于查找散列表上一个单元格里面的元素,再次进行key值 k的比较,

//当i=0时,也就i指向散列表最首个元素索引的时候重新将mask赋值给i,使其指向散列表最后一个元素,重新开始反向遍历散列表,

//其实就相当于绕圈,把散列表头尾连起来,不就是一个圈嘛,从begin值开始,递减索引值,当走过一圈之后,必然会重新回到begin值,

//如果此时还没有找到key对应的bucket_t,或者是空的bucket_t,则循环结束,说明查找失败,调用bad_cache方法。

// hack

Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));

cache_t::bad_cache(receiver, (SEL)k, cls);

}

*********************************** cache_hash(k, m);

static inline mask_t cache_hash(cache_key_t key, mask_t mask)

{

return (mask_t)(key & mask);

}

*********************************** cache_next(i, m)

static inline mask_t cache_next(mask_t i, mask_t mask) {

// return (i-1) & mask; // 非arm64

return i ? i-1 : mask; // arm64

}

cache_t::find函数还被源码里面的另一个函数调用过——cache_fill_nolock,缓存填充(插入)操作,源码如下

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)

{

cacheUpdateLock.assertLocked();

// Never cache before +initialize is done

if (!cls->isInitialized()) return;

// Make sure the entry wasn't added to the cache by some other thread

// before we grabbed the cacheUpdateLock.

if (cache_getImp(cls, sel)) return;

cache_t *cache = getCache(cls);

cache_key_t key = getKey(sel);

// Use the cache as-is if it is less than 3/4 full

mask_t newOccupied = cache->occupied() + 1;

mask_t capacity = cache->capacity();

if (cache->isConstantEmptyCache()) {

// Cache is read-only. Replace it.

cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);

}

else if (newOccupied <= capacity / 4 * 3) {

// Cache is less than 3/4 full. Use it as-is.

}

else {

// Cache is too full. Expand it.

cache->expand();

}

// Scan for the first unused slot and insert there.

// There is guaranteed to be an empty slot because the

// minimum size is 4 and we resized at 3/4 full.

bucket_t *bucket = cache->find(key, receiver);

if (bucket->key() == 0) cache->incrementOccupied();

bucket->set(key, imp);

}

上面源码的最后一段以及它的注释说明可以明白,当通过cache->find返回的bucket->key() == 0,就说明该位置上是空的,没有缓存过方法,是一个unused slot(未使用的槽口),因此可以进行插入操作bucket->set(key, imp);,也就是将方法缓存到这个位置上。

根据上面的分析,下面用图示来总结一下方法存入cache_t中,以及从cache_t中取方法的整体流程

向cache_t存入方法

a1a91d91641b

(1)缓存bucket_t(key_A,IMP_A)

a1a91d91641b

(2)缓存bucket_t(key_B,IMP_B)

a1a91d91641b

(3)缓存bucket_t(key_C,IMP_C)

a1a91d91641b

(4)缓存bucket_t(key_D,IMP_D)

从cache_t查询方法

a1a91d91641b

(1)查询 SEL = key_A

a1a91d91641b

(1)查询 SEL = key_C

a1a91d91641b

(1)查询 SEL = key_D

a1a91d91641b

(1)查询 SEL = key_E

你可能还会有一个疑问,如果不断的往缓存里添加方法,缓存满了怎么办?我们回到刚才看过的一段代码cache_fill_nolock函数,直接用截图解读一下

a1a91d91641b通过上面的解读,可以知道,其实苹果的做法是,在已缓存的方法数量达到当前缓存容量的3/4时候,就会出发扩容操作expand(),源码如下

void cache_t::expand()

{

cacheUpdateLock.assertLocked();

uint32_t oldCapacity = capacity();

uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;

if ((uint32_t)(mask_t)newCapacity != newCapacity) {

// mask overflow - can't grow further

// fixme this wastes one bit of mask

newCapacity = oldCapacity;

}

reallocate(oldCapacity, newCapacity);

}

上面代码里面uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;说的很明白,扩容就是将当前缓存容量* 2,如果是首次调用这个函数,会使用一个初始容量值INIT_CACHE_SIZE来设定缓存容量

enum {

INIT_CACHE_SIZE_LOG2 = 2,

INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2)

};

从INIT_CACHE_SIZE的定义显示它的值是4,也就是说苹果给cache_t设定的初始容量是4。

你可能还会问,重置缓存之后,原来老缓存里面的内容还要不要呢,expand()函数里面调用的最后一个函数是reallocate(oldCapacity, newCapacity);,我们在进入它的源码看看

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)

{

bool freeOld = canBeFreed();

bucket_t *oldBuckets = buckets();

bucket_t *newBuckets = allocateBuckets(newCapacity);

// Cache's old contents are not propagated.

// This is thought to save cache memory at the cost of extra cache fills.

// fixme re-measure this

assert(newCapacity > 0);

assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);

setBucketsAndMask(newBuckets, newCapacity - 1);

if (freeOld) {

cache_collect_free(oldBuckets, oldCapacity);

cache_collect(false);

}

}

很明显,在最对旧的缓存空间进行了释放,但是条件是freeOld = true,函数开头给出了freeOld的由来,通过canBeFreed()函数获得

bool cache_t::isConstantEmptyCache()

{

return

occupied() == 0 &&

buckets() == emptyBucketsForCapacity(capacity(), false);

}

bool cache_t::canBeFreed()

{

return !isConstantEmptyCache();

}

canBeFreed()函数其实很简单,就是判断一下缓存是不是空的,如果空的,旧没必要释放空间了,如果原来的缓存不是空的,就直接释放掉,并且我们发现,扩容的操作里面,并没有对旧的缓存空间里面的内容进行复制保留,就是很粗暴的直接分配一块新的缓存空间,然后直接释放掉旧的缓存空间,这意味着,每次进行扩容操作之后,原来缓存过的方法就会全部丢失,而上面的cache_fill_nolock函数里面,在进行完expand()扩容操作之后,也仅仅是把当前处理的方法放到缓存空间里面,因此,扩容之前曾经被缓存过的方法,如果下次再次调用的话,有需要被重新缓存了。这里好好体会一下。

父类的方法被调用的时候,会如何缓存?

现在,我们知道,当对一个对象发送消息后,会通过对象的isa找到它的Class对象,在Class对象里面先从方法缓存cache_t查找该方法,没有的话再对Class对象的方法列表进行遍历查找,如果找到了方法,就进行缓存并且调用,那么这里肯定是将方法缓存到了该对象的Class对象的cache_t里面。

如果在当前Class对象里面没有找到该方法,那么会通过Class对象的superclass进入其父类的Class对象里面,同样,会先查找它的cache_t,如果没有找到方法,会对其方法列表进行遍历查找,问题就在这里,如果此时在方法列表里面找到了方法,进行缓存操作的时候,是会将方法存入当前父类的Class对象的cache_t里面呢,还是会存到接收消息的对象的Class对象的cache_t里面呢?

要搞清楚这个问题,首先可以看一下哪些地方调用了static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)函数,因为它的参数里面传入了一个Class cls我们只需要搞清楚这个Class cls到底是谁。

根据下图的操作进入上层调用函数cache_fill

a1a91d91641b

用同样方法查看一下cache_fill的上层调用函数,如下图

a1a91d91641b

首先来看一下这个log_and_cache()

a1a91d91641b

image.png可以看到它实际上是被lockUpImpOrForward()函数调用的。

接下来我们在先看一下lookUpMethodInClassAndLoadCache()函数

a1a91d91641b很显然,这个函数没有处理superclass的问题,不是我们要找的。

最后在来看一下剩下的那个 lookUpImpOrForward函数,下面代码请看⚠️⚠️⚠️处标记的中文注解即可

/***********************************************************************

* lookUpImpOrForward.

* The standard IMP lookup.-------------------------------->⚠️⚠️⚠️标准的IMP查找流程

* initialize==NO tries to avoid +initialize (but sometimes fails)

* cache==NO skips optimistic unlocked lookup (but uses cache elsewhere)

* Most callers should use initialize==YES and cache==YES.

* inst is an instance of cls or a subclass thereof, or nil if none is known.

* If cls is an un-initialized metaclass then a non-nil inst is faster.

* May return _objc_msgForward_impcache. IMPs destined for external use

* must be converted to _objc_msgForward or _objc_msgForward_stret.

* If you don't want forwarding at all, use lookUpImpOrNil() instead.

**********************************************************************/

IMP lookUpImpOrForward(Class cls, SEL sel, id inst,

bool initialize, bool cache, bool resolver)

{

IMP imp = nil;

bool triedResolver = NO;

runtimeLock.assertUnlocked();

// Optimistic cache lookup

if (cache) {//------------------------------>⚠️⚠️⚠️查询当前Class对象的缓存,如果找到方法,就返回该方法

imp = cache_getImp(cls, sel);

if (imp) return imp;

}

// runtimeLock is held during isRealized and isInitialized checking

// to prevent races against concurrent realization.

// runtimeLock is held during method search to make

// method-lookup + cache-fill atomic with respect to method addition.

// Otherwise, a category could be added but ignored indefinitely because

// the cache was re-filled with the old value after the cache flush on

// behalf of the category.

runtimeLock.read();

if (!cls->isRealized()) {//------------------------------>⚠️⚠️⚠️当前Class如果没有被realized,就进行realize操作

// Drop the read-lock and acquire the write-lock.

// realizeClass() checks isRealized() again to prevent

// a race while the lock is down.

runtimeLock.unlockRead();

runtimeLock.write();

realizeClass(cls);

runtimeLock.unlockWrite();

runtimeLock.read();

}

if (initialize && !cls->isInitialized()) {//-------------->⚠️⚠️⚠️当前Class如果没有初始化,就进行初始化操作

runtimeLock.unlockRead();

_class_initialize (_class_getNonMetaClass(cls, inst));

runtimeLock.read();

// If sel == initialize, _class_initialize will send +initialize and

// then the messenger will send +initialize again after this

// procedure finishes. Of course, if this is not being called

// from the messenger then it won't happen. 2778172

}

retry:

runtimeLock.assertReading();

// Try this class's cache.//------------------------------>⚠️⚠️⚠️尝试从该Class对象的缓存中查找,如果找到,就跳到done处返回该方法

imp = cache_getImp(cls, sel);

if (imp) goto done;

// Try this class's method lists.//---------------->⚠️⚠️⚠️尝试从该Class对象的方法列表中查找,找到的话,就缓存到该Class的cache_t里面,并跳到done处返回该方法

{

Method meth = getMethodNoSuper_nolock(cls, sel);

if (meth) {

log_and_fill_cache(cls, meth->imp, sel, inst, cls);

imp = meth->imp;

goto done;

}

}

// Try superclass caches and method lists.------>⚠️⚠️⚠️进入当前Class对象的superclass对象

{

unsigned attempts = unreasonableClassCount();

for (Class curClass = cls->superclass;//------>⚠️⚠️⚠️该for循环每循环一次,就会进入上一层的superclass对象,进行循环内部方法查询流程

curClass != nil;

curClass = curClass->superclass)

{

// Halt if there is a cycle in the superclass chain.

if (--attempts == 0) {

_objc_fatal("Memory corruption in class list.");

}

// Superclass cache.------>⚠️⚠️⚠️在当前superclass对象的缓存进行查找

imp = cache_getImp(curClass, sel);

if (imp) {

if (imp != (IMP)_objc_msgForward_impcache) {

// Found the method in a superclass. Cache it in this class.

log_and_fill_cache(cls, imp, sel, inst, curClass);

goto done;//------>⚠️⚠️⚠️如果在当前superclass的缓存里找到了方法,就调用log_and_fill_cache进行方法缓存,注意这里传入的参数是cls,也就是将方法缓存到消息接受对象所对应的Class对象的cache_t中,然后跳到done处返回该方法

}

else {

// Found a forward:: entry in a superclass.

// Stop searching, but don't cache yet; call method

// resolver for this class first.

break;//---->⚠️⚠️⚠️如果缓存里找到的方法是_objc_msgForward_impcache,就跳出该轮循环,进入上一层的superclass,再次进行查找

}

}

// Superclass method list.---->⚠️⚠️⚠️如过画缓存里面没有找到方法,则对当前superclass的方法列表进行查找

Method meth = getMethodNoSuper_nolock(curClass, sel);

if (meth) {

//------>⚠️⚠️⚠️如果在当前superclass的方法列表里找到了方法,就调用log_and_fill_cache进行方法缓存,注意这里传入的参数是cls,也就是将方法缓存到消息接受对象所对应的Class对象的cache_t中,然后跳到done处返回该方法

log_and_fill_cache(cls, meth->imp, sel, inst, curClass);

imp = meth->imp;

goto done;

}

}

}

// No implementation found. Try method resolver once.//------>⚠️⚠️⚠️如果到基类还没有找到方法,就尝试进行方法解析

if (resolver && !triedResolver) {

runtimeLock.unlockRead();

_class_resolveMethod(cls, sel, inst);

runtimeLock.read();

// Don't cache the result; we don't hold the lock so it may have

// changed already. Re-do the search from scratch instead.

triedResolver = YES;

goto retry;

}

// No implementation found, and method resolver didn't help. //------>⚠️⚠️⚠️如果方法解析不成功,就进行消息转发

// Use forwarding.

imp = (IMP)_objc_msgForward_impcache;

cache_fill(cls, sel, imp, inst);

done:

runtimeLock.unlockRead();

return imp;

}

关于上面再方法列表查找的函数Method meth = getMethodNoSuper_nolock(cls, sel);还需要说明一下,进入它的实现

static method_t *

getMethodNoSuper_nolock(Class cls, SEL sel)

{

runtimeLock.assertLocked();

assert(cls->isRealized());

// fixme nil cls?

// fixme nil sel?

for (auto mlists = cls->data()->methods.beginLists(),

end = cls->data()->methods.endLists();

mlists != end;

++mlists)

{

method_t *m = search_method_list(*mlists, sel);//---⚠️⚠️⚠️核心函数

if (m) return m;

}

return nil;

}

再进入其核心函数search_method_list(*mlists, sel)

static method_t *search_method_list(const method_list_t *mlist, SEL sel)

{

int methodListIsFixedUp = mlist->isFixedUp();

int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t);

if (__builtin_expect(methodListIsFixedUp && methodListHasExpectedSize, 1)) {

//---⚠️⚠️⚠️如果方法列表是经过排序的,则进行二分查找

return findMethodInSortedMethodList(sel, mlist);

} else {

// Linear search of unsorted method list

//---⚠️⚠️⚠️如果方法列表没有进行排序,则进行线性遍历查找

for (auto& meth : *mlist) {

if (meth.name == sel) return &meth;

}

}

#if DEBUG

// sanity-check negative results

if (mlist->isFixedUp()) {

for (auto& meth : *mlist) {

if (meth.name == sel) {

_objc_fatal("linear search worked when binary search did not");

}

}

}

#endif

return nil;

}

}

根据代码中的逻辑,如果方法列表是经过排序的,会使用findMethodInSortedMethodList进行查找,而这里面实际上是用二分法进行查找的,具体代码如下

static method_t *findMethodInSortedMethodList(SEL key, const method_list_t *list)

{

assert(list);

const method_t * const first = &list->first;

const method_t *base = first;

const method_t *probe;

uintptr_t keyValue = (uintptr_t)key;

uint32_t count;

//---⚠️⚠️⚠️count >>= 1相当于 count/=2,说明是从数组中间开始查找,也就是二分查找发

for (count = list->count; count != 0; count >>= 1) {

probe = base + (count >> 1);

uintptr_t probeValue = (uintptr_t)probe->name;

if (keyValue == probeValue) {

// `probe` is a match.

// Rewind looking for the *first* occurrence of this value.

// This is required for correct category overrides.

while (probe > first && keyValue == (uintptr_t)probe[-1].name) {

probe--;

}

return (method_t *)probe;

}

if (keyValue > probeValue) {

base = probe + 1;

count--;

}

}

return nil;

}

经过上述解读,我们已经基本了解方法查询和方法缓存所涉及到的细节,现在可以把方法查找和方法缓存流程结合起来描述一下 Runtime消息机制 当中的 消息发送 流程

(1) 当一个对象接收到消息时[obj message];,首先根据obj的isa指针进入它的类对象cls里面。

(2) 在obj的cls里面,首先到缓存cache_t里面查询方法message的函数实现,如果找到,就直接调用该函数。

(3) 如果上一步没有找到对应函数,在对该cls的方法列表进行二分/遍历查找,如果找到了对应函数,首先会将该方法缓存到obj的类对象cls的cache_t里面,然后对函数进行调用。

(4) 在每次进行缓存操作之前,首先需要检查缓存容量,如果缓存内的方法数量超过规定的临界值(设定容量的3/4),需要先对缓存进行2倍扩容,原先缓存过的方法全部丢弃,然后将当前方法存入扩容后的新缓存内。

(5) 如果在obj的cls对象里面,发现缓存和方法列表都找不到mssage方法,则通过cls的superclass指针进入它的父类对象f_cls里面

(6) 进入f_cls后,首先在它的cache_t里面查找mssage,如果找到了该方法,那么会首先将方法缓存到消息接受者obj的类对象cls的cache_t里面,然后调用方法对应的函数。

(7) 如果上一步没有找到方法,将会对f_cls的方法列表进行遍历二分/遍历查找,如果找到了mssage方法,那么同样,会首先将方法缓存到消息接受者obj的类对象cls的cache_t里面,然后调用方法对应的函数。需要注意的是,这里并不会将方法缓存到当前父类对象f_cls的cache_t里面。

(8) 如果还没找到方法,则会通过f_cls的superclass进入更上层的父类对象里面,按照(6)->(7)->(8)步骤流程重复。如果此时已经到了基类对象NSObject,仍没有找到mssage,则进入步骤(9)

(9) 接下来将会转到消息机制的 动态方法解析 阶段

a1a91d91641b

消息发送流程

至此,OC Runtime里面的消息发送流程和方法缓存策略就分析完毕。

Runtime系列文章

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值