首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

并发集合ConcurrentHashMap和普通集合HashMap应用对比示例

在多线程访问共享数据时,可能会导致数据不一致等问题。为了解决这个问题,Java并发包提供了一系列并发集合类,用于在多线程环境中安全地共享数据。

常见的并发集合类包括:

ConcurrentHashMap:线程安全的哈希表,允许多个线程并发读写,性能优越。

CopyOnWriteArrayList:适用于读多写少的场景,写操作时会复制整个数组,确保读操作的安全性。

BlockingQueue:如ArrayBlockingQueue和LinkedBlockingQueue,用于生产者-消费者模式,支持阻塞的插入和删除操作。

并发集合类与传统的集合类(如ArrayList或HashMap)不同,它在设计时考虑了线程安全,能够在多个线程同时读写数据时,自动处理同步问题,避免了手动加锁的复杂性。

先看第一个类 ConcurrentHashMap,它提供了线程安全的哈希表实现。

空口无凭,来个例子测试下Map和ConcurrentHashMap的区别。

一、Map和ConcurrentHashMap在多线程环境下的测试

假设我们有一个计数器应用程序,需要在多线程环境中统计某些事件的发生次数。我们将分别使用 HashMap 和 ConcurrentHashMap 来实现这个计数器,并观察它们在多线程环境下的表现。

1、使用 HashMap 的示例

首先,我们来看一下使用 HashMap 的实现。在多线程环境中,HashMap 不是线程安全的,因此我们需要手动进行同步。

import java.util.HashMap;

import java.util.Map;

public class HashMapCounter {

private final Map<String, Integer> counter = new HashMap<>();

public synchronized void increment(String key) {

// 在已有的值上自增1

counter.put(key, counter.getOrDefault(key, 0) + 1);

}

// 获取key的值

public synchronized int getCount(String key) {

// 获取key的值,值不存在时返回0,相当于给key设置了一个默认值

return counter.getOrDefault(key, 0);

}

public static void main(String[] args) throws InterruptedException {

HashMapCounter counter = new HashMapCounter();

Runnable task = () -> {

for (int i = 0; i < 1000; i++) {

counter.increment("event");

}

};

Thread thread1 = new Thread(task);

Thread thread2 = new Thread(task);

thread1.start();

thread2.start();

thread1.join();

thread2.join();

System.out.println("Final count: " + counter.getCount("event"));

}

}

运行结果:

Final count: 2000

在这里定义了一个测试类HashMapCounter,它有一个名为 counter 的成员变量,类型为 Map<String, Integer>,使用 HashMap 实例化,用来存储键值对,键是字符串类型,值是整数类型。counter 被定义为 final,这意味着它的引用不可更改,但可以修改其内容。

increment 方法用于增加指定键的计数。如果该键存在,则其值增加1;如果不存在,则从0开始计数。这个方法是同步的,意味着在多线程环境中同时只有一个线程可以执行该方法。

getCount 方法返回指定键的当前计数值,如果不存在,则返回0。这个方法也是同步的,确保读取时数据的一致性。把synchronized 关键字去掉会发现运行结果非常不稳定,每次都离正确的值偏差很远。

启动这两个线程,并通过 join 方法等待它们执行完毕。join 方法确保主线程(即执行 main 方法的线程)在两个工作线程完成工作前暂停。最后,输出 “event” 键的计数结果,理论上应该是2000,因为两个线程各自增加了1000次。

我们不用synchronized 关键字行不行?

去掉synchronized 的运行结果:

Final count: 1370

Final count: 1269

每运行一次,结果变化一次,妥妥地线程不安全呀!只能加synchronized 关键字保证线程安全。

在这个示例中,我们使用 synchronized 关键字来确保对 HashMap 的操作是线程安全的。然而,这种方式会导致较高的锁竞争,从而影响性能。

2、使用 ConcurrentHashMap 的示例

接下来,我们来看一下使用 ConcurrentHashMap 的实现。ConcurrentHashMap 本身是线程安全的,因此不需要手动进行同步。

import java.util.concurrent.ConcurrentHashMap;

import java.util.concurrent.ConcurrentMap;

public class ConcurrentHashMapCounter {

private final ConcurrentMap<String, Integer> counter = new ConcurrentHashMap<>();

public void increment(String key) {

counter.merge(key, 1, Integer::sum);

}

public int getCount(String key) {

return counter.getOrDefault(key, 0);

}

public static void main(String[] args) throws InterruptedException {

ConcurrentHashMapCounter counter = new ConcurrentHashMapCounter();

Runnable task = () -> {

for (int i = 0; i < 1000; i++) {

counter.increment("event");

}

};

Thread thread1 = new Thread(task);

Thread thread2 = new Thread(task);

thread1.start();

thread2.start();

thread1.join();

thread2.join();

System.out.println("Final count: " + counter.getCount("event"));

}

}

成员变量counter的类型为ConcurrentMap<String, Integer>,它使用 ConcurrentHashMap 实例化,这是和上例第一个不同点。

increment 方法用于增加指定键的计数。merge 方法会将指定键的值增加1,如果该键不存在,则插入键并设置值为1。Integer::sum 是一个方法引用,用于定义合并操作,即两个整数相加。这个方法不需要

getCount 方法返回指定键的当前计数值,如果不存在,则返回0。

启动这两个线程,并通过 join 方法等待它们执行完毕。最后,输出 “event” 键的计数结果,理论上应该是2000,因为两个线程各自增加了1000次。

在这个示例中,我们使用 ConcurrentHashMap 来实现计数器。ConcurrentHashMap 提供了线程安全的操作,因此我们不需要手动进行同步。此外,我们使用了 merge 方法来实现计数器的递增操作,这个方法可以保证在并发环境下的正确性。

3、HashMap和ConcurrentHashMap对比

HashMap:在多线程环境中使用 HashMap 时需要手动进行同步,这会导致较高的锁竞争,从而影响性能。

ConcurrentHashMap:ConcurrentHashMap 提供了线程安全的操作,适用于高并发场景,性能更好。

在需要线程安全的多线程环境中,优先选择 ConcurrentHashMap。在单线程或不需要线程安全的环境中,可以使用 HashMap。

ConcurrentHashMap是如何实现线程安全的呢?一起探讨下。

二、ConcurrentHashMap部分源码

ConcurrentHashMap的源码太多了,一个个方法的解读非常麻烦,篇幅比较长,读者也没有耐心看完。我们就从上面的两个方法解读吧。

在 Java 并发集合类 ConcurrentHashMap 中,getOrDefault 这个方法是 Java 8 引入的,它的目的是简化常见的模式,即在获取值时需要进行显式的空检查。

1、getOrDefault方法

提供了一种从映射中获取值的便捷方式,如果映射中不存在指定的键,则返回一个默认值。

public V getOrDefault(Object key, V defaultValue) {

V v;

return (v = get(key)) == null ? defaultValue : v;

}

1.1. 参数解释:

key: 要检索的键。

defaultValue: 如果映射中不存在键时要返回的默认值。

1.2. 方法逻辑:

调用 get(key) 方法尝试从哈希表中获取与指定键关联的值。

使用 v 存储 get 方法返回的结果。

判断 v 的值是否为 null(即键不存在于映射中)。

如果 v 是 null,返回 defaultValue;如果不是 null,则返回 v。

要完全理解 getOrDefault 方法,需要知道 get 方法在 ConcurrentHashMap 中的工作方式。

2、 get 方法

ConcurrentHashMap 的 get 方法是高效的,因为它几乎不需要锁定:

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;

}

2.1. 参数解释:

key: 要检索的键。

2.2. 方法逻辑:

在 get 方法中:

首先,计算键的哈希值,并定位到数组的位置。

检查该位置上的第一个节点,如果节点的键与搜索的键匹配,直接返回节点的值。

如果首节点是转发节点(即哈希表正在进行扩容操作),会通过节点的 find 方法在正确的数据结构中继续搜索。

如果不是转发节点,就在链表中遍历,查找匹配的键。

2.3.性能考量

getOrDefault 方法的性能很大程度上取决于 get 方法的性能。由于 get 方法在 ConcurrentHashMap 中是高度优化的,它能够在并发环境中提供良好的读取性能而无需加锁,因此 getOrDefault 也继承了这一高效性。

getOrDefault 非常适用于需要从映射中检索值,但又希望在键不存在时有一个默认返回值的情况。这避免了额外的条件语句检查 null,使得代码更加简洁和易于理解。

3、 merge

merge 方法允许在哈希表中插入或更新一个键值对,如果键已经存在,则通过提供的合并函数来决定如何处理现有值与新值。它的主要用途是避免显式的 put 和 get 操作,以及手动的同步。

public V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {

if (key == null || value == null || remappingFunction == null) throw new NullPointerException();

int hash = spread(key.hashCode());

V oldValue;

for (Node<K,V>[] tab = table;;) {

Node<K,V> f; int n, i, fh; V v;

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;

}

else if ((fh = f.hash) == MOVED) // 如果节点正在扩容,帮助进行扩容

tab = helpTransfer(tab, f);

else {

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)))) {

oldValue = e.val; // 找到匹配的键,获取旧值

break;

}

pred = e;

if ((e = e.next) == null) { // 没有匹配的键

oldValue = 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)

oldValue = p.val; // 在树中找到匹配的键

else

oldValue = null;

}

}

}

if (validated) {

if (oldValue != null) { // 如果键存在

v = remappingFunction.apply(oldValue, value); // 合并旧值和新值

if (v != null) {

if (oldValue != v)

setTabAt(tab, i, new Node<K,V>(hash, key, v, f.next)); // 更新节点

}

else

removeNode(hash, key, null, false, false); // 如果合并结果为 null,则删除节点

}

else if (casTabAt(tab, i, f, new Node<K,V>(hash, key, value, f.next))) // 如果键不存在,则插入新节点

break;

}

}

}

return v;

}

3.1. 参数解释:

key: 要设置的键。

value: 要插入的值。

remappingFunction: 是提供的函数。

3.2.详解代码逻辑

空值检查:最开始对 key、value 和 remappingFunction 进行空值检查,如果有一个为空,则抛出 NullPointerException。这是为了确保哈希表中不能有 null 键或值。

哈希值计算:hash = spread(key.hashCode()),计算键的哈希值。spread 方法用来将哈希值进一步均匀分布,减少冲突。

初始化表:tab = initTable(); 如果哈希表还没有初始化,调用 initTable() 来创建表。

检查目标位置是否为空:通过计算哈希值找到目标位置,i = (n - 1) & hash 计算哈希桶的索引。如果对应位置为空,则使用 CAS 操作插入新节点。

处理并发扩容:如果当前位置的节点标记为 MOVED,说明哈希表正在进行扩容,调用 helpTransfer 方法帮助完成扩容。

并发控制:进入 synchronized (f) 块,这里对哈希桶中的链表或红黑树的头节点进行同步锁定,以确保线程安全。

查找旧值:

如果当前位置是链表,遍历链表寻找是否有与当前键相同的键。

如果当前位置是红黑树,则在树中查找匹配的键。

合并或插入:

如果找到了匹配的旧值,调用 remappingFunction.apply(oldValue, value) 来合并旧值和新值。如果合并后的值不为 null,则更新该节点的值;如果合并结果为 null,则删除该节点。

如果没有找到旧值,直接插入新节点。

最后,结束循环:一旦插入或更新操作完成,退出循环。

3.3、关键技术点

CAS(Compare-And-Swap):通过 CAS 操作来保证在并发环境下的原子性。CAS 操作是 merge 方法实现线程安全的核心机制之一。

链表与红黑树转换:为了避免哈希冲突,ConcurrentHashMap 使用链表或红黑树来存储哈希冲突的元素。当哈希桶中的元素较多时,会自动将链表转换为红黑树,提高查询效率。

扩容机制:当哈希表负载因子超过某个阈值时,ConcurrentHashMap 会自动进行扩容。为了避免扩容期间数据不一致的问题,merge 方法会检查当前节点是否正在被迁移。

3.4、merge 方法的优势

线程安全:在并发环境中,merge 方法通过锁、CAS 操作和分段锁等机制实现了线程安全,允许多个线程安全地进行插入或更新操作。

简化操作:merge 方法简化了显式的 put、get 和条件判断操作,允许开发者通过提供一个合并函数来处理值的冲突,从而提高了代码的可读性和维护性。

三、最后总结

ConcurrentHashMap 能够保证线程安全的原因在于其独特的设计和实现。首先,它将整个哈希表划分为多个段(Segment),每个段使用独立的锁来进行控制。这意味着多个线程可以并发访问不同的段,而不会互相干扰,从而显著提高了性能。

其次,ConcurrentHashMap 在读取操作时几乎不需要加锁,这使得在读多写少的场景中,它的性能尤为突出。这种设计允许多个线程同时执行读操作,大大减少了因加锁带来的性能开销。

此外,ConcurrentHashMap 提供了一些原子操作,如 putIfAbsent 和 remove,这些操作在内部使用了更细粒度的锁机制,确保在并发环境中对数据的安全访问。这种设计使得开发者可以在多线程应用中共享数据,而无需担心复杂的同步问题,从而有效避免了竞争条件和数据不一致的情况,确保了线程安全。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OiR7WXyS86zFEDSK20N44VtQ0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券