likes
comments
collection
share

来看看你以为的性能优化

作者站长头像
站长
· 阅读数 23

1. 前言

本文测试环境是 open-jdk 17

这篇文章记录了我对理论性能优化的一次实践尝试。可以将其视为一个实验性的探索。在这次探索中,我特地选择了开发者们经常接触的几个领域:集合操作、循环处理、以及字符串拼接,进行了深入的测试和比较。虽然这种方法可能不是最严格的,但有时简单的道理恰恰只需要简洁的实践来验证。

注:本文适合所有对此话题感兴趣的读者。我为那些不想亲自动手的朋友们完成了这个实践。

2. ArrayList 和 LinkedList

2. ArrayList 与 LinkedList 的性能比较

2.1 实验准备

在这部分,我将分享我的测试代码。为了适应不同的数据量,我只需修改部分代码即可。至于结果如何解读,我留给读者判断。但我的观点是,即使这样简单的测试,也足以揭示两者之间的性能差异。

我为测试创建了一个专门的方法,用于统计循环的总耗时:

// 计算循环总耗时
public static void countMills(List<String> list, Integer num) {
    long startTime = System.currentTimeMillis(); // 开始时间
    for (int i = 0; i < num; i++) {
        list.add(String.format("%06d", i)); // 拖延时间
    }
    long endTime = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime - startTime) + "millis");
}

接下来,我为 ArrayList 和 LinkedList 创建了测试流程:

/* List的性能测试 */
public static void main(String[] args) {
    // 1. ArrayList
    List<String> list1 = new ArrayList<>();
    System.out.print("\nArrayList: ");
    countMills(list1, 10);

    // 2. LinkedList
    List<String> list2 = new LinkedList<>();
    System.out.print("\nLinkedList: ");
    countMills(list2, 10);

    // 3. ArrayList + 初始化
    List<String> list3 = new ArrayList<>(6);
    System.out.print("\nArrayList + 初始化: ");
    countMills(list3, 10);
}
2.2 测试结果

在这次测试中,我特意将初始化值设置为总数据量的60%。这意味着在大约达到容量后,集合需要进行一次扩容。在实际开发中,很难预先知道数据量从而设置100%的初始化容量,因为这可能会牺牲更多的性能。而在查询时,我选择了数据的60%位置,没有特殊的理由,只是觉得这个数字比较吉利。

  • 10次增加集合元素

来看看你以为的性能优化

  • 100次增加集合元素

来看看你以为的性能优化

  • 100000次增加集合元素

来看看你以为的性能优化

  • 100000数据量下的查询

来看看你以为的性能优化

在经过一系列测试后,我们可以得出以下结论:

LinkedList 的优势:

  • 综合性能:无论数据量大小,即使没有初始化,LinkedList 都能保持较好的性能表现。
  • 查询速度:尽管它是基于链表实现的,但其查询速度并不像许多人所想象的那样慢。
  • 内存碎片问题LinkedList 在多次分配空间时可能会产生内存碎片。但是,关于 GC 是否能及时清理这些碎片,这仍然是一个不确定的问题。

ArrayList 的特点:

  • 小数据量下的性能ArrayList 只有在小数据量下且进行100%初始化时,才能体现出较好的性能。但实际上,小数据量下的性能优势并没有太大实际意义。
  • 定点查询:由于 ArrayList 的底层实现是数组,它的定点查询速度相对会更快。

3. 循环、迭代器与 forEach 的性能比较

3.1 实验准备

为了进行此次测试,我准备了以下代码。读者可以自行判断其有效性。需要注意的是,随后的代码写法可能会更加自由和不规范,建议不深究具体实现,因为我会确保测试结果是客观和公正的。

public static void main(String[] args) {
    int num = 10000;
    List<Integer> list1 = new ArrayList<>();

    for (int i = 0; i < num; i++) {
        list1.add(i); // 拖延时间
    }

    System.out.print("\n带索引循环: ");
    long startTime1 = System.currentTimeMillis(); // 开始时间
    for (int i = 0; i < num; i++) {
        List<Integer> list2 = new ArrayList<>();
        list2.add(list1.get(i));
    }
    long endTime1 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime1 - startTime1) + "millis");

    System.out.print("\nforEach循环: ");
    long startTime2 = System.currentTimeMillis(); // 开始时间
    list1.forEach(integer -> {
        List<Integer> list2 = new ArrayList<>();
        list2.add(integer);
    });
    long endTime2 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime2 - startTime2) + "millis");

    System.out.print("\n增强循环: ");
    long startTime3 = System.currentTimeMillis(); // 开始时间
    for (Integer integer : list1) {
        List<Integer> list2 = new ArrayList<>();
        list2.add(integer);
    }
    long endTime3 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime3 - startTime3) + "millis");

    System.out.print("\n迭代器: ");
    long startTime4 = System.currentTimeMillis(); // 开始时间
    for (Iterator<Integer> iterator = list1.iterator(); iterator.hasNext(); ) {
        List<Integer> list2 = new ArrayList<>();
        list2.add(iterator.next());
    }
    long endTime4 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime4 - startTime4) + "millis");
}
3.2 测试结果

在以下的循环测试中,我尽量确保变量初始化的最小化,以避免由此引入的时间差异。所选用的数据量大小是为了明显地展现各种方法之间的性能差异。

  • 1000次循环

来看看你以为的性能优化

  • 100000次循环

来看看你以为的性能优化

从测试中,我们可以得到以下观察:

迭代器的性能:迭代器展现出了最佳的性能。增强循环(for-each loop)在底层其实就是使用迭代器。虽然为了代码的清晰性可能牺牲了一些性能,但我认为这是一个值得的权衡。

关于 forEachforEach() 方法因为支持 Lambda 表达式而变得非常受欢迎。有些开发者可能将增强循环替换为 forEach(),这可能是为了代码的简洁或其他原因。不过,值得注意的是,forEach() 在不同数据量下都展现出了稳定的性能,本次测试中大约为 30 毫秒。

3.3 Stream API 在 5000000 次循环中的性能表现

我特意将 Stream API 单独提出来进行讨论,因为它的表现确实令我震惊。或许许多人,包括我自己,都曾听说过 Stream API 在启动时较慢,或者在处理小数据量时性能不如其他方法。但实验结果却颠覆了这些看法:

System.out.print("\nStream流: ");
long startTime5 = System.currentTimeMillis(); // 开始时间
list1.stream().map(li -> {
    List<Integer> list2 = new ArrayList<>();
    list2.add(li);
    return list2;
});
long endTime5 = System.currentTimeMillis(); // 结束时间
System.out.println("cost " + (endTime5 - startTime5) + "millis");

来看看你以为的性能优化

如果说之前带索引的循环和增强循环差距不大,但在大数据量处理下,两者之间的底层优化差异变得明显。而得益于流式处理,Stream API 显示出了卓越的性能,远超其他方法。然而,需要注意的是,老旧的硬件设备可能不支持 Stream API 形式的处理。

4. String 与 StringBuilder 的性能对比

4.1 实验准备

关于 String 和 StringBuilder 的性能特点,大多数开发者都有所了解,这两者之间的性能差异并不是一个争议点。但为了更直观地展示其性能差距,我们还是进行了简单的测试:

public static void main(String[] args) {
    System.out.print("\nString: ");
    long startTime1 = System.currentTimeMillis(); // 开始时间
    String s1 = "";
    for (int i = 0; i < 10; i++) {
        s1 += String.format("%d", i);
    }
    System.out.println(s1);
    long endTime1 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime1 - startTime1) + "millis");


    System.out.print("\nStringBuilder: ");
    long startTime2 = System.currentTimeMillis(); // 开始时间
    StringBuilder s2 = new StringBuilder();
    for (int i = 0; i < 10; i++) {
        s2.append(String.format("%d", i));
    }
    System.out.println(s2);
    long endTime2 = System.currentTimeMillis(); // 结束时间
    System.out.println("cost " + (endTime2 - startTime2) + "millis");
}
4.2 测试-10次拼接

来看看你以为的性能优化

尽管测试的数据量非常小,但 StringBuilder 的性能提升仍然十分明显。众所周知,当我们使用 String 进行字符串拼接时,其底层实际上是通过创建 StringBuilder 来完成操作的。

5. 总结

这并非我首次进行此类性能测试。在过去,我也进行过更严格的测试,而且所得结果都是一致的。这告诉我们一个真理:最实践的学习方式往往能够带给我们最真实、最有价值的知识。如果读者们也有进行过类似的测试或有相关的文章,欢迎在评论区分享链接!