2024 年,我们分析了大量项目,并在博客上分享我们的发现。现在是除夕夜——是时候讲喜庆故事了!我们收集了在开源项目中检测到的最有趣的 Java 错误,现在将它们带给您!
我们长期以来一直秉承发布 PVS-Studio 检测到的最有趣的 bug 的传统,但自 2020 年以来就没有出现与 Java 相关的置顶!这一次,我尝试复兴复古风格。我希望您手边有一条舒适的毯子和一杯热茶,因为我专门为您挑选了 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 的实际版本,而是来自我们的测试基地。旧的资源仍然存在,并且这个错误已经修复了很长时间。这是来自过去的致敬,又是一次时光旅行:)
第九位,我们收到来自 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)); .... }
有评论称该代码由于某种原因无法运行。说实话,我第一次看到的时候就笑了。
不过,这个评论相当含糊。测试很可能是故意以这种方式编写的,以防止在比较失败时出现故障。然而,该代码已经处于这种状态十多年了,这引发了一些问题。这种模糊性就是我没有将其排名更高的原因。
如果我们不能将 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; } .... }
尝试自己找出错误!为了不让你们偷看,我把警告和解释隐藏在剧透里了。
V6107 正在使用常量 0.7071067811865。结果值可能不准确。考虑使用 Math.sqrt(0.5)。 DrawAngle.java 303 事实上,0.7071067811865 并不是什么神奇的数字——它只是 0.5 平方根的四舍五入结果。但这种精度损失有多严重呢? GeoGebra 是一款为数学家量身定制的软件,额外的精度似乎并没有什么坏处。 为什么我这么喜欢这个bug? 首先,在会议上,我经常要求与会者在其他代码片段中找到类似的错误。当错误隐藏在常量中时,看着他们仔细分析代码总是很有趣。 其次,这是我为 Java 分析器实现的第一个诊断规则。这就是为什么我无法抗拒将它放在顶部的原因——即使意识到了偏见——我把它放在第七位:)答案
首先,我们看一下PVS-Studio的警告:
以下警告是我从基于 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 模式仍然是一个常见的陷阱,可能会产生上述的严重后果。这证实了我们的分析器在旧项目中仍然发现此类疏忽错误的事实。
第五名是另一个 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 有很多这样的警告。很多。许多都是相对无害的,但对于好奇的读者,我在下面留下了一些例子:
ExasolConnectionManager.java: ExasolDataSource.java:使用不会导致错误的按位运算
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));
....
}
@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);
}
}
@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);
}
}
深入挖掘后,我的团队假设开发人员可能一直在尝试对性能进行微观优化。想了解完整情况,你可以看看我们的文章——这里我总结一下。
关键点是按位运算不依赖于分支预测,与逻辑运算相比,可能允许更快的执行。
令我们惊讶的是,一个本土基准测试支持了这一说法:
图表说明了每种操作类型所需的时间。如果我们相信它,按位运算似乎比逻辑运算快 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分支永远不会被执行,所以我们不知道有没有异常。更糟糕的是,在代码审查中很难注意到这样的错误,因为变量名称非常相似。
从这个故事中可以汲取两个智慧:
由于提供了如此宝贵的课程,我将此警告授予第四名。
前三名获胜者属于 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)); .... }
最后一次,尝试自己找到错误——我会等...
正在寻找?
不错!那些仅在表达式 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 方法的调用,它引用了子类字段!
这是 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 作为参数传递不会在功能上影响任何内容,因为它不会包含在最终字符串中。如果超出初始大小,它只会自行增加,不会导致异常或其他副作用。
但是等等,我提到了两个错误,不是吗?第二个在哪里,它们是如何连接的?为了发现这一点,我们必须读入方法体并了解这段代码的作用。
它生成文件或目录的绝对路径。根据代码,生成的路径应如下所示:
代码看起来非常正确。这就是问题所在。代码确实可以正常工作:)但是如果我们通过用字符串替换字符来修复错误,我们将得到这个而不是正确的结果:
换句话说,我们会在字符串末尾得到一个额外的斜杠。它将位于末尾,因为上面的代码每次都会将新文本添加到行的开头。
因此,第二个错误是这个斜杠根本作为参数传递给构造函数。但是,我不会低估这样的错误,因为如果有人决定在不检查的情况下用字符串替换字符,可能会出现问题。
这就是错误顶部的第一个位置转到正确工作的代码的方式。新年奇迹,你期待什么? :)
我希望您喜欢阅读我的错误故事。如果您有任何特别的故事让您印象深刻,或者您有调整排名的建议,请随时在评论中分享您的想法,我会在下次记住它们:)
如果您对其他语言感兴趣,我邀请您在此处查看 2024 年最热门的 C# 错误 - 请继续关注新的热门错误!
所有这些错误都是用PVS-Studio分析器检测到的,最新版本(7.34)刚刚发布!您可以通过此链接尝试一下。
要继续关注有关代码质量的新文章,我们邀请您订阅:
新年快乐!
以上是4 中最有趣的 Java 错误的详细内容。更多信息请关注PHP中文网其他相关文章!