15.2 理解synchronized

上一节,我们提到,共享内存有两个重要问题,一个是竞态条件,另一个是内存可见性,解决这两个问题的一个方案是使用synchronized关键字,本节就来讨论这个关键字。我们先来了解synchronized的用法和基本原理,然后再从多个角度进一步理解,最后介绍使用synchronized实现的同步容器及其注意事项。

15.2.1 用法和基本原理

synchronized可以用于修饰类的实例方法、静态方法和代码块,我们分别介绍。

1.实例方法

上节我们介绍了一个计数的例子,当多个线程并发执行counter++的时候,由于该语句不是原子操作,出现了意料之外的结果,这个问题可以用synchronized解决,如代码清单15-4所示。

代码清单15-4 用synchronized修饰的Counter类
1
2
3
4
5
6
7
8
9
public class Counter {
private int count;
public synchronized void incr(){
count ++;
}
public synchronized int getCount() {
return count;
}
}

Counter是一个简单的计数器类,incr方法和getCount方法都加了synchronized修饰。加了synchronized后,方法内的代码就变成了原子操作,当多个线程并发更新同一个Counter对象的时候,也不会出现问题。使用的代码如代码清单15-5所示。

代码清单15-5 多线程访问synchronized保护的Counter对象
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
30
31
32
33
34
public class Counter {
private int count;
public synchronized void incr(){
count ++;
}
public synchronized int getCount() {
return count;
}
}
public class CounterThread extends Thread {
Counter counter;
public CounterThread(Counter counter) {
this.counter = counter;
}
@Override
public void run() {
for(int i = 0; i < 1000; i++) {
counter.incr();
}
}
public static void main(String[] args) throws InterruptedException {
int num = 1000;
Counter counter = new Counter();
Thread[] threads = new Thread[num];
for(int i = 0; i < num; i++) {
threads[i] = new CounterThread(counter);
threads[i].start();
}
for (int i = 0; i < num; i++) {
threads[i].join();
}
System.out.println(counter.getCount());
}
}

与上节类似,我们创建了1000个线程,传递了相同的counter对象,每个线程主要就是调用Counter的incr方法1000次,main线程等待子线程结束后输出counter的值,这次,不论运行多少次,结果都是正确的100万。

这里,synchronized到底做了什么呢?看上去,synchronized使得同时只能有一个线程执行实例方法,但这个理解是不确切的。多个线程是可以同时执行同一个synchronized实例方法的,只要它们访问的对象是不同的即可,比如:

1
2
3
4
5
6
Counter counter1 = new Counter();
Counter counter2 = new Counter();
Thread t1 = new CounterThread(counter1);
Thread t2 = new CounterThread(counter2);
t1.start();
t2.start();

这里,t1和t2两个线程是可以同时执行Counter的incr方法的,因为它们访问的是不同的Counter对象,一个是counter1,另一个是counter2。

所以,synchronized实例方法实际保护的是同一个对象的方法调用,确保同时只能有一个线程执行。再具体来说,synchronized实例方法保护的是当前实例对象,即this, this对象有一个锁和一个等待队列,锁只能被一个线程持有,其他试图获得同样锁的线程需要等待。执行synchronized实例方法的过程大致如下:
1)尝试获得锁,如果能够获得锁,继续下一步,否则加入等待队列,阻塞并等待唤醒。
2)执行实例方法体代码。
3)释放锁,如果等待队列上有等待的线程,从中取一个并唤醒,如果有多个等待的线程,唤醒哪一个是不一定的,不保证公平性。

synchronized的实际执行过程比这要复杂得多,而且Java虚拟机采用了多种优化方式以提高性能,但从概念上,我们可以这么简单理解。

当前线程不能获得锁的时候,它会加入等待队列等待,线程的状态会变为BLOCKED。

我们再强调下,synchronized保护的是对象而非代码,只要访问的是同一个对象的synchronized方法,即使是不同的代码,也会被同步顺序访问。比如,对于Counter中的两个实例方法getCount和incr,对同一个Counter对象,一个线程执行getCount,另一个执行incr,它们是不能同时执行的,会被synchronized同步顺序执行。

此外,需要说明的是,synchronized方法不能防止非synchronized方法被同时执行。比如,如果给Counter类增加一个非synchronized方法:

1
2
3
public void decr(){
count --;
}

则该方法可以和synchronized的incr方法同时执行,这通常会出现非期望的结果,所以,一般在保护变量时,需要在所有访问该变量的方法上加上synchronized

2.静态方法

synchronized同样可以用于静态方法,如代码清单15-6所示。

代码清单15-6 synchronized修饰静态方法
1
2
3
4
5
6
7
8
9
public class StaticCounter {
private static int count = 0;
public static synchronized void incr() {
count++;
}
public static synchronized int getCount() {
return count;
}
}

前面我们说,synchronized保护的是对象,对实例方法,保护的是当前实例对象this,对静态方法,保护的是哪个对象呢?是类对象,这里是StaticCounter.class。实际上,每个对象都有一个锁和一个等待队列,类对象也不例外。

synchronized静态方法和synchronized实例方法保护的是不同的对象,不同的两个线程,可以一个执行synchronized静态方法,另一个执行synchronized实例方法。

3.代码块

除了用于修饰方法外,synchronized还可以用于包装代码块,比如对于代码清单15-4的 Counter类,等价的代码如代码清单15-7所示。

代码清单15-7 synchronized代码块修饰的Counter类
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter {
private int count;
public void incr(){
synchronized(this){
count ++;
}
}
public int getCount() {
synchronized(this){
return count;
}
}
}

synchronized括号里面的就是保护的对象,对于实例方法,就是this, {}里面是同步执行的代码。对于前面的StaticCounter类,等价的代码如代码清单15-8所示。

代码清单15-8 synchronized代码块修饰的StaticCounter类
1
2
3
4
5
6
7
8
9
10
11
12
13
public class StaticCounter {
private static int count = 0;
public static void incr() {
synchronized(StaticCounter.class){
count++;
}
}
public static int getCount() {
synchronized(StaticCounter.class){
return count;
}
}
}

synchronized同步的对象可以是任意对象,任意对象都有一个锁和等待队列,或者说,任何对象都可以作为锁对象。比如,Counter类的等价代码还可以如代码清单15-9所示。

代码清单15-9 使用单独对象作为锁的Counter类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Counter {
private int count;
private Object lock = new Object();
public void incr(){
synchronized(lock){
count ++;
}
}
public int getCount() {
synchronized(lock){
return count;
}
}
}

15.2.2 进一步理解synchronized

介绍了synchronized的基本用法和原理之后,我们再从下面几个角度来进一步介绍syn-chronized:

  • 可重入性。
  • 内存可见性。
  • 死锁。

1.可重入性

synchronized有一个重要的特征,它是可重入的,也就是说,对同一个执行线程,它在获得了锁之后,在调用其他需要同样锁的代码时,可以直接调用。比如,在一个syn-chronized实例方法内,可以直接调用其他synchronized实例方法。可重入是一个非常自然的属性,应该是很容易理解的,之所以强调,是因为并不是所有锁都是可重入的,后续章节我们会看到不可重入的锁。

可重入是通过记录锁的持有线程和持有数量来实现的,当调用被synchronized保护的代码时,检查对象是否已被锁,如果是,再检查是否被当前线程锁定,如果是,增加持有数量,如果不是被当前线程锁定,才加入等待队列,当释放锁时,减少持有数量,当数量变为0时才释放整个锁。

2.内存可见性

对于复杂一些的操作,synchronized可以实现原子操作,避免出现竞态条件,但对于明显的本来就是原子的操作方法,也需要加synchronized吗?比如,下面的开关类Switcher只有一个boolean变量on和对应的setter/getter方法:

1
2
3
4
5
6
7
8
9
public class Switcher {
private boolean on;
public boolean isOn() {
return on;
}
public void setOn(boolean on) {
this.on = on;
}
}

当多线程同时访问同一个Switcher对象时,会有问题吗?没有竞态条件问题,但正如上节所说,有内存可见性问题,而加上synchronized可以解决这个问题。

synchronized除了保证原子操作外,它还有一个重要的作用,就是保证内存可见性,在释放锁时,所有写入都会写回内存,而获得锁后,都会从内存中读最新数据。

不过,如果只是为了保证内存可见性,使用synchronized的成本有点高,有一个更轻量级的方式,那就是给变量加修饰符volatile,如下所示:

1
2
3
4
5
6
7
8
9
public class Switcher {
private volatile boolean on;
public boolean isOn() {
return on;
}
public void setOn(boolean on) {
this.on = on;
}
}

加了volatile之后,Java会在操作对应变量时插入特殊的指令,保证读写到内存最新值,而非缓存的值。

3.死锁

使用synchronized或者其他锁,要注意死锁。所谓死锁就是类似这种现象,比如,有a、b两个线程,a持有锁A,在等待锁B,而b持有锁B,在等待锁A, a和b陷入了互相等待,最后谁都执行不下去,如代码清单15-10所示。

代码清单15-10 死锁示例
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
30
31
32
33
34
35
36
37
38
39
40
public class DeadLockDemo {
private static Object lockA = new Object();
private static Object lockB = new Object();
private static void startThreadA() {
Thread aThread = new Thread() {
@Override
public void run() {
synchronized (lockA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
synchronized (lockB) {
}
}
}
};
aThread.start();
}
private static void startThreadB() {
Thread bThread = new Thread() {
@Override
public void run() {
synchronized (lockB) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
synchronized (lockA) {
}
}
}
};
bThread.start();
}
public static void main(String[] args) {
startThreadA();
startThreadB();
}
}

运行后aThread和bThread陷入了相互等待。怎么解决呢?首先,应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。比如,对于上面的例子,可以约定都先申请lockA,再申请lockB。

不过,在复杂的项目代码中,这种约定可能难以做到。还有一种方法是使用后续章节介绍的显式锁接口Lock,它支持尝试获取锁(tryLock)和带时间限制的获取锁方法,使用这些方法可以在获取不到锁的时候释放已经持有的锁,然后再次尝试获取锁或干脆放弃,以避免死锁。

如果还是出现了死锁,怎么办呢?Java不会主动处理,不过,借助一些工具,我们可以发现运行中的死锁,比如,Java自带的jstack命令会报告发现的死锁。对于上面的程序,在笔者的计算机中,jstack会生成图15-1所示的报告。

epub_923038_127

图15-1 jstack的死锁检测

15.2.3 同步容器及其注意事项

我们知道,类Collection中有一些方法,可以返回线程安全的同步容器,比如:

1
2
3
public static <T> Collection<T> synchronizedCollection(Collection<T> c)
public static <T> List<T> synchronizedList(List<T> list)
public static <K, V> Map<K, V> synchronizedMap(Map<K, V> m)

它们是给所有容器方法都加上synchronized来实现安全的,比如Synchronized-Collection,其部分代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static class SynchronizedCollection<E> implements Collection<E> {
final Collection<E> c; //Backing Collection
final Object mutex; //Object on which to synchronize
SynchronizedCollection(Collection<E> c) {
if(c==null)
throw new NullPointerException();
this.c = c;
mutex = this;
}
public int size() {
synchronized (mutex) {return c.size(); }
}
public boolean add(E e) {
synchronized (mutex) {return c.add(e); }
}
public boolean remove(Object o) {
synchronized (mutex) {return c.remove(o); }
}
//…
}

这里线程安全针对的是容器对象,指的是当多个线程并发访问同一个容器对象时,不需要额外的同步操作,也不会出现错误的结果。

加了synchronized,所有方法调用变成了原子操作,客户端在调用时,是不是就绝对安全了呢?不是的,至少有以下情况需要注意:

  • 复合操作,比如先检查再更新。
  • 伪同步。
  • 迭代。

我们分别介绍。

1.复合操作

先来看复合操作,我们看段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EnhancedMap <K, V> {
Map<K, V> map;
public EnhancedMap(Map<K, V> map){
this.map = Collections.synchronizedMap(map);
}
public V putIfAbsent(K key, V value){
V old = map.get(key);
if(old! =null){
return old;
}
return map.put(key, value);
}
public V put(K key, V value){
return map.put(key, value);
}
//…
}

EnhancedMap是一个装饰类,接受一个Map对象,调用synchronizedMap转换为了同步容器对象map,增加了一个方法putIfAbsent,该方法只有在原Map中没有对应键的时候才添加(在Java 8之后,Map接口增加了putIfAbsent默认方法,这是针对Java 8之前的Map接口演示概念)。

map的每个方法都是安全的,但这个复合方法putIfAbsent是安全的吗?显然是否定的,这是一个检查然后再更新的复合操作,在多线程的情况下,可能有多个线程都执行完了检查这一步,都发现Map中没有对应的键,然后就会都调用put,这就破坏了putIf-Absent方法期望保持的语义。

2.伪同步

那给该方法加上synchronized就能实现安全吗?如下所示:

1
2
3
4
5
6
7
public synchronized V putIfAbsent(K key, V value){
V old = map.get(key);
if(old! =null){
return old;
}
return map.put(key, value);
}

答案是否定的!为什么呢?同步错对象了。putIfAbsent同步使用的是EnhancedMap对象,而其他方法(如代码中的put方法)使用的是Collections.synchronizedMap返回的对象map,两者是不同的对象。要解决这个问题,所有方法必须使用相同的锁,可以使用EnhancedMap的对象锁,也可以使用map。使用EnhancedMap对象作为锁,则Enhanced-Map中的所有方法都需要加上synchronized。使用map作为锁,putIfAbsent方法可以改为:

1
2
3
4
5
6
7
8
9
public V putIfAbsent(K key, V value){
synchronized(map){
V old = map.get(key);
if(old! =null){
return old;
}
return map.put(key, value);
}
}

3.迭代

对于同步容器对象,虽然单个操作是安全的,但迭代并不是。我们看个例子,创建一个同步List对象,一个线程修改List,另一个遍历,看看会发生什么,如代码清单15-11所示。

代码清单15-11 同步容器迭代问题
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
30
31
32
33
private static void startModifyThread(final List<String> list) {
Thread modifyThread = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 100; i++) {
list.add("item " + i);
try {
Thread.sleep((int) (Math.random() * 10));
} catch (InterruptedException e) {
}
}
}
});
modifyThread.start();
}
private static void startIteratorThread(final List<String> list) {
Thread iteratorThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
for(String str : list) {
}
}
}
});
iteratorThread.start();
}
public static void main(String[] args) {
final List<String> list = Collections
.synchronizedList(new ArrayList<String>());
startIteratorThread(list);
startModifyThread(list);
}

运行该程序,程序抛出并发修改异常:

1
2
3
Exception in thread "Thread-0" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
at java.util.ArrayList$Itr.next(ArrayList.java:831)

我们之前介绍过这个异常,如果在遍历的同时容器发生了结构性变化,就会抛出该异常。同步容器并没有解决这个问题,如果要避免这个异常,需要在遍历的时候给整个容器对象加锁。比如,上面的代码startIteratorThread可以改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void startIteratorThread(final List<String> list) {
Thread iteratorThread = new Thread(new Runnable() {
@Override
public void run() {
while(true) {
synchronized(list){
for(String str : list) {
}
}
}
}
});
iteratorThread.start();
}

4.并发容器

除了以上这些注意事项,同步容器的性能也是比较低的,当并发访问量比较大的时候性能比较差。所幸的是,Java中还有很多专为并发设计的容器类,比如:

  • CopyOnWriteArrayList。
  • ConcurrentHashMap。
  • ConcurrentLinkedQueue。
  • ConcurrentSkipListSet。

这些容器类都是线程安全的,但都没有使用synchronized,没有迭代问题,直接支持一些复合操作,性能也高得多,它们能解决什么问题?怎么使用?实现原理是什么?我们后续章节介绍。

至此,关于synchronized就介绍完了。本节详细介绍了synchronized的用法和实现原理,为进一步理解synchronized,介绍了可重入性、内存可见性、死锁等,最后,介绍了同步容器及其注意事项,如复合操作、伪同步、迭代异常、并发容器等。

14.5 使用Jackson序列化为JSON/XML/MessagePack

由于Java标准序列化机制的一些限制,实践中经常使用一些替代方案,比如XML/JSON/MessagePack。Java SDK中对这些格式的支持有限,有很多第三方的类库提供了更为方便的支持,Jackson是其中一种,它支持多种格式,包括XML/JSON/MessagePack等,本节就来介绍如何使用Jackson进行序列化。我们先来简单了解下这些格式以及Jackson。

14.5.1 基本概念

XML/JSON都是文本格式,都容易阅读和理解,格式细节我们就不介绍了,后面我们会看到一些例子,来演示其基本格式。XML是最早流行的跨语言数据交换标准格式,如果不熟悉,可以查看 http://www.w3school.com.cn/xml/ 快速了解。JSON是一种更为简单的格式,最近几年来越来越流行,如果不熟悉,可以查看 http://json.org/json-zh.html 。MessagePack是一种二进制形式的JSON,编码更为精简高效,官网地址是 http://msgpack.org/ 。JSON有多种二进制形式,MessagePack只是其中一种。

Jackson的Wiki地址是 http://wiki.fasterxml.com/JacksonHome ,它起初主要是用来支持JSON格式的,现在也支持很多其他格式,它的各种方式的使用方式是类似的。要使用Jackson,需要下载相应的库。对于JSON/XML,本节使用2.8.5版本,对于MessagePack,本节使用0.8.11版本,所有依赖库均可从以下地址下载:https://github.com/swiftma/program-logic/tree/master/jackson_libs 。配置好依赖库后,下面我们就来介绍如何使用。

14.5.2 基本用法

我们还是通过Student类来演示Jackson的基本用法,格式包括JSON、XML和Message-Pack。

1. JSON

序列化一个Student对象的基本代码为:

1
2
3
4
5
Student student = new Student("张三", 18, 80.9d);
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(student);
System.out.println(str);

Jackson序列化的主要类是ObjectMapper,它是一个线程安全的类,可以初始化并配置一次,被多个线程共享,SerializationFeature.INDENT_OUTPUT的目的是格式化输出,以便于阅读。ObjectMapper的writeValueAsString方法就可以将对象序列化为字符串,输出为:

1
2
3
4
5
{
"name" : "张三",
"age" : 18,
"score" : 80.9
}

ObjectMapper还有其他方法,可以输出字节数组,写出到文件、OutputStream、Writer等,方法声明如下:

1
2
3
4
public byte[] writeValueAsBytes(Object value)
public void writeValue(OutputStream out, Object value)
public void writeValue(Writer w, Object value)
public void writeValue(File resultFile, Object value)

比如,输出到文件”student.json”,代码为:

1
mapper.writeValue(new File("student.json"), student);

ObjectMapper怎么知道要保存哪些字段呢?与Java标准序列化机制一样,它也使用反射,默认情况下,它会保存所有声明为public的字段,或者有public getter方法的字段。

反序列化的代码如下所示:

1
2
3
ObjectMapper mapper = new ObjectMapper();
Student s = mapper.readValue(new File("student.json"), Student.class);
System.out.println(s.toString());

使用readValue方法反序列化,有两个参数:一个是输入源,这里是文件student.json;另一个是反序列化后的对象类型,这里是Student.class,输出为:

1
Student [name=张三, age=18, score=80.9]

说明反序列化的结果是正确的,除了接受文件,还可以是字节数组、字符串、Input-Stream、Reader等,如下所示:

1
2
3
4
public <T> T readValue(InputStream src, Class<T> valueType)
public <T> T readValue(Reader src, Class<T> valueType)
public <T> T readValue(String content, Class<T> valueType)
public <T> T readValue(byte[] src, Class<T> valueType)

在反序列化时,默认情况下,Jackson假定对象类型有一个无参的构造方法,它会先调用该构造方法创建对象,然后解析输入源进行反序列化。

2. XML

使用类似的代码,格式可以为XML,唯一需要改变的是替换ObjectMapper为Xml-Mapper。XmlMapper是ObjectMapepr的子类,序列化代码为:

1
2
3
4
5
6
Student student = new Student("张三", 18, 80.9d);
ObjectMapper mapper = new XmlMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(student);
mapper.writeValue(new File("student.xml"), student);
System.out.println(str);

输出为:

1
2
3
4
5
<Student>
<name>张三</name>
<age>18</age>
<score>80.9</score>
</Student>

反序列化代码为:

1
2
3
ObjectMapper mapper = new XmlMapper();
Student s = mapper.readValue(new File("student.xml"), Student.class);
System.out.println(s.toString());

3. MessagePack

类似的代码,格式可以为MessagePack,同样使用ObjectMapper类,但传递一个Mess-agePackFactory对象。另外,MessagePack是二进制格式,不能写出为String,可以写出为文件、OutpuStream或字节数组。序列化代码为:

1
2
3
4
Student student = new Student("张三", 18, 80.9d);
ObjectMapper mapper = new ObjectMapper(new MessagePackFactory());
byte[] bytes = mapper.writeValueAsBytes(student);
mapper.writeValue(new File("student.bson"), student);

序列后的字节如图14-4所示。

epub_923038_124

图14-4 MessagePack序列化示例

反序列化代码为:

1
2
3
ObjectMapper mapper = new ObjectMapper(new MessagePackFactory());
Student s = mapper.readValue(new File("student.bson"), Student.class);
System.out.println(s.toString());

14.5.3 容器对象

对于容器对象,Jackson也是可以自动处理的,但用法稍有不同,我们来看下List和Map。

1. List

序列化一个学生列表的代码为:

1
2
3
4
5
6
7
List<Student> students = Arrays.asList(new Student[] {
new Student("张三", 18, 80.9d), new Student("李四", 17, 67.5d) });
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(students);
mapper.writeValue(new File("students.json"), students);
System.out.println(str);

这与序列化一个学生对象的代码是类似的,输出为:

1
2
3
4
5
6
7
8
9
[ {
"name" : "张三",
"age" : 18,
"score" : 80.9
}, {
"name" : "李四",
"age" : 17,
"score" : 67.5
} ]

反序列化代码不同,要新建一个TypeReference匿名内部类对象来指定类型,代码如下所示:

1
2
3
4
ObjectMapper mapper = new ObjectMapper();
List<Student> list = mapper.readValue(new File("students.json"),
new TypeReference<List<Student>>() {});
System.out.println(list.toString());

XML/MessagePack的代码是类似的,我们就不赘述了。

2. Map

Map与List类似,序列化不需要特殊处理,但反序列化需要通过TypeReference指定类型,我们看一个XML的例子。序列化一个学生Map的代码为:

1
2
3
4
5
6
7
8
Map<String, Student> map = new HashMap<String, Student>();
map.put("zhangsan", new Student("张三", 18, 80.9d));
map.put("lisi", new Student("李四", 17, 67.5d));
ObjectMapper mapper = new XmlMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(map);
mapper.writeValue(new File("students_map.xml"), map);
System.out.println(str);

输出为:

1
2
3
4
5
6
7
8
9
10
11
12
<HashMap>
<lisi>
<name>李四</name>
<age>17</age>
<score>67.5</score>
</lisi>
<zhangsan>
<name>张三</name>
<age>18</age>
<score>80.9</score>
</zhangsan>
</HashMap>

反序列化的代码为:

1
2
3
4
ObjectMapper mapper = new XmlMapper();
Map<String, Student> map = mapper.readValue(new File("students_map.xml"),
new TypeReference<Map<String, Student>>() {});
System.out.println(map.toString());

14.5.4 复杂对象

对于复杂一些的对象,Jackson也是可以自动处理的,我们让Student类稍微复杂一些,改为如下定义:

1
2
3
4
5
6
7
public class ComplexStudent {
String name;
int age;
Map<String, Double> scores;
ContactInfo contactInfo;
//省略构造方法和getter/setter方法
}

分数改为一个Map,键为课程,ContactInfo表示联系信息,是一个单独的类,定义如下:

1
2
3
4
5
6
public class ContactInfo {
String phone;
String address;
String email;
//省略构造方法和getter/setter方法
}

构建一个ComplexStudent对象,代码为:

1
2
3
4
5
6
7
8
9
10
ComplexStudent student = new ComplexStudent("张三", 18);
Map<String, Double> scoreMap = new HashMap<>();
scoreMap.put("语文", 89d);
scoreMap.put("数学", 83d);
student.setScores(scoreMap);
ContactInfo contactInfo = new ContactInfo();
contactInfo.setPhone("18500308990");
contactInfo.setEmail("zhangsan@sina.com");
contactInfo.setAddress("中关村");
student.setContactInfo(contactInfo);

我们看JSON序列化,代码没有特殊的,如下所示:

1
2
3
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
mapper.writeValue(System.out, student);

输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "张三",
"age": 18,
"scores": {
"语文": 89.0,
"数学": 83.0
},
"contactInfo": {
"phone": "18500308990",
"address": "中关村",
"email": "zhangsan@sina.com"
}
}

XML格式的代码也是类似的,替换ObjectMapper为XmlMapper即可,输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
<ComplexStudent>
<name>张三</name>
<age>18</age>
<scores>
<语文>89.0</语文>
<数学>83.0</数学>
</scores>
<contactInfo>
<phone>18500308990</phone>
<address>中关村</address>
<email>zhangsan@sina.com</email>
</contactInfo>
</ComplexStudent>

反序列化的代码也不需要特殊处理,指定类型为ComplexStudent.class即可。

14.5.5 定制序列化

上面的例子中,我们没有做任何定制,默认的配置就是可以的。但很多情况下,我们需要做一些配置,Jackson主要支持两种配置方法。
1)注解,后续章节会详细介绍注解,这里主要是介绍Jackson一些注解的用法。
2)配置ObjectMapper对象,ObjectMapper支持对序列化和反序列化过程做一些配置,前面使用的SerializationFeature.INDENT_OUTPUT是其中一种。

哪些情况需要配置呢?我们看一些典型的场景。
1)配置达到类似标准序列化中transient关键字的效果,忽略一些字段。
2)在标准序列化中,可以自动处理引用同一个对象、循环引用的情况,反序列化时,可以自动忽略不认识的字段,可以自动处理继承多态,但Jackson都不能自动处理,这些情况都需要进行配置。
3)标准序列化的结果是二进制、不可读的,但XML/JSON格式是可读的,有时我们希望控制这个显示的格式。
4)默认情况下,反序列时,Jackson要求类有一个无参构造方法,但有时类没有无参构造方法,Jackson支持配置其他构造方法。

针对这些场景,我们分别介绍。

1.忽略字段

在Java标准序列化中,如果字段标记为了transient,就会在序列化中被忽略,在Jack-son中,可以使用以下两个注解之一。

  • @JsonIgnore:用于字段、getter或setter方法,任一地方的效果都一样。
  • @JsonIgnoreProperties:用于类声明,可指定忽略一个或多个字段。

比如,上面的Student类,忽略分数字段,可以为:

1
2
@JsonIgnore
double score;

也可以修饰getter方法,如:

1
2
3
4
@JsonIgnore
public double getScore() {
return score;
}

也可以修饰Student类,如:

1
2
@JsonIgnoreProperties("score")
public class Student {

加了以上任一标记后,序列化后的结果中将不再包含score字段,在反序列化时,即使输入源中包含score字段的内容,也不会给score字段赋值。

2.引用同一个对象

我们看个简单的例子,有两个类Common和A, A中有两个Common对象,为便于演示,我们将所有属性定义为了public,它们的类定义如下:

1
2
3
4
5
6
7
static class Common {
public String name;
}
static class A {
public Common first;
public Common second;
}

有一个A对象,如下所示:

1
2
3
4
Common c = new Common();
c.name= "common";
A a = new A();
a.first = a.second = c;

a对象的first和second都指向都一个c对象,不加额外配置,序列化a的代码为:

1
2
3
4
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(a);
System.out.println(str);

输出为:

1
2
3
4
5
6
7
8
{
"first": {
"name": "abc"
},
"second": {
"name": "abc"
}
}

在反序列化后,first和second将指向不同的对象,如下所示:

1
2
3
4
5
6
A a2 = mapper.readValue(str, A.class);
if(a2.first == a2.second){
System.out.println("reference same object");
}else{
System.out.println("reference different objects");
}

输出为:

1
reference different objects

那怎样才能保持这种对同一个对象的引用关系呢?可以使用注解@JsonIdentityInfo,对Common类做注解,如下所示:

1
2
3
4
5
6
@JsonIdentityInfo(
generator = ObjectIdGenerators.IntSequenceGenerator.class,
property="id")
static class Common {
public String name;
}

@JsonIdentityInfo中指定了两个属性,property=”id”表示在序列化输出中新增一个属性”id”以表示对象的唯一标示,generator表示对象唯一ID的产生方法,这里是使用整数顺序数产生器IntSequenceGenerator。

加了这个标记后,序列化输出会变为:

1
2
3
4
5
6
7
{
"first": {
"id": 1,
"name": "common"
},
"second": 1
}

注意:”first”中加了一个属性”id”,而”second”的值只是1,表示引用第一个对象,这个格式反序列化后,first和second会指向同一个对象。

3.循环引用

我们看个循环引用的例子。有两个类Parent和Child,它们相互引用,为便于演示,我们将所有属性定义为了public,类定义如下:

1
2
3
4
5
6
7
8
static class Parent   {
public String name;
public Child child;
}
static class Child {
public String name;
public Parent parent;
}

有一个对象,如下所示:

1
2
3
4
5
6
Parent parent = new Parent();
parent.name = "老马";
Child child = new Child();
child.name = "小马";
parent.child = child;
child.parent = parent;

如果序列化parent这个对象,Jackson会进入无限循环,最终抛出异常,解决这个问题,可以分别标记Parent类中的child和Child类中的parent字段,将其中一个标记为主引用,而另一个标记为反向引用,主引用使用@JsonManagedReference,反向引用使用@JsonBackReference,如下所示:

1
2
3
4
5
6
7
8
9
10
static class Parent   {
public String name;
@JsonManagedReference
public Child child;
}
static class Child {
public String name;
@JsonBackReference
public Parent parent;
}

加了这个注解后,序列化就没有问题了。我们看XML格式的序列化代码:

1
2
3
4
ObjectMapper mapper = new XmlMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String str = mapper.writeValueAsString(parent);
System.out.println(str);

输出为:

1
2
3
4
5
6
<Parent>
<name>老马</name>
<child>
<name>小马</name>
</child>
</Parent>

在输出中,反向引用没有出现。不过,在反序列化时,Jackson会自动设置Child对象中的parent字段的值,比如:

1
2
Parent parent2 = mapper.readValue(str, Parent.class);
System.out.println(parent2.child.parent.name);

输出为:老马。说明标记为反向引用的字段的值也被正确设置了。

4.反序列化时忽略未知字段

在Java标准序列化中,反序列化时,对于未知字段会自动忽略,但在Jackson中,默认情况下会抛出异常。还是以Student类为例,如果student.json文件的内容为:

1
2
3
4
5
6
{
"name": "张三",
"age": 18,
"score": 333,
"other": "其他信息"
}

其中,other属性是Student类没有的,如果使用标准的反序列化代码:

1
2
ObjectMapper mapper = new ObjectMapper();
Student s = mapper.readValue(new File("student.json"), Student.class);

Jackson会抛出异常:

1
2
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:  Unrecognized
field "other" ...

怎样才能忽略不认识的字段呢?可以配置ObjectMapper,如下所示:

1
2
3
ObjectMapper mapper = new ObjectMapper();
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
Student s = mapper.readValue(new File("student.json"), Student.class);

这样就没问题了,这个属性是配置在整个ObjectMapper上的,如果只是希望配置Student类,可以在Student类上使用如下注解:

1
2
3
4
@JsonIgnoreProperties(ignoreUnknown=true)
public class Student {
//...
}

5.继承和多态

Jackson也不能自动处理多态的情况。我们看个例子,有4个类,定义如下,我们忽略了构造方法和getter/setter方法:

1
2
3
4
5
6
7
8
9
10
11
static class Shape {
}
static class Circle extends Shape {
private int r;
}
static class Square extends Shape {
private int l;
}
static class ShapeManager {
private List<Shape> shapes;
}

ShapeManager中的Shape列表中的对象可能是Circle,也可能是Square。比如,有一个ShapeManager对象,如下所示:

1
2
3
4
5
ShapeManager sm =   new ShapeManager();
List<Shape> shapes = new ArrayList<Shape>();
shapes.add(new Circle(10));
shapes.add(new Square(5));
sm.setShapes(shapes);

使用JSON格式序列化,输出为:

1
2
3
4
5
6
7
8
9
10
{
"shapes": [
{
"r": 10
},
{
"l": 5
}
]
}

这个输出看上去是没有问题的,但由于输出中没有类型信息,反序列化时,Jackson不知道具体的Shape类型是什么,就会抛出异常。

解决方法是在输出中包含类型信息,在基类Shape前使用如下注解:

1
2
3
4
5
6
@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Circle.class, name = "circle"),
@JsonSubTypes.Type(value = Square.class, name = "square") })
static class Shape {
}

这些注解看上去比较多,含义是指在输出中增加属性”type”,表示对象的实际类型,对Circle类,使用”circle”表示其类型,而对于Square类,使用”square”。加了注解后,序列化输出变为:

1
2
3
4
5
6
7
8
9
10
11
12
{
"shapes": [
{
"type": "circle",
"r": 10
},
{
"type": "square",
"l": 5
}
]
}

这样,反序列化时就可以正确解析了。

6.修改字段名称

对于XML/JSON格式,有时,我们希望修改输出的名称,比如对Student类,我们希望输出的字段名变为对应的中文,可以使用@JsonProperty进行注解,如下所示:

1
2
3
4
5
6
7
8
9
public class Student {
@JsonProperty("名称")
String name;
@JsonProperty("年龄")
int age;
@JsonProperty("分数")
double score;
//……
}

加了这个注解后,输出的JSON格式变为:

1
2
3
4
5
{
"名称": "张三",
"年龄": 18,
"分数": 80.9
}

对于XML格式,一个常用的修改是根元素的名称。默认情况下,它是对象的类名,比如对Student对象,它是”Student”,如果希望修改,比如改为小写”student”,可以使用@JsonRootName修饰整个类,如下所示:

1
2
@JsonRootName("student")
public class Student {

7.格式化日期

默认情况下,日期的序列化格式为一个长整数,比如:

1
2
3
static class MyDate {
public Date date = new Date();
}

序列化代码:

1
2
3
MyDate date = new MyDate();
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(System.out, date);

输出如下所示:

1
{"date": 1482758152509}

这个格式是不可读的,怎样才能可读呢?使用@JsonFormat注解,如下所示:

1
2
3
4
static class MyDate {
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8")
public Date date = new Date();
}

加注解后,输出变为如下所示:

1
{"date":"2016-12-26 21:26:18"}

8.配置构造方法

前面的Student类,如果没有定义默认构造方法,只有如下构造方法:

1
2
3
4
5
public Student(String name, int age, double score) {
this.name = name;
this.age = age;
this.score = score;
}

则反序列化时会抛异常,提示找不到合适的构造方法,可以使用@JsonCreator@Json-Property标记该构造方法,如下所示:

1
2
3
4
5
6
7
8
9
@JsonCreator
public Student(
@JsonProperty("name") String name,
@JsonProperty("age") int age,
@JsonProperty("score") double score) {
this.name = name;
this.age = age;
this.score = score;
}

这样,反序列化就没有问题了。

14.5.6 Jackson对XML支持的局限性

需要说明的是,对于XML格式,Jackson的支持不是太全面。比如,对于一个Map<String, List<String>>对象,Jackson可以序列化,但不能反序列化,如下所示:

1
2
3
4
5
6
7
8
Map<String, List<String>> map = new HashMap<>();
map.put("hello", Arrays.asList(new String[]{"老马", "小马"}));
ObjectMapper mapper = new XmlMapper();
String str = mapper.writeValueAsString(map);
System.out.println(str);
Map<String, List<String>> map2 = mapper.readValue(str,
new TypeReference<Map<String, List<String>>>() {});
System.out.println(map2);

在反序列化时,代码会抛出异常,如果mapper是一个ObjectMapper对象,反序列化就没有问题。如果Jackson不能满足需求,可以考虑其他库,如XStream(http://x-stream.github.io/ )。

14.5.7 小结

本节介绍了如何使用Jackson来实现JSON/XML/MessagePack序列化。使用方法是类似的,主要是创建的ObjectMapper对象不一样,很多情况下,不需要做额外配置,但也有很多情况,需要做额外配置,配置方式主要是注解,我们介绍了Jackson中的很多典型注解,大部分注解适用于所有格式。本节完整的代码在github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c63下。

Jackson还支持很多其他格式,如YAML、AVRO、Protobuf、Smile等。Jackson中也还有很多其他配置和注解,用得相对较少,限于篇幅,我们就不介绍了。

从注解的用法,我们可以看出,它也是一种神奇的特性,它类似于注释,但却能实实在在改变程序的行为,它是怎么做到的呢?我们暂且搁置这个问题,留待到第22章介绍。

至此,关于文件的整个内容就介绍完了,从下一章开始,让我们一起探索并发和线程的世界!

14.4 标准序列化机制

在前面几节,我们在将对象保存到文件时,使用的是DataOutputStream,从文件读入对象时,使用的是DataInputStream,使用它们,需要逐个处理对象中的每个字段,我们提到,这种方式比较烦琐,Java中有一种更为简单的机制,那就是序列化。

简单来说,序列化就是将对象转化为字节流,反序列化就是将字节流转化为对象。在Java中,具体如何来使用呢?它是如何实现的?有什么优缺点?本节就来探讨这些问题,我们先从它的基本用法谈起。

14.4.1 基本用法

要让一个类支持序列化,只需要让这个类实现接口java.io.Serializable。Serializable没有定义任何方法,只是一个标记接口。比如,对于前面章节提到的Student类,为支持序列化,可改为:

1
2
3
public class Student implements Serializable {
//省略主体代码
}

声明实现了Serializable接口后,保存/读取Student对象就可以使用ObjectOutput-Stream/ObjectInputStream流了。ObjectOutputStream是OutputStream的子类,但实现了Object-Output接口。ObjectOutput是DataOutput的子接口,增加了一个方法:

1
public void writeObject(Object obj) throws IOException

这个方法能够将对象obj转化为字节,写到流中。

ObjectInputStream是InputStream的子类,它实现了ObjectInput接口。ObjectInput是DataInput的子接口,增加了一个方法:

1
public Object readObject() throws ClassNotFoundException, IOException

这个方法能够从流中读取字节,转化为一个对象。

使用这两个流,保存学生列表的代码就可以变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void writeStudents(List<Student> students)
throws IOException {
ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("students.dat")));
try {
out.writeInt(students.size());
for(Student s : students) {
out.writeObject(s);
}
} finally {
out.close();
}
}

而从文件中读入学生列表的代码可以变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static List<Student> readStudents() throws IOException,
ClassNotFoundException {
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
new FileInputStream("students.dat")));
try {
int size = in.readInt();
List<Student> list = new ArrayList<>(size);
for(int i = 0; i < size; i++) {
list.add((Student) in.readObject());
}
return list;
} finally {
in.close();
}
}

实际上,只要List对象也实现了Serializable(ArrayList/LinkedList都实现了),上面代码还可以进一步简化,读写只需要一行代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void writeStudents(List<Student> students)
throws IOException {
ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("students.dat")));
try {
out.writeObject(students);
} finally {
out.close();
}
}
public static List<Student> readStudents() throws IOException,
ClassNotFoundException {
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
new FileInputStream("students.dat")));
try {
return (List<Student>) in.readObject();
} finally {
in.close();
}
}

是不是很神奇?只要将类声明实现Serializable接口,然后就可以使用ObjectOutput-Stream/ObjectInputStream直接读写对象了。我们之前介绍的各种类,如String、Date、Double、ArrayList、LinkedList、HashMap、TreeMap等,都实现了Serializable。

14.4.2 复杂对象

上面例子中的Student对象是非常简单的,如果对象比较复杂呢?比如:
1)如果a、b两个对象都引用同一个对象c,序列化后c是保存两份还是一份?在反序列化后还能让a、b指向同一个对象吗?答案是,c只会保存一份,反序列化后指向相同对象。
2)如果a、b两个对象有循环引用呢?即a引用了b,而b也引用了a。这种情况Java也没问题,可以保持引用关系。

这就是Java序列化机制的神奇之处,它能自动处理引用同一个对象的情况,也能自动处理循环引用的情况,具体例子我们就不介绍了,感兴趣可以参看微信公众号“老马说编程”第62篇文章。

14.4.3 定制序列化

默认的序列化机制已经很强大了,它可以自动将对象中的所有字段自动保存和恢复,但这种默认行为有时候不是我们想要的。

对于有些字段,它的值可能与内存位置有关,比如默认的hashCode()方法的返回值,当恢复对象后,内存位置肯定变了,基于原内存位置的值也就没有了意义。还有一些字段,可能与当前时间有关,比如表示对象创建时的时间,保存和恢复这个字段就是不正确的。

还有一些情况,如果类中的字段表示的是类的实现细节,而非逻辑信息,那默认序列化也是不适合的。为什么不适合呢?因为序列化格式表示一种契约,应该描述类的逻辑结构,而非与实现细节相绑定,绑定实现细节将使得难以修改,破坏封装。

比如,我们在容器类中介绍的LinkedList,它的默认序列化就是不适合的。为什么呢?因为LinkedList表示一个List,它的逻辑信息是列表的长度,以及列表中的每个对象,但LinkedList类中的字段表示的是链表的实现细节,如头尾节点指针,对每个节点,还有前驱和后继节点指针等。

那怎么办呢?Java提供了多种定制序列化的机制,主要的有两种:一种是transient关键字,另外一种是实现writeObject和readObject方法。

将字段声明为transient,默认序列化机制将忽略该字段,不会进行保存和恢复。比如,类LinkedList中,它的字段都声明为了transient,如下所示:

1
2
3
transient int size = 0;
transient Node<E> first;
transient Node<E> last;

声明为了transient,不是说就不保存该字段了,而是告诉Java默认序列化机制,不要自动保存该字段了,可以实现writeObject/readObject方法来自己保存该字段。

类可以实现writeObject方法,以自定义该类对象的序列化过程,其声明必须为:

1
2
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException

可以在这个方法中,调用ObjectOutputStream的方法向流中写入对象的数据。比如, LinkedList使用如下代码序列化列表的逻辑数据:

1
2
3
4
5
6
7
8
9
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
s.defaultWriteObject();
//写元素个数
s.writeInt(size);
//循环写每个元素
for(Node<E> x = first; x ! = null; x = x.next)
s.writeObject(x.item);
}

需要注意的是代码:

1
s.defaultWriteObject();

这一行是必需的,它会调用默认的序列化机制,默认机制会保存所有没声明为transient的字段,即使类中的所有字段都是transient,也应该写这一行,因为Java的序列化机制不仅会保存纯粹的数据信息,还会保存一些元数据描述等隐藏信息,这些隐藏的信息是序列化之所以能够神奇的重要原因。

与writeObject对应的是readObject方法,通过它自定义反序列化过程,其声明必须为:

1
2
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException

在这个方法中,调用ObjectInputStream的方法从流中读入数据,然后初始化类中的成员变量。比如,LinkedList的反序列化代码为:

1
2
3
4
5
6
7
8
9
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
//读元素个数
int size = s.readInt();
//循环读入每个元素
for (int i = 0; i < size; i++)
linkLast((E)s.readObject());
}

注意代码:

1
s.defaultReadObject();

这一行代码也是必需的。

除了自定义writeObject/readObject方法,还有一些自定义序列化过程的机制:Exter-nalizable接口、readResolve方法和writeReplace方法,这些机制用得相对较少,我们就不介绍了。

14.4.4 序列化的基本原理

稍微总结一下。

1)如果类的字段表示的就是类的逻辑信息,如上面的Student类,那就可以使用默认序列化机制,只要声明实现Serializable接口即可。
2)否则的话,如LinkedList,那就可以使用transient关键字,实现writeObject和read-Object自定义序列化过程。
3)Java的序列化机制可以自动处理如引用同一个对象、循环引用等情况。

序列化到底是如何发生的呢?关键在ObjectOutputStream的writeObject和ObjectInput-Stream的readObject方法内。它们的实现都非常复杂,正因为这些复杂的实现才使得序列化看上去很神奇,我们简单介绍其基本逻辑。

writeObject的基本逻辑是:
1)如果对象没有实现Serializable,抛出异常NotSerializableException。
2)每个对象都有一个编号,如果之前已经写过该对象了,则本次只会写该对象的引用,这可以解决对象引用和循环引用的问题。
3)如果对象实现了writeObject方法,调用它的自定义方法。
4)默认是利用反射机制(反射在第21章介绍),遍历对象结构图,对每个没有标记为transient的字段,根据其类型,分别进行处理,写出到流,流中的信息包括字段的类型,即完整类名、字段名、字段值等。

readObject的基本逻辑是:
1)不调用任何构造方法;
2)它自己就相当于是一个独立的构造方法,根据字节流初始化对象,利用的也是反射机制;
3)在解析字节流时,对于引用到的类型信息,会动态加载,如果找不到类,会抛出ClassNotFoundException。

14.4.5 版本问题

前面的介绍,我们忽略了一个问题,那就是版本问题。我们知道,代码是在不断演化的,而序列化的对象可能是持久保存在文件上的,如果类的定义发生了变化,那持久化的对象还能反序列化吗?

默认情况下,Java会给类定义一个版本号,这个版本号是根据类中一系列的信息自动生成的。在反序列化时,如果类的定义发生了变化,版本号就会变化,与流中的版本号就会不匹配,反序列化就会抛出异常,类型为java.io.InvalidClassException。

通常情况下,我们希望自定义这个版本号,而非让Java自动生成,一方面是为了更好地控制,另一方面是为了性能,因为Java自动生成的性能比较低。怎么自定义呢?在类中定义如下变量:

1
private static final long serialVersionUID = 1L;

在Java IDE如Eclipse中,如果声明实现了Serializable而没有定义该变量,IDE会提示自动生成。这个变量的值可以是任意的,代表该类的版本号。在序列化时,会将该值写入流,在反序列化时,会将流中的值与类定义中的值进行比较,如果不匹配,会抛出InvalidClassException。

那如果版本号一样,但实际的字段不匹配呢?Java会分情况自动进行处理,以尽量保持兼容性,大概分为三种情况:

  • 字段删掉了:即流中有该字段,而类定义中没有,该字段会被忽略;
  • 新增了字段:即类定义中有,而流中没有,该字段会被设为默认值;
  • 字段类型变了:对于同名的字段,类型变了,会抛出InvalidClassException。

14.4.6 序列化特点分析

序列化的主要用途有两个:一个是对象持久化;另一个是跨网络的数据交换、远程过程调用。Java标准的序列化机制有很多优点,使用简单,可自动处理对象引用和循环引用,也可以方便地进行定制,处理版本问题等,但它也有一些重要的局限性。
1)Java序列化格式是一种私有格式,是一种Java特有的技术,不能被其他语言识别,不能实现跨语言的数据交换。
2)Java在序列化字节中保存了很多描述信息,使得序列化格式比较大。
3)Java的默认序列化使用反射分析遍历对象结构,性能比较低。
4)Java的序列化格式是二进制的,不方便查看和修改。

由于这些局限性,实践中往往会使用一些替代方案。在跨语言的数据交换格式中,XML/JSON是被广泛采用的文本格式,各种语言都有对它们的支持,文件格式清晰易读。有很多查看和编辑工具,它们的不足之处是性能和序列化大小,在性能和大小敏感的领域,往往会采用更为精简高效的二进制方式,如ProtoBuf、Thrift、MessagePack等。

至此,关于Java的标准序列化机制就介绍完了。我们介绍了它的用法和基本原理,最后分析了它的特点,它是一种神奇的机制,通过简单的Serializable接口就能自动处理很多复杂的事情,但它也有一些重要的限制,最重要的是不能跨语言。

14.3 内存映射文件

本节介绍内存映射文件,内存映射文件不是Java引入的概念,而是操作系统提供的一种功能,大部分操作系统都支持。我们先来介绍内存映射文件的基本概念,它是什么,能解决什么问题,然后介绍如何在Java中使用。我们会设计和实现一个简单的、持久化的、跨程序的消息队列来演示内存映射文件的应用。

14.3.1 基本概念

所谓内存映射文件,就是将文件映射到内存,文件对应于内存中的一个字节数组,对文件的操作变为对这个字节数组的操作,而字节数组的操作直接映射到文件上。这种映射可以是映射文件全部区域,也可以是只映射一部分区域。

不过,这种映射是操作系统提供的一种假象,文件一般不会马上加载到内存,操作系统只是记录下了这回事,当实际发生读写时,才会按需加载。操作系统一般是按页加载的,页可以理解为就是一块,页的大小与操作系统和硬件相关,典型的配置可能是4K、8K等,当操作系统发现读写区域不在内存时,就会加载该区域对应的一个页到内存。

这种按需加载的方式,使得内存映射文件可以方便高效地处理非常大的文件,内存放不下整个文件也不要紧,操作系统会自动进行处理,将需要的内容读到内存,将修改的内容保存到硬盘,将不再使用的内存释放。

在应用程序写的时候,它写的是内存中的字节数组,这个内容什么时候同步到文件上呢?这个时机是不确定的,由操作系统决定,不过,只要操作系统不崩溃,操作系统会保证同步到文件上,即使映射这个文件的应用程序已经退出了。

在一般的文件读写中,会有两次数据复制,一次是从硬盘复制到操作系统内核,另一次是从操作系统内核复制到用户态的应用程序。而在内存映射文件中,一般情况下,只有一次复制,且内存分配在操作系统内核,应用程序访问的就是操作系统的内核内存空间,这显然要比普通的读写效率更高

内存映射文件的另一个重要特点是:它可以被多个不同的应用程序共享,多个程序可以映射同一个文件,映射到同一块内存区域,一个程序对内存的修改,可以让其他程序也看到,这使得它特别适合用于不同应用程序之间的通信

操作系统自身在加载可执行文件的时候,一般都利用了内存映射文件,比如:

  • 按需加载代码,只有当前运行的代码在内存,其他暂时用不到的代码还在硬盘。
  • 同时启动多次同一个可执行文件,文件代码在内存也只有一份。
  • 不同应用程序共享的动态链接库代码在内存也只有一份。

内存映射文件也有局限性。比如,它不太适合处理小文件,它是按页分配内存的,对于小文件,会浪费空间;另外,映射文件要消耗一定的操作系统资源,初始化比较慢。

简单总结下,对于一般的文件读写不需要使用内存映射文件,但如果处理的是大文件,要求极高的读写效率,比如数据库系统,或者需要在不同程序间进行共享和通信,那就可以考虑内存映射文件。理解了内存映射文件的基本概念,接下来,我们看怎么在Java中使用它。

14.3.2 用法

内存映射文件需要通过FileInputStream/FileOutputStream或RandomAccessFile,它们都有一个方法:

1
public FileChannel getChannel()

FileChannel有如下方法:

1
2
public MappedByteBuffer map(MapMode mode, long position,
long size) throws IOException

map方法将当前文件映射到内存,映射的结果就是一个MappedByteBuffer对象,它代表内存中的字节数组,待会我们再来详细看它。map有三个参数,mode表示映射模式, positon表示映射的起始位置,size表示长度。mode有三个取值:

  • MapMode.READ_ONLY:只读。
  • MapMode.READ_WRITE:既读也写。
  • MapMode.PRIVATE:私有模式,更改不反映到文件,也不被其他程序看到。

这个模式受限于背后的流或RandomAccessFile,比如,对于FileInputStream,或者RandomAccessFile但打开模式是”r”, mode就不能设为MapMode.READ_WRITE,否则会抛出异常。如果映射的区域超过了现有文件的范围,则文件会自动扩展,扩展出的区域字节内容为0。映射完成后,文件就可以关闭了,后续对文件的读写可以通过Mapped-ByteBuffer。看段代码,比如以读写模式映射文件”abc.dat”,代码可以为:

1
2
3
4
5
6
7
8
9
10
RandomAccessFile file = new RandomAccessFile("abc.dat", "rw");
try {
MappedByteBuffer buf = file.getChannel()
.map(MapMode.READ_WRITE, 0, file.length());
//使用buf...
} catch (IOException e) {
e.printStackTrace();
}finally{
file.close();
}

怎么来使用MappedByteBuffer呢?它是ByteBuffer的子类,而ByteBuffer是Buffer的子类。ByteBuffer和Buffer不只是给内存映射文件提供的,它们是JavaNIO中操作数据的一种方式,用于很多地方,方法也比较多,我们只介绍一些主要相关的。

ByteBuffer可以简单理解为封装了一个字节数组,这个字节数组的长度是不可变的,在内存映射文件中,这个长度由map方法中的参数size决定。ByteBuffer有一个基本属性position,表示当前读写位置,这个位置可以改变,相关方法是:

1
2
public final int position() //获取当前读写位置
public final Buffer position(int newPosition) //修改当前读写位置

ByteBuffer中有很多基于当前位置读写数据的方法,部分方法如下:

1
2
3
4
5
public abstract byte get() //从当前位置获取一个字节
public ByteBuffer get(byte[] dst) //从当前位置复制dst.length长度的字节到dst
public abstract int getInt() //从当前位置读取一个int
public final ByteBuffer put(byte[] src) //将字节数组src写入当前位置
public abstract ByteBuffer putLong(long value); //将value写入当前位置

这些方法在读写后,都会自动增加position。与这些方法相对应的,还有一组方法,可以在参数中直接指定position,比如:

1
2
3
4
5
6
public abstract int getInt(int index) //从index处读取一个int
public abstract double getDouble(int index) //从index处读取一个double
//在index处写入一个double
public abstract ByteBuffer putDouble(int index, double value)
//在index处写入一个long
public abstract ByteBuffer putLong(int index, long value)

这些方法在读写时,不会改变当前读写位置position。

MappedByteBuffer自己还定义了一些方法:

1
2
3
4
//检查文件内容是否真实加载到了内存,这个值是一个参考值,不一定精确
public final boolean isLoaded()
public final MappedByteBuffer load() //尽量将文件内容加载到内存
public final MappedByteBuffer force() //将对内存的修改强制同步到硬盘上

14.3.3 设计一个消息队列BasicQueue

了解了内存映射文件的用法,接下来,我们来看怎么用它设计和实现一个简单的消息队列,我们称之为BasicQueue。本小节先介绍它的功能、用法和设计,下小节介绍它的具体代码。完整的代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c61下。

1.功能

BasicQueue是一个先进先出的循环队列,长度固定,接口主要是出队和入队,与之前介绍的容器类的区别是:
1)消息持久化保存在文件中,重启程序消息不会丢失。
2)可以供不同的程序进行协作。典型场景是,有两个不同的程序,一个是生产者,另一个是消费者,生成者只将消息放入队列,而消费者只从队列中取消息,两个程序通过队列进行协作。这种协作方式更灵活,相互依赖性小,是一种常见的协作方式。

BasicQueue的构造方法是:

1
public BasicQueue(String path, String queueName) throws IOException

path表示队列所在的目录,必须已存在;queueName表示队列名,BasicQueue会使用以queueName开头的两个文件来保存队列信息,一个扩展名是.data,保存实际的消息,另一个扩展名是.meta,保存元数据信息,如果这两个文件存在,则会使用已有的队列,否则会建立新队列。

BasicQueue主要提供出队和入队两个方法,如下所示:

1
2
public void enqueue(byte[] data) throws IOException //入队
public byte[] dequeue() throws IOException //出队

与上节介绍的BasicDB类似,消息格式也是byte数组。BasicQueue的队列长度是有限的,如果满了,调用enqueue方法会抛出异常;消息的最大长度也是有限的,不能超过1020,如果超了,也会抛出异常。如果队列为空,那么dequeue方法返回null。

2.用法示例

BasicQueue的典型用法是生产者和消费者之间的协作,我们来看下简单的示例代码。生产者程序向队列上放消息,每放一条,就随机休息一会儿,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Producer {
public static void main(String[] args) throws InterruptedException {
try {
BasicQueue queue = new BasicQueue("./", "task");
int i = 0;
Random rnd = new Random();
while(true) {
String msg = new String("task " + (i++));
queue.enqueue(msg.getBytes("UTF-8"));
System.out.println("produce: " + msg);
Thread.sleep(rnd.nextInt(1000));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

消费者程序从队列中取消息,如果队列为空,也随机休息一会儿,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Consumer {
public static void main(String[] args) throws InterruptedException {
try {
BasicQueue queue = new BasicQueue("./", "task");
Random rnd = new Random();
while (true) {
byte[] bytes = queue.dequeue();
if(bytes == null) {
Thread.sleep(rnd.nextInt(1000));
continue;
}
System.out.println("consume: " + new String(bytes, "UTF-8"));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

假定这两个程序的当前目录一样,它们会使用同样的队列”task”。同时运行这两个程序,会看到它们的输出交替出现。

3.设计

我们采用如下简单方式来设计BasicQueue。
1)使用两个文件来保存消息队列:一个为数据文件,扩展为.data;一个是元数据文件.meta。
2)在.data文件中使用固定长度存储每条信息,长度为1024,前4个字节为实际长度,后面是实际内容,每条消息的最大长度不能超过1020。
3)在.meta文件中保存队列头和尾,指向.data文件中的位置,初始都是0,入队增加尾,出队增加头,到结尾时,再从0开始,模拟循环队列。
4)为了区分队列满和空的状态,始终留一个位置不保存数据,当队列头和队列尾一样的时候表示队列为空,当队列尾的下一个位置是队列头的时候表示队列满。

BasicQueue的基本设计如图14-3所示。

epub_923038_123

图14-3 BasicQueue的基本设计

为简化起见,我们暂不考虑由于并发访问等引起的一致性问题。

14.3.4 实现消息队列

下面来看BasicQueue的具体实现代码,包括常量定义、内部组成、构造方法、入队、出队等。

BasicQueue中定义了如下常量,名称和含义如下:

1
2
3
4
5
6
7
8
9
10
//队列最多消息个数,实际个数还会减1
private static final int MAX_MSG_NUM = 10201024;
//消息体最大长度
private static final int MAX_MSG_BODY_SIZE = 1020;
//每条消息占用的空间
private static final int MSG_SIZE = MAX_MSG_BODY_SIZE + 4;
//队列消息体数据文件大小
private static final int DATA_FILE_SIZE = MAX_MSG_NUM * MSG_SIZE;
//队列元数据文件大小 (head + tail)
private static final int META_SIZE = 8;

BasicQueue的内部成员主要就是两个MappedByteBuffer,分别表示数据和元数据:

1
2
private MappedByteBuffer dataBuf;
private MappedByteBuffer metaBuf;

BasicQueue的构造方法代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public BasicQueue(String path, String queueName) throws IOException {
if(! path.endsWith(File.separator)) {
path += File.separator;
}
RandomAccessFile dataFile = null;
RandomAccessFile metaFile = null;
try {
dataFile = new RandomAccessFile(path + queueName + ".data", "rw");
metaFile = new RandomAccessFile(path + queueName + ".meta", "rw");
dataBuf = dataFile.getChannel().map(MapMode.READ_WRITE, 0,
DATA_FILE_SIZE);
metaBuf = metaFile.getChannel().map(MapMode.READ_WRITE, 0,
META_SIZE);
} finally {
if(dataFile ! = null) {
dataFile.close();
}
if(metaFile ! = null) {
metaFile.close();
}
}
}

为了方便访问和修改队列头尾指针,我们定义了如下辅助方法:

1
2
3
4
5
6
7
8
9
10
11
12
private int head() {
return metaBuf.getInt(0);
}
private void head(int newHead) {
metaBuf.putInt(0, newHead);
}
private int tail() {
return metaBuf.getInt(4);
}
private void tail(int newTail) {
metaBuf.putInt(4, newTail);
}

为了便于判断队列是空还是满,我们定义了如下方法:

1
2
3
4
5
6
private boolean isEmpty(){
return head() == tail();
}
private boolean isFull(){
return (tail() + MSG_SIZE) % DATA_FILE_SIZE) == head();
}

入队的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void enqueue(byte[] data) throws IOException {
if(data.length > MAX_MSG_BODY_SIZE) {
throw new IllegalArgumentException("msg size is " + data.length
+ ", while maximum allowed length is " + MAX_MSG_BODY_SIZE);
}
if(isFull()) {
throw new IllegalStateException("queue is full");
}
int tail = tail();
dataBuf.position(tail);
dataBuf.putInt(data.length);
dataBuf.put(data);
if(tail + MSG_SIZE >= DATA_FILE_SIZE) {
tail(0);
} else {
tail(tail + MSG_SIZE);
}
}

基本逻辑是:
1)如果消息太长或队列满,抛出异常;
2)找到队列尾,定位到队列尾,写消息长度,写实际数据;
3)更新队列尾指针,如果已到文件尾,再从头开始。

出队的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public byte[] dequeue() throws IOException {
if(isEmpty()) {
return null;
}
int head = head();
dataBuf.position(head);
int length = dataBuf.getInt();
byte[] data = new byte[length];
dataBuf.get(data);
if(head + MSG_SIZE >= DATA_FILE_SIZE) {
head(0);
} else {
head(head + MSG_SIZE);
}
return data;
}

基本逻辑是:
1)如果队列为空,返回null;
2)找到队列头,定位到队列头,读消息长度,读实际数据;
3)更新队列头指针,如果已到文件尾,再从头开始;
4)最后返回实际数据。

14.3.5 小结

本节介绍了内存映射文件的基本概念及在Java中的用法,在日常普通的文件读写中,我们用到得比较少,但在一些系统程序中,它却是经常被用到的一把利器,可以高效地读写大文件,且能实现不同程序间的共享和通信。

利用内存映射文件,我们设计和实现了一个简单的消息队列,消息可以持久化,可以实现跨程序的生产者/消费者通信,我们演示了这个消息队列的功能、用法、设计和实现代码。

14.2 随机读写文件

我们先介绍RandomAccessFile的用法,然后介绍怎么利用它实现一个简单的键值对数据库。

14.2.1 用法

RandomAccessFile有如下构造方法:

1
2
3
4
public RandomAccessFile(String name, String mode)
throws FileNotFoundException
public RandomAccessFile(File file, String mode)
throws FileNotFoundException

参数name和file容易理解,表示文件路径和File对象,mode是什么意思呢?它表示打开模式,可以有4个取值。
1)”r”:只用于读。
2)”rw”:用于读和写。
3)”rws”:和”rw”一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上。
4)”rwd”:和”rw”一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和”rws”的区别是,元数据的更新不要求同步。

RandomAccessFile虽然不是InputStream/OutputStream的子类,但它也有类似于读写字节流的方法。另外,它还实现了DataInput/DataOutput接口。这些方法我们之前基本都介绍过,这里列举部分方法,以增强直观感受:

1
2
3
4
5
6
//读一个字节,取最低8位,0~255
public int read() throws IOException
public int read(byte b[]) throws IOException
public final int readInt() throws IOException
public final void writeInt(int v) throws IOException
public void write(byte b[]) throws IOException

RandomAccessFile还有另外两个read方法:

1
2
public final void readFully(byte b[]) throws IOException
public final void readFully(byte b[], int off, int len) throws IOException

与对应的read方法的区别是,它们可以确保读够期望的长度,如果到了文件结尾也没读够,它们会抛出EOFException异常。

RandomAccessFile内部有一个文件指针,指向当前读写的位置,各种read/write操作都会自动更新该指针。与流不同的是,RandomAccessFile可以获取该指针,也可以更改该指针,相关方法是:

1
2
3
4
//获取当前文件指针
public native long getFilePointer() throws IOException
//更改当前文件指针到pos
public native void seek(long pos) throws IOException

RandomAccessFile是通过本地方法,最终调用操作系统的API来实现文件指针调整的。

InputStream有一个skip方法,可以跳过输入流中n个字节,默认情况下,它是通过实际读取n个字节实现的。RandomAccessFile有一个类似方法,不过它是通过更改文件指针实现的:

1
public int skipBytes(int n) throws IOException

RandomAccessFile可以直接获取文件长度,返回文件字节数,方法为:

1
public native long length() throws IOException

它还可以直接修改文件长度,方法为:

1
public native void setLength(long newLength) throws IOException

如果当前文件的长度小于newLength,则文件会扩展,扩展部分的内容未定义。如果当前文件的长度大于newLength,则文件会收缩,多出的部分会截取,如果当前文件指针比newLength大,则调用后会变为newLength。

RandomAccessFile中有如下方法,需要注意一下:

1
2
public final void writeBytes(String s) throws IOException
public final String readLine() throws IOException

看上去,writeBytes方法可以直接写入字符串,而readLine方法可以按行读入字符串,实际上,这两个方法都是有问题的,它们都没有编码的概念,都假定一个字节就代表一个字符,这对于中文显然是不成立的,所以,应避免使用这两个方法。

14.2.2 设计一个键值数据库BasicDB

在日常的一般文件读写中,使用流就可以了,但在一些系统程序中,流是不适合的, RandomAccessFile因为更接近操作系统,更为方便和高效。

下面,我们来看怎么利用RandomAccessFile实现一个简单的键值数据库,我们称之为BasicDB。我们从功能、接口、使用和设计等几个方面进行介绍,完整的代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c60下。

1.功能

BasicDB提供的接口类似于Map接口,可以按键保存、查找、删除,但数据可以持久化保存到文件上。此外,不像HashMap/TreeMap,它们将所有数据保存在内存,BasicDB只把元数据如索引信息保存在内存,值的数据保存在文件上。相比HashMap/TreeMap, BasicDB的内存消耗可以大大降低,存储的键值对个数大大提高,尤其当值数据比较大的时候。BasicDB通过索引,以及RandomAccessFile的随机读写功能保证效率。

2.接口

对外,BasicDB提供的构造方法是:

1
public BasicDB(String path, String name) throws IOException

path表示数据库文件所在的目录,该目录必须已存在。name表示数据库的名称,BasicDB会使用以name开头的两个文件,一个存储元数据,扩展名是.meta,一个存储键值对中的值数据,扩展名是.data。比如,如果name为student,则两个文件为student.meta和student.data,这两个文件不一定存在,如果不存在,则创建新的数据库,如果已存在,则加载已有的数据库。

BasicDB提供的公开方法有:

1
2
3
4
5
6
7
//保存键值对,键为String类型,值为byte数组
public void put(String key, byte[] value) throws IOException
//根据键获取值,如果键不存在,返回null
public byte[] get(String key) throws IOException
public void remove(String key) //根据键删除
public void flush() throws IOException //确保将所有数据保存到文件
public void close() throws IOException //关闭数据库

为便于实现,我们假定值即byte数组的长度不超过1020,如果超过,会抛出异常,当然,这个长度在代码中可以调整。在调用put和remove后,修改不会马上反映到文件中,如果需要确保保存到文件中,需要调用flush。

3.使用

在BasicDB中,我们设计的值为byte数组,这看上去是一个限制,不便使用,我们主要是为了简化,而且任何数据都可以转化为byte数组保存。对于字符串,可以使用getBytes()方法,对于对象,可以使用之前介绍的流转换为byte数组。

比如,保存一些学生信息到数据库,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static byte[] toBytes(Student student) throws IOException {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);
dout.writeUTF(student.getName());
dout.writeInt(student.getAge());
dout.writeDouble(student.getScore());
return bout.toByteArray();
}
public static void saveStudents(Map<String, Student> students)
throws IOException {
BasicDB db = new BasicDB("./", "students");
for(Map.Entry<String, Student> kv : students.entrySet()) {
db.put(kv.getKey(), toBytes(kv.getValue()));
}
db.close();
}

保存学生信息到当前目录下的students数据库,toBytes方法将Student转换为了字节。14.3节会介绍序列化,使用序列化,toBytes方法的代码可以更为简洁。

4.设计

我们采用如下简单的设计。
1)将键值对分为两部分,值保存在单独的.data文件中,值在.data文件中的位置和键称为索引,索引保存在.meta文件中。
2)在.data文件中,每个值占用的空间固定,固定长度为1024,前4个字节表示实际长度,然后是实际内容,实际长度不够1020的,后面是补白字节0。
3)索引信息既保存在.meta文件中,也保存在内存中,在初始化时,全部读入内存,对索引的更新不立即更新文件,调用flush方法才更新。
4)删除键值对不修改.data文件,但会从索引中删除并记录空白空间,下次添加键值对的时候会重用空白空间,所有的空白空间也记录到.meta文件中。

我们暂不考虑由于并发访问、异常关闭等引起的一致性问题。这个设计虽然是比较粗糙的,但可以演示一些基本概念。

14.2.3 BasicDB的实现

下面,我们来看实现代码,先来看内部组成和构造方法,然后看一些主要方法的实现。

BasicDB定义了如下静态变量:

1
2
3
4
5
6
7
private static final int MAX_DATA_LENGTH = 1020;
//补白字节
private static final byte[] ZERO_BYTES = new byte[MAX_DATA_LENGTH];
//数据文件扩展名
private static final String DATA_SUFFIX = ".data";
//元数据文件扩展名,包括索引和空白空间数据
private static final String META_SUFFIX = ".meta";

内存中表示索引和空白空间的数据结构是:

1
2
Map<String, Long> indexMap; //索引信息,键->值在.data文件中的位置
Queue<Long> gaps; //空白空间,值为在.data文件中的位置

表示文件的数据结构是:

1
2
RandomAccessFile db; //值数据文件
File metaFile; //元数据文件

构造方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
public BasicDB(String path, String name) throws IOException{
File dataFile = new File(path + name + DATA_SUFFIX);
metaFile = new File(path + name + META_SUFFIX);
db = new RandomAccessFile(dataFile, "rw");
if(metaFile.exists()){
loadMeta();
}else{
indexMap = new HashMap<>();
gaps = new ArrayDeque<>();
}
}

元数据文件存在时,会调用loadMeta将元数据加载到内存,我们先假定不存在,先来看其他代码。保存键值对的方法是put,其代码为:

1
2
3
4
5
6
7
8
public void put(String key, byte[] value) throws IOException{
Long index = indexMap.get(key);
if(index==null){
index = nextAvailablePos();
indexMap.put(key, index);
}
writeData(index, value);
}

先通过索引查找键是否存在,如果不存在,调用nextAvailablePos方法为值找一个存储位置,并将键和存储位置保存到索引中,最后,调用writeData方法将值写到数据文件中。

nextAvailablePos的代码是:

1
2
3
4
5
6
7
private long nextAvailablePos() throws IOException{
if(! gaps.isEmpty()){
return gaps.poll();
}else{
return db.length();
}
}

它首先查找空白空间,如果有,则重用,否则定位到文件末尾。

writeData方法实际写值数据,它的代码是:

1
2
3
4
5
6
7
8
9
10
private void writeData(long pos, byte[] data) throws IOException {
if(data.length > MAX_DATA_LENGTH) {
throw new IllegalArgumentException("maximum allowed length is "
+ MAX_DATA_LENGTH + ", data length is " + data.length);
}
db.seek(pos);
db.writeInt(data.length);
db.write(data);
db.write(ZERO_BYTES, 0, MAX_DATA_LENGTH - data.length);
}

它先检查长度,长度满足的情况下,定位到指定位置,写实际数据的长度、写内容、最后补白。

可以看出,在这个实现中,索引信息和空白空间信息并没有实时保存到文件中,要保存,需要调用flush方法,待会我们再看这个方法。

根据键获取值的方法是get,其代码为:

1
2
3
4
5
6
7
public byte[] get(String key) throws IOException{
Long index = indexMap.get(key);
if(index! =null){
return getData(index);
}
return null;
}

如果键存在,就调用getData方法获取数据。getData方法的代码为:

1
2
3
4
5
6
7
private byte[] getData(long pos) throws IOException{
db.seek(pos);
int length = db.readInt();
byte[] data = new byte[length];
db.readFully(data);
return data;
}

代码也很简单,定位到指定位置,读取实际长度,然后调用readFully方法读够内容。

删除键值对的方法是remove,其代码为:

1
2
3
4
5
6
public void remove(String key){
Long index = indexMap.remove(key);
if(index! =null){
gaps.offer(index);
}
}

从索引结构中删除,并添加到空白空间队列中。

同步元数据的方法是flush(),其代码为:

1
2
3
4
public void flush() throws IOException{
saveMeta();
db.getFD().sync();
}

回顾一下,getFD方法会返回文件描述符,其sync方法会确保文件内容保存到设备上, saveMeta方法的代码为:

1
2
3
4
5
6
7
8
9
10
private void saveMeta() throws IOException{
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(metaFile)));
try{
saveIndex(out);
saveGaps(out);
}finally{
out.close();
}
}

索引信息和空白空间保存在一个文件中,saveIndex保存索引信息,代码为:

1
2
3
4
5
6
7
private void saveIndex(DataOutputStream out) throws IOException{
out.writeInt(indexMap.size());
for(Map.Entry<String, Long> entry : indexMap.entrySet()){
out.writeUTF(entry.getKey());
out.writeLong(entry.getValue());
}
}

先保存键值对个数,然后针对每条索引信息,保存键及值在.data文件中的位置。

saveGaps方法保存空白空间信息,代码为:

1
2
3
4
5
6
private void saveGaps(DataOutputStream out) throws IOException{
out.writeInt(gaps.size());
for(Long pos : gaps){
out.writeLong(pos);
}
}

也是先保存长度,然后保存每条空白空间信息。

我们使用了之前介绍的流来保存,这些代码比较烦琐,如果使用后续介绍的序列化,代码会更为简洁。

在构造方法中,我们提到了loadMeta方法,它是saveMeta的逆操作,代码为:

1
2
3
4
5
6
7
8
9
10
private void loadMeta() throws IOException{
DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(metaFile)));
try{
loadIndex(in);
loadGaps(in);
}finally{
in.close();
}
}

loadIndex加载索引,代码为:

1
2
3
4
5
6
7
8
9
private void loadIndex(DataInputStream in) throws IOException{
int size = in.readInt();
indexMap = new HashMap<String, Long>((int) (size / 0.75f) + 1, 0.75f);
for(int i=0; i<size; i++){
String key = in.readUTF();
long index = in.readLong();
indexMap.put(key, index);
}
}

loadGaps加载空白空间,代码为:

1
2
3
4
5
6
7
8
private void loadGaps(DataInputStream in) throws IOException{
int size = in.readInt();
gaps = new ArrayDeque<>(size);
for(int i=0; i<size; i++){
long index = in.readLong();
gaps.add(index);
}
}

数据库关闭的代码为:

1
2
3
4
public void close() throws IOException{
flush();
db.close();
}

就是同步数据,并关闭数据文件。

14.2.4 小结

本节介绍了RandomAccessFile的用法,它可以随机读写,更为接近操作系统的API,在实现一些系统程序时,它比流要更为方便高效。利用RandomAccessFile,我们实现了一个非常简单的键值对数据库,我们演示了这个数据库的用法、接口、设计和实现代码。在这个例子中,我们同时展示了之前介绍的容器和流的一些用法。

这个数据库虽然简单粗糙,但也具备了一些优良特点,比如占用的内存空间比较小,可以存储大量键值对,可以根据键高效访问值等。

第14章 文件高级技术

在日常编程中,我们经常会需要处理一些具体类型的文件,如属性文件、CSV、Excel、HTML和压缩文件,直接使用上一章介绍的方式来处理一般是很不方便的。一些第三方的类库基于之前介绍的技术提供了更为方便易用的接口,本章会简要介绍这几种文件类型的处理。

上一章介绍了字节流和字符流,它们都是以流的方式读写文件,流的方式有几个限制:
1)要么读,要么写,不能同时读和写。
2)不能随机读写,只能从头读到尾,且不能重复读,虽然通过缓冲可以实现部分重读,但是有限制。

Java中还有一个类RandomAccessFile,它没有这两个限制,既可以读,也可以写,还可以随机读写,是一个更接近于操作系统API的封装类。

访问文件还有一种方式:内存映射文件,它可以高效处理非常大的文件,而且可以被多个不同的应用程序共享,特别适合用于不同应用程序之间的通信。

在前面章节,我们在将对象保存到文件时,使用的是DataOutputStream,从文件读入对象时,使用的是DataInputStream,使用它们,需要逐个处理对象中的每个字段,我们提到,这种方式比较啰嗦,Java中有一种更为简单的机制,那就是序列化。

Java的标准序列化机制有一些重要的限制,而且不能跨语言,实践中经常使用一些替代方案,比如XML/JSON/MessagePack。Java SDK中对这些格式的支持有限,有很多第三方的类库提供了更为方便的支持,Jackson是其中一种,它支持多种格式。

本章主要就来介绍以上这些技术,具体分为5个小节:14.1节介绍几种常见文件类型的处理;14.2节介绍RandomAccessFile,演示它的一个应用,实现一个简单的键值对数据库;14.3节介绍内存映射文件,演示它的一个应用,设计和实现一个简单的、持久化的、跨程序的消息队列;14.4节介绍Java标准序列化机制;14.5节介绍利用Jackson序列化为XML/JSON/MessagePack。

14.1 常见文件类型处理

本节简要介绍如何利用Java API和一些第三方类库,来处理如下5种类型的文件:
1)属性文件:属性文件是常见的配置文件,用于在不改变代码的情况下改变程序的行为。
2)CSV:CSV是Comma-Separated Values的缩写,表示逗号分隔值,是一种非常常见的文件类型。大部分日志文件都是CSV, CSV也经常用于交换表格类型的数据,待会我们会看到,CSV看上去很简单,但处理的复杂性经常被低估。
3)Excel:在编程中,经常需要将表格类型的数据导出为Excel格式,以方便用户查看,也经常需要接受Excel类型的文件作为输入以批量导入数据。
4)HTML:所有网页都是HTML格式,我们经常需要分析HTML网页,以从中提取感兴趣的信息。
5)压缩文件:压缩文件有多种格式,也有很多压缩工具,大部分情况下,我们可以借助工具而不需要自己写程序处理压缩文件,但某些情况下,需要自己编程压缩文件或解压缩文件。

14.1.1 属性文件

属性文件一般很简单,一行表示一个属性,属性就是键值对,键和值用等号(=)或冒号(:)分隔,一般用于配置程序的一些参数。在需要连接数据库的程序中,经常使用配置文件配置数据库信息。比如,设有文件config.properties,内容大概如下所示:

1
2
3
4
db.host = 192.168.10.100
db.port : 3306
db.username = zhangsan
db.password = mima1234

处理这种文件使用字符流是比较容易的,但Java中有一个专门的类java.util.Properties,它的使用也很简单,有如下主要方法:

1
2
3
public synchronized void load(InputStream inStream)
public String getProperty(String key)
public String getProperty(String key, String defaultValue)

load用于从流中加载属性,getProperty用于获取属性值,可以提供一个默认值,如果没有找到配置的值,则返回默认值。对于上面的配置文件,可以使用类似下面的代码进行读取:

1
2
3
4
Properties prop = new Properties();
prop.load(new FileInputStream("config.properties"));
String host = prop.getProperty("db.host");
int port = Integer.valueOf(prop.getProperty("db.port", "3306"));

使用类Properties处理属性文件的好处是:

  • 可以自动处理空格,分隔符=前后的空格会被自动忽略。
  • 可以自动忽略空行。
  • 可以添加注释,以字符#或!开头的行会被视为注释,进行忽略。

使用Properties也有限制,它不能直接处理中文,在配置文件中,所有非ASCII字符需要使用Unicode编码。比如,不能在配置文件中直接这么写:

1
name=老马

“老马”需要替换为Unicode编码,如下所示:

1
name=\u8001\u9A6C

在Java IDE(如Eclipse)中,如果使用属性文件编辑器,它会自动替换中文为Unicode编码;如果使用其他编辑器,可以先写成中文,然后使用JDK提供的命令native2ascii转换为Unicode编码。用法如下例所示:

1
native2ascii -encoding UTF-8 native.properties ascii.properties

native.properties是输入,其中包含中文;ascii.properties是输出,中文替换为了Unicode编码;-encoding指定输入文件的编码,这里指定为了UTF-8。

14.1.2 CSV文件

CSV是Comma-Separated Values的缩写,表示逗号分隔值。一般而言,一行表示一条记录,一条记录包含多个字段,字段之间用逗号分隔。不过,一般而言,分隔符不一定是逗号,可能是其他字符,如tab符’\t’、冒号’:’、分号’; ‘等。程序中的各种日志文件通常是CSV文件,在导入导出表格类型的数据时,CSV也是经常用的一种格式。

CSV格式看上去很简单。比如,我们在上一章保存学生列表时,使用的就是CSV格式:

1
2
张三,18,80.9
李四,17,67.5

使用之前介绍的字符流,看上去就可以很容易处理CSV文件,按行读取,对每一行,使用String.split进行分隔即可。但其实CSV有一些复杂的地方,最重要的是:

  • 字段内容中包含分隔符怎么办?
  • 字段内容中包含换行符怎么办?

对于这些问题,CSV有一个参考标准:RFC-4180(https://tools.ietf.org/html/rfc4180 ),但实践中不同程序往往有其他处理方式,所幸的是,处理方式大体类似,大概有以下两种处理方式。
1)使用引用符号比如”,在字段内容两边加上”,如果内容中包含”本身,则使用两个”。
2)使用转义字符,常用的是\,如果内容中包含\,则使用两个\。

比如,如果字段内容有两行,内容为:

1
2
hello, world \ abc
"老马"

使用第一种方式,内容会变为:

1
2
"hello, world \ abc
""老马"""

使用第二种方式,内容会变为:

1
hello\, world \\ abc\n"老马"

CSV还有其他一些细节,不同程序的处理方式也不一样,比如:

  • 怎么表示null值
  • 空行和字段之间的空格怎么处理
  • 怎么表示注释

对于以上这些复杂问题,使用简单的字符流就难以处理了。有一个第三方类库:Apache Commons CSV,对处理CSV提供了良好的支持,它的官网地址是http://commons.apache.org/proper/commons-csv/index.html 。本节使用其1.4版本,简要介绍其用法。Apache Commons CSV中有一个重要的类CSVFormat,它表示CSV格式,它有很多方法以定义具体的CSV格式,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
//定义分隔符
public CSVFormat withDelimiter(final char delimiter)
//定义引号符
public CSVFormat withQuote(final char quoteChar)
//定义转义符
public CSVFormat withEscape(final char escape)
//定义值为null的对象对应的字符串值
public CSVFormat withNullString(final String nullString)
//定义记录之间的分隔符
public CSVFormat withRecordSeparator(final char recordSeparator)
//定义是否忽略字段之间的空白
public CSVFormat withIgnoreSurroundingSpaces(
final boolean ignoreSurroundingSpaces)

比如,如果CSV格式使用分号;作为分隔符,使用”作为引号符,使用N/A表示null对象,忽略字段之间的空白,那么CSVFormat可以如下创建:

1
2
3
CSVFormat format = CSVFormat.newFormat('; ')
.withQuote('"').withNullString("N/A")
.withIgnoreSurroundingSpaces(true);

除了自定义CSVFormat, CSVFormat类中也定义了一些预定义的格式,如CSVFormat. DEFAULT, CSVFormat.RFC4180。

CSVFormat有一个方法,可以分析字符流:

1
public CSVParser parse(final Reader in) throws IOException

返回值类型为CSVParser,它有如下方法获取记录信息:

1
2
3
public Iterator<CSVRecord> iterator()
public List<CSVRecord> getRecords() throws IOException
public long getRecordNumber()

CSVRecord表示一条记录,它有如下方法获取每个字段的信息:

1
2
3
4
5
6
7
8
//根据字段列索引获取值,索引从0开始
public String get(final int i)
//根据列名获取值
public String get(final String name)
//字段个数
public int size()
//字段的迭代器
public Iterator<String> iterator()

分析CSV文件的基本代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CSVFormat format = CSVFormat.newFormat('; ')
.withQuote('"').withNullString("N/A")
.withIgnoreSurroundingSpaces(true);
Reader reader = new FileReader("student.csv");
try{
for(CSVRecord record : format.parse(reader)){
int fieldNum = record.size();
for(int i=0; i<fieldNum; i++){
System.out.print(record.get(i)+" ");
}
System.out.println();
}
}finally{
reader.close();
}

除了分析CSV文件,Apache Commons CSV也可以写CSV文件,有一个CSVPrinter,它有很多打印方法,比如:

1
2
3
4
//输出一条记录,参数可变,每个参数是一个字段值
public void printRecord(final Object... values) throws IOException
//输出一条记录
public void printRecord(final Iterable<? > values) throws IOException

代码示例:

1
2
3
4
5
CSVPrinter out = new CSVPrinter(new FileWriter("student.csv"),
CSVFormat.DEFAULT);
out.printRecord("老马", 18, "看电影,看书,听音乐");
out.printRecord("小马", 16, "乐高;赛车;");
out.close();

输出文件student.csv中的内容为:

1
2
"老马",18, "看电影,看书,听音乐"
"小马",16,乐高;赛车;

14.1.3 Excel

Excel主要有两种格式,扩展名分别为.xls和.xlsx。.xlsx是Office 2007以后的Excel文件的默认扩展名。Java中处理Excel文件及其他微软文档广泛使用POI类库,其官网是http://poi.apache.org/ 。本节使用其3.15版本,简要介绍其用法。使用POI处理Excel文件,有如下主要类。
1)Workbook:表示一个Excel文件对象,它是一个接口,有两个主要类HSSFWork-book和XSSFWorkbook,前者对应.xls格式,后者对应.xlsx格式。
2)Sheet:表示一个工作表。
3)Row:表示一行。
4)Cell:表示一个单元格。

比如,保存学生列表到student.xls,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void saveAsExcel(List<Student> list) throws IOException {
Workbook wb = new HSSFWorkbook();
Sheet sheet = wb.createSheet();
for(int i = 0; i < list.size(); i++) {
Student student = list.get(i);
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(student.getName());
row.createCell(1).setCellValue(student.getAge());
row.createCell(2).setCellValue(student.getScore());
}
OutputStream out = new FileOutputStream("student.xls");
wb.write(out);
out.close();
wb.close();
}

如果要保存为.xlsx格式,只需要替换第一行为:

1
Workbook wb = new XSSFWorkbook();

使用POI也可以方便的解析Excel文件,使用WorkbookFactory的create方法即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static List<Student> readAsExcel() throws Exception   {
Workbook wb = WorkbookFactory.create(new File("student.xls"));
List<Student> list = new ArrayList<Student>();
for(Sheet sheet : wb){
for(Row row : sheet){
String name = row.getCell(0).getStringCellValue();
int age = (int)row.getCell(1).getNumericCellValue();
double score = row.getCell(2).getNumericCellValue();
list.add(new Student(name, age, score));
}
}
wb.close();
return list;
}

以上只是介绍了基本用法,如果需要更多信息,如配置单元格的格式、颜色、字体,可参看http://poi.apache.org/spreadsheet/quick-guide.html

14.1.4 HTML

HTML是网页的格式,如果不熟悉,可以参看 http://www.w3school.com.cn/html/html_intro.asp 。在日常工作中,可能需要分析HTML页面,抽取其中感兴趣的信息。有很多HTML分析器,我们简要介绍一种:jsoup,其官网地址为https://jsoup.org/ 。本节使用其1.10.2版本。我们通过一个简单例子来看jsoup的使用,我们要分析的网页地址是 http://www.cnblogs.com/swiftma/p/5631311.html 。浏览器中看起来的样子(部分截图)如图14-1所示。

epub_923038_121

图14-1 HTML网页示例

将网页保存下来,其HTML代码(部分截图)看上去如图14-2所示。

图14-2 HTML网页代码示例

假定我们要抽取网页主题内容中每篇文章的标题和链接,怎么实现呢?jsoup支持使用CSS选择器语法查找元素,如果不了解CSS选择器,可参看 http://www.w3school.com.cn/cssref/css_selectors.asp

定位文章列表的CSS选择器可以是:

1
#cnblogs_post_body p a

我们来看代码(假定文件为articles.html):

1
2
3
4
5
6
7
Document doc = Jsoup.parse(new File("articles.html"), "UTF-8");
Elements elements = doc.select("#cnblogs_post_body p a");
for(Element e : elements){
String title = e.text();
String href = e.attr("href");
System.out.println(title+", "+href);
}

输出为(部分):

1
2
计算机程序的思维逻辑 (1) - 数据和变量, http://www.cnblogs.com/swiftma/p/5396551.html
计算机程序的思维逻辑 (2) - 赋值, http://www.cnblogs.com/swiftma/p/5399315.html

jsoup也可以直接连接URL进行分析,比如,上面代码的第一行可以替换为:

1
2
String url = "http://www.cnblogs.com/swiftma/p/5631311.html";
Document doc = Jsoup.connect(url).get();

关于jsoup的更多用法,请参看其官网。

14.1.5 压缩文件

压缩文件有多种格式,Java SDK支持两种:gzip和zip, gzip只能压缩一个文件,而zip文件中可以包含多个文件。下面介绍Java API中的基本用法,如果需要更多格式,可以考虑Apache Commons Compress,网址为http://commons.apache.org/proper/commons-compress/ 。先来看gzip,有两个主要的类:

1
2
java.util.zip.GZIPOutputStream
java.util.zip.GZIPInputStream

它们分别是OutputStream和InputStream的子类,都是装饰类,GZIPOutputStream加到已有的流上,就可以实现压缩,而GZIPInputStream加到已有的流上,就可以实现解压缩。比如,压缩一个文件的代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void gzip(String fileName) throws IOException {
InputStream in = null;
String gzipFileName = fileName + ".gz";
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(fileName));
out = new GZIPOutputStream(new BufferedOutputStream(
new FileOutputStream(gzipFileName)));
copy(in, out);
} finally {
if(out ! = null) {
out.close();
}
if(in ! = null) {
in.close();
}
}
}

调用的copy方法是我们在上一章介绍的。解压缩文件的代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void gunzip(String gzipFileName, String unzipFileName)
throws IOException {
InputStream in = null;
OutputStream out = null;
try {
in = new GZIPInputStream(new BufferedInputStream(
new FileInputStream(gzipFileName)));
out = new BufferedOutputStream(new FileOutputStream(
unzipFileName));
copy(in, out);
} finally {
if(out ! = null) {
out.close();
}
if(in ! = null) {
in.close();
}
}
}

zip文件支持一个压缩文件中包含多个文件,Java API中主要的类是:

1
2
java.util.zip.ZipOutputStream
java.util.zip.ZipInputStream

它们也分别是OutputStream和InputStream的子类,也都是装饰类,但不能像GZIP-OutputStream/GZIPInputStream那样简单使用。

ZipOutputStream可以写入多个文件,它有一个重要方法:

1
public void putNextEntry(ZipEntry e) throws IOException

在写入每一个文件前,必须要先调用该方法,表示准备写入一个压缩条目ZipEntry,每个压缩条目有个名称,这个名称是压缩文件的相对路径,如果名称以字符’/‘结尾,表示目录,它的构造方法是:

1
public ZipEntry(String name)

我们看一段代码,压缩一个文件或一个目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void zip(File inFile, File zipFile) throws IOException {
ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(
new FileOutputStream(zipFile)));
try {
if(! inFile.exists()) {
throw new FileNotFoundException(inFile.getAbsolutePath());
}
inFile = inFile.getCanonicalFile();
String rootPath = inFile.getParent();
if(! rootPath.endsWith(File.separator)) {
rootPath += File.separator;
}
addFileToZipOut(inFile, out, rootPath);
} finally {
out.close();
}
}

参数inFile表示输入,可以是普通文件或目录,zipFile表示输出,rootPath表示父目录,用于计算每个文件的相对路径,主要调用了addFileToZipOut将文件加入到ZipOutput-Stream中,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static void addFileToZipOut(File file, ZipOutputStream out,
String rootPath) throws IOException {
String relativePath = file.getCanonicalPath().substring(
rootPath.length());
if(file.isFile()) {
out.putNextEntry(new ZipEntry(relativePath));
InputStream in = new BufferedInputStream(new FileInputStream(file));
try {
copy(in, out);
} finally {
in.close();
}
} else {
out.putNextEntry(new ZipEntry(relativePath + File.separator));
for(File f : file.listFiles()) {
addFileToZipOut(f, out, rootPath);
}
}
}

它同样调用了copy方法将文件内容写入ZipOutputStream,对于目录,进行递归调用。ZipInputStream用于解压zip文件,它有一个对应的方法,获取压缩条目:

1
public ZipEntry getNextEntry() throws IOException

如果返回值为null,表示没有条目了。使用ZipInputStream解压文件,可以使用类似如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void unzip(File zipFile, String destDir) throws IOException {
ZipInputStream zin = new ZipInputStream(new BufferedInputStream(
new FileInputStream(zipFile)));
if(! destDir.endsWith(File.separator)) {
destDir += File.separator;
}
try {
ZipEntry entry = zin.getNextEntry();
while(entry ! = null) {
extractZipEntry(entry, zin, destDir);
entry = zin.getNextEntry();
}
} finally {
zin.close();
}
}

调用extractZipEntry处理每个压缩条目,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static void extractZipEntry(ZipEntry entry, ZipInputStream zin,
String destDir) throws IOException {
if(! entry.isDirectory()) {
File parent = new File(destDir + entry.getName()).getParentFile();
if(! parent.exists()) {
parent.mkdirs();
}
OutputStream entryOut = new BufferedOutputStream(
new FileOutputStream(destDir + entry.getName()));
try {
copy(zin, entryOut);
} finally {
entryOut.close();
}
} else {
new File(destDir + entry.getName()).mkdirs();
}
}

至此,关于5种常见文件类型的处理:属性文件、CSV、Excel、HTML和压缩文件,就介绍完了。完整的代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c64下。

13.4 文件和目录操作

文件和目录操作最终是与操作系统和文件系统相关的,不同系统的实现是不一样的,但Java中的java.io.File类提供了统一的接口,底层会通过本地方法调用操作系统和文件系统的具体实现,本节,我们就来介绍File类。File类中的操作大概可以分为三类:文件元数据、文件操作、目录操作,在介绍这些操作之前,我们先来看下File的构造方法。

13.4.1 构造方法

File既可以表示文件,也可以表示目录,它的主要构造方法有:

1
2
3
4
5
//pathname表示完整路径,该路径可以是相对路径,也可以是绝对路径
public File(String pathname)
//parent表示父目录,child表示孩子
public File(String parent, String child)
public File(File parent, String child)

File中的路径可以是已经存在的,也可以是不存在的。通过new新建一个File对象,不会实际创建一个文件,只是创建一个表示文件或目录的对象,new之后,File对象中的路径是不可变的。

13.4.2 文件元数据

文件元数据主要包括文件名和路径、文件基本信息以及一些安全和权限相关的信息。文件名和路径相关的主要方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//pathname表示完整路径,该路径可以是相对路径,也可以是绝对路径
public File(String pathname)
//parent表示父目录,child表示孩子
public File(String parent, String child)
public File(File parent, String child)
public String getName() //返回文件或目录名称,不含路径名
public boolean isAbsolute() //判断File中的路径是否是绝对路径
public String getPath() //返回构造File对象时的完整路径名,包括路径和文件名称
public String getAbsolutePath() //返回完整的绝对路径名
//返回标准的完整路径名,它会去掉路径中的冗余名称如".", "..",跟踪软链接(Unix系统概念)等
public String getCanonicalPath() throws IOException
public String getParent() //返回父目录路径
public File getParentFile() //返回父目录的File对象
//返回一个新的File对象,新的File对象使用getAbsolutePath()的返回值作为参数构造
public File getAbsoluteFile()
//返回一个新的File对象,新的File对象使用getCanonicalPath()的返回值作为参数构造
public File getCanonicalFile() throws IOException

这些方法比较直观,我们就不解释了。File类中有4个静态变量,表示路径分隔符,它

们是:

1
2
3
4
public static final String separator
public static final char separatorChar
public static final String pathSeparator
public static final char pathSeparatorChar

separator和separatorChar表示文件路径分隔符,在Windows系统中,一般为’',Linux系统中一般为’/‘。pathSeparator和pathSeparatorChar表示多个文件路径中的分隔符,比如,环境变量PATH中的分隔符,Java类路径变量classpath中的分隔符,在执行命令时,操作系统会从PATH指定的目录中寻找命令,Java运行时加载class文件时,会从classpath指定的路径中寻找类文件。在Windows系统中,这个分隔符一般为’; ‘,在Linux系统中,这个分隔符一般为’:’。

除了文件名和路径,File对象还有如下方法,以获取文件或目录的基本信息:

1
2
3
4
5
6
public boolean exists() //文件或目录是否存在
public boolean isDirectory() //是否为目录
public boolean isFile() //是否为文件
public long length() //文件长度,字节数,对目录没有意义
public long lastModified() //最后修改时间,从纪元时开始的毫秒数
public boolean setLastModified(long time) //设置最后修改时间,返回是否修改成功

需要说明的是,File对象没有返回创建时间的方法,因为创建时间不是一个公共概念, Linux/Unix就没有创建时间的概念。

File类中与安全和权限相关的主要方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean isHidden() //是否为隐藏文件
public boolean canExecute() //是否可执行
public boolean canRead() //是否可读
public boolean canWrite() //是否可写
public boolean setReadOnly() //设置文件为只读文件
//修改文件读权限
public boolean setReadable(boolean readable, boolean ownerOnly)
public boolean setReadable(boolean readable)
//修改文件写权限
public boolean setWritable(boolean writable, boolean ownerOnly)
public boolean setWritable(boolean writable)
//修改文件可执行权限
public boolean setExecutable(boolean executable, boolean ownerOnly)
public boolean setExecutable(boolean executable)

在修改方法中,如果修改成功,返回true,否则返回false。在设置权限方法中,owner-Only为true表示只针对owner,为false表示针对所有用户,没有指定ownerOnly的方法中,ownerOnly相当于是true。

13.4.3 文件操作

文件操作主要有创建、删除、重命名。

新建一个File对象不会实际创建文件,但如下方法可以:

1
public boolean createNewFile() throws IOException

创建成功返回true,否则返回false,新创建的文件内容为空。如果文件已存在,不会创建。

File对象还有两个静态方法,可以创建临时文件:

1
2
3
4
public static File createTempFile(String prefix, String suffix)
throws IOException
public static File createTempFile(String prefix, String suffix,
File directory) throws IOException

临时文件的完整路径名是系统指定的、唯一的,但可以通过参数指定前缀(prefix)、后缀(suffix)和目录(directory)。prefix是必需的,且至少要三个字符;suffix如果为null,则默认为.tmp; directory如果不指定或指定为null,则使用系统默认目录。

File类的删除方法为:

1
2
public boolean delete()
public void deleteOnExit()

delete删除文件或目录,删除成功返回true,否则返回false。如果File是目录且不为空,则delete不会成功,返回false,换句话说,要删除目录,先要删除目录下的所有子目录和文件。deleteOnExit将File对象加入到待删列表,在Java虚拟机正常退出的时候进行实际删除。

File类的重命名方法为:

1
public boolean renameTo(File dest)

参数dest代表重命名后的文件,重命名能否成功与系统有关,返回值代表是否成功。

13.4.4 目录操作

当File对象代表目录时,可以执行目录相关的操作,如创建、遍历。有两个方法用于创建目录:

1
2
public boolean mkdir()
public boolean mkdirs()

它们都是创建目录,创建成功返回true,失败返回false。需要注意的是,如果目录已存在,返回值是false。这两个方法的区别在于:如果某一个中间父目录不存在,则mkdir会失败,返回false,而mkdirs则会创建必需的中间父目录。

有如下方法访问一个目录下的子目录和文件:

1
2
3
4
5
public String[] list()
public String[] list(FilenameFilter filter)
public File[] listFiles()
public File[] listFiles(FileFilter filter)
public File[] listFiles(FilenameFilter filter)

它们返回的都是直接子目录或文件,不会返回子目录下的文件。list返回的是文件名数组,而listFiles返回的是File对象数组。FilenameFilter和FileFilter都是接口,用于过滤, FileFilter的定义为:

1
2
3
public interface FileFilter {
boolean accept(File pathname);
}

FilenameFilter的定义为:

1
2
3
public interface FilenameFilter {
boolean accept(File dir, String name);
}

在遍历子目录和文件时,针对每个文件,会调用FilenameFilter或FileFilter的accept方法,只有accept方法返回true时,才将该子目录或文件包含到返回结果中。Filename-Filter和FileFilter的区别在于:FileFilter的accept方法参数只有一个File对象,而File-nameFilter的accept方法参数有两个,dir表示父目录,name表示子目录或文件名。我们来看个例子,列出当前目录下的所有扩展名为.txt的文件,代码可以为:

1
2
3
4
5
6
7
8
9
10
File f = new File(".");
File[] files = f.listFiles(new FilenameFilter(){
@Override
public boolean accept(File dir, String name) {
if(name.endsWith(".txt")){
return true;
}
return false;
}
});

我们创建了个FilenameFilter的匿名内部类对象并传递给了listFiles。

使用遍历方法,可以方便地进行递归遍历,完成一些更为高级的功能。比如,计算一个目录下的所有文件的大小(包括子目录),代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static long sizeOfDirectory(final File directory) {
long size = 0;
if(directory.isFile()) {
return directory.length();
} else {
for(File file : directory.listFiles()) {
if(file.isFile()) {
size += file.length();
} else {
size += sizeOfDirectory(file);
}
}
}
return size;
}

再如,在一个目录下,查找所有给定文件名的文件,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
public static Collection<File> findFile(final File directory,
final String fileName) {
List<File> files = new ArrayList<>();
for(File f : directory.listFiles()) {
if(f.isFile() && f.getName().equals(fileName)) {
files.add(f);
} else if(f.isDirectory()) {
files.addAll(findFile(f, fileName));
}
}
return files;
}

前面介绍了File类的delete方法,我们提到,如果要删除目录而目录不为空,需要先清空目录,利用遍历方法,我们可以写一个删除非空目录的方法,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void deleteRecursively(final File file) throws IOException {
if(file.isFile()) {
if(! file.delete()) {
throw new IOException("Failed to delete "
+ file.getCanonicalPath());
}
} else if(file.isDirectory()) {
for(File child : file.listFiles()) {
deleteRecursively(child);
}
if(! file.delete()) {
throw new IOException("Failed to delete "
+ file.getCanonicalPath());
}
}
}

完整的代码在github上,地址为https://github.com/swiftma/program-logic,位于包shuo. laoma.file.c59下。至此,关于File类就介绍完了,File类封装了操作系统和文件系统的差异,提供了统一的文件和目录API。

关于文件处理的基本技术,包括文件的基本概念、二进制文件与字节流、文本文件与字符流,以及文件和目录操作,至此,我们就介绍完了。下一章,我们来看文件处理相关的一些高级技术。

13.3 文本文件和字符流

上节介绍了如何以字节流的方式处理文件,对于文本文件,字节流没有编码的概念,不能按行处理,使用不太方便,更适合的是使用字符流,本节就来介绍字符流。

我们首先简要介绍文本文件的基本概念、与二进制文件的区别、编码,以及字符流和字节流的区别,然后介绍Java中的主要字符流,它们有:
1)Reader/Writer:字符流的基类,它们是抽象类;
2)InputStreamReader/OutputStreamWriter:适配器类,将字节流转换为字符流;
3)FileReader/FileWriter:输入源和输出目标是文件的字符流;
4)CharArrayReader/CharArrayWriter:输入源和输出目标是char数组的字符流;
5)StringReader/StringWriter:输入源和输出目标是String的字符流;
6)BufferedReader/BufferedWriter:装饰类,对输入/输出流提供缓冲,以及按行读写功能;
7)PrintWriter:装饰类,可将基本类型和对象转换为其字符串形式输出的类。

除了这些类,Java中还有一个类Scanner,类似于一个Reader,但不是Reader的子类,可以读取基本类型的字符串形式,类似于PrintWriter的逆操作。理解了字节流和字符流后,我们介绍Java中的标准输入输出和错误流。最后,我们总结一些简单的实用方法。

13.3.1 基本概念

我们先来看一些基本概念,包括文本文件、编码和字符流。

1.文本文件

上节提到,处理文件要有二进制思维。从二进制角度,我们通过一个简单的例子解释下文本文件与二进制文件的区别。比如,要存储整数123,使用二进制形式保存到文件test. dat,代码为:

1
2
3
4
5
6
7
DataOutputStream output = new DataOutputStream(
new FileOutputStream("test.dat"));
try{
output.writeInt(123);
}finally{
output.close();
}

使用UltraEdit打开该文件,显示的却是:

1
{

打开十六进制编辑器,显示如图13-3所示。

epub_923038_115

图13-3 整数123的二进制存储

在文件中存储的实际有4个字节,最低位字节7B对应的十进制数是123,也就是说,对int类型,二进制文件保存的直接就是int的二进制形式。这个二进制形式,如果当成字符来解释,显示成什么字符则与编码有关,如果当成UTF-32BE编码,解释成的就是一个字符,即{。

如果使用文本文件保存整数123,则代码为:

1
2
3
4
5
6
7
OutputStream output = new FileOutputStream("test.txt");
try{
String data = Integer.toString(123);
output.write(data.getBytes("UTF-8"));
}finally{
output.close();
}

代码将整数123转换为字符串,然后将它的UTF-8编码输出到了文件中,使用Ultra-Edit打开该文件,显示的就是期望的:

1
123

打开十六进制编辑器,显示如图13-4所示。

epub_923038_116

图13-4 整数123的文本存储

文件中实际存储的有三个字节:31、32、33,对应的十进制数分别是49、50、51,分别对应字符’1’、’2’、’3’的ASCII编码。

2.编码

在文本文件中,编码非常重要,同一个字符,不同编码方式对应的二进制形式可能是不一样的。我们看个例子,对同样的文本:

1
hello, 123, 老马

1)UTF-8编码,十六进制如图13-5所示。

epub_923038_117

图13-5 示例文本的UTF-8编码

英文和数字字符每个占一个字节,而每个中文占三个字节。

2)GB18030编码,十六进制如图13-6所示。

epub_923038_118

图13-6 示例文本的GB18030编码

英文和数字字符与UTF-8编码是一样的,但中文不一样,每个中文占两个字节。

3)UTF-16BE编码,十六进制为如图13-7所示。

epub_923038_119

图13-7 示例文本的UTF-16BE编码

无论是英文还是中文字符,每个字符都占两个字节。UTF-16BE也是Java内存中对字符的编码方式。

3.字符流

字节流是按字节读取的,而字符流则是按char读取的,一个char在文件中保存的是几个字节与编码有关,但字符流封装了这种细节,我们操作的对象就是char。

需要说明的是,一个char不完全等同于一个字符,对于绝大部分字符,一个字符就是一个char,但我们之前介绍过,对于增补字符集中的字符,需要两个char表示,对于这种字符,Java中的字符流是按char而不是一个完整字符处理的。

理解了文本文件、编码和字符流的概念,我们再来看Java中的相关类,从基类开始。

13.3.2 Reader/Writer

Reader与字节流的InputStream类似,也是抽象类,部分主要方法有:

1
2
3
4
5
public int read() throws IOException
public int read(char cbuf[]) throws IOException
abstract public void close() throws IOException
public long skip(long n) throws IOException
public boolean ready() throws IOException

方法的名称和含义与InputStream中的对应方法基本类似,但Reader中处理的单位是char,比如read读取的是一个char,取值范围为0~65 535。Reader没有available方法,对应的方法是ready()。

Writer与字节流的OutputStream类似,也是抽象类,部分主要方法有:

1
2
3
4
5
public void write(int c)
public void write(char cbuf[])
public void write(String str) throws IOException
abstract public void close() throws IOException;
abstract public void flush() throws IOException;

含义与OutputStream的对应方法基本类似,但Writer处理的单位是char, Writer还接受String类型,我们知道,String的内部就是char数组,处理时,会调用String的getChar方法先获取char数组。

13.3.3 InputStreamReader/OutputStreamWriter

InputStreamReader和OutputStreamWriter是适配器类,能将InputStream/OutputStream转换为Reader/Writer。

1. OutputStreamWriter

OutputStreamWriter的主要构造方法为:

1
2
public OutputStreamWriter(OutputStream out)
public OutputStreamWriter(OutputStream out, String charsetName)

一个重要的参数是编码类型,可以通过名字charsetName或Charset对象传入,如果没有传入,则为系统默认编码,默认编码可以通过Charset.defaultCharset()得到。Output-StreamWriter内部有一个类型为StreamEncoder的编码器,能将char转换为对应编码的字节。

我们看一段简单的代码,将字符串”hello, 123,老马”写到文件hello.txt中,编码格式为GB2312:

1
2
3
4
5
6
7
8
Writer writer = new OutputStreamWriter(
new FileOutputStream("hello.txt"), "GB2312");
try{
String str = "hello, 123, 老马";
writer.write(str);
}finally{
writer.close();
}

创建一个FileOutputStream,然后将其包在一个OutputStreamWriter中,就可以直接以字符串写入了。

2. InputStreamReader

InputStreamReader的主要构造方法为:

1
2
public InputStreamReader(InputStream in)
public InputStreamReader(InputStream in, String charsetName)

与OutputStreamWriter一样,一个重要的参数是编码类型。InputStreamReader内部有一个类型为StreamDecoder的解码器,能将字节根据编码转换为char。

我们看一段简单的代码,将上面写入的文件读进来:

1
2
3
4
5
6
7
8
9
Reader reader = new InputStreamReader(
new FileInputStream("hello.txt"), "GB2312");
try{
char[] cbuf = new char[1024];
int charsRead = reader.read(cbuf);
System.out.println(new String(cbuf, 0, charsRead));
}finally{
reader.close();
}

这段代码假定一次read调用就读到了所有内容,且假定长度不超过1024。为了确保读到所有内容,可以借助待会介绍的CharArrayWriter或StringWriter。

13.3.4 FileReader/FileWriter

FileReader/FileWriter的输入和目的是文件。FileReader是InputStreamReader的子类,它的主要构造方法有:

1
2
public FileReader(File file) throws FileNotFoundException
public FileReader(String fileName) throws FileNotFoundException

FileWriter是OutputStreamWriter的子类,它的主要构造方法有:

1
2
public FileWriter(File file) throws IOException
public FileWriter(String fileName, boolean append) throws IOException

append参数指定是追加还是覆盖,如果没传,则为覆盖。

需要注意的是,FileReader/FileWriter不能指定编码类型,只能使用默认编码,如果需要指定编码类型,可以使用InputStreamReader/OutputStreamWriter

13.3.5 CharArrayReader/CharArrayWriter

CharArrayWriter与ByteArrayOutputStream类似,它的输出目标是char数组,这个数组的长度可以根据数据内容动态扩展。

CharArrayWriter有如下方法,可以方便地将数据转换为char数组或字符串:

1
2
public char[] toCharArray()
public String toString()

使用CharArrayWriter,我们可以改进上面的读文件代码,确保将所有文件内容读入:

1
2
3
4
5
6
7
8
9
10
11
12
13
Reader reader = new InputStreamReader(
new FileInputStream("hello.txt"), "GB2312");
try{
CharArrayWriter writer = new CharArrayWriter();
char[] cbuf = new char[1024];
int charsRead = 0;
while((charsRead=reader.read(cbuf))! =-1){
writer.write(cbuf, 0, charsRead);
}
System.out.println(writer.toString());
}finally{
reader.close();
}

读入的数据先写入CharArrayWriter中,读完后,再调用其toString()方法获取完整数据。

CharArrayReader与上节介绍的ByteArrayInputStream类似,它将char数组包装为一个Reader,是一种适配器模式,它的构造方法有:

1
2
public CharArrayReader(char buf[])
public CharArrayReader(char buf[], int offset, int length)

13.3.6 StringReader/StringWriter

StringReader/StringWriter与CharArrayReader/CharArrayWriter类似,只是输入源为String,输出目标为StringBuffer,而且,String/StringBuffer内部是由char数组组成的,所以它们本质上是一样的,具体我们就不赘述了。之所以要将char数组和String与Reader/Writer进行转换,也是为了能够方便地参与Reader/Writer构成的协作体系,复用代码。

13.3.7 BufferedReader/BufferedWriter

BufferedReader/BufferedWriter是装饰类,提供缓冲,以及按行读写功能。Buffered-Writer的构造方法有:

1
2
public BufferedWriter(Writer out)
public BufferedWriter(Writer out, int sz)

参数sz是缓冲大小,如果没有提供,默认为8192。它有如下方法,可以输出平台特定的换行符:

1
public void newLine() throws IOException

BufferedReader的构造方法有:

1
2
public BufferedReader(Reader in)
public BufferedReader(Reader in, int sz)

参数sz是缓冲大小,如果没有提供,默认为8192。它有如下方法,可以读入一行:

1
public String readLine() throws IOException

字符’\r’或’\n’或’\r\n’被视为换行符,readLine返回一行内容,但不会包含换行符,当读到流结尾时,返回null。

FileReader/FileWriter是没有缓冲的,也不能按行读写,所以,一般应该在它们的外面包上对应的缓冲类。我们来看个例子,还是学生列表,这次我们使用可读的文本进行保存,一行保存一条学生信息,学生字段之间用逗号分隔,保存的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void writeStudents(List<Student> students) throws IOException{
BufferedWriter writer = null;
try{
writer = new BufferedWriter(new FileWriter("students.txt"));
for(Student s : students){
writer.write(s.getName()+", "+s.getAge()+", "+s.getScore());
writer.newLine();
}
}finally{
if(writer! =null){
writer.close();
}
}
}

保存后的文件内容显示为:

1
2
张三,18,80.9
李四,17,67.5

从文件中读取的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static List<Student> readStudents() throws IOException{
BufferedReader reader = null;
try{
reader = new BufferedReader(
new FileReader("students.txt"));
List<Student> students = new ArrayList<>();
String line = reader.readLine();
while(line! =null){
String[] fields = line.split(", ");
Student s = new Student();
s.setName(fields[0]);
s.setAge(Integer.parseInt(fields[1]));
s.setScore(Double.parseDouble(fields[2]));
students.add(s);
line = reader.readLine();
}
return students;
}finally{
if(reader! =null){
reader.close();
}
}
}

使用readLine读入每一行,然后使用String的方法分隔字段,再调用Integer和Double的方法将字符串转换为int和double。这种对每一行的解析可以使用类Scanner进行简化,待会我们介绍。

13.3.8 PrintWriter

PrintWriter有很多重载的print方法,如:

1
2
public void print(int i)
public void print(Object obj)

它会将这些参数转换为其字符串形式,即调用String.valueOf(),然后再调用write。它也有很多重载形式的println方法,println除了调用对应的print,还会输出一个换行符。除此之外,PrintWriter还有格式化输出方法,如:

1
public PrintWriter printf(String format, Object ... args)

format表示格式化形式,比如,保留小数点后两位,格式可以为:

1
2
PrintWriter writer =
writer.format("%.2f", 123.456f);

输出为:

1
123.45

更多格式化的内容可以参看API文档,本节就不赘述了。

PrintWriter的方便之处在于,它有很多构造方法,可以接受文件路径名、文件对象、OutputStream、Writer等,对于文件路径名和File对象,还可以接受编码类型作为参数,比如:

1
2
3
4
public PrintWriter(File file) throws FileNotFoundException
public PrintWriter(String fileName, String csn)
public PrintWriter(OutputStream out, boolean autoFlush)
public PrintWriter(Writer out)

参数csn表示编码类型,对于以文件对象和文件名为参数的构造方法,PrintWriter内部会构造一个BufferedWriter,比如:

1
2
3
4
public PrintWriter(String fileName) throws FileNotFoundException {
this(new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(fileName))), false);
}

对于以OutputSream为参数的构造方法,PrintWriter也会构造一个BufferedWriter,比如:

1
2
3
4
public PrintWriter(OutputStream out, boolean autoFlush) {
this(new BufferedWriter(new OutputStreamWriter(out)), autoFlush);

}

对于以Writer为参数的构造方法,PrintWriter就不会包装BufferedWriter了。

构造方法中的autoFlush参数表示同步缓冲区的时机,如果为true,则在调用println、printf或format方法的时候,同步缓冲区,如果没有传,则不会自动同步,需要根据情况调用flush方法。

可以看出,PrintWriter是一个非常方便的类,可以直接指定文件名作为参数,可以指定编码类型,可以自动缓冲,可以自动将多种类型转换为字符串,在输出到文件时,可以优先选择该类。

上面的保存学生列表代码,使用PrintWriter,可以写为:

1
2
3
4
5
6
7
8
9
10
public static void writeStudents(List<Student> students) throws IOException{
PrintWriter writer = new PrintWriter("students.txt");
try{
for(Student s : students){
writer.println(s.getName()+", "+s.getAge()+", "+s.getScore());
}
}finally{
writer.close();
}
}

PrintWriter有一个非常相似的类PrintStream,除了不能接受Writer作为构造方法外, PrintStream的其他构造方法与PrintWriter一样。PrintStream也有几乎一样的重载的print和println方法,只是自动同步缓冲区的时机略有不同,在PrintStream中,只要碰到一个换行字符’\n’,就会自动同步缓冲区。PrintStream与PrintWriter的另一个区别是,虽然它们都有如下方法:

1
public void write(int b)

但含义是不一样的,PrintStream只使用最低的8位,输出一个字节,而PrintWriter是使用最低的两位,输出一个char。

13.3.9 Scanner

Scanner是一个单独的类,它是一个简单的文本扫描器,能够分析基本类型和字符串,它需要一个分隔符来将不同数据区分开来,默认是使用空白符,可以通过useDelimiter()方法进行指定。Scanner有很多形式的next()方法,可以读取下一个基本类型或行,如:

1
2
3
public float nextFloat()
public int nextInt()
public String nextLine()

Scanner也有很多构造方法,可以接受File对象、InputStream、Reader作为参数,它也可以将字符串作为参数,这时,它会创建一个StringReader。比如,以前面的解析学生记录为例,使用Scanner,代码可以改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static List<Student> readStudents() throws IOException{
BufferedReader reader = new BufferedReader(
new FileReader("students.txt"));
try{
List<Student> students = new ArrayList<Student>();
String line = reader.readLine();
while(line! =null){
Student s = new Student();
Scanner scanner = new Scanner(line).useDelimiter(", ");
s.setName(scanner.next());
s.setAge(scanner.nextInt());
s.setScore(scanner.nextDouble());
students.add(s);
line = reader.readLine();
}
return students;
}finally{
reader.close();
}
}

13.3.10 标准流

我们之前一直在使用System.out向屏幕上输出,它是一个PrintStream对象,输出目标就是所谓的“标准”输出,经常是屏幕。除了System.out, Java中还有两个标准流:System. in和System.err。

System.in表示标准输入,它是一个InputStream对象,输入源经常是键盘。比如,从键盘接受一个整数并输出,代码可以为:

1
2
3
Scanner in = new Scanner(System.in);
int num = in.nextInt();
System.out.println(num);

System.err表示标准错误流,一般异常和错误信息输出到这个流,它也是一个Print-Stream对象,输出目标默认与System.out一样,一般也是屏幕。

标准流的一个重要特点是,它们可以重定向,比如可以重定向到文件,从文件中接受输入,输出也写到文件中。在Java中,可以使用System类的setIn、setOut、setErr进行重定向,比如:

1
2
3
4
5
6
7
8
9
10
System.setIn(new ByteArrayInputStream("hello".getBytes("UTF-8")));
System.setOut(new PrintStream("out.txt"));
System.setErr(new PrintStream("err.txt"));
try{
Scanner in = new Scanner(System.in);
System.out.println(in.nextLine());
System.out.println(in.nextLine());
}catch(Exception e){
System.err.println(e.getMessage());
}

标准输入重定向到了一个ByteArrayInputStream,标准输出和错误重定向到了文件,所以第一次调用in.nextLine就会读取到”hello”,输出文件out.txt中也包含该字符串,第二次调用in.nextLine会触发异常,异常消息会写到错误流中,即文件err.txt中会包含异常消息,为”No line found”。

在实际开发中,经常需要重定向标准流。比如,在一些自动化程序中,经常需要重定向标准输入流,以从文件中接受参数,自动执行,避免人手工输入。在后台运行的程序中,一般都需要重定向标准输出和错误流到日志文件,以记录和分析运行的状态和问题。

在Linux系统中,标准输入输出流也是一种重要的协作机制。很多命令都很小,只完成单一功能,实际完成一项工作经常需要组合使用多条命令,它们协作的模式就是通过标准输入输出流,每个命令都可以从标准输入接受参数,处理结果写到标准输出,这个标准输出可以连接到下一个命令作为标准输入,构成管道式的处理链条。比如,查找一个日志文件access.log中127.0.0.1出现的行数,可以使用命令:

1
cat access.log | grep 127.0.0.1 | wc -l

有三个程序cat、grep、wc, |是管道符号,它将cat的标准输出重定向为了grep的标准输入,而grep的标准输出又成了wc的标准输入。

13.3.11 实用方法

可以看出,字符流也包含了很多的类,虽然很灵活,但对于一些简单的需求,却需要写很多代码,实际开发中,经常需要将一些常用功能进行封装,提供更为简单的接口。下面我们提供一些实用方法,以供参考,代码比较简单,就不解释了。

复制Reader到Writer,代码为:

1
2
3
4
5
6
7
8
public static void copy(final Reader input,
final Writer output) throws IOException {
char[] buf = new char[4096];
int charsRead = 0;
while((charsRead = input.read(buf)) ! = -1) {
output.write(buf, 0, charsRead);
}
}

将文件全部内容读入到一个字符串,参数为文件名和编码类型,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static String readFileToString(final String fileName,
final String encoding) throws IOException{
BufferedReader reader = null;
try{
reader = new BufferedReader(new InputStreamReader(
new FileInputStream(fileName), encoding));
StringWriter writer = new StringWriter();
copy(reader, writer);
return writer.toString();
}finally{
if(reader! =null){
reader.close();
}
}
}

这个方法利用了StringWriter,并调用了上面的复制方法。

将字符串写到文件,参数为文件名、字符串内容和编码类型,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void writeStringToFile(final String fileName,
final String data, final String encoding) throws IOException {
Writer writer = null;
try{
writer = new OutputStreamWriter(
new FileOutputStream(fileName), encoding);
writer.write(data);
}finally{
if(writer! =null){
writer.close();
}
}
}

按行将多行数据写到文件,参数为文件名、编码类型、行的集合,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void writeLines(final String fileName, final String encoding,
final Collection<? > lines) throws IOException {
PrintWriter writer = null;
try{
writer = new PrintWriter(fileName, encoding);
for(Object line : lines){
writer.println(line);
}
}finally{
if(writer! =null){
writer.close();
}
}
}

按行将文件内容读到一个列表中,参数为文件名、编码类型,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static List<String> readLines(final String fileName,
final String encoding) throws IOException{
BufferedReader reader = null;
try{
reader = new BufferedReader(new InputStreamReader(
new FileInputStream(fileName), encoding));
List<String> list = new ArrayList<>();
String line = reader.readLine();
while(line! =null){
list.add(line);
line = reader.readLine();
}
return list;
}finally{
if(reader! =null){
reader.close();
}
}
}

13.3.12 小结

本节介绍了如何在Java中以字符流的方式读写文本文件,我们强调了二进制思维、文本文本与二进制文件的区别、编码,以及字符流与字节流的不同,介绍了个各种字符流、Scanner以及标准流,最后总结了一些实用方法。完整的代码在github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c58下。

写文件时,可以优先考虑PrintWriter,因为它使用方便,支持自动缓冲、指定编码类型、类型转换等。读文件时,如果需要指定编码类型,需要使用InputStreamReader;如果不需要指定编码类型,可使用FileReader,但都应该考虑在外面包上缓冲类Buffered-Reader。

通过前面两个小节,我们应该可以从容地读写文件内容了,但文件和目录本身的操作,如查看元数据信息、文件重命名、遍历文件、查找文件、新建目录等,又该如何进行呢?让我们下节介绍。

13.2 二进制文件和字节流

本节介绍在Java中如何以二进制字节的方式来处理文件,前面我们提到Java中有流的概念,以二进制方式读写的主要流有:

  • InputStream/OutputStream:这是基类,它们是抽象类。
  • FileInputStream/FileOutputStream:输入源和输出目标是文件的流。
  • ByteArrayInputStream/ByteArrayOutputStream:输入源和输出目标是字节数组的流。
  • DataInputStream/DataOutputStream:装饰类,按基本类型和字符串而非只是字节读写流。
  • BufferedInputStream/BufferedOutputStream:装饰类,对输入输出流提供缓冲功能。

下面,我们就来介绍这些类的功能、用法、原理和使用场景,最后总结一些简单的实用方法。

13.2.1 InputStream/OutputStream

我们分别看下InputStream和OutputStream。

1. InputStream

(1)InputStream的基本方法

InputStream是抽象类,主要方法是:

1
public abstract int read() throws IOException;

read方法从流中读取下一个字节,返回类型为int,但取值为0~255,当读到流结尾的时候,返回值为-1,如果流中没有数据,read方法会阻塞直到数据到来、流关闭或异常出现。异常出现时,read方法抛出异常,类型为IOException,这是一个受检异常,调用者必须进行处理。read是一个抽象方法,具体子类必须实现,FileInputStream会调用本地方法。所谓本地方法,一般不是用Java写的,大多使用C语言实现,具体实现往往与虚拟机和操作系统有关。

InputStream还有如下方法,可以一次读取多个字节:

1
public int read(byte b[]) throws IOException

读入的字节放入参数数组b中,第一个字节存入b[0],第二个存入b[1],以此类推,一次最多读入的字节个数为数组b的长度,但实际读入的个数可能小于数组长度,返回值为实际读入的字节个数。如果刚开始读取时已到流结尾,则返回-1;否则,只要数组长度大于0,该方法都会尽力至少读取一个字节,如果流中一个字节都没有,它会阻塞,异常出现时也是抛出IOException。该方法不是抽象方法,InputStream有一个默认实现,主要就是循环调用读一个字节的read方法,但子类如FileInputStream往往会提供更为高效的实现。

批量读取还有一个更为通用的重载方法:

1
public int read(byte b[], int off, int len) throws IOException

读入的第一个字节放入b[off],最多读取len个字节,read(byte b[])就是调用了该方法:

1
2
3
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}

流读取结束后,应该关闭,以释放相关资源,关闭方法为:

1
public void close() throws IOException

不管read方法是否抛出了异常,都应该调用close方法,所以close方法通常应该放在finally语句内。close方法自己可能也会抛出IOException,但通常可以捕获并忽略。

(2)InputStream的高级方法

InputStream还定义了如下方法:

1
2
3
4
5
public long skip(long n) throws IOException
public int available() throws IOException
public synchronized void mark(int readlimit)
public boolean markSupported()
public synchronized void reset() throws IOException

skip跳过输入流中n个字节,因为输入流中剩余的字节个数可能不到n,所以返回值为实际略过的字节个数。InputStream的默认实现就是尽力读取n个字节并扔掉,子类往往会提供更为高效的实现,FileInputStream会调用本地方法。在处理数据时,对于不感兴趣的部分,skip往往比读取然后扔掉的效率要高。

available返回下一次不需要阻塞就能读取到的大概字节个数。InputStream的默认实现是返回0,子类会根据具体情况返回适当的值,FileInputStream会调用本地方法。在文件读写中,这个方法一般没什么用,但在从网络读取数据时,可以根据该方法的返回值在网络有足够数据时才读,以避免阻塞。

一般的流读取都是一次性的,且只能往前读,不能往后读,但有时可能希望能够先看一下后面的内容,根据情况再重新读取。比如,处理一个未知的二进制文件,我们不确定它的类型,但可能可以通过流的前几十个字节判断出来,判读出来后,再重置到流开头,交给相应类型的代码进行处理。

InputStream定义了三个方法:mark、reset、markSupported,用于支持从读过的流中重复读取。怎么重复读取呢?先使用mark()方法将当前位置标记下来,在读取了一些字节,希望重新从标记位置读时,调用reset方法。能够重复读取不代表能够回到任意的标记位置,mark方法有一个参数readLimit,表示在设置了标记后,能够继续往后读的最多字节数,如果超过了,标记会无效。为什么会这样呢?因为之所以能够重读,是因为流能够将从标记位置开始的字节保存起来,而保存消耗的内存不能无限大,流只保证不会小于readLimit。

不是所有流都支持mark、reset方法,是否支持可以通过markSupported的返回值进行判断。InpuStream的默认实现是不支持,FileInputStream也不直接支持,但BufferedInput-Stream和ByteArrayInputStream可以支持。

2. OutputStream

OutputStream的基本方法是:

1
public abstract void write(int b) throws IOException;

向流中写入一个字节,参数类型虽然是int,但其实只会用到最低的8位。这个方法是抽象方法,具体子类必须实现,FileInputStream会调用本地方法。

OutputStream还有两个批量写入的方法:

1
2
public void write(byte b[]) throws IOException
public void write(byte b[], int off, int len) throws IOException

在第二个方法中,第一个写入的字节是b[off],写入个数为len,最后一个是b[off+len-1],第一个方法等同于调用write(b, 0, b.length); 。OutputStream的默认实现是循环调用单字节的write()方法,子类往往有更为高效的实现,FileOutpuStream会调用对应的批量写本地方法。

OutputStream还有两个方法:

1
2
public void flush() throws IOException
public void close() throws IOException

flush方法将缓冲而未实际写的数据进行实际写入,比如,在BufferedOutputStream中,调用flush方法会将其缓冲区的内容写到其装饰的流中,并调用该流的flush方法。基类OutputStream没有缓冲,flush方法代码为空。

需要说明的是文件输出流FileOutputStream,你可能会认为,调用flush方法会强制确保数据保存到硬盘上,但实际上不是这样,FileOutputStream没有缓冲,没有重写flush方法,调用flush方法没有任何效果,数据只是传递给了操作系统,但操作系统什么时候保存到硬盘上,这是不一定的。要确保数据保存到了硬盘上,可以调用FileOutputStream中的特有方法,具体待会介绍。

close方法一般会首先调用flush方法,然后再释放流占用的系统资源。同InputStream一样,close方法一般应该放在finally语句内。

13.2.2 FileInputStream/FileOutputStream

1. FileOutputStream

FileOutputStream有多个构造方法,其中两个如下所示:

1
2
3
public FileOutputStream(File file, boolean append)
throws FileNotFoundException
public FileOutputStream(String name) throws FileNotFoundException

File类型的参数file和字符串的类型的参数name都表示文件路径,路径可以是绝对路径,也可以是相对路径,如果文件已存在,append参数指定是追加还是覆盖,true表示追加, false表示覆盖,第二个构造方法没有append参数,表示覆盖。new一个FileOutputStream对象会实际打开文件,操作系统会分配相关资源。如果当前用户没有写权限,会抛出异常SecurityException,它是一种RuntimeException。如果指定的文件是一个已存在的目录,或者由于其他原因不能打开文件,会抛出异常FileNotFoundException,它是IOException的一个子类。

我们看一段简单的代码,将字符串”hello, 123,老马”写到文件hello.txt中:

1
2
3
4
5
6
7
8
OutputStream output =   new FileOutputStream("hello.txt");
try{
String data = "hello, 123, 老马";
byte[] bytes = data.getBytes(Charset.forName("UTF-8"));
output.write(bytes);
}finally{
output.close();
}

OutputStream只能以byte或byte数组写文件,为了写字符串,我们调用String的get-Bytes方法得到它的UTF-8编码的字节数组,再调用write()方法,写的过程放在try语句内,在finally语句中调用close方法。

FileOutputStream还有两个额外的方法:

1
2
public FileChannel getChannel()
public final FileDescriptor getFD()

FileChannel定义在java.nio中,表示文件通道概念。我们不会深入介绍通道,但内存映射文件方法定义在FileChannel中,我们会在下章介绍。FileDescriptor表示文件描述符,它与操作系统的一些文件内存结构相连,在大部分情况下,我们不会用到它,不过它有一个方法sync:

1
public native void sync() throws SyncFailedException;

这是一个本地方法,它会确保将操作系统缓冲的数据写到硬盘上。注意与Output-Stream的flush方法相区别,flush方法只能将应用程序缓冲的数据写到操作系统,sync方法则确保数据写到硬盘,不过一般情况下,我们并不需要手工调用它,只要操作系统和硬件设备没问题,数据迟早会写入。在一定特定情况下,一定需要确保数据写入硬盘,则可以调用该方法。

2. FileInputStream

FileInputStream的主要构造方法有:

1
2
public FileInputStream(String name) throws FileNotFoundException
public FileInputStream(File file) throws FileNotFoundException

参数与FileOutputStream类似,可以是文件路径或File对象,但必须是一个已存在的文件,不能是目录。new一个FileInputStream对象也会实际打开文件,操作系统会分配相关资源,如果文件不存在,会抛出异常FileNotFoundException,如果当前用户没有读的权限,会抛出异常SecurityException。我们看一段简单的代码,将上面写入的文件”hello.txt”读到内存并输出:

1
2
3
4
5
6
7
8
9
InputStream input = new FileInputStream("hello.txt");
try{
byte[] buf = new byte[1024];
int bytesRead = input.read(buf);
String data = new String(buf, 0, bytesRead, "UTF-8");
System.out.println(data);
}finally{
input.close();
}

读入到的是byte数组,我们使用String的带编码参数的构造方法将其转换为了String。这段代码假定一次read调用就读到了所有内容,且假定字节长度不超过1024。为了确保读到所有内容,可以逐个字节读取直到文件结束:

1
2
3
4
5
int b = -1;
int bytesRead = 0;
while((b=input.read())! =-1){
buf[bytesRead++] = (byte)b;
}

在没有缓冲的情况下逐个字节读取性能很低,可以使用批量读入且确保读到结尾,如下所示:

1
2
3
4
5
6
7
byte[] buf = new byte[1024];
int off = 0;
int bytesRead = 0;
while((bytesRead=input.read(buf, off, 1024-off ))! =-1){
off += bytesRead;
}
String data = new String(buf, 0, off, "UTF-8");

不过,这还是假定文件内容长度不超过一个固定的大小1024。如果不确定文件内容的长度,但不希望一次性分配过大的byte数组,又希望将文件内容全部读入,怎么做呢?可以借助ByteArrayOutputStream,我们下面进行介绍。

13.2.3 ByteArrayInputStream/ByteArrayOutputStream

它们的输入源和输出目标是字节数组,我们分别介绍。

1. ByteArrayOutputStream

ByteArrayOutputStream的输出目标是一个byte数组,这个数组的长度是根据数据内容动态扩展的,它有两个构造方法:

1
2
public ByteArrayOutputStream()
public ByteArrayOutputStream(int size)

第二个构造方法中的size指定的就是初始的数组大小,如果没有指定,则长度为32。在调用write方法的过程中,如果数组大小不够,会进行扩展,扩展策略同样是指数扩展,每次至少增加一倍。

ByteArrayOutputStream有如下方法,可以方便地将数据转换为字节数组或字符串:

1
2
3
public synchronized byte[] toByteArray()
public synchronized String toString()
public synchronized String toString(String charsetName)

toString()方法使用系统默认编码。

ByteArrayOutputStream中的数据也可以方便地写到另一个OutputStream:

1
public synchronized void writeTo(OutputStream out) throws IOException

ByteArrayOutputStream还有如下额外方法:

1
2
public synchronized int size()
public synchronized void reset()

size方法返回当前写入的字节个数。reset方法重置字节个数为0, reset后,可以重用已分配的数组。

使用ByteArrayOutputStream,我们可以改进前面的读文件代码,确保将所有文件内容读入:

1
2
3
4
5
6
7
8
9
10
11
12
13
InputStream input = new FileInputStream("hello.txt");
try{
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int bytesRead = 0;
while((bytesRead=input.read(buf))! =-1){
output.write(buf, 0, bytesRead);
}
String data = output.toString("UTF-8");
System.out.println(data);
}finally{
input.close();
}

读入的数据先写入ByteArrayOutputStream中,读完后,再调用其toString方法获取完整数据。

2. ByteArrayInputStream

ByteArrayInputStream将byte数组包装为一个输入流,是一种适配器模式,它的构造方法有:

1
2
public ByteArrayInputStream(byte buf[])
public ByteArrayInputStream(byte buf[], int offset, int length)

第二个构造方法以buf中offset开始的length个字节为背后的数据。ByteArrayInput-Stream的所有数据都在内存,支持mark/reset重复读取。

为什么要将byte数组转换为InputStream呢?这与容器类中要将数组、单个元素转换为容器接口的原因是类似的,有很多代码是以InputStream/OutputSteam为参数构建的,它们构成了一个协作体系,将byte数组转换为InputStream可以方便地参与这种体系,复用代码。

13.2.4 DataInputStream/DataOutputStream

上面介绍的类都只能以字节为单位读写,如何以其他类型读写呢?比如int、double。可以使用DataInputStream/DataOutputStream,它们都是装饰类。

1. DataOutputStream

DataOutputStream是装饰类基类FilterOutputStream的子类,FilterOutputStream是Output-Stream的子类,它的构造方法是:

1
public FilterOutputStream(OutputStream out)

它接受一个已有的OutputStream,基本上将所有操作都代理给了它。DataOutputStream实现了DataOutput接口,可以以各种基本类型和字符串写入数据,部分方法如下:

1
2
3
void writeBoolean(boolean v) throws IOException;
void writeInt(int v) throws IOException;
void writeUTF(String s) throws IOException;

在写入时,DataOutputStream会将这些类型的数据转换为其对应的二进制字节,比如:
1)writeBoolean:写入一个字节,如果值为true,则写入1,否则0。
2)writeInt:写入4个字节,最高位字节先写入,最低位最后写入。
3)writeUTF:将字符串的UTF-8编码字节写入,这个编码格式与标准的UTF-8编码略有不同,不过,我们不用关心这个细节。

与FilterOutputStream一样,DataOutputStream的构造方法也是接受一个已有的Output-Stream:

1
public DataOutputStream(OutputStream out)

我们来看一个例子,保存一个学生列表到文件中,学生类的定义为:

1
2
3
4
5
6
class Student {
String name;
int age;
double score;
//省略构造方法和getter/setter方法
}

学生列表内容为:

1
2
3
List<Student> students = Arrays.asList(new Student[]{
new Student("张三", 18, 80.9d), new Student("李四", 17, 67.5d)
});

将该列表内容写到文件students.dat中的代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void writeStudents(List<Student> students) throws IOException{
DataOutputStream output = new DataOutputStream(
new FileOutputStream("students.dat"));
try{
output.writeInt(students.size());
for(Student s : students){
output.writeUTF(s.getName());
output.writeInt(s.getAge());
output.writeDouble(s.getScore());
}
}finally{
output.close();
}
}

我们先写了列表的长度,然后针对每个学生、每个字段,根据其类型调用了相应的write方法。

2. DataInputStream

DataInputStream是装饰类基类FilterInputStream的子类,FilterInputStream是Input-Stream的子类。DataInputStream实现了DataInput接口,可以以各种基本类型和字符串读取数据,部分方法有:

1
2
3
boolean readBoolean() throws IOException;
int readInt() throws IOException;
String readUTF() throws IOException;

在读取时,DataInputStream会先按字节读进来,然后转换为对应的类型。

DataInputStream的构造方法接受一个InputStream:

1
public DataInputStream(InputStream in)

还是以上面的学生列表为例,我们来看怎么从文件中读进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static List<Student> readStudents() throws IOException{
DataInputStream input = new DataInputStream(
new FileInputStream("students.dat"));
try{
int size = input.readInt();
List<Student> students = new ArrayList<Student>(size);
for(int i=0; i<size; i++){
Student s = new Student();
s.setName(input.readUTF());
s.setAge(input.readInt());
s.setScore(input.readDouble());
students.add(s);
}
return students;
}finally{
input.close();
}
}

读基本是写的逆过程,代码比较简单,就不赘述了。使用DataInputStream/DataOutput-Stream读写对象,非常灵活,但比较麻烦,所以Java提供了序列化机制,我们在下章介绍。

13.2.5 BufferedInputStream/BufferedOutputStream

FileInputStream/FileOutputStream是没有缓冲的,按单个字节读写时性能比较低,虽然可以按字节数组读取以提高性能,但有时必须要按字节读写,怎么解决这个问题呢?方法是将文件流包装到缓冲流中。BufferedInputStream内部有个字节数组作为缓冲区,读取时,先从这个缓冲区读,缓冲区读完了再调用包装的流读,它的构造方法有两个:

1
2
public BufferedInputStream(InputStream in)
public BufferedInputStream(InputStream in, int size)

size表示缓冲区大小,如果没有,默认值为8192。除了提高性能,BufferedInputStream也支持mark/reset,可以重复读取。与BufferedInputStream类似,BufferedOutputStream的构造方法也有两个,默认的缓冲区大小也是8192,它的flush方法会将缓冲区的内容写到包装的流中。

在使用FileInputStream/FileOutputStream时,应该几乎总是在它的外面包上对应的缓冲类,如下所示:

1
2
3
4
InputStream input = new BufferedInputStream(
new FileInputStream("hello.txt"));
OutputStream output = new BufferedOutputStream(
new FileOutputStream("hello.txt"));

再比如:

1
2
3
4
DataOutputStream output = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream("students.dat")));
DataInputStream input = new DataInputStream(
new BufferedInputStream(new FileInputStream("students.dat")));

13.2.6 实用方法

可以看出,即使只是按二进制字节读写流,Java也包括了很多的类,虽然很灵活,但对于一些简单的需求,却需要写很多代码。实际开发中,经常需要将一些常用功能进行封装,提供更为简单的接口。下面我们提供一些实用方法,以供参考,这些代码都比较简单易懂,我们就不解释了。

复制输入流的内容到输出流,代码为:

1
2
3
4
5
6
7
8
public static void copy(InputStream input,
OutputStream output) throws IOException{
byte[] buf = new byte[4096];
int bytesRead = 0;
while((bytesRead = input.read(buf))! =-1){
output.write(buf, 0, bytesRead);
}
}

实际上,在Java 9中,InputStream类增加了一个方法transferTo,可以实现相同功能,实现是类似的,具体代码为:

1
2
3
4
5
6
7
8
9
10
11
public long transferTo(OutputStream out) throws IOException {
Objects.requireNonNull(out, "out");
long transferred = 0;
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; //buf大小是8192
int read;
while((read = this.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) {
out.write(buffer, 0, read);
transferred += read;
}
return transferred;
}

将文件读入字节数组,这个方法调用了上面的复制方法,具体代码为:

1
2
3
4
5
6
7
8
9
10
public static byte[] readFileToByteArray(String fileName) throws IOException{
InputStream input = new FileInputStream(fileName);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try{
copy(input, output);
return output.toByteArray();
}finally{
input.close();
}
}

将字节数组写到文件,代码为:

1
2
3
4
5
6
7
8
9
public static void writeByteArrayToFile(String fileName,
byte[] data) throws IOException{
OutputStream output = new FileOutputStream(fileName);
try{
output.write(data);
}finally{
output.close();
}
}

Apache有一个类库Commons IO,里面提供了很多简单易用的方法,实际开发中,可以考虑使用。

13.2.7 小结

本节介绍了如何在Java中以二进制字节的方式读写文件,介绍了主要的流。
1)InputStream/OutputStream:是抽象基类,有很多面向流的代码,以它们为参数,比如本节介绍的copy方法。
2)FileInputStream/FileOutputStream:流的源和目的地是文件。
3)ByteArrayInputStream/ByteArrayOutputStream:源和目的地是字节数组,作为输入相当于适配器,作为输出封装了动态数组,便于使用。
4)DataInputStream/DataOutputStream:装饰类,按基本类型和字符串读写流。
5)BufferedInputStream/BufferedOutputStream:装饰类,提供缓冲,FileInputStream/FileOutputStream一般总是应该用该类装饰。

最后,我们提供了一些实用方法,以方便常见的操作,在实际开发中,可以考虑使用专门的类库,如Apache Commons IO(http://commons.apache.org/proper/commons-io/ )。本节完整的代码在github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c57下。

第13章 文件基本技术

我们在日常计算机操作中,接触和处理最多的,除了上网,大概就是各种各样的文件了,从本章开始,我们就来探讨文件处理。文件处理的内容比较多,我们先在13.1节进行概述,并介绍后续章节的安排。

13.1 文件概述

在本节,我们主要介绍文件有关的一些基本概念和常识,Java中处理文件的基本思路和类结构,以及接下来的章节安排。

13.1.1 基本概念和常识

下面,我们先介绍一些基本概念和常识,包括二进制思维、文件类型、文本文件的编码、文件系统和文件读写等。

1.二进制思维

为了透彻理解文件,我们首先要有一个二进制思维。所有文件,不论是可执行文件、图片文件、视频文件、Word文件、压缩文件、txt文件,都没什么可神秘的,它们都是以0和1的二进制形式保存的。我们所看到的图片、视频、文本,都是应用程序对这些二进制的解析结果。

作为程序员,我们应该有一个编辑器,能查看文件的二进制形式,比如UltraEdit,它支持以十六进制进行查看和编辑。比如,一个文本文件,看到的内容为:

1
hello, 123, 老马

打开十六进制编辑,看到的内容如图13-1所示。

epub_923038_113

图13-1 使用UltraEdit查看十六进制

左边的部分就是其对应的十六进制,”hello”对应的十六进制是”68 65 6C 6C 6F”,对应ASCII码编号”104 101 108 108 111”, “马”对应的十六进制是”E9 A9 AC”,这是”马”的UTF-8编码。

2.文件类型

虽然所有数据都是以二进制形式保存的,但为了方便处理数据,高级语言引入了数据类型的概念。文件处理也类似,所有文件都是以二进制形式保存的,但为了便于理解和处理文件,文件也有文件类型的概念。

文件类型通常以扩展名的形式体现,比如,PDF文件类型的扩展名是.pdf,图片文件的一种常见扩展名是.jpg,压缩文件的一种常见扩展名是.zip。每种文件类型都有一定的格式,代表着文件含义和二进制之间的映射关系。比如一个Word文件,其中有文本、图片、表格,文本可能有颜色、字体、字号等,doc文件类型就定义了这些内容和二进制表示之间的映射关系。有的文件类型的格式是公开的,有的可能是私有的,我们也可以定义自己私有的文件格式。

对于一种文件类型,往往有一种或多种应用程序可以解读它,进行查看和编辑,一个应用程序往往可以解读一种或多种文件类型。在操作系统中,一种扩展名往往关联一个应用程序,比如.doc后缀关联Word应用。用户通过双击试图打开某扩展名的文件时,操作系统查找关联的应用程序,启动该程序,传递该文件路径给它,程序再打开该文件。

需要说明的是,给文件加正确的扩展名是一种惯例,但并不是强制的,如果扩展名和文件类型不匹配,应用程序试图打开该文件时可能会报错。另外,一个文件可以选择使用多种应用程序进行解读,在操作系统中,一般通过右键单击文件,选择打开方式即可。

文件类型可以粗略分为两类:一类是文本文件;另一类是二进制文件。文本文件的例子有普通的文本文件(.txt),程序源代码文件(.java)、HTML文件(.html)等;二进制文件的例子有压缩文件(.zip)、PDF文件(.pdf)、MP3文件(.mp3)、Excel文件(.xlsx)等。

基本上,文本文件里的每个二进制字节都是某个可打印字符的一部分,都可以用最基本的文本编辑器进行查看和编辑,如Windows上的notepad、Linux上的vi。二进制文件中,每个字节就不一定表示字符,可能表示颜色、字体、声音大小等,如果用基本的文本编辑器打开,一般都是满屏的乱码,需要专门的应用程序进行查看和编辑。

3.文本文件的编码

对于文本文件,我们还必须注意文件的编码方式。文本文件中包含的基本都是可打印字符,但字符到二进制的映射(即编码)却有多种方式,如GB18030、UTF-8,我们在第2章详细介绍过各种编码,这里就不赘述了。

对于一个给定的文本文件,它采用的是什么编码方式呢?一般而言,我们是不知道的。那应用程序用什么编码方式进行解读呢?一般使用某种默认的编码方式,可能是应用程序默认的,也可能是操作系统默认的,当然也可能采用一些比较智能的算法自动推断编码方式。

对于UTF-8编码的文件,我们需要特别说明。有一种方式,可以标记该文件是UTF-8编码的,那就是在文件最开头加入三个特殊字节(0xEF 0xBB 0xBF),这三个特殊字节被称为BOM头,BOM是Byte Order Mark(即字节序标记)的缩写。比如,对前面的hello.txt文件,带BOM头的UTF-8编码的十六进制形式如图13-2所示。

epub_923038_114

图13-2 带BOM头的文件

图13-1和图13-2所示都是UTF-8编码,看到的字符内容也一样,但二进制内容不一样,一个带BOM头,一个不带BOM头。

需要注意的是,不是所有应用程序都支持带BOM头的UTF-8编码文件,比如PHP就不支持BOM,如果PHP源代码文件带BOM头,PHP运行就会出错。碰到这种问题时,前面介绍的二进制思维就特别重要,不要只看文件的显示,还要看文件背后的二进制

另外,我们需要说明下文本文件的换行符。在Windows系统中,换行符一般是两个字符”\r\n”,即ASCII码的13(’\r’)和10(’\n’),在Linux系统中,换行符一般是一个字符”\n”。

4.文件系统

文件一般是放在硬盘上的,一个机器上可能有多个硬盘,但各种操作系统都会隐藏物理硬盘概念,提供一个逻辑上的统一结构。在Windows中,可以有多个逻辑盘,如C、D、E等,每个盘可以被格式化为一种不同的文件系统,常见的文件系统有FAT32和NTFS。在Linux中,只有一个逻辑的根目录,用斜线/表示。Linux支持多种不同的文件系统,如Ext2/Ext3/Ext4等。不同的文件系统有不同的文件组织方式、结构和特点,不过,一般编程时,语言和类库为我们提供了统一的API,我们并不需要关心其细节。

在逻辑上,Windows中有多个根目录,Linux中有一个根目录,每个根目录下有一棵子目录和文件构成的树。每个文件都有文件路径的概念,路径有两种形式:一种是绝对路径,另一种是相对路径

所谓绝对路径,是从根目录开始到当前文件的完整路径,在Windows中,目录之间用反斜线分隔,如C:\code\hello.java,在Linux中,目录之间用斜线分隔,如/Users/laoma/Desktop/code/hello.java。在Java中,java.io.File类定义了一个静态变量File.separator,表示路径分隔符,编程时应使用该变量而避免硬编码。

所谓相对路径,是相对于当前目录而言的。在命令行终端上,通过cd命令进入的目录就是当前目录;在Java中,通过System.getProperty(“user.dir”)可以得到运行Java程序的当前目录。相对路径不以根目录开头,比如在Windows上,当前目录为D:\laoma,相对路径为code\hello.java,则完整路径为D:\laoma\code\hello.java。

每个文件除了有具体内容,还有元数据信息,如文件名、创建时间、修改时间、文件大小等。文件还有一个是否隐藏的性质。在Linux系统中,如果文件名以.开头,则为隐藏文件;在Windows系统中,隐藏是文件的一个属性,可以进行设置。

大部分文件系统的文件和目录具有访问权限的概念,对所有者、用户组可以有不同的权限,具体权限包括读、写、执行。

文件名有大小写是否敏感的概念。在Windows系统中,一般是大小写不敏感的,而Linux则一般是大小写敏感的。也就是说,同一个目录下,abc.txt和ABC.txt在Windows中被视为同一个文件,而在Linux中则被视为不同的文件。

操作系统中有一个临时文件的概念。临时文件位于一个特定目录,比如Windows7中,临时文件一般位于“C:\Users\用户名\AppData\Local\Temp”; Linux系统中,临时文件位于/tmp。操作系统会有一定的策略自动清理不用的临时文件。临时文件一般不是用户手工创建的,而是应用程序产生的,用于临时目的。

5.文件读写

文件是放在硬盘上的,程序处理文件需要将文件读入内存,修改后,需要写回硬盘。操作系统提供了对文件读写的基本API,不同操作系统的接口和实现是不一样的,不过,有一些共同的概念。Java封装了操作系统的功能,提供了统一的API。

一个基本常识是:硬盘的访问延时,相比内存,是很慢的。操作系统和硬盘一般是按块批量传输,而不是按字节,以摊销延时开销,块大小一般至少为512字节,即使应用程序只需要文件的一个字节,操作系统也会至少将一个块读进来。一般而言,应尽量减少接触硬盘,接触一次,就一次多做一些事情。对于网络请求和其他输入输出设备,原则都是类似的。

另一个基本常识是:一般读写文件需要两次数据复制,比如读文件,需要先从硬盘复制到操作系统内核,再从内核复制到应用程序分配的内存中。操作系统运行所在的环境和应用程序是不一样的,操作系统所在的环境是内核态,应用程序是用户态,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切到内核态,再从内核态切到用户态。这种用户态/内核态的切换是有开销的,应尽量减少这种切换

为了提升文件操作的效率,应用程序经常使用一种常见的策略,即使用缓冲区。读文件时,即使目前只需要少量内容,但预知还会接着读取,就一次读取比较多的内容,放到读缓冲区,下次读取时,如果缓冲区有,就直接从缓冲区读,减少访问操作系统和硬盘。写文件时,先写到写缓冲区,写缓冲区满了之后,再一次性调用操作系统写到硬盘。不过,需要注意的是,在写结束的时候,要记住将缓冲区的剩余内容同步到硬盘。操作系统自身也会使用缓冲区,不过,应用程序更了解读写模式,恰当使用往往可以有更高的效率。

操作系统操作文件一般有打开和关闭的概念。打开文件会在操作系统内核建立一个有关该文件的内存结构,这个结构一般通过一个整数索引来引用,这个索引一般称为文件描述符。这个结构是消耗内存的,操作系统能同时打开的文件一般也是有限的,在不用文件的时候,应该记住关闭文件。关闭文件一般会同步缓冲区内容到硬盘,并释放占据的内存结构。

操作系统一般支持一种称为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件。在内存映射文件中,只有访问到的数据才会被实际复制到内存,且数据只会复制一次,被操作系统以及多个应用程序共享。

13.1.2 Java文件概述

在Java中处理文件有一些基本概念和类,包括流、装饰器设计模式、Reader/Writer、随机读写文件、File、NIO、序列化和反序列化,下面分别介绍。

1.流

在Java中(很多其他语言也类似),文件一般不是单独处理的,而是视为输入输出(Input/Output, IO)设备的一种。Java使用基本统一的概念处理所有的IO,包括键盘、显示终端、网络等。

这个统一的概念是,流有输入流输出流之分。输入流就是可以从中获取数据,输入流的实际提供者可以是键盘、文件、网络等;输出流就是可以向其中写入数据,输出流的实际目的地可以是显示终端、文件、网络等。

Java IO的基本类大多位于包java.io中。类InputStream表示输入流,OutputStream表示输出流,而FileInputStream表示文件输入流,FileOutputStream表示文件输出流。

有了流的概念,就有了很多面向流的代码,比如对流做加密、压缩、计算信息摘要、计算检验和等,这些代码接受的参数和返回结果都是抽象的流,它们构成了一个协作体系,这类似于之前介绍的接口概念、面向接口的编程,以及容器类协作体系。一些实际上不是IO的数据源和目的地也转换为了流,以方便参与这种协作,比如字节数组,也包装为了流ByteArrayInputStream和ByteArrayOutputStream。

2.装饰器设计模式

基本的流按字节读写,没有缓冲区,这不方便使用。Java解决这个问题的方法是使用装饰器设计模式,引入了很多装饰类,对基本的流增加功能,以方便使用。一般一个类只关注一个方面,实际使用时,经常会需要多个装饰类。

Java中有很多装饰类,有两个基类:过滤器输入流FilterInputStream和过滤器输出流FilterOutputStream。过滤类似于自来水管道,流入的是水,流出的也是水,功能不变,或者只是增加功能。它有很多子类,这里列举一些:
1)对流起缓冲装饰的子类是BufferedInputStream和BufferedOutputStream。
2)可以按8种基本类型和字符串对流进行读写的子类是DataInputStream和DataOutput-Stream。
3)可以对流进行压缩和解压缩的子类有GZIPInputStream、ZipInputStream、GZIPOutput-Stream和ZipOutputStream。
4)可以将基本类型、对象输出为其字符串表示的子类有PrintStream。

众多的装饰类使得整个类结构变得比较复杂,完成基本的操作也需要比较多的代码;其优点是非常灵活,在解决某些问题时也很优雅。

3. Reader/Writer

以InputStream/OutputStream为基类的流基本都是以二进制形式处理数据的,不能够方便地处理文本文件,没有编码的概念,能够方便地按字符处理文本数据的基类是Reader和Writer,它也有很多子类:
1)读写文件的子类是FileReader和FileWriter。
2)起缓冲装饰的子类是BufferedReader和BufferedWriter。
3)将字符数组包装为Reader/Writer的子类是CharArrayReader和CharArrayWriter。
4)将字符串包装为Reader/Writer的子类是StringReader和StringWriter。
5)将InputStream/OutputStream转换为Reader/Writer的子类是InputStreamReader和OutputStreamWriter。
6)将基本类型、对象输出为其字符串表示的子类是PrintWriter。

4.随机读写文件

大部分情况下,使用流或Reader/Writer读写文件内容,但Java提供了一个独立的可以随机读写文件的类RandomAccessFile,适用于大小已知的记录组成的文件。该类在日常应用开发中用得比较少,但在一些系统程序中用得比较多。

5. File

上面介绍的都是操作数据本身,而关于文件路径、文件元数据、文件目录、临时文件、访问权限管理等,Java使用File这个类来表示。

6. NIO

以上介绍的类基本都位于包java.io下,Java还有一个关于IO操作的包java.nio, nio表示New IO,这个包下同样包含大量的类。

NIO代表一种不同的看待IO的方式,它有缓冲区和通道的概念。利用缓冲区和通道往往可以达成和流类似的目的,不过,它们更接近操作系统的概念,某些操作的性能也更高。比如,复制文件到网络,通道可以利用操作系统和硬件提供的DMA机制(Direct Memory Access,直接内存存取),不用CPU和应用程序参与,直接将数据从硬盘复制到网卡。

除了看待方式不同,NIO还支持一些比较底层的功能,如内存映射文件、文件加锁、自定义文件系统、非阻塞式IO、异步IO等

不过,这些功能要么是比较底层,普通应用程序用到得比较少,要么主要适用于网络IO操作,我们大多不会介绍,只会介绍内存映射文件。

7.序列化和反序列化

简单来说,序列化就是将内存中的Java对象持久保存到一个流中,反序列化就是从流中恢复Java对象到内存。序列化和反序列化主要有两个用处:一是对象状态持久化,二是网络远程调用,用于传递和返回对象。

Java主要通过接口Serializable和类ObjectInputStream/ObjectOutputStream提供对序列化的支持,基本的使用是比较简单的,但也有一些复杂的地方。不过,Java的默认序列化有一些缺点,比如,序列化后的形式比较大、浪费空间,序列化/反序列化的性能也比较低,更重要的问题是,它是Java特有的技术,不能与其他语言交互。

XML是前几年最为流行的描述结构性数据的语言和格式,Java对象也可以序列化为XML格式。XML容易阅读和编辑,且可以方便地与其他语言进行交互。XML强调格式化但比较“笨重”, JSON是近几年来逐渐流行的轻量级的数据交换格式,在很多场合替代了XML,也非常容易阅读和编辑。Java对象也可以序列化为JSON格式,且与其他语言进行交互。

XML和JSON都是文本格式,人容易阅读,但占用的空间相对大一些,在只用于网络远程调用的情况下,有很多流行的、跨语言的、精简且高效的对象序列化机制,如ProtoBuf、Thrift、MessagePack等。其中,MessagePack是二进制形式的JSON,更小更快。

文件看起来是一件非常简单的事情,但实际却没有那么简单,Java的设计也不是太完美,包含了大量的类,这使得对于文件的理解变得困难。为便于理解,我们将采用以下思路在接下来的章节中进行探讨。

首先,我们介绍如何处理二进制文件,或者将所有文件看作二进制,介绍如何操作,对于常见操作,我们会封装,提供一些简单易用的方法。下一步,我们介绍如何处理文本文件,我们会考虑编码、按行处理等,同样,对于常见操作,我们会封装,提供简单易用的方法。接下来,我们介绍文件本身和目录操作File类,我们也会封装常见操作。以上这些内容是文件处理的基本技术,我们会在本章进行讨论。

在日常编程中,我们经常会需要处理一些具体类型的文件,如属性文件、CSV文件、Excel文件、HTML文件和压缩文件,直接使用字节流/字符流来处理一般是很不方便的,往往有一些更为高层的API,关于这些,我们下章介绍。此外,下章还会介绍比较底层的对文件的操作RandomAccessFile类、内存映射文件,以及序列化。文件看上去应该很简单,但实际却包含很多内容,让我们耐住性子,下一节,先从二进制开始。