理解Java序列化
前言
Java物件是在JVM中產生的,若要將其進行傳輸或儲存到硬碟,就要將物件轉換為可傳輸的檔案流。而目前Java物件的轉換方式有:
- 利用Java的序列化功能序列成位元組(位元組流),一般是需要加密傳輸時使用。
- 將物件包裝成JSON字串(字元流),一般使用JSON工具進行轉換 。
- protoBuf工具(二進位制),效能好,效率高,位元組數很小,網路傳輸節省IO。但二進位制格式可讀性差。
序列化基礎
序列化:Serialization(序列化)是一種將物件以一連串的位元組描述的過程
反序列化:反序列化deserialization是一種將這些位元組重建成一個物件的過程
序列化機制演算法:
- 所有儲存到磁碟中的物件都有一個序列化編號
- 當程式試圖序列化一個物件時,程式先檢查該物件是否已經被序列化過。如果從未被序列化過,系統就會將該物件轉換成位元組序列並輸出;如果已經序列化過,將直接輸出一個序列化編號。
應用場景
持久化物件:把物件的位元組序列永久地儲存到硬碟上
Java中能夠在JVM中建立可複用的Java物件,但只用JVM執行時,物件才能存在,即物件的生命週期不可能比JVM生命週期更長。但實際情況可能遇到需要當JVM停止時也需要物件依舊存在,因而就需要對物件進行持久化,並在JVM停止的情況下,能夠對儲存的物件進行持久化。
物件複製:通過序列化,將物件儲存在記憶體中,可以再通過此資料得到多個物件的副本。
網路傳輸物件
序列化實現
實現Serializable,Externalizable兩個介面之一的類的物件才能被序列化,他們的區別主要有:
- Serializable序列化時不會呼叫預設的構造器,而Externalizable序列化時會呼叫預設構造器的!!!
- Serializable:一個物件想要被序列化,那麼它的類就要實現 此介面,這個物件的所有屬性(包括private屬性、包括其引用的物件)都可以被序列化和反序列化來儲存、傳遞。Externalizable:他是Serializable介面的子類,有時我們不希望序列化那麼多,可以使用這個介面,這個介面的writeExternal()和readExternal()方法可以指定序列化哪些屬性。
序列化物件注意事項
- 物件的類名、屬性都會被序列化;而方法、static屬性(靜態屬性)、transient屬性(即瞬態屬性)都不會被序列化(這也就是第4條注意事項的原因)雖然加static也能讓某個屬性不被序列化,但static不這麼用
- 要序列化的物件的引用屬性也必須是可序列化的,否則該物件不可序列化,除非以transient關鍵字修飾該屬性使其不用序列化。
- 反序列化地象時必須有序列化物件生成的class檔案(很多沒有被序列化的資料需要從class檔案獲取)
- 當通過檔案、網路來讀取序列化後的物件時,必須按實際的寫入順序讀取。
序列化例項
SerializeTest類實現了序列化介面,物件進行序列化,反序列化,最後檢視序列化物件儲存內容。專案程式碼如下:
public class SerializeTest implements Serializable {
private static final long serialVersionUID = 1L;
public int num = 1390;
public void serialized() {
try {
FileOutputStream fos = new FileOutputStream("serialize.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
SerializeTest serialize = new SerializeTest();
oos.writeObject(serialize);
oos.flush();
oos.close();
fos.close();
System.out.println("------序列化結束------");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
public void deserialized()
{
SerializeTest serialize = null;
try
{
FileInputStream fis = new FileInputStream("serialize.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
serialize = (SerializeTest) ois.readObject();
ois.close();
fis.close();
System.out.println("------反序列化結束------");
}
catch (ClassNotFoundException | IOException e)
{
e.printStackTrace();
}
System.out.println(serialize.num);
}
public static void main(String[] args) {
SerializeTest serialize = new SerializeTest();
serialize.serialized();
serialize.deserialized();
}
}
序列化物件讀取:
public class ReadSerialize {
public static void main(String[] args) {
try {
File file = new File("serialize.obj");
InputStream in = new FileInputStream(file);
byte buff[] = new byte[1024];
int len = 0;
while((len = in.read(buff)) != -1)
{
for(int i=0;i<len;i++)
{
System.out.printf("%02X ",buff[i]);
}
System.out.println();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
執行結果
AC ED 00 05 73
72 00 20 63 6F 6D 2E 6C 75 69 73 2E 73 65 72 69 61 6C 69 7A 65 2E 53 65 72 69 61 6C 69 7A 65 54 65 73 74 00 00 00 00 00 00 00 01 02 00 01
49 00 03 6E 75 6D
78 70
00 00 05 6E
注:%02x 格式控制: 以十六進位制輸出,2為指定的輸出欄位的寬度.如果位數小於2,則左端補0
結果分析:
第一部分是序列化檔案頭
AC ED:STREAM_MAGIC宣告使用了序列化協議
00 05:STREAM_VERSION序列化協議版本
73:TC_OBJECT宣告這是一個新的物件
第二部分是序列化類的描述
72:TC_CLASSDESC宣告這裡開始一個新class
00 20:class名字的長度是32位元組
63 6F 6D 2E 6C 75 69 73 2E 73 65 72 69 61 6C 69 7A 65 2E 53 65 72 69 61 6C 69 7A 65 54 65 73 74:類名(ASCII碼:com.luis.serialize.SerializeTest)
00 00 00 00 00 00 00 01: SerialVersionUID
02:標記號,改值宣告改物件支援序列化
00 01:該類所包含的域的個數為1
第三部分是物件中各個屬性項的描述
49:域型別,代表I,表示Int型別(又如:44,查ASCII碼錶為D,代表Double型別)
00 03:域名字的長度,為3
6E 75 6D: num屬性的名稱
第四部分輸出該物件父類資訊描述
這裡沒有父類,如果有,則資料格式與第二部分一樣
78:TC_ENDBLOCKDATA,物件塊接收標誌
70:TC_NULL,說明沒有其他超類的標誌
第五部分輸出物件的屬性的實際值
如果屬性項是一個物件,那麼這裡還將序列化這個物件,規則和第2部分一樣。
00 00 05 6E:1390的值
需要注意的是,序列化前後物件是不同的,它們的物件地址不同
序列化實際應用
序列化 ID
一般系統都會要求實現serialiable介面的類顯式的生明一個serialVersionUID,顯式定義serialVersionUID有如下兩種用途:
- 希望類的不同版本對序列化相容時,需要確保類的不同版本具有相同的serialVersionUID;
- 不希望類的不同版本對序列化相容時,需要確保類的不同版本具有不同的serialVersionUID。
虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。若兩個類的功能程式碼完全一致,但是序列化 ID 不同,他們無法相互序列化和反序列化。如下所示:
public class SerializeDemo implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
public class SerializeDemo implements Serializable {
private static final long serialVersionUID = 2L;
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
序列化ID在 Eclipse下提供了兩種生成策略,一個是固定的 1L,一個是隨機生成一個不重複的 long 型別資料(實際上是使用 JDK 工具生成),如果沒有特殊需求,就是用預設的 1L 就可以,這樣可以確保程式碼一致時反序列化成功。隨機的生成序列化,通過改變序列化 ID 可以用來限制某些使用者的使用。
應用例項
Façade 模式,它是為應用程式提供統一的訪問介面,Client 端通過 Façade Object 才可以與業務邏輯物件進行互動。而客戶端的 Façade Object 不能直接由 Client 生成,而是需要 Server 端生成,然後序列化後通過網路將二進位制物件資料傳給 Client,Client 負責反序列化得到 Façade 物件。該模式可以使得 Client 端程式的使用需要伺服器端的許可,同時 Client 端和伺服器端的 Façade Object 類需要保持一致。當伺服器端想要進行版本更新時,只要將伺服器端的 Façade Object 類的序列化 ID 再次生成,當 Client 端反序列化 Façade Object 就會失敗,也就是強制 Client 端從伺服器端獲取最新程式。
靜態變數的序列化
序列化儲存的是物件的狀態,靜態變數屬於類的狀態,因此 序列化並不儲存靜態變數。
如下,在SerializeStatic中,存入時num為8,當讀取出來時為10.
public class SerializeStatic implements Serializable {
private static final long serialVersionUID = 1L;
public static int num = 8;
public static void main(String[] args) {
try {
//初始時staticVar為5
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
out.writeObject(new SerializeStatic());
out.close();
//序列化後修改為10
SerializeStatic.num = 10;
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("result.obj"));
SerializeStatic t = (SerializeStatic) oin.readObject();
oin.close();
//再讀取,通過t.staticVar列印新的值
System.out.println(t.num);
} catch (Exception e) {
e.printStackTrace();
}
}
}
父類的序列化與 Transient 關鍵字
當對某個物件進行序列化時,系統會自動將該物件的所有屬性依次進行序列化,如果某個屬性引用到別一個物件,則被引用的物件也會被序列化。如果被引用的物件的屬性也引用了其他物件,則被引用的物件也會被序列化。 這就是遞迴序列化。有時,我們並不希望出現遞迴序列化,或是某個存敏感資訊(如銀行密碼)的屬性不被序列化,我們就可通過transient關鍵字修飾該屬性來阻止被序列化。
一個子類實現了 Serializable 介面,它的父類都沒有實現 Serializable 介面,序列化該子類物件,然後反序列化後輸出父類定義的某變數的數值要想將父類物件也序列化,就需要讓父類也實現Serializable 介面。如果父類不實現的話的,就需要有預設的無參的建構函式。 在父類沒有實現Serializable 介面時,虛擬機器是不會序列化父物件的,而一個 Java 物件的構造必須先有父物件,才有子物件,反序列化也不例外。所以反序列化時,為了構造父物件,只能呼叫父類的無參建構函式作為預設的父物件。因此當我們取 父物件的變數值時,它的值是呼叫父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化,否則的話,父類變數值都 是預設宣告的值,如 int 型的預設是 0,string型的預設是 null。
根據以上特性在具體的應用過程中可根據父類物件序列化的規則,我們可以將不需要被序列化的欄位抽取出來放到父類中,子類實現 Serializable 介面,父類不實現,根據父類序列化規則,父類的欄位資料將不被序列化。
對敏感欄位加密
伺服器端給客戶端傳送序列化物件資料,物件中有一些資料是敏感的,比如密碼字串等,希望對該密碼欄位在序列化時,進行加密,而客戶端如果擁有解密的金鑰,只有在客戶端進行反序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化物件的資料安全,這時可以使用transient關鍵字阻止序列化,這種方法雖然簡單方便,但被它修飾的屬性被完全隔離在序列化機制之外,導致了在反序列化時無法獲取該屬性的值,而通過在需要序列化的物件的Java類里加入writeObject()方法與readObject()方法可以控制如何序列化各屬性,甚至完全不序列化某些屬性(此時就transient一樣)。
在序列化過程中,虛擬機器會試圖呼叫物件類裡的 writeObject 和 readObject 方法,進行使用者自定義的序列化和反序列化,如果沒有這樣的方法,則預設呼叫是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。使用者自定義的 writeObject 和 readObject 方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動態改變序列化的數值。基於這個原理,可以在實際應用中得到使用,用於敏感欄位的加密工作。
public class EncryptPwd implements Serializable{
private static final long serialVersionUID = 1L;
private String password = "luis";
public String getPassword() {
return password;
}
private void writeObject(ObjectOutputStream out) {
try {
PutField putFields = out.putFields();
System.out.println("原密碼:" + password);
password = "encryption";//模擬加密
putFields.put("password", password);
System.out.println("加密後的密碼" + password);
out.writeFields();
} catch (IOException e) {
e.printStackTrace();
}
}
private void readObject(ObjectInputStream in) {
try {
GetField readFields = in.readFields();
Object object = readFields.get("password", "");
System.out.println("要解密的字串:" + object.toString());
password = "luis";//模擬解密,需要獲得本地的金鑰
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
try {
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
out.writeObject(new EncryptPwd());
out.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
"result.obj"));
EncryptPwd t = (EncryptPwd) oin.readObject();
System.out.println("解密後的字串:" + t.getPassword());
oin.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
執行結果:
原密碼:luis
加密後的密碼encryption
要解密的字串:encryption
解密後的字串:luis
應用例項
RMI 技術是完全基於 Java 序列化技術的,伺服器端介面呼叫所需要的引數物件來至於客戶端,它們通過網路相互傳輸。這就涉及 RMI 的安全傳輸的問題。一些敏感的欄位,如使用者名稱密碼(使用者登入時需要對密碼進行傳輸),我們希望對其進行加密,這時,就可以採用上面的方法在客戶端對密 碼進行加密,伺服器端進行解密,確保資料傳輸的安全性。
序列化儲存規則
在下面的SerializeRule中,同一物件兩次寫入檔案,並打印出寫入一次物件與寫入兩次物件的儲存大小,並將反序列化出的兩個物件進行對比。
public class SerializeRule implements Serializable{
private static final long serialVersionUID = 1L;
public static void main(String[] args) {
try {
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
SerializeRule rule = new SerializeRule();
// 試圖將物件兩次寫入檔案
out.writeObject(rule);
out.flush();
System.out.println(new File("result.obj").length());
out.writeObject(rule);
out.close();
System.out.println(new File("result.obj").length());
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("result.obj"));
// 從檔案依次讀出兩個檔案
SerializeRule r1 = (SerializeRule) oin.readObject();
SerializeRule r2 = (SerializeRule) oin.readObject();
oin.close();
// 判斷兩個引用是否指向同一個物件
System.out.println(r1 == r2);
} catch (Exception e) {
e.printStackTrace();
}
}
}
執行結果:
53
58
true
由結果發現,存入兩次物件,檔案並非預期的那樣為兩倍檔案的大小,且反序列化生成的兩個物件為ture。原來,Java 序列化機制為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一物件時,並不會再將物件的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。反序列化時,恢復引用關係,使得清單r1與r2指向唯一的物件,二者相等,輸出 true。該儲存規則極大的節省了儲存空間。
參考自:
本文中有極大部分參考了深入理解JAVA序列化與Java物件序列化詳解的內容
https://blog.csdn.net/zcl_love_wx/article/details/52126876
https://www.cnblogs.com/wade-luffy/p/5915499.html
https://www.cnblogs.com/wxgblogs/p/5849951.html