一、HashMap为什么是线程不安全的
问题主要出现是hashmap的扩容操作的rehash操作上。
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);
//下面两行代码我们可以看出在rehash的时候是通过头插法插入到table中的
e.next = newTable[i];
//以下分析都假设在并发时线程A在此刻被挂起
newTable[i] = e;
e = next;
}
}
}
1、hashmap会形成死循环,环形链表
假设容器初始值为如下图、hash的算法简单的使用取模操作
线程A和B并发向容器中put元素,发现容器使用率已经超过了容器的个人乘以加载因子的值,则需要扩容。
线程A在执行到上述代码时候时间片结束,此时A的结构为
此时线程B获取时间片,进行操作,并且扩容完成。
然后线程A再获取时间片来进行执行,由java内存模型可知道,newTable和table中的链表都是最新的值,A执行完成一轮循环后的结构为。
继续第二次循环
此时主存中的7的next是3,此时再将3rehash到table中,此时e已经为nulll,循环结束,就会出现如下结构
之后涉及到轮询table3的结构时就会发生死循环操作
数据丢失的问题只要将初始化的结构该为7-》5-》3。最终resize后的结构是如下图,有兴趣的可以自己分析下。出现了环形链表和丢失了3
二、concurrentHashMap
JDK1.7的实现
1、concurrentHashMap的数据结构
segment可以看作是一把可重入锁,因为它继承了ReentrantLock,也就是所谓的分段锁,这里是在为并发时候做提高性能使用的。
hashEntry的定义是用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;
}
}
2、初始化做了哪些事情
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
//默认的segment的大小
static final int DEFAULT_INITIAL_CAPACITY= 16;
//加载因子,当table的占用个数大于table的容量乘以当前值的时候要触发扩容,进行rehash
static final float DEFAULT_LOAD_FACTOR= 0.75f;
// 散列表的默认并发级别为 16。该值表示当前更新线程的估计并发量
static final int DEFAULT_CONCURRENCY_LEVEL= 16;
/**
* segments 的掩码值
* key 的散列码的高位用来选择具体的 segment
* 初始化的时候取的是
*/
final int segmentMask;
/**
* 偏移量
*/
final int segmentShift;
/**
* 由 Segment 对象组成的数组
*/
final Segment<K,V>[] segments;
/**
* 创建一个带有指定初始容量、加载因子和并发级别的新的空映射。
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
if(!(loadFactor > 0) || initialCapacity < 0 ||
concurrencyLevel <= 0)
throw new IllegalArgumentException();
//seghment的大小不能超过65535
if(concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 寻找最佳匹配参数(不小于给定参数的最接近的 2 次幂)
int sshift = 0;
int ssize = 1;
while(ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//此时ssize算下来等于16因为将1左移了4位
//sshift等于4
segmentShift = 32 - sshift; // 偏移量值等于32-4=28这里是在put操作时候取用的是hash值的前3位来进行的定位segment位置
segmentMask = ssize - 1; // 掩码值,等于15,因为当前值要
this.segments = Segment.newArray(ssize); // 创建数组
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if(c * ssize < initialCapacity)
++c;
//table的个数是2
int cap = 1;
while(cap < c)
cap <<= 1;
// 依次遍历每个数组元素
for(int i = 0; i < this.segments.length; ++i)
// 初始化每个数组元素引用的 Segment 对象
this.segments[i] = new Segment<K,V>(cap, loadFactor);
}
/**
* 创建一个带有默认初始容量 (16)、默认加载因子 (0.75) 和 默认并发级别 (16)
* 的空散列映射表。
*/
public ConcurrentHashMap() {
// 使用三个默认参数,调用上面重载的构造函数来创建空散列映射表
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
总结下初始化做的事情
- 计算出segment的掩码值=15,以为对segment定位的时候是按照%的方式进行操作的,使用的方式是利用位运算&的方式,因为a&2^n = a&2的n次方减1,2的4次方等于16.对16取余就相当于对2的4次方减1=15按位&的操作,这里一会get\put的时候会用到
- 计算出cap的值=2,cap的含义就是table中的数组的个数
- 初始化segment
3、get操作、怎么定位、如何保证线程安全、get方法的弱一致性
1 public V get(Object key) {
2 Segment<K,V> s; // manually integrate access methods to reduce overhead
3 HashEntry<K,V>[] tab;
4 int h = hash(key);
5 long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
6 if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
7 (tab = s.table) != null) {
8 for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
9 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
10 e != null; e = e.next) {
11 K k;
12 if ((k = e.key) == key || (e.hash == h && key.equals(k)))
13 return e.value;
14 }
15 }
16 return null;
17 }
- 通过获取到hash的值,如果是自定义对象的话,会要求重写hashCode方法,hash中是先取到对象的hash值,再进行一个wang jenkis算法的再哈希。获取到hash值后先先右移segmentShift=28位,取前三位和segmentmask进行按位&运算得到segment的下标
- 再对segmet中的table进行也进行按位&的操作获取到table的下标
- 然后去遍历链表,获取到对应的值
如何保证线程安全:开始我们看到使用volatile修饰了hashEntry,即保证了线程之间的内存可见性,线程A改变之后不会进行缓存,直接会回写到内存当中,保证了线程之间的数据可见。
get方法的弱一致性:我们对hashEntry的可见性有了保证但是如果hashEntry已经进行了扩容and rehash则我们查询的还是旧的链表,则会出现get到的数据还是旧数据,这就是get方法的弱一致性。
4、put操作、怎么定位、如何保证线程安全、key相同是否会覆盖,那种方法会覆盖。
1 public V put(K key, V value) {
2 Segment<K,V> s;
3 if (value == null)
4 throw new NullPointerException();
5 int hash = hash(key);
//获取segment的位置
6 int j = (hash >>> segmentShift) & segmentMask;
7 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
8 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//如果segmengt还未被初始化,则此处进行一个初始化动作
9 s = ensureSegment(j);
//执行put元素的操作
10 return s.put(key, hash, value, false);
11 }
1 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//首先对segmnet进行一个加锁操作
2 HashEntry<K,V> node = tryLock() ? null :
3 scanAndLockForPut(key, hash, value);
4 V oldValue;
5 try {
6 HashEntry<K,V>[] tab = table;
//定位segment中的table中的下标
7 int index = (tab.length - 1) & hash;
8 HashEntry<K,V> first = entryAt(tab, index);
9 for (HashEntry<K,V> e = first;;) {
10 if (e != null) {
11 K k;
//如果hash值相同,key也相同,根据是否需要覆盖的标识onlyIfAbsent来进行操作,覆盖的话直接将数据更新,不覆盖的话直接返回旧值
12 if ((k = e.key) == key ||
13 (e.hash == hash && key.equals(k))) {
14 oldValue = e.value;
15 if (!onlyIfAbsent) {
16 e.value = value;
17 ++modCount;
18 }
19 break;
20 }
21 e = e.next;
22 }
23 else {
24 if (node != null)
25 node.setNext(first);
26 else
27 node = new HashEntry<K,V>(hash, key, value, first);
28 int c = count + 1;
29 if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//如果当前的table使用已经超过数组大小乘以加载因子则进行扩容和rehash
30 rehash(node);
31 else
32 setEntryAt(tab, index, node);
33 ++modCount;
34 count = c;
35 oldValue = null;
36 break;
37 }
38 }
39 } finally {
40 unlock();
41 }
42 return oldValue;
43 }
- 定位segment位置并加锁
- 定位table中的index
- 去链表中查找,根据hashCode和key和onlyIfAbsent来判断是否需要覆盖旧值,代码中有详细注释
- 如果当前的table使用已经超过数组大小乘以加载因子则进行扩容和rehash,并插入要插入的数据
- 解锁
JDK1.8实现
1、1.8和1.7之间的变化
- 取消了segment数据,锁的粒度直接作用在table上,减少了并发冲突的概率
- 存储数据用链表+红黑树的的形式,红黑树的查找速度是log(n),性能很快,。但是插入操作需要进行红黑树的平衡调整,所以在8个元素以内使用链表的形式,8个元素以上使用红黑树的存储方式
2.主要数据结构和关键变量
- Node和1.7中的hashEntry基本一致,是存储链表时的节点数据
- sizeCtl
负数:表示正在初始化或者扩容,-1表示正在初始化、-N表示正在有N个线程进行扩容
正数:0表示还没有被初始化,N表示初始化或者下一次扩容的阈值 - TreeNode 红黑树节点
- TreeBin放在table中数据,也就是红黑树的头节点
3、操作的剖析,都做了哪些事情。
3、初始化
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的幂次方的正数
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
只是给成员变量赋值,put时进行实际数组的填充
4、get()方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//对hash值进行再散列,使得散列值更均衡
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;
}
//table中存储的是红黑树,需要去红黑树中进行查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//table中存储的是链表,遍历链表进行查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
5、put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
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)
//如果第一次put需要对容器进行初始化
tab = initTable();
//如果当前table中没有元素,则直接将数据插入到当前table的当前位置中
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
}
//当前线程检测到容易正在扩容,去帮助进行扩容,所做的事情就是将数据进行重新rehash并且搬数据
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//对当前table进行加锁
synchronized (f) {
//如果当前table存储的是链表。则将数据插入到链表中,和1.7操作类似
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) {
//如果当前table中存储的是链表,并且数据已经超过了8则进行链表到红黑树的转化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
初始化方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//如果有其他线程正在初始化,则将当前线程让出cpu
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) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//数组的初始化
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//sc的值设置为0.75n
sc = n - (n >>> 2);
}
} finally {
//设置下一次需要扩容的阈值
sizeCtl = sc;
}
break;
}
}
return tab;
}
这里需要注意下,在扩容的过程中,如果rehash后的table下的数据小于链表于红黑树的转化值(6),则需要将红黑树转化为链表,1.7版本hash的算法是将再散列后值的高位来进行和segmnet的个数减一进行&操作,1.8是用再散列后的值的高16位和tables的大小进行异或操作。