1 HashMap 的数据结构
HashMap 就是以 key-value 的方式进行数据存储的一种数据结构,在 JDK 1.7 中,HashMap 的底层数据结构是数组 + 链表,使用 Entry 来存储 key 和 value ,在 JDK 1.8 中,HashMap 的底层数据结构是数组 + 链表/红黑树,使用 Node 存储 key 和 value 。(Entry 和 Node 并没有什么不同)。
其中,桶数组是用来存储数据元素的,链表是用来解决冲突的,红黑树是为了提高查询效率。
- 根据 key 的 hash 值计算出数据元素在数组中的索引;
- 如果发生 hash 冲突,从冲突的位置添加一个链表,插入冲突的元素;
- 如果
链表长度 > 8 & 数组大小 >= 64
,链表转为红黑树; - 如果
红黑树节点个数 <= 8
,转为链表;
Hash(散列函数):把任意长度的输入通过散列算法变换成固定长度的输出,输出的就是散列值。这种转换是一种压缩映射,也就是散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不能从散列值来确定唯一的输入值,这就是哈希冲突。
数组:采用一段连续的存储空间来存储数据。对于指定下标的查找,时间复杂度为 O(1),对于给定值的查找,需要遍历数组,逐一比较给定的值和数组元素,时间复杂度为 O(n)。对于有序数组,可以采用二分查找、插值查找、斐波那契查找等方式,可将查找的复杂度提高为 O(logn)。对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度为 O(n)。
线性链表:对于链表的插入、删除等操作,在指定操作的位置仅需要处理节点之间的引用即可,时间复杂度为 O(1)。而对于查找操作需要遍历链表逐一进行对比,复杂度为 O(n)。
二叉树:对于一颗相对平衡的有序二叉树,对其进行插入、删除、查找等操作,时间平均复杂度为 O(logn)。
哈希表(hash table):也成散列表,相对于上述几种数据结构,在哈希表中进行添加、删除、查找等操作,性能很高,在不考虑哈希冲突的情况下,仅需要一次定位就可以完成。哈希表是一种非常重要的数据结构,应用场景很多,很多缓存技术(比如 memcached)的核心其实就是在内存中维护一张大的哈希表。
数据存储的物理结构只有两种:顺序存储结构和链式存储结构。像栈、队列、树、图等都是从逻辑结构中去抽象的,映射到内存中,最终也是这两种物理组织形式。在数组中根据某个下标查找元素,一次定位就可以达到,哈希表就使用了则中数据结构,其主干就是数组。
那么为什么是选用红黑树,为什么不用平衡树/二叉树呢?
红黑树本质上是一种二叉查找树,为了保持平衡,它在二叉树的基础上增加了一些规则:
- 每个节点要么是红色,要么是黑色;
- 根结点永远是黑色;
- 所有的叶子结点都是黑色,这里所说的叶子结点就是图中的 NULL 节点;
- 每个红色节点的两个子节点一定都是黑色的;
- 从任一节点到其子树中每个叶子结点的路径都包含相同数量的黑色节点;
这些约束强制了红黑树的关键性质:从根节点到叶子节点的最长的可能路径不多于最短的可能路径的两倍,结果是这个数大致上是平衡的,因为操作比如插入、删除和查找某个值的最坏情况事件都要与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏的情况下都是高效的,而不同于普通的二叉查找树。
红黑树是一种平衡的二叉树,插入、删除、查找最坏的时间复杂度都是 O(logn),避免了二叉树最坏情况下的 O(n) 时间复杂度。而平衡二叉树是比红黑树更严格的平衡树,为了保持平衡,需要旋转的次数更多,也就是说平衡二叉树保持平衡的效率更低,所以平衡二叉树插入和删除的效率比红黑树要低。
红黑树是怎么保持平衡的?红黑树有两种范式保持平衡:旋转和染色
2 JDK 1.8 中的 HashMap
HashMap 中有一个存储 Node
的数组 Node<K, V>[] table
,每个 Node
都是一个含有 key-value 的键值对。其实所谓的 Map 就是保存了两个对象之间的映射关系的一种集合。以下是相关源码:
// /xref/libcore/ojluni/src/main/java/java/util/HashMap.java
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>,
Cloneable, Serializable {
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 下一个节点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
}
从源码中可知,Node
是 HashMap 中的静态内部类,每个 Node 都会保存自身的 hash
、key
、value
以及下个节点 next
:
在进行数据插入时,会使用哈希函数计算出 key 的 hash 值,根据 hash 来确定元素在桶数组的位置。比如计算出的结果是 2:
采用链表的形式是因为数组的长度是有限的,在有限的数组上使用哈希函数计算索引值,哈希冲突是无法避免的,很有可能有多个元素计算出的索引值是相同的,这个时候是如何解决哈希冲突呢?—— 拉链法,就是把哈希后值相同的元素放在同一条链表上:
这里就会有一个问题,就是 hash 严重冲突时,在数组上形成的链表就会变得越来越长,由于链表不支持索引,想要在链表中找到某个元素就需要遍历一遍链表,效率比较低。为此,JDK 1.8 中引入了红黑树,当链表的长度大于 8 并且数组长度大于等于 64 的时候就会转化成红黑树,如果数组的长度是小于 64,HashMap 会优先对数组进行扩容 resize
,而不是把链表转换成红黑树, 以下是 JDK 1.8 的 HashMap 的完整示意图:
在 HashMap 中有几个重要的字段:
transient Node<K,V>[] table;
transient int size; // 存储 key-value 键值对的个数
/*
* 负载因子,代表 table 的填充度为多少,默认为 0.75
* 负载因子存在的原因,还是为了减缓哈希冲突
* 如果初始的桶数组大小为 16,等到满 16 个元素时才开始扩容,某些位置就不止有一个元素了
* 负载因子默认为 0.75,也就是说,大小为 16 的 HashMap,到第 13 个元素的时候就要扩容成 32
*/
final float loadFactor;
/*
* 阈值,当 table 为空时,该值为初始容量,默认为 16
* 当 table 被填充了,threshold 一般为 capacity * loadFactory
* HashMap 在进行扩容时需要参考 threshold
*/
int threshold;
以下是 HashMap 的构造函数:
// HashMap 的默认初始容量为 16。其容量必须是 2 的 n 次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
HashMap 一共有 4 个构造器,如果用户没有传入initialCapacity
和 loadFactor
,就会使用默认值,initialCapacity
的默认值为 16,loadFactor
的默认值为 0.75。
以下是 HashMap.put(K, V)
方法的相关源码:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>,
Cloneable, Serializable {
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true); // put方法直接调用putVal方法
}
static final int hash(Object key) {
int h;
// 进行一次高低位的异或运算,key呈散列状态
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
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. 判断数组是否为空,如果为空则进行扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 根据数组的长度 (n-1) 与 key 的 hash 值进行与运算,得出在数组中的索引,如果索引制定的位置为 null,直接插入节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果当前 key 存在,则进行替换操作
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 如果出现 hash 冲突的节点是树类型,则在树中追加新的节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 如果出现 hash 冲突的节点是链表节点
else {
for (int binCount = 0; ; ++binCount) {
// 6.如果出现 hash 冲突的节点的下一个节点为空,则将新节点直接放到下一个节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7. 节点插入后判断当前链表的长度是否超过 8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 10. 如果替换成功,则返回旧的 value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 7 所有元素处理完成后,判断是否超过阈值,超过就进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 8. 如果链表的长度大于等于 8,但数组的长度还小于 64,那么进行扩容操作
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 9. 如果链表的长度大于等于 8,且数组的长度大于等于 64,则进行链表到红黑树的转换
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
}
- 首先判断数组是否为空,如果是空,则进行数组扩容;
- 根据
key
的hash
值计算出元素在数组中的索引,如果索引指定的位置为空,则直接插入一个节点; - 如果索引所在的位置不为空,则判断当前的
key
是否存在,如果存在则进行替换;如果key
不存在,说明数组的索引位置已经有元素,这个时候就出现了哈希冲突; - 判断当前节点是否是树类型,如果是树类型的话,则按照树的操作添加节点;如果是链表节点,则进入循环,找到下一个节点为空的节点,并将新节点插入;
- 插入节点后判断,当前链表上的元素个数是否超过了 8,如果是,则判断当前数组的长度是否小于 64,如果是,则进行扩容操作;如果数组的长度大于等于 64 则进行链表到树的转换,并插入节点;
- 最后,进行容量判断,如果超过阈值,则进行扩容;
以下是流程图:
当 HashMap 中的元素的个数达到阈值,就会进行扩容,以下是 HashMap 中的扩容机制:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 旧的容量和阈值
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
// 新的容量和阈值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
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"})
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;
if (e.next == null) // 此位置上还没有元素,直接放入元素
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 树状结构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order 链表结构
Node<K,V> loHead = null, loTail = null; // 旧的 index
Node<K,V> hiHead = null, hiTail = null; // 新的 index
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
数组容量是有限的,如果数据多次插入并达到一定的数量就会进行数组扩容(resize()
)。什么时候会进行 resize()
呢?与两个因素有关:
Capacity
:HashMap
当前最大容量/长度;LoadFactory
:负载因子,默认值是 0.75f;
capacity [kəˈpæsəti] 容积;生产量,生产能力
如果当前存入的数据量大于 Capacity * LoadFactory
的时候,就会进行数组扩容。 就比如当前的 HashMap 的最大容量为 100,当要存入第 76 的元素的时候,判断发现需要进行扩容了。
以下是 HashMap.get(Object)
的相关源码:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>,
Cloneable, Serializable {
public V get(Object key) {
Node<K,V> e;
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;
// 1. 如果数组不为 null,且数组的长度大于 0,且数组对应的索引的节点不为空
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// 2. 首先和第一个节点比较,key 的 hash 值和第一个节点的 hash 值相等,且 key 的地址或值相等,则返回第一个节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 3. 如果 key 和第一个节点不匹配,则看 first.next 是否为空,如果不为空则继续比较
if ((e = first.next) != null) {
// 4. 如果是红黑树的结构,则进行处理树节点的搜索
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 5. 如果是链表结构,则继续遍历查询
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
}
- 首先根据
key
的hash
值计算出元素在数组中的索引; - 如果数组不为空且长度大于 0 且第一个节点不为空,则进入查找流程;
- 首先
key
的hash
值和第一个节点的hash
值比较,之后再通过==
和equals
方法比较key
是否相等;如果相等则返回第一个节点; - 如果第一个节点不符合查找条件,则判断第二个节点,如果是树节点,则按照红黑树的结构执行相应的查询;如果是链表结构则按照链表的结构执行相应的查询;
以下是流程图:
3 JDK 1.7 中的 HashMap
在 JDK 1.7 中,新节点在插入链表的时候,是怎么插入的?—— 头插法:
在 JDK 1.8 中改用了尾插法,这是因为,采用头插法在多线程环境下可能会造成循环链表问题。
头插法:
如果进行扩容:
HashMap 进行扩容后,假设节点 A、B、C 经过哈希后得到的数值依然相同,在新的 HashMap 中顺序是 C -> B -> A。
死循环是因为并发 HashMap 扩容导致的,并发扩容的第一步,线程 T1 和线程 T2 要对 HashMap 进行扩容,此时 T1 和 T2 指向的是链表的头节点元素 A ,而 T1 和 T2 指向的是链表的头节点 A,而 T1 和 T2 的下一个节点,也就是 T1.next 和 T2.next 指向的是 B 节点,如下所示:
如果此时线程 T2 时间片用完进入休眠,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才背唤醒,扩容之后的场景是:
从上图中可以看到线程 T1 执行之后,因为都是头插法,所以 HashMap 的顺序已经发生了改变,但线程 T2 对于发生的一切是不可知的,所以它指向的元素依然没有变化,T2 指向的是 A 元素,T2.next 指向的节点是 B 元素。当线程 T1 执行完毕后,线程 T2 恢复执行时,环形链表就建立了。
4 JDK 1.7 中的 HashMap 和 JDK 1.8 中的 HashMap
HashMap 线程不安全的表现有哪些?在 JDK 1.7 的 HashMap 的线程不安全是因为会出现环形链表。虽然 JDK 1.8 采用尾插法避免了环形链表的问题,但是它仍然是不安全的。比如说 HashMap.put(K, V)
的源码:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
public V put(K key, V value) {
return 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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 1
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
}
在注释 1 处如果没有发生哈希冲突就会直接插入元素。假设线程 1 和线程 2 同时进行 put 操作,恰好这两条数据的 hash 值是一样的,并且该位置数据为 null 这样线程 1 和线程 2 都会进入这段代码进行插入元素。假设线程 1 进入后还没有开始进行元素插入就被挂起,而线程 2 正常执行,并且正常插入数据,随后线程 1 得到 CPU 调度后进行元素插入,这样,线程 2 插入的数据就被覆盖了。
总结一下:
- 多线程下扩容死循环。 JDK 1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候可能会导致环形链表的出现,形成死循环。因此, JDK 1.8 使用尾插法插入元素,在扩容时会保持链表元素的原本顺序,不会出现环形链表的问题;
- 多线程的 put 操作可能导致元素的丢失。多线程同时 put,如果计算出的索引位置时相同的,可能会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在;
- put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素的个数查过 threshold 而导致 rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在;
如何保证 HashMap 线程安全?Java 中有 HashTable、Collections.synchronizedMap 以及ConcurrentMap 可以实现线程安全的 Map:
- HashTable 是直接在操作方法上加 synchronized 关键字;
- 使用 Collections.synchronizedMap 包装一下 HashMap,得到线程安全的 HashMap,其原理就是对所有的修都加上 synchronized;
- 使用线程安全的 ConcurrentHashMap,该类在 JDK 1.7 和 JDK 1.8 的底层原理有所不同,JDK 1.7 采用数组+链表,使用分段锁 Segment(Segment 继承自 ReentrantLock)保证线程安全;JDK 1.8 采用的是数组+链表/红黑树存储数据,使用 CAS(内存位置、预期原职、新值,如果内存位置的值与预期原址相匹配,那么处理器会更新为新值)+ synchronized 保证线程安全;
concurrent [kənˈkɜːrənt] (两个或两个以上徒刑判决)同时执行的 segment [ˈseɡmənt] 部分,片段
以下是 JDK 1.7 中的 ConcurrentHashMap 的相关流程:
整个流程和 HashMap 非常类似,只不过是先定位到具体的 Segment,然后通过 ReentrantLock 去操作而已,后面的流程,就和 HashMap 基本上是一样的:
- 计算 hash 值,定位到 Segment,Segment 如果是空就先初始化;
- 使用 ReentrantLock 加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功;
- 遍历 HashEntry ,就是和 HashMap 一样,数组中 key 和 hash 一样就直接替换,不存在就再插入链表,链表同样操作
以下是 JDK 1.8 中的 ConcurrentHashMap.put
流程:
以下是相关源码:
// /xref/libcore/ojluni/src/main/java/java/util/concurrent/ConcurrentHashMap.java
transient volatile Node<K,V>[] table;
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
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)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
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)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (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)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
JDK 1.8 中 ConcurrentHashMap 参考了 JDK 1.8 HashMap 的实现,采用了数组 + 链表/红黑树的实现方式来设计,内部大量采用 CAS 操作。
CAS 是 compare and swap 的缩写,中文称为【比较交换】。CAS 是一种基于锁的操作,而且是乐观锁。在 Java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下 一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS 是通过无限循环来获取数据的,如果在第一轮循环中,线程 1 获取地址里面的值被线程 2 修改了,那么线程 1 需要自旋,到下次循环才有可能机会执行。
Java 按照锁的实现分为乐观锁和悲观锁,乐观锁和悲观锁并不是一种真实存在的锁,而是一种设计思想。
悲观锁
悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源 或者数据锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁, 表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。
Java 中的 Synchronized 和 ReentrantLock 等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁。
乐观锁
乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是,乐观锁在进行写入操作的时候会判断当前数据是否被修改过。乐观锁的实 现方案一般来说有两种:版本号机制和CAS
实现 。乐观锁多适用于多度的应用类型,这样可以提高吞吐量。
在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现 的。
JDK 1.8对 HashMap 做了哪些优化?
- 数据结构:数组+链表改成了数组 + 链表/红黑树,这是因为发生了哈希冲突,元素会存入链表,链表过长转为红黑树,将查询元素的时间复杂度由 O(n) 降低为 O(logn);
- 链表插入方式从头插法改为尾插法,这是因为 JDK 1.7 扩容时,头插法会发生链表反转,多线程的环境下会产生环形链表;
- 扩容:扩容时,JDK 1.7 需要对原数组中的元素重新进行哈希定位新数组中的位置,JDK 1.8 采用更简单的判断逻辑,不需要重新哈希计算位置,新的位置不变或索引 + 新增容量大小。原因是,提高扩容的效率,更快扩容;
- 扩容机制:在插入时,JDK 1.7 先判断是否需要扩容,再插入,JDK 1.8 先进性插入,插入后再判断是否需要扩容;
- 哈希/散列/扰动函数:JDK 1.7 做了四次位移和四次异或,JDK 1.8 做了一次,原因是做 4 次,边际效用也不大,改为 1 次提升效率;
扩容(resize)分为两步:
- 扩容:创建一个新的 Entry/Node 空数组,长度是原数组的 2 倍;
- rehash(JDK 1.7):遍历原 Entry/Node 数组,把所有的 Entry/Node 节点重新 hash 到新数组;
参考
https://baijiahao.baidu.com/s?id=1712744857260389238&wfr=spider&for=pc
https://baijiahao.baidu.com/s?id=1722255125227536994&wfr=spider&for=pc
https://developer.51cto.com/article/699495.html
https://blog.csdn.net/weixin_48286142/article/details/124251482
https://blog.csdn.net/qq_45369827/article/details/114935265
https://baijiahao.baidu.com/s?id=1662733945029523270&wfr=spider&for=pc
https://blog.csdn.net/woshimaxiao1/article/details/83661464
https://blog.csdn.net/hqy1719239337/article/details/83044599