美文网首页
IOS基础:内存管理

IOS基础:内存管理

作者: 时光啊混蛋_97boy | 来源:发表于2020-10-22 14:49 被阅读0次

    原创:知识点总结性文章
    创作不易,请珍惜,之后会持续更新,不断完善
    个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
    温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

    目录

    • 一、内存布局
    • 二、内存管理方案:Tagged Pointer
      • 1、引入原因
      • 2、特点
      • 3、原理
      • 4、面试题
    • 三:内存管理方案:SideTables 散列表中的引用计数表
      • 1、简介
      • 2、MRC
      • 3、ARC
    • 四:内存管理方案:SideTables 散列表中的弱引用表 weak
      • 1、简介
      • 2、常见的循环引用场景:NSTimer
      • 3、探索NSTimer循环引用的解决方案
      • 4、weak的源代码解析
    • 三、自动释放池 Autoreleasepool
      • 1、简介
      • 2、手动调用autoreleasepool
      • 3、子线程AutoRelease对象何时释放
    • Demo
    • 参考文献

    一、内存布局

    内存布局

    栈区

    • 创建临时变量时由编译器自动分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。
    • 在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。
    • 和堆一样,用户栈在程序执行期间可以动态地扩展和收缩,向下增长。

    堆区

    • 通过newalloc等分配的对象、block经过copy后,c通过malloc
    • 它们的释放系统不会主动去管,由我们的开发者去告诉系统什么时候释放这块内存(一个对象引用计数为0是系统就会回销毁该内存区域对象)。
    • 一般一个 new 就要对应一个release。在ARC下编译器会自动在合适位置为OC对象添加release操作,会在当前线程Runloop退出或休眠时销毁这些对象。MRC则需程序员手动释放。
    • 堆可以动态地扩展和收缩,向上增长。

    NSString的对象就是stack 中的对象,NSMutableString 的对象就是heap 中的对象。前者创建时分配的内存长度固定且不可修改;后者是分配内存长度是可变的,适用于计数管理内存管理模式。两类对象的创建方法也不同,前者直接创建NSString * str1=@"welcome";,而后者需要先分配再初始化NSMutableString * mstr1=[[NSMutableString alloc] initWithString:@"welcome"];

    bss(静态区)

    未初始化的全局变量和静态变量,程序运行过程内存的数据一直存在,程序结束后由系统释放。

    data(常量区)

    已初始化的全局变量、静态变量、常量,在程序结束后由系统释放。

    text(代码区)

    用于存放程序运行时的代码,是被编译成二进制的程序代码。

    二、内存管理方案:Tagged Pointer

    1、引入原因

    通常我们创建对象,对象存储在堆中,对象的指针存储在栈中,如果我们要找到这个对象,就需要先在栈中,找到指针地址,然后根据指针地址找到在堆中的对象。

    这个过程比较繁琐,当存储的对象只是一个很小的东西,比如一个字符串,一个数字。去走这么一个繁琐的过程,无非是耗费性能的,所以苹果就搞出了TaggedPointer这么一个东西。

    2、特点

    • TaggedPointer是苹果为了解决32位CPU64位CPU的转变带来的内存占用和效率问题,专门用来存储小的对象,针对NSNumberNSDate以及部分NSString的内存优化方案。
    • Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮囊的普通变量而已。所以,它的内存并不存储在堆中,也不需要mallocfree
    • Tagged Pointer指针中包含了当前对象的地址、类型、具体数值。因此Tagged Pointer指针在内存读取上有着3倍的效率,创建时比普通需要mallocfree的类型快106倍。

    3、原理

    苹果将Tagged Pointer引入,给 64 位系统带来了内存的节省和运行效率的提高。Tagged Pointer通过在其最后一个 bit位设置一个特殊标记,用于将数据直接保存在指针本身中。因为Tagged Pointer并不是真正的对象,我们在使用时需要注意不要直接访问其isa变量。

    如果这个整数只是一个 NSIntegerNSNumberNSDate的普通变量,在 32 位 CPU 下占 4 个字节,在 64 位 CPU 下是占 8 个字节的,占用的内存会翻倍。

    为了存储和访问一个 NSNumber 对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失

    int main(int argc, char * argv[])
    {
        @autoreleasepool {
            NSNumber *number1 = @1;
            NSNumber *number2 = @2;
            NSNumber *number3 = @3;
            NSNumber *numberFFFF = @(0xFFFF);
    
            NSLog(@"number1 pointer is %p", number1);
            NSLog(@"number2 pointer is %p", number2);
            NSLog(@"number3 pointer is %p", number3);
            NSLog(@"numberffff pointer is %p", numberFFFF);
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }
    
    //输出结果
    number1 pointer is 0xb000000000000012
    number2 pointer is 0xb000000000000022
    number3 pointer is 0xb000000000000032
    numberFFFF pointer is 0xb0000000000ffff2
    

    苹果确实是将值直接存储到了指针本身里面。我们还可以猜测,数字最末尾的 2 以及最开头的 0xb 是否就是苹果对于Tagged Pointer的特殊标记呢?

    我们尝试放一个 8 字节的长的整数到NSNumber实例中,对于这样的实例,由于Tagged Pointer无法将其按上面的压缩方式来保存,那么应该就会以普通对象的方式来保存,我们的实验代码如下:

    NSNumber *bigNumber = @(0xEFFFFFFFFFFFFFFF);
    NSLog(@"bigNumber pointer is %p", bigNumber);
    
    // 输出
    bigNumber pointer is 0x10921ecc0
    

    验证了我们的猜测,bigNumber的地址更像是一个普通的指针地址。可见,当 8 字节可以承载用于表示的数值时,系统就会以Tagged Pointer的方式生成指针,如果 8 字节承载不了时,则又用以前的方式来生成普通的指针。

    4、面试题

    为什么第二个for会崩溃?答:taggedpointer

        dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
        dispatch_async(queue, ^{
            for (int i = 0 ; i<1000000; i++) {
                self.str = @"abcd";
            }
        });
        dispatch_async(queue, ^{
            for (int i = 0 ; i<1000000; i++) {
                self.str = [NSString stringWithFormat:@"adfalkdjfldkasjflakjsdkflasf-- %d",I];
            }
        });
    

    执行了objc_release(id obj),由于大量的循环,导致了线程问题,使引用计数<=-1。但是由于第一个循环中的objtaggedpointer类型的string,会直接return obj,并不会release

    这里release,那retain的时候咋办呢,引用计数是一直往上增吗?并不是,在objc_retain(id obj)中,同样判断了obj->isTaggedPointer,如果是true,就直接return obj

    三、内存管理方案:SideTables 散列表中的引用计数表

    1、简介

    SideTables是一个64个元素长度的hash数组,里面存储了SideTableSideTableshash键值就是一个对象objaddress。因此可以说,一个obj对应了一个SideTable,但是一个SideTable,会对应多个obj。因为SideTable的数量只有64个,所以会有很多obj共用同一个SideTable

    SideTables 散列表 SideTable结构
    a、什么是Spinlock_t 自旋锁?

    自旋锁若已被其他线程获取,则当前线程会不断探索该锁是否被释放,如果释放则第一时间获取,适用于轻量访问的情况。

    b、为什么不是一个SideTable?

    一张表的引用计数或者弱引用需要顺序操作,这就需要等前一个解开锁才能继续工作,存在效率问题。所以通过分离锁打散成多张表(共8个),支持并发操作,提高访问效率。

    c、怎样实现快速分流,即通过指针快速定位到属于哪一张SideTable表?

    SideTable本质是一个哈希表,给定值是对象内存地址,目标值是数组下标索引,哈希查找通过取余存储值和取值,内存地址均匀分布。哈希函数是一个均匀散列函数,不需要通过遍历操作,效率很高。

    2、MRC

    a、什么是MRC?

    MRC自己负责管理对象生命周期,负责对象的创建和销毁。

    b、MRC举例分析
        NSNumber *i = [NSNumber numberWithInteger:2];
        [i retainCount];//1
    
        NSMutableArray *array = [NSMutableArray array];
        [array addObject:i];
        [i retainCount];//2
    
        NSNumber *j = i;//不获得i的所有权
        [i retainCount];//2
    
        NSNumber *j = [i retain];//获得
        [i retainCount];//3
    
        [i release];//2
    
        [array removeObjectAtIndex:0];
        [i retainCount];//1
    
        [i release];//销毁
        
        //释放动态分配的内存和关闭文件
        - (void)dealloc{[super dealloc];}
    
    c、MRC通过引用计数如何管理对象生命周期?
    alloc
    retain +1
    release -1
    retainCount 引用计数值
    autorelease 结束时候调用release释放
    dealloc
    

    alloc实现:经过一系列调用,最终调用了C函数的calloc,但此时并没有将引用计数+1

    retainCount实现:alloc对象引用计数值为0,之所以 retainCount能获取到1,是因为添加了局部变量size_t refcnt_result令其值为1,再让其实现相加操作。

    SideTable &table = sideTables()[this];
    size_t refcnt_result = 1;
    RefcountMap::iterator it = table.refcnts.find(this);
    refcnt_resount+= it->second>>SIDE_TABLE_RC_SHIFT
    

    retain实现:经过两次哈希查找,第一次从SideTables中查找到对象所在的SideTable,第二次从引用计数表中查找到该对象的引用计数值实现+1(实际是通过地址偏移量来实现的)。

    SideTable& table = SideTables()[This];
    size_t& refcntStorage = table.refcnts[This];
    refcntStorage += SIZE_TABLE_RC_ONE
    

    release实现:经过两次哈希查找,第一次从SideTables中查找到对象所在的SideTable,第二次从引用计数表中查找到该对象的引用计数值实现-1(实际是通过地址偏移量来实现的)。

    SideTable& table = SideTables()[This];
    size_t& refcntStorage = table.refcnts[This];
    refcntStorage -= SIZE_TABLE_RC_ONE;
    
    d、MRC经常会出现的内存泄漏问题

    内存泄漏:指一个对象或变量在使用完成后没有释放掉。

    容易导致内存泄漏的使用方式:

    • string开头的方法,它是静态工厂方法,通过类直接调用 。 对于 使用该方法创建的对象, 其所有权非调用者所有 , 调用者无权释放它,否则就会因过度释放而“僵尸化"。
    • 采用allocnewcopymutableCopy所创建的对象,所有权属于调用者,它的生命周期由调用者管理,调用者负责通过releaseautorelease方法释放对象。

    如何查找泄漏点?
    Analyzeproduct-->Analyze)是静态分析工具 , Instrumentsproduct-->profile)是动态分析工具( LeaksAllocations)。

    静态分析方法能发现大部分的问题,但是仅仅使用静态内存泄漏分析得到的结果并不是非常可靠,因为有的内存泄露是在运行时,用户操作时才产生的,所以我们需要将对项目进行更为完善的内存泄漏分析和排查。那就需要用到我们下面要介绍的动态内存泄漏分析方法Instruments中的Leaks方法进行排查。

    Leaks
    点击左上角的红色圆点,这时项目开始启动了,由于leaks是动态监测,所以手动进行一系列操作,可检查项目中是否存在内存泄漏问题。如图所示,橙色矩形框中所示绿色为正常,如果出现如右侧红色矩形框中显示红色,则表示出现内存泄漏。 Leaks
    选中Leaks Checks,在Details所在栏中选择CallTree,并且在右下角勾选Invert Call TreeHide System Libraries,会发现显示若干行代码,双击即可跳转到出现内存泄漏的地方,修改即可。
    Leaks
    • 监控内存分布情况,随着时间的变化内存使用的折线图,有红色菱形图标 O 出现 , 则有内存泄漏
    • 显示泄漏的对象,列出了它们的内存地址 、 占用字节 、 所属框架和响应方法等信息
    • RetCt是引用计数列,最后的引用计数不为零 ,这说明该对象内存没有释放
    • 点击右边的跟踪栈信息按钮,可以定位到泄露点在项目中的代码位置
    • 调用者没有这个对象的所有权而释放它 ,都会造成过度释放,从而产生僵尸对象(Zombies分析模板),试图调用僵尸对象 ,则 会崩溃(应用直接跳出),并抛出EXC_BAD_ACCESS异常,僵尸对象的引用计数变化是 : l (创建) ----->0 (释放)一 (僵尸化)

    3、ARC

    a、什么是ARC?

    ARCLLVMRuntime协同作用的结果,在这种模式下,程序员不需要清楚地了解获得/放弃对象所有权的时机,ARC 会自动添加被注释掉的行,即在适当的位置插入retainreleaseautorelease 函数,这样程序员能将更多的精力用于开发程序的核心功能。

    b、ARC有什么好处?
    • 不需要再在意对象的所有权
    • 可以删除程序中内存管理部分的大部分代码,使程序看起来更清爽
    • 可以避免手动内存管理时的错误(内存泄漏等)
    • 可以使多线程环境下的编程更简单。例如:不用担心不同的线程之间可能出现的所有权冲突
    c、ARC有什么特点?
    • ARC会禁止调用引用计数的相关函数retainreleaseautoreleaseretainCount
    • 可以重写dealloc方法,但是不能显示调用super.dealloc
    • ARC中新增weakstrong属性关键字。

    四、内存管理方案:SideTables 散列表中的弱引用表 weak

    1、简介

    a、什么是循环引用?

    当双方都在等待对方释放的时候,就形成了循环引用,结果两个对象都不会被释放,只有打破循环引用关系才能够释放内存。

    ARC代码中的内存泄漏多半是由于强引用循环引起的 ,从而导致一些内存无法释放,这就会导致dealloc()方法无法被调用, Leaks模板提供了查看引用循环视图,选择Cycles & Roots菜单项即可查看。

    b、循环引用的解决方案有哪些?
    • 通过使用弱引用既可以防止生成循环引用,实例对象被释放后自动变成nil,又可以防止对象被释放后形成野指针。
    • 或者通过手动给一方赋值为nil 来打破循环引用关系

    2、常见的循环引用场景:NSTimer

    a、NSTimer的创建

    第一中方法需要手动加入Runloop

    __weak typeof(self) weakSelf = self;
    self.timer = [NSTimer timerWithTimeInterval:1
                                         target:weakSelf
                                       selector:@selector(fireHome)
                                       userInfo:nil
                                        repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.timer
                                      forMode:NSDefaultRunLoopMode];
    

    第二中方法自动加入Runloop

    __weak typeof(self) weakSelf = self;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1
                                                  target:weakSelf
                                                selector:@selector(fireHome)
                                                userInfo:nil
                                                 repeats:YES];
    

    调用的方法为:

    - (void)fireHome {
        num++;
        NSLog(@"hello word - %d",num);
    }
    
    b、NSTimer循环引用分析

    发现VC pop之后,定时器并没有停止输出,deinit(swift)方法也没有执行,这就是循环引用了。这个TimeraddTargetUIButtonaddTarget有什么不同呢?buttontarget是弱引用,Timertarget是强引用。self 强持有timer我们都能直接看出来,target方法中,timerself对象进行了强持有,因此造成了循环引用。

    3、探索NSTimer循环引用的解决方案

    a、weakSelf

    按照惯例用weakSelf去打破强引用的时候,发现weakSelf没有打破循环引用,timer仍然在运行。

    self -> timer -> weakSelf -> self

    变成了self -> timer -> weakSelf -> selftimer之所以无法打破循环关系是因为timer创建时target是对weakSelf的对象强持有操作,而weakSelfself虽然是不同的指针但是指向的对象是相同的,也就相当于间接的强持有了self,所以weakSelf并没有打破循环引用关系。

    那么block使用weakSelf为什么可以打破循环引用呢?

    void _Block_object_assign(void *destArg, const void *object, const int flags) {
        const void **dest = (const void **)destArg; 
        switch (os_assumes(flags & BLOCK_ALL_COPY_DISPOSE_FLAGS)) {
          case BLOCK_FIELD_IS_OBJECT:
            _Block_retain_object(object);
            *dest = object; // 实际上是指针赋值
    

    .cpp文件中我们可以看到如上代码段,虽然weakself对象传入进来,但是内部实际操作的是对象的指针,也就是weakself的指针,我们知道weakselfself虽然内存地址相同,但指针地址是不一样的,也就是block中并没有直接持有self,而是通过weakSelf指针操作,所以就打破了self -> block -> weakSelf -> selfself这一层的循环引用,变成了self -> block -> weakSelf (临时变量的指针地址)来打破循环引用。

    b、使用weak关键字修饰

    一说到循环引用很容易就想到weak,但是这里用weak不行。这要从weak修饰的对象的释放时机说起,用了weak关键字修饰之后,系统会在一个hash表中增加一对key valuekey就是这个对象的hash值,value就是这个对象的指针地址。

    我们都知道每一个runloop都有自己的一个autorelease pool,在一次runloop结束之后会销毁这个autorelease pool,在释放这个autorelease pool的时候,也会到hash表中找到weak的对象把它和它的指针都释放掉,同时,那么问题来了,我这个timer是在runloop里面重复执行的,换而言之,这个runloop是一直在执行的,所以这个池子根本不会释放啊有木有,所以用它没什么卵用啊。

    c、使用invalidate结束timer运行

    我们第一时间肯定想到的是[self.timer invalidate]不就可以了吗,当然这是正确的思路,那么我们调用时机是什么呢?viewWillDisAppear还是viewDidDisAppear?实际上在我们实际操作中,如果当前页面有push操作的话,而当前页面并没有pop即还在栈里面,这时候我们释放timer肯定是错误的,所以这时候我们可以用到下面的方法:

    - (void)didMoveToParentViewController:(UIViewController *)parent {
        // 无论 push进来 还是 pop出去 都能正常运行
        // 就算继续push到下一层 pop回去还是能够继续
        if (parent == nil) {
           [self.timer invalidate];
            self.timer = nil;
            NSLog(@"timer 走了");
        }
    }
    

    为什么不在deinit(swift)方法里面释放?因为denint方法都不执行了,写了也没用。

    d、中介者模式
    中介者模式

    换个思路,timer会造成循环引用是因为target强持有了self,造成的循环引用,那我们是否可以包装一下target,使得timer绑定另外一个不是selftarget对象来打破这层强持有关系。

    中介者模式

    根据打印结果我们发现在dealloc的时候也可以实现timer的释放,打破了循环引用。class_addMethod的作用看着是给target增加了一个方法,但是实际上timer的执行是在fireHomeObjc里面执行的,而不是应该执行的fireHome函数。

    优化下代码,既然class_addMethod中需要一个函数的IMP,那么我们直接获取fireHomeIMP就可以了:

    self.target = [[NSObject alloc] init];
    Method method = class_getInstanceMethod([self class], @selector(fireHome));
    class_addMethod([self.target class], @selector(fireHome), method_getImplementation(method), "v@:");
    self.timer = [NSTimer
                  scheduledTimerWithTimeInterval:1
                  target:self.target
                  selector:@selector(fireHome)
                  userInfo:nil
                  repeats:YES];
    
    e、NSProxy虚基类的方式

    NSProxy是一个虚基类,它的地位等同于NSObject。我们不用self来响应timer方法的target,而是用NSProxy来响应。虚基类方法是用proxy打破self这一块的循环。

    // XJPProxy.h
    @interface XJPProxy : NSProxy
    + (instancetype)proxyWithTransformObject:(id)object;
    @end
    
    //XJPProxy.m
    @interface XJPProxy()
    @property (nonatomic, weak) id object;
    @end
    
    @implementation XJPProxy
    + (instancetype)proxyWithTransformObject:(id)object {
        XJPProxy *proxy = [[XJPProxy alloc] init]; 
        proxy.object = object; // 我们拿到外边的self,weak弱引用持有
        return proxy;
    }
    
    // 仅仅添加了weak类型的属性还不够,为了保证中间件能够响应外部self的事件,需要通过消息转发机制,让实际的响应target还是外部self,这一步至关重要,主要涉及到runtime的消息机制。
    // proxy虚基类并没有持有vc,而是消息的转发,又给了vc
    - (id)forwardingTargetForSelector:(SEL)aSelector {
        return self.object;
    }
    
    //VC.m
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.proxy = [DZProxy proxyWithTransformObject:self];
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.proxy selector:@selector(fireHome) userInfo:nil repeats:YES];
    }
    
    - (void)dealloc{
        [self.timer invalidate];
        self.timer = nil;
        NSLog(@"%s",__func__);
    }
    

    4、weak的源代码解析

    a、weak_entry_t
    • 查找weak_entry_t
    • 判断weak_table_t是否需要扩容
    • 插入新的weak_entry_t
    • 移除weak_entry_t

    查找weak_entry_t:找到对应对象的存放位置,需要处理hash冲突,如果存在hash冲突具体会往下一个查找,查找到返回对应weak_entry_t,当超过了最大的冲突处理次数后,说明没有查找到,就会返回nil,源代码如下:

    static weak_entry_t *
    weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent)
    {
        assert(referent);
    
        weak_entry_t *weak_entries = weak_table->weak_entries;
    
        if (!weak_entries) return nil;
        
        ///hash算法
        size_t begin = hash_pointer(referent) & weak_table->mask;
        size_t index = begin;
        size_t hash_displacement = 0;
    
        ///找到对应对象的存放位置,需要处理hash冲突,如果存在hash冲突具体会往下一个查找,直到找到空位,这个就是开放地址法处理hash冲突
        while (weak_table->weak_entries[index].referent != referent) {
            index = (index+1) & weak_table->mask;
            if (index == begin) bad_weak_table(weak_table->weak_entries);
            hash_displacement++;///冲突处理次数加1
    
            ///当超过了最大的冲突处理次数后,说明没有查找到,就会返回nil。
            if (hash_displacement > weak_table->max_hash_displacement) {
                return nil;
            }
        }
        
        ///查找到返回对应weak_entry_t
        return &weak_table->weak_entries[index];
    }
    

    判断weak_table_t是否需要扩容:默认容量大小初始值是64,超过最大容量的3/4,就在原来的容量基础上*2,源代码如下:

    static void weak_grow_maybe(weak_table_t *weak_table)
    {
        size_t old_size = TABLE_SIZE(weak_table);
    
        ///超过最大容量的3/4
        if (weak_table->num_entries >= old_size * 3 / 4) {
            ///在原来的容量基础上*2,默认初始值是64
            weak_resize(weak_table, old_size ? old_size*2 : 64);
        }
    }
    

    插入新的weak_entry_t:通过hash算法找到为nil的位置,存储weak_entry_t,源代码如下:

    static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry)
    {
        weak_entry_t *weak_entries = weak_table->weak_entries;
        assert(weak_entries != nil);
    
        ///跟查找一样的hash算法
        size_t begin = hash_pointer(new_entry->referent) & (weak_table->mask);
        size_t index = begin;
        size_t hash_displacement = 0;
    
        ///找到为nil的位置
        while (weak_entries[index].referent != nil) {
            index = (index+1) & weak_table->mask;
            if (index == begin) bad_weak_table(weak_entries);
            hash_displacement++;///当前的最大冲突次数
        }
    
        weak_entries[index] = *new_entry;///存储weak_entry_t
        weak_table->num_entries++;///已存储位置数+1
    
        if (hash_displacement > weak_table->max_hash_displacement) {
            ///当前冲突次数比max_hash_displacement大,则赋值给max_hash_displacement
            weak_table->max_hash_displacement = hash_displacement;
        }
    }
    

    移除weak_entry_t:分别从静态和动态数组中移除即可,代码量挺大,如下:

    static void remove_referrer(weak_entry_t *entry, objc_object **old_referrer)
    {
        ///从静态数组中移除
        if (! entry->out_of_line()) {
            for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {
                if (entry->inline_referrers[i] == old_referrer) {
                    entry->inline_referrers[i] = nil;
                    return;
                }
            }
            _objc_inform("Attempted to unregister unknown __weak variable "
                         "at %p. This is probably incorrect use of "
                         "objc_storeWeak() and objc_loadWeak(). "
                         "Break on objc_weak_error to debug.\n", 
                         old_referrer);
            objc_weak_error();
            return;
        }
    
        ///从动态数组中移除
        size_t begin = w_hash_pointer(old_referrer) & (entry->mask);
        size_t index = begin;
        size_t hash_displacement = 0;
        while (entry->referrers[index] != old_referrer) {
            index = (index+1) & entry->mask;
            if (index == begin) bad_weak_table(entry);
            hash_displacement++;
            if (hash_displacement > entry->max_hash_displacement) {
                _objc_inform("Attempted to unregister unknown __weak variable "
                             "at %p. This is probably incorrect use of "
                             "objc_storeWeak() and objc_loadWeak(). "
                             "Break on objc_weak_error to debug.\n", 
                             old_referrer);
                objc_weak_error();
                return;
            }
        }
        entry->referrers[index] = nil;
        entry->num_refs--;
    }
    
    b、添加weak变量

    添加的位置是通过哈希算法进行添加的位置查找,如果查找到的位置已经有了当前对象的弱引用数组,就把新的weak变量添加到该数组,如果没有创建新数组,添加weak变量到第一个,其余初始化为nil

    1. 调用objc_initWeak()
    2. 调用storeWeak()
    3. 调用weak_register_no_lock()

    步骤一:objc_initWeak方法中的两个参数location是指weak指针,newObjweak指针将要指向的对象,源代码如下:

    id
    objc_initWeak(id *location, id newObj)
    {
        if (!newObj) {
            *location = nil;
            return nil;
        }
    
        return storeWeak<DontHaveOld, DoHaveNew, DoCrashIfDeallocating>
            (location, (objc_object*)newObj);
    }
    

    步骤二:这里要先进行haveOld判断,也就是如果该指针有指向的旧值,先要weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);,处理旧值即移除weak指针。然后通过 weak_register_no_lock(&newTable->weak_table, (id)newObj, location,crashIfDeallocating);进行赋值操作即插入weak指针,源代码如下:

        ///如果有旧指向,从旧表中移除weak地址值
        if (haveOld) {
            weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);
        }
    
        if (haveNew) {
            ///在新表中插入weak指针的地址,成功返回newObj,否则返回nil
            newObj = (objc_object *) weak_register_no_lock(&newTable->weak_table, (id)newObj, location, crashIfDeallocating);
            
            ///Tagged Pointer是苹果在64位系统之后,用来优化内存的 
            if (newObj  &&  !newObj->isTaggedPointer()) {
                ///在引用计数表中标记有弱引用指向,当引用计数为0时,触发移除相对应的weak指针。
                newObj->setWeaklyReferenced_nolock();
            }
    
            *location = (id)newObj;///将weak指针指向新的对象
        }
    

    步骤三:weak_register_no_lock()的功能是判断是否存在弱引用weak_entry_t,有,则在对应的数组中插入当前需要插入的weak指针,没有,新建一个weak_entry_t数据插入到弱引用表中,源代码如下:

    weak_register_no_lock(weak_table_t *weak_table, id referent_id, 
                          id *referrer_id, bool crashIfDeallocating)
    {
        objc_object *referent = (objc_object *)referent_id;
        objc_object **referrer = (objc_object **)referrer_id;
    
        if (!referent  ||  referent->isTaggedPointer()) return referent_id;///运用TaggedPointer计数,无需维护weak_table_t弱引用表
    
        weak_entry_t *entry;///弱引用 
        if ((entry = weak_entry_for_referent(weak_table, referent))) {///在弱引用表中查找weak_entry_t,存在
            append_referrer(entry, referrer);///插入weak指针 
        } 
        else {///没有weak_entry_t
            weak_entry_t new_entry(referent, referrer);///new一份weak_entry_t
            weak_grow_maybe(weak_table);///判断weak_table是否需要扩容 
            weak_entry_insert(weak_table, &new_entry);///插入weak指针,
        }
        return referent_id;
    }
    
    c、清除weak变量
    ❶ dealloc的流程图

    举个例子:

    __weak Person *weakPerson = person;
    

    假如我们的person对象已经被释放掉了,那么就需要告诉对象weakPerson一声,防止野指针,导致程序崩溃,而clearDeallocating函数所做的事情就是把很多类似weakPerson这样的弱引用全部都置为nil。具体weak是如何实现的,这里给了一个表:

    weak_table

    ❶ 从图中知道有个全局表叫SideTables,它里面维护了所有对象的引用计数,还有弱引用对象。当RefcountMap表中某个对象的引用计数为0时,系统会调用此类的dealloc方法,再调用其父类的dealloc,沿着继承链一直调用到NSObjectdealloc方法,最终走到objc_destructInstance函数里,主要操作四个:释放实例变量、移除关联属性、把弱引用置为nil、释放自己self

    dealloc实现

    在调用dealloc之后,会经过 _objc_rootDealloc()rootDealloc(),然后判断是否可以释放?判断释放的条件比较关键:

    • nonpointer_isa:判断这个isa指针类型,是否为非指针型的isa
    • weakly_referenced:是否有弱引用
    • has_assoc:是否有关联对象
    • has_cxx_dtor:判断是否有C++实现或者ARC的实现
    • has_sideTable_rc:引用计数是否在sideTable中有存储

    如果有一个满足条件,调用 object_disponse(),再开始释放,objc_dispose()的实现如下:

    objc_dispose()的实现

    先判断是否有C++实现,然后判断是否有关联对象,如果没有c++,也没有关联对象,则直接调用clearDeallocating(),否则分别调用object_cxxDestruct()_object_remove_assocaations来释放。在这里系统内部实现清除了分类里定义的关联对象实例。

    clearDeallocating实现

    接下来在调用 clearDeallocating的方法中,会调用sidetable_calearDeallocationg()weak_clear_no_lock()两个方法,作用是指向该对象的弱引用指针置为nil,这就是为什么我们不需要在dealloc中将指向他的弱引用指针置为nil的原因,接下来会调用table.refcnts.erase(),来进行引用计数的擦除操作,然后结束流程。

    ❷ dealloc的源码解析

    销毁对象这块最终需要调用到NSObject类中的dealloc方法:

    //释放对象
    - (void)dealloc {
        _objc_rootDealloc(self);
    }
    

    沿着调用函数流程dealloc—>_objc_rootDealloc—>rootDealloc object_dispose—>objc_destructInstance走最终会发现:

    void *objc_destructInstance(id obj) 
    {
        if (obj) {
            bool cxx = obj->hasCxxDtor();
            bool assoc = obj->hasAssociatedObjects();
    
            // This order is important.
            //---释放 C++ 的实例变量 
            if (cxx) object_cxxDestruct(obj);
            //---移除关联属性
            if (assoc) _object_remove_assocations(obj);
            //---将弱引用置为nil
            obj->clearDeallocating();
        }
        return obj;
    }
    
    ❸ 清除weak变量

    weak_table里有个数组weak_entries,数组里元素都是结构体weak_entry_t,这结构体里面有被引用的对象referent,还有引用数组referrersinline_referrers。需要注意的是,不是所有的对象都具有结构体weak_entry_t的,只有当某个对象具有弱引用时,才会给这对象创建一个weak_entry_t,并把它添加到数组weak_entries中去。同理当一个对象变得没有弱引用时,会从数组weak_entries中删去它对应的weak_entry_t。我们知道一个对象可能有多个若引用,比如:

    __weak Person *weakPerson1 = person;
    __weak Person *weakPerson2 = person;
    

    此时weakPerson1weakPerson2都会放到weak_entry_t中的referrers数组或者inline_referrers数组中去,二者区别主要是看数组长度大小超过4了没有,超过4,则放到referrers中,否则放到inline_referrers中,此时对应的referentperson

    这里说个非常重要的头文件objc-weak.h,它专门处理OC中对象的弱引用问题,里面有几个核心方法:

    • weak_register_no_lock:给指定对象添加一个弱引用,当我们执行_weak ClassA *objB = objA类似代码时,会触发NSObjectobjc_initWeak方法,最终会调用到weak_register_no_lock方法。
    • weak_unregister_no_lock:移除指定对象的一个弱引用。
    • weak_is_registered_no_lock:判断指定对象是否存在弱引用。
    • weak_clear_no_lock:清除指定对象所有弱引用,上述clearDeallocating里最终调用的就是此方法。

    对象dealloc后,会调用弱引用清除函数weak_unregister_no_lock,根据该对象查找弱引用表,遍历弱引用数组所有指针,分别置为nil,源代码如下:

    weak_unregister_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id)
    {
        //weak 指针指向的对象
        objc_object *referent = (objc_object *)referent_id; 
        //referrer_id是 weak 指针, 操作时需要用到这个指针的地址
        objc_object **referrer = (objc_object **)referrer_id; 
    
        weak_entry_t *entry;
    
        if (!referent) return;
    
        //查找 referent 对象对应的 entry
        if ((entry = weak_entry_for_referent(weak_table, referent))) {  
            //从 referent 对应的 entry 中删除地址为 referrer 的 weak 指针
            remove_referrer(entry, referrer); 
    
            //如果 entry 中的数组容量大于 4 并且数组中还有元素
            bool empty = true;
            if (entry->out_of_line()  &&  entry->num_refs != 0) { 
                empty = false; //entry 非空
            }
            else {
                for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {
                    //否则循环查找 entry 数组, 如果 4 个位置中有一个非空
                    if (entry->inline_referrers[i]) { 
                        empty = false;  //entry 非空
                        break;
                    }
                }
            }
            if (empty) {
                //从 weak_table 中移除该条 entry
                weak_entry_remove(weak_table, entry); 
            }
        }
    }
    

    其中从 weak_table 中移除该条 entryweak_entry_remove()方法源代码如下:

    static void weak_entry_remove(weak_table_t *weak_table, weak_entry_t *entry)
    {
        ///如果是静态数组,直接释放静态数组
        if (entry->out_of_line()) free(entry->referrers);
        ///释放entry
        bzero(entry, sizeof(*entry));
    
        ///对应的已存数量-1
        weak_table->num_entries--;
    
        ///这里会查看下weakTable的容量是否需要减容
        weak_compact_maybe(weak_table);
    }
    

    上面讲到过扩容,相对应的这里使用到的减容的方法 weak_compact_maybe()源代码如下:

    static void weak_compact_maybe(weak_table_t *weak_table)
    {
        size_t old_size = TABLE_SIZE(weak_table);
    
        //当就容量超过1024且是利用率不及1/16的时候,减容
        if (old_size >= 1024  && old_size / 16 >= weak_table->num_entries) {
             ///在原有的基础上除以8
            weak_resize(weak_table, old_size / 8);
        }
    }
    

    三、自动释放池 Autoreleasepool

    1、简介

    a、什么是@autoreleasepool?

    用来管理自动释放池。release消息马上将引用计数减1,而使用autorelease 对象的引用计数并不变化,而是向内存释放池中添加 一 条记录,会延迟到内存释放池周期到后,向池中所有对象发送 release消息,引用计数减 1。 当引用计数为 0时,对象所占用的内存才被释放 。

    应用程序入口文件 main.m,代码被包裹在@autoreleasepool {... }之间,这是池的作用范围,默认是整个应用,释放默认在程序结束。

    int main(int argc, char * argv[]) {
        @autoreleasepool {
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }
    

    编译的时候,这段代码会被转换成:

    {
        __AtAutoreleasePool __autoreleasepool;
    }
    

    其中,出现的结构体__AtAutoreleasePool为:

    struct __AtAutoreleasePool {
      __AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
      ~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
      void * atautoreleasepoolobj;
    };
    

    这表明,我们的main函数实际执行了:

    int main(int argc, const char * argv[]) {
        {
            void * atautoreleasepoolobj = objc_autoreleasePoolPush();
            // do whatever you want
            objc_autoreleasePoolPop(atautoreleasepoolobj);
        }
        return 0;
    }
    

    @autoreleasepool只是帮助我们少写了这两行代码而已,让代码看起来更美观,然后要根据上述两个方法来分析自动释放池的实现。

    b、Autorelease Pool的主要结构

    每一个autorelease pool都是由一系列的AutoreleasePoolPage组成。

    class AutoreleasePoolPage {
        magic_t const magic;                //完整性检验
        id *next;
        pthread_t const thread;             //保存当前的进程
        AutoreleasePoolPage * const parent; //双线链表使用 指向父节点
        AutoreleasePoolPage *child;         //双向链表使用 指向子结点
        uint32_t const depth;
        uint32_t hiwat;
    };
    

    可见,自动释放池的AutoreleasePoolPage是以双向链表的结构连接起来的,并且和线程是一一对应的关系。而在自动释放池的内存中,AutoreleasePoolPage被以栈结构存储起来。

    c、什么对象会加入Autoreleasepool中
    • 使用allocnewcopymutableCopy的方法进行初始化时,由系统管理对象,在适当的位置release
    • 使用array会自动将返回值的对象注册到Autoreleasepool
    • __weak修饰的对象,为了保证在引用时不被废弃,会注册到Autoreleasepool中。
    • id的指针或对象的指针,在没有显示指定时会被注册到Autoleasepool中。
    d、Autorelease对象的释放时机

    autorelease释放对象的依据是Runloop,简单说,runloop就是iOS中的消息循环机制,当一个runloop结束时系统才会一次性清理掉被autorelease处理过的对象,其实本质上说是在本次runloop迭代结束时清理掉被本次迭代期间被放到autorelease pool中的对象的。

    2、手动调用autoreleasepool

    既然由runloop来决定对象释放时机而不是作用域,那么,在一个{}内使用循环大量创建对象就有可能带来内存上的问题,大量对象会被创建而没有及时释放,这时候就需要靠我们人工的干预autorelease的释放了。手动添加的是大括号结束的时候释放。

    上文有提到autorelease pool,一旦一个对象被autorelease,则该对象会被放到iOS的一个池:autorelease pool,其实这个pool本质上是一个stack,扔到pool中的对象等价于入栈。

    objc_autoreleasePoolPush()的入栈流程图如下:

    1. push就是在page中插入一个哨兵对象,代表这些属于要一起release的对象。
    2. 如果page满了,则创建新的page,和老的page关联起来,将对象指针压栈。
    objc_autoreleasePoolPush()

    多层嵌套就是多次插入哨兵对象。

    同样,objc_autoreleasePoolPop()的出栈流程图如下:

    1. 根据传入的哨兵对象找到对应位置。
    2. 给上次push操作之后添加的对象依次发送release消息
    3. 回退next指针到正确位置。
    objc_autoreleasePoolPop()

    我们把需要及时释放掉的代码块放入我们生成的autorelease pool中,结束后清空这个自定义的pool,主动地让pool清空掉,从而达到及时释放内存的目的。以上述图片处理的例子为例,优化如下:

    for (int i = 0; i <= 1000; i ++) {
        //创建一个自动释放池
        NSAutoreleasePool *pool = [NSAutoreleasePool new];//也可以使用@autoreleasePool{domeSomething}的方式
        NSString *filePath = [[NSBundle mainBundle] pathForResource:@"test" ofType:@"PNG"];
        UIImage *image = [[UIImage alloc] initWithContentsOfFile:filePath];
        UIImage *image2 = [image imageByScalingAndCroppingForSize:CGSizeMake(480, 320)];
        [image release];
    
        //将自动释放池内存释放,它会同时释放掉上面代码中产生的临时变量image2
        [pool drain];
    }
    

    这样,每次循环结束时,可以及时的释放临时对象的内存,其中,对自动释放池的操作可以用上文提到的方法来替代:

    @autoreleasePool{
        //domeSomeThing;
    }
    

    3、子线程AutoRelease对象何时释放

    AutoreleasePoolPage pop的时候释放,在主线程的runloop中,有两个oberserver负责创建和清空autoreleasepool,详情可以看我的文章IOS的RunLoop,看这一篇文章就够了。那么子线程呢?子线程的runloop都需要手动开启,那么子线程中使用autorelease对象会内存泄漏吗,如果不会又是什么时候释放呢。

    a、子线程的autoreleasepool如何被创建?

    MRC下,使用@autoreleasing修饰符等同于MRC下调用autorelease方法,所以在NSObject源码中找到-(id)autorelese方法开始看(简化版的):

    static inline id autorelease(id obj)
    {
        id *dest __unused = autoreleaseFast(obj);
        return obj;
    }
    
    static inline id *autoreleaseFast(id obj)
    {
        AutoreleasePoolPage *page = hotPage();
        if (page && !page->full()) {// 存在 未满
            return page->add(obj);
        } else if (page) {// 存在 满了
            return autoreleaseFullPage(obj, page);
        } else {// 不存在
            return autoreleaseNoPage(obj);
        }
    }
    
    id *autoreleaseNoPage(id obj)
    {
        // Install the first page.
        AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
        setHotPage(page);
        
        // Push the requested object or pool.
        return page->add(obj);
    }
    

    得知如果当前线程没有AutorelesepoolPage的话,代码执行顺序为autorelease -> autoreleaseFast -> autoreleaseNoPage。在autoreleaseNoPage方法中,会创建一个hotPage,然后调用page->add(obj)

    也就是说即使这个线程没有AutorelesepoolPage,使用了autorelease对象时也会new一个AutoreleasepoolPage出来管理autorelese对象,不用担心内存泄漏。

    b、子线程的autoreleasepool何时被释放?

    明确了何时创建autoreleasepool以后就自然而然的有下一个问题,这个autoreleasepool何时清空?对于这个问题,这里使用watchpoint set variable命令来观察。首先是一个最简单的场景,创建一个子线程:

    __weak id obj;
    [NSThread detachNewThreadSelector:@selector(createAndConfigObserverInSecondaryThread) toTarget:self withObject:nil];
    

    使用一个weak指针观察子线程中的autorelease对象,子线程中执行的任务。

    - (void)createAndConfigObserverInSecondaryThread{
        __autoreleasing id test = [NSObject new];
        NSLog(@"obj = %@", test);
        obj = test;
    
        [[NSThread currentThread] setName:@"test runloop thread"];
        NSLog(@"thread ending");
    }
    

    obj = test处设置断点使用watchpoint set variable obj命令观察obj,可以看到obj在释放时的方法调用栈是这样的。

    obj在释放时的方法调用栈

    通过这个调用栈可以看到释放的时机在_pthread_exit。然后执行到AutorelepoolPagetls_dealloc方法。这个方法如下(简化版的):

    static void tls_dealloc(void *p)
    {
        // pop all of the pools
        if (!page->empty()) pop(page->begin());
        
        // clear TLS value so TLS destruction doesn't loop
        setHotPage(nil);
    }
    

    在这找到了if (!page->empty()) pop(page->begin());这句关键代码。再往上看一点,在_pthread_exit时会执行下面这个函数:

    void _pthread_tsd_cleanup(pthread_t self)
    {
        // clean up dynamic keys first
        for (j = 0; j < PTHREAD_DESTRUCTOR_ITERATIONS; j++) {
            pthread_key_t k;
            for (k = __pthread_tsd_start; k <= self->max_tsd_key; k++) {
                _pthread_tsd_cleanup_key(self, k);
            }
        }
    }
    

    也就是说thread在退出时会释放自身资源,这个操作就包含了销毁autoreleasepool,在tls_delloc中,执行了pop操作。

    c、子线程加入runloop的效果

    上述这个例子中的线程并没有加入runloop,只是一个一次性的线程。现在给这个线程加入runloop来看看效果会是怎么样的。

    对于runloop,我们知道runloop一定要有source才能保证run起来以后不立即结束,而source有三种,custom sourceport sourcetimer

    先加一个timer尝试下:

    - (void)createAndConfigObserverInSecondaryThread{
        [[NSThread currentThread] setName:@"test runloop thread"];
        NSRunLoop *loop = [NSRunLoop currentRunLoop];
    
        // 用于监控runloop的状态
        CFRunLoopObserverRef observer;
        observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                           kCFRunLoopAllActivities,
                                           true,      // repeat
                                           0xFFFFFF,  // after CATransaction(2000000)
                                           YYRunLoopObserverCallBack, NULL);
    
        CFRunLoopRef cfrunloop = [loop getCFRunLoop];
        if (observer) {
            CFRunLoopAddObserver(cfrunloop, observer, kCFRunLoopCommonModes);
            CFRelease(observer);
        }
    
        [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(testAction) userInfo:nil repeats:YES];
        [loop run];
    
        NSLog(@"thread ending");
    }
    

    相应测试代码为:

    - (void)testAction{
        __autoreleasing id test = [NSObject new];
        obj = test;
        NSLog(@"obj = %@", obj);
    }
    

    testAction()中加上watchpoint断点,观察obj的释放时机:

    obj的释放时机

    可以看到释放的时机在CFRunloopRunSpecific中,也就是runloop切换状态的时候。

    用自己实现的source尝试下:

    - (void)createAndConfigObserverInSecondaryThread{
        __autoreleasing id test = [NSObject new];
        NSLog(@"obj = %@", test);
        obj = test;
    
        [[NSThread currentThread] setName:@"test runloop thread"];
        NSRunLoop *loop = [NSRunLoop currentRunLoop];
    
        CFRunLoopObserverRef observer;
        observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                           kCFRunLoopAllActivities,
                                           true,      // repeat
                                           0xFFFFFF,  // after CATransaction(2000000)
                                           YYRunLoopObserverCallBack, NULL);
    
        CFRunLoopRef cfrunloop = [loop getCFRunLoop];
        if (observer) {        
            CFRunLoopAddObserver(cfrunloop, observer, kCFRunLoopCommonModes);
            CFRelease(observer);
        }
        
        // 自定义source
        CFRunLoopSourceRef source;
        CFRunLoopSourceContext sourceContext = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL, NULL, NULL, &runLoopSourcePerformRoutine};
        source = CFRunLoopSourceCreate(NULL, 0, &sourceContext);
    
        CFRunLoopAddSource(cfrunloop, source, kCFRunLoopDefaultMode);
        runLoopSource = source;
        runLoop = cfrunloop;
        [loop run];
    
        NSLog(@"thread ending");
    }
    
    -(void)wakeupSource{
        //通知InputSource
        CFRunLoopSourceSignal(runLoopSource);
        //唤醒runLoop
        CFRunLoopWakeUp(runLoop);
    }
    

    这里wakeupSource()是一个按钮的点击事件,用于唤醒runlooprunloop唤醒之后将执行runLoopSourcePerformRoutine函数:

    void runLoopSourcePerformRoutine (void *info)
    {
          __autoreleasing id test = [NSObject new];
        obj = test;
        // 如果不对obj赋值,obj会一直持有createAndConfigObserverInSecondaryThread函数入口的那个object,那个object不受这里面的autoreleasepool影响。
        NSLog(@"obj is %@" , obj);
        NSLog(@"回调方法%@",[NSThread currentThread]);
    }
    

    runLoopSourcePerformRoutine()中观察obj的释放时机,发现是在[NSRunloop run:beforeDate:]中。所以即使是我们自定义的source,执行函数中没有释放autoreleasepool的操作也不用担心,系统在各个关键入口都给我们加了这些操作。

    d、总结
    1. 子线程在使用autorelease对象时,如果没有autoreleasepool会在autoreleaseNoPage中懒加载一个出来。
    2. runlooprun:beforeDate,以及一些sourcecallback中,有autoreleasepoolpushpop操作,总结就是系统在很多地方都有autorelease的管理操作。
    3. 就算插入后没有pop也没关系,在线程exit的时候会释放资源,执行AutoreleasePoolPage::tls_dealloc,在这里面会清空autoreleasepool

    Demo

    Demo在我的Github上,欢迎下载。
    MemoryManagementDemo

    参考文献

    深入理解 Tagged Pointer
    iOS weak底层原理及源码解析
    iOS weak源码详解
    iOS Runtime探索之旅

    相关文章

      网友评论

          本文标题:IOS基础:内存管理

          本文链接:https://www.haomeiwen.com/subject/djaumktx.html