计算机系统应用教程网站

网站首页 > 技术文章 正文

详解JAVA序列化

btikc 2024-09-11 01:59:41 技术文章 19 ℃ 0 评论

享学课堂作者:逐梦々少年

转载请声明出处!

现在开发过程中经常遇到多个进程多个服务间需要交互,或者不同语言的服务之间需要交互,这个时候,我们一般选择使用固定的协议,将数据传输过去,但是在很多语言,比如java等jvm语言中,传输的数据是特有的类对象,而类对象仅仅在当前jvm是有效的,传递给别的jvm或者传递给别的语言的时候,是无法直接识别类对象的,那么,我们需要多个服务之间交互或者不同语言交互,该怎么办?这个时候我们就需要通过固定的协议,传输固定的数据格式,而这个数据传输的协议称之为序列化,而定义了传输数据行为的框架组件也称之为序列化组件(框架)

序列化有什么意义

首先我们先看看,java中的序列化,在java语言中实例对象想要序列化传输,需要实现Serializable 接口,只有当前接口修饰定义的类对象才可以按照指定的方式传输对象。而传输的过程中,需要使用java.io.ObjectOutputStream 和java.io.ObjectInputStream 来实现对象的序列化和数据写入,接着我们看一个最基础的序列化:

我们创建一个java实体类:

public class User {
 private Integer id;
 private String name;
 private Byte sex;
 private Integer age;
 public Integer getId() {
 return id;
 }
 public void setId(Integer id) {
 this.id = id;
 }
 public String getName() {
 return name;
 }
 public void setName(String name) {
 this.name = name;
 }
 public Byte getSex() {
 return sex;
 }
 public void setSex(Byte sex) {
 this.sex = sex;
 }
 public Integer getAge() {
 return age;
 }
 public void setAge(Integer age) {
 this.age = age;
 }
 @Override
 public String toString() {
 return "User [id=" + id + ", name=" + name + ", sex=" + sex + ", age=" + age + "]";
 }
}

然后我们编写发送对象(序列化)的实现:

public class OutPutMain 
{
 public static void main( String[] args ) throws UnknownHostException, IOException
 {
 Socket socket = new Socket("localhost",8080);
 try(ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream())){
 User user = new User().setAge(10).setId(10).setName("张三").setSex((byte)0);
 outputStream.writeObject(user);
 outputStream.flush();
 System.out.println("对象已经发送:--->"+user);
 }catch (Exception e) {
 e.getStackTrace();
 System.err.println("对象发送失败:--->");
 }finally{
 if(!socket.isClosed()){
 socket.close();
 }
 }
 }
}

然后定义读取实体(反序列化)的代码:

public class InputMain {
 public static void main(String[] args) throws IOException {
 ServerSocket serverSocket = new ServerSocket(8080);
 Socket socket = serverSocket.accept();
 try(ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream())){
 User user = (User) inputStream.readObject();
 System.out.println(user);
 }catch (Exception e) {
 e.getStackTrace();
 }finally {
 if(!serverSocket.isClosed()){
 serverSocket.close();
 }
 }
 }
}

接着我们先运行InputMain,再运行OutPutMain,看下结果:

java.io.NotSerializableException: demo.ser.User
 at java.io.ObjectOutputStream.writeObject0(Unknown Source)
 at java.io.ObjectOutputStream.writeObject(Unknown Source)
 at demo.ser.OutPutMain.main(OutPutMain.java:15)

很明显报错了,告诉我们user类不能序列化,原因很明显,我们的User类没有实现Serializable接口,现在我们修改如下:

public class User implements Serializable{

然后我们再按照顺序执行一次后,就能看到打印的结果了:

对象已经发送:--->User [id=10, name=张三, sex=0, age=10]

serialVersionUID的认知

上面我们学习了一个最基础的序列化传递的方法,但是我们仔细观察代码,发现编译器在class申明那里报了一个黄色的波浪线,这个是为什么呢?原来jdk推荐我们实现序列化接口后,让我们再去生成一个固定的序列化id--serialVerionUID,而这个id的作用是用来作为传输/读取双端进程的版本是否一致的,防止我们因为版本不一致导致的序列化失败,那么serialVerionUID取值应该如何取值?又或者serialVerionUID不一致的时候,是不是序列化会失败呢?接下来我们来看看serialVerionUID的取值方案:

可以看到编译器推荐我们有两种方式,一种是生成默认的versionID,这个值为1L,还有一种方式是根据类名、接口名、成员方法及属性等来生成一个 64 位的哈希字段,只要我们类名、方法名、变量有修改,或者有空格、注释、换行等操作,计算出来的哈希字段都会不同,当然这里需要注意,每次我们有以上的操作的时候尽量都要重新生成一次serialVerionUID(编译器并不会给你自动修改)。接下来我们来看下一个问题,如果我们修改了serialVerionUID,而另一个的serialVerionUID还是原来的,我们能否序列化,是否会有影响呢?我们把上述的案例修改下:

OutPutMain对应的User类的serialVerionUID修改为2L:

public class User implements Serializable{
 private static final long serialVersionUID = 2L;
 ........

而InputMain对应的User还是使用的默认值:

public class User implements Serializable{
 private static final long serialVersionUID = 1L;
 ........

再次运行一下,果不其然,抛出了InvalidClassException,告诉我们序列化id不一样,导致传输失败:

java.io.InvalidClassException: demo.ser.User; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 1
 at java.io.ObjectStreamClass.initNonProxy(Unknown Source)
 at java.io.ObjectInputStream.readNonProxyDesc(Unknown Source)
 at java.io.ObjectInputStream.readClassDesc(Unknown Source)
 at java.io.ObjectInputStream.readOrdinaryObject(Unknown Source)
 at java.io.ObjectInputStream.readObject0(Unknown Source)
 at java.io.ObjectInputStream.readObject(Unknown Source)
 at demo.ser.InputMain.main(InputMain.java:13)

serialVersionUID两种方式的区别及选择

那么又有个问题出现了,既然这个serialVersionUID如此重要,那么编译器推荐我们两种方法,我们到底该如何选择,这两种区别又在哪?上面我们也知道两种序列化UID一个是固定的1L默认值,一个是按照类方法属性等计算出来的hash,只要有代码的修改,重新计算出来的结果就会改变,所以两个id一个是固定的,除非手动修改,另外一个可以认为每次修改完都会变化(其实是需要我们重新生成),根据这个特性,我们可以分别用在不同的场景下,比如,我们的一些dto与业务并无太大关系,很长时间甚至整个项目周期中,都是固定不会进行改变或者很少改变的dto,这里的dto建议使用默认值方式,同样也防止因为误操作等方式导致uid改变造成序列化失败(比如不小心修改了顺序等,如果是第二种方式,重新生成的话,就会改变),也可以在基础库或者基础jar中定义的dto使用固定UID方式,保证dto的稳定,而在业务线开发过程中,我习惯动态生成UID,尤其是频繁修改的dto中,更是需要如此,防止在开发阶段一些未知的序列化问题或者未知问题没有被检测出来,而serialVersionUID的作用就是在序列化的时候,判断两个dto是否一致,也是jdk实现的接口规则,防止序列化不一致导致问题,除此之外并无其他区别

Transient关键字

到现在我们已经知道了序列化的大概使用方式,但是这个时候我们遇到一个需求,一个dto在使用的时候需要有这个字段完成业务流程,但是序列化的时候我们不需要这个字段,该如何呢?这个时候就需要Transient关键字了,这个是java针对序列化出的关键字,修饰在指定字段上,可以在序列化的时候,排除当前关键字修饰的字段,仅序列化其他字段,当我们反序列化的时候,可以看到基础类型为默认值,引用类型则为null,代码如下:

修改outPutMain工程的User类:

public class User implements Serializable{
 private static final long serialVersionUID = 2L;
 //不序列化id字段
 private transient Integer id;
 private String name;
 private Byte sex;
 private Integer age;
 ......

再次进行序列化后可以看到序列化的结果如下:

对象已经发送:--->User [id=10, name=张三, sex=0, age=10]

但是反序列化的结果如下:

User [id=null, name=张三, sex=0, age=10]

可以看到,当前的id字段果然没有任何结果,但是这个时候我们不禁怀疑,如果这个dto刚好没有id字段,其他完全一样,并且故意把serialVersionUID也设置为一样的,我们序列化会有问题吗?接着我们把IntputMain工程的User类的id字段移除,再来看下运行结果:

序列化的结果和上面一样:

对象已经发送:--->User [id=10, name=张三, sex=0, age=10]

但是反序列化的结果居然没有出现序列化异常,而且成功的完成了反序列化操作:

User [name=张三, sex=0, age=10]

怎么会这样呢?原来transient关键字会把所有属性都序列化到IO(内存、硬盘)等,但是有了当前关键字修饰的属性并不会包含在序列化中,所以当序列化完成后,已经丢失了transient修饰的属性信息,而在反序列化的时候,是按照序列化的结果来反向给属性赋值,所以我们反序列化的属性存在多余的或者仅和序列化结果一致,缺少几个属性也是可以的,所以我们通过以上的案例我们可以总结以下三点:

1)一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问

2)transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口

3)java的序列化机制是向上兼容的,也就是说,可以包含或者超过序列化的属性,但是当反序列化的时候缺少属性,序列化就会失败

而序列化的时候还需要注意一点,序列化不是万能的,除了transient关键字外,如果某个属性存在static关键字修饰,那么无论是否有transient修饰,都不能参与序列化

可能有人会比较疑惑,如果我们给id属性使用static修饰,并且初始化的时候设置了值,但是序列化完成后我们依然收到了之前设置的值,这不是和上面的描述矛盾吗?其实不然,我们都知道static在jvm加载的过程中会有唯一一份初始化的结果,而我们拿到的所谓序列化的值,是因为jvm初始化的值,而不是序列化带来的值,接着我们修改上面的案例来检测下:

将两个工程中得User类修改如下:

public class User implements Serializable{
 private static final long serialVersionUID = 1L;
 
 private Integer id;
 public static String name;
 private Byte sex;
 private Integer age;
 ........

然后修改反序列化(InputMain)工程的main代码:

public static void main(String[] args) throws IOException {
 ServerSocket serverSocket = new ServerSocket(8080);
 Socket socket = serverSocket.accept();
 try(ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream())){
 //在反序列化之前设置一个值
 User.name = "李四";
 //进行反序列化
 User user = (User) inputStream.readObject();
 System.out.println(user);
 }catch (Exception e) {
 e.printStackTrace();
 }finally {
 if(!serverSocket.isClosed()){
 serverSocket.close();
 }
 }
 }

可以看到这里我们给值修改为李四,如果结论正确,那么结果应该为李四而不是初始化传递的张三,现在我们看下序列化的对象:

对象已经发送:--->User [id=10, name=张三, sex=0, age=10]

再来看反序列化的结果:

User [id=10, name=李四, sex=0, age=10]

果然是按照静态加载的结果来的,而不是序列化,从而确定结论是正确的

Externalizable 自定义序列化

如果这个时候有人会说,transient关键字不够灵活啊,如果我需要动态的指定哪些可以序列化哪些不能序列化,该怎么办?这个时候我们不妨考虑Externalizable 接口,这个接口是Serializable接口的子接口,使用当前接口的时候必须存在无参构造,接口定义如下:

public interface Externalizable extends Serializable { 
 public void writeExternal(ObjectOutput out) throws IOException ; 
 public void readExternal(ObjectInput in) throws IOException,ClassNot FoundException ; 
} 

可以看到我们实现当我们实现当前接口的时候,必须要重写writeExternal和readExternal两个方法,而当前的两个方法作用则是自定义序列化和反序列化的操作,接着我们通过自定义的序列化实现id不被序列化的操作:

public class NUser implements Externalizable {
 private Integer id;
 private String name;
 private Byte sex;
 private Integer age;
 
 public Integer getId() {
 return id;
 }
 public void setId(Integer id) {
 this.id = id;
 }
 public String getName() {
 return name;
 }
 public void setName(String name) {
 this.name = name;
 }
 public Byte getSex() {
 return sex;
 }
 public void setSex(Byte sex) {
 this.sex = sex;
 }
 public Integer getAge() {
 return age;
 }
 public void setAge(Integer age) {
 this.age = age;
 }
 //反序列化的时候调用--自定义反序列化
 @Override
 public void readExternal(ObjectInput input) throws IOException, ClassNotFoundException {
 //按照序列化的顺序获取反序列化的字段
 this.name = input.readObject().toString();
 this.sex = input.readByte();
 this.age = input.readInt();
 }
 //序列化的时候调用--自定义序列化
 @Override
 public void writeExternal(ObjectOutput output) throws IOException {
 output.writeObject(this.name);
 output.writeByte(this.sex);
 output.writeInt(this.age);
 }
 @Override
 public String toString() {
 return "NUser [id=" + id + ", name=" + name + ", sex=" + sex + ", age=" + age + "]";
 }
}

从上面可以看出来,实现了Externalizable接口以后,编译器不能自动实现serialVersionUID,需要我们给OutPutMain和InputMain工程手动添加如下代码:

private static final long serialVersionUID = 1L;

因为当前完全属于自定义的序列化,系统不再提供默认的方式和自动计算的hash方式,而是完全由我们决定是否创建serialVersionUID以及对应的版本,接着将OutPutMain工程下的代码修改:

//User user = new User().setAge(10).setId(10).setName("张三").setSex((byte)0);
NUser user = new NUser().setAge(10).setId(10).setName("张三").setSex((byte)0);

InputMain工程的代码修改为:

//User user = (User) inputStream.readObject();
NUser user = (NUser) inputStream.readObject();

接着序列化的结果如下:

对象已经发送:--->NUser [id=10, name=张三, sex=0, age=10]

反序列化的结果为:

NUser [id=null, name=张三, sex=0, age=10]

可以看到完全按照我们的序列化方式来操作了,这样就可以实现灵活的序列化/反序列化代码了

writeObject 和 readObject

通过Externalizable接口我们可以实现自定义的序列化和反序列化,但是我们可以看到这两个操作需要依赖readExternal和writeExternal方法实现,而这两个方法内部是依赖了ObjectInput和ObjectOutput实现的自定义,这个时候我们不禁疑问,难道序列化机制和IO流有关系?ObjectInput接口我们知道,内部定义了很多read相关的方法,最常见的实现类为ObjectInputStream,而ObjectOutput内部定义了很多write相关的方法,常见的实现类为ObjectInputStream,那么我们可以大胆猜测是因为writeObject和readObject方法实现的,现在我们修改两个工程中的NUser类如下:

public class NUser implements Serializable{
 private static final long serialVersionUID = 1L;
 private Integer id;
 private String name;
 private Byte sex;
 private Integer age;
 public Integer getId() {
 return id;
 }
 public NUser setId(Integer id) {
 this.id = id;
 return this;
 }
 public String getName() {
 return name;
 }
 public NUser setName(String name) {
 this.name = name;
 return this;
 }
 public Byte getSex() {
 return sex;
 }
 public NUser setSex(Byte sex) {
 this.sex = sex;
 return this;
 }
 public Integer getAge() {
 return age;
 }
 public NUser setAge(Integer age) {
 this.age = age;
 return this;
 }
 private void writeObject(ObjectOutputStream output) throws IOException{
 output.writeObject(this.name);
 output.writeByte(this.sex);
 output.writeInt(this.age);
 }
 private void readObject(ObjectInputStream input) throws IOException,ClassNotFoundException{
 //按照序列化的顺序获取反序列化的字段
 this.name = input.readObject().toString();
 this.sex = input.readByte();
 this.age = input.readInt();
 }
 @Override
 public String toString() {
 return "NUser [id=" + id + ", name=" + name + ", sex=" + sex + ", age=" + age + "]";
 }
}

可以看到我们和之前重写Externalizable的两个方法一样的写法,再次运行序列化后,结果如下:

对象已经发送:--->NUser [id=10, name=张三, sex=0, age=10]
NUser [id=null, name=张三, sex=0, age=10]

是不是和之前的结果一样?所以可以看到我们的猜测是正确的,并且我们在查看源码后可以看到:

我们的readObject/writeObjet方法 是通过反射来调用的,所以最终都是会调用了readObject/writeObject方法来实现

Java序列化使用的总结

通过上面的案例测试和比较,我们可以得到序列化使用的一些经验总结:

  1. Java 序列化只是针对对象的属性的传递,至于方法和序列化过程无关
  2. 当一个父类实现了序列化,那么子类会自动实现序列化,不需要显示实现序列化接口,反过来,子类实现序列化,而父类没有实现序列化则序列化会失败---即序列化具有传递性
  3. 当一个对象的实例变量引用了其他对象,序列化这个对象的时候会自动把引用的对象也进
  4. 行序列化(实现深度克隆)
  5. 当某个字段被申明为 transient 后,默认的序列化机制会忽略这个字段
  6. 被申明为 transient 的字段,如果需要序列化,可以添加两个私有方法:writeObject 和
  7. readObject或者实现Externalizable接口

既然来了,点个关注再走呗~

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表