您有没有想过跨国公司的项目源代码中可能潜藏着哪些错误?不要错过在开源 Apache Kafka 项目中发现 PVS-Studio 静态分析器检测到的有趣错误的机会。
Apache Kafka 是一个著名的开源项目,主要用 Java 编写。 LinkedIn 于 2011 年将其开发为消息代理,即各种系统组件的数据管道。如今,它已成为同类产品中最受欢迎的解决方案之一。
准备好看看引擎盖下的内容了吗?
附注
只是想简单说明一下标题。它参考了弗朗茨·卡夫卡的《变形记》,其中主角变成了可怕的害虫。我们的静态分析器致力于防止您的项目变身为可怕的害虫转变为一个巨大的错误,所以对“变形记”说不。
这不是我的话;这句话出自理查德·普赖尔之口。但这有什么关系呢?我想告诉你的第一件事是一个愚蠢的错误。然而,在多次尝试理解程序无法正常运行的原因后,遇到如下示例的情况令人沮丧:
@Override public KeyValueIterator<Windowed<K>, V> backwardFetch( K keyFrom, K keyTo, Instant timeFrom, Instant timeTo) { .... if (keyFrom == null && keyFrom == null) { // <= kvSubMap = kvMap; } else if (keyFrom == null) { kvSubMap = kvMap.headMap(keyTo, true); } else if (keyTo == null) { kvSubMap = kvMap.tailMap(keyFrom, true); } else { // keyFrom != null and KeyTo != null kvSubMap = kvMap.subMap(keyFrom, true, keyTo, true); } .... }
如您所见,这是任何开发人员都无法避免的事情——一个微不足道的拼写错误。在第一个条件下,开发人员希望使用以下逻辑表达式:
keyFrom == null && keyTo == null
分析器发出两个警告:
V6001 在“&&”运算符的左侧和右侧有相同的子表达式“keyFrom == null”。 ReadOnlyWindowStoreStub.java 327、ReadOnlyWindowStoreStub.java 327
V6007 表达式“keyFrom == null”始终为 false。 ReadOnlyWindowStoreStub.java 329
我们可以明白为什么。对于每个开发人员来说,这种可笑的打字错误都是永恒的。虽然我们可以花很多时间寻找它们,但要回忆起它们潜伏的地方可不是小菜一碟。
在同一个类中,另一个方法中存在完全相同的错误。我认为称其为复制面食是公平的。
@Override public KeyValueIterator<Windowed<K>, V> fetch( K keyFrom, K keyTo, Instant timeFrom, Instant timeTo) { .... NavigableMap<K, V> kvMap = data.get(now); if (kvMap != null) { NavigableMap<K, V> kvSubMap; if (keyFrom == null && keyFrom == null) { // <= kvSubMap = kvMap; } else if (keyFrom == null) { kvSubMap = kvMap.headMap(keyTo, true); } else if (keyTo == null) { kvSubMap = kvMap.tailMap(keyFrom, true); } else { // keyFrom != null and KeyTo != null kvSubMap = kvMap.subMap(keyFrom, true, keyTo, true); } } .... }
以下是相同的警告:
V6007 表达式“keyFrom == null”始终为 false。 ReadOnlyWindowStoreStub.java 273
V6001 在“&&”运算符的左侧和右侧有相同的子表达式“keyFrom == null”。 ReadOnlyWindowStoreStub.java 271, ReadOnlyWindowStoreStub.java 271
不用担心——我们不必一次查看数百行代码。 PVS-Studio 非常擅长处理此类简单的事情。解决一些更具挑战性的事情怎么样?
Java 中 synchronized 关键字的用途是什么?在这里,我将只关注同步方法,而不是块。根据 Oracle 文档,synchronized 关键字将方法声明为同步,以确保与实例的线程安全交互。如果一个线程调用该实例的同步方法,则尝试调用同一实例的同步方法的其他线程将被阻塞(即它们的执行将被挂起)。它们将被阻塞,直到第一个线程调用的方法处理其执行。当实例对多个线程可见时,需要执行此操作。此类实例的读/写操作只能通过同步方法执行。
开发人员违反了 Sensor 类中的规则,如下面的简化代码片段所示。对实例字段的读/写操作可以通过同步和非同步两种方式执行。它可能会导致竞争条件并使输出不可预测。
private final Map<MetricName, KafkaMetric> metrics; public void checkQuotas(long timeMs) { // <= for (KafkaMetric metric : this.metrics.values()) { MetricConfig config = metric.config(); if (config != null) { .... } } .... } public synchronized boolean add(CompoundStat stat, // <= MetricConfig config) { .... if (!metrics.containsKey(metric.metricName())) { metrics.put(metric.metricName(), metric); } .... } public synchronized boolean add(MetricName metricName, // <= MeasurableStat stat, MetricConfig config) { if (hasExpired()) { return false; } else if (metrics.containsKey(metricName)) { return true; } else { .... metrics.put(metric.metricName(), metric); return true; } }
分析器警告如下所示:
V6102 “metrics”字段同步不一致。考虑在所有用途上同步该字段。传感器.java 49,传感器.java 254
如果不同的线程可以同时更改实例状态,则允许此操作的方法应该同步。如果程序没有预料到多个线程可以与实例交互,则使其方法同步是没有意义的。最坏的情况下,甚至会损害程序性能。
程序中有很多这样的错误。这是分析器发出警告的类似代码片段:
private final PrefixKeyFormatter prefixKeyFormatter; @Override public synchronized void destroy() { // <= .... Bytes keyPrefix = prefixKeyFormatter.getPrefix(); .... } @Override public void addToBatch(....) { // <= physicalStore.addToBatch( new KeyValue<>( prefixKeyFormatter.addPrefix(record.key), record.value ), batch ); } @Override public synchronized void deleteRange(....) { // <= physicalStore.deleteRange( prefixKeyFormatter.addPrefix(keyFrom), prefixKeyFormatter.addPrefix(keyTo) ); } @Override public synchronized void put(....) { // <= physicalStore.put( prefixKeyFormatter.addPrefix(key), value ); }
分析器警告:
V6102 “prefixKeyFormatter”字段同步不一致。考虑在所有用途上同步该字段。 LogicalKeyValueSegment.java 60、LogicalKeyValueSegment.java 247
In the example, there are two rather unpleasant errors within one line at once. I'll explain their nature within the part of the article. Here's a code snippet:
private final Map<String, Uuid> topicIds = new HashMap(); private Map<String, KafkaFutureVoid> handleDeleteTopicsUsingNames(....) { .... Collection<String> topicNames = new ArrayList<>(topicNameCollection); for (final String topicName : topicNames) { KafkaFutureImpl<Void> future = new KafkaFutureImpl<>(); if (allTopics.remove(topicName) == null) { .... } else { topicNames.remove(topicIds.remove(topicName)); // <= future.complete(null); } .... } }
That's what the analyzer shows us:
V6066 The type of object passed as argument is incompatible with the type of collection: String, Uuid. MockAdminClient.java 569
V6053 The 'topicNames' collection of 'ArrayList' type is modified while iteration is in progress. ConcurrentModificationException may occur. MockAdminClient.java 569
Now that's a big dilemma! What's going on here, and how should we address it?!
First, let's talk about collections and generics. Using the generic types of collections helps us avoid ClassCastExceptions and cumbersome constructs where we convert types.
If we specify a certain data type when initializing a collection and add an incompatible type, the compiler won't compile the code.
Here's an example:
public class Test { public static void main(String[] args) { Set<String> set = new HashSet<>(); set.add("str"); set.add(UUID.randomUUID()); // java.util.UUID cannot be converted to // java.lang.String } }
However, if we delete an incompatible type from our Set, no exception will be thrown. The method returns false.
Here's an example:
public class Test { public static void main(String[] args) { Set<String> set = new HashSet<>(); set.add("abc"); set.add("def"); System.out.println(set.remove(new Integer(13))); // false } }
It's a waste of time. Most likely, if we encounter something like this in the code, this is an error. I suggest you go back to the code at the beginning of this subchapter and try to spot a similar case.
Second, let's talk about the Iterator. We can talk about iterating through collections for a long time. I don't want to bore you or digress from the main topic, so I'll just cover the key points to ensure we understand why we get the warning.
So, how do we iterate through the collection here? Here is what the for loop in the code fragment looks like:
for (Type collectionElem : collection) { .... }
The for loop entry is just syntactic sugar. The construction is equivalent to this one:
for (Iterator<Type> iter = collection.iterator(); iter.hasNext();) { Type collectionElem = iter.next(); .... }
We're basically working with the collection iterator. All right, that's sorted! Now, let's discuss ConcurrentModificationException.
ConcurrentModificationException is an exception that covers a range of situations both in single-threaded and multi-threaded programs. Here, we're focusing on single-threading. We can find an explanation quite easily. Let's take a peek at the Oracle docs: a method can throw the exception when it detects parallel modification of an object that doesn't support it. In our case, while the iterator is running, we delete objects from the collection. This may cause the iterator to throw a ConcurrentModificationException.
How does the iterator know when to throw the exception? If we look at the ArrayList collection, we see that its parent, AbstactList, has the modCount field that stores the number of modifications to the collection:
protected transient int modCount = 0;
Here are some usages of the modCount counter in the ArrayList class:
public boolean add(E e) { modCount++; add(e, elementData, size); return true; } private void fastRemove(Object[] es, int i) { modCount++; final int newSize; if ((newSize = size - 1) > i) System.arraycopy(es, i + 1, es, i, newSize - i); es[size = newSize] = null; }
So, the counter is incremented each time when the collection is modified.
Btw, the fastRemove method is used in the remove method, which we use inside the loop.
Here's the small code fragment of the ArrayList iterator inner workings:
private class Itr implements Iterator<E> { .... int expectedModCount = modCount; final void checkForComodification() { if (modCount != expectedModCount) // <= throw new ConcurrentModificationException(); } public E next() { checkForComodification(); .... } public void remove() { .... checkForComodification(); try { ArrayList.this.remove(lastRet); .... expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } .... public void add(E e) { checkForComodification(); try { .... ArrayList.this.add(i, e); .... expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } }
Let me explain that last fragment. If the collection modifications don't match the expected number of modifications (which is the sum of the initial modifications before the iterator was created and the number of the iterator operations), a ConcurrentModificationException is thrown. That's only possible when we modify the collection using its methods while iterating over it (i.e. in parallel with the iterator). That's what the second warning is about.
So, I've explained you the analyzer messages. Now let's put it all together:
We attempt to delete an element from the collection when the Iterator is still running:
topicNames.remove(topicIds.remove(topicName)); // topicsNames – Collection<String> // topicsIds – Map<String, UUID>
However, since the incompatible element is passed to ArrayList for deletion (the remove method returns a UUID object from topicIds), the modification count won't increase, but the object won't be deleted. Simply put, that code section is rudimentary.
I'd venture to guess that the developer's intent is clear. If that's the case, one way to fix these two warnings could be as follows:
Collection<String> topicNames = new ArrayList<>(topicNameCollection); List<String> removableItems = new ArrayList<>(); for (final String topicName : topicNames) { KafkaFutureImpl<Void> future = new KafkaFutureImpl<>(); if (allTopics.remove(topicName) == null) { .... } else { topicIds.remove(topicName); removableItems.add(topicName); future.complete(null); } .... } topicNames.removeAll(removableItems);
Where would we go without our all-time favorite null and its potential problems, right? Let me show you the code fragment for which the analyzer issued the following warning:
V6008 Potential null dereference of 'oldMember' in function 'removeStaticMember'. ConsumerGroup.java 311, ConsumerGroup.java 323
@Override public void removeMember(String memberId) { ConsumerGroupMember oldMember = members.remove(memberId); .... removeStaticMember(oldMember); .... } private void removeStaticMember(ConsumerGroupMember oldMember) { if (oldMember.instanceId() != null) { staticMembers.remove(oldMember.instanceId()); } }
If members doesn't contain an object with the memberId key, oldMember will be null. It can lead to a NullPointerException in the removeStaticMember method.
Boom! The parameter is checked for null:
if (oldMember != null && oldMember.instanceId() != null) {
The next error will be the last one in the article—I'd like to wrap things up on a positive note. The code below—as well as the one at the beginning of this article—has a common and silly typo. However, it can certainly lead to unpleasant consequences.
Let's take a look at this code fragment:
protected SchemaAndValue roundTrip(...., SchemaAndValue input) { String serialized = Values.convertToString(input.schema(), input.value()); if (input != null && input.value() != null) { .... } .... }
Yeah, that's right. The method actually accesses the input object first, and then checks whether it's referencing null.
V6060 The 'input' reference was utilized before it was verified against null. ValuesTest.java 1212, ValuesTest.java 1213
Again, I'll note that such typos are ok. However, they can lead to some pretty nasty results. It's tough and inefficient to search for these things in the code manually.
In sum, I'd like to circle back to the previous point. Manually searching through the code for all these errors is a very time-consuming and tedious task. It's not unusual for issues like the ones I've shown to lurk in code for a long time. The last bug dates back to 2018. That's why it's a good idea to use static analysis tools. If you'd like to know more about PVS-Studio, the tool we have used to detect all those errors, you can find out more here.
That's all. Let's wrap things up here. "Oh, and in case I don't see ya, good afternoon, good evening, and good night."
I almost forgot! Catch a link to learn more about a free license for open-source projects.
The above is the detailed content of Belay the Metamorphosis: analyzing Kafka project. For more information, please follow other related articles on the PHP Chinese website!