所以,思考一下,既然锁住整张表的话,并发效率低下,那我把整张表分成 N 个部分,并使元素尽量均匀的分布到每个部分中,分别给他们加锁,互相之间并不影响,这种方式岂不是更好 。这就是在 JDK1.7 中 ConcurrentHashMap 采用的方案,被叫做锁分段技术,每个部分就是一个 Segment(段)。
但是,在JDK1.8中,完全重构了,采用的是 Synchronized + CAS ,把锁的粒度进一步降低,而放弃了 Segment 分段。(此时的 Synchronized 已经升级了,效率得到了很大提升,锁升级可以了解一下)
ConcurrentHashMap 1.7 源码解析我们看下在 JDK1.7中 ConcurrentHashMap 是怎么实现的。墙裂建议,在本文之前了解一下多线程的基本知识,如JMM内存模型,volatile关键字作用,CAS和自旋,ReentranLock重入锁。
底层存储结构在 JDK1.7中,本质上还是采用链表+数组的形式存储键值对的。但是,为了提高并发,把原来的整个 table 划分为 n 个 Segment 。所以,从整体来看,它是一个由 Segment 组成的数组。然后,每个 Segment 里边是由 HashEntry 组成的数组,每个 HashEntry之间又可以形成链表。我们可以把每个 Segment 看成是一个小的 HashMap,其内部结构和 HashMap 是一模一样的。
当对某个 Segment 加锁时,如图中 Segment2,并不会影响到其他 Segment 的读写。每个 Segment 内部自己操作自己的数据。这样一来,我们要做的就是尽可能的让元素均匀的分布在不同的 Segment中。最理想的状态是,所有执行的线程操作的元素都是不同的 Segment,这样就可以降低锁的竞争。
废话了这么多,还是来看底层源码吧,因为所有的思想都在代码里体现。借用 Linus的一句话,“No BB . Show me the code ” (改编版,哈哈)
常用变量先看下 1.7 中常用的变量和内部类都有哪些,这有助于我们了解 ConcurrentHashMap 的整体结构。
//默认初始化容量,这个和 HashMap中的容量是一个概念,表示的是整个 Map的容量 static final int DEFAULT_INITIAL_CAPACITY = 16; //默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认的并发级别,这个参数决定了 Segment 数组的长度 static final int DEFAULT_CONCURRENCY_LEVEL = 16; //最大的容量 static final int MAXIMUM_CAPACITY = 1 << 30; //每个Segment中table数组的最小长度为2,且必须是2的n次幂。 //由于每个Segment是懒加载的,用的时候才会初始化,因此为了避免使用时立即调整大小,设定了最小容量2 static final int MIN_SEGMENT_TABLE_CAPACITY = 2; //用于限制Segment数量的最大值,必须是2的n次幂 static final int MAX_SEGMENTS = 1 << 16; // slightly conservative //在size方法和containsValue方法,会优先采用乐观的方式不加锁,直到重试次数达到2,才会对所有Segment加锁 //这个值的设定,是为了避免无限次的重试。后边size方法会详讲怎么实现乐观机制的。 static final int RETRIES_BEFORE_LOCK = 2; //segment掩码值,用于根据元素的hash值定位所在的 Segment 下标。后边会细讲 final int segmentMask; //和 segmentMask 配合使用来定位 Segment 的数组下标,后边讲。 final int segmentShift; // Segment 组成的数组,每一个 Segment 都可以看做是一个特殊的 HashMap final Segment<K,V>[] segments; //Segment 对象,继承自 ReentrantLock 可重入锁。 //其内部的属性和方法和 HashMap 神似,只是多了一些拓展功能。 static final class Segment<K,V> extends ReentrantLock implements Serializable { //这是在 scanAndLockForPut 方法中用到的一个参数,用于计算最大重试次数 //获取当前可用的处理器的数量,若大于1,则返回64,否则返回1。 static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; //用于表示每个Segment中的 table,是一个用HashEntry组成的数组。 transient volatile HashEntry<K,V>[] table; //Segment中的元素个数,每个Segment单独计数(下边的几个参数同样的都是单独计数) transient int count; //每次 table 结构修改时,如put,remove等,此变量都会自增 transient int modCount; //当前Segment扩容的阈值,同HashMap计算方法一样也是容量乘以加载因子 //需要知道的是,每个Segment都是单独处理扩容的,互相之间不会产生影响 transient int threshold; //加载因子 final float loadFactor; //Segment构造函数 Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf; this.threshold = threshold; this.table = tab; } ... // put(),remove(),rehash() 方法都在此类定义 } // HashEntry,存在于每个Segment中,它就类似于HashMap中的Node,用于存储键值对的具体数据和维护单向链表的关系 static final class HashEntry<K,V> { //每个key通过哈希运算后的结果,用的是 Wang/Jenkins hash 的变种算法,此处不细讲,感兴趣的可自行查阅相关资料 final int hash; final K key; //value和next都用 volatile 修饰,用于保证内存可见性和禁止指令重排序 volatile V value; //指向下一个节点 volatile HashEntry<K,V> next; HashEntry(int hash, K key, V value, HashEntry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } 构造函数ConcurrentHashMap 有五种构造函数,但是最终都会调用同一个构造函数,所以只需要搞明白这一个核心的构造函数就可以了。