原创: 许光明 杏仁技术站 1周前程序员
做者 | 许光明算法
杏仁后端工程师。少青年程序员,关注服务端技术和农药。后端
JAVA 语言提供了大量丰富的集合, 好比 List, Set, Map 等。其中 Map 是一个经常使用的一个数据结构,HashMap 是基于 Hash 算法实现 Map 接口而被普遍使用的集类。HashMap 里面是一个数组,而后数组中每一个元素是一个单向链表。可是 HashMap 并非线程安全的, 在多线程场景下使用存在并发和死循环问题。HashMap 结构如图所示:数组
线程安全的 Map 的实现有 HashTable 和 ConcurrentHashMap 等。HashTable 对集合读写操做经过 Synchronized 同步保障线程安全, 整个集合只有一把锁, 对集合的操做只能串行执行,性能不高。ConcurrentHashMap 是另外一个线程安全的 Map, 一般来讲他的性能优于 HashTable。 ConcurrentHashMap 的实如今 JDK1.7 和 JDK 1.8 有所不一样。缓存
在 JDK1.7 版本中,ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 组成。简单理解就是ConcurrentHashMap 是一个 Segment 数组,Segment 经过继承 ReentrantLock 来进行加锁,因此每次须要加锁的操做锁住的是一个 Segment,这样只要保证每一个 Segment 是线程安全的,也就实现了全局的线程安全。安全
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操做,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,可是已经简化了属性,只是为了兼容旧版本。 经过 HashMap 查找的时候,根据 hash 值可以快速定位到数组的具体下标,若是发生 Hash 碰撞,须要顺着链表一个个比较下去才能找到咱们须要的,时间复杂度取决于链表的长度,为 O(n)。为了下降这部分的开销,在 Java8 中,当链表中的元素超过了 8 个之后,会将链表转换为红黑树,在这些位置进行查找的时候能够下降时间复杂度为 O(logN)。性能优化
由上面分析可知,ConcurrentHashMap 更适合做为线程安全的 Map。在实际的项目过程当中,咱们一般须要获取集合类的长度, 那么计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,由于他是并发操做的,就是在你计算 size 的时候,它还在并发的插入数据,可能会致使你计算出来的 size 和你实际的 size 有差距。本文主要分析下 JDK1.8 的实现。 关于 JDK1.7 简单提一下。数据结构
在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试屡次计算 ConcurrentHashMap 的 size,最多三次,比较先后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是若是第一种方案不符合,他就会给每一个 Segment 加上锁,而后计算 ConcurrentHashMap 的 size 返回。其源码实现:多线程
public int size() { final Segment<K,V>[] segments = this.segments; int size; boolean overflow; // true if size overflows 32 bits long sum; // sum of modCounts long last = 0L; // previous sum int retries = -1; // first iteration isn't retry try { for (;;) { if (retries++ == RETRIES_BEFORE_LOCK) { for (int j = 0; j < segments.length; ++j) ensureSegment(j).lock(); // force creation } sum = 0L; size = 0; overflow = false; for (int j = 0; j < segments.length; ++j) { Segment<K,V> seg = segmentAt(segments, j); if (seg != null) { sum += seg.modCount; int c = seg.count; if (c < 0 || (size += c) < 0) overflow = true; } } if (sum == last) break; last = sum; } } finally { if (retries > RETRIES_BEFORE_LOCK) { for (int j = 0; j < segments.length; ++j) segmentAt(segments, j).unlock(); } } return overflow ? Integer.MAX_VALUE : size; }
JDK1.8 实现相比 JDK 1.7 简单不少,只有一种方案,咱们直接看 size()
代码:并发
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); }
最大值是 Integer 类型的最大值,可是 Map 的 size 可能超过 MAX_VALUE, 因此还有一个方法 mappingCount()
,JDK 的建议使用 mappingCount()
而不是size()
。mappingCount()
的代码以下:
public long mappingCount() { long n = sumCount(); return (n < 0L) ? 0L : n; // ignore transient negative values }
以上能够看出,不管是 size()
仍是 mappingCount()
, 计算大小的核心方法都是 sumCount()
。sumCount()
的代码以下:
final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }
分析一下 sumCount()
代码。ConcurrentHashMap 提供了 baseCount、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。sumCount()
就是迭代 counterCells 来统计 sum 的过程。 put 操做时,确定会影响 size()
,在 put()
方法最后会调用 addCount()
方法。
addCount()
代码以下:
若是 counterCells == null, 则对 baseCount 作 CAS 自增操做。
若是并发致使 baseCount CAS 失败了使用 counterCells。
若是counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操做,直到成功。
而后,CounterCell 这个类究竟是什么?咱们会发现它使用了 @sun.misc.Contended 标记的类,内部包含一个 volatile 变量。@sun.misc.Contended 这个注解标识着这个类防止须要防止 "伪共享"。那么,什么又是伪共享呢?
缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,通常为32-256个字节。最多见的缓存行大小是64个字节。当多线程修改互相独立的变量时,若是这些变量共享同一个缓存行,就会无心中影响彼此的性能,这就是伪共享。
CounterCell 代码以下:
@sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } }
JDK1.7 和 JDK1.8 对 size 的计算是不同的。 1.7 中是先不加锁计算三次,若是三次结果不同在加锁。
JDK1.8 size 是经过对 baseCount 和 counterCell 进行 CAS 计算,最终经过 baseCount 和 遍历 CounterCell 数组得出 size。
JDK 8 推荐使用mappingCount 方法,由于这个方法的返回值是 long 类型,不会由于 size 方法是 int 类型限制最大值。
全文完
如下文章您可能也会感兴趣: