JavaNIO快速入门

NIO是Jdk中非常重要的一个组成部分,基于它的Netty开源框架可以很方便的开发高性能、高可靠性的网络服务器和客户端程序。本文将就其核心基础类型Channel, Buffer, Selector进行详细介绍,之后将介绍内存映射文件,Scatter/Gatter等扩展知识,最后将对Linux的5种IO模型进行剖析。

基础概念

Java NIO(non-blocking IO非阻塞IO)是jdk1.4后提供的新IO API,为所有基础类型都提供类缓存支持。其基础类型Channel定义了一个新的I/O接口,支持锁和访问内存映射文件,提供多路非阻塞式的高伸缩性网络I/O,其与传统IO的区别如下所示。

  1. Channels and Buffers(通道和缓冲区):标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
  2. Asynchronous IO(异步IO):Java NIO可以让你异步的使用IO,例如:当线程从通道读取数据到缓冲区时,线程还是可以进行其他事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。
  3. Selectors(选择器):Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道。

核心理解
NIO中的SocketChannel等网络Channel充分利用了操作系统内核对象的调度,只使用少量的几个线程来处理和客户端的所有通信,消除了无谓的线程上下文切换,最大限度的提高了网络通信的性能。可以看到,NIO主要关注文件IO和基于传输层的网络传输IO。对于.NET程序员来说,Windows的完成端口模型和NIO有一些相似之处,具体的IO模型分析请见本文最后部分的五种IO模型
使用场景
对于比较时髦的物联网公司,需要收集设备的数据,通常需要自定义相关协议并基于传输层通信。

Channel

Channel通道和传统IO中的Stream流类似,但它是双向的,而流式单向的,如InputStreamOutputStream等,Channel的常见实现如下所示。
FileChannel: 从文件中读写数据。
DatagramChannel: 能通过UDP读写网络中的数据。
SocketChannel: 能通过TCP读写网络中的数据。
ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。
接下里通过一个TCP客户端的NIO实现来熟悉Channel的应用。

public class TCPClient {
	public void start() {
		ByteBuffer buffer = ByteBuffer.allocate(1024);
		try (SocketChannel channel = SocketChannel.open()) {
			channel.configureBlocking(false);// 在非阻塞式信道上调用一个方法总是会立即返回
			channel.connect(new InetSocketAddress("127.0.0.1", 8080));

			if (channel.finishConnect()) {
				int i = 0;
				while (true) {
					TimeUnit.SECONDS.sleep(1);
					String info = "I'm  " + (i++) + "-th information from client";
					buffer.clear();
					buffer.put(info.getBytes());
					buffer.flip();
					while (buffer.hasRemaining()) {
						// 问题,这样一个个直接的输出,速度快么?
						System.out.println(buffer);
						channel.write(buffer);
					}
				}
			}
		} catch (Throwable ex) {
			ex.printStackTrace();
		}
	}
}

比对一下传统的IO

try (RandomAccessFile aFile = new RandomAccessFile("src/test/resources/test.xml", "r")) {
	// 1.读数据
	byte[] buffer = new byte[1024];
	StringBuilder requestString = new StringBuilder();
	int bytesRead = 0;
	while (-1 != (bytesRead = aFile.read(buffer))) {
		requestString.append(new String(buffer), 0, bytesRead);
	}
}

Buffer

NIO中主要的Buffer缓存实现包括ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer等,分别对应相应的基础类型,Buffer的主要作用请见下图。

Buffer的数据结构包括一个连续数组,表示缓冲区数组的总长度的capacity,记录下一个要操作的数据元素的位置position,以及limit、mark等字段,接下来请见一个最简单的NIO示例(其中使用到了文件锁和CharBuffer)。

public static void readFile() {
	long timeBegin = System.currentTimeMillis();
	try (RandomAccessFile aFile = new RandomAccessFile("src/nio.txt", "rw")) {
		FileChannel fileChannel = aFile.getChannel();
		ByteBuffer buf = ByteBuffer.allocate(1024);
		Charset charset = Charset.forName("UTF-8");

		int position = 0;
		while (true) {
			// 文件锁,锁一段区域,shard为true表示共享锁
			FileLock fileLock = fileChannel.lock(position, 1024, true);
			int bytesRead = fileChannel.read(buf);
			fileLock.release();
			if (-1 == bytesRead)
				break;
			position = position + bytesRead;
			buf.flip();
			//将ByteBuffer转化为CharBuffer
			System.out.println(charset.decode(buf));
			buf.clear();
		}
	} catch (Exception e) {
		e.printStackTrace();
	}
	long timeEnd = System.currentTimeMillis();
	System.out.println("Read time: " + (timeEnd - timeBegin) + "ms");
}

Selector

Selector允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。例如,在一个聊天服务器中,要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,如新连接进来,数据接收等。仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好,接下来通过TCP服务端的NIO实现来熟悉Selector的应用。

public class TCPServer {
	private static final int BUF_SIZE = 1024;
	private static final int PORT = 8080;
	private static final int TIMEOUT = 3000;
	private TCPServer() {
	}
	public static final TCPServer instance = new TCPServer();
	public static TCPServer getInstance() {
		return instance;
	}

	/*
	 * 关键类
	 */
	public void selector() {
		try (Selector selector = Selector.open(); ServerSocketChannel ssChannel = ServerSocketChannel.open()) {
			ssChannel.bind(new InetSocketAddress(PORT));
			ssChannel.configureBlocking(false);
			ssChannel.register(selector, SelectionKey.OP_ACCEPT);

			while (true) {
				if (0 == selector.select(TIMEOUT)) {
					System.out.println("==");
					continue;
				}
				// 使用 for (SelectionKey key : selector.selectedKeys()) 方式无法移除对象
				// Iterator对象的remove方法是迭代过程中删除元素的唯一方法
				Iterator<SelectionKey> selectKeys = selector.selectedKeys().iterator();
				while (selectKeys.hasNext()) {
					SelectionKey key = selectKeys.next();
					//分别处理接受、连接、读、写4中状态
					if (key.isAcceptable()) {
						handleAccept(key);
					}
					if (key.isReadable()) {
						handleRead(key);
					}
					if (key.isWritable() && key.isValid()) {
						handleWrite(key);
					}
					if (key.isConnectable()) {
						System.out.println("isConnectable = true");
					}
					selectKeys.remove();
				}
			}
		} catch (Throwable ex) {
			ex.printStackTrace();
		}
	}

	public void handleAccept(SelectionKey key) throws IOException {
		ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();// 获取ServerSocket的Channel
		SocketChannel sc = ssChannel.accept();// 监听新进来的链接
		sc.configureBlocking(false);// 设置client链接为非阻塞
		sc.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(BUF_SIZE));// 将channel注册Selector
	}

	public void handleRead(SelectionKey key) throws IOException {
		SocketChannel channel = (SocketChannel) key.channel();// 获取链接client的channel
		ByteBuffer buf = (ByteBuffer) key.attachment();// 获取附加在key上的数据
		long bytesRead = channel.read(buf);// 读channel上数据到buf?这部分表达的是否正确?
		while (bytesRead > 0) {
			buf.flip();
			while (buf.hasRemaining()) {
				System.out.println(buf.getChar());
			}
			System.out.println();
			buf.clear();
			bytesRead = channel.read(buf);
		}
		if (-1 == bytesRead) {
			channel.close();
		}
	}

	public void handleWrite(SelectionKey key) throws IOException {
		ByteBuffer buf = (ByteBuffer) key.attachment();// 获取buffer对象
		buf.flip();
		SocketChannel channel = (SocketChannel) key.channel();
		while (buf.hasRemaining()) {
			channel.write(buf);
		}
		buf.compact();
	}
}

进阶概念

Java IO与NIO的区别主要包括如下的3个方面。
面向流和面向缓冲:Java IO是面向流的,其意味着在读取所有字节前,数据未被缓存到任何地方,其不能前后移动流中的数据。而NIO是面向缓存的,可以方便在缓存区的移动数据。
阻塞和非阻塞IO:java IO是完全阻塞的,在数据读取或写入完成前,该线程一直被占用。而NIO的非阻塞模式,当线程获取不到数据时,可以被释放用于其他工作。
选择器Selector:其允许一个单独的线程来监视多个出入通道,利于线程的高效利用。

内存映射文件

对于内存映射文件,大家应该不会陌生,大学里学习windows网络编程时就终点介绍过该概念。在JAVA中,一般用BufferedReaderBufferedInputStream的来处理大文件。而当文件超大时推荐使用MappedByteBuffer,其是NIO引入的文件内存映射方案,读写性能极高。一般操作系统的内存包括物理内存和虚拟内存。其中虚拟内存一般使用的是页面映像文件,即硬盘中的某个特殊的文件。操作系统负责该页面文件内容的读写,这个过程叫”页面中断/切换”MappedByteBuffer与其类似,可以看做一个超级大的ByteBuffer,示例如下所示。

public class MappedByteBufferDemo {
	public static void commonReadBigFile() {
		try (RandomAccessFile aFile = new RandomAccessFile("D:/svn.rar", "rw");
				FileChannel fileChannel = aFile.getChannel()) {

			long timeBegin = System.currentTimeMillis();
			ByteBuffer buff = ByteBuffer.allocate((int) aFile.length());
			buff.clear();
			fileChannel.read(buff);
			long timeEnd = System.currentTimeMillis();
			System.out.println("Read time: " + (timeEnd - timeBegin) + "ms");
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	public static void highPerformanceReadBigFile() {
		try (RandomAccessFile aFile = new RandomAccessFile("D:/svn.rar", "rw");
				FileChannel fileChannel = aFile.getChannel()) {

			long timeBegin = System.currentTimeMillis();
			MappedByteBuffer mappedBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, aFile.length());
			long timeEnd = System.currentTimeMillis();
			System.out.println("Read time: " + (timeEnd - timeBegin) + "ms");
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

通过一个1G的文件做单元测试测试,普通的Buffer读需要1373ms,而内存映射文件只需要1ms,这个测试可能不太准确,但差异已经无比明显。当然使用内存映射文件也存在问题,就是它只要GC时才能被回收,会占用大量内存资源,我所了解的一个使用场景涉及复杂的推荐算法因素和规则的加载(国际机票组合选择),之后进行运算。

其他

Pipe:之前介绍的Channel虽然既可以用作读,也可以用作写,但它每次只能支持一种方式的通讯,即单工/半双工的。而Pipe是全双工的,其内部包含两个Channel,一个是SinkChannel用于写入数据,一个Source通道用于读取数据。
Scatter/Gatter:前者在读操作时将数据从一个Channel中读入到多个Buffer,而后者用于将多个Buffer写入一个Channel,常见的使用场景为将消息头和消息体一起写入到Channel中。
TransferFrom & TransferTo:传输方法用于通道之间的通信,比如FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中,而transferTo()方法将数据从FileChannel传输到其他的channel中。
Java的Path接口是Java NIO2 的一部分,是对Java6 和Java7的 NIO的更新。Java的Path接口在Java7 中被添加到Java NIO,位于java.nio.file包中, 其全路径是java.nio.file.Path。一个Path实例代表了一个文件系统中的路径。一个路径可以指向一个文件或者一个文件夹。一个路径可以是绝对路径或者是相对路径。绝对路径是从根路径开始的全路径,相对路径是一个相对其他路径的文件或文件夹路径。相对路径可能会造成一点混乱,但是不要担心,在本文章中,我会详细解释相对路径的。
DatagramChannel:用于基于UDP协议,用于发送和接受数据包,常用于QQ语音、视频等通讯质量要求不高的场景。

NIO2

Jdk7对NIO做了一些改进,包括对AIO的支持,以及增强型的文件操作类。过去的java.io.File访问文件系统时,无法利用特定文件系统的特性,且性能不高,因此引入了Path,Paths,Files等。对于AIO,其提供了AsynchronousChannelAsynchronousFileChannel等与NIO响应的类型,大大简化了异步IO操作,比如在过去我们需要自己管理线程池来进行Callable的调用返回Future<?>,而现在省去了该步骤,请见下面的示例(该场景下直接使用NIO更合适,AIO适合更加复杂的场景)。

public static void readFile() {
	Path filePath = Paths.get("src/nio.txt");

	long timeBegin = System.currentTimeMillis();
	try (AsynchronousFileChannel afc = AsynchronousFileChannel.open(filePath)) {
		ByteBuffer buf = ByteBuffer.allocate(1024);
		Charset charset = Charset.forName("UTF-8");
		
		int position = 0;
		for (;;) {
			Future<Integer> futureResult = afc.read(buf, position);
			int result = futureResult.get();

			if (-1 == result)
				break;

			position = position + result;
			buf.flip();
			System.out.println(charset.decode(buf));
			buf.clear();
		}
	} catch (Exception e) {
		e.printStackTrace();
	}
	long timeEnd = System.currentTimeMillis();
	System.out.println("Read time: " + (timeEnd - timeBegin) + "ms");
}

Linux的五种IO模型

通常来说,网络IO模型大致包括如下2大类,其中同步模型包括4种。
1. 同步模型
a.阻塞IO:简称bio,linux中默认所有的socket都是blocking,其特点是进程会一直阻塞,直到数据拷贝完成。
b.非阻塞IO:简称nio,与本文的NIO(New IO)不是一个概念,其特点是进程会反复调用IO函数,并马上返回,但在数据拷贝的过程中,进行仍然是阻塞的。
c.多路复用IO:由于NIO需要大量的轮训会消耗大量CPU时间,而如果这件事有操作内核来通知就好了,这是就会用到Linux的selectpollepoll函数。比如select,其可以对多个IO端口进行监控。
d.信号驱动IO:允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。
2. 异步模型(aio):相对于同步IO,异步IO不是顺序执行。其特点是IO的两个阶段,进程都是非阻塞的。

小结
以上对Linux的IO模型进行了介绍,对应到java程序,那么io包中的操作其实就是阻塞IO的方式,而nio包中的Channel的类型就是非阻塞IO的方式,Selector提供了多路复用的方式,而AsynchronousChannel提供了异步IO的方式。此外,这几种IO模型并没有谁优谁劣的说法,需要结合具体场景进行分析,通常来说,高并发的程序会使用同步非阻塞的方式。如果感觉解释的不是很完善,请见博文聊聊Linux 五种IO模型,该博主通过和女友等餐的过程对5种IO的过程做了很好的诠释,大赞。
Tip:
之后的文章中将对Netty,mina框架进行介绍,其是NIO的封装库。

参考资料
netty官网
Java NIO系列教程
攻破JAVA NIO技术壁垒
完成端口(Completion Port)详解
java nio框架netty 与tomcat的关系
Tomcat7中NIO处理分析(一)
NIO文档
高性能IO模型浅析
聊聊同步、异步、阻塞与非阻塞

posted @ 2017-08-09 18:19  代码熊二  阅读(972)  评论(5编辑  收藏  举报