基于 socket 進(jìn)行對象傳輸
先舉個(gè)簡單的例子,基于我們前面幾次課程的只是,寫一個(gè) socket 通信的代碼
User.java
public class User {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
SocketServerProvider.java
public class SocketServerProvider {
public static void main(String[] args) throws
IOException {
ServerSocket serverSocket = null;
BufferedReader in = null;
try {
serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept();
ObjectInputStream objectInputStream =
new ObjectInputStream(socket.getInputStream());
User user=(User)objectInputStream.readObject();
System.out.println(user);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (serverSocket != null) {
serverSocket.close();
}
}
}
}
SocketClientConsumer.java
public class SocketClientConsumer {
public static void main(String[] args) {
Socket socket = null;
ObjectOutputStream out = null;
try {
socket = new Socket("127.0.0.1", 8080);
User user = new User();
out = new
ObjectOutputStream(socket.getOutputStream());
out.writeObject(user);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
運(yùn)行結(jié)果
這段代碼運(yùn)行以后,能夠?qū)崿F(xiàn) Java 對象的正常傳輸嗎? 很顯然,會報(bào)錯(cuò)
如何解決報(bào)錯(cuò)的問題呢?
對 User 這個(gè)對象實(shí)現(xiàn)一個(gè) Serializable 接口,再次運(yùn)行就可以看到對象能夠正常傳輸了
public class User implements Serializable {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
了解序列化的意義
我們發(fā)現(xiàn)對 User 這個(gè)類增加一個(gè) Serializable,就可以解決 Java 對象的網(wǎng)絡(luò)傳輸問題。這就是今天想給大家講解的序列化這塊的意義。
Java 平臺允許我們在內(nèi)存中創(chuàng)建可復(fù)用的 Java 對象,但一般情況下,只有當(dāng) JVM 處于運(yùn)行時(shí),這些對象才可能存在,即,這些對象的生命周期不會比 JVM 的生命周期更長。但在現(xiàn)實(shí)應(yīng)用中,就可能要求在 JVM 停止運(yùn)行之后能夠保存(持久化)指定的對象,并在將來重新讀取被保存的對象。 Java 對象序列化就能夠幫助我們實(shí)現(xiàn)該功能。
簡單來說
序列化是把對象的狀態(tài)信息轉(zhuǎn)化為可存儲或傳輸?shù)男问竭^程,也就是把對象轉(zhuǎn)化為字節(jié)序列的過程稱為對象的序列化。
反序列化是序列化的逆向過程,把字節(jié)數(shù)組反序列化為對象,把字節(jié)序列恢復(fù)為對象的過程成為對象的反序列化
序列化的高階認(rèn)識
簡單認(rèn)識一下 Java 原生序列化
前面的代碼中演示了,如何通過 JDK 提供了 Java 對象的序列化方式實(shí)現(xiàn)對象序列化傳輸,主要通過輸出流java.io.ObjectOutputStream和對象輸入流java.io.ObjectInputStream來實(shí)現(xiàn)。
java.io.ObjectOutputStream:表示對象輸出流 , 它的 writeObject(Object obj)方法可以對參數(shù)指定的 obj 對象進(jìn)行序列化,把得到的字節(jié)序列寫到一個(gè)目標(biāo)輸出流中。
java.io.ObjectInputStream:表示對象輸入流 ,它的 readObject()方法源輸入流中讀取字節(jié)序列,再把它們反序列化成為一個(gè)對象,并將其返回。
需要注意的是,被序列化的對象需要實(shí)現(xiàn) java.io.Serializable 接口
-
serialVersionUID 的作用
在 IDEA 中通過如下設(shè)置可以生成 serializeid
字面意思上是序列化的版本號,凡是實(shí)現(xiàn) Serializable 接口的類都有一個(gè)表示序列化版本標(biāo)識
符的靜態(tài)變量
演示步驟
- 先將 user 對象序列化到文件中
- 然后修改 user 對象,增加 serialVersionUID 字段
- 然后通過反序列化來把對象提取出來
- 演示預(yù)期結(jié)果:提示無法反序列化
結(jié)論
Java 的序列化機(jī)制是通過判斷類的 serialVersionUID 來驗(yàn)證版本一致性的。在進(jìn)行反序列化時(shí), JVM 會把傳來的字節(jié)流中的 serialVersionUID 與本地相應(yīng)實(shí)體類的 serialVersionUID 進(jìn)行比較,如果相同就認(rèn)為是一致的,可以進(jìn)行反序列化,否則就會出現(xiàn)序列化版本不一致的異常,即是 InvalidCastException。
從結(jié)果可以看出,文件流中的 class 和 classpath 中的 class,也就是修改過后的 class,不兼容了,處于安全機(jī)制考慮,程序拋出了錯(cuò)誤,并且拒絕載入。從錯(cuò)誤結(jié)果來看,如果沒有為指定的 class 配置 serialVersionUID,那么 java 編譯器會自動(dòng)給這個(gè) class 進(jìn)行一個(gè)摘要算法,類似于指紋算法,只要這個(gè)文件有任何改動(dòng),得到的 UID 就會截然不同的,可以保證在這么多類中,這個(gè)編號是唯一的。所以,由于沒有顯指定 serialVersionUID,編譯器又為我們生成了一個(gè) UID,當(dāng)然和前面保存在文件中的那個(gè)不會一樣了,于是就出現(xiàn)了 2 個(gè)序列化版本號不一致的錯(cuò)誤。因此,只要我們自己指定了 serialVersionUID,就可以在序列化后,去添加一個(gè)字段,或者方法,而不會影響到后期的還原,還原后的對象照樣可以使用,而且還多了方法或者屬性可以用。
tips: serialVersionUID 有兩種顯示的生成方式:
一是默認(rèn)的 1L,比如: private static final long serialVersionUID = 1L;
二是根據(jù)類名、接口名、成員方法及屬性等來生成一個(gè) 64 位的哈希字段,當(dāng)實(shí)現(xiàn) java.io.Serializable 接口的類沒有顯式地定義一個(gè) serialVersionUID 變量時(shí)候, Java 序列化機(jī)制會根據(jù)編譯的 Class 自動(dòng)生成一個(gè) serialVersionUID 作序列化版本比較用,這種情況下,如果 Class 文件(類名,方法明等)沒有發(fā)生變化(增加空格,換行,增加注釋等等),就算再編譯多次, serialVersionUID 也不會變化的。
Transient 關(guān)鍵字
Transient 關(guān)鍵字的作用是控制變量的序列化,在變量聲明前加上該關(guān)鍵字,可以阻止該變量被序列化到文件中,在被反序列化后, transient 變量的值被設(shè)為初始值,如 int 型的是0,對象型的是 null。-
writeObject 和 readObject 原理
writeObject 和 readObject 是兩個(gè)私有的方法,他們是什么時(shí)候被調(diào)用的呢?從運(yùn)行結(jié)果來看,它確實(shí)被調(diào)用。而且他們并不存在于 Java.lang.Object,也沒有在 Serializable 中去聲明。
我們唯一的猜想應(yīng)該還是和 ObjectInputStream 和 ObjectOutputStream 有關(guān)系,所以基于這個(gè)入口去看看在哪個(gè)地方有調(diào)用
從源碼層面來分析可以看到, readObject 是通過反射來調(diào)用的。
其實(shí)我們可以在很多地方看到 readObject 和 writeObject 的使用,比如 HashMap。
Java 序列化的一些簡單總結(jié)
- Java 序列化只是針對對象的狀態(tài)進(jìn)行保存,至于對象中的方法,序列化不關(guān)心
- 當(dāng)一個(gè)父類實(shí)現(xiàn)了序列化,那么子類會自動(dòng)實(shí)現(xiàn)序列化,不需要顯示實(shí)現(xiàn)序列化接口
- 當(dāng)一個(gè)對象的實(shí)例變量引用了其他對象,序列化這個(gè)對象的時(shí)候會自動(dòng)把引用的對象也進(jìn)
行序列化(實(shí)現(xiàn)深度克隆) - 當(dāng)某個(gè)字段被申明為 transient 后,默認(rèn)的序列化機(jī)制會忽略這個(gè)字段
- 被申明為 transient 的字段,如果需要序列化,可以添加兩個(gè)私有方法: writeObject 和
readObject
分布式架構(gòu)下常見序列化技術(shù)
初步了解了 Java 序列化的知識以后,我們又得回到分布式架構(gòu)中,了解序列化的發(fā)展過程。
隨著分布式架構(gòu)、微服務(wù)架構(gòu)的普及。服務(wù)與服務(wù)之間的通信成了最基本的需求。這個(gè)時(shí)候,我們不僅需要考慮通信的性能,也需要考慮到語言多元化問題。所以,對于序列化來說,如何去提升序列化性能以及解決跨語言問題,就成了一個(gè)重點(diǎn)考慮的問題。
由于 Java 本身提供的序列化機(jī)制存在兩個(gè)問題
- 序列化的數(shù)據(jù)比較大,傳輸效率低
- 其他語言無法識別和對接
以至于在后來的很長一段時(shí)間,基于 XML 格式編碼的對象序列化機(jī)制成為了主流,一方面解決了多語言兼容問題,另一方面比二進(jìn)制的序列化方式更容易理解。以至于基于 XML的 SOAP協(xié)議及對應(yīng)的 WebService 框架在很長一段時(shí)間內(nèi)成為各個(gè)主流開發(fā)語言的必備的技術(shù)。
再到后來,基于 JSON 的簡單文本格式編碼的 HTTP REST 接口又基本上取代了復(fù)雜的 Web Service 接口,成為分布式架構(gòu)中遠(yuǎn)程通信的首要選擇。但是 JSON 序列化存儲占用的空間大、性能低等問題,同時(shí)移動(dòng)客戶端應(yīng)用需要更高效的傳輸數(shù)據(jù)來提升用戶體驗(yàn)。在這種情況下與語言無關(guān)并且高效的二進(jìn)制編碼協(xié)議就成為了大家追求的熱點(diǎn)技術(shù)之一。首先誕生的一個(gè)開源的二進(jìn)制序列化框架-MessagePack。它比 google 的 Protocol Buffers 出現(xiàn)得還要早。
簡單了解各種序列化技術(shù)
XML 序列化框架介紹
XML 序列化的好處在于可讀性好,方便閱讀和調(diào)試。但是序列化以后的字節(jié)碼文件比較大,而且效率不高,適用于對性能不高,而且 QPS 較低的企業(yè)級內(nèi)部系統(tǒng)之間的數(shù)據(jù)交換的場景,同時(shí) XML 又具有語言無關(guān)性,所以還可以用于異構(gòu)系統(tǒng)之間的數(shù)據(jù)交換和協(xié)議。比如我們熟知的Webservice,就是采用 XML 格式對數(shù)據(jù)進(jìn)行序列化的。 XML 序列化/反序列化的實(shí)現(xiàn)方式有很多,熟知的方式有 XStream 和 Java 自帶的 XML 序列化和反序列化兩種。JSON 序列化框架
JSON(JavaScript Object Notation)是一種輕量級的數(shù)據(jù)交換格式,相對于 XML 來說, JSON的字節(jié)流更小,而且可讀性也非常好。現(xiàn)在 JSON 數(shù)據(jù)格式在企業(yè)運(yùn)用是最普遍的。
JSON 序列化常用的開源工具有很多:
- Jackson (https://github.com/FasterXML/jackson)
- 阿里開源的 FastJson (https://github.com/alibaba/fastjon)
- Google 的 GSON (https://github.com/google/gson)
這幾種 json 序列化工具中, Jackson 與 fastjson 要比 GSON 的性能要好,但是 Jackson、GSON 的穩(wěn)定性要比 Fastjson 好。而 fastjson 的優(yōu)勢在于提供的 api 非常容易使用。
Hessian 序列化框架
Hessian 是一個(gè)支持跨語言傳輸?shù)亩M(jìn)制序列化協(xié)議,相對于 Java 默認(rèn)的序列化機(jī)制來說,Hessian 具有更好的性能和易用性,而且支持多種不同的語言。
實(shí)際上 Dubbo 采用的就是 Hessian 序列化來實(shí)現(xiàn),只不過 Dubbo 對 Hessian 進(jìn)行了重構(gòu),性能更高。Avro 序列化
Avro 是一個(gè)數(shù)據(jù)序列化系統(tǒng),設(shè)計(jì)用于支持大批量數(shù)據(jù)交換的應(yīng)用。它的主要特點(diǎn)有:支持二進(jìn)制序列化方式,可以便捷,快速地處理大量數(shù)據(jù);動(dòng)態(tài)語言友好, Avro 提供的機(jī)制使動(dòng)態(tài)語言可以方便地處理 Avro 數(shù)據(jù)。kyro 序列化框架
Kryo 是一種非常成熟的序列化實(shí)現(xiàn),已經(jīng)在 Hive、 Storm)中使用得比較廣泛,不過它不能跨語言. 目前 dubbo 已經(jīng)在 2.6 版本支持 kyro 的序列化機(jī)制。它的性能要優(yōu)于之前的hessian2Protobuf 序列化框架
Protobuf 是 Google 的一種數(shù)據(jù)交換格式,它獨(dú)立于語言、獨(dú)立于平臺。 Google 提供了多種語言來實(shí)現(xiàn),比如 Java、 C、 Go、 Python,每一種實(shí)現(xiàn)都包含了相應(yīng)語言的編譯器和庫文件,Protobuf 是一個(gè)純粹的表示層協(xié)議,可以和各種傳輸層協(xié)議一起使用。
Protobuf 使用比較廣泛,主要是空間開銷小和性能比較好,非常適合用于公司內(nèi)部對性能要求高的 RPC 調(diào)用。 另外由于解析性能比較高,序列化以后數(shù)據(jù)量相對較少,所以也可以應(yīng)用在對象的持久化場景中。
但是要使用 Protobuf 會相對來說麻煩些,因?yàn)樗凶约旱恼Z法,有自己的編譯器,如果需要用到的話必須要去投入成本在這個(gè)技術(shù)的學(xué)習(xí)中。
protobuf 有個(gè)缺點(diǎn)就是要傳輸?shù)拿恳粋€(gè)類的結(jié)構(gòu)都要生成對應(yīng)的 proto 文件,如果某個(gè)類發(fā)生修改,還得重新生成該類對應(yīng)的 proto 文件。
Protobuf 序列化的原理
那么接下來著重分析一下 protobuf 的序列化原理,前面說過它的優(yōu)勢是空間開銷小,性能也相對較好。它里面用到的一些算法還是值得我們?nèi)W(xué)習(xí)的。
protobuf 的基本應(yīng)用
使用 protobuf 開發(fā)的一般步驟是
- 配置開發(fā)環(huán)境,安裝 protocol compiler 代碼編譯器
- 編寫.proto 文件,定義序列化對象的數(shù)據(jù)結(jié)構(gòu)
- 基于編寫的.proto 文件,使用 protocol compiler 編譯器生成對應(yīng)的序列化/反序列化工具類
- 基于自動(dòng)生成的代碼,編寫自己的序列化應(yīng)用
Protobuf 案例演示
下載 protobuf 工具,https://github.com/google/protobuf/releases, 找到 protoc-3.5.1-win32.zip。
編寫 proto 文件
syntax="proto2";
package com.gupaoedu.serial;
option java_package =
"com.gupaoedu.serial";
option java_outer_classname="UserProtos";
message User {
required string name=1;
required int32 age=2;
}
數(shù)據(jù)類型:string / bytes / bool / int32(4 個(gè)字節(jié))/ int64 / float / double / enum 枚舉類 / message 自定義類
修飾符:required 表示必填字段;optional 表示可選字段;repeated 可重復(fù),表示集合。
1, 2, 3, 4 需要在當(dāng)前范圍內(nèi)是唯一的,表示順序。
生成實(shí)體類
【.\protoc.exe --java_out=./ ./user.proto】
實(shí)現(xiàn)序列化
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>RELEASE</version>
</dependency>
UserProtos.User user=UserProtos.User.newBuilder()
.setName("Mic")
.setAge(18).build();
ByteString bytes=user.toByteString();
System.out.println(bytes);
UserProtos.User nUser=UserProtos.User.parseFrom(bytes);
System.out.println(nUser);
protobuf 序列化原理
我們可以把序列化以后的數(shù)據(jù)打印出來看看結(jié)果
我們可以看到,序列化出來的數(shù)字基本看不懂,但是序列化以后的數(shù)據(jù)確實(shí)很小,那我們接下來帶大家去了解一下底層的原理。
正常來說,要達(dá)到最小的序列化結(jié)果,一定會用到壓縮的技術(shù),而 protobuf 里面用到了兩種壓縮算法,一種是 varint,另一種是 zigzag。
varint
先說第一種,我們先來看 age=300 這個(gè)數(shù)字是如何被壓縮的
這兩個(gè)字節(jié)字節(jié)分別的結(jié)果是: -84 、 2
-84 怎么計(jì)算來的呢? 我們知道在二進(jìn)制中表示負(fù)數(shù)的方法,高位設(shè)置為 1, 并且是對應(yīng)數(shù)字的二進(jìn)制取反以后再計(jì)算補(bǔ)碼表示(補(bǔ)碼是反碼+1)
所以如果要反過來計(jì)算
- 【補(bǔ)碼】 10101100 -1 得到 10101011
- 【反碼】 01010100 得到的結(jié)果為 84. 由于高位是 1,表示負(fù)數(shù)所以結(jié)果為-84
字符如何轉(zhuǎn)化為編碼
“Mic”這個(gè)字符,需要根據(jù) ASCII 對照表轉(zhuǎn)化為數(shù)字。
M =77、 i=105、 c=99
所以結(jié)果為 77 105 99
大家肯定有個(gè)疑問,這里的結(jié)果為什么直接就是 ASCII 編碼的值呢?怎么沒有做壓縮呢?有沒有同學(xué)能夠回答出來。
原因是, varint 是對字節(jié)碼做壓縮,但是如果這個(gè)數(shù)字的二進(jìn)制只需要一個(gè)字節(jié)表示的時(shí)候,其實(shí)最終編碼出來的結(jié)果是不會變化的。
還有兩個(gè)數(shù)字, 3 和 16 代表什么呢?那就要了解 protobuf 的存儲格式了
存儲格式
protobuf 采用 T-L-V 作為存儲方式
tag 的計(jì)算方式是 field_number(當(dāng)前字段的編號) << 3 | wire_type
比如 Mic 的字段編號是 1 ,類型 wire_type 的值為 2 所以 : 1 <<3 | 2 =10
age=300 的字段編號是 2,類型 wire_type 的值是 0, 所以 : 2<<3|0 =16
第一個(gè)數(shù)字 10,代表的是 key,剩下的都是 value。
負(fù)數(shù)的存儲
在計(jì)算機(jī)中,負(fù)數(shù)會被表示為很大的整數(shù),因?yàn)橛?jì)算機(jī)定義負(fù)數(shù)符號位為數(shù)字的最高位,所以如果采用 varint 編碼表示一個(gè)負(fù)數(shù),那么一定需要 5 個(gè)比特位。所以在 protobuf 中通過sint32/sint64 類型來表示負(fù)數(shù),負(fù)數(shù)的處理形式是先采用 zigzag 編碼(把符號數(shù)轉(zhuǎn)化為無符號數(shù)),在采用 varint 編碼。
sint32: (n << 1) ^ (n >> 31)
sint64: (n << 1) ^ (n >> 63)
比如存儲一個(gè)(-300)的值
-300
原碼: 0001 0010 1100
取反: 1110 1101 0011
加 1 : 1110 1101 0100
n<<1: 整體左移一位,右邊補(bǔ) 0 -> 1101 1010 1000
n>>31: 整體右移 31 位,左邊補(bǔ) 1 -> 1111 1111 1111
n<<1 ^ n >>31
1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111
十進(jìn)制: 0010 0101 0111 = 599
varint 算法: 從右往做,選取 7 位,高位補(bǔ) 1/0(取決于字節(jié)數(shù))
得到兩個(gè)字節(jié)
1101 0111 0000 0100
-41 、 4
總結(jié)
Protocol Buffer 的性能好,主要體現(xiàn)在 序列化后的數(shù)據(jù)體積小 & 序列化速度快,最終使得
傳輸效率高,其原因如下:
序列化速度快的原因:
a. 編碼 / 解碼 方式簡單(只需要簡單的數(shù)學(xué)運(yùn)算 = 位移等等)
b. 采用 Protocol Buffer 自身的框架代碼 和 編譯器 共同完成
序列化后的數(shù)據(jù)量體積小(即數(shù)據(jù)壓縮效果好)的原因:
a. 采用了獨(dú)特的編碼方式,如 Varint、 Zigzag 編碼方式等等
b. 采用 T - L - V 的數(shù)據(jù)存儲方式:減少了分隔符的使用 & 數(shù)據(jù)存儲得緊湊
序列化技術(shù)的選型
- 技術(shù)層面
- 序列化空間開銷,也就是序列化產(chǎn)生的結(jié)果大小,這個(gè)影響到傳輸?shù)男阅?/li>
- 序列化過程中消耗的時(shí)長,序列化消耗時(shí)間過長影響到業(yè)務(wù)的響應(yīng)時(shí)間
- 序列化協(xié)議是否支持跨平臺,跨語言。因?yàn)楝F(xiàn)在的架構(gòu)更加靈活,如果存在異構(gòu)系統(tǒng)通信需求,那么這個(gè)是必須要考慮的
- 可擴(kuò)展性/兼容性,在實(shí)際業(yè)務(wù)開發(fā)中,系統(tǒng)往往需要隨著需求的快速迭代來實(shí)現(xiàn)快速更新,這就要求我們采用的序列化協(xié)議基于良好的可擴(kuò)展性/兼容性,比如在現(xiàn)有的序列化數(shù)據(jù)結(jié)構(gòu)中新增一個(gè)業(yè)務(wù)字段,不會影響到現(xiàn)有的服務(wù)
- 技術(shù)的流行程度,越流行的技術(shù)意味著使用的公司多,那么很多坑都已經(jīng)淌過并且得到了解決,技術(shù)解決方案也相對成熟
- 學(xué)習(xí)難度和易用性
- 選型建議
- 對性能要求不高的場景,可以采用基于 XML 的 SOAP 協(xié)議;
- 對性能和間接性有比較高要求的場景,那么 Hessian、 Protobuf、 Thrift、 Avro 都可以;
- 基于前后端分離,或者獨(dú)立的對外的 api 服務(wù),選用 JSON 是比較好的,對于調(diào)試、可讀性都很不錯(cuò);
- Avro 設(shè)計(jì)理念偏于動(dòng)態(tài)類型語言,那么這類的場景使用 Avro 是可以的。
各個(gè)序列化技術(shù)的性能比較
這 個(gè) 地 址 有 針 對 不 同 序 列 化 技 術(shù) 進(jìn) 行 性 能 比 較 :https://github.com/eishay/jvmserializers/wiki