Java学习笔记13-BIO阻塞、NIO非阻塞网络编程
BIO阻塞式
BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。它的优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
阻塞(blocking)IO:资源不可用时,IO请求一直阻塞,直到返回结果(有数据或超时)。
非阻塞(non-blocking)IO:资源不可用时,IO请求离开返回,返回数据标识资源不可用。
同步(synchronous)IO:应用阻塞在发送或接收数据的状态,直到数据成功传输或返回失败。
异步(asynchronous)IO:应用发送或接收数据后立即返回,实际处理是异步执行的。
阻塞和非阻塞是获取资源的方式,同步和异步是程序如何处理资源的逻辑设计。
代码中使用的API:ServerSocket#accept、InputStream#read(我们这里用的BufferedReader#readLine)都是阻塞的API。操作系统底层API中,默认Socket操作都是Blocking型,send / recv等接口都是阻塞的。带来的问题:阻塞导致在处理网络I/O时,一个线程只能处理一个网络连接。
下面我们直接上代码感受下BIO服务端与客户端的网络通信~
BIO服务端测试代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @Author: Wenx
* @Description:
* @Date: Created in 2019/11/18 21:02
* @Modified By:
*/
public class BIOServer {
public static void main(String[] args) throws IOException {
ExecutorService executorService = Executors.newCachedThreadPool();
// 服务端在20480端口监听客户端请求的TCP连接
ServerSocket server = new ServerSocket(20480);
System.out.println("Socket服务端-启动");
while (!server.isClosed()) {
// 等待客户端的连接,如果没有获取连接则阻塞
Socket client = server.accept();
System.out.println("接收到客户端连接:" + client);
// 为每个客户端连接开启一个线程
executorService.execute(() -> {
try {
// 获取Socket的输入流,用来接收从客户端发送过来的数据
BufferedReader buf = new BufferedReader(new InputStreamReader(client.getInputStream(), "UTF-8"));
// 获取Socket的输出流,用来向客户端发送数据
PrintStream out = new PrintStream(client.getOutputStream(), false, "UTF-8");
String msg;
// 接收从客户端发送过来的数据
while ((msg = buf.readLine()) != null) {
if (msg.length() == 0) {
out.println("");
break;
// 响应结果 200
//OutputStream outputStream = client.getOutputStream();
//outputStream.write("HTTP/1.1 200 OK\r\n".getBytes());
//outputStream.write("Content-Length: 11\r\n\r\n".getBytes());
//outputStream.write("Hello World".getBytes());
//outputStream.flush();
}
System.out.println(msg);
// 将接收到的字符串前面加上“已收到”,发送到对应的客户端
out.println("已收到:" + msg);
}
out.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
System.out.println("客户端连接关闭:" + client);
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
server.close();
System.out.println("Socket服务端-关闭");
}
}
BIO客户端测试代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.Socket;
import java.net.SocketTimeoutException;
/**
* @Author: Wenx
* @Description:
* @Date: Created in 2019/11/18 21:31
* @Modified By:
*/
public class BIOClient {
public static void main(String[] args) throws IOException {
// 客户端请求与本机在20480端口建立TCP连接
Socket client = new Socket("127.0.0.1", 20480);
client.setSoTimeout(600000);
System.out.println("Socket客户端-启动");
// 获取Socket的输入流,用来接收从服务端发送过来的数据
BufferedReader buf = new BufferedReader(new InputStreamReader(client.getInputStream(), "UTF-8"));
// 获取Socket的输出流,用来发送数据到服务端
PrintStream out = new PrintStream(client.getOutputStream(), false, "UTF-8");
// 获取键盘输入
BufferedReader input = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
new Thread(() -> {
String msg;
// 接收从键盘发送过来的数据
try {
System.out.print("请输入信息:");
while ((msg = input.readLine()) != null) {
out.println(msg);
if (msg.length() == 0) {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
try {
String echo;
// 从服务器端接收数据有个时间限制(系统自设,也可以自己设置),超过了这个时间,便会抛出该异常
while ((echo = buf.readLine()) != null) {
if (echo.length() == 0) {
System.out.println("服务端连接关闭");
break;
}
System.out.println(echo);
System.out.print("请输入信息:");
}
} catch (SocketTimeoutException e) {
System.out.println("Time out, No response");
}
input.close();
if (client != null) {
// 如果构造函数建立起了连接,则关闭套接字,如果没有建立起连接,自然不用关闭
client.close(); // 只关闭socket,其关联的输入输出流也会被关闭
System.out.println("Socket客户端-关闭");
}
}
}
Http协议
Http协议-请求数据包解析
第一部分:请求行,请求类型,资源路径以及HTTP版本。
第二部分:请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
第三部分:空行,请求头部后面的空行是必须的,请求头部和数据主体之间必须有换行。
第四部分:请求数据也叫主体,可以添加任意的数据。这个例子的请求数据为空。
GET / HTTP/1.1
Host: localhost:20010
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: __guid=111872281.1396970361135427000.1571659920085.445
Http协议-响应数据包解析
第一部分:状态行,HTTP版本、状态码、状态消息。
第二部分:响应报头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
第三部分:空行,请求头部后面的空行是必须的,请求头部和数据主体之间必须有换行。
第四部分:响应正文,可以添加任意的数据。这个例子的响应正文为“Hello World”。
HTTP/1.1 200 OK
Content-Length: 11
Hello World
Http协议-响应状态码
1xx(临时响应):表示临时响应并需要请求者继续执行操作的状态代码。
2xx(成功):表示成功处理了请求的状态代码。
3xx(重定向):表示要完成请求,需要进一步操作。通常,这些状态代码用来重定向。
4xx(请求错误):这些状态代码表示请求可能出错,妨碍了服务器的处理。
5xx(服务器错误):这些状态代码表示服务器在尝试处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是请求出错。
NIO非阻塞式
NIO 是 Java 1.4 引入的 java.nio 包,提供了新的 Java IO操作非阻塞API。用意是替代 Java IO 和 Java Networking相关的API,NIO中有三个核心组件:Buffer(缓冲区)、Channel(通道)、Selector(选择器),可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。
Buffer缓冲区
缓冲区本质上是一个可以写入数据的内存块(类似数组),然后可以再次读取。此内存块包含在NIO Buffer对象中,该对象提供了一组方法,可以轻松地使用内存块。相比较直接的对数组的操作,Buffer API更加容易操作和管理。
使用Buffer进行数据写入与读取,需要进行如下四个步骤:
- 将数据写入缓冲区
- 调用buffer.flip(),转换为读取模式
- 缓冲区读取数据
- 调用buffer.clear()或buffer.compact()清除缓冲区
Buffer工作原理
Buffer三个重要属性:
capacity容量:作为一个内存块,Buffer具有一定的固定大小,也称为“容量”。
position位置:写入模式时代表写数据的位置。读取模式时代表读数据的位置。
limit限制:写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量。
写入模式 | 长度为8 | 读取模式 | 长度为8 |
---|---|---|---|
位置0(有数据) | position --> | 位置0(有数据) | |
位置1(有数据) | 位置1(有数据) | ||
位置2(有数据) | 位置2(有数据) | ||
position --> | 位置3(有数据) | limit --> | 位置3(有数据) |
位置4 | 位置4 | ||
位置5 | 位置5 | ||
位置6 | 位置6 | ||
limit / capacity --> | 位置7 | capacity --> | 位置7 |
Buffer测试代码
import java.nio.ByteBuffer;
/**
* @Author: Wenx
* @Description:
* @Date: Created in 2019/11/19 11:33
* @Modified By:
*/
public class BufferDemo {
public static void main(String[] args) {
// 构建一个byte字节缓冲区,容量是4
ByteBuffer byteBuffer = ByteBuffer.allocate(4);
//ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4); // 堆外
// 默认写入模式,查看三个重要的指标
System.out.println(String.format("初始化:position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// 写入3字节的数据
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
// 再看数据
System.out.println(String.format("写入3字节后,position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// 转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
byteBuffer.flip();
System.out.println(String.format("转换为读取模式后,position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// 读取2字节的数据
byte a = byteBuffer.get();
System.out.println(a);
byte b = byteBuffer.get();
System.out.println(b);
System.out.println(String.format("读取2字节数据后,position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// 继续写入3字节,此时读模式下,limit=3,position=2.继续写入只能覆盖写入一条数据
// clear()方法清除整个缓冲区。compact()方法仅清除已阅读的数据。转为写入模式
byteBuffer.compact(); // buffer : 1 , 3
System.out.println(String.format("清除已阅读的数据后,position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// 写入3字节的数据
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
byteBuffer.put((byte) 5);
System.out.println(String.format("再写入3字节后最终的情况,position位置:%s, limit限制:%s capacity容量:%s,", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity()));
// rewind() 重置position为0
// mark() 标记position的位置
// reset() 重置position为上次mark()标记的位置
}
}
ByteBuffer内存类型
ByteBuffer为性能关键性代码提供了直接内存(direct堆外)和非直接内存(heap堆)两种实现。
堆外内存获取的方式:ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(noByteBuffer);
好处:
- 进行网络IO或者文件IO时比HeapByteBuffer少一次拷贝。(file/socket — OS memory — jvm heap)CG会移动对象内存,在写file或socket的过程中,JVM的实现中,会先把数据复制到堆外,再进行写入。
- GC范围之外,降低GC压力,但实现了自动管理。DirectByteBuffer中有一个Cleaner对象(PhantomReference),Cleaner被GC前会执行clean方法,触发DirectByteBuffer中定义的Deallocator。
建议:
- 性能确实可观的时候才去使用;分配给大型、长寿命;(网络传输、文件读写场景)
- 通过虚拟机参数MaxDirectMemorySize现值大小,防止耗尽整个机器的内存。
Channel通道
BIO:代码 --byte[]数据写入–> outputStream -----网络-----> inputStream --read阻塞读取–> 代码
NIO:代码 --> Buffer --缓冲区数据写入通道–> channel -----网络-----> channel --通道数据写入缓冲区–> Buffer–> 代码
Channel的API涵盖了 TCP/UDP 网络和文件IO:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel,和标准IO Stream操作的区别:在一个通道内进行读取和写入,而stream通常是单向的(input或output),可以非阻塞读取和写入通道,通道始终读取和写入缓冲区。
Selector选择器
Selector是一个Java NIO组件,可以检查一个或多个NIO通道,并确定哪些通道已准备好进行读取或写入。实现多个线程可以管理多个通道,从而管理多个网络连接。
一个线程使用Selector监听多个channel的不同事件:四个事件分别对应SelectionKey四个常量。
- Connect连接(SelectionKey.OP_CONNECT)
- Accept准备就绪(SelectionKey.OP_ACCEPT)
- Read读取(SelectionKey.OP_READ)
- Write写入(SelectionKey.OP_WRITE)
实现一个线程处理多个通道的核心概念理解:事件驱动机制。
非阻塞的网络通道下,开发者通过Selector注册对于通道感兴趣的事件类型,线程通过监听事件来出发相应的代码执行。(拓展:更底层是操作系统的多路复用机制)
NIO对比BIO
BIO:客户端 -----连接-----> Acceptor ----------> 线程-n read…write
- 阻塞IO,线程等待时间长
- 一个线程负责一个连接处理
- 线程多且利用率低
NIO:客户端 -----连接-----> Selector事件通知机制 ----------> 线程-n read…write
- 非阻塞IO,线程利用率更高
- 一个线程处理多个连接事件
- 性能更强大
如果你的程序需要支撑大量的连接,使用NIO是最好的方式。
Tomcat8中,已经完全去除BIO相关的网络处理代码,默认采用NIO进行网络处理。
NIO与多线程结合的改进方案
采用Reactor模式进行改进,参照Doug Lea的著名文章《Scalable IO in Java》
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;
/**
* @Author: Wenx
* @Description:
* @Date: Created in 2019/11/19 13:26
* @Modified By:
*/
public class NIOServer {
public static void main(String[] args) throws IOException {
// 1. 创建Socket服务端
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false); // 设置为非阻塞模式
// 2. 构建一个Selector选择器,并且将ServerSocketChannel的accept事件注册绑定到selector选择器上
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
// 3. 绑定端口
server.socket().bind(new InetSocketAddress(20480)); // 绑定端口
System.out.println("Socket服务端-启动");
ByteBuffer readBuff = ByteBuffer.allocate(1024);
ByteBuffer writeBuff;
while (true) {
// 不再轮询通道,改用下面轮询事件的方式。select方法有阻塞效果,直到有事件通知才会有返回
selector.select();
// 获取事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 遍历查询结果e
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
// 被封装的查询结果
SelectionKey key = iterator.next();
// 关注 Accept 和 Read 两个事件
if (key.isAcceptable()) {
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
// 拿到客户端的连接通道,并且将SocketChannel的read事件注册绑定到selector选择器上
SocketChannel client = channel.accept(); // mainReactor 轮询accept
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
System.out.println("接收到客户端连接:" + client.getRemoteAddress());
} else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
try {
readBuff.clear();
while (client.isOpen() && client.read(readBuff) != -1) {
// 长连接情况下,需要手动判断数据有没有读取结束
// 此处做一个简单的判断:超过0字节就认为请求结束了
if (readBuff.position() > 0) break;
}
// 如果没数据了, 则不继续后面的处理
if (readBuff.position() == 0) continue;
readBuff.flip();
byte[] content = new byte[readBuff.limit()];
readBuff.get(content);
System.out.println("已收到:" + new String(content));
System.out.println("来自:" + client.getRemoteAddress());
// TODO 业务操作 数据库 接口调用等等
// 响应结果 200
String response = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"Hello World";
writeBuff = ByteBuffer.wrap(response.getBytes());
while (writeBuff.hasRemaining()) {
client.write(writeBuff);
}
} catch (IOException e) {
// e.printStackTrace();
key.cancel(); // 取消事件订阅
}
}
iterator.remove();
}
selector.selectNow();
}
//channel.close();
//System.out.println("Socket服务端-关闭");
}
}
NIO客户端测试代码
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;
/**
* @Author: Wenx
* @Description:
* @Date: Created in 2019/11/19 13:26
* @Modified By:
*/
public class NIOClient {
public static void main(String[] args) throws IOException {
// 客户端请求与本机在20480端口建立TCP连接
SocketChannel client = SocketChannel.open();
client.configureBlocking(false); // 设置为非阻塞模式
client.connect(new InetSocketAddress("127.0.0.1", 20480));
while (!client.finishConnect()) {
// 没连接上,则一直等待
Thread.yield();
}
System.out.println("Socket客户端-启动");
Scanner scanner = new Scanner(System.in);
System.out.println("请输入:");
// 发送内容
String msg = scanner.nextLine();
ByteBuffer writeBuff = ByteBuffer.wrap(msg.getBytes());
while (writeBuff.hasRemaining()) {
client.write(writeBuff);
}
// 读取响应
System.out.println("收到服务端响应:");
ByteBuffer readBuff = ByteBuffer.allocate(1024);
while (client.isOpen() && client.read(readBuff) != -1) {
// 长连接情况下,需要手动判断数据有没有读取结束
// 此处做一个简单的判断:超过0字节就认为请求结束了
if (readBuff.position() > 0) break;
}
readBuff.flip();
byte[] content = new byte[readBuff.limit()];
readBuff.get(content);
System.out.println("发送数据:" + new String(content));
scanner.close();
client.close();
}
}
小结
NIO为开发者提供了功能丰富及强大的IO处理API,但是在应用于网络应用开发的过程中,直接使用 JDK提供的API,比较繁琐。而且想要将性能进行提升,光有NIO还不够,还需要将多线程技术与之结合起来。
因为网络编程本身的复杂性,以及 JDK API开发的使用难度较高,所以在开源社区中,涌出来很多对 JDK NIO进行封装、增强后的网络编程框架,例如:Netty、Mina等。