1.HashMap(基于jdk1.8)

1.1、HashMap(基于jdk1.8)的存储结构

jdk1.8中,HashMap的存储结构为:数组(哈希表)+链表+红黑树(jdk1.7中HashMap的存储结构为数组(哈希表)+链表)

jdk1.8中HashMap的结构图:

HashMap与ConcurrentHashMap详解_hashmap

1.2、HashMap(基于jdk1.8)的源码分析

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
//................
}

1.2.1、HashMap中的常量值

/**
* 默认初始容量值 16(数组(哈希表)初始容量),必须为 2 的 n 次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 即 16

/**
* 最大容量 2 ^ 30(也是指的数组(哈希表)的容量),如果构造方法传入的最大容量值大于该值,则将最大容量设置为该值,同样必须为 2 的 n 次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* 默认负载因子 0.75,当构造方法中没有指定的时候,将使用该值
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
* 将链表转化二叉树的阈值 8
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 将二叉树转化回链表的阈值 6
*/
static final int UNTREEIFY_THRESHOLD = 6;

/**
* 将链表转化成红黑树时,最小容量值 64(注意:不是 map 中的元素个数,是数组(哈希表)的容量)
*/
static final int MIN_TREEIFY_CAPACITY = 64;
1.2.1.1、为什么加载因子是0.75

加载因子如果定的太大,比如1,这就意味着数组的每个空位都需要填满,即达到理想状态,不产生链表,但实际是不可能达到这种理想状态,如果一直等数组填满才扩容,虽然达到了最大的数组空间利用率,但会产生大量的哈希碰撞,同时产生更多的链表,显然不符合我们的需求。

但如果设置的过小,比如0.5,这样一来保证了数组空间很充足,减少了哈希碰撞,这种情况下查询效率很高,但消耗了大量空间。

因此,我们就需要在时间和空间上做一个折中,选择最合适的负载因子以保证最优化,取到了0.75

1.2.2、HashMap中的内部字段及作用

/**
* 用来存放键值对的 Node 数组,也就是哈希表,在 resize() 方法中会初始化这个数组的大小,当分配空间时它的长度总是 2 的 n 次方,也就是容量capacity
* 注意该字段被 transient 修饰,意味着序列化 HashMap 的时候将忽略该字段,反序列化的时候该字段将为 null
*/
transient Node<K,V>[] table;

/**
* 该字段维护了一个键值对的集合 entrySet,而 HashMap 的父类 AbstractMap 维护了键集合 keySet 和值集合 values
*/
transient Set<Map.Entry<K,V>> entrySet;

/**
* 当前 HashMap 对象的键值对数量
*/
transient int size;

/**
* 该字段记录了当前 HashMap 对象结构修改的次数(包括数量的改变、结构调整,例如调整哈希值)
* 作用是在遍历 Map 的时候,如果此时有其他操作修改了该 Map(增删改元素)就会造成该字段值的改变,
* 通过比较该值的前后是否相等,如果不相等,则会抛出异常
*/
transient int modCount;

/**
* 极限值,在每次 resize 的时候将值设置为 (capacity * loadFactor),当键值对的数量达到该值的时候将会进行 resize() 扩容操作
*/
int threshold;

/**
* 负载因子
*/
final float loadFactor;

1.2.3、HashMap中的构造方法

HashMap是采用的懒加载机制,也就是说在执行new HashMap()的时候,构造方法并没有在构造出HashMap实例的同时也把HashMap实例里所需的数组给初始化出来。那么什么时候才去初始化里面的数组呢?答案只有在第一次需要用到这个数组的时候才会去初始化它,就是在你往HashMap里面put第一个元素的时候。

HashMap 有四个构造方法,分别是:

  • HashMap():仅仅只是设置了负载因子(loadFactor)为默认值 0.75(此时极限值(threshold)仍然为0,真正设置该值是在第一次调用 put 方法,会进行一次 resize() 的过程,此过程发生后,极限值(threshold)会变成 capacity * loadFactor 即 16 * 0.75 = 12)
  • HashMap(int initialCapacity) :调用 HashMap(int initialCapacity, float loadFactor) 方法。
  • HashMap(int initialCapacity, float loadFactor) :校验两个参数之后,设置了负载因子(loadFactor)以及极限值(threshold),此时极限值就是最大容量值 capacity,capacity 总是 2 的 n 次方。同样的,在第一次 put 时,会进行一次 resize() 的过程,就会改变极限值(threshold)为 capacity * loadFactor。
  • HashMap(Map<? extends K, ? extends V> m):相当于把 m 中的值都复制到一个新的 HashMap 对象(该过程复制的是引用地址,也就是说,一旦改变该对象,两个 HashMap 对应的值都会改变)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;//加载因子,默认值为0.75f
}
//自己定义初始化容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
//调用 HashMap(int initialCapacity, float loadFactor)这个方法
}
// 自己定义初始化容量与加载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)//数组(哈希表)的最大容量2^30
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/*
当你调用带参构造器初始化一个指定数组容量的HashMap时,构造器会根据你输入的参数重新计算得到一个HashMap初始化数组之后数组实际的长度,这个值也是会在put元素初始化数组时起作用。计算的逻辑在tableSizeFor(int cap)中,如下:
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; // n = 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;
}
/*
这个方法的作用是什么呢?,比如你输入的容量参数为A,返回值为B,那么A与B的关系如下:
1、B大于或等于A
2、B为最接近A的一个2的n次方
*/
/*
那么方法体是如何实现这些逻辑的呢?,从代码中可以看到是:将传进来的容量值减1之后赋值给n,之后n的二进制码要与(>>>)n进行无符号右移之后的二进制码进行(|)位或运算,
最终 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1这一行,
先判断n是否大于0:
1、不大于0返回1
2、否则再判断n是否大于定义的最大容量(1>>30):
(1)若大于1>>30,则返回1>>30,(2)否则返回n+1。
实际最终这个方法,就是利用移位与位或运算,将n-1得到的值转成2进制之后,从1的最高位开始将低位全部转化为1,再加1之后就可以得到一个2^n的数~


*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;//加载因子,默认值为0.75f
putMapEntries(m, false);
}

1.2.4、HashMap中定位哈希桶数组索引位置

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过 HashMap 的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。下面是定位哈希桶数组的源码:

// 代码1
static final int hash(Object key) { // 计算key的hash值
int h;
// 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2 (这部分代码会在putVal ()方法等其他方法中出现)
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;//代替了模运算hash%n
  • 整个过程本质上就是三步:
  • 拿到 key 的 hashCode 值
  • 将 hashCode 的高16位参与异或运算,重新计算 hash 值
  • 将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
  • 方法解读:
  • 对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
  • 但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & hash” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。
  • 我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “hash mod table.length”,对应上面的公式,可以得到该运算等同于“hash & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。
  • 我们知道计算机的运行效率:加法(减法)>乘法>除法>取模,取模的效率是最低的。所以我们要在HashMap中避免频繁的取模运算,又因为在我们HashMap中他要通过取模去定位我们的索引,并且HashMap是在不停的扩容,数组一旦达到容量的阈值的时候就需要对数组进行扩容。那么扩容就意味着要进行数组的移动,数组一旦移动,每移动一次就要重新计算索引,这个过程中牵扯大量元素的迁移,就会大大影响效率。那么如果说我们直接使用与运算,这个效率是远远高于取模运算的
  • 在 JDK1.8 的实现中,还优化了高位运算的算法,将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。
1.2.4.1、为什么数组的长度必须是2的指数次幂

在HashMap中有一个tableSizeFor(int cap)方法,它的作用是返回一个大于输入参数且最近的2的整数次幂的数,所以构造出来的HashMap实例对象的容量始终是2的指数次幂。那么为什么呢?

在上面提到了,用位与运算“hash & (table.length - 1)”代替取模运算“hash mod table.length”来确定索引(下标),而前者可以代替后者的前提就是table.length为2的指数次幂,不然这两种方式计算出来的值是不等的,就不能用与运算来代替模运算求索引(下标),这是第一点,看下面的例子;

//length = 4时
(n-1) & hash = (4-1) & 10 = 00000011 & 00001010 = 00000010 = 2
hash % length = 10 % 4 = 2
//length = 5时
(n-1) & hash = (5-1) & 10 = 00000100 & 00001010 = 00000000 = 0
hash % length = 10 % 5 = 2

第二点,也是最重要的一点,要保证通过用位与运算“hash & (table.length - 1)定位出来的值是在数组的长度之内,不能超过数组长度,并且减少哈希碰撞,让每个位置都可能被取到,看下面的例子:

例如:(16-1) & hash
二进制的15: 0000 0000 0000 1111
hash(随机) 1101 0111 1011 0000
hash(随机) 1101 0111 1011 1111
结果 0000 0000 0000 0001 ~ 0000 0000 0000 1111
即得出的索引下标只能在0~15之间,保证了所有索引都在数组长度的范围内而不会越界,并且由于2的指数次幂-1都是...1111的形式的,即最后一位是1,这样,由于hash是随机的,进行与运算后每一位都是能取到的

反例:(7-1) & hash
二进制6: 0000 0000 0000 0110
hash 1011 1001 0101 0000
hash 1001 0001 0000 1111
结果 0000 0000 0000 0000 ~ 0000 0000 0000 0110
即得出的索引范围在0~6,虽然不会越界,但最后一位是0,即现在无论hash为何值,0001,0011,0101这几个值是不可能取到的,这就加剧了hash碰撞,并且浪费了大量数组空间,显然是我们不想看到的

即我们得出结果:

  • 第一点:只有数组的长度是2的指数次幂,位与运算才能和取模算法的值相等,位与运算“hash & (table.length - 1)”才能代替取模运算“hash mod table.length”来确定索引(下标),这是为了提高计算效率。
  • 第二点:只有2的指数次幂,才能使位与运算“hash & (table.length - 1)”计算出来的值,保证能取到数组的每一位,减少哈希碰撞,不浪费大量的数组资源。
  • 从扩容方面来说,扩容是扩容到原来的两倍(二进制的位运算就是左移1位,计算速度快),所以扩容之后的容量也是2的指数次幂,扩容之后的索引(下标)需要重新计算,并且新的索引要么落在原位置,要么按照 2 的 n 次方偏移。

1.2.5、HashMap中的put()方法,putVal ()方法

  • put 方法实际上是调用了 putVal 方法
  • 这一部分也来看一下 jdk1.8 中 HashMap 由链表转化成红黑树的条件:
  • 当前 hash 位置的链表长度(结点数)大于等于 8
  • table(哈希表)的容量大于等于 64

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

//上面putVal()方法中第一个参数调用的方法,计算哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

/**
* Implements Map.put and related methods
*
* @param hash hash for key (key 的哈希值)
* @param key the key (key 值)
* @param value the value to put(需要 put 的值)
* @param onlyIfAbsent if true, don't change existing value(是否保留已存在的值)
* @param evict if false, the table is in creation mode(非创建模式(HashMap 中没有使用该值))
* @return previous value, or null if none (返回之前存在的值,如果不存在则为空)
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表该索引位置不为空,则进行查找
Node<K,V> e; K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 如果 onlyIfAbsent 为 false 说明不保留原来的值,而是用新值替换原来的值,如果原来的值为 null,也会用新值替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 在 HashMap 中这是一个空方法,在其子类 LinkedHashMap 中实现了该方法,作用是将修改结点移到最后,保证 LinkedHashMap 元素的顺序
afterNodeAccess(e);
// 返回原值
return oldValue;
}
}
// 修改计数器自增1
++modCount;
// 判断当前元素个数是否达到需要扩容的极限值
if (++size > threshold)
resize();
// 在 HashMap 中同样是一个空方法
afterNodeInsertion(evict);
return null;
}
  • 可以看到一个很重要的判断,if (binCount >= TREEIFY_THRESHOLD - 1) 从这里也可以看出树化的第一个条件是:当 bitCount 达到 TREEIFY_THRESHOLD - 1 时,将会对 table 进行红黑树化(treeifyBin),接下来我们来看看 treeifyBin() 方法的源码,探究链表树化的第二个条件。
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
* 用红黑树替换给定 hash 位置的所有连接的结点,如果 table(哈希表)空间太小,这种情况下只进行 resize (扩容)操作
*/

final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 当 tab 为空或者 tab 的 length 小于 MIN_TREEIFY_CAPACITY 也就是 64 时,只是对 tab 进行 resize 而不进行树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 否则将 hash 对应位置非空的链表进行树化
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 遍历 hash 对应位置的链表,将 Node 类型的节点转化成 TreeNode 类型,并且保留链表的前驱后继结点的关系
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);
// 将 TreeNode 类型的结点数组转化成红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
  • 可以看到 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) 这句条件判断,当 tab 为空或者其长度小于 64 时,只是进行 resize 而没有进行树化。这就是链表树化的第二个条件。综合上面提到的,可以看出,要将 hash 对应位置的链表转化成二叉树,要满足以下两个条件:
  • 当前 hash 位置的链表长度(结点数)大于等于 8
  • table(哈希表/数组)的容量大于等于 64
1.2.5.1、为什么链表长度大于等于8时转成了红黑树

这里要提到一个概率论中的泊松分布,因为链表长度大于等于8时转成红黑树正是遵循泊松分布,先来看一下泊松分布

HashMap与ConcurrentHashMap详解_java.util.concurrent_02

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. 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.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1

以上时HashMap源码中注释对泊松分布的描述,HashMap节点分布遵循泊松分布,按照泊松分布的计算公式计算出了链表中元素个数和概率的对照表,可以看到链表中元素个数为8时的概率已经非常小

另一方面红黑树平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是链表和红黑树之间的转换也很耗时。

当然,虽然在hashmap底层有这种红黑树的结构,但是我们要知道能产生这种结构的概率也不大,所以我们知道在 JDK1.7 到 JDK1.8 这其中HashMap的性能也只提高了7%~8% 左右

1.2.5.2、内部字段++size代表的具体意义
  • HashMap中定义了一个字段transient int size; 代表的是当前 HashMap 对象中的键值对数量。
  • threshold 极限值(数组/哈希表的容量*加载因子),其实初始容量和最大容量也是指的是数组(哈希表)的容量。

而在putVal()方法中,插入一个键值对后,就要判断当前map集合中键值对的数量是否到了扩容的极限值,

// 判断当前元素个数是否达到需要扩容的极限值
if (++size > threshold)
resize();

我就产生了一个疑问,与扩容极限值比较大小的不应该是一个代表当前数组(哈希表)里的键值对数量吗,为什么是整个map集合里面的键值对量(它包括了数组+链表+红黑树中的键值对数量)?

  • 这是因为,我们希望的是HashMap集合中键值对都存放在数组(哈希表)中,这样定位最快,访问也最快,不用再去遍历链表或者红黑树,而决定键值对元素放在数组中的哪个位置是由 hash 方法来决定的。当一个hash方法很优秀时,它的哈希冲突是很少的,这样效率也是很高的。而在目前的实际情况下,如果要仅仅判断数组(哈希表)中的键值对是否到了扩容的极限值才去扩容的话,那么说明在整个hashmap的存储结构中已经存在了很长的链表甚至红黑树也出现了。
1.2.5.3、put方法总结:

HashMap与ConcurrentHashMap详解_数组_03

  1. 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的​​key、key 的 hashcode​​ 与写入的 key 是否相等,相等就赋值给 ​​e​​,之后再判断e为空否。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果​​e != null​​ 就相当于存在相同的 key,那就需要将值覆盖。
  9. 最后判断是否需要进行扩容。

1.2.6、HashMap中的resize()方法(扩容方法)

  • 这一部分也来看一下 jdk1.8 中 HashMap 将红黑树转化回链表的条件:
  • 当对应位置(哈希桶)红黑树的结点数小于等于 6 时,会将红黑树转化回链表。
  • 另外还有一个值得注意的方法就是前面多次提到的 resize(),其源码如下:
/**
* 初始化或者扩容 table 的空间,如果 table 为空,则用默认值初始化 capacity,threshold 与 capacity 的值相等.
* 否则,由于使用 2 的 n 次方来扩容,因此 table 中每一个位置的元素在新 table(哈希表)中要么落在原位置,要么按照 2 的 n 次方偏移。
*
* @return 返回 table(哈希表)
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.老表的容量不为0,即老表不为空
if (oldCap > 0) {
// 1.1 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
// 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 2.如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
else if (oldThr > 0)
newCap = oldThr;
else {
// 3.老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 6.如果老表不为空,则需遍历所有节点,将节点赋值给新表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 将索引值为j的老表头节点赋值给e
oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
// 7.如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 9.如果是普通的链表节点,则进行普通的重hash分布
// 存储索引位置为:“原索引位置”的节点
Node<K,V> loHead = null, loTail = null;
// 存储索引位置为:“原索引位置+oldCap”的节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 9.1 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
}
// 9.2 如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e; // 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
}
} while ((e = next) != null);
// 10.如果loTail不为空(说明老表的数据有分布到新表上“原索引位置”的节点),则将最后一个节点的next设为空,并将新表上索引位置为“原索引位置”的节点设置为对应的头节点(将不需要调整的元素放入新 table 对应位置)
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 11.如果hiTail不为空(说明老表的数据有分布到新表上“原索引+oldCap位置”的节点),则将最后一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点(将需要调整的元素放入新 table 重新计算后的位置(对原位置偏移 oldCap 长度))
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回新表
return newTab;
}
  • 在 resize 方法中,如果 hash 对应位置的元素是一个红黑树,则调用 TreeNode 类的 split 方法拆分红黑树,接下来看看 split 方法的源码:
/**
* 将红黑树中的节点拆分为上下红黑树,如果树太小则去树化操作,只进行 resize 操作,逻辑可以参考 resize 方法
*
* @param map the map
* @param tab 记录红黑树父节点的 table(哈希表)
* @param index 需要被拆分的位置
* @param bit 需要被拆分的大小
*/
/**
* 扩容后,红黑树的hash分布,只可能存在于两个位置:原索引位置、原索引位置+oldCap
*/
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this; // 拿到调用此方法的节点
TreeNode<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
TreeNode<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引+oldCap”的节点
int lc = 0, hc = 0;
// 1.以调用此方法的节点开始,遍历整个红黑树节点
for (TreeNode<K,V> e = b, next; e != null; e = next) { // 从b节点开始遍历
next = (TreeNode<K,V>)e.next; // next赋值为e的下个节点
e.next = null; // 同时将老表的节点设置为空,以便垃圾收集器回收
// 2.如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
++lc; // 统计原索引位置的节点个数
}
// 3.如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
else {
if ((e.prev = hiTail) == null) // 如果hiHead为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e; // 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
++hc; // 统计索引位置为原索引+oldCap的节点个数
}
}
// 4.如果原索引位置的节点不为空
if (loHead != null) { // 原索引位置的节点不为空
// 4.1 如果节点个数<=6个则将红黑树转为链表结构
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
// 4.2 将原索引位置的节点设置为对应的头节点
tab[index] = loHead;
// 4.3 如果hiHead不为空,则代表原来的红黑树(老表的红黑树由于节点被分到两个位置)
// 已经被改变, 需要重新构建新的红黑树
if (hiHead != null)
// 4.4 以loHead为根节点, 构建新的红黑树
loHead.treeify(tab);
}
}
// 5.如果索引位置为原索引+oldCap的节点不为空
if (hiHead != null) { // 索引位置为原索引+oldCap的节点不为空
// 5.1 如果节点个数<=6个则将红黑树转为链表结构
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
// 5.2 将索引位置为原索引+oldCap的节点设置为对应的头节点
tab[index + bit] = hiHead;
// 5.3 loHead不为空则代表原来的红黑树(老表的红黑树由于节点被分到两个位置)
// 已经被改变, 需要重新构建新的红黑树
if (loHead != null)
// 5.4 以hiHead为根节点, 构建新的红黑树
hiHead.treeify(tab);
}
}
}
  • 由上可以得出结论:当对应位置(哈希桶)红黑树的结点数小于等于 6 时,会将红黑树转化回链表。

1.2.7、 HashMap jdk1.7 与jdk1.8的不同

HashMap与ConcurrentHashMap详解_hashmap_04

2.ConcurrentHashMap

2.1、HashMap存在的问题

  • HashMap存在的问题:HashMap线程不安全,并发情况下会造成并发修改异常
  • 因为多线程环境下,使用Hashmap进行put操作可能会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。例如如下代码:
package com.wlw.unsafe;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* 多线程使用HashMap()也会 造成 java.util.ConcurrentModificationException (并发修改异常)
* 解决方案:
* 1.Map<String, String> map = Collections.synchronizedMap( new HashMap<>());
* 2. Map<String, String> map = new ConcurrentHashMap<>(); (juc包下的类)
*/
public class MapTest {

public static void main(String[] args) {
//map 是这样用的吗? 不是,工作中不使用 HashMap()
//默认等价什么? new HashMap<>(16,0.75);加载因子为0.75、初始化容量16
//Map<String, String> map = new HashMap<>();
//Map<String, String> map = Collections.synchronizedMap( new HashMap<>());
Map<String, String> map = new ConcurrentHashMap<>();
for (int i = 1; i <30; i++) {
new Thread(()->{
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,5));
System.out.println(map);
},String.valueOf(i)).start();
}
}
}
  • HashMap是线程不安全的原因:
  • HashMap在高并发的环境下,存在多个进程同时进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,从而导致Entry的next节点始终不为空,进而导致在get时会出现死循环,所以HashMap是线程不安全的。

Hashtable线程安全但效率低下

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

2.2、ConcurrentHashMap(基于jdk1.7)

  • jdk1.7中,ConcurrentHashMap采用分段锁的机制,实现并发的更新操作,

2.2.1、jdk1.7ConcurrentHashMap的数据结构

  • 底层由Segment数组和HashEntry数组组成。Segment继承ReentrantLock用来充当锁的角色,不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
  • 每个 Segment 对象守护每个散列映射表的若干个桶(HashEntry 数组)。HashEntry 用来封装映射表的键值对;每个桶是由若干个 HashEntry 对象链接起来的链表。一个 ConcurrentHashMap 实例中包含由若干个 Segment 对象组成的数组,再用Segment数组(继承ReentrantLock)来链接各个HashEntry数组,然后各个HashEntry数组中连接各自的HashEntry链(Segment的结构与HashMap类似,每个片段对应一个table数组和链表结构,即每一个Segment元素存储的是HashEntry数组+链表),下面我们通过一个图来演示一下 ConcurrentHashMap 的结构:

HashMap与ConcurrentHashMap详解_链表_05

2.2.2、jdk1.7ConcurrentHashMap中的Segment & HashEntry

/**
* The segments, each of which is a specialized hash table.
*/
final Segment<K,V>[] segments;

// 集成 ReentrantLock
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;

static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

// 每一个segment对应一个HashEntry数组
transient volatile HashEntry<K,V>[] table;

// 总的元素个数
transient int count;

// 修改次数
transient int modCount;

// 阈值
transient int threshold;

// 加载因子
final float loadFactor;

// 构造函数
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}

// 往segment添加一个元素
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// ...
}

// 扩容数组,变为之前的两倍,重新打包之前的数据,然后把新的节点添加进去
@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
// ...
}

}
/**
* ConcurrentHashMap list entry. Note that this is never exported
* out as a user-visible Map.Entry.
*/
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value; //与HashMap相比,加了volatile 关键字
volatile HashEntry<K,V> next;

HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

/**
* Sets next field with volatile write semantics. (See above
* about use of putOrderedObject.)
*/
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}

// Unsafe mechanics
static final sun.misc.Unsafe UNSAFE;
static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}

2.2.3、jdk1.7ConcurrentHashMap中的构造函数

  • 一些常量
// 默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默认segment层级
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// segment最小容量
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
// segment最大容量
static final int MAX_SEGMENTS = 1 << 16;
// 锁之前重试次数
static final int RETRIES_BEFORE_LOCK = 2;
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR,DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}
  • initialCapacity 表示创建ConcurrentHashMap的初始容量。默认值是16
  • loadFactor 表示加载因子。当 ConcurrentHashMap中元素个数 > 最大容量 * loadFactor 时就需要进行扩容。
  • concurrencyLevel 表示并发的级别,也可以理解为segment数组的长度。Segment数组的长度 大于等于concurrencyLevel的第一个2的n次方。
  • 理想情况下,有concurrentLevel个线程同时访问不同的segment数据,这样这些线程之间互不干扰,达到了最高并发级别!
@SuppressWarnings("unchecked")
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
//
int sshift = 0;
// segment数组的长度是由concurrentLevel计算来的,segment数组的长度是2的N次方,

// 默认concurrencyLevel = 16, 所以ssize在默认情况下也是16,此时 sshift = 4

// sshift相当于ssize从1向左移的次数

int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 段偏移量,默认值情况下此时segmentShift = 28
this.segmentShift = 32 - sshift;
// 散列算法的掩码,默认值情况下segmentMask = 15(这个值很重要)
this.segmentMask = ssize - 1;

if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//c记录每个Segment上要放置多少个元素
int c = initialCapacity / ssize;
//假如有余数,则Segment数量加1
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
// 创建ssize长度的Segment数组
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}

2.2.4、jdk1.7ConcurrentHashMap中的put()方法

  • put()方法思路:
  • 首先对key进行第1次hash,通过hash值确定segment的位置
  • 然后在segment内进行操作,获取锁
  • 接着获取当前segment的HashEntry数组,然后对key进行第2次hash,通过hash值确定在HashEntry数组的索引位置
  • 然后对当前索引的HashEntry链进行遍历,如果有重复的key,则替换;如果没有重复的,则插入到链头
  • 关闭锁
  • 可见,在整个put过程中,进行了2次hash操作,才最终确定key的位置。
public V put(K key, V value) {
Segment<K,V> s;
//ConcurrentHashMap的key和value都不能为null
if (value == null)
throw new NullPointerException();

//这里对key求hash值,并确定应该放到segment数组的索引位置
int hash = hash(key);
//j为索引位置,思路和HashMap的思路一样,这里不再多说
int j = (hash >>> segmentShift) & segmentMask;
/*
得到hash值向右按位移动segmentShift位,然后再与segmentMask做&运算得到segment的索引j。例如concurrencyLevel等于16,则sshift等于4,则segmentShift为28。hash值是一个32位的整数,将其向右移动28位就变成这个样子:0000 0000 0000 0000 0000 0000 0000 xxxx,然后再用这个值与segmentMask做&运算,也就是取最后四位的值。这个值确定Segment的索引。
*/
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);//这个函数的目的就是找到对应的segment。

//这里很关键,找到了对应的Segment,则把元素放到Segment中去
return s.put(key, hash, value, false);
}
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//这里是并发的关键,每一个Segment进行put时,都会加锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//tab是当前segment所连接的HashEntry数组
HashEntry<K,V>[] tab = table;
//确定key的hash值所在HashEntry数组的索引位置
int index = (tab.length - 1) & hash;
//取得要放入的HashEntry链的链头
HashEntry<K,V> first = entryAt(tab, index);
//遍历当前HashEntry链
for (HashEntry<K,V> e = first;;) {
//如果链头不为null
if (e != null) {
K k;
//如果在该链中找到相同的key,则用新值替换旧值,并退出循环
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
//如果没有和key相同的,一直遍历到链尾,链尾的next为null,进入到else
e = e.next;
}
else {//如果没有找到key相同的,则把当前Entry插入到链头

if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
//此时数量+1
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//如果超出了限制,要进行扩容
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//最后释放锁
unlock();
}
return oldValue;
}

2.2.5、jdk1.7ConcurrentHashMap中的get()方法

get()方法思路:

  • 首先找到对应的segment,将Key 通过 Hash 之后定位到具体的 Segment
  • 然后找到segment中对应HashEntry链表,再通过一次 Hash 定位到具体的元素上。
  • 遍历链表即可

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁

public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
// 首先计算出segment数组的下标 ((h >>> segmentShift) & segmentMask))
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) { // 根据下标找到segment
// 然后(tab.length - 1) & h) 得到对应HashEntry数组的下标
// 遍历链表
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;

if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}

2.2.6、jdk1.7ConcurrentHashMap中的remove()方法

remove()方法思路:

  • 首先对key进行第1次hash,通过hash值确定segment的位置
  • 然后在segment内进行操作,获取锁
  • 接着获取当前segment的HashEntry数组,然后对key进行第2次hash,通过hash值确定在HashEntry数组的索引位置
  • 用一个HashEntry引用来记录待删除节点的前一个节点,然后删除待删除节点
  • 关闭锁
public V remove(Object key) {
//求key的hash
int hash = hash(key);
//根据hash值找到对应的segment
Segment<K,V> s = segmentForHash(hash);
//调用Segment.remove 函数
return s == null ? null : s.remove(key, hash, null);
}
final V remove(Object key, int hash, Object value) {
//获取锁
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
//tab是当前segment所连接的HashEntry数组
HashEntry<K,V>[] tab = table;
//确定key的hash值所在HashEntry数组的索引位置
int index = (tab.length - 1) & hash;
//取得要放入的HashEntry链的链头
HashEntry<K,V> e = entryAt(tab, index);
//pred用来记录待删除节点的前一个节点
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
//当找到了待删除及节点的位置
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
if (value == null || value == v || value.equals(v)) {
//如果待删除节点的前节点为null,即待删除节点时链头节点,此时把该位置指向第2个结点就行了
if (pred == null)
setEntryAt(tab, index, next);
//如果有前节点,则待删除节点的前节点的next指向待删除节点的的下一个节点,删除成功
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
//最后关闭锁
unlock();
}
return oldValue;
}

2.3、ConcurrentHashMap(基于jdk1.8)

可参考这篇博客(侵删)

1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。

那就是查询遍历链表效率太低。

因此 1.8 做了一些数据结构上的调整。

首先来看下底层的组成结构:

2.3.1、jdk1.8ConcurrentHashMap的数据结构

JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。

也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。其中的 ​​val next​​ 都用了 volatile 修饰,保证了可见性。

HashMap与ConcurrentHashMap详解_java.util.concurrent_06

2.3.2、jdk1.8ConcurrentHashMap中的常量值和成员变量

// 数组的最大容量:2^30=1073741824  
private static final int MAXIMUM_CAPACITY = 1 << 30 ;

// 默认初始容量值 16(数组(哈希表)初始容量),必须为 2 的 n 次方
private static final int DEFAULT_CAPACITY = 16 ;

//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8 ;

//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16 ;

// 负载因子
private static final float LOAD_FACTOR = 0 .75f;

// 链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8 ;

//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6 ;

//将链表转化成红黑树时,最小容量值 64(注意:不是 map 中的元素个数,是数组(哈希表)的容量)
static final int MIN_TREEIFY_CAPACITY = 64 ;

// 每次进行转移的最小值
private static final int MIN_TRANSFER_STRIDE = 16 ;

// 生成sizeCtl所使用的bit位数
private static int RESIZE_STAMP_BITS = 16 ;

// 2^15-1, 进行扩容所允许的最大线程数
private static final int MAX_RESIZERS = ( 1 << ( 32 - RESIZE_STAMP_BITS)) - 1 ;

// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

// forwarding nodes的hash值
static final int MOVED = - 1 ;

// 树根节点的hash值
static final int TREEBIN = - 2 ;

// ReservationNode的hash值
static final int RESERVED = - 3 ;

// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();

//存放node的数组
transient volatile Node<K,V>[] table;

/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1 代表正在初始化,-N代表有N-1 个线程正在 进行扩容
*当为0 时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小
*/
private transient volatile int sizeCtl;
/*
Node:保存key,value及key的hash值的数据结构。
其中value和next都用volatile修饰,保证并发的可见性。
(val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序 )
*/
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
//... 省略部分代码
}
/*
ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。
只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动。
*/
final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}

2.3.3、jdk1.8ConcurrentHashMap中的构造函数

//该构造函数用于创建一个带有默认初始容量 (16)、加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射。
public ConcurrentHashMap() {
}
//该构造函数用于创建一个带有指定初始容量、默认加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射。
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//找到最接近该容量的2的幂次方数
this.sizeCtl = cap;// 初始化
}
//该构造函数用于构造一个与给定映射具有相同映射关系的新映射。
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
//该构造函数用于创建一个带有指定初始容量、加载因子和默认 concurrencyLevel (1) 的新的空映射。
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
/*
该构造函数用于创建一个带有指定初始容量、加载因子和并发级别的新的空映射。
  对于构造函数而言,会根据输入的initialCapacity的大小来确定一个最小的且大于等于initialCapacity大小的2的n次幂,如initialCapacity为15,则sizeCtl为16,若initialCapacity为16,则sizeCtl为
16。若initialCapacity大小超过了允许的最大值,则sizeCtl为最大值。值得注意的是,构造函数中的concurrencyLevel参数已经在JDK1.8中的意义发生了很大的变化,其并不代表所允许的并发数,
其只是用来确定sizeCtl大小,在JDK1.8中的并发控制都是针对具体的桶而言,即有多少个桶就可以允许多少个并发数。
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
//这个函数使集合的初始化容量始终都是2的指数次幂(与jdk1.8HashMap中的一样)
private static final int tableSizeFor(int c) {
int n = c - 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;
}

2.3.4、jdk1.8ConcurrentHashMap中的put()方法

假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作:

  • 当前bucket(桶,数组中的一个位置)为空时,使用CAS操作,将Node放入对应的bucket(桶,数组中的一个位置)中。
  • 出现hash冲突,则采用synchronized关键字。倘若当前hash对应的节点是链表的头节点,遍历链表,若找到对应的node节点,则修改node节点的val,否则在链表末尾添加node节点;倘若当前节点是红黑树的根节点,在树结构上遍历元素,更新或增加节点。
  • 倘若当前map正在扩容​​f.hash == MOVED​​, 则跟其他线程一起进行扩容
public V put(K key, V value) {
return putVal(key, value, false);
}

//用到的CAS方法
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}


/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 键或值为空,抛出异常
if (key == null || value == null) throw new NullPointerException();
// 键的hash值经过计算获得hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) { // 无限循环
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0) // 表为空或者表的长度为0
// 初始化表
tab = initTable();
// 表不为空并且表的长度大于0,并且该桶不为空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 比较并且交换值,如tab的第i项为空则用新生成的node替换
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED) // 该结点的hash值为MOVED
// 进行结点的转移(在扩容的过程中)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // 加锁同步
if (tabAt(tab, i) == f) { // 找到table表下标为i的节点
if (fh >= 0) { // 该table表中该结点的hash值大于0
// binCount赋值为1
binCount = 1;
for (Node<K,V> e = f;; ++binCount) { // 无限循环
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) { // 结点的hash值相等并且key也相等
// 保存该结点的val值
oldVal = e.val;
if (!onlyIfAbsent) // 进行判断
// 将指定的value保存至结点,即进行了结点值的更新
e.val = value;
break;
}
// 保存当前结点
Node<K,V> pred = e;
// 当前结点的下一个结点为空,即为最后一个结点
if ((e = e.next) == null) {
// 新生一个结点并且赋值给next域
pred.next = new Node<K,V>(hash, key,value, null);
// 退出循环
break;
}
}
}
else if (f instanceof TreeBin) { // 结点为红黑树结点类型
Node<K,V> p;
// binCount赋值为2
binCount = 2;
// 将hash、key、value放入红黑树
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
// 保存结点的val
oldVal = p.val;
if (!onlyIfAbsent) // 判断
// 赋值结点value值
p.val = value;
}
}
}
}
if (binCount != 0) { // binCount不为0
// 如果binCount大于等于转化为红黑树的阈值
if (binCount >= TREEIFY_THRESHOLD)
// 进行转化
treeifyBin(tab, i);
if (oldVal != null) // 旧值不为空
// 返回旧值
return oldVal;
break;
}
}
}
// 增加binCount的数量
addCount(1L, binCount);
return null;
}

说明:put函数底层调用了putVal进行数据的插入,对于putVal函数的流程大体如下:

  • ① 判断存储的key、value是否为空,若为空,则抛出异常,否则,进入步骤②
  • ② 计算key的hash值,随后进入无限循环,该无限循环可以确保成功插入数据,若table表为空或者长度为0,则初始化table表,否则,进入步骤③
  • ③ 根据key的hash值取出table表中的结点元素,若取出的结点为空(该桶为空),则使用CAS将key、value、hash值生成的结点放入桶中。否则,进入步骤④
  • ④ 若该结点的的hash值为MOVED,则对该桶中的结点进行转移,否则,进入步骤⑤
  • ⑤ 对桶中的第一个结点(即table表中的结点)进行加锁,对该桶进行遍历,桶中的结点的hash值与key值与给定的hash值和key值相等,则根据标识选择是否进行更新操作(用给定的value值替换该结点的value值),若遍历完桶仍没有找到hash值与key值和指定的hash值与key值相等的结点,则直接新生一个结点并赋值为之前最后一个结点的下一个结点。进入步骤⑥
  • ⑥ 若binCount值达到红黑树转化的阈值,则将桶中的结构转化为红黑树存储,最后,增加binCount的值。

2.3.5、jdk1.8ConcurrentHashMap中的hash算法

与HashMap类似

static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;}

定位索引

int index = (n - 1) & hash  // n为bucket的个数1

获取table对应的索引元素f

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

采用​​Unsafe.getObjectVolatie()​​​来获取,而不是直接用​​table[index]​​的原因跟ConcurrentHashMap的弱一致性有关。在java内存模型中,我们已经知道每个线程都有一个工作内存,里面存储着table的副本,虽然table是volatile修饰的,但不能保证线程每次都拿到table中的最新元素,Unsafe.getObjectVolatile可以直接获取指定内存的数据,保证了每次拿到数据都是最新的。

2.3.6、jdk1.8ConcurrentHashMap中的get()方法

读取操作,不需要同步控制,比较简单:

  • 如果是空tab,直接返回null
  • 如果不空计算hash值,找到相应的bucket位置,为node节点直接返回,否则返回null

get函数根据key的hash值来计算在哪个桶中,再遍历桶,查找元素,若找到则返回该结点,否则,返回null。

//get函数根据key的hash值来计算在哪个桶中,再遍历桶,查找元素,若找到则返回该结点,否则,返回null。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算key的hash值
int h = spread(key.hashCode());
// 表不为空并且表的长度大于0并且key所在的桶不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 表中的元素的hash值与key的hash值相等
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek))) // 键相等
// 返回值
return e.val;
}
else if (eh < 0) // 结点hash值小于0
// 在桶(链表/红黑树)中查找
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) { // 对于结点hash值大于0的情况
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}

2.3.7、jdk1.8ConcurrentHashMap中的扩容

可参考(侵删)

什么时候触发扩容?

什么时候会触发扩容?

  • 如果新增节点之后,所在的链表的元素个数大于等于8,则会调用​​treeifyBin​​​把链表转换为红黑树。在转换结构时,若tab的长度小于​​MIN_TREEIFY_CAPACITY​​​,默认值为64,则会将数组长度扩大到原来的两倍,并触发​​transfer​​​,重新调整节点位置。(只有当​​tab.length >= 64, ConcurrentHashMap​​才会使用红黑树。)
  • 新增节点后,​​addCount​​​统计tab中的节点个数大于阈值(sizeCtl),会触发​​transfer​​,重新调整节点位置。

addCount函数,此函数主要完成binCount的值加1的操作。

//新增元素时,也就是在调用 putVal 方法后,为了通用,增加了个 check 入参,用于指定是否可能会出现扩容的情况
//check >= 0 即为可能出现扩容的情况,例如 putVal方法中的调用
private final void addCount(long x, int check){
CounterCell[] as; long b, s;
// 利用CAS更新baseCount
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended); // 多线程修改baseCount时,竞争失败的线程会执行fullAddCount(x, uncontended),把x的值插入到counterCell类中
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//检查当前集合元素个数 s 是否达到扩容阈值 sizeCtl ,扩容时 sizeCtl 为负数,依旧成立,同时还得满足数组非空且数组长度不能大于允许的数组最大长度这两个条件才能继续
//这个 while 循环除了判断是否达到阈值从而进行扩容操作之外还有一个作用就是当一条线程完成自己的迁移任务后,如果集合还在扩容,则会继续循环,继续加入扩容大军,申请后面的迁移任务
while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
// sc < 0 说明集合正在扩容当中
if (sc < 0) {
//判断扩容是否结束或者并发扩容线程数是否已达最大值,如果是的话直接结束while循环
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
break;
//扩容还未结束,并且允许扩容线程加入,此时加入扩容大军中
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//如果集合还未处于扩容状态中,则进入扩容方法,并首先初始化 nextTab 数组,也就是新数组
//(rs << RESIZE_STAMP_SHIFT) + 2 为首个扩容线程所设置的特定值,后面扩容时会根据线程是否为这个值来确定是否为最后一个线程
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
  • treeifyBin()函数,此函数用于将桶中的数据结构转化为红黑树,其中,值得注意的是,当table的长度未达到阈值时,会进行一次扩容操作,该操作会使得触发treeifyBin操作的某个桶中的所有元素进行一次重新分配,这样可以避免某个桶中的结点数量太大。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) { // 表不为空
if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // table表的长度小于最小的长度
// 进行扩容,调整某个桶中结点数量过多的问题(由于某个桶中结点数量超出了阈值,则触发treeifyBin)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { // 桶中存在结点并且结点的hash值大于等于0
synchronized (b) { // 对桶中第一个结点进行加锁
if (tabAt(tab, index) == b) { // 第一个结点没有变化
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) { // 遍历桶中所有结点
// 新生一个TreeNode结点
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null) // 该结点前驱为空
// 设置p为头结点
hd = p;
else
// 尾节点的next域赋值为p
tl.next = p;
// 尾节点赋值为p
tl = p;
}
// 设置table表中下标为index的值为hd
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
扩容代码详解
//调用该扩容方法的地方有:
//java.util.concurrent.ConcurrentHashMap#addCount 向集合中插入新数据后更新容量计数时发现到达扩容阈值而触发的扩容
//java.util.concurrent.ConcurrentHashMap#helpTransfer 扩容状态下其他线程对集合进行插入、修改、删除、合并、compute 等操作时遇到 ForwardingNode 节点时触发的扩容
//java.util.concurrent.ConcurrentHashMap#tryPresize putAll批量插入或者插入后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//计算每条线程处理的桶个数,每条线程处理的桶数量一样,如果CPU为单核,则使用一条线程处理所有桶
//每条线程至少处理16个桶,如果计算出来的结果少于16,则一条线程处理16个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // 初始化新数组(原数组长度的2倍)
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
//将 transferIndex 指向最右边的桶,也就是数组索引下标最大的位置
transferIndex = n;
}
int nextn = nextTab.length;
//新建一个占位对象,该占位对象的 hash 值为 -1 该占位对象存在时表示集合正在扩容状态,key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组
//该占位对象主要有两个用途:
// 1、占位作用,用于标识数组该位置的桶已经迁移完毕,处于扩容中的状态。
// 2、作为一个转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//该标识用于控制是否继续处理下一个桶,为 true 则表示已经处理完当前桶,可以继续迁移下一个桶的数据
boolean advance = true;
//该标识用于控制扩容何时结束,该标识还有一个用途是最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶
boolean finishing = false; // to ensure sweep before committing nextTab
//这个循环用于处理一个 stride 长度的任务,i 后面会被赋值为该 stride 内最大的下标,而 bound 后面会被赋值为该 stride 内最小的下标
//通过循环不断减小 i 的值,从右往左依次迁移桶上面的数据,直到 i 小于 bound 时结束该次长度为 stride 的迁移任务
//结束这次的任务后会通过外层 addCount、helpTransfer、tryPresize 方法的 while 循环达到继续领取其他任务的效果
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//每处理完一个hash桶就将 bound 进行减 1 操作
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
//transferIndex <= 0 说明数组的hash桶已被线程分配完毕,没有了待分配的hash桶,将 i 设置为 -1 ,后面的代码根据这个数值退出当前线的扩容操作
i = -1;
advance = false;
}
//只有首次进入for循环才会进入这个判断里面去,设置 bound 和 i 的值,也就是领取到的迁移任务的数组区间
else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
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)) {
//(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT 成立,说明该线程不是扩容大军里面的最后一条线程,直接return回到上层while循环
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//(sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT 说明这条线程是最后一条扩容线程
//之所以能用这个来判断是否是最后一条线程,因为第一条扩容线程进行了如下操作:
// U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
//除了修改结束标识之外,还得设置 i = n; 以便重新检查一遍数组,防止有遗漏未成功迁移的桶
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
//遇到数组上空的位置直接放置一个占位对象,以便查询操作的转发和标识当前处于扩容状态
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
//数组上遇到hash值为MOVED,也就是 -1 的位置,说明该位置已经被其他线程迁移过了,将 advance 设置为 true ,以便继续往下一个桶检查并进行迁移操作
advance = true; // already processed
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//该节点为链表结构
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
//遍历整条链表,找出 lastRun 节点
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//根据 lastRun 节点的高位标识(0 或 1),首先将 lastRun设置为 ln 或者 hn 链的末尾部分节点,后续的节点使用头插法拼接
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//使用高位和低位两条链表进行迁移,使用头插法拼接链表
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);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//setTabAt方法调用的是 Unsafe 类的 putObjectVolatile 方法
//使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
//使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上
setTabAt(nextTab, i, ln);
//使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
setTabAt(nextTab, i + n, hn);
//迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用
setTabAt(tab, i, fwd);
//advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶
advance = true;
}
//该节点为红黑树结构
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
//lo 为低位链表头结点,loTail 为低位链表尾结点,hi 和 hiTail 为高位链表头尾结点
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
//同样也是使用高位和低位两条链表进行迁移
//使用for循环以链表方式遍历整棵红黑树,使用尾插法拼接 ln 和 hn 链表
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
//这里面形成的是以 TreeNode 为节点的链表
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;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//形成中间链表后会先判断是否需要转换为红黑树:
//1、如果符合条件则直接将 TreeNode 链表转为红黑树,再设置到新数组中去
//2、如果不符合条件则将 TreeNode 转换为普通的 Node 节点,再将该普通链表设置到新数组中去
//(hc != 0) ? new TreeBin<K,V>(lo) : t 这行代码的用意在于,如果原来的红黑树没有被拆分成两份,那么迁移后它依旧是红黑树,可以直接使用原来的 TreeBin 对象
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方法调用的是 Unsafe 类的 putObjectVolatile 方法
//使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
//使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上
setTabAt(nextTab, i, ln);
//使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
setTabAt(nextTab, i + n, hn);
//迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用
setTabAt(tab, i, fwd);
//advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶
advance = true;
}
}
}
}
}
}

2.4、ConcurrentHashMap 在1.7与1.8中的不同

项目

JDK1.7

JDK1.8

概览

HashMap与ConcurrentHashMap详解_链表_07

HashMap与ConcurrentHashMap详解_数组_08

同步机制

分段锁,每个segment继承ReentrantLock

CAS + synchronized保证并发更新

存储结构

数组+链表

数组+链表+红黑树

键值对

HashEntry

Node

put操作

多个线程同时竞争获取同一个segment锁,获取成功的线程更新map;失败的线程尝试多次获取锁仍未成功,则挂起线程,等待释放锁

访问相应的bucket时,使用sychronizeded关键字,防止多个线程同时操作同一个bucket,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;更新了节点数量,还要考虑扩容和链表转红黑树

size实现

统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的。先采用不加锁的方式,连续计算元素的个数,最多计算3次:如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;

通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数;

3. ConcurrentHashMap与HashMap,HashTable

  • ConcurrentHashMap是HashMap的高并发版本
  • ConcurrentHashMap能完全替代HashTable吗?

HashTable虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,HashTable的迭代器是强一致性的,而ConcurrentHashMap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。
下面是大白话的解释:

  • Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
  • ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分 数据。

选择哪一个,是在性能与数据一致性之间权衡。ConcurrentHashMap适用于追求性能的场景,大多数线程都只做insert/delete操作,对读取数据的一致性要求较低。

ConcurrentHashMap与HashTable一致性检测