- 说说 List,Set,Map 三者的区别?三者底层的数据结构?
- 有哪些集合是线程不安全的?怎么解决呢?
- ArrayList 和 LinkedList 的区别
- ArrayList的扩容机制
- Queue 和 Deque 的区别
- 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashMap 和 Hashtable 的区别?HashMap 和 HashSet 区别?HashMap 和 TreeMap 区别?
- HashMap 的底层实现
- HashMap 的长度为什么是 2 的幂次方
- ConcurrentHashMap 和 Hashtable 的区别?
- ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
集合底层数据结构
- List
ArrayList
:Object[]
数组Vector
:Object[]
数组LinkedList
: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
- Map
HashMap
: JDK1.8 之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间LinkedHashMap
:LinkedHashMap
继承自HashMap
,由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
: 数组+链表TreeMap
: 红黑树(自平衡的排序二叉树)
- Set
HashSet
: 基于HashMap
实现的,底层采用HashMap
来保存元素LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。TreeSet
: 红黑树(自平衡的排序二叉树)
- Queue
PriorityQueue
:Object[]
数组来实现二叉堆ArrayDeque
:Object[]
数组 + 双指针
Collection
ArrayList扩容机制
ArrayList
的扩容机制如下:
- 当向
ArrayList
中添加第一个元素时,ArrayList
会创建一个大小为10的数组。 - 当继续向
ArrayList
中添加元素时,如果当前元素个数已经等于数组大小,就需要扩容。扩容的大小默认为原来的容量大小加上原来容量大小的一半,也就是新的容量大小为原来容量大小的1.5倍。 ArrayList
在扩容时,会创建一个新的数组,将原有的元素复制到新的数组中,并将新的元素添加到新的数组中。- 扩容后,
ArrayList
的容量会变成新的容量大小。
需要注意的是,ArrayList
的扩容是一种比较耗时的操作,因为它需要将原有的元素复制到新的数组中。因此,在预知需要存储大量元素时,可以通过指定初始容量来减少扩容的次数,从而提高ArrayList
的性能。另外,如果我们已经知道要添加的元素个数,也可以使用ArrayList
的构造函数来指定初始容量,以避免不必要的扩容操作。
ArrayList 与 LinkedList 区别?
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; - 底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环) - 插入和删除是否受元素位置的影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList
采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响,时间复杂度为 O(1),如果是要在指定位置i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
- 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 - 内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间。
我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
。
HashSet、LinkedHashSet、TreeSet
HashSet
、LinkedHashSet
和 TreeSet
的主要区别在于底层数据结构不同。
HashSet
的底层数据结构是哈希表。
LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。
TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
Queue 与 Deque 的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue
扩展了 Collection
的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Deque
是双端队列,在队列的两端均可以插入或删除元素。
Deque
扩展了 Queue
的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
事实上,Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。
PriorityQueue
PriorityQueue
是在 JDK1.5 中被引入的, 其与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储NULL
和non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个Comparator
作为构造参数,从而来自定义元素优先级的先后。
Map
负载因子和阈值
负载因子:static final float DEFAULT_LOAD_FACTOR = 0.75f
map扩容判断时用的,也能够在构造函数中进行指定(不推荐),每次进行put的时候都会进行判断是否需要扩容,当size超过了阈值=总容量 * 负载因子,则会扩容,默认情况下初始总容量是16,负载因子是0.75,0.75是经过大量的实验证明该系数是最合适的。
如果设置过小,HashMap扩容频率太高,消耗大量的性能。
如果设置过大的话,当HashMap存储的数据数量接近总容量时,发生hash碰撞的概率将达到接近1,这违背的HashMap减少hash碰撞的原则。
头插与尾插
1.8采用的尾插法,1.7采用的头插法
当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表尾部;而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法,将节点添加到链表头部。
-
put元素时为了计算链表长度,判断是否要转为红黑树
-
rehash时,头插法会打乱节点顺序。容易造成死循环
HashMap的长度为什么是2的幂次方
数组下标的计算方法是“ (n - 1) & hash
”。(n 代表数组长度)。
取余(%)操作中如果除数是 2 的幂次方则等价于与其除数减一的与(&)操作。
采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
HashMap 和 Hashtable 的区别
- 线程是否安全:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized
修饰。 - 效率: 因为线程安全的问题,
HashMap
要比Hashtable
效率高。另外,Hashtable
基本被淘汰,不要在代码中使用它; - 对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。 - 初始容量大小和每次扩充容量大小的不同 : 创建时如果不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。创建时如果给定了容量初始值,那么Hashtable
会直接使用你给定的大小,而HashMap
会将其扩充为 2 的幂次方大小。 - 底层数据结构: JDK1.8 以后的
HashMap
为数组+链表+红黑树,以减少搜索时间。Hashtable
没有这样的机制。
HashMap 和 TreeMap 区别
TreeMap
和HashMap
都继承自AbstractMap
,但是需要注意的是TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。
实现 NavigableMap
接口让 TreeMap
有了对集合内元素的搜索的能力。
实现SortedMap
接口让 TreeMap
有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
ConcurrentHashMap 和 Hashtable 的区别
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
采用 数组+链表 的形式 - 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁) ,Segment
继承自ReentrantLock
。每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。最大并发度是 Segment 的个数,默认是 16。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。最大并发度是 Node 数组的大小,并发度更大。 Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。
- 在 JDK1.7 的时候,
JDK1.7 的 ConcurrentHashMap :
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。Segment
数组中的每个元素包含一个 HashEntry
数组,每个 HashEntry
数组属于链表结构。
首先将数据分为一段一段(这个“段”就是 Segment
)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
Segment
继承了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
一个 ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
JDK1.8 的 ConcurrentHashMap :
数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。