对象的底层探索(下)

274 阅读12分钟
  1. 知识准备

一些lldb指令

  • p/x
    以十六进制打印数据
  • p/o
    以八进制打印数据
  • p/t
    以二进制打印数据
  • p/f
    以浮点形式打印数据
  • x
    输出对象的内存地址,x/4gx中4代表输出4个,g代表每一个是8字节大小,x代表以16进制打印

位域

C语言允许在一个结构体中以位为单位来指定其成员所占内存,但指定的内存大小不能超过该成员类型所占的最大内存大小。

一个正常的结构体,它所占的内存空间由它的数据结构决定,如下列结构体,四个char类型占四个字节。

struct MyStruct {
    char a;
    char b;
    char c;
    char d;
}MyStruct;     // 4字节

但我们可以为每个成员指定存储所用的比特位,假如我们为MyStruct所有的成员都指定占1个比特位,那这个结构体总共只占4个比特位,最终这个结构体只占1个字节的内存空间。

struct MyStruct {
    char a : 1;
    char b : 1;
    char c : 1;
    char d : 1;
}MyStruct;     // 1字节

联合体

几种不同类型的变量存放到同一段内存单元中,几个变量互相覆盖。

union LGTeacher2 {
    char *name;
    int age;
    int height;
}t2;

给联合体的成员赋值时,会将上一次所赋值的成员的值覆盖掉,并且所有的成员变量都是指向同一块内存空间。

内存大小的计算规则

  1. 联合体必须能够容纳最大的成员变量
  2. 通过1计算出来的大小必须是其最大成员变量(基本数据类型)的整数倍
union LGTeacher3 {
    char a[7];
    int b;
}t3;

联合体LGTeacher3中最大的成员变量是char a[7],占7个字节,但因其不是基本数据类型,而int是,所以LGTeacher3必须是int类型大小的整数倍,并且要够容纳a数组,所以该联合体占8个字节。

联合体和结构体的区别

结构体(struct)中所有变量是“共存”的,⽽联合体(union)中是各变量是“互斥”的,只能存在⼀个。struct内存空间的分配是粗放的,不管⽤不⽤,全部分配。这样带来的⼀个坏处就是对于内存的消耗要⼤⼀些。但是结构体⾥⾯的数据是完整的。联合体⾥⾯的数据只能存在⼀个,但优点是内存使⽤更为精细灵活,也节省了内存空间。

对象在内存中

分布

一个对象在内存中占用多少字节呢?我们以LGPerson为例来探索一下。

@interface LGPerson : NSObject
@property (nonatomic ,copy) NSString *name;
@property (nonatomic ,copy) NSString *hobby;
@property (nonatomic ,assign) int age;
@property (nonatomic ,assign) double hight;
@property (nonatomic ,assign) short number;
@end

int main(int argc, char * argv[]) {
    LGPerson *p = [LGPerson new];
    // 输出结果为48
    NSLog(@"%lu",malloc_size((__bridge const void *)(p)));
}

在对象中,都有isa指针,指针占8字节,name和hobby都是字符串指针,所以也是8位,int占4位,double占8位,short占2位,char占1位,LGPerson的属性所占的字节数应该为8(isa)+8(name)+8(hobby)+4(age)+8(double)+2(short)+1(char)=39。但根据之前的探索,我们可知实际开辟内存空间要以16字节对齐,所以LGPerson占用48字节。 当我们执行main中的代码后,发现输出结果为48,与我们的分析相符合。

接下来我们将所有的属性赋值,然后在main中打个断点,通过终端输出来看看属性的值在内存中是怎么存储的。

LGPerson *p = [LGPerson new];
p.name = @"iOS";
p.hobby = @"code";
p.hight = 1.80;
p.age = 18;
p.number = 8;

通过终端的输出,我们发现,int和short类型被放到了一个8字节的内存空间里,NSString类型自己占了8个字节,double同样也占了8字节。并且int和short在书写的时候虽然被放在了NSString后面,但在内存中并没有按这个顺序存储,而是先存储了int和short,然后才存储了NSString类型。

image.png

那么有哪些因素会影响对象在内存中的占用呢?

影响因素

交换属性的书写顺序

@property (nonatomic ,assign) double hight;
@property (nonatomic ,copy) NSString *name;
@property (nonatomic ,copy) NSString *hobby;
@property (nonatomic ,assign) short number;
@property (nonatomic ,assign) int age;

再次查看内存,发现short和int类型,就算写在最后,最后也是放在了同一个8字节的内存空间中,并且这个内存空间就紧挨着isa指针。另外,虽然修改了double和NSString的顺序确实改变了它们在内存中间的位置,但由于它们都是8字节,没有优化的空间,所以苹果就按其编写的顺序存储到了内存中。 image.png

结论:对象在内存中是以8字节对齐的,当有多个属性可以存放在同一个8字节的内存中时,不管其书写的顺序,编译器都会尽可能帮我们进行优化。

添加属性

我们尝试在LGPerson中添加一个char类型的属性

@property (nonatomic ,assign) double hight;
@property (nonatomic ,copy) NSString *name;
@property (nonatomic ,assign) char a;   // 添加char类型的属性
@property (nonatomic ,copy) NSString *hobby;
@property (nonatomic ,assign) short number;
@property (nonatomic ,assign) int age;

发现新添加char类型的数据,被放在了与int和short同一个8字节的内存中,其中int占了前4个字节,当只添加一个char类型的属性时,short和char各占了两个字节,当添加两个char类型时,short占了前2个字节,两个char类型属性各占1个字节。

image.png

image.png

结论:不管有多少个属性,编译器将尽可能多的属性放在8字节的内存空间中,从而达到节省内存空间的目的。

继承

@interface Father : NSObject
{
isa
    int age1;
    NSString *name;
    NSString *job;
}
@end

@interface Son : Father
{
isa
    int age2;
}

Son *s = [Son new];
NSLog(@"对象所占空间:%zd", class_getInstanceSize([Son class]));
NSLog(@"系统开辟的空间:%zd", malloc_size((__bridge  const void *)s));

Father中有一个int类型的成员变量,两个NSString类型的成员变量,而Son中只有一个int类型的成员变量。理论上,Father尝试打印对象所占空间发现,编译器并没有为我们进行优化。

image.png

接下来我们将Father中的int类型的成员变量换个位置,放到最后。

@interface Father : NSObject
{
    NSString *name;
    NSString *job;
    int age1;
}
@end

我们惊奇地发现,编译器为我们做了内存优化。

image.png

当存在继承关系时,父类是存储在一个连续的内存空间中,子类无法改变父类数据结构。但如果子类的头部数据和父类的尾部数据能够存在同一个8字节内存中,编译器就会把它们放在一起达到优化的目的。(但使用属性而非成员变量时,即使在这种情况下编译器也没有为我们进行优化,原因暂不得而知。)

nonPointerIsa

继续探索 _class_createInstanceFromZone

对象的底层探索(上)中,我们探索到了objc源码中的_class_createInstanceFromZone函数,得知了其中调用instanceSize来计算对象所占用的内存空间,calloc来开辟对象所需的空间。接下来,我们继续查看_class_createInstanceFromZone函数中剩余的代码。

static ALWAYS_INLINE id _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              int construct_flags = OBJECT_CONSTRUCT_NONE,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil)
{

    ASSERT(cls->isRealized());

    // Read class's info bits all at once for performance
    bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
    bool hasCxxDtor = cls->hasCxxDtor();
    bool fast = cls->canAllocNonpointer();
    size_t size;

    //计算需要的大小
    size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;
    id obj;
    if (zone) {
        obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
    } else {
        obj = (id)calloc(1, size);
    }
    if (slowpath(!obj)) {
        if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
            return _objc_callBadAllocHandler(cls);
        }
        return nil;
    }

    if (!zone && fast) {
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        // Use raw pointer isa on the assumption that they might be
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (fastpath(!hasCxxCtor)) {
        return obj;
    }
    
    construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
    return object_cxxConstructFromClass(obj, cls, construct_flags);
}

首先在if (slowpath(!obj))if (fastpath(!hasCxxCtor))各下一个断点,在代码走到这两个地方时,分别打印obj。由控制台输出的信息发现,在前者处obj还只是一个id类型,但在后者处,obj已经变成了LGPerson的类型,说明此时开辟的内存空间已经与类完成了绑定,并且对象的isa指针,就是指向实例对象所属的类对象。

image.png

在这两个断点中间,无论if (!zone && fast)判断结果如何,最终都会执行到initIsa函数,那就从initIsa中一探究竟吧。

initIsa

inline void objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{ 
    ASSERT(!isTaggedPointer()); 
    
    isa_t newisa(0);

    if (!nonpointer) {
        newisa.setClass(cls, this);
    } else {
        ASSERT(!DisableNonpointerIsa);
        ASSERT(!cls->instancesRequireRawIsa());


#if SUPPORT_INDEXED_ISA
        ASSERT(cls->classArrayIndex() > 0);
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
#   if ISA_HAS_CXX_DTOR_BIT
        newisa.has_cxx_dtor = hasCxxDtor;
#   endif
        newisa.setClass(cls, this);
#endif
        // extra_rc就是对象的引用计数,而这个引用计数,正是存储在对象的isa指针中。
        newisa.extra_rc = 1;    // 引用计数
    }

    // This write must be performed in a single store in some cases
    // (for example when realizing a class because other threads
    // may simultaneously try to use the class).
    // fixme use atomics here to guarantee single-store and to
    // guarantee memory order w.r.t. the class index table
    // ...but not too atomic because we don't want to hurt instantiation
    isa = newisa;
}

initIsa中创建了一个initIsa类型的结构体对象,查看isa_t,发现它是一个联合体,并且其中有一个结构体,结构体的内容是宏定义ISA_BITFIELD,查看这个宏定义。

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    uintptr_t bits;

private:
    // Accessing the class requires custom ptrauth operations, so
    // force clients to go through setClass/getClass by making this
    // private.
    Class cls;

public:
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };

    bool isDeallocating() {
        return extra_rc == 0 && has_sidetable_rc == 0;
    }
    void setDeallocating() {
        extra_rc = 0;
        has_sidetable_rc = 0;
    }
#endif

    void setClass(Class cls, objc_object *obj);
    Class getClass(bool authenticated);
    Class getDecodedClass(bool authenticated);
};
// ISA_BITFIELD根据架构的不同,其成员的值也不相同。
# if __arm64__
// ARM64 simulators have a larger address space, so use the ARM64e
// scheme even when simulators build for ARM64-not-e.
#   if __has_feature(ptrauth_calls) || TARGET_OS_SIMULATOR
#     define ISA_MASK        0x007ffffffffffff8ULL
#     define ISA_MAGIC_MASK  0x0000000000000001ULL
#     define ISA_MAGIC_VALUE 0x0000000000000001ULL
#     define ISA_HAS_CXX_DTOR_BIT 0
#     define ISA_BITFIELD                                                     
        uintptr_t nonpointer        : 1;                                     
        uintptr_t has_assoc         : 1;                                     
        uintptr_t weakly_referenced : 1;                                     
        uintptr_t shiftcls_and_sig  : 52;                                     
        uintptr_t has_sidetable_rc  : 1;                                     
        uintptr_t extra_rc          : 8
#     define RC_ONE   (1ULL<<56)
#     define RC_HALF  (1ULL<<7)
#   else
#     define ISA_MASK        0x0000000ffffffff8ULL
#     define ISA_MAGIC_MASK  0x000003f000000001ULL
#     define ISA_MAGIC_VALUE 0x000001a000000001ULL
#     define ISA_HAS_CXX_DTOR_BIT 1
#     define ISA_BITFIELD                                                     
        uintptr_t nonpointer        : 1;                                     
        uintptr_t has_assoc         : 1;                                     
        uintptr_t has_cxx_dtor      : 1;                                     
        uintptr_t shiftcls          : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/
        uintptr_t magic             : 6;                                     
        uintptr_t weakly_referenced : 1;                                     
        uintptr_t unused            : 1;                                     
        uintptr_t has_sidetable_rc  : 1;                                     
        uintptr_t extra_rc          : 19
#     define RC_ONE   (1ULL<<45)
#     define RC_HALF  (1ULL<<18)
#   endif

# elif __x86_64__
#   define ISA_MASK        0x00007ffffffffff8ULL
#   define ISA_MAGIC_MASK  0x001f800000000001ULL
#   define ISA_MAGIC_VALUE 0x001d800000000001ULL
#   define ISA_HAS_CXX_DTOR_BIT 1
#   define ISA_BITFIELD                                                       
      uintptr_t nonpointer        : 1;                                       
      uintptr_t has_assoc         : 1;                                       
      uintptr_t has_cxx_dtor      : 1;                                       
      uintptr_t shiftcls          : 44; /*MACH_VM_MAX_ADDRESS 0x7fffffe00000*/
      uintptr_t magic             : 6;                                       
      uintptr_t weakly_referenced : 1;                                       
      uintptr_t unused            : 1;                                       
      uintptr_t has_sidetable_rc  : 1;                                       
      uintptr_t extra_rc          : 8
#   define RC_ONE   (1ULL<<56)
#   define RC_HALF  (1ULL<<7)

我们发现这个结构体中有不少成员,而这些成员变量的作用,则由下图列出。 image.png

利用isa得到类对象

根据表中的内容,shiftcls是存储类对象的指针,那我们要怎么从isa中获取到类对象的指针地址呢?

通过移位

shiftcls存储的是类指针的值,那把isa指针中其他的值都去掉,就能获得类对象的指针。以x86_64架构为例,ISA_BITFIELD一共存储了64位的数据,首页我们将这段数据左移3位,再右移3位,那么前面3位nonpointerhas_assochas_cxx_dtor的值都变成了0,那么我们接着向右移动17位,同理右边的数据也都变成了0,最后在左移17位回到原位,那么ISA_BITFIELD中就只剩下了shiftcls的值。这个移动的过程,我们可以优化成先左移3位去除左边3位的数据,右移20位直接去除右边的数据,再左移17位回到原来的位置。

那么我们现在通过控制台打印来验证一下。先通过x/4gxp命令获取到对象的isa指针,然后将指针移动完毕后得到的shiftcls的值,接着我们查看LGPerson类对象的地址,发现二者一致,证明了shiftcls就是LGPerson类对象的地址,并且我们可以通过对象的isa指针获取到对象所属类对象的地址。 image.png

通过掩码

除了通过移位,苹果还为我们提供了一个掩码,可以通过这个掩码,快速地获取到类对象的地址。 我们可以直接将isa指针的地址&define ISA_MASK 0x00007ffffffffff8ULL,就可以得出类对象的指针。&上掩码的操作,其实就相当于把前后的数据都去掉,只留下中间类对象地址的值。 image.png

setClass

接着查看initIsa函数,发现其最后有一行代码shiftcls = (uintptr_t)newCls >> 3;。这行将传入的Class对象的右移三位以后赋值给了shiftcls,这也验证了shiftcls就是存放类对象的地址(右移3位是因为前面3位存储其他的内容,写死右移3位是因为不管任何架构,shiftcls前面都是只有3位)。

inline void
isa_t::setClass(Class newCls, UNUSED_WITHOUT_PTRAUTH objc_object *obj)
{
    // Match the conditional in isa.h.
#if __has_feature(ptrauth_calls) || TARGET_OS_SIMULATOR
#   if ISA_SIGNING_SIGN_MODE == ISA_SIGNING_SIGN_NONE
    // No signing, just use the raw pointer.
    uintptr_t signedCls = (uintptr_t)newCls;

#   elif ISA_SIGNING_SIGN_MODE == ISA_SIGNING_SIGN_ONLY_SWIFT
    // We're only signing Swift classes. Non-Swift classes just use
    // the raw pointer
    uintptr_t signedCls = (uintptr_t)newCls;
    if (newCls->isSwiftStable())
        signedCls = (uintptr_t)ptrauth_sign_unauthenticated((void *)newCls, ISA_SIGNING_KEY, ptrauth_blend_discriminator(obj, ISA_SIGNING_DISCRIMINATOR));

#   elif ISA_SIGNING_SIGN_MODE == ISA_SIGNING_SIGN_ALL
    // We're signing everything
    uintptr_t signedCls = (uintptr_t)ptrauth_sign_unauthenticated((void *)newCls, ISA_SIGNING_KEY, ptrauth_blend_discriminator(obj, ISA_SIGNING_DISCRIMINATOR));

#   else
#       error Unknown isa signing mode.
#   endif

    shiftcls_and_sig = signedCls >> 3;

#elif SUPPORT_INDEXED_ISA
    // Indexed isa only uses this method to set a raw pointer class.
    // Setting an indexed class is handled separately.
    cls = newCls;

#else // Nonpointer isa, no ptrauth
    shiftcls = (uintptr_t)newCls >> 3;
#endif
}

new方法

我们时常会看到,有些人的代码里创建对象使用allocinit方法,而有些人则使用new方法。那二者又有什么区别呢?

我们先给LGPerson添加一个属性name,并且重写init方法来对该属性进行赋值,然后分别通过两种方式创建对象,并打印name的值,那两个name的值分别是什么呢?

@interface LGPerson : NSObject
@property (nonatomic ,copy) NSString *name;
@end

@implementation LGPerson
-(instancetype)init {
    if (self = [super init]) {
        self.name = @"LG";
    }
    return self;
}
@end

int main(int argc, char * argv[]) {
    LGPerson *p1 = [[LGPerson alloc] init];
    LGPerson *p2 = [LGPerson new];
    NSLog(@"alloc init的name:%@\n new的name:%@",p1.name,p2.name);
    // 输出的结果为:
    // alloc init的name:LG
    // new的name:LG
    return 0;
}

通过运行代码我们发现,二者输出的name值均为LG。打开objc源码,在NSObject.m中找到new方法的实现。

+ (id)new {
    return [callAlloc(self, false/*checkNil*/) init];
}

通过源码我们发现,new方法调用callAlloc函数后调用了init方法,根据之前的内容,alloc方法调用的也是callAlloc函数,所以allocinit方法和new方法是等价的。

我们也可以通过汇编来验证一下。首先在调用new方法的地方打个断点,同时打开汇编,发现调用了objc_opt_new

image.png

我们继续在objc源码中查找这个函数,发现其同样调用callAllocinit,这也进一步验证了我们的结论。

id
objc_opt_new(Class cls)
{
#if __OBJC2__
    if (fastpath(cls && !cls->ISA()->hasCustomCore())) {
        return [callAlloc(cls, false/*checkNil*/) init];
    }
#endif
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(new));
}

对象的总结

alloc方法在底层的调用顺序

image.png

alloc核心方法

image.png

为什么要字节对齐

字节是内存的容量单位。但是,CPU在读取内存的时候,却不是以字节为单位来读取的,⽽是以“块”为单位读取的,所以⼤家也经常听到⼀块内存,“块”的⼤⼩也就是内存存取的⼒度。如果不对⻬的话,在我们频繁的存取内存的时候,CPU就需要花费⼤量的精⼒去分辨你要读取多少字节,这就会造成CPU的效率低下,如果想要CPU能够⾼效读取数据,那就需要找⼀个规范,这个规范就是字节对⻬。

为什么对象内部的成员变量是以8字节对⻬,系统实际分配的内存以16字节对⻬?

以空间换时间。苹果采取16字节对⻬,是因为OC的对象中,第⼀位叫isa指针,它是必然存在的,⽽且它就占了8位字节,就算对象中没有其他的属性了,也⼀定有⼀个isa,那对象就⾄少要占⽤8位字节。如果以8位字节对⻬的话,如果连续的两块内存都是没有属性的对象,那么它们的内存空间就会完全的挨在⼀起,是容易混乱的。以16字节为⼀块,这就保证了CPU在读取的时候,按照块读取就可以,效率更⾼,同时还不容易混乱。

影响对象内存的因素

对象⾥⾯存储了isa指针 + 成员变量的值,isa指针是固定的,占8个字节,所以影响对象内存的只有成员变量(属性会⾃动⽣成带下划线的成员变量)。

对象的内存分布

在对象的内部是以8字节进⾏对⻬的。苹果会⾃动重排成员变量的顺序,将占⽤不⾜ 8 字节的成员挨在⼀起,凑满8字节,以达到优化内存的⽬的。

nonPointerIsa

nonPointerIsa是内存优化的⼀种⼿段。isa是⼀个Class类型的结构体指针,占8个字节,主要是⽤来存内存地址的。但是8个字节意味着它就有8*8=64位。存储地址根本不需要这么多的内存空间。⽽且每个对象都有个isa指针,这样就浪费了内存。所以苹果就把和对象⼀些息息相关的东⻄,存在了这块内存空间⾥⾯。这种isa指针就叫nonPointerIsa。