改善Java程序的151个建议 11-15 章

改善Java程序的151个建议 11-15 章

11. 养成良好习惯,显示声明UID

知识点

  • 类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。
  • 若没有序列化,现在我们熟悉的远程调用、对象数据库都不可能存在

一个简单的序列化类

/**
*这是一个简单JavaBean,实现了Serializable接口, *可以在网络上传输,也可以本地存储然后读取
*/
@Data
public class Person implements Serializable {

//    private static final long serialVersionUID = 5799L;

    private String name;
//    private int age;

}

一个生产者

/**
 * @author luxiaoyang
 * @create 2021-07-31-16:26
 */
public class Producer {

    public static void main(String[] args) {
        Person person = new Person();
        person.setName("混世魔王");
//        person.setAge(20);
        // 序列化 保存到磁盘上
        SerializationUtils.writeObject(person);

    }
}

一个序列化反序列化工具

/**
	这里引入了一个工具类SerializationUtils,其	作用是对一个类进行序列化和反序列化,并存储到	硬盘   上(模拟网络传输)
 * @author luxiaoyang
 * @create 2021-07-31-16:28
 */
public class SerializationUtils {

    private static String FILR_NAME = "h:/obj.bin";

    // 序列化
    public static void writeObject(Serializable s) {
        try {
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILR_NAME));
            oos.writeObject(s);
            oos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static Object readObject() {
        Object obj = null;
        // 反序列化
        try {
            ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(FILR_NAME));

            obj = inputStream.readObject();
            inputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return obj;
    }
}

一个消费者

通过对象序列化过程,把一个对象从内存块转化为可传输的数据流,然后通过网络发送到消息消费者(Consumer)那里,并进行反序列化,生成实例对象,代码如下:

/**
 * @author luxiaoyang
 * @create 2021-07-31-16:33
 */
public class Consumer {

    public static void main(String[] args) {
        Person o = (Person) SerializationUtils.readObject();
        System.out.println("p" + o.getName());
    }

    // java.io.InvalidClassException:
    // ad11to15.uid.Person; local class incompatible:
    // stream classdesc serialVersionUID = 3683203073993944757, local class serialVersionUID = -4992661363964737263

}

隐藏问题

如果消息的生产者和消息的消费者所参考的类(Person类)有差异,会出现何种神奇事件?比如:消息生产者中的Person类增加了一个年龄属性,而消费者没有增加该属性。为啥没有增加?!因为这是个分布式部署的应用,你甚至都不知道这个应用部署在何处

JVM是根据什么来判断一个类版本

在这种序列化和反序列化的类不一致的情形下,反序列化时会报一个InvalidClassException异常,原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转换为实例对象。接着刨根问底:JVM是根据什么来判断一个类版本的呢?

显示声明SerialVersionUID

通过SerialVersionUID,也叫做流标识符(Stream UniqueIdentifier),即类的版本定义的,它可以显式声明也可以隐式声明。显式声明格式如下:

private static final long serialVersionUID = 5799L;
隐式声明SerialVersionUID

而隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的

serialVersionUID的作用

JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有发生改变,可以把数据流load为实例对象;如果不相同,对不起,我JVM不干了,抛个异常InvalidClassException给你瞧瞧

使用场景

​ 有时候我们需要一点特例场景,例如:我的类改变不大,JVM是否可以把我以前的对象反序列化过来?就是依靠显式声明serialVersionUID,向JVM撒谎说“我的类版本没有变更”,如此,我们编写的类就实现了向上兼容。我们修改一下上面的Person类,代码如下:

/**
 * @author luxiaoyang
 * @create 2021-07-31-16:14
 */
@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 5799L;

    private String name;
//    private int age;
}

刚开始生产者和消费者持有的Person类版本一致,都是V1.0,某天生产者的Person类版本变更了,增加了一个“年龄”属性,升级为V2.0,而由于种种原因(比如程序员疏忽、升级时间窗口不同等)消费端的Person还保持为V1.0版本,代码如下

/**
 * @author luxiaoyang
 * @create 2021-07-31-16:14
 */
@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 5799L;

    private String name;
    private int age;
}

此时虽然生产者和消费者对应的类版本不同,但是显式声明的serialVersionUID相同,反序列化也是可以运行的,所带来的业务问题就是消费端不能读取到新增的业务属性(age属性)而已

总结

通过此例,我们的反序列化实现了版本向上兼容的功能,使用V1.0版本的应用访问了一个V2.0版本的对象,这无疑提高了代码的健壮性。我们在编写序列化类代码时,随手加上serialVersionUID字段,也不会给我们带来太多的工作量,但它却可以在关键时候发挥异乎寻常的作用。

注意

显式声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。

12. 避免用序列化类在构造函数中为不变量复制

序列化的基本规则说明

保持新旧对象的final变量相同,有利于代码业务逻辑统一,这是序列化的基本规则之一,也就是说,如果final属性是一个直接量,在反序列化时就会重新计算

假设有这样一个类:

/**
 * @author luxiaoyang
 * @create 2021-08-01-15:17
 */
@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 558584L;

    /**
     * 不变量
     */
    public final String name = "混世魔王";
//    public final String name = "德天使";

}

这个Person类(此时V1.0版本)被序列化,然后存储在磁盘上,在反序列化时name属性会重新计算其值.比如name属性修改成了“德天使”(版本升级为V2.0),那么反序列化对象的name值就是“德天使”。

final变量另外一种赋值方式:通过构造函数赋值

/**
 * @author luxiaoyang
 * @create 2021-08-01-15:17
 */
@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 558584L;

    /**
     * 不变量
     */
    public final String name;

    public Person() {
        name = "混世魔王";
    }

}

序列化

/**
 * @author luxiaoyang
 * @create 2021-07-31-16:26
 */
public class Producer {

    public static void main(String[] args) {

        Person person = new Person();
        // 序列化 保存到磁盘上
        SerializationUtils.writeObject(person);
    }
}

Person的实例对象保存到了磁盘上,它是一个贫血对象(承载业务属性定义,但不包含其行为定义),我们做一个简单的模拟,修改一下name值代表变更,要注意的是serialVersionUID保持不变,修改后的代码如下:

@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 558584L;

    /**
     * 不变量
     */
    public final String name;

    public Person() {
        name = "德天使";
    }
}

此时Person类的版本是V2.0,但serialVersionUID没有改变,仍然可以反序列化,其代码如下:

public class Consumer {

    public static void main(String[] args) {
        Person o = (Person)SerializationUtils.readObject();
        System.out.println("p" + o.getName());
    }
}

现在问题来了:打印的结果是什么?是混世魔王还是德天使?答案即将揭晓,答案是:混世魔王。

final类型的变量不是会重新计算吗?答案应该是“德天使”才对啊,为什么会是“混世魔王”?这是因为这里触及了反序列化的另一个规则:反序列化时构造函数不会执行

原理分析

反序列化的执行过程是这样的:JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,注意是类描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的name值,而此时JVM又发现name竟然没有赋值,不能引用,于是它很“聪明”地不再初始化,保持原值状态,所以结果就是“混世魔王”了。

序列化规则总结

  • 保持新旧对象的final变量相同,有利于代码业务逻辑统一
  • 反序列化时构造函数不会执行

发生场景

读者不要以为这样的情况很少发生,如果使用Java开发过桌面应用,特别是参与过对性能要求较高的项目(比如交易类项目),那么很容易遇到这样的问题。比如一个C/S结构的在线外汇交易系统,要求提供24小时的联机服务,如果在升级的类中有一个final变量是构造函数赋值的,而且新旧版本还发生了变化,则在应用请求热切的过程中(非常短暂,可能只有30秒),很可能就会出现反序列化生成的final变量值与新产生的实例值不相同的情况,于是业务异常就产生了,情况严重的话甚至会影响交易数据,那可是天大的事故了。

13. 避免为final变量复杂赋值

为final变量赋值的第三种方式

通过方法赋值,即直接在声明时通过方法返回值赋值。还是以Person类为例来说明,代码如下

final会被重新赋值的前提条件

final会被重新赋值,其中的“值”指的是简单对象。简单对象包括:8个基本类型,以及数组、字符串(字符串情况很复杂,不通过new关键字生成String对象的情况下,final变量的赋值与基本类型相同),但是不能方法赋值。

原理

保存到磁盘上(或网络传输)的对象文件包括两部分:

  • 类描述信息
    • 包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
  • 非瞬态(transient关键字)和非静态(static关键字)的实例变量值
    • 注意,这里的值如果是一个基本类型,好说,就是一个简单值保存下来;如果是复杂对象,也简单,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是说递归到最后,其实还是基本数据类型的保存。

正是因为这两点原因,一个持久化后的对象文件会比一个class类文件大很多,有兴趣的读者可以自己写个Hello word程序检验一下,其体积确实膨胀了不少。

反序列化时final变量在以下情况下不会被重新赋值

  • 通过构造函数为final变量赋值。
  • 通过方法返回值为final变量赋值。
  • final修饰的属性不是基本类型

14.使用序列化类的私有方法巧妙解决部分属性持久化问题

解决方案及优劣说明

  • 把不需要持久化的属性加上瞬态关键字(transient关键字)

    • 但有时候行不通

      • 例如一个计税系统和人力资源系统(HR系统)通过RMI(Remote Method Invocation,远程方法调用)对接,计税系统需要从HR系统获得人员的姓名和基本工资,以作为纳税的依据,而HR系统的工资分为两部分:基本工资和绩效工资,基本工资没什么秘密,根据工作岗位和年限自己都可以计算出来,但绩效工资却是保密的,不能泄露到外系统,很明显这是两个相互关联的类。

      • 先来看薪水类Salary类的代码:

        • package ad11to15plus;
          
          import lombok.Data;
          import lombok.ToString;
          
          import java.io.Serializable;
          
          /**
           * @author luxiaoyang
           * @create 2021-08-01-16:18
           */
          @Data
          @ToString
          public class Salary implements Serializable {
          
              private static final long serialVersionUID = 44663L;
          
              // 基本工资
              private int basePay;
          
              // 绩效工资
              private int bonus;
          
              public Salary( int _basePay,int _bonus ){
                  basePay = _basePay;
                  bonus = _bonus;
              }
          }
          
      • Peron类与Salary类是关联关系,代码如下:

        • package ad11to15plus;
          
          import lombok.Data;
          import lombok.ToString;
          
          import java.io.IOException;
          import java.io.ObjectInputStream;
          import java.io.ObjectOutputStream;
          import java.io.Serializable;
          
          /**
           * @author luxiaoyang
           * @create 2021-08-01-15:17
           */
          @Data
          @ToString
          public class Person implements Serializable {
          
              private static final long serialVersionUID = 558584L;
          
              // 姓名
              private String name;
              // 薪水
              private Salary salary;
          
              public Person(String _name,Salary _salary ) {
                  name = _name;
                  salary = _salary;
              }
          }
          
      • 这是两个简单的JavaBean,都实现了Serializable接口,都具备了持久化条件。首先计税系统请求HR系统对某一个Person对象进行序列化,把人员和工资信息传递到计税系统中,代码如下:

        • package ad11to15plus;
          
          import ad11to15.uid.SerializationUtils;
          
          /**
           * @author luxiaoyang
           * @create 2021-08-01-17:13
           */
          public class Serilize {
          
              public static void main(String[] args) {
          
                  // 基本工资1000元,绩效工资2500元
                  Salary salary = new Salary(1000,2500);
                  // 记录人员信息
                  Person person = new Person("张三",salary);
                  // hr系统持久化,并传递到计税xitong
                  SerializationUtils.writeObject(person);
              }
          }
          
      • 在通过网络传送到计税系统后,进行反序列化,代码如下:

        • package ad11to15plus;
          
          import ad11to15.uid.SerializationUtils;
          
          /**
           * @author luxiaoyang
           * @create 2021-08-01-17:16
           */
          public class Deserailize {
          
              public static void main(String[] args) {
          
                  Person o = (Person) SerializationUtils.readObject();
                  System.out.println(o);
              }
          }
          
      • 打印出的结果很简单:

        • 姓名:张三 基本工资:1000 绩效工资:2500。

        • 但是这不符合需求,因为计税系统只能从HR系统中获得人员姓名和基本工资,而绩效工资是不能获得的,这是个保密数据,不允许发生泄露。怎么解决这个问题呢?你可能马上会想到四种方案:

          • 在bonus前加上transient关键字,这是一个方法,但不是一个好方法,加上transient关键字就标志着Salary类失去了分布式部署的功能,它可是HR系统最核心的类了,一旦遭遇性能瓶颈,想再实现分布式部署就不可能了,此方案否定。

          • 新增业务对象,增加一个Person4Tax类,完全为计税系统服务,就是说它只有两个属性:姓名和基本工资。符合开闭原则,而且对原系统也没有侵入性,只是增加了工作量而已。这是个方法,但不是最优方法。

          • 请求端过滤,在计税系统获得Person对象后,过滤掉Salary的bonus属性,方案可行但不合规矩,因为HR系统中的Salary类安全性竟然让外系统(计税系统)来承担,设计严重失职。

          • 变更传输契约,例如改用XML传输,或者重建一个Web Service服务。可以做,但成本太高。

          • 优秀方案:

            • 实现了Serializable接口的类可以实现两个私有方法:writeObject和readObject,以影响和控制序列化和反序列化的过程

            • 我们把Person类稍做修改,看看如何控制序列化和反序列化,代码如下:

            • package ad11to15plus;
              
              import lombok.Data;
              import lombok.ToString;
              
              import java.io.IOException;
              import java.io.ObjectInputStream;
              import java.io.ObjectOutputStream;
              import java.io.Serializable;
              
              /**
               * @author luxiaoyang
               * @create 2021-08-01-15:17
               */
              @Data
              @ToString
              public class Person implements Serializable {
              
                  private static final long serialVersionUID = 558584L;
              
                  // 姓名
                  private String name;
                  // 薪水
                  private Salary salary;
              
                  public Person(String _name,Salary _salary ) {
                      name = _name;
                      salary = _salary;
                  }
              
                  // 序列化委托方法
                  private void writeObject(ObjectOutputStream out) throws IOException {
                      out.defaultWriteObject();
                      out.writeInt(salary.getBasePay());
                  }
              
                  // 反序列化委托方法
                  private void readObject(ObjectInputStream in) throws IOException,ClassNotFoundException {
                      in.defaultReadObject();
                      salary = new Salary(in.readInt(),0);
                  }
              }
              
            • 其他代码不做任何改动,我们先运行看看,结果为:姓名:张三 基本工资:1000 绩效工资:0。

      原理说明
      序列化独有的机制:序列化回调。

      Java调用ObjectOutputStream类把一个对象转换成流数据时,会通过反射(Reflection)检查被序列化的类是否有writeObject方法,并且检查其是否符合私有、无返回值的特性。若有,则会委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则继续序列化。同样,在从流数据恢复成实例对象时,也会检查是否有一个私有的readObject方法,如果有,则会通过该方法读取属性值。

      关键点要说明:
      • out.defaultWriteObject():告知JVM按照默认的规则写入对象,惯例的写法是写在第一句话里。
      • in.defaultReadObject(): 告知JVM按照默认规则读入对象,惯例的写法也是写在第一句话里。
      • out.writeXX和in.readXX: 分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的数据逻辑,建议按封装Collection对象处理。
      这样做的优势:

      ​ 再回到我们的业务领域,通过上述方法重构后,其代码的修改量减少了许多,也优雅了许多

      这样做的问题:

      ​ 如此一来,Person类也失去了分布式部署的能力

      问题分析:

      ​ 确实是,但是HR系统的难点和重点是薪水计算,特别是绩效工资,它所依赖的参数很复杂(仅从数量上说就有上百甚至上千种),计算公式也不简单(一般是引入脚本语言,个性化公式定制),而相对来说Person类基本上都是“静态"属性,计算的可能性不大,所以即使为性能考虑,Person类为分布式部署的意义也不大。

15. break万万不可忘

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

赞一下鼓励

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值