I/O篇(4)——物件序列化
一.什麼是物件序列化
Java平臺允許我們在記憶體中建立可複用的Java物件,但一般情況下,只有當JVM處於執行時,這些物件才可能存在,即這些物件的生命週期不會比JVM的生命週期更長。但在現實應用中,就可能要求在JVM停止執行之後能夠儲存指定的物件(也就是持久化: 持久化指的是一個物件的生命週期並不取決於程式是否正在執行.),並在將來能重新讀取被儲存的物件。Java物件序列化就能夠幫助我們實現該功能。
Java的物件序列化是將那些實現了Serializable介面的物件轉換成一個字序列,並能夠在以後將這個位元組序列完全恢復為原來的物件。必須注意的是,物件序列化儲存的是物件的”狀態”,即它的成員變數。由此可知,預設的物件序列化不會關注類中的靜態變數。(但是如果想序列化靜態變數也可以實現,下面會提到)
除了在持久化物件時會用到物件序列化之外,當使用RMI(遠端方法呼叫),或在網路中傳遞物件時,都會用到物件序列化。
二.序列化的前提
如果需要讓某個物件支援序列化機制, 則必須讓他的類是可序列化的(serializable). 該類必須實現如下介面之一:
- Serializable:標記介面,無須實現任何方法,只是表明該類是可序列化的。
- Externalizable:Serializable的子介面, 使用該介面之後,之前基於Serializable介面的序列化機制就將失效。當使用該介面時,序列化的細節需要由程式設計師去完成。
所有在網路上傳輸的物件都應該是可序列化的,否則將會出現異常;所有需要儲存到磁盤裡的物件的類都必須可序列化
通常建議:程式建立的每個JavaBean類都實現Serializable
三.序列化的實現
一般用ObjectOutputStream/ObjectIputStream來實現物件的序列化和反序列化
//要序列化和反序列化的物件類
public class Person implements Serializable{
private String name;
private int age;
public Person(String name , int age){
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
//物件序列化
public static void main(String[] args) {
Person per = new Person("Mr.Z", 18);
String destPath = "D:/wotest.out";
File dest = new File(destPath);
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(dest)));
oos.writeObject(per);
oos.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
//物件反序列化
public static void main(String[] args) {
String srcPath = "D:/wotest.out";
File src = new File(srcPath);
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream(src)));
//這裡返回的是Object型別的物件,要強制轉型成其真實型別(這裡強轉為Person物件)
Person per = (Person) ois.readObject();
ois.close();
System.out.println("name:"+per.getName()+","+"age:"+per.getAge());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
四.序列化應注意的問題
1.物件的類名, 例項變數(基本型別/陣列/引用物件)都會被序列化; 而方法,類變數(即static靜態變數),transient例項變數都不會被序列化.
2.反序列化讀取的僅僅是Java物件的資料,而不是Java類,因此採用反序列化恢復Java物件時,必須提供Java物件所屬的class檔案,否則會引發ClassNotFoundException異常;
3.如果我們向檔案中使用序列化機制寫入了多個Java物件,使用反序列化機制恢復物件必須按照實際寫入的順序讀取。
4.在對一個Serializable物件進行還原的過程中,沒有呼叫任何構造器,包括預設的構造器,整個物件都是通過從InputStream中取得資料回覆而來的.
5.當一個可序列化類有多個父類時(包括直接父類和間接父類),這些父類要麼有無參的構造器,要麼也是可序列化的,否則反序列化將丟擲InvalidClassException異常。如果父類是不可序列化的,只是帶有無引數的構造器,則該父類定義的Field值不會被序列化到二進位制流中
詳解:
要想將父類物件也序列化,就需要讓父類也實現Serializable 介面。如果父類不實現的話的,就需要有預設的無參的建構函式。在父類沒有實現 Serializable 介面時,虛擬機器是不會序列化父物件的,而一個 Java 物件的構造必須先有父物件,才有子物件,反序列化也不例外。所以反序列化時,為了構造父物件,只能呼叫父類的無參建構函式作為預設的父物件。因此當我們取父物件的變數值時,它的值是呼叫父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化,否則的話,父類變數值都是預設宣告的值,如 int 型的預設是 0,string 型的預設是 null。
Transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如 int 型的是 0,物件型的是 null。
6.如果某個類的成員變數不是基本型別或String型別,而是另一個引用型別, 那麼這個引用類必須是可序列化的, 否則擁有該型別成員變數的類也是不可序列化的.
7.所有儲存到磁碟(或傳輸到網路中)的物件都有一個序列化編號,虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。即使兩個類的功能程式碼完全一致,但是序列化 ID 不同,他們也 無法相互序列化和反序列化。
8.當程式試圖序列化一個物件時, 程式將先檢查該物件是否已經被序列化過, 只有該物件(在本次虛擬機器的上下文Context中)從未被序列化過, 系統才會將該物件轉換成位元組序列並輸出,如果某個物件已經序列化過(即使該物件的例項變數後來發生了改變), 程式將不再重新序列化該物件
9.物件序列化不僅儲存了物件的全景圖,而且還能追蹤物件內所包含的所有引用並儲存哪些物件.
10.如果僅僅只是讓某個類實現Serializable介面,而沒有其它任何處理的話,則就是使用預設序列化機制。使用預設機制,在序列化物件時,不僅會序列化當前物件本身,還會對該物件引用的其它物件也進行序列化,同樣地,這些其它物件引用的另外物件也將被序列化,以此類推。所以,如果一個物件包含的成員變數是容器類物件,而這些容器所含有的元素也是容器類物件,那麼這個序列化的過程就會較複雜,開銷也較大。
五.序列化版本
Java序列化機制允許為序列化的類提供一個private static final long serialVersionUID = xxxL;值, 該值用於標識該Java類的序列化版本; 一個類升級之後, 只要他的serialVersionUID值不變, 序列化機制也會把它們當成同一個序列化版本(由於提供了serialVersionUID之後JVM不需要再次計算該值,因此還有個小小的效能好處).
如果不顯式定義serialVersionUID的值,可能會造成以下問題:
- 該值將由JVM根據類的相關資訊計算,而修改後的類的計算結果與修改前的類的計算結果往往不同,從而造成物件的反序列化因為類的版本不相容而失敗.
- 不利於程式在不同JVM之間移植, 因為不同的編譯器對該變數的計算策略可能不同, 而從造成類雖然沒有改變, 但因為JVM的不同, 也會出現序列化版本不相容而導致無法正確反序列化的現象.
六.序列化過程的控制(自定義序列化)
在一些的場景下, 如果一個類裡面包含的某些變數不希望對其序列化(或某個例項變數是不可序列化的,因此不希望對該例項變數進行遞迴序列化,以避免引發java.io.NotSerializableException異常); 或者將來這個類的實現可能改動(最大程度的保持版本相容), 或者我們需要自定義序列化規則, 在這種情景下我們可選擇實用自定義序列化.
1.Externalizable介面
可以通過實現Externalizable介面來對序列化過程進行控制,如果實現該介面,那麼序列化操作的細節都需要自己實現.這個Externalizable介面繼承了Serializable介面,同時增添了兩個方法:wirteExternal(ObjectOutput out)和readExternal(ObjectInput in).這兩個方法會在序列化和反序列化的過程中被自動呼叫,以便執行一些特殊操作
要注意的是,對於Serializable物件來說,物件完全以它儲存的二進位制資料為基礎來構造而不呼叫構造器.但對於一個Externalizable物件,會呼叫其無參構造器建立一個新的物件,然後呼叫readExternal(ObjectInput in)方法,根據程式將被儲存物件的欄位的值分別填充到新物件中.由於這個原因,實現Externalizable介面的類必須要提供一個無參的構造器,且它的訪問許可權為public。
public class Blip implements Externalizable {
private int i;
private String s;
public Blip(){
System.out.println("Blip Constructor");
}
public Blip(int i,String s){
System.out.println("Blip(int i,String s)");
this.i = i;
this.s = s;
}
public String toString(){
return s+i;
}
public void writeExternal(ObjectOutput out) throws IOException {
System.out.println("Blip.writeExternal");
//You must do this
out.writeObject(s);
out.writeInt(i);
}
public void readExternal(ObjectInput in) throws IOException,ClassNotFoundException {
System.out.println("Bilp.readExternal");
//You must do this
s = (String) in.readObject();
i = in.readInt();
}
public static void main(String[] args) {
System.out.println("Constructing objects:");
Blip blip = new Blip(47,"A String");
System.out.println(blip);
try {
System.out.println("Saving object:");
ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("D:/blip.out")));
oos.writeObject(blip);
oos.close();
System.out.println("Recovering blip:");
ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream("D:/blip.out")));
try {
blip = (Blip) ois.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println(blip);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
輸出結果:
Constructing objects:
Blip(int i,String s)
A String47
Saving object:
Blip.writeExternal
Recovering blip:
Blip Constructor
Bilp.readExternal
A String47
結果分析:
其中,屬性s和i只在有參構造器中初始化,而不是在預設的構造其中初始化.這意味著如果不再readExternal()中初始化s和i,那麼s就會為null,而i就會為0(因為建立物件的第一步中將物件的儲存空間清理為0).如果註釋掉”You must do this”後面的兩行程式碼,然後執行程式,就會發現當物件被還原後,s是null,i是0
我們如果從一個Externalizable物件繼承,通常需要呼叫基類版本的writeExternal()和readExternal()來為基類元件提供恰當的儲存和回覆功能.
2.Serializable介面
如果只是實現預設的序列化,那麼只要實現該標記介面就行了,如果需要對序列化過程進行控制除了Externalizable介面外,Serializable介面也可以完成,有兩種方法:
(1)transient(瞬時)關鍵字(transient關鍵字只能和Serializable物件一起使用,且只能修飾字段不可修飾其他成員,因為Externalizable物件在預設情況下不儲存它們自己的任何欄位)
當我們對序列化進行控制時,可能某個特定子物件不想讓java的序列化機制自動儲存與恢復.如果子物件表示的是我們不希望將其序列化的敏感資訊(如密碼),通常就會面臨這樣的情況.即使物件中的這些資訊是private屬性,一經序列化處理,人們就可以通過讀取檔案或者攔截網路傳輸的方式來訪問到它.
所以當我們正在操作一個Serializable物件時,為了能夠予以控制,可以用transient關鍵字逐個屬性地關閉序列化.
例如:某個Login物件儲存某個特定的登入資訊.登入的合法性通過校驗之後,我們想把資料儲存下來,但不包括密碼.為了做到這一點,最簡單的辦法是實現Serializable,並將password屬性標記為transient.
程式碼如下:
public class Login implements Serializable {
private Date date = new Date();
private String username;
private transient String password;
public Login(String username,String password){
this.username = username;
this.password = password;
}
public String toString(){
return "Login info:\n username:"+username+"\n date:"+date+"\n password:"+password;
}
public static void main(String[] args){
Login lg = new Login("Mr.Z","HelloWorld");
System.out.println("login lg ="+lg);
try {
ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("D:/login.out")));
oos.writeObject(lg);
oos.close();
TimeUnit.SECONDS.sleep(1);
ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream("D:/login.out")));
System.out.println("Recovering object at "+new Date());
lg = (Login) ois.readObject();
System.out.println("login lg ="+lg);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
輸出結果:
login lg =Login info:
username:Mr.Z
date:Fri Jun 03 13:10:13 CST 2016
password:HelloWorld
Recovering object at Fri Jun 03 13:10:14 CST 2016
login lg =Login info:
username:Mr.Z
date:Fri Jun 03 13:10:13 CST 2016
password:null
結果分析:
可以看到,其中的date和username屬性是普通的(不是transient的),所以它會被自動序列化.而password是transient的,所以不會被自動儲存到磁碟,而且,自動序列化機制也不會嘗試去恢復它.當物件被恢復時,password屬性就會變成null.
(2)為Serializable物件新增writeObject(OutputStream stream)/readObject(InputStream stream)方法
(可以新增的方法還有:readObjectNoData(),writeReplace(),readResolve())
我們還可以實現Serializable介面,並新增(注意是新增,不是覆蓋或實現,因為這兩個方法不是Serializable介面中的方法)writeObject(OutputStream stream)/readObject(InputStream stream)方法來進行序列化過程的控制.這樣一旦物件被序列化或者被反序列化時,就會自動地呼叫這兩個方法.也就是說,只要我們添加了這兩個方法,就會使用它們而不是預設的序列化機制.
新增的這兩個方法有準確的方法特徵簽名(新增的時候不能更改):
- private void writeObject(ObjectOutputStream stream) throws IOException;
- private void readObject(ObjectInputStream stream) throws IOException;
實際上這兩個方法是由ObjectOutputStream /ObjectInputStream 物件的writeObject()/readObject()方法自動呼叫的.
在呼叫ObjectOutputStream.writeObject()時,會檢查所傳遞的Serializable物件看看是否實現了它自己的writeObject()方法,如果實現了,就跳過正常的序列化過程並呼叫它的writeObject()方法,如果沒有實現就進行預設的序列化.readObject()的情形與此相同
另外在writeObjet()內部,我們可以呼叫ObjectOutputStream .defaultWriteObject()來執行預設的writeObject().類似的,在readObject內部,我們可以但呼叫ObjectInputStream .defaultReadObject().而且建議readObject()/writeObject()的方法內首先呼叫defaultReadObject()/defaultWriteObject();
public class SerialCtl implements Serializable {
private String a;
private transient String b;
public SerialCtl(String a,String b){
this.a = "Not Transient"+a;
this.b = "Transient"+b;
}
public String toString(){
return a+"\n"+b;
}
private void writeObject(ObjectOutputStream os) throws IOException{
os.defaultWriteObject();
os.writeObject(b);
}
private void readObject(ObjectInputStream is) throws IOException, ClassNotFoundException{
is.defaultReadObject();
b = (String) is.readObject();
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
SerialCtl sc = new SerialCtl("Test1", "Test2");
System.out.println("Before:\n"+sc);
ByteArrayOutputStream buf = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(buf);
oos.writeObject(sc);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(buf.toByteArray()));
SerialCtl sc2 = (SerialCtl) ois.readObject();
System.out.println("After:\n"+sc2);
}
}
輸出結果:
Before:
Not TransientTest1
TransientTest2
After:
Not TransientTest1
TransientTest2
結果分析:
這個例子中有一個String屬性是普通屬性,而另一個String屬性是transient屬性,用來證明非transient屬性由defaultWriteObject()方法儲存,而transient屬性必須在程式中明確儲存和恢復.屬性是在構造器內部而不是定義處進行初始化的,以此可以證明它們在反序列化還原期間沒有被一些自動化機制初始化.
如果我們打算使用預設機制寫入物件的廢transient部分,那麼必須呼叫defaultWriteObject()方法作為writeObject()中的第一個操作,並讓defaultReadObject()方法作為readObject()中的第一個操作.
在main()中,建立SerialCtl物件,然後將其序列化到ObjectOutputStream中.序列化發生在這行程式碼中:oos.writeObject(),writeObject()方法必須檢查sc,判斷它是否擁有自己的writeObject()方法(不是檢查介面——這裡根本沒有介面,也不是檢查型別,而是利用反射來真正的搜尋方法).如果有,那麼就會使用它.對readObject()也採用了類似的方法.
七.readResolve()方法
當我們使用Singleton模式時,應該是期望某個類的例項應該是唯一的,但如果該類是可序列化的,那麼情況可能會略有不同。
無論是實現Serializable介面,或是Externalizable介面,當從I/O流中讀取物件時,readResolve()方法都會被呼叫到。實際上就是用readResolve()中返回的物件直接替換在反序列化過程中建立的物件,而被建立的物件則會被垃圾回收掉。
public class Person implements Serializable {
private static class InstanceHolder {
private static final Person instatnce = new Person("John", 31, Gender.MALE);
}
public static Person getInstance() {
return InstanceHolder.instatnce;
}
private String name = null;
private Integer age = null;
private Gender gender = null;
private Person() {
System.out.println("none-arg constructor");
}
private Person(String name, Integer age, Gender gender) {
System.out.println("arg constructor");
this.name = name;
this.age = age;
this.gender = gender;
}
}
public class SimpleSerial {
public static void main(String[] args) throws Exception {
File file = new File("person.out");
ObjectOutputStream oout = new ObjectOutputStream(new FileOutputStream(file));
oout.writeObject(Person.getInstance()); // 儲存單例物件
oout.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
Object newPerson = oin.readObject();
oin.close();
System.out.println(newPerson);
System.out.println(Person.getInstance() == newPerson); // 將獲取的物件與Person類中的單例物件進行相等性比較
}
}
輸出結果:
arg constructor
[John, 31, MALE]
false
結果分析:
從檔案person.out中獲取的Person物件與Person類中的單例物件並不相等。為了能在序列化過程仍能保持單例的特性,可以在Person類中新增一個readResolve()方法,在該方法中直接返回Person的單例物件.
public class Person implements Serializable {
private static class InstanceHolder {
private static final Person instatnce = new Person("John", 31, Gender.MALE);
}
public static Person getInstance() {
return InstanceHolder.instatnce;
}
private String name = null;
private Integer age = null;
private Gender gender = null;
private Person() {
System.out.println("none-arg constructor");
}
private Person(String name, Integer age, Gender gender) {
System.out.println("arg constructor");
this.name = name;
this.age = age;
this.gender = gender;
}
private Object readResolve() throws ObjectStreamException {
return InstanceHolder.instatnce;
}
}
輸出結果:
arg constructor
[John, 31, MALE]
true
八.序列化儲存規則
1.下面程式碼將輸出什麼:
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
Test test = new Test();
//試圖將物件兩次寫入檔案
out.writeObject(test);
out.flush();
System.out.println(new File("result.obj").length());
out.writeObject(test);
out.close();
System.out.println(new File("result.obj").length());
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("result.obj"));
//從檔案依次讀出兩個檔案
Test t1 = (Test) oin.readObject();
Test t2 = (Test) oin.readObject();
oin.close();
//判斷兩個引用是否指向同一個物件
System.out.println(t1 == t2);
輸出結果:
31
36
true
結果分析:
Java 序列化機制為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一物件時,並不會再將物件的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。反序列化時,恢復引用關係,使得程式碼中的 t1 和 t2 指向唯一的物件,二者相等,輸出 true。該儲存規則極大的節省了儲存空間。
2.下面程式碼將輸出什麼:
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
Test test = new Test();
test.i = 1;
out.writeObject(test);
out.flush();
test.i = 2;
out.writeObject(test);
out.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("result.obj"));
Test t1 = (Test) oin.readObject();
Test t2 = (Test) oin.readObject();
System.out.println(t1.i);
System.out.println(t2.i);
輸出結果:
1
1
結果分析:
第一次寫入物件以後,第二次再試圖寫的時候,虛擬機器根據引用關係知道已經有一個相同物件已經寫入檔案,因此只儲存第二次寫的引用,所以讀取時,都是第一次儲存的物件。讀者在使用一個檔案多次 writeObject 需要特別注意這個問題。