Objective-C 底層對象探究-中

「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!前端

目錄

1. 背景

學習不迷茫,無阻我飛揚!你們好我是Tommy!今天咱們繼續來對底層進行探索,本章內容會比較多,裏面的可能有些知識不太好理解,你們能夠分小節進行閱讀。廢話不說咱們這就開始!git

2. LLVM對alloc的優化

  • 再次分析 alloc 流程:
    • 經過上篇《Objective-C 底層對象研究-上》咱們已經對alloc的運行流程進行了梳理,但這裏存在一個問題不知道你們是否發現了?就是咱們經過符號斷點等方式發現,alloc最早是調用了objc_alloc方法後再開始走調用流程的;(動態分析)
    • 可是咱們經過源碼方式分析發現alloc調用的並非objc_alloc而是_objc_rootAlloc函數(靜態分析),這又是什麼緣由呢? index.gif
    • 咱們這裏不如大膽猜想一下,OC裏面的方法調用都離不開兩個東西SELIMPSEL就是方法標示,IMP就是指向方法具體實現的指針,就比如一本書的目錄同樣,你須要先查到目錄的條目以後再根據對應的頁碼找到具體內容。OC是動態語言SELIMP是能夠進行動態改變的,因此alloc是存在被改變可能性的。
  • 探索調用 objc_alloc 的緣由:
    • 通過咱們的分析咱們已經有了大體思路,那麼咱們就用過研究源碼來驗證咱們的分析是否正確。
    • 首先咱們先經過搜索objc_alloc看看是否有結果......
    圖片.png
    • 哈哈!發現搜索出來的內容仍是挺多的,可是不要怕,通過個人逐一排查我定位到了這裏(紅框處)。
    • 從這段代碼咱們就很明顯的發現了在runtimeallocIMP的的確確是被替換了,這個已經證實咱們分析的思路是正確的;
    圖片.png
    • 那麼咱們繼續看一下這個fixupMessageRef函數是在何時被調用的?繼續經過搜索來得出答案。
    圖片.png
    • 通過排查找到了fixupMessageRef函數是在_read_images這個函數中被調用的。
    • 再看_read_images方法上面的註釋:「對連接中的頭信息執行初始化處理」,應該能夠猜到_read_images方法可能與DYLD加載Mach-O文件有必定關係。咱們能夠給map_images_nolock下個符號斷點,爲啥呢?由於_read_images我測試了沒法斷住,根據方法上面的註釋得知是經過map_images_nolock這個函數調用的,因此果斷試了一下能夠斷住。
    圖片.png 圖片.png
    • 經過符號斷點驗證了咱們的想法的的確確是由dyld進行調用的。到此咱們能夠先作一個簡單的梳理:
  • 思路梳理:
    • 一、經過分析確認了alloc確實是在runtime的源碼中有被替換的跡象;
    • 二、經過fixupMessageRef這個方法名稱,咱們能夠理解程序在運行時,須要對alloc等一些方法進行修復處理;那咱們是否是能夠理解成:無論當前是否存在問題,alloc方法始終都會被改動調用objc_alloc
    • 三、fixupMessageRef方法是在_read_images中被調用的,而_read_images是在DYLD加載Mach-O文件時進行加載的;Mach-O文件中會存在一個叫作符號列表的內容,裏面就會將App的方法存放到此表中,當DYLD加載時就會讀取列表進行映射操做,而這個過程就叫作符號綁定(如今能夠先這麼簡單的理解)
    圖片.png
    • 五、經過以上分析咱們能夠得知,alloc方法在運行時會被進行檢測,若是檢測沒有問題它依然仍是調用objc_alloc,若是存在問題就經過fixupMessageRef方法進行修復處理,而處理結果依然是調用objc_alloc,這一點須要你們細品一下。 若是以上思路都明確以後,咱們應該會想到alloc方法在運行時作的只是修復工做,那麼其實真正對alloc方法進行修改的並非在運行時,實際上可能仍是在更底層進行修改的,而只是在runtime層增長了修復的邏輯,極可能是蘋果出於嚴謹性的考慮,在這一步額外增長的一層保護(多是爲了防止開發人員經過hook等方式對alloc方法進行修改吧!~)。
  • 在LLVM中探索緣由:
    • 想要探索LLVM咱們須要下載LLVM-project這裏是連接[LLVM-project下載],建議使用VSCode進行打開。
    • 下載完畢以後試試搜索objc_alloc看看有什麼結果,咱們點擊第一個結果就能發現這些線索;「當此方法返回true時,Clang將把某些選擇器的非超級消息發送轉換爲對相應入口點的調用」,經過這條註釋以及下面的alloc => objc_alloc例子咱們就能夠明白了,在編譯階段alloc就已經被進行了轉換設置。
    圖片.png
    • 咱們繼續搜索shouldUseRuntimeFunctionsForAlloc函數看看調用邏輯,發現是在tryGenerateSpecializedMessageSend函數中進行調用的。
    圖片.png
    • 再搜索tryGenerateSpecializedMessageSend函數查看調用邏輯,搜索後咱們來到了GeneratePossiblySpecializedMessageSend函數。
    圖片.png
    • 從代碼咱們能夠簡要的看出,當發送消息時會先判斷是否符合發送特殊消息的條件,若是符合就嘗試經過特殊方式發送,若是不知足就按正常流程發送消息。按照這個邏輯就能得出一個結論了:
  • 小結論:
    • 就是當alloc()第一次執行時,被LLVM按特殊消息發送來處理了,底層將目標轉換成了objc_alloc();objc_alloc執行後第一次調用了callAlloc();github

    • 首次進入callAlloc()後去執行objc_msgSend的方法,又再一次調用了alloc(),可是此次LLVM是按正常方式進行處理,發送給了_objc_rootAlloc();_objc_rootAlloc()執行後第二次調用了callAlloc();而後開始對內存進行對象內存的開闢工做直至完成。web

  • 再次梳理alloc流程:
    • 我在上篇《Objective-C 底層對象研究-上》中畫過一個alloc流程圖,在這幅圖中咱們當時發現callAlloc()被執行了2次,那麼咱們將咱們今天探索獲得的結果,添加到這幅流程圖中進行補完,你們能夠對比看一下就能瞭解callAlloc爲何會被調用了2次的真正緣由了。
    圖片.png 圖片.png
    • 接下來咱們能夠在深刻一點,查看一下底層是如何處理函數調用的,咱們能夠經過tryGenerateSpecializedMessageSend函數中對alloc方法處理爲例子,一步一步跟蹤,最終咱們走到了下面圖片所示的位置;經過上下傳參最終會經過Builder.CreateCall()Builder.CreateInvoke()進行函數的指令調用;
    圖片.png 圖片.png
    • 經過對底層LLVM的探索,咱們能夠發現蘋果對一些重要方法,尤爲是跟內存有關的方法都進行了相似HOOK方式的處理,這裏猜想應該是對這些方法進行了一些監測和監控處理。到此本小節結束。

三、對象內存大小的影響因素

  • 查看對象佔用內存的大小:objective-c

    • 咱們接下來探索一下對象在內存中的大小,每一個對象都是在執行alloc後都會開闢出內存空間;咱們來看一下ZXPerson的對象在內存中佔用了多少空間,咱們能夠經過class_getInstanceSize()方法打印大小,使用此方法時請導入 #import <objc/runtime.h>頭文件。編譯運行後顯示了佔用大小。

    圖片.png

  • 發現影響大小的因素:算法

    • 增長屬性和成員變量:咱們添加或者刪除一下屬性和成員變量能夠觀察到,對象的大小會有不一樣的不變化,增長時大小會增大,反之亦然;

    圖片.png

    • 添加方法:屬性和變量會影響大小改變,咱們也能夠試試添加方法是否也會改變大小?答案是並不會。

    圖片.png

    • 到此咱們能夠獲得一個結論:對象的內存大小是由成員變量決定的,跟其餘內容沒有關係
  • class_getInstanceSize()方法:後端

    • 咱們進入到objc源碼Command+shift+O搜索class_getInstanceSize直接就能夠定位到。

    圖片.png

    • 咱們一步一步定位到這裏給出了明確提示:May be unaligned depending on class's ivars.

    圖片.png

  • 沒有變量時打印爲何是8?:緩存

    • 當咱們將全部定義的成員變量刪除以後,經過class_getInstanceSize()方法打印結果是8,這也就說明咱們必定從父類中繼承過來了成員變量,咱們再經過源碼進行驗證。

    圖片.png

    • 咱們直接搜索父類NSObject,就會看到父類中存在一個變量叫作isa;那麼第一個疑問就解開了,確實從父類中繼承了變量過來;那麼大小爲何是8呢?咱們繼續分析。
    • 咱們發現這個isa的類型是Class,咱們跟蹤一下看看有什麼結果,Command+shift+O搜索Class,發現Class是一個類型定義,實際是objc_class類型的指針類型,而在arm64下一個指針正好是佔用8個字節。

    圖片.png 圖片.png

    • objc_class是一個結構體而且繼承objc_object,那麼咱們自定義的類在底層實際都變成了objc_object。咱們能夠經過clang命令對.m文件進行編譯。(個人實例程序都寫在了mian.m文件裏,因此我就編譯了main.m文件)
    clang -x objective-c -rewrite-objc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk main.m
    複製代碼
    • 編譯成C++文件咱們就能看到咱們定義的類在編譯以後都會變成objc_object結構體類型。 圖片.png

ps:這麼作的目的是蘋果爲了在底層對開發人員定義的類進行統一處理而進行了轉換,由於蘋果不可能在底層去逐一的去實現開發人員定義的類,這是不可能定義出來的,由於可變性太大了;因此爲了方便對類進行管理和操做,就必須設計一個通用的類型來替代。markdown

通源碼探究咱們發現Object-C的底層都是經過C/C++來實現的,因此OC中的對象也會轉化成C/C++中的某一個數據結構,到此本小結結束。數據結構

四、字節對齊

  • 經過上一節的研究,咱們得知Object-C的底層都是經過C/C++來實現的,因此OC中的對象也會轉化成C/C++中的某一個數據結構。
  • 咱們再次回到源碼_class_createInstanceFromZone()裏找到instanceSize(),經過上一篇的探索咱們已經得知了,該方法是負責返回對象所需的空間大小的;咱們跟蹤進去能夠看到優先從緩存中查找大小,若是緩存沒有就從新計算大小,最後還有一個判斷就是若是計算的大小不足16字節,就補足16字節圖片.png
  • alignedInstanceSize()方法中我看能夠看到底層系統將對象佔用的內存大小進行了字節對齊,我看經過word_align()瞭解具體對齊算法。 圖片.png
  • 算法解析:
    • WORD_MASK 的值是7UL,其實就是7;(UL的意思是 unsignedLong 無符號長整型);
    • 假如x=7;(7+7) & ~7 ;14 & ~7 ;0000 1110 & 1111 1000 = 0000 1000(8)
    • 假如x=9;(9+7) & ~7 ;16 & ~7 ;0001 0000 & 1111 1000 = 0001 0000(16)
    • 咱們能夠看到算法實際上是按8字節進行對齊,不足8就按8算,超過8就以8的倍數進行,例如9:就按8的2倍計算也就是16;若是是20就按8的3倍計算也就是24(你們能夠自行驗證)
    • (ps:~7 是意思是非7 就是按7的二進制取反)
  • 字節對齊原理:
    • 爲何要進行字節對齊?這是爲了提升CPU讀內的效率將內存統一按一個大小進行對齊處理,實際佔用的大小不足時,就經過補0方式對齊。這麼作雖然犧牲了必定的內存空間,可是讀取的效率會大幅提高,也就是用 「空間換時間」
    圖片.png
  • 思路梳理:
    • 咱們定義的類從NSObject裏集成了isa屬性佔用8字節;
    • 分析源碼instanceSize()得知對象內部結構是已8字節進行對齊,但系統是最小給分配了16字節;
    • 字節對齊算法:經過(x + WORD_MASK) & ~WORD_MASK方式進行計算;
    • 爲何要選擇以8字節對齊?這是由於在arm64下,8字節基本上就是最大的佔用字節數了。
    • 若是對象大小超過16字節會怎麼樣?其實在最後底層還會以16字節進行一次對齊處理,請看下一個小節內容結構體內存對齊

五、結構體內存對齊

  • 在上一篇咱們經過x/4gx 查看了類對象中在內存中的存放狀態,其中咱們發現了一個現象就是一個8字節的空間裏面存放了2個不一樣的數據,這種現象就叫作內存對齊而且作了相關優化處理。當咱們建立一個對象指針時,該指針實際指向的是一個結構體類型,那麼對於結構體來講內存大小這塊是否有什麼不同?下面就讓咱們來一塊兒探究一番。圖片.png
  • 結構體內存的三個原則:
    • 結構體內第一個成員以0爲起始位置,然後的成員起始位置要從成員的佔用大小或子成員的佔用大小的整數倍開始;
    • 若是內部成員是一個結構體,則結構體成員要從其內部最大元素佔用大小的整數倍地址開始存儲;
    • 構體的總大小,也就是sizeof的結果,必須是其內部最大成員的整數倍.不足的要補⻬;
  • 咱們能夠本身編寫2個結構體來進行驗證:
    • 內部成員聲明位置前後不一樣,獲得的大小不一樣;出現這樣的緣由就是根依據上面的三個原則而獲得的結果,咱們先來驗證一下非嵌套的結構體。
    圖片.png 圖片.png
  • 測試下帶嵌套的結構體,我新建一個ZXStruct3,而後將ZXStruct1聲明爲內部的一個成員。 圖片.png 圖片.png
    • 理解:
      • ZXStruct3 的第一個成員佔用到第 3 個字節位置,根據 原則2 應按照結構內部最大元素的大小的整數倍開始存儲,因此從 8 開始;而後再用 8 + zx_t1 大小,就能夠直接得出實際大小了也就是 8 + 24 = 32
      • 結論:先計算原結構體佔用大小,再根據原則2對齊,最後加上嵌套結構體就是最終的大小結果。
  • 爲什麼要對齊?帶來什麼好處?
    • 結合咱們上面介紹的字節對齊、和結構體對齊的知識,咱們就能夠猜到對齊的緣由就是爲了提高讀取效率,蘋果在內存讀取上作了優化處理,請看下面的例子你們就能有所感悟了。
    • 咱們仍是以ZXStruct1前三個成員爲例,將3個成員放大來觀察。
    圖片.png
    • 不採起對齊:
      • 若是不按成員大小進行對齊,就會安裝圖上所示的樣子進行排序,最後再進行補齊,可是讀取邏輯就發生變化了。
      • 首先8位讀取,p1能夠一次讀完,再次按8位讀取的時候就發現沒法正確讀取了,由於發現後8位包含了混合數據,因此須要根據成員大小調整步長讀取,共須要4次完成,這樣就會下降效率。
    • 採起對齊:
      • 按成員大小進行對齊後,首先按8位讀取,p1能夠一次讀完,這個沒有發生改變,後面讀取時判斷含有混合數據的話,按數據中最大的佔位進行讀取,而且將補位的空位進行合併,(反正最後都須要補位,不如將空位移動到前面一塊兒讀取來提升效率)因此讀取3次就能夠完成了。
  • 至此結構體內存對齊的相關知識介紹完畢,最後附上一個各個類型所佔用大小的列表圖。
    C OC 32位 64位
    bool BOOL(64位) 1 1
    signed char (_signed char)int8_t、BOOL(32位) 1 1
    unsigned char Boolean 1 1
    short int16_t 2 2
    unsigned short unichar 2 2
    int、int32_t NSInterger(32位)、boolean_t(32位) 4 4
    unsigned int NSUInterger(32位)、boolean_t(64位) 4 4
    long NSInterger(64位) 4 8
    unsigned long NSUInterger(64位) 4 8
    long long int64_t 8 8
    float CGFloat(32位) 4 4
    double CGFloat(64位) 8 8

六、malloc的分析探索

  • 首先咱們先來看一個現象,我對ZXPerson類的對象*zxp分別經過class_getInstanceSize()sizeof()malloc_size()3個函數進行打印輸出; 圖片.png 圖片.png

  • 此時咱們ZXPerson類中定義了4個屬性再加上隱藏屬性isa,一共是5個屬

    • class_getInstanceSize()打印了32, 這個沒有問題(8+8+8+4+1 最後按8字節對齊 = 32)
    • sizeof()打印了8,這個沒有問題(由於打印的是指針,指針的大小就是8佔字節)
    • malloc_size()打印了32,跟class_getInstanceSize()同樣,貌似也應該沒有問題;
  • 此時咱們ZXPerson類中新增一個屬性zxNikeName再來看看結果。 圖片.png 圖片.png

    • class_getInstanceSize()打印了40 沒毛病!(8+8+8+4+1+8 最後按8字節對齊正好 = 40)
    • sizeof()沒變化;
    • malloc_size()結果卻不一樣了變成了48,奇奇怪怪的事情就這樣神奇的發生了!那麼爲何呢?接下來咱們來一塊兒探索一下。
  • 首先咱們先經過追蹤下malloc_size(),從註釋「Returns size of given ptr」咱們得知malloc_size()函數會根據ptr來返回大小值,而ptr就是咱們傳入的指針。當咱們想繼續往下追蹤時發現已經沒法往下走了。那怎麼辦呢?首先不要慌!咱們肯定一下這個malloc_size()函數的所在位置是在哪裏,從上面的導航咱們能夠看到這個函數是在malloc這個庫下面。咱們就能夠再經過源碼方式來進行研究了(往後咱們探究的思路都是以這個方式來進行的)圖片.png

  • 在探索源碼前咱們還能夠去蘋果官網搜索這個函數的官方解釋 malloc_size 的蘋果官網解釋: 「返回ptr所指向的分配的內存塊的大小。內存塊的大小老是至少和它的分配同樣大,也可能會更大」,經過官方的解釋咱們就能理解咱們如今遇到的這個現象了吧,現象就是返回的大小可能跟實際分配的一致或更大。那麼接下來,咱們帶着這個問題來開始源碼的探索。 圖片.png

  • 下載libmalloc可編譯的源碼:下載libmalloc可編譯的源碼 圖片.png

  • 在上一篇文章中咱們已經對alloc的開闢流程進行了梳理,發現 alloc 申請內存是 calloc 發起的,因此咱們直接把斷點斷到calloc上。對於這塊不清楚的同窗請走傳送門 《Objective-C 底層對象研究-上》 圖片.png 圖片.png

  • 咱們將斷點斷在calloc上,來跟蹤內存開闢的機制,編譯-運行後咱們進入到了calloc裏,這只是一個封裝函數,繼續跟蹤_malloc_zone_calloc()圖片.png

  • 進來後咱們能夠觀察一下,根據上面的官方文檔的說明,咱們只需關注ptr就能夠了,那麼咱們就定位到了1560行。可是在想從1560行往下走就走不到了(不管是搜索關鍵字,符號斷點都沒法定位)。仔細觀察後發現是經過zone這個對象中calloc的方法返回的,這時咱們能夠經過LLDB命令 po zone->calloc 進行查看,返回的結果就是實際調用。 (這個zone->calloc其實能夠理解成是一個賦值語句,從這個zone->calloc中獲取到相關的函數去執行,當搜索 「=zone->calloc」關鍵字時,會有好多相似的語句,都是用於從獲取賦值的) 圖片.png 圖片.png

  • 咱們搜索default_zone_calloc()找到位置發現又調用了zone這個對象中calloc的方法,咱們繼續po它獲得結果。 圖片.png 圖片.png 圖片.png

  • 咱們再尋找nano_malloc.c文件的878行,根據分析咱們能夠分析出return p 是正確的路線,p是經過_nano_malloc_check_clear()函數返回的,咱們繼續就探索下去。 圖片.png

    • 進到_nano_malloc_check_clear()咱們能夠將複雜的方法簡單化處理下,先將不重要的判斷隱藏掉。

    圖片.png

  • 思路分析:

    • *ptr從堆區開闢空間,若是ptr沒有,就循環進行查找。segregated_next_block()函數你們能夠本身看一下,內部是一個while死循環,我這裏不作過多介紹;(額……這裏仍是囉嗦一下吧,這個函數的功能就是在堆區不斷的進行查找,找到合適的位置就分配存儲地址,由於堆存儲是否是按序的,數據之間存在不規則的空隙,因此須要不斷的循環來進行處理)
    • 實際上因爲*ptr是新開闢的,因此最終仍是會走到segregated_next_block()這步,並將上面算好的slot_bytes大小傳遞過來進行開闢工做。
    • 那麼具體大小就是根據segregated_size_to_fit()函數進行處理的了,咱們能夠追蹤進去。
  • 追蹤到segregated_size_to_fit()後咱們就看到了NANO_REGIME_QUANTA_SIZE宏定義,追蹤進去查看發現是讓1左移了4位也就是16,最後再經過公式來進行對齊運算。

    //16字節對齊公式:
    k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM\
    slot_bytes = k << SHIFT_NANO_QUANTUM;
    複製代碼

    圖片.png 圖片.png

  • 算法解析:

    • NANO_REGIME_QUANTA_SIZE 的值是16
    • 假如 size=7;((7+15)>>4)<<4 ;(22>>4)<<4 ;0001 0110 >> 4 = 0000 0001 ; 0000 0001 << 4 = 0001 0000(16)
    • 假如 size=32;((32+15)>>4)<<4 ;(47>>4)<<4 ;0010 1111 >> 4 = 0000 0010 ; 0000 0010 << 4 = 0010 0000(32)
    • 實際能夠替換爲:slot_bytes = (size + NANO_REGIME_QUANTA_SIZE - 1) & ~ SHIFT_NANO_QUANTUM
  • 到此就知道了用malloc_size()打印對象是48的緣由了,由於進行了16字節對齊。

七、對象內部對齊與結構體內部對齊的差異與意義

  • 對象中成員變量(結構體內部)採用8字節對齊;
  • 對象與對象在堆內存中採用16字節對齊;
  • 爲什麼不考慮都是用8字節對齊?
    • 緣由1:拉伸對象與對象直接的內存空隙,有效下降野指針內存訪問帶來的問題。
    • 緣由2:因爲咱們的類都是繼承於NSObject,因此每一個類默認都會包含一個8字節的isa屬性,若是隨便增長1個變量就已經超過8字節(也就是最少也是16字節起步),因此蘋果索性就按16字節進行對齊處理下降運算次數。

八、總結

  • 經過了解LLVM對alloc的優化處理,咱們探究了callAlloc調用2次的緣由,以及調用的流程;
  • 對象中的屬性、成員變量是惟一影響大小的因素;
  • 對象內部屬性、成員變量是已8字節進行對齊處理;
  • 記住結構體內部對齊的三個原則;
  • 對象在堆內存中是以16字節進行對齊的;
  • 要理解對象內部對齊與結構體內部對齊的差異與意義;
注:
寫到最後
導航:
相關文章
相關標籤/搜索