错误的 Java 代码:Java 开发人员最常犯的 10 个错误

已发表: 2022-03-11

Java 是一种编程语言,最初是为交互式电视开发的,但随着时间的推移,它已广泛应用于任何可以使用软件的地方。 Java 采用面向对象编程的概念设计,消除了其他语言(如 C 或 C++)、垃圾收集和与架构无关的虚拟机的复杂性,创造了一种新的编程方式。 此外,它的学习曲线平缓,似乎成功地坚持了自己的原则——“一次编写,到处运行”,这几乎总是正确的; 但是Java问题仍然存在。 我将解决十个我认为是最常见错误的 Java 问题。

常见错误 #1:忽略现有库

Java 开发人员忽略无数用 Java 编写的库绝对是一个错误。 在重新发明轮子之前,请尝试搜索可用的库 - 其中许多已经在其存在多年中得到了完善,并且可以免费使用。 这些可能是日志库,如 logback 和 Log4j,或网络相关库,如 Netty 或 Akka。 一些库,例如 Joda-Time,已经成为事实上的标准。

以下是我之前的一个项目的个人经验。 负责 HTML 转义的代码部分是从头开始编写的。 它多年来一直运行良好,但最终它遇到了用户输入,导致它陷入无限循环。 用户发现服务没有响应,尝试使用相同的输入重试。 最终,服务器上为这个应用程序分配的所有 CPU 都被这个无限循环占用了。 如果这个天真的 HTML 转义工具的作者决定使用可用于 HTML 转义的知名库之一,例如来自 Google Guava 的HtmlEscapers ,那么这可能不会发生。 至少,对于大多数有社区支持的流行库来说,这个错误会被社区更早地发现并修复。

常见错误 #2:在 Switch-Case 块中缺少“break”关键字

这些 Java 问题可能非常令人尴尬,有时直到在生产环境中运行才被发现。 switch 语句中的失败行为通常很有用; 但是,如果不希望出现这种行为,则缺少“break”关键字可能会导致灾难性的结果。 如果您忘记在下面的代码示例中的“case 0”中放置“break”,程序将编写“Zero”后跟“One”,因为这里的控制流将遍历整个“switch”语句,直到它达到了“休息”。 例如:

 public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }

在大多数情况下,更简洁的解决方案是使用多态性并将具有特定行为的代码移动到单独的类中。 可以使用静态代码分析器(例如 FindBugs 和 PMD)检测诸如此类的 Java 错误。

常见错误 #3:忘记释放资源

每次程序打开文件或网络连接时,Java 初学者在完成使用后释放资源非常重要。 如果在对此类资源的操作期间引发任何异常,则应采取类似的谨慎态度。 有人可能会争辩说 FileInputStream 有一个终结器,它在垃圾收集事件上调用 close() 方法。 但是,由于我们无法确定垃圾回收周期何时开始,输入流可能会无限期地消耗计算机资源。 事实上,Java 7 中专门针对这种情况引入了一个非常有用且简洁的语句,称为 try-with-resources:

 private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }

此语句可用于任何实现 AutoClosable 接口的对象。 它确保在语句结束时关闭每个资源。

相关: 8 个基本 Java 面试问题

常见错误 #4:内存泄漏

Java 使用自动内存管理,虽然忘记手动分配和释放内存是一种解脱,但这并不意味着 Java 初学者不应该知道内存在应用程序中是如何使用的。 内存分配问题仍然存在。 只要程序创建了对不再需要的对象的引用,它就不会被释放。 在某种程度上,我们仍然可以称之为内存泄漏。 Java 中的内存泄漏可能以多种方式发生,但最常见的原因是持久的对象引用,因为垃圾收集器无法在仍然存在对它们的引用时从堆中删除对象。 可以通过使用包含一些对象集合的静态字段定义类来创建这样的引用,并且在不再需要该集合后忘记将该静态字段设置为 null。 静态字段被视为 GC 根,永远不会被收集。

这种内存泄漏背后的另一个潜在原因是一组对象相互引用,导致循环依赖,因此垃圾收集器无法决定是否需要这些具有交叉依赖引用的对象。 另一个问题是使用 JNI 时非堆内存中的泄漏。

原始泄漏示例可能如下所示:

 final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }

此示例创建两个计划任务。 第一个任务从名为“numbers”的双端队列中获取最后一个数字,并打印数字和双端队列大小,以防该数字可被 51 整除。第二个任务将数字放入双端队列。 这两个任务都以固定的速率安排,每 10 毫秒运行一次。 如果代码被执行,你会看到双端队列的大小一直在增加。 这最终将导致双端队列被消耗所有可用堆内存的对象填充。 为了在保留该程序的语义的同时防止这种情况,我们可以使用不同的方法从双端队列中获取数字:“pollLast”。 与“peekLast”方法相反,“pollLast”返回元素并将其从双端队列中删除,而“peekLast”只返回最后一个元素。

要了解有关 Java 内存泄漏的更多信息,请参阅我们揭开此问题的神秘面纱的文章。

常见错误 #5:过多的垃圾分配

当程序创建大量短期对象时,可能会发生过多的垃圾分配。 垃圾收集器连续工作,从内存中删除不需要的对象,这会对应用程序的性能产生负面影响。 一个简单的例子:

 String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));

在 Java 开发中,字符串是不可变的。 因此,在每次迭代中都会创建一个新字符串。 为了解决这个问题,我们应该使用一个可变的 StringBuilder:

 StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));

虽然第一个版本需要相当长的时间来执行,但使用 StringBuilder 的版本产生的结果要少得多。

常见错误 #6:在不需要的情况下使用空引用

避免过度使用 null 是一种很好的做法。 例如,最好从方法返回空数组或集合而不是 null,因为它可以帮助防止 NullPointerException。

考虑以下方法,它遍历从另一个方法获得的集合,如下所示:

 List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }

如果当某人没有帐户时 getAccountIds() 返回 null,则会引发 NullPointerException。 要解决此问题,将需要进行空检查。 但是,如果它返回空列表而不是 null,则 NullPointerException 不再是问题。 此外,代码更简洁,因为我们不需要对变量 accountIds 进行空值检查。

为了处理想要避免空值的其他情况,可以使用不同的策略。 其中一种策略是使用 Optional 类型,它可以是空对象或某个值的包装:

 Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }

事实上,Java 8 提供了更简洁的解决方案:

 Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);

自版本 8 以来,可选类型一直是 Java 的一部分,但它在函数式编程世界中早已为人所知。 在此之前,它在早期版本的 Java 的 Google Guava 中可用。

常见错误 #7:忽略异常

不处理异常通常很诱人。 但是,对于初学者和经验丰富的 Java 开发人员来说,最好的做法是处理它们。 异常是故意抛出的,所以在大多数情况下,我们需要解决导致这些异常的问题。 不要忽视这些事件。 如有必要,您可以重新抛出它,向用户显示错误对话框,或将消息添加到日志中。 最起码应该解释一下为什么这个异常没有被处理,以便让其他开发者知道原因。

 selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }

突出异常无关紧要的一种更清晰的方法是将此消息编码为异常的变量名称,如下所示:

 try { selfie.delete(); } catch (NullPointerException unimportant) { }

常见错误 #8:并发修改异常

当使用迭代器对象提供的方法以外的方法对其进行迭代时修改集合时会发生此异常。 例如,我们有一个帽子列表,我们想要删除所有有耳罩的帽子:

 List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }

如果我们运行此代码,将引发“ConcurrentModificationException”,因为代码在迭代时修改了集合。 如果使用同一列表的多个线程之一尝试修改集合,而其他线程对其进行迭代,则可能会发生相同的异常。 多线程并发修改集合是很自然的事情,但应该使用并发编程工具箱中的常用工具,例如同步锁,并发修改采用的特殊集合等。这个Java问题如何解决存在细微差别在单线程情况和多线程情况下。 下面简要讨论了在单线程场景中可以处理的一些方法:

收集对象并在另一个循环中删除它们

在一个列表中收集带有耳罩的帽子,以便稍后从另一个循环中移除它们是一个明显的解决方案,但需要一个额外的集合来存储要移除的帽子:

 List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }

使用 Iterator.remove 方法

这种方法更简洁,不需要创建额外的集合:

 Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }

使用 ListIterator 的方法

当修改后的集合实现 List 接口时,使用列表迭代器是合适的。 实现 ListIterator 接口的迭代器不仅支持删除操作,还支持添加和设置操作。 ListIterator 实现了 Iterator 接口,因此该示例看起来与 Iterator remove 方法几乎相同。 唯一的区别是帽子迭代器的类型,以及我们使用“listIterator()”方法获取该迭代器的方式。 下面的代码片段显示了如何使用“ListIterator.remove”和“ListIterator.add”方法将每顶帽子替换为带阔边帽的耳罩:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }

使用 ListIterator,可以将 remove 和 add 方法调用替换为对 set 的单个调用:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }

使用 Java 8 中引入的流方法 在 Java 8 中,程序员能够将集合转换为流并根据某些标准过滤该流。 这是一个流 api 如何帮助我们过滤帽子并避免“ConcurrentModificationException”的示例。

 hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));

“Collectors.toCollection”方法将创建一个带有过滤帽子的新 ArrayList。 如果过滤条件由大量项目满足,这可能会出现问题,从而导致很大的 ArrayList; 因此,应谨慎使用。 使用 Java 8 中的 List.removeIf 方法 Java 8 中提供的另一个解决方案,显然是最简洁的,是使用“removeIf”方法:

 hats.removeIf(IHat::hasEarFlaps);

而已。 在后台,它使用“Iterator.remove”来完成该行为。

使用专门的集合

如果一开始我们决定使用“CopyOnWriteArrayList”而不是“ArrayList”,那么完全没有问题,因为“CopyOnWriteArrayList”提供了不变的修改方法(如set、add、remove)集合的支持数组,而是创建它的新修改版本。 这允许对集合的原始版本进行迭代并同时对其进行修改,而不会出现“ConcurrentModificationException”的风险。 该集合的缺点很明显 - 每次修改都会生成一个新集合。

还有针对不同情况调整的其他集合,例如“CopyOnWriteSet”和“ConcurrentHashMap”。

并发集合修改的另一个可能错误是从集合创建流,并在流迭代期间修改支持集合。 流的一般规则是避免在流查询期间修改底层集合。 以下示例将显示处理流的错误方法:

 List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));

peek 方法收集所有元素并对每个元素执行提供的操作。 在这里,该操作试图从基础列表中删除元素,这是错误的。 为避免这种情况,请尝试上述一些方法。

常见错误 #9:违反合同

有时,标准库或第三方供应商提供的代码依赖于应该遵守的规则才能使事情正常进行。 例如,它可能是 hashCode 和 equals 契约,当遵循它时,可以保证 Java 集合框架中的一组集合以及使用 hashCode 和 equals 方法的其他类的工作。 不遵守合约并不是那种总是会导致异常或破坏代码编译的错误; 它更棘手,因为有时它会在没有任何危险迹象的情况下更改应用程序行为。 错误的代码可能会进入生产版本并导致一大堆不良影响。 这可能包括不良的 UI 行为、错误的数据报告、较差的应用程序性能、数据丢失等。 幸运的是,这些灾难性的错误并不经常发生。 我已经提到了 hashCode 和 equals 合约。 它用于依赖散列和比较对象的集合,如 HashMap 和 HashSet。 简单来说,合约包含两个规则:

  • 如果两个对象相等,那么它们的哈希码应该相等。
  • 如果两个对象具有相同的哈希码,那么它们可能相等也可能不相等。

在尝试从哈希图中检索对象时,违反合约的第一条规则会导致问题。 第二条规则表示具有相同哈希码的对象不一定相等。 让我们检查一下违反第一条规则的影响:

 public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }

如您所见,Boat 类重写了 equals 和 hashCode 方法。 但是,它违反了约定,因为 hashCode 每次调用时都会为同一个对象返回随机值。 以下代码很可能在哈希集中找不到名为“Enterprise”的船,尽管我们之前添加了这种船:

 public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }

合同的另一个例子涉及 finalize 方法。 这是官方 java 文档中描述其功能的引述:

finalize 的一般约定是,当 JavaTM 虚拟机确定不再有任何方法可以让任何线程(尚未终止)访问该对象时调用它,除非是由于其他准备完成的对象或类的完成所采取的行动。 finalize 方法可以采取任何行动,包括使该对象再次可供其他线程使用; 然而,finalize 的通常目的是在对象被不可撤销地丢弃之前执行清理操作。 例如,代表输入/输出连接的对象的 finalize 方法可能会执行显式 I/O 事务以在对象被永久丢弃之前中断连接。

可以决定使用 finalize 方法来释放文件处理程序等资源,但这不是一个好主意。 这是因为 finalize 何时被调用没有时间保证,因为它是在垃圾收集期间调用的,而 GC 的时间是不确定的。

常见错误 #10:使用原始类型而不是参数化类型

根据 Java 规范,原始类型是未参数化的类型,或者不是从 R 的超类或超接口继承的类 R 的非静态成员。在 Java 中引入泛型类型之前,没有原始类型的替代品. 它从 1.5 版开始支持泛型编程,而泛型无疑是一个重大改进。 然而,由于向后兼容的原因,留下了一个可能破坏类型系统的陷阱。 让我们看下面的例子:

 List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

在这里,我们有一个定义为原始 ArrayList 的数字列表。 由于它的类型没有用类型参数指定,我们可以在其中添加任何对象。 但在最后一行中,我们将元素转换为 int,将其加倍,并将加倍的数字打印到标准输出。 这段代码编译时不会出错,但是一旦运行就会引发运行时异常,因为我们试图将字符串转换为整数。 显然,如果我们隐藏必要的信息,类型系统将无法帮助我们编写安全的代码。 为了解决这个问题,我们需要指定我们要存储在集合中的对象的类型:

 List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

与原始版本的唯一区别是定义集合的行:

 List<Integer> listOfNumbers = new ArrayList<>();

固定代码无法编译,因为我们试图将字符串添加到预期仅存储整数的集合中。 编译器将显示错误并指向我们尝试将字符串“Twenty”添加到列表的行。 对泛型类型进行参数化总是一个好主意。 这样,编译器就能够进行所有可能的类型检查,并且将由类型系统不一致导致的运行时异常的可能性降到最低。

结论

Java 作为一个平台简化了软件开发中的许多事情,它依赖于复杂的 JVM 和语言本身。 然而,它的特性,比如删除手动内存管理或体面的 OOP 工具,并不能消除普通 Java 开发人员面临的所有问题。 与往常一样,像这样的知识、实践和 Java 教程是避免和解决应用程序错误的最佳方法——因此了解您的库、阅读 java、阅读 JVM 文档和编写程序。 也不要忘记静态代码分析器,因为它们可以指出实际的错误并突出显示潜在的错误。

相关:高级 Java 类教程:类重载指南