一、Java IO 和 系统 IO 不匹配
在大多数情况下,Java 应用程序并非真的受着 I/O 的束缚。操作系统并非不能快速传送数据,让 Java 有事可做;相反,是 JVM 自身在 I/O 方面效率欠佳。操作系统与 Java 基于流的 I/O模型有些不匹配。操作系统要移动的是大块数据(缓冲区),这往往是在硬件直接存储器存取(DMA)的协助下完成的。而 JVM 的 I/O 操作类喜欢操作小块数据——单个字节、几行文本。结果,操作系统送来整缓冲区的数据,java.io 包的流数据类再花大量时间把它们拆成小块,往往拷贝一个小块就要往返于几层对象。操作系统喜欢整卡车地运来数据,java.io 类则喜欢一铲子一铲子地加工数据。有了 NIO,就可以轻松地把一卡车数据备份到您能直接使用的地方(ByteBuffer 对象)。
这并不是说使用传统的 I/O 模型无法移动大量数据——当然可以(现在依然可以)。具体地说,RandomAccessFile
类在这方面的效率就不低,只要坚持使用基于数组的read()
和write()
方法。
这些方法与底层操作系统调用相当接近,尽管必须保留至少一份缓冲区拷贝。
为了解决这一问题,java.nio
软件包提供了新的抽象。具体地说,就是 Channel
和Selector
类。
二、 缓冲区
(一)缓冲区操作
缓冲区,以及缓冲区如何工作,是所有 I/O 的基础。所谓“输入/输出”讲的无非就是把数据移进或移出缓冲区。
进程执行 I/O 操作,归结起来,也就是向操作系统发出请求,让它要么把缓冲区里的数据排干(写),要么用数据把缓冲区填满(读)。进程使用这一机制处理所有数据进出操作。操作系统内部处理这一任务的机制,其复杂程度可能超乎想像,但就概念而言,却非常直白易懂。图 1-1 简单描述了数据从外部磁盘向运行中的进程的内存区域移动的过程。进程使用read()
系统调用,要求其缓冲区被填满。内核随即向磁盘控制硬件发出命令,要求其从磁盘读取数据。磁盘控制器把数据直接写入内核内存缓冲区,这一步通过 DMA 完成,无需主CPU协助。一旦磁盘控制器把缓冲区装满,内核即把数据从内核空间的临时缓冲区拷贝到进程执行read()
调用时指定的缓冲区。
图 1-1. I/O 缓冲区操作简图
JVM 就是常规进程,驻守于用户空间。用最重要的是,所有 I/O 都直接或间接通过内核空间。当进程请求 I/O 操作的时候,它执行一个系统调用(有时称为陷阱)将控制权移交给内核。C/C++程序员所熟知的底层函数open()
、read()
、write()
和close()
要做的无非就是建立和执行适当的系统调用。当内核以这种方式被调用,它随即采取任何必要步骤,找到进程所需数据,并把数据传送到用户空间内的指定缓冲区。内核试图对数据进行高速缓存或预读取,因此进程所需数据可能已经在内核空间里了。如果是这样,该数据只需简单地拷贝出来即可。如果数据不在内核空间,则进程被挂起,内核着手把数据读进内存。
为什么不直接让磁盘控制器把数据送到用户空间的缓冲区呢?这样做有几个问题。首先,硬件通常不能直接访问用户空间。其次,像磁盘这样基于块存储的硬件设备操作的是固定大小的数据块,而用户进程请求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间与存储设备的过程中,内核负责数据的分解、再组合工作,因此充当着中间人的角色。
(二) 发散/汇聚
许多操作系统能把组装/分解过程进行得更加高效。根据发散/汇聚的概念,进程只需一个系统调用,就能把一连串缓冲区地址传递给操作系统。然后,内核就可以顺序填充或排干多个缓冲区,读的时候就把数据发散到多个用户空间缓冲区,写的时候再从多个缓冲区把数据汇聚起来(图1-2)。
图 1-2. 三个缓冲区的发散读操作
这样用户进程就不必多次执行系统调用(那样做可能代价不菲),内核也可以优化数据的处理过程,因为它已掌握待传输数据的全部信息。如果系统配有多个 CPU,甚至可以同时填充或排干多个缓冲区。
三、 流I/O
并非所有 I/O 都像前几节讲的是面向块的,也有流 I/O,其原理模仿了通道。I/O 字节流必须顺序存取,常见的例子有TTY(控制台)设备、打印机端口和网络连接。
流的传输一般(也不必然如此)比块设备慢,经常用于间歇性输入。多数操作系统允许把流置于非块模式,这样,进程可以查看流上是否有输入,即便当时没有也不影响它干别的。这样一种能力使得进程可以在有输入的时候进行处理,输入流闲置的时候执行其他功能。
比非块模式再进一步,就是就绪性选择。就绪性选择与非块模式类似(常常就是建立在非块模式之上),但是把查看流是否就绪的任务交给了操作系统。操作系统受命查看一系列流,并提醒进程哪些流已经就绪。这样,仅仅凭借操作系统返回的就绪信息,进程就可以使用相同代码和单一线程,实现多活动流的多路传输。这一技术广泛用于网络服务器领域,用来处理数量庞大的网络连接。就绪性选择在大容量缩放方面是必不可少的。
四、Socket通道
DatagramChannel
和SocketChannel
实现定义读和写功能的接口而ServerSocketChannel
不实现。ServerSocketChannel
负责监听传入的连接和创建新的SocketChannel
对象,它本身从不传输数据。
socket和socket通道之间的关系。之前的章节中有写道,通道是一个连接I/O服务导管并提供与该服务交互的方法。就某个socket而言,它不会再次实现与之对应的socket通道类中的socket协议 API,而java.net
中已经存在的socket通道都可以被大多数协议操作重复使用。
全部socket通道类(DatagramChannel
、SocketChannel
和ServerSocketChannel
)在被实例化时都会创建一个对等socket对象。这些是我们所熟悉的来自java.net
的类(Socket
、ServerSocket
和DatagramSocket
),它们已经被更新以识别通道。对等socket可以通过调用socket()
方法从一个通道上获取。此外,这三个java.net
类现在都有getChannel()
方法。
虽然每个socket通道(在java.nio.channels
包中)都有一个关联的java.net
socket对象,却并非所有的socket都有一个关联的通道。如果您用传统方式(直接实例化)创建了一个Socket对象,它就不会有关联的SocketChannel
并且它的getChannel()
方法将总是返回null
。
五、ServerSocketChannel
让我们从最简单的ServerSocketChannel
来开始对socket通道类的讨论。以下是ServerSocketChannel
的完整 API:
public abstract class ServerSocketChannel extends AbstractSelectableChannel {
public static ServerSocketChannel open() throws IOException
public abstract ServerSocket socket();
public abstract ServerSocket accept() throws IOException;
public final int validOps()
}
ServerSocketChannel
是一个基于通道的socket监听器。它同我们所熟悉的java.net.ServerSocket
执行相同的基本任务,不过它增加了通道语义,因此能够在非阻塞模式下运行。
用静态的open()
工厂方法创建一个新的ServerSocketChannel
对象,将会返回同一个未绑定的java.net.ServerSocket
关联的通道。该对等ServerSocket
可以通过在返回的ServerSocketChannel
上调用socket()
方法来获取。作为ServerSocketChannel
的对等体被创建的ServerSocket
对象依赖通道实现。这些socket关联的SocketImpl
能识别通道。通道不能被封装在随意的socket对象外面。
由于ServerSocketChannel
没有bind()
方法,因此有必要取出对等的socket并使用它来绑定到一个端口以开始监听连接。我们也是使用对等ServerSocket
的API来根据需要设置其他的socket选项。
ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket serverSocket = ssc.socket();
// 监听端口1234
serverSocket.bind (new InetSocketAddress(1234));
同它的对等体java.net.ServerSocket
一样,ServerSocketChannel
也有accept()
方法。一旦您创建了一个ServerSocketChannel
并用对等socket绑定了它,然后您就可以在其中一个上调用accept()
。如果您选择在ServerSocket
上调用accept()
方法,那么它会同任何其他的ServerSocket
表现一样的行为:总是阻塞并返回一个java.net.Socket
对象。如果您选择在ServerSocketChannel
上调用accept()
方法则会返回SocketChannel
类型的对象,返回的对象能够在非阻塞模式下运行。假设系统已经有一个安全管理器(security manager),两种形式的方法调用都执行相同的安全检查。
如果以非阻塞模式被调用,当没有传入连接在等待时,ServerSocketChannel.accept()会立即返回null。正是这种检查连接而不阻塞的能力实现了可伸缩性并降低了复杂性。可选择性也因此得到实现。我们可以使用一个选择器实例来注册一个ServerSocketChannel 对象以实现新连接到达时自动通知的功能。例 3-7 演示了如何使用一个非阻塞的accept()方法:
/*
*例 3-7 使用ServerSocketChannel的非阻塞accept()方法
*/
package com.ronsoft.books.nio.channels;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.net.InetSocketAddress;
/**
* Test nonblocking accept() using ServerSocketChannel.
* Start this program, then "telnet localhost 1234" to
* connect to it.
*
* @author Ron Hitchens (ron@ronsoft.com)
*/
public class ChannelAccept {
public static final String GREETING = "Hello I must be going.\r\n"; public static void main (String [] argv) throws Exception {
int port = 1234; //默认端口
if (argv.length > 0) {
port = Integer.parseInt(argv[0]);
}
ByteBuffer buffer = ByteBuffer.wrap (GREETING.getBytes());
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind (new InetSocketAddress(port));
ssc.configureBlocking(false);
while(true) {
System.out.println ("Waiting for connections");
SocketChannel sc = ssc.accept();
if (sc == null) {
// no connections, snooze a while
Thread.sleep (2000);
} else {
System.out.println ("Incoming connection from: " + sc.socket().getRemoteSocketAddress());
buffer.rewind();
sc.write(buffer);
sc.close();
}
}
}
}
前面列出的最后一个方法validOps()
是同选择器一起使用的。
Reference: