文章目錄
NIO核心Buffer、Selector、Channel分析
上一篇文章中我們簡單的說明了BIO、NIO、AIO之間的關係和區別, 本篇文章主要講解NIO核心buffer、selector、channel原理
一、包含知識點
- Buffer的基本原理
- 緩衝區的分配
- 緩衝區分片
- 只讀緩衝區
- 直接緩衝區
- 內存映射
- 選擇器Selector
- 通道Channel
二、緩衝區Buffer
2.1 基本原理
NIO主要包含Buffer、Selector、Channel三個核心組件, 本小節主要講解buffer。首先看下buffer的類繼承圖
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-jifh3ale-1592446301659)(./Buffer類繼承圖.jpg)]
緩衝區是一個容器對象, 底層通過數組來實現, 在NIO中, 無論數據的讀取還是寫入都需要先經過緩衝區, 其中最常用的是ByteBuffer, 從上面類繼承圖, Java常用的基本類型都有具體的ByteBuffer和它對應。
緩衝區Buffer底層是通過數組來實現的, 那麼在進行數據的讀取、寫入操作時,它是怎麼記錄緩衝區狀態變化的呢 ?查看Buffer類, 有下面幾個重要字段, 0 <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
- position: 數據索引位置,初始化爲0
- get() 讀取數據時, position表示讀取數據的位置
- put() 寫入數據時, position表示可以寫入數據的起始位置
- limit: 剩餘可讀取的數據量或剩餘可插入數據的空間
- 讀取操作, 表示最大可讀取的數據量
- 查詢操作, 表示最大可插入數據量
- capcity: 緩衝區容量, 即底層數組長度
2.2 緩衝區基本操作
首先看下緩衝區基本操作的邏輯代碼
ByteBuffer buffer = ByteBuffer.allocate(10);
//寫入數據
buffer.put((byte)i);
//獲取數據
buffer.get() ;
//刷新緩存區
buffer.flip() ;
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QiGfCb8S-1592446301661)(buffer基本操作position、limit、capacity變化.jpg)]
-
緩衝區分配
創建緩衝區對象時, 通過allocate()方法指定緩存的容量, 實際是創建了指定大小的數組, 將其包裝成緩衝區對象
//1. ByteBuffer.allocate public static ByteBuffer allocate(int capacity) { if (capacity < 0) throw createCapacityException(capacity); return new HeapByteBuffer(capacity, capacity); } //2. HeapByteBuffer HeapByteBuffer(int cap, int lim) { // package-private super(-1, 0, lim, cap, new byte[cap], 0); // new byte[cap] 創建數組, 作爲緩衝區對象 /* hb = new byte[cap]; offset = 0; */ this.address = ARRAY_BASE_OFFSET; } //3. ByteBuffer ByteBuffer(int mark, int pos, int lim, int cap, // package-private byte[] hb, int offset){ super(mark, pos, lim, cap); this.hb = hb; // 緩存區對象 this.offset = offset; }
-
put操作
當通過put()方法向緩衝區添加數據時, 每次添加操作position的值都會自增1,position < limit, 如果position >= limit會拋出BufferOverflowException異常, 添加過程limit、capacity值不變化
//1. put操作 public ByteBuffer put(byte x) { hb[ix(nextPutIndex())] = x; return this; } //2. 獲取下個可添加數據的下標 final int nextPutIndex() { // package-private if (position >= limit) throw new BufferOverflowException(); return position++; }
-
flip操作
flip有種切換的意思,當需要從寫入操作轉換爲讀取操作時, 需要修改position、limit的值, 前面我們提到了Buffer讀取、寫入是共用position、limit、capacity字段, 不同操作時表示不同意思。
public Buffer flip() { limit = position; position = 0; mark = -1; return this; }
-
get操作
在執行flip操作後, position、limit發生了變化, 分別表示可讀取數據的起始位置、緩衝區中可讀數據數量, 每次執行get操作, 都會使position的值增1, position < limit, 如果position >= limit, 會拋出BufferOverflowException異常, 獲取過程中limit、capacity值不發生變化
//1. get操作 public byte get() { return hb[ix(nextGetIndex())]; } //2. 獲取下個可添加數據的下標 final int nextPutIndex() { // package-private if (position >= limit) throw new BufferOverflowException(); return position++; }
-
clear操作
如果需要對position、limit進行復位操作, 可以執行clear方法
//1. ByteBuffer.clear() ByteBuffer clear() { super.clear(); return this; } //2. Buffer.clear() public Buffer clear() { position = 0; limit = capacity; mark = -1; return this; }
2.3 緩衝區分片
在NIO中, 除了可以分配或着包裝緩衝區之外, 還可以基於現有的緩衝區創建一個子緩衝區, 子緩衝區和原緩衝區在底層數據共享, 當共享部分有數據變化時, 子緩衝區與原緩衝區都會發生變化, 下面是測試代碼
public class BufferSlice {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate( 10 );
// 緩衝區中的數據0-9
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
buffer.flip();
while (buffer.remaining() > 0) { // limit - position
System.out.print(buffer.get() + " ");
}
System.out.println();
// 創建子緩衝區
buffer.position( 3 );
buffer.limit( 7 );
ByteBuffer slice = buffer.slice();
// 改變子緩衝區的內容
for (int i=0; i<slice.capacity(); ++i) {
byte b = slice.get( i ); // 直接通過索引獲取數據, 沒有通過position來獲取數據
b *= 10;
slice.put( i, b );
}
// 復位
buffer.position( 0 );
buffer.limit( buffer.capacity() );
while (buffer.remaining()>0) {
System.out.print( buffer.get() + " ");
}
}
}
2.4 只讀緩衝區
只讀緩衝區如字面意思, 緩存的內容只能讀取,不能進行寫入操作, 可以使用asReadOnlyBuffer方法創建只讀緩衝區, 有下面幾點需要注意
- 創建的只讀緩衝區和舊緩衝區共享底層空間
- asReadOnlyBuffer創建只讀緩衝區時,position、limit和原緩存一樣, 如果執行get操作需要手動更新讀取數據的位置
/**
* 只讀緩衝區
*/
public class ReadOnlyBuffer {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate( 10 );
// 緩衝區中的數據0-9
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
// 創建只讀緩衝區
// 基於原buffer創建新的buffer
//position、limit、capcitu 和原緩存一致
ByteBuffer readonly = buffer.asReadOnlyBuffer();
// 改變原緩衝區的內容
for (int i=0; i<buffer.capacity(); ++i) {
byte b = buffer.get( i );
b *= 10;
buffer.put( i, b );
}
//position、limit保持和舊緩存值一致
System.out.println(readonly.position());
System.out.println(readonly.limit());
System.out.println();
readonly.position(0);
readonly.limit(buffer.capacity());
// 只讀緩衝區的內容也隨之改變
while (readonly.remaining()>0) {
System.out.print( readonly.get() + "\t");
}
System.out.println();
//修改制度緩存
readonly.put(0,(byte)-1); //java.nio.ReadOnlyBufferException
}
}
2.5 直接緩衝區
在2.2節中,我們知道普通分配緩存的方式是通過靜態方法ByteBuffer.allocate()來創建的,如果需要加快IO速度, 可以創建直接緩衝區, 它會在每次調用底層操作系統進行IO操作時, 1) 避免將直接緩衝區中的數據copy到中間緩衝區, 2) 避免從中間緩衝區copy數據到直接緩衝區
//1. 普通創建緩衝區的方式
ByteBuffer buffer = ByteBuffer.allocate(10);
//2. 創建直接緩存的方式
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = UNSAFE.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
UNSAFE.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
2.6 內存映射
內存映射是一種**讀和寫文件**數據的方法,內存映射文件IO是通過使文件中的數據爲數組的內容完成的,不會將整個文件內容進行讀取, 只有實際讀取或者寫入的部分纔會映射到內存中, 看下示例代碼
public class MappedBuffer {
static private final int start = 0;
static private final int size = 26;
static public void main( String args[] ) throws Exception {
String rootPath = MappedBuffer.class.getClassLoader().getResource("").getPath();
RandomAccessFile raf = new RandomAccessFile( rootPath + "test.txt", "rw" );
FileChannel fc = raf.getChannel();
//把緩衝區跟文件系統進行一個映射關聯
//只要操作緩衝區裏面的內容,文件內容也會跟着改變
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size );
mbb.put( 0, (byte)97 ); //a
mbb.put( 25, (byte)122 ); //z
raf.close();
}
}
三、選擇器Selector
3.1 傳統會話模式TPR(Thread Per Request)
傳統的Server/Client會話模式是TPR(Thread Per Request),
- 服務端會爲每個請求創建一個新的Thread來處理邏輯, 如果請求併發過大,會同時創建過多的線程來處理Client的請求, 大量的線程會增加服務器壓力, 容易帶來性能問題
- 爲了解決線程的不斷增長, 通常會使用線程池來控制線程數量上限, 但是可能會帶來新的問題, 如果線程池線程都在處理耗時操作, 比如: 文件上傳、下載操作, 如果有個耗時很短的請求過來,會被阻塞不能及時被處理
3.2 Reactor模式
NiO通過Reactor模式來實現非阻塞需求, IO調用不會被阻塞, 只會註冊特定的IO操作, 當特定的事件到來時會發出通知, NIO基於Selector實現非阻塞IO,當有讀或者寫等任何註冊事件發生時, 可以從Selector中獲得相應SelectionKey, 通過這個SelectionKey找到發生事件的SelectableChannel, 然後獲得客戶端發送過來的數據。處理的基本順序可以分爲下面幾個步驟
- 向 Selector 對象註冊感興趣的事件
- 從 Selector 中獲取感興趣的事件
- 根據不同的事件進行相應的處理
下面是Reactor模式圖示
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-2qHZMZyl-1592446301662)(Reactor模式圖.jpg)]
下面通過下面的示例代碼熟悉Selector
package com.gupaoedu.vip.netty.io.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
/**
* NIO的操作過於繁瑣,於是纔有了Netty
* Netty就是對這一系列非常繁瑣的操作進行了封裝
*
* Created by Tom.
*/
public class NIOServerDemo {
private int port = 8080;
//輪詢器 Selector
private Selector selector;
//緩衝區 Buffer
private ByteBuffer buffer = ByteBuffer.allocate(1024);
//初始化完畢
public NIOServerDemo(int port){
try {
this.port = port;
ServerSocketChannel server = ServerSocketChannel.open();
//IP/Port
server.bind(new InetSocketAddress(this.port));
//BIO 升級版本 NIO,爲了兼容BIO,NIO模型默認是採用阻塞式
server.configureBlocking(false);
selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (Exception e) {
e.printStackTrace();
}
}
public void listen(){
System.out.println("listen on " + this.port + ".");
try {
//輪詢主線程
while (true){
//大堂經理再叫號
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
//不斷地迭代,就叫輪詢
//同步體現在這裏,因爲每次只能拿一個key,每次只能處理一種狀態
while (iter.hasNext()){
SelectionKey key = iter.next();
iter.remove();
//每一個key代表一種狀態
//沒一個號對應一個業務
//數據就緒、數據可讀、數據可寫
process(key);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
//每一次輪詢就是調用一次process方法,而每一次調用,只能幹一件事
//在同一時間點,只能幹一件事
private void process(SelectionKey key) throws IOException {
//針對於每一種狀態給一個反應
if(key.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel)key.channel();
//這個方法體現非阻塞,不管你數據有沒有準備好
//你給我一個狀態和反饋
SocketChannel channel = server.accept();
//一定一定要記得設置爲非阻塞
channel.configureBlocking(false);
//當數據準備就緒的時候,將狀態改爲可讀
key = channel.register(selector,SelectionKey.OP_READ);
}
else if(key.isReadable()){
//key.channel 從多路複用器中拿到客戶端的引用
SocketChannel channel = (SocketChannel)key.channel();
int len = channel.read(buffer);
if(len > 0){
buffer.flip();
String content = new String(buffer.array(),0,len);
key = channel.register(selector,SelectionKey.OP_WRITE);
//在key上攜帶一個附件,一會再寫出去
key.attach(content);
System.out.println("讀取內容:" + content);
}
}
else if(key.isWritable()){
SocketChannel channel = (SocketChannel)key.channel();
String content = (String)key.attachment();
channel.write(ByteBuffer.wrap(("輸出:" + content).getBytes()));
channel.close();
}
}
public static void main(String[] args) {
new NIOServerDemo(8080).listen();
}
}
四、Channel
4.1 channel基本知識
通道是一個對象, 數據的讀取和寫入都需要經過通道(Channel),但是需要注意的是數據的直接操作並不是Channel而是Buffer, NIO中提供了多種通道對象, 請看下圖
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-CH2gHQG7-1592446301664)(Channel類繼承關係.jpg)]
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pKFjDKep-1592446301665)(Selector處理事件.jpg)]
使用 NIO 讀取數據
- 從 FileInputStream 獲取 Channel
- 創建 Buffer
- 將數據從 Channel 讀取到 Buffer 中
使用 NIO 寫入數據
- 從 FileInputStream 獲取 Channel
- 創建 Buffer
- 將數據從 Channel 寫入到 Buffer 中
4.2 IO多路複用
多路複用 IO 技術最適用的是“高併發”場景,以滿足 短時間內至少同時有上千個連接請求準備好。其他情
況下多路複用 IO 技術發揮不出來它的優勢。另一方面,使用 JAVA NIO 進行功能實現,相對於傳統的 Socket 套接字
實現要複雜一些,所以實際應用中,需要根據自己的業務需求進行技術選擇。
常見多路複用技術
IO模型 | 相對性能 | 關鍵思路 | 操作系統 | Java支持 |
---|---|---|---|---|
select | 較高 | Reactor | Win/Linux | 支持,Reactor 模式(反應器設計模式)。Linux 操作系統的 kernels 2.4 內核版本之前,默認使用select;而目前 windows 下對同步 IO 的支持,都是 select 模型 |
pool | 較高 | Reactor | Linux | Linux 下的 JAVA NIO 框架,Linux kernels 2.6 內核版本之前使用 poll 進行支持。也是使用的Reactor 模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6 內核版本及以後使用 epoll 進行支持;Linux kernels 2.6 內核版本之前使用 poll進行支持;另外一定注意,由於 Linux 下沒有Windows 下的 IOCP 技術提供真正的 異步 IO 支持,所以 Linux 下使用 epoll 模擬異步 IO |
Kqueue | 高 | Proactor | Linux |
五、NIO源碼分析
在3.2節中我們知道, Selector的創建方式, 那麼它具體實現邏輯是什麼呢 ?
- 創建Provider
//Selector
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector(); // 創建Selector入口
}
//SelectorProvider
public static SelectorProvider provider() {
synchronized (lock) { // 加鎖避免併發問題
if (provider != null)
return provider; // 如果已經創建, 返回創建好的provider , 保證整個Server只有一個provider
return AccessController.doPrivileged(
new PrivilegedAction<>() {
public SelectorProvider run() {
if (loadProviderFromProperty()) //從配置屬性java.nio.channels.spi.SelectorProvider家在provider
return provider;
if (loadProviderAsService()) // 通過SPI機制家在provider
return provider;
/**
* 這裏以PollSelectorImpl爲例說明, 注意這裏會根據不同操作系統創建不同的Provider
* 1. windows, WindowSelectorProvider
* 2. Linux: EpoolSelectorProvider
* 可以將 ${JAVA_HOME}/lib/rt.jar 進行解壓, 解壓後進入 sun.nio.ch目錄, 查看DefaultSelectorProvider.class文件,
* 內容實現會根據系統不一樣創建對應的Provider
*/
provider = sun.nio.ch.DefaultSelectorProvider.create(); // 創建一個基於當前操作系統的provider
return provider;
}
});
}
}
-
創建Selector
查看DefaultSelectorProvider類, 當需要Selector對象時, provider通過openSelector創建對應的selector對象
//DefaultSelectorProvider
public static SelectorProvider create() {
/**
* 這裏以PollSelectorImpl爲例說明, 注意這裏會根據不同操作系統創建不同的Provider
*/
return new PollSelectorProvider(); // 這裏以PollSelectorImpl爲例說明
}
//PollSelectorImpl
PollSelectorImpl(SelectorProvider var1) {
super(var1, 1, 1);
long var2 = IOUtil.makePipe(false); //native方法, 返回文件兩個描述符, 用long來存儲
this.fd0 = (int)(var2 >>> 32); //高位, read描述符
this.fd1 = (int)var2; //低位, write描述符
try {
this.pollWrapper = new PollArrayWrapper(10);
this.pollWrapper.initInterrupt(this.fd0, this.fd1);
this.channelArray = new SelectionKeyImpl[10];
} catch (Throwable var8) {
try {
FileDispatcherImpl.closeIntFD(this.fd0);
} catch (IOException var7) {
var8.addSuppressed(var7);
}
try {
FileDispatcherImpl.closeIntFD(this.fd1);
} catch (IOException var6) {
var8.addSuppressed(var6);
}
throw var8;
}
}
-
創建Pipe
如果selector需要打開創建pipe, 可以通過openPipe來創建Pipe對象
//SelectorProviderImpl
public Pipe openPipe() throws IOException {
return new PipeImpl(this);
}
//PipeImpl
PipeImpl(SelectorProvider var1) {
long var2 = IOUtil.makePipe(true); //native方法, 返回文件兩個描述符, 用long來存儲
int var4 = (int)(var2 >>> 32); //高位, read描述符
int var5 = (int)var2; //低位, write描述符
FileDescriptor var6 = new FileDescriptor();
IOUtil.setfdVal(var6, var4);
this.source = new SourceChannelImpl(var1, var6);
FileDescriptor var7 = new FileDescriptor();
IOUtil.setfdVal(var7, var5);
this.sink = new SinkChannelImpl(var1, var7);
}
-
創建Channel
這裏以創建ServerSocketChannel說明, 從provider創建流程我們知道, 一個應用只有一個provider, Channel的創建是通過這個provider創建的
//SelectorProviderImpl
public ServerSocketChannel openServerSocketChannel() throws IOException {
return new ServerSocketChannelImpl(this);
}
//ServerSocketChannelImpl
ServerSocketChannelImpl(SelectorProvider var1) throws IOException {
super(var1);
this.fd = Net.serverSocket(true);
this.fdVal = IOUtil.fdVal(this.fd);
this.state = 0;
}
//AbstractSelectableChannel
protected AbstractSelectableChannel(SelectorProvider provider) {
this.provider = provider;
}
-
ServerSocketChannel.register()
從3.2節示例代碼我們知道, channel和selector通過 channel.register(selector,SelectionKey.OP_READ) 綁定在一起, 即創建ServerSocketChannel時創建的FD和selector綁定在一起。
//AbstractSelectableChannel
public final SelectionKey register(Selector sel, int ops,
Object att)
throws ClosedChannelException
{
synchronized (regLock) {
if (!isOpen()) // 管道是否已經打開, false拋錯
throw new ClosedChannelException();
if ((ops & ~validOps()) != 0) // 是否支持當前 SelectionKey, 如果不支持,拋錯
throw new IllegalArgumentException();
if (blocking)
throw new IllegalBlockingModeException();
SelectionKey k = findKey(sel); // 遍歷SelectionKey集合, 找到屬於當前selector的SelectorKey
if (k != null) {// 查詢到key, 更新selector
k.interestOps(ops);
k.attach(att);
}
if (k == null) { // 沒查詢到, 新註冊,將Channel和Selector綁定
// New registration
synchronized (keyLock) {
if (!isOpen())
throw new ClosedChannelException();
k = ((AbstractSelector)sel).register(this, ops, att);
addKey(k);
}
}
return k;
}
}
- Selector.doSelect()
protected int doSelect(long var1) throws IOException {
if (this.channelArray == null) {// 沒有SelectorKey信息, 拋異常
throw new ClosedSelectorException();
} else {
this.processDeregisterQueue();
try {
this.begin();
this.pollWrapper.poll(this.totalChannels, 0, var1); // 核心方法, 輪詢pollWrapper中保存的FD
} finally {
this.end();
}
this.processDeregisterQueue();
int var3 = this.updateSelectedKeys(); // 更新SelectedKey
if (this.pollWrapper.getReventOps(0) != 0) {
this.pollWrapper.putReventOps(0, 0);
synchronized(this.interruptLock) {
IOUtil.drain(this.fd0);
this.interruptTriggered = false;
}
}
return var3;
}
}
//PollArrayWrapper
int poll(int var1, int var2, long var3) {
return this.poll0(this.pollArrayAddress + (long)(var2 * 8), var1, var3);
}
private native int poll0(long var1, int var3, long var4);
這個 poll0()會監聽 pollWrapper 中的 FD 有沒有數據進出,這會造成 IO 阻塞,直到有數據讀寫事件發生。比如,由於 pollWrapper 中保存的也有 ServerSocketChannel 的 FD,所以只要 ClientSocket 發一份數據到 ServerSocket,那麼 poll0() 就會返回;又由於 pollWrapper 中保存的也有 pipe 的 write 端的 FD,所以只要 pipe 的 write 端向 FD 發一份數據,也會造 成 poll0()返回;如果這兩種情況都沒有發生,那麼 poll0()就一直阻塞,也就是 selector.select()會一直阻塞;如果有任 何一種情況發生,那麼 selector.select()就會返回,所有在 OperationServer 的 run()裏要用 while (true) {,這樣就可以保證在 selector 接收到數據並處理完後繼續監聽 poll();