每天10个编码坑(《编写高质量代码 改善Java程序的151个建议》)

本文围绕Java编程给出20条实用建议,涉及序列化UID声明、final变量赋值、属性持久化处理等。如显式声明UID可避免对象不一致;序列化类中避免用构造函数为final变量赋值;使用私有方法解决部分属性持久化问题等,还提及脚本语言、动态编译等使用要点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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包发布才是万全之策

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值