《100 Java Mistakes and How to Avoid Them》笔记 1

这几日在阅读 Manning 出版社的 《100 Java Mistakes and How to Avoid Them》, 其中列举的确实是一些容易带入到代码中的错误,不少还是通过代码 Review 或单元测试很难发现的问题。也有些看似很弱智,却可能是隐匿许久的定时炸弹,只等某一特定条件出现时即爆。


阅读的同时简单的作了笔记及少许联想,所以内容有些杂乱无条理。最前面介绍了一些静态代码分析工具,也有两个动态分析工具。本书目前还是 Manning 的 MEAP 体验版,未正式发售。一共讲了 100 个常见错误如何避免(例如,怎么用最新 Java(Java 9 -- Java 21) 语法, API 来改进),以及用静态分析工具,单元测试及早发现。

这是读完了 1/4 数量的记录,笔记开始



几个好用的静态代码分析的 IntelliJ IDEA 插件

  1. SonarLint: https://www.sonarsource.com/products/sonarlint, 还能与 SonarQube 集成
  2. Error Prone: https://errorprone.info, Google 开发的 Java 编译插件, 所以还能与 Maven 或 Gradle 等集成
  3. PVS-Studio: https://www.viva64.com/en/pvs-studio, 付费项目
  4. SpotBugs: https://spotbugs.github.io, 静态分析 Java 字节码
  5. Klocwork: https://www.perforce.com/products/klocwork, 热加载不重启调试的工具 JRebel 就是他们家的
  6. CodeQA:  https://codeql.github.com, 如果项目存储在 GitHub 上,可用该工具分析

IntelliJ 还有一个类似于 SonarQube 的静态代码分析平台 Qodana, IntelliJ IDEA 有相应的插件


有很多种包提供了支持静态检查的 Annotation, 像 @Nullable, @NotThreadSafe 之类的。如

  1. Error Prone: com.google.errorprone.annotations
  2. Checker: org.checkerframework
  3. JetBrains: org.jetbrains.annotations 和 org.intellij.lang.annotations
  4. Android: androidx.annotation
  5. JDT: org.eclipse.jdt.annotation
  6. JCIP(book: Java Concurrency In Practise): net.jcip
  7. JSR 305: javax.annotation 最终未没采用
  8. FindBugs/SpotBugs: edu.umd.cs.findbugs.annotations, 它沿袭了一些 JSR 305 的注解

每套 Annotation 都提供了 Maven 的使用插件

Lombok 代码生成用的 Annotation Processor 会根据注解生成相应的检测代码,比如给方法参数加上

foo(@NonNull String name), 在生成的字节码中就会插入参数检测代码
1if (monthYear == null) {
2    throw new NullPointerException("monthYear is marked non-null but is null");
3}

除了 TDD 测试,还有一种 Property-Based Testing 作为补充,Java 实现 jqwik 框架。TDD 容易为了满足单元测试而写实现代码, PBT 能更全面的进行随机测试,但目前本人还未能检验到 PBT 的好处。

Mutation coverage  - mutation testing system for Java: Pitest. 实现代码的改动能杀死越多的单元测试用例就越好。

静态代码分析不够的话,还有动态分析的工具. 如

  1. NASA 开源的 Java Pathfinder, 它可用来检测数据竞争,未处理的异常,可能的失败断言,它配置起来很复杂
  2. Dl-Check,它使用 Java agent, 主要用来发现潜在线程死锁,使用很简单,比如配置一个 maven 插件就行

代码中加入断言也是很好的办法,它能让问题尽量暴露,及时修复
1assert condition;
2assert condition: explanation;
Java 默认执行是禁用了的,需加 -ea 虚拟机参数启用,在非产品环境可以启用它,或可为某些类/包启用,参数为 -da。Spring 框架提供了更丰富的 assert 方法,它们不受 -ea 虚拟机参数控制了,而总是在那儿。

超越了小学数学中乘除比加减法优先级更高的认知就用括号改变优先级,如混合了移位,逻辑运算等的表达式,即使你清除也会让其他程序员迷惑

避免手写 hashCode() 方法,用 Java IDE 或其他库成熟的方法来生成 hashCode() 方法

String.format() 或 Java 15 开始的 "string".formatted(): 现代 JVM 的字符串格式化方法会比显式的字符串合并( str1 + str2) 慢

Java 9 后有一个 Objects.requireNonNullElse(value, "unknown") 方法,比 Optional.ofNullable(value).orElse("unknown") 便捷

Java 中的字符串可与数字相加(连接),这容易产生问题

String entryName = "Entry#" + index + 1;  // 可能获得的 entryName 是 "Entry#31"

而 Python 中是不允许字符串与数字相连
1>>> "a" + 1
2Traceback (most recent call last):
3File "<stdin>", line 1, in <module>
4TypeError: can only concatenate str (not "int") to str

写成多行的用加号连接字符串时,如果加号在首尾重复时,正好碰到 byte, char, short, long, int 整型时,被认为是 ++ 操作
1jshell> "User not found: " +
2...> + '"' + "Tiger" + '"';
3$1 ==> "User not found: 34Tiger\""

x+=1 -> x = x+1 ,    x =+1 => x = (+1),  这里的加号 + 其实是个正的符号,减号也一样要注意
1>jshell> x=1
2x ==> 1
3jshell> x += 1
4$6 ==> 2
5jshell> x =+1
6x ==> 1
三元操作符在进行数值装箱时的问题
1Double valueOrZero(boolean condition, Double value) {
2    return condition ? value: 0.0;
3}

三元操作符(Ternary) 在决定类型时比较复杂,这里的 condition ? value: 0.0 不受方法返回值 Double 类型的影响

三元操作符中 then 条件(冒号: 前的值) 中是 Boxed 类型,而 else 条件(冒号: 后的值) 中基本类型的话,基本类型赢,所以上面的代码相当于

1Double valueOrZero(boolean condition, Double value) {
2    return Double.valueOf(
3        condition ? value.doubleValue() : 0.0);
4}

所以我们会看到下面的调用错误
 1jshell> Double valueOrZero(boolean condition, Double value) {
 2   ...>     return condition ? value : 0.0;
 3   ...> }
 4|  modified method valueOrZero(boolean,Double)
 5jshell> valueOrZero(true, null)
 6|  Exception java.lang.NullPointerException: Cannot invoke "java.lang.Double.doubleValue()" because "<parameter2>" is null
 7|        at valueOrZero (#12:2)
 8|        at (#13:1)
 9jshell> valueOrZero(false, null)
10$14 ==> 0.0

condition 为 false 时不出错,因为三元操作符也是个短路操作。

如果避免不必要拆装符,可用  if/else 替代三元操作. 不同分支中避免返回不同的类型,尤其是分支中有 基本类型

更好的理解三元操作符如何确定类型转换,可以用 javap -c  反编译看字节码,比如上面的 valueOrZero 方法反编译出来是
 1java.lang.Double valueOrZero(boolean, java.lang.Double);
 2    Code:
 3       0: iload_1
 4       1: ifeq          11
 5       4: aload_2
 6       5: invokevirtual #7                  // Method java/lang/Double.doubleValue:()D
 7       8: goto          12
 8      11: dconst_0
 9      12: invokestatic  #13                 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double;
10      15: areturn
11}

再来一个更复杂的(null 值碰上基本类型的情况)
1Integer mapValue(int input) {
2    return input > 20 ? 2 :
3      input > 10 ? 1 :
4      null;
5  }

javap -c 后是
 1java.lang.Integer mapValue(int);
 2    Code:
 3       0: iload_1
 4       1: bipush        20
 5       3: if_icmple     10
 6       6: iconst_2
 7       7: goto          27
 8      10: iload_1
 9      11: bipush        10
10      13: if_icmple     23
11      16: iconst_1
12      17: invokestatic  #17                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13      20: goto          24
14      23: aconst_null
15      24: invokevirtual #22                 // Method java/lang/Integer.intValue:()I
16      27: invokestatic  #17                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
17      30: areturn

所以它的等效代码是(从字节码还原出下面的代码需要仔细的阅读理解上面的字节码)
1return Integer.valueOf(input > 20 ? 2:
2    (input > 10 ? Integer.valueOf(1) : null).intValue());

使用某些反编译工具,如 IntelliJ IDEA 打开 class 文件看不出字节码的不同,仍然能还原为
1return input > 20 ? 2: input > 10 ? 1 : null;
任何时候都要警惕 Integer, Double, Boolean 等拆箱时产生的 NullPointerException

尽量使用短路操作 &&||, 小心把它们误写成了非短路的 &| 操作
1check1() || check2() || check3()     // 不要写成了 check1() | check2() || check3()

尽可能避免使用非短路操作 &|.

避免把有副作用的操作直接放在条件中,不管是
1if(updateA() && updateB())){ ... }

还是
1if(updateA() & updateB()){ .... }

都会让人感到迷惑,会不会执行 updateB() 变得不确定

逻辑运行 !(a || b) 相当于 !a && !b , 所以对于已有 || 操作,如
1if(line.startsWith("#") || line.startsWith("//")) {...}

反过来的话可以在整体的前面加上 ! , 而写成
1if(!(line.startsWith("#") || line.startsWith("//")) {...}

这时候可能感觉括号多余而写成了
1if( !line.startsWith("#") || !line.startsWith("//")) {...}  // 这是不对的
而要把 || 转成  &&,所以正确的写法是
1if( !line.startsWith("#") && !line.startWith("//")) {...} // 正确写法
相应的 !( a && b) 相当于 !a || !b

非(P 且 Q)=(非 P)或(非 Q)
非(P 或 Q)=(非 P)且(非 Q)

Java 1.5 起,函数的最后一个参数可以是变参,变参函数的传参也是很容易出错的地方,如代码
1void printAll(Object... data) {
2    for (Object d: data) {
3        System.out.println(d);
4    }
5}

变参函数会把传入的参数转成数组,未传的话,就是个空数组,也能显式的传入一个数组

下面两种方式调用等效
printAll("Hello", "World");
printAll(new Object[] {"Hello", "World"}); 

上面传入 Object[] 的时候是内联的方式传参的,如果是先在某处声明了,过后再传入的
1Object[] obj = new Object[]{"Hello", "World"};
2....... // 中间一段其他代码
3printAll(obj);

这会让人有些迷惑,不过结果和前面的 printAll(new Object[]{"Hello", "World"}) 是一样的

对象类型的根类型是 Object, 我们再改变引用类型
1Object obj = new Object[]{"Hello", "World"};
2printAll(obj);
这时候就错乱了,输出结果是
1[Ljava.lang.Object;@548ad73b
new Object[]{"Hello", "World"} 整体作为一个参数传给了 printAll() 方法,调用上相当于
1printAll(new Object[]{new Object[]{"Hello", "World"}});

所以变参函数只认声明类型,对于 printAll(Object... data)

实参与形参的关系

Object obj; 则 data 为 [obj]

Object obj; 则 data 为 obj

更让人奇怪的是

printAll(null), 则 data 为 null, 而不是 [null]

为了避免 null 直接作为 data 传入的情况,我们须明确它的声明类型
1printAll((Object)null);    // data 为 [null]
2printAll((Object[])null);  // data  null

我们应在调用变参函数时避免传入 null

变参函数的参数只适用于逐个传入或数组(数组会被打散),如果传入的是一个集合,则该集合只会被当作数组的第一个元素(即 data = [collection]), 所以对数组重构为集合时要特别小心它是否是一个变参函数的参数。

变参函数调用上,像 Scala, Python 等语言做的好些,它要求传入列表时需显式的用 * 打散,如 foo(*["Hello", "World"])

如果换成引用为 String[] 的字符串数组会怎么样呢?
1String[] obj = new String[]{"Hello", "World"};
2printAll(obj);

执行的效果上与 Object[] 是一样的,也就是 printAll("Hello", "World"); 的效果,但会出现警告
1Test.java:4: warning: non-varargs call of varargs method with inexact argument type for last parameter;
2    printAll(obj);
3             ^
4  cast to Object for a varargs call
5  cast to Object[] for a non-varargs call and to suppress this warning
61 warning
7Hello
8World

变参与基本类型,如
1Arrays.asList(new int[]{1,2,3});        // 输出 [[I@2d127a61]
2Arrays.asList(new Integer[]{1,2,3});    // 输出 [1, 2, 3]

因为 Arrays.asList(T... a) 声明,泛型 T 的上界为 Object, int[] 是一个 Object, 而其中的 int 不是 Object,不会被自动装箱

Java 8 的 List 有一个 replaceAll 函数
1default void replaceAll(UnaryOperator<E> operator) {
2    Objects.requireNonNull(operator);
3    final ListIterator<E> li = this.listIterator();
4    while (li.hasNext()) {
5        li.set(operator.apply(li.next()));
6    }
7}

可以
1list.replaceAll(String::trim);

不要忽略了无副作用函数的返回值,可尝试注解 @CheckReturnValue, @CanIgnoreReturnValue, @Contract(pure = true).

避免使用老的用 true/false 代替异常来标识操作成功与失败的 API, 如旧的 java.io.File 的 delete, mkdir, rename 等操作,相应的使用新的 java.nio.file.Files 中的 API, 如 Files.createDirectories(Paths.get(path)), Java 11 的 readNBytes() 或替代 InputStream.read() 方法

使用方法引用可以帮我们省略参数的传递,但一定要清楚它的实际参数,即明确它是重载方法中哪一个方法的引用。其实即使是用 Lambda 也可能会调用了错误的方法,如 JdbcTemplate.query() 方法,可以返回 Object, 也可以返回 List<T>。特别留意 Map.computeIfAbsent() 和 Arrays.setAll() 中用方法引用的情况。

list.sort(null) 时实际按自然顺序排序,相当于 list.sort(Comparator::naturalOrder)

因为 if/else 的大括号后无需分号,所以避免把多个 if/else 块交织在一起,写成
 1Data data;
 2if (condition1) {
 3    data = getData1();
 4} else if( condition2) {
 5    data = getData2();
 6} if (condition3) {                // 这样写没有语法错误,但会造成上面的 data 赋值被忽略,这里的 if 与上面的 if/else 不成一块,在新的一行里写就容易看出问题来
 7    data = getData3();
 8} else if (condition4) {
 9  data = getData4();
10} else {
11  data = getDefaultData();
12}
13process(data);
如果声明 data 为 final final Data data, 上面的代码就不能通过编译,因为 data 会被赋值多次

Condition dominance 的问题,即前一条件使得后面的条件代码永远无法执行到,如
1if (obj instanceof Number) {
2    ....
3}
4if (object instanceof BigInteger) {
5    ...... // 总是被 obj instanceof Number 阻挡住了
6}

Java 21 的 switch pattern 可以解决 instanceof 的检测,如果
1return switch(obj) {
2    case Number number -> BigInteger.valueOf(number.longValue()); // 这里会有编译错误,this case label is dominated by a preceding case label
3    case BigInteger bigInteger -> bigInteger
4    case null, default -> BigInteger.ZERO
5};

但是对下面的问题
1if (age >= 6 ) return CHILD;
2if (age >= 18) return FULL;

前面条件包含后面条件的情况就要仔细了,或者用封闭区间来避免,如
1if (age >=6 && age <=17 ) return CHILD;
2if (age >= 18) return FULL
3throw new IllegalArgumentException("Wrong age: " + age);   // 测试中未考虑的情况会抛出异常然后回来补充更多的区间

Java 的 switch 操作是和 c/c++ 一样 fall through 的方式, case 只提供入口,未没 break 将进入下一个 case; Java 14 及之后的 switch 表达式克服了这一缺点
1switch(button) {
2    case YES -> actOnYes();
3    case No -> actOnNo();
4    case CANCEl -> actOnCancel();
5}

-> 用法的 switch 相当于每个 case 后都会自动加上 break.

如果在 switch/case 中赋值,那么声明变量为 final, 在 fall through 的情况编译会出错,因为 final 类型变量不能被多次赋值。
 1final Boolean answer;
 2switch(ch) {
 3  case 'T':
 4  case 't':
 5    answer = true;
 6  case 'F':
 7  case 'f':
 8    anser = false;  // complilation error: answer is reassigned
 9    break;
10  default:
11    anser = null;
12}

如果升级到了 Java 14, 最好总是使用 -> 的 switch 语法. 现代 IDE 能自动完成到 switch -> 的转换
1final Boolean anser = switch(ch) {
2    case 'T', 't' -> true;
3    case 'F', 'f' -> false;
4    default -> null;
5};

简单的多

for (int i=lo; i<=hi; i++), 如 hi 是 Integer.MAX_VALUE 或更大的值(Long), 则 i<=hi 将永远是 true, i++ 溢出后变成负数

静态字段初始化顺序的问题
 1class MyHandler {
 2    public static final MyHandler INSTANCE = new MyHandler();                // 1
 3    private static final Logger logger = Logger.getLogger(MyHandler.class);  // 2
 4    private MyHandler() {
 5        try {
 6           ...
 7        } catch (Exception ex) {
 8            logger.error("initialization error", ex);  // 这里会出现 NullPointerException
 9        }
10    }
11}

上面会出现 NullPointerExcpetion, 因为初始化 INSTANCE 时 logger 还未初始化,行 1, 2 调个顺序就行了

字段初始化要保持简单

不完全初始化,父类构造函数中调用被子类覆盖的方法时,在子类方法中使用了子类实例变量时可能有 NullPointerException 异常
 1abstract class ParentClass {
 2    private int id = generatedId();
 3    abstract int generatedId();
 4}
 5class SubClass extends ParentClass {
 6    Random random = new Random();
 7    @Override
 8    int generatedId() {
 9        return random.nextInt();
10    }
11}
12new SubClass();   // NullPointerException, random is null

因为实例的初始化顺序是 父实例 -> 子实例, 实例变量实际会在构造函数中初始化的,构造父实例的时候,子实例的构造函数还未调用,所以 random 仍然是 null

父类的构造函数在调用可被子类覆盖方法时要留心,如果能声明为不可覆盖就安全了,如静态,或私有,或 final 方法

两个相互引用的类,在初始化类和实例时要注意它们初始化顺序,也会产生私有成员未完成初始化的情况

如果两个类在初始化时有静态变量的相互引用,由于虚拟机要保证类只能被初始化一次,所以在多线程环境中可能会产生死锁。
1class A {
2   public static final A INSTANCE = new B();
3}
4class B extends A{
5}

两个线程同时在使用 new A() 和 new B() 时,为保证虚拟机只初始化 A 和 B 一次,虚拟机会进行上锁,然后就可能互锁。

类在初始化时最好不要去访问它的子类

枚举类型是在类初始化的时候预先创建好相对应的常量,所以在枚举类中有互相引用也会触发私有私有成员未完全初始化的情况。

在枚举类的构造函数中不能调用 values() 方法或 switch 作用在当前枚举类型,枚举实际的初始化过程是
1enum DayOfWeek {
2    SUN, MON, TUE, WED, THU, FRI, SAT;

实际会预先创建好对应的常量,如
1// 0 是 original, 枚举中的其他构造参数会放到 int original 参数之前
2public static final DayOfWeek SUN = new DayOfWeek(0);

要求子类覆盖方法中第一行要用 super.onKeyDown(event) 调用父类相同方法的设计是很差劲的设计,更应该用抽象方法。

静态变量在非静态上下文中(如实例方法) 修改时要注意它会产生线程不安全的问题。即使是 final static 的变量,如果它的内部状态是可变的(如集合),也是线程不安全的。

再次重申 SimpleDateFormat 不是线程安全的,调用 format() 方法会修改内部状态,应该用线程安全的 DateTimeFormatter 类

new ArrayList<>(existingList.size()), 预先指定 List 的大小很多时候是没有帮助的,因为在往其中添加元素时接近(不是到达) List 容量时就会扩容。