文章首发于博客:布袋青年,原文链接直达:Java NIO介绍。
在传统的 IO
处理中,当线程在执行 read()
或者 write()
方式时,在数据完全读取或写入之前该线程都时阻塞的,此时如果还有其它任务需要进行,就需要重新创建一个线程,但线程的创建与销毁是十分的耗费资源的。 NIO
的出现就是为了解决传统 IO
阻塞问题而出现。
同时在 NIO
中引入了一系列新概念如通道 Channel
与 缓冲区 Buffer
等,从而即便是同步读写的任务中相较于传统 IO
仍然有着一定性能优势,并且同时新增了一些工具类如 Path
与 Files
,大大提高了文件等对象的操作可用性。
一、Path类
1. 目录管理
通过 Path
类即可更高效的目录信息获取等操作,而无需通过 new File()
对象实现。
方法 | 作用 |
---|---|
get() | 根据传入路径获取 Path 对象。 |
getFileName() | 根据传入的 Path 对象获取对应目录的文件名。 |
getParent() | 根据传入的 Path 对象获取其上级目录。 |
toAbsolutePath() | 根据传入的 Path 对象转为绝对路径。 |
compareTo() | 根据 ASCII 值比较两个 Path 的值。 |
public void pathDemo1() {
String location1 = "src\main\resources\nio\info.txt";
Path path = Paths.get(location1);
System.out.println("File name: " + path.getFileName());
System.out.println("Path parent: " + path.getParent());
System.out.println("Absolute path: " + path.toAbsolutePath());
// Compare file with Alphabetical order of name.
System.out.println("Compare to: " + path.compareTo(Paths.get("")));
}
2. 文件交互
除了替换了传统的 File
目录信息外, Path
类提供了一系列便捷接口方法,相应示例代码如下:
方法 | 作用 |
---|---|
resolve() | 将传入的字符串以当前文件系统分隔符拼接于 Path 对象。 |
getNameCount() | 获取 Path 对象目录层级数量。 |
getName() | 根据传入的数字获取对应的目录层级名称。 |
startsWith() | 判断 Path 对象的首个目录层级名称是否为指定字符。 |
endsWith() | 判断 Path 对象的末尾目录层级名称是否为指定字符。 |
public void pathDemo2() {
String location = "src\main\resources\nio";
Path path = Paths.get(location);
// resolve(): will splice provide value with path and with current system file separator.
Path resolvePath = path.resolve("info.txt");
System.out.println("Origin path: " + path);
System.out.println("Resolve path: " + resolvePath);
// getNameCount(): return the directory level count
List nameList = new ArrayList();
for (int i = 0; i < resolvePath.getNameCount(); i++) {
nameList.add(resolvePath.getName(i).toString());
}
// ["src", "main", "resources", "nio"]
System.out.println("Directory level: " + nameList);
/*
* 1.startWith(): The path first level name is start with provide value.
* 2.startWith(): The path last level name is start with provide value.
*/
System.out.println("Is start with [src]? " + resolvePath.startsWith("src"));
System.out.println("Is start with [info.txt]? " + resolvePath.endsWith("info.txt"));
}
3. 目录监听
在 Path
类提供了文件监听器 WatchService
从而可以实现目录的动态监听,即当目录发生文件增删改等操作时将收到操作信息。
监听器 WatchService
可监控的操作包含下述四类:
操作 | 描述 |
---|---|
OVERFLOW | 当操作失败时触发。 |
ENTRY_CREATE | 当新增文件或目录时触发。 |
ENTRY_MODIFY | 当修改文件或目录时触发。 |
ENTRY_DELETE | 当删除文件或目录时触发。 |
如下示例代码中即监控 src\main\resources\nio
目录,当在目录新增文件时打印对应的文件名。
public void listenerDemo() {
String path = "src\main\resources\nio";
try {
// Create a watch service
WatchService watchService = FileSystems.getDefault().newWatchService();
// Register the watch strategy
Paths.get(path).register(watchService, StandardWatchEventKinds.ENTRY_CREATE);
while (true) {
// path: listening directory
File file = new File(path);
File[] files = file.listFiles();
System.out.println("Waiting upload...");
// When didn't have new file upload will block in here
WatchKey key = watchService.take();
for (WatchEvent event : key.pollEvents()) {
String fileName = path + "\" + event.context();
System.out.println("New file path: " + fileName);
assert files != null;
// get the latest file
File file1 = files[files.length - 1];
System.out.println(file1.getName());
}
if (!key.reset()) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
二、Files类
1. 文件管理
Files
工具类提供一系列便捷接口方法用于判断文件类型等等,同样无需再通过 new File()
实现。
方法 | 作用 |
---|---|
exists() | 根据传入的 Path 对象判断目录或文件是否存在。 |
isDirectory() | 根据传入的 Path 对象判断目标是否为目录。 |
public void file1Demo() {
Path path = Paths.get(location);
boolean isExist = Files.exists(path);
if (isExist) {
boolean isDirectory = Files.isDirectory(path);
if (isDirectory) {
System.out.println(path + " is directory");
} else {
System.out.println(path + " is not directory");
}
} else {
System.out.println(path + " is not existed");
}
}
2. IO操作
Files
工具类同时提供了一系列方法用于 IO
读写,省去大量的重复代码,详细信息参考下表。
方法 | 作用 |
---|---|
createFile() | 根据传入的 Path 对象新建文件或目录。 |
copy() | 根据传入的 Path 对象复制文件或目录。 |
deleteIfExists() | 根据传入的 Path 对象删除文件或目录。 |
Files.newInputStream() | 根据传入的 Path 对象初始化 IO 流对象。 |
上述接口方法对应的操作示例代码如下:
public void file2Demo() throws IOException {
Path path = Paths.get(location);
// Convert path to "File"
File file = path.toFile();
System.out.println("Convert to file: " + file);
/*
* ==> (1).createFile(): Create a file with provide path.
* ==> (2).copy(): Copy a file, didn't have manual to read and write
* ==> (3).deleteIfExists(): Delete file if file exists.
*/
Path targetPath = Paths.get(targetLocate);
Path newFile = Files.createFile(path);
Path copyFile = Files.copy(path, targetPath);
boolean isDeleted = Files.deleteIfExists(path);
System.out.println("Create new file: " + newFile);
System.out.println("Copy a file: " + copyFile);
System.out.println("Delete success? " + isDeleted);
// Get file io resource
try (InputStream in = Files.newInputStream(path)) {
int ch;
while ((ch = in.read()) != -1) {
System.out.write(ch);
}
} catch (Exception e) {
e.printStackTrace();
}
}
三、概念介绍
1. 基本概念
在 NIO
中有三个核心:通道(Channel)
,缓冲区(Buffer)
, 选择区(Selector)
。传统 IO
基于字节流和字符流进行操作,而 NIO
则是基于 Channel
与和 Buffer
进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。 Selector
用于监听多个通道的事件(如连接打开,数据到达)。因此,单个线程可以监听多个数据通道。
-
Channel
Channel
和IO
中的Stream
是差不多一个等级的,只不过Stream
是单向的,譬如:InputStream
,OutputStream
。而Channel
是双向的,既可以用来进行读操作,又可以用来进行写操作。NIO
中的Channel
的主要实现有:FileChannel
,DatagramChannel
,SocketChannel
,ServerSocketChannel
,分别对应文件IO
、UDP
和TCP
(Server 和 Client)。 -
Buffer
NIO
中的关键Buffer
实现有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer
,分别对应基本数据类型:byte, char, double, float, int, long, short
。当然NIO
中还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer
等这里先不进行陈述。 -
Selector
Selector
用于单线程中处理多个Channel
,如果你的应用打开了多个通道,但每个连接的流量都很低,使用Selector
就会很方便。例如在一个聊天服务器中。要使用Selector
, 得向Selector
注册Channel
,然后调用它的select()
方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新的连接进来、数据接收等。
2. 缓冲区
在了解 NIO
的基本概念后下面以图示的方式介绍一下 NIO
读写文件。
NIO
缓存通道的读写常用方法接口参考下表:
方法 | 作用 |
---|---|
allocate() | 通过 allocate(size) 初始化一个缓存区大小。 |
put() | 通过 put(byte[]) 存入指定字节大小数据。 |
flip() | 通过 flip() 将 limit 置于当前已存入数据位的下一位,此时则可以进行读取操作。 |
get() | 通过 get(size) 可以指定读取多少字节数据。 |
clear() | 通过 clear() 会直接释放的缓冲区,无法得知上一次读到哪个位置。 |
compact() | 通过 compact() 将缓冲区的界限设置为当前位置,并将当前位置充值为 0。 |
如下图即通过 NIO
缓冲区 Buffer
进行文件读写的示例图。
四、直接内存
1. 基本定义
在 NIO
中涉及到一个重要概念即 直接内存
,与之相对应的即常见的 Java堆内存
。
所谓直接内存内存,即跳出 JVM
的范围属于物理机的本地内存,如 C
中 malloc()
函数申请的内存空间,同时也正因为不属于 Java堆
所以其并不受 GC
的管控,在使用时要尤为注意资源释放。
你可能此时会有疑问,问什么 NIO
中要使用直接内存而非堆内存?其实原因很简单,因为直接内存并不受 GC
管控,因而通常并不会触发 GC
操作,在操作大对象时则可避免 GC
从而减少应用因频繁 GC
导致的停顿等因素造成的卡顿。
2. 操作示例
NIO
的缓冲区提供了两种创建方式,这里以 ByteBuffer
为例子,不同创建方式的区别如下:
- allocate(): 申请
Java堆
内存空间,对象占用受GC
管控,大小受限于堆内存的上限。- allocateDirect(): 申请直接内存,对象占用不受
GC
管控,大小受限于参数-XX:MaxDirectMemorySize
。
public void init() {
// Allocate memory, limited by java heap(-Xmx)
ByteBuffer buffer = ByteBuffer.allocate(64);
// Allocate direct memory, limited by "-XX:MaxDirectMemorySize"
// The direct memory is not eligible for GC.
ByteBuffer buffer1 = ByteBuffer.allocateDirect(64);
}
3. 内存信息
在 Java
中,Runtime.getRuntime()
返回一个表示 Java
虚拟机运行时环境的 Runtime
对象,其中即包含了当前虚拟机的内存使用情况。
Runtime
中包含下述三类内存使用情况:
- freeMemory():
Java
虚拟机中空闲内存区域,即未分配的内存空间。- totalMemory():
Java
虚拟机在当前时刻占用的内存总量,即已分配的内存大小,包括了用于程序数据、堆、方法区等各种内存区域。- maxMemory():
Java
虚拟机可以使用的最大内存大小。
如下述示例中创建了一个大小为 2MB
的对象,在对象创建前后分别打印了内存占用情况,这里我通过参数 -Xms5m
与 -Xmx10m
将虚拟机最小内存与最大内存限制为 5MB
与 10M
。
运行程序之后可以看到 freeMemory
大小由 4134KB
减少至 2025KB
,这减少的 2MB
内存空间正是被创建的 bytes
对象占用。
/**
* freeMemory: 4134 KB totalMemory: 5632 KB maxMemory: 9216 KB
* freeMemory: 2025 KB totalMemory: 5632 KB maxMemory: 9216 KB
*/
public static void main(String[] args) {
printMemoryUsage();
// 创建 2MB 大小的对象
byte[] bytes = new byte[2 * 1024 * 1024];
printMemoryUsage();
}
public static void printMemoryUsage() {
// The memory that can use
long freeMemory = Runtime.getRuntime().freeMemory() / 1024;
// Current use, include space for java head
long totalMemory = Runtime.getRuntime().totalMemory() / 1024;
// The max memory can use, limited by "-Xmx"
long maxMemory = Runtime.getRuntime().maxMemory() / 1024;
System.out.printf("freeMemory: %s KB ttotalMemory: %s KB tmaxMemory: %s KBn", freeMemory, totalMemory, maxMemory);
}
直接内存的使用情况与堆内存类似,可以通过 SharedSecrets
类进行查看获取。如下示例即通过 allocateDirect()
同样申请了 2MB
的直接内存,并在操作前后分别打印了堆内存和直接内存的使用情况。
这里同样将虚拟机的内存区间设置为 [5MB, 10MB]
,可以看到在 allocateDirect()
执行前后堆内存中 freeMemory
等内存信息仅因方法堆栈减少 38KB
,而直接内存的使用则由 0KB
增加到 2048KB
,也验证了上述提到的 allocateDirect()
申请的内存空间不在堆内存之中。
/**
* freeMemory: 4148 KB totalMemory: 5632 KB maxMemory: 9216 KB
* Direct memory = 0 KB
* freeMemory: 4110 KB totalMemory: 5632 KB maxMemory: 9216 KB
* Direct memory = 2048 KB
*/
public static void main(String[] args) {
printMemoryUsage();
printDirectMemoryUsage();
ByteBuffer buffer = ByteBuffer.allocateDirect(2 * 1024 * 1024);
printMemoryUsage();
printDirectMemoryUsage();
}
public static void printDirectMemoryUsage() {
long memoryUsed = SharedSecrets.getJavaNioAccess().getDirectBufferPool().getMemoryUsed();
memoryUsed = memoryUsed / 1024;
System.out.println("Direct memory = " + memoryUsed + " KB");
}
4. 内存释放
在上面多次提到了直接内存是不归堆内存管理的,因为虚拟机的 GC
显然也无法释放申请的直接内存空间,那就只能由我们自己来销毁。
直接内存的空间默认与堆内存的最大值一致,也可通过参数 -XX:MaxDirectMemorySize
手动指定,因此若没有合理释放内存,内存溢出是早晚的事。但蛋疼的来了,ByteBuffer
提供了 allocateDirect()
方法用于申请直接内存,却没有没有像 C
中显式提供 free()
方法用于释放这部分空间,如果你放其自由,那么 OOM
正在挥手向你走来。
没办法只能硬着头皮钻进源码,可以看到 allocateDirect()
是通过 DirectByteBuffer
类进行声明,检查 DirectByteBuffer
可以发现其中有一个 cleaner()
方法,可以看到其是实现了 DirectBuffer
接口并重写得来,从名称上而言很显然是用于清除某些事物的。进入 Cleaner
类可以看见其提供了 clean()
方法执行清楚操作,具体内容我们先放下不管。
这里我复制了提到的几个类的相关代码,具体内容如下:
public abstract class ByteBuffer {
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
// 略去其它
}
class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer {
DirectByteBuffer(int cap) {
// 略去具体实现
}
private final Cleaner cleaner;
public Cleaner cleaner() { return cleaner; }
// 略去其它
}
public interface DirectBuffer {
Cleaner cleaner();
// 略去其它
}
public abstract class MappedByteBuffer extends ByteBuffer {
// 略去其它
}
public class Cleaner extends PhantomReference {
public void clean() {
// 略去具体实现
}
// 略去其它
}
为了更方便查看,下图为上述类的对应依赖关系:
看到这里,对于基本的依赖关系也有了一个大概的了解,下面就到真枪实弹的时候了。
显然我们想要效果就是获取 DirectByteBuffer
中的 cleaner
对象,从而执行 Cleaner
类的 clean()
方法,如果你看的仔细的话可能注意到 ByteBuffer
调用的 DirectByteBuffer
类作用域并非 public
,这时候脑中出现第一反应就是反射。
那么一切就简单了,首先通过反射获取 DirectBuffer
接口的 cleaner()
方法,再由 ByteBuffer
对象调用 invoke()
方法获取 cleaner
对象,接着通过反射获取 Cleaner
类中的 clean()
方法,最后由 cleaner
通过 invoke()
调用 clean()
方法,完结撒花。
这里提一下为什么可以通过 ByteBuffer
创建的对象调用 DirectBuffer
中的 cleaner()
方法,这是因为 DirectBuffer
继承于 MappedByteBuffer
,而其又继承于 ByteBuffer
,兜兜转转终于绕回来了。
讲了这么多,那就看看代码究竟如何实现,其中 printDirectMemoryUsage()
即之前提到的直接内存使用情况打印。
public void freeDemo() throws Exception {
UsagePrinter.printDirectMemoryUsage();
ByteBuffer buffer = ByteBuffer.allocateDirect(2 * 1024 * 1024);
UsagePrinter.printDirectMemoryUsage();
if (buffer.isDirect()) {
// The "DirectBuffer" is provided method "cleaner()" to return a cleaner
String bufferCleanerCls = "sun.nio.ch.DirectBuffer";
Method cleanerMethod = Class.forName(bufferCleanerCls).getMethod("cleaner");
Object cleaner = cleanerMethod.invoke(buffer);
// When we get "cleaner" then we can call "clean()" to free memory
String cleanerCls = "sun.misc.Cleaner";
Method cleanMethod = Class.forName(cleanerCls).getMethod("clean");
cleanMethod.invoke(cleaner);
}
UsagePrinter.printDirectMemoryUsage();
}
运行上述代码可以看到打印的直接内存使用情况由 0KB
到 2048KB
再重新重置为 0KB
。
五、读写示例
1. 文件读取
通过 NIO
缓冲区文件读取的完整示例代码如下:
public void ReadFileDemo() {
String sourcePath = "src\main\resources\nio\user.csv";
try (FileInputStream in = new FileInputStream(sourcePath)) {
// create channel
FileChannel channel = in.getChannel();
// allocate buffer
ByteBuffer buf = ByteBuffer.allocate(20);
while ((channel.read(buf)) != -1) {
buf.flip();
while (buf.hasRemaining()) {
System.out.write(buf.get());
}
buf.compact();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
2. 文件写入
通过 NIO
缓冲区文件写入的完整示例代码如下:
public void WriteFileDemo() {
String msg = "Message from NIO.";
String targetPath = "src\main\resources\nio\info.txt";
try (FileOutputStream out = new FileOutputStream(targetPath)) {
// create channel
FileChannel channel = out.getChannel();
// allocate buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// read content from buffer
buffer.put(msg.getBytes());
// start read
buffer.flip();
// write to file
channel.write(buffer);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
六、异步读写
在上述的示例中介绍了以通道缓存的方式写入文件,而 NIO 更重要的一大特性即异步的请求读写,下面即分开进行介绍。
1. 异步读取
想要实现异步读取,首先通过 AsynchronousFileChannel
创建异步通道,再由 channel.read()
中的异步回调方法实现文件的读取。
如下述示例即通过异步的方式读取 user.csv
文件,其中 AsynchronousFileChannel
是针对文件的异步通道,同理还有针对网络请求的 AsynchronousSocketChannel
等等。
@Test
public void asyncFileDemo() {
String sourcePath = "src\main\resources\nio\user.csv";
Path path = Paths.get(sourcePath);
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 使用 CompletionHandler 异步读取文件
channel.read(buffer, 0, null, new CompletionHandler() {
/**
* 读取完成后的回调方法
*
* @param result The result of the I/O operation.
* @param attachment The object attached to the I/O operation when it was initiated.
*/
@Override
public void completed(Integer result, Object attachment) {
System.out.println("Read " + result + " bytes, start read.");
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
// Read buffer to bytes
buffer.get(bytes);
// Clean buffer
buffer.clear();
System.out.println(new String(bytes));
}
@Override
public void failed(Throwable exc, Object attachment) {
// 读取失败时的回调方法
exc.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
2. 异步写入
NIO
的异步写入与读取类似,通过 channel.write()
方法实现,这里不再重复介绍。
参考文档: