关于 iOS 内存分配的胡思乱想

作者:@iOS成长指北,本文首发于公众号 iOS成长指北,欢迎各位前往指正

如有转载需求请联系我,记住一定要联系哦!!!

为什么系统给NSObject分配的内存大小为16字节,根本原因是什么?

本文源于一个讨论组中一个关于 iOS 基础的问题,为什么系统给 NSObject 分配的最小内存大小为 16 字节且分配的内存大小是 16 的倍数。

为了探究这部分,你至少需要具备以下知识:

  1. 了解 NSObject 在内存中的本质
  2. 了解如何获取一个实例对象的大小
  3. 了解什么字节对齐

基础知识

测试代码是在一个命令行项目中创建的一个 NSObject 对象

NSObject *object = [[NSObject alloc] init];
复制代码

NSObject 在内存中的本质

当我们使用 xrun 将 Objective-C 代码转换成 iphoneos 平台下的 arm64 64 位下的 C++ 代码

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp
复制代码

如果你需要在模拟器里面的中调试的话——即 Single APP,可以使用下面的命令进行转换

xcrun -sdk iphonesimulator14.4 clang -rewrite-objc -F /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks ViewController.m
复制代码

我们找到 C++ 代码中关于 NSObject 的结构体

struct NSObject_IMPL {
	Class isa; //8 个字节
};
复制代码

可知,NSObject 的本质是一个包含 isa 成员变量的结构体。Class 是一个指向结构体的指针,其大小为 8 个字节。

获取实例对象大小的方法

在 iOS 中我们有三种方法来获取我们对象实例的大小,我们来了解一下

这里说的是在 OC 中的调试方法,在 Swift 中我们还有 Memory layout

class_getInstanceSize()

当我们在项目中打印 NSObject 类的实例大小时,我们可以通过 runtime 中的 class_getInstanceSize() 方法来获取

#import <objc/runtime.h>
...
NSLog(@"%zd", class_getInstanceSize([NSObject class])); // 8
复制代码

结果与我们已知的系统为 NSObject 实例所分配的 16 个字节大小不一致

malloc_size()

我们需要探究的是具体对象分配的大小,所以我们来探究一下其实例的 malloc_size,也就是获取 object 所指向内存的大小

#import <malloc/malloc.h>
...
NSLog(@"%zd", malloc_size((__bridge const void*)object)); //16
复制代码

这个值与我们已知的大小是相符合的

sizeof()

我们还有 sizeof() 这个方法来获取 object 占多大内存。

NSLog(@"%zd", sizeof(object)); // 8
复制代码

值得注意的是,sizeof() 是一个运算符,并不是一个函数sizeof() 传进来的是类型,用来计算这个类型占多大内存,这个在 编译器编译阶段 就会确定大小并直接转化成 8 、16 、24 这样的常数,而不是在运行时计算。参数可以是数组、指针、类型、对象、结构体、函数等。

字节对齐

在 struct 中,编译器为 struct 的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同

一般来说字节对齐有两个的作用一是便于 CPU 快速访问,二是合理的利用字节对齐可以有效地节省存储空间

源码分析

源码分析需要用到两个开源项目:objc4[1] 和 libmalloc[2]

请使用最新的开源代码,不要抄网上的代码看,相差多个版本的话,部分实现是不一样的!!!

从 runtime 源码中我们可以找到关于 + (id)alloc 方法的调用轨迹:

  1. 当调用 alloc 方法时,其本质是调用 [cls allocWithZone:nil] 方法来实现,注意,zone 的值为 nil

    + (id)alloc {
        return _objc_rootAlloc(self);
    }
    ...
    // Base class implementation of +alloc. cls is not nil.
    // Calls [cls allocWithZone:nil].
    id
    _objc_rootAlloc(Class cls) {
        return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
    }
    ...
    // Replaced by ObjectAlloc
    + (id)allocWithZone:(struct _NSZone *)zone {
        return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone);
    }
    复制代码
  2. 实际上是调用 class_createInstance(cls, 0); 来创建对象的——为了行文大小,进行删减

    id
    _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone)
    {
        id obj;
      ...
        obj = class_createInstance(cls, 0);
    ...
    }
    ...
    id 
    class_createInstance(Class cls, size_t extraBytes)
    {
        return _class_createInstanceFromZone(cls, extraBytes, nil);
    }
    复制代码
  3. 利用 cls->instanceSize(extraBytes); 方法获取值 size——为了行文大小,进行删减

    static __attribute__((always_inline)) 
    id
    _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                                  bool cxxConstruct = true, 
                                  size_t *outAllocatedSize = nil)
    {
      ...
        size_t size = cls->instanceSize(extraBytes);
      ...
         obj = (id)calloc(1, size);
      ...
    
        return obj;
    }
    复制代码
  4. 在 instanceSize 方法中会返回一个大小,这个大小经过字节对齐以后生成一个新的,并且这个大小会进行一个判断,判断不小于 16 个字节,然后我们调用 calloc 方法生成一个对象。

      size_t instanceSize(size_t extraBytes) {
            size_t size = alignedInstanceSize() + extraBytes;
            // CF requires all objects be at least 16 bytes.
            if (size < 16) size = 16;
            return size;
        }
    复制代码

从源码上已经佐证了, NSObject 分配的最小内存大小为 16 字节。当然我们还可以查看内存,在 object 赋值之后加上断点,利用 View Memory of "object" 获取对象在内存中的字节

viewInMemory.png

然后我们可以看到,的确分配了 16 个字节。

字节.png

然后通过使用 calloc 方法创建一个对象实例。那我们来看看在 libmalloc 里面 calloc 里面做了什么吧

void *
calloc(size_t num_items, size_t size)
{
	return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}
复制代码

其实现是调用了 _malloc_zone_calloc 函数,

static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
		malloc_zone_options_t mzo) {
  ...
	ptr = zone->calloc(zone, num_items, size); //调用 calloc 方法
  ...
	return ptr;
}
复制代码

在探究源码我们发现,在 macOS/iOS 上,苹果采用了名为 malloc_zone 的内存分配方式,将内存块的分配按照尺寸拆成不同的 zone 来完成,一个 zone 可以看做是一系列内存分配相关 api 的组合结构,一个 _malloc_zone_t 结构体其中包含了大量的方法实现,便于展示做了删减:

typedef struct _malloc_zone_t {
  ...
    size_t 	(* MALLOC_ZONE_FN_PTR(size))(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */
    void 	*(* MALLOC_ZONE_FN_PTR(malloc))(struct _malloc_zone_t *zone, size_t size);
    void 	*(* MALLOC_ZONE_FN_PTR(calloc))(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */
    void 	*(* MALLOC_ZONE_FN_PTR(valloc))(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */
    void 	(* MALLOC_ZONE_FN_PTR(free))(struct _malloc_zone_t *zone, void *ptr);
    void 	*(* MALLOC_ZONE_FN_PTR(realloc))(struct _malloc_zone_t *zone, void *ptr, size_t size);
    void 	(* MALLOC_ZONE_FN_PTR(destroy))(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */
    const char	*zone_name;
  ...
} malloc_zone_t;
复制代码

最终的分配方法其实是根据不同的 zone 来实现的。

malloc_zone 会按照需要分配的内存大小依次分配为 nanotinysmalllarge,一块最小的内存在是被分配在 nano 中的,其分配的内存大小是是 16 的倍数—— 16, 32, … ,256

在阿里云栖社区的《iOS 内存管理和 malloc 源码解读》[3]和泰晓科技的《内存分配奥义·malloc in OS X》[4] 两篇文章中都提及了关于在 64 位机器上 malloc 会默认使用 nano_zone 来进行低内存的分配

类型 处理 size 范围 粒度(QUANTA)
nano 0(返回16B) – 256 B 16B
tiny 0(返回 16B) – 63×16 B 16B
small 1KB — 15KB(设备内存小于1GB) / 1KB — 127KB(大于等于1GB) 512B
large 15+KB(<1GB) / 127+KB(>=1GB) kernel page size(内核页的大小)

这里需要注意粒度的概念

笔者在《让我们来调试 iOS 内存 – Memory Graph》一文中通过分析应用程序的 .memgraph 文件证明了内存中存在三种内存类型:tinysmalllarge,所以如果存在 nano 类型,为什么这个类型在.memgraph 文件中得不到体现呢?

如何证明 NanoZone 或 ScalableZone —— 1.0 测试

一个简单的证明方法是,调试 libmalloc[2] 源码

calloc(size_t __count, size_t __size)
复制代码

注意:源码与真实实现之间存在一定差别的。目前现有的编译方法都是去除代码中关于 nanov2 的实现。而且由于苹果 Open source 的代码是针对 OS X 的特定版本,具体细节可能与 iOS 上有所不同,如地址空间分布

调用 calloc 时传入两个不同的 __size,我们可以依次调试来证明这部分:

#import <Foundation/Foundation.h>
#import <malloc/malloc.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
      void *p  = calloc(1, 8);
      //void *p1 = calloc(1, 300);
    }
    return 0;
}
复制代码

最终我们是通过 malloc 方法进行内存分配的,所以我们在 malloc.c 文件中查找 zone->malloc() 方法,一个值得关注的方法为 _malloc_zone_malloc,现在我们将断点设置在 ptr = zone->malloc(zone, size);

static void *
_malloc_zone_malloc(malloc_zone_t *zone, size_t size, malloc_zone_options_t mzo)
{
  ...
	ptr = zone->malloc(zone, size);		// if lite zone is passed in then we still call the lite methods
  ...
	return ptr;
}
复制代码

这部分的代码分成三部分:安全条件检测 -> 通过 zone 申请内存 -> 日志记录输出,我们需要关注的是通过 zone 申请内存方法。

当我们运行测试程序的时候,我们得到一个方法链

calloc() -> _malloc_zone_calloc() -> _malloc_initialize() ->malloc_set_zone_name() ->_malloc_zone_malloc()

我们模拟的是首次调用时,初始化 default_zone 的情况。所以其会包含 initial_scalable_zoneinitial_nano_zone 初始化的逻辑——即 _malloc_initialize() 初始化方法。

// 在 MALLOC_TARGET_64BIT 下,CONFIG_NANOZONE恒为1
#if MALLOC_TARGET_64BIT 
#define CONFIG_NANOZONE 1
#define CONFIG_ASLR_INTERNAL 0
#else // MALLOC_TARGET_64BIT
#define CONFIG_NANOZONE 0
#define CONFIG_ASLR_INTERNAL 1
#endif // MALLOC_TARGET_64BIT

static void
_malloc_initialize(const char *apple[], const char *bootargs)
{
  ...
  const uint32_t k_max_zones = 3;
	malloc_zone_t *zone_stack[k_max_zones];
	const char *name_stack[k_max_zones];
	uint32_t num_zones = 0;
    //初始化 initial_scalable_zone 和 initial_nano_zone ,以及当 64 位情况下将 initial_default_zone 赋值为 initial_nano_zone
	initial_scalable_zone = create_scalable_zone(0, malloc_debug_flags);
	zone_stack[num_zones] = initial_scalable_zone;
	name_stack[num_zones] = DEFAULT_MALLOC_ZONE_STRING;
	num_zones++;

#if CONFIG_NANOZONE
	nano_common_configure();

	malloc_zone_t *helper_zone = zone_stack[num_zones - 1];
	malloc_zone_t *nano_zone = NULL;
	
	nano_zone = nano_create_zone(helper_zone, malloc_debug_flags);


	if (nano_zone) {
		initial_nano_zone = nano_zone;
		zone_stack[num_zones] = nano_zone;
		name_stack[num_zones] = DEFAULT_MALLOC_ZONE_STRING;
		name_stack[num_zones - 1] = MALLOC_HELPER_ZONE_STRING;
		num_zones++;
	}
#endif
  
	MALLOC_ASSERT(num_zones <= k_max_zones);
	initial_default_zone = zone_stack[num_zones - 1]; // initial_default_zone 值为 initial_nano_zone

	// 2 separate loops: malloc_set_zone_name already requires a working allocator.
	for (int i = num_zones - 1; i >= 0; i--) malloc_zone_register_while_locked(zone_stack[i]);
	for (int i = num_zones - 1; i >= 0; i--)
		malloc_set_zone_name(zone_stack[i], name_stack[i]);
		
	...
}
复制代码

所以当我们初次调用 calloc()方法时, malloc.c 文件中的_malloc_zone_malloc 实际上与内存的分配无关,其应该是 initial_nano_zonecreate_scalable_zone 两个对象的初始化方法。

从测试上来说,当初始内存分配时,zone->malloc(zone, size) 会执行两到三次,这取决于 zone_stack 大小。所以根据 zone_stack 中不用zone 的区别,其 malloc 方法都会执行一次。

如何证明 NanoZone 或 ScalableZone —— 2.0 测试

为了避免这种重复的现象对于源码阅读的影响,笔者重新调整了测试的代码——关于 p8p300 的实现。一般来说,我们分配的第一个对象,肯定是_malloc_initialize() 已经完成初始化的事情

#import <Foundation/Foundation.h>
#import <malloc/malloc.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
		void *p  = calloc(1, 8);
		void *p8  = calloc(1, 8);
		void *p300 = calloc(1, 300);

    return 0;
}

复制代码

然后,我们查看_malloc_zone_calloc 方法,当default_zone 成功以后,返回出来的值就是 zone->calloc() 返回的值。

我们最终分配出来的内存地址,恒为 zone->calloc 方法返回的值

static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
		malloc_zone_options_t mzo)
{
  ...
	// 这个流程是有优化的 会和苹果原系统不一定完全一样
	ptr = zone->calloc(zone, num_items, size);
	...
	return ptr;
}
复制代码

然后我们将聚焦在 nano_malloc.c 文件中的 nano_calloc 方法上。在第一次试验中,我们证明了默认的default_zone 初始化以后,其值应该是 initial_nano_zone,当然我们也可以通过断点在 ptr = zone->calloc(zone, num_items, size); 时,使用两种方法:

  • 断点的下一步
  • 直接使用 po zone->calloc 定位到具体实现的位置,使用 po zone->calloc 首先会定位到malloc.c 中的default_zone_calloc() ,然后继续使用 po zone->calloc 定位到 nano_calloc

nano_calloc 的核心实现如下所示:

static void *
nano_calloc(nanozone_t *nanozone, size_t num_items, size_t size)
{
	size_t total_bytes;

	if (calloc_get_size(num_items, size, 0, &total_bytes)) {
		return NULL;
	}

	if (total_bytes <= NANO_MAX_SIZE) {
		void *p = _nano_malloc_check_clear(nanozone, total_bytes, 1);
		if (p) {
			return p;
		} else {
			/* FALLTHROUGH to helper zone */
		}
	}
	malloc_zone_t *zone = (malloc_zone_t *)(nanozone->helper_zone);
	return zone->calloc(zone, 1, total_bytes);
}
复制代码

size 小于 NANO_MAX_SIZE 时,首先调用_nano_malloc_check_clear 方法进行处理,如果失败,或者当size 大于 NANO_MAX_SIZE 时,会调用 nanozonehelper_zone 去执行这个内存分配的逻辑 calloc。在1.0测试中我们证明了 nano_zone 初始化时 helper_zoneinitial_scalable_zone

我们阅读关于 _nano_malloc_check_clear 的源码,这个方法才是内存分配的核心:

static void *
_nano_malloc_check_clear(nanozone_t *nanozone, size_t size, boolean_t cleared_requested)
{
	MALLOC_TRACE(TRACE_nano_malloc, (uintptr_t)nanozone, size, cleared_requested, 0);
  //获取cpu对应的index以及内存size对应的slot
	void *ptr;
	size_t slot_key;
	size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); // Note slot_key is set here
	mag_index_t mag_index = nano_mag_index(nanozone);
  //以链表的形式将这些区块存储起来。这些链表的头部放在 meta_data 数组中对应的[mag_index][slot_key]元素中
	nano_meta_admin_t pMeta = &(nanozone->meta_data[mag_index][slot_key]);
  //根据需要合并了内存 barriers,以允许线程安全地访问队列元素。用来检测检测最近释放的内存块是否存在可用的
	ptr = OSAtomicDequeue(&(pMeta->slot_LIFO), offsetof(struct chained_block_s, next));
	if (ptr) {
		unsigned debug_flags = nanozone->debug_flags;
		...
	} else {
    //进行新的内存分配
		ptr = segregated_next_block(nanozone, pMeta, slot_bytes, mag_index);
	}
  //非初始化时,cleared_requested值恒为 1,
	if (cleared_requested && ptr) {
		memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first?
	}
	return ptr;
}
复制代码

当我们创建的内存大小值小于NANO_MAX_SIZE 时,segregated_size_to_fit 会返回一个真正合适的大小并且生成一个 slot_key

#define NANO_REGIME_QUANTA_SIZE	(1 << SHIFT_NANO_QUANTUM)	// 16
#define NANO_MAX_SIZE			256 /* Buckets sized {16, 32, 48, ..., 256} */
#define SHIFT_NANO_QUANTUM		4
...
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
	size_t k, slot_bytes;

	if (0 == size) {
		size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
	}
	k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
	slot_bytes = k << SHIFT_NANO_QUANTUM;							// multiply by power of two quanta size
	*pKey = k - 1;													// Zero-based!

	return slot_bytes;
}

复制代码

segregated_next_block 为我们提供了基于链表的内存分配能力,其值取决于 meta_data 这个二维数组,其分配方式是新分配一块内存。

segregated_next_block(nanozone_t *nanozone, nano_meta_admin_t pMeta, size_t slot_bytes, unsigned int mag_index)
{
	while (1) {
    
		uintptr_t theLimit = pMeta->slot_limit_addr; // Capture the slot limit that bounds slot_bump_addr right now
		uintptr_t b = OSAtomicAdd64Barrier(slot_bytes, (volatile int64_t *)&(pMeta->slot_bump_addr));
		b -= slot_bytes; // Atomic op returned addr of *next* free block. Subtract to get addr for *this* allocation.
    //由 slot_bump_addr 起分配出一个内存块 (同时 slot_bump_addr 前进)
		if (b < theLimit) {   // Did we stay within the bound of the present slot allocation?
			return (void *)b; // Yep, so the slot_bump_addr this thread incremented is good to go
		} else {
      //当前 slot 没有任何空闲的 bands
			if (pMeta->slot_exhausted) { // exhausted all the bands availble for this slot?
				pMeta->slot_bump_addr = theLimit;
				return 0;				 // We're toast
			} else {
				// One thread will grow the heap, others will see its been grown and retry allocation
				_malloc_lock_lock(&nanozone->band_resupply_lock[mag_index]);
				// re-check state now that we've taken the lock
				if (pMeta->slot_exhausted) {
					_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
					return 0; // Toast
				} else if (b < pMeta->slot_limit_addr) {
					_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
					continue; // ... the slot was successfully grown by first-taker (not us). Now try again.
				} else if (segregated_band_grow(nanozone, pMeta, slot_bytes, mag_index)) {
					_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
					continue; // ... the slot has been successfully grown by us. Now try again.
				} else {
					pMeta->slot_exhausted = TRUE;
					pMeta->slot_bump_addr = theLimit;
					_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
					return 0;
				}
			}
		}
	}
}
复制代码

malloc 在首次调用时,初始化 default_zone,在 64 位情况下,会初始化 default_zonenano_zone,同时初始化一个 scalable_zone 作为 helper _zonenano_zone 负责 nano 大小的分配,scalable_zone 则负责 tinysmalllarge 内存的分配
每次 malloc 时,根据传入的 size 参数,优先交给 nano_zone 做分配处理,如果大小不在 nano 范围,则转交给 helper_zone 处理。

推荐阅读

由于涉及到的函数过多且篇幅问题,想进一步了解分配规则,建议阅读源码。为了更容易的理解源码,可以借助阿里云栖社区[3]的图表辅助理解

当你阅读源码时,需要厘清一些在概念来辅助

  • nano_zone 在正常情况下进行无锁分配

  • nano_zone OSX上 64 位情况下分配内存的地址空间范围是0x00006xxxxxxxxxxx

  • nano_zone将其地址空间划分为三个级别

    • Magazine: 每个物理 CPU 核(非 SMT 虚拟 CPU),拥有一个属于它的地址空间范围。可以对应测试1.0_malloc_initialize() 方法

      // max_magazines may already be set from a boot argument. Make sure that it
      	// is bounded by the number of CPUs.
      	if (max_magazines) {
      		max_magazines = MIN(max_magazines, logical_ncpus);
      	} else {
      		max_magazines = logical_ncpus;
      复制代码
    • Band:耗尽一个 Band,用下一个 Band。一个 Band 大小为 2 MB。

    • Slot:每个 Band 中 128K 大小的范围,每个 Band 都分为 16 个 Slot,分别对应于 16B、32B、…256B 大小,支持它们的内存分配

  • 分配时会从对应的空间级别获取一个空闲的资源用于分配

总结

当我们遇到 为什么系统给 NSObject 分配的内存大小为 16 字节,其根本原因是什么?

  • 对于 Objective-C 来说,当我们调用 alloc 方法时,其最终是通过(id)calloc(1, size) 进行内存分配的,其中 size 的最小值为16, 当需要分配的内存大小小于 16 时,runtime 中的 instanceSize 方法,会强制其大小不小于 16 字节;
  • 我们是通过 malloc.c 的中 calloc 方法进行赋值时,其最终分配方法是根据不同 zonecalloc 方法实现的。在 64 位(iOS/MacOS)设备上而默认是通过 nano_zone 进行分配的,无论何种大小的size,都会执行一遍 nano_calloc 方法,当需要分配的大小小于 256 字节时,会执行_nano_malloc_check_clear 方法,其中的segregated_size_to_fit 方法会保证最终分配的内存大小不小于 16 字节且大小是 16 的倍数
  • 一个值得相信的根本原因是由于在 64 机器上,系统的分配的最小的内存单位大小是 16 字节,但是并没有太多明确的证据证明

未解决的问题

  • 未证明 Swift 中的分配情况

  • 未证明 NanoV2 的实际情况

  • 并未详细论述当内存不足时或者空间不足时的系统优化方法,即内存释放、内存整理等以及地址空间三个级别的具体信息

  • 并未论述 Scalable_Zone 的分配情况——为说明 tiny、small、large 三种内存的分配情况

  • 为什么在.memgraph 文件并没有 nano 部分

参考资料

objc4:opensource.apple.com/source/objc…

libmalloc:opensource.apple.com/source/libm…

iOS 内存管理和 malloc 源码解读:developer.aliyun.com/article/306…

内存分配奥义·malloc in OS X:tinylab.org/memory-allo…


如果你有任何问题,请直接评论,如果文章有任何不对的地方,请随意表达。如果你愿意,可以通过分享这篇文章来让更多的人发现它。

感谢你阅读本文! ?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享