基本概念
要理解IO需要知道几个基本的概念:
- 缓存区
- 内核空间和用户空间
- 虚拟内存
- 分页
- 面向文件IO和流IO
- 多工IO
常规的输入/输出以相当冗长又极不对称的方式根据需要的方法堆砌InputStream、Reader类,而且在输入/输出被阻塞的时候只能等待,效率非常低下。
缓存区
一个Buffer对象是固定数量数据的容器,这些数据可以被存储之后用于检索。Buffer是java.nio中缓存区的基类,其中的位置信息如下:
- capacity:缓存区能够容纳的数据元素的最大数量,这个容量在创建的时候设定,并且永远不能改变。
- limit:缓存区第一个不能读或写的元素(缓存区现存元素的计数)。
- position:下一个要写/读的位置。
- mark:标记。
四个位置之间的关系如下:
0 <= mark <= position <= limit <= capacity
需要注意的是,一个Buffer同时承载了读、写两种操作,它不需要关注什么时候进行什么样的操作,但是要保证正确地读写切换。在将"ABCDE"写入缓存之后,状态如下:
当需要从写到读切换时,实际上执行的是filp方法,如下:
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
此时Buffer的属性发生变化,如下:
通道使用缓存区的流程如下:
- 创建一个临时的缓存区
- 将非直接缓存区的内容复制到临时缓存区
- 使用临时缓存区进行低层次的IO操作
- 这个临时缓存区变成要被回收的无用对象
在这个过程中会产生大量对象。在这种情况下,可能直接缓存区(DirectByteBuffer)是不错的选择,因为申请的缓存并不在JVM堆栈中。直接缓存区并不完美,它是基于Unsafe实现,读写操作会涉及到本地方法调用,这个开销也比较大。一个简单的解决办法是修改虚拟机,在堆栈中分出来一块区域,在JVM中,但是这块区域的回收不需要GC管理。
IO可以看做是将字节数据四处传递,但是有时候把数据当成long等类型进行处理会方便很多,这种类型的缓存区叫做视图缓存区:
public CharBuffer asCharBuffer() {
int off = this.position();
int lim = this.limit();
assert (off <= lim);
int rem = (off <= lim ? lim - off : 0);
int size = rem >> 1;
if (!unaligned && ((address + off) % (1 << 1) != 0)) {
return (bigEndian
? (CharBuffer)(new ByteBufferAsCharBufferB(this, -1, 0, size, size, off))
: (CharBuffer)(new ByteBufferAsCharBufferL(this, -1, 0, size, size, off))
} else {
return (nativeByteOrder
? (CharBuffer)(new DirectCharBufferU(this, -1, 0, size, size, off))
: (CharBuffer)(new DirectCharBufferS(this, -1, 0, size, size, off));
}
}
在byte数组向其他类型转换的时候首先考虑到的就是字节顺序(大端 or 小端)。
通道
通道用于在字节缓存区和位于通道另一侧的实体(文件或者套接字)之间有效的传输数据。通道是一种途径,借助这种途径,可以用最小的开销来访问系统本身的IO服务。通道可以看做是线段,缓存区则是线段的端点。
不同的操作系统上通道实现会有根本性的差异,所以通道API仅仅描述可以做什么:
public interface Channel {
public boolean isOpen( );
public void close( ) throws IOException;
}
在读写上看,通道有单向和双向的区别:
如果不能实现从单个通道到多个通道的分发或者从多个通道到单个通道的聚合,那么通道的作用就太单一了。通道提供了Scatter/Gather(矢量I/O)的功能,可以在多个缓存区上实现一个简单的I/O操作:
在使用时会依次读取(或者写入)传入的Buffer数组。下面看具体的通道类型:
文件通道总是阻塞的,因此不能置于非阻塞模式。对于文件I/O最强之处在于异步I/O,它允许一个进程可以从操作系统请求一个或多个I/O操作而不必等待这些操作的完成。发起请求的进程之后会收到它请求的I/O已完成的通知。继承关系如下:
简单的用法如下:
public class Test {
public static void main(String[] args) throws Exception {
File file = new File("D://test.txt");
FileOutputStream fos = new FileOutputStream(file);
FileChannel outputChannel = fos.getChannel();
String str = "hello world.";
ByteBuffer bb = ByteBuffer.wrap(str.getBytes());
outputChannel.write(bb);
}
}
有很多文章比较过内存映射操作文件的方式和用直接操作文件的方式的速度上的区别,下面看下内存映射方法的简单写法,在实际使用的时候需要简单封装:
public class Test {
public static void main(String[] args) throws Exception {
// 创建映射
File file = new File("D://test.txt");
MappedByteBuffer mbf = new RandomAccessFile(file, "rw").getChannel().map(MapMode.READ_WRITE, 0, 1000000);
// 写入数据
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
mbf.put((byte) (i));
}
System.out.println((System.nanoTime() - start) / 1000000 + " MS.");
// 读出数据
mbf.flip();
start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
mbf.get();
}
System.out.println((System.nanoTime() - start) / 1000000 + " MS.");
}
}
写入耗时大致为14MS,读取耗时大约7MS。
由于经常要用到从一个位置将文件数据批量移动到另一个位置,FileChannel添加了一些优化的方法来提高效率:
public abstract class FileChannel{
public abstract long transferTo (long position, long count, WritableByteChannel target);
public abstract long transferFrom (ReadableByteChannel src, long position, long count);
}
这种方式如果有底层操作系统支持的时候,可能不必通过用户空间传递数据而直接进行数据传输,速度可能极其快。
新的socket通道类可以运行非阻塞模式并且是可选择的,提高了程序的可伸缩性和灵活性,再也没有为每个socket连接使用一个线程的必要了,也避免了管理大量线程所需的上下文交换总开销。借助NIO,一个或几个线程就可以管理成百上千的活动socket连接并且只有很少甚至可能没有性能损失:
- DatagramChannel(UDP)
- SocketChannel(TCP)
- ServerSocketChannel
ServerSocketChannel用来等待客户端连接上来,用法如下:
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
// 设置端口号
ServerSocket ss = serverSocketChannel.socket();
ss.bind(new InetSocketAddress(port));
// 设置选择器
selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
之后就可以监听感兴趣的事件有没有发生:
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey key : selectionKeys) {
handle(key);// 处理事件
}
selectionKeys.clear();
而作为客户端,则可以用SocketChannel来连接到服务端:
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(false);
selector = Selector.open();
sc.register(selector, SelectionKey.OP_CONNECT);
sc.connect(new InetSocketAddress("localhost", 7777));
虽然每个SocketChannel对象都会创建一个对等的Socket对象,反过来却不成立,直接创建的Socket对象不会关联SocketChannel对象,他们的getChannel方法只会返回null。