1. 前言
本文测试环境是 open-jdk 17
这篇文章记录了我对理论性能优化的一次实践尝试。可以将其视为一个实验性的探索。在这次探索中,我特地选择了开发者们经常接触的几个领域:集合操作、循环处理、以及字符串拼接,进行了深入的测试和比较。虽然这种方法可能不是最严格的,但有时简单的道理恰恰只需要简洁的实践来验证。
注:本文适合所有对此话题感兴趣的读者。我为那些不想亲自动手的朋友们完成了这个实践。
2. ArrayList 和 LinkedList
2. ArrayList 与 LinkedList 的性能比较
2.1 实验准备
在这部分,我将分享我的测试代码。为了适应不同的数据量,我只需修改部分代码即可。至于结果如何解读,我留给读者判断。但我的观点是,即使这样简单的测试,也足以揭示两者之间的性能差异。
我为测试创建了一个专门的方法,用于统计循环的总耗时:
// 计算循环总耗时
public static void countMills(List 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 list1 = new ArrayList();
System.out.print("nArrayList: ");
countMills(list1, 10);
// 2. LinkedList
List list2 = new LinkedList();
System.out.print("nLinkedList: ");
countMills(list2, 10);
// 3. ArrayList + 初始化
List 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 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 {
List 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 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 iterator = list1.iterator(); iterator.hasNext(); ) {
List 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)在底层其实就是使用迭代器。虽然为了代码的清晰性可能牺牲了一些性能,但我认为这是一个值得的权衡。
关于 forEach :forEach()
方法因为支持 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 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. 总结
这并非我首次进行此类性能测试。在过去,我也进行过更严格的测试,而且所得结果都是一致的。这告诉我们一个真理:最实践的学习方式往往能够带给我们最真实、最有价值的知识。如果读者们也有进行过类似的测试或有相关的文章,欢迎在评论区分享链接!