常用集合特点
List
ArrayList:线程不安全,底层实现数组
LinkedList:线程不安全,底层实现双向链表
Set
HashSet:非线程安全,特点不允许重复元素,类中包含HashMap属性,存储元素都放到HashMap集合中,底层实现实际上就是一个HashMap
TreeSet:非线程安全,特点不允许重复元素,可以排序类中包含NavigableMap接口属性,具体实例为TreeMap对象,底层实现实际上是一个TreeMap
LinkedHashSet:非线程安全,特点值允许为空,不允许重复元素,有序。类中包含LinkedHashMap属性,数据都存储在LinkedHashMap上,底层实现实际上是一个LinkedHashMap
Map
HashMap:非线程安全,基于数组、链表、红黑树实现,特点无序,链表长度大于8时会转换为红黑树
TreeMap:非线程安全,基于红黑树NavigableMap的实现,特点可根据键的自然顺序排序,也可以根据创建映射时提供的Comparator进行排序
LinkedHashMap:线程不安全,是一个有序map集合,默认按照key,value插入元素顺序排序,也支持访问顺序。LinkedHashMap继承HashMap,通过HashMap+双向链表的方式实现,在put数据的时候,会把key,value放到HashMap中去,还会加入到双向链表中。
HashTable:线程安全,底层实现数组+链表+红黑树,和HashMap的原理一样,区别在于put、clean、remove等方法增加了同步(synchronized)机制
List和Set一般使用场景
ConcurrentHashMap JDK1.8分析
历史版本的实现演变
首先说下jdk1.7采用分段锁技术,整个Hash表被分为多个段,每个段中会对应一个Segment段锁,段与段之间可以并发访问,但是多线程想要操作同一个段是需要获取锁的。所有的put、get、remove等方法都是根据键的hash值对应到对应的段中,然后尝试获取锁进行访问。
jdk1.8取消了基于Segment的分段锁思想,改用CAS+synchronized控制并发操作,在某些方面提升了性能。并且追随1.8版本的HashMap底层实现,使用数组+链表+红黑树进行数据存储。
重要成员属性介绍
/**
* The array of bins. Lazily initialized upon first insertion.
* Size is always a power of two. Accessed directly by iterators.
*/
transient volatile Node<K,V>[] table;
和 HashMap 中的语义一样,代表整个哈希表 ,用于存储整个值
/**
* The next table to use; non-null only while resizing.
*/
private transient volatile Node<K,V>[] nextTable;
这是一个连接表,用于哈希表扩容,扩容完成后会被重置为 null
private transient volatile long baseCount;
该属性保存着整个哈希表中存储的所有的结点的个数总和,有点类似于 HashMap 的 size 属性。
private transient volatile int sizeCtl;
这是属性无论是初始化哈希表,还是扩容 rehash 的过程,都是需要依赖这个关键属性的。该属性有以下几种取值:
- 0:默认值
- -1:代表哈希表正在进行初始化
- 大于0:相当于HashMap中的threshold,表示阈值
- 小于-1:代表有多个线程正在进行扩容
该属性的使用还是有点复杂的,在我们分析扩容源码的时候再给予更加详尽的描述,此处了解其可取的几个值都分别代表着什么样的含义即可。
构造函数的实现也和 HashMap 的实现类似,此处不再赘述,贴出源码供比较。
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
其他常用的方法我们将在文末进行简单介绍,下面我们主要来分析下 ConcurrentHashMap 的一个核心方法 put,我们也会一并解决掉该方法中涉及到的扩容、辅助扩容,初始化哈希表等方法。
put方法实现并发添加
对于 HashMap 来说,多线程并发添加元素会导致数据丢失等并发问题,那么 ConcurrentHashMap 又是如何做到并发添加的呢?
public V put(K key, V value) {
return putVal(key, value, false);
}
put()会转发到putValue()上,我们分两步对putVal()方法进行分析
//第一部分
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;
//遍历table进处理,自旋操作,出现线程竞争时不断自旋
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果哈希表还未初始化,那么初始化它
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 初始化数组
//根据键的 hash 值找到哈希数组下标得到相应的Node节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组下标得到的Node节点为空,直接通过CAS将key,value值封装为node节点插入数组
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
这里需要详细说明的只有 initTable 方法,这是一个初始化哈希表的操作,它同时只允许一个线程进行初始化操作
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//如果表为空才进行初始化操作
while ((tab = table) == null || tab.length == 0) {
//sizeCtl 小于零说明已经有线程正在进行初始化操作
//当前线程应该放弃 CPU 的使用
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//否则说明还未有线程对表进行初始化,那么本线程就来做这个工作
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//保险起见,再次判断下表是否为空
try {
if ((tab = table) == null || tab.length == 0) {
//sc 大于零说明容量已经初始化了,否则使用默认容量
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//根据容量构建数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
//将数组赋值给table
table = tab = nt;
//计算阈值(下一次扩容的大小),等效于 n*0.75
sc = n - (n >>> 2);
}
} finally {
//设置阈值
sizeCtl = sc;
}
break;
}
}
return tab;
}
关于 initTable 方法的每一步实现都已经给出注释,该方法的核心思想就是,只允许一个线程对表进行初始化,如果不巧有其他线程进来了,那么会让其他线程交出 CPU 等待下次系统调度。这样,保证了表同时只会被一个线程初始化
接着,我们回到 putVal 方法,这样的话,我们第一部分的 putVal 源码就分析结束了,下面我们看后一部分的源码:
//检测到桶结点是 ForwardingNode 类型,协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//桶结点是普通的结点,锁住该桶头结点并试图在该链表的尾部添加一个节点
else {
V oldVal = null;
// 锁定Node头节点
synchronized (f) {
if (tabAt(tab, i) == f) {
//向普通的链表中添加元素,无需赘述
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// map的key一样的直接替换value值
if (e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
// 新老的value值替换为新的value值
e.val = value;
break;
}
// 老的Node节点,既hash计算出来通过数组下标取出的节点
Node<K,V> pred = e;
// 老Node节点的下一节(next)点为null,说明就是链表的最后一个元素
// 这里需要将当前的节点放到链表最后一个元素的next上
if ((e = e.next) == null) {
// 将当前key,value的值放到老节点的next节点上
pred.next = new Node<K,V>(hash, key,value, null);
break;
}
}
}
//向红黑树中添加元素,TreeBin 结点的hash值为TREEBIN(-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;
}
}
}
}
//binCount != 0 说明向链表或者红黑树中添加或修改一个节点成功
//binCount == 0 说明 put 操作将一个新节点添加成为某个桶的首节点
if (binCount != 0) {
//链表深度超过 8 转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
//oldVal != null 说明此次操作是修改操作
//直接返回旧值即可,无需做下面的扩容边界检查
if (oldVal != null)
return oldVal;
break;
}
}
}
//CAS 式更新baseCount,并判断是否需要扩容
addCount(1L, binCount);
//程序走到这一步说明此次 put 操作是一个添加操作,否则早就 return 返回了
return null;
这一部分的源码大体上已如注释所描述,至此整个 putVal 方法的大体逻辑实现相信你也已经清晰了,好好回味一下。下面我们对这部分中的某些方法的实现细节再做一些深入学习
首先需要介绍一下,ForwardingNode 这个节点类型
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
//注意这里
super(MOVED, null, null, null);
this.nextTable = tab;
}
//省略其 find 方法
}
这个节点内部保存了一 nextTable 引用,它指向一张 hash 表。在扩容操作中,我们需要对每个桶中的结点进行分离和转移,如果某个桶结点中所有节点都已经迁移完成了(已经被转移到新表 nextTable 中了),那么会在原 table 表的该位置挂上一个 ForwardingNode 结点,说明此桶已经完成迁移
ForwardingNode 继承自 Node 结点,并且它唯一的构造函数将构建一个键,值,next 都为 null 的结点,反正它就是个标识,无需那些属性。但是 hash 值却为 MOVED
所以,我们在 putVal 方法中遍历整个 hash 表的桶结点,如果遇到 hash 值等于 MOVED,说明已经有线程正在扩容 rehash 操作,整体上还未完成,不过我们要插入的桶的位置已经完成了所有节点的迁移
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) {
//返回一个 16 位长度的扩容校验标识
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
//sizeCtl 如果处于扩容状态的话
//前 16 位是数据校验标识,后 16 位是当前正在扩容的线程总数
//这里判断校验标识是否相等,如果校验符不等或者扩容操作已经完成了,直接退出循环,不用协助它们扩容了
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
//否则调用 transfer 帮助它们进行扩容
//sc + 1 标识增加了一个线程进行扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
下面我们看这个稍显复杂的 transfer 方法,我们依然分几个部分来细说
//第一部分
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//计算单个线程允许处理的最少table桶首节点个数,不能小于 16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
//刚开始扩容,初始化 nextTab
if (nextTab == null) {
try {
// 新建一个长度为原来数组长度2倍的数组
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
//transferIndex 指向最后一个桶,方便从后向前遍历
transferIndex = n;
}
int nextn = nextTab.length;
//定义 ForwardingNode 用于标记迁移完成的桶
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
这部分代码还是比较简单的,主要完成的是对单个线程能处理的最少桶结点个数的计算和一些属性的初始化操作
//第二部分,并发扩容控制的核心
boolean advance = true;
boolean finishing = false;
//i 指向当前桶,bound 指向当前线程需要处理的桶结点的区间下限
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//这个 while 循环的目的就是通过 --i 遍历当前线程所分配到的桶结点
//一个桶一个桶的处理
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
//transferIndex <= 0 说明已经没有需要迁移的桶了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//更新 transferIndex
//为当前线程分配任务,处理的桶结点区间为(nextBound,nextIndex)
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;
}
}
//待迁移桶为空,那么在此位置 CAS 添加 ForwardingNode 结点标识该桶已经被处理过了
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//如果扫描到 ForwardingNode,说明此桶已经被处理过了,跳过即可
else if ((fh = f.hash) == MOVED)
advance = true;
每个新参加进来扩容的线程必然先进 while 循环的最后一个判断条件中去领取自己需要迁移的桶的区间。然后 i 指向区间的最后一个位置,表示迁移操作从后往前的做。接下来的几个判断就是实际的迁移结点操作了。等我们大致介绍完成第三部分的源码再回来对各个判断条件下的迁移过程进行详细的叙述。
//第三部分
else {
//
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//链表的迁移操作
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
//整个 for 循环为了找到整个桶中最后连续的 fh & n 不变的结点
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;
}
//如果fh&n不变的链表的runbit都是0,则nextTab[i]内元素ln前逆序,ln及其之后顺序
//否则,nextTab[i+n]内元素全部相对原table逆序
//这是通过一个节点一个节点的往nextTab添加
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);
}
//把两条链表整体迁移到nextTab中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//将原桶标识位已经处理
setTabAt(tab, i, fwd);
advance = true;
}
//红黑树的复制算法,不再赘述
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);
advance = true;
}
那么至此,有关迁移的几种情况已经介绍完成了,下面我们整体上把控一下整个扩容和迁移过程。
首先,每个线程进来会先领取自己的任务区间,然后开始 —i 来遍历自己的任务区间,对每个桶进行处理。如果遇到桶的头结点是空的,那么使用 ForwardingNode 标识该桶已经被处理完成了。如果遇到已经处理完成的桶,直接跳过进行下一个桶的处理。如果是正常的桶,对桶首节点加锁,正常的迁移即可,迁移结束后依然会将原表的该位置标识为已经处理
当 i < 0,说明本线程处理速度够快的,整张表的最后一部分已经被它处理完了,现在需要看看是否还有其他线程在自己的区间段还在迁移中。这是退出的逻辑判断部分
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
}
}
finnish 是一个标志,如果为 true 则说明整张表的迁移操作已经全部完成了,我们只需要重置 table 的引用并将 nextTable 赋为空即可。否则,CAS 尝试的将 sizeCtl 减一,表示当前线程已经完成了任务,退出扩容操作
如果退出成功,那么需要进一步判断是否还有其他线程仍然在执行任务
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
我们说过 resizeStamp(n) 返回的是对 n 的一个数据校验标识,占 16 位。而 RESIZE_STAMP_SHIFT 的值为 16,那么位运算后,整个表达式必然在右边空出 16 个零。也正如我们所说的,sizeCtl 的高 16 位为数据校验标识,低 16 为表示正在进行扩容的线程数量
(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2
表示当前只有一个线程正在工作,相对应的,如果 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
,说明当前线程就是最后一个还在扩容的线程,那么会将 finishing 标识为 true,并在下一次循环中退出扩容方法。这一块的难点在于对 sizeCtl 的各个值的理解。
看到这里,真的为 Doug Lea 精妙的设计而折服,针对于多线程访问问题,不但没有拒绝式得将他们阻塞在门外,反而邀请他们来帮忙一起工作。
好了,我们一路往回走,回到我们最初分析的 putVal 方法。接着前文的分析,当我们根据 hash 值,找到对应的桶结点,如果发现该结点为 ForwardingNode 结点,表明当前的哈希表正在扩容和 rehash,于是将本线程送进去帮忙扩容。否则如果是普通的桶结点,于是锁住该桶,分链表和红黑树的插入一个节点,具体插入过程类似 HashMap,此处不再赘述。
当我们成功的添加完成一个结点,最后是需要判断添加操作后是否会导致哈希表达到它的阈值,并针对不同情况决定是否需要进行扩容,还有 CAS 式更新哈希表实际存储的键值对数量。这些操作都封装在 addCount 这个方法中,当然 putVal 方法的最后必然会调用该方法进行处理。下面我们看看该方法的具体实现,该方法主要做两个事情。一是更新 baseCount,二是判断是否需要扩容
这一部分主要完成的是对 baseCount 的 CAS 更新
//第一部分,更新 baseCount
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//如果更新失败才会进入的 if 的主体代码中
//s = b + x 其中 x 等于 1
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
//高并发下 CAS 失败会执行 fullAddCount 方法
if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null ||!(uncontended =U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
判断是否需要扩容
//第二部分,判断是否需要扩容
if (check >= 0) {
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) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||transferIndex <= 0)
break;
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();
}
}
get方法实现读取
get 方法可以根据指定的键,返回对应的键值对,由于是读操作,所以不涉及到并发问题。源码也是比较简单的
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 计算hash值
int h = spread(key.hashCode());
// 2. 验证table是否初始化,且通过hash计算的下标位置不能为null,否则直接返回
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2.1 先判断table[i]的头节点(node)的hash值和key生成的hash值是否相等
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 2.2 table[i]的node为红黑树,通过红黑树搜索返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 2.3 通过链表搜索返回
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
这里的处理流程相对比较易懂,主要做以下几件事:
- 获取hash值
- 先比对table[i]头节点的has值和key的has值是否相等
- 再对比table[i]头节点的key是否相等
- hash值小于0表示为红黑树结构,通过红黑树搜索结果
- 不是红黑树,遍历tables[i]链表搜索结果
clear 删除元素
clear 方法将删除整张哈希表中所有的键值对,删除操作也是一个桶一个桶的进行删除
public void clear() {
long delta = 0L; // negative number of deletions
int i = 0;
Node<K,V>[] tab = table;
// 数组不能为空,遍历table数组
while (tab != null && i < tab.length) {
int fh;
// 通过数组下标取出node节点
Node<K,V> f = tabAt(tab, i);
if (f == null)
++i;
// 如果当前节点在正在扩容,就协助扩容
else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f);
i = 0; // restart
}
else {
// 锁定数组中头节点
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> p = (fh >= 0 ? f :(f instanceof TreeBin) ?((TreeBin<K,V>)f).first : null);
//循环到链表或者红黑树的尾部
while (p != null) {
--delta;
p = p.next;
}
//首先删除链、树的末尾元素,避免产生大量垃圾
//利用CAS无锁置null
setTabAt(tab, i++, null);
}
}
}
}
if (delta != 0L)
addCount(delta, -1);
}
HashMap
JDK1.7和1.8对比
- JDK1.7是底层数据结构是数组+链表结构,通过对key进行hash运算除以数组长度取模(hashcode%数组长度)计算数组下标,通过数组下标决定元素存储在哪个数据下标。如果取模计算出的数组下标相同,会把这些相同的元素组成一个链表(Entry)。在极端情况下这些key全部定位到同一个数组下标中国,会导致链表的长度会很长,在put()和get()时会遍历整张链表
- JDK1.8中的底层数据结构是数组+链表+红黑树结构,也是通过key进行hash运算除以数组长度取模计算数组下标,区别在于链表长度大于8时会将链表转换为红黑树结构。红黑树的特点在数组下标的元素很多时,遍历元素时比链表效率要高
- JDK1.7中数组中存放的对象是Entry,是HashMap中的一个内部类,实现Map.Entry接口。JDK1.8数组中存放的对象是Node,实现Map.Entry接口。这点在名字上不同
- JDK1.8中提出了1.7中对key为null的单独处理,1.7中会将key=null的元素存储在数组的第0个下标上,1.8中对key=null没有单独处理和普通key的处理方式一样
- JDK1.7中插入元素到链表采用头插法,所谓头插法就将新元素做为链表的第一个元素,下一个元素为之前链表的第一个元素。1.8采用尾插法,就是新增元素时,将元素放到链表的最后。1.7的头插法会在多线程操作扩容时存在死循环的情况,为什么会存在死循环的分析会在后面介绍。
- JDK1.7中在扩容时,会对所有元素重新进行数组下标定位计算,1.8中采用高低位索引机制,再扩容时不再需要对所有元素进行下标定位计算
JDK1.7 死循环的原因
因为JDK1.7的链表拼接是采用头插法,在多线线程扩容时会出现链表死循环的情况,具体分析如下:
线程1扩容
do {
Entry<K,V> next = e.next; // 假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity); // 计算元素在新数组中的下标位置
e.next = newTable[i]; //之前链表指向当前链表的下一个节点
newTable[i] = e; //当前链表放到数组
e = next;
} while (e != null);
此时线程二开始执行扩容,并完成扩容,扩容后的数据存储。扩容后key=7元素的next结点是key=3这个元素。
线程二执行完后,线程一从挂起状态唤醒继续执行
- 执行e.next=newTable[i], 注意此时newTable[i]链表的头结点key=7,e元素的key=3,也就是说key=3这个元素的next元素指向了key=7这个元素。
- 执行newTable[i]=e,扩容后的链表头结点是key=3
- e=next,这里的next结点是key=7,而key=7这个元素的next结点在线程二中已经为key=3
- 此时我们发现环形链表出现了,key=3的next元素是key=7,key=7的next元素是key=3
HashMap 内存泄露
在java中,内存泄露是指一些被分配的对象,已经不用了,但是对象还是可达的GC无法回收掉,我们认为这就是内存泄露。
class HashMapLeak {
public static void main(String[] args) {
Map<Person, String> map = new HashMap<Person, String>();
Person person = new Person("张山", 30, "杭州市");
map.put(person, "A");
// 修改person的值
person.setAddress("上海市");
// 删除map
// 由于在put时,person对象的address属性参与了hash运算,修改后person对象后,在删除map,hash值发生变化,所以无法删除map导致内存泄露
map.remove(person);
}
}
class Person {
private String name;
private int age;
private String address;
Person(String name, int age, String address) {
this.name = name;
this.age = age;
this.address = address;
}
// 省略set、get...
}
分析原因
因为我们在put时,person对象作为map的key,所以参与了hash运算。当我们修改person对象后在删除,删除时也会对person对象做hash运算,导致和put时的hash值不一致无法删除,所以导致内存泄露
解决方案
重写person对象的hashCode方法,只使用不发生变化的对象参与hash运算
HashMap内存溢出
所谓内存溢出是指内存使用,GC无法及时回收,导致超出JVM的内存大小,抛出java.lang.OutOfMemoryError异常
class HashMapOutOfMemeryError {
public static void main(String[] args) {
Map<Key, String> map = new HashMap<Key, String>(1000);
int counter = 0;
while (true) {
Key key = new Key("张山", 30, "杭州市");
map.put(key, "A");
counter++;
if (counter % 1000 == 0) {
System.out.println("map size: " + map.size());
System.out.println("运行" + counter + "次后,可用内存剩余" + Runtime.getRuntime().freeMemory() / (1024 * 1024) + "MB");
}
}
}
}
class Key {
private String name;
private int age;
private String address;
Key(String name, int age, String address) {
this.name = name;
this.age = age;
this.address = address;
}
@Override
public int hashCode() {
return name.hashCode();
}
}
分析原因
上面代码我们的目的是想覆盖map的key,但是每次都是作为一个新的对象插入map中,因为HashMap判断key相同的条件是e.hash == hash && ((k = e.key) == key || key.equals(k)) ,但是我们只重写了hashCode()方法,只有e.hash=hash这个条件满足,key.equals(k)这个条件不能满足,导致HashMap在添加元素是都认为不是同一个key不能覆盖原来的key,导致内存溢出。
解决方案
除了重写HashCode()方法外,还需要重写equals()方法。
HashMap 源码分析 JDK 1.8
我们在分析HashMap之前,先看一张流程图,来对HashMap的执行逻辑由一个大概的了解。
在JDK1.8中,HashMap的底层数据结构已经是数组+(链表+红黑树)的结构了,在链表深度大于8时,会转换为红黑树。小于8时是链表结构。
构造方法
// 默认构造函数。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);//下面会分析到这个方法
}
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定“容量大小”和“加载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 初始化扩容阈值(负载因子,超过这个阈值时会扩容数组长度)
this.loadFactor = loadFactor;
// 初始化数组长度
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 初始化扩容阈值(负载因子,超过这个阈值时会扩容数组长度)
this.loadFactor = loadFactor;
// 初始化数组长度
this.threshold = tableSizeFor(initialCapacity);
}
添加put()
public V put(K key, V value) {
// hash(key)获取key的hash值
return putVal(hash(key), key, value, false, true);
}
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)
// resize()扩容
n = (tab = resize()).length;
// (n-1) & hash 计算数组下标
// 获取数组下标的值,如果为空说明桶里没有链表
if ((p = tab[i = (n - 1) & hash]) == null)
// 数组的桶为空,说明是链表头节点
// 直接将当前值做为头节点放到数组下标
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 校验插入的key是否已经存在
// 首先根据hash值匹配,在==匹配,最后equals匹配
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// key存在直接覆盖
e = p;
else if (p instanceof TreeNode)
// 如果是红黑树结构,直接往红黑树添加
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表,往链表最后一个节点追加添加的值
for (int binCount = 0; ; ++binCount) {
// next为空时,说明是最后一个节点,往这个节点添加值
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 链表深度大于8,将链表转红黑树
treeifyBin(tab, hash);
break;
}
// 先通过hash值对比,在通过==和equals()方法比较key是否存在
// 如果存在,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e!=null条件成立时,说明添加内容已经在链表中存在
// 那么将旧值替换为新值,返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 旧值替换为新值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 键值对数量超过阈值时,则进行扩容.
// threshold 的值是根据数组长度乘以阈值得出
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
扩容resize()
扩容为HashMap中很重要的一个知识点,因为我们的HashMap的底层实现是数组,数组是有长度的,我们在初始化HashMap的时候,会给数组指定一个长度,可以由构造函数指定,默认长度是16。当我们往HashMap存储的元素越多时,不进行数组扩容的话会造成hash碰撞率提高和数组链表(红黑树)的高度变高,对添加和查找的性能造成影响。所以我们会设置一个对数组最多存储多个元素的阈值,默认是:数组长度乘以0.75,存储元素的个数超过这个阈值时,会对数组进行扩容。扩容后会把原来数组中的值迁移到新的数组,这是非常耗时的,我们在开发当中设置合理的数组长度来避免扩容。
final Node<K,V>[] resize() {
// 当前hashmap的数组
Node<K,V>[] oldTab = table;
// Cap 是 capacity 的缩写,容量。
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0; // newCap新数组长度,newThr新数组最大存储元素阈值
// 如果大于0,说明数组已经初始化过
if (oldCap > 0) {
// 数组容量不能超过最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
// 扩容长度超过最大数组长度,不在进行扩容,还是用原来数组
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 数组扩容后的长度不能超过数组最大长度
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
// 扩容数组长度必须大于16
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容后的数组长度是原来数组长度的2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
// 通过有参构造方法指定了阈值时
// 数组长度为阈值长度
newCap = oldThr;
else {
// 调用无参构造方法时,默认的数组长度
// 默认阈值:数组长度*负载因子0.75
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 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"})
// 初始化扩容后的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 不为空,说明数组已经有值,需要把旧数据迁移到新数组中
if (oldTab != null) {
// 遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 取数组下标的值
if ((e = oldTab[j]) != null) {
// 取完之后设置为null
oldTab[j] = null;
// 没有next元素,说明链表只有一个值
// 直接把值放到新的数组中
if (e.next == null)
// e.hash & (newCap - 1)计算数组下标
// 把值放到这个数组对应的下标中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 是红黑树节点
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// JDK1.8中,hash算法采用的是hashCode的高16位和低16位进行与或运算
// 所以有一个规律:
// 当旧数组长度是2的幂次方,且扩容后的数组长度是旧数组长度的2倍。
// e.hash & oldCap == 0 数据迁移后数组下标不变
// e.hash & oldCap !=0 下标为原位置(j)+旧数组长度(oldCap)
Node<K,V> loHead = null, loTail = null; // 低位链表,数组下标不变
Node<K,V> hiHead = null, hiTail = null; // 高位链表,数组下标为原位置+旧数组长度
Node<K,V> next;
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); //next为空,说明是链表的最后一个节点,结束自旋
if (loTail != null) {
loTail.next = null;
// 低位链表,下标位置不变
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 高位链表,数组下标为原位置+旧数组长度
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
查找get()
首先调用hash(key)获取key的hash值
public V get(Object key) {
Node<K,V> e;
// 获取key的hash值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
调用getNode()方法
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// hash值取余得到数组下标,取出数组下标对应的链表或红黑树
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 链表中第一个节点的hash值、key和查找的key、key的hash值相等,表示找到数据
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 链表中下一个节点
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//是红黑树节点,从红黑树中查找节点数据
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 自旋链表,依次取出节点,直到key、hash和查找的key、hash相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null); // 如果是最后一个节点,调出字自旋
}
}
return null;
}