Java NIO介绍


在传统的 IO 处理中,当线程在执行 read() 或者 write() 方式时,在数据完全读取或写入之前该线程都时阻塞的,此时如果还有其它任务需要进行,就需要重新创建一个线程,但线程的创建与销毁是十分的耗费资源的。 NIO 的出现就是为了解决传统 IO 阻塞问题而出现。

同时在 NIO 中引入了一系列新概念如通道 Channel 与 缓冲区 Buffer 等,从而即便是同步读写的任务中相较于传统 IO 仍然有着一定性能优势,并且同时新增了一些工具类如 PathFiles,大大提高了文件等对象的操作可用性。

一、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<String> 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 fileDemo1() {
    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 fileDemo2() 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 则是用于监听多个通道的事件(如连接打开,数据到达),从而实现单线程监听多个数据通道。

(1) Channel

ChannelIO 中的 Stream 是差不多一个等级的,只不过 Stream 是单向的,譬如:InputStreamOutputStream,而 Channel 是双向的,既可以用来进行读操作,又可以用来进行写操作。

NIO 中的 Channel 的主要实现有: FileChannelDatagramChannelSocketChannelServerSocketChannel,分别对应文件 IOUDPTCP (Server 和 Client)。

(2) Buffer

NIO 中的关键 Buffer 实现有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short,相对应的还有 MappedByteBuffer, HeapByteBuffer, DirectByteBuffer 等等。

(3) Selector

Selector 用于单线程中处理多个 Channel,若应用打开了多个通道,但每个连接的流量都很低,通过 Selector 即可实现编辑的管理。

例如在一个聊天服务器中,通过向 Selector 注册 Channel,然后调用它的 select() 方法即可。这个方法会一直阻塞到某个注册的通道有事件就绪,一旦这个方法返回线程就可以处理这些事件,如新的连接进来、数据接收等。

2. 通道

传统的 IO 操作是与文件等资源直接建立连接通过 stream 的方式进行读取与写入,而 NIO 中引入新的概念 Channel,利用通道与文件或网络资源创建连接。同时 Channel 不同于传统的 IO Stream,其一旦建立连接后既可以用于读取,也可以用于写入,省去一堆恼人的 API

Channel 创建的常用的创建方式有以下两类,更推荐第一种方式:

  • open(): 通过 FileChannel 等通道实现类的 open() 创建,默认创建的只可读。
  • getChannel(): 在 FileInputStream 等类中同样提供了 getChannel() 用于创建通道。

相应的通道创建代码示例如下:

public void channelDemo(){
    String sourcePath = "src\\main\\resources\\nio\\user.csv";
    try (FileChannel channel = FileChannel.open(Paths.get(sourcePath))) {
        // do something ...
    } catch (IOException e) {
        e.printStackTrace();
    }

    try (
        FileInputStream fis = new FileInputStream(sourcePath);
        FileChannel channel = fis.getChannel();
    ) {
        // do something ...
    } catch (IOException e) {
        e.printStackTrace();
    }
}

需要注意默认创建的通道为只读,若需要执行写入等操作需要指定通道权限,通过枚举类 StandardOpenOption 进行指定,详细信息参考下表:

权限 描述
StandardOpenOption.READ 缺省默认值,创建只读通道。
StandardOpenOption.WRITE 创建一个可写通道。
StandardOpenOption.CREATE 当目标文件不存在时新建文件。
StandardOpenOption.CREATE_NEW 当目标文件不存在时新建文件,若存在则异常。
StandardOpenOption.DELETE_ON_CLOSE 删除文件当正常执行通道的 close() 方法。

如下述示例中即创建了一个可写通道。

public void channelDemo(){
    String sourcePath = "src\\main\\resources\\nio\\user.csv";
    Path path = Paths.get(sourcePath);
    try (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
        // do something ...
    } catch (IOException e) {
        e.printStackTrace();
    }
}

3. 缓冲区

在了解 NIO 的基本概念后下面以图示的方式介绍一下 NIO 读写文件。

NIO 缓存通道的读写常用方法接口参考下表:

方法 作用
allocate() 通过 allocate(size) 初始化一个缓存区大小。
put() 通过 put(byte[]) 存入指定字节大小数据。
flip() 通过 flip() 将 limit 置于当前已存入数据位的下一位,此时则可以进行读取操作。
get() 通过 get(size) 可以指定读取多少字节数据。
clear() 通过 clear() 会直接释放的缓冲区,无法得知上一次读到哪个位置。
compact() 通过 compact() 将缓冲区的界限设置为当前位置,并将当前位置充值为 0。

如下图即通过 NIO 缓冲区 Buffer 进行文件读写的示例图。

4. 选择器

选择器 Selector 常用于在单线程中管理多个 Channel,如网络框架 netty 中即为通过封装 NIO 实现,有兴趣的可自行查看其源码,这里不作详细描述。

四、读写示例

1. 文件读取

通过 NIO 缓冲区文件读取的完整示例代码如下:

public void readFileDemo() {
    String sourcePath = "src\\main\\resources\\nio\\user.csv";
    try (FileChannel channel = FileChannel.open(Paths.get(sourcePath))) {
        // set buffer space
        ByteBuffer buf = ByteBuffer.allocate(256);
        // read content to buffer
        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 (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
        // wrap():receive a byte array then create a buffer
        ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
        channel.write(buffer);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

3. 异步读取

想要实现异步读取,首先通过 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<Integer, Object>() {
            /**
             * 读取完成后的回调方法
             *
             * @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();
    }
}

4. 异步写入

NIO 的异步写入与读取类似,通过 channel.write() 方法实现,这里不再重复介绍。

五、直接内存

1. 基本定义

NIO 中涉及到一个重要概念即 直接内存,与之相对应的即常见的 Java堆内存

所谓直接内存内存,即跳出 JVM 的范围属于物理机的本地内存,如 Cmalloc() 函数申请的内存空间,同时也正因为不属于 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 将虚拟机最小内存与最大内存限制为 5MB10M

运行程序之后可以看到 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 KB\n", 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<Object> {
    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();
}

运行上述代码可以看到打印的直接内存使用情况由 0KB2048KB 再重新重置为 0KB


参考文档

  1. Deallocating Direct Buffer Native Memory in Java for JOGL

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录