由 Tagged Pointer 聯想到的一個問題
最近和基友 Maize 聊天,他給我普及了一個有意思的知識點,回看唐巧的 深入理解Tagged Pointer 的文章,再結合之前在公司看到的程式碼,突然有了一些靈感,我們先上一段程式碼。
@interface NSObject (AssociatedObject) @property (nonatomic, assign) CGFloat someProperty; @end @implementation NSObject (AssociatedObject) @dynamic someProperty; - (void)setSomeProperty:(CGFloat)someProperty{ return objc_setAssociatedObject(self, @selector(someProperty), @(someProperty), OBJC_ASSOCIATION_ASSIGN); } - (CGFloat)someProperty{ return [objc_getAssociatedObject(self, @selector(someProperty)) floatValue]; } @end 複製程式碼
如果此時我們給 someProperty 屬性賦值並列印該屬性的值,你認為它會 crash 麼? 我們先說一下結論,如果這個屬性的值為100,那麼它不會 crash,如果是 100.1 那麼就一定會 crash。 如果你馬上就明白了其中的原理,那麼恭喜你,你完全不用再花時間看此篇文章了。 如果你沒有意識到其中的問題,不妨花上 10 分鐘時間來仔細讀讀這篇文章的內容。
Tagged Pointer 是什麼?
本節內容是節選自唐巧的文章 - ofollow,noindex">深入理解Tagged Pointer
在 2013 年的 WWDC 上,Apple 推出了首個 64 位架構的雙核處理器,為了節省記憶體和提高執行效率,Tagged Pointer 概念誕生了。Apple 宣稱引入該技術後,相關邏輯能減少一半的記憶體佔用,以及 3 倍的訪問速度提升,100 倍的建立、銷燬速度提升。
為了能讓大家更好的理解上面程式碼的問題,我們需要了解 Tagged Pointer 的一些實現細節。
我們知道 NSNumber、NSDate 一類的變數本身的值需要佔用的記憶體大小常常不需要 8 個位元組,拿整數來說,4 個位元組所能表示的有符號整數就可以達到 20 多億(注:2^31=2147483648,另外 1 位作為符號位),對於絕大多數情況都是可以處理的。
所以 Apple 將一個物件的指標拆成兩部分,一部分直接儲存資料,另一部分作為特殊標記,表示這是一個特別的指標,不指向任何一個地址。所以,引入了 Tagged Pointer 物件之後,64 位 CPU 下 NSNumber 的記憶體圖變成了以下這樣:

int main(int argc, char * argv[]) { @autoreleasepool { NSNumber *number1 = @1; NSNumber *number2 = @2; NSNumber *number3 = @3; NSNumber *numberFFFF = @(0xFFFF); NSLog(@"number1 pointer is %p", number1); NSLog(@"number2 pointer is %p", number2); NSLog(@"number3 pointer is %p", number3); NSLog(@"numberffff pointer is %p", numberFFFF); return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } } 複製程式碼
執行之後,我們得到的結果如下,可以看到,除去最後的數字最末尾的 2 以及最開頭的 0xb,其它數字剛好表示了相應 NSNumber 的值。
number1 pointer is 0xb000000000000012 number2 pointer is 0xb000000000000022 number3 pointer is 0xb000000000000032 numberFFFF pointer is 0xb0000000000ffff2 複製程式碼
可見,蘋果確實是將值直接儲存到了指標本身裡面。我們還可以猜測,數字最末尾的 2 以及最開頭的 0xb 是否就是蘋果對於 Tagged Pointer 的特殊標記呢?我們嘗試放一個 8 位元組的長的整數到 NSNumber 例項中,對於這樣的例項,由於 Tagged Pointer 無法將其按上面的壓縮方式來儲存,那麼應該就會以普通物件的方式來儲存,我們的實驗程式碼如下:
NSNumber *bigNumber = @(0xEFFFFFFFFFFFFFFF); NSLog(@"bigNumber pointer is %p", bigNumber); 複製程式碼
執行之後,結果如下,驗證了我們的猜測,bigNumber 的地址更像是一個普通的指標地址,和它本身的值看不出任何關係:
bigNumber pointer is 0x10921ecc0 複製程式碼
Tagged Pointer 帶來的問題?
我們結合一下剛才的程式碼,試想如果我們有以下兩種使用場景,哪一種會有問題呢?
- 程式碼片段 1
// CodeSnippets 1 self.view.someProperty = 100; NSLog(@"%@", @(self.view.someProperty)); 複製程式碼
- 程式碼片段 2
//CodeSnippets 2 self.view.someProperty = 100.1; NSLog(@"%@", @(self.view.someProperty)); 複製程式碼
不管你是通過程式碼實驗的,還是已經想明白了,第二個程式碼片段是會造成 crash 的,而且 Xcode 會提示這是一個野指標方面的問題。
那麼結合剛才所說的 Tagged Pointer,我們怎麼解釋這個問題呢?
當賦值為 100 時,由於 Tagged Pointer 的優化, @(100)
生成的 NSNumber 物件並不是一個嚴格意義上的物件,所以系統不會在堆上開闢記憶體儲存物件,值是直接儲存在地址指標裡面,在取出的時候也就不會出現任何釋放的問題。
而當賦值為 100.1 時,由於 Tagged Pointer 對這種值沒法進行優化,@(100.1) 生成的 NSNumber 物件是一個真正意義上的物件,所以此時儲存下來的是一個地址指標,但由於在關聯的時候我們選用了 OBJC_ASSOCIATION_ASSIGN
,那麼此時系統並不會幫我們去進行那些計數引用等操作,所以當我們想再取出的時候,就會出問題了,也就是野指標的問題。
至此,應該很好的解釋了前言中那段程式碼的問題。
結論
那麼我們應該如何優化這段程式碼呢?
- 假設你真的想存一個 CGFloat,由於在存取過程中操作的是一個物件,不妨將
objc_AssociationPolicy
選項中的OBJC_ASSOCIATION_ASSIGN
換成OBJC_ASSOCIATION_RETAIN_NONATOMIC
。 - 如果可以的話,建議直接儲存一個 NSNumber 型別的物件來替換 CGFloat 型別的數值,畢竟這樣會更安全,記得同樣把關聯策略設定為
OBJC_ASSOCIATION_RETAIN_NONATOMIC
。
關於 CGFloat 還需要注意的一點是:在不同的 CPU 下,它的真實型別是不一樣的,有時是 float,有時是 double,那麼在從 NSNumber 做轉換的時候,到底要返回何種型別,仍然需要開發者做好判斷。
- 在 Apple 的 API 文件中,
OBJC_ASSOCIATION_ASSIGN
說是用於 weak 型別的引用,但並不是 ARC 範圍內的 weak,嚴格來說應該更類似於unsafe_unretained
的概念,如果真的要用OBJC_ASSOCIATION_ASSIGN
策略,請時候考慮好物件的生命週期,避免不必要的 crash 。
參考 NSHipster 的文章Associated Objects
- 如果你是宣告一個 ARC 範圍內的
weak
屬性,你可能需要一個類似弱引用表概念的東西,不妨閱讀下瓜神的這篇文章 weak 弱引用的實現方式