Tinking in Java ---Java的NIO和物件序列化

acm_lkl發表於2020-04-04

前面一篇部落格的IO被稱為經典IO,因為他們大多數都是從Java1.0開始就有了的;然後今天這篇部落格是關於NIO的,所以的NIO其實就是JDK從1.4開始,Java提供的一系列改進的輸入/輸出處理的新功能,這些新功能被統稱為新IO(New IO ,簡稱NIO)。另一個概念物件序列化指的是將那些實現了Serializable介面的物件轉換成一個位元組序列,並能夠在以後將這個位元組序列再轉換成原來的物件。這樣的話我們就可以將物件寫入磁碟中,或者是將物件在網路上進行傳遞。下面就對這兩個內容進行總結。

一.Java的NIO
java的NIO之所以擁有更高的效率,是因為其所使用的結構更接近與作業系統的IO方式:使用通道和緩衝器。通道里面放有資料,但是我們不能直接與它打交道,無論是從通道中取資料還是放資料,我們都必須通過緩衝器進行,更嚴格的是緩衝器中存放的是最原始的位元組資料而不是其它型別。其中Channel類對應我們上面講的通道,而Buffer類則對應緩衝器,所以我們有必要了解一下這兩個類。
(1).Buffer類
從底層的資料結構來看,Buffer像是一個陣列,我們可以在其中儲存多個相同型別的資料。Buffer類是一個抽象陣列,它最常用的子類是ByteBuffer,這個類存取的最小單位是位元組,正好用於和Channel打交道。當然除了ByteBuffer外,其它基本型別(除boolean外)都有自己對應的Buffer:CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer。使用這些型別我們可以很方便的將基本型別的資料放入ByteBuffer中去.Buffer類還有一個子類MappedByteBuffer,這個子類用於表示Channel將磁碟檔案的部分或全部內容得到的結果。
Buffer中有三個概念比較重要:容量(capacity),界限(limit)和位置(positiion)。
容量指的緩衝區的大小,即該Buffer能裝入的最大資料量。
界限指的是當前裝入的最後一個資料位置加1的那個值,表示的是第一個不應該被讀或寫的位置。
位置用於表明下一個可以被讀出或寫入的緩衝區位置索引。
Buffer的主要功能就是裝資料,然後輸出資料。所以我們有必要了解一下這個具體的過程:首先Buffer的positiion為0,limit等於capacity,程式可以通過put()方法向Buffer中放入一些資料(或者是從Channel中取出一些資料),在這個過程中position會往後移動。當Buffer裝入資料結束以後,呼叫Buffer的flip()方法為輸出資料做好準備,這個方法會把limit設為position,將position設為limit。當輸出資料結束後,Buffer呼叫clear()方法,clear()方法不會情況所有的資料,只會把position設為0,將limit設為capacity,這樣又為向Buffer中輸入資料做好了準備。
另外指的注意的是Buffer的子類是沒有建構函式的,所以不能顯式的宣告一個Buffer。下面的這份程式碼展示了CharBuffer的基本用法:

package lkl;
import java.nio.*;

public class BufferTest {

    public static void main(String[] args){

        //通過靜態方法建立一個CharBuffer
        System.out.println("建立buffer之後: ");
        CharBuffer buffer = CharBuffer.allocate(10);
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());

        ///向buffer裡放三個字元
        buffer.put("a");
        buffer.put("b");
        buffer.put("c");

        ///為使用buffer做準備
        System.out.println();
        System.out.println("在向buffer中裝入資料並呼叫flip()方法後: ");
        buffer.flip();
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());

        ///讀取buffer中的元素,以絕對方式和相對方式兩種
        //絕對方式不會改變position指標的
        //而相對方式每次都會讓position指標後移一位
        System.out.println(buffer.get());
        System.out.println(buffer.get(2));

        System.out.println();
        System.out.println("呼叫clear()後: ");
        //呼叫clear()方法,為再次向buffer中輸入資料做準備
        //但是這個方法只是移動各個指標的位置,而不會清空緩衝區中的資料
        buffer.clear();
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());

        ///clear()方法沒有清空緩衝區
        //所以還可以通過絕對方式來訪問緩衝區裡面的內容
        System.out.println(buffer.get(2));
    }
}

一般都是用的ByteBuffer,所以需要先將資料轉成位元組陣列後在放入,但是對應基本資料型別可以使用ByteBuffer類的asXXXBuffer簡化這個過程。如下面的程式碼所示:

package lkl;

import java.nio.ByteBuffer;

//向Channel中寫入基本型別的資料
//向ByteBuffer中插入基本型別資料的最簡單的方法就是:利用ascharBuffer()
//asShortBuffer()等獲得該緩衝器上的檢視,然後呼叫該檢視的put()方法
//short型別需要轉一下型,其它基本型別不需要
public class GetData {

    public static void main(String[] args){
        ByteBuffer buff = ByteBuffer.allocate(1024);

        ///讀取char型資料
        buff.asCharBuffer().put("java");
        //buff.flip(); //這時候不需要flip()
        char c;
        while((c=buff.getChar())!=0){
            System.out.print(c+" ");
        }
        System.out.println();
        buff.rewind();

        //讀取short型資料
        buff.asShortBuffer().put((short)423174);
        System.out.println(buff.getShort());
        buff.rewind();

        //讀取long型資料
        buff.asLongBuffer().put(689342343);
        System.out.println(buff.getLong());
        buff.rewind();

        //讀取float型資料
        buff.asFloatBuffer().put(2793);
        System.out.println(buff.getFloat());
        buff.rewind();

        //讀取double型資料
        buff.asDoubleBuffer().put(4.223254);
        System.out.println(buff.getDouble());
        buff.rewind();
    }/*Output
    j a v a 
    29958
    689342343
    2793.0
    4.223254
          */
}

當然Buffer類還有其它的很多方法,可以通過它的API文件來進行了解。反正現在我們知道了要想跟Channel打交道,必須要使用Buffer。

(2).Channel類
Channel類對應我們開頭說的通道了,注意到Channel類是面向位元組流的,所以並不是我們前面學習的所有IO類都可以轉換成Channel的。實際上Java為Channel提供了FileChannel,DataGramChannel,selectableChannel,ServerSocketChannel,SocketChannel等實現類。在這裡我們只瞭解FileChannel,它可以通過FileInputStream,FileOutputStream,RandomAccessFile這幾個類的getChannel()方法得到;當然這幾個類得到的對應的FileChannel物件在功能上也是不同的,FileOutputStream對應的FileChannel只能向檔案中寫入資料,FileInputStream對應的FileChannel只能向檔案中讀資料,而RandomAccessFile對應的FileChannel對檔案即能讀又能寫。這也說明這個類也是沒有構造器可以呼叫的。下面的程式碼演示瞭如何使用Channel向檔案中寫資料和讀取檔案中的資料:

import java.io.*;
import java.nio.*;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;


///Channel是java提供的新的一種流物件
//Channel可以將檔案部分或則全部對映為Channel
///但是我們不能直接與Channel打交道,無論是讀寫都需要通過Buffer才行
///Channel不通過構造器來獲得,而是通過傳統結點的InputStream,OutputStream的getChannel()方法獲得
public class FileChannelTest {

    public static void main(String[] args){

         File f= new File("/Test/a.java");

         try( 
                  ///建立FIleInputStream,以該檔案輸入流建立FileChannel
                   FileChannel  inChannel = new FileInputStream(f).getChannel();

                 ///以檔案輸出流建立FileChannel,用以控制輸出
                 FileChannel outChannel = new FileOutputStream("/Test/test.txt").getChannel())
                 {
                     ///將FileChannel裡的全部資料對映成ByteBuffer
                       MappedByteBuffer buffer   = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, f.length());

                       ///使用GBK字符集來建立解碼器
                       Charset charset = Charset.forName("GBK");

                       ///將buffer中的內容全部輸出
                       outChannel.write(buffer);

                       buffer.clear();

                       ///建立解碼器物件
                       CharsetDecoder decoder = charset.newDecoder();

                       ///使用解碼器將ByteBuffer轉換成CharBuffer
                       CharBuffer charbuffer = decoder.decode(buffer);

                       ///CharBuffer中的toString方法可以獲得對應的字串
                       System.out.println(charbuffer.toString());
                 }
                catch(IOException e){
                    e.printStackTrace();
                }
    }
}

注意到上面的程式碼中使用瞭解碼,這是因為ByteBuffer中裝的是位元組,所以如果我們直接輸出則會產生亂碼,如果想從ByteBuffer中讀取到正確得內容,那麼就需要進行編碼。有兩種形式,第一種是在將資料寫入ByteBuffer中時就進行編碼;第二種是從ByteBuffer中讀出後進行解碼。至於編碼解碼的話可以使用Charset類進行。如下面的程式碼所示:

package lkl;

import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.io.*;

///FileChannel轉換資料型別
//FileChannel的寫入型別只能是ByteBuffer,所以就引生出來編碼解碼的問題
public class BufferToText {

    private static final int SIZE =1024;
    public static void main(String[] args) throws IOException{
        FileChannel fc = new FileInputStream("/Test/b.txt").getChannel();
        ByteBuffer buff = ByteBuffer.allocate(SIZE);
        fc.read(buff);
        buff.flip();
        ///將ByteBuffer轉成CharBuffer,但是實際上沒有實現型別的轉換,輸出亂碼
        System.out.println(buff.asCharBuffer());

        buff.rewind(); //指標返回開始位置,為解碼做準備    
        //輸出時解碼,使得位元組正確的轉換成字元
        String encoding = System.getProperty("file.encoding");
        System.out.println("Decoded using "+encoding+": \n"+Charset.forName(encoding).decode(buff));

        buff.clear();
        //輸入時進行編碼,使得位元組正確的轉換成字元
        fc= new FileOutputStream("/Test/a1.txt").getChannel();
        buff.put("some txt".getBytes("UTF-8"));  ///將字元轉成位元組時進行編碼
        buff.flip();
        fc.write(buff);
        fc.close();
        fc= new FileInputStream("/Test/a1.txt").getChannel();
        buff.clear();
        fc.read(buff);
        buff.flip();
        System.out.println(buff.asCharBuffer()); //進行編碼以後再轉換就不會有問題了

        ///如果直接使用CharBuffer進行寫入的話,也不會有編碼的問題
        fc = new FileOutputStream("/Test/a1.txt").getChannel();
        buff.clear();
        buff.asCharBuffer().put("this is test txt");
        fc.write(buff);

        fc = new FileInputStream("/Test/a1.txt").getChannel();
        buff.clear();
        fc.read(buff);
        buff.flip();
        System.out.println(buff.asCharBuffer());
        fc.close();
    }
    /*
        瑨楳⁩猠瑥獴⁦楬攊
        Decoded using UTF-8: 
        this is test file

        獯浥⁴硴
        this is test txt
     */
}

(3).關於大端序和小端序的問題
大端序(高位優先)和小端序(低位優先)的問題
大端序是指將重要的位元組放在地址最低的儲存單元
小端序是指將重要的位元組放在地址最高的儲存單元
ByteBuffer是以大端序的形式儲存資料的。
舉個例子:00000000 01100001
上面這組二進位制資料表示short型整數(一個數8位)
如果採用大端序表示97,如果是小端序則表示(0110000100000000)24832
下面的程式碼演示了大端序和小端序的比較:

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;

//我們可以採用帶有引數的ByteOrder.BIG_ENDIAN 或ByteOrder.LITTLE_ENDIAN的
//oder()方法改變ByteBuffer的位元組排序方式
public class Endians {

    public static void main(String[] args){

        ByteBuffer bb  =ByteBuffer.allocate(12);
        bb.asCharBuffer().put("abcdef");
        System.out.println(Arrays.toString(bb.array()));

        bb.rewind();
        bb.order(ByteOrder.BIG_ENDIAN);
        bb.asCharBuffer().put("abcdef");
        System.out.println(Arrays.toString(bb.array()));

        bb.rewind();
        bb.order(ByteOrder.LITTLE_ENDIAN);
        bb.asCharBuffer().put("abcdef");
        System.out.println(Arrays.toString(bb.array()));
    }
}/*Output
    [0, 97, 0, 98, 0, 99, 0, 100, 0, 101, 0, 102]
[0, 97, 0, 98, 0, 99, 0, 100, 0, 101, 0, 102]
[97, 0, 98, 0, 99, 0, 100, 0, 101, 0, 102, 0]
*/

資料在網上傳輸時用的也是大端序(高位優先)。

二.物件序列化問題
物件序列化指的是將那些實現了Serializable介面的物件轉換成一個位元組序列,並能夠在以後將這個位元組序列完全恢復為原先的物件。利用物件的序列化可以實現輕量級的永續性。“永續性”意味著一個物件的生存週期並不取決與程式是否正在執行;他可以生存在程式的呼叫之間。通過將一個序列化的物件寫入磁碟然後在重新呼叫程式時恢復該物件就可以實現永續性的過程。之所以稱為”輕量級”,是因為沒有一個關鍵字可以方便的實現這個過程,整個過程還需要我們手動維護。
總的來說一般的序列化是沒有什麼很困難的,我們只要然相應的類繼承一下Serializable介面就行了,而這個介面是一個標記介面,並不需要實現什麼具體的內容,然後呼叫ObjectOutputStream將物件寫入檔案(序列化),如果想要恢復就用ObjectInputStream從檔案中讀取出來(反序列化);注意這兩個類都是包裝流,需要傳入其它的結點流。如下面的程式碼所示,從輸出來看,反序列化後物件的確實和原來的物件是一樣的:

package lkl;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.util.*;

class Base implements Serializable{
    private int i;
    private int j;
    public Base(int i,int j){
        this.i=i;
        this.j=j;
    }

    public String toString(){
        return"[ "+ i+" "+j+" ]";

    }
}

public class Test {
    public  static void main(String[] args) throws IOException,ClassNotFoundException{
        Base base =new Base(1,3);
        System.out.println(base);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("/Test/Base.out"));
        out.writeObject(base); //將對像寫入磁碟

       ObjectInputStream in = new ObjectInputStream(new FileInputStream("/Test/Base.out"));
       Base base1 =(Base)in.readObject(); ///將物件從磁碟讀出
       System.out.println(base1);
    }/*
       [ 1 3 ]
       [ 1 3 ]
    */
}

除了實現Serializable介面外我們也可以通過實現Externalizable介面來實現序列話,這個介面執行我們自己對序列化的過程進行控制,我們手動的選擇對那些變數進行序列化和反序列化。這些是依據這個介面中的兩個函式:writeExternal()和readExternal()函式實現的。下面的程式碼演示了Externalizable介面的簡單實現,要注意Blip1和Blip2類是有輕微不同的:

Constructin objects: 
Blip1 Constructor
Blip2.Constructor
Saving objects: 
Blip1.writeExternal
Blip2.writeExternal
Recovering p1: 
Blip1 Constructor
Blip1.readExternal

我們可以看到在反序列化的過程中,是會呼叫預設構造器的,如果沒有預設構造器可以呼叫(許可權不為public)則在反序列的過程中,會出錯。

另外如果我們實現的是Serializable介面但是我們希望某些變數不進行序列化,那麼我們就可以用transient關鍵字對它們進行修飾。然後還要注意的是對於實現了Serializable介面的量,static變數是不會自動序列化的,我們必須手動進行序列化和反序列化才行。下面的程式碼演示了這兩點:

package lkl;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.util.*;

class Base implements Serializable{
    private int i;
    private transient int j;
    private static int k=9;
    public Base(int i,int j){
        this.i=i;
        this.j=j;
    }

    public String toString(){
        return"[ "+ i+" "+j+" "+k+" ]";
    }
}

public class Test {
    public  static void main(String[] args) throws IOException,ClassNotFoundException{
        Base base =new Base(1,3);
        System.out.println(base);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("/Test/Base.out"));
        out.writeObject(base); //將對像寫入磁碟

       ObjectInputStream in = new ObjectInputStream(new FileInputStream("/Test/Base.out"));
       Base base1 =(Base)in.readObject(); ///將物件從磁碟讀出
       System.out.println(base1);
    }/*
       [ 1 3 9 ]
       [ 1 0 9 ]
    */
}

下面的程式碼演示了在Serializable介面中我們也可以通過自己編寫方法來控制序列化和反序列的過程(感覺很亂):

package lkl;
import java.io.*;

//通過在Serializable介面的實現中新增以下兩個方法:
//private void writeObject(ObjectOutputStream stream) throws IOException
//private void readObject(ObjectInputStream stream) throws IOException,ClassNotFoundException
//就可以在這兩個方法中自己自定需要序列化和反序列化的元素
///在writeObject()中呼叫defaultWriteObject()就可以選擇執行預設的writeObject()
//在readObject()中呼叫defaultReadObject()就可以執行預設的readObject()
public class SerialCtl implements Serializable{

    private String a;
    private transient String b;
    public SerialCtl(String aa,String bb){
        a="Not Transient: "+aa; 
        b="transient: "+bb;
    }

    public String toString(){
        return a+"\n"+b;
    }

    private void writeObject(ObjectOutputStream stream) throws IOException{
        stream.defaultWriteObject();
        stream.writeObject(b);
    }

    private void readObject(ObjectInputStream stream) throws IOException,ClassNotFoundException{
        stream.defaultReadObject();
        b=(String)stream.readObject();
    }

    public static void main(String[] args)throws IOException,ClassNotFoundException{
        SerialCtl sc = new SerialCtl("Test1","Test2");
        System.out.println("Before: ");
        System.out.println(sc);

        //這次序列化資訊不存到檔案,而是存到緩衝區去
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        ObjectOutputStream o = new ObjectOutputStream(buf);
        o.writeObject(sc);

        ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(buf.toByteArray()));
        SerialCtl sc2 =(SerialCtl)in.readObject();
        System.out.println("After: ");
        System.out.println(sc2);
    }
}

最後需要強調的是:如果我們有很多個可以序列化的物件存在相互引用關係,序列化時只需要將他們統一打包進行序列化就可以,系統會自動維護一個序列化關係的網路。然後我們進行反序列化時,其實系統還是通過.class檔案獲得這個物件相應的資訊的。

相關文章