文章目录
Java并发容器
吐槽一下:面试前真要准备好源代码分析,看源码真的是一种享受!
一、ConcurrentHashMap
为什么要用ConcurrentHashMap?
1. HashMap的线程不安全
ConcurrentHashMap 是线程安全且高效的 HashMap
我们看一下下面的代码
@Test
public void hashThreadunsafe() throws InterruptedException {
final HashMap<String, String> map = new HashMap<String, String>(2);
for (int i = 0; i < 100; i++) {
}
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
map.put(UUID.randomUUID().toString(), "");
}
}, "ftf" + i).start();
}
}
}, "ftf");
t.start();
t.join();
}
重点!!!!!!
重点!!!!!!
重点!!!!!!
jdk 1.7中, 多线程不断插入数据会导致HashMap的transfer()
过程, 我们看下面源代码可以看到,使用的是头插法,我们注意一下!
的代码处,如果rehash过程中挂起,那么其他线程在进行rehash过程时会形成一个循环链表,导致当前线程执行时,进入死循环!
transfer()
源码如下:
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);//定位Hash桶
e.next = newTable[i];
newTable[i] = e;//线程在此处挂起!!!!!
e = next;
}
}
}
下面我们看一下 jdk1.8的情况(此处作者查看了一下jdk10的源代码,跟jdk1.8一致),通过尾插法,很好的避免了死锁的情况,但是仍然会出现线程不安全的情况!!同样我们看一下!
的位置。
如果线程A,B都在进行put操作,并且hash值相同的。此时线程A执行到!
处的代码后由于执行代码太累了想去吃瓜被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A上工,继续执行代码,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
下面为HashMap的 putVal()
使用尾插法的源码:
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) // 如果没有hash碰撞则直接插入元素!!!!!!
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;
// 判断 entry头的类型,如果为TreeNode则进行红黑树插入
// TreeNode extends HashMap.Node
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 此处采用链表尾插法!
// 这里会出现循环的问题
for (int binCount = 0; ; ++binCount) {
// 1.
if ((e = p.next) == null) {
// 2.
p.next = newNode(hash, key, value, null);
// 如果entry[hash]链表长度大于8,则转换为红黑树
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;
}
}
//...
}
// ...
return null;
}
resize()
源代码:
final Node<K,V>[] resize() {
//....
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 这里采用尾插法
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
//...
}
2. HashTable 效率低下
HashTable容器使用 synchronized
来保证线程安全。但是效率低下!为什么呢?看一下源代码:
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
可以看到此处用synchronized来对访问的线程加锁。
想到一个问题:那多线程怎么保证可以访问到数据呢?
private static class Entry<K,V> implements Map.Entry<K,V>
这样定义,使得数据被加载到方法区中,方法区线程共享。这样就可以访问到数据了!
源码分析
这里又是一个坑!常被问jdk1.6 、1.8的区别?(这里主要分析jdk1.8)
首先jdk1.6使用分段锁,将数据分成16段,这样使得对比HashTable并发量提高了16倍!!!
1.我们先看一下jdk1.6
结构图及分析:
下面通过源码来看一下如何定位segment?
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
final Segment<K,V> segmentFor(int hash) {
// segmentShift为28 segmentMask为15
// 由于segment为16个 所以需要保留四位!
return segments[(hash >>> segmentShift) & segmentMask];
}
使用再散列,减少散列冲突
下面大致说一下确定到segment后,使用ReentrantLock
对Segment
加锁,保证其他进程访问时被Block住,保证Segment之间的同步。
如何统计segment中的大小呢?!
在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
2. jdk 1.8
jdk 1.8 为每个Entry使用sychronized
锁,大大提升了并发效率。
如果有n个Entry的Map,则并发量为n
放上结构图:
上源代码:
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) {
// 使用CAS保证 节点为空时的原子操作
// 使用了Unsafe类的本地方法
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) {
//......
}
}
}
addCount(1L, binCount);
return null;
}
那怎么确保rehash过程中的线程安全呢?
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
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;
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;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
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
}
}
// 如果rehash为空,则使用CAS原子操作来添加节点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
// ...
// 跟HashMap过程类似,通过同步锁来确保不会有数据丢失!
}
}
}
}
分析:
主要看标记的位置,ConcurrentHashMap 在HashMap的基础上 保证了transfer()
过程的线程安全!
二、 ConcurrentLinkedQueue
结构分析
通过ConcurrentLinkedQueue的类图来分析一下它的结构
ConcurrentLinkedQueue由head
节点和tail
节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列。
private static class Node<E> {
volatile E item;
volatile Node<E> next;
Node(E item) {
UNSAFE.putObject(this, itemOffset, item);
}
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
void lazySetNext(Node<E> val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
//....
}
private transient volatile Node<E> head;
可以看出Node()
使用CAS保证原子性
源码分析
-
入队操作源码:
public boolean offer(E e) { if (e == null) throw new NullPointerException(); // 入队前,创建一个入队节点 Node<E> n = new Node<E>(e); retry: // 死循环,入队不成功反复入队。 for (;;) { // 创建一个指向tail节点的引用 Node<E> t = tail; // p用来表示队列的尾节点,默认情况下等于tail节点。 Node<E> p = t; for (int hops = 0; ; hops++) { // 获得p节点的下一个节点。 Node<E> next = succ(p); // next节点不为空,说明p不是尾节点,需要更新p后在将它指向next节点 if (next != null) { // 循环了两次及其以上,并且当前节点还是不等于尾节点 if (hops > HOPS && t != tail) continue retry; p = next; } // 如果p是尾节点,则设置p节点的next节点为入队节点。 else if (p.casNext(null, n)) { /*如果tail节点有大于等于1个next节点,则将入队节点设置成tail节点, 更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点*/ if (hops >= HOPS) casTail(t, n); // 更新tail节点,允许失败 return true; } // p有next节点,表示p的next节点是尾节点,则重新设置p节点 else { p = succ(p); } } } }
分析: 使用循环加CAS传统操作保证线程安全
-
定位尾节点
tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点!
final Node<E> succ(Node<E> p) { Node<E> next = p.getNext(); return (p == next) head : next; }