type
status
date
slug
summary
tags
category
icon
password
第11讲 | Java提供了哪些IO方式? NIO如何实现多路复用?
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。 首先,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写 入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。 java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。 很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、 Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。 第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方 式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

1.Java NIO 概览
首先,熟悉一下 NIO 的主要组成部分:
Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。Channel,类似在 Linux 之类操作系统上看到的文件描述符,是 NIO 中被用来支持批量式 IO 操作的一种抽象。
File 或者 Socket,通常被认为是比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过 Socket 获取 Channel,反之亦然。
Selector,是 NIO 实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理。
Selector 同样是基于底层操作系统机制,不同模式、不同版本都存在区别,例如,在最新的代码库里
Chartset,提供 Unicode 字符串定义,NIO 也提供了相应的编解码器等,例如,通过下面的方式进行字符串到 ByteBuffer 的转换:
Charset.defaultCharset().encode("Hello world!"));
2.NIO 能解决什么问题?
资源消耗问题
第12讲 | Java有几种文件拷贝方式?哪一种最高效?
Java 有多种比较典型的文件拷贝实现方式,比如:



- 利用 java.io 类库,直接为源文件构建一个 FileInputStream 读取,然后再为目标文件构建一个 FileOutputStream,完成写入工作
- 利用 java.nio 类库提供的 transferTo 或 transferFrom 方法实现
当然,Java 标准类库本身已经提供了几种 Files.copy 的实现。
对于 Copy 的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From 的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。
思考方式远比记住结论重要
不同的 copy 方式,底层机制有什么区别?
为什么零拷贝(zero-copy)可能有性能优势?Buffer 分类与使用。
Direct Buffer 对垃圾收集等方面的影响与实践选择。
1. 拷贝实现机制分析
用户态空间(User Space)和内核态空间(Kernel Space)
当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。
写入操作也是类似,仅仅是步骤相反,可以参考下面这张图。

这种方式会带来一定的额外开销,可能会降低 IO 效率
而基于 NIO transferTo 的实现方式,在 Linux 和 Unix 上,则会使用到零拷贝技术,数据传输并不需要用户态参与,省去了上下文切换的开销和不必要的内存拷贝,进而可能提高应用拷贝性能。注意,transferTo 不仅仅是可以用在文件拷贝中,与其类似的,例如读取磁盘文件,然后进行 Socket 发送,同样可以享受这种机制带来的性能和扩展性提高。
transferTo 的传输过程是:

2.Java IO/NIO 源码结构
既然你提到了标准库,那么它是怎么实现的呢?
copy 不仅仅是支持文件之间操作,没有人限定输入输出流一定是针对文件的,这是两个很实用的工具方法。
后面两种 copy 实现,能够在方法实现里直接看到使用的是 InputStream.transferTo(),你可以直接看源码,其内部实现其实是 stream 在用户态的读写;而对于第一种方法的分析过程要相对麻烦一些,可以参考下面片段。简单起见,我只分析同类型文件系统拷贝过程。
JDK 的源代码中,内部实现和公共 API 定义也不是可以能够简单关联上的,NIO 部分代码甚至是定义为模板而不是 Java 源文件,在 build 过程自动生成源码,下面顺便介绍一下部分 JDK 代码机制和如何绕过隐藏障碍。
首先,直接跟踪,发现 FileSystemProvider 只是个抽象类,阅读它的源码能够理解到,原来文件系统实际逻辑存在于 JDK 内部实现里,公共 API 其实是通过 ServiceLoader 机制加载一系列文件系统实现,然后提供服务。
我们可以在 JDK 源码里搜索 FileSystemProvider 和 nio,可以定位到sun/nio/fs,我们知道 NIO 底层是和操作系统紧密相关的,所以每个平台都有自己的部分特有文件系统逻辑。

省略掉一些细节,最后我们一步步定位到 UnixFileSystemProvider →UnixCopyFile.Transfer,发现这是个本地方法。
最后,明确定位到UnixCopyFile.c,其内部实现清楚说明竟然只是简单的用户态空间拷贝!
所以,我们明确这个最常见的 copy 方法其实不是利用 transferTo,而是本地技术实现的用户态拷贝
如何提高类似拷贝等 IO 操作的性能,有一些宽泛的原则
3. 掌握 NIO Buffer

Buffer 有几个基本属性:
capcity,它反映这个 Buffer 到底有多大,也就是数组的长度。position,要操作的数据起始位置。
limit,相当于操作的限额。在读取或者写入时,limit 的意义很明显是不一样的。比如,读取操作时,很可能将 limit 设置到所容纳数据的上限;而在写入时,则会设置容量或容量以下的可写限度。
mark,记录上一次 postion 的位置,默认是 0,算是一个便利性的考虑,往往不是必须的。
简单梳理下 Buffer 的基本操作:
我们创建了一个 ByteBuffer,准备放入数据,capcity 当然就是缓冲区大小,而position 就是 0,limit 默认就是 capcity 的大小。
当我们写入几个字节的数据时,position 就会跟着水涨船高,但是它不可能超过 limit 的大小。
如果我们想把前面写入的数据读出来,需要调用 flip 方法,将 position 设置为 0,limit设置为以前的 position 那里。
如果还想从头再读一遍,可以调用 rewind,让 limit 不变,position 再次设置为 0。
4.Direct Buffer 和垃圾收集
重点介绍两种特别的 Buffer
Direct Buffer:如果我们看 Buffer 的方法定义,你会发现它定义了 isDirect() 方法,返回当前 Buffer 是否是 Direct 类型。这是因为 Java 提供了堆内和堆外(Direct)Buffer,我们可以以它的 allocate 或者 allocateDirect 方法直接创建。
MappedByteBuffer:它将文件按照指定大小直接映射为内存区域,当程序访问这个内存区域时将直接操作这块儿文件数据,省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建 MappedByteBuffer,它本质上也是种 Direct Buffer。
在实际使用中,Java 会尽量对 Direct Buffer 仅做本地 IO 操作,对于很多大数据量的 IO密集操作,可能会带来非常大的性能优势,因为:
Direct Buffer 生命周期内内存地址都不会再发生更改,进而内核可以安全地对其进行访问,很多 IO 操作会很高效。
减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
但是请注意,Direct Buffer 创建和销毁过程中,都会比一般的堆内 Buffer 增加部分开销,所以通常都建议用于长期使用、数据较大的场景。
使用 Direct Buffer,我们需要清楚它对内存和 JVM 参数的影响。首先,因为它不在堆上,所以 Xmx 之类参数,其实并不能影响 Direct Buffer 等堆外成员所使用的内存额度,我们可以使用下面参数设置大小:
从参数设置和内存问题排查角度来看,这意味着我们在计算 Java 可以使用的内存大小的时候,不能只考虑堆的需要,还有 Direct Buffer 等一系列堆外因素。如果出现内存不足,堆外内存占用也是一种可能性。
另外,大多数垃圾收集过程中,都不会主动收集 Direct Buffer,它的垃圾收集过程,就是
基于我在专栏前面所介绍的 Cleaner(一个内部实现)和幻象引用
(PhantomReference)机制,其本身不是 public 类型,内部实现了一个 Deallocator 负
责销毁的逻辑。对它的销毁往往要拖到 full GC 的时候,所以使用不当很容易导致OutOfMemoryError。
对于 Direct Buffer 的回收,我有几个建议:
在应用程序中,显式地调用 System.gc() 来强制触发。
另外一种思路是,在大量使用 Direct Buffer 的部分框架中,框架会自己在程序中调用释放方法,Netty 就是这么做的,有兴趣可以参考其实现(PlatformDependent0)。
重复使用 Direct Buffer。
5. 跟踪和诊断 Direct Buffer 内存占用?
因为通常的垃圾收集日志等记录,并不包含 Direct Buffer 等信息,所以 Direct Buffer 内存诊断也是个比较头疼的事情。幸好,在 JDK 8 之后的版本,我们可以方便地使用 NativeMemory Tracking(NMT)特性来进行诊断,你可以在程序启动时加上下面参数:
注意,激活 NMT 通常都会导致 JVM 出现 5%~10% 的性能下降,请谨慎考虑。
- Author:atsuc
- URL:https://blog.atsuc.cn/article/base-write-001
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!