1、前言
《手冊(cè)》第 9 頁(yè) “OOP 規(guī)約” 部分有一段關(guān)于序列化的約定 1:
【強(qiáng)制】當(dāng)序列化類(lèi)新增屬性時(shí)菠剩,請(qǐng)不要修改 serialVersionUID 字段戳表,以避免反序列失敗歌懒;如果完全不兼容升級(jí)啦桌,避免反序列化混亂,那么請(qǐng)修改 serialVersionUID 值。
說(shuō)明:注意 serialVersionUID 值不一致會(huì)拋出序列化運(yùn)行時(shí)異常甫男。
我們應(yīng)該思考下面幾個(gè)問(wèn)題:
- 序列化和反序列化到底是什么且改?
- 它的主要使用場(chǎng)景有哪些?
- Java 序列化常見(jiàn)的方案有哪些板驳?
- 各種常見(jiàn)序列化方案的區(qū)別有哪些又跛?
- 實(shí)際的業(yè)務(wù)開(kāi)發(fā)中有哪些坑點(diǎn)?
接下來(lái)將從這幾個(gè)角度去研究這個(gè)問(wèn)題若治。
2. 序列化和反序列化是什么慨蓝?為什么需要它?
序列化是將內(nèi)存中的對(duì)象信息轉(zhuǎn)化成可以存儲(chǔ)或者傳輸?shù)臄?shù)據(jù)到臨時(shí)或永久存儲(chǔ)的過(guò)程端幼。而反序列化正好相反礼烈,是從臨時(shí)或永久存儲(chǔ)中讀取序列化的數(shù)據(jù)并轉(zhuǎn)化成內(nèi)存對(duì)象的過(guò)程。
那么為什么需要序列化和反序列化呢婆跑?
希望大家能夠養(yǎng)成從本源上思考這個(gè)問(wèn)題的思維方式扒磁,即思考它為什么會(huì)出現(xiàn)在讶,而不是單純記憶。
大家可以回憶一下,平時(shí)都是如果將文字文件误债、圖片文件楚午、視頻文件止后、軟件安裝包等傳給小伙伴時(shí)须揣,這些資源在計(jì)算機(jī)中存儲(chǔ)的方式是怎樣的。
進(jìn)而再思考驮审,Java 中的對(duì)象如果需要存儲(chǔ)或者傳輸應(yīng)該通過(guò)什么形式呢鲫寄?
我們都知道,一個(gè)文件通常是一個(gè) m 個(gè)字節(jié)的序列:B0, B1, …, Bk, …, Bm-1疯淫。所有的 I/O 設(shè)備(例如網(wǎng)絡(luò)地来、磁盤(pán)和終端)都被模型化為文件,而所有的輸入和輸出都被當(dāng)作對(duì)應(yīng)文件的讀和寫(xiě)來(lái)執(zhí)行熙掺。2
因此本質(zhì)上講未斑,文本文件,圖片币绩、視頻和安裝包等文件底層都被轉(zhuǎn)化為二進(jìn)制字節(jié)流來(lái)傳輸?shù)睦啵瑢?duì)方得文件就需要對(duì)文件進(jìn)行解析,因此就需要有能夠根據(jù)不同的文件類(lèi)型來(lái)解碼出文件的內(nèi)容的程序缆镣。
大家試想一個(gè)典型的場(chǎng)景:如果要實(shí)現(xiàn) Java 遠(yuǎn)程方法調(diào)用芽突,就需要將調(diào)用結(jié)果通過(guò)網(wǎng)路傳輸給調(diào)用方,如果調(diào)用方和服務(wù)提供方不在一臺(tái)機(jī)器上就很難共享內(nèi)存董瞻,就需要將 Java 對(duì)象進(jìn)行傳輸寞蚌。而想要將 Java 中的對(duì)象進(jìn)行網(wǎng)絡(luò)傳輸或存儲(chǔ)到文件中,就需要將對(duì)象轉(zhuǎn)化為二進(jìn)制字節(jié)流,這就是所謂的序列化挟秤。存儲(chǔ)或傳輸之后必然就需要將二進(jìn)制流讀取并解析成 Java 對(duì)象壹哺,這就是所謂的反序列化。
序列化的主要目的是:方便存儲(chǔ)到文件系統(tǒng)煞聪、數(shù)據(jù)庫(kù)系統(tǒng)或網(wǎng)絡(luò)傳輸?shù)?/strong>斗躏。
實(shí)際開(kāi)發(fā)中常用到序列化和反序列化的場(chǎng)景有:
- 遠(yuǎn)程方法調(diào)用(RPC)的框架里會(huì)用到序列化逝慧。
- 將對(duì)象存儲(chǔ)到文件中時(shí)昔脯,需要用到序列化。
- 將對(duì)象存儲(chǔ)到緩存數(shù)據(jù)庫(kù)(如 Redis)時(shí)需要用到序列化笛臣。
- 通過(guò)序列化和反序列化的方式實(shí)現(xiàn)對(duì)象的深拷貝云稚。
3. 常見(jiàn)的序列化方式
常見(jiàn)的序列化方式包括 Java 原生序列化、Hessian 序列化沈堡、Kryo 序列化静陈、JSON 序列化等。
3.1 Java 原生序列化
正如前面章節(jié)講到的诞丽,對(duì)于 JDK 中有的類(lèi)鲸拥,最好的學(xué)習(xí)方式之一就是直接看其源碼。
Serializable
的源碼非常簡(jiǎn)單僧免,只有聲明刑赶,沒(méi)有屬性和方法:
// 注釋太長(zhǎng),省略
public interface Serializable {
}
在學(xué)習(xí)源碼注釋之前懂衩,希望大家可以站在設(shè)計(jì)者的角度撞叨,先思考一個(gè)問(wèn)題:如果一個(gè)類(lèi)序列化到文件之后,類(lèi)的結(jié)構(gòu)發(fā)生變化還能否保證正確地反序列化呢浊洞?
答案顯然是不確定的牵敷。
那么如何判斷文件被修改過(guò)了呢? 通撤ㄏ#可以通過(guò)加密算法對(duì)其進(jìn)行簽名枷餐,文件作出任何修改簽名就會(huì)不一致。但是 Java 序列化的場(chǎng)景并不適合使用上述的方案苫亦,因?yàn)轭?lèi)文件的某些位置加個(gè)空格毛肋,換行等符號(hào)類(lèi)的結(jié)構(gòu)沒(méi)有發(fā)生變化,這個(gè)簽名就不應(yīng)該發(fā)生變化著觉。還有一個(gè)類(lèi)新增一個(gè)屬性村生,之前的屬性都是有值的,之前都被序列化到對(duì)象文件中饼丘,有些場(chǎng)景下還希望反序列化時(shí)可以正常解析趁桃,怎么辦呢?
那么是否可以通過(guò)約定一個(gè)唯一的 ID,通過(guò) ID 對(duì)比卫病,不一致就認(rèn)為不可反序列化呢油啤?
實(shí)現(xiàn)序列化接口后,如果開(kāi)發(fā)者不手動(dòng)指定該版本號(hào) ID 怎么辦蟀苛?
既然 Java 序列化場(chǎng)景下的 “簽名” 應(yīng)該根據(jù)類(lèi)的特點(diǎn)生成益咬,我們是否可以不指定序列化版本號(hào)就默認(rèn)根據(jù)類(lèi)名、屬性和函數(shù)等計(jì)算呢帜平?
如果針對(duì)某個(gè)自己定義的類(lèi)幽告,想自定義序列化和反序列化機(jī)制該如何實(shí)現(xiàn)呢?支持嗎裆甩?
帶著這些問(wèn)題我們繼續(xù)看序列化接口的注釋冗锁。
Serializable
的源碼注釋特別長(zhǎng),其核心大致作了下面的說(shuō)明:
Java 原生序列化需要實(shí)現(xiàn) Serializable
接口嗤栓。序列化接口不包含任何方法和屬性等冻河,它只起到序列化標(biāo)識(shí)作用。
一個(gè)類(lèi)實(shí)現(xiàn)序列化接口則其子類(lèi)型也會(huì)繼承序列化能力茉帅,但是實(shí)現(xiàn)序列化接口的類(lèi)中有其他對(duì)象的引用叨叙,則其他對(duì)象也要實(shí)現(xiàn)序列化接口。序列化時(shí)如果拋出 NotSerializableException
異常堪澎,說(shuō)明該對(duì)象沒(méi)有實(shí)現(xiàn) Serializable
接口擂错。
每個(gè)序列化類(lèi)都有一個(gè)叫 serialVersionUID
的版本號(hào),反序列化時(shí)會(huì)校驗(yàn)待反射的類(lèi)的序列化版本號(hào)和加載的序列化字節(jié)流中的版本號(hào)是否一致马昙,如果序列化號(hào)不一致則會(huì)拋出 InvalidClassException
異常刹悴。
強(qiáng)烈推薦每個(gè)序列化類(lèi)都手動(dòng)指定其 serialVersionUID
,如果不手動(dòng)指定子房,那么編譯器會(huì)動(dòng)態(tài)生成默認(rèn)的序列化號(hào)证杭,因?yàn)檫@個(gè)默認(rèn)的序列化號(hào)和類(lèi)的特征以及編譯器的實(shí)現(xiàn)都有關(guān)系妒御,很容易在反序列化時(shí)拋出 InvalidClassException
異常乎莉。建議將這個(gè)序列化版本號(hào)聲明為私有奸笤,以避免運(yùn)行時(shí)被修改监右。
實(shí)現(xiàn)序列化接口的類(lèi)可以提供自定義的函數(shù)修改默認(rèn)的序列化和反序列化行為异希。
自定義序列化方法:
private void writeObject(ObjectOutputStream out) throws IOException;
自定義反序列化方法:
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException;
通過(guò)自定義這兩個(gè)函數(shù)称簿,可以實(shí)現(xiàn)序列化和反序列化不可序列化的屬性,也可以對(duì)序列化的數(shù)據(jù)進(jìn)行數(shù)據(jù)的加密和解密處理搏色。
3.2 Hessian 序列化
Hessian 是一個(gè)動(dòng)態(tài)類(lèi)型券册,二進(jìn)制序列化烁焙,也是一個(gè)基于對(duì)象傳輸?shù)木W(wǎng)絡(luò)協(xié)議耕赘。Hessian 是一種跨語(yǔ)言的序列化方案操骡,序列化后的字節(jié)數(shù)更少,效率更高岔激。Hessian 序列化會(huì)把復(fù)雜對(duì)象的屬性映射到 Map
中再進(jìn)行序列化虑鼎。
3.3 Kryo 序列化
Kryo 是一個(gè)快速高效的 Java 序列化和克隆工具键痛。Kryo 的目標(biāo)是快速絮短、字節(jié)少和易用丁频。Kryo 還可以自動(dòng)進(jìn)行深拷貝或者淺拷貝扔嵌。Kryo 的拷貝是對(duì)象到對(duì)象的拷貝而不是對(duì)象到字節(jié)痢缎,再?gòu)淖止?jié)到對(duì)象的恢復(fù)独旷。Kryo 為了保證序列化的高效率寥裂,會(huì)提前加載需要的類(lèi)封恰,這會(huì)帶一些消耗,但是這是序列化后文件較小且反序列化非潮畈快的重要原因低飒。
3.4 JSON 序列化
JSON (JavaScript Object Notation) 是一種輕量級(jí)的數(shù)據(jù)交換方式糕档。JSON 序列化是基于 JSON 這種結(jié)構(gòu)來(lái)實(shí)現(xiàn)的速那。JSON 序列化將對(duì)象轉(zhuǎn)化成 JSON 字符串尿背,JSON 反序列化則是將 JSON 字符串轉(zhuǎn)回對(duì)象的過(guò)程榆俺。常用的 JSON 序列化和反序列化的庫(kù)有 Jackson茴晋、GSON回窘、Fastjson 等啡直。
4.Java 常見(jiàn)的序列化方案對(duì)比
我們想要對(duì)比各種序列化方案的優(yōu)劣無(wú)外乎兩點(diǎn)撮执,一點(diǎn)是查資料抒钱,一點(diǎn)是自己寫(xiě)代碼驗(yàn)證谋币。
4.1 Java 原生序列化
Java 序列化的優(yōu)點(diǎn)是:對(duì)對(duì)象的結(jié)構(gòu)描述清晰蕾额,反序列化更安全。主要缺點(diǎn)是:效率低退个,序列化后的二進(jìn)制流較大。
4.2 Hessian 序列化
Hession 序列化二進(jìn)制流較 Java 序列化更小习柠,且序列化和反序列化耗時(shí)更短资溃。但是父類(lèi)和子類(lèi)有相同類(lèi)型屬性時(shí),由于先序列化子類(lèi)再序列化父類(lèi)垫毙,因此反序列化時(shí)子類(lèi)的同名屬性會(huì)被父類(lèi)的值覆蓋掉,開(kāi)發(fā)時(shí)要特別注意這種情況猎拨。
Hession2.0 序列化二進(jìn)制流大小是 Java 序列化的 50%额各,序列化耗時(shí)是 Java 序列化的 30%蛉加,反序列化的耗時(shí)是 Java 序列化的 20%。 3
編寫(xiě)待測(cè)試的類(lèi):
@Data
public class PersonHessian implements Serializable {
private Long id;
private String name;
private Boolean male;
}
@Data
public class Male extends PersonHessian {
private Long id;
}
編寫(xiě)單測(cè)來(lái)模擬序列化繼承覆蓋問(wèn)題:
/**
* 驗(yàn)證Hessian序列化繼承覆蓋問(wèn)題
*/
@Test
public void testHessianSerial() throws IOException {
HessianSerialUtil.writeObject(file, male);
Male maleGet = HessianSerialUtil.readObject(file);
// 相等
Assert.assertEquals(male.getName(), maleGet.getName());
// male.getId()結(jié)果是1,maleGet.getId()結(jié)果是null
Assert.assertNull(maleGet.getId());
Assert.assertNotEquals(male.getId(), maleGet);
}
上述單測(cè)示例驗(yàn)證了:反序列化時(shí)子類(lèi)的同名屬性會(huì)被父類(lèi)的值覆蓋掉的問(wèn)題。
4.3 Kryo 序列化
Kryo 優(yōu)點(diǎn)是:速度快蹂风、序列化后二進(jìn)制流體積小、反序列化超快。但是缺點(diǎn)是:跨語(yǔ)言支持復(fù)雜趋距。注冊(cè)模式序列化更快,但是編程更加復(fù)雜翼雀。
4.4 JSON 序列化
JSON 序列化的優(yōu)勢(shì)在于可讀性更強(qiáng)肋殴。主要缺點(diǎn)是:沒(méi)有攜帶類(lèi)型信息官地,只有提供了準(zhǔn)確的類(lèi)型信息才能準(zhǔn)確地進(jìn)行反序列化赤炒,這點(diǎn)也特別容易引發(fā)線(xiàn)上問(wèn)題。
下面給出使用 Gson 框架模擬 JSON 序列化時(shí)遇到的反序列化問(wèn)題的示例代碼:
/**
* 驗(yàn)證GSON序列化類(lèi)型錯(cuò)誤
*/
@Test
public void testGSON() {
Map<String, Object> map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String jsonString = GSONSerialUtil.getJsonString(map);
Map<String, Object> mapGSON = GSONSerialUtil.parseJson(jsonString, Map.class);
// 正確
Assert.assertEquals(map.get(name), mapGSON.get(name));
// 不等 map.get(id)為L(zhǎng)ong類(lèi)型 mapGSON.get(id)為Double類(lèi)型
Assert.assertNotEquals(map.get(id).getClass(), mapGSON.get(id).getClass());
Assert.assertNotEquals(map.get(id), mapGSON.get(id));
}
下面給出使用 fastjson 模擬 JSON 反序列化問(wèn)題的示例代碼:
/**
* 驗(yàn)證FatJson序列化類(lèi)型錯(cuò)誤
*/
@Test
public void testFastJson() {
Map<String, Object> map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String fastJsonString = FastJsonUtil.getJsonString(map);
Map<String, Object> mapFastJson = FastJsonUtil.parseJson(fastJsonString, Map.class);
// 正確
Assert.assertEquals(map.get(name), mapFastJson.get(name));
// 錯(cuò)誤 map.get(id)為L(zhǎng)ong類(lèi)型 mapFastJson.get(id)為Integer類(lèi)型
Assert.assertNotEquals(map.get(id).getClass(), mapFastJson.get(id).getClass());
Assert.assertNotEquals(map.get(id), mapFastJson.get(id));
}
大家還可以通過(guò)單元測(cè)試構(gòu)造大量復(fù)雜對(duì)象對(duì)比各種序列化方式或框架的效率巡通。
如定義下列測(cè)試類(lèi)為 User誊锭,包括以下多種類(lèi)型的屬性:
@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 各種常見(jiàn)的序列化性能排序
實(shí)驗(yàn)的版本:kryo-shaded 使用 4.0.2 版本叉讥,gson 使用 2.8.5 版本,hessian 用 4.0.62 版本。
實(shí)驗(yàn)的數(shù)據(jù):構(gòu)造 50 萬(wàn) User 對(duì)象運(yùn)行多次六孵。
大致得出一個(gè)結(jié)論:
- 從二進(jìn)制流大小來(lái)講:JSON 序列化 > Java 序列化 > Hessian2 序列化 > Kryo 序列化 > Kryo 序列化注冊(cè)模式劫窒;
- 從序列化耗時(shí)而言來(lái)講:GSON 序列化 > Java 序列化 > Kryo 序列化 > Hessian2 序列化 > Kryo 序列化注冊(cè)模式孕索;
- 從反序列化耗時(shí)而言來(lái)講:GSON 序列化 > Java 序列化 > Hessian2 序列化 > Kryo 序列化注冊(cè)模式 > Kryo 序列化散怖;
- 從總耗時(shí)而言:Kryo 序列化注冊(cè)模式耗時(shí)最短。
注:由于所用的序列化框架版本不同欠动,對(duì)象的復(fù)雜程度不同,環(huán)境和計(jì)算機(jī)性能差異等原因結(jié)果可能會(huì)有出入碗脊。
5. 序列化引發(fā)的一個(gè)血案
接下來(lái)我們看下面的一個(gè)案例:
前端調(diào)用服務(wù) A祈坠,服務(wù) A 調(diào)用服務(wù) B,服務(wù) B 首次接到請(qǐng)求會(huì)查 DB躺同,然后緩存到 Redis(緩存 1 個(gè)小時(shí))捎谨。服務(wù) A 根據(jù)服務(wù) B 返回的數(shù)據(jù)后執(zhí)行一些處理邏輯涛救,處理后形成新的對(duì)象存到 Redis(緩存 2 個(gè)小時(shí))棵红。
服務(wù) A 通過(guò) Dubbo 來(lái)調(diào)用服務(wù) B致板,A 和 B 之間數(shù)據(jù)通過(guò)
Map<String,Object>
類(lèi)型傳輸,服務(wù) B 使用 Fastjson 來(lái)實(shí)現(xiàn) JSON 的序列化和反序列化御毅。服務(wù) B 的接口返回的
Map
值中存在一個(gè)Long
類(lèi)型的id
字段,服務(wù) A 獲取到Map
柔袁,取出id
字段并強(qiáng)轉(zhuǎn)為Long
類(lèi)型使用插掂。
執(zhí)行的流程如下:
通過(guò)分析我們發(fā)現(xiàn),服務(wù) A 和服務(wù) B 的 RPC 調(diào)用使用 Java 序列化,因此類(lèi)型信息不會(huì)丟失捐凭。
但是由于服務(wù) B 采用 JSON 序列化進(jìn)行緩存垦梆,第一次訪(fǎng)問(wèn)沒(méi)啥問(wèn)題,其執(zhí)行流程如下:
如果服務(wù) A 開(kāi)啟了緩存,服務(wù) A 在第一次請(qǐng)求服務(wù) B 后,緩存了運(yùn)算結(jié)果晃琳,且服務(wù) A 緩存時(shí)間比服務(wù) B 長(zhǎng)人灼,因此不會(huì)出現(xiàn)錯(cuò)誤适贸。
如果服務(wù) A 不開(kāi)啟緩存蕊肥,服務(wù) A 會(huì)請(qǐng)求服務(wù) B ,由于首次請(qǐng)求時(shí),服務(wù) B 已經(jīng)緩存了數(shù)據(jù),服務(wù) B 從 Redis(B)中反序列化得到 Map
惊完。流程如下圖所示:
然而問(wèn)題來(lái)了: 服務(wù) A 從 Map 取出此 Id
字段小槐,強(qiáng)轉(zhuǎn)為 Long
時(shí)會(huì)出現(xiàn)類(lèi)型轉(zhuǎn)換異常。
最后定位到原因是 Json 反序列化 Map 時(shí)如果原始值小于 Int 最大值凿跳,反序列化后原本為 Long 類(lèi)型的字段,變?yōu)榱?Integer 類(lèi)型茧彤,服務(wù) B 的同學(xué)緊急修復(fù)疆栏。
服務(wù) A 開(kāi)啟緩存時(shí), 雖然采用了 JSON 序列化存入緩存壁顶,但是采用 DTO 對(duì)象而不是 Map 來(lái)存放屬性若专,所以 JSON 反序列化沒(méi)有問(wèn)題。
因此大家使用二方或者三方服務(wù)時(shí)调衰,當(dāng)對(duì)方返回的是 Map<String,Object>
類(lèi)型的數(shù)據(jù)時(shí)要特別注意這個(gè)問(wèn)題。
作為服務(wù)提供方米酬,可以采用 JDK 或者 Hessian 等序列化方式趋箩;
作為服務(wù)的使用方琼懊,我們不要從 Map 中一個(gè)字段一個(gè)字段獲取和轉(zhuǎn)換爬早,可以使用 JSON 庫(kù)直接將 Map 映射成所需的對(duì)象启妹,這樣做不僅代碼更簡(jiǎn)潔還可以避免強(qiáng)轉(zhuǎn)失敗。
代碼示例:
@Test
public void testFastJsonObject() {
Map<String, Object> map = new HashMap<>();
final String name = "name";
final String id = "id";
map.put(name, "張三");
map.put(id, 20L);
String fastJsonString = FastJsonUtil.getJsonString(map);
// 模擬拿到服務(wù)B的數(shù)據(jù)
Map<String, Object> mapFastJson = FastJsonUtil.parseJson(fastJsonString,map.getClass());
// 轉(zhuǎn)成強(qiáng)類(lèi)型屬性的對(duì)象而不是使用map 單個(gè)取值
User user = new JSONObject(mapFastJson).toJavaObject(User.class);
// 正確
Assert.assertEquals(map.get(name), user.getName());
// 正確
Assert.assertEquals(map.get(id), user.getId());
}
6. 總結(jié)
本節(jié)的主要講解了序列化的主要概念桨啃、主要實(shí)現(xiàn)方式檬输,以及序列化和反序列化的幾個(gè)坑點(diǎn),希望大家在實(shí)際業(yè)務(wù)開(kāi)發(fā)中能夠注意這些細(xì)節(jié)丧慈,避免趟坑。
下一節(jié)將講述淺拷貝和深拷貝的相關(guān)知識(shí)鹃愤。
7. 課后題
給出一個(gè) PersonTransit
類(lèi)完域,一個(gè) Address
類(lèi),假設(shè) Address
是其它 jar 包中的類(lèi)凹耙,沒(méi)實(shí)現(xiàn)序列化接口肠仪。請(qǐng)使用今天講述的自定義的函數(shù) writeObject
和 readObject
函數(shù)實(shí)現(xiàn) PersonTransit
對(duì)象的序列化,要求反序列化后 address
的值正常藤韵。
@Data
public class PersonTransit implements Serializable {
private Long id;
private String name;
private Boolean male;
private List<PersonTransit> friends;
private Address address;
}
@Data
@AllArgsConstructor
public class Address {
private String detail;
}