因爲篇幅問題我們繼續上一篇的內容繼續。
一、NIO網絡編程原理分析
NIO 非阻塞 網絡編程相關的(Selector、SelectionKey、ServerScoketChannel和SocketChannel) 關係梳理圖
對上圖的說明:
-
當客戶端連接時,會通過ServerSocketChannel 得到 SocketChannel
-
Selector 進行監聽 select 方法, 返回有事件發生的通道的個數.
-
將socketChannel註冊到Selector上, register(Selector sel, int ops), 一個selector上可以註冊多個SocketChannel
-
註冊後返回一個 SelectionKey, 會和該Selector 關聯(集合)
-
進一步得到各個 SelectionKey (有事件發生)
-
在通過 SelectionKey 反向獲取 SocketChannel , 方法 channel()
-
可以通過 得到的 channel , 完成業務處理
二、NIO網絡編程快速入門
接下來我們通過具體的案例代碼來實現網絡通信
服務端:
package com.dpb.netty.nio;
import io.netty.util.CharsetUtil;
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;
/**
* @program: netty4demo
* @description: Nio的服務端
* @author: 波波烤鴨
* @create: 2019-12-28 14:17
*/
public class NioServer {
public static void main(String[] args) throws Exception{
//創建ServerSocketChannel -> ServerSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//得到一個Selecor對象
Selector selector = Selector.open();
//綁定一個端口6666, 在服務器端監聽
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
//設置爲非阻塞
serverSocketChannel.configureBlocking(false);
//把 serverSocketChannel 註冊到 selector 關心 事件爲 OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("註冊後的selectionkey 數量=" + selector.keys().size()); // 1
//循環等待客戶端連接
while (true) {
//這裏我們等待1秒,如果沒有事件發生, 返回
if(selector.select(1000) == 0) { //沒有事件發生
System.out.println("服務器等待了1秒,無連接");
continue;
}
//如果返回的>0, 就獲取到相關的 selectionKey集合
//1.如果返回的>0, 表示已經獲取到關注的事件
//2. selector.selectedKeys() 返回關注事件的集合
// 通過 selectionKeys 反向獲取通道
Set<SelectionKey> selectionKeys = selector.selectedKeys();
System.out.println("selectionKeys 數量 = " + selectionKeys.size());
//遍歷 Set<SelectionKey>, 使用迭代器遍歷
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
//獲取到SelectionKey
SelectionKey key = keyIterator.next();
//根據key 對應的通道發生的事件做相應處理
if(key.isAcceptable()) { //如果是 OP_ACCEPT, 有新的客戶端連接
//該該客戶端生成一個 SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("客戶端連接成功 生成了一個 socketChannel " + socketChannel.hashCode());
//將 SocketChannel 設置爲非阻塞
socketChannel.configureBlocking(false);
//將socketChannel 註冊到selector, 關注事件爲 OP_READ, 同時給socketChannel
//關聯一個Buffer
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("客戶端連接後 ,註冊的selectionkey 數量=" + selector.keys().size()); //2,3,4..
}
if(key.isReadable()) { //發生 OP_READ
//通過key 反向獲取到對應channel
SocketChannel channel = (SocketChannel)key.channel();
//獲取到該channel關聯的buffer
ByteBuffer buffer = (ByteBuffer)key.attachment();
channel.read(buffer);
System.out.println("form 客戶端 " + new String(buffer.array(), CharsetUtil.UTF_8));
}
//手動從集合中移動當前的selectionKey, 防止重複操作
keyIterator.remove();
}
}
}
}
客戶端代碼
package com.dpb.netty.nio;
import io.netty.util.CharsetUtil;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
/**
* @program: netty4demo
* @description: NIO的客戶端
* @author: 波波烤鴨
* @create: 2019-12-28 14:18
*/
public class NioClient {
public static void main(String[] args) throws Exception{
//得到一個網絡通道
SocketChannel socketChannel = SocketChannel.open();
//設置非阻塞
socketChannel.configureBlocking(false);
//提供服務器端的ip 和 端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
//連接服務器
if (!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()) {
System.out.println("因爲連接需要時間,客戶端不會阻塞,可以做其它工作..");
}
}
//...如果連接成功,就發送數據
String str = "hello, bobo烤鴨~";
//Wraps a byte array into a buffer
ByteBuffer buffer = ByteBuffer.wrap(str.getBytes(CharsetUtil.UTF_8));
//發送數據,將 buffer 數據寫入 channel
socketChannel.write(buffer);
System.in.read();
}
}
效果
三、SelectionKey
SelectionKey
,表示 Selector 和網絡通道的註冊關係, 共四種
int OP_ACCEPT:有新的網絡連接可以 accept,值爲 16
int OP_CONNECT:代表連接已經建立,值爲 8
int OP_READ:代表讀操作,值爲 1
int OP_WRITE:代表寫操作,值爲 4
源碼中:
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
SelectionKey相關方法
public abstract class SelectionKey {
public abstract Selector selector();//得到與之關聯的 Selector 對象
public abstract SelectableChannel channel();//得到與之關聯的通道
public final Object attachment();//得到與之關聯的共享數據
public abstract SelectionKey interestOps(int ops);//設置或改變監聽事件
public final boolean isAcceptable();//是否可以 accept
public final boolean isReadable();//是否可以讀
public final boolean isWritable();//是否可以寫
}
四、ServerSocketChannel
ServerSocketChannel 在服務器端監聽
新的客戶端 Socket 連接
相關方法如下
public abstract class ServerSocketChannel extends AbstractSelectableChannel implements NetworkChannel{
//得到一個 ServerSocketChannel 通道
public static ServerSocketChannel open();
//設置服務器端端口號
public final ServerSocketChannel bind(SocketAddress local);
//設置阻塞或非阻塞模式,取值 false 表示採用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);
//接受一個連接,返回代表這個連接的通道對象
public SocketChannel accept();
//註冊一個選擇器並設置監聽事件
public final SelectionKey register(Selector sel, int ops);
}
五、SocketChannel
SocketChannel,網絡 IO 通道,具體負責進行讀寫操作。NIO 把緩衝區的數據寫入通道,或者把通道里的數據讀到緩衝區。
相關方法如下
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{
//得到一個 SocketChannel 通道
public static SocketChannel open();
//設置阻塞或非阻塞模式,取值 false 表示採用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);
//連接服務器
public boolean connect(SocketAddress remote);
//如果上面的方法連接失敗,接下來就要通過該方法完成連接操作
public boolean finishConnect();
public int write(ByteBuffer src);//往通道里寫數據
public int read(ByteBuffer dst);//從通道里讀數據
//註冊一個選擇器並設置監聽事件,最後一個參數可以設置共享數據
public final SelectionKey register(Selector sel, int ops, Object att);
public final void close();//關閉通道
}
六、羣聊系統
接下來提供一個羣聊系統的案例的簡單代碼。
- 編寫一個 NIO 羣聊系統,實現服務器端和客戶端之間的數據簡單通訊(非阻塞)
實現多人羣聊- 服務器端:可以監測用戶上線,離線,並實現消息轉發功能
- 客戶端:通過channel 可以無阻塞發送消息給其它所有用戶,同時可以接受其它用戶發送的消息(有服務器轉發得到)
- 目的:進一步理解NIO非阻塞網絡編程機制
服務端代碼
package com.dpb.netty.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
public class GroupChatServer {
//定義屬性
private Selector selector;
private ServerSocketChannel listenChannel;
private static final int PORT = 6667;
//構造器
//初始化工作
public GroupChatServer() {
try {
//得到選擇器
selector = Selector.open();
//ServerSocketChannel
listenChannel = ServerSocketChannel.open();
//綁定端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
//設置非阻塞模式
listenChannel.configureBlocking(false);
//將該listenChannel 註冊到selector
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
}catch (IOException e) {
e.printStackTrace();
}
}
//監聽
public void listen() {
System.out.println("監聽線程: " + Thread.currentThread().getName());
try {
//循環處理
while (true) {
int count = selector.select();
if(count > 0) {//有事件處理
//遍歷得到selectionKey 集合
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
//取出selectionkey
SelectionKey key = iterator.next();
//監聽到accept
if(key.isAcceptable()) {
SocketChannel sc = listenChannel.accept();
sc.configureBlocking(false);
//將該 sc 註冊到seletor
sc.register(selector, SelectionKey.OP_READ);
//提示
System.out.println(sc.getRemoteAddress() + " 上線 ");
}
if(key.isReadable()) { //通道發送read事件,即通道是可讀的狀態
//處理讀 (專門寫方法..)
readData(key);
}
//當前的key 刪除,防止重複處理
iterator.remove();
}
} else {
System.out.println("等待....");
}
}
}catch (Exception e) {
e.printStackTrace();
}finally {
//發生異常處理....
}
}
//讀取客戶端消息
private void readData(SelectionKey key) {
//取到關聯的channle
SocketChannel channel = null;
try {
//得到channel
channel = (SocketChannel) key.channel();
//創建buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
int count = channel.read(buffer);
//根據count的值做處理
if(count > 0) {
//把緩存區的數據轉成字符串
String msg = new String(buffer.array());
//輸出該消息
System.out.println("form 客戶端: " + msg);
//向其它的客戶端轉發消息(去掉自己), 專門寫一個方法來處理
sendInfoToOtherClients(msg, channel);
}
}catch (IOException e) {
try {
System.out.println(channel.getRemoteAddress() + " 離線了..");
//取消註冊
key.cancel();
//關閉通道
channel.close();
}catch (IOException e2) {
e2.printStackTrace();;
}
}
}
//轉發消息給其它客戶(通道)
private void sendInfoToOtherClients(String msg, SocketChannel self ) throws IOException{
System.out.println("服務器轉發消息中...");
System.out.println("服務器轉發數據給客戶端線程: " + Thread.currentThread().getName());
//遍歷 所有註冊到selector 上的 SocketChannel,並排除 self
for(SelectionKey key: selector.keys()) {
//通過 key 取出對應的 SocketChannel
Channel targetChannel = key.channel();
//排除自己
if(targetChannel instanceof SocketChannel && targetChannel != self) {
//轉型
SocketChannel dest = (SocketChannel)targetChannel;
//將msg 存儲到buffer
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
//將buffer 的數據寫入 通道
dest.write(buffer);
}
}
}
public static void main(String[] args) {
//創建服務器對象
GroupChatServer groupChatServer = new GroupChatServer();
groupChatServer.listen();
}
}
//可以寫一個Handler
class MyHandler {
public void readData() {
}
public void sendInfoToOtherClients(){
}
}
客戶端代碼
package com.dpb.netty.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.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;
public class GroupChatClient {
//定義相關的屬性
private final String HOST = "127.0.0.1"; // 服務器的ip
private final int PORT = 6667; //服務器端口
private Selector selector;
private SocketChannel socketChannel;
private String username;
//構造器, 完成初始化工作
public GroupChatClient() throws IOException {
selector = Selector.open();
//連接服務器
socketChannel = socketChannel.open(new InetSocketAddress("127.0.0.1", PORT));
//設置非阻塞
socketChannel.configureBlocking(false);
//將channel 註冊到selector
socketChannel.register(selector, SelectionKey.OP_READ);
//得到username
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username + " is ok...");
}
//向服務器發送消息
public void sendInfo(String info) {
info = username + " 說:" + info;
try {
socketChannel.write(ByteBuffer.wrap(info.getBytes()));
}catch (IOException e) {
e.printStackTrace();
}
}
//讀取從服務器端回覆的消息
public void readInfo() {
try {
int readChannels = selector.select();
if(readChannels > 0) {//有可以用的通道
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if(key.isReadable()) {
//得到相關的通道
SocketChannel sc = (SocketChannel) key.channel();
//得到一個Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
//讀取
sc.read(buffer);
//把讀到的緩衝區的數據轉成字符串
String msg = new String(buffer.array());
System.out.println(msg.trim());
}
}
iterator.remove(); //刪除當前的selectionKey, 防止重複操作
} else {
//System.out.println("沒有可以用的通道...");
}
}catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
//啓動我們客戶端
GroupChatClient chatClient = new GroupChatClient();
//啓動一個線程, 每個3秒,讀取從服務器發送數據
new Thread() {
public void run() {
while (true) {
chatClient.readInfo();
try {
Thread.currentThread().sleep(3000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
//發送數據給服務器端
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String s = scanner.nextLine();
chatClient.sendInfo(s);
}
}
}
效果
七、NIO與零拷貝
零拷貝基本介紹
零拷貝是網絡編程的關鍵,很多性能優化都離不開。
在 Java 程序中,常用的零拷貝有 mmap(內存映射) 和 sendFile。那麼,他們在 OS 裏,到底是怎麼樣的一個的設計?我們分析 mmap 和 sendFile 這兩個零拷貝
另外我們看下NIO 中如何使用零拷貝
傳統IO數據讀寫
Java 傳統 IO 和 網絡編程的一段代碼
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int) file.length()];
raf.read(arr);
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);
DMA: direct
memory access
直接內存拷貝(不使用CPU)
mmap 優化
mmap
通過內存映射,將文件映射到內核緩衝區,同時,用戶空間可以共享內核空間的數據。這樣,在進行網絡傳輸時,就可以減少內核空間到用戶控件的拷貝次數。如下圖
mmap示意圖
sendFile 優化
Linux 2.1 版本 提供了 sendFile 函數,其基本原理如下:數據根本不經過用戶態,直接從內核緩衝區進入到 Socket Buffer,同時,由於和用戶態完全無關,就減少了一次上下文切換
示意圖和小結
提示:零拷貝從操作系統角度,是沒有cpu 拷貝
Linux 在 2.4 版本中,做了一些修改,避免了從內核緩衝區拷貝到 Socket buffer 的操作,直接拷貝到協議棧,從而再一次減少了數據拷貝。具體如下圖和小結:
這裏其實有 一次cpu 拷貝
kernel buffer -> socket buffer
但是,拷貝的信息很少,比如
lenght , offset , 消耗低,可以忽略
零拷貝的再次理解
我們說零拷貝,是從操作系統的角度來說的。因爲內核緩衝區之間,沒有數據是重複的(只有 kernel buffer 有一份數據)。
零拷貝不僅僅帶來更少的數據複製,還能帶來其他的性能優勢,例如更少的上下文切換,更少的 CPU 緩存僞共享以及無 CPU 校驗和計算。
mmap 和 sendFile 的區別
- mmap 適合小數據量讀寫,sendFile 適合大文件傳輸。
- mmap 需要 4 次上下文切換,3 次數據拷貝;sendFile 需要 3 次上下文切換,最少 2 次數據拷貝。
- sendFile 可以利用 DMA 方式,減少 CPU 拷貝,mmap 則不能(必須從內核拷貝到 Socket 緩衝區)。
NIO 零拷貝案例
NewIOServer
package com.dpb.netty.nio.zerocopy;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
//服務器
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
//創建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
int readcount = 0;
while (-1 != readcount) {
try {
readcount = socketChannel.read(byteBuffer);
}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒帶 position = 0 mark 作廢
}
}
}
}
NewIOClient
package com.dpb.netty.nio.zerocopy;
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";
//得到一個文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();
//準備發送
long startTime = System.currentTimeMillis();
//在linux下一個transferTo 方法就可以完成傳輸
//在windows 下 一次調用 transferTo 只能發送8m , 就需要分段傳輸文件, 而且要主要
//傳輸時的位置 =》 課後思考...
//transferTo 底層使用到零拷貝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("發送的總的字節數 =" + transferCount + " 耗時:" + (System.currentTimeMillis() - startTime));
//關閉
fileChannel.close();
}
}
好了本文就介紹到此~