概述
哈希表(HashMap)是Java数据结构中的重要组成部分,融合了多种高级算法,如散列算法、碰撞解决策略、动态扩缩容机制以及红黑树的应用。在并发编程中,它还涉及到分段锁、CAS操作和扩容安全性等高级议题。掌握哈希表,对于理解Java程序性能优化至关重要。
数据结构
本文主要针对JDK1.8之后的HashMap进行解读,JDK1.7及以前的设计仅提供一些公认的“八股文”作为对照内容,以更好地理解JDK1.8之后的源码。
常量
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
关于常量字段的定义,我们需要关注以下四个关键点:
- 位运算
有关容量的常量通常使用类似 1 << 4
这种方式表示,这样可以确保容量是 2 的幂,并且这种表示方法非常直观。为什么容量要是 2 的幂?这是因为设计者希望通过位运算(按位与)来计算元素的索引。我们可以看看哈希表是如何计算索引的:
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != 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) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
总结一下,其实得出了一个公式 index = hashCode & (capacity - 1)
。为什么按位与操作能够得出正确的索引位置?我们来看一个例子:
hashCode = 100
capacity = 16
计算过程如下:
capacity - 1 = 15
,其二进制表示为0000 1111
hashCode = 100
,其二进制表示为0110 0100
hashCode & (capacity - 1)
的结果是0000 0100
,即 4
所以,使用位运算来计算索引位置是非常有效的。
- 泊松分布
负载因子 = 0.75 是哈希表性能和空间利用率之间的权衡。较低的负载因子可以减少哈希冲突的概率,并增加表中的空闲位置,但也会导致空间浪费。较高的负载因子则能提高空间利用率,但会增加哈希冲突的概率,从而影响性能。0.75 是一个经验值,同时也是一个经过科学计算的值。设计者认为桶内的元素近似遵循泊松分布,于是使用泊松分布公式来进行如下计算:
设计者通过负载因子 0.75 和 k=[0,8]
计算得出,当负载因子为 8 时,哈希碰撞的概率非常低,因此将树化阈值设置为 8,而反树化阈值设置为 6,以防止发生冲突的概率突增。因此,0.75 作为负载因子是一个理想的选择:
* 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 in ten million
3. 边缘抖动
如果树化和反树化的阈值都设置为 8,会出现什么问题?当桶中的结点数量在 7 到 8 之间波动时,可能会导致结点反复树化和反树化,从而造成严重的性能问题。因此,将阈值设置为 6 和 8 的设计可以有效避免这种边缘抖动现象。
- 树化利弊
最后一个常量字段是最小树化容量 = 64,并且要求至少是最小树化阈值的 4 倍。这个数值涉及许多考虑因素,主要包括两个问题:
- 链表转换为红黑树的性能是否一定更好?
- 在超过树化阈值后,转换为红黑树是否总是更优?
盲目使用红黑树并不总是理想的。红黑树有一些问题:
- 维护成本较高,每次修改可能需要调整以保持平衡。
- 对于较短的链表,链表的访问速度通常优于红黑树,这得益于现代 CPU 对链表的优化。
- 红黑树本身的空间利用率不高,进一步破坏了哈希表性能与空间利用率的平衡。
关于红黑树的空间利用率,我们可以通过公式计算得出转为红黑树后的空间使用倍数:
其中 D 为每个结点的数据大小(单位:字节)。以 Java 为例,最小的数据大小 D 可以计算为:
因此,带入 D = 24 得到 X = 2
,即链表转红黑树后空间使用至多为原来的 2 倍。设计者建议树化阈值应为最小容量的 4 倍,主要是为了平衡性能和空间利用。因此,最优方案通常是保持一个中庸的做法。
结构
下面是哈希表节点定义的代码示例:
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
return o instanceof Map.Entry<?, ?> e
&& Objects.equals(key, e.getKey())
&& Objects.equals(value, e.getValue());
}
}
从代码中可以看出,哈希表实际上是一个由 Node
类型定义的数组,每个数组元素都是 Node
类型,即链表的头结点。当发生哈希冲突时,哈希表会使用链表来处理冲突。每个数组元素被称为一个“bucket”(桶)。
此外,entrySet
是对哈希表键值对的一个缓存,提供了哈希表的“视图”,极大地提升了开发者对哈希表的使用体验和性能。代码如下:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
除了数组和链表,哈希表还支持树结构,这就是前面提到的红黑树。红黑树的代码定义如下:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
可以看到,树节点除了存储基本信息外,还需要额外存储父子节点的引用,这也证明了红黑树相较于链表的空间利用率较低。
结合上述代码,我们可以得出结论:哈希表是一种由数组、链表和红黑树组成的数据结构。
散列算法
散列算法,也称为哈希算法,旨在将输入(通常是字符串)转换为固定大小的数值,这个数值称为哈希值或哈希码。哈希函数在计算机科学中用途广泛,例如数据结构(哈希表)、数据校验(文件完整性校验)、密码学(单向加密)等。哈希函数有以下几个关键特性:
- 确定性:对于相同的输入,哈希算法总是生成相同的哈希值。
- 高效性:哈希算法需要快速计算,以便在处理大量数据时保持高效。
- 均匀分布:哈希值应该在输出空间中均匀分布,以减少冲突的可能性。
- 不可逆性:从哈希值通常无法直接推导出原始输入,这使得哈希算法通常被视为单向函数。
常见的哈希函数及其特性如下:
- MD5:一种广泛使用的哈希算法,但现在被认为不安全,因为存在碰撞问题。山东大学的王小云教授已证明其存在碰撞。
- SHA-1:一种比 MD5 更安全的哈希算法,但也不再推荐使用,容易受到碰撞攻击。
- SHA-256:一种更安全的哈希算法,广泛用于需要高安全性的应用,例如比特币和 SSL 证书。
- SHA-3:最新的哈希算法标准,提供了更高的安全性和对碰撞攻击的更强抵抗力。
需要注意的是,哈希算法的输出范围是有限的,而输入范围是无限的,因此任何哈希算法都有可能发生碰撞。尽管如此,只要哈希算法尚未被破解,它通常被认为是安全的(详细了解密码学可以参考电影《解密》)。即使哈希算法被破解,也需要极其严苛的条件和专业工具才能发起有实际意义的攻击,因此不必过于担心。
在哈希表中,哈希码的计算方法如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先使用 key.hashCode()
计算输入的哈希码,然后对其进行扰动处理:将哈希码右移 16 位,并与原哈希码进行按位异或操作。这样做的好处是将哈希码的高位和低位信息混合,使得哈希值包含更多的信息,从而增加了哈希值的随机性和均匀性(具体可以通过概率学进一步研究)。
至于 key.hashCode()
的实现,每种类型对 Object
类中的 hashCode()
方法有特定的重写,例如:
@IntrinsicCandidate
public native int hashCode();
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
String
的 hashCode()
实现值得细细揣摩,它采用了懒计算、缓存和并发控制等技术,是一段精巧的代码,但本文将不再深入讨论。
哈希碰撞
在讨论哈希算法时,哈希碰撞是一个不可避免的话题。哈希碰撞发生在不同的输入产生相同的哈希值时。例如:
public static void main(String[] args) {
String s1 = "FB";
String s2 = "Ea";
int hash1 = s1.hashCode();
int hash2 = s2.hashCode();
System.out.println("s1: " + s1 + " hash: " + hash1);
System.out.println("s2: " + s2 + " hash: " + hash2);
// 输出结果
// s1: FB hash: 2236
// s2: Ea hash: 2236
}
为了应对哈希碰撞,当前主流的方法有以下四种:
拉链法
拉链法(或称链表法)是最常见的解决哈希碰撞的方法。当发生冲突时,使用链表来存储具有相同哈希码的结点。Java 的 HashMap
就是使用这种方法。优点是实现简单,缺点是链表可能变得很长,从而影响性能。优化措施包括将链表转换为红黑树,以提高性能。
开放寻址法
开放寻址法在发生冲突时,顺序查找下一个空位来存储数据。优点是实现简单,缺点是可能导致聚集。因此,常常采用一些优化技术,例如二次探查。二次探查通过在顺序探查的基础上增加步长,进一步提升性能。有时,还会使用不同的哈希函数来改善开放寻址法的性能,使得哈希值分布更加均匀。
再哈希法
再哈希法在冲突发生时,使用第二个哈希函数重新计算键的位置。优点是可以有效避免聚集,但需要额外的哈希函数和存储空间。为了提高效果,常常实现多种哈希算法供选择。
一致性哈希
一致性哈希是一种特殊的方法,通常用于分布式系统中。它通过哈希环的方式,将数据均匀分布到多个节点上。优点是节点增减时,影响的数据量较少。缺点是实现复杂,需要维护哈希环的一致性。此方法广泛应用于分布式缓存、分布式存储等高可用性和扩展性场景中。
扩容与缩容
在哈希表的使用过程中,扩容和缩容是两个重要的操作。它们影响了哈希表的性能和空间利用效率。下面是有关 JDK1.8 中哈希表扩容的详细解释,以及扩容过程中使用的策略和潜在的问题。
扩容机制
在 JDK1.8 中,哈希表的扩容具有一些独特的特点,其中最显著的就是“先插入,再扩容”(尾插法)策略。扩容的条件和过程如下:
扩容条件:
- 哈希表的总容量大于阈值(64)。
- 桶的总容量大于阈值(8)。
扩容过程:
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;
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;
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;
}
扩容过程的关键步骤:
- 计算新容量:将原容量翻倍,生成新的数组
newCap
。如果扩容后的容量超过了最大容量,则不再扩容,直接将阈值设置为Integer.MAX_VALUE
。 - 创建新数组:创建一个新的容量为
newCap
的数组newTab
。 - 重新分配元素:
- 遍历旧的哈希表数组,将元素重新分配到新的数组中。
- 如果桶中只有一个结点,直接将其插入新数组中。
- 如果桶中是树结点,执行树的分裂操作。
- 如果桶中是链表,将链表中的结点按照新容量进行重新分配。需要将链表分为两部分,一部分存放在
newTab[j]
中,另一部分存放在newTab[j + oldCap]
中。
扩容注意事项:
- 扩容过程仅重新计算桶位,链表转化为树的操作只在插入过程中发生。即使链表长度超过 8 也不会自动进行树化,树化只会在插入过程中进行。
- 哈希表在扩容时可能存在链表长度大于 8 但未树化的情况,这可能是因为哈希表容量未达到树化的阈值或动态调整过程中。
尾插法与头插法
在 JDK1.7 中,哈希表使用的是头插法,但在多线程环境中,这种方法会引发一些严重问题,因此 JDK1.8 转向使用尾插法。下面对比一下头插法和尾插法的优缺点:
-
头插法(Head Insertion):
- 特点:新元素被插入到链表的头部,作为新的头节点。
- 优点:插入操作快,因为只需修改链表头部,无需遍历链表。
- 缺点:在多线程环境中容易引发死锁和链表循环问题。两个线程同时插入新节点,可能导致链表顺序混乱,甚至形成循环链表。
- 死锁:两个线程争夺头节点,可能导致死锁,参考哲学家问题可以进一步理解。
- 链表循环:两个线程同时进行扩容操作,可能导致链表顺序的循环链表问题。例如,线程1和线程2同时处理链表,可能导致链表中出现环形结构。
-
尾插法(Tail Insertion):
- 特点:新元素被插入到链表的尾部。
- 优点:维护元素的插入顺序,避免链表循环问题。在多线程环境中更稳定。
- 缺点:插入操作较慢,因为需要遍历链表找到尾部。尽管如此,尾插法在多线程环境中表现更稳定,能够保持链表的正确顺序。
由于缺乏适当的锁机制,头插法在实际使用中可能导致严重的问题,如死锁和链表循环。
死锁:多个线程同时争夺链表头节点,可能导致相互等待资源,从而发生死锁。这种现象可以参考哲学家就餐问题来理解,其中多个线程(或哲学家)因为争夺有限资源而互相阻塞,形成死锁。
链表循环:在扩容过程中,如果两个线程同时对链表进行操作,比如一个线程将链表从A->B
改为B->A
,而另一个线程也在操作中,可能导致链表顺序混乱。如果线程1和线程2同时操作,线程1可能在扩容过程中将B
设置为头节点,而线程2也会将B
插入到同一位置,从而形成B->...->B
的循环链表。这种情况发生是因为线程1和线程2对链表的视图不一致,其中线程1扩容后的B
和线程2看到的B
形成了循环。
这些问题表明,在多线程环境中,头插法可能导致数据结构的不一致性和难以调试的错误。
在哈希表的实际应用中,尾插法的稳定性使其成为更受欢迎的选择,特别是在多线程环境下。
红黑树
红黑树是一种高级数据结构,开发者可以仅了解其基本特性,本文不会深入详细介绍。
红黑树是一种自平衡的二叉搜索树,由鲁道夫·贝尔于1972年发明。它在需要频繁插入、删除和查找操作的环境中表现优越,广泛应用于各种计算领域。
红黑树具有以下特性:
- 节点颜色:每个节点要么是红色,要么是黑色。
- 根节点:树的根节点始终是黑色。
- 红色节点规则:如果一个节点是红色,则其子节点必须是黑色,即树中不能出现两个连续的红色节点。
- 黑色高度:从任意节点到其每一个叶子节点的路径上包含相同数量的黑色节点。
- 新插入节点:新插入的节点通常是红色,以保持树的平衡性。
这些规则确保红黑树的高度保持在 ( \log_2(n) ) 级别,其中 ( n ) 是树中节点的总数。这使得红黑树的查找、插入和删除操作的最坏时间复杂度均为 ( O(\log n) )。
红黑树的基本操作包括:
- 旋转:为了维持树的平衡,可能需要对节点进行左旋或右旋操作。
- 重新着色:在插入或删除节点后,可能需要调整节点的颜色以保持红黑树的性质。
- 插入修复:插入节点后,通过旋转和重新着色操作,保持红黑树的规则。
- 删除修复:删除节点后,可能需要旋转、重新着色等操作,以恢复红黑树的性质。
红黑树广泛应用于许多标准库和框架中,例如Java的TreeMap
和TreeSet
,以及C++ STL中的set
和map
。它因其出色的性能而成为计算机科学中一个重要的数据结构。
并发安全
HashMap
在并发环境下不是线程安全的。尽管 JDK1.8 中通过使用尾插法来避免了死锁问题,但仍然无法完全避免“读-写”和“写-写”的并发冲突。为了确保并发安全,可以采用以下几种主流方式:
HashTable
HashTable
是 Java 早期版本(Java 5)中提供的线程安全的哈希表实现。它继承自 Dictionary
类,并实现了 Map
接口。其实现方式是对 HashMap
的方法进行加锁来保证线程安全:
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length; // 确保为非负数
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
这种实现方式虽然能确保线程安全,但由于每次访问都需要加锁,导致性能较差。在现代并发编程中,这种做法已经过时,不推荐使用。
ConcurrentHashMap(JDK 1.7)
ConcurrentHashMap
在 JDK 1.7 中引入了分段锁机制,以实现更细粒度的锁控制,从而提高并发性能。它通过将数据分成多个段(Segment
),每一段独立加锁,不同段之间可以并发访问:
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
private static final long serialVersionUID = 7249069246763182397L;
// 分段数组,每一段都是一个 hash 表
final Segment<K,V>[] segments;
}
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 每段中的表
transient volatile HashEntry<K,V>[] table;
}
通过这种设计,ConcurrentHashMap
减少了锁的粒度,提高了并发性能。然而,在 JDK 1.8 中,这种设计也进行了优化。
ConcurrentHashMap(JDK 1.8)
在 JDK 1.8 中,ConcurrentHashMap
进行了进一步优化,去除了 Segment
数组,使用了更为高效的设计。它通过使用 Node
结构存储键值对,并大量运用 CAS(Compare-And-Swap)操作来减少锁的开销:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
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);
}
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);
}
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
在一些写操作中,ConcurrentHashMap
结合使用了 synchronized
关键字,以确保必要的并发安全:
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
在扩容时,ConcurrentHashMap
允许多个线程协助扩容,以提高效率。以下是扩容过程中的一个关键变量 stride
的计算方式(通过计算CPU数量和桶的数量来决定 stride
的数值),它决定了每个线程处理的数据范围:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
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 = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
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;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
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)
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;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
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(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
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;
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;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
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);
advance = true;
}
}
}
}
}
}
在 JDK 1.8 中,ConcurrentHashMap
的改进使其在处理并发访问时更为高效,提供了更优的性能和更少的锁开销。
总结
HashMap 在非并发场景下高效,但并发时需使用线程安全的实现。HashTable 是早期的线程安全哈希表,通过同步保证安全,但性能较差。ConcurrentHashMap 引入了分段锁(JDK 1.7)和无锁 CAS 操作(JDK 1.8),提高了并发性能并节省内存。红黑树作为自平衡的二叉搜索树,广泛用于高效的查找和排序。