深入Hotspot源碼與Linux內核理解NIO與Epoll

IO模型
IO模型就是說用什麼樣的通道進行數據的發送和接收,Java共支持3種網絡編程IO模式:BIO,NIO,AIO

BIO(Blocking IO)
同步阻塞模型,一個客戶端連接對應一個處理線程

BIO代碼示例:
package com.tuling.bio;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待連接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客戶端連接了。。");
handler(clientSocket);

        /*new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    handler(clientSocket);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();*/
    }
}

private static void handler(Socket clientSocket) throws IOException {
    byte[] bytes = new byte[1024];
    System.out.println("準備read。。");
    //接收客戶端的數據,阻塞方法,沒有數據可讀時就阻塞
    int read = clientSocket.getInputStream().read(bytes);
    System.out.println("read完畢。。");
    if (read != -1) {
        System.out.println("接收到客戶端的數據:" + new String(bytes, 0, read));
    }
    clientSocket.getOutputStream().write("HelloClient".getBytes());
    clientSocket.getOutputStream().flush();
}

}

//客戶端代碼
public class SocketClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 9000);
//向服務端發送數據
socket.getOutputStream().write("HelloServer".getBytes());
socket.getOutputStream().flush();
System.out.println("向服務端發送數據結束");
byte[] bytes = new byte[1024];
//接收服務端回傳的數據
socket.getInputStream().read(bytes);
System.out.println("接收到服務端的數據:" + new String(bytes));
socket.close();
}
}
缺點:
1、IO代碼裏read操作是阻塞操作,如果連接不做數據讀寫操作會導致線程阻塞,浪費資源
2、如果線程很多,會導致服務器線程太多,壓力太大,比如C10K問題
應用場景:
BIO 方式適用於連接數目比較小且固定的架構, 這種方式對服務器資源要求比較高, 但程序簡單易理解。

NIO(Non Blocking IO)
同步非阻塞,服務器實現模式爲一個線程可以處理多個請求(連接),客戶端發送的連接請求都會註冊到多路複用器selector上,多路複用器輪詢到連接有IO請求就進行處理,JDK1.4開始引入。
應用場景:
NIO方式適用於連接數目多且連接比較短(輕操作) 的架構, 比如聊天服務器, 彈幕系統, 服務器間通訊,編程比較複雜

NIO非阻塞代碼示例:
package com.tuling.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class NioServer {

// 保存客戶端連接
static List<SocketChannel> channelList = new ArrayList<>();

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

    // 創建NIO ServerSocketChannel,與BIO的serverSocket類似
    ServerSocketChannel serverSocket = ServerSocketChannel.open();
    serverSocket.socket().bind(new InetSocketAddress(9000));
    // 設置ServerSocketChannel爲非阻塞
    serverSocket.configureBlocking(false);
    System.out.println("服務啓動成功");

    while (true) {
        // 非阻塞模式accept方法不會阻塞,否則會阻塞
        // NIO的非阻塞是由操作系統內部實現的,底層調用了linux內核的accept函數
        SocketChannel socketChannel = serverSocket.accept();
        if (socketChannel != null) { // 如果有客戶端進行連接
            System.out.println("連接成功");
            // 設置SocketChannel爲非阻塞
            socketChannel.configureBlocking(false);
            // 保存客戶端連接在List中
            channelList.add(socketChannel);
        }
        // 遍歷連接進行數據讀取
        Iterator<SocketChannel> iterator = channelList.iterator();
        while (iterator.hasNext()) {
            SocketChannel sc = iterator.next();
            ByteBuffer byteBuffer = ByteBuffer.allocate(128);
            // 非阻塞模式read方法不會阻塞,否則會阻塞
            int len = sc.read(byteBuffer);
            // 如果有數據,把數據打印出來
            if (len > 0) {
                System.out.println("接收到消息:" + new String(byteBuffer.array()));
            } else if (len == -1) { // 如果客戶端斷開,把socket從集合中去掉
                iterator.remove();
                System.out.println("客戶端斷開連接");
            }
        }
    }
}

}
總結:如果連接數太多的話,會有大量的無效遍歷,假如有10000個連接,其中只有1000個連接有寫數據,但是由於其他9000個連接並沒有斷開,我們還是要每次輪詢遍歷一萬次,其中有十分之九的遍歷都是無效的,這顯然不是一個讓人很滿意的狀態。

NIO引入多路複用器代碼示例:
package com.tuling.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;

public class NioSelectorServer {

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

    // 創建NIO ServerSocketChannel
    ServerSocketChannel serverSocket = ServerSocketChannel.open();
    serverSocket.socket().bind(new InetSocketAddress(9000));
    // 設置ServerSocketChannel爲非阻塞
    serverSocket.configureBlocking(false);
    // 打開Selector處理Channel,即創建epoll
    Selector selector = Selector.open();
    // 把ServerSocketChannel註冊到selector上,並且selector對客戶端accept連接操作感興趣
    serverSocket.register(selector, SelectionKey.OP_ACCEPT);
    System.out.println("服務啓動成功");

    while (true) {
        // 阻塞等待需要處理的事件發生
        selector.select();

        // 獲取selector中註冊的全部事件的 SelectionKey 實例
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeys.iterator();

        // 遍歷SelectionKey對事件進行處理
        while (iterator.hasNext()) {
            SelectionKey key = iterator.next();
            // 如果是OP_ACCEPT事件,則進行連接獲取和事件註冊
            if (key.isAcceptable()) {
                ServerSocketChannel server = (ServerSocketChannel) key.channel();
                SocketChannel socketChannel = server.accept();
                socketChannel.configureBlocking(false);
                // 這裏只註冊了讀事件,如果需要給客戶端發送數據可以註冊寫事件
                socketChannel.register(selector, SelectionKey.OP_READ);
                System.out.println("客戶端連接成功");
            } else if (key.isReadable()) {  // 如果是OP_READ事件,則進行讀取和打印
                SocketChannel socketChannel = (SocketChannel) key.channel();
                ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                int len = socketChannel.read(byteBuffer);
                // 如果有數據,把數據打印出來
                if (len > 0) {
                    System.out.println("接收到消息:" + new String(byteBuffer.array()));
                } else if (len == -1) { // 如果客戶端斷開連接,關閉Socket
                    System.out.println("客戶端斷開連接");
                    socketChannel.close();
                }
            }
            //從事件集合裏刪除本次處理的key,防止下次select重複處理
            iterator.remove();
        }
    }
}

}

NIO 有三大核心組件: Channel(通道), Buffer(緩衝區),Selector(多路複用器)
1、channel 類似於流,每個 channel 對應一個 buffer緩衝區,buffer 底層就是個數組
2、channel 會註冊到 selector 上,由 selector 根據 channel 讀寫事件的發生將其交由某個空閒的線程處理
3、NIO 的 Buffer 和 channel 都是既可以讀也可以寫

NIO底層在JDK1.4版本是用linux的內核函數select()或poll()來實現,跟上面的NioServer代碼類似,selector每次都會輪詢所有的sockchannel看下哪個channel有讀寫事件,有的話就處理,沒有就繼續遍歷,JDK1.5開始引入了epoll基於事件響應機制來優化NIO。

NioSelectorServer 代碼裏如下幾個方法非常重要,我們從Hotspot與Linux內核函數級別來理解下
Selector.open() //創建多路複用器
socketChannel.register(selector, SelectionKey.OP_READ) //將channel註冊到多路複用器上
selector.select() //阻塞等待需要處理的事件發生

總結:NIO整個調用流程就是Java調用了操作系統的內核函數來創建Socket,獲取到Socket的文件描述符,再創建一個Selector對象,對應操作系統的Epoll描述符,將獲取到的Socket連接的文件描述符的事件綁定到Selector對應的Epoll文件描述符上,進行事件的異步通知,這樣就實現了使用一條線程,並且不需要太多的無效的遍歷,將事件處理交給了操作系統內核(操作系統中斷程序實現),大大提高了效率。

Epoll函數詳解
int epoll_create(int size);
創建一個epoll實例,並返回一個非負數作爲文件描述符,用於對epoll接口的所有後續調用。參數size代表可能會容納size個描述符,但size不是一個最大值,只是提示操作系統它的數量級,現在這個參數基本上已經棄用了。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event event);
使用文件描述符epfd引用的epoll實例,對目標文件描述符fd執行op操作。
參數epfd表示epoll對應的文件描述符,參數fd表示socket對應的文件描述符。
參數op有以下幾個值:
EPOLL_CTL_ADD:註冊新的fd到epfd中,並關聯事件event;
EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中移除fd,並且忽略掉綁定的event,這時event可以爲null;
參數event是一個結構體
struct epoll_event {
__uint32_t events; /
Epoll events /
epoll_data_t data; /
User data variable */
};

typedef union epoll_data {
    void        *ptr;
    int          fd;
    __uint32_t   u32;
    __uint64_t   u64;
} epoll_data_t;

events有很多可選值,這裏只舉例最常見的幾個:
EPOLLIN :表示對應的文件描述符是可讀的;
EPOLLOUT:表示對應的文件描述符是可寫的;
EPOLLERR:表示對應的文件描述符發生了錯誤;
成功則返回0,失敗返回-1

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待文件描述符epfd上的事件。
epfd是Epoll對應的文件描述符,events表示調用者所有可用事件的集合,maxevents表示最多等到多少個事件就返回,timeout是超時時間。

I/O多路複用底層主要用的Linux 內核·函數(select,poll,epoll)來實現,windows不支持epoll實現,windows底層是基於winsock2的select函數實現的(不開源)
selectpollepoll(jdk 1.5及以上)操作方式遍歷遍歷回調底層實現數組鏈表哈希表IO效率每次調用都進行線性遍歷,時間複雜度爲O(n)每次調用都進行線性遍歷,時間複雜度爲O(n)事件通知方式,每當有IO事件就緒,系統註冊的回調函數就會被調用,時間複雜度O(1)最大連接有上限無上限無上限

Redis線程模型
Redis就是典型的基於epoll的NIO線程模型(nginx也是),epoll實例收集所有事件(連接與讀寫事件),由一個服務端線程連續處理所有事件命令。
Redis底層關於epoll的源碼實現在redis的src源碼目錄的ae_epoll.c文件裏,感興趣可以自行研究。

AIO(NIO 2.0)
異步非阻塞, 由操作系統完成後回調通知服務端程序啓動線程去處理, 一般適用於連接數較多且連接時間較長的應用
應用場景:
AIO方式適用於連接數目多且連接比較長(重操作)的架構,JDK7 開始支持
AIO代碼示例:
package com.tuling.aio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;

public class AIOServer {

public static void main(String[] args) throws Exception {
    final AsynchronousServerSocketChannel serverChannel =
            AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));

    serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
        @Override
        public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
            try {
                System.out.println("2--"+Thread.currentThread().getName());
                // 再此接收客戶端連接,如果不寫這行代碼後面的客戶端連接連不上服務端
                serverChannel.accept(attachment, this);
                System.out.println(socketChannel.getRemoteAddress());
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                    @Override
                    public void completed(Integer result, ByteBuffer buffer) {
                        System.out.println("3--"+Thread.currentThread().getName());
                        buffer.flip();
                        System.out.println(new String(buffer.array(), 0, result));
                        socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                    }

                    @Override
                    public void failed(Throwable exc, ByteBuffer buffer) {
                        exc.printStackTrace();
                    }
                });
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void failed(Throwable exc, Object attachment) {
            exc.printStackTrace();
        }
    });

    System.out.println("1--"+Thread.currentThread().getName());
    Thread.sleep(Integer.MAX_VALUE);
}

}

package com.tuling.aio;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousSocketChannel;

public class AIOClient {

public static void main(String... args) throws Exception {
    AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 9000)).get();
    socketChannel.write(ByteBuffer.wrap("HelloServer".getBytes()));
    ByteBuffer buffer = ByteBuffer.allocate(512);
    Integer len = socketChannel.read(buffer).get();
    if (len != -1) {
        System.out.println("客戶端收到信息:" + new String(buffer.array(), 0, len));
    }
}

}

BIO、 NIO、 AIO 對比:

爲什麼Netty使用NIO而不是AIO?
在Linux系統上,AIO的底層實現仍使用Epoll,沒有很好實現AIO,因此在性能上沒有明顯的優勢,而且被JDK封裝了一層不容易深度優化,Linux上AIO還不夠成熟。Netty是異步非阻塞框架,Netty在NIO上做了很多異步的封裝。

同步異步與阻塞非阻塞(段子)
老張愛喝茶,廢話不說,煮開水。
出場人物:老張,水壺兩把(普通水壺,簡稱水壺;會響的水壺,簡稱響水壺)。
1 老張把水壺放到火上,立等水開。(同步阻塞)
老張覺得自己有點傻
2 老張把水壺放到火上,去客廳看電視,時不時去廚房看看水開沒有。(同步非阻塞)
老張還是覺得自己有點傻,於是變高端了,買了把會響笛的那種水壺。水開之後,能大聲發出嘀~~~~的噪音。
3 老張把響水壺放到火上,立等水開。(異步阻塞)
老張覺得這樣傻等意義不大
4 老張把響水壺放到火上,去客廳看電視,水壺響之前不再去看它了,響了再去拿壺。(異步非阻塞)
老張覺得自己聰明瞭。

所謂同步異步,只是對於水壺而言。
普通水壺,同步;響水壺,異步。
雖然都能幹活,但響水壺可以在自己完工之後,提示老張水開了。這是普通水壺所不能及的。
同步只能讓調用者去輪詢自己(情況2中),造成老張效率的低下。
所謂阻塞非阻塞,僅僅對於老張而言。
立等的老張,阻塞;看電視的老張,非阻塞。

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