一、迭代器
1. 基本介绍
迭代器与 for
循环类似,可实现集合元素的遍历,常用方法如下:
方法 | 作用 |
---|---|
next() | 访问后一位元素。 |
hasNext() | 是否存在下一位,返回 Boolean。 |
remove() | 删除上一次调用 next 返回的元素。 |
public static void iterateDemo() {
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
list.add("!");
System.out.println(list);
// 利用迭代器遍历集合
// 效果等价于 for 循环
Iterator iter = list.iterator();
while (iter.hasNext()){
System.out.println(iter.next());
}
}
2. 列表迭代
ListIterator
继承于 Iterator
,拓展提供了更丰富的操作。
方法 | 作用 |
---|---|
next() | 访问后一位元素。 |
previous() | 访问前一位元素。 |
hasNext() | 是否存在下一位,返回 Boolean。 |
remove() | 删除上一次调用 next 返回的元素。 |
public static void listDemo() {
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
list.add("!");
System.out.println(list);
ListIterator iter = list.listIterator();
iter.next();
System.out.println(iter.previous());
}
二、集合工具
1. 包含查询
不论是 Set
还是 List
其都提供了 contains()
用于判断是否包含元素。
public void containsDemo(){
List<String> list = new ArrayList<>();
list.add("AA");
list.add("BB");
// 是否包含元素
Boolean isContains = list.contains("BB");
}
虽然 JDK
中常见的集合都提供了 contains()
方法,但各自的实现过程却相差甚远。
查看 ArrayList
的 contains
方法可以看到其实现的逻辑十分简单,就是通过 for
循环遍历匹配,因此其时间复杂度即为 O(n)
。
而在 HashSet
中是基于 HashMap
结构存储数据,其 contains
方法则是通过 HashMap
的 containsKey()
方法实现。我们都知道 Map
的结构上是通过数组与链表(JDK 8
中链表过长时升级为红黑树)实现,其判断元素是否存在为通过计算目标的哈希值进行碰撞比对,效率远高于简单的循环遍历。
2. 模糊查询
通过 contains()
可以快速定位集合是否包含某一元素,其相当于精确查询,但如果需要实现模糊查询呢?
在 Java
中的 java.util
包下提供了正则表达式相关的工具包 regex
,通过其即可实现模糊查询功能。
public void vagueDemo() {
List<User> list = new ArrayList<>();
list.add(new User("张三", 20));
list.add(new User("李四", 30));
String key = "张";
Pattern pattern = Pattern.compile(key, Pattern.CASE_INSENSITIVE);
List<User> result = new ArrayList<>();
for (User user : list) {
Matcher matcher = pattern.matcher(user.getName());
if (matcher.find()) {
result.add(user);
}
}
System.out.println(result);
}
3. 子串截取
通过 subList(start, end)
可用于获取集合的子串,返回 SubList
对象,其为 ArrayList
内部类同样继承于 AbstractList
。
public void subListDemo1() {
List<Integer> list1 = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(i);
}
System.out.println(list);
// subList 取值区间 (start, end-1)
List<Integer> list2 = list1.subList(0, 3);
System.out.println(list2);
// end > size, 下标越界
List<Integer> list3 = list1.subList(0, 6);
System.out.println(list3);
}
需要注意截取的字串其类型为 SubList
继承于 AbstractList
,而 SubList
中并不包含空构造器,在序列化传输时可能会异常。因此当处理后的对象涉及序列化传输如 Jackson
等工具时,通过将截取后的对象通过 ArrayList
重新初始化构造。
public void subListDemo2() {
List<Integer> list1 = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(i);
}
System.out.println(list);
List<Integer> list2 = new ArrayList<>(list1.subList(0, 3));
System.out.println(list2);
}
三、集合排序
1. 基本排序
在 JDK
中提供了 Collections.sort()
方法实现集合内容进行排序。
在 ArrayList
对象中通过 Arrays.sort()
实现,其排序基于两种算法实现:插入排序与归并排序,针对插入排序而言在最坏的情况下其涉及到 O(nlogn)
次比较, O(n^2)
次数据移动,适用于小数据量的集合排序。
import java.util.Collections;
public void sortDemo() {
List<String> list = new ArrayList();
list.add("Alex");
list.add("Mark");
list.add("Beth");
Collections.sort(list);
System.out.println(list);
}
2. 动态排序
在更多的开发场景中,集合元素都为复杂的实体对象,而 Java 也提供了 Comparator
排序器实现集合排序。
在 Comparator
中提供了多个排序方法,参考下述表格:
方法 | 作用 |
---|---|
comparing() | 基础通用排序方法。 |
comparingInt() | 针对 int 优化的排序方法,性能更优。 |
thenComparing() | 对排序结果二次排序。 |
reversed() | 默认排序为升序,由其实现倒叙集合, |
public class User {
private String name;
private int age;
public int getAge() {
return age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
public class Test {
/**
* 根据年龄字段排序
*/
public static void main(String[] args) {
List<User> list = new ArrayList();
list.add(new User("Alex", 20));
list.add(new User("Beth", 15));
list.add(new User("Mark", 25));
list.sort(Comparator.comparing(User::getAge));
}
}
3. 排序接口
针对列表等集合类型实现排序可通过 Comparator
搭配 lambda
表达式快速实现,而对于数组数据同样可通过 Arrays.sort()
与 Comparator<T>
接口实现。
同样与上述 User
排序类型,下述为利用 Comparator<T>
接口实现,其中 compare()
返回值取值范围为:(-1,0,1)
,依次代表 (大于,等于,小于)
。
public class SortTest {
@Test
public void demo() {
User[] users = {
new User("Alex", 20),
new User("Beth", 15),
new User("Mark", 25)
};
Arrays.sort(users, new UserComparator());
System.out.println(Arrays.toString(users));
}
static class UserComparator implements Comparator<User> {
@Override
public int compare(User user1, User user2) {
return Integer.compare(user1.getAge(), user2.getAge());
}
}
}
四、Steam操作
在 Java 8
中引入新概念 stream
,其包含对集合的部分基本操作。
1. 串行流
Stream
提供两种遍历方式:串行流与并发流。通过的 stream
流的方式可以利用 lambda
表达式实现更便捷的操作,相应的方法与其描述参考下表。
方法 | 作用 |
---|---|
map() | 实现集合动态数据提取。 |
sorted() | 对集合数据实现排序,可传入排序器实现动态排序。 |
filter() | 实现对集合数据进行过滤处理。 |
distinct() | 实现对集合数据实现去重处理。 |
findFirst() | 返回集合中的随机一个元素。 |
findAny() | 返回集合中的随机一个元素,但在非 parallel 的情况下通常返回的都是第一个元素。 |
orElse() | 通过 orElse() 用于处理当 findAny() 或 findFirst() 不匹配时返回结果。 |
anyMatch() | 作用效果类似 contains() 方法。 |
上述表格中的方法接口具体使用示例如下:
public void demo() {
List<User> list = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
list.add(new User(String.valueOf(i), i));
}
String name = list.stream()
.filter(user -> user.getAge() == 1)
.map(User::getUserName)
.distinct()
.findAny()
.orElse(null);
System.out.println(name);
}
2. 并发流
除了串行流 Stream
同时提供了并发流 parallelStream()
从而实现更高效的计算,其使用 Fork-Join
框架,默认并发数为 CPU
核心的两倍。
若需要指定 parallelStream()
并发数可通过外层包裹 ForkJoinPool()
线程池控制,同时在使用时一定要注意其 lambda
表达式中涉及的操作都是线程安全。
public void streamDemo() {
List<Integer> list = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
list.add(i);
}
// 使用默认的 forkjoin 核心线程数
list.parallelStream().forEach(System.out::println);
// 手动设置核心线程数
int parallelSize = 5;
new ForkJoinPool(parallelSize).submit(() -> {
list.parallelStream().forEach(it -> {
// doSomething
});
}).join();
}
3. 类型转化
上述介绍了 Stream
提供了一系列 API
用于集合计算,其同时提供了一系列方法用于指定保存计算完成后的集合类型,具体信息参考下表:
方法 | 作用 |
---|---|
collect(Collectors.toList()) | 将目标集合转化 List 类型。 |
collect(Collectors.toSet()) | 将目标集合转化 Set 类型。 |
collect(Collectors.toMap()) | 将目标集合转化 Map 类型。 |
public static void main(String[] args) {
List<Integer> origin = new ArrayList<>();
for (int i = 0; i < 5; i++) {
origin.add(i);
}
// 结果保存为 List 类型
List<Integer> list = origin.stream()
.collect(Collectors.toList());
// 结果保存为 Set 类型
Set<Integer> set = origin.stream()
.collect(Collectors.toSet());
// 结果保存为 Map 类型
Map<Integer, Integer> map = origin.stream()
.collect(Collectors.toMap(it -> it, it -> it));
}
4. 易发异常
(1) 不可变集合
在 JDK 8
提供的 collect(Collectors.toList())
方法在 JDK 17
中可简化为 toList()
,但需要注意的一点是其返回的对象为不可变对象,若对其执行 add()
等操作将抛出 UnsupportedOperationException
。
public void demo1() {
List<User> userList = new ArrayList<>();
userList.add(new User(1, "Alex"));
// JDK 17 中通过 toList() 返回
List<User> collect = userList.stream().toList();
// collect 为不可变对象,此处将抛出 UnsupportedOperationException
collect.add(new User(2, "Beth"));
System.out.println(collect);
}
(2) Key 值重复
在使用 toMap()
是需要特别注意一点,当 key
值重复时将会抛出 IllegalStateException
异常。
因此我们需要手动指定 Key
值重复处理策略,执行替换或跳过,修改上述示例得到如下代码:
public void mapDemo() {
List<Integer> list = List.of(1, 1, 2, 3);
Map<Integer, Integer> map1 = list.stream()
// 重复时跳过
.collect(Collectors.toMap(it -> it, it -> it, (exist, newRow) -> exist));
System.out.println(map);
Map<Integer, Integer> map2 = list.stream()
// 重复时替换
.collect(Collectors.toMap(it -> it, it -> it, (exist, newRow) -> newRow));
System.out.println(map);
}
(3) 空值异常
在 HashMap
中我们知道 Key
值允许有且仅有一个为空,Value
则不做空值限制,但在 toMap()
中其限制了 Value
不能为空,否则其在内部调用 map.merge()
时将抛出空指针异常。
因此,若无法保证 Value
的值不为空,更推荐使用手动 put()
的方式。
public void demo2() {
List<User> userList = new ArrayList<>();
userList.add(new User(1, null));
userList.add(new User(1, "Alex"));
Map<Integer, String> map = new HashMap<>();
userList.forEach(it -> {
// 允许 Key, Value 空值,重复时覆盖
map.put(it.getId(), it.getName());
});
System.out.println(map);
}
5. 分组操作
在 Stream
流中提供了 groupingBy()
接口可快速实现实现集合数据分组。
方法 | 作用 |
---|---|
collect(Collectors.groupingBy()) | 将目标集合根据指定条件进行分组,返回 Map 对象。 |
collect(Collectors.collectingAndThen()) | 对操作完成的集合结果实现二次操作。 |
collect(Collectors.Collectors.counting()) | 返回 stream 操作完成后符合结果的元素个数。 |
如下述示例根据 Foo
类的 username
字段进行数据分组与数量统计:
public class GroupingTest {
private static final List<Foo> fooList = new ArrayList<>();
@Before
public void init() {
fooList.add(new Foo("1-1", "Foo-1"));
fooList.add(new Foo("1-1", "Foo-1"));
fooList.add(new Foo("1-2", "Foo-1"));
fooList.add(new Foo("2-1", "Foo-2"));
fooList.add(new Foo("3-1", "Foo-3"));
fooList.add(new Foo("3-2", "Foo-3"));
System.out.println("\nOrigin: " + fooList);
}
@Test
public void groupingDemo() {
Map<String, List<Foo>> result1 = fooList.stream()
.collect(Collectors.groupingBy(Foo::getUsername));
System.out.println("\nResult 1: " + result1);
Map<String, Long> result2 = fooList.stream()
.collect(Collectors.groupingBy(Foo::getUsername, Collectors.counting()));
System.out.println("\nResult 2: " + result2);
}
}
而 collectingAndThen()
则是对集合递进操作,下面通过实例展示效果:
public static void main(String[] args) {
List<User> origin = new ArrayList<>();
origin.add(new User("111", "Alex1"));
origin.add(new User("111", "Alex2"));
origin.add(new User("222", "Beth"));
// 对列表中的元素根据 ID 字段去重
List<User> unique2 = origin.stream().collect(collectingAndThen(toCollection(() ->
new TreeSet<>(Comparator.comparing(User::getId))), ArrayList::new));
System.out.println(unique2);
}
五、性能对照
不知道你有没有这么一个疑惑,随着 JDK
的升级, Java
针对集合提供越来越多的遍历方式,但它们之间又孰优孰劣?今天就让我们用实际代码来测试一下不同遍历方式之间的性能差异。
1. 基本介绍
先盘点一下 Java
为让我们提供了哪些遍历方式,我相信大家用的最多的应该就是 for
循环了,但在 JDK 8
之后,Java
引入了 Lambda
表达式与 Stream
流的概念,其中流又分为串行流 stream
与 并行流 parallelStream
两类。
下面针对不同的方式举个简单例子说明:
public void visitedDemo() {
List<String> list = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
list.add("element " + i);
}
// for 循环遍历
for (String str : list) {
System.out.print(str + ", ");
}
// lambda 表达式遍历
list.forEach(str -> {
System.out.print(str + ", ");
});
// 串行流遍历
list.stream().forEach(str -> {
System.out.print(str + ", ");
});
// 并发流遍历
list.parallelStream().forEach(str -> {
System.out.print(str + ", ");
});
}
2. 性能测试
既然 Java
为我们提供多种遍历方案那么我们应该如何根据应用场景进行选择?哪种遍历方式又能达到最优性能?下面通过 Demo
测试在不同数据量情况下上述几种方式性能差异。
测试的方法很简单,提供定义好一个列表,使用不同方式对其进行遍历,分别记录在遍历开始时间与结束时间,二者差值即为遍历耗时,对比不同方式耗时即可得出不同遍历方式的性能差异。
这里我列表大小设置为十万,测试代码如下:
public void performTest() {
long begin, end;
List<String> list = new ArrayList<>();
for (int j = 0; j < 100000; j++) {
list.add(String.valueOf(Math.random()));
}
for (int i = 1; i <= 10; i++) {
System.out.println("------------------ 第" + i + "次测试 ------------------ ");
begin = System.nanoTime();
for (String s : list) {
s.toString();
}
end = System.nanoTime();
System.out.println("普通 for 循环耗时: " + (end - begin) / 1000 + " μs");
begin = System.nanoTime();
list.forEach(e -> {
e.toString();
});
end = System.nanoTime();
System.out.println("lambda 表达式 foreach 耗时: " + (end - begin) / 1000 + " μs");
begin = System.nanoTime();
list.stream().forEach(e -> {
e.toString();
});
end = System.nanoTime();
System.out.println("串行流 stream 耗时: " + (end - begin) / 1000 + " μs");
begin = System.nanoTime();
list.parallelStream().forEach(e -> {
e.toString();
});
end = System.nanoTime();
System.out.println("并行流 stream 耗时: " + (end - begin) / 1000 + " μs");
}
}
3. 结果分析
根据测试结果可以很明显的看到在第一次测试中 lambda
表达式耗时最长, for
循环耗时遥遥领先,但随着测试次数增加情况却发生翻转,这是因为 lambda
遍历方式首次编译启动需要进行相应的定义声明,导致首次耗时大大增加,而之后的测试中则无需重复进行定义因此耗时与其它方式基本无差,这也是网上许多人说 lambda
性能低于 for
循环的主要原因。
------------------ 第1次测试 ------------------
普通 for 循环耗时: 6230 μs
lambda 表达式 foreach 耗时: 130087 μs
串行流 stream 耗时: 9358 μs
并行流 stream 耗时: 11641 μs
------------------ 第2次测试 ------------------
普通 for 循环耗时: 2436 μs
lambda 表达式 foreach 耗时: 992 μs
串行流 stream 耗时: 2045 μs
并行流 stream 耗时: 3391 μs
------------------ 第3次测试 ------------------
普通 for 循环耗时: 17906 μs
lambda 表达式 foreach 耗时: 937 μs
串行流 stream 耗时: 870 μs
并行流 stream 耗时: 1607 μs
------------------ 第4次测试 ------------------
普通 for 循环耗时: 34833 μs
lambda 表达式 foreach 耗时: 866 μs
串行流 stream 耗时: 957 μs
并行流 stream 耗时: 1076 μs
------------------ 第5次测试 ------------------
普通 for 循环耗时: 24371 μs
lambda 表达式 foreach 耗时: 807 μs
串行流 stream 耗时: 785 μs
并行流 stream 耗时: 1270 μs
------------------ 第6次测试 ------------------
普通 for 循环耗时: 17677 μs
lambda 表达式 foreach 耗时: 925 μs
串行流 stream 耗时: 893 μs
并行流 stream 耗时: 1127 μs
------------------ 第7次测试 ------------------
普通 for 循环耗时: 19604 μs
lambda 表达式 foreach 耗时: 1417 μs
串行流 stream 耗时: 887 μs
并行流 stream 耗时: 1163 μs
------------------ 第8次测试 ------------------
普通 for 循环耗时: 18726 μs
lambda 表达式 foreach 耗时: 855 μs
串行流 stream 耗时: 1099 μs
并行流 stream 耗时: 1166 μs
------------------ 第9次测试 ------------------
普通 for 循环耗时: 21533 μs
lambda 表达式 foreach 耗时: 850 μs
串行流 stream 耗时: 829 μs
并行流 stream 耗时: 910 μs
------------------ 第10次测试 ------------------
普通 for 循环耗时: 17918 μs
lambda 表达式 foreach 耗时: 824 μs
串行流 stream 耗时: 837 μs
并行流 stream 耗时: 1810 μs
当我们将列表大小设置为百万时,得到如下结果。通过对比可以看到 for
循环在第一次测试中能达到相对较少的耗时,而之后的计中方式耗时基本趋于一致,但并行流 parallelStream
的方式显然效率更高。
因为 parallelStream
相对于其它几种串行的方式相当于多线程,因此在面对大量数据时自然能够表现的更为出色。
------------------ 第1次测试 ------------------
普通 for 循环耗时: 22637 μs
lambda 表达式 foreach 耗时: 110947 μs
串行流 stream 耗时: 21404 μs
并行流 stream 耗时: 26229 μs
------------------ 第2次测试 ------------------
普通 for 循环耗时: 47007 μs
lambda 表达式 foreach 耗时: 15911 μs
串行流 stream 耗时: 8568 μs
并行流 stream 耗时: 7379 μs
------------------ 第3次测试 ------------------
普通 for 循环耗时: 31820 μs
lambda 表达式 foreach 耗时: 8790 μs
串行流 stream 耗时: 9338 μs
并行流 stream 耗时: 7069 μs
------------------ 第4次测试 ------------------
普通 for 循环耗时: 36320 μs
lambda 表达式 foreach 耗时: 7964 μs
串行流 stream 耗时: 7947 μs
并行流 stream 耗时: 7296 μs
------------------ 第5次测试 ------------------
普通 for 循环耗时: 11554 μs
lambda 表达式 foreach 耗时: 7542 μs
串行流 stream 耗时: 7842 μs
并行流 stream 耗时: 7555 μs
------------------ 第6次测试 ------------------
普通 for 循环耗时: 8256 μs
lambda 表达式 foreach 耗时: 8815 μs
串行流 stream 耗时: 10931 μs
并行流 stream 耗时: 10562 μs
------------------ 第7次测试 ------------------
普通 for 循环耗时: 10917 μs
lambda 表达式 foreach 耗时: 11637 μs
串行流 stream 耗时: 11600 μs
并行流 stream 耗时: 7650 μs
------------------ 第8次测试 ------------------
普通 for 循环耗时: 10064 μs
lambda 表达式 foreach 耗时: 8703 μs
串行流 stream 耗时: 9465 μs
并行流 stream 耗时: 6836 μs
------------------ 第9次测试 ------------------
普通 for 循环耗时: 10255 μs
lambda 表达式 foreach 耗时: 7792 μs
串行流 stream 耗时: 8809 μs
并行流 stream 耗时: 8194 μs
------------------ 第10次测试 ------------------
普通 for 循环耗时: 9003 μs
lambda 表达式 foreach 耗时: 11233 μs
串行流 stream 耗时: 8331 μs
并行流 stream 耗时: 8037 μs
因此,当业务场中需要频繁或嵌套使用遍历, lambda
表达式与流的方式遍历都能达到不错的性能,反之则选择增强的 for
循环,而当数据量达到一定数量时,并行流 parallelStream
相对其它几种方式能达到更好的性能。