List
ArrayList和LinkedList的区别
简单的数据结构内容,先不在此阐述…
Queue
ArrayDeque与LinkedList的区别
在Java做算法题的时候,我们时常使用Deque通过多态实现队列和栈,其具体实现 LinkedList 和 ArrayDeque又有什么区别呢?
简单来说:
- ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现
- ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持
- ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢
综上,选用 ArrayDeque 实现队列和栈性能更好一些。
Map⭐
HashMap
JDK1.8之前的底层实现
数组+链表,即链表散列。
HashMap通过 key 的 hashcode,经过扰动函数处理后得到hash值,通过(n - 1) & hash
(n = 数组长度),如果存在元素,则判断该元素和要存入的元素hash值以及key是否相同,相同则直接覆盖,不同则拉链法解决冲突。
JDK1.8之后的底层实现
当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
从链表转换为红黑树的源码很容易理解:
1、putVal
方法中遍历链表来判断是否需要转换
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 遍历到链表最后一个节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表元素个数大于TREEIFY_THRESHOLD(8)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为binCount是从0开始计数的
// 红黑树转换(并不会直接转换成红黑树)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
2、treeifyBin
方法中判断是否真的转换为红黑树
数组长度是否小于64,如果小于就进行数组扩容。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 判断当前数组的长度是否小于 64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果当前数组的长度小于 64,那么会选择先进行数组扩容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 否则才将列表转换为红黑树
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
HashMap数组长度为什么是2的幂次方
哈希值在Java中使用int来表示,也就是一个长度为40亿的空间映射。但是肯定存放不下这么长的数组,所以要对数组进行取余 %
取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作,也就是说如果保证了数组长度为2^n,那么我们就可以使用更有效率的位运算 & ,hash%length==hash&(length-1)
,来提高运算效率。
此外,在HashMap扩容的时候,可以更加均匀,例如:
- length = 8 时,length - 1 = 7 的二进制位0111
- length = 16 时,length - 1 = 15 的二进制位1111
原本在HashMap中的元素重新计算数组位置后,只要关心hash的第四个二进制数是不是1就可以了,是1则存放在扩容数组中(原位置+原容量),是0则数组位置不变。
为什么不直接采用红黑树?
- 时间:链表长度小的时候,遍历查找O(n)时间复杂度也是很快的,只有链表长度变大(>8)之后,才有必要提升为O(log(n))
- 空间:红黑树的构造更复杂一些,并且TreeNode占用空间为Node的两倍。
为什么转换阈值选为8
理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,链表中达到8个元素的概率几乎不可能,如果达到了那么说明链表性能很差,未来还有可能超过这个数量,所以需要红黑树提升性能。
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要。长度<=6,性能提升就不是很明显了
为什么退化阈值选为6
如果阈值同样为8的话,那么添加和删除会频繁导致转换,效率低下。
但是由于树节点占用的空间更多,根据上一题答案中**“长度<=6,性能提升就不是很明显了”**,可以知道这个阈值可以进行转换。
线程安全问题⭐
JDK1.7中会出现数据死链、死循环和数据丢失问题
在扩容的时候,使用的是头插法。
在多线程的情况下,粉色线程进行扩容操作,在将 one 复制到新的位置31后,蓝色线程的e指针指向了扩容前后的两个 one 结点。
随后,粉色线程再次进行头插操作,此时可以发现,指针关系变为了,2 -> 1 -> null
这时粉色线程结束,蓝色线程开始进行扩容,首先,会将自己e指向的one结点放置于扩容后的第一个位置,和前面粉色线程的扩容操作没有什么不同。随后,e指向了之前早已设置好的next,也就是two结点。
执行又一次头插法,指针关系为 2 -> 1,这个时候的e又指向了next,也就是结点one,所以就又要插入一次结点one:
至此,也就形成了循环,1->2->1
JDK1.8中使用了尾插法,解决了死循环的问题,但是数据丢失的问题还是没有解决。
这个问题很容易解释,一共两种情况:
也就是当线程1和线程2发生了哈希冲突,同时到了判断hash桶是否为空时,两者都判断为空,但是线程1在判断完后时间片耗尽,线程2进行判断和插入新节点,由于桶为空,所以这个结点直接就放置在数组的 tab[i] 中,这时线程1苏醒,执行插入结点的逻辑,由于之前判断为空,所以也是直接将结点放入数组中,也就直接替换了线程2的结点。
还有一种情况是if(++size > threshold)
判断中,size实际应该+2 ,但由于自增并不是原子操作,所以size只加了1,导致实际上也只有一个元素被加入。
为了解决并发问题,引入了ConcurrentHashMap
!
ConcurrentHashMap
JDK1.7的底层实现
Segment 数组 + HashEntry 数组 + 链表
给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
Segment
继承了ReentrantLock
,其数组大小初始化后就没法改变,其大小也就是可以同时支持并发写的线程数。
JDK1.8之后的底层实现
采用 Node + CAS + synchronized
来保证并发安全,和HashMap1.8一样,链表长度超过阈值(8)即转换为红黑树。
锁粒度更细。synchronized
只锁定当前链表或红黑二叉树的首节点,也就是说只要hash不冲突就不会产生并发。最大并发线程数为Node数组的大小。
ConcurrentHashMap 为什么 key 和 value 不能为 null?
如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。
同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。
在多线程中,多个线程进行操作的时候,containsKey(key)
的二义性是无法接受和容忍的。而HashMap可以设置为null,也是因为单线程操作,不存在二义性的问题。
ConcurrentHashMap 能保证复合操作的原子性吗?
多个基本操作(如put、get、remove、containsKey等)组成的操作是不可以保证原子性的!
但是提供了如下的原子性复合操作:
如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等