一不小心掉入了 Java Interface 的陷阱

简介: 本文作者记录了一次代码中的踩坑经历,一行很简单的代码在不同的场景下可能也暗藏玄机,希望大家看完都有所收获。

首先请大家花点时间阅读以下的代码块,看看代码是否存在问题或者隐患。


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 修饰的。 image.png

👇🏻是运行的结果,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;
  • 接口可以多重继承多接口;

参考文档:



来源  |  阿里云开发者公众号

作者  |  舟畔

相关文章
|
4月前
|
Java
Java(二十一)interface接口
Java(二十一)interface接口
40 0
|
4月前
|
Java
Java语言接口(Interface)的深入解析
Java语言接口(Interface)的深入解析
|
4月前
|
Java
Java 接口(Interface)
Java接口是抽象类型,定义方法规范而无实现。接口通过`interface`关键字定义,包含方法签名和常量。类可实现多个接口,实现接口必须覆盖所有方法。接口常用于多态、回调和模块化。一个类可继承一个抽象类并实现多个接口。接口中的常量默认为`public static final`。注意接口不能实例化,且多个接口同名方法可通过实现类重写避免冲突。接口继承多个接口时,规范冲突则不允许。
48 0
|
4月前
|
Java 编译器
【JAVA学习之路 | 提高篇】接口(interface)
【JAVA学习之路 | 提高篇】接口(interface)
|
4月前
|
Java
java-基础-Interface、abstract类、Static class 、non static class的区别
【4月更文挑战第5天】Java中的接口、抽象类、静态类和非静态类各具特色:接口仅含抽象方法和常量,用于定义行为规范;抽象类可包含抽象和非抽象方法,提供部分实现,支持多继承;静态内部类不依赖外部类实例,可独立存在,访问外部类的静态成员;非静态内部类持有关联外部类引用,能访问其所有成员。这些机制根据设计需求和场景选择使用。
31 6
|
10月前
|
Java
JAVA 抽象类(Abstract Class) 和 接口(Interface) 的区别
对于面向对象编程来说,抽象是它的一大特征之一。在 Java 中,可以通过两种形式来体现 OOP 的抽象:接口和抽象类。这两者有太多相似的地方,又有太多不同的地方。今天我们就一起来学习一下Java中的接口和抽象类抽象类不能用于实例化对象,抽象类往往用来表示抽象概念。举个例子,中国人(Chinese 类)和美国人(American 类)都有“吃饭”这个行为,因此可以先定义一个 Person 类,然后让 Chinese 和 American 都继承这个类。但如何在父类 Person 中定义“吃饭”这个方法呢?一般
103 0
|
4月前
|
人工智能 Java API
【Java 接口】接口(Interface)的定义,implements关键字,接口实现方法案例
【Java 接口】接口(Interface)的定义,implements关键字,接口实现方法案例
|
Java
Java面向对象 接口(interface)的详解
Java面向对象 接口(interface)的详解
80 0
|
Java
Java 接口协议(interface)
Java 接口协议(interface)
75 0
|
Java 数据库连接 API
【Java基础】Java SPI 一 之SPI(Service Provider Interface)进阶& AutoService
【Java基础】Java SPI 一 之SPI(Service Provider Interface)进阶& AutoService