15.9.2 使用Buffer

从内部结构上来看,Buffer就像一个数组,它可以保存多个类型相同的数据。Buffer是一个抽象类,其最常用的子类是ByteBuffer,它可以在底层字节数组上进行get/set操作。除ByteBuffer之外,对应于其他基本数据类型(boolean除外)都有相应的Buffer类:CharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer

创建Buffer对象

上面这些Buffer类,除ByteBuffer之外,它们都采用相同或相似的方法来管理数据,只是各自管理的数据类型不同而已。这些Buffer类都没有提供构造器,通过使用如下方法来得到一个Buffer对象。

方法 描述
static XxxBuffer allocate(int capacity) 创建一个容量为capacityXxX Buffer对象。
展开/折叠
方法 描述
static ByteBuffer allocate(int capacity) Allocates a new byte buffer.
static ShortBuffer allocate(int capacity) Allocates a new short buffer.
static IntBuffer allocate(int capacity) Allocates a new int buffer.
static LongBuffer allocate(int capacity) Allocates a new long buffer.
static CharBuffer allocate(int capacity) Allocates a new char buffer.
static FloatBuffer allocate(int capacity) Allocates a new float buffer.
static DoubleBuffer allocate(int capacity) Allocates a new double buffer.

但实际使用较多的是ByteBufferCharBuffer,其他Buffer子类则较少用到。

MappedByteBuffer

其中ByteBuffer类还有一个子类:MappedByteBuffer,MappedByteBuffer用于表示Channel将磁盘文件的部分或全部内容映射到内存中后得到的结果,通常MappedByteBuffer对象由Channelmap()方法返回。

容量 界限 位置

Buffer中有三个重要的概念:容量(capacity)、界限(limit)和位置(position)

  • 容量(capacity):
    • 缓冲区的容量(capacity)表示该Buffer的最大数据容量,即最多可以存储多少数据缓冲区的容量不可能为负值,创建后不能改变
  • 界限(limit):
    • 第一个不应该被读出或者写入的缓冲区位置索引。也就是说,位于limit后的数据既不可被读,也不可被写
  • 位置(position):
    • 用于指明下一个可以被读出的或者写入的缓冲区位置索引(类似于IO流中的记录指针)。当使用BufferChannel中读取数据时,position恰好等于已经读到了多少数据:
    • 当刚刚新建一个Buffer对象时,其position为0,也就是第1个位置的索引为0;
    • 如果从Channel中读取了2个数据到该Buffer中,则position为2,指向Buffer中第3个位置

标记

除此之外,Buffer里还支持一个可选的标记(mark),类似于传统IO流中的mark(),Buffer允许直接将position定位到该mark。这些值满足如下关系:
0≤mark≤position≤limit≤capacity
图15.16显示了某个Buffer读入了一些数据后的示意图
这里有一张图片

Buffer的作用

Buffer的主要作用就是装入数据,然后输出数据(其作用类似于前面介绍的取水的“竹筒”),开始时Bufferposition为0,limitcapacity

装入数据 put方法

程序可通过put()方法向Buffer中放入一些数据(或者从Channel中获取一些数据),每放入一些数据,Bufferposition相应地向后移动一些位置

ByteBuffer的put方法

展开/折叠
ByteBufferput方法 描述
abstract ByteBuffer put(byte b) Relative put method (optional operation).
ByteBuffer put(byte[] src) Relative bulk put method (optional operation).
ByteBuffer put(byte[] src, int offset, int length) Relative bulk put method (optional operation).
abstract ByteBuffer put(int index, byte b) Absolute put method (optional operation).
ByteBuffer put(ByteBuffer src) Relative bulk put method (optional operation).
方法 描述
abstract ByteBuffer putChar(char value) Relative put method for writing a char value (optional operation).
abstract ByteBuffer putChar(int index, char value) Absolute put method for writing a char value (optional operation).
abstract ByteBuffer putDouble(double value) Relative put method for writing a double value (optional operation).
abstract ByteBuffer putDouble(int index, double value) Absolute put method for writing a double value (optional operation).
abstract ByteBuffer putFloat(float value) Relative put method for writing a float value (optional operation).
abstract ByteBuffer putFloat(int index, float value) Absolute put method for writing a float value (optional operation).
abstract ByteBuffer putInt(int value) Relative put method for writing an int value (optional operation).
abstract ByteBuffer putInt(int index, int value) Absolute put method for writing an int value (optional operation).
abstract ByteBuffer putLong(int index, long value) Absolute put method for writing a long value (optional operation).
abstract ByteBuffer putLong(long value) Relative put method for writing a long value (optional operation).
abstract ByteBuffer putShort(int index, short value) Absolute put method for writing a short value (optional operation).
abstract ByteBuffer putShort(short value) Relative put method for writing a short value (optional operation).

CharBuffer的put方法

展开/折叠
方法 描述
abstract CharBuffer put(char c) Relative put method (optional operation).
CharBuffer put(char[] src) Relative bulk put method (optional operation).
CharBuffer put(char[] src, int offset, int length) Relative bulk put method (optional operation).
abstract CharBuffer put(int index, char c) Absolute put method (optional operation).
CharBuffer put(String src) Relative bulk put method (optional operation).
CharBuffer put(String src, int start, int end) Relative bulk put method (optional operation).
CharBuffer put(CharBuffer src) Relative bulk put method (optional operation).

准备输出数据 flip方法

Buffer装入数据结束后,调用Bufferflip()方法,该方法将limit设置为position所在位置,并将position设为0,这就使得Buffer的读写指针又移到了开始位置。

需要读取Buffer中的数据时调用flip方法

也就是说,Buffer调用flip()方法之后,Buffer为输出数据做好了准备;

Buffer类的flip方法 描述
Buffer flip() Flips this buffer.

准备再次装入数据 clear方法

Buffer输出数据结束后,Buffer调用clear()方法,clear方法不是清空Buffer的数据,它仅仅position置为0,将limit置为capacity,这样为再次向Buffer中装入数据做好准备

Buffer类的clear方法 描述
Buffer clear() Clears this buffer.

取出flip 再次装入clear

Buffer中包含两个重要的方法,即flip()clear(),

  • flip为从Buffer中取出数据做好准备,
  • clear为再次向Buffer中装入数据做好准备

Buffer其他常用方法

除此之外,Buffer还包含如下一些常用的方法。

方法 描述
int capacity() 返回Buffercapacity大小。
boolean hasRemaining() 判断当前位置(position)和界限(limit)之间是否还有元素可供处理
int limit() 返回Buffer的界限(limit)的位置。
Buffer limit(int newLimit) 重新设置界限(limit)的值,并返回一个具有新的limit的缓冲区对象
Buffer mark() 设置Buffermark位置,它只能在0和位置(position)之间做mark
int position() 返回Buffer中的position
Buffer position(int newPosition) 设置Bufferposition,并返回position被修改后的Buffer对象
int remaining() 返回当前位置和界限(limit)之间的元素个数
Buffer reset() 将位置(position)转到mark所在的位置。
Buffer rewind() 将位置(position)设置成0,取消设置的mark

放入 取出

除这些移动positionlimitmark的方法之外,Buffer的所有子类还提供了两个重要的方法:put()get()方法,用于向Buffer中放入数据和从Buffer中取出数据。当使用put()get()方法放入、取出数据时,Buffer既支持对单个数据的访问,也支持对批量数据的访问(以数组作为参数)。

相对 绝对

当使用put()get()来访问Buffer中的数据时,分为相对和绝对两种。

  • 相对(Relative):
    • Buffer的当前position处开始读取或写入数据,然后将位置(position)的值按处理元素的个数增加
  • 绝对(Absolute):
    • 直接**根据索引Buffer中读取或写入数据**,使用绝对方式访问Buffer里的数据时,并不会影响位置(position)的值

程序示例

下面程序示范了Buffer的一些常规操作。

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
import java.nio.*;

public class BufferTest {
public static void main(String[] args) {
// 创建Buffer
CharBuffer buff = CharBuffer.allocate(8); // ①
System.out.println("capacity: " + buff.capacity());
System.out.println("limit: " + buff.limit());
System.out.println("position: " + buff.position());
// 放入元素
buff.put('a');
buff.put('b');
buff.put('c'); // ②
System.out.println("加入三个元素后,position = " + buff.position());
// 调用flip()方法 准备取出数据
buff.flip(); // ③
System.out.println("执行flip()后,limit = " + buff.limit());
System.out.println("position = " + buff.position());
// 取出第一个元素
System.out.println("第一个元素(position=0):" + buff.get()); // ④
System.out.println("取出一个元素后,position = " + buff.position());
// 调用clear方法 准备再次放入数据
buff.clear(); // ⑤
System.out.println("执行clear()后,limit = " + buff.limit());
System.out.println("执行clear()后,position = " + buff.position());
System.out.println("执行clear()后,缓冲区内容并没有被清除:" +
"第三个元素为:" + buff.get(2)); // ⑥
System.out.println("执行绝对读取后,position = " + buff.position());
}
}

在上面程序的①号代码处:

1
CharBuffer buff = CharBuffer.allocate(8); // ①

通过CharBuffer的一个静态方法allocate()创建了一个capacity为8的CharBuffer,此时该Bufferlimitcapacity为8,position为0,如图15.17所示
这里有一张图片
接下来程序执行到②号代码处:

1
2
3
buff.put('a');
buff.put('b');
buff.put('c'); // ②

程序向CharBuffer中放入3个数值,放入3个数值后的CharBuffer效果如图15.18所示。
这里有一张图片
程序执行到③号代码处:

1
buff.flip(); // ③

调用了Bufferflip()方法,该方法将把limit设为position处,把position设为0,如图15.19所示。
这里有一张图片
从图15.19中可以看出,当Buffer调用了flip()方法之后,limit就移到了原来position所在位置,这样相当于把Buffer没有数据的存储空间“封印”起来,从而避免读取Buffer数据时读到null

无参get方法会使得position加一

接下来程序在④号代码处:

1
System.out.println("第一个元素(position=0):" + buff.get()); // ④

取出一个元素,取出一个元素后position向后移动一位,也就是该Bufferposition等于1。
程序执行到⑤号代码处:

1
buff.clear(); // ⑤

Buffer调用clear()方法将position设为0,将limit设为与capacity相等。执行clear()方法后的Buffer示意图如图15.20所示。
这里有一张图片

根据索引获取值的get方法不会影响position

从图15.20中可以看出,对Buffer执行clear方法后,该Buffer对象里的数据依然存在,所以程序在⑥号代码处依然可以取出位置为2的值,也就是字符c。因为⑥号代码:

1
2
System.out.println("执行clear()后,缓冲区内容并没有被清除:" +
"第三个元素为:" + buff.get(2)); // ⑥

采用的是根据索引来取值的方式,所以该方法不会影响Bufferposition.

普通Buffer 直接Buffer

通过allocate方法创建的Buffer对象是普通Buffer.
ByteBuffer还提供了一个allocateDirect()方法来创建直接Buffer直接Buffer的创建成本比普通Buffer的创建成本高,但直接Buffer的读取效率更高

只有在ByteBuffer级别上才能创建直接Buffer

由于直接Buffer的创建成本很高,所以**直接Buffer只适用于长生存期的Buffer,而不适用于短生存期、一次用完就丢弃的Buffer。而且只有ByteBuffer才提供了allocateDirect()方法,所以只有在ByteBuffer级别上才能创建直接Buffer。如果希望使用其他类型,则应该将该直接Buffer**转换成其他类型的Buffer

方法 描述
static ByteBuffer allocateDirect(int capacity) Allocates a new direct byte buffer.

15.9 NIO 15.9.1 Java新IO概述

旧IO

旧IO阻塞

前面介绍BufferedReader时提到它的一个特征—当BufferedReader读取输入流中的数据时,如果没有读到有效数据,程序将在此处阻塞该线程的执行(使用InputStreamread()方法从流中读取数据时,如果数据源中没有数据,它也会阻塞该线程),也就是前面介绍的输入流、输出流都是阻塞式的输入、输出

旧IO一次只能处理一个字节

不仅如此,传统的输入流、输出流都是通过字节的移动来处理的(即使不直接去处理字节流,但底层的实现还是依赖于字节处理),也就是说,面向流的输入输出系统一次只能处理一个字节,因此面向流的输入输出系统通常效率不高

新IO

JDK1.4开始,Java提供了一系列改进的输入输出处理的新功能,这些功能被统称为新IO(New IO,简称NIO),新增了许多用于处理输入输出的类,这些类都被放在java.nio包以及子包下,并且对原java.io包中的很多类都以NIO为基础进行了改写,新增了满足NIO的功能

15.9.1 Java新IO概述

IO和传统的IO有相同的目的,都是用于进行输入输出,但新IO使用了不同的方式来处理输入/输出。

内存映射文件方式

IO采用内存映射文件的方式来处理输入/输出,新IO模拟了操作系统上的虚拟内存的概念:将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了,通过这种方式来进行输入输出比传统的输入输出要快得多。

NIO相关包

Java中与新IO相关的包如下:

IO相关的包 描述
java.nio 主要包含各种与Buffer相关的类
java.nio.channels 主要包含与ChannelSelector相关的类。
java.nio.charset 主要包含与字符集相关的类
java.nio.channels.spi 主要包含与Channel相关的服务提供者编程接口。
java.nio.charset.spi 包含与字符集相关的服务提供者编程接口。

Channel简介

Channel(通道)和Buffer(缓冲)是新IO中的两个核心对象,Channel是对传统的输入输出系统的模拟,在新IO系统中所有的数据都需要通过通道传输;
Channel与传统的InputStreamOutputStream最大的区别在于它提供了一个map()方法,通过该map()方法可以直接将“一块数据”映射到内存中。
如果说传统的输入输出系统是面向的处理,则IO则是面向的处理

Buffer简介

Buffer可以被理解成一个容器,它的本质是一个数组,发送到Channel中的所有对象都必须首先放到Buffer,而从Channel中读取的数据也必须先放到Buffer中。
此处的Buffer有点类似于前面介绍的“竹筒”,但该Buffer既可以像“竹筒”那样一次次去Channel中取水,也允许使用Channel直接将文件的某块数据映射成Buffer

Charset和Selector简介

ChannelBuffer之外,

  • IO还提供了用于将Unicode字符串映射成字节序列以及逆映射操作的Charset,
  • 也提供了用于支持非阻塞式输入输出Selector

15.8.7 版本

根据前面的介绍可以知道,反序列化Java对象时必须提供该对象的class文件,现在的问题是,随着项目的升级,系统的class文件也会升级,Java如何保证两个class文件的兼容性?

版本serialVersionUID

Java序列化机制允许为序列化类提供一个private static finalserialVersionUID值,该类变量的值用于标识该Java类的序列化版本,也就是说,如果一个类升级后,只要它的serialVersionUID类变量值保持不变,序列化机制也会把它们当成同一个序列化版本
分配serialVersionUID类变量的值非常简单,例如下面代码片段:

1
2
3
4
5
6
public class Test
{
//为该类指定一个serialVersionUID类变量值
private static final long serialversionUID=512L;
...
}

为了在反序列化时确保序列化版本的兼容性,最好在每个要序列化的类中加入private static final long serialVersionUID这个类变量,具体数值自己定义。这样,即使在某个对象被序列化之后,它所对应的类被修改了,该对象也依然可以被正确地反序列化。

不显示指定版本可能造成序列化失败

如果不显式定义serialversionUID类变量的值,该类变量的值将由JVM根据类的相关信息计算,而修改后的类的计算结果与修改前的类的计算结果往往不同,从而造成对象的反序列化因为类版本不兼容而失败.

使用serialver.exe生成版本

可以通过JDK安装路径的bin目录下的serialver.exe工具来获得该类的serialVersionUID类变量的值,该命令帮助文档如下:

1
2
PS G:\Desktop\codes\15\15.8\externalizable> serialver
用法: serialver [-classpath 类路径] [-show] [类名称...]

serialver依赖于.class文件,所以先编译得到.class文件:

1
javac -encoding utf-8 Person.java

然后输入命令:

1
serialver person

运行该命令,输出结果如下:

1
Person: static final long serialVersionUID =-3719034423096421849L;
1
2
3
PS G:\Desktop\codes\15\15.8\externalizable> javac -encoding utf-8 Person.java
PS G:\Desktop\codes\15\15.8\externalizable> serialver Person
Person: private static final long serialVersionUID = -3719034423096421849L;

上面的-3719034423096421849L就是系统为该Person类生成的serialVersionUID类变量的值。如果在运行serialver命令时指定-show选项(不要跟类名参数),即可启动如图15.15所示的图形用户界面。
这里有一张图片

不指定版本不利于程序移植

不显式指定serialVersionUID类变量的值的另一个坏处是,不利于程序在不同的JVM之间移植。因为不同的编译器对该类变量的计算策略可能不同,从而造成虽然类完全没有改变,但是因为JVM不同,也会出现序列化版本不兼容而无法正确反序列化的现象
如果类的修改确实会导致该类反序列化失败,则应该为该类的serialVersionUID类变量重新分配值。

类的哪些修改可能导致该类实例的 反序列化失败

那么对类的哪些修改可能导致该类实例的反序列化失败呢?下面分三种情况来具体讨论

方法的修改 不需要修改版本

如果修改类时仅仅修改了方法,则反序列化不受任何影响,类定义无须修改serialVersionUID类变量的值。

静态变量和瞬态变量的修改 不需要修改版本

如果修改类时仅仅修改了静态变量或瞬态实例变量,则反序列化不受任何影响,类定义无须修改serialVersionUID类变量的值。

修改非瞬态实例变量 可能要修改版本

  • 如果修改类时修改了非瞬态的实例变量,则可能导致序列化版本不兼容。
    • 如果对象流中的对象新类包含同名的实例变量,而实例变量类型不同,则反序列化失败,类定义应该更新serialVersionUID类变量的值。(修改实例变量的类型后应该更新版本)
    • 如果对象流中的对象新类包含更多的实例变量,则多出的实例变量值被忽略,序列化版本可以兼容,类定义可以不更新serialVersionuID类变量的值(减少实例变量可以不更新版本)
    • 如果新类对象流中的对象包含更多的实例变量,则序列化版本也可以兼容,类定义可以不更新serialversionUID类变量的值;但反序列化得到的新对象中多出的实例变量值都是null(引用类型实例变量)或0(基本类型实例变量)。(增加新的实例变量可以不更新版本)

15.8.6 另一种自定义序列化机制

Java还提供了另一种序列化机制,这种序列化方式完全由程序员决定存储和恢复对象数据。要实现该目标,Java类必须实现Externalizable接口。

Externalizable接口

Externalizable接口里定义了如下两个方法。

方法 描述
void writeExternal(ObjectOutput out) 需要序列化的类实现这个writeExternal方法来保存对象的状态。
该方法
  • 调用DataOutput的方法来保存基本类型的实例变量值,
  • 调用ObjectOutputwriteObject()方法来保存引用类型的实例变量值。
void readExternal(ObjectInput in) 需要序列化的类实现这个readExternal()方法来实现反序列化。
该方法
  • 调用DataInput的方法来恢复基本类型的实例变量值,
  • 调用ObjectInputreadObject()方法来恢复引用类型的实例变量值。
  • DataInputObjectInput的父接口
  • DataOutputObjectOutput的父接口

程序 实现Externalizable接口自定义序列化

实际上,采用实现Externalizable接口方式的序列化与前面介绍的自定义序列化非常相似,只是**Externalizable接口强制自定义序列化**。
下面的Person类实现了Externalizable接口,并且实现了该接口里提供的两个方法,用以实现自定义序列化。

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
import java.io.*;
public class Person implements java.io.Externalizable {
private String name;
private int age;
// 注意必须提供无参数的构造器,否则反序列化时会失败。
public Person() {
}
public Person(String name, int age) {
System.out.println("有参数的构造器");
this.name = name;
this.age = age;
}
// 此处省略getter和setter方法,请自己补上

public void writeExternal(java.io.ObjectOutput out)
throws IOException {
// 将name实例变量的值反转后写入二进制流
out.writeObject(new StringBuffer(name).reverse());
out.writeInt(age);
}
public void readExternal(java.io.ObjectInput in)
throws IOException, ClassNotFoundException {
// 将读取的字符串反转后赋给name实例变量
this.name = ((StringBuffer) in.readObject()).reverse().toString();
this.age = in.readInt();
}
}

上面程序中的Person类实现了java.io.Externalizable接口,该Person类还实现了readExternalwriteExternal两个方法,这两个方法除方法签名和readObjectwriteObject两个方法的方法签名不同之外,其方法体完全一样。

序列化和反序列化方法相同

如果程序需要

  • 序列化实现Externalizable接口的对象,一样调用ObjectOutputStreamwriteObject()方法输出该对象即可;
  • 反序列化实现Externalizable接口的对象,则调用ObjectInputStreamreadObject()方法

必须提供 public的 无参数构造器

需要指出的是,当使用Externalizable机制反序列化对象时,程序会先使用public的无参数构造器创建实例,然后才执行readExternal()方法进行反序列化,因此实现Externalizable的序列化类必须提供public的无参数构造器

表15.2两种序列化机制的对比

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

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

对象序列化需要注意的地方

关于对象序列化,还有如下几点需要注意。

  1. 对象的类名实例变量(包括基本类型、数组、对其他对象的引用)都会被序列化;
    1. 方法、类变量(即static修饰的成员变量)、transient实例变量(也被称为瞬态实例变量)都不会被序列化
  2. 实现Serializable接口的类如果需要让某个实例变量不被序列化,则可在该实例变量前加transient修饰符,而不是加static关键字。虽然static关键字也可达到这个效果,但static关键字不能这样用。
  3. 保证序列化对象的实例变量类型也是可序列化的,否则需要使用transient关键字来修饰该实例变量,要不然,该类是不可序列化的。
  4. 反序列化对象时必须有序列化对象的class文件。
  5. 当通过文件、网络来读取序列化后的对象时,必须按实际写入的顺序读取。

15.8.5 自定义序列化

递归序列化

  • 当对某个对象进行序列化时,系统会自动把该对象的所有实例变量依次进行序列化,
    • 如果某个实例变量引用到另一个对象,则被引用的对象也会被序列化;
      • 如果被引用的对象的实例变量也引用了其他对象,则被引用的对象也会被序列化

这种情况被称为递归序列化

transient关键字

不希望序列化的实例变量的情况

在一些特殊的场景下,如果一个类里包含的某些实例变量是敏感信息,例如银行账户信息等,这时不希望系统将该实例变量值进行序列化;
或者某个实例变量的类型是不可序列化的,因此不希望对该实例变量进行递归序列化,以避免引发java.io.NotSerializableException异常。
通过在实例变量前面使用transient关键字修饰,可以指定Java序列化时无须理会该实例变量
transient关键字只能用于修饰实例变量,不可修饰Java程序中的其他成分

程序 transient关键字修饰实例变量

如下Person类与前面的Person类几乎完全一样,只是它的age使用了transient关键字修饰。

1
2
3
4
5
6
7
8
9
10
11
12
public class Person implements java.io.Serializable {
private static final long serialVersionUID = -2595800114629327570L;
private String name;
private transient int age;
// 注意此处没有提供无参数的构造器!
public Person(String name, int age) {
System.out.println("有参数的构造器");
this.name = name;
this.age = age;
}
// 此处省略getter和setter方法,请自己补上
}

程序 序列化后反序列化 再输出transient修饰的实例变量

下面程序先序列化一个Person对象,然后再反序列化该Person对象,得到反序列化的Person对象后程序输出该对象的age实例变量值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.*;

public class TransientTest {
public static void main(String[] args) {
try (
// 创建一个ObjectOutputStream输出流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("transient.txt"));
// 创建一个ObjectInputStream输入流
ObjectInputStream ois =
new ObjectInputStream(new FileInputStream("transient.txt"))) {
Person per = new Person("孙悟空", 500);// 代码1
// 系统会per对象转换字节序列并输出
oos.writeObject(per);// 代码2
// 反序列化
Person p = (Person) ois.readObject();// 代码3
System.out.println(p.getAge());//代码4
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

上面程序中的
代码1创建了一个Person对象,并为它的nameage两个实例变量指定了值;
代码2将该Person对象序列化后输出;
代码3从序列化文件中读取该Person对象;
代码4输出该Person对象的age实例变量值。

由于本程序中的Person类的age实例变量使用transient关键字修饰,所以p.getAge()将得到0,而不是500

transient关键字的问题

使用transient关键字修饰实例变量虽然简单、方便,但transient修饰的实例变量将被完全隔离在序列化机制之外,这样导致在反序列化恢复Java对象时无法取得该实例变量值

自定义序列化机制

Java还提供了一种自定义序列化机制,**通过这种自定义序列化机制可以让程序控制如何序列化各实例变量,甚至完全不序列化某些实例变量(与使用transient关键字的效果相同)**。

在类中添加特殊的方法

在序列化和反序列化过程中需要特殊处理的类应该提供如下特殊签名的方法,这些特殊的方法用以实现自定义序列化。

1
2
3
private void writeObject(java.io.ObjectOutputStream out) throws IOException
private void readObject(java.io.ObjectInputStream in) throws IOException,ClassNotFoundException
private void readObjectNoData()throws ObjectStreamException

writeObject方法

writeObject方法负责写入特定类的实例状态,以便相应的readObject方法可以恢复它。通过重写该writeObject方法,程序员可以完全获得对序列化机制的控制,可以自主决定哪些实例变量需要序列化,需要怎样序列化。在默认情况下,该方法会调用out.defaultObject来保存Java对象的各实例变量,从而可以实现序列化Java对象状态的目的

readObject方法

readObject方法负责从流中读取并恢复对象实例变量,通过重写该readObject方法,程序员可以完全获得对反序列化机制的控制,可以自主决定需要反序列化哪些实例变量,以及如何进行反序列化。在默认情况下,该方法会调用in default Readobject来恢复Java对象的非瞬态实例变量。在通常情况下,readObject方法与writeObject方法对应,如果writeObject方法中对Java对象的实例变量进行了一些处理,则应该在readObject方法中对其实例变量进行相应的反处理,以便正确恢复该对象。

readObjectNoData方法

当序列化流不完整时,readObjectNoData方法可以用来正确地初始化反序列化的对象。例如,接收方使用的反序列化类的版本不同于发送方,或者接收方版本扩展的类不是发送方版本扩展的类,或者序列化流被篡改时,系统都会调用readObjectNoData方法来初始化反序列化的对象。

程序 自定义序列化

下面的Person类提供了writeObjectreadObject两个方法,其中
writeObject方法在保存Person对象时将其name实例变量包装成StringBuffer,并将其字符序列反转后写入;
readObject方法中处理name的策略与此对应——先将读取的数据强制类型转换成StringBuffer,再将其反转后赋给nane实例变量

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
import java.io.*;
public class Person implements java.io.Serializable {
private static final long serialVersionUID = 3069227031912694124L;
private String name;
private int age;
// 注意此处没有提供无参数的构造器!
public Person(String name, int age) {
System.out.println("有参数的构造器");
this.name = name;
this.age = age;
}
// 此处省略getter和setter方法,请自己补上
// 序列化方法
private void writeObject(java.io.ObjectOutputStream out)
throws IOException {
// 将name实例变量的值反转后写入二进制流
out.writeObject(new StringBuffer(name).reverse());
out.writeInt(age);
}
// 反序列化方法
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException {
// 将读取的字符串反转后赋给name实例变量
this.name = ((StringBuffer) in.readObject()).reverse().toString();
this.age = in.readInt();
}
}

上面程序中提供了writeObjectreadObject用以实现自定义序列化,对于这个Person类而言,序列化、反序列化Person实例并没有任何区别——区别在于序列化后的对象流,即使有Cracker截获到Person对象流,他看到的name也是加密后的name值,这样就提高了序列化的安全性。
writeObject方法存储实例变量的顺序应该和readObject方法中恢复实例变量的顺序一致,否则将不能正常恢复该Java对象。
Person对象进行序列化和反序列化的程序与前面程序没有任何区别,故此处不再赘述。

更彻底的自定义序列化方式

writeReplace方法

还有一种更彻底的自定义机制,它甚至可以在序列化对象时将该对象替换成其他对象。如果需要实现序列化某个对象时替换该对象,则应为序列化类提供如下特殊方法。

1
ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException

writeReplace方法将由序列化机制调用,只要该方法存在。因为该方法可以拥有私有(private)受保护的(protected)和包私有(package-private)等访问权限,所以其子类有可能获得该方法。

程序 使用writeReplace方法

例如下面的Person类提供了writeReplace方法,这样可以在写入Person对象时将该对象替换成ArrayList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.*;
import java.io.*;
public class Person implements java.io.Serializable {
private String name;
private int age;
// 注意此处没有提供无参数的构造器!
public Person(String name, int age) {
System.out.println("有参数的构造器");
this.name = name;
this.age = age;
}
// 此处省略getter和setter方法,请自己补上
// 重写writeReplace方法,程序在序列化该对象之前,先调用该方法
private Object writeReplace() throws ObjectStreamException {
ArrayList<Object> list = new ArrayList<>();
list.add(name);
list.add(age);
return list;
}
}

Java的序列化机制保证在序列化某个对象之前,先调用该对象的writeReplace方法,如果该方法返回另一个Java对象,则系统转为序列化另一个对象。如下程序表面上是序列化Person对象,但实际上序列化的是ArrayList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.*;
import java.util.*;

public class ReplaceTest {
public static void main(String[] args) {
try (
// 创建一个ObjectOutputStream输出流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("replace.txt"));
// 创建一个ObjectInputStream输入流
ObjectInputStream ois =
new ObjectInputStream(new FileInputStream("replace.txt"))) {
Person per = new Person("孙悟空", 500);
// 系统将per对象转换字节序列并输出
oos.writeObject(per);//代码一
// 反序列化读取得到的是ArrayList
ArrayList list = (ArrayList) ois.readObject();//代码二
System.out.println(list);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

上面程序中代码一使用writeObject写入了一个Person对象,但代码二使用readObject方法返回的实际上是一个ArayList对象,这是因为Person类的writeReplace方法返回了个ArrayList对象,所以序列化机制在序列化Person对象时,实际上是转为序列化ArrayList对象。

先调用writeReplace方法 再调用writeObject方法

根据上面的介绍,可以知道系统在序列化某个对象之前,会先调用该对象的writeReplacewriteObject两个方法,系统总是先调用被序列化对象的writeReplace方法,如果该方法返回另一个对象,系统将再次调用另一个对象的writeReplace方法…直到该方法不再返回另一个对象为止,程序最后将调用该对象的writeObject方法来保存该对象的状态

readResolve方法

writeReplace()方法相对的是,序列化机制里还有一个特殊的方法,它可以实现保护性复制整个对象。这个方法就是:

1
ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

readResolve方法会紧接着readObject之后被调用,readResolve方法的返回值将会代替原来反序列化的对象,而原来readObject反序列化的对象将会被立即丢弃。

readResolve()方法在序列化单例类、枚举类时尤其有用
当然,如果使用Java5提供的enum来定义枚举类,则完全不用担心,程序没有任何问题。

早期枚举类问题

但如果应用中有早期遗留下来的枚举类,例如下面的Orientation类就是一个枚举类。

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
import java.io.*;

public class Orientation
implements java.io.Serializable
{
public static final Orientation HORIZONTAL = new Orientation(1);
public static final Orientation VERTICAL = new Orientation(2);
private int value;
private Orientation(int value)
{
this.value = value;
}
// // 为枚举类增加readResolve()方法
// private Object readResolve()throws ObjectStreamException
// {
// if (value == 1)
// {
// return HORIZONTAL;
// }
// if (value == 2)
// {
// return VERTICAL;
// }
// return null;
// }
}

Java5以前,这种代码是很常见的。Orientation类的构造器私有,程序只有两个Orientation对象,分别通过OrientationHORIZONTALVERTICAL两个常量来引用。但如果让该类实现Serializable接口,则会引发一个问题,如果将一个Orientation. HORIZONTAL值序列化后再读出,如下代码片段所示

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

public class ResolveTest {
public static void main(String[] args) {
try (
// 创建一个ObjectOutputStream输入流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("transient.txt"));
// 创建一个ObjectInputStream输入流
ObjectInputStream ois =
new ObjectInputStream(new FileInputStream("transient.txt"))) {
oos.writeObject(Orientation.HORIZONTAL);
Orientation ori = (Orientation) ois.readObject();
System.out.println(ori == Orientation.HORIZONTAL);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

**如果立即拿oriOrientation.HORIZONTAL值进行比较,将会发现返回false**。也就是说,ori是个新的Orientation对象,而不等于Orientation类中的任何枚举值——虽然Orientation的构造器是private的,但反序列化依然可以创建Orientation对象。

前面已经指出,反序列化机制在恢复Java对象时无须调用构造器来初始化Java对象从这个意义上来看,序列化机制可以用来“克隆”对象
在这种情况下,可以通过为Orientation类提供一个readResolve()方法来解决该问题,readResolve()方法的返回值将会代替原来反序列化的对象,也就是让反序列化得到的Orientation对象被直接丢弃。
下面是为Orientation类提供的readResolve()方法(程序清单同上)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 为枚举类增加readResolve()方法
private Object readResolve()throws ObjectStreamException
{
if (value == 1)
{
return HORIZONTAL;
}
if (value == 2)
{
return VERTICAL;
}
return null;
}

通过重写readResolve()方法可以保证反序列化得到的依然是OrientationHORIZONTALVERTICAL两个枚举值之一。

单例类 枚举类都应该提供readResolve方法

所有的单例类、枚举类在实现序列化时都应该提供readResolve()方法,这样才可以保证反序列化的对象依然正常

readResolve方法需要注意的问题

writeReplace()方法类似的是,readResolve()方法也可以使用任意的访问控制符,因此父类的readResolve()方法可能被其子类继承。这样利用readResolve()方法时就会存在一个明显的缺点,就是当父类已经实现了readResolve()方法后,子类将变得无从下手。如果父类包含一个protectedpublicreadResolve方法,而且子类也没有重写该方法,将会使得子类反序列化时得到一个父类的对象——这显然不是程序要的结果,而且也不容易发现这种错误。
总是让子类重写readResolve()方法无疑是一个负担,因此对于要被作为父类继承的类而言,实现readResolve(()方法可能有一些潜在的危险。
通常的建议是:

  • 对于final类,重写readResolve()方法不会有任何问题;
  • 否则,重写readResolve()方法时应尽量使用private修饰该方法。

15.8.4 Java9增加的过滤功能

Java9ObjectInputStream增加了setObjectInputFilter()getObjectInputFilter()两个方法:

方法 描述
void setObjectInputFilter(ObjectInputFilter filter) Set the serialization filter for the stream.
ObjectInputFilter getObjectInputFilter() Returns the serialization filter for this stream.

其中setObjectInputFilter方法用于为对象输入流设置过滤器

反序列化时 过滤器ObjectInputFilter的checkInput方法会自动触发

当程序通过ObjectInputStream反序列化对象时,过滤器的checkInput()方法会被自动激发,用于检查序列化数据是否有效

ObjectInputFilter是函数式接口

方法 描述
ObjectInputFilter.Status checkInput(ObjectInputFilter.FilterInfo filterInfo) Check the class, array length, number of object references, depth, stream size, and other available filtering information.

checkInput方法返回值

使用checkInput()方法检查序列化数据时有3种返回值。

checkInput()方法返回值 描述
ObjectInputFilter.Status.REJECTED 拒绝恢复
ObjectInputFilter.Status.ALLOWED 允许恢复
ObjectInputFilter.Status.UNDECIDED 未决定状态,程序继续执行检查

ObjectInputStream将会根据ObjectInputFilter的检查结果来决定是否执行反序列化:

  • 如果checkInput方法返回Status.REJECtED,反序列化将会被阻止;
  • 如果checkInput方法返回Status.ALLOWED,程序将可执行反序列化;

程序 反序列化之前先检查数据

下面程序对前的ReadObject.java程序进行改进,该程序将会在反序列化之前对数据执行检查。

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
import java.io.*;

public class FilterTest {
public static void main(String[] args) {
try (// 创建一个ObjectInputStream输入流
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt")))
{
ois.setObjectInputFilter((info) -> {
System.out.println("===执行数据过滤===");
ObjectInputFilter serialFilter = ObjectInputFilter.Config.getSerialFilter();
if (serialFilter != null) {
// 首先使用ObjectInputFilter执行默认的检查
ObjectInputFilter.Status status = serialFilter.checkInput(info);
// 如果默认检查的结果不是Status.UNDECIDED
if (status != ObjectInputFilter.Status.UNDECIDED) {
// 直接返回检查结果
return status;
}
}
// 如果要恢复的对象不是1个
if (info.references() != 1) {
// 不允许恢复对象
return ObjectInputFilter.Status.REJECTED;
}
// 如果恢复的不是Person类
if (info.serialClass() != null && info.serialClass() != Person.class) {
// 不允许恢复对象
return ObjectInputFilter.Status.REJECTED;
}
return ObjectInputFilter.Status.UNDECIDED;
});
// 从输入流中读取一个Java对象,并将其强制类型转换为Person类
Person p = (Person) ois.readObject();
System.out.println("名字为:" + p.getName() + "\n年龄为:" + p.getAge());
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

上面程序中的粗体字代码为ObjectInputStream设置了ObjectInputFilter过滤器(程序使用Lambda表达式创建过滤器),程序重写了checkInput方法。
重写checkInput方法时先使用默认的ObjectInputFilter执行检查,

  • 如果检查结果不是Status.UNDECIDED,程序直接返回检查结果。
  • 接下来程序通过FilterInfo检验序列化数据,
    • 如果序列化数据中的对象不唯一(数据已被污染),程序拒绝执行反序列化;
    • 如果序列化数据中的对象不是Person对象(数据被污染),程序拒绝执行反序列化。

通过这种检查,程序可以保证反序列化出来的是唯一的Person对象,这样就让反序列化更加安全、健壮

15.8.3 对象引用的序列化

可序列化类 的成员变量 的类型必须可序列化

前面介绍的Person类的两个成员变量分别是String类型和int类型,如果某个类的成员变量的类型不是基本类型或String类型,而是另一个引用类型,那么这个引用类必须是可序列化的,否则拥有该类型成员变量的类也是不可序列化的

程序

如下Teacher类持有一个Person类的引用,只有Person类是可序列化的,Teacher类才是可序列化的。如果Person类不可序列化,不管Teacher类是否实现SterilizableExternalizable接口,Teacher类都是不可序列化的。
这是因为当程序序列化一个Teacher对象时,如果该Teacher对象持有一个Person对象的引用为了在反序列化时可以正常恢复该Teacher对象,程序会顺带将该Person对象也进行序列化,所以Person类也必须是可序列化的,否则Teacher类将不可序列化。

1
2
3
4
5
6
7
8
9
10
public class Teacher implements java.io.Serializable {
private static final long serialVersionUID = -1184339136873829552L;
private String name;
private Person student;
public Teacher(String name, Person student) {
this.name = name;
this.student = student;
}
// 此处省略了name和student的setter和getter方法
}

一个对象可能被多次序列化的情况

现在假设有如下一种特殊情形:
程序中有两个Teacher对象,它们的Student实例变量都引用到同个Person对象,而且该Person对象还有一个引用变量引用它。如下代码所示。

1
2
3
Person per= new Person("孙悟空",500);
Teacher t1= new Teacher("唐僧",per);
Teacher t2= new Teacher("菩提祖师",per);

上面代码创建了两个Teacher对象和一个Person对象,这三个对象在内存中的存储示意图如图15.13所示。
这里有一张图片

这里产生了一个问题:

  • 如果先序列化t1对象,则系统将该t1对象所引用的Person对象一起序列化;
    • 如果程序再序列化t2对象,系统将一样会序列化该t2对象,并且将再次序列化该t2对象所引用的Person对象;
      • 如果程序再显式序列化per对象,系统将再次序列化该Person对象。
        • 这个过程似乎会向输出流中输出三个Person对象

如果系统向输出流中写入了三个Person对象,那么后果是当程序从输入流中反序列化这些对象时,将会得到三个Person对象,从而引起t1t2所引用的Person对象不是同一个对象,这显然与图5.13所示的效果不一致——这也就违背了Java序列化机制的初衷。

Java序列化机制

所以,Java序列化机制采用了一种特殊的序列化算法,其算法内容如下。

  • 所有保存到磁盘中的对象都有一个序列化编号。
  • 当程序试图序列化一个对象时,程序将先检查该对象是否已经被序列化过,
    • 只有该对象从未(在本次虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出
    • 如果某个对象已经序列化过,程序将只是直接输出一个序列化编号,而不是再次重新序列化该对象。

根据上面的序列化算法,可以得到一个结论:
当第二次、第三次序列化Person对象时,程序不会再次将Person对象转换成字节序列并输出,而是仅仅输出一个序列化编号。假设有如下顺序的序列化代码

1
2
3
oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(per);

上面代码依次序列化了t1t2per对象,序列化后磁盘文件的存储示意图如图15.14所示。
这里有一张图片
通过图15.14可以很好地理解Java序列化的底层机制,通过该机制不难看出,当多次调用writeObject()方法输出同一个对象时,只有第一次调用writeObject()方法时才会将该对象转换成字节序列并输出

程序 多次写入一个对象

下面程序序列化了两个Teacher对象,两个Teacher对象都持有一个引用到同一个Person对象的引用,而且程序两次调用writeObject()方法输出同一个Teacher对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.*;

public class WriteTeacher {
public static void main(String[] args) {
try (
// 创建一个ObjectOutputStream输出流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("teacher.txt"))) {
Person per = new Person("孙悟空", 500);
Teacher t1 = new Teacher("唐僧", per);
Teacher t2 = new Teacher("菩提祖师", per);
// 依次将四个对象写入输出流
oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(per);
oos.writeObject(t2);
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

上面程序中4次调用了writeObject()方法来输出对象,实际上只序列化了三个对象,而且序列的两个Teacher对象的student引用实际是同一个Person对象。

程序 读取被写入多次的对象

下面程序读取序列化文件中的对象即可证明这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.*;

public class ReadTeacher {
public static void main(String[] args) {
try (
// 创建一个ObjectInputStream输出流
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("teacher.txt"))) {
// 依次读取ObjectInputStream输入流中的四个对象
Teacher t1 = (Teacher) ois.readObject();
Teacher t2 = (Teacher) ois.readObject();
Person p = (Person) ois.readObject();
Teacher t3 = (Teacher) ois.readObject();
// 输出true
System.out.println("t1的student引用和p是否相同:" + (t1.getStudent() == p));
// 输出true
System.out.println("t2的student引用和p是否相同:" + (t2.getStudent() == p));
// 输出true
System.out.println("t2和t3是否是同一个对象:" + (t2 == t3));
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

运行效果:

1
2
3
t1的student引用和p是否相同:true
t2的student引用和p是否相同:true
t2和t3是否是同一个对象:true

上面程序中依次读取了序列化文件中的4个Java对象,但通过后面比较判断,不难发现t2t3是同一个Java对象,t1student引用的、t2student引用的和p引用变量引用的也是同个Java对象——这证明了图15.14所示的序列化机制。

序列化可变对象引起的问题

由于Java序列化机制使然:如果多次序列化同一个Java对象时,只有第一次序列化时才会把该Java对象转换成字节序列并输出,这样可能引起一个潜在的问题——当程序序列化一个可变对象时,只有第一次使用writeObject()方法输出时才会将该对象转换成字节序列并输出,当程序再次调用writeObject()方法时,程序只是输出前面的序列化编号,即使后面该对象的实例变量值已被改变,改变的实例变量值也不会被输出
如下程序所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.*;

public class SerializeMutable {
public static void main(String[] args) {

try (
// 创建一个ObjectOutputStream输入流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("mutable.txt"));
// 创建一个ObjectInputStream输入流
ObjectInputStream ois =
new ObjectInputStream(new FileInputStream("mutable.txt"))) {
Person per = new Person("孙悟空", 500);
// 系统会per对象转换字节序列并输出
oos.writeObject(per);
// 改变per对象的name实例变量
per.setName("猪八戒");
// 系统只是输出序列化编号,所以改变后的name不会被序列化
oos.writeObject(per);
Person p1 = (Person) ois.readObject(); // 代码1
Person p2 = (Person) ois.readObject(); // 代码2
// 下面输出true,即反序列化后p1等于p2
System.out.println(p1 == p2);
// 下面依然看到输出"孙悟空",即改变后的实例变量没有被序列化
System.out.println(p2.getName());
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

程序中先使用writeObject()方法写入了一个Person对象,接着程序改变了Person对象的name实例变量值,然后程序再次输出Person对象,但这次的输出已经不会将Person对象转换成字节序列并输出了,而是仅仅输出了一个序列化编号
程序中代码1,代码2两次调用readObject()方法读取了序列化文件中的Java对象,比较两次读取的Java对象将完全相同,程序输岀第二次读取的Person对象的name实例变量的值依然是“孙悟空”,这表明改变后的Person对象并没有被写入,这与Java序列化机制相符。

只有第一次调用wirteObject方法时才会将对象转换成字节序列

当使用Java序列化机制序列化可变对象时一定要注意,只有第一次调用wirteObject方法来输出对象时才会将对象转换成字节序列,并写入到ObjectOutputStream,在后面程序中即使该对象的实例变量发生了改变,再次调用writeObjecto方法输出该对象时,改变后的实例变量也不会被输出。

15.8.2 使用对象流实现序列化

如果需要将某个对象保存到磁盘上或者通过网络传输,那么这个类应该实现Serializable接口或者Externalizable接口之一。关于这两个接口的区别和联系,后面将有更详细的介绍,读者先不去理会Externalizable接口。
使用Serializable来实现序列化非常简单,主要让目标类实现Serializable标记接口即可,无须实现任何方法.

序列化对象步骤

一旦某个类实现了Serializable接口,该类的对象就是可序列化的,程序可以通过如下两个步骤来序列化该对象。

1. 创建objectOutputStream

创建一个ObjectorOutputStream,这个输出流是一个处理流,所以必须建立在其他节点流的基础之上。如下代码所示。

1
2
3
// 创建个 Objectoutputstream输出流
ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("object.txt"));

2. 调用writeObject方法

调用ObjectOutputStream对象的writeObject方法输出可序列化对象,如下代码所示。

1
2
// 将一个 Person对象输出到输出流中
oos.writeObject(person);

程序 对象序列化

下面程序定义了一个Person类,这个Person类就是一个普通的Java类,只是实现了Serializable接口,该接口标识该类的对象是可序列化的。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Person implements java.io.Serializable {
private static final long serialVersionUID = 3069227031912694124L;
private String name;
private int age;

// 注意此处没有提供无参数的构造器!
public Person(String name, int age) {
System.out.println("有参数的构造器");
this.name = name;
this.age = age;
}
// 省略name与age的setter和getter方法
}

下面程序使用ObjectOutputStream将一个Person对象写入磁盘文件.

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

public class WriteObject {
public static void main(String[] args) {
try (
// 创建一个ObjectOutputStream输出流
ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("object.txt"))// 1号代码
) {
Person per = new Person("孙悟空", 500);
// 将per对象写入输出流
oos.writeObject(per);// 2号代码
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

上面程序中的1号代码创建了一个ObjectOutputStream输出流,这个ObjectOutputStream输出流建立在一个文件输出流的基础之上;
程序中的2号代码使用writeObject()方法将一个Person对象写入输出流。

运行上面程序,将会看到生成了一个object.txt文件,该文件的内容就是Person对象.

对象反序列化步骤

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

1. 创建ObjectInputStream

这个输入流是一个处理流,所以必须建立在其他节点流的基础之上。如下代码所示:

1
2
//创建一个ObjectInputstream输入流
ObjectInputstream ois=new ObjectInputstream(new FileInputStream("object.txt"));

2. 调用readObject方法

调用ObjectInputStream对象的readObject()方法读取流中的对象,该方法返回一个Object类型的Java对象,如果程序知道该Java对象的类型,则可以将该对象强制类型转换成其真实的类型。如下代码所示:

1
2
//从输入流中读取一个Java对象,并将其强制类型转换为 Person类
Person p = (Person)ois.readObject();

程序 对象反序列化

下面程序示范了从刚刚生成的object.txt文件中读取Person对象的步骤。

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

public class ReadObject {
public static void main(String[] args) {
try (
// 创建一个ObjectInputStream输入流
// 1号代码
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) {
// 从输入流中读取一个Java对象,并将其强制类型转换为Person类
// 2号代码
Person p = (Person) ois.readObject();
System.out.println("名字为:" + p.getName() + "\n年龄为:" + p.getAge());
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

上面程序中1号代码将一个文件输入流包装成ObjectInputStream输入流,
2号代码使用readObject()方法读取了文件中的Java对象,这就完成了反序列化过程。
运行结果:

1
2
名字为:孙悟空
年龄为:500

反序列化只能恢复对象的数据

必须指出的是,反序列化读取的仅仅是Java对象的数据,而不是Java类,因此采用反序列化恢复Java对象时,必须提供该Java对象所属类的class文件,否则将会引发ClassNotFoundException异常
ObjectInputStream输入流中的readObject()方法声明抛出了ClassNotFoundException异常,也就是说,当反序列化时找不到对应的Java类时将会引发该异常。

反序列化不会调用构造器

还有一点需要指出:Person类只有一个有参数的构造器,没有无参数的构造器,而且该构造器内有个普通的打印语句。当反序列化读取Java对象时,并没有看到程序调用该构造器,这表明反序列化机制无须通过构造器来初始化Java对象

反序列化读取对象时要按序列化时的写入的顺序读取

如果使用序列化机制向文件中写入了多个Java对象,使用反序列化机制恢复对象时必须按实际写入的顺序读取

可序列化类的祖先类要满足的条件

当一个可序列化类有多个父类时(包括直接父类和间接父类),这些父类要么有无参数的构造器,要么也是可序列化的——否则反序列化时将抛出InvalidClassException异常。

如果父类是不可序列化的,只是带有无参数的构造器,则该父类中定义的成员变量值不会序列化到二进制流中

15.8 Java9改进的对象序列化

保存对象或者传输对象

对象序列化的目标是将对象保存到磁盘中,或允许在网络中直接传输对象
对象序列化机制允许把内存中的Java对象转换成平台无关的二进制流,从而允许把这种二进制流持久地保存在磁盘上,通过网络将这种二进制流传输到另一个网络节点。其他程序一旦获得了这种二进制流(无论是从磁盘中获取的,还是通过网络获取的),都可以将这种二进制流恢复成原来的Java对象。

15.8.1 序列化的含义和意义

对象可以脱离程序的运行而独立存在

序列化机制允许将实现序列化的**Java对象** 转换成 字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以备以后重新恢复成原来的对象,序列化机制使得对象可以脱离程序的运行而独立存在

  • 对象的序列化(Serialize)指将一个Java对象写入IO流中,
  • 对象的反序列化(Deserialize)则指从IO流中恢复该Java对象.

Java9可以过滤读入的序列化数据

Java9增强了对象序列化机制,它允许对读入的序列化数据进行过滤,这种过滤可在反序列化之前对数据执行校验,从而提高安全性和健壮性。

可序列化的类

如果需要让某个对象支持序列化机制,则必须让它的类是可序列化的(serializable)。为了让某个类是可序列化的,该类必须实现如下两个接口之一

  • Serializable
  • Externalizable

无须实现Serializable接口方法

Java的很多类已经实现了Serializable,该接口是一个标记接口,实现该接口无须实现任何方法,它只是表明该类的实例是可序列化的。

可序列化的类才可以保存到磁盘中 才可以在网络上传输

所有可能在网络上传输的对象的类都应该是可序列化的,否则程序将会出现异常,比如RMI(Remote method Invoke,即远程方法调用,是Java EE的基础)过程中的参数和返回值;
所有需要保存到磁盘里的对象的类都必须可序列化,比如Web应用中需要保存到HttpSessionServletContext属性的Java对象。

建议每个JavaBean类都实现Serializable

因为序列化是RMI过程的参数和返回值都必须实现的机制,而RMI又是Java EE技术的基础所有的分布式应用常常需要跨平台、跨网络,所以要求所有传递的参数、返回值必须实现序列化。因此序列化机制是Java EE平台的基础。通常建议:程序创建的每个JavaBean类都实现Serializable

15.7 RandomAccessFile

RandomAccessFile可读可写

RandomAccessFileJava输入输出流体系中功能最丰富的文件内容访问类,它提供了众多的方法来访问文件内容,它既可以读取文件内容,也可以向文件输出数据

随机访问

与普通的输入/输出流不同的是,RandomAccessFile支持“随机访问”的方式,程序可以直接跳转到文件的任意地方来读写数据
由于RandomAccessFile可以自由访问文件的任意位置,所以如果只需要访问文件部分内容,而不是把文件从头读到尾,使用RandomAccessFile将是更好的选择
OutputStreamWriter等输出流不同的是,RandomAccessFile允许自由定位文件记录指针,RandomAccessFile可以不从开始的地方开始输出,因此RandomAccessFile可以向已存在的文件后追加内容。如果程序需要向已存在的文件后追加内容,则应该使用RandomAccessFile

只能读写文件

RandomAccessFile的方法虽然多,但它有一个最大的局限,就是只能读写文件,不能读写其他IO节点

文件记录指针

RandomAccessFile对象也包含了一个记录指针,用以标识当前读写处的位置,当程序新创建一个RandomAccessFile对象时,该对象的文件记录指针位于文件头(也就是0处),当读写了n个字节后,文件记录指针将会向后移动n个字节。

方法

移动记录指针的方法

除此之外,RandomAccessFile可以自由移动该记录指针,既可以向前移动,也可以向后移动。RandomAccessFile包含了如下两个方法来操作文件记录指针。

方法 描述
long getFilePointer() 返回文件记录指针的当前位置。
void seek(long pos) 将文件记录指针定位到pos位置

read方法

RandomAccessFile既可以读文件,也可以写,所以它既包含了完全类似于InputStream的三个read()方法,其用法和InputStream的三个read()方法完全一样;
也包含了完全类似于OutputStream的三个write方法,其用法和OutputStream的三个write方法完全一样。
除此之外,RandomAccessFile还包含了系列的readXxx()writeXxx()方法来完成输入、输出。

方法 描述
int read() Reads a byte of data from this file.
int read(byte[] b) Reads up to b.length bytes of data from this file into an array of bytes.
int read(byte[] b, int off, int len) Reads up to len bytes of data from this file into an array of bytes.

write方法

方法 描述
void write(int b) Writes the specified byte to this file.
void write(byte[] b) Writes b.length bytes from the specified byte array to this file, starting at the current file pointer.
void write(byte[] b, int off, int len) Writes len bytes from the specified byte array starting at offset off to this file.

readXxx方法

方法 描述
boolean readBoolean() Reads a boolean from this file.
byte readByte() Reads a signed eight-bit value from this file.
char readChar() Reads a character from this file.
double readDouble() Reads a double from this file.
float readFloat() Reads a float from this file.
void readFully(byte[] b) Reads b.length bytes from this file into the byte array, starting at the current file pointer.
void readFully(byte[] b, int off, int len) Reads exactly len bytes from this file into the byte array, starting at the current file pointer.
int readInt() Reads a signed 32-bit integer from this file.
String readLine() Reads the next line of text from this file.
long readLong() Reads a signed 64-bit integer from this file.
short readShort() Reads a signed 16-bit number from this file.
int readUnsignedByte() Reads an unsigned eight-bit number from this file.
int readUnsignedShort() Reads an unsigned 16-bit number from this file.
String readUTF() Reads in a string from this file.

writeXxx方法

方法 描述
void writeBoolean(boolean v) Writes a boolean to the file as a one-byte value.
void writeByte(int v) Writes a byte to the file as a one-byte value.
void writeBytes(String s) Writes the string to the file as a sequence of bytes.
void writeChar(int v) Writes a char to the file as a two-byte value, high byte first.
void writeChars(String s) Writes a string to the file as a sequence of characters.
void writeDouble(double v) Converts the double argument to a long using the doubleToLongBits method in class Double, and then writes that long value to the file as an eight-byte quantity, high byte first.
void writeFloat(float v) Converts the float argument to an int using the floatToIntBits method in class Float, and then writes that int value to the file as a four-byte quantity, high byte first.
void writeInt(int v) Writes an int to the file as four bytes, high byte first.
void writeLong(long v) Writes a long to the file as eight bytes, high byte first.
void writeShort(int v) Writes a short to the file as two bytes, high byte first.
void writeUTF(String str) Writes a string to the file using modified UTF-8 encoding in a machine-independent manner.

其他方法

方法 描述
void close() Closes this random access file stream and releases any system resources associated with the stream.
FileChannel getChannel() Returns the unique FileChannel object associated with this file.
FileDescriptor getFD() Returns the opaque file descriptor object associated with this stream.
long length() Returns the length of this file.
void setLength(long newLength) Sets the length of this file.
int skipBytes(int n) Attempts to skip over n bytes of input discarding the skipped bytes.

随机访问翻译不对

RandomAccessFile的含义是可以自由访问文件的任意地方(与InputStreamReader需要依次向后读取相区分),所以Random Access File的含义决不是“随机访问”,而应该是“任意访问”。

构造器

RandomAccessFile类有两个构造器,其实这两个构造器基本相同,只是指定文件的形式不同而已个使用String参数来指定文件名,一个使用File参数来指定文件本身。

方法 描述
RandomAccessFile(File file, String mode) Creates a random access file stream to read from, and optionally to write to, the file specified by the File argument.
RandomAccessFile(String name, String mode) Creates a random access file stream to read from, and optionally to write to, a file with the specified name.

mode参数

除此之外,创建RandomAccessFile对象时还需要指定一个mode参数,该参数指定RandomAccessfile的访问模式,该参数有如下4个值。

mode参数值 意义
“r” 只读方式打开指定文件。如果试图对该RandomAccessFile执行写入方法,都将抛出IOException异常
“rw” 读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件。
“rwd” 读、写方式打开指定文件。相对于”rw“模式,还要求对文件内容的每个更新都同步写入到底层存储设备
“rws” 读、写方式打开指定文件。相对于”rw“模式,还要求对文件的内容元数据的每个更新都同步写入到底层存储设备

程序 从文件中间读取

下面程序使用了RandomAccessFile来访问指定的中间部分数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.*;

public class RandomAccessFileTest {
public static void main(String[] args) {
try (RandomAccessFile raf = new RandomAccessFile("RandomAccessFileTest.java", "r")// 代码1
) {
// 获取RandomAccessFile对象文件指针的位置,初始位置是0
System.out.println("RandomAccessFile的文件指针的初始位置:" + raf.getFilePointer());
// 移动raf的文件记录指针的位置
raf.seek(300);// 代码2
byte[] bbuf = new byte[1024];
// 用于保存实际读取的字节数
int hasRead = 0;
// 使用循环来重复“取水”过程
while ((hasRead = raf.read(bbuf)) > 0) {
// 取出“竹筒”中水滴(字节),将字节数组转换成字符串输入!
System.out.print(new String(bbuf, 0, hasRead));
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

上面程序中的代码1创建了一个RandomAccessFile对象,该对象以只读方式打开了RandomAccessFileTest.java文件,这意味着该RandomAccessFile对象只能读取文件内容,不能执行写入。
程序中代码2将文件记录指针定位到300处,也就是说,程序将从300字节处开始读、写,程序接下来的部分与使用InputStream读取并没有太大的区别
运行上面程序,将看到程序只读取后面部分的效果。

程序 在文件末尾追加内容

下面程序示范了如何向指定文件后追加内容,为了追加内容,程序应该先将记录指针移动到文件最后,然后开始向文件中输出内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.io.*;

public class AppendContent {
public static void main(String[] args) {
try (
// 以读、写方式打开一个RandomAccessFile对象
RandomAccessFile raf = new RandomAccessFile("out.txt", "rw")) {
// 将记录指针移动到out.txt文件的最后
raf.seek(raf.length());
raf.write("追加的内容!\r\n".getBytes());
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

上面程序中先以读、写方式创建了一个RandomAccessFile对象,然后将RandomAccessFile对象的记录指针移动到最后;接下来使用RandomAccessfile执行输出,与使用OutputStreamWriter执行输出并没有太大区别。
每运行上面程序一次,都可以看到out.txt件中多一行“追加的内容!”字符串,程序在该字符串后使用“\r\n”是为了控制换行。

在文件中间插入内容

直接在中间位置插入内容 会丢失原来中间之后的内容

RandomAccessFile依然不能向文件的指定位置插入内容,如果直接将文件记录指针移动到中间某位置后开始输出,则新输岀的内容会覆盖文件中原有的内容

先缓存中间之后的内容 再插入新内容 最后插入原来中间的内容

如果需要向指定位置插入内容,程序需要先把插入点后面的内容读入缓冲区,等把需要插入的数据写入文件后,再将缓冲区的内容追加到文件后面

程序 在文件中间插入内容

下面程序实现了向指定文件、指定位置插入内容的功能。

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
import java.io.*;

public class InsertContent {
public static void insert(String fileName, long pos, String insertContent) throws IOException {
File tmp = File.createTempFile("tmp", null);
tmp.deleteOnExit();
try (RandomAccessFile raf = new RandomAccessFile(fileName, "rw");
// 使用临时文件来保存插入点后的数据
FileOutputStream tmpOut = new FileOutputStream(tmp);
FileInputStream tmpIn = new FileInputStream(tmp)) {
raf.seek(pos);
// ------下面代码将插入点后的内容读入临时文件中保存------
byte[] bbuf = new byte[64];
// 用于保存实际读取的字节数
int hasRead = 0;
// 使用循环方式读取插入点后的数据
while ((hasRead = raf.read(bbuf)) > 0) {
// 将读取的数据写入临时文件
tmpOut.write(bbuf, 0, hasRead);
}
// ----------下面代码插入内容----------
// 把文件记录指针重新定位到pos位置
raf.seek(pos);
// 追加需要插入的内容,会覆盖插入点后的内容
raf.write(insertContent.getBytes());
// 重新读入被覆盖的内容,追加临时文件中的内容
while ((hasRead = tmpIn.read(bbuf)) > 0) {
raf.write(bbuf, 0, hasRead);
}
}
}

public static void main(String[] args) throws IOException {
insert("InsertContent.java", 45, "插入的内容\r\n");
}
}

上面程序中使用FilecreateTempFile(String prefix, String suffix)方法创建了一个临时文件(该临时文件将在JVM退出时被删除),用以保存被插入文件的插入点后面的内容。程序先将文件中插入点后的内容读入临时文件中,然后重新定位到插入点,将需要插入的内容添加到文件后面,最后将临时文件的内容添加到文件后面。
这样就可以向指定文件、指定位置插入内容。

实现多线程断点传输工具

多线程断点的网络下载工具(如FlashGet等)就可通过RandomAccessFile类来实现.
所有的下载工具在下载开始时都会建立两个文件:

  • 一个是与被下载文件大小相同的空文件,
  • 一个是记录文件指针的位置文件

下载工具用多条线程启动输入流来读取网络数据,并使用RandomAccessFile将从网络上读取的数据写入前面建立的空文件中,每写一些数据后,记录文件指针的文件就分别记下每个RandomAccessFile当前的文件指针位置——网络断开后,再次开始下载时,每个RandomAccessFile都根据记录文件指针的文件中记录的位置继续向下写数据。

本书将会在介绍多线程和网络知识之后,更加详细地介绍如何开发类似于FlashGet的多线程断点传输工具。