1. 程式人生 > >Java IO從入門到精通

Java IO從入門到精通

一、概覽

Java 的 I/O 大概可以分成以下幾類:

  • 磁碟操作:File
  • 位元組操作:InputStream 和 OutputStream
  • 字元操作:Reader 和 Writer
  • 物件操作:Serializable
  • 網路操作:Socket
  • 新的輸入/輸出:NIO

二、磁碟操作

File 類可以用於表示檔案和目錄的資訊,但是它不表示檔案的內容。

遞迴地列出一個目錄下所有檔案:

public static void listAllFiles(File dir) {
    if (dir == null || !dir.exists()) {
        return;
    }
    if
(dir.isFile()) { System.out.println(dir.getName()); return; } for (File file : dir.listFiles()) { listAllFiles(file); } }

三、位元組操作

實現檔案複製

public static void copyFile(String src, String dist) throws IOException {
    FileInputStream in = new FileInputStream(src);
    FileOutputStream out =
new FileOutputStream(dist); byte[] buffer = new byte[20 * 1024]; int cnt; // read() 最多讀取 buffer.length 個位元組 // 返回的是實際讀取的個數 // 返回 -1 的時候表示讀到 eof,即檔案尾 while ((cnt = in.read(buffer, 0, buffer.length)) != -1) { out.write(buffer, 0, cnt); } in.close(); out.close(); }

裝飾者模式

Java I/O 使用了裝飾者模式來實現。以 InputStream 為例,

  • InputStream 是抽象元件;
  • FileInputStream 是 InputStream 的子類,屬於具體元件,提供了位元組流的輸入操作;
  • FilterInputStream 屬於抽象裝飾者,裝飾者用於裝飾元件,為元件提供額外的功能。例如 BufferedInputStream 為 FileInputStream 提供快取的功能。

例項化一個具有快取功能的位元組流物件時,只需要在 FileInputStream 物件上再套一層 BufferedInputStream 物件即可。

FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);

DataInputStream 裝飾者提供了對更多資料型別進行輸入的操作,比如 int、double 等基本型別。

四、字元操作

編碼與解碼

編碼就是把字元轉換為位元組,而解碼是把位元組重新組合成字元。

如果編碼和解碼過程使用不同的編碼方式那麼就出現了亂碼。

  • GBK 編碼中,中文字元佔 2 個位元組,英文字元佔 1 個位元組;
  • UTF-8 編碼中,中文字元佔 3 個位元組,英文字元佔 1 個位元組;
  • UTF-16be 編碼中,中文字元和英文字元都佔 2 個位元組。

UTF-16be 中的 be 指的是 Big Endian,也就是大端。相應地也有 UTF-16le,le 指的是 Little Endian,也就是小端。

Java 使用雙位元組編碼 UTF-16be,這不是指 Java 只支援這一種編碼方式,而是說 char 這種型別使用 UTF-16be 進行編碼。char 型別佔 16 位,也就是兩個位元組,Java 使用這種雙位元組編碼是為了讓一箇中文或者一個英文都能使用一個 char 來儲存。

String 的編碼方式

String 可以看成一個字元序列,可以指定一個編碼方式將它編碼為位元組序列,也可以指定一個編碼方式將一個位元組序列解碼為 String。

String str1 = "中文";
byte[] bytes = str1.getBytes("UTF-8");
String str2 = new String(bytes, "UTF-8");
System.out.println(str2);

在呼叫無引數 getBytes() 方法時,預設的編碼方式不是 UTF-16be。雙位元組編碼的好處是可以使用一個 char 儲存中文和英文,而將 String 轉為 bytes[] 位元組陣列就不再需要這個好處,因此也就不再需要雙位元組編碼。getBytes() 的預設編碼方式與平臺有關,一般為 UTF-8。

byte[] bytes = str1.getBytes();

Reader 與 Writer

不管是磁碟還是網路傳輸,最小的儲存單元都是位元組,而不是字元。但是在程式中操作的通常是字元形式的資料,因此需要提供對字元進行操作的方法。

  • InputStreamReader 實現從位元組流解碼成字元流;
  • OutputStreamWriter 實現字元流編碼成為位元組流。

實現逐行輸出文字檔案的內容

public static void readFileContent(String filePath) throws IOException {

    FileReader fileReader = new FileReader(filePath);
    BufferedReader bufferedReader = new BufferedReader(fileReader);

    String line;
    while ((line = bufferedReader.readLine()) != null) {
        System.out.println(line);
    }

    // 裝飾者模式使得 BufferedReader 組合了一個 Reader 物件
    // 在呼叫 BufferedReader 的 close() 方法時會去呼叫 Reader 的 close() 方法
    // 因此只要一個 close() 呼叫即可
    bufferedReader.close();
}

五、物件操作

序列化

序列化就是將一個物件轉換成位元組序列,方便儲存和傳輸。

  • 序列化:ObjectOutputStream.writeObject()
  • 反序列化:ObjectInputStream.readObject()

不會對靜態變數進行序列化,因為序列化只是儲存物件的狀態,靜態變數屬於類的狀態。

Serializable

序列化的類需要實現 Serializable 介面,它只是一個標準,沒有任何方法需要實現,但是如果不去實現它的話而進行序列化,會丟擲異常。

public static void main(String[] args) throws IOException, ClassNotFoundException {

    A a1 = new A(123, "abc");
    String objectFile = "file/a1";

    ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
    objectOutputStream.writeObject(a1);
    objectOutputStream.close();

    ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
    A a2 = (A) objectInputStream.readObject();
    objectInputStream.close();
    System.out.println(a2);
}

private static class A implements Serializable {

    private int x;
    private String y;

    A(int x, String y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "x = " + x + "  " + "y = " + y;
    }
}

transient

transient 關鍵字可以使一些屬性不會被序列化。

ArrayList 中儲存資料的陣列 elementData 是用 transient 修飾的,因為這個陣列是動態擴充套件的,並不是所有的空間都被使用,因此就不需要所有的內容都被序列化。通過重寫序列化和反序列化方法,使得可以只序列化陣列中有內容的那部分資料。

private transient Object[] elementData;

六、網路操作

Java 中的網路支援:

  • InetAddress:用於表示網路上的硬體資源,即 IP 地址;
  • URL:統一資源定位符;
  • Sockets:使用 TCP 協議實現網路通訊;
  • Datagram:使用 UDP 協議實現網路通訊。

InetAddress

沒有公有的建構函式,只能通過靜態方法來建立例項。

InetAddress.getByName(String host);
InetAddress.getByAddress(byte[] address);

URL

可以直接從 URL 中讀取位元組流資料。

public static void main(String[] args) throws IOException {

    URL url = new URL("http://www.baidu.com");

    /* 位元組流 */
    InputStream is = url.openStream();

    /* 字元流 */
    InputStreamReader isr = new InputStreamReader(is, "utf-8");

    /* 提供快取功能 */
    BufferedReader br = new BufferedReader(isr);

    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }

    br.close();
}

Sockets

  • ServerSocket:伺服器端類
  • Socket:客戶端類
  • 伺服器和客戶端通過 InputStream 和 OutputStream 進行輸入輸出。

Datagram

  • DatagramSocket:通訊類
  • DatagramPacket:資料包類

七、NIO

新的輸入/輸出 (NIO) 庫是在 JDK 1.4 中引入的,彌補了原來的 I/O 的不足,提供了高速的、面向塊的 I/O。

流與塊

I/O 與 NIO 最重要的區別是資料打包和傳輸的方式,I/O 以流的方式處理資料,而 NIO 以塊的方式處理資料。

面向流的 I/O 一次處理一個位元組資料:一個輸入流產生一個位元組資料,一個輸出流消費一個位元組資料。為流式資料建立過濾器非常容易,連結幾個過濾器,以便每個過濾器只負責複雜處理機制的一部分。不利的一面是,面向流的 I/O 通常相當慢。

面向塊的 I/O 一次處理一個數據塊,按塊處理資料比按流處理資料要快得多。但是面向塊的 I/O 缺少一些面向流的 I/O 所具有的優雅性和簡單性。

I/O 包和 NIO 已經很好地集成了,java.io.* 已經以 NIO 為基礎重新實現了,所以現在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些類包含以塊的形式讀寫資料的方法,這使得即使在面向流的系統中,處理速度也會更快。

通道與緩衝區

1. 通道

通道 Channel 是對原 I/O 包中的流的模擬,可以通過它讀取和寫入資料。

通道與流的不同之處在於,流只能在一個方向上移動(一個流必須是 InputStream 或者 OutputStream 的子類),而通道是雙向的,可以用於讀、寫或者同時用於讀寫。

通道包括以下型別:

  • FileChannel:從檔案中讀寫資料;
  • DatagramChannel:通過 UDP 讀寫網路中資料;
  • SocketChannel:通過 TCP 讀寫網路中資料;
  • ServerSocketChannel:可以監聽新進來的 TCP 連線,對每一個新進來的連線都會建立一個 SocketChannel。

2. 緩衝區

傳送給一個通道的所有資料都必須首先放到緩衝區中,同樣地,從通道中讀取的任何資料都要先讀到緩衝區中。也就是說,不會直接對通道進行讀寫資料,而是要先經過緩衝區。

緩衝區實質上是一個數組,但它不僅僅是一個數組。緩衝區提供了對資料的結構化訪問,而且還可以跟蹤系統的讀/寫程序。

緩衝區包括以下型別:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

緩衝區狀態變數

  • capacity:最大容量;
  • position:當前已經讀寫的位元組數;
  • limit:還可以讀寫的位元組數。

狀態變數的改變過程舉例:

① 新建一個大小為 8 個位元組的緩衝區,此時 position 為 0,而 limit = capacity = 8。capacity 變數不會改變,下面的討論會忽略它。

② 從輸入通道中讀取 5 個位元組資料寫入緩衝區中,此時 position 為 5,limit 保持不變。

③ 在將緩衝區的資料寫到輸出通道之前,需要先呼叫 flip() 方法,這個方法將 limit 設定為當前 position,並將 position 設定為 0。

④ 從緩衝區中取 4 個位元組到輸出緩衝中,此時 position 設為 4。

⑤ 最後需要呼叫 clear() 方法來清空緩衝區,此時 position 和 limit 都被設定為最初位置。

檔案 NIO 例項

以下展示了使用 NIO 快速複製檔案的例項:

public static void fastCopy(String src, String dist) throws IOException {

    /* 獲得原始檔的輸入位元組流 */
    FileInputStream fin = new FileInputStream(src);

    /* 獲取輸入位元組流的檔案通道 */
    FileChannel fcin = fin.getChannel();

    /* 獲取目標檔案的輸出位元組流 */
    FileOutputStream fout = new FileOutputStream(dist);

    /* 獲取輸出位元組流的檔案通道 */
    FileChannel fcout = fout.getChannel();

    /* 為緩衝區分配 1024 個位元組 */
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

    while (true) {

        /* 從輸入通道中讀取資料到緩衝區中 */
        int r = fcin.read(buffer);

        /* read() 返回 -1 表示 EOF */
        if (r == -1) {
            break;
        }

        /* 切換讀寫 */
        buffer.flip();

        /* 把緩衝區的內容寫入輸出檔案中 */
        fcout.write(buffer);

        /* 清空緩衝區 */
        buffer.clear();
    }
}

選擇器

NIO 常常被叫做非阻塞 IO,主要是因為 NIO 在網路通訊中的非阻塞特性被廣泛使用。

NIO 實現了 IO 多路複用中的 Reactor 模型,一個執行緒 Thread 使用一個選擇器 Selector 通過輪詢的方式去監聽多個通道 Channel 上的事件,從而讓一個執行緒就可以處理多個事件。

通過配置監聽的通道 Channel 為非阻塞,那麼當 Channel 上的 IO 事件還未到達時,就不會進入阻塞狀態一直等待,而是繼續輪詢其它 Channel,找到 IO 事件已經到達的 Channel 執行。

因為建立和切換執行緒的開銷很大,因此使用一個執行緒來處理多個事件而不是一個執行緒處理一個事件,對於 IO 密集型的應用具有很好地效能。

應該注意的是,只有套接字 Channel 才能配置為非阻塞,而 FileChannel 不能,為 FileChannel 配置非阻塞也沒有意義。

1. 建立選擇器

Selector selector = Selector.open();

2. 將通道註冊到選擇器上

ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

通道必須配置為非阻塞模式,否則使用選擇器就沒有任何意義了,因為如果通道在某個事件上被阻塞,那麼伺服器就不能響應其它事件,必須等待這個事件處理完畢才能去處理其它事件,顯然這和選擇器的作用背道而馳。

在將通道註冊到選擇器上時,還需要指定要註冊的具體事件,主要有以下幾類:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

它們在 SelectionKey 的定義如下:

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

可以看出每個事件可以被當成一個位域,從而組成事件集整數。例如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

3. 監聽事件

int num = selector.select();

使用 select() 來監聽到達的事件,它會一直阻塞直到有至少一個事件到達。

4. 獲取到達的事件

Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if (key.isAcceptable()) {
        // ...
    } else if (key.isReadable()) {
        // ...
    }
    keyIterator.remove();
}

5. 事件迴圈

因為一次 select() 呼叫不能處理完所有的事件,並且伺服器端有可能需要一直監聽事件,因此伺服器端處理事件的程式碼一般會放在一個死迴圈內。

while (true) {
    int num = selector.select();
    Set<SelectionKey> keys = selector.selectedKeys();
    Iterator<SelectionKey> keyIterator = keys.iterator();
    while (keyIterator.hasNext()) {
        SelectionKey key = keyIterator.next();
        if (key.isAcceptable()) {
            // ...
        } else if (key.isReadable()) {
            // ...
        }
        keyIterator.remove();
    }
}

套接字 NIO 例項

public class NIOServer {

    public static void main(String[] args) throws IOException {

        Selector selector = Selector.open();

        ServerSocketChannel ssChannel = ServerSocketChannel.open();
        ssChannel.configureBlocking(false);
        ssChannel.register(selector, SelectionKey.OP_ACCEPT);

        ServerSocket serverSocket = ssChannel.socket();
        InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
        serverSocket.bind(address);

        while (true) {

            selector.select();
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = keys.iterator();

            while (keyIterator.hasNext()) {

                SelectionKey key = keyIterator.next();

                if (key.isAcceptable()) {

                    ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();

                    // 伺服器會為每個新連線建立一個 SocketChannel
                    SocketChannel sChannel = ssChannel1.accept();
                    sChannel.configureBlocking(false);

                    // 這個新連線主要用於從客戶端讀取資料
                    sChannel.register(selector, SelectionKey.OP_READ);

                } else if (key.isReadable()) {

                    SocketChannel sChannel = (SocketChannel) key.channel();
                    System.out.println(readDataFromSocketChannel(sChannel));
                    sChannel.close();
                }

                keyIterator.remove();
            }
        }
    }

    private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {

        ByteBuffer buffer = ByteBuffer.allocate(1024);
        StringBuilder data = new StringBuilder();

        while (true) {

            buffer.clear();
            int n = sChannel.read(buffer);
            if (n == -1) {
                break;
            }
            buffer.flip();
            int limit = buffer.limit();
            char[] dst = new char[limit];
            for (int i = 0; i < limit; i++) {
                dst[i] = (char) buffer.get(i);
            }
            data.append(dst);
            buffer.clear();
        }
        return data.toString();
    }
}
public class NIOClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 8888);
        OutputStream out = socket.getOutputStream();
        String s = "hello world";
        out.write(s.getBytes());
        out.close();
    }
}

記憶體對映檔案

記憶體對映檔案 I/O 是一種讀和寫檔案資料的方法,它可以比常規的基於流或者基於通道的 I/O 快得多。

向記憶體對映檔案寫入可能是危險的,只是改變陣列的單個元素這樣的簡單操作,就可能會直接修改磁碟上的檔案。修改資料與將資料儲存到磁碟是沒有分開的。

下面程式碼行將檔案的前 1024 個位元組對映到記憶體中,map() 方法返回一個 MappedByteBuffer,它是 ByteBuffer 的子類。因此,可以像使用其他任何 ByteBuffer 一樣使用新對映的緩衝區,作業系統會在需要時負責執行對映。

MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, 1024);

對比

NIO 與普通 I/O 的區別主要有以下兩點:

  • NIO 是非阻塞的;
  • NIO 面向塊,I/O 面向流。

八、參考資料