插曲:Kafka原始碼預熱篇--- Java NIO

說出你的願望吧發表於2019-11-29

前言

上一篇的前言我都忘了隨便說兩句了hhh

因為Kafka的原始碼閱讀是需要對Java NIO知識有一定的瞭解的,所以怎麼說,如果覺得自己對於Java這塊算是比較熟悉,同樣作為插曲篇的這篇是可以直接忽略。因為這篇也不會涉及什麼重難點,主要還是過過基礎,讓後面的原始碼篇讀起來更加通暢。

一、NIO基礎

Java New IO是從Java1.4版本開始引入的一個新的IO api,可以替代以往的標準IO,NIO相比原來的IO有同樣的作用和目的,但是使用的方式完全不一樣,NIO是面向緩衝區的,基於通道的IO操作,這也讓它比傳統IO有著更為高效的讀寫。

1.1 IO和NIO的主要區別

IO NIO
面向流 面向緩衝區
阻塞IO 非阻塞IO
選擇器

1.1.1 傳統IO的流

以下用圖來簡單理解一下,在傳統IO中當App要對網路,磁碟中的檔案進行讀寫的時候,它們必須建立一個連線,流到底是一個什麼樣的概念呢,我們可以先把它想象成自來水,家裡要用自來水,需要有水管,讓水從水管過來到家裡,起到一個運輸的作用

所以當我們檔案中的資料需要輸入到App裡面時,它們就會建立一個輸入的管道。而當我們的App有資料需要寫入到檔案系統的時候,就會建立一個輸出的管道,這兩條管道就是我們的輸入流和輸出流。那水從來沒有逆流而上的呀,所以它們都是單向管道。這麼一講,是不是就很好懂了呢??

插曲:Kafka原始碼預熱篇--- Java NIO

1.1.2 NIO

也是同樣的檔案系統和App,不過此時把流換成了一個channel,現在我們可以先認為它就是一條鐵道,那我們知道鐵道本身是不能傳遞貨物的呀,所以我們需要一個載具---火車(也就是緩衝區),App需要的資料就由這個名叫緩衝區的載具運輸過來。那火車是可以開過來,也可以開回去的,所以NIO是雙向傳輸的。

插曲:Kafka原始碼預熱篇--- Java NIO

1.2 Buffer

NIO的核心在於,通道(channel)和緩衝區(buffer)兩個。通道是開啟到IO裝置的連線。使用時需要獲取用於連線IO裝置的通道以及用於容納資料的緩衝區,然後通過操作緩衝區對資料進行處理。(其實就是上面那張圖的事兒,或者一句話就是一個負責傳輸,一個負責儲存)。

緩衝區是Java.nio包定義好的,所有緩衝區都是Buffer抽象類的子類。Buffer根據資料型別不同,常用子類分別是基本資料型別除了Boolean外的xxxBuffer(IntBuffer,DoubleBuffer···等)。不同的Buffer類它們的管理方式都是相同的,獲取物件的方法都是

// 建立一個容量為capacity的xxx型別的Buffer物件
static xxxBuffer allocate(int capacity)
複製程式碼

而且緩衝區提供了兩個核心方法:get()和put(),put方法是將資料存入到緩衝區,而get是獲取緩衝區的資料。

此時我們用程式碼看一下

public class BufferTest {
    @Test
    public void testBuffer(){
        // 建立緩衝區物件
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    }
}
複製程式碼

點進去ByteBuffer,會看到這個東西是繼承了Buffer類的

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
複製程式碼

此時繼續點進去Buffer類,第一眼看到的是有幾個自帶的屬性

插曲:Kafka原始碼預熱篇--- Java NIO

1.2.1 buffer的基本屬性

插曲:Kafka原始碼預熱篇--- Java NIO

① capacity容量

表示Buffer的最大資料容量,這個值不能為負。而且建立後是不能更改的。

② limit限制

第一個不能讀取或寫入的資料的索引,位於此索引後的資料不可讀寫。這個數值不能為負且不能超過capacity,如上圖中第三個緩衝區,在下標為5之後的資料塊均不能讀寫,那limit為5

③ position位置

下一個要讀取或寫入的資料的索引,這個數值不能為負且不能超過capacity,如圖中第二個緩衝區,前面5塊寫完成,此時第6個資料塊的下標為5,所以position為5

④ mark標記/reset重置

mark是一個索引,通過Buffer的mark()方法指定Buffer中一個特定的position後,可以通過reset()方法重置到這個position,這個通過程式碼來解釋會比較好說明

1.2.2 code部分(非常簡單)

插曲:Kafka原始碼預熱篇--- Java NIO

1.首先我們建立一個緩衝區物件,然後把它的屬性列印出來

插曲:Kafka原始碼預熱篇--- Java NIO

ByteBuffer byteBuffer = ByteBuffer.allocate(10);
System.out.println(byteBuffer.position());
System.out.println(byteBuffer.capacity());
System.out.println(byteBuffer.limit());

執行結果:0,10,10
複製程式碼
2.執行一個put()方法,來把一個字元丟進去

插曲:Kafka原始碼預熱篇--- Java NIO

String str = "abcde";
byteBuffer.put(str.getBytes());
System.out.println(byteBuffer.position());
System.out.println(byteBuffer.capacity());
System.out.println(byteBuffer.limit());

執行結果:5,10,10
"abcde"長度為5,position已經變化,其它不變
複製程式碼
3.使用flip()切換為讀模式

插曲:Kafka原始碼預熱篇--- Java NIO

byteBuffer.flip();
System.out.println(byteBuffer.position());
System.out.println(byteBuffer.capacity());
System.out.println(byteBuffer.limit());

執行結果:0,10,5
複製程式碼

此時position變成為0了,因為一開始的5,是因為這時候要寫的是下標為5的資料塊,而轉換成讀模式後,第一個讀的明顯是下標為0的資料塊呀。limit的數值也變成了5,因為當前能讀到的資料從下標為5開始就木有了,所以limit為5

4.簡單獲取一下buffer中的資料
byte[] array = new byte[byteBuffer.limit()];
byteBuffer.get(array);
System.out.println(new String(array,0,array.length));

執行結果:abcde
複製程式碼
5.mark() & reset()
byte[] array = new byte[byteBuffer.limit()];
byteBuffer.get(array,0,2);
System.out.println(new String(array,0,2));
System.out.println(byteBuffer.position());

byteBuffer.mark();
byteBuffer.get(array,2,2);
System.out.println(new String(array,2,2));
System.out.println(byteBuffer.position());

byteBuffer.reset();
System.out.println(byteBuffer.position());

執行結果:ab,2,cd,4,2
複製程式碼

其實很簡單,就是第一次讀取的時候,只是讀取了前面兩個字元,然後此時position的結果為2,然後再讀取後兩個,position為4,可是因為我在讀取前面2個的時候進行了一個mark操作,它就自動回到我mark之前的那個讀取位置而已,就是這麼簡單

6.其他的一些方法

rewind()方法,可重複讀,clear()清空緩衝區,不過這個方法的清空緩衝區,是一種被遺忘的狀態,就是說,資料仍然還存於緩衝區中,可是自動忽略掉了。此時再次讀取資料,是還是可以get()到的。hasRemaining()方法就是表示剩餘可操作的資料量還有多少,比如剛剛的mark的那個例子中,我reset回去之後,剩餘的可運算元據就是3,因為我只讀了ab,還有cde這三個。

1.2.3 直接緩衝區和非直接緩衝區

非直接緩衝區:通過allocate()方法來分配緩衝區。將緩衝區建立在JVM的記憶體中。

直接緩衝區:通過allocateDirect()方法分配緩衝區,將緩衝區建立在實體記憶體中。效率更高。

① 非直接緩衝區

插曲:Kafka原始碼預熱篇--- Java NIO

應用程式想要在磁碟中讀取資料時,首先它發起請求,讓物理磁碟先把它的資料讀到核心地址空間當中,之後這個核心空間再將這個資料copy一份到使用者地址空間去。然後資料才能通過read()方法將資料返回個應用程式。而應用程式需要寫資料進去,也是同理,先寫到使用者地址空間,然後copy到核心地址空間,再寫入磁碟。此時不難發現,這個copy的操作顯得十分的多餘,所以非直接緩衝區的效率相對來說會低一些。

② 直接緩衝區

插曲:Kafka原始碼預熱篇--- Java NIO

直接緩衝區就真的顧名思義非常直接了,寫入的時候,寫到實體記憶體對映檔案中,再由它寫入物理磁碟,讀取也是磁碟把資料讀到這個檔案然後再由它讀取到應用程式中即可。沒有了copy的中間過程。

1.3 channel

1.3.1 扯一下概念背景

由java.nio.channels包定義,表示IO源與目標開啟的連結,它本身不存在直接訪問資料的能力,只能和Buffer進行互動

傳統的IO由cpu來全權負責,此時這個設計在有大量檔案讀取操作時,CPU的利用率會被拉的非常低,因為IO操作把CPU的資源都搶佔了。

插曲:Kafka原始碼預熱篇--- Java NIO

在這種背景下進行了一些優化,把對cpu的連線取消,轉為DMA(直接記憶體存取)的方式。當然DMA這個操作本身也是需要CPU進行排程的。不過這個損耗自然就會比大量的IO要小的多。

插曲:Kafka原始碼預熱篇--- Java NIO

此時,就出現了通道這個概念,它是一個完全獨立的處理器。專門用來負責檔案的IO操作。

插曲:Kafka原始碼預熱篇--- Java NIO

1.3.2 常用通道

Java為Channel介面提供的主要實現類:

FileChannel:用於讀取,寫入,對映和操作檔案的通道
DatagramChannel:通過UDP讀寫網路中的資料通道
SocketChannel:通過TCP讀寫網路中的資料通道
ServerSocketChannel:可以監聽新進來的TCP連線,對每一個新進來的連線
    都會建立一個SocketChannel
複製程式碼

獲取channel的一種方式是對支援通道的物件呼叫getChannel()方法,支援類如下

FileInputStream
FileOutputStream
RandomAccessFile
DatagramSocket
Socket
ServerSocket
複製程式碼

獲取的其他方式是使用Files類的靜態方法newByteChannel()獲取位元組通道。再或者是通過通道的靜態方法open()開啟並返回指定通道。

1.3.3 常用方法和簡單使用

插曲:Kafka原始碼預熱篇--- Java NIO

① 使用非直接緩衝區完成檔案複製
// 建立輸入輸出流物件
FileInputStream fileInputStream = new FileInputStream("testPic.jpg");
FileOutputStream fileOutputStream = new FileOutputStream("testPic2.jpg");

// 通過流物件獲取通道channel
FileChannel inChannel = fileInputStream.getChannel();
FileChannel outChannel = fileOutputStream.getChannel();

// 建立指定大小的緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

// 將通道中的資料寫入到緩衝區中
while (inChannel.read(byteBuffer) != -1){

    // 切換成讀取模式
    byteBuffer.flip();
    // 將緩衝區中的資料寫到輸出通道
    outChannel.write(byteBuffer);

    // 清空緩衝區
    byteBuffer.clear();

}
//回收資源(這裡為了省時間直接丟擲去了,反正這段不太重要)
outChannel.close();
inChannel.close();
fileInputStream.close();
fileOutputStream.close();

執行結果:就自然是複製了一個testPic2出來啦
複製程式碼

因為程式碼本身不難,註釋已經寫得比較詳細,就不展開了


② 使用直接緩衝區來完成檔案的複製

插曲:Kafka原始碼預熱篇--- Java NIO
注意這裡的StandardOpenOption是一個列舉,表示模式,很顯然這裡是要選擇READ讀取模式。

FileChannel inChannel = FileChannel.open(Paths.get("testPic.jpg",StandardOpenOption.READ));
FileChannel outChannel = FileChannel.
        open(Paths.get("testPic2.jpg"),StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);
// 進行記憶體對映
MappedByteBuffer inMappedBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, inChannel.size());
MappedByteBuffer outMapBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, inChannel.size());

// 對緩衝區進行資料的讀寫操作
byte[] array = new byte[inMappedBuffer.limit()];
inMappedBuffer.get(array);
outMapBuffer.put(array);

// 回收資源
inChannel.close();
outChannel.close();
複製程式碼

如果需要看一下它們兩個的時間差,自己用最常規的系統時間來瞧瞧就好,在這裡就不再加上了。

二、NIO非阻塞式網路通訊

傳統的IO流都是阻塞式的,當一個執行緒呼叫read或者write時,該執行緒被阻塞,直到資料被讀取或者寫入,該執行緒在此期間都是不能執行其他任務的,因此,在完成網路通訊進行IO操作時,執行緒被阻塞,所以伺服器端必須為每個客戶端提供一個獨立執行緒進行處理,當伺服器端需要處理大量客戶端時,效能將會急劇下降。

NIO是非阻塞的,當執行緒從某通道進行讀寫資料時,若沒有資料可用,該執行緒可以進行其他任務。執行緒通常將非阻塞IO的空閒時間用於在其他通道上執行IO操作,所以單獨的執行緒可以管理多個輸入和輸出通道。因此NIO可以讓伺服器端使用一個或有限幾個執行緒來同時處理連線到伺服器端的所有客戶端。

2.1 Selector

這個選擇器其實就是在客戶端和服務端之間引入一個通道的註冊器,比如現在我的客戶端要像服務端傳輸資料了,客戶端會給選擇器去傳送一個channel的註冊請求,註冊完成後,Selector就會去監控這個channel的IO狀態(讀寫,連線)。只有當通道中的資料完全準備就緒,Selector才會將資料分配到服務端的某個執行緒去處理。

這種非阻塞性的流程就可以更好地去使用CPU的資源。提高CPU的工作效率。這個可以用收快遞來說明。如果你一開始就告訴我半小時後過來取快遞,而我在這時候已經到目的地了,我有可能就原地不動站著等半個小時。這個期間啥地都去不了,可是你是到了之後,才打電話告訴我過來取,那我就有了更多的自由時間。

2.2 code(阻塞性IO的網路通訊)

現在我們來演示一下阻塞性IO的網路通訊

2.2.1 client(阻塞性IO)

這個程式碼大家可以嘗試這刪除sChannel.shutdownOutput(),此時會發現在啟動好server,執行client程式的時候,程式也會阻塞,這是因為這時服務端並無法確定你是否已經傳送完成資料了,所以client端也產生了阻塞,雙方就一直僵持。

還有一種方法是解阻塞,之後進行闡述。

// 1.獲取通道
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("你的IP地址",9898));
// 2.建立檔案通道
FileChannel inChannel = FileChannel.open(Paths.get("C:/Users/Administrator/Desktop/testPic.jpg"),StandardOpenOption.READ);
// 3.分配指定大小的緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

// 4.傳送資料,需要讀取檔案
while (inChannel.read(byteBuffer) != -1){
    byteBuffer.flip();
    // 將buffer的資料寫入到通道中
    sChannel.write(byteBuffer);
    byteBuffer.clear();
}

// 主動告訴服務端,資料已經傳送完畢
sChannel.shutdownOutput();

while (sChannel.read(byteBuffer) != -1){
        byteBuffer.flip();
        System.out.println("接收服務端資料成功···");
        byteBuffer.clear();
    }

// 5.關閉通道
inChannel.close();
sChannel.close();
複製程式碼
2.2.2 server(阻塞性IO)
// 1.獲取通道
ServerSocketChannel ssChannel = ServerSocketChannel.open();
// 建立一個輸出通道,將讀取到的資料寫入到輸出通道中,儲存為testPic2
FileChannel outChannel = FileChannel.open(Paths.get("testPic2.jpg"),StandardOpenOption.WRITE,StandardOpenOption.CREATE);
// 2.繫結埠
ssChannel.bind(new InetSocketAddress(9898));
// 3.等待客戶端連線,連線成功時會得到一個通道
SocketChannel sChannel = ssChannel.accept();
// 4.建立緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 5.接收客戶端的資料儲存到本地
while (sChannel.read(byteBuffer) != -1){
    byteBuffer.flip();
    outChannel.write(byteBuffer);
    byteBuffer.clear();
}

// 傳送反饋給客戶端
    // 向緩衝區中寫入應答資訊
    byteBuffer.put("服務端接收資料成功".getBytes());
    byteBuffer.flip();
    sChannel.write(byteBuffer);

// 關閉通道
sChannel.close();
outChannel.close();
byteBuffer.clear();
複製程式碼

插曲:Kafka原始碼預熱篇--- Java NIO

然後再當我們的客戶端執行起來,就會進行copy操作

插曲:Kafka原始碼預熱篇--- Java NIO

2.3 Selector完成非阻塞IO

使用NIO完成網路通訊需要三個核心物件:

channel:java.nio.channels.Channel介面,SocketChannel,ServerSocketChannel,DatagramChannel

管道相關:Pipe.SinkChannel,Pine.SourceChannel

buffer:負責儲存資料

Selector:其中Selector是SelectableChannel的多路複用器,主要是用於監控SelectableChannel的IO狀態

插曲:Kafka原始碼預熱篇--- Java NIO

2.3.1 client(非阻塞)
// 1.獲取通道,預設是阻塞的
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("192.168.80.1",9898));

// 1.1 將阻塞的套接字變成非阻塞
sChannel.configureBlocking(false);

// 2.建立指定大小的緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 3.傳送資料給服務端,直接將資料儲存到緩衝區
byteBuffer.put(new Date().toString().getBytes());
// 4.將緩衝區的資料寫入到sChannel
byteBuffer.flip();
sChannel.write(byteBuffer);
byteBuffer.clear();

// 關閉
sChannel.close();
複製程式碼
2.3.2 server(非阻塞)

程式碼的註釋中已經解釋了整個過程的做法,這裡就不一一展開了。

// 1.獲取通道
ServerSocketChannel ssChannel = ServerSocketChannel.open();
// 2.將阻塞的套接字設定為非阻塞的
ssChannel.configureBlocking(false);
// 3.繫結埠號
ssChannel.bind(new InetSocketAddress(9898));

// 4.建立選擇器物件
Selector selector = Selector.open();

// 5.將通道註冊到選擇器上(這裡的第二個引數為selectionKey),下面有解釋
// 此時選擇器就開始監聽這個通道的接收時間,此時接收工作準備就緒,才開始下一步的操作
ssChannel.register(selector,SelectionKey.OP_ACCEPT);

// 6.通過輪詢的方式獲取選擇器上準備就緒的事件
// 如果大於0,至少有一個SelectionKey準備就緒
while (selector.select() > 0){
    // 7.獲取當前選擇器中所有註冊的selectionKey(已經準備就緒的監聽事件)
    Iterator<SelectionKey> selectionKeyIterator = selector.selectedKeys().iterator();
    // 迭代獲取已經準備就緒的選擇鍵
    while (selectionKeyIterator.hasNext()){

        // 8.獲取已經準備就緒的事件
        SelectionKey selectionKey = selectionKeyIterator.next();
        if (selectionKey.isAcceptable()){
            // 9.呼叫accept方法
            SocketChannel sChannel = ssChannel.accept();
            // 將sChannel設定為非阻塞
            // 再次強調,整個過程不能有任何一條阻塞通道
            sChannel.configureBlocking(false);

            // 進行資料接收工作,而且把sChannel也註冊上選擇器讓選擇器來監聽
            sChannel.register(selector,SelectionKey.OP_READ);
        }else if (selectionKey.isReadable()){
            // 如果讀狀態已經準備就緒,就開始讀取資料
            // 10.獲取當前選擇器上讀狀態準備就緒的通道
            SocketChannel sChannel = (SocketChannel) selectionKey.channel();
            // 11.讀取客戶端傳送的資料,需要先建立緩衝區
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

            // 12.讀取緩衝區的資料
            while (sChannel.read(byteBuffer) > 0){
                byteBuffer.flip();
                // 這裡sChannel.read(byteBuffer)就是這個位元組陣列的長度
                System.out.println(new String(byteBuffer.array(),0,sChannel.read(byteBuffer)));

                // 清空緩衝區
                byteBuffer.clear();
            }
        }
        // 當selectionKey使用完畢需要移除,否則會一直優先
        selectionKeyIterator.remove();
    }

}
複製程式碼

當呼叫register方法將通道註冊到選擇器時,選擇器對通道的監聽事件需要通過第二個引數ops決定

讀:SelectionKey.OP_READ(1)
寫:SelectionKey.OP_WRITE(4)
連線:SelectionKey.OP_CONNECT(8)
接收:SelectionKey.OP_ACCEPT(16)
複製程式碼

註冊時不僅僅只有一個監聽事件,則需要用位或操作符連線

int selectionKeySet = SelectionKey.OP_READ|SelectionKey.OP_WRITE
複製程式碼

而關於這個selectionKey,它表示著SelectableChannel和Selectr之間的註冊關係。它也有一系列對應的方法

插曲:Kafka原始碼預熱篇--- Java NIO

2.3.3 客戶端的改造

引入Scanner接收輸入資訊,不過請注意,在測試程式碼中輸入IDEA需要進行一些設定,具體做法是在Help-Edit Custom VM Option中加入一行

-Deditable.java.test.console=true
複製程式碼

這樣就可以輸入了。

// 1.獲取通道,預設是阻塞的
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("192.168.80.1",9898));

// 1.1 將阻塞的套接字變成非阻塞
sChannel.configureBlocking(false);

// 2.建立指定大小的緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()){
    String str = scanner.next();
    // 3.傳送資料給服務端,直接將資料儲存到緩衝區
    byteBuffer.put((new Date().toString()+str).getBytes());
    // 4.將緩衝區的資料寫入到sChannel
    byteBuffer.flip();
    sChannel.write(byteBuffer);
    byteBuffer.clear();
}
// 關閉
sChannel.close();
複製程式碼

這樣就完成了一個問答模式的網路通訊。

2.4 Pipe管道

Java NIO中的管道是兩個執行緒之間的單向資料連線,Pipe有一個source管道和一個sink管道,資料會被寫到sink,從source中獲取

插曲:Kafka原始碼預熱篇--- Java NIO

// 1.獲取管道
Pipe pipe = Pipe.open();

// 2.建立緩衝區物件
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 3.獲取sink通道
Pipe.SinkChannel sinkChannel = pipe.sink();
byteBuffer.put("通過單向管道傳輸資料".getBytes());

// 4.將資料寫入sinkChannel
byteBuffer.flip();
sinkChannel.write(byteBuffer);
// 5.讀取緩衝區中的資料
Pipe.SourceChannel sourceChannel = pipe.source();
// 6.讀取sourceChannel中的資料放入到緩衝區
byteBuffer.flip();
sourceChannel.read(byteBuffer);
System.out.println(new String(byteBuffer.array(),0,sourceChannel.read(byteBuffer)));

sourceChannel.close();
sinkChannel.close();
    
執行結果就是列印了我們的那串字元"通過單向管道傳輸資料",沒啥
複製程式碼

finally

大致地把NIO的一些基礎知識給列舉了一下,內容看似很多其實並沒有涉及太難的知識點,都是按部就班地執行而已。其實如果要深摳的話,還是有很多其他的知識點的,比如NIO2的Path,Paths和Files。這裡就不再列舉說明了。感興趣的朋友可以自行去了解一下。

相關文章