1.8 HashMap分析
1.8中的HashMap不同点有:
数据结构:采用的是数组+链表+红黑树,当链表长度上已经有8个元素了,put第9个元素时,链表变为红黑树
put()方法:在put()操作时采取的尾插法(1.7是头插法),1.8中采用尾插法
扩容条件:1.8中if(++size>threshoId)而1.7中if ((size >= threshold) && (null != table[bucketIndex]))
扩容过程:1.7中在转移元素时是一个一个转移,并采用头插法形式(这会导致多线程下扩容造成死循环)。1.8中分链表转移和红黑树转移,这两个转移共同点是,已拆分成两个链表,然后一个链表整体转移,具体实现看下文。
1,红黑树
我们都知道,红黑树是一种特殊的二叉树。二叉树都有一个特点,逐条插入数据时,左边的子节点小于父节点,右边的子节点大于父节点。红黑树除了这个特征外还有以下特征:
特征1. 结点是红色或黑色。
特征2. 根结点是黑色。
特征3.所有叶子都是黑色。(叶子是Null结点)
特征4. 每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
特征5.. 从任一节结点其每个叶子的所有路径都包含相同数目的黑色结点。
这些约束强制了红黑树的关键性质: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这个树大致上是平衡的。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树
这里有一个学习数据结构的网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
红黑树在插入数据时有以下规律:先调整最下面的子孙三代符合红黑树5个特征,再递归往上面调整一直到root节点符合红黑树5个特征
(1) 新数据进来时一开始默认是红节点
(2) 父亲节点是黑色的:不用调整
(3) 父亲节点是红色:
叔叔是空的,旋转+变色
叔叔是红色,父节点+叔叔节点变黑色,祖父节点变为红色
叔叔是黑色,旋转+变色
2,源码分析
我们先看看HashMap中红黑树TreeNode这个类:我们可以看到有以下几个属性,
static final class TreeNode<K,V> extends LinkedHashMap.Entry<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) {
super(hash, key, val, next);
}
而且继承了LinkedHashMap,LinkedHashMap又继承了HashMap。我们都知道,HashMap中有Node<K,V>静态内部类:(1.7中存的叫Entry<K,V>,属性一样,只是命名不同)
put()方法分析:过程和1.7差不多,就多了一个红黑树的操作
看下这几个参数代表的含义:
/**
*
* @param hash 由key计算出来的 hash值
* @param key 要存储的key
* @param value 要存储的value
* @param onlyIfAbsent 如果当前位置已存在一个值,是否替换,false是替换,true是不替换
* @param evict 表是否在创建模式,如果为false,则表是在创建模式。
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict)
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)
//对tab数组的初始化,初始化和扩都写在resize()方法中
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//
//当前key算出数组下标位置为空,直接new一个新Node<K,V>节点
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;
else if (p instanceof TreeNode)//判断是否为红黑树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {//遍历该位置的链表
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果长度超过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;
}
}
//如果有重复的key,则覆盖value,并把老值返回
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)//扩容判断
resize();
afterNodeInsertion(evict);
return null;
}
我们看看变红黑树这个方法treeifyBin(tab, hash):链表长度大于8只是个前提条件,我们会发现还必须满足数组长度>=64。否则进行扩容而非转红黑树。(因为转红黑树的目的为了缩短链表长度,而扩容重新计算hash也能达到这个效果)
我们在看下treeify(tab)这个方法:
我们看看moveRootToFront(tab,root)这个方法:
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
//把root节点移到双向链表的第一个位置
if (root != first) {
Node<K,V> rn;
//把红黑树放到数组下标位置
tab[index] = root;
TreeNode<K,V> rp = root.prev;
if ((rn = root.next) != null)
((TreeNode<K,V>)rn).prev = rp;
if (rp != null)
rp.next = rn;
if (first != null)
first.prev = root;
root.next = first;
root.prev = null;
}
assert checkInvariants(root);
}
}
红黑树转变过程如下:
put<K,V>流程图:
resize()扩容方法分析:这个方法分为两部分,初始化和扩容
真正的扩容操作:分两种情况转移Node<K,V>对象,一种是链表的转移,另一种是红黑树的转移。
链表的转移:
大致如下:
红黑树的转移:TreeNode<K,V>里有 TreeNode<K,V> pre节点,也有Node<K,V> next节点,有隐藏的双向链表性质,遍历较为方便。
这里在将红黑树拆分成两个TreeNode<K,V>节点时,只对TreeNode<K,V> pre,Node<K,V>赋值,而left,right是没有赋值的。
总结:
- 添加时,当桶中链表个数超过 8 时会转换成红黑树;
- 删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构;
- 查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差。
1.8 ConcurrentHashMap分析
1.8中的ConcurrentHashMap不同点有:
(1)内部存储数据不同:取消了Segment<K,V>[]数组(1.7中是两个数组)
(2)数据结构不同:增加了红黑树(数组+链表+红黑树),这与1.8HashMap又稍微有点不同。他红黑树时封装在TreeBin<K,V>中(HashMap是封装在TreeNode<K,V>中),TreeBin里面包含了TreeNode<K,V>属性,红黑树的实现还是在TreeNode<K,V>中,只是多封装了一层TreeBin。(这样设计的目的是:synchronized(TreeBin<K,V>),这个永远不变,而synchronized(TreeNode<K,V>),红黑树的根节点会随着数据的插入而发生变化)
(3)加锁方式不同:1.7采用分段锁(每个Segment<K,V>一个锁),1.8中是利用CAS(自旋锁)和synchronized
(4)扩容的过程不同:1.8中取消了Segment[]的数组,扩容是针对整个table(1.7是单线程Segment内部扩容,多线程对多个Segment扩容,互不影响),而1.8中支持多线程对table同时扩容
1,源码分析
默认ConcurrentHashMap cmap = new ConcurrentHashMap();创建了一个空map,put()操作时进行初始化
/**
* Creates a new, empty map with the default initial table size (16).
*/
public ConcurrentHashMap() {
}
如果我们自己传参数:ConcurrentHashMap cmap = new ConcurrentHashMap(65,0.75f,16);
我们直接来看put()方法:注意看for()循环里面的if分支,每个线程进来只可能走其中一个分支,但每个if的判断条件还是会走
这里讲一下put()放元素时是通过synchronize(f)来保证线程安全:锁的是table[i]位置的对象。锁的这个对象f两种情况(如果该位置是链表,就是链表的头结点对象),如果是红黑树,锁的就是TreeBin对象。我们来看看TreeBin对象里的属性:这里跟1.7不一样,1.7是存的是 TreeNode对象(我们都知道,树化的过程根节点(TreeNode<K,V> root 是可能会变的。所以1.8在synchronize(f)把红黑树封装在TreeBin对象里,以保证锁的对象不变)
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; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
我们先看初始化的方法initTable():
/**
*sizeCtl默认为0
*-1代表正在初始化
*还有代表阈值
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
//当前线程让出CPU时间,重新竞争
Thread.yield(); // lost initialization race; just spin
//CAS算法保证只有一个线程进来进行初始化
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);//0.75n 即阈值
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
接下来看下树化这个方法treeifyBin(table,i):
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//和hashmap一样,数组长度小于64则扩容不进行树化
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
//进行树化
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
我们再看看addCount(1,binCount)方法;多线程下怎么对数量加一的
我们来看看fullAddCount()方法:这个方法就是每put成功一次进行加一,对ConcurrentHashMap里面数量的统计。这个方法的大概思路就是利用自旋锁(死循环+CAS)的方式对binCount加一或者CounterCell属性里面的value值加一。就是说多线程竞争给baseCount加一失败,会生成CounterCell数组,然后对CounterCell里面的value加一。(每个线程会生成一个随机数,然后进行 随机数&length-1 计算出下标,然后对立面的属性value加一)。这样子设计就有从一开始很多个线程竞争baseCounter,然后分散到CounterCell[]数组上(可能会出翔两个线程算出同一个下标,竞争同一个CounterCell竞争,失败的又重新循环。里面的代码是for(;;),知道每个线程加成功)。
所以我们可以看到计算ConcurrentHashMap里面元素总个数的方法是这样的:baseCount+遍历CounterCell数组里面ConterCell的value值
我们再看看扩容的逻辑:
我们再看put方法里面的帮助扩容方法也是一样:
如何判断扩容工作完成呢,transfer里有一个判断:
看转移元素transfer()方法:这里先讲下整个逻辑
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//算出stride步长(即每个线程需要转移元素的范围),最小16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//翻倍扩容并赋值给nextTab
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;
//重要的属性fwd:当老数组这个位置的元素被转走了(或被线程判断了为空,不需要转移),会在这
个位置放入fwd。如果一个线程在
put()时,发现put的位置存的是fwd,则该线程会去帮助扩容(转移元素)
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//当前这个线程是否继续往前面找元素
boolean advance = true;
//当前这个线程转移工作是否完成(注:不是整个扩容工作)
boolean finishing = false; // to ensure sweep before committing nextTab
//i,bound老数组下标位置,控制上面算的数组步长,从右往左移转移元素
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;
}
//通过CAS算法(改bound值)保证每个线程算出的数组下标不同,即保证每个线程转移不同
范围的元素到新数组
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
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);//该位置为null变为fwd
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {//加锁,开始真正的转移操作
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//链表的转移,和1.7的ConcurrentHashMap一样,可以看我上 一篇文章
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);//第i个位置转移后设置为fwd
advance = true;
}
//红黑树的转移,和1.8的HashMap转移红黑树一样
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);//第i个位置转移完成后设置fwd
advance = true;
}
}
}
}
}
}
/重要的属性:
fwd:当老数组这个位置的元素被转走了(或被线程判断了为空,不需要转移),会在这 个位置放入fwd。如果一个线程在put()时,发现put的位置存的是fwd,则该线程会去帮助扩容(转移元素)
boolean advance:当前这个线程是否继续往前面找元素
boolean finishing:当前这个线程步长区间转移工作是否完成(注:不是整个扩容工作)
通过CAS算法(改bound值)保证每个线程算出的数组下标不同,即保证每个线程转移不同范围的元素到新数组
总结:ConcurrentHashMap扩容原理:支持多线程扩容,对于每一个线程来说会计算出需要去转移的位置元素(步长范围,最小16),转移过程中会对该位置对象加锁,其他位置可以进行put()不影响,转移完后释放锁,并放入ForwardingNode<K,V>对象到该位置,从右往左继续往前进行转移。对于当前线程如果把这个区域的元素都转移完了,就会去看是不是还有其他区域需要帮助转移,有就继续,没有就退出。等待其他线程扩容完成。最后把新数组赋值给table。