分享:并发 HashMap 如何简单实现

转载自品略图书馆 http://www.pinlue.com/article/2020/07/0113/5610908301336.html

 

在开发一款工具的过程中,由于无法使用 java.util.concurrent.ConcurrentHashMap (工具的目标之一就是跟踪 ConcurrentHashMap 内部实现) 因此笔者决定自己实现一个 “乞丐版” computeIfAbsent 方法。这样一个简单可扩展的 ConcurrentHashMap 就唾手可得。

0 算法

算法基于 Jeff Preshing[1] 与 Cliff Click[2] 博士的工作,使用了带线性探测的开放寻址。开放寻址即 key-value 存储在一个数组中。指向 key-value 的 index 等于 hashcode 对数组大小取模。线性探测表示,如果数组元素已占用则使用下一个数组元素。循环往复直到发现一个空的 slot。下面的示例中,在两个已占用的 slot 后面插入新元素:为了保证算法的并发性,需要使用 compareAndSet 在空 slot 中填入新元素。这样可以保证“检查是否为 null 与填入新元素"是原子(atomic)操作。如果 compareAndSet 顺利执行就成功地插入了一个新元素,返回该元素;如果执行失败,则需要检查是否其他线程使用了相同的 key 进行插入操作。接着寻找下一个空 slot 继续尝试。算法源代码如下:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
private static final VarHandle ARRAY =     MethodHandles.arrayElementVarHandle(KeyValue[].class);volatile KeyValue[] currentArray;public V computeIfAbsent(K key, Function<? super K, ? extends V> compute) {    KeyValue[] local = currentArray;    int hashCode = hashAndEquals.hashForKey(key);    // array位置等于hashCode按array大小取模,    // 由于array大小是2的倍数,可以用 & 取代 % 进行计算    int index = (local.length - 1) & hashCode;    int iterations = 0;    KeyValue created = null;    KeyValue current = tabAt(local, index);    // fast path for reading    if (current != null) {        if (hashAndEquals.keyEquals(current.key, key)) {            return (V) current.value;        } else if (current.key == MOVED_NULL_KEY) {            return (V) insertDuringResize(key, compute);        }    }    while (true) {        if (current == null) {            if (created == null) {                created = new KeyValue(key, compute.apply(key));            }            // 用compareAndSet设置array元素,处理null的情况            if (casTabAt(local, index, created)) {                if (((iterations) << resizeAtPowerOfTwo) > local.length) {                    resize(local.length, iterations);                }                // 成功插入新值                return (V) created.value;            }            // 失败则检查是否出现相同的key            current = tabAt(local, index);            if (hashAndEquals.keyEquals(current.key, key)) {                return (V) current.value;            } else if (current.key == MOVED_NULL_KEY) {                return (V) insertDuringResize(key, compute);            }        }        index++;        iterations++;        if (index == local.length) {            index = 0;        }        if ((iterations << resizeAtPowerOfTwo) > local.length) {            resize(local.length, iterations);            return computeIfAbsent(key, compute);        }        current = tabAt(local, index);        if (current != null) {            if (hashAndEquals.keyEquals(current.key, key)) {                return (V) current.value;            } else if (current.key == MOVED_NULL_KEY) {                return (V) insertDuringResize(key, compute);            }        }    }}private static final KeyValue tabAt(KeyValue[] tab, int i) {    return (KeyValue) ARRAY.getVolatile(tab, i);}private static final boolean casTabAt(KeyValue[] tab, int i,         KeyValue newValue) {    return ARRAY.compareAndSet(tab, i, null, newValue); }
 

 

1 工作原理上述方案之所以可以奏效,当线程读到的数组位置已填充元素则元素不变同时 key 也不变。唯一需要确认是,当前读取元素为 null 时另一个线程是否在这里进行了修改。上面通过使用 compareAndSet 确保了这种意外情况不会发生。由于算法本身不会移除 key 且 key 本身不可变 (immutable) 因此算法成立。

2 改变大小

在调整大小过程中,需要确保新增元素不丢失即不更新当前数组。具体的做法,会把数组中每个空元素设为特殊值 MOVED_NULL_KEY。看到这个值,线程知道当前正在进行大小调整。待调整结束再插入元素。

HashMap 调整大小步骤如下:

  1. 把数组中所有值为 null 的元素设为 MOVED_NULL_KEY;

  2. 创建新数组;

  3. 拷贝所有当前数组的值到新数组;

  4. 设置 currentArray 为新数组。

 

实现代码:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
private static final Object MOVED_NULL_KEY = new Object();  private final Object resizeLock = new Object(); private void resize(int checkedLength, int intervall) {    synchronized (resizeLock) {        if (currentArray.length > checkedLength) {               return;        }        resizeRunning = true;        // 把数组中所有值为null的元素设为特殊值        for (int i = 0; i < currentArray.length; i++) {            if (tabAt(currentArray, i) == null) {                casTabAt(currentArray, i, MOVED_KEY_VALUE);            }        }        int arrayLength = Math.max(currentArray.length * 2,            tableSizeFor(intervall * newMinLength + 2));        // 创建新数组        KeyValue[] newArray = new KeyValue[arrayLength];        // 拷贝所有当前数组的值到新数组        for (int i = 0; i < currentArray.length; i++) {            KeyValue current = tabAt(currentArray, i);            if (current != MOVED_KEY_VALUE) {                int hashCode = hashAndEquals.hashForKey(current.key);                int index = (newArray.length - 1) & hashCode;                while (newArray[index] != null) {                    index++;                    if (index == newArray.length) {                        index = 0;                    }                }                newArray[index] = current;            }        }        // 设置currentArray为新数组        currentArray = newArray;        resizeRunning = false;        resizeLock.notifyAll();    }}

 

3 基准测试

 

不同 HashMap 的实现取决于具体负载以及哈希函数,测试结果仅供参考。下面使用 JMH 用随机数 key 调用 computeIfAbsent。基准测试方法的代码如下:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public static final int MAX_KEY  = 10000000;public static final Function COMPUTE = new Function() {    public Object apply(Object t) {        return new Object();    }};@Benchmarkpublic Object computeIfAbsentHashMap() {    int key = ThreadLocalRandom.current().nextInt(MAX_KEY);    return computeIfAbsentHashMap.computeIfAbsent(key, COMPUTE);}

 

测试环境:Intel Xeon Platinum 8124M CPU @ 3.00GHz, 两个 CPU 插槽 (18核/槽), 每个核心启动2个硬件线程;JVM 使用 Amazon Corretto 11。

 

 

新算法的 map 大小与ConcurrentHashMap 类似。500万随机key, java.util.concurrent.ConcurrentHashMap 需要285M字节,新 map 需要253M字节。

4 差异分析

如果其他线程已更新空 slot,新 map 会进行重试。java.util.concurrent.ConcurrentHashMap 使用 array bin 作为同步块监视器,因此同一时刻只有一个线程修改 bin 中的内容。由此带来了差异:新 map 会多次调用回调函数进行计算,每次更新失败会调用一次方法;而 java.util.concurrent.ConcurrentHashMap 最多只计算一次。

5 总结

自己实现的 computeIfAbsent 比 java.util.concurrent.ConcurrentHashMap 扩展性更好。文中的算法之所以简单,因为没有涉及元素删除以及 key 的改变。可以用来存储 class 以及相关元数据。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值