iOS中常見 Crash 及解決方案

發表於2016-09-11

一、訪問了一個已經被釋放的物件

在不使用 ARC 的時候,記憶體要自己管理,這時重複或過早釋放都有可能導致 Crash。

例子

原因

aObj 這個物件已經被釋放,但是指標沒有置空,這時訪問這個指標指向的記憶體就會 Crash。

解決辦法

  • 使用前要判斷非空,釋放後要置空。正確的釋放應該是:

    由於ObjC的特性,呼叫 nil 指標的任何方法相當於無作用,所以即使有人在使用這個指標時沒有判斷至少還不會掛掉。

    在ObjC裡面,一切基於 NSObject 的物件都使用指標來進行呼叫,所以在無法保證該指標一定有值的情況下,要先判斷指標非空再進行呼叫。

    常見的如判斷一個字串是否為空:

  • 適當使用 autorelease。有些時候不能知道自己建立的物件什麼時候要進行釋放,可以使用 autoRelease,但是不鼓勵使用。因為 autoRelease 的物件要等到最近的一個 autoReleasePool 銷燬的時候才會銷燬,如果自己知道什麼時候會用完這個物件,當然立即釋放效率要更高。如果一定要用 autoRelease 來建立大量物件或者大資料物件,最好自己顯式地建立一個 autoReleasePool,在使用後手動銷燬。以前要自己手動初始化 autoReleasePool,現在可以用以下寫法:

二、訪問陣列類物件越界或插入了空物件

NSMutableArray/NSMutableDictionary/NSMutableSet 等類下標越界,或者 insert 了一個 nil 物件。

原因

一個固定陣列有一塊連續記憶體,陣列指標指向記憶體首地址,靠下標來計算元素地址,如果下標越界則指標偏移出這塊記憶體,會訪問到野資料,ObjC 為了安全就直接讓程式 Crash 了。

而 nil 物件在陣列類的 init 方法裡面是表示陣列的結束,所以使用 addObject 方法來插入物件就會使程式掛掉。如果實在要在陣列裡面加入一個空物件,那就使用 NSNull

解決辦法

使用陣列時注意判斷下標是否越界,插入物件前先判斷該物件是否為空。

可以使用 Cocoa 的 Category 特性直接擴充套件 NSMutable 類的 Add/Insert 方法。比如:

這樣,以後在工程裡面使用 NSMutableArray 就可以直接使用 safeAddObject 方法來規避 Crash。

三、訪問了不存在的方法

ObjC 的方法呼叫跟 C++ 很不一樣。 C++ 在編譯的時候就已經繫結了類和方法,一個類不可能呼叫一個不存在的方法,否則就報編譯錯誤。而 ObjC 則是在 runtime 的時候才去查詢應該呼叫哪一個方法。

這兩種實現各有優劣,C++ 的繫結使得呼叫方法的時候速度很快,但是隻能通過 virtual 關鍵字來實現有限的動態繫結。而對 ObjC 來說,事實上他的實現是一種訊息傳遞而不是方法呼叫。

這樣的語句應該理解為,像 aObj 物件傳送一個叫做 aMethod 的訊息,aObj 物件接收到這個訊息之後,自己去查詢是否能呼叫對應的方法,找不到則上父類找,再找不到就 Crash。由於 ObjC 的這種特性,使得其訊息不單可以實現方法呼叫,還能緊繫轉發,對一個 obj 傳遞一個 selector 要求呼叫某方法,他可以直接不理會,轉發給別的 obj 讓別的 obj 來響應,非常靈活。

例子

呼叫一個不存在的方法,可以編譯通過,執行時直接掛掉,報 NSInvalidArgumentException 異常:

解決方案

像這種型別的錯誤通常出現在使用 delegate 的時候,因為 delegate 通常是一個 id 泛型,所以 IDE 也不會報警告,所以這種時候要用 respondsToSelector 方法先判斷一下,然後再進行呼叫。

四、位元組對齊

可能由於強制型別轉換或者強制寫記憶體等操作,CPU 執行 STMIA 指令時發現寫入的記憶體地址不是自然邊界,就會硬體報錯掛掉。iPhone 5s 的 CPU 從32位變成64位,有可能會出現一些位元組對齊的問題導致 Crash 率升高的。

例子

像上面這段程式碼,執行到

這句的時候,報了 EXC_BAD_ACCESS(code=EXC_ARM_DA_ALIGN) 錯誤。

原因

要了解位元組對齊錯誤還需要一點點背景知識,知道的童鞋可以略過直接看後面了。


背景知識

計算機最小資料單位是bit(位),也就是0或1。

而記憶體空間最小單元是byte(位元組),一個byte為8個bit。

記憶體地址空間以byte劃分,所以理論上訪問記憶體地址可以從任意byte開始,但是事實上我們不是直接訪問硬體地址,而是通過作業系統的虛擬記憶體地址來訪問,虛擬記憶體地址是以字為單位的。一個32位機器的字長就是32位,所以32位機器一次訪問記憶體大小就是4個byte。再者為了效能考慮,資料結構(尤其是棧)應該儘可能地在自然邊界上對齊。原因在於,為了訪問未對齊的記憶體,處理器需要作兩次記憶體訪問;而對齊的記憶體訪問僅需要一次訪問。

舉一個栗子:

上面這個結構體,在32位機器上,char 長度為8位,佔一個byte,short 佔2個byte, int 4個byte。
如果記憶體地址從 0 開始,那麼理論上順序分配的地址應該是:

但是事實上編譯後,這些變數的地址是這樣的:

這就是 aChar1 和 aChar2 都被做了記憶體對齊優化,都變成 2 byte 了。


解決辦法

  • 使用 memcpy 來作記憶體拷貝,而不是直接對指標賦值。對上面的例子作修改就是:

    改用 memcpy 之後執行就不會有問題了,這是因為 memcpy 自己的實現就已經做了位元組對齊的優化了。我們來看glibc2.5中的memcpy的原始碼:

    分析這個函式,首先比較一下需要拷貝的記憶體塊大小,如果小於 OP_T_THRES (這裡定義為 16),則直接位元組拷貝就完了,如果大於這個值,視為大記憶體塊拷貝,採用優化演算法。

    OPSIZE 是 op_t 的長度,op_t 是字的型別,所以這裡 OPSIZE 是獲取當前平臺的字長。
    dstp 是記憶體地址,記憶體地址是按byte來算的,對記憶體地址 unsigned long 取負數再模 OPSIZE 得到需要對齊的那部分資料的長度,然後用位元組拷貝做記憶體對齊。取負數是因為要以dstp的地址作為起點來進行復制,如果直接取模那就變成0作為起點去做運算了。
    對 BYTE_COPY_FWD 這個巨集的原始碼有興趣的同學可以看看這篇:BYTE_COPY_FWD 原始碼解析(感謝 @raincai 同學提醒)

    這樣對齊了之後,再做大資料量部分的拷貝:

    看這個巨集的原始碼,儘可能多地作頁拷貝,剩下的大小會寫入len變數。

    PAGE_COPY_FWD 的巨集定義:

    頁拷貝剩餘部分,再做一下字拷貝:

    再再最後就是剩下的一點資料量了,直接位元組拷貝結束。memcpy 可以用來解決記憶體對齊問題,同時對於大資料量的記憶體拷貝,使用 memcpy 效率要高很多,就因為做了頁拷貝和字拷貝的優化。

  • 或者儘量避免這種記憶體不對齊的情況,像這個例子,只要把 +2 改成 +4,記憶體就對齊了。當然具體還得看邏輯實現的需要。

References

ARM Hacking: EXC_ARM_DA_ALIGN exception

GlibC 2.18 memcpy source code

五、堆疊溢位

一般情況下應用程式是不需要考慮堆和棧的大小的,總是當作足夠大來使用就能滿足一般業務開發。但是事實上堆和棧都不是無上限的,過多的遞迴會導致棧溢位,過多的 alloc 變數會導致堆溢位。

例子

不得不說 Cocoa 的記憶體管理優化做得挺好的,單純用 C++ 在 Mac 下編譯後執行以下程式碼,遞迴 174671 次後掛掉:

而在 iOS 上執行以下程式碼則怎麼也不會掛,連 memory warning 都沒有:

而且如果 malloc 的大小改成比 1024 大的如 10240,其記憶體佔用的增長要遠慢於 1024。這大概要歸功於 Cocoa 的 Flyweight 設計模式,不過暫時還沒能真的理解到其優化原理,猜測可能是雖然記憶體空間申請了但是一直沒用到,針對這種迴圈 alloc 的場景,做了記錄,等到用到記憶體空間了才真正給出空間。

原理

iOS 記憶體佈局如下圖所示:

wkiojlgv36absxg6aaa81umwszg349

在應用程式分配的記憶體空間裡面,最低地址位是固定的程式碼段和資料段,往上是堆,用來存放全域性變數,對於 ObjC 來說,就是 alloc 出來的變數,都會放進這裡,堆不夠用的時候就會往上申請空間。最頂部高地址位是棧,區域性的基本型別變數都會放進棧裡。 ObjC 的物件都是以指標進行操控的,區域性變數的指標都在棧裡,全域性的變數在堆裡,而無論是什麼指標,alloc 出來的都在堆裡,所以 alloc 出來的變數一定要記得 release。

對於 autorelease 變數來說,每個函式有一個對應的 autorelease pool,函式出棧的時候 pool 被銷燬,同時呼叫這個 pool 裡面變數的 dealloc 函式來實現其內部 alloc 出來的變數的釋放。

六、多執行緒併發操作

這個應該是全平臺都會遇到的問題了。當某個物件會被多個執行緒修改的時候,有可能一個執行緒訪問這個物件的時候另一個執行緒已經把它刪掉了,導致 Crash。比較常見的是在網路任務佇列裡面,主執行緒往佇列裡面加入任務,網路執行緒同時進行刪除操作導致掛掉。

例子

這個真要寫比較完整的併發操作的例子就有點複雜了。

解決方法

  • 加鎖
    • NSLock普通的鎖,加鎖的時候 lock,解鎖呼叫 unlock。

      可以使用標記符 @synchronized 簡化程式碼:
    • NSRecursiveLock 遞迴鎖使用普通的 NSLock 如果在遞迴的情況下或者重複加鎖的情況下,自己跟自己搶資源導致死鎖。Cocoa 提供了 NSRecursiveLock 鎖可以多次加鎖而不會死鎖,只要 unlock 次數跟 lock 次數一樣就行了。
    • NSConditionLock 條件鎖多數情況下鎖是不需要關心什麼條件下 unlock 的,要用的時候鎖上,用完了就 unlock 就完了。Cocoa 提供這種條件鎖,可以在滿足某種條件下才解鎖。這個鎖的 lock 和 unlock, lockWhenCondition 是隨意組合的,可以不用對應起來。
    • NSDistributedLock 分散式鎖這是用在多程式之間共享資源的鎖,對 iOS 來說暫時沒用處。
  • 無鎖
    放棄加鎖,採用原子操作,編寫無鎖佇列解決多執行緒同步的問題。酷殼有篇介紹無鎖佇列的文章可以參考一下:無鎖佇列的實現
  • 使用其他備選方案代替多執行緒:Operation Objects, GCD, Idle-time notifications, Asynchronous functions, Timers, Separate processes。

References

Threading Programming Guide

七、Repeating NSTimer

如果一個 Timer 是不停 repeat,那麼釋放之前就應該先 invalidate。非repeat的timer在fired的時候會自動呼叫invalidate,但是repeat的不會。這時如果釋放了timer,而timer其實還會回撥,回撥的時候找不到物件就會掛掉。

原因

NSTimer 是通過 RunLoop 來實現定時呼叫的,當你建立一個 Timer 的時候,RunLoop 會持有這個 Timer 的強引用,如果你建立了一個 repeating timer,在下一次回撥前就把這個 timer release了,那麼 runloop 回撥的時候就會找不到物件而 Crash。

解決方案

我寫了個巨集用來釋放Timer

相關文章