使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合
注意:当 value
为 null 时会抛 NPE 异常
class Person {
private String name;
private String phoneNumber;
// getters and setters
}
List<Person> bookList = new ArrayList<>();
bookList.add(new Person("jack","18163138123"));
bookList.add(new Person("martin",null));
// 空指针异常
bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber));
原因:toMap() 方法 ,其内部调用了 Map 接口的 merge() 方法,该方法方法会先调用 Objects.requireNonNull() 方法判断 value 是否为空。
不要在 foreach 循环里进行元素的 remove/add 操作。remove
元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。
集合在每次add、remove时都会执行modCount+1操作,可以理解为版本号加一,foreach 底层是通过Iterator 实现,Iterator 每次迭代时会检查自己所知的版本号expectedModCount和当前集合的modCount是否相同,如果不相同,说明集合被改变了,抛出并发修改异常。
调用Iterator 的remove不会出现异常,是因为本质上执行的集合的remove,并且Iterator 将版本号expectedModCount更新为最新的modCount,所以expectedModCount==modCount
,在比较版本号时不会出现异常。
解决方案
fail-safe
的集合类。java.util包下面的所有的集合类都是 fail-fast 的,而java.util.concurrent包下面的所有的类都是 fail-safe 的。ArrayList 中 Iterator源码分析
public Iterator<E> iterator() {
return new Itr();
}
以ArrayList为例,在调用iterator的时候,会直接返回一个Itr对象,那么我们看一下Itr对象:
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
这是Itr对象的几个类成员变量,其中我们看到了一个叫作expectedModCount
的字段,那么他是干什么用的呢?我们看下iterator的remove函数
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
我们可以看到在Itr进行remove时首先是检查lastRet,这个很合理,就是检查是否越界到最后一个元素。然后进行了checkForComodification检查,具体的操作如上面的函数所示,也就是检查了下modCount是否与expectedModCount是否相等,如果相等,就没事,如果不相等就标出我们上面所出现的异常。
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
这是ArrayList的remove函数,函数中在每次执行remove时,都会对modCount
·加一,不仅仅只是在remove时加一,其实add() ,clear()函数也会对modCount进行加一操作,那么modCount起什么作用呢,其实他就相当于一个记录ArrayList版本的变量,每对他进行操作时就会将其加一,表示进行了新的操作。
Iterator的next方法
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
为什么用list直接删除元素迭代器会报错?
前提:使用ForEach遍历集合底层使用的是Iterator,即需要调用Iterator的next方法。
通过上面源码可以看出,在获取迭代器时,迭代器内的expectedModCount被初始化为modCount
,此时如果直接用ArrayList对象直接remove,那么就会改变modCount的值(进行了加一),当迭代器迭代(next方法)过程中进行checkForComodification检查时
,就会发现expectedModCount!=modCount,也就是发现当前版本和迭代器记录的版本不一样,那么迭代过程中肯定就会有问题,这时,就会报出之前的异常。
那么,为什么用Iterator删除时就可以安全的删除,不会报错呢?
首先其实还是调用了ArrayList的remove函数
ArrayList.this.remove(lastRet)
但是在调用完该函数后,他又进行了如下操作
expectedModCount = modCount;
相当于将最新的版本号告诉了迭代器,所以迭代器在进行异常检查的时候就不会报错,因为他俩是相等的。所以这就解释了标题所提出的问题,还有值得注意的一点是对于add操作,则在整个迭代器迭代过程中是不允许的
。 其他集合(Map/Set)使用迭代器迭代也是一样
可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。
// Set 去重代码示例
public static <T> Set<T> removeDuplicateBySet(List<T> data) {
if (CollectionUtils.isEmpty(data)) {
return new HashSet<>();
}
return new HashSet<>(data);
}
// List 去重代码示例
public static <T> List<T> removeDuplicateByList(List<T> data) {
if (CollectionUtils.isEmpty(data)) {
return new ArrayList<>();
}
List<T> result = new ArrayList<>(data.size());
for (T current : data) {
if (!result.contains(current)) {
result.add(current);
}
}
return result;
}
原因:
O (n)
。O (n^2)
。int[] res = list.stream().mapToInt(Integer::intValue).toArray()
不推荐Arrays.asList()。
使用stream流
Integer [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).collect(Collectors.toList());
//基本类型也可以实现转换(依赖boxed的装箱操作)
int [] myArray2 = { 1, 2, 3 };
List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList());
guide链接
无参构造函数
以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量
。即向数组中添加第一个
元素时,数组容量扩为 10。
public boolean add(E e) {
//添加元素之前,先调用ensureCapacityInternal方法
ensureCapacityInternal(size + 1); // Increments modCount!!
//这里看到ArrayList添加元素的实质就相当于为数组赋值
elementData[size++] = e;
return true;
}
//得到最小扩容量
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 获取默认的容量和传入参数的较大值
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
// 设计扩容grow函数
ensureExplicitCapacity(minCapacity);
}
//判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 当容量不足时扩容
if (minCapacity - elementData.length > 0)
//调用grow方法进行扩容,调用此方法代表已经开始扩容了
grow(minCapacity);
}
参数为集合构造函数
/**
* 保存ArrayList数据的数组
*/
transient Object[] elementData; // non-private to simplify nested class access
public ArrayList(Collection<? extends E> c) {
//将指定集合转换为数组
elementData = c.toArray();
//如果elementData数组的长度不为0
if ((size = elementData.length) != 0) {
// 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断)
if (elementData.getClass() != Object[].class)
//将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// 其他情况,用空数组代替
this.elementData = EMPTY_ELEMENTDATA;
}
}
扩容机制
/**
* 要分配的最大数组大小
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
//如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
扩容原因:调用add添加元素时会判断容量是否充足,不充足则扩容。
根据add方法源码,添加元素是会生成最小容量需求变量minCapacity = size + 1
通过ensureCapacityInternal得到最小容量,如果数组为空则minCapacity = Math.max(10, minCapacity);
ensureExplicitCapacity判断是否需要扩容
,即minCapacity - elementData.length > 0是否成立
以下为扩容逻辑grow
创建变量oldCapacity = elementData.length
创建扩容后数组大小变量newCapacity = oldCapacity + (oldCapacity >> 1);(初始扩容大小
)
然后检查新容量是否大于最小需要容量minCapacity ,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量(一次扩容可能还是无法满足)。
ArrayList中有一个常量MAX_ARRAY_SIZE表示数组的最大容量
如果新容量newCapacity 大于 MAX_ARRAY_SIZE(可能扩容导致newCapacity 过大,也可能最小需求量就是大于 MAX_ARRAY_SIZE)执行hugeCapacity方法
hugeCapacity:如果minCapacity大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8
。
扩容核心:Arrays.copyOf用新数组替换原数组实现扩容
Arrays.copyOf
阅读源码的话,我们就会发现 ArrayList 中大量调用了这个方法
(1)System.arraycopy() 方法
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
(2)Arrays.copyOf()方法
public static int[] copyOf(int[] original, int newLength) {
// 申请一个新的数组
int[] copy = new int[newLength];
// 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
(3)List的toArray方法
/**
以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。
*/
public Object[] toArray() {
//elementData:要复制的数组;size:要复制的长度
return Arrays.copyOf(elementData, size);
}
HashMap源码
Java中的数组是不能自动扩展的。Hashmap的方法是用新数组替换原数组
,计算所有数据在新数组的位置(只是计算通过模运算计算新位置,不需要重新计算hash值
),插入新数组,然后指向新数组;如果数组在扩容前已经达到最大,则直接将阈值设置为最大整数返回`。map扩容和list扩容原理相同,都是用新数组替换原数组,对于list使用的时Object数组,map则使用Node
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
扩容代码:
// 扩容的方法
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); // 迁移旧的Entry到扩容后的数组中
table = newTable; // 将table指向新的数组
threshold = (int)(newCapacity * loadFactor); // 重新计算阈值
}
// Entry进行迁移
void transfer(Entry[] newTable) {
Entry[] src = table; // 旧数组
int newCapacity = newTable.length; // 新的容量
for (int j = 0; j < src.length; j++) { // 遍历旧数组中的每个桶
Entry<K,V> e = src[j]; // 桶中的第一个元素
if (e != null) { // 如果不为null
src[j] = null;
do {
Entry<K,V> next = e.next; // 先保存下一个要transfer的Entry
int i = indexFor(e.hash, newCapacity); // 计算新数组中的位置
e.next = newTable[i]; // 头插法插入元素
newTable[i] = e; // 更新新数组中i位置第一个元素为e
e = next; // e更新为旧数组中i位置下一个元素为e
} while (e != null);
}
}
}
1.7扩容过程
重新计算Entry在新数组中的位置
,然后利用头插法
的方式插入元素。但是hashmap1.7并没有利用这个原理
,而是直接遍历每个元素然后计算新位置并采用头插法
插入新链表。死循环问题:
初始状态
进行扩容,数组大小由2变成4
根据源码可知扩容是遍历链表e指向头节点,next指向下一个节点,下图中因为有两个线程,所以右两个e和next。
假如此时线程t2由于cpu时间片用完导致阻塞,此时只有t1在运行,则t1进行节点迁移。
t1完成扩容,所以t1推出扩容函数,也不存在t1的e和next,但是此时对于t2来说已经出现了问题,因为next和e的相对位置发生了变化
e.next = newTable[i]
newTable[i] = e; // 更新新数组中i位置第一个元素为e
e = next; // e更新为旧数组中i位置下一个元素为e
while (e != null);
此时e的next变为了空
继续对节点e进行头插法迁移到新数组
e.next = newTable[i]
newTable[i] = e; // 更新新数组中i位置第一个元素为e
此时就出现了环。如果此时再使用11,15等数值进行查询,会陷入循环链表无法找到出口指针而陷入死循环,进而导致CPU占用率100%的情况。
总结:
头插法
,头插法会改变链表中元素的相对前后位置头插法 + 链表 + 多线程并发 + HashMap
这四点是产生死循环的必要条件
解决方法:
JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
扩容代码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
由于每次扩容都是2倍,所以新位置只有两种情况oldInx或者oldIdx + n,n为老数组的长度。可以发现1.8扩容时利用到了这个原理,在进行链表迁移时,定义了两个新链表分别对应上面的oldInx和oldIdx + n位置的元素,先将所有元素装入新链表,然后将两个新链表放入新位置,并没有像1.7一样将每个元素采用头插法插入新位置。所以1.8不会出现死循环问题。
注意:无论1.7还是1.8,key value都不允许为空。
这是为了避免在多线程环境下出现歧义问题。
所谓歧义问题,就是如果key或者value为null,当我们通过get(key)获取对应的value的时候,如果返回的结果是null我们没办法判断,它是put(k,v)的时候,value本身为null值,还是这个key本身就不存在。这种不确定性会造成线程安全性问题,而ConcurrentHashMap本身又是一个线程安全的集合。
Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。
初始化
通过 ConcurrentHashMap 的无参构造探寻 ConcurrentHashMap 的初始化流程。
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
无参构造中调用了有参构造,传入了三个参数的默认值,他们的值是。
/**
* 默认初始化容量
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 默认负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 默认并发级别
*/
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
可以发现,无参构造实际调用了有参构造,传入的参数为默认值
@SuppressWarnings("unchecked")
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
// 参数校验
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 校验并发级别大小,大于 1<<16,重置为 65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// 2的多少次方
int sshift = 0;
int ssize = 1;
// 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 记录段偏移量
this.segmentShift = 32 - sshift;
// 记录段掩码
this.segmentMask = ssize - 1;
// 设置容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
//Segment 中的类似于 HashMap 的容量至少是2或者2的倍数
while (cap < c)
cap <<= 1;
// create segments and segments[0]
// 创建 Segment 数组,设置 segments[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];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
流程总结
初始化容量
大小,默认是 16。(初始容量为所有桶的总容量)初始化 segments[0]
,默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容,后续初始化其他segment初始化时后使用到segment[0]ConcurrentHashMap put方法先找到对应的Segment,然后调用Segment 的put方法存储对象。
key value都不能为空
/**
* Maps the specified key to the specified value in this table.
* Neither the key nor the value can be null.
*
* The value can be retrieved by calling the get method
* with a key that is equal to the original key.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with key, or
* null if there was no mapping for key
* @throws NullPointerException if the specified key or value is null
*/
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
// hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算
// 其实也就是把高4位与segmentMask(1111)做与运算
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 如果查找到的 Segment 为空,初始化
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
/**
* Returns the segment for the given index, creating it and
* recording in segment table (via CAS) if not already present.
*
* @param k the index
* @return the segment
*/
@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;
// 判断 u 位置的 Segment 是否为null
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
// 获取0号 segment 里的 HashEntry 初始化长度
int cap = proto.table.length;
// 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的
float lf = proto.loadFactor;
// 计算扩容阀值
int threshold = (int)(cap * lf);
// 创建一个 cap 容量的 HashEntry 数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck
// 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 自旋检查 u 位置的 Segment 是否为null
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
// 使用CAS 赋值,只会成功一次
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
注意:判断Segment是否存在都是通过UnSafe.getObjectVolatile判断内存中是否真实存在。
put方法:
数组
再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作。不为空则返回,否则继续
自旋检查u 位置的 Segment 是否为null, 使用CAS 将HashEntry数组赋值给Segment ,只会成功一次
segment数组中主要包含三个元素:加载因子、扩容阀值、HashEntry数组 (用于存放key value)。 即元素的实际存储是通过segment.put方法存入HashEntry 数组。
Segment继承了ReentrantLock ,所以Segment可以对自身加锁。
下面方法是segment 对象的put方法,所以加锁是针对segment 加的锁
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 计算要put的数据位置
int index = (tab.length - 1) & hash;
// CAS 获取 index 坐标的值
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
K k;
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 {
// first 有值说明 index 位置已经有值了,有冲突,链表头插法。
if (node != null)
node.setNext(first);
else
// node 为空,表示该hashentry确实为空,则创建新的hashentry
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 容量大于扩容阀值,小于最大容量,进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。
tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。
计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry
遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。
如果这个位置上的 HashEntry 不存在:
(1)如果当前容量大于扩容阀值,小于最大容量,进行扩容。
(2)直接头插法插入。
如果这个位置上的 HashEntry 存在:
(1)判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
(2)不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。没有相同的节点则需要插入新节点,判断当前容量是否大于扩容阀值,小于最大容量,进行扩容。直接链表头插法插入。
unlock()释放锁,如果替换返回旧值,否则返回 null.
这里面的scanAndLockForPut操作就是不断的自旋 tryLock() 获取锁。当自旋次数大于指定次数时,使用 lock() 阻塞获取锁。在自旋时顺表获取下 hash 位置的 HashEntry。
注意:
key的个数
超过threshold 时,就会进行扩容,是key的个数并不是HashEntry
的个数。所以每插入一个新的key,元素就会加一,此时会判断是否需要扩容。扩容实际是对Segment中的HashEntry数组进行扩容。
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法
插入到指定位置。这里虽然也是头插法但是扩容是在put方法中调用的put中加了锁,所以不会出现线程安全问题。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
// 老容量
int oldCapacity = oldTable.length;
// 新容量,扩大两倍
int newCapacity = oldCapacity << 1;
// 新的扩容阀值
threshold = (int)(newCapacity * loadFactor);
// 创建新的数组
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,默认2扩容后是4,-1是3,二进制就是11。
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
// 遍历老数组
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
// 如果当前位置还不是链表,只是一个元素,直接赋值
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// 如果是链表了
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
// 新的位置只可能是不便或者是老的位置+老的容量。
// 遍历结束后,lastRun 后面的元素位置都是相同的
for (HashEntry<K,V> last = next; last != null; last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。
newTable[lastIdx] = lastRun;
// Clone remaining nodes
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
// 遍历剩余元素,头插法到指定 k 位置。
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);
}
}
}
}
// 头插法插入新的节点
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 新table替换旧table
table = newTable;
}
计算得到 key 的存放位置。
遍历指定位置查找相同 key 的 value 值。
get方法是没有加锁的,是通过UNSAFE.getObjectVolatile
保证了HashEntry的可见性,1.8中是通过Volatile保证了Node节点的可见性。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 计算得到 key 的存放位置
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
// 如果是链表,遍历查找到相同 key 的 value。
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
ConcurrentHashMap -1.8 源码解析参考
参考2
数据结构:Node 数组 + 链表 / 红黑树
。当冲突链表达到一定长度时,链表会转换成红黑树
。
核心参数:sizeCtl
该值可以判断当前map的状态,以及是否则正在扩容即扩容的线程数。具体如下:
构造函数和arraylist类似,并没有真正创建Node数组,只是初始化了容量和sizeCtl值。
由于map的大小总是为2的次幂
,所以当用户传入的initialCapacity不满足2的次幂时,会寻找一个大于initialCapacity的2的次幂的数作为map的初始容量,并赋值给sizeCtl,此时Node数组还没有初始化,根据上面sizeCtl状态定义可知sizeCtl表示初始table大小。后面我们将Node数组统一称为table。
/**
使用默认的初始表大小 (16) 创建一个新的空映射。
*/
public ConcurrentHashMap() {
}
/**
构造函数,其初始表大小可容纳指定数量的元素,而无需动态调整大小。
@param initialCapacity 初始容量。如果元素的初始容量为负,则抛出异常
@throws IllegalArgumentException
*/
public ConcurrentHashMap(int initialCapacity) {
//如果初始容量为负数抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException();
//如果初始容量>=最大容量逻辑右移一位就赋值最大容量
// 否则返回大于输入参数且最近的2的整数次幂的数
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//赋值给sizeCtl参数
this.sizeCtl = cap;
}
构造函数中已经初始化了sizeCtl ,所以使用 sizeCtl 中记录的大小初始化表
。还可以通过sizeCtl判断当前map的状态
没有用锁,提高并发度,但是用了CAS保证一致性。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 如果table为空或者长度为0,进入while准备开始初始化。
while ((tab = table) == null || tab.length == 0) {
// 将sizeCtl赋值给sc。如果sizeCtl<0说明有线程正在初始化,当前线程要进入等待状态
if ((sc = sizeCtl) < 0)
// 线程进入等待
Thread.yield(); // lost initialization race; just spin
// 将sizeCtl设置为-1,代表抢到了锁,开始进行初始化操作
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//再次判断表是否为空
if ((tab = table) == null || tab.length == 0) {
//判断sc实际为(sizeCtl),构造函数时代表了初始化容量
//如果有指定初始化容量,就用用户指定的,否则用默认的16.
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 生成一个长度为n(上面的容量)的Node数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
//将地址赋给table
table = tab = nt;
// 重新设置sizeCtl=数组长度 - (数组长度 >>>2)
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * 长度(默认的扩容阈值)
sc = n - (n >>> 2);
}
} finally {
// 重新设置sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
concurrenthashmao 键和值都不能为空
。
/**
将指定的键映射到此表中的指定值。键和值都不能为空。
可以通过使用与原始键相同的键调用 {get 方法来检索该值。
@param key 与指定值关联的键
@param value 与指定键关联的值
@return 与 key 关联的前一个值,如果 key没有映射,则为 null
@throws NullPointerException 如果指定的键或值为空
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1.如果key或者value为空抛出异常
if (key == null || value == null) throw new NullPointerException();
// 2.计算hash 值
int hash = spread(key.hashCode());
// 用来记录所在table数组中的桶的中链表的个数,后面会用于判断是否链表过长需要转红黑树
int binCount = 0;
//for循环用break跳出
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 3.如果数组"空",进行数组初始化
if (tab == null || (n = tab.length) == 0)
// 初始化table
tab = initTable();
// i为下标,用(数组长度-1)&hash值计算得出
// 调用tabAt()获取数组中该下标对应的元素
// 4.如果这位置为空 CAS插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 使用CAS 操作将这个新值(将新值放入结点,再将结点放入期中)即可
// 如果 CAS 失败,那就是有并发操作,继续循环
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 5.如果头结点hash值为-1,则为ForwardingNode结点,说明map正在扩容
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,这个等到看完数据迁移部分的介绍后,再理解这个就很简单了
tab = helpTransfer(tab, f);
else { // 6. 到这里就是说,f 是该位置的头结点,而且不为空
V oldVal = null;
// 7. 获取数组该位置的头结点的监视器锁,锁住头结点
synchronized (f) {
// 8.双重检测锁,检测加锁前是否被修改
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 9.头结点的 hash 值大于 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;
}
// 没发现相等的key,到了链表的最末端,将这个新值放到链表的最后面
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) { // 10.红黑树
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;
}
}
}
}
// 11.判断链表是否需要转红黑树
if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 12.如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
treeifyBin(tab, i);
if (oldVal != null)
//返回旧值
return oldVal;
break;
}
}
}
// 13.计数器加1,完成新增后,table扩容,就是这里面触发
addCount(1L, binCount);
//新增后返回空
return null;
}
为了保证线程安全性,1.8在put时是对链表或者红黑树的头节点加锁。
流程:
设计扩容的地方
扩容怎么保证线程安全
transferIndex是一个全局变量,表示还没有迁移的桶(Node节点)的上界,即[0,transferIndex)还没有迁移,可以分配线程进行数据迁移。所以transferIndex初始值为原始table的长度,表示所有的节点都没有开始迁移。
多个线程都做扩容的时候,由字段transferIndex表示当前已分配的桶到什么下标了,对transferIndex字段的修改是用的CAS
,每个线程先获取自己处理哪个区间的桶,每个线程自己迁移自己的桶,互不打扰。一个线程最少处理16个桶。比如,现在数组长度为32,线程A迁移0-15的桶,线程B迁移16-31的桶。当前哪些区间的桶被分配的的临界值是transferIndex表示,对它的修改是CAS的,所以多线程扩容线程安全
如果有线程去写ConcurrentHashMap,发现现在正在扩容,则去帮组扩容。如果有线程去读,发现正在扩容,则通过桶上的ForwdingNode
去新的map中去读。
ForwdingNode
翻译过来就是正在被迁移的 Node,这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED = -1
,后面我们会看到.
作用1:原数组中位置 i 处的节点完成迁移工作后,就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了,所以其他线程可以去新table中get。
作用2:在执行get方法时如果访问的Node节点的hash值为-1,则表示当前table正在进行扩容,且当前访问的节点已经完成迁移,线程会拿到ForwdingNode节点,并且ForwdingNode会指向新table即nextTable,去新table中get。如果Node节点的hash值不为-1,则表示table没有扩容或者正在扩容但当前节点还没有迁移,则访问的是旧table,即在旧table中get。原理:get中调用find方法
。
扩容核心代码
参数中包含就table和新table,这是因为,只有第一个发起扩容的线程会创建新table,后续的线程只是帮助扩容,不需要再次创建新table。保证新table只有一个。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 1.stride为每个线程负责迁移桶的个数
// 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16(每个做扩容的线程至少处理16个桶)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 2.如果nextTab为空,新建一个是原来2倍长度的nextab
if (nextTab == null) {
try {
// 容量翻倍
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;
}
// nextTable 是 ConcurrentHashMap 中的属性
nextTable = nextTab;
// 3.transferIndex 也是 ConcurrentHashMap 的属性,用于控制迁移的位置
// 表示未迁移桶的范围上界,再分配桶时,是按下标从右向左分配,所以只需要transferIndex就可以保证线程安全
transferIndex = n;
}
int nextn = nextTab.length;
/**
4.ForwardingNode 翻译过来就是正在被迁移的 Node
这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
所以它其实相当于是一个标志。
**/
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 5. advance == true 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
// finishing == true 用于判断所有桶是否都已迁移完成
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
* 6.while 是给线程分配要处理的桶的范围,就是[bound, transferIndex-1]
* bound = transferIndex - stride,stride是步长,即每个线程处理的node节点数,前面已经确定了stride的值
* 通过transferIndex的cas修改保证每个线程的区间不同
*/
// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
/*
* 7.这个while是给当前线程分配迁移任务,即它负责迁移哪几个桶,它要处理的桶的下标范围,就是[bound, transferIndex-1]
* bound = transferIndex - stride,stride是步长,即每个线程处理的node节点数,前面已经确定了stride的值
* 通过transferIndex的cas修改保证每个线程的区间不同
*/
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
// (nextIndex = transferIndex) <= 0)表示所有桶都已经分配出去了,只需要等待迁移结束
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 8.用CAS设置transfer减去已分配的桶,并发扩容保证线程安全,每个扩容的线程根据这个字段扩容自己分配到区间的桶,各不干扰
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 确定当前线程每次分配的待迁移桶的范围为[bound, nextIndex)
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 9.上面while执行完,代表已经分配了带迁移的桶或者所有的桶都已经分配完
/**
步骤10-14只有所有桶都分配出去才会执行,所以先看14之后的步骤,这样便于理解代码
下面只需要进行桶迁移或者判断所有迁移任务是否执行完
根据while中的逻辑i = nextIndex - 1 等价于i = transferIndex - 1
if中的判断条件表示所有的桶即node节点都已经分配出了,现在只有两种情况:全部迁移完 or 还在迁移
**/
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 10.所有的迁移操作已经完成
if (finishing) {
nextTable = null;
// 11. 将新的 nextTab 赋值给 table 属性,完成迁移
// 将table指向扩容后的nextTab,此时ForwardingNode就没有用了,元素访问时访问到的就是nextTab中的元素
table = nextTab;
// 12. 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 第一个线程执行扩容时会在迁移前将sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2,
// (rs << RESIZE_STAMP_SHIFT) + 2就是一个基数,没有太大意义
// sizeCtl+1表示扩容线程数+1,上面的rs = resizeStamp(n)
// 13.当前线程已结束扩容,即完成了分配的迁移任务,sizeCtl-1表示参与扩容线程数-1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
/** sizeCtl != 扩容前设定的基数,表明还有线程没有完成扩容,所以当前线程直接结束return
问题:为什么当前线程不继续帮助扩容?
因为当前代码块的入口if (i < 0 || i >= n || i + n >= nextn)
中已经说明所有的桶都已经分配出去了,不需要多余的线程了
**/
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 14. 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 15.如果位置 i 处是空的,没有任何节点,那么CAS放入刚刚初始化的 ForwardingNode ”空节点“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 16.该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 17.重头戏,迁移过程
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 18.头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
/**
需要将链表一分为二,为什么一分为二?
原因很简单map的扩容都是扩大二倍,所以原始table中的元素在新table中的位置只有两种情况i,i+n
下面的两个链表就代表要插入到新table中i和i+n位置的元素集合
平衡二叉树同理
**/
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
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 ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 低位链表放在i处
// 此处枷锁了则不需要CAS
setTabAt(nextTab, i, ln);
// 高位链表放在i+n处
setTabAt(nextTab, i + n, hn);
// 19 将原数组该位置处设置为ForwardingNode,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
// 20 红黑树的迁移
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 ((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;
}
}
// 21 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
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;
// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 22 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
扩容时,每个线程负责的桶扩容结束后会进行if (i < 0 || i >= n || i + n >= nextn)判断,如果所有的桶都已经分配给线程扩容了,那么该线程只需要判断一下是否全部完成桶扩容,全部完成则将新table赋值给旧table,然会推出,否则直接退出。
// if成立分两种情况,1、所有桶的扩容任务结束,2.所有桶已经分配出去了,但是还没有结素
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 所有的迁移操作已经完成
if (finishing) {
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 第一个线程执行扩容时会在迁移前将sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2,
// (rs << RESIZE_STAMP_SHIFT) + 2就是一个基数,没有太大意义
// sizeCtl+1表示扩容线程数+1,上面的rs = resizeStamp(n)
// 当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// sizeCtl != 扩容前设定的基数,表明还有线程没有完成扩容,所以当前线程直接结束return
// 问题:为什么当前线程不继续帮助扩容?
// 因为当前代码块的入口if中已经说明所有的桶都已经分配出去了,不需要多余的线程了
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,当前线程是扩容的最后一个线程,由它完成table = nextTab;
// 也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT
一直没有理解,后面看了源码才发现
第一条扩容线程设置的某个特定基数
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)
很小的负数
,这样在扩容时即使有线程加入也只是sizeCtl += 正在扩容的线程数,保证了sizeCtl < 0,后续线程根据sizeCtl < 0判断当前map正在扩容。我们发现在transfer
中并没有设置这个特定基数的代码U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
。这是因为,第一个扩容的线程肯定是在执行addCount
或者treeifyBin
时才会执行扩容,这两个方法中会判断是否是第一个执行扩容的线程,如果是会设置这个特定的基数。
每次添加元素后都会执行addCount方法,该方法会设计扩容
addCount(1L, binCount);
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// counterCells 记录每个桶中元素的个数,这个分支主要是 counterCells 的维护工作。
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
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;
// map 中节点总数
s = sumCount();
}
// 桶中的节点数大于0,表示可能需要扩容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 当前map中的节点数超过了sizeCtl=容量*装载因子,并且能够扩容
// 这个 while 循环除了判断是否达到阈值从而进行扩容操作之外还有一个作用就是当一条线程完成自己的迁移任务后,
// 如果集合还在扩容,则会继续循环,继续加入扩容大军,申请后面的迁移任务
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// rs 没看懂是什么,这里我们关注扩容逻辑就行
int rs = resizeStamp(n);
// sc < 0 说明集合正在扩容当中
if (sc < 0) {
// 判断扩容是否结束或者并发扩容线程数是否已达最大值,如果是的话直接结束while循环
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);
}
// 如果集合还未处于扩容状态中,则进入扩容方法
// (rs << RESIZE_STAMP_SHIFT) + 2 为首个扩容线程所设置的特定值,
// 后面扩容时会根据线程是否为这个值来确定是否为最后一个线程
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
上面代码中的最后一判断就是第一个执行扩容的线程逻辑。
U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2)
为首个扩容线程所设置的特定值,后面扩容时会根据线程是否为这个值来确定是否为最后一个线程。这也对应了我们transfer中判断是否全部完成扩容的逻辑。
根据上面的逻辑我可以推测出helperTransfer
中的逻辑:
核心:判断是否处于扩容阶段,即sizeCtl是否小于0,如果是扩容阶段根据transferIndex 值可以判断是否已经把任务全部分配出去,当transferIndex > 0时,表示还有桶没有完成迁移,则把当前线程加入到扩容行动中,即U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(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)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
在链表转红黑树是也会锁住头节点
private final void treeifyBin(Node<K,V>[] tab, int index) {
// b表示需要转换为红黑树的那个桶在数组中的下标
Node<K,V> b; int n, sc;
// 如果table不为空
if (tab != null) {
// 如果table长小于64,调用tryPresize扩容,而不是转换为红黑树
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 调用tryPresize扩容
tryPresize(n << 1);
// 开始进行转换为红黑树
// 得到要转换为红黑树的链表的头节点,如果头节点不为空,并且头节点的hash >= 0
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 锁住头节点
synchronized (b) {
// 双重锁检查,以防在锁之前又被其他线程改变了该桶头节点的内容
if (tabAt(tab, index) == b) {
// hd表示红黑树的根节点
// tl表示preNode
TreeNode<K,V> hd = null, tl = null;
// 遍历链表
for (Node<K,V> e = b; e != null; e = e.next) {
// 把链表中的每个Node包装为TreeNode
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
// 确定红黑树的根节点
hd = p;
else
// 还是要维护next指针
tl.next = p;
tl = p;
}
//用TreeBin包装红黑树的根节点,并放入到数组的桶中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
get操作是无锁的。即使TreeBin的find函数有可能会加TreeBin的内部读锁,但也是非阻塞的
这里可以看到get方法是没有加锁的。Node中的value和nextNode定义的时候用了volatile
来保证可见性和有序性。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 得到key的哈希值
int h = spread(key.hashCode());
// 如果tabele不为空,并且tab.length大于0,得到桶的头节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 桶的头节点的哈希值等于要get的key的哈希值
if ((eh = e.hash) == h) {
//桶的头节点的key等于要get的key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
//那么桶的头节点就是我们要get的节点,直接返回头节点的value
return e.val;
}
// 桶的头节点的哈希值小于0,表示在红黑树TreeNode上或者正在扩容,TreeNode继承于Node
// 如果是扩容则访问到的e是ForwardingNode节点,ForwardingNode继承于Node
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 这里表示在桶的链表上
// 遍历该桶的链表找到get的节点,返回节点的value
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
到此为止可以清晰的辨别:
eh < 0则表示访问的节点在红黑树上或者正在扩容
执行e.find(h, key)分析
如果访问到的是红黑树,则调用的是TreeBin的find
方法,就是红黑树的查找。TreeBin继承于Node,可以发现TreeBin的find函数有可能会加TreeBin的内部读锁,但也是非阻塞的。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
/**
* Returns matching node or null if none. Tries to search
* using tree comparisons from root, but continues linear
* search when lock not available.
*/
final Node<K,V> find(int h, Object k) {
if (k != null) {
for (Node<K,V> e = first; e != null; ) {
int s; K ek;
if (((s = lockState) & (WAITER|WRITER)) != 0) {
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
e = e.next;
}
else if (U.compareAndSwapInt(this, LOCKSTATE, s,
s + READER)) {
TreeNode<K,V> r, p;
try {
p = ((r = root) == null ? null :
r.findTreeNode(h, k, null));
} finally {
Thread w;
if (U.getAndAddInt(this, LOCKSTATE, -READER) ==
(READER|WAITER) && (w = waiter) != null)
LockSupport.unpark(w);
}
return p;
}
}
}
return null;
}
/**
* Acquires write lock for tree restructuring.
*/
private final void lockRoot() {
if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER))
contendedLock(); // offload to separate method
}
/**
* Releases write lock for tree restructuring.
*/
private final void unlockRoot() {
lockState = 0;
}
/**
* Possibly blocks awaiting root lock.
*/
private final void contendedLock() {
boolean waiting = false;
for (int s;;) {
if (((s = lockState) & ~WAITER) == 0) {
if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) {
if (waiting)
waiter = null;
return;
}
}
else if ((s & WAITER) == 0) {
if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) {
waiting = true;
waiter = Thread.currentThread();
}
}
else if (waiting)
LockSupport.park(this);
}
}
如果访问到的是ForwardingNode,则调用的是ForwardingNode的find
方法。
表明当前节点已经完成迁移,ForwardingNode会指向新的table,所以回去新tbale中查找
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
总结一下 get 过程:
put
、真正迁移
和链表转红黑树
的时候才会加synchronized锁,并且迁移是多线程共同完成的,所以这个锁的时间也很短。扩容
1.7是单个线程
执行扩容,先将所有的节点迁移到新table中,然后再指向新table
,所以在扩容是get方法只有两种情况
因为此时旧table还未指向新table
1.8是多线程
执行扩容,也是先将所有的节点迁移到新table中,然后再指向新table,但是当某个**桶迁移结束后**会将旧table中的节点修改为forwdingNode
,所以get方法有多种情况
旧table中的旧节点
旧table中的旧节点
forwdingNode
,forwdingNode指向新table,所以访问的是新table中的节点。forwdingNode在节点迁移中起到了重要作用:当某个桶内的节点迁移完成后
会用forwdingNode替换旧table中的节点位置。
put
插入元素和扩容代码
的,只是加锁的时机不同put方法开始时
就调用了互斥锁,因为segment继承了可重入锁,所以segment对象本身就是一个锁。真正在插入节点时
对Node头节点
加了synchronize
锁,添加完成后释放锁。扩容的时候再次对Node头节点加synchronize锁
1.7 put
put(key, value){
tryLock(segment){
插入元素
需要扩容则扩容
}
}
1.8 put
put(key, value){
进行一些判断:
- 如果key或者value为空抛出异常
- 得到key的哈希值
- ...
synchronize(Node头节){
插入元素
}
需要扩容
synchronize(Node头节){
扩容
}
需要转红黑树
synchronize(Node头节){
链表转红黑树
}
}
可以发现1.8中锁的范围更小了。这样并发性会更高。