
作者:陌北有棵树,Java人,架构师社区合伙人!
IO(输入/输出)是指内存和外部设备之间复制数据的过程,这里的外部设备包括磁盘、终端、网络。由于在Linux中,所有的一切都被抽象为文件,包括终端设备,Socket,于是在上层共用一套IO操作也是情理之中的事情了。
这里首先对传统IO和NIO在JDK中构成做一个说明:传统的IO相关数据API在java.io,网络编程相关的API在java.net,这个时候我们在进行网络时,需要调用上述两个包中的API。
但在JDK 1.4引入了java.nio,提供了3个新的抽象:Buffer,Channel,Selector,可以理解为整合了java.io和java.net,并且为我们做了一层封装,让我们在网络编程时不需要再去调用io包中的API。关于具体是如何封装的,后面会进行说明。
传统IO
这里主要了解一下IO包的组成,IO流的本质是数据传输,并且流是单向的,所以我们也可以更形象的称呼其为IO流,IO流主要包括字节流,字符流。
那么,我们怎么来理解流这个概念呢?这是一种对数据传输很好的抽象,数据在设备传输的过程,想象成水流、电流这些概念,是不是更好理解了呢?
既然程序的本质是处理数据,而IO流又是对数据流的抽象,那么Java中一定会有处理IO流的方式。
研究如何处理之前,要先来看IO流的分类,从不同的维度有不同的划分方式:
数据类型维度:字节流、字符流
数据流向维度:输入流、输出流
这里只列出了部分IO包中类的继承关系:


NIO
基本概念
关于NIO,这里要掌握的不仅仅是技术上的实现,还有一些设计思想,都是十分值得学习和借鉴的。
首先我们要先把Java的NIO和操作系统的IO模型分开理解,可能有一些初学者会犯这个问题,Java的NIO是在操作系统之上做的封装,与我们在《Unix网络编程》中提到的五种IO模型不是一个东西,但思想上是类似的。本文讨论的范围是在Java的应用层面,截止JVM对操作系统的调用。关于更底层的,在Linux上依赖的是epoll,在Windows下依赖的是icop,这个后续会继续研究。但其实也是应该掌握的知识。
首先说一下Java NIO中新抽象的三个模型:
Buffer:数据容器
Channel:支持批量式IO操作的抽象
Selector:选择器
Buffer(缓冲区):
NIO的Buffer对象包含了一个缓冲区,这个缓冲区实际上就是一个类似于数组的内存块,可以写入和读取数据。之所以提供这样一个对象,封装一组API,让我们更加轻松的使用内存块,传统BIO的写入和读取的都是一个byte[]数组,对于程序编写来说是比较麻烦的
Buffer数据读取写入的步骤:
数据写入缓冲区
调用flip()方法,转换读取模式
从缓冲区读取数据
调用clear()或compact(),清除缓冲区
Buffer的三个属性:
capacity:Buffer的容量
position:分别在写入模式和读取模式时,代表写入和读取的位置
limit:在写入模式时为buffer的容量,在读取模式时为已写入的数据量
为什么Buffer能更方便的操作,其中记录了我们每个操作点。同时ByteBuffer为了提高性能,提供了两种实现:堆内存和直接内存。
使用直接内存的优势:
少一次内存拷贝:在常规文件IO或者网络IO的情况下,需要调用操作系统的API,会先把堆内存的数据复制到堆外,至于为什么要复制一份数据到堆外,我们知道JVM有GC机制,在GC过程中,会移动对象的位置,这样操作系统在读取或写入数据就找不到数据了。但这样一来,显然增加了一次内存拷贝的过程,如果我们直接通过直接内存的方式,省略掉这次非必须的内存拷贝,可以达到优化性能的目的。
创建直接内存的API:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}降低GC压力:如果我们直接使用直接内存(堆外内存)呢?首先申请一块不受GC管理的堆外内存,但需要我们手动回收堆外内存。在
java.nio.DirectByteBuffer中,有一个虚引用的Cleaner对象,执行GC时会触发Deallocator回收内存。private static class Deallocator
implements Runnable
{
......
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
虽然堆外内存好处很多,但还是建议在网络传输,文件读取等大数据量的IO密集操作时才使用,分配一些大文件大数据读取时才使用。同时设置MaxDirectMemorySize避免耗尽整个机器内存。
Channel(通道)
NIO中提供了新的网络操作API - Channel,总结起来有两个目的:简化操作和实现非阻塞操作
包含了TCP/UDP以及文件IO的相关操作:
java.nio.channels.FileChanneljava.nio.channels.DatagramChanneljava.nio.channels.ServerSocketChanneljava.nio.channels.SocketChannel
这里我们主要来看SocketChannel和ServerSocketChannel。
概括来说,可以把SocketChannel和ServerSocketChannel理解为为java.net.Socket和java.net.ServerSocket的升级版。前面说到的java NIO包的两个升级点,都体现在这里,简化了网络操作API,实现了非阻塞操作。
如果对于java.net.Socket和java.net.ServerSocket不太了解,这里做一个简单的总结:Socket代表一个具体的连接;ServerSocket:代表一个端口绑定,端口监听。
对于ServerSocket和ServerSocketChannel在阻塞方式上的不同实现,主要在于accept方法的调整:
ServerSocket中accept方法会一直等待,直到新的连接出现
ServerSocketChannel如果没有新的连接,会立即返回Null,所以需要客户端检查返回是否为Null。针对不同的操作需要增加额外的判断
写操作:需要在一个循环中调用write方法,可能并没有写入内容就返回了。
读操作:可能读取不到任何数据,所以需要根据返回的int值判断读取了多少字节。
Selector(选择器)
有了Buffer和Channel,就可以写出一个NIO的程序了,但是还存在一个问题,我们需要通过循环检查的方式,来判断是否有读取和写入。首先这种循环检查的方式肯定是低效的,另外,在高并发场景下也会存在问题。
于是Java NIO中抽象出了Selector的模型,用来检查一个或多个NIO Channel,能够监听当前通道是否已经可以进行数据读取或写入了。实现了单个线程可以管理多个通道(也就是多个网络连接)。
实现方式:一个线程使用Selector监听多个通道。
java.nio.channels.SelectionKey中四个常量代表四个不同类型的事件,我们可以通过名字来进行识别:
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;
那么Selector是如何实现一个线程处理多个通道的呢?
这里涉及到的设计思想:事件驱动机制开发人员通过Selector注册对于通道感兴趣的事件类型,线程通过监听事件来触发相应的执行代码。具体的实现代码如下:
public class NIOTest extends Thread {
public void run() {
try (Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open()) {// 创建Selector和Channel
serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
serverSocket.configureBlocking(false);
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
doSomething((ServerSocketChannel) key.channel());
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void doSomething(ServerSocketChannel server) throws IOException {
try (SocketChannel client = server.accept()) { client.write(Charset.defaultCharset().encode("Hello world!"));
}
}
}
如何继续优化性能
如果在生产环境,我们使用一个线程去处理所有的连接通道,那么这个线程是不是就会成为了性能瓶颈呢?
下面是Java界的超级大佬Doug Lea在《Scalable IO in Java》提出的一些关于Java NIO的编程建议,首先他总结到,绝大部分网络服务,Web服务也好,分布式服务也好,都有着共同的结构流程:
Read request - 通过IO读取网络中字节流
Decode request - 将字节流转换为对象
Process service - 业务逻辑
Encode reply - 对象转换为字节流
Send reply - 通过IO将字节流发到网络
基于Reactor线程模型 - 单线程版本,下图模型就是上面实现的最基础的版本抽象而来。

为了解决这个问题,首先提出的方案是引入线程池来处理业务逻辑,于是改进为下图中的单Reactor线程模型:

虽然引入了多线程,但从图中我们也可以看出,Reactor仍然是单线程的,所以我们继续改进,将Reactor拆分为MainReactor和SubReactor,MainReactor负责监听连接,SubReactor负责处理accept连接。多Reactor线程模型如下图:

参考:
Scalable IO in Java
长按订阅更多精彩▼

如有收获,点个在看,诚挚感谢