一、概述:
首先知道一些概念:
1、unsafe:类似C++手动管理内存的能力Unsafe类(本地类),可以直接管理内存的数据的一个类。具体看https://www.jianshu.com/p/db8dce09232d https://www.jianshu.com/p/2e5b92d0962e
2、CAS:java中的CAS一般是用Unsafe类的方法来实现的
3、lock和trylock:lock:阻塞锁 tryLock:尝试锁,非阻塞锁
4、ReentrantLock:可重入锁。
5、读取get不需加锁、修改加锁。
6、分段锁:1.7采用修改时只锁相应的segment,其他segment不受影响。
二、内部结构:
增加了Segment的概念(可以看成一个小hashtable),对每个segment上的数据进行修改时,则会对该segment进行加锁(Segment继承于ReentrantLock),也就是所谓的分段锁概念。
注意:Segment初始化后不能再扩容,而Segment内部的数组可以再扩容
不难看出,ConcurrentHashMap采用了二次hash的方式,第一次hash将key映射到对应的segment,而第二次hash则是映射到segment的不同桶(bucket)中。
为什么要用二次hash,主要原因是为了构造分离锁,使得对于map的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次hash带来的问题是整个hash的过程比hashmap单次hash要长,所以,如果不是并发情形,不要使用concurrentHashmap。
并发编程实践中,ConcurrentHashMap是一个经常被使用的数据结构,它的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响,无论对于Java并发编程的学习还是Java内存模型的理解,ConcurrentHashMap的设计以及源码都值得非常仔细的阅读与揣摩。
HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。
三、源码解析:
1、concurrentHashmap的数据结构
static final class Segment extends ReentrantLock implements Serializable {
transient volatile int count; //Segment中元素的数量(一个Segment中所有hashentry个数)
transient int modCount; //对table的大小造成影响的操作的数量(比如put或者remove操作)
transient int threshold; //阈值,Segment里面元素的数量超过这个值那么就会对Segment进行扩容
final float loadFactor; //负载因子,用于确定threshold
transient volatile HashEntry[] table; //链表数组,数组中的每一个元素代表了一个链表的头部
}
HashEntry:
static final class HashEntry {
final K key;
final int hash;
volatile V value;
final HashEntry next;
}
2、concurrentHashmap的构造函数
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {//整个map的初始大小、负载因子、segment的个数
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) {
//假设此时传进来的segment是初始大小16,此时ssshift等于4(16=2的4次方),ssize为16
++sshift;
ssize <<= 1; //向左移1位即乘于2
}
// 默认值,concurrencyLevel 为 16,sshift 为 4
// 那么计算出 segmentShift 为 28,segmentMask 为 15,后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// initialCapacity 是设置整个 map 初始的大小,
// 这里根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
// 如 initialCapacity 为 64,那么每个 Segment 或称之为"槽"可以分到 4 个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity) //如为小数则再进行++
++c;
// 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容,扩容两倍
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建 Segment 数组,
// 并创建数组的第一个元素 segment[0]
Segment s0 =
new Segment(loadFactor, (int)(cap * loadFactor),
(HashEntry[])new HashEntry[cap]);
Segment[] ss = (Segment[])new Segment[ssize];
// 往数组写入 segment[0],此时的写入操作则使用了unsafe,可以保证是对机器内存中的数据直接进行修改,不会经过中间缓存等。ss表示要插入的数组,SBASE表示插入数组的第一个元素的索引,s0表示要插入的元素。
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
- initialCapacity:整个map的初始大小(用初始大小可以计算每个segment可以分到几个数组)
- Segment 个数默认为 16,不可以扩容。
- Segment[i] 的默认大小为 2(每个Segment对应的数组个数),负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容。
- 只初始化了 segment[0],其他位置仍然是 null。(这里会根据传进来的参数先初始化一个segment[0]即第一个,后面的segment可以不用再去计算,复制第一个的大小等参数即可)
- 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,为移位数和掩码。(每个int是32位, segmentShift即取高位至低为的非1连续位数的长度:例00000000 00000000 00000000 00000001:此时高位到低位有31个连续的非1,则长度为31。segmentMask即为length-1,为了计算每个entry属于哪个segment(需要和hash&运算)。和之前计算hashmap的数组下标一样的)
其中,concurrencyLevel 一经指定,不可改变,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。
总结构造函数:传入int initialCapacity, float loadFactor, int concurrencyLevel //整个map的初始大小、负载因子、segment的个数
此时如不指定,concurrencyLevel默认为16,initialCapacity默认为32,每个segment对应32/16=2个数组。
1、通过concurrencyLevel计算出两个中间参数sshift 、ssize。并利用这两个参数计算出segmentShift = 32 - sshift和segmentMask = ssize - 1。
2、concurrencyLevel和initialCapacity计算出c(每个segemnt分配的数组个数),c和默认值2比较大于则每次扩容两倍直至不小于c。
3、创建segment数组,并初始化第一个元素segment[0]。注意:这里的插入使用了UNSAFE.putOrderedObject(ss, SBASE, s0);
可以保证
3、put方法:两个put,一个是找到对应的segment并初始化,一个是将值插入对应的segment中
3.1 第一个put
public V put(K key, V value) {
Segment 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 位,也就是槽的数组下标(这里的segmentShift是之前计算好的)
int j = (hash >>> segmentShift) & segmentMask;
// 初始化的时候只初始化了 segment[0],其他位置还是 null,
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j); //初始化槽
// 3. 插入新值到 槽 s 中
return s.put(key, hash, value, false); //开始插入
}
第一个put(k,v)
1、计算key的hash值
2、根据hash值找到对应的segment,然后初始化该segment(用ensureSegment方法后面会讲到)
3、将值插入到初始化的segment中(用第二个put方法)
3.2、第二个put:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//1、 在往该 segment 写入前,需要先获取该 segment 的独占锁,获取失败尝试获取自旋锁
//如链表中有相同key的元素则返回node为null,否则node为传进来的链表元素entry
HashEntry node = tryLock() ? null :scanAndLockForPut(key, hash, value);
//先用tryLock非阻塞尝试锁获取一下,如获取失败则调用scanAndLockForPut(key, hash, value)方法获取自旋锁。下面的代码则是在获取到锁的情况下的
V oldValue;
try { 2、在前面初始化好了的segment中找到该元素对应的数组链表(含多个链表)
// segment 内部的数组,即一个segment
HashEntry[] tab = table;
// 利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry first = entryAt(tab, index);
//3、循环该链表链表
for (HashEntry e = first;;) {
if (e != null) { //3.1、当链表不为null,此时向下循环直到链表元素为null,此时则跳到else中(即3.2)
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {//key相同时,则将旧的v替换掉并跳出
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else { //3.2,此时e为null,node的值要看获取锁后,则要看tryLock() ? null :
scanAndLockForPut(key, hash, value)的结果
// node 是不是 null,这个要看获取锁的过程。
// 如果不为 null,那就直接将它设置为链表表头;如果是 null,初始化并设置为链表表头。(node为null则是立即获取到锁的,所以没进去scanAndLockForPut(key, hash, value)方法,此时则初始化新元素,不为null则是进去过(注意scanAndLockForPut方法的返回值有null和非null,只是null在if模块就完成了并break),则已经将新元素初始化了,所以直接设置为链表头即可)
if (node != null)
node.setNext(first);
else
node = new HashEntry(hash, key, value, first);
int c = count + 1;
// 4、如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 将新的结点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {5、解锁
// 解锁
unlock();
}
return oldValue;
}
从上面代码可以看出,将元素插入对应的segment需要以下过程
1、在往该 segment 写入前,需要先获取该 segment 的独占锁,获取失败尝试获取自旋锁
tryLock() ? null :scanAndLockForPut(key, hash, value);
2、在前面初始化好了的segment中找到该元素对应的数组链表
3、循环该链表链表
3.1、当链表不为null,此时向下循环直到链表元素为null,此时则跳到else中(即3.2),如果发现key相同的则覆盖其value并break
3.2,此时e为null,node的值要看获取锁后,则要看tryLock() ? null :scanAndLockForPut(key, hash, value)的结果
如果不为 null,那就直接将它设置为链表表头;如果是 null,初始化并设置为链表表头。
(这里的null是立刻获取到锁,不为null则是进去scanAndLockForPut后获取到锁出来了。当时此时要注意:进去该方法出来后node值有两中情况,一种是null:即该链表不为空且有和要传进去的元素相同key的元素;另一种是传进去的参数初始化后的entry。其中第一种情况在for循环的if代码块中就进行判断并替换了最后break了。也就是说第一种情况已经被if处理了。后面的else只需要处理第二种情况和即可获取锁的情况。)
4、计算数组的个数,如果超过了该 segment 的阈值,这个 segment 需要扩容rehash
5、解锁
3.3、第一个put中的初始化segment的方法ensureSegment()
private Segment ensureSegment(int k) {
final Segment[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment seg;
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k],这就是之前要初始化 segment[0] 的原因。
// 为什么要用 " 当前 ",因为 segment[0] 可能早就扩容过了。
Segment proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化 segment[k] 内部的数组
HashEntry[] tab = (HashEntry[])new HashEntry[cap];
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化。getObjectVolatile获取时即保证原子性和可见性
Segment s = new Segment(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
//这里使用了所谓的自旋锁,可以隔段时间就判断是否为null,有一种情况是其他线程改完,然后该线程发现改了就返回f,但其他线程又将该值又删除了则又变为null,此时刚好在判断while中的条件,则可以再进行循环一次。
while ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
该方法主要对segment[k]进行初始化(一些初始化大小、负载因子、阈值等等的设置)
注意:
ensureSegment()
可能在并发环境下被调用,但并未使用锁来控制竞争,而是使用了 Unsafe 对象的getObjectVolatile()
提供的原子读语义结合 CAS 来确保 Segment 创建的原子性
3.4、第二个put的获取锁方法:scanAndLockForPut(key, hash, value)
private HashEntry scanAndLockForPut(K key, int hash, V value) {
HashEntry first = entryForHash(this, hash);
HashEntry e = first;
HashEntry node = null;
int retries = -1; // negative while locating node
//1、 循环获取锁,尝试自旋锁。如链表中有相同key的元素则返回node为null,否则node为传进来的链表元素entry
//注意:对于有相同的key元素,即返回node为null,在put的方法有另一层判断代码(有进行值的覆盖然后break)
//所以这里可以看成相当于node返回新的entry元素。
while (!tryLock()) {
HashEntry f; // to recheck first below
if (retries < 0) {
//1.1、循环链表,直到e=null,将传进来的参数创建一个entry链表元素赋给node.
//1.2、如果有相同key的/经过1.1的都跳转到2(即else if部分)
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素(e=null)
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry(hash, key, value, null);
retries = 0;//此时跳到其他elsse if
}
else if (key.equals(e.key))//链表不为null,链表有相同key的元素,
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核 1 次多核 64 次),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
//3、尝试自旋锁循环指定次数后还没获取到锁,则不再循环,此时则直接加上阻塞锁lock
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
//2、此时需要判断(f = entryForHash(this, hash)) != first,即如果本线程还没获取锁其他线程进入该链并插入了新的元素,此时则要再去重复1.1和1.2的步骤
else if ((retries & 1) == 0 &&
// 进入这里,说明有新的元素进到了链表,并且成为了新的表头
// 这边的策略是,重新执行 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed//将第一个元素赋给e重新循环
retries = -1;
}
}
return node;
}
3.5、第二个put的扩容方法rehash(node)
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry node) {
HashEntry[] oldTable = table;
int oldCapacity = oldTable.length;
//1、 扩容2 倍和设置新的阈值然后创建新数组
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry[] newTable =
(HashEntry[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;
// 2、遍历原数组,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry e = oldTable[i];
if (e != null) {
HashEntry next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;
// for 循环找到一个 lastRun 结点,这个结点之后的所有元素是将要放到一起的
for (HashEntry last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有结点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的结点,
// 这些结点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry n = newTable[k];
newTable[k] = new HashEntry(h, p.key, v, n);
}
}
}
}
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
扩容方法:主要还是用hash&(length-1)的方法。
其中第一个for对元素转移进行了一点优化。即用一个变量记录一个链表元素,循环链表,在链表循环完后,如果最后有几个的key重计算后还为相同key,则此时直接这几个元素中的第一个元素转移到新数组中即可(其他的不用动,因为其他都是跟在第一个元素后面的),但该优化其实没有提升太大的效率。(即a->b->c->d,此时进行转移发现重算位置时发现b、c、d的位置都一样,则可将b作为新数组的链表头直接接入即可(c和d不用做改变,c还是指向b,d还是指向c))
下面对于整个put方法进行总结:
4、get方法:
public V get(Object key) {
Segment s; // manually integrate access methods to reduce overhead
HashEntry[] tab;
// 1. hash 值
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据 hash 找到对应的 segment
if ((s = (Segment)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment 内部数组相应位置的链表,遍历
for (HashEntry e = (HashEntry) 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;
}
- get 方法的流程。
- 计算 hash 值,找到 segment 数组中的具体位置,获使用的槽。
- 槽中也是一个数组,根据 hash 找到数组中具体的位置。
- 顺着链表进行查找即可。
- 因为 get 过程中没有加锁,因此需要考虑并发问题
问题:get没有加锁,那是怎么避免读取脏数据的?
entry链表元素的value是用volatile修饰的(数组用volatile修饰没有关系),而且get使用了unsafe的getObjectVolatile等可见性操作方法。
- 数组用volatile修饰主要是保证在数组扩容的时候保证可见性。
4、需要锁住整个segment[]数组的方法:size()
要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。
- Segment 里的全局变量 count 是一个 volatile 变量。
ConcurrentHashMap 的做法是先尝试 2 次通过不锁住 Segment 的方式统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有 Segment 的大小。
- 使用 modCount 变量,在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行加 1,在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。
如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?前面我们提到了一个Segment中的有一个modCount变量,代表的是对Segment中元素的数量造成影响的操作的次数,这个值只增不减,size操作就是遍历了两次Segment,每次记录Segment的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回,如果不相同,则把这个过程再重复做一次,如果再不相同,则就需要将所有的Segment都锁住,然后一个一个遍历了,具体的实现大家可以看ConcurrentHashMap的源码,这里就不贴了。