使用 ObjectOutputStream 來進行對象序列化
相信大多數程序員在使用 Java 進行日常開發工作中,都曾經遇到需要把數據進行序列化的情況,比如寫入文件或者寫入 socket 流。Java 的類庫也提供了豐富工具類供我們使用,這其中就包括 ObjectOutputStream。此類允許我們將 Java 對象的基本數據類型和圖形寫入 OutputStream,在需要將 Java 對象進行序列化操作時,使用該類可以極大為我們提供便利。但是使用不當也會引起一些麻煩。
需要說明的是,在使用 ObjectOutputStream 向 OutputStream 流中寫入的時候會在流中添加寫入 Object 的信息。這樣的話在我們使用 ObjectOutputStream 編寫跨語言的 Socket 通信時會遇到問題。因為數據流中加入的是 Java 特有的信息,如 Class 類型以及成員變量的類型信息,只有使用 ObjectInputStream 才能解析這些特定的信息。
好了,通過上面的說明,我們現在對 ObjectOutputStream 有了一個大體的了解,接下來我們使用具體的代碼來看一下如果使用 ObjectOutputStream 來把對象序列化到對象中。
把 Java 對象序列化到文件中
首先我們有一個簡單的 Java 對象類:
清單 1. 要序列化的 java 對象
class MyObject implements Serializable {
private static final long serialVersionUID = -9163423175612080544L;
String str1;
String str2;
}
接下來我們使用 ObjectOutputStream 把 MyObject 對象寫入文件:
清單 2. 寫入文件
FileOutputStream fos = new FileOutputStream("c:\\test.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
MyObject myObj = new MyObject();
myObj.str1 = "test1";
myObj.str2 = "test2";
oos.writeObject(myObj);
fos.close();
我們來看一下寫入文件的內容:
清單 3. 寫入文件內容
#sr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~
#xpt #test1t #test2
我們可以看到寫入的內容中包含了寫入類的類型以及成員變量信息,當然關於插入的內容,我們可以覆蓋 ObjectOutputStream 類的 writeStreamHeader() 方法來實現插入我們自定義的內容。當然如果這樣做的話,就必須對 ObjectInputStream 類進行重寫。
上面是一些題外話,下面回到正題,關於標題中提到的有內存洩漏的問題。為了更清晰直觀的說明該問題,我又寫了一個很簡單的測試,代碼如下:
清單 4. 多次寫入
FileOutputStream fos = new FileOutputStream("c:\\test.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
MyObject myObj = new MyObject();
myObj.str1 = "test1";
myObj.str2 = "test2";
for (int i = 0; i < 5; i++) {
oos.writeObject(myObj);
}
fos.close();
我們再來看一下寫入的內容:
清單 5. 寫入 5 次
#sr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~ #xpt
#test1t #test2q ~ #q ~ #q ~ #q ~ #q ~ #q ~ #q ~ #q ~ #q ~ #
我們可以看到多次寫入同一個類型的對象,那麼對象的類型信息是不會重復寫入的。那麼有人會說了,那是因為你寫入的對象每次的內容都是一樣的,接下來為了得到更清晰的測試結果,我們來讓每次寫入的內容不同。
清單 6. 多次寫入不同內容
FileOutputStream fos = new FileOutputStream("c:\\test.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
for (int i = 0; i < 5; i++) {
MyObject myObj = new MyObject();
myObj.str1 = "test1" + i;
myObj.str2 = "test2" + i;
oos.writeObject(myObj);
}
fos.close();
我們再來看一下寫入的結果:
清單 7. 寫入 5 次不同內容的對象
#sr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~ #xpt
#test10t #test20q ~ #sq ~ t #test11t #test21q ~ #sq ~ t #test12t #test22q ~ #sq ~ t
#test13t #test23q ~ #sq ~ t #test14t #test24q ~ #
測試小結
通過上面的測試就很容易的發現,我們雖然寫入了 5 次,但是不會每次寫入都會插入寫入對象和成員變量類型的信息,而是在第一次寫入的時候插入一些頭信息,以後再寫就不會再插入了。這實際是 Java 做的優化,通過該優化從而減少 socket 傳輸的開銷。
我想現在應該有人已經看出問題來了,它之所以可以這麼做優化,前提是持有 MyObject 的引用,也就是說,不會釋放掉 MyObject 的引用。如果你是長連接的方式(socket 中很常用),ObjectOutputStream 會一直持有你以前發送過的對象的引用,從而導致 JVM 在進行垃圾回收的時候不能回收之前發送的對象的實例,經過漫長時間的運行,最終導致內存溢出。這一點從我通過 Jprobe 跟蹤也得到了印證。
避免長連接的情況下出現內存溢出
下面我們來談談如何避免該問題,說到這裡我們就得提到 ObjectOutputStream 的 reset 方法了,JDK 文檔中是這麼解釋該方法的:
“重置將丟棄已寫入流中的所有對象的狀態。重新設置狀態,使其與新的 ObjectOutputStream 相同。將流中的當前點標記為 reset,相應的 ObjectInputStream 也將在這一點重置。以前寫入流中的對象不再被視為正位於流中。它們會再次被寫入流。”
就是說調用 reset 那麼就丟棄所持有對象的狀態(也就是釋放掉了對對象的引用),同時會在流中設置 reset 標識。
我們來把之前的代碼稍作修改,在進行一下測試來看看有什麼不同:
清單 8. 重置的方式多次寫入
FileOutputStream fos = new FileOutputStream("c:\\test.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
for (int i = 0; i < 5; i++) {
MyObject myObj = new MyObject();
myObj.str1 = "test1" + i;
myObj.str2 = "test2" + i;
oos.writeObject(myObj);
oos.reset();
}
fos.close();
我們來看一下加入 reset 後寫入文件的內容:
清單 9. 重置的方式寫入的內容
#sr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~ #xpt
#test10t #test20q ~ #ysr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t
#Ljava/lang/String;L #str2q ~ #xpt #test11t #test21q ~ #ysr
#com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~ #xpt
#test12t #test22q ~ #ysr #com.travelsky.test.MyObject €喳 +?^`# #L #str1t
#Ljava/lang/String;L #str2q ~ #xpt #test13t #test23q ~ #ysr
#com.travelsky.test.MyObject €喳 +?^`# #L #str1t #Ljava/lang/String;L #str2q ~
#xpt #test14t #test24q ~ #y
這次跟之前不同的,每一次寫入都加入了頭信息且每一次末尾都加入了 y,我想這個標識應該就是 reset 標識,至於具體是什麼,我們沒必要深究了。
結論及一些建議
通過上面一系列的測試,我們大概對 Object 流有了一定了解,那麼具體到我們日常編碼中到底該不該調用 reset 呢,這個我想不能一概而論了。我們通過測試也看到了,在不調用 reset 的方式下,Java 的優化對於減輕 socket 開銷還是很可觀的,當然代價是有的,那就是直到你調用 reset 或者是關閉輸出流之前,對於發送過的對象的實例是不會釋放的。
如果你的程序需要很長時間的運行,建議你還是調用 reset 避免最後內存溢出程序崩潰,但是如果你又要長時間運行,且發送的消息量又很大,那麼調用 reset 無疑會增加開銷,那麼這個時候最好的做法我覺得是自己實現一套機制,定時的調用 reset 或者是定量,比如查看到內存已經漲到一個水平後調用一下,這樣既可以避免內存無限的增長下去,又可以減少不少 socket 通信的開銷。