首先请大家花点时间阅读以下的代码块,看看代码是否存在问题或者隐患。
PostTask.java
public interface PostTask { void process(); }
BaseResult.java
public interface BaseResult extends Serializable { List<PostTask> postTaskList = Lists.newArrayList(); default void addPostTask(PostTask postTask) { postTaskList.add(postTask); } default List<PostTask> getPostTaskList() { return postTaskList; } }
SimpleResult.java
public class SimpleResult implements BaseResult { }
// 请求处理的一部分逻辑 SimpleResult result = new SimpleResult(); ... // 处理过程中,会往后置任务列表加入任务 result.addPostTask(() -> { ...发消息... }); ... // 在返回结果之前,会对所有的后置任务进行遍历执行后置任务 PostTaskUtil.process(result.PostTaskList()); ...
PostTaskUtil.java
public class PostTaskUtil { public static void process(List<PostTask> postTasks) { if(CollectionUtils.isEmpty(postTasks)){ return; } Iterator<PostTask> iterator = postTasks.iterator(); while (iterator.hasNext()){ PostTask postTask = iterator.next(); if (postTask == null) { return; } postTask.process(); iterator.remove(); } } }
如果你已经发现了所有的问题和隐患,那么恭喜你,知识掌握非常扎实。下面让我们一步一步来探究👆🏻代码的问题和隐患,顺便复习一下基本知识😄。
接口的属性是 public static final 修饰的
让我们来回顾下接口属性的知识点,以下内容出自 Oracle Java 教程。
In addition, an interface can contain constant declarations. All constant values defined in an interface are implicitly public, static, and final. Once again, you can omit these modifiers.
另外,接口可以包含常量声明。接口中定义的所有常量值默认为 public 、 static 、 final 。再次说明,你可以省略这些修饰符。
上面出问题的代码在于接口 BaseResult 的属性 postTaskList,因为是接口的属性,那这个属性默认是静态的,也就是说所有实例化的 SimpleResult 所操作的后置任务列表,底层都是同一个队列,这个就是最大的问题了。
由于有问题代码的请求量很少,且后置任务是发送消息,上面的代码问题,虽导致发送了很多重复的消息,但幸好下游消费方都对消息做了幂等的操作,所以暂无情况发生,后续就赶紧解决了这个问题。
二、问题分析
笔者的情况是幸运的,实际在并发量大的场景,上述问题是很严重的,让我们来一一分析下。
- 后置任务列表的元素不确定性,可能包含历史或者其他请求任务;
- 存在并发修改异常 java.util.ConcurrentModificationException;
笔者提供了一个测试的代码,感兴趣的同学可以去运行一下(笔者使用 jdk 22 运行的)。采用最新的特性:虚拟线程和字符串模版,还能学习一下新的知识点,温故而知新😄。
测试代码
import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; public class InterfaceBugTest { public static void main(String[] args) throws InterruptedException { List<CompletableFuture<Boolean>> futures = new ArrayList<>(); for (int i = 0; i < 10; i++) { int finalI = i; CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> { Test test = new Test(); test.add(finalI); System.out.println(STR."\{finalI}: \{test.list.toString()}"); return true; }, Executors.newVirtualThreadPerTaskExecutor()); futures.add(future); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); System.out.println(ITest.list); } static class Test implements ITest { } interface ITest { List<Integer> list = new ArrayList<>(); default void add(Integer num) { list.add(num); } } }
通过 IDEA jclasslib Bytecode Viewer 插件查看字节码,可以看到接口的属性都是 public static final 修饰的。
👇🏻是运行的结果,100% 是会报 java.util.ConcurrentModificationException 错的。
6: [0, 9, 6] 1: [0, 9, 6, 7, 8, 4, 5, 3, 2, 1] 9: [0, 9, 6] 7: [0, 9, 6, 7] 2: [0, 9, 6, 7, 8, 4, 5, 3, 2, 1] 3: [0, 9, 6, 7, 8, 4, 5, 3] Exception in thread "main" java.util.concurrent.CompletionException: java.util.ConcurrentModificationException at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315) at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320) at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770) at java.base/java.util.concurrent.ThreadPerTaskExecutor$TaskRunner.run(ThreadPerTaskExecutor.java:314) at java.base/java.lang.VirtualThread.run(VirtualThread.java:329) Caused by: java.util.ConcurrentModificationException at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096) at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050) at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:458) at com.zh.next.test.InterfaceBugTest.lambda$main$0(InterfaceBugTest.java:16) at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1768)
这个很符合 ArrayList 线程不安全的特性,如果将 ArrayList 替换成 CopyOnWriteArrayList 就可以解决并发修改问题。
The iterators returned by this class's iterator and listIterator methods are fail-fast: if the list is structurally modified at any time after the iterator is created, in any way except through the iterator's own remove or add methods, the iterator will throw a ConcurrentModificationException. Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.
Note that the fail-fast behavior of an iterator cannot be guaranteed as it is, generally speaking, impossible to make any hard guarantees in the presence of unsynchronized concurrent modification. Fail-fast iterators throw ConcurrentModificationException on a best-effort basis. Therefore, it would be wrong to write a program that depended on this exception for its correctness: the fail-fast behavior of iterators should be used only to detect bugs.
这段注释是在解释Java中`ArrayList`类提供的迭代器(`iterator()`和`listIterator(int)`方法返回的)的行为特性,特别是所谓的“快速失败”(fail-fast)机制。
### 快速失败 (Fail-Fast)
1. **定义**:当一个线程正在遍历列表时,如果另一个线程修改了这个列表的结构(添加、删除或替换元素),除了通过迭代器自身的`remove()`或`add()`方法进行的操作外,迭代器会抛出`ConcurrentModificationException`异常。这种行为被称为“快速失败”。
2. **目的**:避免在并发修改的情况下出现未定义的行为或者数据不一致的问题。它确保程序在检测到并发修改时立即失败,而不是在未来某个不确定的时间点以非确定性的方式失败。
3. **实现原理**:通常,迭代器会维护一个与容器相关的内部计数器(称为`modCount`)。每当容器被修改时,这个计数器就会增加。每次迭代器访问下一个元素之前都会检查这个计数器是否自上次调用以来发生了变化。如果有变化,则抛出`ConcurrentModificationException`。
4. **限制**:尽管尽力保证快速失败,但在存在无同步的并发修改情况下,不能做出绝对的保证。这是因为其他线程可能在两次检查之间修改了集合,导致迭代器无法检测到这些更改。
5. **使用建议**:不应该依赖于`ConcurrentModificationException`来保证程序的正确性。它的主要用途是帮助开发者在测试阶段发现潜在的并发问题。正确的做法是在多线程环境中对共享资源使用适当的同步手段,如`synchronized`关键字或显式锁等。
总之,“快速失败”机制是一种设计模式,用于提高多线程环境下程序的健壮性和可调试性,但不应被视为一种可靠的并发控制策略。
// List<Integer> list = new ArrayList<>(); List<Integer> list = new CopyOnWriteArrayList<>();
想要 BaseResult 的后置任务列表只属于实例化的 SimpleResult,可以将 BaseResult 从接口改成类,其他地方做相应的修改即可。
三、进一步分析
如果 BaseResult 一定是接口呢?首先我们需要弄明白为什么会一定要接口呢?
让我们再来回顾下知识点,Java 类是单继承,多实现接口的;而 Java 接口是可以多重继承多个接口的。
假设如下面代码所示 BaseResult 继承了多个接口,其中 IResult 接口有一个抽象方法。此时如果我们将 BaseResult 从接口改成类的话,就需要实现抽象方法了。这样可能就违背了我们的初衷,其实我们是想让继承的上层类去实现的,而不是这个基类。让上层类继承 BaseResult,实现 IResult 是个办法,但是这样我们的抽象和封装其实都没有做好。
所以在接口多重继承多接口的情况下,BaseResult 是有可能必须是接口,而不是类或者抽象类的。在这种情况下,是建议将一些实例属性放到上层中的,不适合放在这个接口里了。
public interface BaseResult extends Serializable, IResult {}
public interface IResult { String getResult(); }
四、总结
很多问题其实都是由很朴素的原因造成的,一行很简单的代码在不同的场景下可能也暗藏玄机,敬畏每一行代码,了解每一行代码的运作逻辑,才能了然于心。
最后再回顾下知识点:
- 接口中定义的所有常量值默认为 public、static、final;
- ArrayList 线程不安全,想要线程安全请使用 CopyOnWriteArrayList;
- 接口可以多重继承多接口;
参考文档:
- Defining an Interface (The Java™ Tutorials > Learning the Java Language > Interfaces and Inheritance) (oracle.com):https://docs.oracle.com/javase/tutorial/java/IandI/interfaceDef.html
- Java Interfaces | Baeldung:https://www.baeldung.com/java-interfaces#overview
- jclasslib Bytecode Viewer:https://plugins.jetbrains.com/plugin/9248-jclasslib-bytecode-viewer
来源 | 阿里云开发者公众号
作者 | 舟畔