NIO前奏之Path、Files、AsynchronousFileChannel

  Java 1.4加入了nio包,Java 1.7 加入了真正的AIO(异步IO),AsynchronousFileChannel就是一个典型的可以异步处理文件的类。

  之前我们处理文件时,只能阻塞着,等待文件写入完毕之后才能继续执行,读取也是一样的道理,系统内核没有准备好数据时,进程只能干等着数据过来而不能做其他事。AsynchronousFileChannel则可以异步处理文件,然后做其他事,等到真正需要处理数据的时候再处理。

Path

  在引入新的nio的同时,专门添加了很多新的类来取代原来的IO操作方式。
  Path和Files就是其中比较基础的两个类,这里先简单介绍下。

创建Path

//单个路径创建,注意这里是Paths静态工厂方法
Path path = Paths.get("data.txt");//以“/”和盘符开头的为绝对路径,“/”会被认为是C盘,相当于“C:/”
//多路径创建,basePath是基础路径,relativePath是相对第一个参数的相对路径,后面可以添加多个相对路径
Path path2 = Path.get(basePath, relativePath, ...);//

Path, File, URI 互转

File file = path.toFile();
Path path = file.toPath();
URI uri = path.toUri();
URI uri = file.toURI();

路径正常化、绝对路径、真实路径

  路径中可以使用.表示当前路径,../表示父级路径,但是这种表示有时会造成路径冗余,这是可以使用下面的几个方法来处理。

Path path = Paths.get("./src");
System.out.println("path = " + path);//path = .\src
System.out.println("normalize : "+ path.normalize());//normalize : src
System.out.println("toAbsolutePath : "+path.toAbsolutePath());//toAbsolutePath : C:\Users\Dell\IdeaProjects\test\.\src
System.out.println("toRealPath : "+path.toRealPath());//toRealPath : C:\Users\Dell\IdeaProjects\test\src

这几个方法返回的还是Path
normalize():压缩路径
toAbsolutePath():绝对路径
toRealPath():真实路径,相当于同时调用上面两个方法。(需要注意调用此方法时路径需要存在,否则会抛出异常)

Files

  Files虽然看起来像File的工具类,但是实际却在java.nio.file包下面,是后来引入的工具类,一般配合Path使用。

这里介绍几个常用的方法:

  • 复制

    • copy(Path source, Path target)Path到Path复制,还有第三个可选参数为复制选项,是否覆盖之类的
    • copy(InputStream in, Path target)从流到Path
    • copy(Path source, OutputStream out)从Path到流
  • 创建文件(夹)

    • createFile(Path path)创建文件
    • createDirectory(Path dir)创建文件夹,父级目录不存在则报错
    • createDirectories(Path dir)创建文件夹,父级目录不存在则自动创建父级目录
  • 移动:move(Path source, Path target)可以移动或者重命名文件(注意这里必须是文件,不能是文件夹),同样也可选是否覆盖
  • 删除:delete(Path path)删除文件
  • 文件是否存在:exists(Path path)
  • 写文本:Files.write(path, Arrays.asList("落霞与孤鹜齐飞,","秋水共长天一色"), StandardOpenOption.APPEND);

具体方法可以查看API文档,这里不再一一赘述。

Files.walkFileTree()

  这是个比较强大的方法,之前我们遍历在一个文件夹里搜索文件或者删除一个非空文件夹的时候只能使用递归(或者自己手动维护一个栈),但是递归效率非常低并且容易爆栈,使用walkFileTree()方法可以很优雅地遍历文件夹。

首先来看看这个方法的其中一种重载的定义:

walkFileTree(Path start, FileVisitor<? super Path> visitor) 

第一个参数是遍历的根目录,第二个参数是控制遍历行为的接口,我们来看看这个接口的定义:

public interface FileVisitor<T> {
    //访问目录前做什么
    FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;

    //访问文件时做什么
    FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;

    //访问文件失败做什么
    FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;

    //访问目录后做什么
    FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}

  接口中定义了四个方法,分别规定了我们访问一个文件(夹)前中后失败分别做什么,我们自己访问文件时也就这么几个时间,使用这个接口就不用自己去递归遍历文件夹了。

  FileVisitor接口定义了四个抽象方法,有时候我们只是想要访问文件时做点什么,不关心访问前、访问后做什么,但是却必须实现其功能,这样显得臃肿。
  此时我们可以使用它的适配器实现类:SimpleFileVisitor,该类提供了四个抽象方法的平庸实现,使用的时候只需要重写特定方法即可。

放上个删除非空文夹的Demo:


删除非空文件夹

Path path = Paths.get("D:/dir/test");
Files.walkFileTree(path,new SimpleFileVisitor<Path>(){
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        System.out.println("删除文件:"+file);
        Files.delete(file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
        System.out.println("删除文件夹:"+dir);
        Files.delete(dir);
        return FileVisitResult.CONTINUE;
    }
});

上面的抽象方法的返回值是一个枚举,用来决定是否继续遍历:

  • CONTINUE:继续遍历
  • SKIP_SIBLINGS:继续遍历,但忽略当前节点的所有兄弟节点直接返回上一层继续遍历
  • SKIP_SUBTREE:继续遍历,但是忽略子目录,但是子文件还是会访问;
  • TERMINATE:终止遍历

因此如果是用来所搜文件的话,在找到文件之后可以终止遍历,具体实现这里就不赘述了。

AsynchronousFileChannel

阅读本节之前请先看另一篇文章:浅析Java NIO

异步的通道有好几个:

  • AsynchronousFileChannel:lock,read,write
  • AsynchronousSocketChannel:connect,read,write
  • AsynchronousDatagramChannel:read,write,send,receive
  • AsynchronousServerSocketChannel:accept

  分别对应文件IO、TCP IO、UDP IO、服务端TCP IO。和非异步通道正好是对应的。

  这里就只说文件异步IO :AsynchronousFileChannel

异步文件通道的创建

  AsynchronousFileChannel是通过静态工厂的方法创建,通过open()方法可以打开一个Path的异步通道:

Path path = Paths.get("data.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path,StandardOpenOption.READ);

第一个参数是关联的Path,第二个参数是操作的方式(或者叫权限,该参数可以省略)

  AsynchronousFileChannel通道的读写分别都有两种方式,一种是Futrue方式,另一种是注册回调函数CompletionHandler的方式。这里稍微演示一下。

Future方式读写

读:

Path path = Paths.get("data.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path,StandardOpenOption.READ);//第二个参数是操作方式
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
//立即返回,不会阻塞
Future<Integer> future = channel.read(buffer, 0);//第二个参数是从哪开始读
//主线程可以继续处理
System.out.println("主线程继续处理...");

//需要处理数据时先判断是否读取完毕
while (!future.isDone()){
    System.out.println("还未完成...");
}
byte[] data = new byte[buffer.limit()];
buffer.flip();//切换成读模式
buffer.get(data);
System.out.println(new String(data));
buffer.clear();
channel.close();

写:

Path path = Paths.get("data2.txt");
if (!Files.exists(path)) {
    Files.createFile(path);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("为美好的世界献上祝福".getBytes());
buffer.flip();
Future<Integer> future = channel.write(buffer, 0);
//主线程继续处理
System.out.println("主线程继续...");

//需要处理数据时
while (!future.isDone()){
    System.out.println("写入未完成");
    Thread.sleep(1);
}
System.out.println("写入完成!");

  很可惜,上面open方法的第二个参数不能设置为StandardOpenOption.APPEND,也就是说这种方式的异步写出只能写入一个新文件,写入已有数据的文件的时候源数据会被覆盖。(Stack Overflow上好像有人给出了解决方式,但是我没看太明白)

回调函数CompletionHandler方式读写

读:

Path path = Paths.get("data.txt");
if (!Files.exists(path)) {
    Files.createFile(path);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024*1024);
//这里,使用了read()的重载方法
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer attachment) {
        System.out.println("读取完成,读取了"+result+"个字节");
        byte[] bytes = new byte[attachment.position()];
        attachment.flip();
        attachment.get(bytes);
        System.out.println(new String(bytes));
        attachment.clear();
    }

    @Override
    public void failed(Throwable exc, ByteBuffer attachment) {
        System.out.println("读取失败...");
    }
});
System.out.println("主线继续运行...");

  read()的重载方式,可以添加一个回调函数CompletionHandler,当读取成功的时候会执行completed方法,读取失败执行failed方法。
  这个read方法的第一个参数和第二个参数是要读入的缓冲位置,第三个参数是一个附件,可以理解为传入completed方法的参数一般用来传递上下文,比如下面的异步读取大文件就是这么做的),可以为null,第四个参数则是传入的回调函数CompletionHandler,完成或失败的时候会执行这个函数的特定方法。
  需要指出的是在异步读取完成之前不要操作缓冲,也就是read方法的第一个参数。
  回调函数CompletionHandler的第一个泛型代表读取的字节数,第二个泛型就是read方法的第三个参数的类型,例子中我使用了Buffer。

写:

Path path = Paths.get("data2.txt");
if (!Files.exists(path)){
    Files.createFile(path);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("为美好的世界献上祝福".getBytes());
buffer.flip();
channel.write(buffer, 0, path, new CompletionHandler<Integer, Path>() {
    @Override
    public void completed(Integer result, Path attachment) {
        System.out.println("写入完毕...");
    }

    @Override
    public void failed(Throwable exc, Path attachment) {
        System.out.println("写入失败...");
    }
});

System.out.println("主线程继续执行...");

至此,四种方式的读写已展示完毕。

  不过你有没有发现,读文件时,不管是Future的方式还是回调的方式,都需要把整个文件加载到内存中来,也就是Buffer的尺寸必须比文件大,有时文件比较大的时候肯定会内存暴涨甚至溢出,那么有没有一种方法可以在一个1000 byte大小的Buffer下读取大文件呢?

神奇的Stack Overflow告诉我们:有!不废话,直接上源码:

import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.io.IOException;

public class TryNio implements CompletionHandler<Integer, AsynchronousFileChannel> {

    //读取到文件的哪个位置
    int pos = 0;
    ByteBuffer buffer = null;

    public void completed(Integer result, AsynchronousFileChannel attachment) {
        //如果result为-1代表未读取任何数据
        if (result != -1) {
            pos += result;  //防止读取相同的数据

            //操作读取的数据,这里直接输出了
            System.out.print(new String(buffer.array(),0,result));

            buffer.clear();  //清空缓冲区来继续下一次读取
        }
        //启动另一个异步读取
        attachment.read(buffer, pos , attachment, this );


    }
    public void failed(Throwable exc,
                       AsynchronousFileChannel attachment) {
        System.err.println ("Error!");
        exc.printStackTrace();
    }

    //主逻辑方法
    public void doit() {
        Path file = Paths.get("data.txt");
        AsynchronousFileChannel channel =  null;
        try {
            channel = AsynchronousFileChannel.open(file);
        } catch (IOException e) {
            System.err.println ("Could not open file: " + file.toString());
            System.exit(1); 
        }
        buffer = ByteBuffer.allocate(1000);

        // 开始异步读取
        channel.read(buffer, pos , channel, this );
        // 此方法调用后会直接返回,不会阻塞
    }

    public static void main (String [] args) {
        TryNio tn = new TryNio();
        tn.doit();
        //因为doit()方法会直接返回不会阻塞,并且异步读取数据不能让虚拟机保持运行,所以这里添加一个输入来防止程序结束。
        try { System.in.read(); } catch (IOException e) { }
    }
}

  Stack Overflow上的答主选择直接实现CompletionHandler接口,而不是使用匿名内部类,他给出的原因是:

The magic happens when you initiate another asynchronous read during the complete method. This is why I discarded the anonymous class and implemented the interface itself.

翻译过来就是:

当您在complete方法期间启动另一个异步读取时,会发生魔法。这就是为什么我放弃了匿名类并实现了接口本身。

不过我自己试了试匿名内部类却并么有发生魔法:

Path path = Paths.get("data.txt");
if (!Files.exists(path)) {
    Files.createFile(path);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path);
ByteBuffer buffer = ByteBuffer.allocate(100);

channel.read(buffer, 0, channel, new CompletionHandler<Integer, AsynchronousFileChannel>() {
    int pos = 0;
    @Override
    public void completed(Integer result, AsynchronousFileChannel attachment) {
        //如果result为-1代表未读取任何数据
        if (result != -1) {
            pos += result;  //防止读取相同的数据

            //操作读取的数据,这里直接输出了
            System.out.print(new String(buffer.array(),0,result));

            buffer.clear();  //清空缓冲区来继续下一次读取
        }
        //启动另一个异步读取
        attachment.read(buffer, pos , attachment, this );
    }

    @Override
    public void failed(Throwable exc, AsynchronousFileChannel attachment) {
        System.out.println("读取失败...");
    }
});

System.out.println("主线继续运行...");
new Scanner(System.in).nextLine();

  所以还是不太明白为什么答主使用实现类而不是直接使用匿名内部类。

  用上面的方法虽然实现了异步读取大文件,但也不是没有缺点,因为这种方法的原理是在异步中递归调用异步读取,也就是说每次读取1000个字节都需要建立新异步,所以效率并没有理想中的高(不过异步的开销还是比线程低就是了)。
  还有一个小瑕疵就是读取中文的时候会乱码,因为UTF-8中中文一般是3个字节,生僻字会是4个字节,换行是1个字节,也就是说一个字有可能会被分成两半,接着就乱码了😆
乱码


此文仅是为另一篇文章铺路,请结合两篇文章同时阅读。
文章地址:浅析Java NIO


参考

至于网络NIO相关可以参考这篇文章:《在 Java 7 中体会 NIO.2 异步执行的快乐》

标签: Java

添加新评论