前言
我从大二开始学习Java,一直偏重于J2EE领域,写多了SSH、SSM代码之后,Java让我失去了新鲜感,以为调调接口就完事了。笔者一度开始拥抱Go语言,直到我知道“JAVA NIO”这回事,才发现,JAVA能做的有很多。比如在多线程(java.util.concurrent)及网络领域(java.nio),老树开新花。
io即输入输出,输入输出的源头与目的地主要是网络和文件,我们先从比较简单的文件IO说起。
文件IO
以读取文件为例,传统bio与nio示例代如下:
// FileInputStream或BufferedInputStream
Byte[] b = new byte[1024]; // 开启1m的缓冲区
while(in.read(b) != -1){
Xxx
}
# nio方式
FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
fc.read(buffer);
传统的io代码,这个缓冲区(byte数组)是程序员约定俗成的行为。在java nio中,这个缓冲区被固定下来,数据直接被读取到缓冲区中。同时,nio使用Channel替代了Stream,前者是双向的,后者是单向的。在jdk较新版本中,传统io类实际是用nio类实现的。
实际上,内核在读取java文件时,会将文件的部分内容拷贝到缓冲区块中。进行网络通信时,网络数据会最先到达tcp接收缓冲区中。Channel用于在字节缓冲区和位于通道另一侧实体(文件缓冲块或网络套接字)的字节缓冲区之间有效地传输数据。使用buffer则使read()和write()调用得到了极大的简化,因为许多工作细节(比如读写位置的维护和数据的结构化访问readInt和writeInt等)都由缓冲区完成了。clear()和flip()方法用于让缓冲区在读和写之间切换。
Linux知识的一些补充
Linux将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor。而对一个socket的读写也会有相应的descriptor,描述符就是一个数字,它指向内核中的一个结构体。
从早期的Linux内核实现看,有一个数组,数组项是一个结构体,包含了文件的地址信息、文件在内存中的缓冲块(一般对应着一个磁盘块,缓冲块也有一个数组负责组织)信息,这个数组项在数组中的索引就是fd。文件缓冲块暂存了一部分文件数据,有专门的内核数据结构和代码进行管理,因此要对文件数据进行处理,需要将数据从这里拷贝到用户空间(用户空间没有权限访问缓冲块中的数据),否则会影响内核空间文件缓冲块的分配与回收。
强烈建议阅读下本文:https://www.ibm.com/developerworks/cn/linux/l-cn-read/
,其中开篇的一句:“Linux 系统调用(SCI,system call interface)的实现机制实际上是一个多路汇聚以及分解的过程,该汇聚点就是 0x80 中断这个入口点(X86 系统结构)。也就是说,所有系统调用都从用户空间中汇聚到 0x80 中断点,同时保存具体的系统调用号。当 0x80 中断处理程序运行时,将根据系统调用号对不同的系统调用分别处理(调用不同的内核函数处理)。”读了之后,真是令人陶醉。
网络IO
在阻塞IO模式下,从网络中读取数据的过程是
InputeStream in = socket.getInputStream();
in.read();
这相当于用户线程主动去查询是否收到数据,读写事件的发起者是用户线程,内核准备好数据后,数据的处理者也是用户线程。
阻塞的点
以一个web服务器为例,最简单的例子是这样的
class SingleThreadWebServer{
public static void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket();
while(true){
Socket connection = socket.accept(); //看到这个变量名,我好像明白,为什么叫"连接"池了
handleRequest(connection);
}
}
}
上述代码有两个阻塞的点, socket.accept()
和connection.read()
,很明显一个线程忙不过来,so
class ThreadPerTaskWebServer{
public static void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket();
while(true){
final Socket connection = socket.accept();
Runnable task = new Runnable(){
public void run(){
handleRequest(connection);
}
}
// 启动一个worker线程
new Thread(task).start();
}
}
}
如果请求过多,这种方式会无限制创建线程,我们可以使用Executor来执行task。但共同点都是,一个worker线程处理一个connection。主线程(boss线程)会阻塞在socket.accept()
上,worker线程会阻塞在connection.read()
上。
使用nio方式
public class NIOServer{
public static void main(String[] args) throws IOException{
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(80));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while(true){
selector.select(1000);
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> it = selectedKeys.iterator();
SelectionKey key = null;
while (it.hasNext()) {
key = it.next();
it.remove();
handleInput(key);
}
}
}
public static void handleInput(SelectionKey key) throws IOException{
if(key.isAcceptable()) {
// Accept the new connection
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
// Add the new connection to the selector
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
// handle buffer
}
}
}
主线程(boss线程)只有一个阻塞的点selector.select(1000)
(此处设置了超时时间),如果使用worker线程处理readable SelectionKey,worker线程不会被阻塞。
reactor 模式
在《Apache Kafka源码剖析》中有一句不起眼的话:Java NIO 提供了实现Reactor 模式的API。
Wikipedia上说:“The reactor design pattern is an event handling pattern for handling service requests delivered concurrently by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to associated request handlers.” 这里有几个关键
- reactor 模式是一个 event handling pattern
- 有一个 event demultiplexor 实现 事件分发的功能
从中可以体会到:学习java nio,首先要以 事件驱动 的思维来理解 io 读写过程,尤其是对习惯了 java bio api的同学,socket.read
一个操作对象、一个api 完成了整个通信过程,java nio 是多个操作对象、多个api 协作完成的。
java io涉及到的一些linux知识当一个read操作发生时,它会经历两个阶段:
- 等待数据准备 (Waiting for the data to be ready)
- 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
不管是阻塞、非阻塞、多路复用io,第一阶段都是用户进程主动去发现socket send/receive buffer是否ready,区别只是盲目轮询还是得到通知去轮询,第二阶段都是要阻塞。而异步io则是内核主动向用户进程发起通知的,第一和第二个阶段都不会阻塞。
所谓事件驱动,说的是java nio 中,read/write只是 对 OP_READ 和 OP_WRITE 事件的处理,并不包揽 OP_READ/OP_WRITE 事件的探测。