编写高质量代码:改善Java程序的151个建议(3)

  建议10:不要在本类中覆盖静态导入的变量和方法

  如果一个类中的方法及属性与静态导入的方法及属性重名会出现什么问题呢?我们先来看一个正常的静态导入,代码如下:

  

    importstaticjava.lang.Math.PI; importstaticjava.lang.Math.abs; publicclassClient{ publicstaticvoidmain(String[]args){ System.out.println(“PI=”+PI); System.out.println(“abs(100)=”+abs(-100)); } }

  很简单的例子,打印出静态常量PI值,计算-100的绝对值。现在的问题是:如果我们在Client类中也定义了PI常量和abs方法,会出现什么问题?代码如下:

  

    importstaticjava.lang.Math.PI; importstaticjava.lang.Math.abs; publicclassClient{ //常量名与静态导入的PI相同 publicfinalstaticStringPI=”祖冲之”; //方法名与静态导入的相同 publicstaticintabs(intabs){ return0; } publicstaticvoidmain(String[]args){ System.out.println(“PI=”+PI); System.out.println(“abs(100)=”+abs(-100)); } }

  以上代码中,定义了一个PI字符串类型的常量,又定义了一个abs方法,与静态导入的相同。首先说好消息:编译器没有报错,接下来是不好的消息了:我们不知道哪个属性和哪个方法被调用了,因为常量名和方法名相同,到底调用了哪一个方法呢?我们运行一下看看结果:

  

    PI=祖冲之 abs(100)=0

  很明显是本地的属性和方法被引用了,为什么不是Math类中的属性和方法呢?那是因为编译器有一个“最短路径”原则:如果能够在本类中查找到的变量、常量、方法,就不会到其他包或父类、接口中查找,以确保本类中的属性、方法优先。

  因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖。

  建议11:养成良好习惯,显式声明UID

  我们编写一个实现了Serializable接口(序列化标志接口)的类,Eclipse马上就会给一个黄色警告:需要增加一个SerialVersionID。为什么要增加?它是怎么计算出来的?有什么用?本章就来解释该问题。

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

  

    publicclassPersonimplementsSerializable{ privateStringname; /*name属性的getter/setter方法省略*/ }

  这是一个简单JavaBean,实现了Serializable接口,可以在网络上传输,也可以本地存储然后读取。这里我们以Java消息服务(Java Message Service)方式传递该对象(即通过网络传递一个对象),定义在消息队列中的数据类型为ObjectMessage,首先定义一个消息的生产者(Producer),代码如下:

  

    publicclassProducer{ publicstaticvoidmain(String[]args)throwsException{ Personperson=newPerson(); person.setName(“混世魔王”); //序列化,保存到磁盘上 SerializationUtils.writeObject(person); } }

  这里引入了一个工具类SerializationUtils,其作用是对一个类进行序列化和反序列化,并存储到硬盘上(模拟网络传输),其代码如下:

  

    publicclassSerializationUtils{ privatestaticStringFILE_NAME=”c:/obj.bin”; //序列化 publicstaticvoidwriteObject(Serializables){ try{ ObjectOutputStreamoos=newObjectOutputStream(newFileOutputStream(FILE_NAME)); oos.writeObject(s); oos.close(); }catch(Exceptione){ e.printStackTrace(); } } publicstaticObjectreadObject(){ Objectobj=null; //反序列化 try{ ObjectInputinput=newObjectInputStream(newFileInputStream(FILE_NAME)); obj=input.readObject(); input.close(); }catch(Exceptione){ e.printStackTrace(); } returnobj; } }

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

  

    publicclassConsumer{ publicstaticvoidmain(String[]args)throwsException{ //反序列化 Personp=(Person)SerializationUtils.readObject(); System.out.println(“name=”+p.getName()); } }

  这是一个反序列化过程,也就是对象数据流转换为一个实例对象的过程,其运行后的输出结果为:混世魔王。这太easy了,是的,这就是序列化和反序列化典型的demo。但此处隐藏着一个问题:如果消息的生产者和消息的消费者所参考的类(Person类)有差异,会出现何种神奇事件?比如:消息生产者中的Person类增加了一个年龄属性,而消费者没有增加该属性。为啥没有增加?!因为这是个分布式部署的应用,你甚至都不知道这个应用部署在何处,特别是通过广播(broadcast)方式发送消息的情况,漏掉一两个订阅者也是很正常的。

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

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

  privatestaticfinallongserialVersionUID=XXXXXL;

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

  serialVersionUID如何生成已经说明了,我们再来看看serialVersionUID的作用。JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有发生改变,可以把数据流load为实例对象;如果不相同,对不起,我JVM不干了,抛个异常InvalidClassException给你瞧瞧。这是一个非常好的校验机制,可以保证一个对象即使在网络或磁盘中“滚过”一次,仍能做到“出淤泥而不染”,完美地实现类的一致性。

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

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=55799L; /*其他保持不变*/ }

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

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=5799L; privateintage; /*age、name的getter/setter方法省略*/ }

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

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

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

  建议12:避免用序列化类在构造函数中为不变量赋值

  我们知道带有final标识的属性是不变量,也就是说只能赋值一次,不能重复赋值,但是在序列化类中就有点复杂了,比如有这样一个类:

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=71282334L; //不变量 publicfinalStringname=”混世魔王”; }

  这个Person类(此时V1.0版本)被序列化,然后存储在磁盘上,在反序列化时name属性会重新计算其值(这与static变量不同,static变量压根就没有保存到数据流中),比如name属性修改成了“德天使”(版本升级为V2.0),那么反序列化对象的name值就是“德天使”。保持新旧对象的final变量相同,有利于代码业务逻辑统一,这是序列化的基本规则之一,也就是说,如果final属性是一个直接量,在反序列化时就会重新计算。对这基本规则不多说,我们要说的是final变量另外一种赋值方式:通过构造函数赋值。代码如下:

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=91282334L; //不变量初始不赋值 publicfinalStringname; //构造函数为不变量赋值 publicPerson(){ name=”混世魔王”; } }

  这也是我们常用的一种赋值方式,可以把这个Person类定义为版本V1.0,然后进行序列化,看看有什么问题没有,序列化的代码如下所示:

  

    publicclassSerialize{ publicstaticvoidmain(String[]args){ //序列化以持久保存 SerializationUtils.writeObject(newPerson()); } }

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

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=91282334L; //不变量初始不赋值 publicfinalStringname; //构造函数为不变量赋值 publicPerson(){ name=”德天使”; } }

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

  

    publicclassDeserialize{ publicstaticvoidmain(String[]args){ //反序列化 Personp=(Person)SerializationUtils.readObject(); System.out.println(p.name); } }

  现在问题来了:打印的结果是什么?是混世魔王还是德天使?

  答案即将揭晓,答案是:混世魔王。

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

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

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

  注意:在序列化类中,不使用构造函数为final变量赋值。

  建议13:避免为final变量复杂赋值

  为final变量赋值还有一种方式:通过方法赋值,即直接在声明时通过方法返回值赋值。还是以Person类为例来说明,代码如下:

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=91282334L; //通过方法返回值为final变量赋值 publicfinalStringname=initName(); //初始化方法名 publicStringinitName(){ return”混世魔王”; } }

  name属性是通过initName方法的返回值赋值的,这在复杂类中经常用到,这比使用构造函数赋值更简洁、易修改,那么如此用法在序列化时会不会有问题呢?我们一起来看看。Person类写好了(定义为V1.0版本),先把它序列化,存储到本地文件,其代码与上一建议的Serialize类相同,不再赘述。

  现在,Person类的代码需要修改,initName的返回值也改变了,代码如下:

  

    publicclassPersonimplementsSerializable{ privatestaticfinallongserialVersionUID=91282334L; //通过方法返回值为final变量赋值 publicfinalStringname=initName(); //初始化方法名 publicStringinitName(){ return”德天使”; } }

  上段代码仅仅修改了initName的返回值(Person类为V2.0版本),也就是说通过new生成的Person对象的 final变量值都是“德天使”。那么我们把之前存储在磁盘上的实例加载上来,name值会是什么呢?

  结果是:混世魔王。很诧异,上一建议说过final变量会被重新赋值,但是这个例子又没有重新赋值,为什么?

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

  其中的原理是这样的,保存到磁盘上(或网络传输)的对象文件包括两部分:

  (1)类描述信息

  包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。

  (2)非瞬态(transient关键字)和非静态(static关键字)的实例变量值

  注意,这里的值如果是一个基本类型,好说,就是一个简单值保存下来;如果是复杂对象,也简单,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是说递归到最后,其实还是基本数据类型的保存。

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

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

  通过构造函数为final变量赋值。

  通过方法返回值为final变量赋值。

  final修饰的属性不是基本类型。

  相关链接:

  编写高质量代码:改善Java程序的151个建议(1)

  编写高质量代码:改善Java程序的151个建议(2)

因为有了梦想,我们才能拥有奋斗的目标,

编写高质量代码:改善Java程序的151个建议(3)

相关文章:

你感兴趣的文章:

标签云: