前言
上文不使用第三方工具, 純java搭建web服務(wù)完成了一個web服務(wù),并封裝實現(xiàn)了一個內(nèi)嵌的tomcat,今天在上文基礎(chǔ)上對性能做優(yōu)化和jmeter壓測
阻塞
上文中最終實現(xiàn)的非多線程版本tomcat代碼如下:
public void run() throws IOException {
// 開啟一個socket服務(wù),綁定端口號8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
while (true) {
Socket clientSocket = serverSocket.accept();
try {
// 解析請求信息為HttpRequest對象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根據(jù)path獲取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
continue;
}
// 執(zhí)行業(yè)務(wù)
String data = servlet.service(request);
// 響應(yīng)
HttpResponse response = new HttpResponse(data);
// 返回數(shù)據(jù)
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
clientSocket.getOutputStream().close();
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
邏輯就是一個死循環(huán),調(diào)用serverSocket.accept方法阻塞等待連接,連接成功之后根據(jù)/path調(diào)用對應(yīng)的servlet執(zhí)行對應(yīng)的服務(wù),最后返回結(jié)果
這種寫法顯然有個致命問題:一次只能處理一個請求
下面使用jmeter工具進行壓測試一下,為了效果明顯,我們把Order服務(wù)的執(zhí)行時間加長500ms:
public class UserController implements Servlet {
public String service(HttpRequest request) {
try {
Thread.sleep(500); // 模擬處理時間
} catch (InterruptedException e) {
e.printStackTrace();
}
return "{\"message\": \"user done\"}";
}
}
使用jmeter建立10個線程訪問/user接口,最終結(jié)果如下

結(jié)果吞吐量是2.0/sec,一個請求0.5秒,一個接一個的做,一秒鐘確實只能處理2個請求,相當于每個請求排著隊一個個執(zhí)行,服務(wù)器也只有一個線程在工作,效率肯定是級低的
BIO
BIO模型就是一個請求一個線程,相當于本來一個人干的活分給多個人干,效率必然大大提升
修改tomcat代碼如下:
public void run() throws IOException {
// 開啟一個socket服務(wù),綁定端口號8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
while (true) {
Socket clientSocket = serverSocket.accept();
// 開啟新線程處理請求
new Thread(()->{
try {
// 解析請求信息為HttpRequest對象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根據(jù)path獲取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 執(zhí)行業(yè)務(wù)
String data = servlet.service(request);
// 響應(yīng)
HttpResponse response = new HttpResponse(data);
// 返回數(shù)據(jù)
try {
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
} finally {
clientSocket.getOutputStream().close();
}
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
相當于每次accpet一個請求,就新開一個線程處理業(yè)務(wù),jmeter測試一下

優(yōu)化效果極其顯著,原來每秒處理2個,現(xiàn)在每秒能處理7個請求,線程數(shù)調(diào)整至100

100個請求同時發(fā)起每秒能處理67個請求,并發(fā)量一下就上來了
繼續(xù)調(diào)整請求數(shù)測試結(jié)果如下
| 并發(fā)請求數(shù) | 吞吐量 |
|---|---|
| 10 | 7.1/sec |
| 100 | 67.0/sec |
| 1000 | 666.7/sec |
| 10000 | 1948.6/sec |
| 100000 | 2028.8/sec |
可以看到線程1000以下吞吞吐量基本都是幾何倍增長,但線程過萬后明顯增長不上去了,100000和10000的吞吐量已經(jīng)差不多了
所以并不是請求越多,吞吐量越高,如果線程過多,服務(wù)器線程切換開銷就會很大,這就是著名的c10k問題
線程池
所以BIO這種模式還是要使用線程池進行優(yōu)化,不能肆無忌憚的創(chuàng)建線程,比如說實際場景一般同時并發(fā)請求最多也就100個左右,那就設(shè)個100大小的線程池(真實tomcat默認好像是200),代碼修改如下
public void run() throws IOException {
// 開啟一個socket服務(wù),綁定端口號8888
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("===server start listen 8888===");
// 創(chuàng)建一個線程池
ExecutorService pool = Executors.newFixedThreadPool(100);
while (true) {
Socket clientSocket = serverSocket.accept();
// 線程池處理請求
pool.execute(()->{
try {
// 解析請求信息為HttpRequest對象
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
// 根據(jù)path獲取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 執(zhí)行業(yè)務(wù)
String data = servlet.service(request);
// 響應(yīng)
HttpResponse response = new HttpResponse(data);
// 返回數(shù)據(jù)
try {
clientSocket.getOutputStream().write(response.getBytes());
clientSocket.getOutputStream().flush();
} finally {
clientSocket.getOutputStream().close();
}
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
jmeter測試結(jié)果如下
| 并發(fā)請求數(shù) | 吞吐量 |
|---|---|
| 10 | 7.1/sec |
| 100 | 67.0/sec |
| 1000 | 196.1/sec |
100以內(nèi)和之前完全一樣,100以上明顯降低(等待線程池釋放空閑線程),但線程池可以有效避免c10k,而且可以結(jié)合實際場景設(shè)置線程池大小
NIO
上文介紹過BIO模型的缺點,主要在inputStream的read上(讀取客戶端發(fā)來的數(shù)據(jù)),這個過程服務(wù)端線程是阻塞的,換句話說這段時間線程占用的cpu就干等著,是一種資源浪費(本來線程池干活的線程固定的,還有幾個線程傻等著,效率能高嗎),而NIO模型就是為了解決這個問題
NIO的最大特點是當客戶端連接建立好后,可以注冊可讀取事件,當客戶端數(shù)據(jù)發(fā)送過來后再去執(zhí)行讀操作,而整個過程是不阻塞的
我們繼續(xù)用線程池處理請求,原來是一個連接建立就分一個線程等待數(shù)據(jù)并處理,現(xiàn)在是某個連接數(shù)據(jù)準備好了,才分線程去處理,可預(yù)見在某些情況下這種分配是更合理且高效的
public class NioWebServer {
/**
* 存儲path到服務(wù)的映射
*/
private Map<String, Servlet> servletMap;
/**
* 初始化
*
* @param servletMap
*/
public NioWebServer(Map<String, Servlet> servletMap) {
this.servletMap = servletMap;
}
/**
* 運行tomcat
*
* @throws IOException
*/
public void run() throws IOException {
// 開啟一個socket服務(wù),綁定端口號8888
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(8888));
// 設(shè)置ServerSocketChannel為非阻塞
serverSocket.configureBlocking(false);
// 打開Selector處理Channel,即創(chuàng)建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注冊到selector上,并且selector對客戶端accept連接操作感興趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("===server start listen 8888===");
// 創(chuàng)建一個業(yè)務(wù)處理線程池
ExecutorService pool = Executors.newFixedThreadPool(100);
while (true) {
// 阻塞等待需要處理的事件發(fā)生
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);
// 這里只注冊了讀事件,如果需要給客戶端發(fā)送數(shù)據(jù)可以注冊寫事件
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(10*1024));
} else if (key.isReadable()) { // 如果是OP_READ事件,則進行讀取和處理
key.cancel();
pool.execute(() -> {
try {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
socketChannel.read(buffer);
// 解析請求信息為HttpRequest對象
HttpRequest request = new HttpRequest(new StringReader(new String(buffer.array())));
// 根據(jù)path獲取servlet
Servlet servlet = servletMap.get(request.getPathInfo());
if (servlet == null) {
return;
}
// 執(zhí)行業(yè)務(wù)
String data = servlet.service(request);
// 響應(yīng)
HttpResponse response = new HttpResponse(data);
// 返回
socketChannel.write(ByteBuffer.wrap(response.getBytes()));
// 關(guān)閉連接
socketChannel.close();
} catch (Exception e) {
e.printStackTrace();
}
});
}
//從事件集合里刪除本次處理的key,防止下次select重復(fù)處理
iterator.remove();
}
}
}
}
jmeter測試結(jié)果如下
| 并發(fā)請求數(shù) | 吞吐量 |
|---|---|
| 10 | 7.1/sec |
| 100 | 67.0/sec |
| 1000 | 196.1/sec |
可以發(fā)現(xiàn)和我們的BIO模型基本上性能一樣,沒啥太大區(qū)別,這結(jié)果一度讓我非常費解,完全感受不到NIO的優(yōu)勢在哪里
NIO的優(yōu)勢
仔細的想了一下,結(jié)合代碼,發(fā)現(xiàn)NIO的優(yōu)勢也就在于讀取網(wǎng)絡(luò)IO請求時不阻塞,而我本地測試,一個http請求過來數(shù)據(jù)基本上立刻就到,所以即使阻塞阻塞的時間也微乎其微,基本上就可以忽略了
為了證明,寫了個代碼計時,計算從InputStream轉(zhuǎn)換為Request對象所執(zhí)行的時間
// BIO中
long startTime = System.currentTimeMillis(); //獲取開始時間
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
long endTime = System.currentTimeMillis(); //獲取結(jié)束時間
System.out.println("IO:" + (endTime - startTime) + "ms"); // 輸出
// NIO中
long startTime = System.currentTimeMillis(); //獲取開始時間
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
socketChannel.read(buffer);
// 解析請求信息為HttpRequest對象
String receive = new String(buffer.array());
HttpRequest request = new HttpRequest(new StringReader(receive));
long endTime = System.currentTimeMillis(); //獲取結(jié)束時間
System.out.println("IO:" + (endTime - startTime) + "ms"); //輸出
一個測試請求過來,輸出IO:0ms,基本上證明了幾乎沒有IO阻塞的猜想
為了讓IO阻塞時間明顯起來,首先增加請求體的數(shù)據(jù)

但還是沒啥效果~本地就是快,所以只能放大招,修改jmeter的測試帶寬:
在jmeter.properties中增加
httpclient.socket.http.cps=5472
httpclient.socket.https.cps=5472
限制了帶寬,再次測試
BIO中輸出:IO: 462ms,而NIO中輸出IO:0ms,對比一下就明顯起來了,也就是說BIO處理線程接收到請求需要阻塞將近半秒的時間才能接受數(shù)據(jù),而NIO是等內(nèi)核準備好數(shù)據(jù)后才接受數(shù)據(jù)幾乎不花費線程的時間
再次測試,這次參數(shù)準備如下:
- 測試線程數(shù)修改為1000,Ramp-up時間改為10(如果用1的話后續(xù)BIO的IO時間會減少,這個具體原因暫時還不太清除,可能是jmeter本身的一些優(yōu)化)
- UserController sleep時間設(shè)為1,代表業(yè)務(wù)代碼執(zhí)行1ms結(jié)束
- worker線程數(shù)改小至10,這樣線程容易占滿,方便呈現(xiàn)差異
結(jié)果又失敗了~測試數(shù)據(jù)BIO和NIO依然是沒啥大差異
又仔細想一下,發(fā)現(xiàn)問題~NIO雖然不阻塞線程,但這段數(shù)據(jù)IO的時間一點沒省,只不過內(nèi)核準備好數(shù)據(jù)才通知線程去讀,比如當前帶寬讀取數(shù)據(jù)時間是500ms,BIO是直接分配線程去等,NIO是內(nèi)核等完之后再交給線程去做,所以當前的場景,NIO雖然不需要線程去阻塞,但網(wǎng)絡(luò)IO時間線程也無事可做,等不等效果都一樣
比如現(xiàn)在有個大眾浴池,BIO是來了一個客人就分配一個搓澡工,等著客人洗完澡他就開始給搓,而NIO是等客人洗完過來搓澡的時候再分配搓澡工
所以以上的測試案例就好比一次來了1000個人洗澡,此時NIO是沒有優(yōu)勢的,因為就算剛開始不分配搓澡工,也得等人洗完澡才能開始搓澡
而NIO的優(yōu)勢也明了了,在客人洗澡的時候,搓澡工可以干點別的活,比如掃掃地,而BIO由于提前分配了搓澡工,搓澡工只能干等著客戶洗完澡,這個過程干不了別的活
為了測試這個場景,我開啟兩個jmeter客戶端,一個帶寬限制(IO時間長),一個不限制(IO時間短),兩個客戶端同時發(fā)出1000個請求測試結(jié)果如下(限制帶寬Ramp-up為10,不限制帶寬Ramp-up為1)
- BIO:
| 帶寬 | 吞吐量 | 最大時間 |
| ---- | ---- | ---- |
| 限制 | 74.4/sec | 3486 |
| 不限制 | 732.1/sec | 389 | - NIO:
| 帶寬 | 吞吐量 | 最大時間 |
| ---- | ---- | ---- |
| 限制 | 74.4/sec | 3460 |
| 不限制 | 1002.0/sec | 4 |
可以發(fā)現(xiàn)明顯的差異,NIO的不限制帶寬請求吞吐量1002.0/sec,基本上1秒就全處理完了,最大響應(yīng)時間是4,而BIO吞吐量732.1/sec,最大的請求需要389ms才響應(yīng)
這也證明了以上猜想,NIO由于線程不阻塞,網(wǎng)路IO數(shù)據(jù)準備時可以去處理其他快請求,而BIO由于阻塞及時來了不限制帶寬的請求也不能分出線程去處理,在以上場景下NIO的優(yōu)勢就體現(xiàn)出來了
總結(jié)
NIO對線程的分配相較于BIO肯定更加合理,充分的壓榨了CPU(但這種優(yōu)勢的測試真的很費勁)
就像浴池的例子,生活中一定是有客人洗完澡才分配搓澡工,而不是客人來了就分配一個搓澡工等著他洗完再搓澡,后者客戶量一上來搓澡效率就會很低,所以NIO顯然更接近現(xiàn)實的工作流程,所以也更加合理。NIO這種方式就相當于老板對搓澡工的壓榨,保證大部分時間搓澡工時可用狀態(tài),就好比我們對CPU的壓榨,而正因為這種壓榨,才能在高并發(fā)下處理更多的請求
總結(jié)一下,NIO不會提升單個請求的速度,也不會提高IO效率,只是在讀取IO數(shù)據(jù)時不占用線程,使更多線程可用,在某個請求IO數(shù)據(jù)準備好后會更快的分到線程處理具體業(yè)務(wù)