并发容器
CopyOnWriteArrayList
并发包中有关List接口的并发容器只有CopyOnWriteArrayList,CopyOnWriteArrayList是线程安全的ArrayList。底层原理使用了写时复制策略。通俗的理解是当我们往容器添加元素的时候,不直接往当前容器添加,而是先将当前容器复制出一个新的容器,然后往新的容器里添加元素,添加完元素之后,再用新的容器替代旧容器。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。CopyOnWrite容器使用了读写分离的思想。
源码分析
首先我们可以看到,CopyOnWriteArrayList类中有一个Object类型的数组array来存放元素,并且拥有ReentrantLock独占锁来保证同时只有一个线程能够对array进行修改。
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
继续向下看构造函数
无参构造函数在内部创建了一个大小为0的数组作为array的初始值。
有参的构造函数则分别是将传入的集合、数组复制到array中。
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
//将传入的集合复制到array中
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
//如果集合是CopyOnWriteArrayList类型,则直接引用
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else {
//否则,先将集合转换为数组,然后复制
elements = c.toArray();
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
setArray(elements);
}
//将传入的数组复制到array中
public CopyOnWriteArrayList(E[] toCopyIn) {
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
接下来我们来看下是如何添加元素的
public boolean add(E e) {
//获取独占锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
//获取array
Object[] elements = getArray();
//获取原数组的长度
int len = elements.length;
//复制array到新数组,使新数组的大小比原数组大1
Object[] newElements = Arrays.copyOf(elements, len + 1);
//将元素添加到最后
newElements[len] = e;
//使用新数组代替当前的数组
setArray(newElements);
return true;
} finally {
//释放独占锁
lock.unlock();
}
}
然后我们来看下获取方法,获取方法会出现弱一致性的问题。如下:
看代码,当我们调用获取指定位置元素方法时,首先会获取数组,然后通过下标去访问指定位置的元素。这是两步操作,但是整个过程中并没有加锁同步,这时可能会存在这样的问题。例如当前数组中有1,2,3三个元素,线程A先获取到了数组,然后此时另外一个线程B进行remove操作,要删除元素1,remove操作首先会获取独占锁,然后进行写时复制操作,然后在复制的数组中删除元素1,之后替代当前的数组。这时线程A开始获取元素,使用的还是旧数组。所以,虽然线程B已经删除了元素,但是线程A还是会返回之前的元素,这就是写时复制策略产生的弱一致性问题。
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
设置方法。
public E set(int index, E element) {
//获取独占锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
//获取array
Object[] elements = getArray();
//获取指定元素
E oldValue = get(elements, index);
//如果指定位置元素的值和要设置的值不一样,则创建数组复制元素,设置指定位置的值
//否则如果指定位置元素的值和要设置的值一样,为了保证volatile语义,还是需要重新设置数组
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
//释放锁
lock.unlock();
}
}
删除元素
public E remove(int index) {
//获取独占锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
//获取array
Object[] elements = getArray();
int len = elements.length;
//获取指定位置元素
E oldValue = get(elements, index);
int numMoved = len - index - 1;
//如果要删除的元素是最后一个元素,则重新设置一个复制的array,容量-1
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
//将剩余的元素分两次复制到新数组
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
弱一致性的迭代器
我们知道遍历列表元素时可以使用迭代器。我们看到调用CopyOnWriteArrayList的iterator方法获取迭代器时实际上会返回一个COWIterator迭代器。我们看到COWIterator的构造函数接收两个参数,一个是array数组,一个是遍历list时的下标。
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
迭代器接收到的数组为什么说是快照呢?因为如果在遍历期间,有其他线程对该List进行了修改,那么迭代器和List里面的array就不再是同一个对象,所以说迭代器里面的数组就是list的快照。当其他线程对list进行增删改时,迭代器内是不可见的,因为它们操作的是两个不同的数组,这就是迭代器的弱一致性。
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public boolean hasPrevious() {
return cursor > 0;
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
@SuppressWarnings("unchecked")
public E previous() {
if (! hasPrevious())
throw new NoSuchElementException();
return (E) snapshot[--cursor];
}
......
}
我们通过例子来演示一下多线程环境下迭代器的弱一致性。
public class CopyList {
private static volatile CopyOnWriteArrayList<String> copyOnWriteArrayList=new CopyOnWriteArrayList<>();
public static void main(String[] args) throws InterruptedException {
copyOnWriteArrayList.add("one");
copyOnWriteArrayList.add("two");
copyOnWriteArrayList.add("three");
copyOnWriteArrayList.add("four");
copyOnWriteArrayList.add("five");
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
copyOnWriteArrayList.set(0,"six");
copyOnWriteArrayList.set(1,"seven");
copyOnWriteArrayList.set(2,"eight");
copyOnWriteArrayList.set(3,"nine");
copyOnWriteArrayList.set(4,"ten");
}
});
//先获取迭代器
Iterator<String> iterator = copyOnWriteArrayList.iterator();
//开启线程进行修改
thread.start();
thread.join();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
CopyOnWriteArrayList的优缺点
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以不能用于实时读的场景。
CopyOnWriteArraySet内部实现就是创建了一个CopyOnWriteArrayList,然后调用CopyOnWriteArrayList的方法,具体的可以自己去看,这里就不再细说。
ConcurrentHashMap
线程不安全的HashMap
我们知道HashMap是线程不安全的,但是在JDK1.7和1.8中,HashMap不安全的原因却有些区别。我们分别来了解一下,对我们分析问题的能力会有很大的帮助。
在JDK1.7中HashMap线程不安全主要是发生在多线程进行put操作时,容器扩容重哈希过程中发生的问题,我们来看下关键的代码。
我们知道,在进行put操作时,如果HashMap容量不足,就会进行resize扩容操作。扩容操作会创建一个新的数组,然后将原来的元素经过哈希计算再放置到新数组里指定的位置。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建新数组
Entry[] newTable = new Entry[newCapacity];
//将原本数组里的元素经过哈希运算放入到新数组
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//新数组替换旧数组
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//循环原本的数组
for (Entry<K,V> e : table) {
//如果链表头不为空
while(null != e) {
//获取链表中该元素的下一个元素
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算索引
int i = indexFor(e.hash, newCapacity);
//让e.next指向新表的元素
//如果newTable[i]为空,则e.next为空,将e直接插入即可
//如果不为空,那就让e.next指向原本存在的链表,然后将e插入在链表的头部,这其实是个头插入的过程
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
我们发现在将旧链表中的数据复制到新数组中时,采用的是队头插入的方式。
为什么采用这种方式呢?我想可能是因为如果选择队尾插入,那么每次插入都要进行遍历链表,找最后一个元素后插入,对性能会造成影响。
作者的本意是好的,但是在多线程情况下,问题也在这里出现。
我们先来看一下单线程情况下,扩容重哈希的过程。
为了简化问题方便理解,假设哈希算法是简单的key对数组长度取模。
初始情况:
此时有线程执行了put操作,引起了扩容,执行重哈希过程
循环到数组1的位置时,对该位置的链表进行复制到新数组的操作
第一次循环,e为key(3)的元素,next为key(7)的元素,然后把e放在新数组索引为3的位置,e.next为null
接着把key(7)赋值给e,进行第二次循环,第二次循环中,这时e为key(7)的元素,next为key(5)的元素,然后把e放在新数组索引为3的位置,进行头插入,让e.next指向原本位置上的链表
然后把key(5)赋值给e,进行第三次循环,第三次循环中,e为key(5)的元素,next为null,然后把e放在新数组索引为1的位置,把next赋值给e,e为null,结束循环
在单线程情况下,这个过程没有任何问题,但是在多线程情况下,就有问题出现了。
我们来看下多线程下的情况。
初始情况和上面一致。
此时有线程A和线程B同时执行put操作,同时引起了扩容,执行该操作。
线程A开始执行,在刚执行完Entry<K,V> next = e.next这句代码之后,线程挂起,此时情况如下,e的值为key(3),next为key(7)。
然后线程B开始执行,并且顺利执行完成,放出执行权,这时候线程B情况如下
千万注意,现在的Map是已经被线程B更新过的。
接下来线程A从挂起处继续执行,别忘了这时候的Entry对象已经被线程B更新完毕的,它的next指向都是新的。
继续执行,e仍然是key(3),next为key(7)。
将next赋值给e。
接下来执行第二次循环,此时e为key(7),这时候别忘了key(7)后面的元素是指向key(3)的,所以next为key(3)
将key(7)插入头部,将key(3)赋值给e,接下来继续执行,e为key(3),next为null,这时key(3)插入,指向key(7),而key(7)的next为key(3),就形成了死循环,next为null,跳出循环。
这样当我们进行get操作时,例如get(11),当落在这个桶时就会造成死循环而让程序崩溃。
并且多线程扩容还会造成数据丢失的问题,我们继续来分析一下
初始情况如下:
此时线程A和线程B同时执行扩容操作,同样当线程A执行完Entry<K,V> next = e.next后挂起。情况如下:
然后线程B成功执行完,情况如下:
然后线程A被激活,e为key(7),next为key(5),插入e
将next赋值给e,继续执行第二次循环,此时e为key(5),next为null,就造成了数据丢失
造成上面两个问题很大的一个原因就是因为头插入的问题使得链表反转,然后另个线程获取到以前的元素,因为反转了,所以后面的元素又链接到了自己,造成了死循环和数据丢失的问题。
所以在JDK1.8中通过增加tail指针,使用尾插入,避免了死循环问题(让数据直接插入到队尾),并且这样又避免了尾部遍历。
但是JDK1.8以后修复这个问题之后,为什么还是线程不安全呢?我们来看下JDK1.8中put的关键代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//判断是否存在哈希冲突
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我们可以假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完判断是否出现哈希碰撞代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
ConcurrentHashMap
因为HashMap的线程不安全,HashTable效率又太过低下。JDK推出了ConcurrentHashMap,它在保证线程安全的情况下,提高了效率。JDK1.7和JDK1.8中,ConcurrentHashMap的实现原理改变了很多,我们分别来讲解一下。
ConcurrentHashMap1.7源码分析
我们知道HashTable效率低下的原因是因为所有访问HashTable的线程都必须竞争同一把锁造成的,那么我们是不是可以在容器中创建多把锁,每把锁用于锁住容器中的一部分数据,这样当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段,每段数据配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁。
ConcurrentHashMap内部主要结构由Segment数组和HashEntry组成。Segment继承自ReentrantLock是可重入锁,HashEntry用于存储键值对数据。一个ConcurrentHashMap里面包含一个Segment数组,每个Segment里面包含着一个HashEntry数组。当对HashEntry数组的数据进行修改时,必须首先获得其对应的Segment锁。ConcurrentHashMap结构图如下。
我们来看下Segment和HashEntry的结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//HashEntry数组
transient volatile HashEntry<K,V>[] table;
//Segment中元素的数量
transient int count;
//对table的大小造成影响的操作的数量(比如put或者remove操作)
transient int modCount;
//阈值,Segment里面元素的数量超过这个值那么就会对Segment进行扩容
transient int threshold;
//负载因子,用于确定threshold
final float loadFactor;
......
}
static final class HashEntry<K,V> {
final int hash;
final K key;
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;
}
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}
......
}
首先我们来看下ConcurrentHashMap里面重要的的成员变量
//默认ConcurrentHashMap的大小
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认支持的最大并发数
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//ConcurrentHashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//segment分段锁的初始容量也是最小容量(即segment中HashEntry的初始容量)
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大segment数(segments数组的最大长度)
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//重试加锁次数
static final int RETRIES_BEFORE_LOCK = 2;
//分段锁的掩码,用来计算key所在的segment在segments的数组下标
final int segmentMask;
//分段锁偏移量,用来查找segment在内存中的位置
final int segmentShift;
//segment数组
final Segment<K,V>[] segments;
接下来我们来看下ConcurrentHashMap的构造函数,看看它是如何初始化的。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//保证最大并发不超过MAX_SEGMENTS(65536)
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// sshift相当于ssize从1向左移的次数
int sshift = 0;
//ssize是用来决定segment数组长度的,初始为1,但是由于concurrencyLevel默认就为16,所以ssize经过循环位运算后也为16,此时 sshift = 4
//因为ssize用位于运算来计算(ssize <<=1),所以Segment的大小取值都是以2的N次方
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 段偏移量,默认值情况下此时segmentShift = 28
this.segmentShift = 32 - sshift;
// 散列算法的掩码,默认值情况下segmentMask = 15
this.segmentMask = ssize - 1;
//ConcurrentHashMap初始容量不超过MAXIMUM_CAPACITY(1 << 30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//根据ConcurrentHashMap总容量initialCapacity除以Segments[]数组的长度得到单个分段锁segment中HashEntry[]的大小
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对象s0
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
// 创建ssize长度的Segment数组
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;
}
默认情况下concurrencyLevel=16,这样就会导致segments的数组长度也是16,每个Segment里面的HashEntry数组的大小为2。Segment和HashEntry的大小都为2的指数,这样做的目的是方便采用移位操作来进行hash,加快hash的过程。
初始化时concurrencyLevel一经传入,就不可改变了,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中HashEntry的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。
注意一下两个变量segmentShift和segmentMask,这两个变量在后面定位时将会起到很大的作用,假设构造函数确定了Segment的数量是2的n次方,那么segmentShift就等于32减去n,而segmentMask就等于2的n次方减一。
初始化完后,我们来看下它是如何放入元素的
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
//计算key的hash值
int hash = hash(key);
//通过hash值、segmentShift、segmentMask定位到具体的Segment
int j = (hash >>> segmentShift) & segmentMask;
// 获取Segment,如果Segment不存在,则调用ensureSegment方法
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
//创建自旋插入并返回
s = ensureSegment(j);
//调用Segment的put方法将键值对放入到Segment中
return s.put(key, hash, value, false);
}
ensureSegment方法是当Segment不存在时创建并返回的方法。根据k的hash值,获取segment,如何获取不到则就初始化一个和Segment[0]一样大小的segment。并通过CAS操作,初始化到Segments[]中。
@SuppressWarnings("unchecked")
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;
// 该索引处还没有Segment
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//初始化一个segment等于Segments[0],ss[0]在构造函数中已经初始化过了
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
//然后就是获取Segments[0]中HashEntry数组的数据
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
//初始化一个HashEntry数组,大小和Segments[0]中的HashEntry一样。
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//再次检查
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//创建Segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//自旋插入,成功则退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
//返回Segment
return seg;
}
两个参数的put方法最终调用了下面方法来进行插入,我们来看下流程
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut()方法自旋获取锁,
//超过自旋最大次数时,就将线程放入到Lock锁的队列中
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//获取Segment对应的HashEntry[ ]数组。
HashEntry<K,V>[] tab = table;
//计算对应HashEntry数组的下标
int index = (tab.length - 1) & hash;
//定位到HashEntry的某一个头结点(数组+链表结构)
HashEntry<K,V> first = entryAt(tab, index);
//开始遍历该节点的链表
for (HashEntry<K,V> e = first;;) {
//如果头节点不为null
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
//是否替换value,默认替换(有个putIfAbsent(key,value)就是不替换)
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
//如果链表为空
else {
if (node != null)
//将新节点插入到链表作为链表头。
node.setNext(first);
else
//根据key和value 创建结点并插入链表。
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//判断元素个数是否超过了阈值或者segment中数组的长度超过了MAXIMUM_CAPACITY,如果满足条件则rehash扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
//不需要扩容时,将node放到数组(HashEntry[])中对应的位置
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//最后释放锁。
unlock();
}
return oldValue;
}
我们来看下scanAndLockForPut方法,看它是如何获取锁的。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//获取k所在的segment中的HashEntry的头节点
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
//尝试获取k所在segment的锁。成功就直接返回、失败进入while循环进行自旋尝试获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
//链表的头结点为null
if (e == null) {
if (node == null)
//创建一个HashEntry,设置重试次数为0
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//找到相同key的节点,设置segment重试次数为0
else if (key.equals(e.key))
retries = 0;
//指向下一个节点
else
e = e.next;
}
//超过最大重试次数就将当前操作放入到Lock的队列中
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
put操作搞清楚以后,接着来看下get方法如何取元素
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
//首先计算出segment数组的下标
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 根据下标找到segment
// 然后(tab.length - 1) & h) 得到对应HashEntry数组的下标
// 遍历链表
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;
}
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值,因此get方法整个过程都不需要加锁,非常高效。
存取都看过后,我们来看下如何删除元素
public V remove(Object key) {
int hash = hash(key);
//根据key的hash值获取Segment
Segment<K,V> s = segmentForHash(hash);
//Segment为null返回null,否则调用remove方法删除
return s == null ? null : s.remove(key, hash, null);
}
final V remove(Object key, int hash, Object value) {
//尝试获取segment锁,失败自旋获取
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
//获取Segment的HashEntry数组
HashEntry<K,V>[] tab = table;
//获取key所在的下标,然后获取头结点
int index = (tab.length - 1) & hash;
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
//如果头节点不为null
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
//判断当前节点e是不是要删除的数据
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
//如果没有传value,或者value相同
if (value == null || value == v || value.equals(v)) {
//如果要删除节点的前一个节点为null,则说明它是头节点,则直接将头节点后面的节点赋值给头节点即可
if (pred == null)
setEntryAt(tab, index, next);
else
//如果不是头节点,直接将当前节点的前一个节点的next设置成当前节点的下一个节点
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
//赋值遍历
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
最后再来看一下size方法
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
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 (;;) {
//判断retries是否等于RETRIES_BEFORE_LOCK(值为2)
//也就是默认有三次的机会,是不加锁来求size的
//如果超过三次,则会给每一个Segment上锁来计算size
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
//遍历Segments[]数组获取里面的每一个segment,然后对modCount进行求和
//这个for嵌套在for(;;)中,默认会执行两次,如果两次值相同,就返回
//如果两次值不同,就进入到上面的if中,进行加锁。之后在进行求和
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;
}
size方法的实现比较的别出心裁,首先有个死循环,刚进去要判断当前重试的次数是否等于2,如果不是,则循环遍历Segment数组取出每个Segment数组中的元素数量进行累加,循环两次后,则判断两次累加值是否相同,相同则跳出循环,不同就要强制给每个Segment上锁,然后再次进行计算。
ConcurrentHashMap1.8源码分析
JDK1.8中的实现ConcurrentHashMap摒弃了Segment的概念,而是直接用Node数组+链表(红黑树)的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个结构就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。结构如下:
我们先来看下类中的基本属性
//表的最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认表的大小
private static final int DEFAULT_CAPACITY = 16;
//最大数组大小
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//默认并发数,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转红黑树阀值,大于8时链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 由红黑树转化为链表的阈值,小于等于6
static final int UNTREEIFY_THRESHOLD = 6;
// 转化为红黑树的表的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 每次进行转移的最小值
private static final int MIN_TRANSFER_STRIDE = 16;
// 生成sizeCtl所使用的bit位数
private static int RESIZE_STAMP_BITS = 16;
// 进行扩容时所允许的最大线程数,ConcurrentHashMap扩容是可以多个线程一起协作的
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
// 记录sizeCtl中的大小所需要进行的偏移位数
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// 一系列的标识
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
// 获取可用的CPU个数
static final int NCPU = Runtime.getRuntime().availableProcessors();
// 进行序列化的属性
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("segments", Segment[].class),
new ObjectStreamField("segmentMask", Integer.TYPE),
new ObjectStreamField("segmentShift", Integer.TYPE)
};
// 表,存放Node的数组,第一次插入时才进行初始化,大小是2的幂
transient volatile Node<K,V>[] table;
// 下一个表,扩容时使用
private transient volatile Node<K,V>[] nextTable;
// 基本计数器值,通过CAS更新
private transient volatile long baseCount;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
*当为0时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小*/
private transient volatile int sizeCtl;
// 扩容下另一个表的索引
private transient volatile int transferIndex;
//调整大小或创建计数器时使用的自旋锁(通过CAS锁定)
private transient volatile int cellsBusy;
//baseCount和counterCells:用来记录当前ConcurrentHashMap存在多少个元素使用的,在进行增删链表节点时,默认是更新baseCount的值即可,
//如果同时存在多个线程并发进行对链表节点的增删操作,则放弃更新baseCount,而是counterCells数组中添加一个CounterCell,之后在计算size的时候,累加baseCount和遍历并累加counterCells。
private transient volatile CounterCell[] counterCells;
//视图
private transient KeySetView<K,V> keySet;
private transient ValuesView<K,V> values;
private transient EntrySetView<K,V> entrySet;
Node是构建ConcurrentHashMap的重要结构,用于存储键值对,接下来我们来看一下Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
//禁止更新value
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
//用于map中的get()方法
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
Node数据结构很简单,从上可知,就是一个链表,但是只允许对数据进行查找,不允许进行修改。
TreeNode继承自Node,但是数据结构换成了二叉树结构,它是红黑树的数据的存储结构,用于红黑树中存储数据,当链表的节点数大于8时会转换成红黑树的结构,他就是通过TreeNode作为存储结构代替Node来转换成黑红树。
static final class TreeNode<K,V> extends Node<K,V> {
//树形结构的属性定义
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
//标志红黑树的红节点
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
Node<K,V> find(int h, Object k) {
return findTreeNode(h, k, null);
}
//根据key查找 从根节点开始找出相应的TreeNode,
final TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
if (k != null) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk; TreeNode<K,V> q;
TreeNode<K,V> pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
接下来,我们从构造函数开始,先来看下ConcurrentHashMap的无参构造函数
public ConcurrentHashMap() {
}
我们可以发现,它的无参构造函数是没有做任何事情的。其实它会在put操作时进行初始化。
我们来看下put方法,put方法比较复杂
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//计算key的hash值
int hash = spread(key.hashCode());
//记录链表的长度,后面根据此值判断是否需要转换为红黑树
int binCount = 0;
//对table进行遍历
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果table为null,开始初始化,初始化是在这里完成的
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//获取i位置的头节点,如果i位置为null的话,说明该位置还不存在链表,就使用CAS操作插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果头节点的hash=-1(MOVED),表示当前有线程在扩容数组,这个节点会成为ForwardingNode类型
//hash值为-1,数组在扩容时,会将正在迁移数据原数组的链表或红黑树的头结点设置为
//ForwardingNode类型,表示当前链表或红黑树已经前已完成,也就是说当前正在进行数据迁移
//,如果迁移完毕,会将新数组覆盖掉老数组,也就不会出现ForwardingNode类型的节点
else if ((fh = f.hash) == MOVED)
//正在进行扩容,帮助移动数据
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//上面的条件都不符合,说明存在hash冲突,则加锁锁住链表或者红黑树的头结点
//使用synchronized同步锁的原因:因为如果该key对应的节点所在的链表已经存在的情况下
//可以通过UNSAFE的tabAt方法基于volatile获取到该链表最新的头节点,但是需要通过遍历该链表来判断该节点是否存在
//如果不使用synchronized对链表头结点进行加锁,则在遍历过程中,其他线程可能会添加这个节点,导致重复添加的并发问题。
//故通过synchronized锁住链表头结点的方式,保证任何时候只存在一个线程对该链表进行更新操作。
synchronized (f) {
if (tabAt(tab, i) == f) {
//表示是链表结构
if (fh >= 0)
//计数
binCount = 1;
//遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果key相同则覆盖原值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//插入链表尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果是红黑树结构
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//红黑树插入
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//如果长度大于8,就要将链表转换为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//统计size,检查是否需要扩容
addCount(1L, binCount);
return null;
}
当table为null时,会进行初始化操作,我们来看下初始化的方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//如果table为空,则进入开始初始化
while ((tab = table) == null || tab.length == 0) {
//sizeCtl<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) {
//sc如果大于0,初始化的数组大小即为sizeCtl,否则为16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//创建Node[]数组,初始化
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//记录下次扩容的大小
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
put方法中,判断头节点的hash值是否等于MOVED(-1),如果是,则会调用helpTransfer方法来帮助移动数据,我们来看下该方法,该方法的目的是调用多个线程帮助扩容,提高效率。而不是只有单个线程进行。
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//判断tab是否等于null并且判断头结点是否是ForwardingNode类型
if (tab != null && (f instanceof ForwardingNode) &&
//新的table nextTab已经存在前提下才能帮助扩容
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//调用方法帮助数据迁移到新table
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
我们来看下transfer迁移方法,该方法很长并且有点复杂
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//stride 在单核下直接等于n,多核模式下为 (n>>>3)/NCPU,最小值是 16
//stride 可以理解为"步长",有n个位置是需要进行迁移的,
//将这n个任务分为多个任务包,每个任务包有stride个任务
// 每核处理的量小于16,则强制赋值16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//nextTab为扩容后的新数组
//如果 nextTab 为 null,先进行一次初始化
//第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
//之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//初始化nextTab,创建一个新的Node[],长度为现有数组长度的2倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
//赋值给ConcurrentHashMap的成员变量nextTable
nextTable = nextTab;
//transferIndex是ConcurrentHashMap成员变量,用于控制迁移的位置
transferIndex = n;
}
int nextn = nextTab.length;
//ForwardingNode 翻译过来就是正在被迁移的 Node
//这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED(-1)
//后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
//就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
//所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//advance指的是已经做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//i为索引,bound为边界
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//advance=true表示可以进行下一个位置的迁移
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
//这里transferIndex一旦小于等于0,说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//通过CAS设置transferIndex的值
//nextBound = (nextIndex > stride ? nextIndex - stride : 0),计算下一次迁移的边界
//未迁移的数组长度是否大于步长,大于则下一次迁移的边界等其差值,否则这次就能迁移完毕,下一次迁移的边界为0
//transferIndex=0是表示迁移完毕,tryPresize()中的while就会跳出循环
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//设置边界
bound = nextBound;
//设置下次迁移的起始索引
i = nextIndex - 1;
//设置false,表示这次迁移完毕
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
//所有的迁移工作已经完后
nextTable = null;
//设置新数组
table = nextTab;
//重新计算sizeCtl:n是原数组长度
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//通过CAS将sizeCtl-1,表示做完自己的任务了
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//如果(sc - 2)== resizeStamp(n) << RESIZE_STAMP_SHIFT
//表述所有迁移任务都已经完成,finishing=true,进入上面的if
finishing = advance = true;
i = n; // recheck before commit
}
}
//如果位置i处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode空节点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//该位置处是一个 ForwardingNode(ForwardingNode节点的Hash值等于MOVED(-1)),代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//获得数组i位置的头节点的对象锁,开始处理其迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//Hash值大于0表示是链表
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
//通过以下if判断将链表分为两个链表
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//将两个链表分别放在新链表中,对应位置为 原位置 和 原位置索引+原数组长度位置
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 在table i 位置处插上ForwardingNode 表示该节点已经处理过了
setTabAt(tab, i, fwd);
advance = true;
}
//如果当前节点是红黑树
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
//循环所有节点
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
//以下的if-else还是将红黑树一分为二
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 扩容后树节点个数若<=6,将树转链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
//将树/链表发到新数组中,对应位置为 原位置 和 原位置索引+原数组长度位置
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//将原数组的该位置设置为ForwardingNode,表示已经迁移完毕
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
迁移数据机制:原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程。其实就是将一个大的迁移任务分为了一个个任务包。
接下来我们再看下,将链表转换为红黑树的过程
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//如果整个table的数量小于64,就扩容至原来的一倍,不转红黑树
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
//依旧是获取链表头节点的锁
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
//循环链表
for (Node<K,V> e = b; e != null; e = e.next) {
//构建新的树节点
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
//将红黑树加入到数组的对应索引位置
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
最后我们来看下put操作最后的addCount方法
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//更新baseCount,table的数量,counterCells表示元素个数的变化
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
//如果多个线程都在执行,则CAS失败,执行fullAddCount,全部加入count
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
//check>=0表示需要进行扩容操作
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
接下来看看get操作。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算key的Hash值
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果首节点的key等于当前要查询的key,就返value
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//头结点的hash值<0,表示正在扩容或者其节点红黑树
//ForwardingNode中保存了nextTable为扩容的新数组,会调用ForwardingNode的find方法会在新数组中查询对应的节点,查到就返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
//既不是首节点也不是ForwardingNode,那就往下遍历
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
最后来看一下size方法。
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
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;
}
baseCount和counterCells:用来记录当前ConcurrentHashMap存在多少个元素使用的,在进行增删链表节点时,默认是更新baseCount的值即可,如果同时存在多个线程并发进行对链表节点的增删操作,则放弃更新baseCount,而是counterCells数组中添加一个CounterCell,之后在计算size的时候,累加baseCount和遍历并累加counterCells。
JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了。JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档。
参考文档