1 Map整体结构

首先,我们先对 Map 相关类型有个整体了解,Map 虽然通常被包括在 Java 集合框架里,但是其本身并不是狭义上的集合类型(Collection),具体你可以参考下面这个简单类图。

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操作数据的容器类型

2 哈希表(Hash table),也叫散列表

2.1 什么是哈希表?

答:哈希表就是通过一个映射函数f(key)将一组数据散列存储在数组中的一种数据结构在这哈希表中,每一个元素的key和它的存储位置都存在一个f(key)的映射关系,我们可以通过f(key)快速的查找到这个元素在表中的位置

2.2 什么是哈希函数 (Hash Function),也叫散列函数?

答:哈希函数,即是f(key)的映射关系。举个例子,有一组数据:[19,24,6,33,51,15],我们用散列存储的方式将其存储在一个长度为11的数组中。采用除留取余法,将这组数据分别模上数组的长度(即f(key)=key % 11),以余数作为该元素在数组中的存储的位置。则会得到一个如下图所示的哈希表:

此时,如果我们想从这个表中找到值为15的元素,只需要将15模上11即可得到15在数组中的存储位置。可见哈希表对于查找元素的效率是非常高的。

2.3 什么是哈希冲突?

答:假如我们向这组数据中再插入一些元素,插入后的数据为:[19,24,6,33,51,15,25,72],新元素25模11后得到3,存储到3的位置没有问题。而接下来我们对72模11之后得到了6,而此时在数组中6的位置已经被其他元素给占据了。“72“只能很无奈的表示我放哪呢?这种情况我们将其称之为哈希冲突。

2.4 如何减少哈希冲突?

答:一般情况下,哈希冲突只能尽可能的减少,但不可能完全避免。一个好的哈希函数可以有效的减少哈希冲突的出现。那什么样的哈希函数才是一个好的哈希函数呢?通常来说,一个好的哈希函数对于关键字集合中的任意一个关键字,经过这个函数映射到地址集合中任何一个集合的概率是相等的

常用的构造哈希函数的方法有以下几种:
(1)除留取余法:这个方法我们在上边已经有接触过了。取关键字被某个不大于哈希表长m的数p除后所得余数为哈希地址。即:f(key)=key % p, p≤m;
(2)直接定址法:直接定址法是指取关键字或关键字的某个线性函数值为哈希地址。即:f(key)=key 或者 f(key)=a*key+b;
(3)数字分析法:假设关键字是以为基的数(如以10为基的十进制数),并且哈希表中可能出现的关键字都是事先知道的,则可以选取关键字的若干位数组成哈希表。

2.5 如何处理哈希冲突?

答:(1)开放定址法:开放定址法是指当发生地址冲突时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。72模11后得到6,而此时6的位置已经被其他元素占用了,那么将6加1得到7, 此时发现7的位置也被占用了,那就再加1得到下一个地址为8,而此时8仍然被占用,再接着加1得到9,此时9处为空,则将72存入其中,即得到如下哈希表:

(2)再哈希法:再哈希法即选取若干个不同的哈希函数,在产生哈希冲突的时候计算另一个哈希函数,直到不再发生冲突为止
(3)链地址法:链地址法是指在碰到哈希冲突的时候,将冲突的元素以链表的形式进行存储。也就是凡是哈希地址为i的元素都插入到同一个链表中,元素插入的位置可以是表头(头插法),也可以是表尾(尾插法)

(4)建立公共溢出区:专门维护一个溢出表,当发生哈希冲突时,将值填入溢出表。

2.6 链地址法的弊端与优化

答:举个例子,我们现在有这样一组数据:[48,15,26,4,70,82,59]。我们将这组数据仍然散列存储到长度为11的数组中,此时则得到了如下的结果:

可以发现,此时的哈希表俨然已经退化成了一个链表,当我们在这样的数据结构中去查找某个元素的话,时间复杂度又变回了o(n)。我们知道,红黑树是一个可以自平衡的二叉查找树,查询的时间复杂度为o(logn),查询效率是远远高于链表的,因此,当哈希表中的链表过长时我们就可以把这个链表变成一棵红黑树,得到如下结果:红黑树是一个可以自平衡的二叉查找树。

2.8 学习链接

哈希表都不知道,你是怎么看懂HashMap的?

3 HashMap

3.1 HashMap的特点是什么?

答:HashMap最早是在JDK1.2中开始出现的,一直到JDK1.7一直没有太大的变化。但是到了JDK1.8突然进行了一个很大的改动。其中一个最显著的改动就是:之前JDK1.7的存储结构是数组+链表,到了JDK1.8变成了数组+链表+红黑树。另外,HashMap是非线程安全的,也就是说在多个线程同时对HashMap中的某个元素进行增删改操作的时候,是不能保证数据的一致性的。

3.2 HashMap的底层数据结构是什么?

答:之前JDK1.7的存储结构是数组+链表,到了JDK1.8变成了数组+链表+红黑树当一个值中要存储到HashMap中的时候会根据Key的值来计算出他的hash,通过hash值来确认存放到数组中的位置,如果发生hash冲突就以链表的形式存储;当链表大于 8 并且容量大于 64 时,HashMap会把这个链表转换成红黑树来存储。

3.2.1 什么是红黑树呢?

答:红黑树是一个近似平衡的二叉搜索树,关键性质是从根到叶子的最长的可能路径不多于最短的可能路径的两倍长,因此说红黑树的查找效率是非常的高,查找效率会从链表的o(n)降低为o(logn)。算法刻意练习之树/二叉树/二叉搜索树/AVL树和红黑树

3.2.2 为什么HashMap中链表大于8,并且容量大于64时才转为红黑树?为什么 Map 桶中超过 8 个才转为红黑树?

答:从两方面来解释
(1)为了提升查找性能,需要把链表转化为红黑树的形式:每次遍历一个链表,平均查找的时间复杂度是O(n),n 是链表的长度;红黑树有自平衡的特点,可以防止不平衡情况的发生,查找的时间复杂度控制在O(log(n))。最初链表还不是很长,所以可能O(n)和O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。

(2)体现了时间和空间平衡的思想:单个TreeNode需要占用的空间大约是普通Node的两倍,最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,链表长度达到8时需要转成红黑树,用红黑树的形式来保证查询的效率;而当红黑树节点长度降到6就转换回去链表,以便节省空间

3.2.3 为什么从链表转化为红黑树的阈值要默认设置为 8 呢?

答:(1)下面这段话的意思是,如果hashCode分布良好,也就是hash计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为8的时候,概率仅为0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换.

(2)事实上,链表长度超过8就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率

(3)总结经验:如果平时开发中发现HashMap或是ConcurrentHashMap内部出现了红黑树的结构,这个时候往往就说明我们的哈希算法出了问题,需要留意是不是我们实现了效果不好的hashCode方法,并对此进行改进,以便减少冲突

In usages with well-distributed user hashCodes, tree bins
are rarely used.  Ideally, under random hashCodes, the
frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because
of resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:0:    0.606530661:    0.303265332:    0.075816333:    0.012636064:    0.001579525:    0.000157956:    0.000013167:    0.000000948:    0.00000006more: less than 1 in ten million

但是,HashMap决定某一个元素落到哪一个桶里,是和这个对象的hashCode有关的,JDK并不能阻止我们实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:

public static void change2TreeNode() {HashMap map = new HashMap<HashMapTest, Integer>(1);for (int i = 0; i < 1000; i++) {HashMapTest hashMapTest = new HashMapTest();map.put(hashMapTest, null);}System.out.println("change2TreeNode 运行结束");}// hashCode 计算出来的值始终为 1,那么就很容易导致 HashMap 里的链表变得很长@Overridepublic int hashCode() {return 1;}

观察 map 内的节点,可以发现已经变成了 TreeNode,而不是通常的 Node,这说明内部已经转为了红黑树。

3.3 什么是加载因子?什么是扩容?加载因子为什么是 0.75?再问 HashMap,拿这篇给TA!

答:(1)加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的依据。假如加载因子是0.5,HashMap的初始化容量是16,那么当HashMap中有16*0.5=8个元素时,HashMap就会进行扩容。

(2)扩容并不是在原数组基础上扩大容量,而是需要申请一个长度为原来2倍的新数组。因此,扩容之后就需要将原来的数据从旧数组中重新散列存放到扩容后的新数组,这个过程我们称之为Rehash

(2)出于容量和性能之间平衡的结果当加载因子比较大的时候,扩容发生的频率比较低,数组的长度会比较小,此时发生Hash冲突的概率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素操作性能比较低

当加载因子比较小的时候,扩容发生的频率比较高,数组的长度会比较大,此时元素的存储就比较稀疏,发生Hash冲突的概率就比较小,因此需要更简单的数据结构来存储元素,这样对元素操作性能会比较高

所以综合了以上情况就取了一个0.5到1.0的平均数0.75作为加载因子

// HashMap 初始化长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
// HashMap 最大长度
static final int MAXIMUM_CAPACITY = 1 << 30; // 1073741824
// 默认的加载因子 (扩容因子)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当链表长度大于此值且容量大于 64 时
static final int TREEIFY_THRESHOLD = 8;
// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树容量
static final int MIN_TREEIFY_CAPACITY = 64

3.4 为什么要把数组长度设计为2的幂次方呢?

3.4.1 如何通过tableSizeFor()方法来确保HashMap数组长度永远为2的幂次?

答:通过tableSizeFor()方法来确保HashMap数组长度永远为2的幂次;它的功能是返回大于等于输入参数且最近的2的整数次幂的数。比如10,则返回16

/*找到大于或等于 cap 的最小2的幂,用来做容量阈值*/
static final int tableSizeFor(int cap) {int n = cap - 1;n |= n >>> 1;n |= n >>> 2;n |= n >>> 4;n |= n >>> 8;n |= n >>> 16;return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

3.4.2 为什么要把数组长度设计为2的幂次方呢?

答:(1)当数组长度为2的幂次方时,可以使用位运算来计算元素在数组中的下标:HashMap是通过index=hash&(table.length-1)公式来计算元素在table数组中存放的下标,就是把元素的hash值和数组长度减1的值做一个与运算,即可求出该元素在数组中的下标,这条公式其实等价于hash%length,也就是对数组长度求模取余只不过只有当数组长度为2的幂次方时,hash&(length-1)才等价于hash%length,使用位运算可以提高效率

(2)增加hash值的随机性,减少hash冲突:如下图,如果length为2的幂次方,则length-1转化为二进制必定是11111……的形式。比如length为16,则length-1为15,对应的二进制为1111,hash值时5、6、7,结果也是5、6、7,相对均匀分布;而如果length为15(假设等价hash%length),则length-1为14,对应的二进制为1110,hash值时5、6、7,结果也是5、6、6,产生了碰撞。

3.5 HashMap查询元素的原理?HashMap源码分析(jdk1.8,保证你能看懂)

答:查找流程:(1)首先通过自定义的hash方法计算出key的hash值,求出在数组中的位置;(2)判断该位置上是否有节点,若没有则返回null,代表查询不到指定的元素;(3)若有则判断该节点是不是要查找的元素,若是则返回该节点;(4)若不是则判断节点的类型,如果是红黑树的话,则调用红黑树的方法去查找元素;(5)如果是链表类型,则遍历链表调用equals方法去查找元素

public V get(Object key) {Node<K,V> e;// 对 key 进行哈希操作return (e = getNode(hash(key), key)) == null ? null : e.value;}
final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;// 非空判断if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {// 判断第一个元素是否是要查询的元素if (first.hash == hash && // always check first node((k = first.key) == key || (key != null && key.equals(k))))return first;// 下一个节点非空判断if ((e = first.next) != null) {// 如果第一节点是树结构,则使用 getTreeNode 直接获取相应的数据if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);do { // 非树结构,循环节点判断// hash 相等并且 key 相同,则返回此节点if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null;}

3.6 HashMap插入元素的原理?

答:第一部分:调用put方法传入键值对,哈希表(数组)为空则通过resize方法来创建一个新的数组

第二部分:根据 (n - 1) & hash 计算出要插入的数组位置,如果该位置未存放节点,即是不存在hash冲突,直接newNode并插入到数组中

第三部分:如果hash冲突,则进行链表操作或者红黑树操作(如果链表树超过8,则修改链表为红黑树);
第三部分a:如果第一个节点就是要插入的键值对,则让e指向第一个节点p;
第三部分b:如果该节点是红黑树节点,那就直接插入键值对到红黑树中;
第三部分c:如果该节点是链表节点,相同的key进行put就会覆盖原先的value;否则插入链表尾部,如果链表长度大于8,数组容量大于64,则将链表转为红黑树

第四部分:实际存在的键值对数量超过最大容量,扩容

public V put(K key, V value) {// 参数1 hash:调用了hash方法对key计算hash值// 参数2 key:键// 参数3 value:值// 参数4 onlyIfAbsent:当键相同时,不修改已存在的值// 参数5 evict :如果为false,那么数组就处于创建模式中,所以一般为truereturn putVal(hash(key), key, value, false, true);}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 第一部分:调用put方法传入键值对,哈希表(数组)为空则通过resize方法来创建一个新的数组;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 第二部分:根据 (n - 1) & hash 计算出要插入的数组位置,如果该位置未存放节点,即是不存在hash冲突,直接newNode并插入到数组中;if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);// 第三部分:如果hash冲突,则进行链表操作或者红黑树操作(如果链表树超过8,则修改链表为红黑树);else {Node<K,V> e; K k;// 第三部分a:如果第一个节点就是要插入的键值对,则让e指向第一个节点p;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;// 第三部分b:如果该节点是红黑树节点,那就直接插入键值对到红黑树中;else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 第三部分c:如果该节点是链表节点,相同的key进行put就会覆盖原先的value;否则插入链表尾部,如果链表长度大于8,数组容量大于64,则将链表转为红黑树;else {for (int binCount = 0; ; ++binCount) {// 第1段:没有相同的key,则插入链表尾部if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);// 如果链表长度大于8,数组容量大于64,则将链表转为红黑树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  treeifyBin(tab, hash);break;}// 第2段:相同的key进行put就会覆盖原先的value;if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;// 第3段:p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;// 第四部分:实际存在的键值对数量超过最大容量,扩容;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}

3.7 HashMap扩容原理?

答:第一部分:定义新数组的大小为原来的2倍和新的阈值

第二部分:新建数组扩容为原来的2倍,并将新数组引用到HashMap的table上

第三部分:迁移数据,将旧数据保存在新数组里面:
第三部分a:迁移一个节点链表,通过索引位置直接赋值
第三部分b:迁移多节点的链表,将旧桶中的链表根据高低位分为两部分:lo链和hi链,lo链会插入到新table的槽i中,hi链会插入到新table的槽i+oldCap

第三部分c:迁移红黑树
首先,以链表方式遍历旧桶中的红黑树,根据高低位分为两个TreeNode链表lo链和hi链;
然后,判断是否需要进行 红黑树 <-> 链表 的转换,当链表长度<= 6将TreeNode链表转为普通Node链表;当链表长度>6将TreeNode链表构造成红黑树;
最后,lo链/树会插入到新table的槽i中,hi链/树会插入到新table的槽i+oldCap

final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0;// 第一部分:定义新数组的大小为原来的2倍和新的阈值 if (oldCap > 0) {// 如果超过了数组的最大容量,那么就直接将阈值设置为整数最大值,不再扩容了if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}// 如果没有超过,那就扩容为原来的2倍,这里要注意是oldThr << 1,移位操作来实现的else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}else if (oldThr > 0)// 如果阈值已经初始化过了,那就直接使用旧的阈值newCap = oldThr;else {// 如果没有初始化,那就初始化一个新的数组容量和新的阈值newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})// 第二部分:新建数组扩容为原来的2倍,并将新数组引用到HashMap的table上Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;// 第三部分:迁移数据,将旧数据保存在新数组里面if (oldTab != null) {for (int j = 0; j < oldCap; ++j) {Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null;// 第三部分a:迁移一个节点链表,通过索引位置直接赋值if (e.next == null)newTab[e.hash & (newCap - 1)] = e;// 第三部分c:迁移红黑树// 首先,以链表方式遍历旧桶中的红黑树,根据高低位分为两个TreeNode链表lo链和hi链; // 然后,判断是否需要进行 红黑树 <-> 链表 的转换,当链表长度<= 6将TreeNode链表转为普通Node链表;当链表长度>6将TreeNode链表构造成红黑树;// 最后,lo链/树会插入到新table的槽i中,hi链/树会插入到新table的槽i+oldCap;else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else {// 第三部分b:迁移多节点的链表,将旧桶中的链表根据高低位分为两部分:lo链和hi链,lo链会插入到新table的槽i中,hi链会插入到新table的槽i+oldCapNode<K,V> loHead = null, loTail = null;Node<K,V> hiHead = null, hiTail = null;Node<K,V> next;do {next = e.next;// 原索引if ((e.hash & oldCap) == 0) {if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}// 原索引 + oldCapelse {if (hiTail == null)hiHead = e;elsehiTail.next = e;hiTail = e;}} while ((e = next) != null);// 将原索引放到哈希桶中if (loTail != null) {loTail.next = null;newTab[j] = loHead;}// 将原索引 + oldCap 放到哈希桶中if (hiTail != null) {hiTail.next = null;newTab[j + oldCap] = hiHead;}}}}}return newTab;}
    final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {TreeNode<K,V> b = this;// 重新转换成TreeNode链表:lo和hi链表,保留顺序TreeNode<K,V> loHead = null, loTail = null;TreeNode<K,V> hiHead = null, hiTail = null;int lc = 0, hc = 0;for (TreeNode<K,V> e = b, next; e != null; e = next) {next = (TreeNode<K,V>)e.next;e.next = null;if ((e.hash & bit) == 0) {if ((e.prev = loTail) == null)loHead = e;elseloTail.next = e;loTail = e;++lc;}else {if ((e.prev = hiTail) == null)hiHead = e;elsehiTail.next = e;hiTail = e;++hc;}}if (loHead != null) {// 判断是否需要进行 红黑树 <-> 链表 的转换if (lc <= UNTREEIFY_THRESHOLD)tab[index] = loHead.untreeify(map);else {tab[index] = loHead;if (hiHead != null) // (else is already treeified)loHead.treeify(tab);}}if (hiHead != null) {if (hc <= UNTREEIFY_THRESHOLD)tab[index + bit] = hiHead.untreeify(map);else {tab[index + bit] = hiHead;if (loHead != null)hiHead.treeify(tab);}}}

3.7.1 为什么扩容呢?

答:很明显就是当前容量不够,也就是put了太多的元素。

3.7.2 为什么要做高低位的划分?

答:JDK1.8 在扩容时并没有像JDK 1.7那样重新计算每个元素的哈希值,而是通过高低位运算来确定元素是否需要移动,极大提升了效率,详细分析如下:

1.要想了解这么设计的目的,我们需要从ConcurrentHashMap的根据下标获取对象的算法来看:

(1)在putVal方法中 :(p = tab[i = (n - 1) & hash]) == null,通过 (n-1) & hash 来获得在table中的数组下标来获取节点数据,

(2)假设table长度是16,二进制是0001 0000,减1以后是0000 1111;某个key的hash值是20,对应的二进制是0001 0100,仍然按照 (n-1) & hash 算法,分别在16长度和扩容后的32长度下的计算结果:

0000 1111(15) & 0001 0100(20)  = 0000 0100(4);
0001 1111(31) & 0001 0100(20) = 0001 0100(20);

从结果来看,同样一个hash值,在扩容前和扩容之后,得到的下标位置是不一样的,这种情况当然是不允许出现的。而使用高低位的迁移方式,就是解决这个问题。

// 再增加 低位 的计算结果
0000 1111(15) & 0000 0100(4)  = 0000 0100(4);
0001 1111(31) & 0000 0100(4) = 0001 0100(4);

(3)大家可以看到:高位计算时 16 位的结果 对 32 位的结果,正好增加了 16;而低位计算时,依然在低位。所以对于高位,直接增加扩容的长度,当下次hash获取数组位置的时候,可以直接定位到对应的位置;低位可以直接定位原来的位置
  
2.从ConcurrentHashMap的根据计算高低位算法来看:

(1)这是一个非常好的设计理念,因为扩容后长度为原hash表的2倍,于是把hash表分为两半,分为低位和高位,如果能把原链表的键值对,一半放在低位,一半放在高位。通过 e.hash & oldCap == 0 来判断,=0时元素在扩容后的位置=原始位置,=1时元素在扩容后的位置=原始位置+扩容后的旧位置。这个判断有什么优点呢?举个例子:n = 16(oldCap),二进制为0001 0000,第5位为1,e.hash & oldCap 是否等于 0 就取决于 e.hash 的第 5 位是 0 还是 1,这就相当于有50%的概率放在新hash表低位,50%的概率放在新hash表高位

3.8 为什么HashMap的“key”部分存放自定义的对象时,需要重写equals()和hashcode()方法?

答:因为k1和k2值是一样的,理论上是可以用这个键获取到对应的值的,但是执行结果是:null。出现这个情况的原因有两个:没①有重写hashCode方法;②没有重写 equals 方法

public static class HashMapKey {private Integer id;public HashMapKey(Integer id) {this.id = id;}public Integer getId() {return id;}}public static void testHashConflict() {HashMapKey k1 = new HashMapKey(1);HashMapKey k2 = new HashMapKey(1);HashMap<HashMapKey, String> map = new HashMap<>();map.put(k1, "急急急急急");System.out.println(TAG + " map.get(k2) : " + map.get(k2)); // map.get(k2) : null}

(1)当往HashMap里放k1时,首先会调用HashMapKey这个类的hashCode方法计算它的hash值,随后把k1放入hash值所指引的内存位置。但是没有在HashMapKey里重写hashCode方法,所以调用的是Object类的hashCode方法,而Object类的hashCode方法返回的hash值其实是根据内存地址换算出来的一个值(假设是 0x100)

如果调用map.get(k1),那么会再次调用hashCode方法返回k1的地址0x100,再根据hash值能很快地找到k1。当调用map.get(k2),就会调用Object类的hashCode方法计算k2的hash值,得到的是k2的内存地址(假设是0x200)。由于k1和k2是两个不同的对象,所以它们的内存地址一定不会相同,也就是说它们的hash值一定不同,这就是无法用k2的hash值去拿k1的原因

(2)接下来在类HashMapKey中重写hashCode方法,因为hashCode方法返回的是id的hash值,所以此处k1和k2这两个对象的hash值就变得相等了:

@Override
public int hashCode() {return id.hashCode();
}

但是根据key取出的值依然是null。因为当k1和k2具有相同的hash值,需要调用equals方法来判断两者是否相等了,由于在HashMapKey对象里没有重写equals方法,不得不调用Object类的equals方法,由于Object的equals方法是使用==来比较对象的,所以k1和k2一定不会相等,所以通过map.get(k2)依然得到null

map.get(k2) : null

(3)为了解决这个问题,继续重写equals方法,只要两个对象都是Key类型,而且它们的id相等,它们就相等

@Override
public boolean equals(Object o) {if (o == null || !(o instanceof HashMapKey)) {return false;} else {return this.getId().equals(((HashMapKey) o).getId());}
}
map.get(k2) : 急急急急急

至此,问题已经解决。

(4)学习链接
为什么要重写 hashcode 和 equals 方法?

3.9 HashMap为什么是线程不安全的?

答:(1)扩容期间取出的值不准确:HashMap本身默认的容量不是很大,如果不停地往map中添加新的数据,它便会在合适的时机进行扩容。而在扩容期间,它会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。那么,在这个填充的过程中,如果有其他线程获取值,很可能会取到null值,而不是我们所希望的、原来添加的值;

(2)同时put碰撞导致数据丢失:有多个线程同时使用put来添加元素,而且恰好两个put的key是一样的,它们发生了碰撞,也就是根据hash值计算出来的bucket位置一样,并且两个线程又同时判断该位置是空的,可以写入,所以这两个线程的两个不同的value便会添加到数组的同一个位置,这样最终就只会保留一个数据,丢失一个数据;

(3)可见性问题无法保证:如果线程1给某个key放入了一个新值,那么线程2在获取对应的key的值的时候,它的可见性是无法保证的,也就是说线程2可能可以看到这一次的更改,但也有可能看不到;

(4)死循环造成CPU 100%:在扩容的时候,也就是内部新建新的HashMap的时候,扩容的逻辑会反转哈希表中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。

3.10 hashmap为什么会出现死循环?以1.7版本讲解

答:HashMap 在并发环境可能出现无限循环占用 CPU、size 不准确等诡异的问题。出问题的就是这个resize的transfer方法:

void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;  // 遍历旧哈希表for (Entry<K,V> e : table) {while(null != e) {// 1.因为是单链表,如果要转移头节点,用 next 临时记录 要转移节点 的下一个节点 Entry<K,V> next = e.next; // 线程一执行此处if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}// 计算节点在新数组中的下标 int i = indexFor(e.hash, newCapacity);// 2.新节点e要插入到链表的头部,所以先用 新节点e的next 指向 哈希表的(旧)第一个元素(将新节点的next指向哈希表旧头节点)e.next = newTable[i];// 3.将 哈希表的(新)第一个元素 指向 新节点enewTable[i] = e; // 4.转移原e的下一个结点 成为 新的当前节点e,以便下一次循环e = next; }}
}

3.10.1 单线程rehash详细演示

答:(1)假设了hash算法就是简单的用hash = key % 表的大小(数组的长度)。
(2)最上面的是old hash表,其中的Hash表的size=2,所以key = 3、7、5,在 % 2以后都冲突在table1这里了
(3)接下来的三个步骤是哈希表 扩容成 4,然后所有的 重新rehash的过程。

while(null != e) {Entry<K,V> next = e.next;e.next = newTable[i];newTable[i] = e;e = next;
}

现在 e = key(3),数组下标 i = 3 % 4 = 3;e.next = key(7),next = key(7)
执行 Entry<K,V> next = e.next;  // next = key(7)
执行 e.next = newTable[i];  // key(3) 的next = newTable[3] = null(未初始化)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(3),newTable[3] = key(3)
执行 e = next; // 新的e = key(7)现在 e = key(7),数组下标 i = 7 % 4 = 3;e.next = key(5),next = key(5)
执行 Entry<K,V> next = e.next;  // next = key(5)
执行 e.next = newTable[i];  // key(5) 的next = newTable[3] = key(3)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(7),newTable[3] = key(7)
执行 e = next; // 新的e = key(5)现在 e = key(5),数组下标 i = 5 % 4 = 1;e.next = null,next = null
执行 Entry<K,V> next = e.next;  // next = null
执行 e.next = newTable[i];  // key(5) 的next = newTable[1] = null(未初始化)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(5),newTable[1] = key(5)
执行 e = next; // 新的e = null

3.10.2 多线程rehash详细演示

答:假设这里有两个线程同时执行了put()操作,并进入了transfer()环节

while(null != e) {Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了e.next = newTable[i];newTable[i] = e;e = next;
}

(1)现在的状态为:从下图可以看到,因为线程1的e指向了key(3),而next指向了key(7),在线程2 rehash 后,就指向了线程2 rehash后的链表中对应节点。

// 线程1
现在 e = key(3),数组下标 i = 3 % 4 = 3;e.next = key(7) ,next = key(7)
执行 Entry<K,V> next = e.next;  // next = key(7)
// 线程2,步骤和 单线程rehash详细演示 一致


(2)然后线程1被唤醒了:

现在 e = key(3),数组下标 i = 3 % 4 = 3;e.next = key(7),next = key(7)
~~执行 Entry<K,V> next = e.next;  // next = key(7)~~
执行 e.next = newTable[i];  // key(3) 的next = newTable[3] = null(未初始化)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(3),newTable[3] = key(3)
执行 e = next; // 新的e = key(7)下一轮  e = key(7),e.next = key(3),next = key(3)


(3)线程一接着工作,一切安好

现在 e = key(7),数组下标 i = 7 % 4 = 3;e.next = key(3),next = key(3)
执行 Entry<K,V> next = e.next;  // next = key(3)
执行 e.next = newTable[i];  // key(7) 的next = newTable[3] = key(3)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(7),newTable[3] = key(7)
执行 e = next; // 新的e = key(3)下一轮  e = key(3),e.next = null,next = null


(4)又该执行key(7)的next节点key(3)了,环形链接出现

现在 e = key(3),数组下标 i = 3 % 4 = 3;e.next = null,next = null
执行 Entry<K,V> next = e.next;  // next = null
执行 e.next = newTable[i];  // key(3) 的next = newTable[3] = key(7)
执行 newTable[i] = e; // 线程的新哈希表第一个元素变成了key(3),newTable[3] = key(3)
执行 e = next; // 新的e = null


(5)当然发生死循环的原因是JDK 1.7链表插入方式为头部倒序插入,这个问题在JDK 1.8得到了改善,变成了尾部正序插入

3.10.3 三种解决方案?

答:(1)Hashtable替换HashMap;(不推荐)
(2)Collections.synchronizedMap将HashMap包装起来;(不推荐)
(3)ConcurrentHashMap替换HashMap;(推荐)

3.10.4 学习链接

第29讲:HashMap 为什么是线程不安全的?

第三天:HashMap为什么是线程不安全的

HashMap多线程并发问题分析

面试必问:hashmap为什么会出现死循环?

3.11 HashMap到底是插入链表头部还是尾部?

答:在JDK1.7版本里是插入链表头部的,在1.8版本HashMap是在插入元链表尾部,以下分别分析:
(1)JDK1.7中put方法的addEntry:

void addEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex];table[bucketIndex] = new Entry<K,V>(hash, key, value, e);if (size++ >= threshold)resize(2 * table.length);
}

构造了一个新的Entry对象(构造方法的最后一个参数传入了当前的Entry链表),然后直接用这个新的Entry对象取代了旧的Entry链表:

Entry( int h, K k, V v, Entry<K,V> n) {value = v;next = n;key = k;hash = h;
}

从构造方法中的 next = n,可以确定是把原本的链表直接链在了新建的Entry对象的后边,断定是插入头部

(2)JDK1.7中put方法的关键部分:

// e是p的下一个节点
if ((e = p.next) == null) {// 插入链表的尾部p.next = newNode(hash, key, value, null);// 如果插入后链表长度大于8则转化为红黑树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;
}

看出当到达链表尾部(即p是链表的最后一个节点)时,e被赋为null,会进入这个分支代码,然后会用newNode方法建立一个新的节点插入尾部

3.11.1 头插法为什么要换成尾插嘛?

答:1.7版本这么设计的原因是作者认为新插入的元素使用到的频率会比较高,插入头部的话可以减少遍历次数。那为什么1.8改成尾插法了呢?主要是因为头插法在多线程环境下可能会导致两个节点互相引用,形成死循环

HashMap到底是插入链表头部还是尾部?

3.12 在JDK 1.8中它都做了哪些优化?

答:(1) 之前JDK1.7的存储结构是数组+链表,到了JDK1.8变成了数组+链表+红黑树
(2)JDK1.8 在扩容时并没有像JDK 1.7那样重新计算每个元素的哈希值,而是通过高位运算(e.hash & oldCap)来确定元素是否需要移动
(3) JDK 1.7链表插入方式为头部倒序插入,这个问题在JDK 1.8得到了改善,变成了尾部正序插入

3.13 对比Hashtable、HashMap、TreeMap有什么不同?

答:(1)Hashtable是早期Java 类库提供的一个哈希表实现,本身是同步的,不支持key为null,由于同步导致的性能开销,所以已经很少被推荐使用

(2)HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持一个Key为null、多个value为null的。通常情况下,HashMap进行put或者get操作都是O(1)的时间复杂度,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户ID和用户信息对应的运行时存储结构。

(3)TreeMap则是基于红黑树实现的Map,主要用于存入元素的时候对元素进行自动排序。和HashMap不同,它的get、put、remove之类操作都是O(log(n))的时间复杂度。为了按键有序,存入TreeMap的元素应当实现Comparable接口或者实现Comparator接口,会按照排序后的顺序迭代元素

举例:HashMap可以存储一个Key为null,多个value为null的元素,但是Hashtable却不可以Key为nul

// HashMap
// 此处计算key的hash值时,会判断是否为null,如果是则返回0,即key为null的键值对的hash为0。
// 因此一个hashmap对象只会存储一个key为null的键值对,因为它们的hash值都相同。
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 将键值对放入table中时,不会校验value是否为null。因此一个hashmap对象可以存储多个value为null的键值对
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}
// Hashtable.class:
public synchronized V put(K key, V value) {// 确保value不为空。这句代码过滤掉了所有value为null的键值对。因此Hashtable不能存储value为null的键值对if (value == null) { throw new NullPointerException(); }// 在此处计算key的hash值,如果此处key为null,则直接抛出空指针异常int hash = key.hashCode(); 。
}
    // ConcurrentHashMap.class:public V put(K key, V value) {return putVal(key, value, false);}final V putVal(K key, V value, boolean onlyIfAbsent) {// 在此处直接过滤掉key或value为null的情况if (key == null || value == null) throw new NullPointerException();}

3.14 HashMap遍历的四种方法

答:

Map<Integer,String> map =new HashMap<Integer,String>();// 方法一:使用For-Each迭代entries
// 最常见的方法,并在大多数情况下更可取的。
// 注意:For-Each循环是Java5新引入的,所以只能在Java5以上的版本中使用。如果你遍历的map是null的话,For-Each循环会抛出NullPointerException异常,所以在遍历之前你应该判断是否为空引用。
for(Map.Entry<Integer,String> entry : map.entrySet()) {System.out.println("方法一:key ="+entry.getKey()+"---value="+entry.getValue());
}// 方法二:使用For-Each迭代keys和values
// 只需要用到map的keys或values时,你可以遍历KeySet或者values代替方法三
// 这个方法比方法三具有轻微的性能优势(大约快10%)并且代码更简洁
for(Integer key : map.keySet()) {System.out.println("方法二:key = "+key);
}
for(String value : map.values()) {System.out.println("方法二:value = "+value);
}// 方法三:使用Iterator迭代
// 优势:①它是遍历老java版本map的唯一方法;②可以在迭代的时候从map中删除entry的(通过调用iterator.remover())唯一方法
// 性能方法看,这个方法等价于使用For-Each迭代
Iterator<Map.Entry<Integer,String>> entries = map.entrySet().iterator();
while(entries.hasNext()) {Map.Entry<Integer,String> entry = entries.next();System.out.println("方法三:key = "+entry.getKey()+"--value="+entry.getValue());
}// 方法四:迭代keys并搜索values(低效的)
// 这个方法看上去比方法#1更简洁,但是实际上它更慢更低效,通过key得到value值更耗时,比方法一慢20%-200%。
for(Integer key:map.keySet()) {String value = map.get(key);System.out.println("方法四:Key = " + key + ", Value = " + value);
}

学习链接:HashMap遍历的四种方法

4 ConcurrentHashMap如何实现并发访问?

4.1 这道题想考察什么?

答:
●是否熟练掌握线程安全的概念(高级)
●是否深入理解CHM的各项并发优化的原理(高级)
●是否掌握锁优化的方法(高级)

题目剖析
●ConcurrentHashMap如何支持并发访问?
●并发访问即考察线程安全问题
●回答ConcurrentHashMap原理即可

如果你对ConcurrentHashMap的原理不了解
●分析下HashMap为什么不是线程安全的
●编写并发程序时你会怎么做,举例说明最佳

4.1 ConcurrentHashMap的特点是什么?

答:ConcurrentHashMap是并发包里面提供的一个线程安全并且高效的HashMap,所以ConcurrentHashMap在并发编程的场景中使用的频率比较高,ConcurrentHashMap是Map的派生类,所以API基本和Hashmap是类似,主要就是put、get这些方法;实现原理是差不多的,但是因为ConcurrentHashMap需要支持并发操作,所以在实现上要比Hashmap复杂一些。

4.1 为什么要使用ConcurrentHashMap?

答:(1)线程不安全的HashMap,详情请看第3节;

(2)效率低下的HashTableHashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。

(3) ConcurrentHashMap的锁分段技术可有效提升并发访问率:HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据, 那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁, 当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

(4)ConcurentHashMap对于HashTable的问题进行了优化:
①HashTable的问题(很暴力):大锁:直接对HashTable对象加锁;长锁:直接对方法加锁; 读写锁共用:只有一把锁,从头锁到尾。②ConcurentHashMap对于HashTable的问题进行的优化:小锁:JDK5-7是分段锁(5~7),JDK8是桶节点锁; 短锁:先尝试获取,失败再加锁;分离读写锁:JDK5-7读失败再加锁(5~7),JDK7-8是volatile读CAS写

4.2 ConcurrentHashMap的底层数据结构是什么?ConcurrentHashMap在JDK7和8有何不同?

答:(1)在JDK1.7的实现上,ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment继承了ReentrantLock,是一种可重入锁(ReentrantLock) ,在ConcurentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组 ,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁来保证每个segment内的操作的线程安全性,如图所示:


(2)在JDK1.8的实现上,相比于1.7版本,它做了两个改进
①取消了segment分段锁设计,直接使用Node数组来保存数据,并且采用Node数组元素作为锁来对每一行数据进行加锁来进一步减少并发冲突的概率
②将原本 数组+单向链表 的数据结构,变更为了 数组+单向链表+红黑树 的结构。为什么要引入红黑树呢?请看 3.2.2


(3)学习链接 第30讲:ConcurrentHashMap 在 Java7 和 8 有何不同?

4.1 ConcurrentHashMap并发优化的历程?

答:(1)JDK5:分段锁,必要时加锁:如下图对Key进行hash后,高位用来找segment,低位用来找table。

(2)JDK6:优化二次Hash算法:因为JDK1.5时的hash算法会导致hashcode的高位不均匀分布,对于30000以下的整数key,hash出来后大部分集中在第16个segment中,对于50万以下的整数key,hash出来后大部分集中在第14,15个segment中,这样就退化为了HashTable,随着数据的不断增加才能逐渐地均匀的分布。而JDK1.6时的hash算法实现高低位的均匀分布。

(3)JDK7:段懒加载(段不一开始就实例化,而是需要时实例化),使用volatile & cas 来避免加锁:Java8的构造和7类似,且ConcurrentHashMap和HashMap的默认无参构造public ConcurrentHashMap() {},构造对象时不初始化段,在使用时再加载。


同时JDK1.7大量的使用了volatil & cas 来避免加锁,保证segment的线程安全。例如:get方法无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据;除非读取不到值是空才会加锁重读

(4)JDK1.8:摒弃段,基于HashMap原理的实现并发。不必加锁的地方尽量使用volatile,必须加锁的地方如写入,尽量小范围的加锁。为何放弃分段锁?段Segment继承了重入锁ReentrantLock,有了锁的功能,每个锁控制的是一段,当每个Segment越来越大时,锁的粒度就变得有些大了。

4.1 ConcurrentHashMap的Node节点原理?

答:每个Node里面是key-value的形式,并且把value用volatile修饰,以便保证可见性;同时内部还有一个指向下一个节点的next 指针,方便产生链表结构

static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;volatile V val;volatile Node<K,V> next;// ...
}

4.1 sizeCtl属性在各个阶段的作用?

答:(1)新建而未初始化时,sizeCtl值用于记录初始容量大小,仅用于记录集合在实际创建时应该使用的大小的作用

public ConcurrentHashMap(int initialCapacity) {int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));this.sizeCtl = cap;
}

(2)初始化过程中,将sizeCtl值设置为sc=-1表示集合正在初始化中,其他线程发现该值为-1时会让出CPU资源以便初始化操作尽快完成

if ((sc = sizeCtl) < 0)Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
}

(3)初始化完成后:sizeCtl值用于记录当前集合的负载容量值,也就是触发集合扩容的极限值

if ((sc = sizeCtl) < 0)Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {try { int n = (sc > 0) ? sc : DEFAULT_CAPACITY;Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];table = tab = nt;sc = n - (n >>> 2);} finally { sizeCtl = sc; }
}

(4)正在扩容时:sizeCtl值用于记录当前扩容的并发线程数情况,此时sizeCtl的值为:((rs << RESIZE_STAMP_SHIFT) + 2) + (正在扩容的线程数) ,并且该状态下sizeCtl < 0,表示需要扩容。如果扩容还未结束,有新的扩容线程加入,每次加 1

// 第一条扩容线程设置的某个特定基数,为首个扩容线程所设置的特定值。表示需要扩容
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
// 后续线程加入扩容大军时每次加 1
U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
// 线程扩容完毕退出扩容操作时每次减 1
U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)

4.1 ConcurrentHashMap的插入元素的原理?(默认JDK 8)

答: 第一部分:校验key-value值,都不能是null。这点和HashMap不同
第二部分:判断tab容器是否初始化,如果容器没有初始化,则调用initTable方法初始化(原理看4.1.1);

第三部分:
第三部分a:根据双哈希之后的hash值找到数组对应的下标位置,如果该位置未存放节点,即是不存在hash冲突,则使用CAS无锁的方式将数据添加到容器中,并且结束循环
第三部分b:判断容器是否正在被其他线程进行扩容操作,如果fh == -1,说明正在被其他线程扩容,则放弃添加操作,调用helpTransfer()去帮扩容,扩容时并未跳出死循环。这一点就保证了容器在扩容时并不会有其他线程进行数据添加操作,这也保证了容器的安全性;

第四部分:如果hash冲突,则进行链表操作或者红黑树操作(如果链表树超过8,则修改链表为红黑树),在进行链表或者红黑树操作时,会使用synchronized锁把f头节点锁住,保证了同时只有一个线程修改链表,防止出现链表成环
第四部分a:如果第一个节点就是要插入的键值对,相同的key进行put就会覆盖原先的value
第四部分b:如果该节点是链表节点,相同的key进行put就会覆盖原先的value,否则插入链表尾部;
第四部分c:如果该节点是红黑树节点,那就直接插入到红黑树中;
第四部分d:插入完之后,如果链表长度大于8,数组容量大于64,则将链表转为红黑树;

第五部分:进行addCount(1L, binCount)操作,该操作会更新size大小,判断是否需要扩容(原理看4.1.2);

第六部分:
深入解析 ConcurrentHashMap 实现内幕,吊打面试官,没问题

并发编程之 ConcurrentHashMap(JDK 1.8) putVal 源码分析

public V put(K key, V value) {// 参数1 key:键// 参数2 value:值// 参数3 onlyIfAbsent:当键相同时,不修改已存在的值return putVal(key, value, false);}final V putVal(K key, V value, boolean onlyIfAbsent) {// 第一部分:校验key-value值,都不能是null。这点和HashMap不同if (key == null || value == null) throw new NullPointerException();// 两次 hash,减少碰撞次数int hash = spread(key.hashCode());// 记录链表节点得个数int binCount = 0;// 无条件得循环遍历整个 node 数组,直到成功for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {ConcurrentHashMap.Node<K,V> f; int n, i, fh;// 第二部分:判断tab容器是否初始化,如果容器没有初始化,则调用 initTable 方法初始化if (tab == null || (n = tab.length) == 0)tab = initTable();// 第三部分:// 第三部分a:根据双哈希之后的hash值找到数组对应的下标位置,如果该位置未存放节点,即是不存在hash冲突,则使用CAS无锁的方式将数据添加到容器中,并且结束循环// tabAt通过Unsafe.getObjectVolatile()的方式获取数组对应index上的元素,如果元素为空,则直接无所插入else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null, new ConcurrentHashMap.Node<K,V>(hash, key, value, null)))break;                   // no lock when adding to empty bin}// 第三部分b:判断容器是否正在被其他线程进行扩容操作,如果fh == -1,说明正在被其他线程扩容,则放弃添加操作,也去帮扩容,扩容时并未跳出死循环// 这一点就保证了容器在扩容时并不会有其他线程进行数据添加操作,这也保证了容器的安全性else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);else {V oldVal = null;// 第四部分:如果hash冲突,则进行链表操作或者红黑树操作(如果链表树超过8,则修改链表为红黑树),// 在进行链表或者红黑树操作时,会使用synchronized锁把f头节点锁住,保证了同时只有一个线程修改链表,防止出现链表成环。synchronized (f) {if (tabAt(tab, i) == f) {// 第四部分a:如果第一个节点就是要插入的键值对,相同的key进行put就会覆盖原先的value// 第四部分b:如果该节点是链表节点,相同的key进行put就会覆盖原先的value,否则插入链表尾部if (fh >= 0) {binCount = 1;for (ConcurrentHashMap.Node<K,V> e = f;; ++binCount) {K ek;if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {oldVal = e.val;if (!onlyIfAbsent)// 遍历该节点上的链表,相同的key进行put就会覆盖原先的value,e.val = value;break;}ConcurrentHashMap.Node<K,V> pred = e;if ((e = e.next) == null) {// 插入链表尾部pred.next = new ConcurrentHashMap.Node<K,V>(hash, key, value, null);break;}}}// 第四部分c:如果该节点是红黑树节点,那就直接插入到红黑树中else if (f instanceof ConcurrentHashMap.TreeBin) {ConcurrentHashMap.Node<K,V> p;binCount = 2;if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {oldVal = p.val;if (!onlyIfAbsent)p.val = value;}}}}// 第四部分d:插入完之后,如果链表长度大于8,数组容量大于64,则将链表转为红黑树;if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD)treeifyBin(tab, i);// 如果存在相同的key,返回原来的值if (oldVal != null)return oldVal;break;}}}// 第五部分:进行addCount(1L, binCount)操作,该操作会更新size大小,判断是否需要扩容addCount(1L, binCount);return null;}

4.1.1 数组初始化initTable()方法的原理?

答:table本质上就是一个Node数组,其初始化过程就是对Node数组的初始化过程,方法中使用了CAS策略执行初始化操作。初始化流程为:
(1)判断sizeCtl值是否小于0,如果小于0则表示其他线程抢占了初始化的操作,所以需要先自旋等待,如果其它线程初始化失败还可以顶替上去
(2)如果sizeCtl值大于等于0,则执行cas操作,将sizeCtl替换为-1,表示当前线程抢占到了初始化资格,然后构造table数组,并更新sizeCtl的值为12

备注:初始化过程中,将sizeCtl值设置为sc=-1表示集合正在初始化中,其他线程发现该值为-1时会让出CPU资源以便初始化操作尽快完成;

private final Node<K,V>[] initTable() {Node<K,V>[] tab; int sc;while ((tab = table) == null || tab.length == 0) {if ((sc = sizeCtl) < 0)// sizeCtl=-1表示被其他线程抢占了初始化的操作,则直接让出自己的CPU时间片,自旋等待Thread.yield(); // 执行cas操作,将sizeCtl替换为-1,表示当前线程抢占到了初始化资格else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {// CAS 抢到了锁try {if ((tab = table) == null || tab.length == 0) {// 默认初始容量为 16int n = (sc > 0) ? sc : DEFAULT_CAPACITY;@SuppressWarnings("unchecked")// 初始化数组,并将这个数组赋值给 tableNode<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];table = tab = nt;// 指定下次扩容的大小,相当于 sc = 0.75 × n = 12sc = n - (n >>> 2);}} finally {// 设置 sizeCtl 为 scsizeCtl = sc;}break;}}return tab;
}

4.1.2 addCount(1L, binCount)的原理?ConcurrentHashMap#addCount()

答:做了2件事情:(1)一共有两个参数参与计数:第一个叫作baseCount,是一个变量,第二个是counterCells,是一个数组。在竞争不激烈的情况下,直接使用baseCount做CAS加1;一旦并发竞争激烈导致CAS失败了,使用counterCells数组的Cell对象CAS累加;如果上面CAS失败了,在fullAddCount方法中,会继续死循环操作,直到累加成功

private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

(2)检查是否需要扩容,或者是否正在扩容。如果需要扩容,就调用扩容方法,如果正在扩容,就帮助其扩容

// 新增元素时,也就是在调用 putVal 方法后,为了通用,增加了个 check 入参,用于指定是否可能会出现扩容的情况
private final void addCount(long x, int check) {ConcurrentHashMap.CounterCell[] as; long b, s;// 在竞争不激烈的情况下,直接使用baseCount做CAS加1if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {ConcurrentHashMap.CounterCell a; long v; int m;boolean uncontended = true;if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null ||// 一旦并发竞争激烈导致CAS失败了,使用counterCells数组的Cell对象CAS累加!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {// 如果上面CAS失败了,在fullAddCount方法中,会继续死循环操作,直到累加成功fullAddCount(x, uncontended);return;}if (check <= 1)return;s = sumCount();}// check就是binCount,binCount 最小都为0,所以这个条件一定会为trueif (check >= 0) {ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;// 这儿是自旋,需同时满足下面的条件// 1. 第一个条件是 sumCount() 大于 sizeCtl,也就是说需要扩容;2. 第二个条件是`table`不为null;3. 第三个条件是`table`的长度不能超过最大容量while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {int rs = resizeStamp(n);// 该判断表示已经有线程在进行扩容操作了if (sc < 0) {if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)break;// 如果扩容还未结束,并且允许扩容线程加入,那么将 sc 加 1。表示多了一个线程在帮助扩容if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))transfer(tab, nt);}// 如果不在扩容,将 sc 更新为((rs << RESIZE_STAMP_SHIFT) + 2),也就是变成一个负数。表示需要扩容else if (U.compareAndSwapInt(this, SIZECTL, sc,  (rs << RESIZE_STAMP_SHIFT) + 2))transfer(tab, null);s = sumCount();}}

4.1.3 CounterCell类是什么?和LongAdder有什么关系?LongAdder 带来的改进和原理

答: CounterCell类,改编自LongAdder和Striped64,我们直接分析LongAdder的原理。LongAdder采用分段累加的理念,内部一共有两个参数参与计数:第一个叫作base,它是一个变量,第二个是Cell[] ,是一个数组。

(1)在竞争不激烈的情况下,直接使用baseCount做CAS加1

(2)当竞争激烈的时候,就要用到Cell[]数组了,LongAdder会通过计算出每个线程的hash值来把把线程分配到不同的Cell对象中,每个Cell相当于是一个独立的计数器,Cell之间并不存在竞争关系
(3)一旦竞争激烈,各个线程会分散CAS累加到自己所对应的那个Cell[]数组的Cell对象中,而不会大家共用同一个累加。所以在自加的过程中,就降低了冲突的概率,提高了并发性。本质是空间换时间,因为它有多个计数器同时在工作,所以占用的内存也要相对更大一些。

4.1.4 ConcurrentHashMap的size()方法的原理?如何实现多线程计数的呢?ConcurrentHashMap#size()

答:看核心的sumCount方法,先取base的值,然后遍历所有Cell,把每个Cell的值都加上去,形成最终的总和。由于在统计的时候并没有进行加锁操作,所以这里得出的sum不一定是完全准确的,因为有可能在计算sum的过程中Cell的值被修改了。

public int size() {long n = sumCount();return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
public long mappingCount() {long n = sumCount();return (n < 0L) ? 0L : n; // ignore transient negative values
}
final long sumCount() {CounterCell[] as = counterCells; CounterCell a;long sum = baseCount;if (as != null) {for (int i = 0; i < as.length; ++i) {if ((a = as[i]) != null)sum += a.value;}}return sum;
}

4.1.5 ConcurrentHashMap的数组上插入节点的操作是否为原子操作,为什么要使用CAS的方式?

答:是原子操作。插入节点是通过CAS插入的,若插入失败,则说明其他线程已插入节点,下一次循环走链表进行插入或替换。总之这一步没加锁,允许失败,主要解决并发插入节点覆盖丢失的问题

4.1 ConcurrentHashMap查询元素的原理?

答:第一部分:计算 Hash 值,并由此值找到对应的桶
第二部分:如果数组是空的,或者当前桶的数据是空的,说明 key 对应的 value 不存在,直接返回 null
第三部分:判断头结点是否就是我们需要的节点,如果是则直接返回该节点的值
第四部分:如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找
第五部分:否则就是链表,进行遍历链表查找

public V get(Object key) {Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;// 第一部分:计算 hash 值,并由此值找到对应的桶;int h = spread(key.hashCode());// 第二部分:如果整个数组是空的,或者当前桶的数据是空的,说明 key 对应的 value 不存在,直接返回 null;if ((tab = table) != null && (n = tab.length) > 0 &&  (e = tabAt(tab, (n - 1) & h)) != null) {// 第三部分:判断头结点是否就是我们需要的节点,如果是则直接返回该节点的值;if ((eh = e.hash) == h) {if ((ek = e.key) == key || (ek != null && key.equals(ek)))return e.val;}// 第四部分:如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找;else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null;// 第五部分:否则就是链表,进行遍历链表查找;while ((e = e.next) != null) {if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))return e.val;}}return null;}

4.1.1 并发情况下,各线程中的数据可能不是最新的,那为什么get方法不需要加锁?

答:get操作全程不需要加锁,是因为Node的成员val是用volatile修饰的,当有线程更新val值时,其他线程可见最新值

4.1 ConcurrentHashMap扩容原理?

答:扩容是ConcurrentHashMap的精华之一,扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。

但是这在多线程环境下,在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?可能大家想到的第一个解决方案是加互斥锁,把转移过程锁住,虽然是可行的解决方案,但是会带来较大的性能开销。因为互斥锁会导致所有访问临界区的线程陷入到阻塞状态,持有锁的线程耗时越长,其他竞争线程就会一直被阻塞,导致吞吐量较低,而且还可能导致死锁。

ConcurrentHashMap并没有直接加锁,而是采用CAS实现无锁的并发同步策略,最精华的部分是它可以利用多线程来进行协同扩容第一步是table数组的扩容:当nextTab为空,首次扩容时,才会将table数组变成原来的2倍第一步是数据迁移:它把数组当作多个线程之间共享的任务队列,然后通过维护一个指针来划分每个线程锁负责的区间,每个线程通过区间逆向遍历来实现数据迁移,一个已经迁移完的bucket(桶)会被替换为一个ForwardingNode节点,标记当前bucket已经被其他线程迁移完了

4.1.1 扩容的基本思路?

答:Hash表的扩容,都包含两个步骤:
①table数组的扩容:一般就是新建一个2倍大小的数组,这个过程通过由一个单线程完成,且不允许出现并发;
②数据迁移:把旧table中的各个桶中的结点重新分配到新table中

4.1.2 扩容的时机?

答:以下三种情况可能触发扩容操作:
(1)调用put方法之后调用addCount方法,当元素个数达到扩容阈值;
(2)在扩容状态下,其他线程进行插入、修改、删除、合并等操作时遇到ForwardingNode节点,去帮助扩容;
(3)当某条链表长度达到8,但数组长度却小于64时,则调用tryPresize触发扩容操作;
(4)调用putAll方法,但目前容量不足以存放所有元素时,调用tryPresize触发扩容操作

详细分析如下:

(1)调用put方法之后调用addCount方法来更新元素个数,当元素个数大于扩容的阈值(sizeCtl)时,会触发transfer方法

// 新增元素时,也就是在调用 putVal 方法后,为了通用,增加了个 check 入参,用于指定是否可能会出现扩容的情况
private final void addCount(long x, int check) {ConcurrentHashMap.CounterCell[] as; long b, s;... ...// check就是binCount,binCount 最小都为0,所以这个条件一定会为trueif (check >= 0) {ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;// 这儿是自旋,需同时满足下面的条件// 1. 第一个条件是 sumCount() 大于 sizeCtl,也就是说需要扩容;2. 第二个条件是`table`不为null;3. 第三个条件是`table`的长度不能超过最大容量while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {int rs = resizeStamp(n);// 该判断表示已经有线程在进行扩容操作了if (sc < 0) {if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)break;// 如果扩容还未结束,并且允许扩容线程加入,那么将 sc 加 1。表示多了一个线程在帮助扩容if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))transfer(tab, nt);}// 如果不在扩容,将 sc 更新为((rs << RESIZE_STAMP_SHIFT) + 2),也就是变成一个负数。表示需要扩容else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))transfer(tab, null);s = sumCount();}}

(2)在扩容状态下,其他线程进行插入、修改、删除、合并等操作时遇到ForwardingNode节点,去帮助扩容

public V merge() {else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);  // helpTransfer()
}
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {// 此处的 while 循环是上面 addCount 方法的简版,可以参考上面的注释while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {transfer(tab, nextTab);  // 帮扩容时nextTab不为空break;}}}return table;
}

并发编程——ConcurrentHashMap#helpTransfer() 分析

(3)当某条链表长度达到8,但数组长度却小于64时,则调用tryPresize触发扩容操作,重新调整节点的位置

if (binCount >= TREEIFY_THRESHOLD)treeifyBin(tab, i);             // 链表 -> 红黑树 转换
private final void treeifyBin(Node<K, V>[] tab, int index) {if (tab != null) {// CASE 1: table的容量 < MIN_TREEIFY_CAPACITY(64)时,直接进行table扩容,不进行红黑树转换if ((n = tab.length) < MIN_TREEIFY_CAPACITY)tryPresize(n << 1);}
}

(4)调用putAll方法,但目前容量不足以存放所有元素时,调用tryPresize触发扩容操作

public void putAll(Map<? extends K, ? extends V> m) {tryPresize(m.size());for (Map.Entry<? extends K, ? extends V> e : m.entrySet())putVal(e.getKey(), e.getValue(), false);
}
// 尝试对table数组进行扩容
private final void tryPresize(int size) {int sc;while ((sc = sizeCtl) >= 0) {Node<K, V>[] tab = table;// CASE3: 进行table扩容else if (tab == table) {int rs = resizeStamp(n);    // 根据容量n生成一个随机数,唯一标识本次扩容操作if (sc < 0) {               // sc < 0 表明此时有别的线程正在进行扩容// 已经有其它线程正在执行扩容了,则当前线程会尝试协助“数据迁移”,把正在执行transfer任务的线程数加1 ;(多线程并发)if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))transfer(tab, nt);}// 没有其它线程正在执行扩容,则当前线程自身发起扩容。(单线程)else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))transfer(tab, null);}}
}

4.1.3 扩容原理?

4.1.3.1 table数组扩容

答:(1)tranfer()的开头,会计算出一个stride变量的值,这个stride其实就是每个线程处理的桶区间,也就是步长;(2)当nextTab为空,首次扩容时,才会将table数组变成原来的2倍

// stride可理解成“步长”,即数据迁移时,每个线程要负责旧table中的多少个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) {           // 首次扩容try {// 创建新table数组Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1];nextTab = nt;} catch (Throwable ex) {     // 处理内存溢出(OOME)的情况sizeCtl = Integer.MAX_VALUE;return;}nextTable = nextTab;transferIndex = n;          // [transferIndex-stride, transferIndex-1]表示当前线程要进行数据迁移的桶区间
}

4.1.3.2 数据迁移

(1)每个调用tranfer的线程会对当前旧table中[transferIndex-stride, transferIndex-1]位置的结点进行迁移。table[transferIndex-stride,transferIndex-1]就是当前线程要进行数据迁移的桶区间,transferIndex是扩容时需要用到的一个下标变量。

整个transfer方法几乎都在一个自旋操作中完成,从右往左开始进行数据迁移,transfer的退出点是当某个线程处理完最后的table区段——table[0,stride-1],代码如下:

// i标识桶索引, bound标识边界
for (int i = 0, bound = 0; ; ) {if (i < 0 || i >= n || i + n >= nextn) {    // CASE1:当前是处理最后一个tranfer任务的线程或出现扩容冲突} else if ((f = tabAt(tab, i)) == null)     // CASE2:旧桶本身为null,不用迁移,直接尝试放一个ForwardingNode// ForwardingNode结点,当旧table的某个桶中的所有结点都迁移完后,用该结点占据这个桶。// 多线程进行数据迁移时,其它线程看到这个桶中是ForwardingNode结点,就知道有线程已经在数据迁移了。advance = casTabAt(tab, i, null, fwd);  else if ((fh = f.hash) == MOVED)            // CASE3:该旧桶已经迁移完成,直接跳过advance = true;   // 标识一个桶的迁移工作是否完成,advance == true 表示可以进行下一个位置的迁移else {                                      // CASE4:该旧桶未迁移完成,进行数据迁移synchronized (f) {if (fh >= 0) { }                        // CASE4.1:桶的hash>0,说明是链表迁移else if (f instanceof TreeBin) { }      // CASE4.2:红黑树迁移}}
}

(2)单线程下线程的数据迁移操作

(3)多线程下线程的数据迁移操作

4.1.3.3 CASE2:桶table[i]为空

答:当旧table的桶table[i] == null,说明原来这个桶就没有数据,那就直接尝试放置一个ForwardingNode,表示这个桶已经处理完成

else if ((f = tabAt(tab, i)) == null)     // CASE2:旧桶本身为null,不用迁移,直接尝试放一个ForwardingNodeadvance = casTabAt(tab, i, null, fwd);
4.1.3.4 CASE3:桶table[i]已迁移完成

答:桶已经用ForwardingNode结点占用了,表示该桶的数据都迁移完了。

4.1.3.5 CASE4:桶table[i]未迁移完成

答:根据桶中结点的类型分为:链表迁移、红黑树迁移

(1)CASE4.1 普通链表如何迁移?
答:首先锁住数组上的Node节点,再进行迁移①根据lastRun节点的高位标识(0 或 1),首先将 lastRun 设置为 ln 或者 hn 链的末尾部分节点②使用高位和低位两条链表进行迁移,将旧桶中的链表拆成2个子链表ln、hn,后续的节点使用 头插法 拼接;③最终,ln链会插入到新table的槽i中,hn链会插入到新table的槽i+n中

if (fh >= 0) {                  // CASE4.1:桶的hash>0,说明是链表迁移int runBit = fh & n;    // 由于n是2的幂次,所以runBit要么是0,要么高位是1Node<K, V> lastRun = f; // lastRun指向最后一个相邻runBit不同的结点for (Node<K, V> p = f.next; p != null; p = p.next) {int b = p.hash & n;if (b != runBit) {runBit = b;lastRun = p;}}// 1.根据lastRun节点的高位标识(0 或 1),首先将 lastRun 设置为 ln 或者 hn 链的末尾部分节点                        if (runBit == 0) {ln = lastRun;hn = null;} else {hn = lastRun;ln = null;}// 2.使用高位和低位两条链表进行迁移,将旧桶中的链表拆成2个子链表ln、hn,后续的节点使用 头插法 拼接for (Node<K, V> p = f; p != lastRun; p = p.next) {int ph = p.hash;K pk = p.key;V pv = p.val;if ((ph & n) == 0)ln = new Node<K, V>(ph, pk, pv, ln);elsehn = new Node<K, V>(ph, pk, pv, hn);}// 3.最终,ln链会插入到新table的槽i中,hn链会插入到新table的槽i+n中setTabAt(nextTab, i, ln);               // ln链表存入新桶的索引i位置setTabAt(nextTab, i + n, hn);           // hn链表存入新桶的索引i+n位置setTabAt(tab, i, fwd);                  // 设置ForwardingNode占位advance = true;                         // 表示当前旧桶的结点已迁移完毕
}

(1.1)什么是lastRun节点?
答:从链表头向链表结尾遍历,最后一个变化的节点就是lastRun节点,lastRun节点后面的颜色都跟lastRun节点的一样,因为颜色相同,所以可以一次性把它们都迁移了,也就是说它找出这个lastRun节点目的在于尽可能的一次移动多个节点节约时间成本

(1.2)如果 lastRun 节点正好在一条全部都为高位或者全部都为低位的链表上,会不会形成死循环?
答:首先lastRun节点经过第一次遍历不变;第二次遍历拆分链表时,因为首节点就是lastRun,所以不会进行遍历;直接走第三步setTabAt(nextTab, i, ln)和setTabAt(nextTab, i + n, hn)

(2)CASE4.2 红黑树如何迁移?
答:首先锁住数组上的TreeBin节点,再进行迁移。①以链表方式遍历红黑树,使用高位和低位两条链表进行迁移,将旧桶中的链表拆成2个子链表ln、hn,后续的节点使用 尾插法 拼接。②形成中间链表ln、hn后,会先判断是否需要转换为红黑树:1.满足形成红黑树的条件,将TreeNode链表构造成红黑树,将TreeBin放入数组上的位置;2.不满足形成红黑树的条件,将TreeNode链表转为普通Node链表,再将该普通链表设置到新数组中去。③最终,ln链会插入到新table的槽i中,hn链会插入到新table的槽i+n中。

else if (f instanceof TreeBin) {    // CASE4.2:红黑树迁移TreeBin<K, V> t = (TreeBin<K, V>) f;TreeNode<K, V> lo = null, loTail = null;TreeNode<K, V> hi = null, hiTail = null;int lc = 0, hc = 0;// 1.以链表方式遍历红黑树,使用高位和低位两条链表进行迁移,将旧桶中的链表拆成2个子链表ln、hn,后续的节点使用 尾插法 拼接for (Node<K, V> e = t.first; e != null; e = e.next) {int h = e.hash;TreeNode<K, V> p = new TreeNode<K, V>(h, e.key, e.val, null, null);if ((h & n) == 0) {if ((p.prev = loTail) == null)lo = p;elseloTail.next = p;loTail = p;++lc;} else {if ((p.prev = hiTail) == null)hi = p;elsehiTail.next = p;hiTail = p;++hc;}}// 形成中间链表后,会先判断是否需要转换为红黑树:// 1.满足形成红黑树的条件,将TreeNode链表构造成红黑树,将TreeBin放入数组上的位置;// 2.不满足形成红黑树的条件,将TreeNode链表转为普通Node链表,再将该普通链表设置到新数组中去ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :(hc != 0) ? new TreeBin<K, V>(lo) : t;hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :(lc != 0) ? new TreeBin<K, V>(hi) : t;setTabAt(nextTab, i, ln);setTabAt(nextTab, i + n, hn);setTabAt(tab, i, fwd);  // 设置ForwardingNode占位advance = true;         // 表示当前旧桶的结点已迁移完毕
}

4.1.3.6 CASE1:当前是最后一个迁移任务或出现扩容冲突

答:首先会更新sizeCtl变量,将扩容线程数减1。然后会做一些收尾工作:设置table指向扩容后的新数组,遍历一遍旧数组,确保每个桶的数据都迁移完成——被ForwardingNode占用。另外,可能在扩容过程中,出现扩容冲突的情况,比如多个线程领用了同一区段的桶,这时任何一个线程都不能进行数据迁移。

 if (i < 0 || i >= n || i + n >= nextn) {int sc;// 扩容结束后做后续工作,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值if (finishing) {nextTable = null;table = nextTab;sizeCtl = (n << 1) - (n >>> 1);return;}// 每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行减 1 操作if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {// 说明该线程不是扩容大军里面的最后一条线程,直接return回到上层while循环if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)return;finishing = advance = true;i = n; // recheck before commit}}

(1)多线程迁移任务完成后的操作

4.1.3.6 学习链接

J.U.C之collections框架:ConcurrentHashMap(2) 扩容

ConcurrentHashMap1.8 - 扩容详解

深入解析 ConcurrentHashMap 实现内幕,吊打面试官,没问题

4.1.4 扩容期间在未迁移到的hash桶插入数据会发生什么?

答:(1)遇到get操作时:迁移期间形成的 hn、In链是复制出来的,而非原来的链表迁移过去的,所以原来hash桶上的链表并没有受到影响,依旧可以正常访问原数组hash桶上面的链表,迁移期间不会阻塞get操作;
(2)遇到put操作时:因为每个hash桶在迁移之前会使用同步锁把自己锁住。而put操作同样也需要获得该锁,所以此时遇到put操作时,put操作会因获取不到锁而阻塞。

4.1.5 扩容期间在未迁移到的hash桶插入数据会发生什么?

答:只要插入的位置扩容线程还未迁移到,就可以插入;如果迁移到该插入的位置时,就会阻塞等待插入操作,完成再继续迁移

4.1.6 扩容完成后为什么要再检查一遍?

答:为了避免遗漏hash桶

4.1 我们在多线程程序设计过程中可以借鉴什么呢?

答:(1)长锁不如短锁,尽量只锁必要部分;
(2)大锁不如小锁,尽可能对加锁的对象进行拆分;
(3)公锁不如私锁,尽可能将锁的逻辑放在私有代码里;
(4)嵌套锁不如扁平锁,尽可能在代码设计时避免嵌套锁;
(5)分离读写锁,尽可能将读锁和写锁分离(如果大部分时间在读,只有少部分时间在写,那么给写加一个大锁,读加volatile或者不加锁);
(6)粗化高频锁,尽可能合并处理频繁的短锁;
(7)消除无用锁,尽可能不加锁,或用volatile代替(可以保证原子性或者可见性的话);

4.8 学习链接

JDK1.8之ConcurrentHashMap

第10讲 | 如何保证集合是线程安全的? ConcurrentHashMap如何实现高效地线程安全

5 ArrayMap

(1)概念
ArrayMap是一个普通的键值映射的数据结构,这种数据结构比传统的HashMap有着更好的内存管理效率。传统HashMap非常的好用,但是它对内存的占用非常的大。为了解决HashMap更占内存的弊端,Android提供了内存效率更高ArrayMap。
ArrayMap利用两个数组,mHashes用来保存每一个key的hash值,mArrray大小为mHashes的2倍,依次保存key和value,关键语句:

mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;

ArrayMap通过二分查找。当插入时,根据key的hashcode()方法得到hash值,计算出在mArrays的index位置,然后利用二分查找找到对应的位置进行插入,当出现哈希冲突时,会在index的相邻位置插入。从空间角度考虑,ArrayMap每存储一条信息,需要保存一个hash值,一个key值,一个value值。对比下HashMap粗略的看,只是减少了一个指向下一个entity的指针。时间效率上看,插入和查找的时候因为都用的二分法,查找的时候没有hash查找快。插入的时候,如果顺序插入的话效率肯定高;但如果是随机插入,需要大量的数组搬移,数据量大效率肯定低。

(2)结构图

(3)分析ArrayMap是如何优化内存的
在allocArrays中,使用缓存的核心代码如下:

final Object[] array = mTwiceBaseCache;
mArray = array;
mTwiceBaseCache = (Object[])array[0];
mHashes = (int[])array[1];
array[0] = array[1] = null;//mArray==null,array==null
mTwiceBaseCacheSize--;//mTwiceBaseCache,mHashes都不为空

对于BASE_SIZE*2和BASE_SIZE两种尺寸的数组在这里它并没有对它们进行释放,而是把它们缓存起来,这样我们在分配的时候,如果需要分配这两种大小的数组,就可以直接从缓存中取得,否则,就直接new两个数组,第二个数组存放的是键值对,所以大小是size的两倍,size<<1左移一位操作就相当于乘以2。mBaseCache和mTwiceBaseCache两种size的缓存过程相同,我们以mTwiceBaseCache为例来分析它的过程。
  
在freeArrays中,第一次执行freeArrays得到如下图:

第二次执行freeArrays得到如下图:

将上面的核心代码和图形结合,由于mTwiceBaseCache指向整个Object数组,这个数组就是存储键值对的数组,可以直接拿了使用,用来存放键值对,因为这个数组的第一个元素存放的上一个键值对数组的引用,把它存放在mTwiceBaseCache中,这样下一次就可以获取下一个缓存的数组了,第二个元素存放的是hash数组的引用,这样就可以得到这个hash数组,直接来使用。

(4)参考链接
【内存优化】ArrayMap源码解析

5 SparseArray

(1)概念
SparseArray是android里为(Interger,Object)这样的Hashmap而专门写的class,目的是提高效率,其核心是折半查找函数(binarySearch)。所以Android开发中官方推荐:当使用HashMap(K, V),如果key为int时,注意是int而不是Integer,使用SparseArray的效率更高。

(2)结构图

(3)原理分析
因为key为int也就不需要什么hash值了,只要int值相等,那就是同一个对象,简单粗暴。插入和查找也是基于二分法,原理和Arraymap基本一致,这里就不多说了。
所以,空间上与HashMap对比,去掉了Hash值的存储空间,没有next的指针占用,还有其他一些小的内存占用,看着节省了不少。时间上对比:插入和查找的情形和Arraymap基本一致,可能存在大量的数组搬移。但是它避免了装箱的环节,这是很费时的过程。

(4)HashMap,ArrayMap,SparseArray源码分析及性能对比

  • 在数据量小的时候一般认为1000以下,当你的key为int的时候,使用SparseArray确实是一个很不错的选择,内存大概能节省30%,相比用HashMap,因为它key值不需要装箱,所以时间性能平均来看也优于HashMap,建议使用!
  • ArrayMap相对于SparseArray,特点就是key值类型不受限,任何情况下都可以取代HashMap,但是通过研究和测试发现,ArrayMap的内存节省并不明显,也就在10%左右,但是时间性能确是最差的,当然了,1000以内的数据量也无所谓了,还不如用HashMap放心。
    HashMap,ArrayMap,SparseArray源码分析及性能对比

(5)参考链接
SparseArray到底哪点比HashMap好

6 LinkedHashMap

(1)概念
LinkedHashMap是由数组+双向链表的数据结构来实现的。其中,next用于维护HashMap各个桶中的Entry链,before、after用于维护LinkedHashMap的双向链表,虽然它们的作用对象都是Entry,但是各自分离。

其中,HashMap与LinkedHashMap的Entry结构示意图如下图所示:

特别地,由于LinkedHashMap是HashMap的子类,所以LinkedHashMap自然会拥有HashMap的所有特性。比如,LinkedHashMap也最多只允许一条Entry的键为Null(多条会覆盖),但允许多条Entry的值为Null。此外,LinkedHashMap 也是 Map 的一个非同步的实现。

(2)结构图

Map<String,String> map = new LinkedHashMap<String,String>();
map.put("数学","数学老师");
map.put("化学","化学老师");
map.put("物理","物理老师");
map.put("生物","生物老师");
map.put("政治","政治老师");


图片来源于:Java_LinkedHashMap工作原理

(3)实现LRU (Least recently used, 最近最少使用)算法
彻底解析Android缓存机制——LruCache

Android DiskLruCache完全解析,硬盘缓存的最佳方案

(4)LRU算法原理
不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。
LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LrhCache和DisLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是LRU缓存算法。

其中,LruCache中维护了一个集合LinkedHashMap,该集合是以访问顺序排序的。当调用put()方法时,就会在结合中添加元素,并调用trimToSize()判断缓存是否已满,如果满了就用集合的迭代器删除队尾元素,即近期最少访问的元素,如:添加F。当调用get()方法访问缓存对象时,就会调用集合的get()方法获得对应集合元素,同时把它在当前位置删除,然后添加该元素到对头,如:获取C。

(5)参考链接
Map 综述(二):彻头彻尾理解 LinkedHashMap

LinkedHashMap源码剖析

7 HashTable

(1)结构图

图片来源于:Map 综述(四):彻头彻尾理解 HashTable
(2)HashMap与HashTable区别

  • HashMap是非线程同步的,HashTable是线程同步的;
  • HashMap允许null作为键或者值,HashTable不允许null作为键或者值;
  • HashMap去掉了contains方法,HashTable中有此方法;
  • 效率上来讲,HashMap因为是非线程安全的,因此效率比HashTable高。

(3)参考链接
Map 综述(四):彻头彻尾理解 HashTable

9 TreeMap

(1)概念
TreeMap实现了Map接口,元素是有序的,内部使用红黑树实现,主要用于存入元素的时候对元素进行自动排序,迭代输出的时候就按排序顺序输出。红黑树是统计效率比较高的大致平衡的排序二叉树。具备以下特点:

  • 按键有序,基于红黑二叉树的NavigableMap的实现,可以方便根据键的顺序进行查找,如第一个、最后一个、附近键;
  • 为了按键有序,存入TreeMap的元素应当实现Comparable接口或者实现Comparator接口,会按照排序后的顺序迭代元素;
  • 根据键保存、查找、删除的效率比较高,时间复杂度为log(n),n为节点数
  • 线程非安全,不允许null,key不可以重复,value允许重复。

(2)HashMap和TreeMap比较

  • HashMap的结果是没有排序的,适用于在Map中插入、删除和定位元素;
  • TreeMap输出的结果是排好序的,适用于按自然顺序或自定义顺序遍历键(key);
  • HashMap通常比TreeMap快一点(树和哈希表的数据结构使然);
  • HashMap非线程安全TreeMap非线程安全。

(3)参考链接
TreeMap原理

红黑树

10 HashSet

(1)概念
HashSet的实现本质,它封装了一个HashMap。它实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要先确保对象重写equals()和hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。
(2)源码分析

public HashSet() {  map = new HashMap<>();
}  private static final Object PRESENT = new Object();
public boolean add(E e) {  return m.put(e, PRESENT)==null;
}

add方法调用了HashMap的put方法。存入到HashSet的值对应map的key,而HashMap的key是不允许重复的,所以HashSet不会有重复的值。同时,不需要关心这个key对应的value,所以才定义了一个静态final的object对象。
(3)HashSet和HashMap的区别

(4)参考链接
HashMap和HashSet的区别和分析

11 TreeSet

算法刻意练习之Map系列相关推荐

  1. 技术图文:如何在leetcode上进行算法刻意练习?

    背景 众所周知,通过刻意练习高质量的题目可以加深我们对计算机科学中经典数据结构的深刻理解,从而可以快速用合适的数据结构去解决现实中的问题.而LeetCode就是一个收集了各大IT公司的笔试面试题的在线 ...

  2. 我是如何组织“算法刻意练习活动”的?

    背景 在上个学期末,我们组织了一次团队的招新活动 – 如何加入 LSGO 软件技术团队?. 我们让预加入团队的同学在假期中完成以下两个任务之一: 学习 C# 语言: https://www.bilib ...

  3. PCA主成分分析算法专题【Python机器学习系列(十五)】

    PCA主成分分析算法专题[Python机器学习系列(十五)] 文章目录 1. PCA简介 2. python 实现 鸢尾花数据集PCA降维 3. sklearn库实现 鸢尾花数据集PCA降维案例    ...

  4. 计算机视觉目标检测算法总结4——其他SSD系列算法

    RSSD算法 rainbow concatenation方式(pooling加deconvolution)融合不同层的特征,在增加不同层之间feature map关系的同时也增加了不同层的featur ...

  5. 【经典算法必读】图片分类系列之(一): 你真的了解图片分类(Image Classification)吗?...

    欢迎关注我的个人微信公众号:小纸屑 图片分类是机器学习经典问题,也是深度学习声名鹊起之作.正是2012年AlexNet在图片分类竞赛ImageNet出乎寻常的性能,使得深度学习一夜爆红,方有今天人工智 ...

  6. python卷积神经网络cnn的训练算法_【深度学习系列】卷积神经网络CNN原理详解(一)——基本原理...

    上篇文章我们给出了用paddlepaddle来做手写数字识别的示例,并对网络结构进行到了调整,提高了识别的精度.有的同学表示不是很理解原理,为什么传统的机器学习算法,简单的神经网络(如多层感知机)都可 ...

  7. 算法:用户喜好--Map与List配合下的查找

    提示:在算法处理过程中,未必就要将出现在前面的作为关键字检索.比如本题,非得先去检索范围,再去判断范围中key的个数.反其道而行,把输入的数字当作关键字,组成Map package test;impo ...

  8. 最短路dijkstra算法详解_图论系列开始填坑--Dijkstra,单源最短路

    暑假只有最开始的几天最有意思,考完试玩了几天就感觉到了无聊.抱着想要出去走走的心态,我制定了一个出行路线图,我在1号城市,想去看一看2,3,4,5号城市(每去一个城市都从1号城市出发),一切准备就绪, ...

  9. MATLAB写UCB算法,科学网—【RL系列】Multi-Armed Bandit问题笔记——UCB策略实现 - 管金昱的博文...

    本篇主要是为了记录UCB策略在解决Multi-Armed Bandit问题时的实现方法,涉及理论部分较少,所以请先阅读Reinforcement Learning: An Introduction ( ...

最新文章

  1. 详解JavaScript数组(一)
  2. 互联网1分钟 | 0307 阿里巴巴与NBA升级中国合作伙伴关系;小米成立AIoT战略委员会...
  3. UML 中关系详解以及在visio中的表示
  4. [图像处理] 直方图均衡化原理 - 数学推导
  5. Android—简单路由框架实践
  6. 如何开启一个Django项目
  7. PowerDesigner 手记
  8. HTML5要点(四)对象全整理
  9. 第二阶段团队站立会议04
  10. gambit2.4证书免费分享
  11. m3u8在线提取工具:M3U8 Downloader 高速专业m3u8下载器下载
  12. 会员积分营销系统,现代营销利器
  13. 【C语言练习】分离英语句子中的单词并统计每个单词出现次数后排序输出
  14. 传真机使用方法,使用说明
  15. 什么是bypass(转载)
  16. 瑞典皇家理工学院计算机,瑞典皇家理工学院
  17. 3.10Hello,C语言君
  18. Go语言获取中文及其他非英语字符长度
  19. python自学笔记+一个汇率计算PyQt实例
  20. MATLAB的图像显示方法

热门文章

  1. Dell键盘卡卡感觉的DIY修复
  2. ayit第十六周周赛题 a题
  3. 内存泄露(十)-- KOOM(高性能线上内存监控方案)
  4. Elisp之语法练习(五)
  5. php serv-u,用php写的serv-u的web申请账号的程序,_php
  6. 应用间跳转/友盟统计/支付宝
  7. vue-video-player文档_基于vue-video-player自定义播放器的方法
  8. petalinux-build: do_compile: oe_runmake failed do_compile: Function failed: do_compile 解决方法
  9. linux .net程序,.Net程序跑在Linux上
  10. soldermask和pastemask的理解