Java集合框架
一、简介
1、集合框架介绍
Java集合框架提供了一套性能优良,使用方便的接口和类,他们位于java.util
包中。容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表
2、相关容器介绍
2.1 Set相关
- TreeSet
基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN) - HashSet
基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。 - LinkedHashSet
具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
2.2 List相关
- ArrayList
基于动态数组实现,支持随机访问。 - Vector
和 ArrayList 类似,但它是线程安全的。 - LinkedList
基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
2.3 Queue相关
- LinkedList
可以实现双向队列。 - PriorityQueue
基于堆结构实现,可以用它来实现优先队列。
2.4 Map相关
- TreeMap
基于红黑树实现。 - HashMap
基于哈希表实现。 - HashTable
和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用ConcurrentHashMap
来支持线程安全,并且ConcurrentHashMap
的效率会更高,因为ConcurrentHashMap
引入了分段锁。 - LinkedHashMap
使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序
3、集合重点👏
- Collection 接口存储一组不唯一,无序的对象
- List 接口存储一组不唯一,有序的对象。
- Set 接口存储一组唯一,无序的对象
- Map 接口存储一组键值对象,提供key到value的映射
- ArrayList实现了长度可变的数组,在内存中分配连续的空间。遍历元素和随机访问元素的效率比较高
- LinkedList采用链表存储方式。插入、删除元素时效率比较高
- HashSet采用哈希算法实现的Set
- HashSet的底层是用HashMap实现的,因此查询效率较高,由于采用hashCode算法直接确定 元素的内存地址,增删效率高
二、ArrayList分析
1、ArrayList使用
方法 | 说明 |
---|---|
boolean add(Object o) | 在列表的末尾顺序添加元素,起始索引位置从0开始 |
void add(int index, Object o) | 在指定的索引位置添加元素,索引位置必须介于0和列表中元素个数之间 |
int size() | 返回列表中的元素个数 |
Object get(int index) | 返回指定索引位置处的元素。取出的元素是Object类型,使用前品要进行益制类型转换 |
boolean contains(Object o) | 判断列表中是否存在指定元素 |
boolean remove(Object o) | 从列表中删除元素 |
Object remove(int index) | 从列表中删除指定位置元素,起始索引位量从0开始 |
2、ArrayList介绍
- ArrayList是可以动态增长和缩减的索引序列,它是基于数组实现的List类
- 该类封装了一个动态再分配的Object[]数组,每一个类对象都有一个capacity[容量]属性,表示它们所封装的Object[]数组的长度,当向ArrayList中添加元素时,该属性值会自动增加。如果想ArrayList中添加大量元素,可使用ensureCapacity方法一次性增加capacity,可以减少增加重分配的次数提高性能
- ArrayList的用法和Vector向类似,但是Vector是一个较老的集合,具有很多缺点,不建议使用
另外,ArrayList和Vector的区别是:ArrayList是线程不安全的,当多条线程访问同一个ArrayList集合时,程序需要手动保证该集合的同步性,而Vector则是线程安全的。
3、源码分析
3.1 继承结构与层次关系
1 | public class ArrayList<E> extends AbstractList<E> |
这里简单解释一下几个接口
- RandomAccess接口
这个是一个标记性接口,通过查看api文档,它的作用就是用来快速随机存取,有关效率的问题,在实现了该接口的话,那么使用普通的for循环来遍历,性能更高,例如ArrayList。而没有实现该接口的话,使用Iterator来迭代,这样性能更高,例如linkedList。所以这个标记性只是为了 让我们知道我们用什么样的方式去获取数据性能更好。 - Cloneable接口
实现了该接口,就可以使用Object.Clone()方法了。 - Serializable接口
实现该序列化接口,表明该类可以被序列化。什么是序列化?简单的说,就是能够从类变成字节流传输,然后还能从字节流变成原来的类。
这里的继承结构可通过IDEA中Navigate>Type Hierarchy查看
3.2 属性
1 | //版本号 |
3.3 构造方法
1 | /** |
3.4 自动扩容✨
每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过一个公开的方法ensureCapacity(int minCapacity)
来实现。在实际添加大量元素前,我也可以使用ensureCapacity
来手动增加ArrayList实例的容量,以减少递增式再分配的数量。
数组进行扩容时,会将**老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。**这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。
1 | private void ensureCapacityInternal(int minCapacity) { |
3.5 add()方法
1 | /** |
当调用add()方法时,实际函数调用:
add→ensureCapacityInternal→ensureExplicitCapacity(→grow→hugeCapacity)
例如刚开始初始化一个空数组后add一个值,会首先进行自动扩容
3.6 trimToSize()
将底层数组的容量调整为当前列表保存的实际元素的大小的功能
1 | public void trimToSize() { |
3.7 remove()方法
remove()
方法也有两个版本,一个是remove(int index)
删除指定位置的元素,另一个是remove(Object o)
删除第一个满足o.equals(elementData[index])
的元素。删除操作是add()
操作的逆过程,需要将删除点之后的元素向前移动一个位置。需要注意的是为了让GC起作用,必须显式的为最后一个位置赋null
值。
1 | public E remove(int index) { |
3.8 其他方法
这里简单介绍了核心方法,其他方法查看源码可以很快了解
3.9 Fail-Fast机制
ArrayList采用了快速失败的机制,通过记录modCount
参数来实现。在面对并发的修改时,迭代器很快就会完全失败,并抛出ConcurrentModificationException
异常,而不是冒着在将来某个不确定时间发生任意不确定行为的风险
4、总结
- ArrayList可以存放null
- ArrayList本质上就是一个elementData数组
- ArrayList区别于数组的地方在于能够自动扩展大小,其中关键的方法就是gorw()方法
- ArrayList中removeAll(collection c)和clear()的区别就是removeAll可以删除批量指定的元素,而clear是全是删除集合中的元素
- ArrayList由于本质是数组,所以它在数据的查询方面会很快,而在插入删除这些方面,性能下降很多,有移动很多数据才能达到应有的效果
- ArrayList实现了RandomAccess,所以在遍历它的时候推荐使用for循环
三、LinkedList分析
1、LinkedList使用
方法名 | 说明 |
---|---|
void addFirst(Object o) | 在列表的首部添加元素 |
void addLast(Object o) | 在列表的未尾添加元素 |
Object getFirst() | 返回列表中的第一个元素 |
Object getLast() | 返回列表中的最后一个元素 |
Object removeFirst() | 删除并返回列表中的第一个元素 |
Object removeLast() | 删除并返回列表中的最后一个元素 |
2、LinkedList介绍
LinkedList
同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。这样看来,LinkedList简直就是个全能冠军。当你需要使用栈或者队列时,可以考虑使用LinkedList
,一方面是因为Java官方已经声明不建议使用Stack类,更遗憾的是,Java里根本没有一个叫做Queue_的类(它是个接口名字)。关于栈或队列,现在的首选是ArrayDeque
,它有着比LinkedList
(当作栈或队列使用时)有着更好的性能。
LinkedList的实现方式决定了所有跟下标相关的操作都是线性时间,而在首段或者末尾删除元素只需要常数时间。为追求效率LinkedList没有实现同步(synchronized),如果需要多个线程并发访问,可以先采用Collections.synchronizedList()
方法对其进行包装
3、源码分析
3.1 继承结构与层次
1 | public class LinkedList<E> |
这里可以发现LinkedList多了一层AbstractSequentialList
的抽象类,这是为了减少实现顺序存取(例如LinkedList)这种类的工作。如果自己想实现顺序存取这种特性的类(就是链表形式),那么就继承 这个AbstractSequentialList抽象类,如果想像数组那样的随机存取的类,那么就去实现AbstracList抽象类。
- List接口
列表add、set等一些对列表进行操作的方法 - Deque接口
有队列的各种特性 - Cloneable接口
能够复制,使用那个copy方法 - Serializable接口
能够序列化。 - 没有RandomAccess
推荐使用iterator,在其中就有一个foreach,增强的for循环,其中原理也就是iterator,我们在使用的时候,使用foreach或者iterator
3.2 属性与构造方法
transient关键字修饰,这也意味着在序列化时该域是不会序列化的
1 | //实际元素个数 |
1 | public LinkedList() { |
3.3 内部类Node✨
1 | //根据前面介绍双向链表就知道这个代表什么了,linkedList的奥秘就在这里 |
3.4 核心方法add()和addAll()✨
1 | public boolean add(E e) { |
addAll()
有两个重载函数,addAll(Collection<? extends E>)
型和addAll(int,Collection<? extends E>)
型,我们平时习惯调用的addAll(Collection<?extends E>)
型会转化为addAll(int,Collection<? extends<E>)
型
1 | public boolean addAll(Collection<? extends E> c) { |
3.5 remove()
1 | /*如果我们要移除的值在链表中存在多个一样的值,那么我们 |
3.6 其他方法
**get(index)、indexOf(Object o)**等查看源码即可
3.7 LinkedList的迭代器
在LinkedList中除了有一个Node的内部类外,应该还能看到另外两个内部类,那就是ListItr
,还有一个是DescendingIterator
内部类
1 | /*这个类,还是调用的ListItr,作用是封装一下Itr中几个方法,让使用者以正常的思维去写代码, |
4、总结
- linkedList本质上是一个双向链表,通过一个Node内部类实现的这种链表结构。linkedList能存储null值
- 跟ArrayList相比较,就真正的知道了,LinkedList在删除和增加等操作上性能好,而ArrayList在查询的性能上好,从源码中看,它不存在容量不足的情况
- linkedList不光能够向前迭代,还能像后迭代,并且在迭代的过程中,可以修改值、添加值、还能移除值
- linkedList不光能当链表,还能当队列使用,这个就是因为实现了Deque接口
四、List总结
1、ArrayList和LinkedList区别
- ArrayList底层是用数组实现的顺序表,是随机存取类型,可自动扩增,并且在初始化时,数组的长度是0,只有在增加元素时,长度才会增加。默认是10,不能无限扩增,有上限,在查询操作的时候性能更好
- LinkedList底层是用链表来实现的,是一个双向链表,注意这里不是双向循环链表,顺序存取类型。在源码中,似乎没有元素个数的限制。应该能无限增加下去,直到内存满了在进行删除,增加操作时性能更好。
两个都是线程不安全的,在iterator时,会发生fail-fast:快速失效。
2、ArrayList和Vector区别
- ArrayList线程不安全,在用iterator,会发生fail-fast
- Vector线程安全,因为在方法前加了Synchronized关键字,也会发生fail-fast
3、fail-fast和fail-safe区别与情况说明
在java.util下的集合都是发生fail-fast,而在java.util.concurrent下的发生的都是fail-safe
- fail-fast
快速失败,例如在arrayList中使用迭代器遍历时,有另外的线程对arrayList的存储数组进行了改变,比 如add、delete等使之发生了结构上的改变,所以Iterator就会快速报一个java.util.ConcurrentModificationException
异常(并发修改异常),这就是快速失败 - fail-safe
安全失败,在java.util.concurrent
下的类,都是线程安全的类,他们在迭代的过程中,如果有线程进行结构的改变,不会报异常,而是正常遍历,这就是安全失败 - 为什么在java.util.concurrent包下对集合有结构的改变却不会报异常?
在concurrent下的集合类增加元素的时候使用Arrays.copyOf()
来拷贝副本,在副本上增加元素,如果有其他线程在此改变了集合的结构,那也是在副本上的改变,而不是影响到原集合,迭代器还是照常遍历,遍历完之后,改变原引用指向副本,所以总的一句话就是如果在此包下的类进行增加删除,就会出现一个副本。所以能防止fail-fast,这种机制并不会出错,所以我们叫这种现象为fail-safe - vector也是线程安全的,为什么是fail-fast呢?
出现fail-safe是因为他们在实现增删的底层机制不一样,就像上面说的,会有一个副本,而像arrayList、linekdList、verctor等他们底层就是对着真正的引用进行操作,所以才会发生异常
4、为什么现在都不提倡使用Vector
- vector实现线程安全的方法是在每个操作方法上加锁,这些锁并不是必须要的,在实际开发中,一般都是通过锁一系列的操作来实现线程安全,也就是说将需要同步的资源放一起加锁来保证线程安全
- 如果多个Thread并发执行一个已经加锁的方法,但是在该方法中,又有Vector的存在,Vector
本身实现中已经加锁了,那么相当于锁上又加锁,会造成额外的开销 - Vector还有fail-fast的问题,也就是说它也无法保证遍历安全,在 遍历时又得额外加锁,又是额外的开销,还不如直接用arrayList,然后再加锁
总结:Vector在你不需要进行线程安全的时候,也会给你加锁,也就导致了额外开销,所以在jdk1.5之后就被弃用了,现在如果要用到线程安全的集合,都是从java.util.concurrent
包下去拿相应的类。
五、HashMap分析
1、HashMap介绍
1.1 Java8以前的HashMap
通过key、value封装成一个entry对象,然后通过key的值来计算该entry的hash值,通过entry的hash 值和数组的长度length来计算出entry放在数组中的哪个位置上面,每次存放都是将entry放在第一个位置。
HashMap实现了Map接口,即允许放入key
为null
的元素,也允许插入value
为null
的元素;除该类未实现同步外,其余跟Hashtable
大致相同;跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。 根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap采用的是冲突链表方式。
1.2 Java8后的HashMap
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
2、Java8 HashMap源码分析
2.1 继承结构与层次
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
2.2 属性
1 | //序列号 |
2.3 构造方法
1 | public HashMap(int initialCapacity, float loadFactor) { |
2.4 核心方法✨
put()方法
先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,则查找是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链表尾部,注意JDK1.7中采用的是头插法,即每次都将冲突的键值对放置在链表头,这样最初的那个键值对最终就会成为链尾,而JDK1.8中使用的是尾插法。此外,若table在该处没有元素,则直接保存。
1 | public V put(K key, V value) { |
resize() 数组扩容
用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移
1 | final Node<K,V>[] resize() { |
get()过程
1 | public V get(Object key) { |
2.5 其他方法
HashSet是对HashMap的简单包装,其他还有迭代器等
3、总结
关于数组扩容,从putVal源代码中我们可以知道,当插入一个元素的时候size就加1,若size大于threshold的时候,就会进行扩容。假设我们的capacity大小为32,loadFator为0.75,则threshold为24 = 32 * 0.75,此时,插入了25个元素,并且插入的这25个元素都在同一个桶中,桶中的数据结构为红黑树,则还有31个桶是空的,也会进行扩容处理,其实此时,还有31个桶是空的,好像似乎不需要进行扩容处理,但是是需要扩容处理的,因为此时我们的capacity大小可能不适当。我们前面知道,扩容处理会遍历所有的元素,时间复杂度很高;前面我们还知道,经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。所以说尽量避免进行扩容处理,也就意味着,遍历元素所带来的坏处大于元素在桶中均匀分布所带来的好处。
- HashMap在JDK1.8以前是一个链表散列这样一个数据结构,而在JDK1.8以后是一个数组加链表加红黑树的数据结构
- 通过源码的学习,HashMap是一个能快速通过key获取到value值得一个集合,原因是内部使用的是hash查找值得方法
另外LinkedHashMap是HashMap的直接子类,二者唯一的区别是LinkedHashMap在HashMap的基础上,采用双向链表(doubly-linked list)的形式将所有**entry**
连接起来,这样是为保证元素的迭代顺序跟插入顺序相同
六、Collections工具类
1、概述
此类完全由在 collection 上进行操作或返回 collection 的静态方法组成。它包含在 collection 上操作的多态算法,即“包装器”,包装器返回由指定 collection 支持的新 collection,以及少数其他内容。如果为此类的方法所提供的 collection 或类对象为 null,则这些方法都将抛出NullPointerException
2、排序常用方法
1 | //反转列表中元素的顺序 |
3、查找、替换操作
1 | //使用二分搜索法搜索指定列表,以获得指定对象在List集合中的索引 |
4、同步控制
Collectons提供了多个synchronizedXxx()方法,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。正如前面介绍的HashSet,TreeSet,arrayList,LinkedList,HashMap,TreeMap都是线程不安全的。Collections提供了多个静态方法可以把他们包装成线程同步的集合。
1 | //返回指定 Collection 支持的同步(线程安全的)collection |
5、Collection设置不可变集合
1 | //返回一个空的、不可变的集合对象,此处的集合既可以是List,也可以是Set,还可以是Map。 |
参考问题
https://pdai.tech/md/java/collection/java-collection-all.html
https://blog.csdn.net/fuzhongmin05/article/details/104355841