MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中ArrayList的性能优化

2023-07-256.3k 阅读

ArrayList 基础回顾

在深入探讨性能优化之前,我们先来回顾一下 ArrayList 的基本概念。ArrayList 是 Java 集合框架中的一员,它实现了 List 接口,提供了基于数组的数据结构来存储元素。与普通数组不同的是,ArrayList 可以动态调整大小,方便地进行元素的添加、删除和访问操作。

ArrayList 的实现原理

ArrayList 内部维护了一个数组来存储元素。当向 ArrayList 中添加元素时,如果数组已满,ArrayList 会自动扩容。扩容的过程是创建一个新的更大的数组,将原数组中的元素复制到新数组中。例如以下简单代码:

import java.util.ArrayList;
import java.util.List;

public class ArrayListDemo {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(i);
        }
    }
}

在上述代码中,我们创建了一个 ArrayList 并向其中添加 10 个元素。在添加过程中,如果初始容量不足,就会触发扩容操作。

ArrayList 的常用方法及时间复杂度

  1. add(E e):向列表末尾添加元素,平均时间复杂度为 O(1)。但在扩容时,时间复杂度会变为 O(n),因为需要复制原数组元素到新数组。
List<String> names = new ArrayList<>();
names.add("Alice");
  1. add(int index, E element):在指定位置插入元素,时间复杂度为 O(n),因为插入位置之后的元素都需要向后移动。
List<String> names = new ArrayList<>();
names.add(0, "Bob");
  1. get(int index):获取指定位置的元素,时间复杂度为 O(1),因为可以通过数组下标直接访问。
List<String> names = new ArrayList<>();
names.add("Charlie");
String name = names.get(0);
  1. remove(int index):删除指定位置的元素,时间复杂度为 O(n),因为删除位置之后的元素都需要向前移动。
List<String> names = new ArrayList<>();
names.add("David");
names.remove(0);
  1. size():获取列表元素个数,时间复杂度为 O(1),因为 ArrayList 内部维护了一个变量记录元素个数。
List<String> names = new ArrayList<>();
names.add("Eve");
int size = names.size();

影响 ArrayList 性能的因素

频繁扩容

如前文所述,ArrayList 的扩容机制是导致性能问题的一个重要因素。每次扩容都需要创建新数组并复制原数组元素,这是一个比较耗时的操作。假设我们有如下代码:

import java.util.ArrayList;
import java.util.List;

public class ArrayListPerformance {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            list.add(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time taken: " + (endTime - startTime) + " ms");
    }
}

在这段代码中,由于初始容量不足,会频繁触发扩容操作,导致性能下降。通过分析 ArrayList 的源码可知,扩容时新容量是原容量的 1.5 倍(如果原容量的 1.5 倍仍小于所需容量,则新容量为所需容量)。

元素移动

在执行 add(int index, E element)remove(int index) 方法时,会涉及到元素的移动。当插入或删除位置靠近列表开头时,移动的元素数量较多,性能损耗较大。例如,以下代码在列表开头插入元素:

import java.util.ArrayList;
import java.util.List;

public class ArrayListInsertPerformance {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            list.add(0, i);
        }
    }
}

在这个例子中,每次插入都需要将已有元素向后移动一位,随着元素数量的增加,性能会急剧下降。

遍历方式

不同的遍历方式对 ArrayList 的性能也有影响。常见的遍历方式有普通 for 循环、增强 for 循环和迭代器遍历。虽然增强 for 循环和迭代器遍历在语法上更加简洁,但在性能上,普通 for 循环在某些情况下可能更优。

  1. 普通 for 循环
List<Integer> list = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
    Integer num = list.get(i);
    // 处理 num
}

普通 for 循环直接通过数组下标访问元素,效率较高。

  1. 增强 for 循环
List<Integer> list = new ArrayList<>();
for (Integer num : list) {
    // 处理 num
}

增强 for 循环在编译后实际上是使用迭代器实现的,虽然语法简洁,但在性能上可能稍逊于普通 for 循环。

  1. 迭代器遍历
List<Integer> list = new ArrayList<>();
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    Integer num = iterator.next();
    // 处理 num
}

迭代器遍历在删除元素时比较方便,但在单纯遍历方面,性能与增强 for 循环类似。

ArrayList 性能优化策略

预分配足够的容量

为了避免频繁扩容,在创建 ArrayList 时,如果能提前预估元素数量,可以预分配足够的容量。例如:

List<Integer> list = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
    list.add(i);
}

通过 new ArrayList<>(1000000) 提前分配了 1000000 个元素的容量,这样在添加元素过程中就不会触发扩容操作,大大提高了性能。对比之前未预分配容量的代码,执行时间会显著缩短。

减少元素移动

  1. 批量操作:尽量使用批量添加或删除方法,如 addAll(Collection c)removeAll(Collection c)。这些方法可以减少元素移动的次数。例如:
List<Integer> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    list2.add(i);
}
list1.addAll(list2);

在这个例子中,addAll 方法一次性将 list2 中的元素添加到 list1 中,相比逐个添加元素,减少了元素移动的次数。

  1. 使用 LinkedList:如果需要频繁在列表开头或中间插入、删除元素,考虑使用 LinkedList。LinkedList 基于链表结构,插入和删除操作不需要移动大量元素,时间复杂度为 O(1)(不考虑查找位置的时间)。例如:
import java.util.LinkedList;
import java.util.List;

public class LinkedListInsertPerformance {
    public static void main(String[] args) {
        List<Integer> list = new LinkedList<>();
        for (int i = 0; i < 100000; i++) {
            list.add(0, i);
        }
    }
}

上述代码使用 LinkedList 在开头插入元素,性能会优于使用 ArrayList。但需要注意的是,LinkedList 的随机访问性能较差,时间复杂度为 O(n),而 ArrayList 的随机访问时间复杂度为 O(1)。所以在选择数据结构时,需要根据具体的操作场景来决定。

选择合适的遍历方式

  1. 普通 for 循环:如果只是简单地遍历 ArrayList 并访问元素,普通 for 循环通常是性能最优的选择,尤其是在 ArrayList 元素数量较多时。例如:
List<Integer> list = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
    Integer num = list.get(i);
    // 进行数值计算等操作
}
  1. 增强 for 循环和迭代器遍历:如果在遍历过程中需要删除元素,建议使用迭代器遍历,因为增强 for 循环在删除元素时可能会抛出 ConcurrentModificationException。例如:
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    Integer num = iterator.next();
    if (num == 2) {
        iterator.remove();
    }
}

在这个例子中,通过迭代器的 remove 方法可以安全地删除元素。而如果使用增强 for 循环:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
for (Integer num : list) {
    if (num == 2) {
        list.remove(num); // 会抛出 ConcurrentModificationException
    }
}

就会抛出异常。这是因为增强 for 循环在编译后使用迭代器实现,在遍历过程中修改列表结构会导致迭代器的内部状态不一致。

使用并行流

在 Java 8 及以上版本中,可以使用并行流来提高 ArrayList 的遍历和处理效率。并行流会将任务分发给多个线程并行处理,适用于数据量较大且处理任务可以并行化的场景。例如:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class ArrayListParallelStream {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            list.add(i);
        }
        List<Integer> result = list.parallelStream()
               .map(num -> num * 2)
               .collect(Collectors.toList());
    }
}

在上述代码中,parallelStream 方法将列表转换为并行流,map 方法对每个元素进行乘以 2 的操作,最后通过 collect 方法收集结果。并行流的性能提升取决于任务的复杂度和 CPU 核心数等因素。在一些情况下,并行流可能会因为线程创建和管理的开销而导致性能下降,所以需要根据实际情况进行测试和优化。

避免不必要的装箱和拆箱

在 Java 中,当使用 ArrayList 存储基本数据类型时,会发生自动装箱和拆箱操作。例如:

List<Integer> list = new ArrayList<>();
list.add(1); // 装箱操作,将 int 转换为 Integer
int num = list.get(0); // 拆箱操作,将 Integer 转换为 int

装箱和拆箱操作会带来一定的性能开销。为了避免这种开销,可以使用 Java 9 引入的 java.util.LongAdderjava.util.IntSummaryStatistics 等类,这些类专门用于处理基本数据类型的统计和计算,避免了装箱和拆箱。另外,从 Java 5 开始,也可以使用 java.util.concurrent.atomic.AtomicIntegerjava.util.concurrent.atomic.AtomicLong 等原子类,它们不仅可以避免装箱和拆箱,还提供了线程安全的操作。例如:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerExample {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        atomicInteger.incrementAndGet(); // 直接对 int 值进行操作,避免装箱和拆箱
        int value = atomicInteger.get();
    }
}

如果只是简单的基本数据类型存储和遍历,也可以考虑使用专门的基本数据类型数组,如 int[]long[] 等,它们的性能通常会优于使用 ArrayList 存储包装类型。

减少内存占用

  1. 使用合适的数据类型:确保 ArrayList 中存储的数据类型是最合适的,避免使用大对象或不必要的包装类型。例如,如果只需要存储 0 - 255 之间的整数,可以考虑使用 byte 类型,而不是 int 类型。这样可以减少内存占用,提高性能。
import java.util.ArrayList;
import java.util.List;

public class ByteArrayList {
    public static void main(String[] args) {
        List<Byte> list = new ArrayList<>();
        for (byte i = 0; i < 100; i++) {
            list.add(i);
        }
    }
}
  1. 及时释放不再使用的 ArrayList:当 ArrayList 不再使用时,及时将其设置为 null,以便垃圾回收器回收其占用的内存。例如:
List<Integer> list = new ArrayList<>();
// 使用 list 进行一些操作
list = null; // 释放 list 占用的内存

同时,在设计程序时,尽量避免创建过多不必要的 ArrayList 对象,以减少内存的整体开销。

性能测试与分析

测试工具

为了准确评估 ArrayList 性能优化前后的效果,可以使用一些性能测试工具,如 JMH(Java Microbenchmark Harness)。JMH 是一个专门用于编写和运行 Java 微基准测试的框架,它可以帮助我们测量代码的性能指标,如执行时间、吞吐量等。以下是一个使用 JMH 测试 ArrayList 扩容性能的示例:

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class ArrayListBenchmark {

    @Param({"1000000"})
    private int size;

    @Benchmark
    public void testArrayListWithoutInitialCapacity() {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
    }

    @Benchmark
    public void testArrayListWithInitialCapacity() {
        List<Integer> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
               .include(ArrayListBenchmark.class.getSimpleName())
               .warmupIterations(5)
               .measurementIterations(5)
               .forks(1)
               .build();
        new Runner(opt).run();
    }
}

在上述代码中,我们定义了两个基准测试方法,一个是不指定初始容量创建 ArrayList 并添加元素,另一个是指定初始容量创建 ArrayList 并添加元素。通过运行这个测试,我们可以比较两种方式的平均执行时间,从而评估预分配容量对性能的提升效果。

分析测试结果

运行 JMH 测试后,会得到详细的性能报告。例如,在上述测试中,如果 testArrayListWithoutInitialCapacity 方法的平均执行时间为 100 毫秒,而 testArrayListWithInitialCapacity 方法的平均执行时间为 20 毫秒,这就明显表明预分配容量可以显著提高性能。通过分析测试结果,我们可以进一步优化代码,确定哪些优化策略真正有效,哪些需要进一步调整。同时,不同的测试环境(如不同的 CPU、内存、操作系统等)可能会对测试结果产生影响,所以在进行性能测试时,尽量在与实际生产环境相似的条件下进行,以确保测试结果的可靠性。

在实际应用中,还可以对其他性能优化策略进行类似的测试,如比较不同遍历方式的性能、批量操作与单个操作的性能等。通过不断地测试和分析,我们可以找到最适合具体业务场景的 ArrayList 使用方式,从而提高整个系统的性能和效率。

通过对 ArrayList 性能优化的深入探讨,我们了解了影响其性能的因素,并掌握了多种优化策略。在实际开发中,根据具体的业务需求和数据规模,合理应用这些优化策略,可以有效提升程序的性能和响应速度,为用户提供更好的体验。同时,持续关注性能测试和分析,不断调整优化方案,也是保证系统高性能运行的关键。