对象序列化的目标是将对象保存到磁盘中或允许在网络中直接传输对象,对象序列化机制允许把内存中的Java对象转换成平台无关的二进制流,从而允许把这种二进制流持久保存在磁盘上,通过网络将这种二进制流传输到另一个网络节点。其他程序一旦获得了这种二进制流(无论是从磁盘中获取,还是通过网络获取),都可以将这种二进制流恢复成原来的Java对象。
1 序列化的含义和意义
序列化机制允许将实现序列化的Java对象转换为字节序列,这些字节序列可以被保存在磁盘上,或通过网络传输,以备以后重新恢复成原来的对象。序列化机制使得对象可以脱离程序的运行而独立存在。
对象的序列化是指将一个Java对象写入IO流中,与此对应的是,对象的反序列化则指从IO流中恢复该Java对象。
如果需要让某个对象可以支持序列化机制,必须让它的类是可序列化的,为了让某个类是可序列化的,该类必须实现如下两个接口之一:
1.Serializable
2.Externlizable
Java的很多类以及实现了Serializable,该接口是一个标记接口,实现该接口无须实现任何方法,它只是表明该类的实例是可序列化的。
所有可能在网络上传输的对象的类都应该是可序列化的,否则程序将会出现异常,比如RMI(远程方法调用)过程中的参数和返回值;所有需要保存到磁盘里的对象的类都必须可序列化。比如Web应用中需要保存到HttpSession或ServletContext属性的Java对象。
因为序列化是RMI过程的参数和返回值都必须实现的机制,而RMI又是JavaEE技术基础,所有分布式应用常常需要跨平台、跨网络,因此要求所有传递的参数、返回值必须实现序列化。因此序列化机制是Java EE平台的基础。通常建议:程序创建的每个JavaBean类都实现Serializable
2 使用对象流实现序列化
如果需要将某个对象保存到磁盘上或者通过网络传输,那么这个类应该实现Serializable接口或者Externalizable接口之一,关于这两个接口的区别和联系,后面有更详细介绍。
使用Serializable来实现序列化非常简单,主要让目标类实现Serializable标记接口即可,无须实现任何方法。
一旦某个类实现了Serializable接口,则该类的对象就是可序列化的,程序可以通过如下两个步骤来序列化该对象:
1.创建一个ObjectOutputStream,这个输出流是一个处理流,所以必须建立在其他节点流的基础之上。
//创建一个ObjectOutputStream输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"));
2.调用ObjectOutputStream对象的writeObject方法输出可序列化对象
//将一个Person对象输出到输出流中
oos.write(per);
下面程序定义了一个Person类,这个Person类就是一个普通Java类,只是实现了Serializable接口,该接口标识该类的对象是可序列化的。
package chapter15; 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;
}
public void setName(String name){
this.name = name;
};
public String getName(){
return this.name;
};
public void setAge(int age){
this.age = age;
};
public int getAge(int age){
return this.age;
}
};
下面程序使用ObjectOutputStream将一个Person对象写入磁盘文件
package chapter15; import java.io.*; public class WriteObject {
public static void main(String[] args){
ObjectOutputStream oos = null;
try{
//创建一个ObjectOutputStream输出流
oos = new ObjectOutputStream(new FileOutputStream("object.txt"));
Person per = new Person("孙悟空", 500);
//将per对象写入输出流
oos.writeObject(per);
}catch(IOException ex){
ex.printStackTrace();
}finally{
try{
if(oos != null){
oos.close();
}
}catch(IOException ex){
ex.printStackTrace()
}
}
}
}
上面程序中创建了一个ObjectOutputStream,这个ObjectOutputStream输出流建立在已文件输出流的基础之上,然后使用writeObject方法将一个Person对象写入输出流。运行上面程序,将会看到程序生成了一个object.txt文件,该文件的内容就是Person对象。
如果程序希望从二进制流中恢复Java对象,则需要使用反序列化,反序列化步骤如下:
1.创建一个ObjectInputStream,这个输入流是一个处理流,所以必须建立在其他节点流的基础之上。
//创建一个ObjectInputStream输入流
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))
2.调用ObjectInputStream对象的readObject方法读取流中的对象,该方法返回一个Object类型的Java对象,如果程序制定该Java对象的类型,则可以将该对象强制类型转换成其真实的类型
//从输入流中读取一个Java对象,并将其强制类型转换为Person类
Person p = (Person)ois.readObject();
下面程序示范了从刚刚生成的object.txt文件中读取Person对象的步骤
package chapter15; import java.io.*; public class ReadObject {
public static void main(String[] args){
ObjectInputStream ois = null;
try{
//创建一个ObjectInputStream输出流
ois = new ObjectInputStream(new FileInputStream("object.txt"));
//从输入流中读取一个Java对象,并将其强制类型转换为Person类
Person p = (Person)ois.readObject();
System.out.println("名字为:" + p.getName() + "\n年龄为:" + p.getAge());
}catch(Exception ex){
ex.printStackTrace();
}finally{
try{
if(ois != null){
ois.close();
}
}catch(IOException ex){
ex.printStackTrace();
}
}
}
}
输出结果:
名字为:孙悟空
年龄为:500
上面程序中将一个文件输入流包装成ObjectInputStream输入流,然后使用readObject读取了文件中的Java对象,这就完成了反序列化过程。
必须指出的是,反序列化读取的仅仅是Java对象的数据,而不是Java类,因此采用反序列化恢复Java对象时,必须提供该Java对象所属类的class文件,否则将会引发ClassNotFoundException异常。
在ObjectInputStream输入流中的readObject方法声明抛出了ClassNotFoundException异常,也就是说当反序列化时找不到对应的Java类时将会引发该异常。
还有一点需要指出的是:Person类只有一个有参数的构造器,没有无参数构造器,而且该构造器内有一个普通的打印语句。当我们反序列化读取Java对象时,并没有看到程序调用该构造器,这表明反序列化机制无须通过构造器来初始化Java对象
如果我们向文件中使用序列化机制写入了多个Java对象,使用反序列化机制恢复对象时必须按实际写入的顺序读取。
3 对象引用的序列化
前面介绍的Person类的两个属性分别是String类型和int类型,如果某个类的属性类型不是基本类型或String类型,而是另一个引用类型,那么这个引用类型必须是可序列化的,否则拥有该类型属性类是不可序列化的。
如下Teacher类持有一个Person类的引用,则只有当Person类是可序列化的,Teacher类才是可序列化的。如果Person不可序列化,则无论Teacher是否实现Serializable、Externlizable接口,则Teacher类都是不可序列化的。
package chapter15; public class Teacher implements java.io.Serializable{
private String name;
private Person student;
public Teacher(String name, Person student){
this.name = name;
this.student = student;
};
public void setName(String name){
this.name = name;
};
public String getName(){
return this.name;
};
public void setStudent(Person student){
this.student = student;
};
public Person getStudent(){
return this.student;
}
}
当程序序列化一个Teacher对象时,如果该Teacher对象持有一个Person对象的引用,为了在反序列化时可以正常恢复该Teacher对象,则程序会顺带将该Person对象也进行序列化,所以Person类也必须是可序列化的,否则Teacher类将不可序列化。
现在假设有如下一种特殊情形:程序中有2个特殊对象,它们的student属性都引用到同一个Person对象,而且该Person对象还有一个引用变量引用它。
Person per = new Person ("孙悟空", 500);
Teacher t1 = new Teacher("唐僧", per);
Teacher t2 = new Teacher("菩提祖师", per);
上面对象创建了2个Teacher对象和一个Person对象,这三个对象在内存中的存储示意图如下
那么这里产生了一个问题,如果我们先序列化t1对象,则系统将该t1对象所引用的Person对象一起序列化;如果程序再序列化t2对象,系统将一样会序列化该t2对象的,并且将再次序列化t2所引用的Person对象,如果程序再显式序列化Per对象,程序将再次序列化该Person对象,这个过程似乎会向输出流中输出3个Person对象。
如果系统向输出流中写入了3个Person对象,那后果是当程序从输入流中反序列这些对象时,将会得到3个Person对象,从而引起t1和t2所引用的Person对象不是同一个对象,这显然与上图效果不一致————这也就违背了Java序列化机制的初衷。
所以Java序列化机制采用了一种特殊的序列化算法,其算法内容是:
1.所有保存到磁盘中的对象都有一个序列化编号。
2.当程序试图序列化一个对象时,程序先检查该对象是否已经被序列化过,只有当该对象从未(在本次虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出。
3.如果某个对象是已经序列化过的,程序将直接只输出一个序列化编号,而不是再次重现序列化该对象。
根据上面的是序列化算法,可以得出截过来,第二次、第三次序列化Person对象时,程序不会再次将Person对象转换成字节序列并输出,而是仅仅输出一个序列化编号。假设有如下顺序的序列化代码:
oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(per);
上面代码依次序列化了t1、t2和per对象,序列化后磁盘文件里的存储示意图如下:
通过上图可以很好地理解Java序列化底层机制,通过该机制可以看出,如果我们多次调用writeObject输出同一个对象时,程序只有第一次调用writeObject方法时才会将该对象转换成字节序列并输出。
下面程序序列化了两个Teacher对象,而两个Teacher对象都持有一个引用到同一个Person对象的引用,而且程序两次调用writeObject输出同一个Teacher对象。
package chapter15; import java.io.*; public class WriteTeacher {
public static void main(String[] args){
ObjectOutputStream oos = null;
try{
oos = new ObjectOutputStream(new FileOutputStream("teacher.txt"));
Person per = new Person ("孙悟空", 500);
Teacher t1 = new Teacher("唐僧", per);
Teacher t2 = new Teacher("菩提祖师", per);
//依次将4个对象写入 输出流
oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(per);
oos.writeObject(t2);
}catch(IOException ex){
ex.printStackTrace();
}finally{
try{
if(oos != null){
oos.close();
}
}catch(IOException ex){
ex.printStackTrace();
}
}
}
}
上面程序四次调用了writeObject方法来输出对象,实际上只是序列化了3个对象,并且序列的两个Teacher对象的student引用实际是同一个Person对象。下面程序读取序列化文件中的对象即可证明这一点:
package chapter15; import java.io.*; public class ReaderTeacher {
public static void main(String[] args) throws Exception{
ObjectInputStream ois = null;
try{
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));
System.out.println("t2的student引用和p的引用是否相同" + (t2.getStudent() == p));
System.out.println("t1的student引用和t3的p的引用是否相同" + (t2 == t3));
}catch(IOException ex){
ex.printStackTrace();
}finally{
try{
if (ois != null){
ois.close();
}
}catch(IOException ex){
ex.printStackTrace();
}
}
}
}
运行结果
t1的student引用和p的引用是否相同true
t2的student引用和p的引用是否相同true
t1的student引用和t3的p的引用是否相同true
上面程序依次读取了序列化文件中的四个Java对象,但通过后面比较判断,可以发现t2和t3是同一个Java对象,t1的student引用的、t2的student引用的和p引用变量引用的也是同一个对象。这证明了上图的序列化机制。
由于Java序列化机制使然:如果多次序列同一个Java对象时,只有第一次序列化时才会把该Java对象转换成字节序列并输出,这样可能引起一个潜在的问题:当程序序列化一个可变对象时,程序只有在第一次使用writeObject方法输出时才会将该对象转换成字节序列并输出,即使后面该对象的属性已被改变,当程序再次调用writeObject方法时,程序至少输出前面的序列化编号,所以改变的属性值不会被输出。如下程序所示:
package chapter15; import java.io.*; public class SerializeMutable {
public static void main(String[] args)throws Exception{
ObjectOutputStream oos = null;
ObjectInputStream ois = null;
try{
//创建一个ObjectOutputStream输出流
oos = new ObjectOutputStream(new FileOutputStream("mutable.txt"));
Person per = new Person("孙悟空", 500);
//对Person对象序列化输出
oos.writeObject(per);
//改变per对象的name属性
per.setName("唐僧");
//系统只是输出序列化编号,所以改变后name不会被序列化
oos.writeObject(per);
//创建一个ObjectInputStream输入流
ois = new ObjectInputStream(new FileInputStream("mutable.txt"));
Person p1 = (Person)ois.readObject();
Person p2 = (Person)ois.readObject();
//下面输出true,即反序列化后p1等于p2
System.out.println(p1 == p2);
//下面依然看到输出孙悟空,即改变后的属性没有被序列化
System.out.println(p2.getName());
}catch(IOException ex){
ex.printStackTrace();
}finally{
try{
if(ois != null){
ois.close();
}
if(oos != null){
oos.close();
}
}catch(IOException ex){
ex.printStackTrace();
}
}
}
}
运行结果:
有参数的构造器
true
孙悟空
从执行结果来看,第二次读取的Person对象的name属性依然是孙悟空,表明改变后的Person对象并没有被写入————这与Java序列化机制相符。
4 自定义序列化
在一些特殊的场景下,如果某个类里包含的属性值是敏感信息,例如银行账户信息等,这时候不希望系统将该属性值进行序列化,或者某个属性的类是不可序列化的,因此不希望对该属性进行递归序列化,避免引发java.io.NotSerializableException异常。 当对某个对象进行序列化时,系统会自动把该对象的所有属性依次进行序列化,如果某个属性引用到另一个对象,则被引用的对象的属性也引用了其他对象,则被引用的对象也会被序列化,这种称为递归序列化。
通过在属性前面加上transient关键字,可以指定Java序列化时无须理会该属性值,如下Person类与前面的Person类几乎完全一样,只是其age属性使用了transient关键字修饰。
transient关键字只能用于修饰属性,不可修饰Java程序其他成分。
下面程序先序列化一个Person对象,然后在反序列化该Person对象,得到反序列化的Person对象后程序输出该对象的age属性值
上面程序创建了一个Person对象,并为该Person对象指定了name、age属性值,然后将Person对象序列化输出,接着程序从序列化文件中读取该Person对象,并输出对象的age属性值,由于本程序中的Person类的age属性使用transient关键字修饰,所以输出的age属性值为0.
使用transient关键字修饰属性虽然简单方便,但被transient修饰的属性将被完全隔离在序列化机制之外,这样导致在反序列化恢复Java对象时候无法取得该属性值。Java还提供了另一种自定义序列化机制,通过这种自定义序列化机制可以让程序控制如何序列化各属性,甚至完全不序列化某些属性(与使用transient关键字的效果相同)
在序列化和反序列化过程中需要特殊处理的类应该提供如下特殊签名的方法,这些特殊的方法用以实现自定义序列化。
writeObject方法负责写入特定类的实例的状态,以便相应的readObject方法可以恢复它。通过重写该方法,程序员可以完全获得对序列化机制的控制,程序员可以自主决定哪些属性需要序列化,需要怎样序列化。默认情况下,该方法会调用out.defaultWriteObject来保存Java对象的各属性,从而可以实现序列化Java对象状态的目的。
readObject方法负责从流中读取并恢复对象属性,通过重写该方法,程序员可以获取对反序列化机制的控制,可以自主决定需要反序列化哪些属性,以及进行怎样的反序列化。默认情况下,该方法会调用in.defaultReadObject来恢复Java对象的非静态和非瞬态属性。通常情况下,readObject方法与writeObject对应人,如果writeObject方法中对Java对象的属性进行了一些处理,则应该在readObject方法中对其属性进行相应的反处理,以便正确恢复该对象。
当序列化流不完整的时候,readObjectNoData方法可以用来正确地初始化反序列化的对象。例如接收方使用的反序列化类的版本不同于发送方,或者接收者版本扩展的类不是发送者版本扩展的类时,或者序列化流被篡改时,系统都会调用readObjectNoData方法来初始化反序列化的对象。
下面的Person类提供了writeObject和readObject两个方法,其中writeObject方法在报错Person属性时将其name包装成StringBuffer,并将其字符串反转后写入,那么在readObject方法中读取name属性时先将读取的数据强制类型转换成StringBuffer,然后将其反转后赋给name属性。
上面程序中的方法用以实现自定义的序列化,对于这样的Person对象而言,程序序列化、反序列化时并没有任何区别————区别在与序列化后的对象流,即使有Cracker截获到Person对象流,他看到name将是加密后的name值,这样提高序列化的安全性。
5 另一种自定义序列化机制
Java还提供了另一种序列化机制,这种序列化方式完全由程序员决定存储和恢复对象数据。要实现该目标,Java类必须实现Externalizable接口,该接口里实现了2个方法:
void readExternal(ObjectInput in):需要序列化的类实现readExternal方法来实现反序列化。该方法调用DataInput(它是ObjectInput的父接口)的方法来恢复基本类型的属性值,调用ObjectInput的readObject方法来恢复引用类型的属性值。
void writeExternal(ObjectOutput out):需要序列化的类实现writeExternal方法来保存对象的状态。该方法调用DataInput(它是ObjectInput的父接口)的方法来保存基本类型的属性值,调用ObjectOutput的writeObject方法来保存引用类型的属性值。
实现上采用实现Externalizable接口方式的序列化与前面介绍的自定义序列化非常像,只是Externalizable接口强制自定义序列化,下面的Person类实现了Externalizable接口,并且实现了该接口里提供的2个方法,用以实现自定义序列化。
上面程序中的Person类实现了java.io.Externalizable接口,该Person类还实现了readExternal、writeExternal两个方法,这两个方法除了方法签名和前面readObject和writeObject两个方法的方法签名不同之外,其方法体完全一样。
如果程序需要序列化实现Externalizable接口的对象,一样调用ObjectOutputStream的writeObject()方法输出该对象即可;反序列化该对象,调用ObjectOutputStream的readObject方法即可。此处不再赘述。
关于两种序列化机制的对比
实现Serializable接口 实现Externalizable接口
系统自动存储必要信息 程序员决定存储哪些信息
Java内建支持,易于实现,只需实现该
接口即可,无须任何代码支持 仅仅提供两个空方法,实现该接口必须为两个空方法提供实现
性能略差 性能略高
通常情况下都是采用Serializable接口
关于对象序列化,还有如下注意:
1.对象的类名、属性(包括基本类型、数组、对其他对象的引用)都会被序列化,方法、static属性、transient属性(也被称为瞬态属性)都不会被序列化。
2.保证序列化对象的属性的类型也是可序列化的,否则需要使用transient关键字来修饰该属性,要不然则该类不可序列化
3.反序列化对象时,必须有序列化对象的class文件
4.当通过文件、网络来读取序列化后对象时,必须按实际写入的顺序读取。
6 版本
根据前面介绍,反序列化Java对象时必须提供该对象的class文件,现在问题是随着项目的升级,系统的class文件也会升级,Java如何保证两个class文件的兼容性。
java序列化机制允许为序列化类提供一个private static final的serialVersionUID属性值,该属性值用于标识该Java类的序列化版本,也就是说如果一个类升级后,只要它的serialVersionUID属性值保持不变,序列化机制也会把它们当成同一个序列化版本。
分配serialVersionUID属性值非常简单。
public class Test{
//为该类指定一个serialVersionUID属性值
private static final long serialVersionUID = 512L;
}
为了在反序列化时确保序列化版本的兼容性,最好在每个要序列化的类中加入private static final long serialVersionUID这个属性,具体数值自己定义。这样即使在某个对象被序列化之后,它所对应类被做出了修改,该对象依然可以被正确地反序列化。
如果不显示定义serialVersionUID属性,该属性值将由JVM根据类的相关信息计算,而修改后的类的计算结果与修改前的类的计算结果往往不同,从而造成对象的反序列化因为类版本不兼容而失败。
我们可以通过JDK安装路径的bin目录下的serialver.exe工具来获得该类的serialVersionUID值。命令:serialver Person,输出结果Person: static final long serialVersionUID = 3069227031912694124L;该值就是系统为该Person类生成的serialVersionUID属性值。
不显示定义serialVersionUID属性值的另一个坏处是,不利于程序在不同的JVM之间的移植,因为不同编译器计算该属性值的计算策略可能不同,从而造成即使类完全 没有改变,但是因为JVM不同,也会出现序列化版本不兼容而无法正确反序列化的现象。
如果对类的修改确实会导致该类反序列化失败时,我们应该为该类重新分配一个serialVersionUID属性值,那么对类的哪些修改可能导致该类实例的反序列化失败呢?下面分三种情况来讨论:
1.如果修改类时仅仅修改了方法,则反序列化完全不受任何影响,类定义无须修改serialVersionUID属性值。
2.如果修改类时仅仅修改了静态属性或瞬态属性,则反序列化不受任何影响,类定义无须修改serialVersionUID属性值。
3.如果修改类时修饰了非静态、非瞬态属性,则可能导致序列化版本不兼容,如果对象流中的对象和新类中包含同名的属性,而属性类型不同,则反序列化失败,类定义应该更新serialVersionUID属性值。如果对象流中对象比新类中包含更多的属性,则多出的属性值被忽略,序列化版本可以兼容,类定义可以不更新serialVersionUID属性值,如果新类比对象流中对象包含更多的属性,序列化版本也可以兼容,类定义可以不更新serialVersionUID属性值。但反序列化得到的新对象多出的属性值都是null(引用类型属性)或0(基本类型属性)
7 Java新IO
前面介绍BufferedReader时介绍到它的一个特征:当BufferedReader读取输入流中的数据时,如果没有读到有效数据时,程序将在此处阻塞该线程的执行(使用InputStream的read方法从流中读取数据时,如果数据源中没有数据,它也会阻塞该线程),也就是前面介绍的输入、输出流都是阻塞式的输入、输出。不仅如此,传统的输入、输出流都是通过字节的移动处理的(即使我们可以不直接去处理字节流,但底层的实现还是依赖于字节处理),也就是说面向流的输入/输出系统一次只能处理一个字节,因此面向流的输入/输出系统通常效率不高。
从JDK1.4开始,Java提供了一系列改进的输入/输出处理的新特性,这些功能被称为I/O,新增了许多用于输入/输出的类,这些类都放在java.nio包以及子包下,并且对源java.io包中的很多类以NIO为基础进行了改写,新增了满足新IO的功能。
7.1 Java新IO概述
新IO和传统的IO有相同的目的,都是用于进行输入/输出功能,但新IO使用了不同的方式来处理输入/输出,新IO采用内存映射文件的方式来处理输入/输出,新IO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。
java中NIO相关的包如下:
java.nio包:主要提供了一些和Buffer相关的类
java.nio.channels包:主要包括Channel和Selector相关的类
java.nio.charset包:主要包含和字符集相关的类
java.nio.channels.spi包:主要包含提供Channel服务的类
java.nio.charset.spi包:主要包含提供字符集服务的相关类。
Channel(通道)和Buffer(缓冲)是新IO中的两个核心对象,Channel是对传统输入/输出系统中的模拟,新IO系统中所有数据都需要通过通道传输:Channel与传统的InputStream、OutputStream最大的区别在于它提供了一个map方法,通过该map方法可以直接将一块数据映射到内存中,如果说传统的输入/输出系统是面向流的处理,而新IO则是面向块的处理。
Buffer可以被理解成一个容器,它的本质是一个数组,发送到Channel中的所有对象都必须首先放到Buffer中,而从Channel中读取的数据也必须首先读到Buffer中,此处的Buffer有点类似于前面我们介绍的竹筒,但该Buffer既可以像前面那样一次、一次去Channel中取水,也允许使用Channel直接将文件的某块数据映射成Buffer。
除了Channel和Buffer之外,新IO还提供了用于将UNICODE字符串映射成字节序列以及逆映射操作的Charset类,还提供了用于支持非阻塞式输入/输出的Selector类。
7.2 使用Buffer
从内部结构上来看,Buffer就像一个数组,它可以保存多个类型相同的数据.Buffer是一个抽象类,其最常用的子类是ByteBuffer,它可以在底层字节数组上进行get/set操作,除了ByteBuffer之外,对应其他疾病数据类型(boolean除外)都有相应的Buffer类:ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer
上面这些Buffer类,除了ByteBuffer之外,它们都采用相同或相似的方法来管理数据,只是各自管理的数据类型不同而已。这些Buffer都没有提供构造器,通过使用如下方法来得到一个Buffer对象。
static XxxBuffer allocate(int capacity):创建一个容量为capacity的XxxBuffer对象。
但实际使用较多的是ByteBuffer和CharBuffer,其他Buffer子类则较少用到,其中ByteBuffer类还有一个子类:MappedByteBuffer,它用于表示Channel将磁盘文件的部分或全部内容映射到内存中后得到的结果,通常MappedByteBuffer对象由Channel的map方法返回。在Buffer中有三个重要的参数容量(capacity)、界限(limit)和位置(position):
1.容量:缓冲区的容量表示该Buffer的最大数据容量,即最多可以存储多少数据,缓冲区的容量不可能为负值,在创建后也不能改变。
2.界限:第一个不应该被读出或者写入的缓冲区位置索引。也就是说,位于limit后的数据既不可以被读,也不可被写。
3.位置:用于指明下一个可以被读出的或者写入的缓冲区位置索引(类似于IO流中的记录指针)。当使用Buffer从Channel中读取数据时,position的值敲好等于已经读到了多少数据。当刚刚新建一个Buffer对象时,其position为0,如果从Channel中读取了2个数据到该Buffer中,则position为2,指向Buffer中第三个(第一个位置的索引为0)位置
除此之外,Buffer里还可以支持一个可选的标记(mark,类似传统IO流中mark),该mark允许程序直接将position定位到该mark处,这些值满足如下关系:
0=<mark=<position=<limit=<capacity
下图显示了某个Buffer读入了一些数据后的示意图:
Buffer的主要作用就是装入数据,然后输出数据(其作用类似于前面介绍的取水的竹筒),开始时Buffer的position为0,limit为capacity,程序调用put方法,不断地向Buffer中放入数据(或者从Channel中获取一些数据),每放入一些数据,Buffer的position相应地向后移动一些位置。
当Buffer装入数据结束后,调用Buffer的flip方法,该方法将limit设置为position所在位置,将position设为0,这样使得从Buffer中读数据时总是从0开始,读完刚刚装入的所有数据即结束,也就是说Buffer调用flip方法后,Buffer为输出数据做好了准备;当Buffer输出数据结束后,Buffer调用clear方法,clear方法不是清空Buffer的数据,它仅仅将position置为0,将limit设置为capacity,这样为再次向Buffer中装入数据做好准备。
Buffer中包含两个重要的方法flip和clear,flip为从Buffer中取出数据做好准备,而clear则向Buffer中装入数据做好准备。
除此之外,Buffer还包含如下一些常用的方法:
Buffer的主要作用就是装入数据,然后输出数据(其作用类似于前面介绍的取水的竹筒),开始时Buffer的position为0,limit为capacity,程序调用put方法,不断地向Buffer中放入数据(或者从Channel中获取一些数据),每放入一些数据,Buffer的position相应地向后移动一些位置。
当Buffer装入数据结束后,调用Buffer的flip方法,该方法将limit设置为position所在位置,将position设为0,这样使得从Buffer中读数据时总是从0开始,读完刚刚装入的所有数据即结束,也就是说Buffer调用flip方法后,Buffer为输出数据做好了准备;当Buffer输出数据结束后,Buffer调用clear方法,clear方法不是清空Buffer的数据,它仅仅将position置为0,将limit设置为capacity,这样为再次向Buffer中装入数据做好准备。
Buffer中包含两个重要的方法flip和clear,flip为从Buffer中取出数据做好准备,而clear则向Buffer中装入数据做好准备。
除此之外,Buffer还包含如下一些常用的方法:
代码说明:
程序调用了flip方法之后,limit就移到了原来position所在位置,这样相当于把Buffer中没有数据的存储空间封印起来,从而避免读取Buffer数据时读取到null值。
调用clear方法将position设为0,将limit设为与capacity相等。但是Buffer对象里的数据依然存在,所以依然能取出数据。
通过allocate方法创建的Buffer对象是普通的Buffer,ByteBuffer还提供了一个allocateDirect方法来创建直接Buffer。创建直接Buffer的成本比创建普通Buffer成本高,但这可以使运行时环境直接在该Buffer上进行较快的本机I/0操作。
由于创建直接Buffer会增加创建的成本,所以直接Buffer只适用于长生存期的Buffer,而不适用于创建短生存期、一次用完就丢弃的Buffer,而且只有ByteBuffer才提供了allocateDirect方法,所以只能在ByteBuffer级别上创建直接Buffer,如果希望使用其他类型,则应该将该Buffer转成其他类型的Buffer。
7.3 使用Channel
Channel类似于传统的流对象,但与传统的流不同的是,Channel有两个主要的区别:
1.Channel可以直接将指定文件的部分或全部直接映射成Buffer
2.程序不能直接访问Channel中的数据,包括读取、写入都不行,Channel只能与Buffer进行交互,也就是说,如果要从Channel中取得数据,必须先用Buffer从Channel中取出一些数据,然后让程序从Buffer中取出这些数据,如果要将程序中的数据写入Channel,一样先让程序将数据放入Buffer中,程序再将Buffer里的输入写入Channel中。
Channel是一个接口,位于java.io.channels包下,系统未该接口提供了DatagramChannel、FileChannel、Pipe.SinkChannel等等实现类,本节主要介绍FileChannel用法。根据这些Channel的名字我们不难发现新IO里的Channel是按功能来划分的,例如Pipe.SinkChannel用于支持线程之间通信的管道Channel,而ServerSocketChannel、SocketChannel则是用于支持TCP网络通信的Channel
所有的Channel都不应该通过构造器直接创建,而是通过传统的节点InputStream、OutputStream的getChannel方法来返回对应的Channel,不同的节点流获得的Channel不一样。
Channel中最常用的三类方法是map、read和write,其中map方法用于将Channel对应的部分或全部数据映射的ByteBuffer,而read或write方法都有一系列重载形式,这些方法用于从Buffer中读取数据藿香Buffer里写入数据。
其map方法的方法签名为:MappedByteBuffer map(FileChannel.MapMode mode, long position, long size):第一个参数执行映射时的模式,分别有只读,读写等模式,而第二个、第三个参数用于控制将Channel的哪些数据映射成ByteBuffer。
下面程序示范了直接将FileChannel的全部数据映射成ByteBuffer的效果:
不仅InpuStream、OutputStream包含了getChannel方法,在RandomAccessFile中也包含一个getChannel方法,返回的FileChannel是只读的还是读写的Channel取决于RandomAccessFile打开文件的模式。
上面程序Channel的记录指针移动到该Channel的最后,可以让程序将指定ByteBuffer的数据追加到该Channel后面
如果读者习惯了传统IO的用竹筒多次重复取水过程,或者担心Channel对应的文件过大,使用map方法一次将所有文件内容映射到内存中引起性能下降,也可以使用Channel和Buffer传统的用竹筒多次重复取水的方式。
上面代码使用FileChannel和Buffer来读取文件,但处理方是和使用InputStream和byte[]来读取文件的方式几乎一样,都是采用用竹筒重复取水方式,但因为Buffer提供了flip和clear两个方法,所以程序处理起来比较方便,每次读取数据后调用flip方法将没有数据的区域封印起来,避免程序从Buffer中取出null值,取出数据后,立即调用clear方法将Buffer的position设为0,为下一次读取数据做准备。
8 编码集和Charset
前面我们已经提到:计算机里的文件、数据、图片文件只是我们看到的表面现象,所有文件在底层都是二进制文件。对于图片、音乐文件先不管,对于文本文件而言,之所以我们可以看到一个个字符,这完全是因为系统将底层的字节序列转换成字符序列的缘故。那么在这个过程中涉及两个概念:编码和解码。通常而言,把明文的字符串序列转换成计算机理解的字节序列(二进制文件,普通人看不懂)称为编码,把字节序列转换成普通人能看懂的明文字符串称为解码。对于一份文本文件而言,我们必须采用合适的字符集来解码它,才可以将这份文件翻译成正确的文本内容。
Java中编码采用UNICODE字符集,但很多操作系统并不使用UNICODE字符集,那么当从系统中读取数据到Java程序中进行处理时,就可能会出现乱码问题。
JDK1.4提供了Charset来处理字节序列和字符序列(字符串)之间的转换关系,该类包含了用于创建解码器和编码器的方法,还提供了获取Charset所支持字符集的方法,Charset类是不可变的。
Charset里提供了一个availableCharsets静态方法来获取当前JDK所支持的所有字符集,所以程序可以使用如下程序来获取该JDK所支持的全部字符集。
上面输出了JDK所支持的全部字符集,从结果可以看出每个字符集都有一个字符串名称,也被称为字符串别名。
一旦知道了想使用字符集的别名之后,程序就可以调用Charset的forName()方法来创建对应的Charset对象,forName方法参数就是相应字符集的别名。例如
Charset cs = Charset.forName("ISO-8859-1")
获得了Charse对象之后就可以通过该对象的newDecoder()、newEncoder()这两个方法分别返回CharsetDecoder和CharsetEncoder对象,代表该Charset的解码器和编码器。调用CharsetDecoder的decode方法就可以将ByteBuffer(字节序列)转换成CharBuffer(字符序列),调用CharsetEncoder的encode方法就可以将CharBuffer或String(字符序列)转换成ByteBuffer(字节序列)。如下程序使用了CharsetEncoder和CharsetDecoder完成了ByteBuffer和CharBuffer之间的转换。
实际上Charset里也提供了如下三个方法:
CharBuffer decode(ByteBuffer bb):将ByteBuffer中字节序列转换成字符序列的便捷方法
ByteBuffer encode(CharBuffer cb):将CharBuffer中字符序列转换成字节序列的便捷方法
ByteBuffer encode(String str):将String中的字符序列转换成字节序列的便捷方法
9 文件锁
文件锁在操作系统上是很平常的事情,如果多个运行的程序需要并发修改同一个文件时,程序之间需要某种机制来进行通信,例如我们用PowerPoint打开一份已经打开的幻灯片文档,将会有是否已只读方式打开提示。
使用文件锁可以有效地阻止多条进程并发修改同一份文件,所以现在的大部分操作系统都提供了文件锁的功能。
文件锁控制文件或者文件部分字节的访问,但文件锁在不同操作系统的差别较大,所以早起的JDK版本没有提供文件锁支持,从JDK1.4的新IO开始,Java开始提供文件锁的支持。
在新IO中,Java提供了FileLock来支持文件锁定功能,在FileChannel中提供的lock/tryLock方法来获得文件锁FileLock对象,从而锁定文件。lock和tryLock方法存在区别:当lock视图锁定某个文件时,如果无法得到文件锁,程序将一直阻塞,而tryLock也是尝试锁定文件,它将直接返回而不是阻塞,如果获得了文件锁,该方法返回该文件锁,否则将返回null。
如果FileChannel只想锁定文件的部分,而不是锁定全部则可以使用lock或tryLock方法
Lock(long position, long size,long shared):对文件从position位置开始,长度为size的内容加锁,该方法是阻塞式的。
tryLock(long position, long size,long shared):不阻塞的加锁方法。参数的作用域上一个方法类似。
当参数shared为true,则表明该锁是一个共享锁,它将允许多个进程来读取该文件,但阻止其他进程获得对该文件的排它锁。当shared为false时,表明该锁是一个排它锁,它将锁住对该文件的读写。程序可以通过调用FileLock的isShared来判断它获得的锁是否为共享锁。
直接使用lock或tryLock对整个文件获取的文件锁是排它锁。
处理完文件后通过FileLock的release()方法释放文件锁。下面程序示范了使用FileLock锁定文件的示例:
文件锁虽然可以用于控制并发访问,但对于搞并发访问的情形,还是推荐使用数据库来保存程序信息,而不是使用文件。
关于文件锁还有如下几点需要指出:
1.在某些平台上,文件锁仅仅是建议性的,并不是强制性的,这意味着即使一个程序不能获得文件锁,它也可以对该文件进行读写
2.在某些平台上,不能同步地锁定一个文件并把它映射到内存中。
3.文件锁是由Java虚拟机所持有的,如果两个Java程序使用同一个Java虚拟机运行,则它们不能对同一个文件进行加锁
4.在某些平台上当关闭FileChannel时,会释放Java虚拟机在该文件上的所有锁,因此应该避免对同一个被锁定的文件打开多个FileChannel
10 本章小结
本章主要介绍了Java输入、输出体现的相关知识,介绍了如何使用File来访问本地文件系统,介绍了JavaIO流的三种分类方式,介绍了IO流的处理模型,以及如何使用IO流来读取物理存储节点中的数据,归纳了Java不同IO流的功能,并介绍了几种典型IO流的用法。也介绍了RandomAccessFile类的用法,通过RandomAccessFile允许程序*移动文件指针,任意访问文件的指定位置。
除此之外,本章介绍了Java对象序列化的相关知识,程序通过序列化把Java对象转换成二进制字节流,然后就可以把二进制字节流写入网络或永久存储器。本章最后介绍了Java提供的新IO支持,使用新IO能以更高效的方式进行输入、输出操作。