NO.11 养成良好习惯,显式声明UID
当一个实体类实现了Serializable接口时,会发现Idea会提示一个警告,需要增加Serial Version UID,这里涉及到了序列化和反序列化的内容。
public class Person implements Serializable {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
这是一个实现序列化接口的一个Person类,接下来可以尝试模仿一下序列化和反序列化的过程。
定义一个生产者用来write(序列化)。
public class Producer {
public static void main(String[] args) {
Person person = new Person();
person.setName("混世魔王");
SerializationUtils.writeObject(person);
}
}
定义一个消费者用来read(反序列化)。
public class Consumer {
public static void main(String[] args) {
Person person = (Person) SerializationUtils.readObject();
System.out.println(person.getName());
}
}
SerializationUtils类用来封装写文件和读文件的逻辑代码。
public class SerializationUtils {
public static final String FILE_NAME = "D:/obj.bin";
public static void writeObject(Serializable s) {
try {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME));
oos.writeObject(s);
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public static Object readObject() {
Object obj = null;
try {
ObjectInput ois = new ObjectInputStream(new FileInputStream(FILE_NAME));
obj = ois.readObject();
ois.close();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
return obj;
}
}
正常情况来讲,由生产者将person对象传输到本地存储之后,是可以由消费者进行反序列化读取的,但是此处隐藏着一个问题,就是如果生产者和消费者所参考的Person类有差异
显式声明的UID可以避免对象不一致,但尽量不要以这种方式对JVM撒谎
NO.12 避免用序列化类在构造函数中为不变量赋值
(1)我们知道final修饰的变量为常量,不能重复赋值,但是在序列化类中就有点复杂。
public class Person implements Serializable {
private static final long serialVersionUID = -2594431214612070077L;
private final String name = "混世魔王";
public String getName() {
return name;
}
}
比如这样一个Person类,当这个对象被序列化到磁盘文件,然后再对这个文件进行反序列化,那么反序列化的过程name会被重新计算其值,假如恰在此时,Person类中的name属性被修改为“德天使”,那么反序列化之后的Person对象的name属性就也是“德天使”,这样会导致数据不统一。
当然,以上只是一种情况,是序列化的基本规则之一,也就是说,如果final修饰的属性是一个直接量赋值,在反序列化时会被重新计算
(2)还有另外一种赋值方式:构造函数赋值:
public class Person implements Serializable {
private static final long serialVersionUID = -2594431214612070077L;
private final String name;
public Person() {
this.name = "混世魔王";
}
public String getName() {
return name;
}
}
使用序列化工具类对Person对象进行模拟name属性值变更导致序列化和反序列化的结果,代码如下:
//序列化
public class Serialize {
public static void main(String[] args) {
SerializationUtils.writeObject(new Person());
}
}
对Person对象进行序列化之后保存到磁盘文件,然后修改Person构造方法里name的属性值为“德天使”(模拟开发过程中业务变更),然后运行一下代码对磁盘文件进行反序列化(为什么修改了属性值还可以进行反序列化?原因:序列化的UID没有变化,认为是一个类型,所以仍然可以反序列化)。
//反序列化
public class Deserialize {
public static void main(String[] args) {
Person person = (Person) SerializationUtils.readObject();
System.out.println(person.getName());
}
}
执行结果:

这种情况与上一种不太一样,是因为在反序列化时不会执行构造函数。
在序列化类中,不要用构造函数为final变量赋值
NO.13 避免为final变量复杂赋值
为final变量赋值还有另外一种方法,在声明时通过方法返回值赋值,还是以Person为例:
public class Person implements Serializable {
private static final long serialVersionUID = -2594431214612070077L;
private final String name = initName();
private String initName() {
return "混世魔王";
}
public String getName() {
return name;
}
}
序列化到磁盘后,修改initName返回值为“德天使”,反序列化后打印name属性值为如下:

上一建议说final变量会被重新赋值,其中的值指的是简单对象,简单对象包括:8个基本类型,数组,字符串,但是不能方法赋值
其中的原理是:
保存到磁盘上(或网络传输)的对象文件包括两部分:
- 类描述信息
包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值以及变量的关联类信息。要注意的是,这份文件并不是class文件的翻版,他不记录方法、构造函数、static变量等的具体实现 - 非瞬态(transient关键字)和非静态(static关键字)的实例变量值
这里的值如果是一个基本类型,就是一个简单值保存下来,如果是复杂对象,那么会将该对象和关联类信息一起保存,并且持续递归下去,并且关联类也要实现序列化,否则会出现序列化异常。也就是说递归到最后,其实还是基本数据的类型的保存。
总结:在反序列化时final变量在一下几种情况下不会被重新赋值:
- 构造函数为final变量赋值
- 通过方法返回值为final变量赋值
- final修饰的属性不是基本类型
NO.14 使用序列化类的私有方法巧妙解决部分属性持久化问题
部分属性持久化的问题看起来很简单,研读上一个建议的可以知道,被transient修饰的属性是不会被持久化的,这只是一种解决方案,但有时候行不通。
例如一个计税系统和HR系统通过远程调用进行对接,计税系统需要从HR系统获取员工的姓名和基本工资,以作为纳税的依据,而HR系统的员工薪水分为两部分:基本工资和绩效工资,但是每个人的绩效工资是保密的,不能泄漏到外部系统。先来看看Salary(薪水)类:
public class Salary implements Serializable {
private static final long serialVersionUID = 458250546794195225L;
/**
* 基本工资
*/
private int basePay;
/**
* 绩效
*/
private int bonus;
public Salary(int basePay, int bonus) {
this.basePay = basePay;
this.bonus = bonus;
}
}
Person类和Salary类是关联关系:
public class Person implements Serializable {
private static final long serialVersionUID = 6196306017004163724L;
private String name;
private Salary salary;
public Person(String name, Salary salary) {
this.name = name;
this.salary = salary;
}
}
这两个简单的JavaBean都是序列化类,都具备了序列化的条件,首先计税系统请求HR系统对某一个Person对象进行序列化,把员工姓名和基本信息传递到计税系统,使用序列化到磁盘进行模拟此过程:
public class Serialize {
public static void main(String[] args) {
Salary salary = new Salary(1000, 3500);
Person person = new Person("张三", salary);
SerializationUtils.writeObject(person);
}
}
然后使用反序列化磁盘文件模拟计税系统接收到了HR系统传递过来的数据:
public class Deserialize {
public static void main(String[] args) {
Person person = (Person) SerializationUtils.readObject();
System.out.println("姓名:"+person.getName());
System.out.println("基本工资:"+person.getSalary().getBasePay());
System.out.println("绩效:"+person.getSalary().getBonus());
}
}
结果如下:

不做任何处理的情况下,绩效工资也被传递到了计税系统,而且计税系统不需要该数据,且该数据为保密的数据,但现在全部泄露给了计税系统。
针对这种情况,你可能会想到四种解决方案:
- 在绩效属性上添加transient关键字
加上了transient关键字就证明Salary类失去了分布式部署的功能,这可是HR系统的最核心类,一旦遇到功能瓶颈,想在实现分布式部署就不可能了 - 新增业务对象
新增一个类,只包括name和基本工资两个属性,但这种方法增加了工作量 - 请求端过滤
即计税系统在收到返回数据之后对绩效工资进行过滤,只保留姓名和基本工资,方案可行但不合规矩,因为HR系统中的Salary类的属性安全性竟然让外部系统来维护,设计严重失职。 - 变更传输契约
例如改用XML传输,或者重建一个Web Service服务,可以做,但是成本太高
最优方案:
可以在序列化类中实现两个私有方法:writeObject()和readObject(),用来影响和控制序列化和反序列化的过程
public class Person implements Serializable {
private static final long serialVersionUID = 6196306017004163724L;
private String name;
private Salary salary;
public Person(String name, Salary salary) {
this.name = name;
this.salary = salary;
}
//序列化时委托方法
private void writeObject(ObjectOutputStream outputStream) throws IOException {
outputStream.defaultWriteObject();
outputStream.writeInt(salary.getBasePay());
}
//反序列化时委托方法
private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException {
inputStream.defaultReadObject();
salary = new Salary(inputStream.readInt(), 0);
}
}
其他代码不做任何改动,执行结果如下:

这就引出一个问题,我们定义的这两个方法都是私有的,为什么会改变程序的运行结果呢?
这是序列化的独有机制:序列化回调
Java通过ObjectOutputStream类把一个对象转化为数据流时,会通过反射检查被序列化的类是否有writeObject方法,并且检查其是否符合私有、无返回值的特性,若有,则委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则进行序列化。同样,在读取数据流恢复成实例对象时,也会在序列化类检查是否有readObject方法,如果有,就按照该方法读取属性值。
关键点说明:
- outputStream.defaultWriteObject();
告诉JVM按照默认规则写入对象 - inputStream.defaultReadObject();
告诉JVM按照默认规则读入对象 - outputStream.writeXX()和inputStream.readXX()
分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的业务逻辑,建议按封装Collection对象处理
NO.15 break万万不可忘
我们经常要写一些转换类,比如货币转换、日期转换、编码转换等,在金融领域最多的要数中文数字转换了,比如把“1”转换为“壹”:代码如下:
public class Client {
public static void main(String[] args) {
System.out.println("2->"+toChineseNumberCase(2));
}
private static String toChineseNumberCase(int i) {
String chineseNumber = "";
switch (i){
case 1:chineseNumber = "壹";
case 2:chineseNumber = "贰";
case 3:chineseNumber = "叁";
case 4:chineseNumber = "肆";
case 5:chineseNumber = "伍";
case 6:chineseNumber = "陆";
case 7:chineseNumber = "柒";
case 8:chineseNumber = "捌";
case 9:chineseNumber = "玖";
case 0:chineseNumber = "零";
}
return chineseNumber;
}
}
结果如下:

导致此结果的原因是没有break关键字
记住在case后面随手写上break,养成好习惯
NO.16 易变业务使用脚本语言编写
脚本语言有三大特性:
- 灵活。脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,也可以在运行期改变类型
- 便捷。脚本语言是一种解释型语言,不需要编译成二进制代码,也不需要像Java一样生成字节码,他的执行时依靠解释器解释的,因此在运行期变更代码非常容易,而且不用停止应用。
- 简单。没有太多技术门槛,看完demo就可以上手
NO.17 慎用动态编译
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
//Java源代码
String sourceStr = "public class Hello{public String sayHello(String name){return \"hello,\"+name+\"!\";}}";
String className = "Hello";
String methodName = "sayHello";
//当前编译器
JavaCompiler cmp = ToolProvider.getSystemJavaCompiler();
//Java标准文件管理器
StandardJavaFileManager fm = cmp.getStandardFileManager(null, null, null);
//Java文件对象
JavaFileObject jfo = new StringJavaObject(className, sourceStr);
//编译参数
ArrayList<String> optionsList = new ArrayList<>();
//编译文件的存放地方
optionsList.addAll(Arrays.asList("-d","./out/production/offer"));
//要编译的单元
List<JavaFileObject> jfoList = Arrays.asList(jfo);
//设置编译环境
JavaCompiler.CompilationTask task = cmp.getTask(null, fm, null, optionsList, null, jfoList);
//编译成功,生成字节码文件,反射调用sayHello方法
if (task.call()){
Object o = Class.forName(className).newInstance();
Class<?> aClass = o.getClass();
Method method = aClass.getMethod(methodName, String.class);
String str = (String)method.invoke(o, "Dynamic Compilation");
System.out.println(str);
}
}
//文本中的Java对象
class StringJavaObject extends SimpleJavaFileObject {
//源代码
private String content = "";
//遵循java规范的类名及文件
public StringJavaObject(String _javaFileName, String _content) {
super(_createStringJavaObjectUri(_javaFileName), Kind.SOURCE);
content = _content;
}
//产生一个URL资源路径
private static URI _createStringJavaObjectUri(String name) {
return URI.create("String:///"+name+Kind.SOURCE.extension);
}
//文本文件代码
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
return content;
}
}
这是一个动态编译的模板程序,可以拷贝到自己的Idea上测试。
只要在本地静态编译能够实现的任务,比如编译参数、输入输出、错误监控等,动态编译都可以实现。
Java的动态编译对源提供了多个渠道,比如:可以使字符串,可以是文本文件,也可以是编译后的字节码文件,甚至可以使存放在数据库中的明文代码或是字节码,只要是符合Java规范的都可以在运行期动态加载。实现方式就是实现JavaFileObject接口,重写getCharContent、openInputStream、openOutputStream方法。
动态编译虽然是很好的工具,让我们更加自由的控制编译过程,但其实静态编译已经能够帮我们处理大部分的工作,而且动态编译也会造成性能下降,同时还要考虑系统安全问题。及时真的想使用动态编译,也有更好的替换方案,比如使用脚本语言。
NO.18 避免instanceof非预期结果
instanceof是一个简单的二元操作符,用来判断一个对象是否是一个类的实例。
public static void main(String[] args) {
boolean b1 = "String" instanceof Object;
boolean b2 = new String() instanceof String;
boolean b3 = new Object() instanceof String;
boolean b4 = 'A' instanceof Character;
boolean b5 = null instanceof String;
boolean b6 = (String) null instanceof String;
boolean b7 = new Date() instanceof String;
boolean b8 = new GenericClass<String>() .isDateInstance("");
}
class GenericClass<T> {
public boolean isDateInstance(T t) {
return t instanceof Date;
}
}
以上代码罗列了instanceof的所有应用场景,但同时问题也产生了。以下选择重要的几行代码进行说明:
‘A’ instanceof Character
编译不通过,因为instanceof只是对象间做判断,‘A’只是一个char基本类型,不能用instanceof做判断
null instanceof String
这是instanceof特有的规则,若左函数为null,结果直接返回false。
(String) null instanceof String
null做了类型转换也是个null
new Date() instanceof String
编译报错,因为Date和String没有继承或实现的关系
new GenericClass() .isDateInstance("")
编译可以通过,返回值是false,但是T是个String类型,也是和Date没有继承或实现的关系,为什么会编译通过。因为泛型T在编译成字节码时,就被编译为Object类型了,那么“T instanceof Date”相当于“Object instanceof Date”,所以编译通过
NO.19 断言绝对不是鸡肋
在防御式编程中经常会用到断言对参数和环境做出判断,避免程序因不当的输入或错误的环境而产生逻辑异常,Java中的断言是assert关键字,并且Java中的assert默认是不开启的,如果要使用assert首先要开启。
开启方法(Idea):

在VM options添加-ea即可

演示代码如下:
public static void main(String[] args) {
StringUtils.encode(null);
}
public class StringUtils {
public static void encode(String str) {
assert str!=null;
}
}
运行结果:

assert的语法较为简单,有两个特性:
- 默认不开启
- assert抛出的异常是继承自Error的
AssertionError是个错误,是不可恢复的。assert并不等价于if(){}else()
有两种情况下不可使用断言:
- 在对外公开的方法中
比如以上那个例子,encode()方法是一个公开的方案,只要传递一个String类型的参数就可以调用,但是main方法中按照规范和契约调用encode,反而会抛出错误。 - 在执行逻辑代码的情况下
assert的支持是可选的,在开发时让他运行,但是在生产环境上为了提高性能就不需要其运行,因此在assert的布尔表达式中不能执行逻辑代码,否则会由于环境不同导致逻辑不同。
public static void doSomething(List list, Object element){
assert list.remove(element):"删除元素"+element+"失败";
}
比如这段代码,在assert启用的时候没有任何问题,但是一旦assert未启用,就不会再执行这个断言,那么删除动作永远不会执行,所以就永远不会报错或者异常。
NO.20 不要只替换一个类
public class Constant {
public static final int MAX_AGE = 150;
}
定义一个常量类,定义一个常量为人类的最大年龄。
public class Client {
public static void main(String[] args) {
System.out.println("人类寿命极限是:" + Constant.MAX_AGE);
}
}
使用java命令编译Constant和Client,并执行Client,结果如下:

修改Constant.MAX_AGE=180,只编译Constant,然后直接执行Client,结果如下:

结果并没有变成“180”,原因是:
对于final修饰的基本类型和String类型,编译器会认为它是稳定态,所以在编译时就直接把值编译到字节码中了,避免了在运行期引用,以提高代码的运行效率。
对于final修饰的是类(即非基本类型),编译器就会认为不是稳定态,在编译时建立的是引用关系。
总结:这种情况也是时有发生,比如在一个WEB项目中,开发人员修改一个final修饰的基本类型的值,考虑到重新发布风险较大,或者是时间较长,为了偷懒,于是采用直接替换class文件的方式进行发布。那么就会导致有的引用还是用的是旧值。
发布应用系统时禁止使用类文件替换的方式,整体WAR包发布才是万全之策
本文围绕Java编程给出20条实用建议,涉及序列化UID声明、final变量赋值、属性持久化处理等。如显式声明UID可避免对象不一致;序列化类中避免用构造函数为final变量赋值;使用私有方法解决部分属性持久化问题等,还提及脚本语言、动态编译等使用要点。

被折叠的 条评论
为什么被折叠?



