Java基礎知識總結(二)——NIO

關於NIO這部分,除了《Java編程思想》中的介紹還有兩份資料我覺得很好:一是《深入Java Web技術內幕》第2章的部分,二是併發編程網上Jakob JenkovNIO系列教程翻譯,讀完之後受益匪淺。

1. NIO是什麼:

java.nio是JDK1.4之後加入的,它新穎的特點在於:(1)面向緩存;(2)非阻塞;(3)直接內存
首先來看看它的整體結構:一個完整的NIO程序體系應該包括Selector,Channel,Buffer,它們是NIO最核心的3個關鍵部分;


非阻塞IO是NIO的一大特點,那它是怎麼實現的呢,Selector和它的名字所反映的一樣起到選擇和調度的作用,它所調度的就是Channel(通道),Channel包括:FileChannel,SocketChannel,ServerSocketChannel,DatagramChannel,它們負責對指定的資源進行訪問讀寫,其中除了FileChannel都具有非阻塞的功能,ServerSocketChannel相當於一個服務器程序,它有accept方法監聽和接受客戶端的請求,而SocketChannel則對應於一個具體的Socket連接,它可以通過ServerSocketChannel的accept方法來得到,我們可以通過read和write對該連接信道進行讀寫,DatagramChannel是UDP數據報通信方式。在傳統的IO中,accept,read,write方法都是阻塞的方式進行的,也就是說accept方法負責接受一個客戶端請求,在未接受到一個請求之前線程就會阻塞在此處不能進行,因此要同時打開多個ServerSocketChannel必須要有多個線程支持,在非阻塞模式下,accept,read,write可以在直接返回,讓其他任務可以執行,這樣我們可以在一個線程中同時處理多個Channel,那你可能會問,accept直接返回了,請求來的時候如何在去調用accept接受呢,這就需要Selector來調度了,Selector的select()方法是阻塞的,它可以同時監聽對Channel的操作請求,將請求轉發到對應的channel中,從總體上看,把每個Channel中阻塞等待的行爲統一移到了Selector,從而我們可以在單線程中同時處理多個信道的讀寫任務。Buffer緩存則是我們對Channel進行讀寫的工具,它還提供了Char,Int等多種不同的視圖讓我們可以以不同的方式讀寫數據,也提供了Heap和Direct直接內存兩種緩存存儲方式。

下面我們對這3個關鍵的“部件”進行詳細的分析,當然我們應當明白不同的技術有不同的使用場景,這裏爲了突出NIO的特點我們集中於單線程(或少量線程)非阻塞的方式,它使用與高併發數據量處理少而簡單的場景。


2. Selector:

結合上面的論述,可以看到Selector起到了代替多個Channel監聽感興趣事件發生的作用,這讓我很容易想起一個設計模式——觀察者模式,在這裏Selector是Obserable,Channel是Observer,Channel要向Selector註冊自己對哪些事情感興趣,當事件發生時,Selector通知對應的Channel。

這裏註冊有個兩個部分:哪個channel和指定的事件,SelectionKey包含了註冊的要素:

2.1 SelectionKey(註冊感興趣的事,監聽返回準備好的事,它關聯一個Selector和Channel,我爲什麼忍不住想到迪米特法則,中毒太深...):

操作事件:

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

因爲SelectionKey包含5個部分:

(1)interest集合和ready集合一樣包含有一些方便判斷的方法,可以看api或源碼;

(2)ready集合;

(3)channel引用;

(4)Selector引用;

(5)attach附加對象(可選);

2.2 Selector的重要方法:

(1)select方法:包括select(),selectNow(非阻塞),select(long timeout)返回int,有多個個ready的Channel;

(2)selectedKeys方法:返回ready的Channel的selectionKey集合,遍歷它們,根據readyOps集合處理對應事件;

(3)wakeUp方法:從select阻塞中喚醒;

(4)close方法:是所用selectionKey無效,也就釋放了對Channel們的引用不影響垃圾回收啦;

2.3 Selector使用示例:

public class SelectorSample {
    private List<SelectableChannel> channels;
    private boolean isListening = true;

    public SelectorSample(List<SelectableChannel> channels) {
        this.channels = channels;
    }

    public void doHandle() {
        try(Selector selector = Selector.open()) {
            for(SelectableChannel channel : channels) {
                channel.configureBlocking(false); //非阻塞
                channel.register(selector, SelectionKey.OP_ACCEPT | SelectionKey.OP_CONNECT
                        | SelectionKey.OP_READ | SelectionKey.OP_WRITE);
            }
            while(isListening) {
                int ready = selector.select();
                if(ready == 0) continue;
                Set<SelectionKey> selectionKeys = selector.keys();
                for(Iterator<SelectionKey> iterator = selectionKeys.iterator(); iterator.hasNext();) {
                    SelectionKey key = iterator.next();
                    if(key.isAcceptable()) {
                        System.out.println("doSomething when be acceptable");
                    } else if(key.isConnectable()) {
                        System.out.println("doSomething when be able to connect");
                    } else if(key.isReadable()) {
                        System.out.println("doSomething when be readable");
                    } else if(key.isWritable()) {
                        System.out.println("doSomething when be writable");
                    }
                    iterator.remove(); //注意要從就緒集合中刪除,下次就緒有selector添加
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

2. Channel :

Channel的體系中有:SelectableChannel和InterruptiableChannel,前者繼承自後者,之前說過FileChannel是不可以非阻塞的它屬於InterruptiableChannel,而其他3種進一步屬於SelectableChannel。

2.1 如何打開通道:

ServerSocketChannel,SocketChannel,DatagramChannel,Pipe都有open方法,可以用來打開通道,它們都屬於網絡編程,底層是要依賴操作系統層對應網絡模塊的實現,在Java中這3個通道和管道都是通過SelectorProvider來創建的,該Provider在不同平臺上有不同的實現,JVM有一個“system-wide”的默認實現,它是單例的。
而FileChannel是對應於文件系統的,基於Java I/O,我們可以從FileInputStream,FileOutputStream,RandomAccessFile來獲取一個對應於特定文件的通道。

我們進一步看看這四個具體的Channel實現那些接口,來分析它們各自有什麼功能:

2.2 ServerSocketChannel:

線程安全;

public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel
它的主要功能就是監聽的某個地址和端口上的套接字請求,並打開SocketChannel;

2.3 SocketChannel:

線程安全,read/write都進行了同步控制;

public abstract class SocketChannel
    extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel
ByteChannel實現了WritableChannel和ReadableChannel,因此它是可讀寫的;

Scatter/Gatther分別實現了將一個channel的內容讀到多個buffer(一個Buffer滿了才能讀到下一個)和多個Buffer寫到一個Channel的功能;

NetworkChannel:綁定到地址/端口的能力;

我們可以通過它來進行一個端到端的,有連接的套接字通信;


2.4 DatagramChannel:

線程安全的;

public abstract class DatagramChannel
    extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, MulticastChannel
與SocketChannel接口上的不同在與MulticastChannel,它是NetworkChannel子類;增加了多播的功能,使得我們可以使用基於UDP套接字的多播功能;


2.5 Pipe:

管道一般可以在兩個線程中進行單向的數據傳輸,它有兩個嵌套類:SinkChannel和SourceChannel,分別負責在一個線程(發送者)寫入和在另一個線程中讀取:

public static abstract class SinkChannel
        extends AbstractSelectableChannel
        implements WritableByteChannel, GatheringByteChannel

public static abstract class SourceChannel
        extends AbstractSelectableChannel
        implements ReadableByteChannel, ScatteringByteChannel
基於上面的論述,相信已經可以很清除的明白它們各自有什麼功能了:都可以非阻塞,一個負責寫,並且可以將多個Buffer一起寫入,一個負責讀,可以從Channel中將數據讀入多個Buffer;

2.6 FileChannel:

這個Channel是阻塞的,是操作磁盤文件一種方式,它和之前幾個Channel大不相同,所以我要將它和下面要介紹的DirectByteBuffer(MappedByteBuffer)一起討論。

3. Buffer:

總的來說,具體工作中使用Buffer的次數要遠遠多與Selector和Channel,我們通過它對Channel進行具體的讀寫操作。

之前說過NIO的特點之一就是面向緩存,我們在使用Buffer時都是基於一塊分配指定的大小的固定內存進行操作的,只有兩種分配方式:Heap和Direct,它們的區別下面會詳細說明。無論我們進行視圖轉換(CharBuffer/IntBuffer等等),還是compact壓縮,還是duplicate複製、slice切片,都是最初的allocate分配那一塊內存。

3.1 Buffer的基本屬性和重要方法:

capacity:容量;

limit:可操作的限制位置;

position:下一個操作位置;

mark:標記;

address:使用direct內存時的內存地址;

capacity,limit,position這3個屬性是我們進行操作最關鍵和常用的,結合操作方法我們來看看它們的使用細節:

flip:limit=position,position=0;這個方法通常在從通道中讀取數據後使用,這樣我們可以再從Buffer中讀取數據;或者在Buffer寫入數據後調用,讓通道寫入;

rewind:position=0,mark=-1;

clear:position=0,limit=capacity,mark=-1;通常在重新從

remaining:limit-position;用於檢查是否還有數據;

mark和reset:mark標記,mark=position;reset復位,position=mark(mark>0時),注意它並不會修改mark值;

compact:將原來(limit-position)未處理完的數據複製到開頭,再將position移到數據的下一個位置,limit=capacity,這個方法是進行壓縮,去掉已處理過的數據,主要   爲了接下來將數據寫入Buffer,注意,它只是在原數組上進行復制的,沒有新分配空間;

另一個你可能需要注意的是equals方法和compareTo方法,它們比較的是limit-position之間的大小;

3.2 Buffer的體系結構:

首先,重要的事先說說:Buffer並不是線程安全的

來看看Buffer的體系:

Channel都是基於字節的,我們一般也從ByteBuffer開始;

       ByteBuffer具體實現(分配):

ByteBuffer有兩個分配方法(它們返回HeapByteBuffer和DirectByteBuffer都是default包權限,我們無法直接使用它們):

allocate:HeapByteBuffer,從JVM堆中分配,收到JVM垃圾回收處理機制管理,實際上就是爲了一個固定的byte[];

allocateDirect:DirectByteBuffer,使用JNI在native內存中分配,那怎麼回收直接內存呢,DirectBuffer(DirectByteBuffer的接口),可以返回Cleaner,通過它我們可以釋放直接內存,否則你就只能等待Full GC的發生來釋放它了;

Buffer的視圖:

在類圖中我們可以看到有CharBuffer,IntBuffer,另外還有FloatBuffer,DoubleBuffer,ShortBuffer,LongBuffer以及MappedBuffer(特別的,內存映射);

它們實際上都是由ByteBuffer而產生,操作同一塊內存,只是讀取的方式不一樣,以HeapByteBuffer和CharBuffer爲例我們來看看它們是怎麼完成“視圖”的使命的。

轉換方法

ByteBuffer.asCharBuffer();

HeapByteBuffer中是這樣實現的:

public CharBuffer asCharBuffer() {
        int size = this.remaining() >> 1;
        int off = offset + position();
        return (bigEndian
                ? (CharBuffer)(new ByteBufferAsCharBufferB(this,
                                                               -1,
                                                               0,
                                                               size,
                                                               size,
                                                               off))
                : (CharBuffer)(new ByteBufferAsCharBufferL(this,
                                                               -1,
                                                               0,
                                                               size,
                                                               size,
                                                               off)));
    }
考慮到字順這裏有兩個實現,新建一個ByteBufferAsCharBufferB還是操作那一塊內存,只是我們換了一組capacity,limit,mark,position來操作;

而在具體的get/put方法中:

public char get() {
        return Bits.getCharB(bb, ix(nextGetIndex()));
    }
是通過Bits這個工具類來進行不同基本類型的讀取和操作。整個事情就是這樣,基於字節ByteBuffer,考慮字順用不同的方式去讀寫同一塊內存

PS:對於CharBuffer和ByteBuffer之間的轉換,涉及到編解碼,Charset有ByteBuffer = encode(CharBuffer)和CharBuffer = decode(ByteBuffer);


4. FileChannel

同樣它也是線程安全的。

4.1 打開FileChannel:

之前我們已經提到了它可通過FileInputStream、FileOutputStream和RandomAccessFile獲得,但是它們具有不同的讀寫權限:
RandomAccessFile:rw
public final FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, true, rw, this);
            }
            return channel;
        }
    }
FileOutputStream:
public FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, false, true, append, this);
            }
            return channel;
        }
    }
FileInputStream:
public FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, true, false, this);
            }
            return channel;
        }
    }
正如RandomAccessFile(可以seek方式前後讀寫文件),FileInputStream和FileOutputStream本身的差異一樣,它們也具有不同的特點;

4.2 FileChannel的獨特的方法:

寫,讀,關閉和其他Channel使用方式是一樣的,我們來看看它對文件還有什麼其他操作:
FileChannel.position(long):移到文件的特定位置,接下來你可以從這個位置寫/讀,你可以在文件結束符之後寫入,這和我們在Unix中一樣,它也可能會造成文件空洞。
FileChannel.truncate(long):截取前多少字節的數據;
FileChannel.size():返回關聯文件大小;
FileChannel.force():將緩存內(操作系統爲了提高性能)的數據強制寫入磁盤;

4.3 transfer:

如果你需要在WritableChannel之間傳遞數據,使用transferTo和transferFrom是一個非常好的選擇;
因爲這種方式不需要將磁盤數據從內核空間複製到應用用戶空間在傳到另一個內核空間,而是直接在內核空間中通過“通道傳輸”;

4.4 FileChannel中的內存映射:

爲了顯示內存映射到底是怎麼存儲,可以看看創建MapByteBuffer的源碼:
static MappedByteBuffer newMappedByteBuffer(int var0, long var1, FileDescriptor var3, Runnable var4) {
        if(directByteBufferConstructor == null) {
            initDBBConstructor();
        }

        try {
            MappedByteBuffer var5 = (MappedByteBuffer)directByteBufferConstructor.newInstance(new Object[]{new Integer(var0), new Long(var1), var3, var4});
            return var5;
        } catch (IllegalAccessException | InvocationTargetException | InstantiationException var7) {
            throw new InternalError(var7);
        }
    }
這裏可以看到顯然是通過直接內存的方式,但是這段代碼是在FileChannel的map方法中調用的,它和allocateDirect是有區別的;要理解直接內存和內存映射中的原理需要一些重要的基礎知識,才能真正弄清楚HeapByteBuffer,DirectByteBuffer,和map之間的區別。
(1)爲什麼FileInputStream和HeapByteBuffer相對要慢很多呢
Java中讀取文件要依賴於內核的系統調用,將文件數據從磁盤讀取到內核IO緩存區(內存中),再轉到JVM堆中(上面已經看到了HeapByteBuffer中new byte[])。首先要明白爲什麼要建立內核IO緩衝區,就是爲了減少磁盤的訪問次數,因爲磁盤IO效率很低,讀到內核IO緩存區轉到JVM堆又需要調用native方法,依賴系統調用,系統調用的開銷有比較大,因爲我們可以BufferedInputStream和HeapByteBuffer來建立緩衝區減少系統調用。儘管我們通過內核IO緩衝區和Java應用緩衝區兩層緩存分別減少了耗時的磁盤IO和系統調用,但是仍然不能避免內核態切換到用戶態,內核空間複製到用戶空間這樣的耗時操作。

(2)爲什麼allocateDirect要比傳統方式快
如果你看了DirectByteBuffer的源碼你就會發現其中並沒有new byte[]這樣在JVM Heap中分配的行爲,用的是address(Buffer中定義,long,64位)。實際上DirectMemory使用的是native堆,因此避免了向JVM堆複製的開銷,但是需要注意的一點是,allocateDirect方法本身的開銷比allocate方法大,因爲它依賴系統調用,因此我們使用時應該避免頻繁調用它分配小塊內存。
設置:JVM參數中,-XX:MaxDirectMemorySize可以控制它的大小,默認值爲-Xmx的大小;
回收:FULL CG和我們手動通過Cleaner去釋放;

PS:2015/10/24 補充
allocateDirect存在於內核空間,如果所有的操作都存在與內核空間中,可以減少內核空間向用戶空間複製,因此很快,但是如果頻繁的分配小的直接內存,系統調用的開銷會抵消減少複製的好處;另外如果我們用很小的直接內存讀取磁盤數據到內存(也就是從ByteBuffer中get出來使用)還是會產生複製,性能並不會有太大改善。而

(3)爲什麼內存映射更快呢
簡單的說,內存映射並沒有上面的分配行爲,既不需要native堆也不需要JVM堆,它是以Java進程直接將需要讀寫的文件映射爲虛擬內存,以內存的方式進行讀寫,這樣讀寫自然要比它們要快的多。同時它也是線程間進行通信的一種方式,兩個進程映射同一塊虛擬內存,共享內存的方式進行通信。
通過FileChannel.map可以對指定文件或其部分進行內存映射,有三種模式:READ_ONLY, READ_WRITE, PRIVATE。

4.5 不同讀取方式的對比:

public class DirectMemoryTest {
    private static final String TEST_FILE = "/home/yjh/test.file";

    public static void testNormal(String TEST_FILE) {
        System.out.print("Normal ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            while(fileChannel.read(buffer) != -1) {
                buffer.flip();
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void testDirect(String TEST_FILE) {
        System.out.print("Direct ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
            while(fileChannel.read(buffer) != -1) {
                buffer.flip();
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void testMapped(String TEST_FILE) {
        System.out.print("Mapped ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
            byte[] b = new byte[1024];
            while(buffer.get(b).position() < buffer.limit()) {

            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void test(Consumer<String> consumer) {
        long startTime = System.currentTimeMillis();

        consumer.accept(TEST_FILE);

        long endTime = System.currentTimeMillis();

        System.out.println("Time consume: " + (endTime - startTime));
    }

    public static void main(String[] args) {
        test(DirectMemoryTest::testNormal);
        test(DirectMemoryTest::testDirect);
        test(DirectMemoryTest::testMapped);
    }
}
我對三種方式分別讀取整個test文件的內容,該文件大小爲500M,運行結果(單位:ms):
Normal Time consume: 930
Direct Time consume: 800
Mapped Time consume: 222

在這個例子中,allocateDirect的直接內存同樣讀取了很多次(500MB / 1024B)磁盤數據,調用了很多次系統調用,因此和傳統方式的效率並不有太大提升。




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章