深入理解Java序列化機制

dreamGong發表於2018-07-24

1、Java序列化介紹

序列化是指物件通過寫出描述自己狀態的數值來記錄自己的過程,即將物件表示成一系列有序位元組,Java提供了將物件寫入流和從流中恢復物件的方法。物件能包含其它的物件,而其它的物件又可以包含另外的物件。Java序列化能夠自動的處理巢狀的物件。對於一個物件的簡單域,writeObject()直接將其值寫入流中。當遇到一個物件域時,writeObject()被再次呼叫,如果這個物件內嵌另一個物件,那麼,writeObject()又被呼叫,直到物件能被直接寫入流為止。程式設計師所需要做的是將物件傳入ObjectOutputStream的writeObject()方法,剩下的將有系統自動完成。

要實現序列化的類必須實現的java.io.Serializable或java.io.Externalizable介面,否則將產生一個NotSerializableException。該介面內部並沒有任何方法,它只是一個"tagging interface",僅僅"tags"它自己的物件是一個特殊的型別。類通過實現 java.io.Serializable介面以啟用其序列化功能。未實現此介面的類將無法使其任何狀態序列化或反序列化。可序列化類的所有子型別本身都是可序列化的。序列化介面沒有方法或欄位,僅用於標識可序列化的語義。Java的"物件序列化"能讓你將一個實現了Serializable介面的物件轉換成一組byte,這樣日後要用這個物件時候,你就能把這些byte資料恢復出來,並據此重新構建那個物件了。

2、序列化必要性及目的

Java中,一切都是物件,在分散式環境中經常需要將Object從這一端網路或裝置傳遞到另一端。這就需要有一種可以在兩端傳輸資料的協議。Java序列化機制就是為了解決這個問題而產生。

Java序列化支援的兩種主要特性:

  • Java 的RMI使本來存在於其他機器的物件可以表現出就象本地機器上的行為。
  • 將訊息發給遠端物件時,需要通過物件序列化來傳輸引數和返回值

Java序列化的目的(我目前能理解的):

  • 支援執行在不同虛擬機器上不同版本類之間的雙向通訊;
  • 提供對永續性和RMI的序列化;

3、關於序列化的一些例子

下面我們通過一個簡單的例子來看下Java預設支援的序列化。我們先定義一個類,然後將其序列化到檔案中,最後讀取檔案重新構建出這個物件。在序列化一個物件的時候,有幾點需要注意下:

  • 當一個物件被序列化時,只序列化物件的非靜態成員變數,不能序列化任何成員方法和靜態成員變數。
  • 如果一個物件的成員變數是一個物件,那麼這個物件的資料成員也會被儲存。
  • 如果一個可序列化的物件包含對某個不可序列化的物件的引用,那麼整個序列化操作將會失敗,並且會丟擲一個NotSerializableException。可以通過將這個引用標記為transient,那麼物件仍然可以序列化。對於一些比較敏感的不想序列化的資料,也可以採用該標識進行修飾。
    下面我們先通過一個簡單的例子來看一下Java內建的序列化過程。
class SuperClass implements Serializable{
    private String name;
    private int age;
    private String email;
    
    public String getName() {
    	return name;
    }
    
    public int getAge() {
    	return age;
    }
    
    public String getEmail() {
    	return email;
    }
    
    public SuperClass(String name,int age,String email) {
    	this.name=name;
    	this.age=age;
    	this.email=email;
    }
}
複製程式碼

下面我們來看下main方法裡面的序列化過程,程式碼如下:

public static void main(String[] args) throws IOException,ClassNotFoundException {
    	System.out.println("序列化物件開始!");
    	SuperClass superClass=new SuperClass("gong",27, "1301334028@qq.com");
    	File rootfile=new File("C:/data");
    	if(!rootfile.exists()) {
    		rootfile.mkdirs();
    	}
    	File file=new File("C:/data/data.txt");
    	if(!file.exists()) {
    		file.createNewFile();
    	}
    	FileOutputStream fileOutputStream=new FileOutputStream(file);
    	ObjectOutputStream objectOutputStream=new ObjectOutputStream(fileOutputStream);
    	objectOutputStream.writeObject(superClass);
    	objectOutputStream.flush();
    	objectOutputStream.close();
    	System.out.println("序列化物件完成!");
    	
    	System.out.println("反序列化物件開始!");
    	FileInputStream fileInputStream=new FileInputStream(new File("C:\\data\\data.txt"));
    	ObjectInputStream objectInputStream=new ObjectInputStream(fileInputStream);
    	SuperClass getObject=(SuperClass) objectInputStream.readObject();
    	System.out.println("反序列化物件資料:");
    	
    	System.out.println("name:"+getObject.getName()+"\nage:"+getObject.getAge()+"\nemail:"+getObject.getEmail());
}
複製程式碼

程式碼執行結果如下:

序列化物件開始!
序列化物件完成!
反序列化物件開始!
反序列化物件資料:
name:gong
age:27
email:1301334028@qq.com
複製程式碼

通過上面的例子,我們看到Java預設提供了序列化與反序列化機制,對於單個實體類來說,整個過程都是自動完成的,無需程式設計師進行額外的干預。如果我們想讓某些關鍵的域不參與序列化過程呢?Java提供了方法,接著往下看。

transient關鍵字與序列化

如果我們現在想讓上面SuperClass類走age和email不參與序列化過程,那麼只需要在其定義前面加上transient關鍵字即可:

private transient int age;
private transient String email;
複製程式碼

這樣我們在進行序列化的時候,位元組流中不不包含age和email的資料的,反序列的時候會賦予這兩個變數預設值。還是執行剛才的工程,這時候我們結果如下:

序列化物件開始!
序列化物件完成!
反序列化物件開始!
反序列化物件資料:
name:gong
age:0
email:null
複製程式碼

自定義序列化過程

如果預設的序列化過程不能滿足需求,我們也可以自定義整個序列化過程。這時候我們只需要在需要序列化的類中定義writeObject方法和readObject方法即可。我們還是以SuperClass為例,現在我們新增自定義的序列化過程,transient關鍵字讓Java內建的序列化過程忽略修飾的變數,我們通過自定義序列化過程,還是序列化age和email,我們來看看改動後的結果:

private String name;
private transient int age;
private transient String email;

public String getName() {
	return name;
}

public int getAge() {
	return age;
}

public String getEmail() {
	return email;
}

public SuperClass(String name,int age,String email) {
	this.name=name;
	this.age=age;
	this.email=email;
}

private void writeObject(ObjectOutputStream objectOutputStream) 
		throws IOException {
	objectOutputStream.defaultWriteObject();
	objectOutputStream.writeInt(age);
	objectOutputStream.writeObject(email);
}


private void readObject(ObjectInputStream objectInputStream) 
		throws ClassNotFoundException,IOException {
	objectInputStream.defaultReadObject();
	age=objectInputStream.readInt();
	email=(String)objectInputStream.readObject();
}
複製程式碼

執行結果如下:

反序列化物件資料:
name:gong
age:27
email:1301334028@qq.com
複製程式碼

我們看到,執行結果和預設的結果是一致的,我們通過自定義序列化機制,修改了預設的序列化過程(讓transient關鍵字失去了作用)。
注意:
細心的同學可能發現了我們在自定義序列化的過程中呼叫了defaultWriteObject()和defaultReadObject()方法。這兩個方法是預設的序列化過程呼叫的方法。如果我們自定義序列化過程僅僅呼叫了這兩個方法而沒有任何額外的操作,這其實和預設的序列化過程沒任何區別,大家可以試一下。

4、存在繼承關係下的序列化

子類支援序列化,超類不支援序列化

預設情況下是這樣的
子類實現了Serializable介面,父類沒有,父類中的屬性不能序列化(不報錯,資料丟失),但是在子類中屬性仍能正確序列化。
如果我們想在序列化的時候儲存父類的域,那麼在序列化子類例項的時候必須顯式的儲存父類的狀態。我們將前面的例子稍作修改:

    class SuperClass{
    protected String name;
    protected int age;
    
    public String getName() {
    	return name;
    }
    
    public int getAge() {
    	return age;
    }
    
    public SuperClass(String name,int age) {
    	this.name=name;
    	this.age=age;
    }
    }
    
    class DeriveClass extends SuperClass implements Serializable{
    private String email;
    private String address;
    
    public DeriveClass(String name,int age,String email,String address) {
    	super(name,age);
    	this.email=email;
    	this.address=address;
    }
    
    public String getEmail() {
    	return email;
    }
    
    public String getAddress() {
    	return address;
    }
    
    private void writeObject(ObjectOutputStream out) throws IOException {  
        out.defaultWriteObject();  
        out.writeObject(name);
        out.writeInt(age);
    }  
    
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {  
        in.defaultReadObject();  
        name=(String)in.readObject();
        age=in.readInt();
    }   
    
    @Override
    public String toString() {
    	return "name:"+getName()+"\nage:"+getAge()+"\nemail:"+getEmail()+"\naddress"+getAddress();
    }
}
複製程式碼

main方法我們修改為序列化子類物件即可:

DeriveClass superClass=new DeriveClass("gong",27,"1301334028@qq.com","NJ");
DeriveClass getObject=(DeriveClass) objectInputStream.readObject();
System.out.println("反序列化物件資料:");
System.out.println(getObject);
複製程式碼

執行程式碼發現報錯了,報錯如下:

Exception in thread "main" java.io.InvalidClassException: com.learn.example.DeriveClass; no valid constructor
	at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(Unknown Source)
	at java.io.ObjectStreamClass.checkDeserialize(Unknown Source)
	at java.io.ObjectInputStream.readOrdinaryObject(Unknown Source)
	at java.io.ObjectInputStream.readObject0(Unknown Source)
	at java.io.ObjectInputStream.readObject(Unknown Source)
	at com.learn.example.RunMain.main(RunMain.java:88)
複製程式碼

我們來仔細分析下,為什麼會這樣。DeriveClass支援序列化,其父類不支援序列化,所以這種情況下,子類在序列化的時候需要額外的序列化父類的域(如果有這個需要的話)。那麼在反序列的時候,由於構建DeriveClass例項的時候需要先呼叫父類的建構函式,然後才是自己的建構函式。反序列化時,為了構造父物件,只能呼叫父類的無參建構函式作為預設的父物件,因此當我們取父物件的變數值時,它的值是呼叫父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化。或者在readObject方法中進行賦值。 我們只需要在SuperClass中新增一個空的建構函式即可:

public SuperClass() {}
複製程式碼

父類支援序列化

這種情況下,子類也支援序列化操作的。一般情況下,無需做特殊的操作即可。

5、序列化與serialVersionUID

上面的例子,我們都沒有看到這個serialVersionUID這個欄位,為什麼我們也能正常的序列化也反序列化呢?這是因為Eclipse預設為我們生成了一個序列化ID。
Eclipse下提供了兩種生成策略,一個是固定的1L,一個是隨機生成一個不重複的long型別資料(實際上是使用JDK工具生成),在這裡有一個建議,如果沒有特殊需求,就是用預設的1L就可以,這樣可以確保程式碼一致時反序列化成功。
注意:虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,一個非常重要的一點是兩個類的序列化ID是否一致(就是 privatestatic final long serialVersionUID = 1L)雖然兩個類的功能程式碼完全一致,但是序列化ID不同,他們無法相互序列化和反序列化(這種情況特別是在網路傳輸後,遠端建立物件的時候需要注意)

6、序列化儲存

通過前面的例子,我們將資料序列化到data.txt檔案中,下面我們通過二進位制檢視工具來看下Java序列化後的位元組流是如何儲存到檔案中的,它的格式是怎麼樣的?我們將上面的SuperClass類改造下:

class SuperClass implements Serializable{
	
	private static final int serialVersionUID=1;
	
	protected String name;
	protected int age;
	
	public SuperClass() {}
	
	public String getName() {
		return name;
	}
	
	public int getAge() {
		return age;
	}
	
	public SuperClass(String name,int age) {
		this.name=name;
		this.age=age;
	}
	
	 @Override
    public String toString() {
    	return "name:"+getName()+"\nage:"+getAge();
    }
}
複製程式碼

寫入的資料如下:

SuperClass superClass=new SuperClass("gong",27);
複製程式碼

下面我們開啟data.txt來看下儲存的內容:具體的儲存內容如圖所示:

深入理解Java序列化機制
下面我們就來詳細解釋每一步的內容。

第1部分是序列化檔案頭

  • AC ED:STREAM_MAGIC序列化協議
  • 00 05:STREAM_VERSION序列化協議版本
  • 73:TC_OBJECT宣告這是一個新的物件

第2部分是要序列化的類的描述,在這裡是SerializableObject類

  • 72:TC_CLASSDESC宣告這裡開始一個新的class
  • 00 1C:十進位制的28,表示class名字的長度是28個位元組
  • 63 6F 6D ... 61 73 73:表示的是“com.learn.example.SuperClass”這一串字元,可以數一下確實是28個位元組
  • 00 00 00 00 00 00 00 01:SerialVersion,我們在這個類裡面設定的值是1,如果我們不設定的話,Eclipse會為我們自動設定一個。
  • 02:標記號,宣告該物件支援序列化
  • 00 02:該類所包含的域的個數為2個

第3部分是物件中各個屬性項的描述

  • 4C:字元"L",表示該屬性是一個物件型別而不是一個基本型別
  • 00 03十進位制的3,表示屬性名的長度
  • 61 67 65:字串“age”,屬性名
  • 4C:字元"L",表示該屬性是一個物件型別而不是一個基本型別
  • 00 04十進位制的4,表示屬性名的長度
  • 6E 61 6D 65:字串“name”,屬性名
  • 74:TC_STRING,代表一個new String,用String來引用物件

第4部分是該物件父類的資訊,如果沒有父類就沒有這部分。有父類和第2部分差不多

  • 00 12:十進位制的18,表示父類的長度
  • 4C 6A 61 ... 6E 67 3B:“L/java/lang/String;”表示的是父類屬性
  • 78:TC_ENDBLOCKDATA,物件塊結束的標誌
  • 70:TC_NULL,說明沒有其他超類的標誌

第5部分輸出物件的屬性項的實際值,如果屬性項是一個物件,這裡還將序列化這個物件,規則和第2部分一樣

  • 00 00 00 1B:屬性值 age=27
  • 74:TC_STRING,代表一個new String,用String來引用物件
  • 00 04十進位制的4,表示屬性名的長度
  • 67 6F 6E 67 name屬性的值gong
    從以上對於序列化後的二進位制檔案的解析,我們可以得出以下幾個關鍵的結論:
  • 1、序列化之後儲存的是物件的資訊
  • 2、被宣告為transient的屬性不會被序列化,這就是transient關鍵字的作用
  • 3、被宣告為static的屬性不會被序列化,這個問題可以這麼理解,序列化儲存的是物件的狀態,但是static修飾的變數是屬於類的而不是屬於物件的,因此序列化的時候不會序列化它

相關文章