一. Map
1. HashMap
HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
1.1 存储结构
1.1.1 Node 结点
HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的 ,当链表长度超过8 并且数组长度超过64,就会将链表转化成红黑树提高查询效率。
阅读源码可知,transient Node<K,V>[] table; 是存储内容的重要字段,所以要理清 Node 是什么
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
- Node 是HashMap 中的静态内部类,用于存储键值对
- 字段存储了该节点的 hash 值,key 和 value 值 以及下一个结点引用
- 重写了hashCode() 方法 和 equals () 方法。
为什么重写 equals 时必须重写 hashCode 方法?
- 哈希表通过 hashCode 确定桶下标位置来减少 equals 的判断。提高哈希表的存取速度。
- 如果没有重写 hashCode 那么,所有对象都会调用Object 的hashCode 方法,即所有对象的 hashCode 都是不一样的,即使它们指向的是同样的数据。
- 另外规定,hashCode 相等,equals 不一定相等,equals 相等,hashCode则一定相等。不重写hashCode显然违背该特性。
- 如果不重写 hashcode 则在存储的时候不会通过 equals 判断直接加进哈希表,导致存入很多相同数据的对象,取出的时候,则 hashcode 无法定位准确的位置。
1.1.2 其他关键字段
哈希桶数组初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为什么容量必须是 2 的 n 次幂
- 为了减少哈希冲突的权衡
- 算取 桶索引的算法是 :(n - 1) & hash 在 n 是 2的n次幂的情况下,实际就是hash % n 这样可以有效减少哈希冲突,并且位移运算 速率快于直接取模
- 2的n次方实际就是1后面n个0,2的n次方-1实际就是n个1;
如果构造函数传入的 初始容量参数不是 2 的 n 次方时
static final int tableSizeFor(int cap) {
int n = cap - 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;
}
会经过这个算法将初识容量转换成大于 传入参数的 最小 2的次方
加载因子和容纳键值对个数
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
默认树化大小
static final int TREEIFY_THRESHOLD = 8; // 默认树化大小
static final int UNTREEIFY_THRESHOLD = 6; // 默认转换回链表大小
TreeNode 占用空间是普通 Node 的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。(8是采用泊松分布运算取得,因为通过散列的哈希值分担之后,每个桶中会有8个元素的概率只有0.00000006)
1.2 Java 7 HashMap 的主要问题
- ① 扩容产生死循环的问题
具体可以看下面这篇博客
简单总结来说就是:
- 当两个线程同时进行 resize 的时候,会创建两个扩容的桶数组进行扩容。
- 如果第一个线程执行的比较快,在第二个线程 transfer 之前完成了整个 transfer 的操作,因为采用的是头插法的操作,所以相应的链表结构会产生一个倒置的现象,再继续通过同样顺序的头插法进行插入,则会产生死循环的问题。
- ② 第二个问题就是采用大量的hashCode相同的键会导致哈希表退化为链表。
1.3 主要方法实现
1.3.1 确定 hash 值 及 哈希桶数组索引
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
i = (n - 1) & hash
- 取得key 的hashCode
- 将hashCode h 和 h 向右移动 16位异或
- 之后和长度 n - 1 按位与运算 获得索引值
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率,并且将负数转化成了整数,减少了哈希冲突。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,hash 值改动不大时,也能保证考虑到高低位都参与到Hash的计算中,减小哈希冲突,同时不会有太大的开销。
1.3.2 put添加方法
// 调用的put 方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 真正做事的 putVal 方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 定义一些局部变量
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
// 判断是否是第一次 put,是否分配过桶数组,没有则进行resize
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// i = (n - 1) & hash 通过该方法计算加入节点的桶下标
// 如果该下标没有数据,则直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
HashMap.Node<K,V> e; K k;
// 如果存在相同的 key 则直接覆盖节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果没有相同的并且,该部分已经变成红黑树,则进入红黑树的插入操作
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 没有变成红黑树的情况
else {
// binCount 代表节点个数,表示遍历节点
for (int binCount = 0; ; ++binCount) {
// 如果下一个是 null 直接插入,代表是尾插
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果插入后达到树化阈值,则进行树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到相同的key 则覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 这主要是帮助 LinkedHashMap 维护一些顺序
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 内部改动次数 + 1
// 该值保证出现并发问题的时候出发 fast-fail抛出异常
++modCount;
// 如果尺寸大于容量,则扩容
if (++size > threshold)
resize();
// 该方法同样留给子类做一些操作
afterNodeInsertion(evict);
return null;
}
总结流程:
- 检查是否桶数组是否分配,如果没有则进入 resize();
- 计算该key的桶下标,如果对应位置为空,则直接插入。
- 如果对应的节点为树节点,则进入红黑树的插入过程。
- 如果是链表,则遍历链表,如果找不到同样的 key 并且到达链表末尾,则插入(尾插),并且长度大于8可能会导致树化。
- 如果找到相同的key则覆盖。
- 添加完最后检查包含键值对的个数是否到达容量阈值,到达则进行 resize();
1.3.3 treeifyBin 树化方法
final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
int n, index; HashMap.Node<K,V> e;
// 如果桶数组是null 或者 桶数组长度小于MIN_TREEIFY_CAPACITY 默认是64 会先进行扩容而不是树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 否则进行树化操作
else if ((e = tab[index = (n - 1) & hash]) != null) {
HashMap.TreeNode<K,V> hd = null, tl = null;
do {
HashMap.TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
1.3.4 resize 扩容方法
final HashMap.Node<K,V>[] resize() {
// 先初始化一些数据
HashMap.Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果旧容量已经超过了最大容量,则设置容纳键值对个数也为最大。
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果扩容为原来的两倍小于最大容量,并且原容量大于了初识容量,
// 则左移一位,扩容为原来的两倍。
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果原容量比0小,但是容纳的键值对不是,则容量改为原来的容纳键值对个数
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 否则,初始化为默认容量。
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 初始化新的容纳键值对个数
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 修改容纳键值对的阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 分配新的桶数组
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
// 如果原先的桶数组不是空的,则需要将旧数据拷贝到新的桶数组中
if (oldTab != null) {
// 遍历键值对
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果只是单个节点,则直接重新计算索引,放进新的桶数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果是树结点,则将红黑树进行拆分,如果确实要树化,在进行树化
else if (e instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 否则将链表插入新的桶数组,并保证顺序
else { // preserve order
// lo 和 hi 分别为两个链表,用来保存原来一个桶中元素被拆分后的两个链表
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
/**
* 检测节点的高位是否是 1
* 是 1 的放入 hi 链表中
* 是 0 的放入 lo 链表中
*/
do {
next = e.next;
// 将哈希值和旧容量取与运算,
// 等于 0 代表原先的散列值是数组长度的偶数倍
// 所以扩容(2 倍)之后,只需要呆在原地
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 如果值为1,则说明原先的散列值是数组长度的奇数倍
// 所以扩容之后,可以放在一个原先长度之后的位置
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 划分完两个链表之后
// lo 链表呆在原来的位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// hi 链表加到【当前下标 + 旧容量】的位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结流程:
- 先分配新的桶数组长度和新的容纳键值对个数阈值。正常扩容为两倍。
- 创建一个新的桶数组。
- 将原键值对拷贝到新的哈希表中,遍历桶数组所有项:
- 如果是单个节点,则直接重新计算下标放入新桶数组。
- 如果是红黑树,则将红黑树拆分为链表,再在必要的时候在树化。
- 如果是链表,则定义两个链表变量,遍历原链表,将hash值与旧长度取与运算,如果等于0,则说明hash值,是原长度的偶数倍,则方在新桶数组的当前下标位置。如果等于1,说明hash值是原长度的奇数倍,则放在新数组的【当前下标 + 旧容量】的位置
1.4 解决哈希冲突的方法
链地址法
:也就是常说的拉链法,也就是 HashMap 使用的方法,将冲突的各个节点通过 next 指针,转换为一个链表。之后的获取操作,先获取对应下标,再遍历链表通过 equals 得到对应的节点。开放定址法
:也就是通过散列函数得到对应下标之后,如果有冲突,则继续向后遍历,找到空闲的位置之后填入。之后的搜索操作也相同,如果对应的位置上不是想要的节点,则向后遍历。再哈希法
:通过多个哈希函数进行下标的计算,第一个计算出的下标如果有冲突,则继续使用下一个哈希函数进行计算,直到放入空闲的位置。建立公共溢出区
:也就是分为一个基础的哈希表和一个溢出区,如果发生哈希冲突则将对应的节点加入到溢出区。
2. ConcurrentHashMap
ConcurrentHashMap 是线程安全的HashMap,又不像 HashTable 一样都通过加互斥锁来同步造成效率低下。
2.1 JDK 7 ConcurrentHashMap
- JDK1.7 ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。
- 也就是ConcurrentHashMap 管理了很多Segment ,Segment 又管理了一些HashEntry,每次到对应segment 中操作时,就进行加锁。
- segment 数量是初始化时 concurrencyLevel 决定的,并且之后不会更改,所以在键值对数量大之后,锁粒度会加大,降低性能。
- 并且ReentrantLock 相比于 synchronized 会更加占用空间。
2.2 JDK 8 ConcurrentHashMap
2.2.1 关键字段
一些和HashMap 一样的就不赘述了
/**
* 默认为 0
* 如果为 -1 则表示正在初始化
* 扩容时,值为 - (1 + 扩容线程数)
* 扩容或初始化完成后,则是下一次扩容的阈值
*/
private transient volatile int sizeCtl;
/**
* 表示是扩容完成的节点
*/
static final int MOVED = -1;
/**
* 表示是树节点
*/
static final int TREEBIN = -2;
/**
* fwd节点,表示已经被多线程处理过
* @param <K>
* @param <V>
*/
static final class ForwardingNode<K,V> extends ConcurrentHashMap.Node<K,V>
2.2.2 主要方法
get 获取方法
public V get(Object key) {
ConcurrentHashMap.Node<K,V>[] tab; ConcurrentHashMap.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
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果头结点就是需要找的key,则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点e的hash值小于0,则说明正在扩容或者是树节点,则调用另外的find方法
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 否则就循环遍历链表得到对应的键值对
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
/**
* 高位和地位异或获取hash值,使得高低位都能参与hash减小hash冲突。
* & HASH_BITS(0x7fffffff)来保证返回值为正数
* @param h
* @return
*/
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
/**
* 该方法获取对应桶数组下标链表的头结点
* @param tab
* @param i
* @param <K>
* @param <V>
* @return
*/
static final <K,V> ConcurrentHashMap.Node<K,V> tabAt(ConcurrentHashMap.Node<K,V>[] tab, int i) {
// 通过 unsafe 类的 getObjectVolatile 方法原子性的获取。
return (ConcurrentHashMap.Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
流程总结:
- get 操作全程不加锁。
- 通过 UnSafe 的 getObjectVolatile 方法原子性地获取对应桶下标的头结点。
put 添加方法
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* 真正执行的添加方法
* @param key
* @param value
* @param onlyIfAbsent 该参数为 true 则表示,只有第一次添加该键值对会加入到 map
* @return
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 同样获取hash值
int hash = spread(key.hashCode());
int binCount = 0;
// 自旋操作
for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {
// 定义一些遍历,f 是头结点;i 是下标;fh 是头节点的hash
ConcurrentHashMap.Node<K,V> f; int n, i, fh;
// 如果桶数组还没有初始化,则初始化桶数组(懒加载)
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 如果桶数组该下标为null,则通过 CAS 创建头结点,成功则break,失败就重新循环重试
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new ConcurrentHashMap.Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果发现头结点hash 等于 MOVED 也就是 -1,则说明在扩容阶段
// 会帮忙进行扩容操作
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 如果上面都没有进入,则进入该过程也就是会加入到该链表(红黑树)中的某个位置
else {
V oldVal = null;
// 给该链表的头结点加上互斥锁
synchronized (f) {
// 再次判断该位置的下标的头结点是不是f,也就是有没有锁错
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
// 自旋遍历链表
for (ConcurrentHashMap.Node<K,V> e = f;; ++binCount) {
K ek;
// 如果找到相同的key 则覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
ConcurrentHashMap.Node<K,V> pred = e;
// 没有找到则在尾部插入
if ((e = e.next) == null) {
pred.next = new ConcurrentHashMap.Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果是红黑树节点,则在红黑树中加入节点
else if (f instanceof ConcurrentHashMap.TreeBin) {
ConcurrentHashMap.Node<K,V> p;
binCount = 2;
if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 如果bin数量超过树化大小,则树化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 更新键值对个数
addCount(1L, binCount);
return null;
}
/**
* 初始化桶数组
* @return
*/
private final ConcurrentHashMap.Node<K,V>[] initTable() {
ConcurrentHashMap.Node<K,V>[] tab; int sc;
// 没有初始化成功就自旋
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl 的值小于 0,则表示有线程在进行初始化。
if ((sc = sizeCtl) < 0)
// yield 让出线程
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")
ConcurrentHashMap.Node<K,V>[] nt = (ConcurrentHashMap.Node<K,V>[])new ConcurrentHashMap.Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
流程总结:
- 还是通过Unsafe原子性的获取对应下标的头结点。
- 如果不存在,直接通过CAS插入,如果在扩容中,则帮助扩容。
- 如果是有一条链表(红黑树)则锁住该链表(红黑树)进行插入。
- 并最终添加键值对个数
addCount 递增键值对个数方法
/**
* 增加键值对个数的方法
* 该方法类似于 LongAdder 的累加方法,通过一个Cells数组,再不同的单元中累加再求和
* @param x
* @param check
*/
private final void addCount(long x, int check) {
ConcurrentHashMap.CounterCell[] as; long b, s;
// 如果 cells 数组已经创建,
// 或者 如果没有创建直接通过 CAS 递增失败了,说明有线程竞争
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
ConcurrentHashMap.CounterCell a; long v; int m;
// true 表示有线程竞争
boolean uncontended = true;
// 如果数组还没有创建
// 或者该线程位置的数组项没有创建
// 或者通过CAS在该数组项添加失败
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 则进入该方法,创建 cells 数组,或者自旋 CAS 递增
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 遍历 cells 数组统计和。
s = sumCount();
}
// 表示一定是一个 put 操作。
if (check >= 0) {
ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;
// 长度大于阈值需要扩容则,进入循环
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// 拿到扩容戳,扩容戳一样的线程才能一起扩容
int rs = resizeStamp(n);
if (sc < 0) {
// 因为一些条件不需要再扩容了,直接break
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 将 sc + 1 成功,说明允许帮助扩容。
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 说明是扩容的第一个线程,则开始扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
transfer扩容方法
/**
* 扩容操作
* @param tab
* @param nextTab
*/
private final void transfer(ConcurrentHashMap.Node<K,V>[] tab, ConcurrentHashMap.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")
ConcurrentHashMap.Node<K,V>[] nt = (ConcurrentHashMap.Node<K,V>[])new ConcurrentHashMap.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;
ConcurrentHashMap.ForwardingNode<K,V> fwd = new ConcurrentHashMap.ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
ConcurrentHashMap.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
}
}
// 如果该位置没有节点,则设置为fwd节点,已经处理过
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 如果是MOVED节点,则说明以及扩容过。
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 如果头结点有数据,则锁住该链表,进行扩容
synchronized (f) {
// 扩容的具体操作和HashMap类似,运用两个链表分离。
if (tabAt(tab, i) == f) {
ConcurrentHashMap.Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
ConcurrentHashMap.Node<K,V> lastRun = f;
for (ConcurrentHashMap.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 (ConcurrentHashMap.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 ConcurrentHashMap.Node<K,V>(ph, pk, pv, ln);
else
hn = new ConcurrentHashMap.Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof ConcurrentHashMap.TreeBin) {
/**
* 如果是红黑树节点则拆分
*/
. . .
}
}
}
}
}
}
3. LinkedHashMap
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
LinkedHashMap 继承自 HashMap,是 HashMap 的一个扩展,所以大体的字段结构都与HashMap 类似,只是在一些功能加上来自己的实现。
3.1 主要属性
大部分的属性都由 HashMap 定义,LinkedHashMap 为了完成链表的功能而加入了一些节点信息。
/**
* LinkedHashMap 的节点类型
* 在 HashMap 的基础上增加来前后指针,让所有节点形成一条双向链表
* @param <K>
* @param <V>
*/
static class Entry<K,V> extends HashMap.Node<K,V> {
LinkedHashMap.Entry<K,V> before, after;
Entry(int hash, K key, V value, HashMap.Node<K,V> next) {
super(hash, key, value, next);
}
}
/**
* 双向链表的头节点,也就是存在最久的节点
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* 双向链表的尾节点,也就是新插入的节点
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* 存取顺序,链表的排序规则
* true 按照访问的顺序排序,也就是每访问一次就会插入到链表的尾部;
* false 按照插入的顺序排序,也就是插入节点之后,位置不会发生变化,直到再次插入;
*/
final boolean accessOrder;
3.2 主要方法
初始化方法
LinkedHashMap 的初始化就是调用父类 HashMap 的初始化,外加对于 accessOrder 属性的设置,默认为 false,也就是按照插入顺序排序。
public LinkedHashMap() {
super();
accessOrder = false;
}
插入方法
LinkedHashMap 的插入还是使用了 HashMap 的 putVal 方法,但是在部分细节上,使用了LinkedHashMap 的具体实现;
- 在插入节点的时候,会使用 newNode 来构造节点,LinkedHashMap 对这个方法做了自己的具体实现:创建一个自己的包含前后指针的节点,并维护链表的头尾节点的关系。也就是把新加入的节点加入到链表尾部。
// 该下表没有节点的情况
tab[i] = newNode(hash, key, value, null);
// 对应下表是链表的情况
p.next = newNode(hash, key, value, null);
HashMap.Node<K,V> newNode(int hash, K key, V value, HashMap.Node<K,V> e) {
// 创建一个包含指针的节点
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
构造链表的关系
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
// 将新加入的节点加入到尾部
p.before = last;
last.after = p;
}
}
看了一些其他操作的源码发现,LinkedHashMap 就是继承自 HashMap 的基础上,构造了一个自己的节点类型,实现了以下三个方法,在插入获取删除的时候,对双向链表进行维护。比较简单,也不再具体分析了。
/**
* 读取节点操作后的维护动作
* 也就是当 accessOrder 设置为 true 的时候,
* 会将被访问的节点更新到链表尾部。
* @param p
*/
void afterNodeAccess(HashMap.Node<K,V> p) { }
/**
* 插入节点后的维护动作
* 插入一个节点之后,将该节点加到链表的尾部
* @param evict
*/
void afterNodeInsertion(boolean evict) { }
/**
* 删除节点的维护动作
* 当一个节点被删除之后,就会在链表中也将该节点删除
* @param p
*/
void afterNodeRemoval(HashMap.Node<K,V> p) { }
二. List
1. CopyOnWriteArrayList
CopyOnWriteArrayList 主要通过写时复制使得迭代器遍历该list的情况下,遭到其他线程的修改不会抛出ConcurrentModificationException 异常。但是修改实时性并不是那么强。
1.1 add 添加方法
- 通过获取全局锁,锁住来保证写入的安全,并且不会在原本的数组上修改,而是拷贝一份,写入完成后再拷贝回去。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
1.2 get 获取方法
- get 方法直接不加锁的获取,实时性不强。
- 但是读操作和写操作不会相互阻塞,只有写操作和写操作会相互阻塞。
private E get(Object[] a, int index) {
return (E) a[index];
}
三. 阻塞队列
1. 概述
阻塞队列是一个支持两个附加操作的队列,即,支持阻塞的插入和移除操作。
- 阻塞的插入:指当队列满时,会阻塞忘队列插入元素的线程,直到队列不满。
- 阻塞的移除:指当队列为空的时候,会阻塞企图删除队列元素的线程,直到队列非空。
这两者的关系正好对应了生产者消费者的场景。
对于这两种附加操作的实现,主要提供了四种处理方式:
- 抛出异常:队列满时添加会抛出 IllegalStateException,而当队列空删除元素时,会抛出 NoSuchElementException 异常。
- 返回特殊值:例如添加操作返回 false,或者移除操作返回 null 等。
- 一直阻塞:队列满时,生产者线程往队列插入元素,则阻塞直到队列可用或者受到中断。同理,队列空时移除操作也会被阻塞。
- 超时退出:在上一个阻塞的基础上增加时间限制,超时则返回。
2. Java 中的阻塞队列
Java 阻塞队列的实现主要包括以下:
2.1 ArrayBlockingQueue
ArrayBlockingQueue 是一个由数组实现的有界阻塞队列。
final Object[] items;
- 以 put 方法为例,阐述阻塞队列如何保证线程安全并且确保阻塞获取。其实就是获取全局锁之后对队列进行操作,队列满则阻塞,生产者生产元素则唤醒等待的消费者。同理,消费的处理逻辑也大同小异。
public void put(E e) throws InterruptedException {
// 校验插入的元素
checkNotNull(e);
// 获取全局锁,放置到栈中的局部变量槽中,读取更快
final ReentrantLock lock = this.lock;
// 可打断的获取锁
lock.lockInterruptibly();
try {
// 循环如果队列满则阻塞,while 防止虚假唤醒
while (count == items.length)
notFull.await();
//
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// 得到数据数组的引用
final Object[] items = this.items;
// 添加元素
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
// 唤醒等待消费的线程
notEmpty.signal();
}
2.2 LinkedBlockingQueue
LinkedBlockingQueue 是一个链表构成的有界阻塞队列,主要的属性如下所示,不难看出,链表为单向链表并且保存虚拟的首尾节点,有界的界限默认为 Integer.MAX_VALUE。
另外一个有意思的现象是,该阻塞队列包含了两把锁,一把用于添加元素的时候锁定,一把用于获取元素的时候锁定。
/**
* 节点类型,单向链表
* @param <E>
*/
static class Node<E> {
E item;
Node<E> next;
Node(E x) { item = x; }
}
/**
* 容量,默认为 Integer.MAX_VALUE
* this(Integer.MAX_VALUE);
*/
private final int capacity;
/**
* 链表的虚拟头节点,数据值一直为 null
*/
transient Node<E> head;
/**
* 链表的尾节点,next值一直为 null
*/
private transient Node<E> last;
/**
* 获取元素的全局锁
*/
private final ReentrantLock takeLock = new ReentrantLock();
/**
* 插入元素的全局锁
*/
private final ReentrantLock putLock = new ReentrantLock();
在获取方法 put 中 只会通过 putLock.lockInterruptibly(); 把 putLock 阻塞,而不会直接阻塞整个队列,获取锁之后就在链表末尾插入节点。
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
notFull.await();
}
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
移除方法的时候同理,只需要获取takeLock,然后再将首元素删除即可。
2.3 PriorityBlockingQueue
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,默认采用升序排列,可以对自定义的类重写compareTo 方法来自定义排序规则或者传入 comparator 比较器。另外,该队列采用的是堆排序的方式。
PriorityBlockingQueue 将元素存储在一个数组中,默认的初始容量是11。
private static final int DEFAULT_INITIAL_CAPACITY = 11;
private transient Object[] queue;
既然是数组,而又是无界的队列,当时需要进行扩容的操作,扩容也是创建一个新的数组,在将原数组拷贝进去。
/**
* 扩容大小是:
* (1)如果原本小于 64,则扩容为原容量的两倍+2
* (2)如果大于等于 64,则扩容为原容量的1.5倍
*/
int newCap = oldCap + ((oldCap < 64) ? (oldCap + 2) : (oldCap >> 1));
2.4 DelayQueue
延时队列支持延时获取元素,内部使用 PriorityQueue 来存储元素,加入的元素必须实现 Delayed 接口。创建元素的时候可以指定多久获取该元素,只有到达时间了才能获取。
public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
implements BlockingQueue<E> {
private final PriorityQueue<E> q = new PriorityQueue<E>();
}
具体的实现就是在添加元素的时候指定需要延时的时间,因为使用优先队列存储元素,所以头元素就是最早到时的元素,如果时间到了直接获取,如果没到的话就通过 available.awaitNanos(delay); 方法阻塞,另外,每次只允许一个线程进行获取。
延时队列的应用有很多:
- 缓存过期的设计,使用延时队列保存缓存元素的有效期,使用一个线程循环查询,从延时队列获取到了元素,则说明到期。
- 定时任务调度,将定时任务和对应的时间加入到延时队列中,一个消费者不断获取任务,获取到则说明到期,则执行。
/**
* 延时队列的获取方法
* @return
* @throws InterruptedException
*/
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
// 全局锁
lock.lockInterruptibly();
try {
for (;;) {
// 获取头节点
E first = q.peek();
if (first == null)
available.await();
else {
// 获取延迟时间
long delay = first.getDelay(NANOSECONDS);
// 如果延迟时间到了直接获取
if (delay <= 0)
return q.poll();
first = null;
// 如果有线程在获取元素则阻塞,也就是一次只能一个线程获取元素
if (leader != null)
available.await();
else {
// 否则将当前线程设置为 leader 线程
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
// 没到时间则阻塞对应的延时时间
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
// 唤醒等待线程以及解锁。
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}
2.5 SynchronousQueue
SynchronousQueue 是一个不存储元素的阻塞队列,也就是一个 put 操作必须对应一个 take 操作。
适合传递性的场景,内部没有锁,通过 CAS 来添加节点,获取节点。没有存取操作对应会一直自旋。
2.6 LinkedTransferQueue
LinkedTransferQueue 是一个由链表结构构成,单向链表,只有一个 next 指针,包含首尾节点的无界阻塞队列,它和其他阻塞队列的主要区别在于:
- 它内部不包含锁字段,通过乐观锁 CAS 来保证线程的安全性,更轻量级。
- 通过一个 xfer 方法,当加入一个元素时,如果有线程在阻塞获取元素,会直接将元素传递,类似于上面的 SynchronousQueue。
2.7 LinkedBlockingDeque
LinkedBlockingDeque 是一个双向的有界阻塞队列,可以在初始化的时候传入容量,因为是双向队列,所以操作更灵活,内部包含了一个全局锁来保证线程安全。