12. 多线程中的HashMap
12.1 HashMap不安全举例
-
Jdk1.7 头插法,多线程扩容时导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,同时也会出现数据丢失的问题。
-
Jdk1.8 尾插法,多线程put时会造成数据丢失。
12.2 HashTable与HashMap的区别
- HashTable的底层数组初始大小为11,HashMap要求其必须为 ;
- HashTable通过取模求Hash值,HashMap通过位运算求,效率高;
- Hash Map底层用数组+链表+红黑树,HashTable用数组+链表;
- HashTable对HashMap中线程不安全的方法加了Synchronized,但效率低;
12.3 HashTable与Collections.synchronizedMap比较
-
默认 Hashtable 和 synchrnizedMap 都是锁 类实例,synchrnizedMap 可以选择锁其他的 Object(mutex)
-
Hashtable 的 synchronized 是方法级别的;synchrnizedMap 的 synchronized 的代码块级别的
-
两者性能相近,但是 synchrnizedMap 可以用 null 作为 key 和 value
12.4 JDK1.7中的ConcurrentHashMap
由Segment数组结构和HashEntry数组结构组成。Segment继承ReentrantLock。
重要变量
static final int DEFAULT_INITIAL_CAPACITY = 16;//默认初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认负载因子
static final int DEFAULT_CONCURRENCY_LEVEL = 16;//默认并发数量,会影响segments数组的长度(初始化后不能修改)
static final int MAXIMUM_CAPACITY = 1 << 30; //map最大容量
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;// 每个segment中HashEntry[]默认容量
static final int MAX_SEGMENTS = 1 << 16; //最大并发数量
static final int RETRIES_BEFORE_LOCK = 2; //非锁定情况下调用size和contains方法的重试次数,避免由于table连续被修改导致无限重试
final int segmentMask; //计算segment位置的掩码
final int segmentShift; //用于算segment位置时,hash参与运算的位数
final Segment<K,V>[] segments; //segment数组
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
static final class Segment<K,V> extends ReentrantLock implements Serializable {
static final int MAX_SCAN_RETRIES =//对segment加锁时,在阻塞之前自旋的次数
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;// 当table大小超过阈值时扩容,值为(int)(capacity *loadFactor)
final float loadFactor;//负载因子
}
(1)初始化
public ConcurrentHashMap(int initialCapacity,float loadFactor,int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)//并发等级不可大于最大并发度
concurrencyLevel = MAX_SEGMENTS;
// 第一步,segments数组的长度ssize为大于等于concurrencyLevel的最小的2的最小次方数
int sshift = 0;//ssize左移的次数
int ssize = 1;//segment数组长度
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 第二步,初始化segmentShift和segmentMask
this.segmentShift = 32 - sshift; // 用于计算key的hash值参与运算位数
this.segmentMask = ssize - 1; // 哈希运算的掩码,每位都是1
// 第三步,确定每个segemnt中HashEntry[]的长度
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;// 计算每个segment中table的容量
if (c * ssize < initialCapacity)
++c;
// HashEntry[]默认容量
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
for (int i = 0; i < this.segments.length; ++i)
this.segments[i] = new Segment<K,V>(cap, loadFactor);
}
要点:确认ConcurrentHashMap的并发度,也就是Segment数组长度,并保证它是2的n次幂;确认HashEntry数组的初始化长度,并保证它是2的n次幂。
(2)定位Segment
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
要点:取哈希值的高4位参与运算,获得每个key值的定位
(3)get操作
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
要点:get阶段不需要加锁,变量都可以保证可见性。
(4)put操作
put方法首先需要循环获取锁,获得锁后定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。
- **是否需要扩容:**在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
- **如何扩容:**在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。
(5)size操作
先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。(modCount)
12.5 JDK1.8中的ConcurrentHashMap
重要常量
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;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
(1)初始化
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;
}
(2)put()
-
如果没有初始化就先调用initTable()方法来进行初始化过程
-
如果没有hash冲突就直接CAS插入
-
如果还在进行扩容操作就先进行扩容
-
如果存在hash冲突,就加锁(synchronized)来保证线程安全,遍历到尾端插入,或按照红黑树结构插入,
-
若该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
-
如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//取hashCode
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
} else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
(2)get()
-
计算hash值,定位到该table索引位置,如果是首节点符合就返回
-
如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
-
以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
public V get(Object key) {
ConcurrentHashMap.Node<K, V>[] tab;
ConcurrentHashMap.Node<K, V> e, p;
int n, eh;
K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
} else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
12.6 两种实现方式的对比
JDK1.7:ReentrantLock+Segment+HashEntry, JDK1.8:synchronized+CAS+HashEntry+红黑树
JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了;
JDK1.8使用红黑树来优化链表;