内存管理(内存布局,引用计数,自动释放池)

在这里插入图片描述

5大内存分区

这里所说的内存,并非内存管理,是其他系统以及编程语言都有提及的你内存分区,是对编程语言来说比较广泛的内存说明。

  1. 栈区
  2. 堆区
  3. 全局区
  4. 常量区
  5. 代码区

在这里插入图片描述

栈区

栈区:栈区是由系统来自动分配释放,是一个栈的数据结构

  • 存放的局部变量、先进后出、一旦出了作用域就会被销毁;
  • 函数的参数(id self SEL_cmd)
  • 函数跳转地址,现场保护等;(可被递归代用的最大方法)
  • 程序员不需要管理栈区变量的内存;
  • 栈区地址从高到低分配;

堆区

堆区是由开发者手动管理,或者程序结束时由系统全部回收,是一种树状的数据结构,一般用于存储malloc,new等方式创建的对象。在ios开发中,大多数内存管理问题也多出此。都是一些开发者没有及时回收内存,或者内存溢出以及泄露等问题。

  • 堆区的内存分配使用的是 alloc/new开辟空间创建对象;
  • 使用字符串,载入图片,或使用JSON/XML数据,使用视图都会消耗大量的堆内存。
  • 需要程序员管理内存;
  • ARC 的内存的管理,是编译器再便宜的时候自动添加 retain、release、autorelease;
  • 堆区的地址是从低到高分配

全局区(静态储存区)

用于存放全局变量和静态变量,储存方式是:未经初始化的全局变量和静态变量存放在一个区域,初始化后的全局变量和静态变量在另一个区域。回收方式是等进程结束后由系统回收。

  • 包括两个部分:未初始化 、已初始化;
  • 全局区/静态区,在内存中是放在一起的,已初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域;

文字常量区

主要存储基本数据类型的值,以及常量,同样是进程结束后由系统回收。

代码区

储存要执行函数的二进制代码,如果需要执行就加载到改区域中。

通过下面代码详解


int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        int a;
        int b = 100;
        static int c = 101;
        
        NSString *str1 = @"hello world";
        NSObject *obj = [NSObject new];
        char *w1 = (char *)malloc(10);
        
        NSLog(@"a in 全局区:%p",&a);
        NSLog(@"b in 栈区:%p",&b);
        NSLog(@"c in 静态区:%p",&c);
        
        NSLog(@"str1 in 常量区:%p",&str1);
        // heap: 理论由低地址到高地址拓展,实际多运行几次发现有可能出现由高地址到地地址拓展。
        
        NSLog(@"obj in 堆:%p",obj);
        NSLog(@"w1 in 堆:%p",w1);
        
        
        
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

打印结果

 a in 全局区:0x7ffeeb90fe14
 b in 栈区:0x7ffeeb90fe10
 c in 静态区:0x1042f79c8
 str1 in 常量区:0x7ffeeb90fe08
 obj in:0x600000278000
 w1 in:0x600000278010

系统的内存分配是按照一定的逻辑来实现的,但并不是绝对的,例如图,堆区是由低地址向高地址扩展的 obj 安利应该要比 w1低一些。,实际上有可能高,这是因为堆并不是连续的内存区域,是系统用树来实前,如果 w1分配的空间大一些,则 w1的地址就会在obj之后了。

特例

1.字符串类型

多个直接声明的相同字符串在内存只占用一份内存,例如:

NSLog(@"hello1 in 常量区:%p",@"hello");
NSLog(@"hello2 in 常量区:%p",@"hello");

打印结果

 hello1 in 常量区:0x10deb6048
 hello2 in 常量区:0x10deb6048

这个变量的地址是在常量区存储,虽然声明的是两个字符串,看似应该开辟两端内存,但通过打印看出实际上是用同一块内存,这是可以理解的,因为这是同一个固定的字符串,在编译器就确定的了,不会更改,是一个不可变量,因此引用同一份内存并没有什么问题,如果需要在此字符串进行修改也是另外开辟一块内存。

1. block类型
block 声明的时候是在栈中的,但赋值给变量的时候会复制到堆中。

 // 声明一个block
    NSLog(@"block in 栈区:%p",^(){});
    
    // 将一个block赋值给一个变量
    id block = ^(){};
    NSLog(@"block in 堆区:%p",block);

例子1

- (void)blockTes1 {
    
    __block int a = 1;
    int * p = &a;
    ^{
        a = a + 1;
    }();
    
    a += 1;
    NSLog(@"a = %d,*p = %d\n",a,*p);
    
}

结果

a = 3,*p = 3

解析

在第一个方法中,直接调用了一个block,由于block还没有被赋值,所以这时block没有被赋值到堆区。所以对于a来说也没有发生复制,与p指向同一你内容,经过两次相加都是是3;

例子2

    __block int a = 1;
    int * p = &a;
    
    void (^block)(void) = ^{
        a = a + 1;
    };
    a += 1;
    
    block();
    NSLog(@"a = %d,*p = %d\n",a,*p);

结果

a = 3,*p = 1

解析

将block赋值给一个临时变量,此时根据之前所说的内容,发生了复制,__ block 修饰的a也复制为一份新的内容,但是p依然指向之前的内容,此时p与a以已不是同一内容了,所以*p依然为1,而a经过两次相加变3.

栈与堆

当对象被创建和赋值时,数据可能从栈赋值到堆,类似的,当值仅在方法内部使用时,它们也可能从堆赋值到栈中,这个操作代价昂贵

#import "AClass.h"

@interface AClass ()

@property (nonatomic, assign) NSInteger anInteger; // 值类型
@property (nonatomic, copy) NSString *aString; // 引用传递

@end

例子1 栈复制到堆

- (AClass *)creatAClassWithInteger:(NSInteger)i
                            string:(NSString *)s {
    
    // i 在栈上
    AClass *result = [AClass new];
    // 赋值给属性时,它被复制到堆中,因为那是储存result地方
    result.anInteger = i;
    result.aString = s;
    return result;
}

例子12堆复制到栈

- (void)someMethod:(NSArray *)items {
    // total 是栈内存
    NSInteger total = 0;
    NSMutableString *finalString = [NSMutableString string];
    for (AClass *obj in items) {
        //obj.anInteger 从堆复制到栈才可以使用
        total += obj.anInteger;
        [finalString appendString:obj.aString];
    }
}

内存管理方案

tagged pointer

苹果提出了Tagged Pointer对象。由于NSNumber、NSDate一类的变量本身的值需要占用的内存大小常常不需要8个字节,拿整数来说,4个字节所能表示的有符号整数就可以达到20多亿(注:2^31=2147483648,另外1位作为符号位),对于绝大多数情况都是可以处理的。

在这里插入图片描述
在这里插入图片描述

tagged pointer 修饰小数据类型做的特殊处理

就引入了tagged pointer,tagged pointer是一种特殊的“指针”,其特殊在于,其实它存储的并不是地址,而是真实的数据和一些附加的信息。

Tagged Pointer特点的介绍

  • Tagged Pointer专门用来存储小的对象,例如NSNumber, NSDate, NSString。
  • Tagged
    Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要malloc和free。
  • 在内存读取上有着3倍的效率,创建时比以前快106倍。

为什么要引入Tagged Pointer

iPhone5s 采用64位处理器。
对于64位程序,我们的数据类型的长度是跟CPU的长度有关的。

请添加图片描述

这样就导致了 一些对象占用的内存会翻倍。

同时 维护程序中的对象需要 分配内存,维护引用计数,管理生命周期,使用对象给程序的运行增加了负担。

案例讲解

NSMutableString *mutableStr = [NSMutableString string];
    NSString *immutable = nil;
    #define _OBJC_TAG_MASK (1UL<<63)
    char c = 'a';
    do {
        [mutableStr appendFormat:@"%c", c++];
        immutable = [mutableStr copy];
        NSLog(@"%p %@ %@", immutable, immutable, immutable.class);
    }while(((uintptr_t)immutable & _OBJC_TAG_MASK) == _OBJC_TAG_MASK);

打印结果

 0xab59d8bbfe98cd26 a NSTaggedPointerString
 0xab59d8bbfe9eed25 ab NSTaggedPointerString
 0xab59d8bbf8aeed24 abc NSTaggedPointerString
 0xab59d8bdb8aeed23 abcd NSTaggedPointerString
 0xab59deedb8aeed22 abcde NSTaggedPointerString
 0xab5fbeedb8aeed21 abcdef NSTaggedPointerString
 0xad2fbeedb8aeed20 abcdefg NSTaggedPointerString
 0xab5bf8835e89a26f abcdefgh NSTaggedPointerString
 0xabd1d693fac29f2e abcdefghi NSTaggedPointerString
 0x600002234cc0 abcdefghij __NSCFString

上图我们可以看到,当字符串的长度为10个以内时,字符串的类型都是NSTaggedPointerString类型,当超过10个时,字符串的类型才是__NSCFString

打印结果分析:

static inline bool 
_objc_isTaggedPointer(const void * _Nullable ptr)
{
    return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

上面这个方法我们看到,判断一个对象类型是否为NSTaggedPointerString类型实际上是讲对象的地址与_OBJC_TAG_MASK进行按位与操作,结果在跟_OBJC_TAG_MASK进行对比,我们在看下_OBJC_TAG_MASK的定义:

#if OBJC_MSB_TAGGED_POINTERS
#   define _OBJC_TAG_MASK (1UL<<63)
#else
#   define _OBJC_TAG_MASK 1UL
#endif

我们都知道一个对象地址为64位二进制,它表明如果64位数据中,最高位是1的话,则表明当前是一个tagged pointer类型。

那么我们在看下上面打印出的地址,所有NSTaggedPointerString地址都是0xd开头,d转换为二进制1110,根据上面的结论,我们看到首位为1表示为NSTaggedPointerString类型。在这里得到验证。

弱引用,引用计数

alloc:分配内存

retain:

引用计数加1

retain 底层流程
请添加图片描述

Retain源码

ALWAYS_INLINE id 
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
    if (isTaggedPointer()) return (id)this;

    bool sideTableLocked = false;
    bool transcribeToSideTable = false;
    //为什么有isa?因为需要对引用计数+1,即retain+1,而引用计数存储在isa的bits中,需要进行新旧isa的替换
    isa_t oldisa;
    isa_t newisa;
    //重点
    do {
        transcribeToSideTable = false;
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        //判断是否为nonpointer isa
        if (slowpath(!newisa.nonpointer)) {
            //如果不是 nonpointer isa,直接操作散列表sidetable
            ClearExclusive(&isa.bits);
            if (rawISA()->isMetaClass()) return (id)this;
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
            else return sidetable_retain();
        }
        // don't check newisa.fast_rr; we already called any RR overrides
        //dealloc源码
        if (slowpath(tryRetain && newisa.deallocating)) {
            ClearExclusive(&isa.bits);
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            return nil;
        }

        uintptr_t carry;
        //执行引用计数+1操作,即对bits中的 1ULL<<45(arm64) 即extra_rc,用于该对象存储引用计数值
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++
        //判断extra_rc是否满了,carry是标识符
        if (slowpath(carry)) {
            // newisa.extra_rc++ overflowed
            if (!handleOverflow) {
                ClearExclusive(&isa.bits);
                return rootRetain_overflow(tryRetain);
            }
            // Leave half of the retain counts inline and 
            // prepare to copy the other half to the side table.
            if (!tryRetain && !sideTableLocked) sidetable_lock();
            sideTableLocked = true;
            transcribeToSideTable = true;
            //如果extra_rc满了,则直接将满状态的一半拿出来存到extra_rc
            newisa.extra_rc = RC_HALF;
            //给一个标识符为YES,表示需要存储到散列表
            newisa.has_sidetable_rc = true;
        }
    } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));

    if (slowpath(transcribeToSideTable)) {
        // Copy the other half of the retain counts to the side table.
        //将另一半存在散列表的rc_half中,即满状态下是8位,一半就是1左移7位,即除以2
        //这么操作的目的在于提高性能,因为如果都存在散列表中,当需要release-1时,需要去访问散列表,每次都需要开解锁,比较消耗性能。extra_rc存储一半的话,可以直接操作extra_rc即可,不需要操作散列表。性能会提高很多
        sidetable_addExtraRC_nolock(RC_HALF);
    }

    if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    return (id)this;
}

retain 源码分析

【第一步】判断是否为Nonpointer_isa

【第二步】操作引用计数

1、如果不是Nonpointer_isa,则直接操作SideTables散列表,此时的散列表并不是只有一张,而是有很多张(后续会分析,为什么需要多张)
2、判断是否正在释放,如果正在释放,则执行dealloc流程
3、执行extra_rc+1,即引用计数+1操作,并给一个引用计数的状态标识carry,用于表示extra_rc是否满了
4、如果carray的状态表示extra_rc的引用计数满了,此时需要操作散列表,即 将满状态的一半拿出来存到extra_rc,另一半存在 散列表的rc_half。这么做的原因是因为如果都存储在散列表,每次对散列表操作都需要开解锁,操作耗时,消耗性能大,这么对半分操作的目的在于提高性能

散列表

  • sidetable 包括自旋锁(spinlock_t)
  • 引用计数表(refcountMap)
  • 弱引用表(weak_table_t)
散列表为什么在内存有多张?最多能够多少张?
  • 如果散列表只有一张表,意味着全局所有的对象都会存储在一张表中,都会进行开锁解锁(锁是锁整个表的读写)。当开锁时,由于所有数据都在一张表,则意味着数据不安全
  • 如果每个对象都开一个表,会耗费性能,所以也不能有无数个表
为什么在用散列表,而不用数组、链表?
  • 数组:特点在于查询方便(即通过下标访问),增删比较麻烦(类似于之前讲过的methodList,通过memcopy、memmove增删,非常麻烦),所以数据的特性是读取快,存储不方便
  • 链表:特点在于增删方便,查询慢(需要从头节点开始遍历查询),所以链表的特性是存储快,读取慢
  • 散列表的本质就是一张哈希表,哈希表集合了数组和链表的长处,增删改查都比较方便,例如拉链哈希表(在之前锁的文章中,讲过的tls的存储结构就是拉链形式的),是最常用的,如下所示

请添加图片描述

Dealloc

Dealloc源码

请添加图片描述

inline void
objc_object::rootDealloc()
{
    //对象要释放,需要做哪些事情?
    //1、isa - cxx - 关联对象 - 弱引用表 - 引用计数表
    //2、free
    if (isTaggedPointer()) return;  // fixme necessary?

    //如果没有这些,则直接free
    if (fastpath(isa.nonpointer  &&  
                 !isa.weakly_referenced  &&  
                 !isa.has_assoc  &&  
                 !isa.has_cxx_dtor  &&  
                 !isa.has_sidetable_rc))
    {
        assert(!sidetable_present());
        free(this);
    } 
    else {
        //如果有
        object_dispose((id)this);
    }
}

源码分析

//对象要释放,需要做哪些事情?
//1、isa - cxx - 关联对象 - 弱引用表 - 引用计数表
//2、free

dealloc 源码分析

在retain和release的底层实现中,都提及了dealloc析构函数,下面来分析dealloc的底层的实现

进入dealloc -> _objc_rootDealloc -> rootDealloc源码实现,主要有两件事:
根据条件判断是否有isa、cxx、关联对象、弱引用表、引用计数表,如果没有,则直接free释放内存
如果有,则进入object_dispose方法

object_dispose 源码
id 
object_dispose(id obj)
{
    if (!obj) return nil;
    //销毁实例而不会释放内存
    objc_destructInstance(obj);
    //释放内存
    free(obj);

    return nil;
}
👇
void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        bool cxx = obj->hasCxxDtor();
        bool assoc = obj->hasAssociatedObjects();

        // This order is important.
        //调用C ++析构函数
        if (cxx) object_cxxDestruct(obj);
        //删除关联引用
        if (assoc) _object_remove_assocations(obj);
        //释放
        obj->clearDeallocating();
    }

    return obj;
}
👇
inline void 
objc_object::clearDeallocating()
{
    //判断是否为nonpointer isa
    if (slowpath(!isa.nonpointer)) {
        // Slow path for raw pointer isa.
        //如果不是,则直接释放散列表
        sidetable_clearDeallocating();
    }
    //如果是,清空弱引用表 + 散列表
    else if (slowpath(isa.weakly_referenced  ||  isa.has_sidetable_rc)) {
        // Slow path for non-pointer isa with weak refs and/or side table data.
        clearDeallocating_slow();
    }

    assert(!sidetable_present());
}
👇
NEVER_INLINE void
objc_object::clearDeallocating_slow()
{
    ASSERT(isa.nonpointer  &&  (isa.weakly_referenced || isa.has_sidetable_rc));

    SideTable& table = SideTables()[this];
    table.lock();
    if (isa.weakly_referenced) {
        //清空弱引用表
        weak_clear_no_lock(&table.weak_table, (id)this);
    }
    if (isa.has_sidetable_rc) {
        //清空引用计数
        table.refcnts.erase(this);
    }
    table.unlock();
}

object_dispose源码分析

进入object_dispose源码,其目的有以下几个
销毁实例,主要有以下操作

调用c++析构函数

删除关联引用

释放散列表

清空弱引用表

free释放内存

release

请添加图片描述

release 源码

ALWAYS_INLINE bool 
objc_object::rootRelease(bool performDealloc, bool handleUnderflow)
{
    if (isTaggedPointer()) return false;

    bool sideTableLocked = false;

    isa_t oldisa;
    isa_t newisa;

 retry:
    do {
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        //判断是否是Nonpointer isa
        if (slowpath(!newisa.nonpointer)) {
            //如果不是,则直接操作散列表-1
            ClearExclusive(&isa.bits);
            if (rawISA()->isMetaClass()) return false;
            if (sideTableLocked) sidetable_unlock();
            return sidetable_release(performDealloc);
        }
        // don't check newisa.fast_rr; we already called any RR overrides
        uintptr_t carry;
        //进行引用计数-1操作,即extra_rc-1
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
        //如果此时extra_rc的值为0了,则走到underflow
        if (slowpath(carry)) {
            // don't ClearExclusive()
            goto underflow;
        }
    } while (slowpath(!StoreReleaseExclusive(&isa.bits, 
                                             oldisa.bits, newisa.bits)));

    if (slowpath(sideTableLocked)) sidetable_unlock();
    return false;

 underflow:
    // newisa.extra_rc-- underflowed: borrow from side table or deallocate

    // abandon newisa to undo the decrement
    newisa = oldisa;
    //判断散列表中是否存储了一半的引用计数
    if (slowpath(newisa.has_sidetable_rc)) {
        if (!handleUnderflow) {
            ClearExclusive(&isa.bits);
            return rootRelease_underflow(performDealloc);
        }

        // Transfer retain count from side table to inline storage.

        if (!sideTableLocked) {
            ClearExclusive(&isa.bits);
            sidetable_lock();
            sideTableLocked = true;
            // Need to start over to avoid a race against 
            // the nonpointer -> raw pointer transition.
            goto retry;
        }

        // Try to remove some retain counts from the side table.
        //从散列表中取出存储的一半引用计数
        size_t borrowed = sidetable_subExtraRC_nolock(RC_HALF);

        // To avoid races, has_sidetable_rc must remain set 
        // even if the side table count is now zero.

        if (borrowed > 0) {
            // Side table retain count decreased.
            // Try to add them to the inline count.
            //进行-1操作,然后存储到extra_rc中
            newisa.extra_rc = borrowed - 1;  // redo the original decrement too
            bool stored = StoreReleaseExclusive(&isa.bits, 
                                                oldisa.bits, newisa.bits);
            if (!stored) {
                // Inline update failed. 
                // Try it again right now. This prevents livelock on LL/SC 
                // architectures where the side table access itself may have 
                // dropped the reservation.
                isa_t oldisa2 = LoadExclusive(&isa.bits);
                isa_t newisa2 = oldisa2;
                if (newisa2.nonpointer) {
                    uintptr_t overflow;
                    newisa2.bits = 
                        addc(newisa2.bits, RC_ONE * (borrowed-1), 0, &overflow);
                    if (!overflow) {
                        stored = StoreReleaseExclusive(&isa.bits, oldisa2.bits, 
                                                       newisa2.bits);
                    }
                }
            }

            if (!stored) {
                // Inline update failed.
                // Put the retains back in the side table.
                sidetable_addExtraRC_nolock(borrowed);
                goto retry;
            }

            // Decrement successful after borrowing from side table.
            // This decrement cannot be the deallocating decrement - the side 
            // table lock and has_sidetable_rc bit ensure that if everyone 
            // else tried to -release while we worked, the last one would block.
            sidetable_unlock();
            return false;
        }
        else {
            // Side table is empty after all. Fall-through to the dealloc path.
        }
    }
    //此时extra_rc中值为0,散列表中也是空的,则直接进行析构,即自动触发dealloc流程
    // Really deallocate.
    //触发dealloc的时机
    if (slowpath(newisa.deallocating)) {
        ClearExclusive(&isa.bits);
        if (sideTableLocked) sidetable_unlock();
        return overrelease_error();
        // does not actually return
    }
    newisa.deallocating = true;
    if (!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)) goto retry;

    if (slowpath(sideTableLocked)) sidetable_unlock();

    __c11_atomic_thread_fence(__ATOMIC_ACQUIRE);

    if (performDealloc) {
        //发送一个dealloc消息
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
    return true;
}

release 源码分析

分析了retain的底层实现,下面来分析release的底层实现

  • 通过setProperty -> reallySetProperty -> objc_release -> release ->
    rootRelease -> rootRelease顺序,进入rootRelease源码,其操作与retain 相反
  • 判断是否是Nonpointer isa,如果不是,则直接对散列表进行-1操作
  • 如果是Nonpointer isa,则对extra_rc中的引用计数值进行-1操作,并存储此时的extra_rc状态到carry中
  • 如果此时的状态carray为0,则走到underflow流程
  • underflow流程有以下几步:
  • 判断散列表中是否存储了一半的引用计数
  • 如果是,则从散列表中取出存储的一半引用计数,进行-1操作,然后存储到extra_rc中
  • 如果此时extra_rc没有值,散列表中也是空的,则直接进行析构,即dealloc操作,属于自动触发

retainCount

retainCount源码

进入retainCount -> _objc_rootRetainCount -> rootRetainCount源码,其实现如下

- (NSUInteger)retainCount {
    return _objc_rootRetainCount(self);
}
👇
uintptr_t
_objc_rootRetainCount(id obj)
{
    ASSERT(obj);

    return obj->rootRetainCount();
}
👇
inline uintptr_t 
objc_object::rootRetainCount()
{
    if (isTaggedPointer()) return (uintptr_t)this;

    sidetable_lock();
    isa_t bits = LoadExclusive(&isa.bits);
    ClearExclusive(&isa.bits);
    //如果是nonpointer isa,才有引用计数的下层处理
    if (bits.nonpointer) {
        //alloc创建的对象引用计数为0,包括sideTable,所以对于alloc来说,是 0+1=1,这也是为什么通过retaincount获取的引用计数为1的原因
        uintptr_t rc = 1 + bits.extra_rc;
        if (bits.has_sidetable_rc) {
            rc += sidetable_getExtraRC_nolock();
        }
        sidetable_unlock();
        return rc;
    }
    //如果不是,则正常返回
    sidetable_unlock();
    return sidetable_retainCount();
}

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值