iOS的内存分布探究

作者: 落影loyinglin | 来源:发表于2021-07-17 22:25 被阅读0次

    前言

    最近遇到一些内存相关crash,排查问题过程中产生对进程内整个地址空间分布的疑惑。搜查了一番资料,网上关于Linux进程地址空间分布的介绍比较详细,但是iOS实际运行效果的比较少。
    本文基于网上相关文章,进行实际测试,探究App实际运行过程中的地址分布。

    正文

    32位的分布情况

    32位的机器,每个进程会有4G虚拟地址空间,较高的1G是从0xC0000000到0xFFFFFFFF的内核空间(Kernel Space ),较低的3G是从0x00000000到0xBFFFFFFF用户空间(User Space )。 内核空间中存放的是内核代码和数据,用户空间中存放的是App进程的代码和数据。这里地址指的都是虚拟地址空间,由操作系统负责映射为物理地址。
    把最常用的几个概念堆、栈、数据段、代码段做一个地址从大到小的排序:

    • 栈:在函数调用过程中,每个函数都会有一个相关的区域来存储函数的参数和局部变量,每次进行函数调用的时候系统都会往栈压入一个新的栈帧,在函数返回时清除。入栈和出栈的操作非常快,这个过程会用到两个寄存器:fp和sp寄存器。
    • 堆:在进程运行过程中,用于存储局部变量之外的变量。工作中常用的malloc函数、new操作符等可以从堆中申请内存。上面的栈很像数据结构中的栈,但这里的堆并不像数据结构的堆,其分配的方式是链表式,用brk()函数从操作系统批发内存,再零售给用户。
    • 数据段:通常指的段和data段,bss段内是未被初始化的静态变量,data段是在代码中已经初始化的静态变量。data段变大会导致启动速度变慢,bss段变大几乎不影响。因为bss段只需要预留位置,并没有真正的copy操作。相比data段增加的是具体的数据,bss段增加的只是数据描述信息。
    • 代码段:程序运行的机器指令,由代码编译产生。

    64位的实际分布

    对于一个iOS开发来说,目前大部分手机都是64位机器,还是需要对实际运行结果进行一些测试。
    以下真机测试的机型是iPhone XS Max + iOS 14.5。

    64位机器,进程内存地址从高到低分别是:
    0xFFFF FFFF FFFF FFFF ⬇️
    内核空间
    用户空间-保留区域
    扩展使用区域
    系统共享库
    栈空间
    内存映射区域(mmap)
    堆空间
    BSS段
    DATA段
    Text段
    0x0000 0000 0000 0000

    常见概念-堆、栈、数据段、代码段

    堆和栈

    用一段简单的代码,分别从堆和栈上面创建一块内存:

      char stack_address;
      UIView *heap_view_address = [[UIView alloc] init];
      NSLog(@"0x%016lx => stack 0x%016lx => heap", (long)&stack_address, (long)heap_view_address);
    

    输出 0x16f4c5af7 => stack 0x100e0d8a0 => heap,可以大概知道栈和堆所在区域,0x16F4...是栈地址的开始,0x100E...是堆地址的开始。

    数据段

    bss段内是未被初始化的静态变量,data段是在代码中已经初始化的静态变量。

    // 函数外-静态变量
    static int vcStaticInt = 1024;
    static int vcStaticNotInit;
    
    // 函数内
    NSLog(@"0x%lx => data 0x%lx => bss", (long)&vcStaticInt, (long)&vcStaticNotInit);
    

    vcStaticNotInit代表bss段,最终的地址是0x100945788
    vcStaticInt代表data段,最终的地址是0x1009455f8

    代码段

    代码段是代码编译后的机器指令,可以用一个类来定位:

    NSLog(@"class_address: 0x%lx\n", (long)[ViewController class]);
    

    最终输出的class_address是0x100945500。

    将这几个地址的大小进行排序,可以看到有:
    0x16F4C 5AF7(栈地址)
    0x100E0 D8A0(堆地址)
    0x10094 5788(bss段)
    0x10094 55F8(data段)
    0x10094 5500(Text段)

    系统共享库

    下面是两个不同App(bundle id不一样)在同手机上的运行crash日志,对比可以发现:在dyld之前的系统库地址不一样,在dyld之后的地址都是一样的。

    App中存在很多系统动态库,在启动时依赖dyld加载系统动态库到内存中。App依赖的具体系统动态库可能不同,但是都是iOS系统提供的。自然可以采用一种优化App启动速度方法:将所有的的系统依赖库按照固定的地址写在某个固定区域,这样只需保证App运行时这块内存不被使用,就能保证所有App启动时候不需要去装载所有的动态库。

    内存映射区域

    在栈空间的下方和堆空间的上方,有一块区域是内存映射区域。系统可以将文件的内容直接映射到内存,App可以通过mmap()方法请求将磁盘上文件的地址信息与进程用的虚拟逻辑地址进行映射。相比普通的读写文件,当App读取一个文件时有两步:先将文件从磁盘读取到物理内存,再从内核空间拷贝到用户空间。内存映射则可以减少操作系统的地址转换带来的消耗。

    可以写一段mmap的代码来观察生成的地址

    - (void)testMmap {
      NSString *imagePathStr = [[NSBundle mainBundle] pathForResource:@"abc" ofType:@"png"];
      size_t dataLength;
      void *dataPtr;
      // MapFile是自己写的mmap方法
      int errorCode = MapFile([imagePathStr cStringUsingEncoding:NSUTF8StringEncoding], &dataPtr, &dataLength);
      NSLog(@"mmapData:0x%lx, bytes_address:0x%lx, size:%d, error:%d", (long)dataPtr, (long)dataPtr, (long)dataLength, errorCode);
    }
    

    最终输出的dataLength地址是0x1026b8000,size是18432,注意到这个地址是在上面的堆和栈之间。

    用户空间-保留区域

    这一块没有查到相关信息,如有资料求分享。以下是实际运行的分析。

    @interface TestOCObject : NSObject
    @property (nonatomic, readonly, assign) char *name_buffer;
    @end
    @implementation TestOCObject {
      char name[102400];
    }
    - (char *)name_buffer {
      return name;
    }
    @end
    
    - (void)testHeapSize:(int)count {
      NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
      while (true) {
        char stackSize;
        TestOCObject *obj = [[TestOCObject alloc] init];
        ++count;
        if (obj) {
          NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
          [arr addObject:obj];
        }
        else {
          break;
        }
      }
    }
    

    当进程不断从堆空间申请内存,刚开始的时候从堆空间分配的地址是小于栈空间地址,但是随着内存不断被使用,在14700次左右的时候,堆空间分配的地址就会超过栈空间的地址。

    14703 stack_address => 0x16d751aef heap_address => 0x16d630000
    14704 stack_address => 0x16d751aef heap_address => 0x16db28000
    
    

    然后在17000次左右的时候,出现了一次大的地址变动:从0x1变成了0x2a开始。0x2a的地址空间是在系统共享库地址(0x1a)上方。

    之所以有这样的现象,个人理解是为了兼容32位的情况。因为不管是系统共享库,还是堆、栈地址空间的大小,初始地址都是在32位的地址空间内。而后面地址从0x2a0000000开始,就已经超过了32位的地址空间,属于64位机器的地址空间。最终运行到达到63000次左右,一次是100KB,可以计算得到63000*100KB/1024/1024=6G左右的空间。

    这时候产生了一个疑问:为什么32位的情况下,堆空间只有1G多空间大小?为什么64位的情况下,堆空间也只有6G多空间大小?(可以先暂停阅读,思考后见最下面分析)

    思维发散

    经过上面的分析,再来解析一下以前的问题:

    普通对象和静态变量有哪些区别?

    对象存储区域不同,普通对象一般是在栈、堆上,但是静态变量会存储在数据段,地址会有较大的差别。

    对象实例和对象方法的关系?

    一个OC对象的实例,其实就是一块存储数据的内存。内存中有指针,可以指向对象的类地址(代码段);访问一个对象方法其实是通过内存中的指针找到类地址,然后将对象的内存地址和调用的方法名作为参数传递。也可以用一种形象但可能不太恰当的比喻:执行一个方法就像带着原料跑到加工厂进行流水线的处理,原料就是对象的内存地址和其他传入方法的内存地址,流水线编译生成的固定机器指令。

    栈空间地址从高到低增长?

    前面已经提到,在函数调用过程中,会往栈压入一个新的栈帧,在函数返回时清除。
    那么只需要构造一个递归调用,观察每个函数局部变量的地址即可观察到栈空间的地址变化:

    - (void)testStackSize:(int)count {
      char stackSize[1024];
      NSLog(@"%05d stack_address => 0x%lx ", count, (long)&stackSize);
      if (count < 1000) {
        ++count;
        [self testStackSize:count];
      }
      else {
        NSLog(@"end");
      }
    

    需要注意,同一个函数内,先后申请两个局部变量A和B,观察A和B的地址,并不能看出栈空间的地址变化。因为同一个函数内的局部变量可能会受到编译器的优化,导致不符合预期。所以观察不同栈帧间的局部变量地址变化更为准确。
    通过上面的代码可以知道,栈空间地址确实是从高到低增长,随着递归函数的不断调用,局部变量的地址也在不断变小。在真机测试的情况下,两次运行的stackSize分别为 0x16ce86868和0x16ce86408,地址差为0x000000460, 转换成二进制4(16^2)+616=1024+96, 其中1024是申请的char数组,96则是函数递归调用的其他开销。这段递归代码运行994次会报错,由此可以计算主线程的栈空间有1MB左右。(此部分为实际运行效果推算,不同环境下可能结果各异)

    堆空间地址从低到高增长?

    堆空间的内存分配方式与栈空间不同,如果先后从堆上创建两个对象A和B,再对比两个对象的内存地址,那么A和B的大小应该没有直接关系。因为堆空间存在对象的创建和销毁,当对象A和B创建时,都有可能用到前面某些对象销毁时被回收的内存地址。
    常说的堆空间地址从低到高增长,是Linux系统堆空间初始分配之后,扩大堆空间大小的时候,会往高地址增长。iOS实际运行过程中,有可能先申请到一个很大的内存地址,比如说下面这代码:

    NSObject *oc_object = [[NSObject alloc] init];
    TestOCObject *oc_big_object = [[TestOCObject alloc] init];
    NSLog(@"oc_object_address => 0x%lx oc_big_object_address => 0x%lx", (long)oc_object, (long)oc_big_object);
    

    TestOCObject是上文用到一个自定义OC类,当代码实际运行的时候,可以会看到输出
    oc_object_address => 0x283d84cb0 oc_big_object_address => 0x1026b8000
    其中oc_object的地址是0x283d84cb0,而oc_big_object的地址是0x1026b8000
    0x28开头的地址也会被用于分配内存,一般用于内存较小的情况,而内存比较大的时候仍然会从正常的堆地址空间开始。(这个不同地址取决于libsystem_malloc.dylib对申请内存大小的不同处理)

    为什么32位的情况下,堆空间只有1G多空间大小?为什么64位的情况下,堆空间也只有6G多空间大小?

    操作系统内存是段页式管理,App先分段再分页,页是内存管理的基本单位。(32位是4096B=4KB,64位是16KB)
    当App访问虚拟内存时,操作系统会检查虚拟内存对应物理内存是否存在,如果不存在则触发一次缺页中断(Page Fault),将数据从磁盘加载到物理内存中,并建立物理内存和虚拟内存的映射。
    32位机器的虚拟空间最多只有4G,其中1G还要留给内核空间,堆和栈之间能留下来的空间并不宽裕,即使加上栈空间到系统共享库之间的区域,总共也只有1G多空间。而64位的机器用于充足的虚拟地址空间,虚拟内存占用超过1G多之后,会从0x2a开始申请虚拟地址。但是由于有物理内存的限制,过大的虚拟内存占用会导致物理内存快速消耗,当物理内存被消耗完成后,就需要释放现有的内存页。所以App并不需要有非常大的虚拟内存,因为瓶颈往往出现在物理内存上面。
    另外这里为什么可以创建6G的虚拟内存,这是因为测试代码申请的内存页大都没有写入操作,当内存有压力的时候,会被系统进行压缩成Compressed Memory。如果增加一个简单的写入操作,那么这个内存页就变成了脏内存,进程在1G多占用的时候就会被操作系统kill。

    - (void)testHeapSize:(int)count {
      NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
      while (true) {
        char stackSize;
        TestOCObject *obj = [[TestOCObject alloc] init];
        ++count;
        if (obj) {
          NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
          // 增加write操作
          for (int i = 0; i < 100; ++i) {
            memcpy(obj.name_buffer + (i * 1024), "hello", 6);
          }
          [arr addObject:obj];
        }
        else {
          break;
        }
      }
    }
    

    辅助工具

    objdump指令可以得到二进制分布,比如说下面的objdump -d LearnMemoryAddress

    总结

    本文为实际运行结果的分析,测试机型-iPhone XS Max + iOS 14.5。
    实际运行结果的解析部分可能存在错误,如果发现请帮忙纠正。
    知道各个地址空间的分布,能帮助我们更好理解iOS系统。在面对内存相关crash的时候,看到地址就能大概判断是属于哪一个区域,也能更加清晰具体去解析错误。

    参考资料-Memory Usage Performance Guidelines

    相关文章

      网友评论

        本文标题:iOS的内存分布探究

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