iOS 進階—— iOS 內存管理 & Block

2014招聘 7年前發布 | 10K 次閱讀 iOS開發 移動開發 Objective-C

第一篇 iOS 內存管理

1 似乎每個人在學習 iOS 過程中都考慮過的問題

  1. alloc retain release delloc 做了什么?
  2. autoreleasepool 是怎樣實現的?
  3. __unsafe_unretained 是什么?
  4. Block 是怎樣實現的
  5. 什么時候會引起循環引用,什么時候不會引起循環引用?

所以我將在本篇博文中詳細的從 ARC 解釋到 iOS 的內存管理,以及 Block 相關的原理、源碼。

2 從 ARC 說起

說 iOS 的內存管理,就不得不從 ARC(Automatic Reference Counting / 自動引用計數) 說起, ARC 是 WWDC2011 和 iOS5 引入的變化。ARC 是 LLVM 3.0 編譯器的特性,用來自動管理內存。

與 Java 中 GC 不同,ARC 是編譯器特性,而不是基于運行時的,所以 ARC 其實是在編譯階段自動幫開發者插入了管理內存的代碼,而不是實時監控與回收內存。

ARC 的內存管理規則可以簡述為:

  1. 每個對象都有一個『被引用計數』
  2. 對象被持有,『被引用計數』+1
  3. 對象被放棄持有,『被引用計數』-1
  4. 『引用計數』=0,釋放對象

3 你需要知道

  1. 包含 NSObject 類的 Foundation 框架并沒有公開
  2. Core Foundation 框架源代碼,以及通過 NSObject 進行內存管理的部分源代碼是公開的。
  3. GNUstep 是 Foundation 框架的互換框架

GNUstep 也是 GNU 計劃之一。將 Cocoa Objective-C 軟件庫以自由軟件方式重新實現

某種意義上,GNUstep 和 Foundation 框架的實現是相似的

通過 GNUstep 的源碼來分析 Foundation 的內存管理

4 alloc retain release dealloc 的實現

4.1 GNU – alloc

查看 GNUStep 中的 alloc 函數。

GNUstep/modules/core/base/Source/NSObject.m alloc:

+ (id) alloc
{
return [selfallocWithZone: NSDefaultMallocZone()];
}
 
+ (id)allocWithZone: (NSZone*)z
{
return NSAllocateObject (self, 0, z);
}

GNUstep/modules/core/base/Source/NSObject.m NSAllocateObject:

struct obj_layout {
NSUInteger retained;
};
 
NSAllocateObject(Class aClass, NSUInteger extraBytes, NSZone *zone)
{
int size = 計算容納對象所需內存大小;
id new = NSZoneCalloc(zone, 1, size);
memset (new, 0, size);
new = (id)&((obj)new)[1];
}

NSAllocateObject 函數通過調用 NSZoneCalloc 函數來分配存放對象所需的空間,之后將該內存空間置為 nil,最后返回作為對象而使用的指針。

我們將上面的代碼做簡化整理:

GNUstep/modules/core/base/Source/NSObject.m alloc 簡化版本:

struct obj_layout {
NSUInteger retained;
};
 
+ (id) alloc
{
int size = sizeof(struct obj_layout) + 對象大小;
struct obj_layout*p = (struct obj_layout*)calloc(1, size);
return (id)(p+1)
return [selfallocWithZone: NSDefaultMallocZone()];
}

alloc 類方法用 struct obj_layout 中的 retained 整數來保存引用計數,并將其寫入對象的內存頭部,該對象內存塊全部置為 0 后返回。

一個對象的表示便如下圖:

4.2 GNU – retain

GNUstep/modules/core/base/Source/NSObject.m retainCount:

- (NSUInteger) retainCount
{
return NSExtraRefCount(self) + 1;
}
 
inline NSUInteger
NSExtraRefCount(id anObject)
{
return ((obj_layout)anObject)[-1].retained;
}

GNUstep/modules/core/base/Source/NSObject.m retain:

- (id) retain
{
NSIncrementExtraRefCount(self);
return self;
}
 
inline void
NSIncrementExtraRefCount(id anObject)
{
if (((obj)anObject)[-1].retained == UINT_MAX - 1)
[NSExceptionraise: NSInternalInconsistencyException
format: @"NSIncrementExtraRefCount() askedtoincrementtoofar”];
((obj_layout)anObject)[-1].retained++;
}

以上代碼中, NSIncrementExtraRefCount 方法首先寫入了當 retained 變量超出最大值時發生異常的代碼(因為 retained 是 NSUInteger 變量),然后進行 retain ++ 代碼。

4.3 GNU – release

和 retain 相應的,release 方法做的就是 retain -- 。

GNUstep/modules/core/base/Source/NSObject.m release

- (oneway void) release
{
if (NSDecrementExtraRefCountWasZero(self))
{
[self dealloc];
}
}
 
BOOL
NSDecrementExtraRefCountWasZero(id anObject)
{
if (((obj)anObject)[-1].retained == 0)
{
return YES;
}
((obj)anObject)[-1].retained--;
return NO;
}

4.4 GNU – dealloc

dealloc 將會對對象進行釋放。

GNUstep/modules/core/base/Source/NSObject.m dealloc:

- (void) dealloc
{
NSDeallocateObject (self);
}
 
inline void
NSDeallocateObject(id anObject)
{
obj_layout o = &((obj_layout)anObject)[-1];
free(o);
}

4.5 Apple 實現

在 Xcode 中 設置 Debug -> Debug Workflow -> Always Show Disassenbly 打開。這樣在打斷點后,可以看到更詳細的方法調用。

通過在 NSObject 類的 alloc 等方法上設置斷點追蹤可以看到幾個方法內部分別調用了:

retainCount

__CFdoExternRefOperation

CFBasicHashGetCountOfKey

retain

__CFdoExternRefOperation

CFBasicHashAddValue

release

__CFdoExternRefOperation

CFBasicHashRemoveValue

可以看到他們都調用了一個共同的 __CFdoExternRefOperation 方法。

該方法從前綴可以看到是包含在 Core Foundation,在 CFRuntime.c 中可以找到,做簡化后列出源碼:

CFRuntime.c __CFDoExternRefOperation:

int __CFDoExternRefOperation(uintptr_top, id obj) {
CFBasicHashRef table = 取得對象的散列表(obj);
int count;
 
switch (op) {
caseOPERATION_retainCount:
count = CFBasicHashGetCountOfKey(table, obj);
return count;
break;
caseOPERATION_retain:
count = CFBasicHashAddValue(table, obj);
return obj;
caseOPERATION_release:
count = CFBasicHashRemoveValue(table, obj);
return 0 == count;
}
}

所以 __CFDoExternRefOperation 是針對不同的操作,進行具體的方法調用,如果 op 是 OPERATION_retain ,就去掉用具體實現 retain 的方法。

從 BasicHash 這樣的方法名可以看出,其實引用計數表就是散列表。

key 為 hash(對象的地址) value 為 引用計數。

下圖是 Apple 和 GNU 的實現對比:

5 autorelease 和 autorelaesepool

在蘋果對于 NSAutoreleasePool 的 文檔 中表示:

每個線程(包括主線程),都維護了一個管理 NSAutoreleasePool 的棧。當創先新的 Pool 時,他們會被添加到棧頂。當 Pool 被銷毀時,他們會被從棧中移除。

autorelease 的對象會被添加到當前線程的棧頂的 Pool 中。當 Pool 被銷毀,其中的對象也會被釋放。

當線程結束時,所有的 Pool 被銷毀釋放。

對 NSAutoreleasePool 類方法和 autorelease 方法打斷點,查看其運行過程,可以看到調用了以下函數:

NSAutoreleasePool *pool = [[NSAutoreleasePool alloc]init];
// 等同于 objc_autoreleasePoolPush
 
id obj = [[NSObject alloc]init];
[objautorelease];
// 等同于 objc_autorelease(obj)
 
[NSAutoreleasePool showPools];
// 查看 NSAutoreleasePool 狀況
 
[pooldrain];
// 等同于 objc_autoreleasePoolPop(pool)

[NSAutoreleasePool showPools] 可以看到當前線程所有 pool 的情況:

objc[21536]: ##############
objc[21536]: AUTORELEASEPOOLSfor thread 0x10011e3c0
objc[21536]: 2 releasespending.
objc[21536]: [0x101802000] ................ PAGE (hot) (cold)
objc[21536]: [0x101802038] ################ POOL 0x101802038
objc[21536]: [0x101802040] 0x1003062e0 NSObject
objc[21536]: ##############
Programendedwithexitcode: 0

objc4 中可以查看到 AutoreleasePoolPage:

objc4/NSObject.mmAutoreleasePoolPage
 
class AutoreleasePoolPage
{
static inlinevoid *push()
{
生成或者持有 NSAutoreleasePool 類對象
}
static inlinevoid pop(void *token)
{
廢棄 NSAutoreleasePool 類對象
releaseAll();
}
static inlineidautorelease(idobj)
{
相當于 NSAutoreleasePool 類的 addObject 類方法
AutoreleasePoolPage *page = 取得正在使用的 AutoreleasePoolPage 實例;
}
id *add(idobj)
{
將對象追加到內部數組
}
void releaseAll()
{
調用內部數組中對象的 release 方法
}
};
 
void *
objc_autoreleasePoolPush(void)
{
if (UseGC) return nil;
return AutoreleasePoolPage::push();
}
 
void
objc_autoreleasePoolPop(void *ctxt)
{
if (UseGC) return;
AutoreleasePoolPage::pop(ctxt);
}

AutoreleasePoolPage 以雙向鏈表的形式組合而成(分別對應結構中的 parent 指針和 child 指針)。

thread 指針指向當前線程。

每個 AutoreleasePoolPage 對象會開辟4096字節內存(也就是虛擬內存一頁的大小),除了上面的實例變量所占空間,剩下的空間全部用來儲存autorelease對象的地址。

next 指針指向下一個 add 進來的 autorelease 的對象即將存放的位置。

一個 Page 的空間被占滿時,會新建一個 AutoreleasePoolPage 對象,連接鏈表。

6 __unsafe_unretained

有時候我們除了 __weak 和 __strong 之外也會用到 __unsafe_unretained 這個修飾符,那么我們對 __unsafe_unretained 了解多少?

__unsafe_unretained 是不安全的所有權修飾符,盡管 ARC 的內存管理是編譯器的工作,但附有 __unsafe_unretained 修飾符的變量不屬于編譯器的內存管理對象。 賦值時即不獲得強引用也不獲得弱引用

來運行一段代碼:

id __unsafe_unretainedobj1 = nil;
{
id __strongobj0 = [[NSObject alloc]init];
 
obj1 = obj0;
 
NSLog(@"A: %@", obj1);
}
 
NSLog(@"B: %@", obj1);

運行結果:

2017-01-12 19:24:47.245220 __unsafe_unretained[55726:4408416] A:
2017-01-12 19:24:47.246670 __unsafe_unretained[55726:4408416] B:
Programendedwithexitcode: 0

對代碼進行詳細分析:

id __unsafe_unretainedobj1 = nil;
{
// 自己生成并持有對象
id __strongobj0 = [[NSObject alloc]init];
 
// 因為 obj0 變量為強引用,
// 所以自己持有對象
obj1 = obj0;
 
// 雖然 obj0 變量賦值給 obj1
// 但是 obj1 變量既不持有對象的強引用,也不持有對象的弱引用
NSLog(@"A: %@", obj1);
// 輸出 obj1 變量所表示的對象
}
 
NSLog(@"B: %@", obj1);
// 輸出 obj1 變量所表示的對象
// obj1 變量表示的對象已經被廢棄
// 所以此時獲得的是懸垂指針
// 錯誤訪問

所以,最后的 NSLog 只是碰巧正常運行,如果錯誤訪問,會造成 crash

在使用 __unsafe_unretained 修飾符時,賦值給附有 __strong 修飾符變量時,要確保對象確實存在

第二篇 Block

花幾分鐘時間看下面三個小題目,寫下你的答案。

這個三個小題目,我在整理此片博文之前給了三位朋友去解答,最后的結果,除了一位朋友 3 題全部正確,其他兩個朋友均只答中 1 題。

說明還是有很多 iOS 的朋友對于 Block 并沒有透徹理解。本篇博文會對 Block 進行詳細的解說。

1 Block 使用的簡單規則

先了解簡單規則,再去分析原理和實現:

Block 中,Block 表達式截獲 所使用的自動變量的值,即保存該自動變量的 瞬間值

修飾為 __block 的變量,在捕獲時,獲取的 不再是瞬間值

至于 Why,后面將會繼續說。

2 Block 的實現

Block 是帶有自動變量(局部變量)的匿名函數。

Block 表達式很簡單,總體可以描述為:『 ^ 返回值類型 參數列表 表達式 』。

但是 Block 并不是 Objective-C 中才有的語法,這是怎么一回事?

clang 編譯器提供給程序員了解 Objective-C 背后機制的方法,通過 clang 的轉換可以看到 Block 的實現原理。

通過 clang -rewrite-objc yourfile.m clang 將會把 Objective-C 的代碼轉換成 C 語言的代碼。

2.1 Block 基本實現剖析

用 Xcode 創建 Command Line 項目,寫如下代碼:

int main(int argc, const char * argv[]) {
void (^blk)(void) = ^{NSLog(@"Block")};
blk();
return 0;
}

用 clang 轉換:

以上是轉換后的代碼,不要方,一段一段看。

可以看到,Block 內部的內容, 被轉換成了一個普通的靜態函數 __main_func_0

再看其他部分:

main.cpp __block_impl:

struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};

__block_impl 結構體包括了一些標志、今后版本升級 預留的變量函數指針

main.cpp __main_block_desc_0:

static struct __main_block_desc_0 {
size_treserved;
size_tBlock_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

__main_block_desc_0 結構體包括了今后版本升級預留的變量、block 大小。

main.cpp __main_block_impl_0:

__main_block_impl_0 結構體含有兩個成員變量,分別是 __block_impl 和 __main_block_desc_0 實例變量。

此外,還含有一個構造方法。該構造方法在 main 函數中被如下調用:

main.cpp __main_block_impl_0 構造函數的調用:

void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,
&__main_block_desc_0_DATA));

去掉各種強制轉換,做簡化:

main.cpp __main_block_impl_0 構造函數的調用 簡化:

struct __main_block_impl_0tmp = __main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA);
struct __main_block_impl_0*blk = &tmp;

以上代碼即:將 __main_block_impl_0 結構體實例的指針,賦值給 __main_block_impl_0 結構體指針類型的變量 blk 。也就是我們最初的結構體定義:

void (^blk)(void) = ^{NSLog(@"Block");};

另外,main 函數中還有另外一段:

((void (*)(__block_impl*))((__block_impl*)blk)->FuncPtr)((__block_impl*)blk);

去掉各種轉換:

(*blk->impl.FuncPtr)(blk);

實際就是最初的:

blk();

2.2 Block 截獲外部變量瞬間值的實現剖析

2.1 中對最簡單的 無參數 Block 聲明、調用 進行了 clang 轉換。接下來再看一段『截獲自動變量』的代碼(可以使用命令 clang -rewrite-objc -fobjc-arc -fobjc-runtime=macosx-10.7 main.m ):

int main(int argc, const char * argv[]) {
 
int val = 10;
const char *fmt = "val = %d\n";
void (^blk)(void) = ^{printf(fmt, val);};
 
val = 2;
fmt = "These values were changed, val = %d\n";
 
blk();
 
return 0;
}

clang 轉換之后:

和 2.1 節中的轉換代碼對比,可以發現多了一些代碼。

首先, __main_block_impl_0 多了一個變量 val ,并在構造函數的參數中加入了 val 的賦值:

main.cpp __main_block_impl_0:

struct __main_block_impl_0 {
struct __block_implimpl;
struct __main_block_desc_0* Desc;
const char *fmt;
int val;
__main_block_impl_0(void *fp, struct __main_block_desc_0*desc, const char *_fmt, int _val, int flags=0) : fmt(_fmt), val(_val) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

而在 main 函數中,對 Block 的聲明變為此句:

main.cpp __main_block_impl_0 構造函數的調用:

void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, fmt, val));

去掉轉換:

main.cpp __main_block_impl_0 構造函數的調用 簡化:

struct __main_block_impl_0tmp = __main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA, val);
struct __main_block_impl_0*blk = &tmp;

_所以,在 Block 被聲明時,Block 已經將 val 作為 __main_block_impl_0 的內部變量保存下來了。無論在在聲明之后怎樣更改 val 的值,都不會影響,Block 調用時訪問的內部 val 值。這就是 Block 捕獲變量瞬間值的原理。_

本節所有代碼在 EX05 中

2.3 __block 變量的訪問實現剖析

我們知道,Block 中能夠讀取,但是不能更改一個局部變量,如果去更改,Xcode 會提示你無法在 Block 內部更改變量。

Block 內部只是對局部變量只讀,但是 Block 能讀寫以下幾種變量:

  1. 靜態變量
  2. 靜態全局變量
  3. 全局變量

也就是說以下代碼是沒有問題的:

int global_val = 1;
static int static_global_val = 2;
 
int main(int argc, const char * argv[]) {
static int static_val = 3;
 
void (^blk)(void) = ^ {
global_val = 1 * 2;
static_global_val = 2 * 2;
static_val = 3 * 2;
}
 
return 0;
}

如果想在 Block 內部寫局部變量,需要對訪問的局部變量增加 __block 修飾。

__block 修飾符其實類似于 C 語言中 static、auto、register 修飾符。用于指定將變量值設置到哪個存儲域中。

具體 __block 之后究竟做了哪些變化我們可以寫代碼測試:

EX07:

int main(int argc, const char * argv[]) {
 
__blockint val = 10;
void (^blk)(void) = ^{val = 1;};
 
return 0;
}

clang 轉換之后:

跟 2.2 對比,似乎又加了非常代碼。發現多了兩個結構體。

main.cpp __Block_byref_val_0:

struct __Block_byref_val_0 {
void *__isa;
__Block_byref_val_0*__forwarding;
int __flags;
int __size;
int val;
};

很驚奇的發現, block 類型的 val 變成了結構體 Block_byref_val_0 的實例。這個實例內,包含了 isa 指針、一個標志位 flags 、一個記錄大小的 size 。最最重要的,多了一個 forwarding 指針和 val變量。這是怎么回事?

在 main 函數部分,實例化了該結構體:

main.cpp main.m 部分:

__Block_byref_val_0val = {(void*)0,
(__Block_byref_val_0*)&val,
0,
sizeof(__Block_byref_val_0),
10};

我們可以看出該結構體對象初始化時:

  1. __forwarding 指向了結構體實例本身在內存中的地址
  2. val = 10

而在 main 函數中, val = 1 這句賦值語句變成了:

main.cpp val = 1; 對應的函數

(val->__forwarding->val) = 1;

這里就可以看出其精髓,val = 1,實際上更改的是 __Block_byref_val_0 結構體實例 val 中的 __forwarding 指針(也就是本身)指向的 val 變量。

而對 val 訪問也是如此。你可以理解為通過取地址改變變量的值,這和 C 語言中取地址改變變量類似。

所以,聲明 block 的變量可以被改變。至于 forwarding的其他巨大作用,會繼續分析。

本節代碼在 EX05

3 Block 的存儲域

Block 有三種類型,分別是:

  1. __NSConcreteStackBlock ————————棧中
  2. __NSConcreteGlobalBlock ————————數據區域中
  3. __NSConcreteMallocBlock ————————堆中

__NSConcreteGlobalBlock 出現的地方有:

  1. 設置全局變量的地方有 Block 語法時
  2. Block 語法的表達式中不使用任何外部變量時

設置在棧上的 Block,如果所屬的變量作用域結束,Block 就會被廢棄。如果其中用到了 block, block 所屬的變量作用域結束也會被廢棄。

為了解決這個問題,Block 在必要的時候就需要從棧中移到堆中。ARC 有效時,很多情況下,編譯器會幫助完成 Block 的 copy,但很多情況下,我們需要手動 copy Block。

對不同存儲域的 Block copy 時,影響如下:

copy 時,對訪問到的 __block 類型對象影響如下:

此時可以看出 __forwarding 的巨大作用——無論 Block 此時在堆中還是在棧中,由于 __forwarding 指向局部變量轉換成的結構體實例的真是地址,所以都能確保正確的訪問。

具體的來說:

  1. 當 block 變量被一個 Block 使用時,Block 從棧復制到堆, block 變量也會被復制到,并被該 Block 持有。
  2. 在 block 變量被多個 Block 使用時,在任何一個 Block 從棧復制到堆時, block 變量也會被復制到堆,并被該 Block 持有。但由于 __forwarding 指針的存在,無論 block 變量和 Block 在不在同一個存儲域,都可以正確的訪問 block 變量。
  3. 如果堆上的 Block 被廢棄,那么它所使用的 __block 變量也會被釋放。

前面說到編譯器會幫助完成一些 Block 的 copy,也有手動 copy Block。那么 Block 被復制到堆上的情況有(此段摘自于『Objective-C高級編程 iOS與OS X多線程和內存管理』):

  1. 調用 Block 的 copy 方法時
  2. Block 作為返回值時
  3. 將 Block 賦值給附有 __strong 修飾符的成員變量時(id類型或 Block 類型)時
  4. 在方法名中含有 usingBlock 的 Cocoa 框架方法或 GCD 的 API 中傳遞 Block 時

4 Block 循環引用

Block 循環引用,是在編程中非常常見的問題,甚至很多時候,我們并不知道發生了循環引用,直到我們突然某一天發現『怎么這個對象沒有調用 delloc』,才意識到有問題存在。

在『Block 存儲域』中也說明了 Block 在 copy 后對 __block 對象會 retain 一次。

那么對于如下情況就會發生循環引用:

block_retain_cycle:

@interfaceMyObject: NSObject
 
@property (nonatomic, copy) blk_tblk;
@property (nonatomic, strong) NSObject *obj;
 
@end
 
@implementation MyObject
 
- (instancetype)init {
self = [super init];
_blk = ^{NSLog(@"self = %@", self);};
return self;
}
 
- (void)dealloc {
NSLog(@"%@ dealloc", self.class);
}
 
@end
 
int main(int argc, const char * argv[]) {
id myobj = [[MyObject alloc]init];
NSLog(@"%@", myobj);
return 0;
}

由于 self -> blk,blk -> self,雙方都無法釋放。

但要注意的是,對于以下情況,同樣會發生循環引用:

block_retain_cycle
 
@interfaceMyObject: NSObject
 
@property (nonatomic, copy) blk_tblk;
 
// 下面是多加的一句
@property (nonatomic, strong) NSObject *obj;
 
@end
 
@implementation MyObject
 
- (instancetype)init {
self = [super init];
 
// 下面是多加的一句
_blk = ^{NSLog(@"self = %@", _obj);};
 
return self;
}
 
- (void)dealloc {
NSLog(@"%@ dealloc", self.class);
}
 
@end
 
int main(int argc, const char * argv[]) {
id myobj = [[MyObject alloc]init];
NSLog(@"%@", myobj);
return 0;
}

這是由于 self -> obj,self -> blk,blk -> obj。這種情況是非常容易被忽視的。

5 重審問題

我們再來看看最初的幾個小題目:

iOS 進階—— iOS 內存管理 & Block

  1. 第一題:

    由于 Block 捕獲瞬間值,所以輸出為 in block val = 0

  2. 第二題:

    由于 val 為 __block,外部更改會影響到內部訪問,所以輸出為 in block val = 1

  3. 第三題:

    和第二題類似, val = 1 能影響到 Block 內部訪問,所以先輸出 in block val = 1 ,之后在 Block 內部更改 val 值,再次訪問時輸出 after block val = 2 。

Other

我寫這篇文章是在我閱讀了『Objective-C高級編程 iOS與OS X多線程和內存管理』一書之后,博文中也有很內容源于『Objective-C高級編程 iOS與OS X多線程和內存管理』。

非常向大家推薦此書。這本書里記錄了關于 iOS 內存管理的深入內容。但要注意的是,此書中的多處知識點并不是很詳細,需要你以拓展的心態去學習。在有解釋不詳細的地方,自己主動去探索,去拓展,找更多的資料,最后,你會發現你對 iOS 內存管理有了更多的深入的理解。

 

 

來自:http://ios.jobbole.com/92903/

 

 本文由用戶 2014招聘 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!