上一篇文章 介绍了 Java
的传统 I/O
,也就是 BIO
(Blocking IO)。这篇文章介绍一下 NIO
(Non-Block)的基本知识点,以及为什么 NIO
在高并发以及大文件的处理方面更有优势。
本地文件I/O操作——NIO小试牛刀
Channel和Buffer
BIO
里操作的是 InputStream
和 OutputStream
,在 NIO
中操作的则是 Channel
和 Buffer
。我们可以把 Channel
想象成矿藏,把 Buffer
想象成运矿的车。如果想移动数据,必须借助 Buffer
,这是移动数据的唯一方式。也就是说 Buffer
跟 Channel
必定形影不离。
NIO
中用的最多的三种 Channel
,分别是
FileChannel
, SocketChannel
,以及 ServerSocketChannel
。
FileChannel
是用来操作本地文件的,而另外两个则是进行网络 I/O
操作的。
FileChannel
这里通过将文件 test-io.tmp
里面的内容移动到文件 test-io.md
中,让大家感受一下如何使用 Channel
和 Buffer
进行文件 I/O
操作。
示例: NIO
方式操作本地文件。
//通过FileInputstream拿到输入FileChannel。 FileChannel in = new FileInputStream("test-io.tmp").getChannel(); //通过FileOutPutStream拿到输出FileChannel FileChannel out = new FileOutputStream("test-io.md").getChannel(); //创建一个字节缓冲器,用于运送数据。 ByteBuffer buffer = ByteBuffer.allocate(1024); while (in.read(buffer) != -1){ //相当于缓冲器的开关,只有调用该方法,缓冲器里面的数据才能被写入到输出Channel. buffer.flip(); out.write(buffer); buffer.clear(); }
上面的代码很轻松的实现了,将文件 test-io.tmp
中的内容移动到 test-io.md
中。
代码解读
通过 FileInputStream
对象的 getChannel
方法拿到了 Channel
。
通过 ByteBuffer
的 allocate
方法(也可以是 allocateDirecty
方法)声明一个缓冲器,容量是 1024
字节,用于传输数据。
将数据源 channel
里面的数据通过 read
方法读取到缓冲器。
通过 out.write()
方法,将缓冲器里面的数据写入到输出 Channel
。最后清空缓冲器,为下次读取数据做准备。
注意:将缓冲区里面的数据写入到输出 channel
前一定要调用 buffer
的 flip()
方法。你可以把该方法的作用理解成,打开 Buffer
的阀门。只有打开阀门数据才能被取出。
ByteBuffer
ByteBuffer
是 Buffer
的一个子类。还有很多其它子类,比如 CharBuffer
, DoubleBuffer
等 , ByteBuffer
是用的最多的缓冲器。
我们可以把 ByteBuffer
想象成一个字节数组。大概是这个样子。
上图是刚刚初始化的示意图,position表示游标,每读取一个字节,position就移动一个位置。
ByteBuffer
有几个比较重要的方法,如下
allocate()
: 创建一个缓冲器,例如 ByteBuffer.allocate(1024)
。
allocateDirect()
: 创建一个与操作系统底层更耦合的缓冲器。
capacity()
: 返回缓冲区数组的容量。
position()
: 下一个要操作的元素位置。
limit()
: 返回limit的值。
flip()
:打开缓冲器的阀门,做好被读取的准备。
put()
:将字节存储进缓冲器。例如
byteBuffer.put("hello".getBytes("utf-8"));
wrap()
:将字节数组存储进缓冲器。例如
ByteBuffer.wrap("hello".getBytest())
rewind()
:将position设置为0。
clear()
:清空缓冲区。
hasRemaining()
若介于position和limit之间有值,则返回true。
零拷贝
上面的例子还有另外一种实现,看代码。
public class ChannelTransfer { public static void main(String[] args) throws Exception { FileChannel in = new FileInputStream("test-io.tmp").getChannel(); FileChannel out = new FileOutputStream("test-io.md").getChannel(); in.transferTo(0,in.size(),out); //或者 //out.transferFrom(in,0,in.size()); } }
直接将输入端和输出端进行对接,不经过操作系统的内核态。这就是大名鼎鼎的零拷贝技术的运用。Kafka的性能之所以那么生猛,很大一部分原因是运用了零拷贝技术。
超大内存文件读取
所谓超大文件就是,要操作的文件比你系统的可用内存还大,此时可以使用 NIO
提供的类库方法进行如下操作。
public static void main(String[] args) throws Exception { FileChannel fileChannel = new FileInputStream("test-io.tmp").getChannel(); //通过map()方法产生一个缓冲器. MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size()); if (mappedByteBuffer != null){ CharBuffer charBuffer = Charset.forName("utf-8").decode(mappedByteBuffer); System.out.println(charBuffer.toString()); } }
注意map()函数有三个参数,分别表示读写模式,初始位置以及映射长度。 因为我的测试文件很小,所以就全部映射了。如果源文件较大(100G)可以每次映射500M或1G,根据机器性能不同找到一个最优值。
FileChannel
的知识点基本就这些了,相信通过上面的介绍,各位对 NIO
的 Channel
和 Buffer
已经有了一个基本的认识。
网络I/O——NIO大显身手。
我们一直在说 NIO
是非阻塞 I/O
,但是上面介绍的 FileChanel
并不能设置成非阻塞模式,你说搞笑不。 FileChannel
相比于传统的(BIO)来说,最大的优势在于大文件的处理,以及零拷贝等技术的运用和处理。如果你问我这些技术的底层实现原理是什么,其实我也不知道,只知道 FileChannel
提供的很多方法,以一种更迎合操作系统的方式来工作。所谓马屁拍的好,升职加薪来的早。
如果各位真想深究底层原理,建议先去了解操作系统的知识,然后再去扒 JDK
的源码。
真正支持非阻塞操作的是 ServerSocketChannel
和 SocketChannel
。也只有在进行网络 I/O
的时候,非阻塞 I/O
的优势才能被最大程度的发挥出来。
如果想了解各种 I/O
的详细内容可以看我 这篇文章 。
需求提出
假设我们要实现一个简单的服务端程序,唯一的功能是接收客户端发过来的请求,然后将请求内容转换为大写之后在发回给客户端。
BIO实现方式
当客户端发送一个请求的时候,服务端则创建一个线程进行处理。当客户端同时发送100个请求的时候,服务端就创建100个线程进行处理。这看起来还不错,但如果请求数量有几千或者更高的时候,那么服务端可能就会有点儿吃不消了。
原因如下:
- 线程的创建和销毁很占用系统资源,即便有线程池技术,也不能从根本上解决问题,而且在 Linux 里面线程就是轻量级进程
- 线程不可以无限制的创建下去,Java里面每个线程要占用512K-1M的内存空间。
- 线程间的不断切换很消耗系统资源,因为要保留上下文等内容。
BIO是个实在孩子。
BIO
选择多线程的方式也是无奈之选。因为 Socket.write
和 Socket.read
都是阻塞的。所谓的阻塞的意思就是一旦线程开始执行 socket.read
操作了,那么就需要等这个读操作执行完成。如果这个时候没有数据可以读,那么就需要等待,等到有数为止。这是 BIO
的天然属性,没有办法,简直太实在了。所以如果想充分的利用CPU,就得多创建几个线程,一个线程没有数据,另外一个总有吧,这就叫东方不亮西方亮。
来一段简简单单的伪代码,大家稍微感受一下吧。
//整个线程池 ExecutorService executor = Executors.newFixedThreadPool(100); ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8888)); //循环监听等待新连接到来 while(true) { Socket socket = serverSocket.accept(); //为新的连接创建新的线程 executor.submit(new Task(socket)); } class Task implements Runnable { private Socket socket; public Task(Socket socket) { this.socket = socket; } @Override public void run() { while (!socket.isClosed()) { //读数据,阻塞 String someThing = socket.read(); if (someThing != null) { //处理数据,返回客户端,阻塞 socket.write(); } } } }
NIO是个聪明孩子。
BIO
的问题出在了阻塞的读和写上面。因为阻塞 I/O
太实在,没有数据就死等数据,造成 CPU
没有被充分利用的尴尬局面。相比于 BIO
, NIO
就聪明多,因为它根本就不会等,而是有数据的时候,你通知我一下,我派 CPU
去取。到哪儿就取,取完就走,一点儿不废话,速度那叫一个快。以 CPU
的(智商)运算速度,一个人管理几千个通道根本不是事儿。这就是 Reactor
编程模型,也叫基于事件编程。
既然是基于事件编程,那么 NIO
里面比较重要的几个事件分别是, Read
, Write
, Accept
, Connect
。
在 NIO
编程模型中,每个客户端跟服务端建立的连接都是一个 Channel
,这些 Channel
一旦有数据了,就会通知 CPU
去对应的通道取数。所以根本不会像 BIO
那样,发生线程死等数据的情况。这也就是 CPU
利用高的原因。
NIO的网络编程模型有点儿类似于孙悟空的悬丝诊脉。
使用NIO进行网络编程
上面提到了,NIO网络编程是基于事件编程,那么就得有人负责事件的监听。这个工作由 Select
完成。当有感兴趣的事情发生, Select
就会第一时间知道。
SelectionKey
也是一个相当重要的角色,相当于 Select
和 Channel
沟通的桥梁。因为 Select
不光要知道有感兴趣的事情发生了,还要知道哪个 Channel
发生了什么事件。
NIO
网络编程里面的主角就给大家都介绍完了,分别是选择器 Selector
,通道 ServerSocketChannel
和 SocketChanel
,以及在上面提到的缓冲器 ByteBuffer
,还有 SelectionKey
。
下面给大家简单演绎一下,如何用NIO的方式,实现上文中提到的那个服务端程序。先看代码吧。
public class EchoNioServer { public static final int BUF_SIZE = 1024; public static void main(String[] args) { ByteBuffer byteBuffer = ByteBuffer.allocate(BUF_SIZE); try { Selector selector = Selector.open(); ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.bind(new InetSocketAddress(8888)); System.out.println("正在8888端口监听..."); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT, byteBuffer); while (true) { selector.select(); Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); if (!key.isValid()) { continue; } if (key.isAcceptable()) { ServerSocketChannel serverSocketChannel1 = (ServerSocketChannel) key.channel(); SocketChannel socketChannel = serverSocketChannel1.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer); } else if (key.isReadable()) { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer readBuffer = (ByteBuffer) key.attachment(); readBuffer.clear(); socketChannel.read(readBuffer); readBuffer.flip(); System.out.println("received from client: " + new String(readBuffer.array()).trim()); socketChannel.register(selector, SelectionKey.OP_WRITE, readBuffer); } else if (key.isWritable()) { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer writeBuffer = (ByteBuffer) key.attachment(); String msg = new String(writeBuffer.array()).trim().toUpperCase(); writeBuffer.clear(); writeBuffer.put(msg.getBytes("utf-8")); writeBuffer.flip(); socketChannel.write(writeBuffer); writeBuffer.clear(); socketChannel.close(); } } } } catch (IOException e) { e.printStackTrace(); } } }
代码解读
帮大家做个简单的解读。方便大家理解。
- 先创建一个选择器及缓冲器备用,一个用于监听感兴趣的事件,一个用于运送数据。
Selector select = Selector.open();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
- 创建一个
ServerSocketChannel ServerSocketChannel ssc = ServerSocketChannel.open();
- 设置为非阻塞模式(必须设置为非阻塞,不然你还是什么NIO)
ssc.configureBlocking(false)
- 绑定端口
ssc.bind(8888)
- 将通道注册到选择器,并告诉选择器,我对哪些些事件感兴趣。当事件到来就调用相应的逻辑进行处理。
sss.register(select,SelectionKey.Accept)
- 调用
select.selct()
方法,找出可用的通道,这个方法是阻塞的,所以放到while(true)也不会造成CPU空转。 - 针对不同的事件做不同的处理。
与上面服务端代码配套的客户端代码,我就不做过多解释了。
public class EchoNioClient { public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(5); for (int i = 0; i < 10; i++) { executor.submit(new Task()); } executor.shutdown(); } } class Task implements Runnable { InetSocketAddress remoteAddress = new InetSocketAddress(8888); static final int BUF_SIZE = 1024; @Override public void run() { try { String msg = "hello I'm " + Thread.currentThread().getName(); SocketChannel socketChannel = SocketChannel.open(remoteAddress); ByteBuffer byteBuffer = ByteBuffer.allocate(BUF_SIZE); byteBuffer.clear(); byteBuffer.put(msg.getBytes("utf-8")); byteBuffer.flip(); socketChannel.write(byteBuffer); byteBuffer.clear(); ByteBuffer receiveBuffer = ByteBuffer.allocate(1024); while (socketChannel.read(receiveBuffer) != -1) { receiveBuffer.flip(); System.out.println("received from server: " + new String(receiveBuffer.array()).trim()); receiveBuffer.clear(); } socketChannel.close(); } catch (IOException e) { e.printStackTrace(); } } }
结束
希望这篇文章能帮助你更好的理解NIO基础编程。了解了这些基础知识之后,无聊的时候就可以去看看Tomcat的源码,有机会也可以跟那些经常用Netty写高性能网关服务的大牛聊聊天了。
最后强烈建议各位,把文中的例子放到自己的IDE里面,跑一遍,最好自己在动手写一写,千万不要一看我都会,一写就蒙圈,眼高手低可不好。
推荐阅读:
以上所述就是小编给大家介绍的《教你如何学习的Java NIO》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 一文读懂监督学习、无监督学习、半监督学习、强化学习这四种深度学习方式
- 学习:人工智能-机器学习-深度学习概念的区别
- 统计学习,机器学习与深度学习概念的关联与区别
- 混合学习环境下基于学习行为数据的学习预警系统设计与实现
- 学习如何学习
- 深度学习的学习历程
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Is Parallel Programming Hard, And, If So, What Can You Do About
Paul E. McKenney
The purpose of this book is to help you understand how to program shared-memory parallel machines without risking your sanity.1 By describing the algorithms and designs that have worked well in the pa......一起来看看 《Is Parallel Programming Hard, And, If So, What Can You Do About 》 这本书的介绍吧!