在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。很显然,Java的数组可以看作是一种集合。
String[] ss = new String[10]; // 可以持有10个String对象
ss[0] = "Hello"; // 可以放入String对象
String first = ss[0]; // 可以获取String对象
为什么在又了数组这种数据类型后,还需要其他集合类?
因为数组有如下的限制:
- 数组初始化后的大小不能改变;
- 数组只能按索引顺序存取。
因此,我们需要各种不同的集合来满足不同的需求。例如:
- 可变大小的顺序链表;
- 保证无重复元素的集合;
- …
Java标准库自带的java.util
包提供了集合类:Collection
,它是除Map
外所有其他集合类的根接口。
Java的java.util
包主要提供了以下三种类型的集合:
List
:一种有序列的集合;Set
:一种保证没有重复元素的集合;Map
:一种通过键值查找的映射表集合。Java集合的设计有几个特点:
List
,具体的实现类有ArrayList
,LinkedList
等;由于Java的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:
Hashtable
:一种线程安全的Map
实现;Vector
:一种线程安全的List
实现;Stack
:基于Vector
实现的LIFO
的栈。还有一小部分接口是遗留接口,也不应该继续使用:
Enumeration
:已被Iterator
取代。
List
是最基础的一种集合:它是一种有序链表。
List
的行为和数组几乎完全相同:List
内部按照放入元素的先后顺序存放,每个元素都可以通过索引值确定自己的位置。
在实际应用中,需要增删元素的有序列表,因此我们使用最多的是ArrayList
。
实际上,ArrayList
在内部使用了数组来存储所有元素。它的处理和数组的使用类似,例如,一个ArrayList拥有5个元素,实际数组大小为6
(即有一个空位):
size=5
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │
└───┴───┴───┴───┴───┴───┘
当添加一个元素并指定索引到ArrayList
时,ArrayList
自动移动需要移动的元素:
size=5
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ │ C │ D │ E │
└───┴───┴───┴───┴───┴───┘
然后,往内部指定索引的数组位置添加一个元素,然后把size
加1
:
size=6
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │
└───┴───┴───┴───┴───┴───┘
继续添加元素,但是数组已满,没有空闲位置的时候,ArrayList
先创建一个更大的新数组,然后把旧数组的所有元素复制到新数组,紧接着用新数组取代旧数组:
size=6
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
现在,新数组就有了空位,可以继续添加一个元素到数组末尾,同时size
加1
:
size=7
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │ G │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
LinkedList
通过“链表”也实现了List接口。在LinkedList
中,它的内部每个元素都指向下一个元素:
┌───┬───┐ ┌───┬───┐ ┌───┬───┐ ┌───┬───┐
HEAD ──>│ A │ ●─┼──>│ B │ ●─┼──>│ C │ ●─┼──>│ D │ │
└───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘
ArrayList | LinkedList | |
---|---|---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
通常情况下,我们总是优先使用
ArrayList
。
@Test
public void m0() {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("pear");
list.add("orange");
//方法一:通过索引遍历,不推荐
for (int i = 0; i < list.size(); ++i) {
String str = list.get(i);
System.out.println(str);
}
//方法二:通过Iterator遍历
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
//方法三:for earch方法遍历
for (String str : list) {
System.out.println(str);
}
}
上面通过三种方法遍历了List:
get(i)
方法只有ArrayList
的实现是高效的,换成LinkedList
后,索引越大,访问速度越慢。Iterator
来访问List
。Iterator
本身也是一个对象,但它是由List
的实例调用iterator()
方法的时候创建的。Iterator
对象知道如何遍历一个List
,并且不同的List
类型,返回的Iterator
对象实现也是不同的,但总是具有最高的访问效率。for each
循环变成Iterator
的调用。需要记住,通过
Iterator
遍历List
永远是最高效的方式。
对于JDK 11之前的版本,可以使用Arrays.asList(T...)
方法把数组转换成List
。但返回的是一个只读 List
。
@Test
public void m1() {
Integer[] array = { 1, 2, 3 };
List<Integer> list = Arrays.asList(array);
for (int i : list) {
System.out.println(i);
}
list.add(5); //java.lang.UnsupportedOperationException
}
对于高版本的,可以通过List.of(T...)
方法转换,返回的也是只读 List
。
Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);
在 List
中提供了有两个方法:
boolean contains(Object o)
方法判断 List
是否包含某个指定元素;int indexOf(Object o)
方法返回某个元素的索引,如果元素不存在,返回 -1
。下面是一个例子:
@Test
public void m3() {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("pear");
list.add("orange");
System.out.println(list.contains(new String("apple"))); //true
System.out.println(list.contains(new String("banana"))); //false
System.out.println(list.indexOf(new String("apple"))); //0
System.out.println(list.indexOf(new String("banana"))); //-1
}
虽然传入的 new String("apple")
和List
中的 apple
是不同的实例。但是仍然得到了true
。这是因为在 List
内部并不是通过 ==
来判断两个元素是否相等,而是通过 equals()
方法判断两个元素是否相等。源码如下:
/*ArrayList.java*/
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
因此,要正确使用 List
的contains()
、indexOf()
这些方法,必须要覆写对象的equals()
方法。
之所以放入
String
、Integer
这些对象能够得到预期结果,是因为Java标准库定义的这些类已经正确实现了equals()
方法。
例如,在 Student
类没有编写 equals
方法时,不能得到正确结果。
@Test
public void m3() {
List<Student> students = new ArrayList<>();
Student stu1 = new Student("zhangsan");
students.add(stu1);
Student stu2 = new Student("lisi");
students.add(stu2);
Student stu3 = new Student("wangwu");
students.add(stu3);
System.out.println(students.contains(new Student("zhangsan"))); //false
}
编写
equals()
方法,必须满足的条件:
- 自反性(Reflexive):对于非
null
的x
来说,x.equals(x)
必须返回true
;- 对称性(Symmetric):对于非
null
的x
和y
来说,如果x.equals(y)
为true
,则y.equals(x)
也必须为true
;- 传递性(Transitive):对于非
null
的x
、y
和z
来说,如果x.equals(y)
为true
,y.equals(z)
也为true
,那么x.equals(z)
也必须为true
;- 一致性(Consistent):对于非
null
的x
和y
来说,只要x
和y
状态不变,则x.equals(y)
总是一致地返回true
或者false
;- 对
null
的比较:即x.equals(null)
永远返回false
。
根据规则,编写Student类的 equals()
方法:
@Override
public boolean equals(Object obj) {
if (obj instanceof Student) {
Student student = (Student) obj;
return Objects.equals(this.name, student.name) && this.id == student.id;
}
return false;
}
再来运行上一个测试代码,便能得到正确结果。
使用
Objects.equals()
比较两个引用类型是否相等的目的是省去了判断null
的麻烦。两个引用类型都是null
时也返回true
。
Map
这种键值(key-value)映射表的数据结构,作用是能高效地通过key
快速查找value
(元素)。比如,通过name
查询某个 Student
。
Map
和 List
一样,也是一个接口,常用的方法包括:
put(K key, V value)
方法:把key
和value
做了映射并放入Map
V get(K key)
:通过key
获取到对应的value
。如果key
不存在,则返回null
。boolean containsKey(K key)
:查询某个 key
是否存在。对于 Map
来说,常用的实现类是 HashMap
。
对 Map
来说,要遍历 key
可以使用 for each
循环遍历 Map
实例的 keySet()
方法返回的Set
集合,它包含不重复的key
的集合。
如果要遍历key
和value
,可以使用for each
循环遍历Map
对象的entrySet()
集合,它包含每一个key-value
映射。
具体如下:
@Test
public void m3() {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
//方法一
for (String key : map.keySet()) {
System.out.println(key + " " + map.get(key));
}
//方法二
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
Map
和List
不同:
Map
存储的是key-value
的映射关系,- 它不保证顺序。
Map
的遍历顺序没有逻辑可言,甚至不同的JDK版本,相同的代码输出顺序都不同。
HashMap
之所以能够根据 key
直接拿到 value
,原因是它在内部通过空间换时间的方法,用一个大数组存储所有的 value
,并根据 key
直接计算出来 value
应该存储的位置。
┌───┐
0 │ │
├───┤
1 │ ●─┼───> Student("Xiao Ming")
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> Student("Xiao Hong")
├───┤
6 │ ●─┼───> Student("Xiao Jun")
├───┤
7 │ │
└───┘
上述行为就是本科学的 哈希表
,根据 key
通过一个算法得到一个索引值,将 value
放到相应的位置。这个算法得到的索引值应该尽可能减少冲突,也就是说,对于两个 key
:a
和b
,该算法得到的索引应该尽量不一样,如果发生冲突,也会有冲突解决办法。在 HashMap
中采用的办法是:如果发生冲突,在数组中,实际存储的就不是一个Student
实例,而是一个 List
,如下所示:
┌───┐
0 │ │
├───┤
1 │ │
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> List>
├───┤
6 │ │
├───┤
7 │ │
└───┘
在查找时,先通过 a
得到索引5,再得到List
,接着它还需要遍历这个 List
,才能返回对应的Student
实例。所以如果冲突的概率越大,这个List
就越长,Map
的get()
方法查找效率就越低。
由上分析,得到结论:
key
的类必须正确覆写 equals()
和hashCode()
方法;equals()
,就必须覆写hashCode()
,并且覆写规则是:
equals()
返回true
,则hashCode()
返回值必须相等;equals()
返回false
,则hashCode()
返回值尽量不要相等。hashCode()
方法可以通过Objects.hashCode()
辅助方法实现如果Map
传入的 key
对象是 enum
类型,那么可以使用 EnumMap
,它在内部以一个非常紧凑的数组存储value,并且根据 enum
类型的key直接定位到内部数组的索引,并不需要计算hashCode()
,不但效率最高,而且没有额外的空间浪费。
我们以 DayOfWeek
(import java.time.DayOfWeek;)枚举来做一个“翻译”功能:
@Test
public void m4() {
Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
map.put(DayOfWeek.MONDAY, "星期一");
map.put(DayOfWeek.TUESDAY, "星期二");
map.put(DayOfWeek.WEDNESDAY, "星期三");
map.put(DayOfWeek.THURSDAY, "星期四");
map.put(DayOfWeek.FRIDAY, "星期五");
map.put(DayOfWeek.SATURDAY, "星期六");
map.put(DayOfWeek.SUNDAY, "星期日");
System.out.println(map);
System.out.println(map.get(DayOfWeek.MONDAY));
}
Map
接口的一个实现HashMap
是一种以空间换时间的映射表,它的实现原理决定了内部的 key 是无序的,导致了在遍历时的输出顺序是不可预测的。
如果要实现对 key
进行排序,就需要用到 SortedMap
接口,它的实现类是 TreeMap
。
┌───┐
│Map│
└───┘
▲
┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashMap│ │SortedMap│
└───────┘ └─────────┘
▲
│
┌─────────┐
│ TreeMap │
└─────────┘
要使用 TreeMap
实现key的排序,放入Map
中的key
必须实现 Comparable
接口。当然对于 String
、Integer
这些类来说,已经实现了 Comparable
接口,因此可以直接使用。但对于自定义类,比如Student
类,则需要自己实现。
如果作为Key的class没有实现Comparable
接口,那么,必须在创建TreeMap
时指定一个自定义排序算法。
实现的例子如下:
//方法一
//修改 Student.java
public class Student implements Comparable<Student> {
//...
@Override
public int compareTo(Student o) {
if (Objects.equals(this.name, o.getName())) {
return 0;
}
return this.getName().compareTo(o.getName());
}
}
//测试方法
@Test
public void m5() {
Map<Student, Integer> map = new TreeMap<>();
map.put(new Student("zhangsan"), 111);
map.put(new Student("lisi"), 222);
map.put(new Student("asan"), 333);
map.put(new Student("wangwu"), 444);
for (Student stu : map.keySet()) {
System.out.println(stu);
}
}
//方法二
@Test
public void m5() {
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
@Override
public int compare(Student o1, Student o2) {
if(Objects.equals(o1.getName(), o2.getName())) {
return 0;
}
return o1.getName().compareTo(o2.getName());
}
});
map.put(new Student("zhangsan"), 111);
map.put(new Student("lisi"), 222);
map.put(new Student("asan"), 333);
map.put(new Student("wangwu"), 444);
for (Student stu : map.keySet()) {
System.out.println(stu);
}
}
实现对配置文件的读写需要用到Properties
。由于历史遗留原因,Properties
内部本质上是一个Hashtable
,但我们只需要用到Properties
自身关于读写配置的接口。
如果我们只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set
。在应该中,我们常用Set
来去除重复元素。
Set
用于存储不重复的元素集合,它主要提供以下几个方法:
Set
:boolean add(E e)
Set
删除:boolean remove(Object e)
boolean contains(Object e)
@Test
public void m5() {
Set<String> set = new HashSet<>();
System.out.println(set.add("abc")); // true
System.out.println(set.add("xyz")); // true
System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在
System.out.println(set.contains("xyz")); // true,元素存在
System.out.println(set.contains("XYZ")); // false,元素不存在
System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在
System.out.println(set.size()); // 2,一共两个元素
}
Set
实际上相当于只存储key、不存储value的Map
。我们经常用Set
用于去除重复元素。因为放入
Set
的元素和Map
的key类似,都要正确实现equals()
和hashCode()
方法,否则该元素无法正确地放入Set
。最常用的
Set
实现类是HashSet
,实际上,HashSet
仅仅是对HashMap
的一个简单封装,它的核心代码如下:public class HashSet<E> implements Set<E> { private transient HashMap<E,Object> map; // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT)==null; } public boolean contains(Object o) { return map.containsKey(o); } public boolean remove(Object o) { return map.remove(o) == PRESENT; } }
和Map
的TreeMap
类似, Set
接口也有一个 TreeSet
。关系如下:
┌───┐
│Set│
└───┘
▲
┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashSet│ │SortedSet│
└───────┘ └─────────┘
▲
│
┌─────────┐
│ TreeSet │
└─────────┘
用法也是一样,添加的元素必须要正确的实现 Comparable
接口;如果没有实现,那么创建TreeSet
时必须传入一个Comparator
对象。
队列(Queue
)是一种经常使用的集合。Queue
实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和 List
的区别就是:限制了添加和取出的方向。Queue
只能从末尾添加元素、从头部取出元素。
在Java的标准库中,队列接口Queue
定义了以下几个方法:
int size()
:获取队列长度;boolean add(E)
/boolean offer(E)
:添加元素到队尾;E remove()
/E poll()
:获取队首元素并从队列中删除;E element()
/E peek()
:获取队首元素但并不从队列中删除。对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。
注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下:
throw Exception | 返回false或null | |
---|---|---|
添加元素到队尾 | add(E e) | boolean offer(E e) |
取队首元素并删除 | E remove() | E poll() |
取队首元素但不删除 | E element() | E peek() |
注意:不要把
null
添加到队列中,否则poll()
方法返回null
时,很难确定是取到了null
元素还是队列为空。
LinkedList
类即实现了List
接口,又实现了Queue
接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:
// 这是一个List:
List list = new LinkedList<>();
// 这是一个Queue:
Queue queue = new LinkedList<>();
PriorityQueue
是一种允许插队的 Queue
。它的出队顺序与元素的优先级有关。对PriorityQueue
调用remove()
或poll()
方法,返回的总是优先级最高的元素。
元素的优先级是通过排序得到的,因此和TreeMap
、TreeSet
一样,放入PriorityQueue
的元素,必须实现Comparable
接口,如果没有实现,那么创建PriorityQueue
时必须传入一个Comparator
对象。
//方法一
@Test
public void m6() {
Queue<Student> queue = new PriorityQueue<>();
queue.add(new Student("zhangsan"));
queue.add(new Student("lisi"));
queue.add(new Student("asan"));
queue.add(new Student("wangwu"));
System.out.println(queue.remove()); //asan
System.out.println(queue.remove()); //lisi
System.out.println(queue.remove()); //wangwu
System.out.println(queue.remove()); //zhangsan
}
Deque
是对 Queue
的一种变体,它允许两端都进,两端都出,叫做双端队列(Double Ended Queue)
Queue
和Deque
出队和入队的方法:
Queue | Deque | |
---|---|---|
添加元素到队尾 | add(E e) / offer(E e) | addLast(E e) / offerLast(E e) |
取队首元素并删除 | E remove() / E poll() | E removeFirst() / E pollFirst() |
取队首元素但不删除 | E element() / E peek() | E getFirst() / E peekFirst() |
添加元素到队首 | 无 | addFirst(E e) / offerFirst(E e) |
取队尾元素并删除 | 无 | E removeLast() / E pollLast() |
取队尾元素但不删除 | 无 | E getLast() / E peekLast() |
虽然Deque
是Queue
的扩展,Queue
提供的add()
/offer()
方法也可以使用,但是使用Deque
时,最好不要调用offer()
,而是调用offerLast()
。
Deque
是一个接口,它的实现类有ArrayDeque
和LinkedList
。
LinkedList
是一个全能选手,它即是List
,又是Queue
,还是Deque
。但是我们在使用的时候,总是要用特定的接口来引用它。
栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。
Stack
的操作:
push(E)
;pop(E)
;peek(E)
。在Java中,没有单独的
Stack
接口。
Collections
是JDK提供的工具类,同样位于java.util
包中。它提供了一系列静态方法,能更方便地操作各种集合。
注意Collections结尾多了一个s,不是Collection!
Collections
提供了一系列方法来创建空集合:
List emptyList()
Map emptyMap()
Set emptySet()
但是返回的空集合时不可变集合,无法向其中添加或删除元素。
@Test
public void m8() {
List<String> list = Collections.emptyList();
list.add("111"); //java.lang.UnsupportedOperationException
}
Collections
提供了一系列方法来创建一个单元素集合:
List singletonList(T o)
Map singletonMap(K key, V value)
Set singleton(T o)
同样,返回的单元素集合也是不可变集合,无法向其中添加或删除元素。
Collections
可以对List
进行排序。因为排序会直接修改List
元素的位置,因此必须传入可变List
:
@Test
public void m8() {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("pear");
list.add("orange");
// 排序前:
System.out.println(list);
Collections.sort(list);
// 排序后:
System.out.println(list);
}
//输出结果
[apple, pear, orange]
[apple, orange, pear]
Collections
提供了洗牌算法,即传入一个有序的List
,可以随机打乱List
内部元素的顺序,效果相当于让计算机洗牌:
@Test
public void m8() {
List<Integer> list = new ArrayList<>();
for (int i=0; i<10; i++) {
list.add(i);
}
// 洗牌前:
System.out.println(list);
Collections.shuffle(list);
// 洗牌后:
System.out.println(list);
}
//输出结果
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[5, 1, 4, 9, 2, 0, 7, 6, 8, 3]
Collections
还提供了一组方法把可变集合封装成不可变集合:
List unmodifiableList(List list)
Set unmodifiableSet(Set set)
Map unmodifiableMap(Map m)
这种封装实际上是通过创建一个代理对象,拦截掉所有修改的方法。
@Test
public void m9() {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
immutable.add("orange"); // UnsupportedOperationException
}
但是继续对原始的可变List
进行增删是可以的,并且,还会影响到封装后的“不可变”List
:
@Test
public void m8() {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
mutable.add("orange");
System.out.println(immutable); //[apple, pear, orange]
}
所以在返回不可变 List
后,最好立刻将可变List
等于 null
,如下:
@Test
public void m8() {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
// 立刻扔掉mutable的引用:
mutable = null;
System.out.println(immutable);
}