1. 概述

在本篇文章中,我们将深入分析 Java Collection API 中常用集合类的性能表现。当我们谈到集合类时,通常会想到 ListMapSet 这三种数据结构,以及它们的常见实现类。

首先,我们会从 Big-O 复杂度的角度来分析常见操作的时间复杂度。随后,我们将通过实际的运行时数据来验证理论分析。

2. 时间复杂度

通常,当我们讨论时间复杂度时,指的是 Big-O 表示法。简单来说,它描述了算法运行时间随着输入规模增长的变化趋势。

如果你对 Big-O 表示法还不太熟悉,可以先阅读 Big-O 理论基础Java 实例分析 的相关文章。

3. List

我们从最简单的有序集合 List 开始。

下面将分别介绍 ArrayListLinkedListCopyOnWriteArrayList 的性能表现。

3.1. ArrayList

Java 中的 ArrayList 是基于数组实现的。这有助于我们理解其内部逻辑。更详细的 ArrayList 实现细节可参考 这篇文章

我们先从常见操作的时间复杂度入手:

  • add() – 平均时间复杂度为 *O(1)*,但最坏情况下(需要扩容并复制数组)为 O(n)
  • add(index, element) – 平均为 *O(n)*,因为可能需要移动后续元素
  • get() – 始终为常数时间 *O(1)*,直接通过索引访问
  • remove() – 时间复杂度为 *O(n)*,需要遍历数组查找目标元素
  • indexOf() – 同样为 *O(n)*,需要逐个比较元素
  • contains() – 基于 indexOf() 实现,因此也是 O(n)

3.2. CopyOnWriteArrayList

该集合类在多线程环境中非常有用。它线程安全,具体实现细节可参考 此文章

CopyOnWriteArrayList 的性能表现如下:

  • add() – 取决于插入位置,平均为 O(n)
  • get() – 常数时间 O(1)
  • remove()O(n)
  • contains()O(n)

⚠️ 可以看出,该集合类的性能开销较大,特别是 add() 操作。

3.3. LinkedList

LinkedList 是一种线性结构,由节点组成,每个节点包含数据和指向下一个节点的引用。更多特性可参考 这篇文章

常见操作的时间复杂度如下:

  • add() – 添加到末尾,仅需更新尾节点,O(1)
  • add(index, element) – 平均 *O(n)*,需要遍历到指定位置
  • get() – *O(n)*,需要从头遍历查找
  • remove(element) – 先查找再删除,O(n)
  • remove(index) – 从头遍历到指定位置后删除,O(n)
  • contains()O(n)

3.4. JVM 预热

为了验证理论,我们使用实际数据进行测试。我们使用 JMH(Java Microbenchmark Harness)来测试集合类的常见操作性能

如果你对 JMH 不熟悉,可以查看 这篇指南

以下是我们的基准测试配置:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 10)
public class ArrayListBenchmark {
}

我们设置预热轮数为 10,并输出平均运行时间(单位:微秒)。

3.5. 基准测试

我们以 ArrayList 为例,编写测试代码:

@State(Scope.Thread)
public static class MyState {

    List<Employee> employeeList = new ArrayList<>();

    long iterations = 100000;

    Employee employee = new Employee(100L, "Harry");

    int employeeIndex = -1;

    @Setup(Level.Trial)
    public void setUp() {
        for (long i = 0; i < iterations; i++) {
            employeeList.add(new Employee(i, "John"));
        }

        employeeList.add(employee);
        employeeIndex = employeeList.indexOf(employee);
    }
}

我们在 ArrayListBenchmark 中定义 State 类来初始化测试数据。

接着是具体测试方法:

@Benchmark
public void testAdd(ArrayListBenchmark.MyState state) {
    state.employeeList.add(new Employee(state.iterations + 1, "John"));
}

@Benchmark
public void testAddAt(ArrayListBenchmark.MyState state) {
    state.employeeList.add((int) (state.iterations), new Employee(state.iterations, "John"));
}

@Benchmark
public boolean testContains(ArrayListBenchmark.MyState state) {
    return state.employeeList.contains(state.employee);
}

@Benchmark
public int testIndexOf(ArrayListBenchmark.MyState state) {
    return state.employeeList.indexOf(state.employee);
}

@Benchmark
public Employee testGet(ArrayListBenchmark.MyState state) {
    return state.employeeList.get(state.employeeIndex);
}

@Benchmark
public boolean testRemove(ArrayListBenchmark.MyState state) {
    return state.employeeList.remove(state.employee);
}

3.6. 测试结果

测试结果(单位:微秒)如下:

Benchmark                        Mode  Cnt     Score     Error
ArrayListBenchmark.testAdd       avgt   20     2.296 ±   0.007
ArrayListBenchmark.testAddAt     avgt   20   101.092 ±  14.145
ArrayListBenchmark.testContains  avgt   20   709.404 ±  64.331
ArrayListBenchmark.testGet       avgt   20     0.007 ±   0.001
ArrayListBenchmark.testIndexOf   avgt   20   717.158 ±  58.782
ArrayListBenchmark.testRemove    avgt   20   624.856 ±  51.101

✅ 从结果可以看出:

  • testContains()testIndexOf() 性能相近
  • testAdd()testGet() 差距巨大(2.296 vs 0.007)
  • 查找或删除元素大约需要 700 微秒,符合 O(n) 复杂度

同样,我们可以对 CopyOnWriteArrayList 进行测试,结果如下:

Benchmark                          Mode  Cnt    Score     Error
CopyOnWriteBenchmark.testAdd       avgt   20  652.189 ±  36.641
CopyOnWriteBenchmark.testAddAt     avgt   20  897.258 ±  35.363
CopyOnWriteBenchmark.testContains  avgt   20  537.098 ±  54.235
CopyOnWriteBenchmark.testGet       avgt   20    0.006 ±   0.001
CopyOnWriteBenchmark.testIndexOf   avgt   20  547.207 ±  48.904
CopyOnWriteBenchmark.testRemove    avgt   20  648.162 ± 138.379

✅ 从数据上看:

  • testGet() 仍为常数时间
  • testAdd()ArrayList 慢得多,符合 O(n) 复杂度

最后是 LinkedList 的测试结果:

Benchmark        Cnt     Score       Error
testAdd          20     2.580        ± 0.003
testContains     20     1808.102     ± 68.155
testGet          20     1561.831     ± 70.876 
testRemove       20     0.006        ± 0.001

✅ 从中可以看出:

  • add()remove() 性能很好
  • get()contains() 性能较差,因为需要遍历链表

4. Map

在 JDK 最新版本中,Map 的实现性能得到了显著优化。例如,HashMapLinkedHashMap 在冲突时使用平衡树代替链表,将最坏情况下的查找时间从 O(n) 降低到 *O(log n)*。

但如果你正确实现了 .equals().hashCode() 方法,冲突概率会大大降低。

关于 HashMap 冲突处理,可参考 这篇文章。*文中也提到,在理想情况下,HashMap* 的插入和查找操作为常数时间 O(1)

4.1. 测试 O(1) 操作

我们先看 HashMap 的测试结果:

Benchmark                         Mode  Cnt  Score   Error
HashMapBenchmark.testContainsKey  avgt   20  0.009 ± 0.002
HashMapBenchmark.testGet          avgt   20  0.011 ± 0.001
HashMapBenchmark.testPut          avgt   20  0.019 ± 0.002
HashMapBenchmark.testRemove       avgt   20  0.010 ± 0.001

✅ 数据表明,上述方法的时间复杂度确实为 *O(1)*。

其他 Map 实现(LinkedHashMapIdentityHashMapWeakHashMapEnumMapConcurrentHashMap)也具有类似表现:

Benchmark      LinkedHashMap  IdentityHashMap  WeakHashMap  ConcurrentHashMap
testContainsKey    0.008         0.009          0.014          0.011
testGet            0.011         0.109          0.019          0.012
testPut            0.020         0.013          0.020          0.031
testRemove         0.011         0.115          0.021          0.019

4.2. 测试 O(log(n)) 操作

对于基于树结构的 TreeMapConcurrentSkipListMap,其 put()get()remove()containsKey() 的时间复杂度为 *O(log(n))*。

我们测试了不同规模数据下的运行时间:

items count (n)         1000      10,000     100,000   1,000,000
all tests total score   00:03:17  00:03:17   00:03:30  00:05:27

✅ 从结果可以看出,随着数据规模增加,运行时间呈对数增长,验证了 O(log(n)) 的复杂度。

5. Set

Set 是一个不包含重复元素的集合。我们将分析以下实现类:

  • HashSet
  • LinkedHashSet
  • EnumSet
  • TreeSet
  • CopyOnWriteArraySet
  • ConcurrentSkipListSet

HashSet 的内部实现依赖于 HashMap,因此其 add()remove()contains() 操作的时间复杂度为 *O(1)*。

**而 TreeSetConcurrentSkipListSet 由于基于树结构,时间复杂度为 O(log(n))

CopyOnWriteArraySet 的操作复杂度为 *O(n)*。

5.1. 测试方法

@Benchmark
public boolean testAdd(SetBenchMark.MyState state) {
    return state.employeeSet.add(state.employee);
}

@Benchmark
public Boolean testContains(SetBenchMark.MyState state) {
    return state.employeeSet.contains(state.employee);
}

@Benchmark
public boolean testRemove(SetBenchMark.MyState state) {
    return state.employeeSet.remove(state.employee);
}

5.2. 结果对比

HashSet 的测试结果如下:

Benchmark      1000    10,000    100,000
.add()         0.026   0.023     0.024
.remove()      0.009   0.009     0.009
.contains()    0.009   0.009     0.010

LinkedHashSet 的结果:

Benchmark      1000    10,000    100,000
.add()         0.022   0.026     0.027
.remove()      0.008   0.012     0.009
.contains()    0.008   0.013     0.009

✅ 从结果可以看出,HashSetLinkedHashSet 的操作时间基本一致,符合 O(1) 复杂度。

6. 总结

本文分析了 Java 中常用集合类的时间复杂度,并通过基准测试验证了理论分析。

我们通过 JMH 测试了各类集合的性能表现,并对比了不同集合在相同操作下的性能差异。最终,我们可以根据实际需求选择最适合的集合类。

完整代码可从 GitHub 获取。


原始标题:Time Complexity of Java Collections | Baeldung