11 - 集合框架
1. Java集合简介
集合:由若干个确定的元素所构成的整体。在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,就把这种Java对象称为集合。
Collection
Java标准库自带的java.util包提供了集合类——Collection,它是除Map外所有其他集合类的根接口。主要提供了以下三种类型的集合:
-
List:一种有序列表的集合;
-
-
Map:一种通过键值查找的映射表集合。
Java集合设计的特点:
-
实现了接口和实现类相分离;
-
支持泛型,可以限制在一个集合中只能放入同一种数据类型的元素。
Java访问集合总是通过统一的方式——迭代器来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
注意:尽量不要使用遗留类(如Hashtable, Vector, Stack)和遗留接口(Enumeration<E>)。
2. 使用List
一种有序列表。
List内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List的索引和数组一样,从0开始。
使用数组实现增删元素非常麻烦,ArrayList把添加和删除的操作封装起来,操作List类似于操作数组,却不用关心内部元素如何移动。考察List<E>接口,可以看到几个主要的接口方法:
-
在末尾添加一个元素:void add(E e)
-
在指定索引添加一个元素:void add(int index, E e)
-
删除指定索引的元素:int remove(int index)
-
删除某个元素:int remove(Object e)
-
获取指定索引的元素:E get(int index)
-
获取链表大小(包含元素的个数):int size()
实现List接口并非只能通过数组,即ArrayList方式,另一种LinkedList通过“链表”也实现了List接口。在LinkedList中,它的内部每个元素都指向下一个元素。ArrayList和LinkedList的比较:
ArrayList(优先使用) | LinkedList | |
---|---|---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
List的特点
-
List接口允许我们添加重复的元素,即List内部的元素可以重复。
-
List还允许添加null
创建List
除了使用ArrayList和LinkedList,还可以通过List接口提供的of()方法根据给定元素快速创建List。但是List.of()方法不接受null值,如果传入null会抛出NullPointerException异常。
遍历List
①可以使用for循环根据索引配合get(int)方法遍历(不推荐),但其代码复杂,且get(int)方法只有ArrayList的实现是高效的,换成LinkedList后,索引越大,访问速度越慢。
②使用迭代器Iterator来访问List。Iterator本身也是一个对象,但它是由List的实例调用iterator()方法的时候创建的。该对象知道如何遍历一个List,且不同的List类型,返回的Iterator对象实现也是不同的,但总是具有最高的访问效率。由于Iterator遍历十分常用,因此,Java的for each循环本身就可以帮我们使用Iterator遍历。
Iterator对象有两个方法:
-
boolean hasNext():判断是否有下一个元素;
-
E next():返回下一个元素。
List和Array转换
List转换为Array的三种方法:
-
调用toArray()方法直接返回一个Obeject[]数组,但该方法会丢失类型信息,因而实际应用很少;
-
给toArray(T[])传入一个类型相同的Array,List内部自动把元素复制到传入的Array中;
-
通过List接口定义的T[] toArray(IntFunction<T[]> generator)方法。
Array转换为List:
-
通过List.of(T...)方法,返回的是只读List,如果调用add()或remove()方法会抛出UnsupportedOperationException;
-
对于JDK11之前的版本,可以使用Arrays.asList(T...)方法把数组转换成List。
3. 编写equals方法
List提供了boolean contains(Object o)方法来判断List是否包含某个指定元素。此外,int indexOf(Object o)方法可以返回某个元素的索引,如果元素不存在,返回-1。
上述List中包含的指定元素Object o可以与方法中的参数Object o不是同一个实例,其方法功能仍能实现,因为List内部并不是通过“==”来判断两个元素相等,而是使用equals()方法进行判断的。因此要正确使用contains()和indexOf()方法,放入的实例必须正确覆写equals方法,对于String,Integer这些对象,即Java标准库定义的这些类,已经正确实现了equals()方法,而对于我们自己编写的类,就需要我们编写equals()方法才能达到目标。
编写equals
equals()方法要求我们必须满足以下条件:
-
自反性(Reflexive)
-
对称性(Symmetric)
-
传递性(Transitive)
-
一致性(Consistent)
-
对null的比较:即x.equals(null)永远返回false。
equals()方法的正确编写方法:
①先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
②用instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false;
③对引用类型用Objects.equals()比较,对基本类型直接用“==”比较。
4. 使用Map
Map这种键值映射表的数据结构,作用就是能高效通过key快速查找value(元素)。Map也是一个接口,最常用的实现类是HashMap。
-
put(K key, V value)方法把key和value做了映射并放入Map;
-
V get(K key)方法通过key获取到对应的value,如果key不存在,返回null;
-
boolean containsKey(K key)方法查询某个key是否存在。
注意:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。但是value可以重复。
遍历Map
①可以使用for each循环遍历Map实例的keySet()方法返回的Set集合,它包含不重复的key的集合。
②同时遍历key和value可以使用for each循环遍历Map对象的entrySet()集合,它包含每一个key-value映射。
Map与List的区别:Map存储的是key-value的映射关系,且它不保证顺序。在遍历的时候,遍历的顺序既不一定是put()时放入的key的顺序,也不一定是key的排序顺序。使用Map时,任何依赖顺序的逻辑都是不可靠的。
5. 编写equals和hashCode
HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引。
通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value。
正确使用Map必须保证:
①作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()方法必须返回true;
②作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:
-
如果两个对象相等,则两个对象的hashCode()必须相等。必须保证实现,否则HashMap不能正常工作;
-
如果两个对象不相等,则两个对象的hashCode()尽量不要相等。如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode(),会造成Map内部存储冲突,使存取的效率下降。
编写equals()和hashCode()遵循的原则是:
equals()用到的用于比较的每一个字段,都必须在hashCode()中用于计算;equals()中没有使用到的字段,绝不可放在hashCode()中计算。
不同的key具有相同的hashCode()时就出现了哈希冲突,在冲突的时候,一种最简单的解决办法是用List存储hashCode()相同的key-value。显然,如果冲突的概率越大,这个List就越长,Map的get()方法效率就越低,所以要尽量满足条件二。
6. 使用EnumMap
如果作为key的对象时enum类型,那么还可以使用Java集合库提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。
使用EnumMap时,总是用Map接口来引用它。
7. 使用TreeMap
SortedMap会在内部对key进行排序,它是一个接口,其实现类是TreeMap。SortedMap保证遍历时以key的顺序来进行排序。使用TreeMap时,放入的key必须实现Comparable接口,作为value的对象则没有任何要求。
注意:TreeMap在比较两个key是否相等时,依赖key的compareTo()方法或者Comparator.compare()方法。在两个key相等时,必须返回0。
8. 使用Properties
配置文件的特点是:key-value一般都是String-String类型的,因此我们完全可以用Map<String, String>来表示它。配置文件十分常用,所以Java集合库提供了一个Properties来表示一组“配置”。由于历史遗留原因,Properties内部本质是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。
读取配置文件
Java默认配置文件以“.properties”为扩展名,每行以“key=value”表示,以“#”开头的是注释。读取配置文件步骤:
①创建Properties实例;
②调用load()读取文件;
③调用getProperty()获取配置。
写入配置文件
如果通过setProperty()修改了Properties实例,可以把配置写入文件,以便下次启动时获得最新配置。写入配置文件使用store()方法。
9. 使用Set
如果只需要存储不重复的key,并不需要存储映射的value,就可以使用Set。Set用于存储不重复的元素集合,它主要提供以下几个方法:
-
boolean add(E e):将元素添加进Set<E>;
-
boolean remove(Object e):将元素从Set<E>删除;
-
boolean contains(Object e):判断是否包含元素。
Set实际上相当于只存储key、不存储value的Map。经常使用Set去除重复元素。因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。最常用的Set实现类是HashSet,实际上HashSet仅仅是对HashMap的一个简单封装。
Set接口并不保证有序,而SortedSet接口则保证元素是有序的:
-
HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口;
-
TreeSet是有序的,因为它实现类SortedSet接口。
使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。
10. 使用Queue
队列Queue实际上是实现了一个先进先出的有序表。它与List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:
-
把元素添加到队列末尾;
-
从队列头部取出元素。
在Java标准库中,队列接口Queue定义了以下几个方法:
-
int size():获取队列长度;
-
boolean add(E) / boolean offer(E):添加元素到队尾;
-
E remove() / E poll():获取队首元素并从队列中删除;
-
E element() / E peek():获取队首元素但不从队列中删除。
其中,添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,两个方法的行为是不同的,前者抛出异常,即throw Exception,而后者返回false或null。
注意:不要把null添加到队列中,否则poll()方法返回null时,很难确定是取到了null元素还是队列为空。
11. 使用PriorityQueue
Queue会严格按FIFO的原则取出队首元素,而PriorityQueue的出队顺序与元素的优先级有关,对其调用remove()或poll()方法,返回的总是优先级最高的元素。
放入PriorityQueue的元素必须实现Comparable接口,PriorityQueue会根据元素的排序顺序决定出队的优先级。
12. 使用Deque
双端队列:两头都进,两头都出。Deque的功能是:
-
既可以添加到队尾,也可以添加到队首;
-
既可以从队首获取,又可以从队尾获取。
Deque接口实际上扩展自Queue,因此Queue提供的add()/offer()方法在Deque中也可以调用,但是使用Deque最好不要调用offer(),而是调用offerLast()。
Deque是一个接口,它的实现类有ArrayDeque和LinkedList。其中,LinkedList既是List,又是Queue,还是Deque。我们在使用时,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。
抽象编程的一个原则:尽量持有接口,而不是具体的实现类。
13. 使用Stack
栈:后进先出的数据结构。
Stack只有入栈和出栈操作:
-
push(E):把元素压栈;
-
pop(E):把栈顶的元素“弹出”;
-
peek(E):取栈顶元素但不弹出。
在Java中,可以使用Deque实现Stack的功能。
Stack的作用
①JVM在处理Java方法调用的时候就会通过栈维护方法调用的层次。JVM创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,引发StackOverflowError。
②对整数进行进制转换。
③计算中缀表达式。计算机执行表达式时不能直接计算中缀表达式,而是通过编译器把中缀表达式转换成后缀表达式,其编译过程就会用到栈。
14. 使用Iterator
Java的集合类都可以使用for each循环,List、Set和Queue会迭代每个元素,Map会迭代每个key。实际上,Java编译器并不知道如何遍历List,编译成功只是因为编译器把for each循环通过Iterator改写为了普通的for循环。
使用迭代器的好处:调用方总是以统一的方式遍历各种集合类型,而不必关心它们内部的存储结构。
自定义的集合类想要使用for each循环,只需要满足以下条件:
-
集合类实现Iterable接口,该接口要求返回一个Iterator对象;
-
用Iterator对象迭代集合内部数据。
15. 使用Collections
Collections,JDK提供的工具类,位于java.util包中,提供了一系列静态方法,能更方便地操作各种集合。
-
创建空集合:返回的空集合是不可变集合,无法向其中添加或删除元素;
-
创建单元素集合:返回的单元素集合是不可变集合,无法向其中添加或删除元素;
-
排序
-
洗牌
-
不可变集合:把可变集合封装成不可变集合,这种封装实际上是通过创建一个代理对象,拦截掉所有修改方法实现的;
-