iOS内存管理底层原理

内存布局

了解程序内存布局请点击程序的内存布局以及栈、堆原理

内存管理方案

在学习内存管理之前先思考一下这几个问题:

1、对象的引用计数存放在什么地方?怎么读写的?
2、对象释放的时候怎么处理弱引用表、关联对象的?弱引用为什么可以在对象时自动置为nil?
3、什么是SideTable?它跟引用计数表和弱引用表是什么关系?
4、自动释放池是如何管理内存的?什么时候创建?什么时候释放对象?

MRC

MRC(Manual Reference Counting)翻译出来就是手动引用计数。在Xcode4之前,只能通过MRC机制管理内存,MRC要求开发人员手动管理内存,维护OC对象的引用计数。也就是说,在需要方手动调用retain、release等内存管理相关操作。

ARC

ARC(Automatic Reference Counting),翻译出来就是自动引用计数。这是相对于MRC的改进,本身内存管理还是通过引用计数机制的,只不过是不需要开发人员手动维护,程序在编译时期会在适当的地方自动插入相关的retain、release等代码,达到自动管理引用计数的目的。

Tagged Pointer小对象

Tagged Pointer计数是将一些小对象诸如NSString、NSNumber、NSDate等类型转成Tagged Pointer对象,它们的值直接存储在对象指针中。不需要开辟堆内存,也就是不需要malloc和free,也不需要引用计数retain、release等操作(点击了解更多关于Tagged Pointer小对象的内容)。

引用计数机制的底层原理

不管是MRC还是ARC,都是通过引用计数来管理内存的。那么什么是引用计数?它是如何通过引用计数来管理内存的呢?引用计数是计算机编程语言中的一种内存管理技术,是指将对象的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。在iOS中引用又分为强引用(strong)和弱引用(weak),强引用是引用计数会增加,弱引用则不会。iOS中常见的引用计数相关的操作有:

  • alloc对象创建时(老版本源码的alloc是不会初始化引用计数的,这里版本是objc4-818.2);
    retain(包括strong修饰的属性),引用计数加1;
    release,引用计数减1;
    autorelease 自动释放,对象指针会被添加到释放池中,在自动释放池drain时释放;
    retainCount,获取引用计数个数。

接下来通过源码对各个操作进行解析。

alloc时初始化引用计数

alloc是对象创建时会调用initIsa方法初始化isa(点击了解对象创建过程),初始化isa的时候会初始化引用计数为1(点击了解更多关于isa的信息):

inline void 
objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{ 
    ASSERT(!isTaggedPointer()); 
    
    const char *mangledName = cls->mangledName();
    if (strcmp("MyObject", mangledName) == 0) {
        if(!cls->isMetaClass()){//避免元类的影响
            printf("我来了 MyObject");//定位要调试的类
        }
    }
    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
        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;
}

初始化引用计数:

newisa.extra_rc = 1;
retain底层源码解析

对象的retain操作最终是通过方法rootRetain来实现的。rootRetain主要做了如下几件事情:
1、读取出isa指针信息(点击了解更多关于isa的信息)。

oldisa = LoadExclusive(&isa.bits);

2、判断对象是否有自己的默认实现的retain方法。

 if (variant == RRVariant::FastOrMsgSend) {
        // These checks are only meaningful for objc_retain()
        // They are here so that we avoid a re-load of the isa.
        if (slowpath(oldisa.getDecodedClass(false)->hasCustomRR())) {
            ClearExclusive(&isa.bits);
            if (oldisa.getDecodedClass(false)->canCallSwiftRR()) {
                return swiftRetain.load(memory_order_relaxed)((id)this);
            }
            return ((id(*)(objc_object *, SEL))objc_msgSend)(this, @selector(retain));
        }
    }

如果有的话就走自己方法。
3、判断是否是nonpointer对象,如果是nonpointer对象的话不需要引用计数管理,比如类对象等,直接return。点击了解更多关于nonpointer的信息

if (slowpath(!oldisa.nonpointer)) {
        // a Class is a Class forever, so we can perform this check once
        // outside of the CAS loop
        if (oldisa.getDecodedClass(false)->isMetaClass()) {
            ClearExclusive(&isa.bits);
            return (id)this;
        }
    }

4、判断对象是否正在释放,如果是正在释放就没必要retain了

 if (slowpath(newisa.isDeallocating())) {
            ClearExclusive(&isa.bits);
            if (sideTableLocked) {
                ASSERT(variant == RRVariant::Full);
                sidetable_unlock();
            }
            if (slowpath(tryRetain)) {
                return nil;
            } else {
                return (id)this;
            }
        }

5、对引用技术加1,因为nonpointer对象的引用计数是存在isa指针里的有限为(指针长度64位,引用计数extra_rc总共占8位,RC_ONE (1ULL<<45),从地45位开始写 )(点击了解isa更多信息),所以有可能出现溢出情况,如果溢出就读取出当前引用计数的一半(RC_HALF)存储到SideTable(后面有分析)。

uintptr_t carry;
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++

        if (slowpath(carry)) {
            // newisa.extra_rc++ overflowed
            if (variant != RRVariant::Full) {
                ClearExclusive(&isa.bits);
                return rootRetain_overflow(tryRetain);
            }
            // Leave half of the retain counts inline and 
            // prepare to copy the other half to the side table.
            if (!tryRetain && !sideTableLocked) sidetable_lock();
            sideTableLocked = true;
            transcribeToSideTable = true;
            newisa.extra_rc = RC_HALF;//更新isa中的extra_rc
            newisa.has_sidetable_rc = true;
        }

如果溢出,则RC_HALF存储到 SideTable:

 if (variant == RRVariant::Full) {
        if (slowpath(transcribeToSideTable)) {
            // Copy the other half of the retain counts to the side table.
            sidetable_addExtraRC_nolock(RC_HALF);
        }

        if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    }

增加完引用计数之后就返回了。

release源码解析

release最终是通过方法objc_object::rootRelease来实现引用计数操作的。rootRelease主要做了以下几件事情:
1、读取isa指针oldisa:

oldisa = LoadExclusive(&isa.bits);

2、判断是否有自定义的release方法,如果有走自己的方法,没有就往下:

if (variant == RRVariant::FastOrMsgSend) {
        // These checks are only meaningful for objc_release()
        // They are here so that we avoid a re-load of the isa.
        if (slowpath(oldisa.getDecodedClass(false)->hasCustomRR())) {
            ClearExclusive(&isa.bits);
            if (oldisa.getDecodedClass(false)->canCallSwiftRR()) {
                swiftRelease.load(memory_order_relaxed)((id)this);
                return true;
            }
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(release));
            return true;
        }
    }

3、判断是否是nonpointer,如果不是就返回,不需要release:

if (slowpath(!oldisa.nonpointer)) {
        // a Class is a Class forever, so we can perform this check once
        // outside of the CAS loop
        if (oldisa.getDecodedClass(false)->isMetaClass()) {
            ClearExclusive(&isa.bits);
            return false;
        }
    }

4、判断对象是否正在释放,如果是正在释放就退出循环跳到deallocate:

       if (slowpath(newisa.isDeallocating())) {
            ClearExclusive(&isa.bits);
            if (sideTableLocked) {
                ASSERT(variant == RRVariant::Full);
                sidetable_unlock();
            }
            return false;
        }

如果是正在释放则直接跳到deallocate:

 if (slowpath(newisa.isDeallocating()))
        goto deallocate;

5、引用计数extra_rc--,如果isa中的引用计数已经减为0了,则跳转到underflow:

uintptr_t carry;
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
        if (slowpath(carry)) {
            // don't ClearExclusive()
            goto underflow;
        }
 underflow:
    // newisa.extra_rc-- underflowed: borrow from side table or deallocate

    // abandon newisa to undo the decrement
    newisa = oldisa;

    if (slowpath(newisa.has_sidetable_rc)) {
        if (variant != RRVariant::Full) {
            ClearExclusive(&isa.bits);
            return rootRelease_underflow(performDealloc);
        }

        // Transfer retain count from side table to inline storage.

        if (!sideTableLocked) {
            ClearExclusive(&isa.bits);
            sidetable_lock();
            sideTableLocked = true;
            // Need to start over to avoid a race against 
            // the nonpointer -> raw pointer transition.
            oldisa = LoadExclusive(&isa.bits);
            goto retry;
        }

        // Try to remove some retain counts from the side table.        
        auto borrow = sidetable_subExtraRC_nolock(RC_HALF);

        bool emptySideTable = borrow.remaining == 0; // we'll clear the side table if no refcounts remain there

        if (borrow.borrowed > 0) {
            // Side table retain count decreased.
            // Try to add them to the inline count.
            bool didTransitionToDeallocating = false;
            newisa.extra_rc = borrow.borrowed - 1;  // redo the original decrement too
            newisa.has_sidetable_rc = !emptySideTable;

            bool stored = StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits);

            if (!stored && oldisa.nonpointer) {
                // Inline update failed. 
                // Try it again right now. This prevents livelock on LL/SC 
                // architectures where the side table access itself may have 
                // dropped the reservation.
                uintptr_t overflow;
                newisa.bits =
                    addc(oldisa.bits, RC_ONE * (borrow.borrowed-1), 0, &overflow);
                newisa.has_sidetable_rc = !emptySideTable;
                if (!overflow) {
                    stored = StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits);
                    if (stored) {
                        didTransitionToDeallocating = newisa.isDeallocating();
                    }
                }
            }

            if (!stored) {
                // Inline update failed.
                // Put the retains back in the side table.
                ClearExclusive(&isa.bits);
                sidetable_addExtraRC_nolock(borrow.borrowed);
                oldisa = LoadExclusive(&isa.bits);
                goto retry;
            }

            // Decrement successful after borrowing from side table.
            if (emptySideTable)
                sidetable_clearExtraRC_nolock();

            if (!didTransitionToDeallocating) {
                if (slowpath(sideTableLocked)) sidetable_unlock();
                return false;
            }
        }
        else {
            // Side table is empty after all. Fall-through to the dealloc path.
        }
    }

在underflow流程中判断之前是否有用于存储引用计数的SideTable,如果有,从里面读取引用计数,然后减1,然后重新把引用计数同步更新到isa指针(方便下次读取),清除SideTable(清理内存)。

6、如果引用计数为0就会调用dealloc

deallocate:
    // Really deallocate.

    ASSERT(newisa.isDeallocating());
    ASSERT(isa.isDeallocating());

    if (slowpath(sideTableLocked)) sidetable_unlock();

    __c11_atomic_thread_fence(__ATOMIC_ACQUIRE);

    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
dealloc源码解析

dealloc底层通过objc_object::rootDealloc()方法实现,其源码:

inline void
objc_object::rootDealloc()
{
    if (isTaggedPointer()) return;  // fixme necessary?

    if (fastpath(isa.nonpointer                     &&
                 !isa.weakly_referenced             &&
                 !isa.has_assoc                     &&
#if ISA_HAS_CXX_DTOR_BIT
                 !isa.has_cxx_dtor                  &&
#else
                 !isa.getClass(false)->hasCxxDtor() &&
#endif
                 !isa.has_sidetable_rc))
    {
        assert(!sidetable_present());
        free(this);
    } 
    else {
        object_dispose((id)this);
    }
}

1、这里我们看到它会判断当前对象是否有弱引用(weakly_referenced)、关联对象(has_assoc)、C++析构函数(has_cxx_dtor)、Sidetable(has_sidetable_rc),如果没有直接释放free(this);如果有,则调用object_dispose方法。object_dispose的流程如下:

object_dispose -> objc_destructInstance -> clearDeallocating -> clearDeallocating_slow- > free(obj);

2、objc_destructInstances首先判断有没有C++析构函数和关联对象,有C++析构函数就调用,有关联对象remove:

void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        bool cxx = obj->hasCxxDtor();
        bool assoc = obj->hasAssociatedObjects();

        // This order is important.
        if (cxx) object_cxxDestruct(obj);
        if (assoc) _object_remove_assocations(obj, /*deallocating*/true);
        obj->clearDeallocating();
    }

    return obj;
}

clearDeallocating方法中会判断有没有弱引用或者SideTable中是否有引用计数has_sidetable_rc,如果有则调用clearDeallocating_slow处理(因为如引用表和部分引用计数表是存储于SideTable中,所以它们被放在一个方法里处理):

inline void 
objc_object::clearDeallocating()
{
    if (slowpath(!isa.nonpointer)) {
        // Slow path for raw pointer isa.
        sidetable_clearDeallocating();
    }
    else if (slowpath(isa.weakly_referenced  ||  isa.has_sidetable_rc)) {
        // Slow path for non-pointer isa with weak refs and/or side table data.
        clearDeallocating_slow();
    }

    assert(!sidetable_present());
}
NEVER_INLINE void
objc_object::clearDeallocating_slow()
{
    ASSERT(isa.nonpointer  &&  (isa.weakly_referenced || isa.has_sidetable_rc));

    SideTable& table = SideTables()[this];
    table.lock();
    if (isa.weakly_referenced) {
        weak_clear_no_lock(&table.weak_table, (id)this);
    }
    if (isa.has_sidetable_rc) {
        table.refcnts.erase(this);
    }
    table.unlock();
}
retainCount源码解析

retainCount就是读取isa和Sidetable(如果有)中的引用计数的和:

inline uintptr_t 
objc_object::rootRetainCount()
{
    if (isTaggedPointer()) return (uintptr_t)this;

    sidetable_lock();
    isa_t bits = __c11_atomic_load((_Atomic uintptr_t *)&isa.bits, __ATOMIC_RELAXED);
    if (bits.nonpointer) {
        uintptr_t rc = bits.extra_rc;
        if (bits.has_sidetable_rc) {
            rc += sidetable_getExtraRC_nolock();
        }
        sidetable_unlock();
        return rc;
    }
    sidetable_unlock();
    return sidetable_retainCount();
}
SideTable表的作用

SideTable是个哈希表,适用于存储引用计数和弱引用表的,它的表结构如下:


SideTable.jpg

这里有个问题:引用计数为什么要使用SideTable? 因为引用计数开始是存储在isa指针中的,但是由于isa指针位数有限(64位),而分配给引用技术8位,如果引用计数溢出,那么就需要一个引用计数表来存储多余的部分,SideTable是个哈希表,方便增删改查。
那么这里又有一个问题:既然isa指针位数有限,为什么不直接使用SideTable呢?主要是为了节省内存和提高读写效率。首先isa存储指针的初衷就是为了节省内存、提高读写效率的,而且对于大部分对象来说8位(2^8个)用于存储引用计数也已经足够,只有少部分对象引用计数可能会超过,而如果每个对象都使用SideTable表的话会影响效率,还浪费内存。

弱引用

弱引用不会增加对象的引用计数,但是它会有一个表来记录引用变量,用于当对象释放的时候把这些变量指针置为nil,防止野指针。有前面可知弱引用表是存储在SideTable中的(weak_table_t weak_table),下面是weak_table_t的数据结构:

/**
 * The global weak references table. Stores object ids as keys,
 * and weak_entry_t structs as their values.
 */
struct weak_table_t {
    weak_entry_t *weak_entries;
    size_t    num_entries;
    uintptr_t mask;
    uintptr_t max_hash_displacement;
};

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;
    union {
        struct {
            weak_referrer_t *referrers;
            uintptr_t        out_of_line_ness : 2;
            uintptr_t        num_refs : PTR_MINUS_2;
            uintptr_t        mask;
            uintptr_t        max_hash_displacement;
        };
        struct {
            // out_of_line_ness field is low bits of inline_referrers[1]
            weak_referrer_t  inline_referrers[WEAK_INLINE_COUNT];
        };
    };

    bool out_of_line() {
        return (out_of_line_ness == REFERRERS_OUT_OF_LINE);
    }

    weak_entry_t& operator=(const weak_entry_t& other) {
        memcpy(this, &other, sizeof(other));
        return *this;
    }

    weak_entry_t(objc_object *newReferent, objc_object **newReferrer)
        : referent(newReferent)
    {
        inline_referrers[0] = newReferrer;
        for (int i = 1; i < WEAK_INLINE_COUNT; i++) {
            inline_referrers[i] = nil;
        }
    }
};

在对象释放的时候会把关联的弱引用表中的变量指针一个个删除,并把指针指向nil:

/** 
 * Called by dealloc; nils out all weak pointers that point to the 
 * provided object so that they can no longer be used.
 * 
 * @param weak_table 
 * @param referent The object being deallocated. 
 */
void 
weak_clear_no_lock(weak_table_t *weak_table, id referent_id) 
{
    objc_object *referent = (objc_object *)referent_id;

    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) {
        /// XXX shouldn't happen, but does with mismatched CF/objc
        //printf("XXX no entry for clear deallocating %p\n", referent);
        return;
    }

    // zero out references
    weak_referrer_t *referrers;
    size_t count;
    
    if (entry->out_of_line()) {
        referrers = entry->referrers;
        count = TABLE_SIZE(entry);
    } 
    else {
        referrers = entry->inline_referrers;
        count = WEAK_INLINE_COUNT;
    }
    
    for (size_t i = 0; i < count; ++i) {
        objc_object **referrer = referrers[i];
        if (referrer) {
            if (*referrer == referent) {
                *referrer = nil;
            }
            else if (*referrer) {
                _objc_inform("__weak variable at %p holds %p instead of %p. "
                             "This is probably incorrect use of "
                             "objc_storeWeak() and objc_loadWeak(). "
                             "Break on objc_weak_error to debug.\n", 
                             referrer, (void*)*referrer, (void*)referent);
                objc_weak_error();
            }
        }
    }
    
    weak_entry_remove(weak_table, entry);
}

自动释放池

关于自动释放池直接参考iOS自动释放池的底层原理

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,082评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,231评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,047评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,977评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,893评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,014评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,976评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,605评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,888评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,906评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,732评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,513评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,980评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,027评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,232评论 2 339

推荐阅读更多精彩内容

  • 从这篇文章开始探索iOS的内存管理,主要涉及的内容有1. 内存布局;2. 内存管理方案:Tagged Pointe...
    风紧扯呼阅读 1,520评论 1 16
  • 一、在 Obj-C 中,如何检测内存泄漏?你知道哪些方式? 目前我知道的方式有以下几种 Memory Leaks ...
    maskerII阅读 311评论 0 0
  • 一. 引用计数 1. 引用计数存储在哪 我们都知道,调用retain会让OC对象的引用计数+1,调用release...
    Imkata阅读 1,396评论 5 2
  • 本文中的源代码来源:需要下载Runtime的源码,官方的工程需要经过大量调试才能使用。这里有处理好的objc4-7...
    shen888阅读 828评论 0 3
  • 1、内存布局 stack:方法调用 heap:通过alloc等分配对象 bss:未初始化的全局变量等。 data:...
    AKyS佐毅阅读 1,584评论 0 19