一、阻塞與非阻塞
1.1 阻塞
1.1.1 阻塞模式會存在哪些問題?
1)在阻塞模式下,以下的方法都會導致線程暫停
- ServerSocketChannel.accept 會在沒有連接建立時讓線程暫停
- SocketChannel.read 會在沒有數據可讀時讓線程暫停
- 阻塞的表現其實就是線程暫停了,暫停期間不會佔用 cpu,但線程處於閒置狀態
2)單線程下,阻塞方法之間相互影響,幾乎不能正常工作,需要多線程支持
3)多線程下,有新的問題,體現在以下方面
32 位 jvm 一個線程最大堆棧是 320k,64 位 jvm 一個線程 最大堆棧是1024k,如果連接數過多,必然導致 OOM,並且線程太多,反而會因爲頻繁上下文切換導致性能降低。
可以採用線程池技術來減少線程數和線程上下文切換,但治標不治本,如果有很多連接建立,但長時間 inactive(不活躍),會阻塞線程池中所有線程,因此不適合長連接,只適合短連接
1.1.2 測試代碼:
服務端代碼:
public class BioServerTest {
public static void main(String[] args) throws IOException {
// 使用 nio 來理解阻塞模式, 單線程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 創建了服務器
ServerSocketChannel ssc = ServerSocketChannel.open();
// 2. 綁定監聽端口
ssc.bind(new InetSocketAddress(8080));
// 3. 連接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立與客戶端連接, SocketChannel 用來與客戶端之間通信
System.out.println("connecting...");
// 阻塞方法,線程停止運行
SocketChannel sc = ssc.accept();
System.out.println("connected... " + sc);
channels.add(sc);
for (SocketChannel channel : channels) {
// 5. 接收客戶端發送的數據
System.out.println("before read..."+channel);
try {
// 阻塞方法,線程停止運行
channel.read(buffer);
} catch (IOException e) {
}
buffer.flip();
System.out.println(print(buffer));
buffer.clear();
System.out.println("after read..."+channel);
}
}
}
static String print(ByteBuffer b) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < b.limit(); i++) {
stringBuilder.append((char) b.get(i));
}
return stringBuilder.toString();
}
}
客戶端代碼:
public class SocketClientTest{
public static void main(String[] args) throws Exception {
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
System.out.println("waiting...");
//模擬長連接一直存在
while (true) {
}
}
}
啓動服務端看結果,一直在connecting,此時線程阻塞了:
connecting...
啓動客戶端看結果,此時連接成功,又阻塞到收到消息之前:
connecting...
connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:64800]
before read...java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:64800]
1.2 非阻塞
1.2.1 相比阻塞改變了什麼?
非阻塞模式下,相關方法都不會讓線程暫停
- 在 ServerSocketChannel.accept 在沒有連接建立時,會返回 null,繼續運行
- SocketChannel.read 在沒有數據可讀時,會返回 0,但線程不必阻塞,可以去執行其它 SocketChannel 的 read 或是去執行 ServerSocketChannel.accept
- 寫數據時,線程只是等待數據寫入 Channel 即可,無需等 Channel 通過網絡把數據發送出去
1.2.2 非阻塞模型存在哪些問題?
1)但非阻塞模式下,即使沒有連接建立,和可讀數據,線程仍然在不斷運行,白白浪費了 cpu
2)數據複製過程中,線程實際還是阻塞的(AIO 改進的地方)
1.2.3 測試代碼
服務端代碼:
public class NonIoServerTest {
public static void main(String[] args) throws IOException {
// 使用 nio 來理解非阻塞模式, 單線程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 創建了服務器
ServerSocketChannel ssc = ServerSocketChannel.open();
// 非阻塞模式
ssc.configureBlocking(false);
// 2. 綁定監聽端口
ssc.bind(new InetSocketAddress(8080));
// 3. 連接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立與客戶端連接, SocketChannel 用來與客戶端之間通信
// 非阻塞,線程還會繼續運行,如果沒有連接建立,但sc是null
SocketChannel sc = ssc.accept();
if (sc != null) {
System.out.println("connected... " + sc);
// 非阻塞模式
sc.configureBlocking(false);
channels.add(sc);
}
for (SocketChannel channel : channels) {
// 5. 接收客戶端發送的數據
// 非阻塞,線程仍然會繼續運行,如果沒有讀到數據,read 返回 0
int read = channel.read(buffer);
if (read > 0) {
buffer.flip();
System.out.println(print(buffer));
buffer.clear();
System.out.println("after read..."+channel);
}
}
// 用於查非阻塞狀態,連接客戶端時可關閉,便於觀看結果
System.out.println("wait connecting...");
}
}
static String print(ByteBuffer b) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < b.limit(); i++) {
stringBuilder.append((char) b.get(i));
}
return stringBuilder.toString();
}
}
啓動服務端結果如下,不斷刷新:
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
... ...
服務端與前面測試阻塞時一樣,我們將服務端的System.out.println("wait connecting...");這行代碼註釋掉,方便看結果。
啓動客戶端,看服務端結果:
connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:61254]
二、多路複用
單線程可以配合 Selector 完成對多個 Channel 可讀寫事件的監控,這稱之爲多路複用。
多路複用僅針對網絡 IO,普通文件 IO 沒法利用多路複用
-
如果使用非阻塞模式,而不使用selector,則線程大部分時間都在做無用功,使用Selector 能夠保證以下三點:
- 有可連接事件時纔去連接
- 有可讀事件纔去讀取
- 有可寫事件纔去寫入( 限於網絡傳輸能力,Channel 未必時時可寫,一旦 Channel 可寫,會觸發 Selector 的可寫事件)
2.1 Selector
上述方案的好處:
- 一個線程配合 selector 就可以監控多個 channel 的事件,事件發生線程纔去處理。避免非阻塞模式下所做無用功。
- 讓這個線程能夠被充分利用
- 節約了線程的數量
- 減少了線程上下文切換
2.1.1 如何使用Selector?
如下代碼及註釋描述:
public class SelectorTest {
public static void main(String[] args) throws IOException {
// 創建Selector
Selector selector = Selector.open();
// 綁定channel事件(SelectionKey當中有四種事件:OP_ACCEPT,OP_CONNECT, OP_READ, OP_WRITE)
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
SelectionKey selectionKey = channel.register(selector, SelectionKey.OP_ACCEPT);
// 監聽channel事件,返回值是發生事件的channel數
// 監聽方法1,阻塞直到綁定事件發生
int count1 = selector.select();
// 監聽方法2,阻塞直到綁定事件發生,或是超時(時間單位爲 ms)
int count2 = selector.select(1000);
// 監聽方法3,不會阻塞,也就是不管有沒有事件,立刻返回,自己根據返回值檢查是否有事件
int count3 = selector.selectNow();
}
}
上述代碼當中,在selector進行channel時間監聽時,會發生阻塞,直到時間發生,那麼有哪些情況會使線程變成不阻塞狀態呢?如下所示:
1)事件發生時(SelectionKey當中有四種事件:OP_ACCEPT,OP_CONNECT, OP_READ, OP_WRITE)
- 客戶端發起連接請求,會觸發 accept 事件
- 客戶端發送數據過來,客戶端正常、異常關閉時,都會觸發 read 事件,另外如果發送的數據大於 buffer 緩衝區,會觸發多次讀取事件
- channel 可寫,會觸發 write 事件
- 在 linux 下 nio bug 發生時
2)調用 selector.wakeup()
3)調用 selector.close()
4)selector 所在線程 interrupt
2.2 處理accept事件
服務端如下所示:
public class AcceptEventServerTest {
public static void main(String[] args) {
try {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8080));
System.out.println(channel);
Selector selector = Selector.open();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int count = selector.select();
// int count = selector.selectNow();
System.out.println("select count: " + count);
// if(count <= 0) {
// continue;
// }
// 獲取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍歷所有事件,逐一處理
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 判斷事件類型
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
// 必須處理
SocketChannel sc = c.accept();
System.out.println(sc);
}
// 處理完畢,必須將事件移除
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
客戶端如下:
public class ClientTest {
public static void main(String[] args) {
// accept事件
try (Socket socket = new Socket("localhost", 8080)) {
System.out.println(socket);
// read事件
socket.getOutputStream().write("world".getBytes());
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}
服務端打印結果:
sun.nio.ch.ServerSocketChannelImpl[/0:0:0:0:0:0:0:0:8080]
select count: 1
java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:60469]
上述代碼中服務端註釋掉了使用selector.selectNow()的方法,如果使用該方法,需要自己去判斷返回值是否爲0。
事件發生後,要麼處理,要麼取消(cancel),不能什麼都不做,否則下次該事件仍會觸發,這是因爲 nio 底層使用的是水平觸發。
2.3 處理read事件
此處仍然使用代碼的方式講解,客戶端與前面的客戶端相同,只是此處會同時啓動兩個客戶端,其中發送的內容分別是“hello” 和 “world”。
服務端代碼如下所示:
public class ReadEventServerTest {
public static void main(String[] args) {
try {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8080));
System.out.println(channel);
Selector selector = Selector.open();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int count = selector.select();
System.out.println("select count:" + count);
// 獲取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍歷所有事件,逐一處理
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 判斷事件類型
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
// 必須處理
SocketChannel sc = c.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
System.out.println("連接已建立:" + sc);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(128);
int read = sc.read(buffer);
if (read == -1) {
key.cancel();
sc.close();
} else {
buffer.flip();
System.out.println(print(buffer));
}
}
// 處理完畢,必須將事件移除
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
static String print(ByteBuffer b) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < b.limit(); i++) {
stringBuilder.append((char) b.get(i));
}
return stringBuilder.toString();
}
}
啓動服務端,並先後啓動兩個客戶端,看結果,首先服務端channel自己註冊到selector,客戶端1發送accept事件,服務端接收到後,繼續while循環,監聽到read事件,打印內容爲“hello”,客戶端2步驟相同。
sun.nio.ch.ServerSocketChannelImpl[/0:0:0:0:0:0:0:0:8080]
select count:1
連接已建立:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:61693]
select count:1
hello
select count:1
連接已建立:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:51495]
select count:1
world
注意:最後的iter.remove(),爲什麼要移除?
因爲 select 在事件發生後,就會將相關的 key 放入 selectedKeys 集合,但不會在處理完後從 selectedKeys 集合中移除,需要我們自己編碼刪除。例如
- 第一次觸發了 ssckey 上的 accept 事件,沒有移除 ssckey
- 第二次觸發了 ssckey 上的 read 事件,但這時 selectedKeys 中還有上次的 ssckey ,在處理時因爲沒有真正的 serverSocket 連上了,就會導致空指針異常
上述代碼中cancel有什麼作用?
cancel 會取消註冊在 selector 上的 channel,並從 keys 集合中刪除 key 後續不會再監聽事件
2.3.1 關注消息邊界
首先看如下的代碼是否有問題,客戶端代碼如下:
public class ServerTest {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(9000);
while (true) {
Socket s = ss.accept();
InputStream in = s.getInputStream();
// 每次讀取4個字節
byte[] arr = new byte[4];
while (true) {
int read = in.read(arr);
// 讀取長度是-1,則不讀取了
if (read == -1) {
break;
}
System.out.println(new String(arr, 0, read));
}
}
}
}
服務端代碼如下:
public class ClientTest {
public static void main(String[] args) throws IOException {
Socket max = new Socket("localhost", 9000);
OutputStream out = max.getOutputStream();
out.write("hello".getBytes());
out.write("world".getBytes());
out.write("你好".getBytes());
out.write("世界".getBytes());
max.close();
}
}
結果:
hell
owor
ld�
�好
世�
��
爲什麼會產生上述的問題?
這裏面涉及到消息邊界的問題。消息的長短是不同的,當我們指定相同長度的ByteBuffer去接收消息時,必然存在不同時間段存在很多種情況,如下所示:
由於buffer長度固定,必然存在消息被截斷的情況,那麼如何解決這些問題呢?
1)一種思路是固定消息長度,數據包大小一樣,服務器按預定長度讀取,缺點是浪費帶寬
2)另一種思路是按分隔符拆分,缺點是效率低
3)TLV 格式,即 Type 類型、Length 長度、Value 數據,類型和長度已知的情況下,就可以方便獲取消息大小,分配合適的 buffer,缺點是 buffer 需要提前分配,如果內容過大,則影響 server 吞吐量
- Http 1.1 是 TLV 格式
- Http 2.0 是 LTV 格式
通過面給出的答案都不是最好的解決方案,重點的問題在於如何分配Bytebuffer的大小?
buffer是給一個channel獨立使用的,不能被多個channel共同使用,因爲存在粘包、半包的問題。
buffer的大小又不能太大,如果要支持很大的連接數,同時又設置很大的buffer,則必然需要龐大的內存。
所以我們需要設置一個大小可變的ByteBuffer。
目前有兩種較爲簡單實現方案,其都有其優缺點:
1)預先分配一個較小的buffer,例如4k,如果發現不能裝下全部內容,則創建一個更大的buffer,比如8k,將已寫入的4k拷貝到新分配的8kbuffer,將剩下的內容繼續寫入。
其優點是消息必然是連續的,但是不斷的分配和拷貝,必然會對性能造成較大的影響。
2)使用多個數組的形式組成buffer,當一個數組存不下數據內容,就將剩餘數據放入下一個數組當中。在netty中的CompositeByteBuf類,就是這種方式。
其缺點是數據不連續,需要再次解析整合,優點是解決了上一個方案的造成性能損耗的問題。
2.4 處理write事件
什麼是兩階段策略?
其出現原因有如下兩個:
1)在非阻塞模式下,我們無法保證將buffer中的所有數據全部寫入channel當中,所以我們需要追蹤寫入後的返回值,也就是實際寫入字節的數值。
int write = channel.write(buffer);
2)我們可以使所有的selector監聽channel的可寫事件,每個channel都會有一個key用來跟蹤buffer,這樣會佔用過多的內存。(關於此點不太理解的,下面可以通過代碼來理解)
鑑於以上問題,出現的兩階段策略:
1)當第一次寫入消息時,我們纔將channel註冊到selector
2)如果第一次沒寫完,再次添加寫事件, 檢查 channel 上的可寫事件,如果所有的數據寫完了,就取消 channel 的註冊(不取消則每次都會出現寫事件)。
下面通過代碼的方式演示:
服務端:
public class ServerTest {
public static void main(String[] args) throws IOException {
// 開啓一個服務channel
ServerSocketChannel ssc = ServerSocketChannel.open();
// 設置非阻塞
ssc.configureBlocking(false);
// 綁定端口
ssc.bind(new InetSocketAddress(8080));
//初始化selector
Selector selector = Selector.open();
//註冊服務端到selector
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
//監聽事件,此處會阻塞
selector.select();
//獲取所有事件key,遍歷
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
//不管處理成功與否,移除key
iter.remove();
//如果是建立連接事件
if (key.isAcceptable()) {
//處理accept事件
SocketChannel sc = ssc.accept();
//設置非阻塞
sc.configureBlocking(false);
// 此處是第一階段
//註冊一個read事件到selector
SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ);
// 1. 向客戶端發送內容
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 30000000; i++) {
sb.append("a");
}
//字符串轉buffer
ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
//2. 寫入數據到客戶端channel
int write = sc.write(buffer);
// 3. write 表示實際寫了多少字節
System.out.println("實際寫入字節:" + write);
// 4. 如果有剩餘未讀字節,才需要關注寫事件
// 此處是第二階段
if (buffer.hasRemaining()) {
// 在原有關注事件的基礎上,多關注 寫事件
sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE);
// 把 buffer 作爲附件加入 sckey
sckey.attach(buffer);
}
} else if (key.isWritable()) {
// 檢索key中的附件buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
// 獲取客戶端channel
SocketChannel sc = (SocketChannel) key.channel();
// 根據上次的position繼續寫
int write = sc.write(buffer);
System.out.println("實際寫入字節:" + write);
// 如果寫完了,需要將綁定的附件buffer去掉,並且去掉寫事件
// 如果沒寫完將會繼續while,執行寫事件,知道完成爲止
if (!buffer.hasRemaining()) {
key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);
key.attach(null);
}
}
}
}
}
}
客戶端:
public class ClientTest {
public static void main(String[] args) throws IOException {
// 開啓selector
Selector selector = Selector.open();
// 開啓客戶端channel
SocketChannel sc = SocketChannel.open();
//設置非阻塞
sc.configureBlocking(false);
// 註冊一個連接事件和讀事件
sc.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
//連接到服務端
sc.connect(new InetSocketAddress("localhost", 8080));
int count = 0;
while (true) {
//此處監測事件,阻塞
selector.select();
//獲取時間key集合,並遍歷
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
//無論成功與否,都要移除
iter.remove();
//連接
if (key.isConnectable()) {
System.out.println(sc.finishConnect());
} else if (key.isReadable()) {
//分配內存buffer
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
//讀數據到buffer
count += sc.read(buffer);
//清空buffer
buffer.clear();
//打印總字節數
System.out.println(count);
}
}
}
}
}
分別啓動服務端和客戶端,結果如下:
實際寫入字節:3801059
實際寫入字節:3014633
實際寫入字節:4063201
實際寫入字節:4718556
實際寫入字節:2490349
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:1426522
true
131071
262142
393213
524284
655355
... ...
29753117
29884188
30000000
兄弟們,看到這了就給個讚唄,感謝