MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java的ConcurrentHashMap使用技巧

2021-09-142.6k 阅读

1. 理解ConcurrentHashMap的基本结构

在深入探讨使用技巧之前,我们首先要理解 ConcurrentHashMap 的基本结构。ConcurrentHashMap 自 Java 1.5 引入,它是线程安全的哈希表,采用了分段锁(Segment)的设计理念,在 JDK 1.8 之后摒弃了分段锁,采用了 Node 数组加链表或红黑树的结构,结合 CAS(Compare - and - Swap)操作来保证线程安全。

1.1 JDK 1.7 中的结构

在 JDK 1.7 中,ConcurrentHashMap 由多个 Segment 组成,每个 Segment 就像是一个小型的 HashMapSegment 继承自 ReentrantLock,这意味着每个 Segment 都可以独立加锁。当对 ConcurrentHashMap 进行写操作(如 put 方法)时,只会锁住操作的那个 Segment,而其他 Segment 仍然可以被并发访问,从而提高了并发性能。例如,假设有一个 ConcurrentHashMap 有 16 个 Segment,那么理论上可以支持 16 个线程同时进行写操作而不产生竞争。

// JDK 1.7 中 ConcurrentHashMap 部分代码示例
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.75f, 16);
map.put(1, "value1");

1.2 JDK 1.8 中的结构

JDK 1.8 对 ConcurrentHashMap 进行了重大改进,不再使用分段锁,而是采用了与 HashMap 类似的数组加链表或红黑树的结构。在 put 操作时,首先通过 hash 值找到对应的数组位置,如果该位置为空,则直接使用 CAS 操作插入数据。如果该位置已经有数据,且为链表结构,那么就会对链表进行遍历,若链表长度超过阈值(默认为 8),则将链表转换为红黑树。在这个过程中,synchronized 关键字被用于锁住链表或红黑树的头节点,而不是整个数组,从而进一步提高了并发性能。

// JDK 1.8 中 ConcurrentHashMap 部分代码示例
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");

2. 初始化ConcurrentHashMap

正确地初始化 ConcurrentHashMap 对于性能至关重要。在初始化时,需要考虑初始容量、加载因子和并发级别(仅 JDK 1.7 有)等参数。

2.1 初始容量

初始容量指的是 ConcurrentHashMap 在创建时的大小。如果能够预先估计数据量的大小,设置合适的初始容量可以减少扩容的次数,从而提高性能。扩容操作会涉及到重新计算哈希值、迁移数据等操作,开销较大。

例如,如果预计会有 1000 个元素,根据加载因子的默认值 0.75,我们可以计算出合适的初始容量:

// 预计元素个数为1000
int expectedSize = 1000;
// 计算初始容量
int initialCapacity = (int) Math.ceil(expectedSize / 0.75f) + 1;
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(initialCapacity);

2.2 加载因子

加载因子表示 ConcurrentHashMap 在达到一定的元素填充比例时会进行扩容。默认的加载因子是 0.75,这个值在时间和空间效率上取得了较好的平衡。如果加载因子设置得过高,虽然可以减少扩容次数,但会增加哈希冲突的概率,导致链表或红黑树变长,影响查找性能;如果设置得过低,则会频繁扩容,增加开销。

// 设置加载因子为0.8
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.8f);

2.3 并发级别(JDK 1.7)

在 JDK 1.7 中,并发级别表示 ConcurrentHashMap 内部 Segment 的数量。默认值是 16,意味着最多可以支持 16 个线程同时进行写操作。如果应用程序中并发写操作较多,可以适当增加并发级别,以减少锁竞争。

// JDK 1.7 中设置并发级别为32
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.75f, 32);

3. 使用ConcurrentHashMap进行操作

ConcurrentHashMap 提供了一系列的操作方法,包括 putgetremove 等,我们需要正确地使用这些方法来发挥其高性能和线程安全的特性。

3.1 put操作

put 操作是向 ConcurrentHashMap 中插入键值对。在 JDK 1.8 中,put 方法的实现如下:

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;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

在使用 put 方法时,需要注意以下几点:

  • 键值不能为空ConcurrentHashMap 不允许键或值为 null,否则会抛出 NullPointerException
  • 避免重复插入:如果已经存在相同的键,put 方法会覆盖旧的值。如果不希望覆盖,可以使用 putIfAbsent 方法。
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
// 再次插入相同键,值会被覆盖
map.put(1, "newValue1");
// 使用putIfAbsent方法,如果键不存在则插入
map.putIfAbsent(2, "value2");

3.2 get操作

get 操作是从 ConcurrentHashMap 中获取对应键的值。get 操作通常是无锁的,这是因为 ConcurrentHashMap 的数据结构设计使得在大多数情况下可以直接通过哈希值找到对应的位置,而不需要加锁。

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

在使用 get 方法时,虽然它是线程安全的,但由于 ConcurrentHashMap 是允许并发修改的,所以在某些极端情况下,可能会获取到旧的值。例如,在一个线程进行 put 操作的同时,另一个线程进行 get 操作,可能会出现短暂的不一致。不过,这种情况在大多数应用场景下不会对程序逻辑产生严重影响。

ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
String value = map.get(1);
System.out.println(value); // 输出 value1

3.3 remove操作

remove 操作是从 ConcurrentHashMap 中移除指定键的键值对。在 JDK 1.8 中,remove 方法的实现如下:

public V remove(Object key) {
    return replaceNode(key, null, null);
}

final V replaceNode(Object key, V value, Object cv) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0 ||
            (f = tabAt(tab, i = (n - 1) & hash)) == null)
            break;
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            boolean validated = false;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        validated = true;
                        for (Node<K,V> e = f, pred = null;;) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                V ev = e.val;
                                if (cv == null || cv == ev ||
                                    (ev != null && cv.equals(ev))) {
                                    oldVal = ev;
                                    if (value != null)
                                        e.val = value;
                                    else if (pred != null)
                                        pred.next = e.next;
                                    else
                                        setTabAt(tab, i, e.next);
                                }
                                break;
                            }
                            pred = e;
                            if ((e = e.next) == null)
                                break;
                        }
                    }
                    else if (f instanceof TreeBin) {
                        validated = true;
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> r, p;
                        if ((r = t.root) != null &&
                            (p = r.findTreeNode(hash, key, null)) != null) {
                            V ev = p.val;
                            if (cv == null || cv == ev ||
                                (ev != null && cv.equals(ev))) {
                                oldVal = ev;
                                if (value != null)
                                    p.val = value;
                                else if (t.removeTreeNode(p))
                                    setTabAt(tab, i, untreeify(t.first));
                            }
                        }
                    }
                }
            }
            if (validated) {
                if (oldVal != null) {
                    if (value == null)
                        addCount(-1L, -1);
                    return oldVal;
                }
                break;
            }
        }
    }
    return null;
}

在使用 remove 方法时,需要注意以下几点:

  • 键不能为空:与 put 方法一样,remove 方法的键也不能为 null,否则会抛出 NullPointerException
  • 返回值remove 方法会返回被移除的旧值,如果键不存在,则返回 null
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
String removedValue = map.remove(1);
System.out.println(removedValue); // 输出 value1

4. 并发访问控制

虽然 ConcurrentHashMap 本身是线程安全的,但在某些复杂的业务场景下,可能需要额外的并发访问控制。

4.1 复合操作

有时候,我们需要进行一些复合操作,例如先检查某个键是否存在,然后根据结果进行插入或更新操作。在这种情况下,如果不进行适当的同步控制,可能会出现竞态条件。

例如,假设有一个场景,我们需要统计某个单词在文本中出现的次数:

ConcurrentHashMap<String, Integer> wordCountMap = new ConcurrentHashMap<>();
// 错误的实现方式,可能出现竞态条件
String word = "example";
if (!wordCountMap.containsKey(word)) {
    wordCountMap.put(word, 1);
} else {
    wordCountMap.put(word, wordCountMap.get(word) + 1);
}

上述代码中,containsKeyput 操作不是原子的,在多线程环境下,可能会出现多个线程同时判断键不存在,然后都进行插入操作,导致统计结果错误。

为了解决这个问题,可以使用 compute 方法,它会以原子方式执行复合操作:

ConcurrentHashMap<String, Integer> wordCountMap = new ConcurrentHashMap<>();
String word = "example";
wordCountMap.compute(word, (k, v) -> v == null? 1 : v + 1);

4.2 迭代器

ConcurrentHashMap 的迭代器是弱一致性的。这意味着在迭代过程中,如果 ConcurrentHashMap 被其他线程修改,迭代器仍然可以继续迭代,不会抛出 ConcurrentModificationException,但可能会反映不出最新的修改。

ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
map.put(2, "value2");

map.forEach((key, value) -> {
    System.out.println(key + " : " + value);
});

// 使用迭代器
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<Integer, String> entry = iterator.next();
    System.out.println(entry.getKey() + " : " + entry.getValue());
}

在使用迭代器时,要注意其弱一致性的特点,避免在迭代过程中依赖于数据的实时一致性。如果需要强一致性的迭代,可以考虑在迭代前对 ConcurrentHashMap 进行快照(例如使用 Collections.unmodifiableMap 方法创建一个不可修改的视图)。

5. 性能优化

为了充分发挥 ConcurrentHashMap 的性能,我们可以采取一些优化措施。

5.1 合理设置参数

在初始化 ConcurrentHashMap 时,合理设置初始容量、加载因子和并发级别(JDK 1.7)等参数,可以减少扩容和锁竞争的开销。如前文所述,根据预计的数据量和并发情况来设置这些参数是关键。

5.2 减少哈希冲突

哈希冲突会导致链表或红黑树变长,影响查找和插入性能。为了减少哈希冲突,我们可以提供高质量的哈希函数。ConcurrentHashMap 默认使用 spread 方法来计算哈希值,在自定义类作为键时,也应该重写 hashCode 方法,确保哈希值的分布均匀。

class CustomKey {
    private int id;
    private String name;

    public CustomKey(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + id;
        result = 31 * result + (name != null? name.hashCode() : 0);
        return result;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CustomKey that = (CustomKey) o;
        return id == that.id &&
                Objects.equals(name, that.name);
    }
}

ConcurrentHashMap<CustomKey, String> map = new ConcurrentHashMap<>();
CustomKey key = new CustomKey(1, "customKey1");
map.put(key, "value1");

5.3 避免不必要的同步

ConcurrentHashMap 本身已经是线程安全的,在使用时应避免在外部再进行不必要的同步操作,以免降低并发性能。例如,不要在对 ConcurrentHashMap 进行操作的方法上使用 synchronized 关键字,除非有特殊的业务需求。

6. 与其他集合的比较

ConcurrentHashMap 与其他一些集合类在功能和性能上有一些区别。

6.1 与HashMap的比较

  • 线程安全性HashMap 是非线程安全的,在多线程环境下使用需要进行额外的同步控制,而 ConcurrentHashMap 是线程安全的,无需外部同步。
  • 性能:在单线程环境下,HashMap 的性能通常略高于 ConcurrentHashMap,因为 ConcurrentHashMap 为了保证线程安全会有一些额外的开销。但在多线程环境下,ConcurrentHashMap 的并发性能优势明显,能够支持更高的并发访问。

6.2 与Hashtable的比较

  • 线程安全性HashtableConcurrentHashMap 都是线程安全的。但 Hashtable 使用的是全局锁,在任何时候只有一个线程可以进行读写操作,而 ConcurrentHashMap 在 JDK 1.8 之后采用更细粒度的锁机制,允许多个线程同时进行部分操作,并发性能更高。
  • 对null的支持Hashtable 不允许键或值为 null,这一点与 ConcurrentHashMap 相同,但 HashMap 允许键或值为 null

通过深入理解 ConcurrentHashMap 的结构、操作方法、并发控制和性能优化等方面,我们可以在多线程编程中充分发挥它的优势,构建高效、稳定的应用程序。在实际应用中,根据具体的业务需求和场景,合理选择和使用 ConcurrentHashMap,能够有效提升系统的并发处理能力。