ConcurrentHashMap和HashMap的思路是差不多的,但是因为它支持并发操作,所以要复杂一些。
数据结构
整个ConcurrentHashMap是由一个一个的Segment组成,Segment代表一个分段,一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构,当对HashEntry数组的数据进行修改的时候,必须首先获得与它对应的Segment锁。
成员变量
//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认加载因子(针对Segment数组中的某个Segment中的HashEntry数组扩容)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认Segment数组的大小,也成为并发量
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//一个Segment的HashEntry数组的最小容量
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//一个Segment的HashEntry数组的最大容量
static final int MAX_SEGMENTS = 1 << 16;
// 锁之前重试次数
static final int RETRIES_BEFORE_LOCK = 2;
构造方法
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;//segment的偏移
int ssize = 1;//segment的size = ssize * 2^sshift
//计算并行级别,保持并行级别是2的n次方
while (ssize < concurrencyLevel) {
//探讨默认情况下concurrencyLevel=16,sshift=4,ssize经过4此左移,和并行度相等=16
++sshift;
ssize <<= 1;
}
//下边这两个变量是为了put方法中的计算key对应Segment数组的索引
this.segmentShift = 32 - sshift;//-->默认为28
this.segmentMask = ssize - 1;//-->默认为15
//initialCapacity 是设置整个map初始的大小
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//这里根据initialCapacity 计算segment数组中的每个segment中的HashEntry数组可以分到的大小
//如initialCapacity =64,那么每个segment中的HashEntry数组就可以分到4个
int c = initialCapacity / ssize;
//当不能整除的时候,则让c+1
if (c * ssize < initialCapacity)
++c;
//默认MIN_SEGMENT_TABLE_CAPACITY=2.这个值也是有用的,因为这样的话,对于具体的HashEntry上,插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建Segment数组,并创建数组的第一个元素,segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//将s0写入segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
put过程分析
public V put(K key, V value) {
Segment<K,V> s;
//判断value是否为null,如果为null则抛出空指针异常
if (value == null)
throw new NullPointerException();
//计算key的hash值
int hash = hash(key);
//根据key的hash值计算出在Segment数组中的位置j
//hash值是32位的,默认情况下先无符号右移28位,剩下高四位,然后&15,还是hash的高四位
//也就是说j是hash的高4位的值,也就是对应的segment数组中的下标
int j = (hash >>> segmentShift) & segmentMask;
//判断该位置是否为null,
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//如果为null,初始化该位置segment[j],通过ensureSegment(j)
s = ensureSegment(j);
//调用Segment的put方法将数据插入到HashEntry中
//见下方
return s.put(key, hash, value, false);
}
Segment–put()
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//在往segment写之前,需要先获取该segment的独占锁
//获取到了直接返回null
//获取不到就会进入scanAndLockForPut()方法获取锁,初始化node,,具体我也没看懂
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//table是segment内部的数组,HashEntry类型的
HashEntry<K,V>[] tab = table;
//再利用hash,求应该放置的数组下标,和hashmap的一样
int index = (tab.length - 1) & hash;
//获取该位置的链表的表头,赋给first
HashEntry<K,V> first = entryAt(tab, index);
//一个死循环
//判断当前位置的链表是否为null,并针对两种情况具体操作
for (HashEntry<K,V> e = first;;) {
if (e != null) {
//如果当前链表中有元素
K k;
//判断当前key是否和当前链表上的节点的元素相等
//如果相等则直接覆盖并跳出循环
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
//如果不相等则指向下一个节点
e = e.next;
}
else {
//如果当前链表中没有元素
//判断node是否为null
if (node != null)
//如果不为null则将元素添加在链表的头节点,并指向当前链表的头结点
node.setNext(first);
else
//如果node为null则初始化node,并将value传入,next指向当前链表的头节点
node = new HashEntry<K,V>(hash, key, value, first);
//计数+1
int c = count + 1;
//判断是否需要扩容
//如果当前segment中的元素个数大于扩容阈值并且HashEntry数组的长度小于规定的map最大容量,则进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//具体后边说
rehash(node);
else
//如果没有达到扩容的条件,将node放到数组HashEntry数组的index位置
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//释放锁
unlock();
}
//返回旧值
return oldValue;
}
put中的关键操作
在初始化ConcurrentHashMap的时候,会初始化第一个分段segment[0],对于其他分段,当put的时候才会进行初始化,通过ensureSegment()方法
private Segment<K,V> ensureSegment(int k) {
//获取到当前的Segment数组
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]
//用来当做一个模板来初始化其他的segmengt
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
//初始化segment[k]内部的HashEntry数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//再次检查一次该分段是否被其他线程初始化了
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
//对于并发操作使用CAS控制
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
扩容方法:rehash()
扩容条件:put的时候,如果判断该值的插入会导致segment中的元素个数超过阈值,那么先会进行扩容,再插值。
该方法不需要考虑并发,因为到这里的时候,是持有该segment的独占锁
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];
//新的掩码,为容量-1
int sizeMask = newCapacity - 1;
//遍历数组,将原数组位置i处的链表拆分到新数组位置i和i+oldCapacity两个位置
for (int i = 0; i < oldCapacity ; i++) {
//e为链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
//如果e不为Null
HashEntry<K,V> next = e.next;
//计算应该放置在新数组中的位置
//假设原数组长度为16,e在oldTable[3]处,那么idx只可能是3或者3+16=19
//因为大多数HashEntry中的节点在扩容前后可以保持不变,rehash方法中会定位第一个后续所有节点在扩容后index都保持不变的节点,然后将这个节点之前的所有节点重排即可
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
newTable[idx] = e;
else { // 重复利用一些扩容后,位置不变的节点,这些节点在原先链表的尾部
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
//这个for循环就是找到第一个后续节点新的index不变的节点。
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
newTable[lastIdx] = lastRun;
// 第一个后续节点新index不变节点前的所有节点都需要重新创建分配
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
//将新来的node放到新数组中刚刚的两个链表之一的头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。
我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。
get过程分析
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
//计算key的hash
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//根据key的hash找到对应的segment的位置,并判断segment中的HashEntry数组是否为null
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//对segment中的数组的每个链表进行遍历
//这里第一次初始化通过getObjectVolatile获取HashEntry时,获取到的是主存中最新的数据
//但是在后续遍历过程中有可能被其他线程修改,从而导致这里返回的可能是过时的数据
//所以这里就是ConcurrentHahsMap的弱一致性的体现,containsKey方法也一样
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操作。
1、 put操作的线程安全性
- 添加节点到链表的操作时插入到表头的,所以如果这个时候get操作在链表的过程中已经到了中间是不会被影响的。另一个并发问题就是get操作在put之后,需要保证刚刚插入表头的节点被读取,这个依赖于setEntryAt方法中使用的UNSAFE.putOrderedObject.
- 扩容:扩容是新创建了数组,然后进行迁移数据,最后将newTable设置给属性table, 所以如果get操作此时也在进行,那么也没关系,如果get先行,那么就是在旧的table上做查询操作;而put先行,那么put操作的可见性保证就是table使用了volatile关键字.
2、 remove 操作的线程安全性
- 如果remove破坏的节点get操作已经过去了,那么这里不存在任何问题
- 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。