HashMap源码精读

1,118 阅读21分钟

概述

哈希表(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. 位运算

有关容量的常量通常使用类似 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

所以,使用位运算来计算索引位置是非常有效的。

  1. 泊松分布

负载因子 = 0.75 是哈希表性能和空间利用率之间的权衡。较低的负载因子可以减少哈希冲突的概率,并增加表中的空闲位置,但也会导致空间浪费。较高的负载因子则能提高空间利用率,但会增加哈希冲突的概率,从而影响性能。0.75 是一个经验值,同时也是一个经过科学计算的值。设计者认为桶内的元素近似遵循泊松分布,于是使用泊松分布公式来进行如下计算:

P(X=k)=eλλkk!P(X = k) = \frac{e^{-\lambda} \cdot \lambda^k}{k!}

设计者通过负载因子 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 的设计可以有效避免这种边缘抖动现象。

  1. 树化利弊

最后一个常量字段是最小树化容量 = 64,并且要求至少是最小树化阈值的 4 倍。这个数值涉及许多考虑因素,主要包括两个问题:

  1. 链表转换为红黑树的性能是否一定更好?
  2. 在超过树化阈值后,转换为红黑树是否总是更优?

盲目使用红黑树并不总是理想的。红黑树有一些问题:

  1. 维护成本较高,每次修改可能需要调整以保持平衡。
  2. 对于较短的链表,链表的访问速度通常优于红黑树,这得益于现代 CPU 对链表的优化。
  3. 红黑树本身的空间利用率不高,进一步破坏了哈希表性能与空间利用率的平衡。

关于红黑树的空间利用率,我们可以通过公式计算得出转为红黑树后的空间使用倍数:

X=D+24DX = \frac{D + 24}{D}

其中 D 为每个结点的数据大小(单位:字节)。以 Java 为例,最小的数据大小 D 可以计算为:

D最小=对象头大小+键大小+值大小D_{\text{最小}} = \text{对象头大小} + \text{键大小} + \text{值大小} D最小=16字节+4字节+4字节=24字节D_{\text{最小}} = 16 \text{字节} + 4 \text{字节} + 4 \text{字节} = 24 \text{字节}

因此,带入 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);
    }
}

可以看到,树节点除了存储基本信息外,还需要额外存储父子节点的引用,这也证明了红黑树相较于链表的空间利用率较低。

结合上述代码,我们可以得出结论:哈希表是一种由数组、链表和红黑树组成的数据结构。

2.png

散列算法

散列算法,也称为哈希算法,旨在将输入(通常是字符串)转换为固定大小的数值,这个数值称为哈希值或哈希码。哈希函数在计算机科学中用途广泛,例如数据结构(哈希表)、数据校验(文件完整性校验)、密码学(单向加密)等。哈希函数有以下几个关键特性:

  • 确定性:对于相同的输入,哈希算法总是生成相同的哈希值。
  • 高效性:哈希算法需要快速计算,以便在处理大量数据时保持高效。
  • 均匀分布:哈希值应该在输出空间中均匀分布,以减少冲突的可能性。
  • 不可逆性:从哈希值通常无法直接推导出原始输入,这使得哈希算法通常被视为单向函数。

常见的哈希函数及其特性如下:

  • 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;
}

StringhashCode() 实现值得细细揣摩,它采用了懒计算、缓存和并发控制等技术,是一段精巧的代码,但本文将不再深入讨论。

哈希碰撞

在讨论哈希算法时,哈希碰撞是一个不可避免的话题。哈希碰撞发生在不同的输入产生相同的哈希值时。例如:

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 中,哈希表的扩容具有一些独特的特点,其中最显著的就是“先插入,再扩容”(尾插法)策略。扩容的条件和过程如下:

扩容条件

  1. 哈希表的总容量大于阈值(64)。
  2. 桶的总容量大于阈值(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;
}

扩容过程的关键步骤

  1. 计算新容量:将原容量翻倍,生成新的数组 newCap。如果扩容后的容量超过了最大容量,则不再扩容,直接将阈值设置为 Integer.MAX_VALUE
  2. 创建新数组:创建一个新的容量为 newCap 的数组 newTab
  3. 重新分配元素
    • 遍历旧的哈希表数组,将元素重新分配到新的数组中。
    • 如果桶中只有一个结点,直接将其插入新数组中。
    • 如果桶中是树结点,执行树的分裂操作。
    • 如果桶中是链表,将链表中的结点按照新容量进行重新分配。需要将链表分为两部分,一部分存放在 newTab[j] 中,另一部分存放在 newTab[j + oldCap] 中。

扩容注意事项

  • 扩容过程仅重新计算桶位,链表转化为树的操作只在插入过程中发生。即使链表长度超过 8 也不会自动进行树化,树化只会在插入过程中进行。
  • 哈希表在扩容时可能存在链表长度大于 8 但未树化的情况,这可能是因为哈希表容量未达到树化的阈值或动态调整过程中。

尾插法与头插法

在 JDK1.7 中,哈希表使用的是头插法,但在多线程环境中,这种方法会引发一些严重问题,因此 JDK1.8 转向使用尾插法。下面对比一下头插法和尾插法的优缺点:

  1. 头插法(Head Insertion)

    • 特点:新元素被插入到链表的头部,作为新的头节点。
    • 优点:插入操作快,因为只需修改链表头部,无需遍历链表。
    • 缺点:在多线程环境中容易引发死锁和链表循环问题。两个线程同时插入新节点,可能导致链表顺序混乱,甚至形成循环链表。
    • 死锁:两个线程争夺头节点,可能导致死锁,参考哲学家问题可以进一步理解。
    • 链表循环:两个线程同时进行扩容操作,可能导致链表顺序的循环链表问题。例如,线程1和线程2同时处理链表,可能导致链表中出现环形结构。
  2. 尾插法(Tail Insertion)

    • 特点:新元素被插入到链表的尾部。
    • 优点:维护元素的插入顺序,避免链表循环问题。在多线程环境中更稳定。
    • 缺点:插入操作较慢,因为需要遍历链表找到尾部。尽管如此,尾插法在多线程环境中表现更稳定,能够保持链表的正确顺序。

由于缺乏适当的锁机制,头插法在实际使用中可能导致严重的问题,如死锁和链表循环。

死锁:多个线程同时争夺链表头节点,可能导致相互等待资源,从而发生死锁。这种现象可以参考哲学家就餐问题来理解,其中多个线程(或哲学家)因为争夺有限资源而互相阻塞,形成死锁。

链表循环:在扩容过程中,如果两个线程同时对链表进行操作,比如一个线程将链表从A->B改为B->A,而另一个线程也在操作中,可能导致链表顺序混乱。如果线程1和线程2同时操作,线程1可能在扩容过程中将B设置为头节点,而线程2也会将B插入到同一位置,从而形成B->...->B的循环链表。这种情况发生是因为线程1和线程2对链表的视图不一致,其中线程1扩容后的B和线程2看到的B形成了循环。

这些问题表明,在多线程环境中,头插法可能导致数据结构的不一致性和难以调试的错误。

在哈希表的实际应用中,尾插法的稳定性使其成为更受欢迎的选择,特别是在多线程环境下。

红黑树

红黑树是一种高级数据结构,开发者可以仅了解其基本特性,本文不会深入详细介绍。

红黑树是一种自平衡的二叉搜索树,由鲁道夫·贝尔于1972年发明。它在需要频繁插入、删除和查找操作的环境中表现优越,广泛应用于各种计算领域。

红黑树具有以下特性:

  1. 节点颜色:每个节点要么是红色,要么是黑色。
  2. 根节点:树的根节点始终是黑色。
  3. 红色节点规则:如果一个节点是红色,则其子节点必须是黑色,即树中不能出现两个连续的红色节点。
  4. 黑色高度:从任意节点到其每一个叶子节点的路径上包含相同数量的黑色节点。
  5. 新插入节点:新插入的节点通常是红色,以保持树的平衡性。

这些规则确保红黑树的高度保持在 ( \log_2(n) ) 级别,其中 ( n ) 是树中节点的总数。这使得红黑树的查找、插入和删除操作的最坏时间复杂度均为 ( O(\log n) )。

红黑树的基本操作包括:

  • 旋转:为了维持树的平衡,可能需要对节点进行左旋或右旋操作。
  • 重新着色:在插入或删除节点后,可能需要调整节点的颜色以保持红黑树的性质。
  • 插入修复:插入节点后,通过旋转和重新着色操作,保持红黑树的规则。
  • 删除修复:删除节点后,可能需要旋转、重新着色等操作,以恢复红黑树的性质。

红黑树广泛应用于许多标准库和框架中,例如Java的TreeMapTreeSet,以及C++ STL中的setmap。它因其出色的性能而成为计算机科学中一个重要的数据结构。

并发安全

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 中,这种设计也进行了优化。

企业微信20240729-155916@2x.png

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),提高了并发性能并节省内存。红黑树作为自平衡的二叉搜索树,广泛用于高效的查找和排序。