HashMap
从以下5点入手
数据结构:
1.7 数组+链表
1.8 数组+链表+红黑树
当链表长度大于8时会转换为红黑树,当红黑树节点小于6时会转换成链表。
因为红黑树是自平衡二叉查找树,检索效率为O(logn)
引入红黑树是为了解决哈希碰撞后链表索引效率的问题,以加快检索速度。
hashmap结构源码:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
......
}
put流程:
第一步:将k v封装到Node对象中;
第二步:对k进行hashCode()得到hash值,并用该hash值和数组长度减一进行与运算 (n - 1) & hash, 得到数组下标;(hashMap就是以这种方式处理hash冲突的)
第三步:如果下标位置没有元素就把Node添加到该位置上,如果有元素,就拿个这个hash值与节点上的k进行equals比较,如果都为false,就将该节点插到链表头部;如果某个为true就覆盖该节点的value值;
get流程:
第一步:用同样的方法得到当前k对应的数组下标;
第二步:通过下标快速定位到某个位置,如果当前位置没有元素,就返回null。如果当前有链表,就那这个k的hashCode值与链表上每个节点k的hashCode值进行equals比较,如果与某个节点比较结果为true,那么这个节点就是我们要找的节点,就返回当前节点的value。否则返回null;
扩容:初始化容量16,加载因子0.75,每次扩容2倍容量,容量始终是2的幂次方(为了散列均匀,减少hash冲突)。
扩容过程:1.7 中整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部,也就是发生 Hash 冲突后最后放入的冲突元素),然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换(即原来 hash 冲突的单向链表尾部变成了扩容后单向链表的头部)。
而在 JDK 1.8 中 HashMap 的扩容操作就显得更加的骚气了,由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize =4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值与新数组长度的最高位,按位与操作是 0 或 1 就行,0 的话索引就不变,1 的话索引=原索引+扩容前数组长度,所以其实现如下流程图所示:
可以看见,这个设计非常赞,因为 hash 值本来就是随机性的,所以 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度)就是随机的,所以扩容的过程就能把之前hash冲突的元素再随机的分布到不同的索引去,这算是 JDK1.8 的一个优化点。
此外,在 JDK1.7 中扩容操作时哈西冲突的数组索引处的旧链表元素扩容到新数组时如果扩容后索引位置在新数组的索引位置与原数组中索引位置相同,则链表元素会发生倒置(即如上面图1,原来链表头扩容后变为尾巴);而在 JDK1.8 中不会出现链表倒置现象。
请你解释HashMap的容量为什么是2的n次幂?
考点:集合
参考回答:
负载因子默认是0.75, 2^n是为了让散列更加均匀,例如出现极端情况都散列在数组中的一个下标,那么hashmap会由O(1)复杂退化为O(n)的。
线程安全:扩容时,链表可能会产生循环链表导致put或get的时候出现死循环。
HashMap是线程不安全的是吧?你可以举一个例子吗?
HashMap线程不安全主要是考虑到了多线程环境下进行扩容可能会出现HashMap死循环
Hashtable线程安全是由于其内部实现在put和remove等方法上使用synchronized进行了同步,所以对单个方法的使用是线程安全的。但是对多个方法进行复合操作时,线程安全性也无法保证。 比如一个线程在进行get然后put更新的操作,这就是两个复合操作,在两个操作之间,可能别的线程已经对这个key做了改动,所以,你接下来的put操作可能会不符合预期。
如果hashMap的key是一个自定义的类,怎么办?
考点:集合
参考回答:
使用HashMap,如果key是自定义的类,就必须重写hashcode()和equals()。
因为,在put和get过程中会对key进行hash运算和equals比较。
HashMap为什么用头插法后来为什么又改成了尾插法?
JDK 1.7及之前,为什么采用头插法
呃… 这个可能需要问头插法的实现者了;
但有种说法,我觉得挺有道理:缓存的时间局部性原则,最近访问过的数据下次大概率会再次访问,把刚访问过的元素放在链表最前面可以直接被查询到,减少查找次数。
只有在并发情况下,头插法才会出现链表成环的问题,多线程情况下,HashMap 本就非线程安全,这就相当于你在它的规则之外出了问题,那能怪谁?
1.8 采用尾插,是对 1.7 的优化
既然 1.8 没有链表成环的问题,那是不是说明可以把 1.8 中的 HashMap 用在多线程中
链表成环只是并发问题中的一种,1.8 虽然解决了此问题,但是还是会有很多其他的并发问题,比如:上秒 put 的值,下秒 get 的时候却不是刚 put 的值;因为操作都没有加锁,不是线程安全的。
======================================================================
ConcurrentHashMap
数据结构
1.7 数组+链表+segment
1.8 数组+链表+红黑树+(Node+CAS+Synchronized)
put流程
get流程
扩容
1.7版本ConcurrentHashMap:
为什么ConcurrentHashMap可以多线程访问呢?是因为ConcurrentHashMap将Map分段了,每个段进行加锁,而不是想Hashtable,SynchronizedMap是整个map加锁,这样就可以多线程访问了。
多个线程可以同时从一个分段中读数据吗?
可以
如果一个线程正在向一个分段写入数据,其他线程可以从该分段中读取数据吗?
可以。但是读取到最后更新的数据。
最后需要注意的一点是CoucrrentHashMap是不允许key和vlaue为null的。
ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。ConcurrentHashMap锁的方式是稍微细粒度的,ConcurrentHashMap将hash表分为16个桶(默认值),诸如get,put,remove等常用操作只锁上当前需要用到的桶。
HashEntry 用来封装映射表的键 / 值对;Segment 用来充当锁的角色,Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。每个 Segment 对象守护整个散列映射表的若干个桶。每个桶是由若干个 HashEntry 对象链接起来的链表。 一个 ConcurrentHashMap 实例中包含由若干个 Segment 对象组成的数组。HashEntry 用来封装散列映射表中的键值对。在 HashEntry 类中,key,hash 和 next 域都被声明为 final 型,value 域被声明为 volatile 型。
static final class HashEntry<K,V> {
final K key; // 声明 key 为 final 型
final int hash; // 声明 hash 值为 final 型
volatile V value; // 声明 value 为 volatile 型
final HashEntry<K,V> next; // 声明 next 为 final 型
HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
this.key = key;
this.hash = hash;
this.next = next;
this.value = value;
}
}
在ConcurrentHashMap 中,在散列时如果产生“碰撞”,将采用“分离链接法”来处理“碰撞”:把“碰撞”的 HashEntry 对象链接成一个链表。由于 HashEntry 的 next 域为 final 型,所以新节点只能在链表的表头处插入。 下图是在一个空桶中依次插入 A,B,C 三个 HashEntry 对象后的结构图.
注意:由于只能在表头插入,所以链表中节点的顺序和插入的顺序相反。
初始化:
从源码可知,1.7的ConcurrentHashMap初始化容量是16,加载因子是0.75,并发级别是16
并发级的大小就是Segment的个数。
//空参构造
public ConcurrentHashMap() {
//调用本类的带参构造
//DEFAULT_INITIAL_CAPACITY = 16
//DEFAULT_LOAD_FACTOR = 0.75f
//int DEFAULT_CONCURRENCY_LEVEL = 16
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
三个参数的构造:一些非核心逻辑的代码已经省略
//initialCapacity 定义ConcurrentHashMap存放元素的容量
//concurrencyLevel 定义ConcurrentHashMap中Segment[]的大小
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
int sshift = 0;
int ssize = 1;
//计算Segment[]的大小,保证是2的幂次方数
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//这两个值用于后面计算Segment[]的角标
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
//计算每个Segment中存储元素的个数
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
//最小Segment中存储元素的个数为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
矫正每个Segment中存储元素的个数,保证是2的幂次方,最小为2
while (cap < c)
cap <<= 1;
//创建一个Segment对象,作为其他Segment对象的模板
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类,将创建的Segment对象存入0角标位置
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
综上:ConcurrentHashMap中保存了一个默认长度为16的Segment[],每个Segment元素中保存了一个默认长度为2的HashEntry[],我们添加的元素,是存入对应的Segment中的HashEntry[]中。所以ConcurrentHashMap中默认元素的长度是32个,而不是16个
Segment是一种可重入的锁ReentrantLock,每个Segment守护若干个HashEntry元素,当对HashEntry数组的数据进行修改时,必须首先获得对应的Segment锁。
1.7版本的 put()操作:
1、基于key,计算hash值。因为一个键要计算两个数组的索引(segment和hashEntry),为了避免冲突,这里取hash的高位参与计算Segment[]的索引:(hash >>> segmentShift) & (segment[].length - 1)得到segment数组下标。
2、判断该索引位的Segment对象是否创建,没有就创建。(自旋方式,将创建的Segment对象放到Segment[]中,确保线程安全)
3、调用Segment的put方法实现元素添加。
4、尝试获取锁,获取成功,node为null,代码向下执行,取hash的低位,计算HashEntry[]的索引
int index = (HashEntry[].length - 1) & hash;
如果有其他线程占据锁对象,那么去做别的事情,而不是一直等待,提升效率
5、拿k与这个索引位置上的元素进行equals比较
如果当前位置为空,就把元素插到当前位置
如果当前位置不空,比较hash相同就覆盖原值,不同,链表元素个数++,判断是否需要扩容,若需要就扩容后再插入链表,若不需要就将元素插入到链表。
6、最后完成插入,释放锁
1.1、ConcurrentHashMap的put方法
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
//基于key,计算hash值
int hash = hash(key);
//因为一个键要计算两个数组的索引,为了避免冲突,这里取高位计算Segment[]的索引
int j = (hash >>> segmentShift) & segmentMask;
//判断该索引位的Segment对象是否创建,没有就创建
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
//调用Segment的put方法实现元素添加
return s.put(key, hash, value, false);
}
1.2、ConcurrentHashMap的ensureSegment方法
//创建对应索引位的Segment对象,并返回
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;
//获取,如果为null,即创建
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//以0角标位的Segment为模板
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);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//获取,如果为null,即创建
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//创建
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//自旋方式,将创建的Segment对象放到Segment[]中,确保线程安全
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
//返回
return seg;
}
1.3、Segment的put方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//尝试获取锁,获取成功,node为null,代码向下执行
//如果有其他线程占据锁对象,那么去做别的事情,而不是一直等待,提升效率
//scanAndLockForPut 稍后分析
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
//取hash的低位,计算HashEntry[]的索引
int index = (tab.length - 1) & hash;
//获取索引位的元素对象
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
//获取的元素对象不为空
if (e != null) {
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 { //如果获取到的元素为空
//当前添加的键值对的HashEntry对象已经创建
if (node != null)
node.setNext(first); //头插法关联即可
else
//创建当前添加的键值对的HashEntry对象
node = new HashEntry<K,V>(hash, key, value, first);
//添加的元素数量递增
int c = count + 1;
//判断是否需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//需要扩容
rehash(node);
else
//不需要扩容
//将当前添加的元素对象,存入数组角标位,完成头插法添加元素
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//释放锁
unlock();
}
return oldValue;
}
1.4、Segment的scanAndLockForPut方法
该方法在线程没有获取到锁的情况下,去完成HashEntry对象的创建,提升效率
但是这个操作个人感觉有点累赘了。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//获取头部元素
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
while (!tryLock()) {
//获取锁失败
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
//没有下一个节点,并且也不是重复元素,创建HashEntry对象,不再遍历
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
//重复元素,不创建HashEntry对象,不再遍历
retries = 0;
else
//继续遍历下一个节点
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
//如果尝试获取锁的次数过多,直接阻塞
//MAX_SCAN_RETRIES会根据可用cpu核数来确定
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//如果期间有别的线程获取锁,重新遍历
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
get()操作
1、同样的方法定位hash桶
2、遍历链表找到hash的相同的k返回其值,找不到就返回null。
扩容安全
1、对segment中的HashEntry[]进行扩容。创建新HashEntry[],扩容为两倍容量
2、通过hash值与上新数组容量减一得到新下标,hash &(newCapacity - 1)。
3、实现数据迁移。
如果原位置只有一个元素,直接放到新数组即可,
如果有多个元素,会先找出新下标相同连续的元素,lashRun标记这样一组元素的第一个元素,
转移的时候,只转移第一个元素到对应位置即可,因为是链表结构这就等同于把这样一组新下标相同且连续的一组元素一同转移到了新下标位置上。(是不是有点像玩蜘蛛纸牌呢)
源码:
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数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
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;
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;
//=========图二=====================
// Clone remaining nodes
//=========图三=====================
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];
//这里旧的HashEntry不会放到新数组
//而是基于原来的数据创建了一个新的HashEntry对象,放入新数组
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 = newTable;
}
concurrentHashMap扩容时有没有并发安全的问题呢?
当然没有,因为它是在put的时候会进行扩容,put的时候已经加锁了。所以不会有并发安全的问题。
请你说明ConcurrentHashMap锁加在了哪些地方?
考点:集合
参考回答:
加在每个Segment 上面。
1.8版本ConcurrentHashMap:
数据结构
数组+链表+红黑树+(Node+CAS+Synchronized)
容器初始化
在jdk8的ConcurrentHashMap中一共有5个构造方法,这四个构造方法中都没有对内部的数组做初始化, 只是对一些变量的初始值做了处理
jdk8的ConcurrentHashMap的数组初始化是在第一次添加元素时完成。
//没有维护任何变量的操作,如果调用该方法,数组长度默认是16
public ConcurrentHashMap() {
}
//传递进来一个初始容量,ConcurrentHashMap会基于这个值计算一个比这个值大的2的幂次方数作为初始容量
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
/*java中有三种移位运算符
<< : 左移运算符,num << 1,相当于num乘以2
>> : 右移运算符,num >> 1,相当于num除以2
>>> : 无符号右移,忽略符号位,空位都以0补齐
*/
注意,调用这个方法,得到的初始容量和我们之前讲的HashMap以及jdk7的ConcurrentHashMap不同,即使你传递的是一个2的幂次方数,该方法计算出来的初始容量依然是比这个值大的2的幂次方数
//调用四个参数的构造
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
//计算一个大于或者等于给定的容量值,该值是2的幂次方数作为初始容量
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
//基于一个Map集合,构建一个ConcurrentHashMap
//初始容量为16
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
添加安全-put()操作
1、根据key计算出hashcode。
2、判断这个数组是否需要进行初始化,如果数组为空或长度为0,就调用initTable()进行初始化数组。
3、根据当前k的hashcode计算出所对应的数组下标(n - 1) & hash
如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功。
4、如果当前位置的hashcode == MOVED == -1, 则需要扩容。就会去协助扩容。
5、如果以上情况都不满足,则利用synchronized加锁(锁的是链表的头节点或红黑树对象),然后向链表或红黑树中写入数据。
6、binCount变量用来记录链表/红黑树上元素个数,每次put,binCount都被+1,addCount(1L,binCount),判断是否需要进行链表/红黑树的转换。
当binCount >= 8,链表 → 红黑树,
当binCount < 6 , 红黑树 → 链表。
addCount()方法里有一个baseCount变量和CounterCell[]数组。
因为我们put的过程只对一个桶位进行加锁,并不影响其他桶位也进行put操作,所以如果同时有多个线程都去对baseCount+1,拿一定会出现线程安全问题。
又为了不影响效率,就引入了一个CounterCell [ ]数组。
如果有一个线程在对baseCount进行+1操作,那么其他线程就去操作这个CounterCell [ ],通过生成一个随机数&CounterCell [ ]的长度得到下标,对该下标位置的value采用CAS方式执行+1操作。
最终集合长度=baseCount + CounterCell [ ]中所有元素的值
public V put(K key, V value) {
return putVal(key, value, false);
}
CAS+自旋,保证线程安全,对数组进行初始化操作。
扩容安全
注意:1.7只是对单个segment扩容,1.8是对整个数组进行扩容。
1、会把数组划分为多个小任务,每个线程负责一个划分的任务。
2、如果是扩容线程,此时新数组为null,两倍扩容创建新数组,用transferIndex变量记录线程开始迁移的桶位,从后往前迁移。每个线程负责一个小任务,用i记录当前正在迁移桶位的索引值
bound记录下一次任务迁移的开始桶位
3、当前迁移的桶位没有元素,直接在该位置添加一个fwd节点。表示该位置完成了迁移。
4、当前节点需要迁移,synchronized加锁迁移,保证多线程安全。
5、如果没有更多的需要迁移的桶位,CAS扩容任务线程数减1,扩容结束后,保存新数组,并重新计算扩容阈值,赋值给sizeCtl
6、在这个过程中都会使用CAS+自旋的方式判断是否迁移完成,如果没有,就CAS计算下一次任务迁移的开始桶位,并将这个值赋值给transferIndex
6、没有元素需要迁移 – 后续会去将扩容线程数减1,并判断扩容是否完成。所有扩容线程都执行完,标识迁移结束。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//如果是多cpu,那么每个线程划分任务,最小任务量是16个桶位的迁移
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//如果是扩容线程,此时新数组为null
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//两倍扩容创建新数组
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 = nextTab;
//记录线程开始迁移的桶位,从后往前迁移
transferIndex = n;
}
//记录新数组的末尾
int nextn = nextTab.length;
//已经迁移的桶位,会用这个节点占位(这个节点的hash值为-1--MOVED)
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//i记录当前正在迁移桶位的索引值
//bound记录下一次任务迁移的开始桶位
//--i >= bound 成立表示当前线程分配的迁移任务还没有完成
if (--i >= bound || finishing)
advance = false;
//没有元素需要迁移 -- 后续会去将扩容线程数减1,并判断扩容是否完成
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//计算下一次任务迁移的开始桶位,并将这个值赋值给transferIndex
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//如果没有更多的需要迁移的桶位,就进入该if
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//扩容结束后,保存新数组,并重新计算扩容阈值,赋值给sizeCtl
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//扩容任务线程数减1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//判断当前所有扩容任务线程是否都执行完成
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//所有扩容线程都执行完,标识结束
finishing = advance = true;
i = n; // recheck before commit
}
}
//当前迁移的桶位没有元素,直接在该位置添加一个fwd节点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//当前节点已经被迁移
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//当前节点需要迁移,加锁迁移,保证多线程安全
//此处迁移逻辑和jdk7的ConcurrentHashMap相同,不再赘述
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
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 ((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);
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 ((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;
}
}
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);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
多线程协助扩容
多线程协助扩容的操作会在两个地方被触发:
① 当添加元素时,发现添加的元素对应的桶位为fwd节点,就会先去协助扩容,然后再添加元素
② 当添加完元素后,判断当前元素个数达到了扩容阈值,此时发现sizeCtl的值小于0,并且新数组不为空,这个时候,会去协助扩容
1、源码分析
1.1、元素未添加,先协助扩容,扩容完后再添加元素
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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
}
//发现此处为fwd节点,协助扩容,扩容结束后,再循环回来添加元素
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//省略代码
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)) {
//扩容,传递一个不是null的nextTab
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
1.2、先添加元素,再协助扩容
private final void addCount(long x, int check) {
//省略代码
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);
//sizeCtl小于0,说明正在执行扩容,那么协助扩容
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();
}
}
}
注意:扩容的代码都在transfer方法中,这里不再赘述
维护集合长度
CAS+自旋的方式给baseCount + 1,加不上就给CounterCell [ ] 数组某个位置+1,加不上就再找一个位置加。直到加上为止。
==========================================================================
下面总结一下常见的问题
请你说明concurrenthashmap有什么优势以及1.7和1.8区别?
考点:集合
参考回答:
Concurrenthashmap线程安全的,1.7是在jdk1.7中采用Segment + HashEntry的方式进行实现的,lock加在Segment上面。1.7size计算是先采用不加锁的方式,连续计算元素的个数,最多计算3次:1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;2、如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;
1.8中放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,1.8中使用一个volatile类型的变量baseCount记录元素的个数,当插入新数据或则删除数据时,会通过addCount()方法更新baseCount,通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数;
添加安全操作:puVal () 方法,首先是通过cas+自旋的方式对数组进行初始化,保证线程安全。如果当前桶位为空,往这个桶位添加元素,如果数组正在扩容就会帮助扩容,如果以上都不是就使用sychronized给这个桶位加锁,然后进行元素的添加,要么加到链表里面,要么加到红黑树上。加完以后判断数组数量是否达到了变成树的阈值,如果达到还要先判断是否数组容量小于,如果大于就先扩容,然后再就变树,如果不大于就变成树。
请你说明一下Map和ConcurrentHashMap的区别?
考点:集合
参考回答:
hashmap是线程不安全的,put时在多线程情况下,会形成环从而导致死循环。
hashmap是采用(fail-fast)快速失败的容错机制。
CoucurrentHashMap是线程安全的,采用分段锁机制,减少锁的粒度。
是采用(fail-safe)安全失败的容错机制。
那么快速失败机制底层是怎么实现的呢?
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount
变量。
集合在被遍历期间如果内容发生变化,就会改变modCount
的值。
当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount
变量是否为expectedModCount
值,是的话就返回遍历;
否则抛出并发修改异常,终止遍历。
JDK源码中的判断大概是这样的:
以往错误的认为快速机制就是HashMap线程不安全的表现。并且坚定的认为Hashtable和Vector等线程安全的集合不会存在并发修改时候的快速失败,这是大错特错。概念和原理理解的不清晰导致掉入了面试官的陷阱里了,大家可以打开JDK源码,会发现Hashtable也会在迭代的时候抛出该异常,可能发生快速失败。
为什么不用ReentrantLock而用synchronized ?
Segment继承了重入锁ReentrantLock,有了锁的功能,每个锁控制的是一段,但当每个Segment越来越大时,锁的粒度就变得有些大了,效率就低了。
减少内存开销:如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
内部优化:synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。