.NET陷阱之六:從枚舉值持久化帶來大量空間消耗談起


好長時間沒有寫博文了,今天繼續。

這次跟大家分享的內容起因於對一個枚舉值列表的序列化,下面簡化后的代碼即能重現。為了明確起見,我顯式指定了枚舉的基礎類型。

// 定義一個枚舉類型。
public enum SomeEnum :int
{
    First,
    Second,
    Third,
    ... ...
}

// 重現問題的代碼。
var list = new List<SomeEnum>();
for (int i = 0; i < 1000; ++i)
{
    list.Add((SomeEnum)(i % 3));
}

var formatter = new BinaryFormatter();
var stream = File.OpenWrite("c:\\a.data");
formatter.Serialize(stream, list);
stream.Close()

你預料生成的a.data文件大約有多大?

  • 如果你估計的結果是12K以上,那么你應該知道我要說什么了,可以洗洗睡了;
  • 如果你估計的結果是4K多一些,那么請繼續看本文后面的內容。

得到4K結果的同學,我想是這樣估計的,SomeEnum枚舉用int表示,每個值占用4字節,1000個大約就是4K左右,加上其它一些序列化信息,可能就4K多一些吧。最初我也是這么想的,直到在軟件中這樣的列表占用了幾十兆的內存時,問題才暴露出來。我想我還是比較天真,以為那么簡潔的類型應該有相應簡潔的序列化方式,我甚至天真到從來沒有意識到這是個問題。

我用Reflector跟蹤了具體的持久化過程,才發現原來在.NET framework內部,對枚舉值並沒有像基本類型那樣進行處理,而是直接當成普通的值對象處理的。更糟糕的是,對於值對象的處理,居然也要像引用對象那樣保存objectId和mapId。我用了“居然”這個詞,因為我真的認為值對象(ValueType)就只是數據,不會存在兩個reference引用同一個值對象的情況(我知道這樣說有些奇怪,但希望你能明白我的意思)——直到現在我也這么認為。

下面是 formatter.Serialize(stream, list) 這句代碼執行過程中某一時刻的堆棧狀態,為了避免大量的折行影響你的心情,我只保留了函數名部分。

 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.BinaryObject.Write(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.__BinaryWriter.WriteObject(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.Write(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.Write(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.WriteArrayMember(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.WriteArray(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.Write(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.ObjectWriter.Serialize(...)
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Serialize(...) 
 mscorlib.dll!System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Serialize(System.IO.Stream serializationStream, object graph) 

在棧頂上是.NET framework二進制序列化中BinaryObject.Write方法,其實現如下:

public void Write(__BinaryWriter sout)
{
    sout.WriteByte(1);
    sout.WriteInt32(this.objectId);
    sout.WriteInt32(this.mapId);
}

也就是說每寫一個枚舉值,系統都會先寫入1 + 4 + 4 = 9個字節的額外數據!這樣算起來,開始處代碼產生的文件就大約是 1K * (9 + 4) = 13K !

這幾天我一直在想:為什么對值對象也要寫入objectId和mapId呢?根據框架的代碼的實際輸出來看,系統不會“對值相等的多個值對象只保存一份數據”,那么為什么還要寫入這些額外的數據呢?對此我仍不得其解,如果有人知道,還請不吝賜教。

為了解決這個問題,我在類型內部使用了List<int>來保存數據,而在對外接口中完成int和SomeEnum的轉換,這樣做一來不會影響其它模塊的代碼,二來也可以將此處理進行屏蔽。

基於同樣的原因,對於如下一個值類型來說,要直接使用.NET提供的序列化機制,則每保存一個對象,將額外消耗一倍多的空間。是的,對於引用類型來說也是一樣,但還是那句話——我只是沒有意識到這個問題,或者說現在還不能接受framework那么粗糙的實現!

[Serializable]
public struct Point
{
    private float x, y;
}

為了避免這樣的問題,最直接的方法是在包含此類成員的類型上實現ISerializable接口,然后存儲轉換到基本類型的數據。如果類中要序列化的成員比較多的話,這樣做可能會導致其它成員也要手工處理。如果感興趣,也可以參考我的另一篇博文《深入挖掘.NET序列化機制——實現更易用的序列化方案》看看能不能實現一個統一的機制。

最后再次呼吁:有誰能告訴我微軟為什么要如此處理值類型的序列化?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM