ConcurrentHashMap
概述
是线程安全HashMap的。 ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。 哈希表支持检索的完全并发和更新的高预期并发。 该类遵循与Hashtable相同的功能规范,并且包括与Hashtable每个方法相对应的方法版本。 但是,即使所有操作都是线程安全的,检索操作也不需要锁定,并且也不支持以阻止所有访问的方式锁定整个表。 这个类在Hashtable中与依赖其线程安全性的程序完全可互操作,但不依赖其同步细节。 哈希表支持检索的完全并发和更新的高预期并发。
API
构造函数
函数
clear
从此映射中移除所有映射。
ompute
尝试计算指定键和其当前映射值的映射(如果没有当前映射, null )。
computeIfAbsent
如果指定的键尚未与值相关联,则尝试使用给定的映射函数计算其值,并将其输入到此映射中,除非 null 。
computeIfPresent
如果指定键的值存在,则尝试计算给定键和其当前映射值的新映射。
contains
测试某些键是否映射到此表中的指定值。
containsKey
测试指定的对象是否是此表中的键。
containsValue
如果此映射将一个或多个键映射到指定值,则返回 true 。
elements
返回此表中值的枚举。
entrySet
返回此映射中映射的映射的 Set视图。
equals
将指定的对象与此映射进行比较以获得相等性。
forEach
对此映射中的每个条目执行给定操作,直到处理完所有条目或操作抛出异常为止。
forEach
对每个(键,值)执行给定的操作。 forEach void forEach(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> transformer, Consumer<? super U> action) 对每个(键,值)的每个非空变换执行给定的操作。 forEachEntry void forEachEntry(long parallelismThreshold, Function<Entry<K, V>, ? extends U> transformer, Consumer<? super U> action) 对每个条目的每个非空转换执行给定的操作。 forEachEntry void forEachEntry(long parallelismThreshold, Consumer<? super Entry<K, V>> action) 为每个条目执行给定的操作。 forEachKey void forEachKey(long parallelismThreshold, Function<? super K, ? extends U> transformer, Consumer<? super U> action) 对每个键的每个非空转换执行给定的操作。 forEachKey void forEachKey(long parallelismThreshold, Consumer<? super K> action) 对每个键执行给定的操作。 forEachValue void forEachValue(long parallelismThreshold, Function<? super V, ? extends U> transformer, Consumer<? super U> action) 对每个值的每个非空变换执行给定的操作。 forEachValue void forEachValue(long parallelismThreshold, Consumer<? super V> action) 对每个值执行给定的操作。 get V get(Object key) 返回指定键映射到的值,如果此映射不包含键映射,则返回 null 。 getOrDefault V getOrDefault(Object key, V defaultValue) 返回指定键映射到的值,或者如果此映射不包含键映射,则返回给定的默认值。 hashCode int hashCode() 返回此 Map的散列码值,即映射中每个键值对的总和,即 key.hashCode() ^ value.hashCode() 。 isEmpty boolean isEmpty() 如果此映射不包含键 - 值映射,则返回 true 。 该实现返回 size() == 0 。 keySet Set keySet() 返回此映射中包含的键的 Set视图。 keySet KeySetView<K, V> keySet(V mappedValue) 使用给定的公共映射值( add(E)和 addAll(Collection) ),返回此映射中键的 Set视图。 keys Enumeration keys() 返回此表中键的枚举。 mappingCount long mappingCount() 返回映射的数量。 merge V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) 如果指定的键尚未与(非空)值相关联,则将其与给定值相关联。 KeySetView static KeySetView<K, Boolean> newKeySet() 创建一个由给定类型的ConcurrentHashMap支持的新的 Set到 Boolean.TRUE 。 KeySetView static KeySetView<K, Boolean> newKeySet(int initialCapacity) 创建一个由给定类型的ConcurrentHashMap支持的新的 Set到 Boolean.TRUE 。 put V put(K key, V value) 将指定的键映射到此表中的指定值。 putAll void putAll(Map<? extends K, ? extends V> m) 将指定映射中的所有映射复制到此映射。 putIfAbsent V putIfAbsent(K key, V value) 如果指定的键尚未与值相关联,则将其与给定值相关联。 reduce U reduce(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) 返回使用给定的缩减器合并值累积所有(键,值)对的给定变换的结果,如果不是,则返回null。 reduceEntries Entry<K, V> reduceEntries(long parallelismThreshold, BiFunction<Entry<K, V>, Entry<K, V>, ? extends Entry<K, V>> reducer) 返回使用给定reducer合并值的所有条目的累积结果,如果没有,则返回null。 reduceEntries U reduceEntries(long parallelismThreshold, Function<Entry<K, V>, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) 返回使用给定缩减器合并值累积给定转换的结果,如果没有,则返回null。 reduceEntriesToDouble double reduceEntriesToDouble(long parallelismThreshold, ToDoubleFunction<Entry<K, V>> transformer, double basis, DoubleBinaryOperator reducer) 返回使用给定缩减器合并值并将给定基础作为标识值累加给定变换的结果。 reduceEntriesToInt int reduceEntriesToInt(long parallelismThreshold, ToIntFunction<Entry<K, V>> transformer, int basis, IntBinaryOperator reducer) 返回使用给定缩减器合并值并将给定基础作为标识值累加给定变换的结果。 reduceEntriesToLong long reduceEntriesToLong(long parallelismThreshold, ToLongFunction<Entry<K, V>> transformer, long basis, LongBinaryOperator reducer) 返回使用给定缩减器合并值并将给定基础作为标识值累加给定变换的结果。 reduceKeys K reduceKeys(long parallelismThreshold, BiFunction<? super K, ? super K, ? extends K> reducer) 返回使用给定缩减器合并值累积所有键的结果,如果没有,则返回null。 reduceKeys U reduceKeys(long parallelismThreshold, Function<? super K, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) 返回使用给定的缩减器合并值累加给定所有键的转换的结果,如果没有,则返回null。 reduceKeysToDouble double reduceKeysToDouble(long parallelismThreshold, ToDoubleFunction<? super K> transformer, double basis, DoubleBinaryOperator reducer) 返回使用给定缩减器累加给定所有键的转换以合并值,并将给定基准作为标识值的结果。 reduceKeysToInt int reduceKeysToInt(long parallelismThreshold, ToIntFunction<? super K> transformer, int basis, IntBinaryOperator reducer) 返回使用给定缩减器累加给定所有键的转换以合并值,并将给定基准作为标识值的结果。 reduceKeysToLong long reduceKeysToLong(long parallelismThreshold, ToLongFunction<? super K> transformer, long basis, LongBinaryOperator reducer) 返回使用给定缩减器累加给定所有键的转换以合并值,并将给定基准作为标识值的结果。 reduceToDouble double reduceToDouble(long parallelismThreshold, ToDoubleBiFunction<? super K, ? super V> transformer, double basis, DoubleBinaryOperator reducer) 返回使用给定的缩减器将所有(键,值)对的给定变换累加以合并值,并将给定基础作为标识值的结果。 reduceToInt int reduceToInt(long parallelismThreshold, ToIntBiFunction<? super K, ? super V> transformer, int basis, IntBinaryOperator reducer) 返回使用给定的缩减器将所有(键,值)对的给定变换累加以合并值,并将给定基础作为标识值的结果。 reduceToLong long reduceToLong(long parallelismThreshold, ToLongBiFunction<? super K, ? super V> transformer, long basis, LongBinaryOperator reducer) 返回使用给定的缩减器将所有(键,值)对的给定变换累加以合并值,并将给定基础作为标识值的结果。 reduceValues V reduceValues(long parallelismThreshold, BiFunction<? super V, ? super V, ? extends V> reducer) 返回使用给定缩减器合并值累加所有值的结果,如果没有,则返回null。 reduceValues U reduceValues(long parallelismThreshold, Function<? super V, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) 返回使用给定的缩减器合并值累积所有值的给定变换的结果,如果没有,则返回null。 reduceValuesToDouble double reduceValuesToDouble(long parallelismThreshold, ToDoubleFunction<? super V> transformer, double basis, DoubleBinaryOperator reducer) 返回使用给定的缩减器合并值并将给定基础作为标识值累加给定所有值的转换的结果。 reduceValuesToInt int reduceValuesToInt(long parallelismThreshold, ToIntFunction<? super V> transformer, int basis, IntBinaryOperator reducer) 返回使用给定的缩减器合并值并将给定基础作为标识值累加给定所有值的转换的结果。 reduceValuesToLong long reduceValuesToLong(long parallelismThreshold, ToLongFunction<? super V> transformer, long basis, LongBinaryOperator reducer) 返回使用给定的缩减器合并值并将给定基础作为标识值累加给定所有值的转换的结果。 remove V remove(Object key) 从此映射中删除键(及其相应的值)。 remove boolean remove(Object key, Object value) 只有在当前映射到给定值的情况下才删除键的条目。 replace boolean replace(K key, V oldValue, V newValue) 仅当当前映射到给定值时才替换密钥的条目。 replace V replace(K key, V value) 仅当当前映射到某个值时才替换密钥的条目。 replaceAll void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) 用对该条目调用给定函数的结果替换每个条目的值,直到处理完所有条目或者该函数抛出异常。 search U search(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> searchFunction) 对每个(键,值)应用给定的搜索函数返回一个非空结果,如果没有,则返回null。 searchEntries U searchEntries(long parallelismThreshold, Function<Entry<K, V>, ? extends U> searchFunction) 在每个条目上应用给定搜索函数返回非空结果,如果没有,则返回null。 searchKeys U searchKeys(long parallelismThreshold, Function<? super K, ? extends U> searchFunction) 对每个键应用给定的搜索函数返回非空结果,如果不是,则返回null。 searchValues U searchValues(long parallelismThreshold, Function<? super V, ? extends U> searchFunction) 对每个值应用给定的搜索函数返回一个非空结果,如果没有,则返回null。 size int size() 返回此映射中键 - 值映射的数量。 该实现返回 entrySet().size() 。 toString String toString() 返回此映射的字符串表示形式。 values Collection values() 返回此映射中包含的值的 Collection视图。
内部类 ConcurrentHashMap.KeySetView 将ConcurrentHashMap视图作为键的Set ,其中可以通过映射到公共值来选择性地启用添加。 class ConcurrentHashMap.KeySetView<K, V> 函数 boolean add(K e) 如果已定义,则通过将密钥映射到支持映射中的默认映射值,将指定的密钥添加到此设置视图。
boolean addAll(Collection<? extends K> c) 将指定集合中的所有元素添加到该集合,就像通过每个元素调用 add(E) 。
final void clear() 通过从支持该视图的地图中删除所有映射,从该视图中移除所有元素。
boolean contains(Object o) 如果此集合包含指定的元素,则返回 true 。
final boolean containsAll(Collection<?> c) 如果此集合包含指定集合中的所有元素,则返回 true 。
boolean equals(Object o) 指示其他某个对象是否“等于”这一个。
void forEach(Consumer<? super K> action) ConcurrentHashMap<K, V> getMap() 返回支持此视图的地图。
V getMappedValue() 返回添加的默认映射值,如果不支持添加,则返回 null 。
int hashCode() 返回对象的哈希码值。
final boolean isEmpty() 如果此集合不包含元素,则返回 true 。
Iterator iterator() 返回此集合中元素的迭代器。
boolean remove(Object o) 通过从备份映射中除去键(及其相应的值),从该映射视图中除去键。
final boolean removeAll(Collection<?> c) 删除指定集合中包含的所有此集合的元素(可选操作)。
final boolean retainAll(Collection<?> c) 仅保留包含在指定集合中的此集合中的元素(可选操作)。
final int size() 返回此集合中的元素数量。
Spliterator spliterator() 在此集合中的元素上创建一个 Spliterator 。
final T[] toArray(T[] a) 返回包含此集合中所有元素的数组; 返回数组的运行时类型是指定数组的运行时类型。
final Object[] toArray() 返回包含此集合中所有元素的数组。
final String toString() 返回此集合的字符串表示形式。
版本区别 JDK < 1.8 ConcurrentHashMap - JDK 1.7 在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap. 简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可是实现多线程put操作。接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。 数据结构 整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个 segment。 简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrencyLevel: 并行级别、并发数、Segment 数,怎么翻译不重要,理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。 再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。 初始化 initialCapacity: 初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。 loadFactor: 负载因子,之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments int sshift = 0; int ssize = 1; // 计算并行级别 ssize,因为要保持并行级别是 2 的 n 次方 while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 我们这里先不要那么烧脑,用默认值,concurrencyLevel 为 16,sshift 为 4 // 那么计算出 segmentShift 为 28,segmentMask 为 15,后面会用到这两个值 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1;
}
初始化完成,我们得到了一个 Segment 数组。 我们就当是用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后: Segment 数组长度为 16,不可以扩容 Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容 这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数和掩码,这两个值马上就会用到 put 过程分析 我们先看 put 的主流程,对于其中的一些关键细节操作,后面会进行详细介绍。 public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); // 1. 计算 key 的 hash 值 int hash = hash(key); // 2. 根据 hash 值找到 Segment 数组中的位置 j // hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位, // 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标 int j = (hash >>> segmentShift) & segmentMask; // 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null, // ensureSegment(j) 对 segment[j] 进行初始化 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); // 3. 插入新值到 槽 s 中 return s.put(key, hash, value, false); }
第一层皮很简单,根据 hash 值很快就能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。 Segment 内部是由 数组+链表 组成的。 final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 在往该 segment 写入前,需要先获取该 segment 的独占锁 // 先看主流程,后面还会具体介绍这部分内容 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { // 这个是 segment 内部的数组 HashEntry<K,V>[] tab = table; // 再利用 hash 值,求应该放置的数组下标 int index = (tab.length - 1) & hash; // first 是数组该位置处的链表的表头 HashEntry<K,V> first = entryAt(tab, index);
}
整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。至于这里面的并发问题,我们稍后再进行介绍。 到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。 初始化槽: ensureSegment ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。 这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。 private Segment<K,V> ensureSegment(int k) { final Segment<K,V>[] ss = this.segments; long u = (k << SSHIFT) + SBASE; // raw offset Segment<K,V> seg; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // 这里看到为什么之前要初始化 segment[0] 了, // 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k] // 为什么要用“当前”,因为 segment[0] 可能早就扩容过了 Segment<K,V> proto = ss[0]; int cap = proto.table.length; float lf = proto.loadFactor; int threshold = (int)(cap * lf);
}
总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制。 获取写入锁: scanAndLockForPut 前面我们看到,在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。 下面我们来具体分析这个方法中是怎么控制加锁的。 private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { HashEntry<K,V> first = entryForHash(this, hash); HashEntry<K,V> e = first; HashEntry<K,V> node = null; int retries = -1; // negative while locating node
}
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。 这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node。 扩容: rehash 重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍。 首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值,读者这个时候可以回去 put 方法看一眼。 该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。 // 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。 private void rehash(HashEntry<K,V> node) { HashEntry<K,V>[] oldTable = table; int oldCapacity = oldTable.length; // 2 倍 int newCapacity = oldCapacity << 1; threshold = (int)(newCapacity * loadFactor); // 创建新数组 HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity]; // 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’ int sizeMask = newCapacity - 1;
}
这里的扩容比之前的 HashMap 要复杂一些,代码难懂一点。上面有两个挨着的 for 循环,第一个 for 有什么用呢? 仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。 我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。 get 过程分析 相对于 put 来说,get 就很简单了。 计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽” 槽中也是一个数组,根据 hash 找到数组中具体的位置 到这里是链表了,顺着链表进行查找即可 public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; // 1. hash 值 int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // 2. 根据 hash 找到对应的 segment if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { // 3. 找到segment 内部数组相应位置的链表,遍历 for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
并发问题分析 现在我们已经说完了 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。 添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。 put 操作的线程安全性。 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组。 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。 remove 操作的线程安全性。 remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。 get 操作需要遍历链表,但是 remove 操作会"破坏"链表。 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。 如果 remove 先破坏了一个节点,分两种情况考虑。 1、如果此节点是头节点,那么需要将头节点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头节点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。 JDK > 1.8 在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。 数据结构
结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。 初始化 // 这构造函数里,什么都不干 public ConcurrentHashMap() { } public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }
这个初始化方法有点意思,通过提供初始容量,计算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。 sizeCtl 这个属性使用的场景很多,不过只要跟着文章的思路来,就不会被它搞晕了。 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(); // 得到 hash 值 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();
}
初始化数组: initTable 这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。 初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。 private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 初始化的"功劳"被其他线程"抢去"了 if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin // CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { // DEFAULT_CAPACITY 默认初始容量是 16 int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 初始化数组,长度为 16 或初始化时提供的长度 Node<K,V>[] nt = (Node<K,V>[])new Node[n]; // 将这个数组赋值给 table,table 是 volatile 的 table = tab = nt; // 如果 n 为 16 的话,那么这里 sc = 12 // 其实就是 0.75 * n sc = n - (n >>> 2); } } finally { // 设置 sizeCtl 为 sc,我们就当是 12 吧 sizeCtl = sc; } break; } } return tab; }
链表转红黑树: treeifyBin 前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。我们还是进行源码分析吧。 private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { // MIN_TREEIFY_CAPACITY 为 64 // 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // 后面我们再详细分析这个方法 tryPresize(n << 1); // b 是头节点 else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { // 加锁 synchronized (b) {
}
扩容: tryPresize 如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。 这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。 这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。 // 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了 private final void tryPresize(int size) { // c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。 int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1); int sc; while ((sc = sizeCtl) >= 0) { Node<K,V>[] tab = table; int n;
}
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。 所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。 数据迁移: transfer 下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。 虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。 此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。 阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。 第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。 private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride;
}
说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制。 这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。 get 过程分析 get 方法从来都是最简单的,这里也不例外: 计算 hash 值 根据 hash 值找到数组对应位置: (n - 1) & h 根据该位置处结点性质进行相应查找 如果该位置为 null,那么直接返回 null 就可以了 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法 如果以上 3 条都不满足,那就是链表,进行遍历比对即可 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; } // 如果头节点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树 else if (eh < 0) // 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k) return (p = e.find(h, key)) != null ? p.val : null;
}
简单说一句,此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些,不过在了解了数据迁移的过程后,这个也就不难了,所以限于篇幅这里也不展开说了。
对比总结 HashTable 使用了synchronized关键字对put等操作进行加锁; ConcurrentHashMap JDK1.7 使用分段锁机制实现; ConcurrentHashMap JDK1.8 则使用数组+链表+红黑树数据结构和CAS原子操作实现;
相关问题 为什么HashTable慢? 它的并发度是什么? 那么ConcurrentHashMap并发度是什么? ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别? JDK1.8解決了JDK1.7中什么问题 ConcurrentHashMap JDK1.7实现的原理是什么? 分段锁机制 ConcurrentHashMap JDK1.8实现的原理是什么? 数组+链表+红黑树,CAS ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少? 为何一旦初始化就不可再扩容? ConcurrentHashMap JDK1.7说说其put的机制? ConcurrentHashMap JDK1.7是如何扩容的? rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容) ConcurrentHashMap JDK1.8是如何扩容的? tryPresize ConcurrentHashMap JDK1.8链表转红黑树的时机是什么? 临界值为什么是8? ConcurrentHashMap JDK1.8是如何进行数据迁移的? transfer
著作权归@pdai所有原文链接:https://pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentHashMap.html 为什么HashTable慢 Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。
Last updated