改善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类为分布式部署的意义也不大。
-
-