1、前言
《手冊》第 9 頁 “OOP 規約” 部分有一段關于序列化的約定
【強制】當序列化類新增屬性時,請不要修改 serialVersionUID 字段,以避免反序列失敗;如果完全不兼容升級,避免反序列化混亂,那么請修改 serialVersionUID 值。
說明:注意 serialVersionUID 值不一致會拋出序列化運行時異常。
我們應該思考下面幾個問題:
序列化和反序列化到底是什么?
它的主要使用場景有哪些?
Java 序列化常見的方案有哪些?
各種常見序列化方案的區別有哪些?
實際的業務開發中有哪些坑點?
接下來將從這幾個角度去研究這個問題。
2. 序列化和反序列化是什么?為什么需要它?
序列化是將內存中的對象信息轉化成可以存儲或者傳輸的數據到臨時或永久存儲的過程。而反序列化正好相反,是從臨時或永久存儲中讀取序列化的數據并轉化成內存對象的過程。
那么為什么需要序列化和反序列化呢?
希望大家能夠養成從本源上思考這個問題的思維方式,即思考它為什么會出現,而不是單純記憶。
大家可以回憶一下,平時都是如果將文字文件、圖片文件、視頻文件、軟件安裝包等傳給小伙伴時,這些資源在計算機中存儲的方式是怎樣的。
進而再思考,Java 中的對象如果需要存儲或者傳輸應該通過什么形式呢?
我們都知道,一個文件通常是一個 m 個字節的序列:B0, B1, …, Bk, …, Bm-1。所有的 I/O 設備(例如網絡、磁盤和終端)都被模型化為文件,而所有的輸入和輸出都被當作對應文件的讀和寫來執行。
因此本質上講,文本文件,圖片、視頻和安裝包等文件底層都被轉化為二進制字節流來傳輸的,對方得文件就需要對文件進行解析,因此就需要有能夠根據不同的文件類型來解碼出文件的內容的程序。
大家試想一個典型的場景:如果要實現 Java 遠程方法調用,就需要將調用結果通過網路傳輸給調用方,如果調用方和服務提供方不在一臺機器上就很難共享內存,就需要將 Java 對象進行傳輸。而想要將 Java 中的對象進行網絡傳輸或存儲到文件中,就需要將對象轉化為二進制字節流,這就是所謂的序列化。存儲或傳輸之后必然就需要將二進制流讀取并解析成 Java 對象,這就是所謂的反序列化。
序列化的主要目的是:方便存儲到文件系統、數據庫系統或網絡傳輸等。
實際開發中常用到序列化和反序列化的場景有:
遠程方法調用(RPC)的框架里會用到序列化。
將對象存儲到文件中時,需要用到序列化。
將對象存儲到緩存數據庫(如 Redis)時需要用到序列化。
通過序列化和反序列化的方式實現對象的深拷貝。
3. 常見的序列化方式
常見的序列化方式包括 Java 原生序列化、Hessian 序列化、Kryo 序列化、JSON 序列化等。
3.1 Java 原生序列化
正如前面章節講到的,對于 JDK 中有的類,最好的學習方式之一就是直接看其源碼。
Serializable 的源碼非常簡單,只有聲明,沒有屬性和方法:
// 注釋太長,省略
public interface Serializable {
}
在學習源碼注釋之前,希望大家可以站在設計者的角度,先思考一個問題:如果一個類序列化到文件之后,類的結構發生變化還能否保證正確地反序列化呢?
答案顯然是不確定的。
那么如何判斷文件被修改過了呢? 通常可以通過加密算法對其進行簽名,文件作出任何修改簽名就會不一致。但是 Java 序列化的場景并不適合使用上述的方案,因為類文件的某些位置加個空格,換行等符號類的結構沒有發生變化,這個簽名就不應該發生變化。還有一個類新增一個屬性,之前的屬性都是有值的,之前都被序列化到對象文件中,有些場景下還希望反序列化時可以正常解析,怎么辦呢?
那么是否可以通過約定一個唯一的 ID,通過 ID 對比,不一致就認為不可反序列化呢?
實現序列化接口后,如果開發者不手動指定該版本號 ID 怎么辦?
既然 Java 序列化場景下的 “簽名” 應該根據類的特點生成,我們是否可以不指定序列化版本號就默認根據類名、屬性和函數等計算呢?
如果針對某個自己定義的類,想自定義序列化和反序列化機制該如何實現呢?支持嗎?
帶著這些問題我們繼續看序列化接口的注釋。
Serializable 的源碼注釋特別長,其核心大致作了下面的說明:
Java 原生序列化需要實現 Serializable 接口。序列化接口不包含任何方法和屬性等,它只起到序列化標識作用。
一個類實現序列化接口則其子類型也會繼承序列化能力,但是實現序列化接口的類中有其他對象的引用,則其他對象也要實現序列化接口。序列化時如果拋出 NotSerializableException 異常,說明該對象沒有實現 Serializable 接口。
每個序列化類都有一個叫 serialVersionUID 的版本號,反序列化時會校驗待反射的類的序列化版本號和加載的序列化字節流中的版本號是否一致,如果序列化號不一致則會拋出 InvalidClassException 異常。
強烈推薦每個序列化類都手動指定其 serialVersionUID,如果不手動指定,那么編譯器會動態生成默認的序列化號,因為這個默認的序列化號和類的特征以及編譯器的實現都有關系,很容易在反序列化時拋出 InvalidClassException 異常。建議將這個序列化版本號聲明為私有,以避免運行時被修改。
實現序列化接口的類可以提供自定義的函數修改默認的序列化和反序列化行為。
自定義序列化方法:
private void writeObject(ObjectOutputStream out) throws IOException;
自定義反序列化方法:
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException;
通過自定義這兩個函數,可以實現序列化和反序列化不可序列化的屬性,也可以對序列化的數據進行數據的加密和解密處理。
3.2 Hessian 序列化
Hessian 是一個動態類型,二進制序列化,也是一個基于對象傳輸的網絡協議。Hessian 是一種跨語言的序列化方案,序列化后的字節數更少,效率更高。Hessian 序列化會把復雜對象的屬性映射到 Map 中再進行序列化。
3.3 Kryo 序列化
Kryo 是一個快速高效的 Java 序列化和克隆工具。Kryo 的目標是快速、字節少和易用。Kryo 還可以自動進行深拷貝或者淺拷貝。Kryo 的拷貝是對象到對象的拷貝而不是對象到字節,再從字節到對象的恢復。Kryo 為了保證序列化的高效率,會提前加載需要的類,這會帶一些消耗,但是這是序列化后文件較小且反序列化非常快的重要原因。
3.4 JSON 序列化
JSON (JavaScript Object Notation) 是一種輕量級的數據交換方式。JSON 序列化是基于 JSON 這種結構來實現的。JSON 序列化將對象轉化成 JSON 字符串,JSON 反序列化則是將 JSON 字符串轉回對象的過程。常用的 JSON 序列化和反序列化的庫有 Jackson、GSON、Fastjson 等。
4.Java 常見的序列化方案對比
我們想要對比各種序列化方案的優劣無外乎兩點,一點是查資料,一點是自己寫代碼驗證。
4.1 Java 原生序列化
Java 序列化的優點是:對對象的結構描述清晰,反序列化更安全。主要缺點是:效率低,序列化后的二進制流較大。
4.2 Hessian 序列化
Hession 序列化二進制流較 Java 序列化更小,且序列化和反序列化耗時更短。但是父類和子類有相同類型屬性時,由于先序列化子類再序列化父類,因此反序列化時子類的同名屬性會被父類的值覆蓋掉,開發時要特別注意這種情況。
Hession2.0 序列化二進制流大小是 Java 序列化的 50%,序列化耗時是 Java 序列化的 30%,反序列化的耗時是 Java 序列化的 20%。
編寫待測試的類:
@Data
public class PersonHessian implements Serializable {
private Long id;
private String name;
private Boolean male;
}
@Data
public class Male extends PersonHessian {
private Long id;
}
編寫單測來模擬序列化繼承覆蓋問題:
/**
* 驗證Hessian序列化繼承覆蓋問題
*/
@Test
public void testHessianSerial() throws IOException {
HessianSerialUtil.writeObject(file, male);
Male maleGet = HessianSerialUtil.readObject(file);
// 相等
Assert.assertEquals(male.getName(), maleGet.getName());
// male.getId()結果是1,maleGet.getId()結果是null
Assert.assertNull(maleGet.getId());
Assert.assertNotEquals(male.getId(), maleGet);
}
上述單測示例驗證了:反序列化時子類的同名屬性會被父類的值覆蓋掉的問題。
4.3 Kryo 序列化
Kryo 優點是:速度快、序列化后二進制流體積小、反序列化超快。但是缺點是:跨語言支持復雜。注冊模式序列化更快,但是編程更加復雜。
4.4 JSON 序列化
JSON 序列化的優勢在于可讀性更強。主要缺點是:沒有攜帶類型信息,只有提供了準確的類型信息才能準確地進行反序列化,這點也特別容易引發線上問題。
下面給出使用 Gson 框架模擬 JSON 序列化時遇到的反序列化問題的示例代碼:
/**
* 驗證GSON序列化類型錯誤
*/
@Test
public void testGSON() {
Map map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String jsonString = GSONSerialUtil.getJsonString(map);
Map mapGSON = GSONSerialUtil.parseJson(jsonString, Map.class);
// 正確
Assert.assertEquals(map.get(name), mapGSON.get(name));
// 不等 map.get(id)為Long類型 mapGSON.get(id)為Double類型
Assert.assertNotEquals(map.get(id).getClass(), mapGSON.get(id).getClass());
Assert.assertNotEquals(map.get(id), mapGSON.get(id));
}
下面給出使用 fastjson 模擬 JSON 反序列化問題的示例代碼:
/**
* 驗證FatJson序列化類型錯誤
*/
@Test
public void testFastJson() {
Map map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String fastJsonString = FastJsonUtil.getJsonString(map);
Map mapFastJson = FastJsonUtil.parseJson(fastJsonString, Map.class);
// 正確
Assert.assertEquals(map.get(name), mapFastJson.get(name));
// 錯誤 map.get(id)為Long類型 mapFastJson.get(id)為Integer類型
Assert.assertNotEquals(map.get(id).getClass(), mapFastJson.get(id).getClass());
Assert.assertNotEquals(map.get(id), mapFastJson.get(id));
}
大家還可以通過單元測試構造大量復雜對象對比各種序列化方式或框架的效率。
如定義下列測試類為 User,包括以下多種類型的屬性:
@Data
public class User implements Serializable {
private Long id;
private String name;
private Integer age;
private Boolean sex;
private String nickName;
private Date birthDay;
private Double salary;
}
4.5 各種常見的序列化性能排序
實驗的版本:kryo-shaded 使用 4.0.2 版本,gson 使用 2.8.5 版本,hessian 用 4.0.62 版本。
實驗的數據:構造 50 萬 User 對象運行多次。
大致得出一個結論:
從二進制流大小來講:JSON 序列化 > Java 序列化 > Hessian2 序列化 > Kryo 序列化 > Kryo 序列化注冊模式;
從序列化耗時而言來講:GSON 序列化 > Java 序列化 > Kryo 序列化 > Hessian2 序列化 > Kryo 序列化注冊模式;
從反序列化耗時而言來講:GSON 序列化 > Java 序列化 > Hessian2 序列化 > Kryo 序列化注冊模式 > Kryo 序列化;
從總耗時而言:Kryo 序列化注冊模式耗時最短。
注:由于所用的序列化框架版本不同,對象的復雜程度不同,環境和計算機性能差異等原因結果可能會有出入。
5. 序列化引發的一個血案
接下來我們看下面的一個案例:
前端調用服務 A,服務 A 調用服務 B,服務 B 首次接到請求會查 DB,然后緩存到 Redis(緩存 1 個小時)。服務 A 根據服務 B 返回的數據后執行一些處理邏輯,處理后形成新的對象存到 Redis(緩存 2 個小時)。
服務 A 通過 Dubbo 來調用服務 B,A 和 B 之間數據通過 Map 類型傳輸,服務 B 使用 Fastjson 來實現 JSON 的序列化和反序列化。
服務 B 的接口返回的 Map 值中存在一個 Long 類型的 id 字段,服務 A 獲取到 Map ,取出 id 字段并強轉為 Long 類型使用。
執行的流程如下:
通過分析我們發現,服務 A 和服務 B 的 RPC 調用使用 Java 序列化,因此類型信息不會丟失。
但是由于服務 B 采用 JSON 序列化進行緩存,第一次訪問沒啥問題,其執行流程如下:
如果服務 A 開啟了緩存,服務 A 在第一次請求服務 B 后,緩存了運算結果,且服務 A 緩存時間比服務 B 長,因此不會出現錯誤。
如果服務 A 不開啟緩存,服務 A 會請求服務 B ,由于首次請求時,服務 B 已經緩存了數據,服務 B 從 Redis(B)中反序列化得到 Map。流程如下圖所示:
然而問題來了: 服務 A 從 Map 取出此 Id 字段,強轉為 Long 時會出現類型轉換異常。
最后定位到原因是 Json 反序列化 Map 時如果原始值小于 Int 最大值,反序列化后原本為 Long 類型的字段,變為了 Integer 類型,服務 B 的同學緊急修復。
服務 A 開啟緩存時, 雖然采用了 JSON 序列化存入緩存,但是采用 DTO 對象而不是 Map 來存放屬性,所以 JSON 反序列化沒有問題。
因此大家使用二方或者三方服務時,當對方返回的是 Map 類型的數據時要特別注意這個問題。
作為服務提供方,可以采用 JDK 或者 Hessian 等序列化方式;
作為服務的使用方,我們不要從 Map 中一個字段一個字段獲取和轉換,可以使用 JSON 庫直接將 Map 映射成所需的對象,這樣做不僅代碼更簡潔還可以避免強轉失敗。
代碼示例:
@Test
public void testFastJsonObject() {
Map map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String fastJsonString = FastJsonUtil.getJsonString(map);
// 模擬拿到服務B的數據
Map mapFastJson = FastJsonUtil.parseJson(fastJsonString,map.getClass());
// 轉成強類型屬性的對象而不是使用map 單個取值
User user = new JSONObject(mapFastJson).toJavaObject(User.class);
// 正確
Assert.assertEquals(map.get(name), user.getName());
// 正確
Assert.assertEquals(map.get(id), user.getId());
}
6. 總結
本節的主要講解了序列化的主要概念、主要實現方式,以及序列化和反序列化的幾個坑點,希望大家在實際業務開發中能夠注意這些細節,避免趟坑。
下一節將講述淺拷貝和深拷貝的相關知識。
7. 課后題
給出一個 PersonTransit 類,一個 Address 類,假設 Address 是其它 jar 包中的類,沒實現序列化接口。請使用今天講述的自定義的函數 writeObject 和 readObject 函數實現 PersonTransit 對象的序列化,要求反序列化后 address 的值正常。
@Data
public class PersonTransit implements Serializable {
private Long id;
private String name;
private Boolean male;
private List friends;
private Address address;
}
@Data
@AllArgsConstructor
public class Address {
private String detail;
}
參考資料
阿里巴巴與 Java 社區開發者.《 Java 開發手冊 1.5.0》華山版. 2019. 9 ??
[美] Randal E.Bryant/ David O’Hallaron.《深入理解計算機系統》. [譯] 龔奕利,賀蓮。機械工業出版社. 2016 ??
楊冠寶。高海慧.《碼出高效:Java 開發手冊》. 電子工業出版社. 2018 ??}