Java基础笔记(四) IO/NIO与序列化

本文主要是我在看《疯狂Java讲义》时的读书笔记,阅读的比较仓促,就用 markdown 写了个概要。

第十一章 I/O与序列化

1、File类

Java中访问本地系统是通过java.io.File类。 File类可以使用 绝对路径相对路径 来创建 File 对象,然后调用 File 对象的方法来操作文件和目录。

>> File类常用方法

  1. 访问文件名相关的方法

    • String getName():获取文件名
    • String getPath():获取路径名
    • String getAbsolutePath():获取绝对路径名
    • File getAbsoluteFile():返回绝对路径的File对象
    • String getParent():返回上一级目录名
    • boolean renameTo(File newName):重命名
  2. 文件检测相关的方法

    • boolean exists():判断是否存在
    • boolean canWrite():判断是否可写
    • boolean canRead():判断是否可读
    • boolean isFile():判断是否是文件(而不是目录)
    • boolean isDirectory():判断是否是目录
    • boolean isAbsolute():判断是否是绝对路径
  3. 获取常规文件信息

    • long lastModified():返回文件的最后修改时间
    • long length():返回文件内容的长度
  4. 文件操作相关的方法

    • boolean createNewFile():当此File对象所对应的文件不存在时,创建它。
    • boolean delete():删除文件或路径
    • static File createTempFile(String prefix, String suffix):创建临时空文件
    • void deleteOnExit():指定当Java虚拟机退出时,删除该文件或目录
  5. 目录操作相关的方法

    • boolean mkdir():创建目录
    • String[] list():列出当前目录的所有子文件名和子目录名,返回String数组
    • File[] listFiles():列出当前目录的所有子文件名和子目录名,返回File数组
    • static File[] listRoots():列出系统所有的根路径,静态方法。

>> 文件过滤器

上述File类的list()方法有一个重载版本:

  • String[] list(FilenameFilter filter)

通过过滤器参数可以只列出符合条件的文件或目录。FilenameFilter接口内只有一个方法accept(File dir, String name),该方法将依次对所有的子文件名或子目录名进行迭代,返回true则表示符合要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.io.File;
import java.io.FilenameFilter;

class MyFilter implements FilenameFilter {
public boolean accept(File dir, String name) {
return name.endsWith(".gz");
}
}

public class FileTest {
public static void main(String[] args) {
File file = new File("/home/master/software");
String[] nameList = file.list(new MyFilter());
for(String n : nameList) {
System.out.println(n);
}
}
}



2、理解Java的IO流

>> 流的分类

Java中把不同的输入/输出源(键盘、文件、网络连接等)抽象表述为“流”(Stream)。按照不同的分类方式,可以将流分为不同的类型。

  1. 按照流的流向来分:

    • 输入流:从程序的角度,只能从中读取数据,不能向其写入数据 —— 输入流主要由InputStreamReader作为基类。
    • 输出流:从程序的角度,只能向其写入数据,不能从中读取数据 —— 输出流主要由OutputStreamWriter作为基类。
  2. 按照操作的数据单元来分:

    • 字节流:字节流操作的数据单元是一个字节(8个bit)—— 字节流主要由InputStreamOutputStream作为基类。
    • 字符流:字符流操作的数据单元是一个字符(两个字节,16个bit)—— 字符流主要由ReaderWriter作为基类。
  3. 按照流的角色来分:

    • 节点流:直接与IO设备(如磁盘、网络)交互的流,称为节点流,节点流也被称为低级流。
    • 处理流:对一个已存在的流进行封装,封装后的流称为处理流,处理流也被称为高级流。

>> 流的概念模型

Java把所有IO设备里的有序数据抽象成流模型。

对于输入流的 InputStream 和 Reader 而言,它们把输入设备抽象成一个“水管”,水管里的每个“水滴”依次排序,如下图:

输入流使用 隐式的指针 来记录读取位置,每当程序从输入流里读取一个或多个“水滴”后,记录指针自动向后移动。

对于输出流的 OutputStream 和 Writer 而言,它们同样把输出设备抽象成一个“水管”,只是这个水管里一开始没有“水滴”,如下图:

当程序执行输出时,相当于依次把“水滴”放入水管中,输出流同样采用 隐式的指针 来标识当前水滴即将放入的位置。

>> 基类的接口

InputStream 和 Reader 是所有输入流的抽象基类,它们都包含如下三个方法:

  • int read():从输入流读取单个字节/字符,返回所读取到的字节/字符数据。

  • int read(byte[]/char[] buf):从输入流中最多读取 buf.length 个字节/字符数据,并将其存在 buf 数组中,返回实际读取的字节/字符数量。

  • int read(byte[]/char[] buf, int off, int len):从输入流中最多读取 len 个字节/字符数据,并将其存在 buf 数组中。放入数组 buf 时,并不是从数组起点开始,而是从 off 位置开始,返回实际读取的字节/字符数量。

可以看出,两个基类提供的方法基本一样,只是读取的数据单元不相同。当 read 方法返回 -1 时,表明到了输入流的结尾。

OutputStream 和 Writer 是所有输出流的抽象基类,它们都包含了如下三个方法:

  • void write(int c):将指定的字节/字符输出到输出流中。

  • void write(byte[]/char[] buf):将字节数组/字符数组中的数据输出到指定的输出流中。

  • void write(byte[]/char[] buf, int off, int len):将字节数组/字符数组从 off 位置开始,长度为 len 的字节/字符输出到输出流中。

其中 Writer 还包含额外的两个方法:

  • void write(String str)
  • void write(String str, int off, int len)


3、处理流模型

像 FileInputStream、FileReader 等实现类都是节点流,它们都是直接与文件交互的。处理流则是对节点流进行了一层包装,处理流的好处主要在于:

  1. 性能的提高:主要以增加缓冲的方式来提高I/O的效率;

  2. 操作的便捷:处理流可能提供一系列更便捷的方法来一次输入/输出大批量的内容;

  3. 接口的统一:通过处理流来读写,程序可以采用完全相同的代码来访问不同的数据源,随着处理流所包装的节点流的变化,程序访问的数据源也相应变化。(这是典型的装饰器设计模式)

Java程序无须理会访问的设备是磁盘、网络还是文件,只需要将这些节点流包装成处理流,通过处理流来执行输入/输出功能。在关闭I/O流资源时,只要关闭最上层的处理流即可。

那么,怎么识别处理流呢?只要流的构造器参数不是一个物理节点,而是已经存在的流,那么这种流就一定是处理流。



4、输入/输出流体系

Java的输入/输出体系提供了近40个类,如下表 —— 粗体标出的类属于节点流,必须直接与指定的物理节点关联;斜体标出的代表抽象基类,无法直接创建实例。

从表中可以看出,我们不仅可以把 数组 或 字符串 当作物理节点来访问,也可以把 管道 作为物理节点进行进程间通信。另外,Java提供了 4 个缓冲流(处理流),用以增加缓冲功能,提高输入输出效率。

>> 转换流

Java提供了两个转换流,用来将字节流转换成字符流:

  • InputStreamReader:将字节输入流转换成字符输入流;
  • OutputStreamWriter:将字节输出流转换成字符输出流。

由于字符流比字节流操作更方便,如果输入输出的是二进制内容,通常使用字节流;而如果是文本内容,则应该考虑字符流。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClass {
public static void main(String[] args) throws IOException {
InputStreamReader reader = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(reader);
String line = null;
while((line = br.readLine()) != null) {
if(line.equals("exit")) {
System.exit(1);
}
System.out.println("输入内容为:"+line);
}
}
}

如上例所示,标准输入System.in是 InputStream 类的实例,使用不太方便。由于键盘输入都是文本内容,所以可以使用InputStreamReader转换成字符流,普通的 Reader 读取输入依然不方便,可以再将普通 Reader 包装成BufferedReader,然后逐行进行读取。

>> 推回输入流

Java提供了两个与众不同的流PushbackInputStreamPushbackReader,叫推回输入流。

  • void unread(int b):将一个字节/字符推回到推回缓冲区里,从而允许重复读取刚刚读取的内容;

  • void unread(byte[]/char[] buf):将一个字节数组/字符数组推回到推回缓冲区里,从而允许重复读取刚刚读取的内容;

  • void unread(byte[]/char[] buf, int off, int len):将一个数组从off开始,长为len的字节/字符推回到推回缓冲区,从而允许重复读取刚刚读取的内容。

简单地说,Pushback 输入流就是将刚刚从输入流中读出来的内容推回去,再读一遍 —— 不过不是推回到原来的输入流中,而是推回到推回缓冲区中。每次 Pushback 输入流调用read()方法时总是先从推回缓冲区中读,读完了以后再从原输入流中读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MyClass {
public static void main(String[] args) throws IOException {
FileReader reader = new FileReader("D:/a.txt");
PushbackReader pr = new PushbackReader(reader, 32); // 推回缓冲区的长度为32
char[] buf = new char[16]; // 每次读取16个字符
String lastStr = "";
int len = 0;
while((len = pr.read(buf)) > 0) {
String currentStr = new String(buf, 0, len);
int targetIndex = 0;
if((targetIndex = (lastStr + currentStr).indexOf("yesterday")) > 0) {
pr.unread((lastStr + currentStr).toCharArray()); // 推回
if(targetIndex > 16)
buf = new char[targetIndex];
pr.read(buf, 0, targetIndex); // 再次读取指定长度的内容
System.out.print(new String(buf, 0, targetIndex));
break;
} else {
System.out.print(lastStr);
lastStr = currentStr;
}
}
pr.close();
}
}

上面的程序使用PushbackReader输入流,试图在文件中找出 “yesterday” 字符串,然后打印出目标字符串之前的内容。注意:如果push back到推回缓冲区的内容超出了推回缓冲区的大小,会报 IOException。



5、重定向

在Java中,System.inSystem.out代表标准输入与输出,默认情况下它们分别代表键盘和显示器。

在 System 类里提供了三个重定向标准输入/输出的方法:

  • static void setErr(PrintStream err):重定向标准错误输出流
  • static void setIn(InputStream in):重定向标准输入流
  • static void setOut(PrintStream out):重定向标准输出流

示例:将 System.out 重定向到文件

1
2
3
4
5
6
7
public class MyClass {
public static void main(String[] args) throws IOException {
PrintStream ps = new PrintStream(new FileOutputStream("D:/out.txt"));
System.setOut(ps); // 重定向输出到文件
System.out.print("Hello Java!");
}
}



6、RandomAccessFile

RandomAccessFile是Java提供的功能最丰富的文件访问类,它只能读写文件,不能读写其他IO设备。与普通的输入/输出流不同的是:RandomAccessFile支持“随机访问”的方式,允许自由定位文件记录指针。

它包含了如下2个方法来操作文件记录指针:

  • long getFilePointer():返回文件记录指针的位置;
  • void seek(long pos):将文件记录指针定位到 pos 位置。

RandomAccessFile 有两个构造器:RandomAccessFile(String name, String mode)RandomAccessFile(File file, String mode),第二个参数 mode 指定文件的访问模式:

  • "r":以只读的方式打开指定文件;
  • "rw":以读、写的方式打开指定文件,如果文件不存在,创建该文件;
  • "rws":以读、写的方式打开指定文件。相对于”rw”,要求对“文件内容”或“元数据”的每个更新都同步写入到底层存储设备。
  • "rwd":以读、写的方式打开指定文件。相对于”rw”,要求对“文件内容”的每个更新都同步写入到底层存储设备。(不对metadata同步更新)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyClass {
public static void main(String[] args) throws IOException {
RandomAccessFile raf = new RandomAccessFile("D:/a.txt", "r");
System.out.println("文件指针的初始位置:" + raf.getFilePointer());
raf.seek(100); // 从100字节处开始读
//raf.seek(raf.length()); // 定位到文件尾

byte[] buf = new byte[64];
int len = 0;
while((len = raf.read(buf)) > 0) {
System.out.print(new String(buf, 0, len));
}
}
}

RandomAccessFile 的 read() 方法、write() 方法与 InputStream/OutputStream 类似。



7、对象序列化(Serialize)

>> 序列化的含义和意义

  • 含义:序列化机制允许将内存中的Java对象转换成字节序列,保存到磁盘上或通过网络进行传输;其他程序(从磁盘上或从网络上)获取到这些字节序列,就可以将这些字节序列恢复成原来的Java对象。

  • 意义:序列化使得对象可以脱离程序的运行而独立存在,它是RMI(Remote Method Invoke,即远程方法调用)的参数和返回值必须实现的机制,而 RMI 又是Java EE技术的基础 —— 所有的分布式应用都需要跨平台、跨网络,所以序列化也是分布式技术的基础。

>> 使用”对象流”实现序列化

若想将某个对象序列化,该对象的类需要实现Serializable接口或Externalizable接口。

这里先讲Serializable,步骤如下:

  1. 让目标类实现Serializable标记接口即可,无须实现任何方法;
  2. 创建一个ObjectOutputStream对象输出流,调用writeObject()方法将目标类的对象输出到磁盘或网络;

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person implements java.io.Serializable {

private String name;
private int age;
public Person(String name, int age) {
System.out.println("带参数的构造器");
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

public class MyClass {
public static void main(String[] args) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/person.txt"));
Person person = new Person("科比·布莱恩特",38);
oos.writeObject(person);
}
}

运行上面程序,一个Person对象被序列化输出到文件中,该文件的内容就是person对象。

可以看到文件的内容是乱码的,因为是二进制。

如果希望从二进制流中恢复Java对象,则需要使用反序列化(Deserialize),步骤如下:

  1. 创建一个ObjectInputStream输入流(这是一个处理流),从文件中读取二进制流。
  2. 调用readObject()方法读取流中的对象,该方法返回一个 Object 类型的对象,再强制类型转换。
1
2
3
4
5
6
7
public class MyClass {
public static void main(String[] args) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/person.txt"));
Person p = (Person)ois.readObject();
System.out.println("名字:" + p.getName() + "\n年龄:" + p.getAge());
}
}

需要注意的是:

  • 反序列化恢复Java对象时,必须提供该对象所属类的 class 文件,否则将报ClassNotFoundException异常。

  • 反序列化恢复Java对象时,并没有看到程序调用构造器,这表明反序列化机制无须通过构造器来初始化Java对象。

  • 当一个可序列化类有多个父类时,这些父类要么有无参数的构造器,要么也是可序列化的,否则将抛出InvalidClassException —— 父类最好都是可序列化的,因为如果某个父类不可序列化,而只是带有无参数构造器,那么该父类中定义的成员变量值不会序列化到二进制流中。

>> 多次序列化同一个对象

如果某个类的成员变量是另一种引用类型,那么这个引用类也必须是可序列化的,否则拥有该类型成员变量的类是不可序列化的。

1
2
3
4
5
6
7
8
9
class Teacher implements java.io.Serializable {
private String name;
private Person student;
public Teacher(String name, Person student) {
this.name = name;
this.student = student;
}
// 此处省略成员变量的setter和getter方法
}

由于Teacher对象持有一个Person对象的引用,当尝试序列化一个Teacher对象时,程序会顺带将该Person对象也进行序列化,所以Person类也必须是可序列化的,否则Teacher类将不可序列化。

先假设有如下一种情况:

1
2
3
4
5
6
7
8
9
10
11
public class MyClass {
public static void main(String[] args) throws Exception {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/a.txt"));
Person student = new Person("孙悟空", 500);
Teacher t1 = new Teacher("唐僧", student);
Teacher t2 = new Teacher("菩提师祖", student);
oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(student);
}
}

当序列化 t1 对象时,程序会顺带序列化 student 对象;当序列化 t2 对象时,程序一样会顺带序列化 student 对象;最后再显式序列化了 student 对象 —— 也就是说student对象被序列化了三次。

如果程序向输出流中写入了三个student对象,那么反序列化恢复时将得到三个student对象,也就是说反序列化后,t1 和 t2 所引用的不是同一个Person对象,这显然就出现了Bug!为了避免这种情况的出现,Java采用了一种特殊的序列化算法:

  • 所有保存到磁盘中的对象都有一个序列化编号;
  • 当程序试图序列化一个对象时,程序将先检查该对象是否已经被序列化过了。若没有,系统才会将该对象转换成字节序列输出;
  • 如果某个对象已经序列化过了,程序将直接输出一个序列化编号,不会重复序列化该对象。

++所以当多次调用writeObject()输出同一个对象时,只有第一次调用会将该对象转换成字节序列输出。++

验证一下:

1
2
3
4
5
6
7
8
9
10
11
public class MyClass {
public static void main(String[] args) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/a.txt"));
Teacher t1 = (Teacher)ois.readObject();
Teacher t2 = (Teacher)ois.readObject();
Person stu = (Person)ois.readObject();
System.out.println(t1.getStudent() == stu); // true
System.out.println(t2.getStudent() == stu); // true
// 反序列化时读的顺序,必须和序列化时写的顺序一致。
}
}

>> transient关键字

如果一个类中的某些实例变量是敏感信息,或者是不可序列化的,因此不希望对该实例变量进行递归序列化,则可以使用transient关键字修饰该实例变量,那么在Java对象序列化时 该变量就会被系统忽略掉。

1
2
3
4
5
6
7
8
9
10
11
class Person implements java.io.Serializable {

private String name;
private transient int age; // transient只能修饰实例变量
public Person(String name, int age) {
System.out.println("带参数的构造器");
this.name = name;
this.age = age;
}
// 省略成员变量的setter方法和getter方法
}

transient关键字修饰的 age 变量将被完全隔离在序列化机制之外,反序列化恢复后将无法取得该成员变量的值。

>> Externalizable序列化机制

前面介绍了要让某个类的对象可序列化,该类只要实现Serializable接口,不用实现任何方法。Java还提供了另一种序列化机制 —— 实现Externalizable接口,并实现下面两个方法:

  • void writeExternal(ObjectOutput out):实现序列化,调用ObjectOutput的writeObject()方法;

  • void readExternal(ObjectInput in):实现反序列化,调用ObjectInput的readObject()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person implements java.io.Externalizable {

private String name;
private transient int age;
public Person(String name, int age) {
System.out.println("带参数的构造器");
this.name = name;
this.age = age;
}
// 省略成员变量的setter方法和getter方法

@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeObject(new StringBuffer(name).reverse());
out.writeInt(age);
}

@Override
public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException {

this.name = ((StringBuffer)in.readObject()).reverse().toString();
this.age = in.readInt();
}
}

实现 Externalizable 接口并重写两个方法以后,其余的步骤跟前面一样。

那么,这两种序列化机制有什么不同呢?

实现Serializable接口 实现Externalizable接口
系统自动存储必要信息 程序员决定存储哪些信息
Java内建支持,易于实现,只需实现该接口,无须任何代码支持 仅仅提供两个空方法,实现该接口必须为两个空方法提供实现
性能略差 性能略好

虽然实现 Externalizable 接口能带来一定的性能提升,但导致了编程复杂度的增加,所以大部分时候都采用 Serializable 接口的方式。

>> 版本兼容

前面提到了,反序列化时必须提供对象的所属类的 class 文件。但是,如果类定义被修改导致 class 文件变了呢?

  • 如果修改类时仅仅修改了方法,反序列化不受影响
  • 如果修改类时仅仅修改了静态变量或 transient 修饰的变量,反序列化同样不受影响
  • 如果修改类时修改了未被 transient 修饰的实例变量,版本不兼容,则反序列化可能会失败

Java序列化机制允许为类提供一个private static final longserialVersionUID值,该类变量的值用于标识该 Java 类的序列化版本。即便一个类升级了,只要它的 serialVersionUID 类变量值保持不变,序列化机制也会把它们当成同一个序列化版本。

JDK提供了一个serialver命令行工具来生成一个类的 serialVersionUID 版本值:

1
2
$ serialver com.songlee.test.Person
com.songlee.test.Person: private static final long serialVersionUID = 2790932326217855955L;

显示指定 serialVersionUID 有利于程序在不同 JVM 之间移植,避免类定义没有改变却因为JVM不同导致版本不兼容的情况出现。



8、NIO

前面介绍的java.io.包下的输入/输出流都是阻塞式的,而且,面向流的输入/输出通常效率不高,故 Java 从1.4版本开始,引入了一系列改进的输入/输出处理的新功能,这些新功能统称为新IO(New IO,简称NIO)

>> Java NIO概述

NIO新增了许多IO处理的类,这些类都被放在java.nio包下。与传统IO采用流处理模型不同的是,NIO采用内存映射文件的方式来处理输入/输出,即将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了,这种方式比传统IO要快得多。

IO NIO
面向流(Stream-oriented) 面向块/缓冲(Buffer-oriented)
阻塞IO(Blocking IO) 非阻塞IO(Non-blocking IO)
选择器(Selectors)

Java NIO主要有3个核心部分:

  • Channel(通道)
  • Buffer(缓冲)
  • Selector(选择器)

>> Channel

在NIO中所有的输入输出都是从一个Channel开始。Channel(通道)类似于传统的流对象,但与传统的流对象有两个主要的区别:

  1. Channel提供了一个map()方法,可以直接将“一块数据”映射到Buffer中;
  2. 程序不能直接访问Channel中的数据,读写都不行,Channel只能与Buffer进行交互。

也就是说,程序必须通过读写Buffer来访问Channel中的数据。


下面是Java NIO中一些主要的Channel实现:

  • FileChannel
  • Pipe.SinkChannel、Pipe.SourceChannel
  • SocketChannel、ServerSocketChannel
  • DatagramChannel

可以看出这些通道涵盖了管道IO、UDP和TCP网络IO,以及文件IO等 —— 除了FileChannel之外,其他的Channel都可以通过configureBlocking(false)方法设置为非阻塞模式,这也是为什么NIO称为非阻塞IO。


所有的Channel都不应该通过构造器来直接创建,而应该通过传统流对象 InputStream/OutputStream 的getChannel()方法返回对应的Channel。通道Channel最常用的有三个方法:

  • map():将Channel中的部分或全部数据映射成 ByteBuffer
  • read():一系列重载形式,用于读取Channel中的数据到Buffer中
  • write():一系列重载形式,用于将Buffer中的数据写到Channel中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyClass {
public static void main(String[] args) throws Exception {
File file = new File("D:/a.txt");
FileChannel inChannel = new FileInputStream(file).getChannel();
FileChannel outChannel = new FileOutputStream("D:/b.txt").getChannel();
// 将a.txt文件中的全部数据映射成ByteBuffer
ByteBuffer buffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
outChannel.write(buffer); // 再将数据写到outChannel,输出到b.txt文件

buffer.clear();
CharBuffer charBuf = Charset.forName("UTF-8").decode(buffer);
System.out.println(charBuf);
}
}

上述代码通过 map 一次将全部数据映射到 Buffer 中,当然也可以使用Channel的 read/write 方法多次读写,类似于传统IO一样。

>> Buffer

Buffer(缓冲)是一个抽象类,它用于和Channel交互。在Java NIO中的一些常用的Buffer实现有:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

实际使用较多的是ByteBufferCharBuffer,其中 ByteBuffer 还有一个子类MappedByteBuffer,它是 Channel.map() 方法的返回类型。


在Buffer中有三个重要的概念:

  • 容量(capacity):该Buffer的最大数据容量,创建后不能改变。
  • 界限(limit):该Buffer中的一个位置索引,位于 limit 后的数据不可读也不可写。
  • 位置(position):用于指明下一个被读或被写的位置的索引。(类似于传统IO中的记录指针)

另外,Buffer还支持一个可选的标记 mark(类似于传统IO流中的mark),通过reset()方法可以将 position 定位到 mark 处。这些值满足大小关系:0 ≤ mark ≤ position ≤ limit ≤ capacity


Buffer类都没有构造器,需通过各实现类的静态方法XxxBuffer.allocate(int capacity)来创建。 —— 刚创建时 position=0,limit=capacity,当通过 put() 方法或从Channel中读取一些数据放入Buffer中时,position会相应地向后移动。当装入数据结束后:

  • flip():调用Buffer的 flip() 方法,limit置为position,position置0,为从Buffer中取出数据做好准备。
  • clear():数据读取完以后,调用Buffer的 clear() 方法,该方法将 position置0,limit置为capacity,回到刚创建时的状态,为再次装入数据做好准备。

下面的代码可以验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyClass {
public static void main(String[] args) throws Exception {
CharBuffer buffer = CharBuffer.allocate(8);
System.out.println("position:"+buffer.position());
System.out.println("limit:"+buffer.limit());
System.out.println("capacity:"+buffer.capacity());

buffer.put("abc");
System.out.println("放入一些数据后, position:"+buffer.position());

buffer.flip(); // 准备读取数据
System.out.println("执行flip方法后, position:"+buffer.position());
System.out.println("执行flip方法后, limit:"+buffer.limit());

System.out.println("通过get方法读取数据:"+buffer.get()+buffer.get());
System.out.println("读取一些数据后,position: "+buffer.position());

buffer.clear(); // 回到刚创建时的状态
System.out.println("执行clear方法后, position:"+buffer.position());
System.out.println("执行clear方法后, limit:"+buffer.limit());

System.out.println("绝对位置的get方法: "+buffer.get(3)); // 通过绝对位置获取数据
}

Buffer提供了一系列重载形式的 put() 和 get() 方法来访问 Buffer 中的数据,分为相对和绝对两种方式:

  • 相对(Relative):从当前 position 处开始读取或写入,然后 position 进行相应的移动。
  • 绝对(Absolute):直接根据索引(下标)向Buffer中读取或写入数据,不会影响 position 的值。

>> Selector

选择器Selector是Java里实现IO复用的概念,通过它一个线程可以管理多个Channel通道,从而管理多个网络连接。

试想一下,如果一个线程负责处理一个网络IO,那么线程占用达到上限的时候怎么办?

在使用 Selector 时,还涉及到两个重要的概念:

  1. SelectableChannel:可选择的通道,使用Selector管理的通道必须都是SelectableChannel,它的特点是存在 阻塞模式 和 非阻塞模式。(除了FileChannel,其他类型的Channel都属于SelectableChannel)

  2. SelectionKey:当一个SelectableChannel向Selector中注册时,就会创建并返回一个选择键。简单点说,SelectionKey就是通道在 Selector 中的注册的标记。它维护了两个集合:

    • interest集合:表示选择器对该channel的哪些操作感兴趣,只会检测这些操作是否准备就绪。
    • ready集合:表示该channel已经为这些操作准备就绪。
1
2
3
Selector selector = Selector.open();       // 创建一个Selector
channel.configureBlocking(false); // 设置为非阻塞模式
SelectionKey key = channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

与向Selector注册之前,Channel必须处于非阻塞模式下。注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听该Channel时对什么操作感兴趣 —— 可监听以下四种类型:

  • SelectionKey.OP_CONNECT:连接
  • SelectionKey.OP_ACCEPT:接收
  • SelectionKey.OP_READ:读
  • SelectionKey.OP_WRITE:写

当你向Selector注册了多个通道以后,准备开始工作了,就可以调用几个重载的 select() 方法来检测有哪些channel准备就绪了(该channel至少有一种操作准备就绪):

  • select():阻塞到至少有一个通道的一个interest操作准备就绪了。
  • select(long timeout):同样会阻塞,但超时会返回。
  • selectNow():不会阻塞

上述三个方法均返回的是本次执行select时已经准备就绪的channel数;如果不为 0,就可以调用selector的selectedKeys()方法,得到这些已就绪的channel对应的 SelectionKey 对象的集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if(selector.select() != 0) {
Set<SelectionKey> keys = selector.selectedKeys();
Iterator it = keys.iterator();
while(it.hasNext()) {
SelectionKey k = (SelectionKey)it.next();
if(k.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (k.isConnectable()) {
// a connection was established with a remote server.
} else if (k.isReadable()) {
// the channel is ready for reading
} else if (k.isWritable()) {
// the channel is ready for writing
}
it.remove(); // 手动移除
}
}

上面的代码遍历已就绪的SelectionKey集合,并检测选择键所对应的channel的就绪事件。

当得到已就绪的SelectionKey集合后,调用SelectionKey.channel()方法就可以得到每个SelectionKey所对应的channel对象,然后就可以对通道进行处理了。通常,我们使用Selector单线程来监控多个通道,而对于select得到的channel和对应的IO操作,就可以开辟新线程或者使用线程池来处理。这也正是IO复用的意义所在。

>> 字符集和Charset

对于计算机里二进制与字符之间的转换,涉及到两个概念:编码(Encode)解码(Decode)

Java 1.4提供了Charset来处理二进制和字符之间的转换,该类包含了创建解码器和编码器的方法,还提供了一个availableCharsets()静态方法来获取 Charset 所支持的所有字符集。

1
2
3
4
5
6
public class MyClass {
public static void main(String[] args) throws Exception {
SortedMap<String, Charset> map = Charset.availableCharsets();
map.keySet().forEach((str) -> System.out.println(map.get(str))); // 输出
}
}

上面的代码使用 lambda 表达式遍历输出系统支持的所有字符集 —— Java默认使用 Unicode 字符集,但有些操作系统并不使用 Unicode 字符集,那么程序读取数据时就可能出现乱码。

1
2
3
4
5
6
7
Charset charset = Charset.forName("UTF-8");
// 编码
CharsetEncoder encoder = charset.newEncoder();
ByteBuffer byteBuf = encoder.encode(charBuf);
// 解码
CharsetDecoder decoder = charset.newDecoder();
CharBuffer charBuf = decoder.decode(byteBuf);

Charset对象的newEncoder()newDecoder()可以创建编码器和解码器,然后就可以进行二进制和字符的相互转换了。

>> 文件锁

从Java 1.4开始NIO中引入了FileLock文件锁,使用文件锁可以有效地阻止多个进程并发修改同一个文件,文件锁可以控制文件的全部或部分字节的访问。FileChannel中提供了阻塞/非阻塞的两个方法来获取 FileLock 对象:

  • lock():试图锁定某个文件并返回 FileLock 对象,如果无法得到文件锁,程序将一直阻塞。
  • tryLock():尝试锁定某个文件,它将直接返回而不是阻塞,如果获得了文件锁,返回该 FileLock 对象,否则返回 null。

上述两个方法还有两个重载形式,用于对部分文件内容而非整个文件加锁:

  • lock(long position, long size, boolean shared):对文件从 position 开始,长度为 size 的部分内容加锁。参数 shared 为true时,该锁是一个共享锁;shared 为false时,该锁是一个互斥锁。
  • tryLock(long position, long size, boolean shared):非阻塞式的加锁方法,参数作用同上。
1
2
3
4
5
6
7
8
public class MyClass {
public static void main(String[] args) throws Exception {
FileChannel channel = new FileOutputStream("D:/a.txt").getChannel();
FileLock lock = channel.tryLock(); // 使用非阻塞的方式获取文件锁
Thread.sleep(3000);
lock.release(); // 释放文件锁
}
}

处理完文件后通过 FileLock 的 release() 方法释放文件锁。



9、NIO.2

在Java 4引入了NIO后,Java 7又对原有的NIO进行了重大改进:

  • 提供了全面的文件IO和文件系统访问支持,位于java.nio.file包下;
  • 基于异步Channel的IO。

Java 7把这种改进称为NIO.2

>>Path、Paths和Files

早期的Java只提供了一个 File 类来访问文件系统,但 File 类的功能比较有限,方法性能也不高,而且大多数方法在出错时仅返回失败而不会提供异常信息。为了弥补这种不足,NIO.2 引入了Path接口和FilesPaths两个工具类。

  • Path:代表一个平台无关的平台路径;
  • Paths:工具类,包含了两个返回 Path 的静态工厂方法,比如Path path = Paths.get(".")
  • Files:工具类,包含了大量的操作文件的静态方法,例如文件复制、文件读写、列出路径下所有文件目录等。
1
2
3
4
5
6
7
8
9
10
11
public class MyClass {
public static void main(String[] args) throws Exception {
Path path = Paths.get("."); // 。表示当前路径
Files.list(path).forEach(p -> System.out.println(p));
Files.isHidden(Paths.get("b.txt")); // 判断是否是隐藏文件
Files.size(Paths.get("out.txt")); // 获取文件大小
FileStore C = Files.getFileStore(Paths.get("C:")); // C盘
System.out.println("C盘全部空间:" + C.getTotalSpace());
System.out.println("C盘可用空间:" + C.getUsableSpace());
}
}

我们应该熟悉 Files 工具类,它可以大大简化文件系统的访问。更多请自行查询API。

>>FileVisitor遍历文件和目录

早期Java版本中,如果要遍历指定目录下的所有文件和子目录,只能进行递归遍历,复杂且灵活性低。

NIO引入了 Files 工具类以后,现在可以用更优雅的方式遍历文件和子目录了。首先,我们需要实现一个FileVisitor文件访问器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyFileVisitor implements FileVisitor<Path> {
@Override
public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) throws IOException
{

// 访问子目录之前触发本方法
return null;
}

@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {

// 访问子目录之后触发本方法
return null;
}

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {

// 访问file文件时触发本方法
return null;
}

@Override
public FileVisitResult visitFileFailed(Path file, IOException exc)
throws IOException {

// 访问file文件失败时触发本方法
return null;
}
}

上述四个方法都返回一个FileVisitorResult对象,它是一个枚举类,代表了访问之后的后续行为。

  • CONTINUE:继续访问;
  • SKIP_SIBLINGS:继续访问,但不访问该文件/目录的兄弟文件/目录;
  • SKIP_SUBTREE:继续访问,但不访问该文件/目录的子目录树;
  • TERMINATE:中止访问。

实际使用时没必要实现全部的4个方法,所以Java提供了一个 SimpleFileVisitor 简化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyClass {
public static void main(String[] args) throws Exception {
Path path = Paths.get("."); // 。表示当前路径
Files.walkFileTree(path, new SimpleFileVisitor<Path>(){
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {

System.out.println("正在访问 "+ file + " 文件");
if(file.endsWith("MyClass.java")) {
System.out.println("---已找到目标文件---");
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) throws IOException
{

System.out.println("正在访问 " + dir + " 目录");
return FileVisitResult.CONTINUE;
}
});
}
}

如上面代码,Files工具类提供了静态方法Files.walkFileTree(Path, FileVisitor)用于遍历指定目录下的所有文件和目录,当找到以 MyClass.java 结尾的目标文件后,程序停止遍历。—— 本程序可用于对指定目录进行搜索。

>> WatchService监控文件变化

NIO.2的Path类提供了一个方法,可以优雅地监控指定目录下文件的变化:

  • register(WatchService watcher, WatchEvent.Kind<?>... events)

其中 WatchService 代表一个文件系统监听服务,它负责监听path目录下的文件变化;event参数指定要监听哪些类型的事件。

一旦使用register()完成注册之后,就可以调用WatchService的三个方法来获取被监听目录的文件变化事件:

  • WatchKey poll():获取下一个WatchKey,如果没有WatchKey发生就立即返回null;
  • WatcheKey poll(long timeout,TimeUnit unit):尝试等待timeout时间去获取下一个WatchKey;
  • WatchKey take():获取下一个WatchKey,如果没有发生就一直等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyClass {
public static void main(String[] args) throws Exception {
WatchService watcher = FileSystems.getDefault().newWatchService();
Path path = Paths.get("D:/");
path.register(watcher, // 注册
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
while(true) {
WatchKey key = watcher.take(); // 获取下一个文件变化事件
for(WatchEvent<?> event : key.pollEvents()) {
System.out.println(event.context()+" 文件发生了 "+event.kind()+"事件!");
}
if(!key.reset()) { // 重设WatchKey,重设失败则退出监听
break;
}
}
}
}

上述程序监听D:盘下文件的新建、修改和删除事件。如果程序需要一直监控,则应该使用take()阻塞式方法;如果只需要监控指定时间,则可以考虑使用poll()方法。

>> 访问文件属性

本章开头介绍了,传统的File类可以获取一些简单的文件属性,比如最后修改时间、文件长度、是否隐藏文件等。

为了获取或修改更多的文件属性,NIO.2在java.nio.file.attribute包下提供了大量的工具类,运用这些工具类,开发者可以非常简单地读取、修改文件属性。这些工具类主要分为以下两类:

  • XxxAttributeView:代表某种文件属性的视图。
  • XxxAttributes:代表某种文件属性的集合,程序一般通过 XxxAttributeView 对象获取 XxxAttributes。

这里只介绍两种,很多请自行查询:

  • BasicFileAttributeView:它可以获取或修改文件的基本属性,包括文件的最后修改时间,最后访问时间,创建时间,大小,是否为目录,是否为符号链接等。它的readAttribute()方法返回一个BasicFileAttributes对象,对文件夹基本属性的修改是通过BasicFileAtributes对象完成。
  • DosFileAttributeView:它主要用于获取或修改文件DOS相关属性,比如文件是否只读,是否隐藏,是否是系统文件,是否是存档文件等。它的readAttributes()方法返回一个DosFileAttributes对象对这些属性的修改其实是由DosfileAttributes对象来完成。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class MyClass {
    public static void main(String[] args) throws Exception {
    Path path=Paths.get("D:/a.txt");
    BasicFileAttributeView attrView = Files.getFileAttributeView(path, BasicFileAttributeView.class);
    BasicFileAttributes attrs = attrView.readAttributes();
    System.out.println("创建时间" + new Date(attrs.creationTime().toMillis()));
    System.out.println("文件大小" + attrs.size());
    DosFileAttributeView dosView = Files.getFileAttributeView(path, DosFileAttributeView.class);
    dosView.setHidden(true);
    dosView.setReadOnly(true);
    }
    }