首页 > Java > java教程 > 4 中最有趣的 Java 错误

4 中最有趣的 Java 错误

Mary-Kate Olsen
发布: 2025-01-01 09:19:09
原创
597 人浏览过

2024 年,我们分析了大量项目,并在博客上分享我们的发现。现在是除夕夜——是时候讲喜庆故事了!我们收集了在开源项目中检测到的最有趣的 Java 错误,现在将它们带给您!

Top most intriguing Java errors in 4

前言

我们长期以来一直秉承发布 PVS-Studio 检测到的最有趣的 bug 的传统,但自 2020 年以来就没有出现与 Java 相关的置顶!这一次,我尝试复兴复古风格。我希望您手边有一条舒适的毯子和一杯热茶,因为我专门为您挑选了 10 多种有趣的昆虫。以下是他们的排名:

  • 我的个人意见;
  • 该错误的有趣背景;
  • 多样性、可信度和重要性。

准备好用自己的编程智慧来迎接 10 个有趣的故事吧——除夕夜很快就要到来了:)

第10名。回到未来

第十位,第一个代码片段张开双臂欢迎我们。

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

我忍不住把它放在除夕夜的顶部,因为这段代码中的一个错误可以让我们更快地到达下一年:)猜猜这个错误是从哪里来的?

让我们看一下传递给 SimpleDateFormat 构造函数的参数。看起来有效吗?如果我们传递几乎任何日期,例如撰写本文的日期 (10/12/2024),代码将返回正确的值 20241210。

但是,如果我们传递 29/12/2024,它将返回 20251229,从而巧妙地提前进入新年。顺便说一句,时光倒流也是可行的。

发生这种情况是因为 SimpleDateFormat 参数中的 Y 字符代表基于周数的年份。简而言之,当一周至少包含新年的四天时,该一周被视为第一周。所以,如果我们的一周从周日开始,我们就可以提前三天进入新的一年。

要修复此问题,只需将大写 Y 替换为小写 y 即可。想了解更多吗?我们专门写了一整篇文章来讨论这个主题。

这是针对此错误的 PVS-Studio 警告:

V6122 检测到使用“Y”(周年)模式:它可能打算使用“y”(年)。 SkeinParameters.java 246

由于周数的具体情况,因此测试并不是发现此错误的最佳方法。那么为什么这样一个话题性的错误会出现在最后呢?原因是该警告不是来自 Bouncy Castle 的实际版本,而是来自我们的测试基地。旧的资源仍然存在,并且这个错误已经修复了很长时间。这是来自过去的致敬,又是一次时光旅行:)

第9名。 “看来不行”

第九位,我们收到来自 GeoServer 分析的警告:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // ....
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);              // <=
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

这是 PVS-Studio 警告:

V6027 变量“newValue”、“oldValue”通过调用同一函数进行初始化。这可能是一个错误或未优化的代码。 DataAccessRuleDAOTest.java 110、DataAccessRuleDAOTest.java 111

这样的错误有什么有趣的?让我来揭示这四个点背后隐藏的是什么:

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

有评论称该代码由于某种原因无法运行。说实话,我第一次看到的时候就笑了。

不过,这个评论相当含糊。测试很可能是故意以这种方式编写的,以防止在比较失败时出现故障。然而,该代码已经处于这种状态十多年了,这引发了一些问题。这种模糊性就是我没有将其排名更高的原因。

第8名。脚中弹

如果我们不能将 JBullet 中的 bug 称为“搬起石头砸自己的脚”,我不知道哪些可以这样称呼。这是文章中的一个错误:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // ....
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);              // <=
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

我认为我们甚至不需要 PVS-Studio 警告来发现错误所在。无论如何,以防万一,这里是:

V6026 该值已分配给“proxy1”变量。 HashedOverlappingPairCache.java 233

是的,这是一个令人尴尬的简单错误。不过,这种简单性让它变得更加搞笑。尽管如此,它还是有自己的故事。

JBullet 库是 C/C 子弹库的移植,那里有类似的功能:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // properties equality does not seem to work...
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制

很容易看出这段代码写得正确。从 gitblame 来看,原来写的是正确的。原来是代码从一种语言移植到另一种语言时出现了错误。

由于其惊人的朴实加上丰富的历史,我将这个警告评为第八名。我希望你喜欢这个搬起石头砸自己脚的 bug 原来是与 C 语言相关的。

第七名。即使是伟大的数学家也会犯错误

诚然,下一个警告出于多种原因温暖了我的心。以下是 GeoGebra 检查的代码片段:

@Override
public BroadphasePair findPair(BroadphaseProxy proxy0, BroadphaseProxy proxy1) {
  BulletStats.gFindPairs++;
  if (proxy0.getUid() > proxy1.getUid()) {
    BroadphaseProxy tmp = proxy0;
    proxy0 = proxy1;
    proxy1 = proxy0;
  }
  ....
}
登录后复制
登录后复制
登录后复制

尝试自己找出错误!为了不让你们偷看,我把警告和解释隐藏在剧透里了。

答案
首先,我们看一下PVS-Studio的警告:

V6107 正在使用常量 0.7071067811865。结果值可能不准确。考虑使用 Math.sqrt(0.5)。 DrawAngle.java 303

事实上,0.7071067811865 并不是什么神奇的数字——它只是 0.5 平方根的四舍五入结果。但这种精度损失有多严重呢? GeoGebra 是一款为数学家量身定制的软件,额外的精度似乎并没有什么坏处。

为什么我这么喜欢这个bug?

首先,在会议上,我经常要求与会者在其他代码片段中找到类似的错误。当错误隐藏在常量中时,看着他们仔细分析代码总是很有趣。

其次,这是我为 Java 分析器实现的第一个诊断规则。这就是为什么我无法抗拒将它放在顶部的原因——即使意识到了偏见——我把它放在第七位:)

第六名。这个模式不起作用

以下警告是我从基于 DBeaver 检查的第一篇文章中获取的,可能不会立即引起我们的注意。这是一个代码片段:

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

以下是 PVS-Studio 分析器检测到的内容:

V6082 不安全的双重检查锁定。该字段应声明为易失性的。 TaskImpl.java 59、TaskImpl.java317

虽然这个特定的警告没有什么特别的,但我仍然觉得它非常有趣。关键是所应用的双重检查锁定模式不起作用。有什么窍门呢?这在 20 年前是相关的:)

如果您想了解有关该主题的更多信息,我建议您阅读全文。但现在,让我给您一个快速总结。

双重检查锁定模式用于在多线程环境中实现延迟初始化。在“重量级”检查之前,会在没有同步块的情况下执行“轻量级”检查。仅当两项检查都通过时才会创建资源。

但是,在这种方法中,对象创建是非原子,并且处理器和编译器可以更改操作顺序。因此,另一个线程可能会意外收到部分创建的对象并开始使用它,这可能会导致不正确的行为。这个错误可能很少发生,因此调试对于开发人员来说将是一个巨大的挑战。

这里有一个变化:这种模式直到 JDK 5 才起作用。从 JDK 5 开始,由于 happens-before 原则,引入了 volatile 关键字来解决重新排序操作的潜在问题。分析器警告我们应该添加此关键字。

但是,无论如何,最好避免这种模式。从那时起,硬件和 JVM 性能已经取得了长足的进步,并且同步操作不再那么慢了。然而,不正确地实现 DCL 模式仍然是一个常见的陷阱,可能会产生上述的严重后果。这证实了我们的分析器在旧项目中仍然发现此类疏忽错误的事实。

第5名。微观优化

第五名是另一个 DBeaver 警告,我们专门写了一篇文章。我们来看看:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // ....
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);              // <=
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

这里有一个解释:

V6030 无论左侧操作数的值如何,都会调用“&”运算符右侧的方法。也许,最好使用“&&”。 ExasolTableColumnManager.java 79、DB2TableColumnManager.java 77

开发人员将逻辑 && 与按位 & 混淆了。它们具有不同的行为:表达式中的条件在按位 AND 之后不会终止。 短路求值 不适用于按位 AND。因此,即使 exasolTableBase != null 将返回 false,执行线程也会到达 exasolTableBase.getClass() 并导致 NPE。

好吧,这只是一个错字,让我们继续吧,好吗? DBeaver 有很多这样的警告。很多。许多都是相对无害的,但对于好奇的读者,我在下面留下了一些例子:

使用不会导致错误的按位运算
ExasolSecurityPolicy.java:
public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

ExasolConnectionManager.java:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // ....
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);              // <=
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

ExasolDataSource.java:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // properties equality does not seem to work...
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制

深入挖掘后,我的团队假设开发人员可能一直在尝试对性能进行微观优化。想了解完整情况,你可以看看我们的文章——这里我总结一下。

关键点是按位运算不依赖于分支预测,与逻辑运算相比,可能允许更快的执行。

令我们惊讶的是,一个本土基准测试支持了这一说法:

Top most intriguing Java errors in 4

图表说明了每种操作类型所需的时间。如果我们相信它,按位运算似乎比逻辑运算快 40%。

我为什么要提出这个话题?强调潜在微观优化的成本。

首先,开发人员发明分支预测是有原因的——放弃它的成本太高。因此,基准测试可能运行得更快,因为值具有正态分布,而在实际情况下不太可能观察到。

第二,放弃短路评估机制会导致成本大幅上升。如果我们看一下上面剧透中的第三个示例,我们可以看到最快的 contains 操作并不是一直执行而不是立即停止。

第三,我们从本章开始就全权处理此类错误。

总体而言,我发现微优化价格的警示故事足以进入我们的前五名。

第四名。不行的话测试也不会落下

自动化测试通常被认为是防止各种错误的最终保障。然而,时不时地,我很想问:“谁自己测试这些测试?”来自 GeoServer 检查的另一个警告再次证明了这一点。这是一个代码片段:

@Override
public BroadphasePair findPair(BroadphaseProxy proxy0, BroadphaseProxy proxy1) {
  BulletStats.gFindPairs++;
  if (proxy0.getUid() > proxy1.getUid()) {
    BroadphaseProxy tmp = proxy0;
    proxy0 = proxy1;
    proxy1 = proxy0;
  }
  ....
}
登录后复制
登录后复制
登录后复制

PVS-Studio 警告:

V6060 在验证“e”引用是否为 null 之前,已使用该引用。 ResourceAccessManagerWCSTest.java 186、ResourceAccessManagerWCSTest.java 193

乍一看,这个警告似乎不是分析器最令人兴奋的警告,因为 V6060 经常是针对冗余代码发出的。然而,我承诺我会根据他们的吸引力来选择提名。所以,这个案例远比看上去有趣。

最初,测试逻辑可能看起来是错误的,因为 e 变量是从 catch 运算符获取的,并且进一步保持不变,因此它永远不会为 null。我们可以进行杂散编辑,并将 if(e == nul) 条件的 then 分支删除为无法到达。然而,那是完全错误的。你找到窍门了吗?

关键在于包含异常对象的代码中多了一个变量,它是一个se。它的值会在循环体内发生变化。所以,我们很容易猜测,条件中应该有se变量,而不是e。

这个错误会导致then分支永远不会被执行,所以我们不知道有没有异常。更糟糕的是,在代码审查中很难注意到这样的错误,因为变量名称非常相似。

从这个故事中可以汲取两个智慧:

  1. 清楚地命名变量,即使在测试中也是如此。不然更容易犯这样的错误;
  2. 测试不足以保证项目质量,因为它们也可能包含错误。因此,它会在应用程序内留下漏洞。

由于提供了如此宝贵的课程,我将此警告授予第四名。

第三名。祝各位调试愉快

前三名获胜者属于 NetBeans 检查的警告。之前的代码片段比较紧凑,我们看一下比较长的代码片段:

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

最后一次,尝试自己找到错误——我会等...

Top most intriguing Java errors in 4

正在寻找?

不错!那些仅在表达式 iDesc.neighbor != null || 中发现错误的人iDesc.index == iDesc.index,很遗憾,你输了:)

当然,有一个问题,但对于排名第一的问题来说还不够有趣。是的,这里有两个错误,我欺骗了你一点。但是没有一点恶作剧的假期怎么算呢? :)

分析器检测到此处的 i^i 表达式存在错误,并发出以下警告:

V6001 在“^”运算符的左侧和右侧有相同的子表达式“i”。 LayoutFeeder.java 3897

异或运算没有任何意义,因为两个相同值的异或将始终为零。为了快速回顾一下,这里是 XOR 的真值表:

a b a^b
0 0 0
0 1 1
1 0 1
1 1 0

换句话说,只有当操作数不同时,运算才为真。我们将拥有相同的所有位,因为值是相同的。

为什么我这么喜欢这个bug?有 i^1 操作,看起来与 i^i 几乎相同。因此,在代码审查中很容易错过这个错误,因为我们已经在上面看到了正确的 i^1。

我不了解你,但这让我想起了著名的:

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

否则,很难解释它是如何进入代码的——除非我们用一个简单的拼写错误来忽略这个无聊的版本。如果您确实发现了该错误,请拍拍自己的背,或者在评论中分享您的侦探技巧:)

第二名。当模式失败时

我已经显示了第一篇和第三篇 DBeaver 文章中的错误,跳过第二篇文章。我纠正了——以下内容仅来自第二篇文章。

PVS-Studio 分析器不喜欢从 TextWithOpen 类的构造函数调用 isBinaryContents,该类在子类中被重写:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // ....
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);              // <=
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

那又怎样?它被覆盖了——不过,没什么大不了的。这看起来像是代码味道,没什么关键的。至少,我以前是这么认为的。我专门写了一篇文章来阐述我与这个错误的斗争。

TextWithOpen 有很多子类,其中之一就是 TextWithOpenFile。在那里,该方法实际上被重写,它返回一个超类没有的字段,而不是 false:

@Test
public void testStore() {
  Properties newProps = dao.toProperties();

  // properties equality does not seem to work...
  Assert.assertEquals(newProps.size(), props.size());
  for (Object key : newProps.keySet()) {
    Object newValue = newProps.get(key);
    Object oldValue = newProps.get(key);
    Assert.assertEquals(newValue, oldValue);
  }
}
登录后复制
登录后复制
登录后复制

还有疑问吗?这个类的构造函数是什么样的?

@Override
public BroadphasePair findPair(BroadphaseProxy proxy0, BroadphaseProxy proxy1) {
  BulletStats.gFindPairs++;
  if (proxy0.getUid() > proxy1.getUid()) {
    BroadphaseProxy tmp = proxy0;
    proxy0 = proxy1;
    proxy1 = proxy0;
  }
  ....
}
登录后复制
登录后复制
登录后复制

注意到了吗?调用超类构造函数后,将初始化二进制字段。然而,有一个对 isBinaryContents 方法的调用,它引用了子类字段!

Top most intriguing Java errors in 4

这是 PVS-Studio 警告:

V6052 在“TextWithOpen”父类构造函数中调用重写的“isBinaryContents”方法可能会导致使用未初始化的数据。检查字段:二进制。 TextWithOpenFile.java(77), TextWithOpen.java 59

这是一张相当有趣的图片。乍一看,开发人员似乎遵循了最佳实践:避免无法维护的意大利面条式代码,并尝试通过模板方法模式实现规范的 OOP。但是,即使在实现这样一个简单的模式时,我们也可能会犯错误,这就是所发生的情况。在我看来,这种(错误的)简单之美是稳居第二的。

第一名。一个错误抵消了另一个错误

高兴吧!舞台第一名!竞争很激烈,但必须做出选择。经过深思熟虑,我决定接受 NetBeans 检查中的警告。让我介绍一下最终的代码片段:

public Builder setPersonalisation(Date date, ....) {
  ....
  final OutputStreamWriter
    out = new OutputStreamWriter(bout, "UTF-8");
  final DateFormat
    format = new SimpleDateFormat("YYYYMMdd");
  out.write(format.format(date));
    ....
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

我确信不可能一眼就能发现这样的错误——当然,除非你自己犯过这个错误。我不会让您久等的——这是 PVS-Studio 警告:

V6009 缓冲区容量使用字符值设置为“47”。最有可能的是,“/”符号应该放置在缓冲区中。忽略UnignoreCommand.java 107

事实上,这个错误非常简单:StringBuilder 构造函数没有接受 char 的重载。那么调用什么构造函数呢?开发者显然认为会调用一个接受 String 的重载,然后 StringBuilder 的初始值就是这个斜​​杠。

但是,会发生隐式类型转换,并调用接受 int 的类型构造函数。在我们的例子中,它代表 StringBuilder 的初始大小。将 char 作为参数传递不会在功能上影响任何内容,因为它不会包含在最终字符串中。如果超出初始大小,它只会自行增加,不会导致异常或其他副作用。

但是等等,我提到了两个错误,不是吗?第二个在哪里,它们是如何连接的?为了发现这一点,我们必须读入方法体并了解这段代码的作用。

它生成文件或目录的绝对路径。根据代码,生成的路径应如下所示:

  • 对于文件:/folder1/file
  • 对于目录:/folder1/folder/.

代码看起来非常正确。这就是问题所在。代码确实可以正常工作:)但是如果我们通过用字符串替换字符来修复错误,我们将得到这个而不是正确的结果:

  • /文件夹1/文件/;
  • /文件夹1/文件夹//

换句话说,我们会在字符串末尾得到一个额外的斜杠。它将位于末尾,因为上面的代码每次都会将新文本添加到行的开头。

因此,第二个错误是这个斜杠根本作为参数传递给构造函数。但是,我不会低估这样的错误,因为如果有人决定在不检查的情况下用字符串替换字符,可能会出现问题。

这就是错误顶部的第一个位置转到正确工作的代码的方式。新年奇迹,你期待什么? :)

结论

我希望您喜欢阅读我的错误故事。如果您有任何特别的故事让您印象深刻,或者您有调整排名的建议,请随时在评论中分享您的想法,我会在下次记住它们:)

如果您对其他语言感兴趣,我邀请您在此处查看 2024 年最热门的 C# 错误 - 请继续关注新的热门错误!

所有这些错误都是用PVS-Studio分析器检测到的,最新版本(7.34)刚刚发布!您可以通过此链接尝试一下。

要继续关注有关代码质量的新文章,我们邀请您订阅:

  • PVS-Studio X(推特);
  • 我们的每月文章摘要;

新年快乐!

以上是4 中最有趣的 Java 错误的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板