BIO:
阻塞IO,是指线程在访问IO资源的时候,如果资源不存在也会一直等待。使用线程池的BIO的并发能力基本就是跟定义的线程池的大小一致,甚至更糟。
BIO编程主要用的就是java net api和io api,这2种api都是阻塞的
服务端:
public class BIOSocketServer {
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket(8081);
System.out.println("服务器启动成功,端口8081。");
while (!serverSocket.isClosed()) {
Socket request = serverSocket.accept(); //阻塞线程的api
System.out.println("收到新连接:" + request.toString());
try{
System.out.println("开始打印接受信息:");
// 开始io操作
InputStream inputStream = request.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
String msg;
while ((msg = bufferedReader.readLine()) != null) { // 阻塞线程的api
if (msg.length() == 0) {
break;
}
System.out.println(msg);
}
System.out.println("数据来自:" + request.toString());
}catch (IOException e) {
e.printStackTrace();
}finally {
request.close(); // 关闭此次连接
}
}
}
}
客户端:
public class BIOSocketClient {
private static Charset charset = Charset.forName("UTF-8");
public static void main(String[] args) throws IOException {
String url = "localhost";
int port = 8081;
System.out.println("请求连接 "+ url + ":" + port);
Socket socket = new Socket(url, port);
OutputStream out = socket.getOutputStream();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入:");
String msg = scanner.nextLine(); // 阻塞线程io
System.out.println("开始发送数据...");
out.write(msg.getBytes(charset)); // 阻塞线程io
scanner.close();
socket.close();
System.out.println("数据发送完毕。");
}
}
BIO的代码是直接面向底层网络接口的,要想实现应用级的功能是非常麻烦的,相当于自己要设计访问协议(比如请求、断开、状态管理怎样控制),多线程管理等。
NIO:
非阻塞IO,是指线程在访问IO资源的时候,如果资源不存在就会返回一个标识,线程就可以去做别事情而不是傻傻的等。从而可以实现单线程处理多网络连接。
NIO的出现就是为了替代原始的BIO这种直接操作Java Networking和Java IO的方式。
NIO有三个组件:缓冲区(Buffer),通道(Channel),选择器(Selector)。主要思路就是通过缓存来解决线程阻塞的问题,从而解放线程提高效率。
缓冲区 Buffer:
Buffer本质就是一个内存块,我们可以通过相关api方便的存取数据。
Buffer的3个主要属性:
1)容量 capacity:代表这个内存块的固定大小。
2)位置 position:写入模式代表写入的位置;读取模式代表读取的位置。
3)限制 limit:写入模式等价于容量大小;读取模式等价于之前写入的数据量。
buffer的2种内存:
1)Direct内存,也叫堆外内存:
特点是直接跟操作系统交互,IO操作的时候相比堆内存少了一次copy,速度更快(不经过jvm堆,也不受GC整理影响)。
虽然GC不能直接回收对外内存,但是jvm帮我们实现好了方法,DirectByteBuffer中有个cleaner对象,通过虚引用关联着堆外内存,GC的时候是可以回收DirectByteBuffer对象的,cleaner对象被回收之前会执行clean方法,调用DirectByteBuffer内定义好的回收堆外内存的方法。
堆外内存适合性能要求较高,频繁使用并且占用空间较大的情况。
2)非直接内存,也叫堆内存:特点是直接由JVM管理,适用于绝大多数场景。
通道 Channel:
channel是nio包封装的对象,用于简化网络开发。相比bio需要socket和io2套api去操作,使用nio网络读写只需要使用channel一套api。
nio最大的特点是支持非阻塞,也因为这个特点,nio的io操作需要在循环中判断执行(因为调用的时候不见得就立即会执行,所以需要不停的判断)。
选择器 Selector:
可以看到,以上nio程序里有各种网络状态相关的循环和判断。先不讨论写法,这种单线程不停循环的方式在处理高并发场景下效率也是低下的,因为会有很多无效的执行。
selector使用了事件驱动机制,底层原理是操作系统的多路复用机制。他可以监听多个channel的网络状态,状态改变就会触发相关事件回调,实现了单线程管理多channel。
selector监听的的事件:
1)SelectionKey.OP_CONNECT: connect连接
2)SelectionKey.OP_ACCEPT: accept准备就绪
3)SelectionKey.OP_READ: read读
4)SelectionKey.OP_WRITE: write写
public void testChannelWithSelector () throws IOException {
// 1.创建服务端channel
int port = 8081;
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 设置非阻塞
// 2.使用selector选择器注册channel
Selector selector = Selector.open();
SelectionKey selectionKey = serverSocketChannel.register(selector, 0, serverSocketChannel);
// 表示对sserverSocketChannel上的accept事件关注,serverSocketChannel只能注册accept事件。
selectionKey.interestOps(selectionKey.OP_ACCEPT);
// 3。绑定端口,开启通道
serverSocketChannel.socket().bind(new InetSocketAddress(port));
// 循环检查selector
while(true) {
selector.select(); // 这是一个阻塞方法,有事件通知才会执行,因为只注册了accept事件,所以有新连接才会往下执行
// 获取事件
Set selectionKeys = selector.selectedKeys();
// 使用迭代器遍历
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
// 处理accept事件,受益于事件机制,这里服务端accept一定会拿到socketChannel
// 同时注册read事件,方便后续读数据
if (key.isAcceptable()) {
// 先拿到当前ServerSocketChannel
ServerSocketChannel server = (ServerSocketChannel) key.attachment();
// 获取socketChannel
SocketChannel clientChannel = server.accept();
clientChannel.configureBlocking(false);
// 使用selector注册read事件
clientChannel.register(selector, SelectionKey.OP_READ, clientChannel);
System.out.println("收到新连接:"+clientChannel.getRemoteAddress());
}
// 处理read事件
if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.attachment();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(byteBuffer) != -1) {
// TODO 长连接情况下需要手动判断是否读结束,此处暂时简单处理
if (byteBuffer.position() > 0) {
break;
}
}
// 如果读不到数据则跳出此次事件处理
if (byteBuffer.position() == 0) continue;
// 缓存读模式
byteBuffer.flip();
byte[] content = new byte[byteBuffer.limit()];
byteBuffer.get(content);
System.out.println("收到"+socketChannel.getRemoteAddress()+"数据:" + new String(content));
// 模拟响应
String response = "HTTP/1.1 200 OK\r\n"+
"Content-Length: 1\r\n\r\n"+
"Hello World";
ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes());
while (responseBuffer.hasRemaining()) {
socketChannel.write(responseBuffer); // 非阻塞写入通道
}
}
}
}
}
上述代码最大特点就是使用循环检查selector事件监听代替了循环accept,利用事件驱动机制提高了执行效率;同时读取请求数据的逻辑也使用了selector进行了调整,优化了实现。
NIO 进一步改进:
以上的NIO的写法是基于单线程的,能尽量提高单线程的利用率。但是俗话说”双拳难敌四手“,面对现实生产场景中的海量并发,一个线程再怎么优化,执行效率也是有瓶颈的,而且目前的cpu大多都是多核,只是单线程也不能充分利用硬件资源。解决这个问题的方案就是大名鼎鼎的React设计模式。
Dog Lea 的React设计思想:React的设计思路主要是在NIO(非阻塞)的基础上使用发布订阅模式进一步优化了网络连接(accept)、网络IO(read,send),并且把业务线程和网络线程隔离开,各自分工,尽可能放大各环节的效率,尽量保证系统的网络并发能力。
打个比方,一个顾客要去逛商城,开门,导购,买卖交易如果始终是一个人在服务的话,这个商城也接纳不了几个顾客。各自分工专业,各个环节都变的效率了,整体效率才会提高。
下图的重要的几个环节是:
1)mainReactor:接收线程组,专注连接接收,并且通过注册分发给网络io线程组处理。
2)subReactor:网络io线程组,专注处理网络io,同时把网络数据注册给工作线程组处理
3)workerThreads:工作线程组,专注处理业务。
参考文章:
ByteBuffer常用操作
NIO技术1
NIO技术2