作为并发版本的HashMap
,它的很多特性都是和HashMap
一样的, 那么在这里我主要还是从源码的层面解析一下,然后看看和HashMap
有哪些不同的地方,同时怎么保证线程安全的。
ConcurrentHashMap解析
我在写的时候发现里面新增了很多属性,而且这些属性很晦涩,所以为了便于理解,我不会先解释属性的作用,还是从方法入手,然后逐步的扩展解释各种对比HashMap
新增的属性的作用。
ConcurrentHashMap的构造方法解析
java
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
//构造方法很简单,就是计算容量,然后将容量赋值给sizeCtl,这个时候后面说做什么的
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
//这里传入参数和HashMap不同,HashMap是initialCapacity
//主要就是考虑并发情况下table扩容的效率问题,比HashMap的初始容量要大
//就是初始容量+初始容量向右移动一位+1 = 初始容量*1.5+1
//空间更大,会降低一些扩容的几率,提高性能
//tableSizeFor方法和Hash Map一样,都是传入参数最近的比它大的2的幂次倍
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
重头戏:putVal方法
这里面将会涉及到并发情况下的扩容处理,维持数据一致性问题,怕说起来没完,具体的扩容方法不再这里展示,还是2倍,但是实现细节都是针对多线程的情况,加了sychronized
,我这里只循着主线看。
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//这个计算Hash的方式和HashMap是不一样的,(h ^ (h >>> 16)) & HASH_BITS;
//HASH_BITS这个值是0x7fffffff,转为二进制就是
//2^31-1,二进制就是首位0,其他的都是1。所以这样的计算结果就会发现
//如果原hashcode是负数,那么就会将最高位设置成0,也就是正数,然后
//其他位保持不变,负数在和长度-1进行&运算的时候会和正数有差异
//会导致向左移动,在并发的环境下更容易造成hash冲突。所以加了这样一个变化
int hash = spread(key.hashCode());
int binCount = 0;
//循环table
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果table为空,就初始化,然后直接转移到下面看初始化的方法------------------1,做个标记一会回来
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//长度-1 &hash计算索引地址然后调用tabAt方法一行代码,信息量很大
//Unsafe包的getObjectVolatile方法,防止指令重排弄乱结果
//(Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
//然后ASHIFT和ABASE这个常量是啥,
//首先我们需要先普及一点小小的知识,我们通常使用数据进行随机访问的时候,无非就是如tab[1]这样
//但是对于计算机来说,这样并不能确定数据在内存的具体位置,毕竟你只传入了一个index
//而数组在计算机内存中是一段连续的存储空间,所以就需要起始地址+偏移量来确定
//所以就需要一个寻址公式:就是起始地址+索引*数据类型大小,这个数据类型大小就是根据不同类型决定的
//比如int类型是4个字节,byte类型是一个字节这样,所以记住这个公式,我们继续说这两个参数
//arrayBaseOffset这个方法就是获取数组的起始地址的,在Java中这个方法如果获取数组的类型是基础数据类型
//比如int,float等等还包括Object,String,那么返回的起始地址都是16
//毕竟对象头大小在64为操作系统中是16个字节,方便对齐。
//假设我们存储的是基础数据类型那么ABASE就=16,标记一下
//Class<?> ak = Node[].class;
//ABASE = U.arrayBaseOffset(ak);
//这个就是获取数据类型大小的比如,byte是1,int是4,double是8,这有基础数据类型是这样的
//如果我们是一个Object数组,那么返回值都是4,因为对象格式是4个字节对齐的。
//而且如果是对象数组的话,那么存储的是对象的引用,别在这扯什么对象很大的事
//int scale = U.arrayIndexScale(ak);
// if ((scale & (scale - 1)) != 0)
// throw new Error("data type scale not a power of two");
//Integer.numberOfLeadingZeros这个是计算值的二进制有多少个0的,现在我们求出来scale是4了
//4的二进制就是00000...0000100,1前面有20个0
//所以ASHIFT = 31-29 =2 再标记一下 现在我们回到tabAt方法
// ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
//计算方式是((long)i << ASHIFT) + ABASE 所以向左移动2位,然后+起始地址,向左移动2位就=*4
//我们再找到上面说的寻址公式:地址 = 起始地址+索引*数据类型大小,闭环了。所以在tabAt中
//使用getObjectVolatile就是使用上面这种方式获取的数据,同时能保证多线程环境下的一致性。
//我会在多线程的篇章中详细的介绍Volatile
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果计算的索引地址没有数据,那么,再通过CAS将数据创建个节点然后放入到索引位置
//放入的方式和tabAt类型,一样通过相同的内存计算方式放入,就不再说了
//如果CAS失败了,不会break,继续这个循环,直到put成功!!
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//这里这么判断需要对扩容有一些了解,当有线程对table进行扩容的时候
//会将所有的节点转为ForwardingNode类型,然后将所有几点的hash指定为MOVED也就是-1.以此来代表
//table正在扩容中,需要到新的table中查找数据。扩容的方法是transfer
//其实也就是一定,大家可以自己看一看
else if ((fh = f.hash) == MOVED)
//所以就需要去帮助扩容,也就是将节点移动值新的table中,而进行扩容的时候sizeCtl就记录
//帮助扩容的线程数,同时是负数,-N代表有N-1个线程正在进行扩容
//这个方法没什么说的,就是里面一堆判断然后调用transfer方法
tab = helpTransfer(tab, f);
//到这里终于可以put数据了
else {
V oldVal = null;
//毫无例外,加锁
synchronized (f) {
//二次判断,必须要保证持有锁之后数据也是一样的
if (tabAt(tab, i) == f) {
//fh就是记录的hash,>0说明hash是对的,没有正在进行移动
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;
}
}
}
//这里需要说一点的是,TreeBin就是红黑树结构,当进行树化的时候会将
//所有节点的hash都变成-2,用来标识,
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) {
//链表长度>8 就树化,里面有对数组长度的判断
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
initTable方法解析--sizeCtl初露锋芒
java
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//判断sizeCtl是否小于0,我们看到上面的构造方法中将扩容之后的容量给到了sizeCtl,默认值是0,sizeCtl是个特殊的情况
//当出现这样的情况下,就建议CPU让出资源,让其他线程优先执行
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//我们看一下SIZECTL是哪里来的,还是个常量
//private static final long SIZECTL;
// 这个常量是通过Unsafe反射拿到类属性sizeCtl的值,也就是初始化的时候的sizeCtl的值
// U = sun.misc.Unsafe.getUnsafe();
// Class<?> k = ConcurrentHashMap.class;
// SIZECTL = U.objectFieldOffset
// (k.getDeclaredField("sizeCtl"));
//所以到这里我们可以猜测一下,sizeCtl 是干啥的,首先它是volatile的,那么它一定是独立线程之外的东西,去记载某些东西
//我们知道多线程中有一个ctl,来记录线程池状态的,那么这个sizeCtl是不是也是和记录状态相关,和size挂上,那么是不是和扩容状态相关?
//我们往下找推测(虽然源码给注释了,但还是想自己推测一下作用,记忆深刻)
//compareAndSwapInt这个方法的第二个参数是内存偏移量,而这个常量确实是sizeCtl字段的内存偏移量
//所以这个方法就是去SIZECTL的内存偏移量找数据,然后和sc对比,
//如果相等 ,然后就将SIZECTL偏移量位置的数据,也就是sizeCtl设置为-1,返回true,
//否则不动,返回false,就是CAS操作,所以sizeCtl =-1的时候说明table正在初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//如果table为空的时候
if ((tab = table) == null || tab.length == 0) {
//如果sc>0,sc给n,否则n设置为默认容量16
//而从上面的构造方法中我们发现sc经过计算是>0的,而有一种情况,就是调用ConcurrentHashMap的无参构造方法
//这个时候就是0了,然后就取默认容量了,然后创建个这个长度的数组,给table,table就初始化好了
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//然后sc设置为数组长度的四分之三,也就是0.75。嗯?变成了扩容的阈值了
sc = n - (n >>> 2);
}
} finally {
//这个时候将sizeCtl赋值为sc,这个方法是初始化table,如果刚开始都是0的时候sizeCtl就是扩容阈值了,同时SIZECTL = -1
//如果不是的话,sizeCtl,就取sc的值
sizeCtl = sc;
}
//回去接着看putVal
break;
}
}
return tab;
}
这个初始化的方法中sizeCtl
我们可以看出,如果它>0的时候就表示已经初始化过了,等于阈值。如果=0的时候那么就是还没有初始化,等于-1的时候就是正在初始化。这一下出来三种状态的表示了,还有一状态在putVal
注释中写了。
小总结: : 虽然我没有一行一行代码的进行分析,只分析了一些我们没有在HashMap
中见到的代码,从这些代码中我们可以发现ConcurrentHashMap
能保证并发下的数据一致性就是依赖于锁,也就是sychronized
,而且细化到了节点,摒弃了1.7中分段锁的概念,很大程度的提升了性能,节省了锁的开销,同时用大量的CAS操作来保证数据的一致性,而扩容的算法,计算index
等都是相同的算法。比如里面有个非常好的思路,如果你查找数据时候发现正在扩容,那么就帮助扩容,自己将自己需要的数据移动到新的数组中,然后返回。get
方法我就不列出来了,因为很多代码都和putVal
重复,毕竟更新也许需要先查找的,大家可以自己看一看。对于并发容器最核心的是对多线层的理解,后面我会出文章仔细分析Java的多线程和相关的池化技术。
LinkedHashMap解析
在这里我可以简单的说一下,不管是ConcurrentHashMap
还是LinkedHashMap
其实底层的思想都是HashMap
,弄懂HashMap
,那么这两个容器对我们来说都是进行扩展,只不过在展现形式上不同。大家可以看我的这篇文章# HashMap很细的分析--令人发指,比如ConcurrentHashMap
完善了多线程环境下的数据一致性问题,而LinkedHashMap
也有它的优势,比如它可以按照数据插入的顺序进行访问,支持按照元素的访问顺序进行排序(可以作为LRU容器)等。那么我也是和上面一样,从源码的角度分析一下LinkedHashMap
。
LinkedHashMap的结构
相对于HashMap
,它的结构是一个双向链表,我们上面的特性说过了,它支持按照插入顺序进行遍历,但是呢HashMap
本身是不支持的,所以就需要扩展存储数据的节点,而它的双向链表就体现出来了,看下面它的结构,它继承了Node
,然后定义了before
和after
,这样就能使链表具备双向的能力了。
java
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
LinkedHashMap get方法解析
相对于ConcurrentHashMap
,LinkedHashMap
真的是非常简单的,连Put
方法都是使用父类HashMap
的,那么我们看一下get
方法的源码
java
public V get(Object key) {
Node<K,V> e;
//直接调用了HashMap的getNode方法,脸都不要了
if ((e = getNode(hash(key), key)) == null)
return null;
//这里我们猜都能猜出来是干啥的,accessOrder就是访问顺序的意思,也就是如果你的LinedHash
//是支持访问顺序排序的话,那么这个参数就是true,那我们也就知道,这个方法就是维护访问的
//作用就是将访问的元素移动到链表末尾
//因为是尾插法,按照插入顺序访问就直接从尾开始,这就会有疑问,每个桶都有链表
//咋能知道拿个链表的尾部是真正最后插入的数据节点呢?看下面的方法
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
LinkedHashMap
重写了newNode
的方法,在linkNodeLast
方法为Entry
指定了before
和after
java
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
java
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
//因为插入的都在链表的尾部
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
//如果尾部位空,说明是个新节点,before和after都没有,毕竟就自己一个节点
if (last == null)
head = p;
else {
//否则尾部的after就是新节点,新节点的before就是尾部,双向了吧
p.before = last;
last.after = p;
}
}
LinkedHashMap对于插入和删除节点时的维护解析
如果看过我写HashMap
解析的时候应该看到我在put
方法中有一个方法的注释写的是HashMap
是空实现,LinkedHashMap
会使用,这个方法就是afterNodeInsertion
,插入节点的后处理方法,这个就是个钩子方法,也叫做模版方法。
afterNodeInsertion方法解析
java
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
//removeEldestEntry如果自己实现LRU的时候需要重写,就是逐出数据的条件
if (evict && (first = head) != null && removeEldestEntry(first)) {
//移除头节点
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
afterNodeRemoval方法解析
这个也是模版方法,在HashMap
移除节点的时候调用,看注释吧
java
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
//把p的before和after都设置null,断开联系
p.before = p.after = null;
//下面就是干一件事,删除节点之后得前后连起来。
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}