个性化阅读
专注于IT技术分析

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

点击下载

本文概述

Java是最初为交互式电视开发的一种编程语言, 但是随着时间的流逝, 它已经遍及可以使用的所有软件。 Java以面向对象编程的概念进行设计, 从而消除了其他语言(例如C或C ++), 垃圾回收以及与体系结构无关的虚拟机的复杂性, Java创建了一种新的编程方式。而且, 它具有柔和的学习曲线, 并且似乎成功地遵循了自己的原则-“写一次, 到处跑”, 这几乎总是正确的。但是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。

常见错误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();
}

本示例创建两个计划任务。第一个任务从称为”数字”的双端队列中获取最后一个数字, 并在数字可被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的一部分, 但是在函数式编程领域中, 它早已为人所熟知。在此之前, Google Guava中提供了Java的早期版本。

常见错误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方法调用:

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));

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

常见错误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的数字列表。由于未使用type参数指定其类型, 因此我们可以向其中添加任何对象。但是在最后一行中, 我们将元素强制转换为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类教程:类重载指南

赞(0)
未经允许不得转载:srcmini » Buggy Java代码:Java开发人员最常犯的10个错误

评论 抢沙发

评论前必须登录!