1. 认识NIO

之前介绍了基于InputStream、OutputStream、Reader、Writer 的输入输出,

对于高阶输入输出处理,Java 从JDK1.4 开始提供了NIO(New IO),

而Java SE 7 之后又提供了NIO2,认识与善用这些高阶输入输出处理API,

对于输入输出的处理效率会有很大的帮助。

1.1 NIO 概念

InputStream、OutputStream 的输入输出,基本上是以字节数组为单位进行低层处理,

虽然你得直接面对字节数组,但实际上多半是对字节数组阵列中整个区块进行处理。

例如,下面的方法。

实际上是整块资料读入后又整块资料写出,然而你必须处理byte[],必须记录读取的字节数,必须指定写出的byte[]起点与字节数:

	public static void dump(InputStream src, OutputStream dest) throws IOException {
		try (src; dest) {
			byte[] data = new byte[1024];
			int length;
			while ((length = src.read(data)) != -1) {
				dest.write(data, 0, length);
			}
		}
	}

虽然java.io API中也有一些装饰( Decorator ) 类型,

像DataInputStream、DataOutputStream、 BufferedReader 与BufferedWriter 等。

不过,若只要对感兴趣的区块进行处理,这些类别就不见得适合,必须自行撰写API 或寻找相关的类库来处理索引、标记等细节。

相对于串流输入输出使用InputStream、OutputStream 来衔接资料来源与目的地,

NIO 使用频道(Channel)来衔接文档节点,在处理资料时,NIO 可以让你设定缓冲区(Buffer)容量,在缓冲区中对感兴趣的资料区块进行标记,像是标记读取位置、资料有效位置。

对于这些区块标记,提供了clear()、 rewind()、flip()、compact()等高阶操作。

举例来说,上面的 dump()方法, 若使用 NIO 的话,可以这样写:

	public static void dump(ReadableByteChannel src, WritableByteChannel dest) throws IOException {
		ByteBuffer buffer = ByteBuffer.allocate(1024);
		try (src; dest) {
			while (src.read(buffer) != -1) {
				buffer.flip();
				dest.write(buffer);
				buffer.clear();
			}
		}
	}

之后会说明API 的细节,现阶段你可以先了解的是,在这段代码中,

你只要确认有将资料从Channel 中读入Buffer(read()方法不传回-1),

使用高阶filp()方法标记Buffer 中读入资料的所在区块,

然后Buffer 中的资料写到另一个Channel,

最后使用clear()方法清除Buffer 中的标记,

这个过程中,你不用接触byte[]的相关细节。

1.2 Channel 架构与常见操作

NIO 中Channel 相关接口与类型,都在java.nio.channels 包中,

Channel 接口是AutoCloseable 的子接口,因此都可以使用JDK7 之后的自动关闭资源的语法,

Channel 接口上主要新增了isOpen()方法,用来确认Channel 是否开启,

对NIO 入门来说,主要可以先认识以下的Channel 继承架构:

image-20191007205455491

ReadableByteChannel 定义了read()方法, 负责将ReadableByte Channel 中的资料读取至ByteBuffer,

WritableByteChannel 定义了write() 方法, 负责将ByteBuffer 的资料写到WritableByteChannel 中,

ScatteringByteChannel 定义的read()方法,可以将ScatteringByte Channel分配到ByteBuffer 阵列中,

GatheringByteChannel 定义的write() 方法可以将ByteBuffer数组的资料写到GatheringByteChannel。

ByteChannel 没有定义任何方法,单纯继承了 ReadableByteChannel 与 WritableByteChannel 的 行 为 ,

ByteChannel 的子接口SeekableByte Channel 可以读取与改变下一个要存取资料的位置。

我们在API 文件上可以看到Channel 的实际类型,都是抽象类别,不能直接实例化,想要取得Channel 的实例,可以使用Channels 类别,它定义了静态方法newChannel(),

可以让你从InputStream、OutputStream 分别建立ReadableByteChannel 、 WritableByteChannel ,

有些InputStream、OutputStream 实例本身也有方法可以取得Channel 实例,

比如,FileInputStream、FileOutputStream 都有个getChannel()方法可 以 分 别 取 得 FileChannel 实 例 ( 实现了SeekableByteChannel 、 GatheringByteChannel, ScatteringByteChannel 接口)。如果你已经有相关的 Channel实例,也可以透过 Channels上其他 newXXX() 静态方法,取得 InputStream、OutputStream、Reader、Writer 实例。

1.4 Buffer 架构与常见操作

在NIO 设计中,资料都是在java.nio.Buffer 中处理,Buffer 是个抽象类别,定义了clear()、flip()、reset()、rewind()等对资料区块的高阶操作,这类操作返回值都是Buffer,实际上传回this,因此在需要连续高阶操作时,可以形成管线操作风格,Buffer 的类型继承架构如下,它们都是抽象类别:

image-20191007211251728

1.4.1 容量、界限与存取位置

根据不同的资料型态处理需求,你可以选择不同的Buffer子类别,然而它们都是抽象类别,因此你不能直接实例化,然而Buffer 的直接子类别们都有个allocate()静态方法,可以让你指定Buffer 容量(Capacity),

如果是ByteBuffer,容量是指内部实作时使用的byte[]长度,

如果是CharBuffer, 容量是指char[]长度,

如果是FloatBuffer,容量是指float[]长度,

依此类推,Buffer 的容量大小可以使用capacity()方法取得,

如果想取得Buffer 内部的数组,可以使用array()方法,

如果你有个数组想要转为某个Buffer 子类实例,每个Buffer子类别实例都有wrap()静态方法可以提供这项服务。

如果你使用 ByteBuffer,它还有一个 allocateDirect()方法,相较于 allocate()方法配置的内存是由 JVM 管理,allocateDirect()会直接利用操作系统的原生I/O 操作,试着避免JVM 的中介转接,理论上会比allocate() 配置的内存更有效率,不过allocateDirect()在配置内存时会耗用较多系统资源,因此建议只用在大型、存活长的ByteBuffer 物件,并能观察出明显效能差异的场合,想要知道Buffer 是否为直接配置,可以透过isDirect() 得知。

Buffer 是个容器,你填装的资料不会超过它的容量,实际可读取或写入的资料界限(Limit)索引值可以由limit()方法获得或设定,

举例来说,容量为1024 字节的ByteBuffer, ReadableByteChannel 对其写入了512 字节, 那么limit()应该设为512,至于下一个可读取资料的位置(Position)索引值, 可以使用position()方法获得或设定。

1.4.2 clear()、flip()与rewind()

Buffer 的操作可以先从clear()、flip()与rewind()开始学习,当一个缓冲区刚被配置或调用clear()方法后,limit()会等于capacity(), position()会是0,

例如配置了容量为32 位字节的ByteBuffer 时,内部的字节数据容量、资料界限与可读写位置分别是

image-20191007212320946

若 ReadableByteChannel 对 BufferBuffer 写入了 16 个字节,那么 position()就是 16:

image-20191007212401166

现在如果要对ByteBuffer 中已写入的16 字节进行读取,position 必须设回0 , 为了不读取到索引16 , limit 必须设为16 , 虽然可以使用buffer.position(0).limit(16)来完成这项任务,不过,你可以直接调用flip()方法,它会将limit 值设为position 目前值,而position 设为0。

下面我们写一个完整的例子:

public class NIOUtil {
	public static void dump(ReadableByteChannel src, WritableByteChannel dest) throws IOException {
		ByteBuffer buffer = ByteBuffer.allocate(1024);
		try (src; dest) {
			while (src.read(buffer) != -1) {
				buffer.flip();
				dest.write(buffer);
				buffer.clear();
			}
		}
	}

	public static void main(String[] args) throws Exception {
		URL url = new URL("http://www.ripjava.com");
		ReadableByteChannel src = Channels.newChannel(url.openStream());
		try (FileOutputStream fileops = new FileOutputStream("index.html");) {
			WritableByteChannel dest = fileops.getChannel();
			NIOUtil.dump(src, dest);
		}

	}
}

这段代码可以从网站下载首页, 并自动在工作资料夹下存为index.html,

在dump()方法中,destCH.write(buffer)会将buffer 中从position 至limit 前的资料写到WritableByteChannel中,

最后position 会等于limit,因此调用clear()将position 设为0,limit 设为等于容量的值,以便下一次循序,

让ReadableByteChannel 将资料写到buffer 中。调用 rewind()方法的话,会将 position 设为 0,而 limit 不变,

这个方法通常用在想要重复读取 Buffer 中某段资料时使用,作用相当于调用 Buffer 的 position(0)方法。

1.4.3 mark()、reset()、remaining()

Buffer上还有个mark()方法,可以在目前position 上标记,在存取Buffer 之后,若调用reset()方法,会将position设回被mark( )标记的位置。

position 与 limit 之间的为剩余可存取的资料,可以使用 remaining()方法得知还有多少 长度,使用 hasRemaining()可以测试是否还有剩余可存取的资料。

2. NIO2文件系统

JDK7提出了NIO2文件系统API,在java.nio.file、java.nio.file.attribute与java.nio.file.spi包中,提供了存取默认文件系统进行各种输入/输出的API,既可简化现有文档输入/输出API的操作,也增加了许多过去没有提供的文件系统存取功能。

2. 1 API架构概述

现今世界上存在着各式各样文件系统,不同文件系统会提供不同的存取方式、文件属性、权限控制等操作。在JDK6之前,常要针对特定文件系统撰写特定程序,不仅撰写方式没有标准,针对特定功能撰写程序也会增加应用程序开发者负担。

NIO2文件系统API提供一组标准接口与类,应用程序开发者只要基于这些标准接口与类进行文件系统操作,底层实际如何进行文件系统操作,是由文件系统提供者负责(由厂商操作)。

image-20191007214856649

应用程序开发者主要使用java.nio.file与java.nio.file.attribute,包中必须操作的抽象类或接口,由文件系统提供者操作,应用程序开发者无须担心底层实际如何存取文件系统;

通常只有文件系统提供者才需关心java.nio.file.spi包。

NIO2文件系统的中心是java.nio.file.spi.FileSystemProvider,本身为抽象类,是文件系统提供者才要操作的类,作用是产生java.nio.file与java.nio.file.attribute中各种抽象类或接口的操作对象。

image-20191007214028812

对应用程序开发者而言,只要知道有FileSystemProvider的存在即可。应用程序开发者可以通过java.nio.file包中FileSystems、Paths、Files等类提供的静态方法,取得相关操作对象或进行各种文件系统操作,这些静态方法内部会运用FileSystemProvider来取得所需的操作对象,完成应有的操作。

比如想要取得java.nio.file.FileSystem操作对象,可以通过FileSystems.getDefault()取得:

 FileSystem fileSystem = FileSystems.getDefault();

FileSystems.getDefault()内部会使用FileSystemProvider操作对象的getFileSystem()方法,取得默认的FileSystem操作对象。如果有其他厂商操作的FileSystemProvider类,可以使用系统属性java.nio.file.spi.DefaultFileSystemProvider指定该厂商操作的类名称,这样就会使用指定的FileSystemProvider操作对象。

一旦更换FileSystemProvider操作对象,通过FileSystems.getDefault(),就会取得该厂商的FileSystem操作对象。当然,FileSystems、Paths、Files等类静态方法使用到的操作对象也会一并更换为该厂商的操作对象。

想要操作文档,就得先指出文档路径。Path实例是在JVM中路径的代表对象,也是NIO2文件系统API操作的起点,NIO2文件系统API中有许多操作,都必须使用Path指定路径。

想要取得Path实例,可以使用Paths.get()方法。

最基本的使用方式,就是使用字符串路径,可使用相对路径或绝对路径。

例如:

Path path = Paths.get("C:\\workspace");

Paths.get()的第二个参数开始接受不定长度自变量,因此可指定起始路径,之后的路径分段指定。

例如,以下可指定用户目录下的Documents\Downloads:

Path path2 = Paths.get(System.getProperty("user.home"), "Documents", "Downloads");
System.out.println(path2.toString());

如果用户目录是/Users/jundongpei,那么以上Path实例代表的路径就是/Users/jundongpei/Documents/Downloads

Path实例仅代表路径信息,该路径实际对应的文档或文件夹(也是一种文档)不一定存在。

Path提供一些方法取得路径的各种信息。例如:

		Path path = Paths.get(System.getProperty("user.home"), "Documents", "Downloads");
		System.out.printf("toString: %s%n", path.toString());
		System.out.printf("getFileName: %s%n", path.getFileName());
		System.out.printf("getName(0): %s%n", path.getName(0));
		System.out.printf("getNameCount: %d%n", path.getNameCount());
		System.out.printf("subpath(0,2): %s%n", path.subpath(0, 2));
		System.out.printf("getParent: %s%n", path.getParent());
		System.out.printf("getRoot: %s%n", path.getRoot());

输出如下:

toString: /Users/jundongpei/Documents/Downloads
getFileName: Downloads
getName(0): Users
getNameCount: 4
subpath(0,2): Users/jundongpei
getParent: /Users/jundongpei/Documents
getRoot: /

Path实现了Iterable接口,可以循序取得Path中分段的路径信息,当然也可以使用增强式for循环语法。

例如:

Path path = Paths.get(System.getProperty("user.home"),
                      "Documents", "Downloads");
for(Path p : path) {
  System.out.println(p);
}

输出如下:

Users
jundongpei
Documents
Downloads

路径中若有冗余信息,可以使用normalize()方法移除。

Path p1 = Paths.get("/Users/jundongpei/./Documents/Downloads").normalize();
Path p2 = Paths.get("/Users/jundongpei/../Documents/Downloads").normalize();

System.out.println(p1);
System.out.println(p2);

输出如下:

/Users/jundongpei/Documents/Downloads
/Users/Documents/Downloads

Path的toAbsolutePath()方法可以将相对路径Path转为绝对路径Path;

如果路径是符号链接(Symbolic link),toRealPath()可以转换为真正的路径,

若是相对路径则转换为绝对路径,若路径中有冗余信息也会移除。

路径与路径可以使用resolve()结合。

如果有两个路径,想知道如何从一个路径切换至另一个路径,则可以使用relativize()方法。

可以使用equals()方法比较两个Path实例的路径是否相同,

使用startsWith()比较路径起始是否相同,

使用endsWith()比较路径结尾是否相同。

如果文件系统支持符号链接,两个路径不同的Path实例,有可能是指向同一文档,

可以使用Files.isSameFile()测试看看是否如此。

如果想确定Path代表的路径,实际上是否存在文档,可以使用Files.exists()Files.notExists()

Files.exists()仅在文档存在时返回true,如果文档不存在或无法确认存不存在(例如没有权限存取文档)则返回false。

Files.notExists()会在文档不存在时返回true,如果文档存在或无法确认存不存在则返回false。

对于文档的一些基本属性,可以使用Files的isExecutable()isHidden()isReadable()isRegularFile()isSymbolicLink()isWritable()等方法来得知。

如果需要更多文件属性信息,则必须通过BasicFileAttributes或搭配FileAttributeView来取得。

2.3 属性读取与设定

在过去并无标准方式取得不同文件系统支持的不同属性,在JDK7中,可以通过BasicFileAttributes、DosFileAttributes、PosixFileAttributes,针对不同文件系统取得支持的属性信息。

image-20191008204936959

BasicFileAttributes顾名思义,就是取得各文件系统中都支持的属性,可以通过Files.readAttributes()取得BasicFileAttributes实例。

Path file = Paths.get("/Users/jundongpei/file.txt");
BasicFileAttributes attrs = 
  Files.readAttributes(file, BasicFileAttributes.class);
System.out.printf("creationTime: %s%n", attrs.creationTime());
System.out.printf("lastAccessTime: %s%n",  attrs.lastAccessTime());
System.out.printf("lastModifiedTime: %s%n",  attrs.lastModifiedTime());

System.out.printf("isDirectory: %b%n", attrs.isDirectory());
System.out.printf("isOther: %b%n", attrs.isOther());
System.out.printf("isRegularFile: %b%n",  attrs.isRegularFile());
System.out.printf("isSymbolicLink: %b%n", attrs.isSymbolicLink());
System.out.printf("size: %d%n", attrs.size());

输出如下:

creationTime: 2019-06-10T13:43:27Z
lastAccessTime: 2019-10-08T11:51:14.48452Z
lastModifiedTime: 2019-06-10T13:43:28.23568Z
isDirectory: false
isOther: false
isRegularFile: true
isSymbolicLink: false
size: 40

creationTime()lastAccessTime()lastModifiedTime()返回的是FileTime实例,也可以通过Files.getLastModifiedTime()取得最后修改时间。

若想设定最后修改时间,可以通过Files.setLastModifiedTime()指定代表修改时间的FileTime实例:

// 修改最后修改时间
long currentTimeMillis = System.currentTimeMillis();
FileTime ft = FileTime.fromMillis(currentTimeMillis);
Files.setLastModifiedTime(file, ft);
System.out.printf("lastModifiedTime: %s%n",  attrs.lastModifiedTime());

实际上,Files.setLastModifiedTime()只是个简便方法,属性设定主要可通过Files.setAttribute()方法。

Files.setAttribute()第二个自变量必须指定FileAttributeView子接口规范的名称,

格式为[view-name:]attribute-name。

view-name可以从FileAttributeView子接口操作对象的name()方法取得,

如果省略就默认为"basic",attribute-name可在FileAttributeView各子接口的API文件中查询。

例如,同样设定最后修改时间,改用Files.setAttributes()可以这样撰写:

long currentTimeMillis = System.currentTimeMillis();
FileTime ft = FileTime.fromMillis(currentTimeMillis);
Files.setAttribute(file, "lastModifiedTime", ft);

类似地,可以通过Files.getAttribute()方法取得各种文件属性,使用方式类似setAttributes(),

也可从通过Files.readAttributes()另一版本取得Map<String, Object>对象,键部分指定属性名称,就可以取得属性值。例如:

  Map<String, Object> readAttributes = Files.readAttributes(file, "size,lastModifiedTime,lastAccessTime");

DosFileAttributes继承BasicFileAttributes,新增了isArchive()、isHidden()、isReadOnly()、isSystem()等方法,

可以这样取得DosFileAttributes实例:

 DosFileAttributes dosAttributes = Files.readAttributes(file,DosFileAttributes.class);

PosixFileAttributes继承BasicFileAttributes,新增了owner()、group()方法,可取得UserPrincipal(java.security.Principal子接口)、GroupPrincipal(UserPrincipal子接口)实例,可分别取得文档的群组(Group)与拥有者(Owner)信息;permissions()会以Set返回Enum类型的PosixFilePermission实例,代表文档拥有者、群组与其他用户的读写权限信息。

如果想取得储存装置本身的信息,可以利用Files.getFileStore()方法取得指定路径的FileStore实例,

或通过FileSystem的getFileStores()方法取得所有储存装置的FileStore实例。

以下是利用FileStore计算磁盘使用率的范例:

	public static void main(String[] args) throws IOException {
        if (args.length == 0) {
            FileSystem fs = FileSystems.getDefault();
            for (FileStore store: fs.getFileStores()) {
                print(store);
            }
        } 
        else {
            for (String file: args) {
                FileStore store = Files.getFileStore(Paths.get(file));
                print(store);
            }
        }
    }
    
    public static void print(FileStore store) throws IOException {
        long total = store.getTotalSpace();
        long used = store.getTotalSpace() - store.getUnallocatedSpace();
        long usable = store.getUsableSpace();
        DecimalFormat formatter = new DecimalFormat("#,###,###");
        System.out.println(store.toString());
        System.out.printf("\t- 总容量\t%s\t字节%n", formatter.format(total));
        System.out.printf("\t- 可用空間\t%s\t字节%n", formatter.format(used));
        System.out.printf("\t- 已用空間\t%s\t字节%n", formatter.format(usable));
    }

FileSystem的getFileStores()方法会以Iterable返回所有储存装置的FileStore对象。

一个参考的执行结果如下所示:

/Volumes/PEIJD-01T1 (/dev/disk2s2)
	- 总容量	999,860,912,128	字节
	- 可用空間	842,038,857,728	字节
	- 已用空間	157,822,054,400	字节

2.4 操作文档与目录

如果想要删除Path代表的文档或目录,可以使用Files.delete()方法,

如果不存在,会抛出NoSuchFileException

如果因目录不为空而无法删除文档,会抛出DirectoryNotEmptyException

使用Files.deleteIfExists()方法也可以删除文档,这个方法在文档不存在时调用,并不会抛出异常。

如果想要复制来源Path的文档或目录至目的地Path,可以使用Files.copy()方法,

这个方法的第三个选项可以指定CopyOption接口的操作对象,CopyOption操作类有以Enum类型的StandardCopyOptionLinkOption

例如,指定StandardCopyOptionREPLACE_EXISTING实例进行复制时,

若目标文档已存在就会予以覆盖,COPY_ATTRIBUTES会尝试复制相关属性,

LinkOption的NOFOLLOW_LINKS则不会跟随符号链接。

一个使用Files.copy()的范例如下:

		Path srcPath = Paths.get("/Users/jundongpei/file.txt");
		Path dest = Paths.get("/Users/jundongpei/file2.txt");
		
		Files.copy(srcPath, dest, StandardCopyOption.REPLACE_EXISTING);

Files.copy()还有两个重载版本,

一个是接受InputStream作为来源,可直接读取数据,并将结果复制至指定的Path中;

另一个Files.copy()版本是将来源Path复制至指定的OutputStream。

例如:

URL url = new URL("http://www.ripjava.com");
Files.copy(url.openStream(), Paths.get("/Users/jundongpei/ripjava.txt"), 		    StandardCopyOption.REPLACE_EXISTING);

若要进行文档或目录移动,可以使用Files.move()方法,使用方式与Files.copy()方法类似,可指定来源Path、目的地Path与CopyOption。

如果文件系统支持原子移动,可在移动时指定StandardCopyOption.ATOMIC_MOVE选项。

如果要建立目录,可以使用Files.createDirectory()方法,如果调用时父目录不存在,会抛出NoSuchFileException。

Files.createDirectories()会在父目录不存在时一并建立。

如果要建立暂存目录,可以使用Files. createTempDirectory()方法,这个方法有可指定路径与使用默认路径建立暂存目录两个版本。

对于java.io中的基本输入/输出API,NIO2也做了封装。

例如,如果Path实例是个文档,可使用Files.readAllBytes()读取整个文档,然后以byte[]返回文档内容;

如果文档内容都是字符,则可使用Files.readAllLines()指定文档Path与编码,读取整个文档,将文档中每行收集在List<String>中返回。

如果文档内容都是字符,则需要在读取或写入时使用缓冲区,也可以使用Files.newBufferedReader()、Files.newBufferedWriter()指定文档Path与编码,它们分别会返回BufferedReader、BufferedWriter实例,可以使用它们来进行文档读取或写入。

例如:

// java.io
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(
  new FileInputStream("/Users/jundongpei/ripjava.txt"), "UTF-8"));
// NIO2的封装
BufferedReader bufferedReader =     Files.newBufferedReader(Paths.get("/Users/jundongpei/ripjava.txt"), "UTF-8");

在使用Files.newBufferedWriter()时,还可以指定OpenOption接口的操作对象,其操作类为StandardOpenOption与LinkOption(操作了CopyOption与OpenOption),可以指定开启文档时的行为,可以查看StandardOpenOption与LinkOption的API,了解有哪些选项可以使用。

如果想要以InputStream、OutputStream处理文档,也有对应的Files.newInputStream()、Files.newOutputStream()可以使用。

2.5 读取、访问目录

2.6 过滤、搜索文档