1. 程式人生 > >[瘋狂Java]I/O:I/O流的最高境界——物件流(序列化:手動序列化、自動序列化、引用序列化、版本)

[瘋狂Java]I/O:I/O流的最高境界——物件流(序列化:手動序列化、自動序列化、引用序列化、版本)

1. 什麼是物件流:序列化/反序列化的概念

    1) 物件流是和位元組流/字元流同處於一個概念體系的:

        a. 這麼說位元組流是流動的位元組序列,字元流是流動的字元序列,那麼物件流就是流動的物件序列咯?

        b. 概念上確實可以這樣理解,物件流就是專門用來傳輸Java物件的;

        c. 但是位元組和字元都是非常直觀的二進位制碼(位元組本身就是,而字元是一種二進位制編碼),二進位制碼的流動是符合計算機的概念模型的,可是物件是一個抽象的東西,物件怎麼能像二進位制碼那樣流動呢?

        d. 其實很好理解,物件流只不過是Java的API而已,表象上(方法呼叫等)流動的是物件,而實際上在底層肯定都是轉換成二進位制碼流動的;

        e. 具體來說底層是將物件轉換成平臺無關的Java位元組流進行傳播的,以為物件流的類名就是ObjectInputStream和ObjectOutputStream,以stream作為字尾必然傳遞的是位元組流;

        f. 只不過在呼叫物件流的read和write系列方法時不用將物件轉換成位元組陣列傳入了,而是可以直接傳入物件本身,這些方法內部會自動將物件轉化成位元組流傳遞!!

    2) 為什麼需要物件流:

        i. 首先,Java本身就是一個面向物件的語言,因此物件比位元組/字元的使用更廣泛;

        ii. 傳遞文本當然使用字元流,因此字元流的使用很廣泛,即文字、字串的處理、儲存等應用很廣,這毋庸置疑,而直接傳遞位元組的應用(比如影象、音訊、二進位制檔案等)可能也非常廣泛,但是Java不僅僅是用來處理這兩種資料的,Java真正面對最多的還是物件;

        iii. 程式往往需要在各個儲存節點間傳遞Java物件(即Java物件的輸入輸出),按照傳統方法要麼就是使用位元組流來傳遞要麼就是用字元流來傳遞:

             a. 首先字元流可以排除,因為物件中可能既包含文字型資料(String等),也可能包含非文字類資料(比如位元組、影象等),如果將影象這樣的資料也轉換成字元的話顯然是行不通的;

             b. 那就只能使用位元組流了,但是位元組流的使用很麻煩,需要自己手動將物件內所有非位元組型資料現轉換成位元組資料,然後將所有轉換成位元組的成員全部擠進一個位元組陣列寫入(輸出),而輸入的時候必須先用一整個位元組陣列讀取,然後對陣列進行解析,最後再還原成原來的物件;

!!這一看上去就已經難於上青天了,誰都不願意這樣進行物件的I/O;

    3) 因此Java提供了物件流(ObjectInputStream、ObjectOutputStream)——用以自動序列化物件並傳輸物件:

        i. 首先了解序列化(Serialize)的概念:

           a. 即C++的序列化;

           b. 即不管傳遞什麼資料(位元組、字元、物件),在底層都必須是二進位制位元組流,以為計算機只能識別二進位制碼,因此位元組就不用說了,字元肯定也要轉換成二進位制編碼,同樣物件也必須轉換成二進位制位元組序列才能傳輸;

           c. 序列化就是指,把原本程式中的資料(抽象資料,如字元、物件等)轉化成二進位制位元組序列的過程;

           d. 序列化是資料傳輸的前提;

!!而反序列化就是將已經儲存在儲存節點上的序列化的資料讀取並還原成原來的Java物件咯!

        ii. Java的物件流首先可以自動序列化物件:

輸出:ObjectOutputStream

           a. 當用物件流輸出一個物件時會先自動解析物件中的成員;

           b. 然後自動將各個成員序列化成一個個位元組陣列;

           c. 然後將各陣列按照成員的定義順序拼接成一個完整的位元組陣列,最後用該陣列傳遞;

輸入:ObjectInputStream

           a. 當然輸出的時候ObjectOutputStream可定會給物件本身以及每個物件成員做一定的身份標識;

           b. 身份標識其實就是資料的Java型別(還原物件的時候必須要知道Java型別)、以及物件的大小(必須要知道讀多少個位元組才能剛好把該物件讀完);

           c. 物件輸入流就可以根據這些完備的資訊從六中還原Java物件;

           d. 底層就是先將完整的序列化物件儲存在一個位元組陣列中,然後根據這些資訊解析陣列,並還原出一個完整的Java物件;

           e. 那些身份標識其實就是序列化和反序列化的協議了,必須遵守協議才能保證序列化後能正確地反序列化;

2. 使用物件流輸入輸出的大致過程:

    1) 已經知道了物件流就是ObjectOutputStream和ObjectInputStream,現在介紹使用它們的大致流程;

    2) 首先I/O是有目的地的,即你要從哪兒流向哪兒,其中兩點之中一點是確定的,那就是當前的程式,那麼就必須指定另外一點了;

    3) 因此第一步就是要確定儲存節點,因此ObjectOutputStream和ObjectInputStream是一種高階處理流,必須要包裝一個具體的節點流才行;

    4) 它倆的構造器:

        i. ObjectInputStream(InputStream in);

        ii. ObjectOutputStream(OutputStream out);

!!可以用任何節點流來初始化它們;

    5) 接著就是使用物件流的read和write系列方法進行讀寫了:

        i. 物件流的讀寫系列方法很簡單,不涉及位元組、位元組陣列等;

        ii. read系列:Xxx ObjectInputStream.readXxx();

!!read之後需要根據實際的型別強制轉換一下

        iii. write系列:void ObjectOutputStream.writeXxx(Xxx val);

        iv. Xxx是涵蓋了Java幾乎所有的基礎型別(byte、int、char、boolean、double等),其中最重要的就是Object,readObject和writeObject就是輸入輸出Java物件的關鍵所在;

!!注意沒有String,因為String不是基礎型別,String是類,因此讀寫String直接用readObject、writeObject即可!!

!!也就是說,物件流不僅可以輸入輸出Java物件,也可以輸入輸出普通資料的,可見功能之強大!

!!那既然可以輸入輸出Java物件那為啥還要提供byte、int、double等普通資料型別的版本呢?這是為了可以實現自定義的序列化而提供的!

3. 自定義序列化:必須實現Serializable接口才能序列化

    1) 不是隨便呼叫物件流的read和write就能隨隨便便輸入輸出一個物件的,前提必須是這個物件是可序化/可反序列化的!

    2) 即物件流必須知道這個物件該如何序列化以及反序列化,才能正確對該物件進行輸入輸出;

    3) Serializable介面:

         i. 必須實現該接口才能自動序列化和反序列化;

         ii. 該介面有兩個要實現的方法,反別對應如何序列化和反序列化:

             a. 序列化的演算法實現:private void writeObject(ObjectOutputStream out) throws IOException;

             b. 反序列化的演算法實現:private Object readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;

!!可以看到反序列化時並不是用構造器構造的,而是直接根據輸入流生成一坨無型別的資料,然後用強制型別轉換轉換出來的!因此可以看到該方法會丟擲ClassNotFoundException,如果沒有準備好相應的型別則會丟擲該異常;

!!物件流輸入輸出底層其實是這樣呼叫的:

       a. ObjectOutputStream oos:oos.writeObject(obj)    ->    obj.wirteObject(oos)

       b. ObjectInputStream ois:ois.readObject(obj)    ->    obj.readObject(ois)

!!可以看到,實現的演算法中就是利用obj得到的oos和ois進行輸入輸出;

    4) 畢竟有些讀寫並不是規規矩矩地原模原樣地讀寫,比如像密碼這樣的資訊,在輸出的時候往往需要加密輸出,因此讀取的時候也要解密讀取,像這樣的情況就必須自己定義序列化和反序列化的演算法了;

    5) 絕大多數的Java基礎類,比如String、Date等都實現了Serializable介面,因此可以直接用物件流的readObject和writeObject讀寫;

    6) 所有的基礎型別(int、double、boolean等)物件流也提供了相應的readXxx和writeXxx進行序列化和反序列化,因此也不用擔心;

    7) 因此大多數的自定義型別的物件就要自己實現序列化和反序列化的演算法了,而上面提供的基礎型別的物件流輸入輸出就是為自定義準備的,例如以下:

class Member implements Serializable {
	String name;
	int age;
	
	public Member(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}

	@Override
	public String toString() {
		// TODO Auto-generated method stub
		return name + "(" + age + ")";
	}



	private void writeObject(ObjectOutputStream out) throws IOException { // 輸出之前進行加密(序列化演算法)
		out.writeObject(new StringBuffer(name).reverse()); // 名字反序加密
		out.writeInt((age << 4) + 13); // 年齡左移4位再加13加密
	}
	private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { // 輸入解密(反序列化演算法,就是序列化的逆過程)
		name = ((StringBuffer)in.readObject()).reverse().toString();
		age = (in.readInt() - 13) >> 4;
	}
}

public class Test implements Serializable {

	
	public static void print(String s) {
		System.out.println(s);
	}

	public static void main(String[] args) throws IOException, ClassNotFoundException {
		try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.txt"))) {
			Member m = new Member("lalala", 15);
			oos.writeObject(m);
			oos.close();
		}
		try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.txt"))) {
			Member m = (Member)ois.readObject();
			print("" + m);
		}
		
	}
}

!!可以檢視一下,雖然序列化的結果是二進位制,開啟會看到亂碼,但不過英文字元部分還是按照Unicode進行編碼的,可以看到裡面倒序的"alalal";

    8) 實現序列化和反序列化演算法時的注意事項和規範:

         i. 一般要先寫序列化厚些反序列化,以為序列化是一種編碼過程,而反序列化是一種解碼過程,一般編碼邏輯優先於解碼,解碼是編碼的逆過程,但通常不會說編碼是解碼的逆過程;

!!簡單地將就是反序列化要參照著序列化來寫;

         ii. 反序列化要按照序列化的順序來,比如序列化是按照先String成員再int成員的順序,那麼反序列化是也是先String後int,因為序列化是一種順序結構;

4. 自動序列化——遞迴序列化(其實Serializable只是一個標記介面):

    1) 之前介紹的自定義序列化就是手動序列化,它的手動體現在需要自己手動實現序列化和反序列化的演算法;

    2) 而實際上如果你物件中的所有成員都已經實現了Serializable介面了(已經可序列化了),那麼該物件不實現序列化和反序列化演算法也可以自動序列化;

    3) 比如:物件a中包含成員物件b,而b中包含成員物件c,c中包含成員物件d...,如果b、c、d...都已經實現了序列化演算法,那麼a就可以自動序列化而無需實現自己的序列化演算法了,當a序列化時會按照如下方式:a -> b.writeObject -> c.writeObject -> d.writeObject....其中a -> b.writeObject表示a在序列化時會自動呼叫b.writeObject對b進行序列化,而b.writeObject -> c.writeObject是指b.writeObject方法中呼叫了c.writeObject,以此類推,也就是一層一層自動地遞迴呼叫內層物件的writeObject進行序列化,這種呼叫是自動的;

!具體的例子:A a(String b, B c(Date d, String e))物件,其中A型別的a物件包含成員b(String型別)和成員c(型別B),而成員c包含成員d(Date型別)和成員e(String型別),如果B已經實現了序列化/反序列化演算法了,那麼序列化a時可以不實現A自己的序列化/反序列化演算法而自動序列化,自動序列化方式是:呼叫b.writeObject,再呼叫c.writeObject,而如果B沒有實現自己的序列化演算法(沒有實現B的writeObject方法)也沒關係,因為其成員d和e也是可序列化的,會在b.writeObject當中自動呼叫d.writeObject和e.writeObject;

    4) 那為什麼達成序列化而可以不實現其介面方法呢?

         i. 其實Serializable是一個標記介面,其中的序列化演算法和反序列化演算法可以不用實現,該介面只是一個標記,表示該類是可序列話的!

         ii. 讓一個類可序列化其實就只要給它一個標記就行了!即使不實現那兩個演算法也是可序列化的!

    5) 自動序列化的嚴格定義:

         i. 就是隻給一個Serializable的標記,但不實現序列化和反序列化演算法,就表示要使用自動序列化功能;

         ii. 如果自己實現了序列化/反序列化演算法,那麼在序列化/反序列化時就會呼叫自己實現的演算法;

         iii. 如果自己沒有實現序列化演算法,就會遞迴呼叫下一層成員物件的序列化演算法(也就是說,如果下一層成員物件也沒有實現自己的序列化演算法就會自動呼叫下下層的序列化演算法);

         iv. 因此自動序列化也稱作遞迴序列化;

    6) 遞迴序列化的前提:既然遞迴序列化(自動序列化)是一種不自己實現序列化演算法的序列化,那麼其要求自然就是其所有的成員都必須是可序列化的!

!!這很好理解,如果某個成員不可序列化(沒有Serializable標記),那麼如何呼叫其writeObject/readObject方法呢?必然會丟擲異常(雖然編譯不會有異常!);

!!一般,如果類中包含的都是Java的基礎類或基礎型別資料,一般會採用自動序列化,或者成員物件(自定義型別)已經自己實現過序列化演算法了也一般會採用自動序列化;

!除非有特殊需求,比如要對物件進行加密等,這種情況下是必須使用手動序列化的(自己實現序列化演算法);

**小結:如果一個物件的某個成員不可序列化,那麼不管該物件有無Serializable標記都是不可序列化的,如果強行這樣做會丟擲異常!

    7) 自動序列化示例:很簡單,就只需要一個Serializable標記即可

class Member implements Serializable {
	String name; // 可序列化的,Java已經幫你實現了
	int age; // 基礎型別也是可序列化的
	
	public Member(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	@Override
	public String toString() {
		// TODO Auto-generated method stub
		return name + "(" + age + ")";
	}
}

public class Test implements Serializable {

	
	public static void print(String s) {
		System.out.println(s);
	}

	public static void main(String[] args) throws IOException, ClassNotFoundException {
		try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.txt"))) {
			Member m = new Member("lalala", 15);
			oos.writeObject(m);
			oos.close();
		}
		try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.txt"))) {
			Member m = (Member)ois.readObject();
			print("" + m);
		}
		
	}
}
!!正是因為Serializable只是一個標記介面,因此你使用Eclipse的自動override功能的時候找不到那兩個序列化演算法,因此那兩個演算法的方法介面必須全部背出來(丟擲的異常等);

5. Java序列化機制——引用序列化編號:

    1) 考慮到如下情形:

Son son = new Son("Tom", 15);
Parent father = new Parent(son, 40);
Parent mother = new Parent(son, 39);
!即兩個物件持有相同的成員物件,這裡father.son == mother.son(地址也是完全相同的),這種關聯應用在Java(特別是資料庫應用中)使用特別廣泛;

!!現在如果序列化son、father、mother會不會序列化了3次son呢?如果是這樣的話,那麼反序列化的時候不就得到了三個son嗎?那這樣反序列化的結果中father、mother持有的就是不同的son了(地址不同了,三個完全不一樣的記憶體空間了),這不就未被了關聯性的初衷了嗎?

!但還好Java的序列化不是這樣的,它可以智慧地識別這種持有相同物件的情況,並保證只序列化一次公共持有物件;

    2) Java序列化機制——引用序列化編號:

         i. 在使用writeObject時傳入的其實都是引用(引用其實就是指標,而指標的值就是物件的記憶體地址);

         ii. 在序列化時會對每個傳入的待序列化的物件的引用分配一個序列化編號(即為每個待序列化物件的記憶體地址對映一個序列化編號);

         iii. 在序列化之前會先檢查該編號對應的物件是否已經序列化過了,如果序列化過了就不再序列化而是隻寫入該物件的序列化編號,如果沒有,那就對該物件的內容進行序列化並和其編號一併寫入;

         iv. 即無論是否序列化過都一定要寫入編號的,如果之前沒有序列化過就序列化,如果序列化過了則不序列化(但還是要寫入編號);

    3) 對於上面的例子,序列化的結果就是:son的編號1("Tom", 15)、father編號2(編號1, 40)、mother編號3(編號1, 39)

    4) 反序列化也是一樣的,會根據編號來確定物件,每個編號只對應一個物件,保證還原的時候相同編號的都還原到同一個物件,不會重複;

    5) 示例:

class Son implements Serializable {
	String name;
	int age;
	
	public Son(String name, int age) {
		this.name = name;
		this.age = age;
	}
}

class Parent implements Serializable {
	Son son;
	String name;
	int age;
	
	public Parent(Son son, String name, int age) {
		this.son = son;
		this.name = name;
		this.age = age;
	}
}

public class Test {
	
	public static void print(String s) {
		System.out.println(s);
	}

	public static void main(String[] args) throws IOException, ClassNotFoundException {
		try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.buf"))) {
			Son son = new Son("Tom", 15);
			Parent father = new Parent(son, "Peter", 40);
			Parent mother = new Parent(son, "Mary", 39);
			oos.writeObject(son);
			oos.writeObject(father);
			oos.writeObject(mother);
			oos.close();
		}
		try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.buf"))) {
			Son son = (Son)ois.readObject();
			Parent father = (Parent)ois.readObject();
			Parent mother = (Parent)ois.readObject();
			print("" + (son == father.son)); // 答案都是true
			print("" + (father.son == mother.son));
		}
	}
}


6. 序列化機制的潛在危險:

    1) 由於是根據引用值來進行編號的,這就意味著只有引用(地址)才能決定物件是否會被序列化;

    2) 設想一個可變物件,已經被序列化過了,之後再改變該物件的成員的值然後再對該物件進行序列化,那麼改變後的物件也不會被序列化,也只是寫入它的編號而已(因為之前已經序列化過一次了);

    3) 因此Java的物件序列化必須要遵守該法則:保證物件完全確定不再更改後再序列化,序列化過就不要再更改了!!

    4) 測試:

class Son implements Serializable {
	String name;
	int age;
	
	public Son(String name, int age) {
		this.name = name;
		this.age = age;
	}
}

class Parent implements Serializable {
	Son son;
	String name;
	int age;
	
	public Parent(Son son, String name, int age) {
		this.son = son;
		this.name = name;
		this.age = age;
	}
}

public class Test {
	
	public static void print(String s) {
		System.out.println(s);
	}

	public static void main(String[] args) throws IOException, ClassNotFoundException {
		try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.buf"))) {
			Son son = new Son("Tom", 15);
			Parent father = new Parent(son, "Peter", 40);
			Parent mother = new Parent(son, "Mary", 39);
			oos.writeObject(son);
			son.name = "ChaCha"; // 序列化過之後進行更改,再嘗試序列化
			oos.writeObject(father);
			oos.writeObject(mother);
			oos.close();
		}
		try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.buf"))) {
			Son son = (Son)ois.readObject();
			Parent father = (Parent)ois.readObject();
			Parent mother = (Parent)ois.readObject();
			print(son.name); // 結果是Tom而不是ChaCha,可見第二次沒有被真正序列化
			print("" + (father.son == mother.son));
		}
	}
}


7. 序列化版本:

    1) 設想如果你的類在日後擴充套件、升級了,那你以前儲存在節點中的舊版本的物件該如何讀取呢?兩者相容嗎?如果不相容應該怎麼辦?這就涉及到序列化的版本控制問題了;

    2) 序列化類的版本號:

         i. 其實所有標記過Serializable的類都隱藏含有一個版本號:private static final long serialVersionUID;

         ii. 當然這個版本號的值是可以自己顯示定義的,比如直接在類定義中寫下:private static final long serialVersionUID = 2016L;  // 該類的序列化版本好就是2016了

         iii. 如果自己不顯示定義該版本號,那麼JVM就會根據一定的演算法自己預設生成一個版本號(可能根本毫無意義,一個負20位的整數也有可能);

    3) 版本號的作用:在序列化時會將物件所對應類的版本號也寫入,而在反序列化時,JVM會檢視當前該類的版本號是否和當初寫入的時候相同,如果不同那麼就拒絕序列化而丟擲異常!

!!這就引出了一個非常重要的規矩:如果要讓軟體更加健壯那就必須自己手動顯示定義版本號!如果你讓JVM自己預設給出版本號的話,也許你換臺計算機或者換個其它版本JVM得到的版本號值可能就會不同(JVM根據當前環境來計算該版本號的),這樣即使是相同的程式碼也可能導致反序列化時的版本不相容!

    4) JDK檢視類的序列化版本號的工具:在JDK的bin目錄下的serialver命令,其用法:serialver 類名  // 方可返回該類的序列化版本號,注意類名要包含完整包路徑的!因此要調整好當前路徑再執行該命令

!!或者執行命令:serialver -show,就會開啟一個圖形介面的對話方塊,讓你填入完整的類路徑,然後點顯示按鈕就可以在下面的文字框中顯示出版本號,而該類名同樣是針對當前pwd而言的!

    5) 有些對類的升級即使你不改變版本號也會導致序列化失敗:

         i. 如果只是升級了方法而不改變資料成員則不受影響; // 這是必然的,因為序列化的僅僅是資料而已,不包括方法

         ii. 如果只是修改了類的靜態變數則不受影響; // 這也是顯然的,因為序列化的是物件而不是類本身!

         iii. 如果更新後的類只是比舊的少了一些資料成員(其它不變)則不受影響;  // 反序列化是少掉的那幾個直接被捨棄

         iv. 如果更新後的類比舊的多了一些資料成員(其它不變)則不受影響;  // 反序列化是多出的資料成員用null或0來填充

!!對於其它情況(比如改變了資料成員的定義順序,某個變數改變了型別等),即使版本號不變也會導致反序列化失敗,原因很明顯,那就是這些改變會直接導致舊資料填入物件後會發生成員錯位的情況!

!!因此需要為這些改動提供一個新的版本,舊版本的資料就用舊版本的類來讀取,要和新版本完全區分開(簡單將就是無法用新版本的類去接受舊版本節點上的資料了,即不相容);