適用于: 摘要:本文介紹托管代碼執行時間的低級操作開銷模型,該模型是通過測量操作時間得到的,開發人員可以據此做出更好的編碼決策并編寫更快的代碼。 下載 CLR Profiler。(330KB) 目錄簡介(和誓言) 簡介(和誓言)實現計算的方法有無數種,但這些方法良莠不齊,有些方法遠勝于其他方法:更簡單,更清晰,更容易維護。有些方法速度很快,有些卻慢得出奇。 不要錯用那些速度慢、內容臃腫的代碼。難道您不討厭這樣的代碼嗎:不能連續運行的代碼、不時將用戶界面鎖定幾秒種的代碼、頑固占用 CPU 或嚴重損害磁盤的代碼? 千萬不要用這樣的代碼。相反,請站起來,和我一起宣誓: “我保證,我不會向用戶提供慢速代碼。速度是我關注的特性。每天我都會注意代碼的性能。我會經常地、系統地‘測量’代碼的速度和大小。我將學習、構建或購買為此所需的工具。這是我的責任! (我保證。)你是這樣保證的嗎?非常好。 那么,怎樣才能在日常工作中編寫出最快、最簡潔的代碼呢?這就要不斷有意識地優先選擇節儉的方法,而不要選擇浪費、臃腫的方法,并且要深入思考。即使是任意指定的一段代碼,都會需要許多這樣的小決定。 但是,如果不知道開銷的情況,就無法面對眾多方案作出明智的選擇:如果您不知道開銷情況,也就無法編寫高效的代碼。 在過去的美好日子里,事情要容易一些,好的 C 程序員都知道。C 中的每個運算符和操作,不管是賦值、整數或浮點數學、解除引用,還是函數調用,都在不同程度上一一對應著單一的原始計算機操作。當然,有時會需要數條計算機指令來將正確的操作數放置在正確的寄存器中,而有時一條指令就可以完成幾種 C 操作(比較著名的是 到了 20 世紀 90 年代,為了將數據抽象、面向對象編程和代碼復用等技術更好地用于軟件工程和生產,PC 軟件業將 C 發展為 C++。 C++ 是 C 的超集,并且是“使用才需付出”,即如果不使用,新功能不會有任何開銷。因此,C 的專用編程技術,包括其內在的開銷模型,都可以直接應用。如果編寫一段 C 代碼并用 C++ 重新編譯這段代碼,則執行時間和空間的系統開銷不會有太大變化。 另一方面,C++ 引入了許多新的語言功能,包括構造函數、析構函數、New、Delete、單繼承、多繼承、虛擬繼承、數據類型轉換、成員函數、虛函數、重載運算符、指向成員的指針、對象數組、異常處理和相同的復合,這些都會造成許多不易察覺但非常重要的開銷。例如,每次調用虛函數時都要花費兩次額外的定位,而且還會將隱藏的 vtable 指針字段添加到每個實例中;蛘,考慮將這段看起來比較安全的代碼: { complex a, b, c, d; ... a = b + c * d; } 編譯為大約十三個隱式成員函數調用(但愿是內聯的)。 九年前,在我的文章 C++:Under the Hood(英文)中曾探討過這個主題,我寫道: “了解編程語言的實現方式是非常重要的。這些知識可以讓我們消除‘編譯器到底在做些什么?’的恐懼和疑慮,讓我們有信心使用新功能,并使我們在調試和學習其他的語言功能時更具洞察力。這些知識還能使我們認識到各種編碼方案的相對開銷,而這正是我們在日常工作中編寫出最有效的代碼所必需的! 現在,我們將以同樣的方式來了解托管代碼。本文將探討托管執行的“低級”時間和空間開銷,以使我們能夠在日常的編碼工作中權衡利弊,做出明智的判斷。 并遵守我們的承諾。 為什么是托管代碼?對大多數本機代碼的開發人員來說,托管代碼為運行他們的軟件提供了更好、更有效率的平臺。它可以消除整類錯誤,如堆損壞和數組索引超出邊界的錯誤,而這些錯誤常常使深夜的調試工作無功而返。它支持更為現代的要求,如安全移動代碼(通過代碼訪問安全性實現)和 XML Web Service,而且與過去的 Win32/COM/ATL/MFC/VB 相比,.NET Framework 更加清楚明了,利用它可以做到事半功倍。 對軟件用戶來說,托管代碼為他們提供了更豐富、更健壯的應用程序,讓他們通過更優質的軟件享受更好的生活。 編寫更快的托管代碼的秘訣是什么?盡管可以做到事半功倍,但還是不能放棄認真編碼的責任。首先,您必須承認:“我是個新手!蹦莻新手。我也是個新手。在托管代碼領域中,我們都是新手。我們仍然在學習這方面的訣竅,包括開銷的情況。 面對功能豐富、使用方便的 .NET Framework,我們就像糖果店里的孩子:“哇,不需要枯燥的 一切都是那么容易。真的是很容易。即使是從 XML 信息集中提出幾個元素,也會輕易地投入幾兆字節的 RAM 來分析 XML 信息集。使用 C 或 C++ 時,這件事是很令人頭疼的,必須考慮再三,甚至您會想在某些類似 SAX 的 API 上創建一個狀態機。而使用 .NET Framework 時,您可以在一口氣加載整個信息集,甚至可以反復加載。這樣一來,您的應用程序可能就不再那么快了。也許它的工作集達到了許多兆字節。也許您應該重新考慮一下那些簡單方法的開銷情況。 遺憾的是,在我看來,當前的 .NET Framework 文檔并沒有足夠詳細地介紹 Framework 的類型和方法的性能含義,甚至沒有具體指明哪些方法會創建新對象。性能建模不是一個很容易闡述的主題,但是“不知道”會使我們更難做出恰當的決定。 既然在這方面我們都是新手,又不知道任何開銷情況,而且也沒有什么文檔可以清楚說明開銷情況,那我們應該做些什么呢? 測量,對開銷進行測量。秘訣就是“對開銷進行測量”并“保持警惕”。我們都應該養成測量開銷的習慣。如果我們不怕麻煩去測量開銷,就不會輕易調用比我們“假設”的開銷高出十倍的新方法。 (順便說一下,要更深入地了解 BCL [基類庫] 的性能基礎或 CLR,請查看 Shared Source CLI [英文],又稱 Rotor。Rotor 代碼與 .NET Framework 和 CLR 屬于同一類別,但并不是完全相同的代碼。不過即使是這樣,我保證在認真學習 Rotor 之后,您會對 CLR 有更新、更深刻的理解。但一定保證首先要審核 SSCLI 許可證。 知識如果您想成為倫敦的出租車司機,首先必須學習 The Knowledge(英文)。學生們通過幾個月的學習,要記住倫敦城里上千條的小街道,還要了解到達各個地點的最佳路線。他們每天騎著踏板車四處查看,以鞏固在書本上學到的知識。 同樣,如果您想成為一名高性能托管代碼的開發人員,您必須獲得“托管代碼知識”。您必須了解每項低級操作的開銷,必須了解像委托 (Delegate) 和代碼訪問安全等這類功能的開銷,還必須了解正在使用以及正在編寫的類型和方法的開銷。能夠發現哪些方法的開銷太大,對您的應用程序不會有什么損害,反倒因此可以避免使用這些方法。 這些知識不在任何書本中,也就是說,您必須騎上自己的踏板車進行探索:準備好 csc、ildasm、VS.NET 調試器、CLR 分析器、您的分析器、一些性能計時器等,了解代碼的時間和空間開銷。 關于托管代碼的開銷模型讓我們開門見山地談談托管代碼的開銷模型。利用這種模型,您可以查看葉方法,能馬上判斷出開銷較大的表達式或語句,而在您編寫新代碼時,就可以做出更明智的選擇。 (有關調用您的方法或 .NET Framework 方法所需的可傳遞的開銷,本文將不做介紹。這些內容以后會在另一篇文章中介紹。) 之前我曾經說過,大多數的 C 開銷模型仍然適用于 C++ 方案。同樣,許多 C/C++ 開銷模型也適用于托管代碼。 怎么會這樣呢?您一定了解 CLR 執行模型。您使用幾種語言中的一種來編寫代碼,并將其編譯成 CIL(公用中間語言)格式,然后打包成程序集。當您運行主應用程序的程序集時,它開始執行 CIL。但是不是像舊的字節碼解釋器一樣,速度會非常慢? 實時編譯器不,它一點也不慢。CLR 使用 JIT(實時)編譯器將 CIL 中的各種方法編譯成本機 x86 代碼,然后運行本機代碼。盡管 JIT 在編譯首次調用的方法時會稍有延遲,但所調用的各種方法在運行純本機代碼時都不需要解釋性的系統開銷。 與傳統的脫機 C++ 編譯過程不同,JIT 編譯器花費的時間對用戶來說都是“時鐘時間”延遲,因此 JIT 編譯器不具備占用大量時間的徹底優化過程。盡管如此,JIT 編譯器所執行的一系列優化仍給人以深刻印象:
結果可以與傳統的本機代碼相媲美,至少是相近。 至于數據,可以混合使用值類型和引用類型。值類型(包括整型、浮點類型、枚舉和結構)通常存儲在棧中。這些數據類型就像 C/C++ 中的本地和結構一樣又小又快。使用 C/C++ 時,應該避免將大的結構作為方法參數或返回值進行傳送,因為復制的系統開銷可能會大的驚人。 引用類型和裝箱后的值類型存儲在堆中。它們通過對象引用來尋址,這些對象引用只是計算機的指針,就像 C/C++ 中的對象指針一樣。 因此實時編譯的托管代碼可以很快。下面我們將討論一些例外,如果您深入了解了本機 C 代碼中某些表達式的開銷,您就不會像在托管代碼中那樣錯誤地為這些開銷建模。 我還應該提一下 NGEN,這是一種“超前的”工具,可以將 CIL 編譯為本機代碼程序集。盡管利用 NGEN 編譯程序集在當前并不會對執行時間造成什么實質性的影響(好的或壞的影響),卻會使加載到許多應用程序域和進程中的共享程序集的總工作集減少。(操作系統可以跨所有客戶端共享一份利用 NGEN 編譯的代碼,而實時編譯的代碼目前通常不會跨應用程序域或進程共享。請參閱 自動內存管理托管代碼與本機代碼的最大不同之處在于自動內存管理。您可以分配新的對象,但 CLR 垃圾回收器 (GC) 會在這些對象無法訪問時自動釋放它們。GC 不時地運行,通常不為人覺察,但一般會使應用程序停止一兩毫秒,偶爾也會更長一些。 有一些文章探討了垃圾回收器的性能含義,這里就不作介紹了。如果您的應用程序遵循這些文章中的建議,那么總的內存回收開銷就不會很大,也就是百分之幾的執行時間,與傳統的 C++ 對象 但仍不能“免費”分配對象。對象會占用空間。無限制的對象分配將會導致更加頻繁的內存回收。 更糟糕的是,不必要地持續引用無用的對象圖 (Object Graph) 會使對象保持活動。有時,我們會發現有些不大的程序竟然有 100 MB 以上的工作集,可是這些程序的作者卻拒絕承認自己的錯誤,反而認為性能不佳是由于托管代碼本身存在一些神秘、無法確認(因此很難處理)的問題。這真令人遺憾。但是,只需使用 CLR 編譯器花一個小時做一下研究,更改幾行代碼,就可以將這些程序用到的堆減少十倍或更多。如果您遇上大的工作集問題,第一步就應該查看真實的情況。 因此,不要創建不必要的對象。由于自動內存管理消除了許多對象分配和釋放方面的復雜情況、問題和錯誤,并且用起來又快又方便,因此我們會很自然地想要創建越來越多的對象,最終形成錯綜復雜的對象群。如果您想編寫真正的快速托管代碼,創建對象時就需要深思熟慮,確保對象的數量合適。 這也適用于 API 的設計。由于可以設計類型及其方法,因此它們會要求客戶端創建可以隨便放棄的新對象。不要那樣做。 托管代碼的開銷情況現在,讓我們來研究一下各種低級托管代碼操作的時間開銷。 表 1 列出了各種低級托管代碼操作的大致開銷,單位是毫微秒。這些數據是在配備了 1.1 GHz Pentium-III、運行了 Windows XP 和 .NET Framework v1.1 (Everett) 的靜止 PC 上通過一套簡單的計時循環收集到的。 測試驅動程序調用各種測試方法,指定要執行的多個迭代,自動調整為迭代 218 到 230 次,并根據需要使每次測試的時間不少于 50 毫秒。一般情況下,這么長的時間足可以在一個進行密集對象分配的測試中觀察幾個 0 代內存回收周期。該表顯示了 10 次實驗的平均結果,對于每個測試主題,都列出了最好(最少時間)的實驗結果。 根據需要,每個測試循環都展開 4 至 60 次,以減少測試循環的系統開銷。我檢查了每次測試生成的主機代碼,以確保 JIT 編譯器沒有將測試徹底優化,例如,我修改了幾個示例中的測試,以使中間結果在測試循環期間和測試循環之后都存在。同樣,我還對幾個測試進行了更改,以使通用子表達式消除不起作用。 表 1:原語時間(平均和最。(ns)
免責聲明:請不要照搬這些數據。時間測試會由于無法預料的二次影響而變得不準確。偶然事件可能會使實時編譯的代碼或某些關鍵數據跨過緩存行,影響其他的緩存或已有數據。這有點像不確定性原則:1 毫微秒左右的時間和時間差異是可觀察到的范圍限度。 另一項免責聲明:這些數據只與完全適應緩存的小代碼和數據方案有關。如果應用程序中最常用的部分不適應芯片緩存,您可能會遇到其他的性能問題。本文的結尾將詳細介紹緩存。 還有一項免責聲明:將組件和應用程序作為 CIL 的程序集的最大好處之一是,您的程序可以做到每秒都變快、每年都變快!懊棵攵甲兛臁笔且驗檫\行時(理論上)可以在程序運行時重新調整 JIT 編譯的代碼;“每年都變快”是因為新發布的運行時總能提供更好、更先進、更快的算法以將代碼迅速優化。因此,如果 .NET 1.1 中的這幾個計時不是最佳結果,請相信在以后發布的產品中它們會得到改善。而且在今后發布的 .NET Framework 中,本文中所列代碼的本機代碼序列可能會更改。 不考慮這些免責聲明,這些數據確實讓我們對各種原語的當前性能有了充分的認識。這些數字很有意義,并且證實了我的判斷,即大多數實時編譯的托管代碼可以像編譯過的本機代碼一樣,“接近計算機”運行。原始的整型和浮點操作很快,而各種方法調用卻不太快,但(請相信我)仍可比得上本機 C/C++。同時我們還會發現,有些通常在本機代碼中開銷不太大的操作(如數據類型轉換、數組和字段存儲、函數指針 [委托])現在的開銷卻變大了。為什么是這樣呢?讓我們來看一下。 算術運算表 2:算術運算時間 (ns)
過去,浮點運算幾乎比整數運算慢一個數量級。如表 2 所示,在使用現代的管道化的浮點單位之后,二者之間的差別變得很小或沒有差別。而且令人驚奇的是,普通的筆記本 PC 現在已經可以在每秒內進行十億次浮點運算(對于適應緩存的問題)。 讓我們看一行從整數和浮點的加法運算測試中得到的實時編譯代碼: 反匯編 1:整數加法運算和浮點加法運算 int add a = a + b + c + d + e + f + g + h + i; 0000004c 8B 54 24 10 mov edx,dword ptr [esp+10h] 00000050 03 54 24 14 add edx,dword ptr [esp+14h] 00000054 03 54 24 18 add edx,dword ptr [esp+18h] 00000058 03 54 24 1C add edx,dword ptr [esp+1Ch] 0000005c 03 54 24 20 add edx,dword ptr [esp+20h] 00000060 03 D5 add edx,ebp 00000062 03 D6 add edx,esi 00000064 03 D3 add edx,ebx 00000066 03 D7 add edx,edi 00000068 89 54 24 10 mov dword ptr [esp+10h],edx float add i += a + b + c + d + e + f + g + h; 00000016 D9 05 38 61 3E 00 fld dword ptr ds:[003E6138h] 0000001c D8 05 3C 61 3E 00 fadd dword ptr ds:[003E613Ch] 00000022 D8 05 40 61 3E 00 fadd dword ptr ds:[003E6140h] 00000028 D8 05 44 61 3E 00 fadd dword ptr ds:[003E6144h] 0000002e D8 05 48 61 3E 00 fadd dword ptr ds:[003E6148h] 00000034 D8 05 4C 61 3E 00 fadd dword ptr ds:[003E614Ch] 0000003a D8 05 50 61 3E 00 fadd dword ptr ds:[003E6150h] 00000040 D8 05 54 61 3E 00 fadd dword ptr ds:[003E6154h] 00000046 D8 05 58 61 3E 00 fadd dword ptr ds:[003E6158h] 0000004c D9 1D 58 61 3E 00 fstp dword ptr ds:[003E6158h] 這里我們可以看到,實時編譯的代碼已接近最佳狀態。在 方法調用本節將探討方法調用的開銷和實現。測試主題是實現接口 列表 1:方法調用的測試方法 interface I { void 請參閱表 3。首先可以判斷出,表中的方法可以是內聯的(抽象不需要任何開銷),也可以不是內聯的(抽象的開銷是整型操作的 5 倍還多)。靜態調用、實例調用、虛擬調用和接口調用的原始開銷看起來并沒有什么大的差別。 表 3:方法調用的時間 (ns)
但是,這些結果是不具代表性的“最好情況”,是連續上百萬次運行計時循環的結果。在這些測試示例中,虛擬方法和接口方法的調用位置都是單態的(例如,對于每個調用位置,目標方法不因時間而改變),因此,緩存的虛擬方法和接口方法的調度機制(方法表、接口映射指針和輸入)再加上非常有預測性的分支預測,使得處理器可以調用這些用其他方法難以預測并與數據相關的分支來完成這項不切實際但卻富有成效的工作。實際上,任何調度機制數據的數據緩存不命中或分支預測錯誤(可能是強制性的容量不命中或多態的調用位置),都可以在多個循環之后使虛擬調用和接口調用的速度減慢。 讓我們進一步看一下這些方法調用的時間。 在第一個 inlined static call 示例中,我們調用了 為了測量 static method call 的大致開銷,我們將 我們甚至不得不使用一個顯式假謂詞變量
JIT 編譯器將像以前那樣把死調用 (Dead Call) 消除到 內聯的實例調用和常規實例調用的計時使用了相同的方法。但是,由于 C# 語言規范規定,對 Null 對象引用的任何調用都會拋出 NullReferenceException,因此每個調用位置都必須確保實例不為空。這可以通過解除實例引用的引用來實現。如果該實例確實是 Null,則會生成一個故障,并轉變為此異常。 在反匯編 2 中,我們使用靜態變量
時,編譯器會提起簽出循環的 Null 實例。 反匯編 2:使用 Null 實例“檢查”的實例方法調用位置 t.i1(); 00000012 8B 0D 30 21 A4 05 mov ecx,dword ptr ds:[05A42130h] 00000018 39 09 cmp dword ptr [ecx],ecx 0000001a E8 C1 DE FF FF call FFFFDEE0 inlined this instance call 和 this instance call 相同,只是此實例是 反匯編 3:this 實例方法調用位置 this.i1(); 00000012 8B CE mov ecx,esi 00000014 E8 AF FE FF FF call FFFFFEC8 “虛擬方法調用”的運行情況與傳統的 C++ 實現類似。每個新引入的虛擬方法的地址都存儲在類型方法表的新插槽中。每個導出類型的方法表都與其基本類型的方法表一致并有所擴展,并且所有虛擬方法替代都會使用導出類型的虛擬方法地址(在導出的類型方法表的相應插槽中)來替換基本類型的虛擬方法地址。 在調用位置,與實例調用相比,虛擬方法調用要進行兩次額外的加載,一次是獲取方法表地址(隨時可以在 反匯編 4:虛擬方法調用位置 this.v1(); 00000012 8B CE mov ecx,esi 00000014 8B 01 mov eax,dword ptr [ecx] ; 獲取方法表地址 00000016 FF 50 38 call dword ptr [eax+38h] ; 獲取/調用方法地址 最后,討論一下“接口方法調用”(反匯編 5)。在 C++ 中,沒有等效的接口方法調用。任何給定的類型都可以實現任意數量的接口,并且每個接口在邏輯上都需要自己的方法表。要對接口方法進行調度,就要查找方法表、方法的接口映射、該映射中接口的入口,然后通過方法表中接口部分適當的入口進行調用。 反匯編 5:接口方法調用位置 i.itf1(); 00000012 8B 0D 34 21 A4 05 mov ecx,dword ptr ds:[05A42134h]; 實例地址 00000018 8B 01 mov eax,dword ptr [ecx] ; 方法表地址 0000001a 8B 40 0C mov eax,dword ptr [eax+0Ch] ; 接口映射地址 0000001d 8B 40 7C mov eax,dword ptr [eax+7Ch] ; 接口方法表地址 00000020 FF 10 call dword ptr [eax] ; 獲取/調用方法地址 其余的原語計時,inst itf instance call、this itf instance call、inst itf virtual call 和 this itf virtual call,充分印證了這樣一個觀點:不論何時,導出類型的方法在實現接口方法時,都可以通過實例方法調用位置來保持可調用性。 例如,在 this itf instance call 測試中,通過實例(不是接口)引用來調用接口方法實現,結果接口方法被成功內聯并且開銷為 0 ns。甚至當您將接口方法作為實例方法進行調用時,接口方法實現都有可能被內聯。 尚未實時編譯的方法調用對于靜態方法調用和實例方法調用(不是虛擬方法調用和接口方法調用),JIT 編譯器會根據在目標方法的調用位置被實時編譯時,目標方法是否已經被實時編譯,從而在當前生成不同的方法調用序列。 如果被調用者(目標方法)還未被實時編譯,編譯器將通過已經用“prejit stub”初始化的指針來發出調用。對目標方法的第一個調用到達 stub 時,將觸發方法的 JIT 編譯,同時生成本機代碼,并對指針進行更新以尋址新的本機代碼。 如果被調用者已經過實時編譯,其本機代碼地址已知,則編譯器將直接向其發出調用。 創建新對象創建新對象包括兩個階段:對象分配和對象初始化。 對于引用類型,對象被分配在可以進行內存回收的堆上。對于值類型,不管是以棧形式駐留在另一個引用類型或值類型中,還是嵌入到另一個引用類型或值類型中,值類型對象都與封閉結構有一些固定的差異,即不需要進行任何分配。 對典型的引用類型的小對象來說,堆分配的速度非?。每次內存回收之后,除了固定的對象之外,第 0 代堆的活對象都將被壓縮并被提升到第 1 代,因此,內存分配程序可以使用一個相當大的連續可用內存空間。大多數的對象分配只會引起指針的遞增和邊界檢查,這要比典型的 C/C++ 釋放列表分配程序(malloc/操作符 new)節省很多開銷。垃圾回收器甚至會考慮計算機的緩存大小,以設法將第 0 代對象保留在緩存/內存層次結構中快速有效的位置。 由于首選的托管代碼風格要求大多數分配的對象生存期很短,并且快速回收這些對象,所以我們還包含了這些新對象的內存回收的分期開銷(在時間開銷中)。 請注意,垃圾回收器不會為死對象浪費時間。如果一個對象是死的,GC 不會處理它,也不會回收它,甚至是根本就不考慮它。GC 只關注那些存活的對象。 (例外:可終結的死對象屬于特殊情況。GC 會跟蹤這些對象,并且專門將可終結的死對象提升到下一代,等待終結。這會花費很大的開銷,而且在最壞的情況下,還會可傳遞地提升大的死對象圖。因此,若非確實需要,請不要使對象成為可終結的。如果必須這樣做,請考慮使用“清理模式”[Dispose Pattern],并在可能時調用 當然,生存期短的大對象的分期 GC 開銷要大于生存期短的小對象的開銷。每次對象分配都使我們更接近下一個內存回收周期;而較大的對象比較小的對象達到得更早。但無論早晚,“算帳”的時刻終會到來。GC 周期(尤其第 0 代回收)的速度非?,但不是不需要開銷的,即使絕大多數新對象是死的也是如此:因為要查找(標記)活對象,需要先暫停線程,然后查找棧和其他數據結構,以將根對象引用回收到堆中。 (也許更為重要的是,只有極少的大對象能夠適應小對象所利用的緩存數量。緩存不命中的影響很容易超過代碼路徑長度的影響。) 一旦為對象分配了空間,空間就將保留下來以初始化對象(構造對象)。CLR 可以保證,所有的對象引用都預先初始化為 Null,所有的原始標量類型都初始化為 0、0.0、False 等。(因此沒有必要在用戶定義的構造函數中進行多余的初始化。當然,不必擔心。但請注意,當前不必使用 JIT 編譯器優化掉冗余的存儲。) 除了消除實例字段外,CLR 還初始化(僅引用類型)對象的內部實現字段:方法表指針和對象標頭詞。而后者要優先于方法表指針。數組也獲得一個 Length 字段,對象數組獲得 Length 和元素類型字段。 然后,CLR 調用對象的構造函數(如果有的話)。每種類型的構造函數,不管是用戶定義的還是編譯器生成的,都是首先調用其基本類型的構造函數,然后運行用戶定義的初始化操作(如果有的話)。 從理論上講,這樣做對于深度繼承方案來說可能會花費比較大的開銷。如果 E 擴展 D 擴展 C 擴展 B 擴展 A(擴展 System.Object),那么初始化 E 將導致五次方法調用。實際上,情況并沒有這么糟糕,因為編譯器會內聯掉對空的基本類型構造函數的調用(使其不存在)。 參考表 4 的第一列時會發現,我們可以創建和初始化一個結構 表 4:值類型和引用類型對象的創建時間 (ns)
反匯編 6:值類型對象的構造 A a1 = new A(); ++a1.a; 00000020 C7 45 FC 00 00 00 00 mov dword ptr [ebp-4],0 00000027 FF 45 FC inc dword ptr [ebp-4] C c1 = new C(); ++c1.c; 00000024 8D 7D F4 lea edi,[ebp-0Ch] 00000027 33 C0 xor eax,eax 00000029 AB stos dword ptr [edi] 0000002a AB stos dword ptr [edi] 0000002b AB stos dword ptr [edi] 0000002c FF 45 FC inc dword ptr [ebp-4] E e1 = new E(); ++e1.e; 00000026 8D 7D EC lea edi,[ebp-14h] 00000029 33 C0 xor eax,eax 0000002b 8D 48 05 lea ecx,[eax+5] 0000002e F3 AB rep stos dword ptr [edi] 00000030 FF 45 FC inc dword ptr [ebp-4] 另外的五個計時(new reftype L1、……、new reftype L5)針對引用類型 public class A { int a; } public class B : A { int b; } public class C : B { int c; } public class D : C { int d; } public class E : D { int e; } 將引用類型的時間與值類型的時間進行比較,我們會發現,對于每個實例,其分配和釋放的分期開銷在測試計算機上大約為 20 ns(是整型加法運算時間的 20 倍)。這個速度非?,也就是說,一秒鐘可以分配、初始化和回收大約 5 千萬個生存期很短的對象,而且這種速度可以保持不變。對于像五個字段一樣小的對象,分配和回收的時間僅占對象創建時間的一半。請參閱反匯編 7。 反匯編 7:引用類型對象的構造 new A(); 0000000f B9 D0 72 3E 00 mov ecx,3E72D0h 00000014 E8 9F CC 6C F9 call F96CCCB8 new C(); 0000000f B9 B0 73 3E 00 mov ecx,3E73B0h 00000014 E8 A7 CB 6C F9 call F96CCBC0 new E(); 0000000f B9 90 74 3E 00 mov ecx,3E7490h 00000014 E8 AF CA 6C F9 call F96CCAC8 最后三組五個計時說明了這種繼承類構造方案的變化情況。
編譯器將每組嵌套的基類構造函數調用內聯到 反匯編 8:深度內聯的繼承構造函數 new A(); 00000012 B9 A0 77 3E 00 mov ecx,3E77A0h 00000017 E8 C4 C7 6C F9 call F96CC7E0 0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1 new C(); 00000012 B9 80 78 3E 00 mov ecx,3E7880h 00000017 E8 14 C6 6C F9 call F96CC630 0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1 00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1 0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1 new E(); 00000012 B9 60 79 3E 00 mov ecx,3E7960h 00000017 E8 84 C3 6C F9 call F96CC3A0 0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1 00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1 0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1 00000031 C7 40 10 01 00 00 00 mov dword ptr [eax+10h],1 00000038 C7 40 14 01 00 00 00 mov dword ptr [eax+14h],1
表 4 中的最后五個計時顯示了調用嵌套的基本構造函數時所需的額外系統開銷。 中間程序:CLR 分析器(CLR Profiler)演示現在來簡單演示一下 CLR 分析器。CLR 分析器(舊稱“分配分析器”)使用 CLR 分析 API 在應用程序運行時收集事件數據,特別是調用、返回以及對象分配和內存回收事件。(CLR 分析器是一種“侵害性”的分析器,即它會嚴重地減慢被分析的應用程序的運行速度。)收集事件之后,您可以使用 CLR 分析器來檢查應用程序的內存分配和 GC 行為,包括分層調用圖和內存分配模式之間的交互。 CLR 分析器之所以值得學習,是因為對許多“面臨性能挑戰的”托管代碼應用程序來說,了解數據分配配置文件可以使您獲得很關鍵的認知,從而減少工作集并由此而開發出快速、價廉的組件和應用程序。 CLR 分析器還可以揭示哪些方法分配的存儲比您預期的多,并可以發現您不小心保留的對無用對象圖的引用,而這些引用原本可能會由 GC 回收。(一種常見的問題設計模式是項目的軟件緩存或查找表已不再需要,或者對以后的重建是安全的。當緩存使對象圖的生存期超出其有用壽命時,情況將非常糟糕。因此,務必解除對不再需要的對象的引用。) 圖 1 是在執行計時測試驅動程序時堆的時間線圖。鋸齒狀圖案表示對象 圖 1:CLR 分析器時間線圖 注意,對象越大(E 大于 D,D 大于 C),第 0 代堆充滿的速度就越快,GC 周期就越頻繁。 類型轉換和實例類型檢查要使托管代碼安全、可靠、“可驗證”,必須保證類型安全。如果可以將一個對象的類型轉換為其他類型,就很容易危及 CLR 的完整性,并因此而使其被不可信的代碼支配。 表 5:類型轉換和 isinst 時間 (ns)
表 5 顯示了這些強制性類型檢查的系統開銷。從導出類型轉換到基本類型總是安全的,而且也是不需要開銷的,而從基本類型轉換到導出類型則必須經過類型檢查。 (已檢查的)類型轉換將對象引用轉換為目標類型,或者拋出 相反,
如果 列表 2 是一個類型轉換的計時循環,反匯編 9 顯示了向下轉換為導出類型的生成代碼。為執行類型轉換,編譯器直接調用 Helper 例程。 列表 2:測試類型轉換計時的循環 public static void castUp2Down1(int n) { A ac = c; B bd = d; C ce = e; D df = f; B bac = null; C cbd = null; D dce = null; E edf = null; for (n /= 8; --n >= 0; ) { bac = (B)ac; cbd = ©bd; dce = (D)ce; edf = (E)df; bac = (B)ac; cbd = ©bd; dce = (D)ce; edf = (E)df; } } 反匯編 9:向下類型轉換 bac = (B)ac; 0000002e 8B D5 mov edx,ebp 00000030 B9 40 73 3E 00 mov ecx,3E7340h 00000035 E8 32 A7 4E 72 call 724EA76C 屬性在托管代碼中,屬性是一對方法,即一個屬性獲取方法和一個屬性設置方法,類似于對象的字段。get_ 方法獲取屬性,set_ 方法將屬性更新為新的值。 除此之外,屬性的行為和開銷與常規的實例方法、虛擬方法的行為和開銷非常相像。如果使用一個屬性來獲取或存儲一個實例字段,通常是以內聯方式進行,這與小方法相同。 表 6 顯示了獲。ê吞砑樱┎⒋鎯σ唤M整數實例字段和屬性所需的時間。獲取或設置屬性的開銷實際上與直接訪問基本字段相同,除非將屬性聲明為虛擬的。如果聲明為虛擬的,則開銷基本上就是虛擬方法調用的開銷。這沒什么可奇怪的。 表 6:字段和屬性時間 (ns)
|