为什么要使用ConcurrentHashMap?
首先我们先讨论一下为什么要使用ConcureentHashMap,为了线程安全?确实是为了线程安全,但不知这个原因,因为HashMap在多线程下不安全,但是HashTable是线程安全的,完全可以使用HashTable,那为什么不呢?这是因为HashTable相比HashMap在所有方法上都添加了synchronized关键字来修饰,即:HashTable是对整个table数组进行了锁定,即在一个线程访问HashTable的同步方法时,另外一个线程访问HashTable的同步方法时便会进入阻塞或轮询状态。这就造成了HashTable在多线程竞争激烈的情况下的效率低下。例如:线程1访问HashTable的put方法,线程2的任何同步方法都不能使用,put、get等方法都不能使用。
而ConcurrentHashMap解决了这两个问题。既然给整个table表加一把锁效率低下,那么把一把锁分为好多把锁,把table数组分段,让每一把锁负责一部分数据,这样如果是访问不同锁的时候就不会产生竞争,提高了效率。这就ConcurrentHashMap的锁分段技术的思想。在jdk1.7中就是采用锁分段技术,ConcurrentHashMap由多个Segment组(Segment下包含很多Node,也就是我们的键值对了),每个Segment都有把锁来实现线程安全,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。具体实现可以搜索相关文章进行学习。
前言
- 在1.8中ConcurrentHashMap仍然保留了segment,源码中注释中写道只是为了兼容以前版本的序列化而申明的类。
- 在1.8中ConcurrentHashMap由数组(Node)+链表+红黑树实现,与HashMap不同的是红黑树对象不是TreeNode,而是用TreeBin进行了封装。而在1.7中,ConcurrentHashMap采用锁分段技术,数组(Segment)+链表的数据结构。下图为1.7的ConcurrentHashMap的实现结构图。
- 1.7中的采用ReentrantLock+Segment+HashEntry,通过ReentrantLock来实现同步,到了1.8版本中synchronized +CAS +Node+红黑树,采用synchronized和大量的CAS操作实现同步和原子性操作。
jdk1.7实现同步和1.8实现同步的方式
-
1.7的分段锁Segment继承于ReentrantLock,所以带有锁功能,保证线程安全,例如当执行put操作时,会进行2次hash,第一次hash定位到Segment位置,第二次定位到HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。此段描述基本来源于【JAVA秒会技术之ConcurrentHashMap】JDK1.7与JDK1.8源码区别
-
前面已经提到,1.8中采用数组+链表+红黑树的数据结构来实现,这和1.8中HashMap的实现很相似,不同之处就是ConcurrentHashMap采用synchronized保证了线程安全以及使用了CAS来保证原子性操作。同样是对put举例,1.8中一次hash定位出数组的索引值table[i],接着使用synchronized来对table[i]进行锁定,保证线程安全,接着判断这个位置是否有元素,如果有,证明这个元素key以及存在,替换并返回旧值,如果为null,直接插入到table[i]这个位置,如果不为null,判断是链表或者红黑树,然后进行插入,这个过程基本和HashMap一致,不过这里的查找插入很多都是采用CAS的原子性操作。
//jdk1.7Segment定义
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile int count;
transient int modCount;
transient int threshold;
final float loadFactor;
transient volatile HashEntry<K,V>[] table;
}
属性介绍
// 最大的table容量2的30次方
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 数组的默认容量大小
private static final int DEFAULT_CAPACITY = 16;
// 数组可能最大值,需要与toArray()相关方法关联
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;
// 在table数组容量小于64时,在链表大于8时也不会转红黑树,而是对数组进行扩容
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
private static int RESIZE_STAMP_BITS = 16;
// 最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
// 节点数组
transient volatile Node<K,V>[] table;
// 控制标识符
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
*当为0时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小*/
private transient volatile int sizeCtl;
Node
Node就是一个链表,可以指向下一个值,只允许查,不允许setValue()。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
// 用于map中的get()方法,子类重写
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
TreeNode
TreeNode继承于Node,但是这是个红黑树的数据结构,在大于8时,Node链表便会转换为红黑树。
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;//红黑树标志
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
Node<K,V> find(int h, Object k) {
return findTreeNode(h, k, null);
}
// 从根节点开始查找,返回查找到的key的TreeNode,没找到返回null
final TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
if (k != null) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk; TreeNode<K,V> q;
TreeNode<K,V> pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
TreeBin
可以看出TreeBin是将TreeNode红黑树进行了封装,增加了一些其他方法。
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; // 已经获得写锁状态
static final int WAITER = 2; // 等待写锁状态
static final int READER = 4; // 增加数据时读锁的状态
// 初始化红黑树
TreeBin(TreeNode<K,V> b) {
super(TREEBIN, null, null, null);
this.first = b;
TreeNode<K,V> r = null;
for (TreeNode<K,V> x = b, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (r == null) {
x.parent = null;
x.red = false;
r = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = r;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
r = balanceInsertion(r, x);
break;
}
}
}
}
this.root = r;
assert checkInvariants(root);
}
// 省略部分code...太多了
}
put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key如果为null抛出空指针异常
if (key == null || value == null) throw new NullPointerException();
// 计算hash值,得到hashCode后再次hash散列
int hash = spread(key.hashCode());
// tab[i]位置的链表元素数量
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();
// 通过hash值与当前数组tab长度进行与,得到数组索引i以及f = 这个位置的Node元素,tabAt为CAS操作
// 如果tab[i]位置为null,那么采用CAS操作创建新节点,将键值对插入tab[i],退出循环
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
}
//检查table[i]的节点的hash是否等于MOVED,如果等于,则检测到正在扩容,则帮助其扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 走到这里说明tab[i]位置有元素,发生了碰撞,而且说明hash值不为MOVED
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;
}
// 如果为null,创建节点,插入到队尾,退出循环
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 走到这里说明是树类型,调用TreeBin的putTreeVal进行替换或者插入节点操作
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);
// 如果oldValue不为空,返回oldValue
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
注释很详细,肯定可以看懂,如果看着有些吃力的话建议先看一下HashMap的put源码HashMap源码学习笔记(jdk1.8),说白了,就是和HashMap的put方法实现几乎一样:
- 都是先计算hash值
- 接着定位数组tab索引
- 判断索引位置元素是否是否为null,为null直接插入元素
- 不为null,如果是链表,遍历链表,如果相同元素已经存在,替换并返回oldValue,如果不存在,那么将新元素插到链表尾部
- 如果是树结构,调用树的相应方法进行操作。
- 最后判断链表是否需要转为红黑树
不同之处在于用hash值定位到数组索引后,如果此位置元素为null,那么尝试CAS进行操作,如果不为空,而且判断正在扩容,那么多个线程帮助一起扩容,如果有这个位置有元素,而且没有在扩容,那么,ConcurrentHashMap为tab[i]进行了synchronized锁定,然后进行后续操作。
在putVal方法中涉及到table数组初始化方法spread()、initTable()、tabAt()、casTabAt()、helpTransfer()、treeifyBin()方法,接下来我们简单看一下具体实现。spread方法时计算hash值的方法,没什么好说的。
initTable方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 当table为null时才进行初始化
while ((tab = table) == null || tab.length == 0) {
// 等sizeCtl小于0的时候表明正在初始化,sizeCtl初始默认状态为0
if ((sc = sizeCtl) < 0)
Thread.yield(); // 线程从运行状态转到可运行状态进行等待
// 否则将sizeCTL采用CAS操作设置为-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 = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
tabAt()、casTabAt()方法
这三个CAS方法在ConcurrentHashMap的实现中用了很多,这是为了保证操作的原子性
// 获取table索引i处的Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// 利用CAS算法设置i位置上的Node节点(将c和table[i]比较,相同则插入v)。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 设置节点位置的值,仅在上锁区被调用
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
helpTransfer方法
在put方法中我们可以看出如果发现正在扩容,那么线程会调用helpTransfer()方法帮助一起扩容,这样效率会更高,也就是只要发现正在扩容,大家一起扩容,而不是等待正在扩容的线程进行扩容,当前线程等待扩容。
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;
}
transfer方法
这是方法才是进行并发扩容并将元素重新散列到新表的关键,方法很长,分析可以看这篇博客,分析的很详细并发编程——ConcurrentHashMap#transfer() 扩容逐行分析,在这里大概说一下并发扩容的关键:
- 将老表拆分,计算每个线程可以处理的桶区间。默认 16。
- 初始化临时变量 nextTable,扩容 2 倍。
- 死循环,计算下标。完成总体判断。
- 如果桶内有数据,同步转移数据。通常会像链表拆成 2 份,在新表中的下标为原索引或者或者原索引+原table长度。
get方法
get方法比较简单,思路就是计算hash值,定位到索引,如果是首节点就返回,如果遇到扩容,就调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回,如果都不是,那么继续往下遍历,匹配则返回。
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());
// 定位到table的索引位置的元素e
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;
}
// 如果当前hash值<0,说明正在扩容,调用ForwardingNode的find方法来定位到nextTable来
//查找,查找到就返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// table[i]位置不为null,遍历查询
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结和思考
就像前面说的,1.7中采用的数据结构是Segment+HashEntry,1.8中采用是Node数组+链表+红黑树。对于线程安全,1.7采用reentrantlock,1.8采用synchronized和CAS。
- 相对1.7来说,1.8降低了锁的粒度,1.7是对Segment加锁,而每个Segment下还有多个HashEntry,这样在访问一个HashEntry时,其他的就无法访问,在1.8中,采用对table数组头结点采用synchronized来实现同步,锁的粒度更低,效率更高。
- 与1.8HashMap一样,同样采用数组+链表+红黑树,在hash碰撞严重的时候,大大提高了查询效率。
- 1.7中必须进行2次hash,第一次hash定位到是哪个segment,第二次hash定位是哪个HashEntry。在1.8中通过链表加红黑树的形式弥补了put、get时的性能差距。
JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock?
对于这个问题,这篇博客ConcurrentHashMap(JDK1.8)为什么要放弃Segment给出了以下解答
- 减少内存开销 假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
- 获得JVM的支持 可重入锁毕竟是API这个级别的,后续的性能优化空间很小。 synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。
文章如有错误麻烦能够指出,谢谢
参考文献:
[1]https://blog.csdn.net/u010412719/article/details/52145145
[2]https://blog.csdn.net/qq296398300/article/details/79074239