本文主要是我在看《疯狂Java讲义》时的读书笔记,阅读的比较仓促,就用 markdown 写了个概要。
第十一章 I/O与序列化
1、File类
Java中访问本地系统是通过java.io.File
类。 File类可以使用 绝对路径 或 相对路径 来创建 File 对象,然后调用 File 对象的方法来操作文件和目录。
>> File类常用方法
访问文件名相关的方法
String getName()
:获取文件名String getPath()
:获取路径名String getAbsolutePath()
:获取绝对路径名File getAbsoluteFile()
:返回绝对路径的File对象String getParent()
:返回上一级目录名boolean renameTo(File newName)
:重命名
文件检测相关的方法
boolean exists()
:判断是否存在boolean canWrite()
:判断是否可写boolean canRead()
:判断是否可读boolean isFile()
:判断是否是文件(而不是目录)boolean isDirectory()
:判断是否是目录boolean isAbsolute()
:判断是否是绝对路径
获取常规文件信息
long lastModified()
:返回文件的最后修改时间long length()
:返回文件内容的长度
文件操作相关的方法
boolean createNewFile()
:当此File对象所对应的文件不存在时,创建它。boolean delete()
:删除文件或路径static File createTempFile(String prefix, String suffix)
:创建临时空文件void deleteOnExit()
:指定当Java虚拟机退出时,删除该文件或目录
目录操作相关的方法
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
18import 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)。按照不同的分类方式,可以将流分为不同的类型。
按照流的流向来分:
- 输入流:从程序的角度,只能从中读取数据,不能向其写入数据 —— 输入流主要由
InputStream
和Reader
作为基类。 - 输出流:从程序的角度,只能向其写入数据,不能从中读取数据 —— 输出流主要由
OutputStream
和Writer
作为基类。
- 输入流:从程序的角度,只能从中读取数据,不能向其写入数据 —— 输入流主要由
按照操作的数据单元来分:
- 字节流:字节流操作的数据单元是一个字节(8个bit)—— 字节流主要由
InputStream
和OutputStream
作为基类。 - 字符流:字符流操作的数据单元是一个字符(两个字节,16个bit)—— 字符流主要由
Reader
和Writer
作为基类。
- 字节流:字节流操作的数据单元是一个字节(8个bit)—— 字节流主要由
按照流的角色来分:
- 节点流:直接与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 等实现类都是节点流,它们都是直接与文件交互的。处理流则是对节点流进行了一层包装,处理流的好处主要在于:
性能的提高:主要以增加缓冲的方式来提高I/O的效率;
操作的便捷:处理流可能提供一系列更便捷的方法来一次输入/输出大批量的内容;
接口的统一:通过处理流来读写,程序可以采用完全相同的代码来访问不同的数据源,随着处理流所包装的节点流的变化,程序访问的数据源也相应变化。(这是典型的装饰器设计模式)
Java程序无须理会访问的设备是磁盘、网络还是文件,只需要将这些节点流包装成处理流,通过处理流来执行输入/输出功能。在关闭I/O流资源时,只要关闭最上层的处理流即可。
那么,怎么识别处理流呢?只要流的构造器参数不是一个物理节点,而是已经存在的流,那么这种流就一定是处理流。
4、输入/输出流体系
Java的输入/输出体系提供了近40个类,如下表 —— 粗体标出的类属于节点流,必须直接与指定的物理节点关联;斜体标出的代表抽象基类,无法直接创建实例。
从表中可以看出,我们不仅可以把 数组 或 字符串 当作物理节点来访问,也可以把 管道 作为物理节点进行进程间通信。另外,Java提供了 4 个缓冲流(处理流),用以增加缓冲功能,提高输入输出效率。
>> 转换流
Java提供了两个转换流,用来将字节流转换成字符流:
InputStreamReader
:将字节输入流转换成字符输入流;OutputStreamWriter
:将字节输出流转换成字符输出流。
由于字符流比字节流操作更方便,如果输入输出的是二进制内容,通常使用字节流;而如果是文本内容,则应该考虑字符流。1
2
3
4
5
6
7
8
9
10
11
12
13public 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提供了两个与众不同的流PushbackInputStream
、PushbackReader
,叫推回输入流。
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
25public 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.in
和System.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
7public 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
14public 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
,步骤如下:
- 让目标类实现
Serializable
标记接口即可,无须实现任何方法; - 创建一个
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
26class 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),步骤如下:
- 创建一个
ObjectInputStream
输入流(这是一个处理流),从文件中读取二进制流。 - 调用
readObject()
方法读取流中的对象,该方法返回一个 Object 类型的对象,再强制类型转换。
1 | public class MyClass { |
需要注意的是:
反序列化恢复Java对象时,必须提供该对象所属类的 class 文件,否则将报
ClassNotFoundException
异常。反序列化恢复Java对象时,并没有看到程序调用构造器,这表明反序列化机制无须通过构造器来初始化Java对象。
当一个可序列化类有多个父类时,这些父类要么有无参数的构造器,要么也是可序列化的,否则将抛出
InvalidClassException
—— 父类最好都是可序列化的,因为如果某个父类不可序列化,而只是带有无参数构造器,那么该父类中定义的成员变量值不会序列化到二进制流中。
>> 多次序列化同一个对象
如果某个类的成员变量是另一种引用类型,那么这个引用类也必须是可序列化的,否则拥有该类型成员变量的类是不可序列化的。1
2
3
4
5
6
7
8
9class 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
11public 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
11public 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
11class 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 | class Person implements java.io.Externalizable { |
实现 Externalizable 接口并重写两个方法以后,其余的步骤跟前面一样。
那么,这两种序列化机制有什么不同呢?
实现Serializable接口 | 实现Externalizable接口 |
---|---|
系统自动存储必要信息 | 程序员决定存储哪些信息 |
Java内建支持,易于实现,只需实现该接口,无须任何代码支持 | 仅仅提供两个空方法,实现该接口必须为两个空方法提供实现 |
性能略差 | 性能略好 |
虽然实现 Externalizable 接口能带来一定的性能提升,但导致了编程复杂度的增加,所以大部分时候都采用 Serializable 接口的方式。
>> 版本兼容
前面提到了,反序列化时必须提供对象的所属类的 class 文件。但是,如果类定义被修改导致 class 文件变了呢?
- 如果修改类时仅仅修改了方法,反序列化不受影响
- 如果修改类时仅仅修改了静态变量或 transient 修饰的变量,反序列化同样不受影响
- 如果修改类时修改了未被 transient 修饰的实例变量,版本不兼容,则反序列化可能会失败
Java序列化机制允许为类提供一个private static final long
的serialVersionUID
值,该类变量的值用于标识该 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(通道)类似于传统的流对象,但与传统的流对象有两个主要的区别:
- Channel提供了一个
map()
方法,可以直接将“一块数据”映射到Buffer中; - 程序不能直接访问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中的部分或全部数据映射成 ByteBufferread()
:一系列重载形式,用于读取Channel中的数据到Buffer中write()
:一系列重载形式,用于将Buffer中的数据写到Channel中
1 | public class MyClass { |
上述代码通过 map 一次将全部数据映射到 Buffer 中,当然也可以使用Channel的 read/write 方法多次读写,类似于传统IO一样。
>> Buffer
Buffer
(缓冲)是一个抽象类,它用于和Channel交互。在Java NIO中的一些常用的Buffer实现有:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
实际使用较多的是ByteBuffer
和CharBuffer
,其中 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
23public 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 时,还涉及到两个重要的概念:
SelectableChannel:可选择的通道,使用Selector管理的通道必须都是SelectableChannel,它的特点是存在 阻塞模式 和 非阻塞模式。(除了FileChannel,其他类型的Channel都属于SelectableChannel)
SelectionKey:当一个SelectableChannel向Selector中注册时,就会创建并返回一个选择键。简单点说,SelectionKey就是通道在 Selector 中的注册的标记。它维护了两个集合:
- interest集合:表示选择器对该channel的哪些操作感兴趣,只会检测这些操作是否准备就绪。
- ready集合:表示该channel已经为这些操作准备就绪。
1 | Selector selector = Selector.open(); // 创建一个Selector |
与向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
17if(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
6public 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
7Charset 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 | public class MyClass { |
处理完文件后通过 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
接口和Files
、Paths
两个工具类。
Path
:代表一个平台无关的平台路径;Paths
:工具类,包含了两个返回 Path 的静态工厂方法,比如Path path = Paths.get(".")
;Files
:工具类,包含了大量的操作文件的静态方法,例如文件复制、文件读写、列出路径下所有文件目录等。
1 | public class MyClass { |
我们应该熟悉 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
29class 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
24public 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 | public class MyClass { |
上述程序监听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
12public 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);
}
}