好長時間沒有寫博文了,今天繼續。
這次跟大家分享的內容起因於對一個枚舉值列表的序列化,下面簡化后的代碼即能重現。為了明確起見,我顯式指定了枚舉的基礎類型。
// 定義一個枚舉類型。 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序列化機制——實現更易用的序列化方案》看看能不能實現一個統一的機制。
最后再次呼吁:有誰能告訴我微軟為什么要如此處理值類型的序列化?