Java 集合, 也叫作容器,主要是由两大接口派生而来:
一个是 Collection
接口,主要用于存放单一元素;对于Collection
接口,下面又有三个主要的子接口:List
、Set
和 Queue
。
另一个是 Map
接口,主要用于存放键值对。
List
(对付顺序的好帮手): 存储的元素是有序的、可重复的。Set
(注重独一无二的性质): 存储的元素是无序的、不可重复的。Queue
(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。先来看一下 Collection
接口下面的集合。
List
Arraylist
: Object[]
数组
Vector
:Object[]
数组
LinkedList
: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
HashSet
(无序,唯一): 基于 HashMap
实现的,底层采用 HashMap
来保存元素
LinkedHashSet
: LinkedHashSet
是 HashSet
的子类,并且其内部是通过 LinkedHashMap
来实现的。有点类似于我们之前说的 LinkedHashMap
其内部是基于 HashMap
实现一样,不过还是有一点点区别的
TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)
Queue
PriorityQueue
: Object[]
数组来实现二叉堆
ArrayQueue
: Object[]
数组 + 双指针
再来看看 Map
接口下面的集合
Map
HashMap
: JDK1.8 之前 HashMap
由数组+链表组成的,数组是 HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
LinkedHashMap
: LinkedHashMap
继承自 HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》 (opens new window)
Hashtable
: 数组+链表组成的,数组是 Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的
TreeMap
: 红黑树(自平衡的排序二叉树)
ArrayList
是 List
的主要实现类,底层使用 Object[ ]
存储,适用于频繁的查找工作,线程不安全 ;Vector
是 List
的古老实现类,底层使用 Object[ ]
存储,线程安全的。是否保证线程安全: ArrayList
和 LinkedList
都是不同步的,也就是不保证线程安全;
底层数据结构: Arraylist
底层使用的是 Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
插入和删除是否受元素位置的影响:
① ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。
② LinkedList
采用链表存储,所以对于add(E e)
方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i
插入和删除元素的话((add(int index, E element)
) 时间复杂度近似为o(n),因为需要先移动到指定位置再插入。
是否支持快速随机访问: LinkedList
不支持高效的随机元素访问,而 ArrayList
支持。
内存空间占用: ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList
的空间花费则体现在它的每一个元素都需要消耗比 ArrayList
更多的空间(因为要存放直接后继和直接前驱以及数据)。
jdk 7
以无参数构造方法创建 ArrayList
时,底层创建了长度是10的Object[]数组elementData。
当添加元素导致底层elementData数组容量不够,则扩容。
默认情况下,扩容为原来的容量的1.5倍,然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,如果新容量大于 MAX_ARRAY_SIZE
,进入hugeCapacity()
方法来比较 minCapacity
和 MAX_ARRAY_SIZE
,如果minCapacity
大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为 MAX_ARRAY_SIZE
即为 Integer.MAX_VALUE - 8
,最后调用Arrays.copyOf(elementData, newCapacity)
将原有数组中的数据复制到新的数组中。
建议开发中使用带参的构造器:
ArrayList list = new ArrayList(int capacity)
ArrayList
时,底层Object[] elementData初始化为空数组{}。小结:jdk7中的ArrayList的对象的创建类似于单例的饿汉式,而jdk8中的ArrayList的对象的创建类似于单例的懒汉式,延迟了数组的创建,节省内存。
Set接口:存储无序的、不可重复的数据
HashSet
、LinkedHashSet
和 TreeSet
都是 Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和 TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于 HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。1、什么是无序性?无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
2、什么是不可重复性?不可重复性是指添加的元素按照 equals()判断是否相同 ,需要同时重写 equals()
方法和 HashCode()
方法。
equals和hashCode这两个方法都是从object类中继承过来的,equals主要用于判断对象的内存地址引用是否是同一个地址;hashCode根据定义的哈希规则将对象的内存地址转换为一个哈希码。HashSet中存储的元素是不能重复的,主要通过hashCode与equals两个方法来判断存储的对象是否相同:
Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue
扩展了 Collection
的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 |
抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque
是双端队列,在队列的两端均可以插入或删除元素。
Deque
扩展了 Queue
的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 |
抛出异常 | 返回特殊值 |
---|---|---|
插入队首 | addFirst(E e) | offerFirst(E e) |
插入队尾 | addLast(E e) | offerLast(E e) |
删除队首 | removeFirst() | pollFirst() |
删除队尾 | removeLast() | pollLast() |
查询队首元素 | getFirst() | peekFirst() |
查询队尾元素 | getLast() | peekLast() |
事实上,Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。
PriorityQueue
是在 JDK1.5 中被引入的, 其与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储 NULL
和 non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个 Comparator
作为构造参数,从而来自定义元素优先级的先后。线程是否安全: HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
);
效率: 因为线程安全的问题,HashMap
要比 Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它;
对 Null key 和 Null value 的支持: HashMap
可以存储 null 的 key 和 value;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
。
初始容量大小和每次扩充容量大小的不同 :
① 创建时如果不指定容量初始值,Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
② 创建时如果给定了容量初始值,那么 Hashtable
会直接使用给定的大小,而 HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证)。也就是说 HashMap
总是使用 2 的幂作为哈希表的大小。
底层数据结构: JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
底层数据结构: JDK1.7 的 ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的 HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
① 在 JDK1.7 的时候,ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段(Segment
),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment
的概念,而是直接用 Node
数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized
和 CAS 来操作。(JDK1.6 以后 对 synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap
,虽然在 JDK1.8 中还能看到 Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本;
② Hashtable
(同一把锁):使用 synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
如果你看过 HashSet
源码的话就应该知道:HashSet
底层就是基于 HashMap
实现的。(HashSet
的源码非常非常少,因为除了 clone()
、writeObject()
、readObject()
是 HashSet
自己不得不实现之外,其他方法都是直接调用 HashMap
中的方法)。
HashMap |
HashSet |
---|---|
实现了 Map 接口 |
实现 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put() 向 map 中添加元素 |
调用 add() 方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 hashcode |
HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals() 方法用来判断对象的相等性 |
JDK 7及以前版本:HashMap是数组+链表实现
JDK 8版本发布以后:HashMap是数组+链表/红黑树实现。
hashCode
和equals
,并且**key的内容不能修改 **(不可变)hashCode
应该有良好的散列性hashCode()
HashMap
的hash()
方法进行二次哈希
hash()
是为了综合高位数据,让哈希分布更为均匀capacity
必须是2的n次幂,这里相当于mod( capacity )
put流程
1.7与1.8的区别
Entry[] table
。1.8 new HashMap()
底层没有创建一个长度为16的数组,首次使用才创建Node[] table
数组。loadFactor * 数组总长度
)且没有空位时才扩容(容量×2);而1.8是大于阈值就扩容,并且链表长度超过8时,会先尝试扩容来减少链表长度,如果数组容量已经>=64,会转化成红黑树。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yiSkcy5k-1646466040175)(E:\Typora\imgs\image-20220225164756916.png)]
树化规则
退化规则
get
方法出现失误的情况。(数据错乱)synchronized
锁。31 * h
可以被优化为位运算,即 h<<5 - h
fast-fail是Java集合的一种错误机制,遍历的同时不能修改,尽快失败。当多个线程对同一个集合进行操作时,就有可能会产生fast-fail
事件。例如:当线程a正通过iterator遍历集合时,另一个线程b修改了集合的内容,此时modCount
(记录集合操作过程的修改次数)会加1,不等于expectedModCount
,那么线程a访问集合的时候,就会抛出ConcurrentModificationException
,产生fast-fail
事件。边遍历边修改集合也会产生fast-fail
事件。
解决方法:
Colletions.synchronizedList()
方法或在修改集合内容的地方加上synchronized
。这样的话,增删集合内容的同步锁会阻塞遍历操作,影响性能。CopyOnWriteArrayList
来替换ArrayList
。在对CopyOnWriteArrayLis
t进行修改操作的时候,会拷贝一个新的数组,对新的数组进行操作,操作完成后再把引用移到新的数组。采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception
。(读写分离)
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception
,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。(牺牲了一定的一致性)。