2019-07-10
一、概述
上一篇文章 从Runtime源代码解读内存管理机制——Retain/Release,学习了 Objective-C 的引用计数内存管理的实现机制,本文继续探究另一种内存管理方式 Autoreleasing,也就是通过自动释放池 autorelease pool 进行内存管理。以下摘自 Runtime 源代码中名为 Autorelease pool implementation 的注释:
A thread's autorelease pool is a stack of pointers.
Each pointer is either an object to release, or POOL_SENTINEL which is
an autorelease pool boundary.
A pool token is a pointer to the POOL_SENTINEL for that pool. When
the pool is popped, every object hotter than the sentinel is released.
The stack is divided into a doubly-linked list of pages. Pages are added
and deleted as necessary.
Thread-local storage points to the hot page, where newly autoreleased
objects are stored.
其表达的要点如下:
- 线程的 autorelease pool 的实质是指针的堆栈,autorelease pool 是与线程关联的;
- Autorelease pool 中的指针要么指向需要release的对象,要么是
POOL_SENTINEL
,POOL_SENTINEL
是 autorelease pool 的边界; - Autorelease pool 的
token
是指向 autorelease pool 自身的POOL_SENTINEL
的指针。当autorelease pool释放时,会释放所有比token
hotter更“热”的对象; - Autorelease pool 中,指针的堆栈被划分到 分页中,分页使用双向链表的数据结构关联,分页可按需添加或删除;
- 线程本地存储指向hot page“热”分页,“热”分页保存最新的 autorelease 的对象。
接下来第二章,会从 Runtime 源代码中,找到以上要点的实现原理。
二、源码分析
首先以每个 iOS app 工程都会包含main.m
文件作为突破口,其源码如下:
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
逻辑很简单:1、新建 autorelease pool;2、调用UIApplicationMain(...)
函数新建一个UIApplication
实例并将代理设置为AppDelegate
,开始运行。这是能找到的关于autorelease 的最简短的代码。
2.1 @autoreleasepool块原理
打开命令行cd
到main.m
文件所在目录,使用clang -rewrite-objc main.m
将main.m
转化为 C/C++ 语言。这里可能会抛如下的错误。
没关系,这里只关心@autoreleasepool
的实现,可以把不关心的代码悉数删掉,只留下:
int main(int argc, char * argv[])
{
@autoreleasepool {
}
}
再重新执行clang
命令,成功后会在main.m
所在目录下生成一个main.cpp
文件,摘出 autorelease pool 相关代码:
struct __AtAutoreleasePool {
__AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
void * atautoreleasepoolobj;
};
int main(int argc, char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
}
}
其逻辑:
- 在C语言的作用域
{ }
内声明一个__AtAutoreleasePool
结构体__autoreleasepool
会自动触发__AtAutoreleasePool()
构建方法; - 超出作用域会释放变量占用的内存空间,即自动触发
~__AtAutoreleasePool()
方法。
最后,进一步剔除结构体的代码,@autoreleasepool{ }
块实际等价于:
{
void* token = objc_autoreleasePoolPush();
objc_autoreleasePoolPop(token);
}
此处的
token
实际上就是第一章第3个要点所提到的 token。
再看objc_autoreleasePoolPush()
函数以及objc_autoreleasePoolPop(...)
函数的源代码:
void * objc_autoreleasePoolPush(void)
{
if (UseGC) return nil;
return AutoreleasePoolPage::push();
}
void objc_autoreleasePoolPop(void *ctxt)
{
if (UseGC) return;
AutoreleasePoolPage::pop(ctxt);
}
忽略UseGC
判断逻辑,两者只是分别简单调用了AutoreleasePoolPage
的push()
和pop(...)
静态方法。至此,定位到实现 autorelease pool 的关键数据结构AutoreleasePoolPage
。
2.2 AutoreleasePoolPage数据结构
AutoreleasePoolPage
的实质是 双向链表节点。
AutoreleasePoolPage
的类图如下(忽略用于校验的magic
、hiwat
成员变量)。AutoreleasePoolPage
的parent
成员指向当前节点的上一个节点,若parent
为null
则表示该节点为双向链表的开始节点;child
成员指向当前节点的下一个节点;depth
成员表示当前节点的深度,满足depth = parent->depth + 1
,可以视为节点在双向链表中的索引;next
成员指向AutoreleasePoolPage
中下一个可分配的地址。
从类图的成员变量中,似乎找不到用于存储 autorelease object 的成员变量。那AutoreleasePoolPage
是如何保存autorelease object的呢?AutoreleasePoolPage
重载了new
运算符,指定构建AutoreleasePoolPage
实例分配定长的4096字节内存空间。
/** 来自系统架构头文件 */
#define I386_PGBYTES 4096
#define PAGE_SIZE I386_PGBYTES
#define PAGE_MAX_SIZE PAGE_SIZE
/** 来自AutoreleasePoolPage */
static size_t const SIZE = PAGE_MAX_SIZE;
static void * operator new(size_t size) {
return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);
}
为什么构建只占用56个字节的AutoreleasePoolPage
实例却分配了4096字节的空间呢?因为除开保存AutoreleasePoolPage
实例的sizeof(this)
长度的空间,其余空间(其中包括用于保存POOL_SENTINEL
的8个字节)均用于 以堆栈后入先出的方式 保存 autorelease object。
不妨将这段空间称为AutoreleasePoolPage
实例的堆栈空间。以下是一个堆栈空间为空的AutoreleasePoolPage
占用内存的示例:
接下来的章节开始介绍AutoreleasePoolPage
是如何操作其堆栈空间的。
2.2.1 AutoreleasePoolPage的堆栈空间
AutoreleasePoolPage
定义了以下实例方法 查询其堆栈空间的属性及状态。
id * begin() {
return (id *) ((uint8_t *)this+sizeof(*this));
}
id * end() {
return (id *) ((uint8_t *)this+SIZE);
}
bool empty() {
return next == begin();
}
bool full() {
return next == end();
}
bool lessThanHalfFull() {
return (next - begin() < (end() - begin()) / 2);
}
-
begin()
返回指向堆栈空间的初始地址的指针; -
end()
返回指向堆栈空间的结束地址; -
empty()
返回堆栈空间是否为空; -
full()
返回堆栈空间是否已满; -
lessThanHalfFull()
返回堆栈空间是否分配过半。
AutoreleasePoolPage
的关键指针的指向如下图所示:
2.2.2 AutoreleasePoolPage基本操作(Private实例方法)
正式分析本节代码之前,需要先弄清楚 hotPage 、coldPage 的概念。hotPage 保存 autorelease pool 当前所分配到的AutoreleasePoolPage
;相应地, coldPage 是从 hotPage 开始沿parent
指针链回溯找到的第一个分配的AutoreleasePoolPage
。
源代码如下:
static pthread_key_t const key = AUTORELEASE_POOL_KEY;
static inline AutoreleasePoolPage *hotPage()
{
AutoreleasePoolPage *result = (AutoreleasePoolPage *) tls_get_direct(key);
return result;
}
static inline void setHotPage(AutoreleasePoolPage *page)
{
tls_set_direct(key, (void *)page);
}
static inline AutoreleasePoolPage *coldPage()
{
AutoreleasePoolPage *result = hotPage();
if (result) {
while (result->parent) {
result = result->parent;
}
}
return result;
}
static inline void *tls_get_direct(tls_key_t k)
{
assert(is_valid_direct_key(k));
if (_pthread_has_direct_tsd()) {
return _pthread_getspecific_direct(k);
} else {
return pthread_getspecific(k);
}
}
static inline void tls_set_direct(tls_key_t k, void *value)
{
assert(is_valid_direct_key(k));
if (_pthread_has_direct_tsd()) {
_pthread_setspecific_direct(k, value);
} else {
pthread_setspecific(k, value);
}
}
其中,tls_get_direct()
、tls_set_direct()
函数分别通过pthread_getspecific()
、pthread_setspecific()
函数,使用Key-Value方式访问线程的私有空间。显然 hotPage 是与线程关联的,在不同的线程上调用AutoreleasePoolPage
类的hotPage()
静态方法返回的是不同的AutoreleasePoolPage
实例。代码中的key
是AutoreleasePoolPage
的一个const
静态变量。
2.2.2.1 添加对象
id *add(id obj)
实例方法用于向当前AutoreleasePoolPage
节点的堆栈空间添加 autorelease object。具体过程是:
- 将
obj
写入next
指向的内存地址; -
next
递增(由于next
指针占8个字节,因此next
递增实质是所指向的地址增加8);
id *add(id obj)
{
assert(!full());
id *ret = next; // faster than `return next-1` because of aliasing
*next++ = obj;
return ret;
}
注意:
assert(!full())
断言仅在堆栈空间未满的情况下才能调用add(...)
。
2.2.2.2移除对象
void releaseUntil(id *stop)
实例方法用于释放AutoreleasePoolPage
中,比*stop
更晚推入堆栈空间的所有 autorelease object。步骤如下:
- 在
this->next
到达stop
之前,进行以下迭代; - 找到 hotPage,若 hotPage为空,则沿
parent
指针一直回溯找到第一个非空的AutoreleasePoolPage
,并将其设为 hotPage; -
next
指针递减,obj
指向next
的内容,重置page->next
内存地址中的内容为0xA3A3A3A3
; - 若
obj != POOL_SENTINEL
,则调用objc_release(obj)
释放obj
; - 完成以上迭代后,将当前
AutoreleasePoolPage
设置为 hotPage。
static uint8_t const SCRIBBLE = 0xA3; // 0xA3A3A3A3 after releasing
void releaseUntil(id *stop)
{
while (this->next != stop) {
AutoreleasePoolPage *page = hotPage();
while (page->empty()) {
page = page->parent;
setHotPage(page);
}
id obj = *--page->next;
memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
if (obj != POOL_SENTINEL) {
objc_release(obj);
}
}
setHotPage(this);
#if DEBUG
for (AutoreleasePoolPage *page = child; page; page = page->child) {
assert(page->empty());
}
#endif
}
注意:
#if DEBUG
块中表示,调用releaseUtil()
后,stop
指针所在的AutoreleasePoolPage
的child
链上的所有节点都应该为空。
void releaseAll()
删除堆栈空间中的所有对象。
void releaseAll()
{
releaseUntil(begin());
}
void kill()
从双向链表中移除当前AutoreleasePoolPage
节点后的所有节点,包括节点本身。注意kill()
不包含释放对象的操作,只是简单移除节点。
void kill()
{
AutoreleasePoolPage *page = this;
while (page->child) page = page->child;
AutoreleasePoolPage *deathptr;
do {
deathptr = page;
page = page->parent;
if (page) {
page->child = nil;
}
delete deathptr;
} while (deathptr != this);
}
2.2.3 AutoreleasePoolPage基本操作(Private类方法)
id *autoreleaseNewPage(id obj)
方法仅在DebugPoolAllocation
调试配置项打开时才会使用,忽略。
2.2.3.1 向填满的 page 添加对象
autoreleaseFullPage(id obj, AutoreleasePoolPage *page)
向已填满的page
添加对象obj
。其逻辑:
- 顺着
page
的child
指针链循环,找到第一个未填满的AutoreleasePoolPage
节点赋值给page
;若最后一个AutoreleasePoolPage
也是满的,则新建一个AutoreleasePoolPage
赋值给page
,接到双向链表末尾。退出循环; - 将
page
其设置为hotPage,将obj
推入page
的堆栈空间。
static __attribute__((noinline))
id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page)
{
assert(page == hotPage());
assert(page->full() || DebugPoolAllocation);
do {
if (page->child) page = page->child;
else page = new AutoreleasePoolPage(page);
} while (page->full());
setHotPage(page);
return page->add(obj);
}
/** 新建以传入参数newParent为父节点的AutoreleasePoolPage节点 */
AutoreleasePoolPage(AutoreleasePoolPage *newParent)
: magic(), next(begin()), thread(pthread_self()),
parent(newParent), child(nil),
depth(parent ? 1+parent->depth : 0),
hiwat(parent ? parent->hiwat : 0)
{
if (parent) {
parent->check();
assert(!parent->child);
parent->unprotect();
parent->child = this;
parent->protect();
}
protect();
}
2.2.3.2 向空 autorelease pool 添加对象
autoreleaseNoPage(id obj)
向空 autorelease pool 添加autorelease object,空AutoreleasePool
的判断标准是 hotPage 为nil
。其逻辑:
- 创建一个
parent
为nil
的AutoreleasePoolPage
实例page
,并设置为hotPage; - 若
obj != POOL_SENTINEL
,则添加POOL_SENTINEL
到page
; - 添加
obj
到page
;
static __attribute__((noinline))
id *autoreleaseNoPage(id obj)
{
assert(!hotPage());
AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
setHotPage(page);
if (obj != POOL_SENTINEL) {
page->add(POOL_SENTINEL);
}
return page->add(obj);
}
至此可对POOL_SENTINEL
有第一步认识:AutoreleasePoolPage
节点组成的 双向链表的 开始节点 的堆栈空间 的首地址中,必然保存POOL_SENTINEL
。
2.2.3.3 向 autorelease pool 添加对象的通用方法
static inline id *autoreleaseFast(id obj)
方法用于将obj
添加到autorelease pool,即找到合适的AutoreleasePoolPage
(实际上就是 hotPage)并调用其add()
方法将obj
添加到该分页。其逻辑:
- 若 hotPage 存在且未填满,则将
obj
直接添加到 hotPage; - 若 hotPage 存在且已填满,则调用
autoreleaseFullPage(obj, page)
; - 若 hotPage 不存在,则调用
autoreleaseNoPage(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);
}
}
2.2.4 AutoreleasePoolPage基本操作(Public类方法)
该节介绍 autorelease pool 暴露给外部的实现 autorelease object 管理的公有类方法。
2.2.4.1 新建 autorelease pool
调用AutoreleasePoolPage::push()
新建 autorelease pool。其实现只是简单调用了dest = autoreleaseFast(POOL_SENTINEL)
并返回dest
。dest
实际指向 autorelease pool 的首个AutoreleasePoolPage
节点的 堆栈空间的 起始地址,必定是个POOL_SENTINEL
。
至此可对POOL_SENTINEL
有第二步认识:autorelease pool 的堆栈空间必然以POOL_SENTINEL
为起始。
static inline void *push()
{
id *dest;
dest = autoreleaseFast(POOL_SENTINEL);
assert(*dest == POOL_SENTINEL);
return dest;
}
2.2.4.2 释放 autorelease pool
调用AutoreleasePoolPage::pop(void *token)
释放 autorelease pool。
首先需要了解pageForPointer(const void *token)
方法。该方法是用来获取token
指针所在分页,其实现是offset = token % SIZE
获取token
的偏移量,然后result = (AutoreleasePoolPage *)(token - offset)
获得所在分页的地址。为什么可以这么计算呢?2.2节中提到的AutoreleasePoolPage
类的new
运算符重载是关键:malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE)
指定了分配内存时按SIZE
对齐,也就是说AutoreleasePoolPage
实例的内存首地址一定是4096的整数倍。
实现代码稍微较长,删除不关心的逻辑得到以下代码。
static inline void pop(void *token)
{
AutoreleasePoolPage *page;
id *stop;
page = pageForPointer(token);
stop = (id *)token;
if (*stop != POOL_SENTINEL) {
_objc_fatal("invalid or prematurely-freed autorelease pool %p; ",
token);
}
page->releaseUntil(stop);
if (page->child) {
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
}
static AutoreleasePoolPage *pageForPointer(const void *p)
{
return pageForPointer((uintptr_t)p);
}
static AutoreleasePoolPage *pageForPointer(uintptr_t p)
{
AutoreleasePoolPage *result;
uintptr_t offset = p % SIZE;
assert(offset >= sizeof(AutoreleasePoolPage));
result = (AutoreleasePoolPage *)(p - offset);
return result;
}
pop(void *token)
方法的处理逻辑如下:
-
token
记为stop
,stop命名更能表达传入参数的在方法内部的角色,表示 autorelease pool 释放对象到stop
终止; - 找到
stop
所在的AutoreleasePoolPage
赋值给page
; - 限定
stop
必须为POOL_SENTINEL
,否则抛出异常; -
page->releaseUntil(stop)
从 autorelease pool 的堆栈空间弹出对象,直到stop
地址为止; - 若
page->child
为非空则需要调用kill()
方法清理 autorelease pool 中不必要的空节点:判断若page
填满未过半,则删掉page
的child
链上的所有节点;若page
填满过半且page->child->child
为非空,则保留page->child
节点而删掉page->child
的child
链上的所有节点。
采用上述最后一点的处理策略是为了清理掉无用的AutoreleasePoolPage
占用空间的同时,又保留一定的缓冲空间,以避免刚释放完AutoreleasePoolPage
又不得不马上新建的情况。
2.2.4.3 添加对象到 autorelease pool
使用id autorelease(id obj)
类方法添加 autorelease object 到 autorelease pool,只是简单调用了autoreleaseFast(obj)
私有类方法。
static inline id autorelease(id obj)
{
assert(obj);
assert(!obj->isTaggedPointer());
id *dest __unused = autoreleaseFast(obj);
assert(!dest || *dest == obj);
return obj;
}
从AutoreleasePoolPage
暴露的三个主要接口可以看出,autorelease pool 对 autorelease object 的操作,遵循 逐个添加、批量释放的原则。
2.2.5 Autorelease Pool 与线程
前文提及 hotPage 和 codePage 的实现,线程中私有空间中保存了 hotPage 的地址,因此在不同的线程上调用AutoreleasePoolPage
类的hotPage()
静态方法时,返回的是不同的AutoreleasePoolPage
实例。AutoreleasePoolPage
双向链表中的所有节点的堆栈空间,实际是统一的整体,它是一条线程上创建的所有 autorelease pool 的堆栈。
线程与AutoreleasePoolPage
的关系如下图所示。假设App使用了三条线程主线程Thread_Main、后台线程Thread_A及Thread_B,其中红色箭头表示线程与AutoreleasePoolPage
之间的关联,线程通过私有空间中的 Key-Value 映射可以获取到该线程的 hotPage,AutoreleasePoolPage
通过thread
指针可获取其关联线程;蓝色箭头表示AutoreleasePoolPage
之间使用双向链表通过parent
、child
指针关联;
Autorelease pool 与 RunLoop 也有非常紧密的关系。App 启动后再主线程 RunLoop 会注册两个 Observer,第一个 Observer 监听 Entry 事件,其回调会调用objc_autoreleasePoolPush()
函数创建自动释放池;第二个Observer监听两个事件,监听到BeforeWaiting(即将进入休眠)时调用objc_autoreleasePoolPop()
函数释放旧的 autorelease pool 并调用objc_autoreleasePoolPush()
函数建立新的 autorelease pool ;监听到 Exit 事件时,调用objc_autoreleasePoolPop(void *ctxt)
函数释放 autorelease pool (顺便一提:调用pop
传入的ctxt
参数实际上是调用push
新建 autorelease pool 时返回的POOL_SENTINEL
的地址)。
2.2.6 理解 POOL_SENTINEL
Autorelease pool 的本质是AutoreleasePoolPage
双向链表中的某段堆栈空间。双向链表上的 autorelease pool 之间通过POOL_SENTINEL
分隔。POOL_SENTINEL
是全面理解 autorelease pool 实现的关键。
POOL_SENTINEL
的定义其实非常简单:
#define POOL_SENTINEL nil
首先autorelease(id obj)
方法中assert(obj)
断言限定 添加到 autorelease pool 中的对象不能为空,但这只是限定了对象添加到 autorelease pool 当时不能为空;其次在添加后,堆栈空间中的对象引用也不可能变为空,因为堆栈空间中已分配的存储单元(8个字节空间)存储的是指向对象的指针,实质为对象的内存地址,无论对象是否已释放,在 autorelease pool 释放之前,该指针的值始终会是该内存地址。因此,autorelease pool 的已分配堆栈空间中,除了POOL_SENTINEL
外不可能存在其他nil
指针。
如果使用weak指针呢?如以下代码,释放obj
,weakRef
指针自动置nil
后,autorelease pool 堆栈空间中对应的指针是否也被置nil
呢?
id obj = [[NSObject alloc] init]; //引用计数:1
__weak id weakRef = obj;
[weakRef autorelease]; //引用计数:1;weakRef:NSObject
[obj release]; // 引用计数:0;weakRef:nil
obj = nil;
用两张图表示上述5句代码执行过程中,内存中到底发生了什么:
第1、2、3句代码.jpg 第4、5句代码.jpg- 第1句:见图一绿色文字,在内存栈中分配8个字节保存
obj
指针,在内存堆中分配连续空间保存实例化的NSObject
的实例,假设实例地址为0x60ACC008800
,置obj
指向0x60ACC008800
; - 第2句:见图一蓝色文字,在内存栈中分配8个字节保存
weakRef
指针(忽略weak指针实现、引用计数实现等机制 使用到的其他内存),置weakRef
指向0x60ACC008800
; - 第3句:见图一红色文字,将
obj
指针指向的对象的引用添加到 autorelease pool,实质上是将对象的内存地址0x60ACC008800
推入 autorelease pool 堆栈空间的栈顶; - 第4、5句:见图二黄色文字,执行完成后,内存堆中保存对象的内存被释放,内存栈中的
weakRef
弱指针被自动置nil
,obj
被代码手动置nil
,其值均变为0x0
,然而 autorelease pool 堆栈空间中原本指向对象的指针则成为 野指针。
因此,上述代码不仅不能达到目的,且运行以上代码程序会崩溃。原因是:对象release
操作后,obj
对象被释放,堆栈空间中指向obj
的指针就变成了野指针,autorelease pool 释放对象调用指针指向对象的release
方法时必然抛EXC_BAD_ACCESS
错误。
三、总结
总结本文要点如下:
-
Autorelease 是将内存堆中的对象统一交由 autorelease pool 管理的一种内存管理方式。
NSObject
对象调用autorelease
方法时,对象被添加到 autorelease pool 中(内部逻辑不调用retain
方法因此refCount
不会递增)。在合适的时候,如@autoreleasepool
块结尾、或者调用了NSAutoreleasePool
的drain
方法、或者 autorelease pool 所在线程的 Runloop 的 Observer 观察到 Runloop 的 BeforeWaiting 或 Exit 通知等,系统将调用objc_autoreleasePoolPop()
函数释放其中所有的autorelease object(内部逻辑有调用objc_release
函数因此refCount
会递减); -
Autorelease体现在 Cocoa框架的工厂方法使用中(创建对象会将其自动添加到当前线程的默认 autorelease pool 中)是一种内存的 延迟释放机制,如
[UIImage imageNamed:@"xxx"]
,在短时间内需频繁创建占用内存较大的对象的场景中,需要慎用这些工厂方法;体现在@autoreleasepool
块的使用中,则是一种内存的 提前释放机制,对上述场景则可使用@autoreleasepool
块提前释放内存; -
Autorelease pool 的本质是
AutoreleasePoolPage
双向链表中的某段堆栈空间。双向链表上的 autorelease pool 之间通过POOL_SENTINEL
分隔; -
线程的私有空间中保存了
AutoreleasePoolPage
双向链表的 hotPage 的地址。一条AutoreleasePoolPage
双向链表与一条线程关联; -
新建 autorelease pool 需要记录返回
POOL_SENTINEL
的地址;释放 autorelease pool 时,以该地址为token
释放其对应的 autorelease pool 中的所有对象; -
NSObject
对象调用autorelease
方法时,将对象的内存地址推入 autorelease pool 的堆栈空间,autorelease pool 的已分配堆栈空间中,除了POOL_SENTINEL
外不可能存在其他nil
指针;
参考文章:
[1] 内存管理总结-autoreleasePool
[2] 深入理解RunLoop
[3] opensource-apple/objc4
网友评论