作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
米哈伊尔·塞利万诺夫的头像

米哈伊尔·Selivanov

作为一个后端程序员,Mikhail有着丰富的经验,在开发的各个阶段都有许多成功的项目.

专业知识

以前在

Truecaller
分享

Java是一种编程语言 最初是为互动电视开发的但随着时间的推移,它已经广泛应用于任何可以使用软件的地方. 用面向对象编程的概念设计的, 消除了C或c++等其他语言的复杂性, 垃圾收集, 和一个架构不可知论的虚拟机, Java创造了一种新的编程方式. 此外, 它有一个平缓的学习曲线,似乎成功地坚持了自己的座右铭——“写一次”, 到处运行”, which is almost always true; but Java problems are still present. 我将讨论我认为最常见的10个Java问题.

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

这绝对是一个错误 Java开发人员 忽略用Java编写的无数库. 在重新发明轮子之前, 试着搜索可用的库——它们中的许多已经经过了多年的完善,并且可以免费使用. 这些可以是日志库, 比如logback和Log4j, 或者网络相关库, 比如妮蒂或阿卡. 一些库,如Joda-Time,已经成为事实上的标准.

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

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

这些Java问题可能非常令人尴尬, 有时直到在生产中运行才被发现. Fallthrough behavior in switch statements is often useful; 然而, 当不希望出现这种行为时,缺少“break”关键字可能会导致灾难性的结果. 如果您忘记在下面的代码示例中在“case 0”中添加“break”, 程序会写“0”,后面跟着“1”。, 因为这里的控制流将经历整个" switch "语句,直到它到达一个" break ". 例如:

switchCasePrimer() {
    	int caseIndex = 0;
    	switch (caseIndex) {
        	例0:
            	系统.出.println(“0”);
        	案例1:
            	系统.出.println(“一”);
            	打破;
        	案例2:
            	系统.出.println(“两个”);
            	打破;
        	默认值:
            	系统.出.println(“违约”);
    	}
}

在大多数情况下, 更简洁的解决方案是使用多态性,并将具有特定行为的代码移到单独的类中. 像这样的Java错误可以使用静态代码分析器来检测.g. FindBugsPMD.

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

每当程序打开文件或网络连接时, 对于Java初学者来说,在使用完资源后释放它是很重要的. 如果在对此类资源进行操作期间抛出任何异常,也应采取类似的谨慎态度. One could argue t帽子。 the FileInputStream has a finalizer t帽子。 invokes the close() method on a 垃圾收集 event; 然而, 因为我们不能确定垃圾收集周期何时开始, 输入流可以无限期地消耗计算机资源. 事实上, Java 7中专门针对这种情况引入了一个非常有用且简洁的语句, 被称为 try-with-resources:

printFileJava7()抛出IOException {
    try(FileInputStream input = new FileInputStream.txt”)){
        Int 数据=输入.read ();
        而(数据 != -1){
            系统.出.打印((char)数据);
            数据=输入.read ();
        }
    }
}

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

常见错误#4:内存泄漏

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

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

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

最后ScheduledExecutorService ScheduledExecutorService = executor.newScheduledThreadPool (1);
final Deque 数字 = new LinkedBlockingDeque<>();
最后一个BigDecimal除数= new BigDecimal(51);

scheduledExecutorService.scheduleAtFixedRate(() -> {
	BigDecimal 数量 =数字.peekLast ();
   	如果数量 !=零 && 数量.剩余(因子).byteValue() == 0) {
     	系统.出.println("Number: " + Number);
		系统.出.println("Deque size: " + 数量.大小());
	}
}, 10,10, TimeUnit.毫秒);

	scheduledExecutorService.scheduleAtFixedRate(() -> {
		数字.添加(新BigDecimal(系统.currentTimeMillis ()));
	}, 10,10, TimeUnit.毫秒);

尝试{
	scheduledExecutorService.awaitTermination (TimeUnit.天);
} catch (InterruptedException e) {
	e.printStackTrace ();
}

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

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

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

当程序创建大量短寿命对象时,可能会发生过多的垃圾分配. 垃圾收集器连续工作, 从内存中删除不需要的对象, 哪些会以消极的方式影响应用程序的性能. 一个简单的例子:

String oneMillionHello = "";
for (int i = 0; i < 1000000; i++) {
    oneMillionHello = oneMillionHello +“你好。!";
}
系统.出.println (oneMillionHello.substring (0, 6));

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

StringBuilder oneMillionHelloSB = new StringBuilder();
    for (int i = 0; i < 1000000; i++) {
        oneMillionHelloSB.追加(“你好!");
    }
系统.出.println (oneMillionHelloSB.toString ().substring (0, 6));

而第一个版本需要相当多的时间来执行, 使用StringBuilder的版本产生的结果要短得多.

常见错误#6:不必要地使用空引用

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

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

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

如果一个人没有账户,getAccountIds()返回null, 那么将引发NullPointerException. 要解决这个问题,需要一个null检查. 然而, 如果不是null,则返回一个空列表, 那么NullPointerException就不再是问题了. 此外,由于不需要对变量accountid进行空检查,因此代码更加清晰.

为了处理其他想要避免null的情况,可以使用不同的策略. 其中一种策略是使用可选类型,它可以是一个空对象,也可以是某个值的换行:

Optional optionalString = Optional.ofNullable (nullableString);
如果(optionalString.isPresent ()) {
    系统.出.println (optionalString.get ());
}

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

Optional optionalString = Optional.ofNullable (nullableString);
optionalString.ifPresent(系统.:: println);

自版本8以来,可选类型已经成为Java的一部分, 但是它在函数式编程的世界里已经很有名了. 在此之前,它可以在谷歌番石榴中用于早期版本的Java.

常见错误7:忽略异常

通常不处理异常是很诱人的. 然而,对于初学者和有经验的Java开发人员来说,最好的做法是处理它们. 抛出异常是有目的的, 因此,在大多数情况下,我们需要解决导致这些异常的问题. 不要忽视这些事件. 如果有必要的话, 你可以重新扔, 向用户显示错误对话框, 或者在日志中添加一条消息. 至少, 应该解释为什么不处理异常,以便让其他开发人员知道原因.

自拍=人.shootASelfie ();
尝试{
    有问题.显示();
} catch (NullPointerException) {
    //也许,隐形人. 谁在乎呢??
}

强调异常不重要的更清晰的方法是将此消息编码到异常的变量名中, 是这样的:

尝试{自拍.delete(); } catch (NullPointerException unimportant) {  }

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

当使用迭代器对象提供的方法以外的方法对一个集合进行迭代时修改该集合,就会发生此异常. 例如,我们有一个帽子列表,我们想去掉所有有耳盖的帽子:

List 帽子 = new ArrayList<>();
帽子.添加(new Ushanka()); // t帽子。 one has ear flaps
帽子.添加(新的Fedora ());
帽子.添加(新草帽());
for (IHat 帽子。: 帽子) {
    如果帽子.hasEarFlaps ()) {
        帽子.删除(帽子);
    }
}

如果我们运行这个代码, “ConcurrentModificationException”将被引发,因为代码在迭代集合时修改了它. 如果处理同一列表的多个线程中的一个试图在其他线程遍历该集合时修改该集合,也可能发生同样的异常. 在多个线程中并发修改集合是很自然的事情, 但是应该使用并发编程工具箱中的常用工具(如同步锁)来处理, 为并发修改而采用的特殊集合, 等. 在单线程情况和多线程情况下如何解决这个Java问题有细微的不同. 以下是在单线程场景中处理此问题的一些方法的简要讨论:

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

在列表中收集带有耳盖的帽子,以便稍后从另一个循环中移除它们是一个显而易见的解决方案, 但需要额外的收藏来存放要取下的帽子:

List 帽子ToRemove = new LinkedList<>();
for (IHat 帽子。: 帽子) {
    如果帽子.hasEarFlaps ()) {
        帽子ToRemove.添加(帽子);
    }
}
for (IHat 帽子。: 帽子ToRemove) {
    帽子.删除(帽子);
}

使用迭代器.删除方法

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

Iterator 帽子。Iterator = 帽子.iterator ();
而(帽子。Iterator.hasNext ()) {
    那个=那个=那个.next ();
    如果帽子.hasEarFlaps ()) {
        帽子。Iterator.remove ();
    }
}

使用ListIterator的方法

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

IHat sombrero = new sombrero ();
ListIterator 帽子。Iterator = 帽子.listIterator ();
而(帽子。Iterator.hasNext ()) {
    那个=那个=那个.next ();
    如果帽子.hasEarFlaps ()) {
        帽子。Iterator.remove ();
        帽子。Iterator.添加(草帽);
    }
}

使用listtiterator,可以将remove和添加方法调用替换为单个set调用:

IHat sombrero = new sombrero ();
ListIterator 帽子。Iterator = 帽子.listIterator ();
而(帽子。Iterator.hasNext ()) {
    那个=那个=那个.next ();
    如果帽子.hasEarFlaps ()) {
        帽子。Iterator.set(sombrero); // set instead of remove 和 添加
    }
}

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

帽子=帽子.流().filter((帽子。 -> !帽子。.hasEarFlaps ()))
        .收集(收藏家.toCollection (ArrayList::新));

“收藏家.方法将创建一个带有过滤帽的新数组列表. 如果要满足大量项目的过滤条件,这可能是一个问题, resulting in a large ArrayList; thus, 要小心使用. 使用列表.java8中的removeIf方法 Java 8中提供的另一个解决方案, 而且显然是最简洁的, 是使用" removeIf "方法:

帽子.removeIf (IHat:: hasEarFlaps);

就是这样. 在底层,它使用“Iterator”.“移除”来完成该行为.

使用专门的集合

如果一开始我们决定使用CopyOnWriteArrayList而不是ArrayList, 那就完全没有问题了, 因为“CopyOnWriteArrayList”提供了修改方法(比如set . list), 添加, 并删除不改变集合的后备数组的元素, 而是创造一个新的修改版本. 这允许对集合的原始版本进行迭代,并同时对其进行修改, 没有" ConcurrentModificationException "的风险. 这种集合的缺点很明显——每次修改都会生成一个新的集合.

还有其他针对不同情况调整的集合,例如.g. " CopyOnWriteSet "和" ConcurrentHashMap ".

并发集合修改的另一个可能的错误是从集合创建流, 在流迭代过程中, 修改后备集合. 流的一般规则是避免在流查询期间修改底层集合. 下面的例子将展示处理流的错误方式:

List filteredHats = 帽子.流().peek(帽子。 -> {
    如果帽子.hasEarFlaps ()) {
        帽子.删除(帽子);
    }
}).收集(收藏家.toCollection (ArrayList::新));

方法peek收集所有元素,并对每个元素执行所提供的操作. 这里,操作试图从底层列表中删除元素,这是错误的. 为了避免这种情况,可以尝试上面描述的一些方法.

常见错误9:违反合同

有时, 由标准库或第三方供应商提供的代码依赖于应该遵守的规则,以便使事情正常工作. 例如, 它可以是hashCode和equals contract, 使得来自Java集合框架的一组集合的工作得到保证, 以及其他使用hashCode和equals方法的类. Disobeying contracts isn’t the kind of error t帽子。 always leads to exceptions or breaks code compilation; it’s more tricky, 因为有时它会在没有任何危险迹象的情况下改变应用程序的行为. 错误的代码可能会溜进生产版本,并导致一大堆不希望看到的结果. 这可能包括糟糕的UI行为, 错误的数据报告, 应用程序性能差, 数据丢失, 和更多的. 幸运的是,这些灾难性的错误并不经常发生. 我已经提到了hashCode和equals合约. 它用于依赖散列和比较对象的集合,如HashMap和HashSet. 简单地说,合约包含两条规则:

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

违反合约的第一条规则会导致在尝试从哈希映射中检索对象时出现问题. 第二条规则表示具有相同哈希码的对象不一定相等. 让我们来看看违反第一条规则的后果:

公共静态类Boat {
    私有字符串名称;

    船(字符串名称){
        这.Name = Name;
    }

    @Override
    public boolean = (Object 0) {
        If (这 == 0)返回true;
        if (0 ==零 || getClass()) != o.getClass())返回false;

        船=(船)0;

        返回 !(名称 !=零 ? !名字。.equals(船.名称):船.名字。 !=零);
    }

    @Override
    public int hashCode() {
        返回(int.R和om () * 5000);
    }
}

如您所见,类Boat已经覆盖了equals和hashCode方法. 然而, 它违反了合同, 因为hashCode每次调用时都会为同一对象返回随机值. 下面的代码很可能在哈希集中找不到名为“企业”的船, 尽管我们之前添加了这种船:

public static void main(String[] args) {
    Set 船 = new HashSet<>();
    船.添加(新船(“企业”));

    系统.出.“我们有一艘名为‘企业号’的船:%b\n”,船.包含(新船(“企业”)));
}

合同的另一个例子涉及finalize方法. 下面是官方java文档中描述其功能的一段话:

finalize的一般约定是,当java虚拟机确定不再有任何方法可以让任何线程(尚未死亡的线程)访问该对象时,将调用finalize。, 除非是其他一些准备结束的对象或类的结束所采取的动作的结果. finalize方法可以执行任何操作, including making 这 object available again to other threads; the usual purpose of finalize, 然而, 是否在不可撤销地丢弃对象之前执行清理操作. 例如, 表示输入/输出连接的对象的finalize方法可能会在对象被永久丢弃之前执行显式的I/O事务来中断连接.

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

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

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

listOfNumbers = new ArrayList();
listOfNumbers.添加(10);
listOfNumbers.添加(20);
listOfNumbers.forEach(n -> 系统.出.Println (n * 2));

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

List listOfNumbers = new ArrayList<>();

listOfNumbers.添加(10);
listOfNumbers.添加(20);

listOfNumbers.forEach(n -> 系统.出.Println (n * 2));

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

List listOfNumbers = new ArrayList<>();

修复后的代码无法编译,因为我们试图将字符串添加到预期仅存储整数的集合中. 编译器将显示一个错误,并指向我们试图将字符串“Twenty”添加到列表的行. 对泛型类型进行参数化总是一个好主意. 这种方式, 编译器能够进行所有可能的类型检查, 并且最小化了由类型系统不一致引起的运行时异常的可能性.

结论

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

就这一主题咨询作者或专家.
预约电话
米哈伊尔·塞利万诺夫的头像
米哈伊尔·Selivanov

位于 瑞典索尔纳

成员自 8月15日

作者简介

作为一个后端程序员,Mikhail有着丰富的经验,在开发的各个阶段都有许多成功的项目.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前在

Truecaller

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.