Java NIO學習一

一、NIO概述

NIO即New IO,這個庫是在JDK1.4中才引入的。NIO和IO有相同的作用和目的,但實現方式不同,NIO主要用到的是塊,所以NIO的效率要比IO高很多。
NIO主要有三大核心部分:Channel(通道),Buffer(緩衝區), Selector。傳統IO基於字節流和字符流進行操作,而NIO基於Channel和Buffer(緩衝區)進行操作,數據總是從通道讀取到緩衝區中,或者從緩衝區寫入到通道中。Selector(選擇區)用於監聽多個通道的事件(比如:連接打開,數據到達)。因此,單個線程可以監聽多個數據通道。

NIO和傳統IO(一下簡稱IO)之間第一個最大的區別是,IO是面向流的,NIO是面向緩衝區的。 Java IO面向流意味着每次從流中讀一個或多個字節,直至讀取所有字節,它們沒有被緩存在任何地方。此外,它不能前後移動流中的數據。如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩衝區。NIO的緩衝導向方法略有不同。數據讀取到一個它稍後處理的緩衝區,需要時可在緩衝區中前後移動。這就增加了處理過程中的靈活性。但是,還需要檢查是否該緩衝區中包含所有您需要處理的數據。而且,需確保當更多的數據讀入緩衝區時,不要覆蓋緩衝區裏尚未處理的數據。

IO的各種流是阻塞的。這意味着,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再幹任何事情了。 NIO的非阻塞模式,使一個線程從某通道發送請求讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼都不會獲取。而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可以繼續做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。 線程通常將非阻塞IO的空閒時間用於在其它通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。

舉例來說,傳統BIO的處理方式大概如下:

{
 ExecutorService executor = Excutors.newFixedThreadPollExecutor(100);//線程池

 ServerSocket serverSocket = new ServerSocket();
 serverSocket.bind(8088);
 while(!Thread.currentThread.isInturrupted()){//主線程死循環等待新連接到來
 Socket socket = serverSocket.accept();
 executor.submit(new ConnectIOnHandler(socket));//爲新的連接創建新的線程
}

class ConnectIOnHandler extends Thread{
    private Socket socket;
    public ConnectIOnHandler(Socket socket){
       this.socket = socket;
    }
    public void run(){
      while(!Thread.currentThread.isInturrupted()&&!socket.isClosed()){死循環處理讀寫事件
          String someThing = socket.read()....//讀取數據
          if(someThing!=null){
             ......//處理數據
             socket.write()....//寫數據
          }

      }
    }
}

這是一個經典的每連接每線程的模型,之所以使用多線程,主要原因在於socket.accept()、socket.read()、socket.write()三個主要函數都是同步阻塞的,當一個連接在處理I/O的時候,系統是阻塞的,如果是單線程的話必然就掛死在那裏;但CPU是被釋放出來的,開啓多線程,就可以讓CPU去處理更多的事情。

現在的多線程一般都使用線程池,可以讓線程的創建和回收成本相對較低。在活動連接數不是特別高(小於單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注於自己的I/O並且編程模型簡單,也不用過多考慮系統的過載、限流等問題。線程池本身就是一個天然的漏斗,可以緩衝一些系統處理不了的連接或請求。不過,這個模型最本質的問題在於,嚴重依賴於線程。但線程是很”貴”的資源,主要表現在:

  • 線程的創建和銷燬成本很高,在Linux這樣的操作系統中,線程本質上就是一個進程。創建和銷燬都是重量級的系統函數。
  • 線程本身佔用較大內存,像Java的線程棧,一般至少分配512K~1M的空間,如果系統中的線程數過千,恐怕整個JVM的內存都會被吃掉一半。
  • 線程的切換成本是很高的。操作系統發生線程切換的時候,需要保留線程的上下文,然後執行系統調用。如果線程數過高,可能執行線程切換的時間甚至會大於線程執行的時間,這時候帶來的表現往往是系統load偏高、CPU sy使用率特別高(超過20%以上),導致系統幾乎陷入不可用的狀態。
  • 容易造成鋸齒狀的系統負載。因爲系統負載是用活動線程數或CPU核心數,一旦線程數量高但外部網絡環境不是很穩定,就很容易造成大量請求的結果同時返回,激活大量阻塞線程從而使系統負載壓力過大。

所以,當面對十萬甚至百萬級連接的時候,傳統的BIO模型是無能爲力的。

二、NIO的實現原理

回憶BIO模型,之所以需要多線程,是因爲在進行I/O操作的時候,我們無法知道到底能不能寫、能不能讀,因爲這是個阻塞式操作,只有數據流到的時候才能執行,只能”傻等”。即使通過各種估算,算出來當前操作系統沒有能力進行讀寫,也沒法在socket.read()和socket.write()函數中返回,這兩個函數無法進行有效的中斷。所以爲了提高CPU利用率,只能使用多線程。

對NIO來說,NIO的讀寫函數可以立刻返回,這就給了我們不開線程利用CPU的最好機會:如果一個連接條件還未滿足,該連接不能讀寫(socket.read()返回0或者socket.write()返回0),我們可以把這個件事記下來,記錄的方式通常是在Selector上註冊標記位,然後切換到其它就緒的連接(channel)繼續進行讀寫。這其實就是通過事件驅動模型來進行管理的。

下面具體看下如何利用事件模型單線程處理所有I/O請求:
我們首先需要註冊相應的處理器來處理這幾個事件的到來。然後在合適的時機告訴事件選擇器:我對這個事件感興趣。java NIO採用了雙向通道(channel)進行數據傳輸,而不是單向的流(stream),在通道上可以註冊我們感興趣的事件。一共有以下四種事件:
這裏寫圖片描述
我們用一個死循環選擇就緒的事件,會執行系統調用(Linux 2.6之前是select、poll,2.6之後是epoll,Windows是IOCP),還會阻塞的等待新事件的到來。新事件到來的時候,會在selector上註冊標記位,標示是那個事件的到來。
注意,select是阻塞的,無論是通過操作系統的通知(epoll)還是不停的輪詢(select,poll),這個函數是阻塞的。所以你可以放心大膽地在一個while(true)裏面調用這個函數而不用擔心CPU空轉。

拿服務器和客戶端的處理模型來說,服務端和客戶端各自維護一個管理通道的對象,我們稱之爲selector,該對象能檢測一個或多個通道 (channel) 上的事件。我們以服務端爲例,如果服務端的selector上註冊了讀事件,某時刻客戶端給服務端發送了一些數據,阻塞I/O這時會調用read()方法阻塞地讀取數據,而NIO的服務端會在selector中添加一個讀事件。服務端的處理線程會輪詢地訪問selector,如果訪問selector時發現有感興趣的事件到達,則處理這些事件,如果沒有感興趣的事件到達,則處理線程會一直阻塞直到感興趣的事件到達爲止。
總的來看,java NIO的基本處理原則如下:
1. 通過一個專門的線程來處理所有的 IO 事件,並負責分發。
2. 事件驅動機制:事件到的時候觸發,而不是同步的去監視事件。
3. 線程通訊:線程之間通過 wait,notify 等方式通訊。保證每次上下文切換都是有意義的。減少無謂的線程切換。

三、緩衝區Buffer

Buffer是一個對象,它包含一些要寫入或讀出的數據。在NIO中,數據是放入buffer對象的,而在IO中,數據是直接寫入或者讀到Stream對象的。應用程序不能直接對 Channel 進行讀寫操作,而必須通過 Buffer 來進行,即 Channel 是通過 Buffer 來讀寫數據的。
在NIO中,所有的數據都是用Buffer處理的,它是NIO讀寫數據的中轉池。Buffer實質上是一個數組,通常是一個字節數據,但也可以是其他類型的數組。但一個緩衝區不僅僅是一個數組,重要的是它提供了對數據的結構化訪問,而且還可以跟蹤系統的讀寫進程。NIO中的關鍵Buffer實現有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分別對應基本數據類型: byte, char, double, float, int, long, short。當然NIO中還有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等這裏先不進行陳述。
使用 Buffer 讀寫數據一般遵循以下四個步驟:

  • 分配空間(ByteBuffer buf = ByteBuffer.allocate(1024); )
  • 寫入數據到 Buffer;(int bytesRead = fileChannel.read(buf);)
  • 調用 flip() 方法;( buf.flip();)
  • 從 Buffer 中讀取數據;(System.out.print((char)buf.get());)
  • 調用 clear() 方法或者 compact() 方法。

向Buffer中寫數據:

  • 從Channel寫到Buffer (fileChannel.read(buf))
  • 通過Buffer的put()方法 (buf.put(…))

從Buffer中讀取數據:

  • 從Buffer讀取到Channel (channel.write(buf))
  • 使用get()方法從Buffer中讀取數據 (buf.get())

當向 Buffer 寫入數據時,Buffer 會記錄下寫了多少數據。一旦要讀取數據,需要通過 flip() 方法將 Buffer 從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到 Buffer 的所有數據。

一旦讀完了所有的數據,就需要清空緩衝區,讓它可以再次被寫入。有兩種方式能清空緩衝區:調用 clear() 或 compact() 方法。clear() 方法會清空整個緩衝區。compact() 方法只會清除已經讀過的數據。任何未讀的數據都被移到緩衝區的起始處,新寫入的數據將放到緩衝區未讀數據的後面。

可以把Buffer簡單地理解爲一組基本數據類型的元素列表,它通過幾個變量來保存這個數據的當前位置狀態:capacity, position, limit, mark:
這裏寫圖片描述

舉例來說,我們通過ByteBuffer.allocate(11)方法創建了一個11個byte的數組的緩衝區,初始狀態如下圖,position的位置爲0,capacity和limit默認都是數組長度。
這裏寫圖片描述

當我們寫入5個字節時,變化如下圖:
這裏寫圖片描述

這時我們需要將緩衝區中的5個字節數據寫入Channel的通信信道,所以我們調用ByteBuffer.flip()方法,變化如下圖所示(position設回0,並將limit設成之前的position的值):
這裏寫圖片描述

這時底層操作系統就可以從緩衝區中正確讀取這個5個字節數據併發送出去了。在下一次寫數據之前我們再調用clear()方法,緩衝區的索引位置又回到了初始位置。

調用clear()方法時,position將被設回0,limit設置成capacity,換句話說,Buffer被清空了,其實Buffer中的數據並未被清除,只是這些標記告訴我們可以從哪裏開始往Buffer裏寫數據。如果Buffer中有一些未讀的數據,調用clear()方法,數據將“被遺忘”,意味着不再有任何標記會告訴你哪些數據被讀過,哪些還沒有。如果Buffer中仍有未讀的數據,且後續還需要這些數據,但是此時想要先先寫些數據,那麼使用compact()方法。

compact()方法將所有未讀的數據拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面。limit屬性依然像clear()方法一樣,設置成capacity。現在Buffer準備好寫數據了,但是不會覆蓋未讀的數據。

通過調用Buffer.mark()方法,可以標記Buffer中的一個特定的position,之後可以通過調用Buffer.reset()方法恢復到這個position。Buffer.rewind()方法將position設回0,所以你可以重讀Buffer中的所有數據。limit保持不變,仍然表示能從Buffer中讀取多少個元素。

四、通道Channel

Channel是一個對象,可以通過它讀取和寫入數據。可以把它看做IO中的流。但是它和流相比還有一些不同:

  • Channel是雙向的,既可以讀又可以寫,而流是單向的
  • Channel可以進行異步的讀寫
  • 對Channel的讀寫必須通過buffer對象

正如上面提到的,所有數據都通過Buffer對象處理,所以,您永遠不會將字節直接寫入到Channel中,相反,您是將數據寫入到Buffer中;同樣,您也不會從Channel中讀取字節,而是將數據從Channel讀入Buffer,再從Buffer獲取這個字節。

因爲Channel是雙向的,所以Channel可以比流更好地反映出底層操作系統的真實情況。特別是在Unix模型中,底層操作系統通常都是雙向的。

在Java NIO中Channel主要有如下幾種類型:

  • FileChannel:從文件讀取數據的
  • DatagramChannel:讀寫UDP網絡協議數據
  • SocketChannel:讀寫TCP網絡協議數據
  • ServerSocketChannel:可以監聽TCP連接

打開通道比較簡單,除了FileChannel,都用open方法打開。
下面來看一下通道間數據傳輸的代碼:

private static void channelCopy(ReadableByteChannel src,
                                     WritableByteChannel dest)
            throws IOException {
        ByteBuffer buffer = ByteBuffer.allocateDirect(16 * 1024);
        while (src.read(buffer) != -1) {//從通道中讀取數據
            // 切換讀寫模式
            buffer.flip();
            // 向通道中寫入數據
            dest.write(buffer);
            buffer.compact();
        }
        //循環退出後可能還有數據未處理,完成最後一次寫入
        buffer.flip();
        while (buffer.hasRemaining()) {
            dest.write(buffer);
        }
}

通道不能被重複使用,這點與緩衝區不同;關閉通道後,通道將不再連接任何東西,任何的讀或寫操作都會導致ClosedChannelException。

五、示例演示

NIO中從通道中讀取:創建一個緩衝區,然後讓通道讀取數據到緩衝區。NIO寫入數據到通道:創建一個緩衝區,用數據填充它,然後讓通道用這些數據來執行寫入。

1、從文件中讀取

我們已經知道,在NIO系統中,任何時候執行一個讀操作,都是從Channel中讀取,但又不是直接從Channel中讀取數據,因爲所有的數據都必須用Buffer來封裝,所以應該是從Channel讀取數據到Buffer。因此,如果從文件讀取數據的話,需要如下步驟:

  • 第一步:獲取通道
FileInputStream fin = new FileInputStream( "test.txt" );
FileChannel fc = fin.getChannel();  
  • 第二步:創建緩衝區
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
  • 第三步:將數據從通道讀到緩衝區
fc.read( buffer );

示例:

package com.kang;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

public class ReadFileApp {

    public static void readFileByIO(String fileName) {
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(fileName);
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = fis.read(buffer)) != -1) {
                System.out.write(buffer, 0, len);
            }
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            try {
                fis.close();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        String fileName = "E:\\testFile.txt";
        ReadFileApp.readFileByIO(fileName);

    }
}

運行結果:
這裏寫圖片描述

2、寫入數據到文件

  • 第一步:獲取一個通道
FileOutputStream fout = new FileOutputStream( "newtest.txt" );
FileChannel fc = fout.getChannel();
  • 第二步:創建緩衝區,將數據放入緩衝區
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
for (int i=0; i<message.length; ++i) {
 buffer.put( message[i] );
}
buffer.flip();
  • 第三步:把緩衝區數據寫入通道中
fc.write( buffer );

示例:

package com.kang;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class WriteFileApp {

    public static void writeFileByNIO(String file) {
        FileOutputStream fos = null;
        FileChannel fc = null;
        ByteBuffer buffer = null;
        try {
            fos = new FileOutputStream(file);
            // 第一步 獲取一個通道
            fc = fos.getChannel();
            // buffer=ByteBuffer.allocate(1024);
            // 第二步 定義緩衝區
            buffer = ByteBuffer.wrap("Hello World".getBytes());
            // 將內容寫到緩衝區
            fos.flush();
            fc.write(buffer);

        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            try {
                fc.close();
                fos.close();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }

        }
    }

    public static void main(String[] args) {
        String file = "E:\\hello.txt";
        WriteFileApp.writeFileByNIO(file);

    }
}

3、讀寫結合

CopyFile是一個非常好的讀寫結合的例子。CopyFile執行三個基本的操作:創建一個Buffer,然後從源文件讀取數據到緩衝區,然後再將緩衝區寫入目標文件。

package com.kang;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class CopyFileApp {
    public static void copyFileUseNIO(String src, String dst) throws IOException {
        // 聲明源文件和目標文件
        FileInputStream fi = new FileInputStream(new File(src));
        FileOutputStream fo = new FileOutputStream(new File(dst));
        // 獲得傳輸通道channel
        FileChannel inChannel = fi.getChannel();
        FileChannel outChannel = fo.getChannel();
        // 獲得容器buffer
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        while (true) {
            // 判斷是否讀完文件
            int eof = inChannel.read(buffer);
            if (eof == -1) {
                break;
            }
            // 重設一下buffer的position=0,limit=position
            buffer.flip();
            // 開始寫
            outChannel.write(buffer);
            // 寫完要重置buffer,重設position=0,limit=capacity
            buffer.clear();
        }
        inChannel.close();
        outChannel.close();
        fi.close();
        fo.close();
    }

    public static void main(String[] args) {
        String src="E:\\testFile.txt";
        String dst="E:\\new_testFile.txt";
        try {
            CopyFileApp.copyFileUseNIO(src, dst);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章