託管物件本質-第三部分-託管陣列結構
目錄
- 託管物件本質-第三部分-託管陣列結構
- 目錄
- 陣列協方差和一些歷史
- 內部結構和實現細節
- 通過刪除型別檢查來提高效能
託管物件本質-第三部分-託管陣列結構
原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-3-the-layout-of-a-managed-array-3/
原文作者:Sergey
譯文作者:傑哥很忙
目錄
託管物件本質1-佈局
託管物件本質2-物件頭佈局和鎖成本
託管物件本質3-託管陣列結構
託管物件本質4-欄位佈局
陣列是每個應用程式的基本構建基塊之一。即使你不是每天直接使用陣列,你也可以將他們作為庫包的一部分間接使用。
C# 一直都有陣列結構,陣列結構也是唯一一個類似泛型而且型別安全的資料結構。現在你可能沒有那麼頻繁的直接使用他們,但是為了提升效能,都有可能會從一些更高階的資料結構(比如List<T>
)切換回陣列。
陣列和CLR有著非常特殊的關係,但是今天我們將從使用者的角度來探討它們。我們將討論以下內容:
* 探索一個最奇怪的 C# 功能,稱為陣列協方差
* 討論陣列的內部結構
* 探索一些效能技巧,我們可以這樣做,從陣列中擠壓更多的效能
陣列協方差和一些歷史
C# 語言中最奇怪的特徵之一是陣列協方差:能夠將T型別的陣列賦值給object型別或任何其他T型別的基類的陣列的能力。
string[] strings = new[] { "1", "2" };
object[] objects = strings;
這個轉換並完全是型別安全的。如果objects變數僅用於讀取資料,那麼一切正常。但是,如果嘗試修改陣列時,如果引數的型別不相容,則可能會失敗。
objects[0] = 42; //執行時錯誤
關於這個特性,.NET 社群中有一個眾所周知的笑話:C# 作者在一開始非常努力地將 Java 生態系統的各個方面複製到 CLR 世界,所以他們也複製了語言設計問題。
但是我並不認為這是原因:)
在 90 年代後期,CLR 並沒有泛型,對嗎?在這種情況下,語言使用者如何編寫處理任意資料型別陣列的可重用程式碼?例如,如何編寫將任意陣列轉儲到控制檯的函式?
一種方法是定義接收object[]
的函式,並通過將陣列複製到物件陣列來強制每個呼叫方手動轉換陣列。這是可行的,但效率很低。另一種解決方案是允許從任何引用型別的陣列轉換為object[]
,即保留 Derived[]
到 Base[]
的 IS-A
關係,其中派生類從基類繼承。在值型別的陣列中,轉換不起作用,但至少可以實現一些通用性。
第一個 CLR 版本中缺少泛型,迫使設計人員削弱型別系統。但是這個決定(我想)是經過深思熟慮的,而不僅僅是來自Java生態系統的模仿。
內部結構和實現細節
陣列協方差在編譯時在型別系統中開啟一個洞,但這並不意味著型別錯誤會使應用程式崩潰(C++中的類似"錯誤"將導致"未定義行為")。CLR 將確保型別安全,但檢查會在執行時進行。為此,CLR 必須儲存陣列元素的型別,並在使用者嘗試更改陣列例項時進行檢查。幸運的是,此檢查僅對引用型別的陣列是必需的,因為struct是密封的(sealed
),因此不支援繼承。
譯者補充:struct由於是值型別,我們可以檢視它的IL語言,可以看到struct前會有
sealed
關鍵字。
儘管不同值型別(如 int
到 byte
)之間存在隱式轉換,但 int[]
和 byte[]
之間沒有隱式或顯式轉換。陣列協方差轉換是引用轉換,它不會更改轉換物件的佈局,並保留要轉換物件的引用標識。
在舊版本的 CLR 中,引用陣列和值型別具有不同的佈局。引用型別的陣列具有對每個例項中元素的型別控制代碼的引用:
這在最新版本的 CLR 中已更改,現在元素型別儲存在方法表中:
有關佈局的詳細資訊,請參閱 CoreClr 程式碼庫中的以下程式碼段:
- ArrayBase::GetArrayElementTypeHandle宣告:
// Get the element type for the array, this works whether the element
// type is stored in the array or not
inline TypeHandle GetArrayElementTypeHandle() const;
- PtrArray::GetArrayElementTypeHandle 實現:
TypeHandle GetArrayElementTypeHandle()
{
LIMITED_METHOD_CONTRACT;
return GetMethodTable()->GetApproxArrayElementTypeHandle();
}
- Table::GetApproxArrayElementTypeHandle實現:
TypeHandle GetApproxArrayElementTypeHandle()
{
LIMITED_METHOD_DAC_CONTRACT;
_ASSERTE(IsArray());
return TypeHandle::FromTAddr(m_ElementTypeHnd);
}
- MethodTable::m_ElementTypeHnd 宣告:
union
{
PerInstInfo_t m_pPerInstInfo;
TADDR m_ElementTypeHnd;
TADDR m_pMultipurposeSlot1;
};
我不確定陣列佈局是什麼時候改變的,但似乎在速度和(託管)記憶體之間有一個權衡。由於記憶體區域性性,初始實現(當型別控制代碼儲存在每個陣列例項中時)訪問應該更快,但肯定有不可忽略的記憶體開銷。當時,所有引用型別的陣列都有共享方法表。但現在情況不一樣了:每個引用型別的陣列都有自己的方法表,該表指向相同的 EEClass ,指標指向元素型別控制代碼的指標。
也許CLR團隊的人可以解釋一下.
我們知道 CLR 如何儲存陣列的元素型別,現在我們可以探索 CoreClr 程式碼庫,看看實現型別檢查。
首先,我們需要找到檢查發生的位置。陣列是 CLR 的一種非常特殊的型別,IDE 中沒有"轉到宣告"按鈕來"反編譯"陣列並顯示原始碼。但是我們知道,檢查發生在索引器setter中,它與一組IL指令StElem*
相對應:
* StElem.i4 用於整型陣列
* StElem 用於任意值型別陣列
* StElem.ref 用於引用型別陣列
瞭解指令後,我們可以輕鬆地在程式碼庫中找到實現。據我所知,實現在了jithelpers.cpp中。下面是方法JIT_Stelem_Ref_Portable
稍微簡化的版本:
/****************************************************************************/
/* assigns 'val to 'array[idx], after doing all the proper checks */
HCIMPL3(void, JIT_Stelem_Ref_Portable, PtrArray* array, unsigned idx, Object *val)
{
FCALL_CONTRACT;
if (!array)
{
// ST: explicit check that the array is not null
FCThrowVoid(kNullReferenceException);
}
if (idx >= array->GetNumComponents())
{
// ST: bounds check
FCThrowVoid(kIndexOutOfRangeException);
}
if (val)
{
MethodTable *valMT = val->GetMethodTable();
// ST: getting type of an array element
TypeHandle arrayElemTH = array->GetArrayElementTypeHandle();
// ST: g_pObjectClass is a pointer to EEClass instance of the System.Object
// ST: if the element is object than the operation is successful.
if (arrayElemTH != TypeHandle(valMT) && arrayElemTH != TypeHandle(g_pObjectClass))
{
// ST: need to check that the value is compatible with the element type
TypeHandle::CastResult result = ObjIsInstanceOfNoGC(val, arrayElemTH);
if (result != TypeHandle::CanCast)
{
// ST: ArrayStoreCheck throws ArrayTypeMismatchException if the types are incompatible
if (HCCALL2(ArrayStoreCheck, (Object**)&val, (PtrArray**)&array) != NULL)
{
return;
}
}
}
HCCALL2(JIT_WriteBarrier, (Object **)&array->m_Array[idx], val);
}
else
{
// no need to go through write-barrier for NULL
ClearObjectReference(&array->m_Array[idx]);
}
}
通過刪除型別檢查來提高效能
現在我們知道,CLR 確實在底層確保引用型別陣列的型別安全。對陣列例項的每個"寫入"都有一個附加檢查,如果陣列在熱路徑上中使用,則該檢查不可忽略。但在得出錯誤的結論之前,讓我們先看看這個檢查的效能消耗程度。
譯者補充:熱路徑指的是那些會被頻繁呼叫的程式碼塊。
為了避免檢查,我們可以更改 CLR,或者使用一個眾所周知的技巧:將物件包裝到結構中
public struct ObjectWrapper
{
public readonly object Instance;
public ObjectWrapper(object instance)
{
Instance = instance;
}
}
比較 object[] 和 ObjectWrapper[]的時間
private const int ArraySize = 100_000;
private object[] _objects = new object[ArraySize];
private ObjectWrapper[] _wrappers = new ObjectWrapper[ArraySize];
private object _objectInstance = new object();
private ObjectWrapper _wrapperInstanace = new ObjectWrapper(new object());
[Benchmark]
public void WithCheck()
{
for (int i = 0; i < _objects.Length; i++)
{
_objects[i] = _objectInstance;
}
}
[Benchmark]
public void WithoutCheck()
{
for (int i = 0; i < _objects.Length; i++)
{
_wrappers[i] = _wrapperInstanace;
}
}
結果如下:
Method | 平均值 | 錯誤 | 標準差 |
---|---|---|---|
WithCheck | 807.7 us | 15.871 us | 27.797 us |
WithoutCheck | 442.7 us | 9.371 us | 8.765 us |
不要被"幾乎 2 倍"的效能差異所迷惑。即使在最壞的情況下,分配 100K 元素也不到一毫秒。效能表現非常好。但在現實世界中,這種差異是顯而易見的。
許多效能關鍵的 .NET 應用程式使用物件池。池允許重用託管例項,而無需每次都建立新例項。此方法降低了記憶體壓力,並可能對應用程式效能產生非常合理的影響。
可以基於併發資料結構(如ConcurrentQueue)或基於簡單陣列實現物件池。下面是 Roslyn 程式碼庫中物件池實現的程式碼段:
internal class ObjectPool<T> where T : class
{
[DebuggerDisplay("{Value,nq}")]
private struct Element
{
internal T Value;
}
// Storage for the pool objects. The first item is stored in a dedicated field because we
// expect to be able to satisfy most requests from it.
private T _firstItem;
private readonly Element[] _items;
// other members ommitted for brievity
}
該實現管理一個快取項陣列,但池化並不是直接使用 T[]
,而是將 T 包裝到結構元素中,以避免在執行時進行檢查。
前段時間,我在應用程式中修復了一個物件池,使得解析階段的效能提升了的 30%。這不是由於我在這裡描述的技巧,是與池的併發訪問相關。但關鍵是,物件池可能位於應用程式的熱路徑上,甚至像上面提到的小效能改進也可能對整體效能產生明顯影響。
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/12266538.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及連結。