HashMap是我们开发中经常使用。面试官也总喜欢问关于HashMap的实现原理,那么咱们就用一篇文章来说明一下。
关于HashMap的底层结构,那么必须要说的就是
JDK1.7及之前,采用的是数组+链表
JDK1.8及之后采用数组+链表 或者数组+红黑树的方式进行元素存储
首先贴一段我们常用的代码
String key = "keyTest";
Map<String, String> hashMap = new HashMap<>();
hashMap.put(key,"valueTest");
hashMap.get(key);
还是先说一下整体逻辑。创建一个HashMap容器后,指定数组和扩容因子大小,或使用默认值。hashmap中具体数组的创建是在put中进行的。执行put方法插入一条数据,如果数组为空,就根据指定的数组大小或默认大小来创建Node数组,根据传入的key,计算出数组下标位置,如果下标位置为空就直接插入,如果为空,就直接插入;如果不为空判断是不是红黑树,如果是树就插入数据。如果不是树就遍历链表,存在相同key就覆盖value,否则插入链表尾节点,然后判断当前链表是否满足转红黑树条件,如果满足就转红黑树。最后判断数据条数是否大于需要扩容的数量,如果满足,进行扩容
先创建一个没指定长度的HashMap对象,那么底层是如何做的呢?
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
使用无参构造创建对象,所有属性均使用默认值
我们来看一下
//默认数组长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组最大的长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//加载因子,当数组中数据达到 DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR的数量时,容器就要进行扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转红黑树临界值,就是当数组某个节点的链表长度大于等于当前值,就需要将链表转为红黑树
static final int TREEIFY_THRESHOLD = 8;
//红黑树转链表临界值,就是当红黑树中节点小于等于当前值时,需要退化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树的数组长度临界值
static final int MIN_TREEIFY_CAPACITY = 64;
//HashMap中数据实际存储位置
transient Node<K,V>[] table;
然后从put方法开始看起
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第一个步需要根据当前key计算hash,后面根据这个hash来计算下标位
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key是null的话,直接返回0,否则计算hashCode ^ hashCode无符号右移16位
以上面的key计算结果
String key = "keyTest";
//-815460463
int h = key.hashCode();
System.out.println(h);
//不带符号右移
int s = h >>> 16;
//53093
System.out.println(s);
继续跟踪putVal方法
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)
//初始化table后,tab为创建的Node数组,n = 16
n = (tab = resize()).length;
//当前计算是i=4,取出数组下标为4的数据,如果为null就创建一个Node节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果hash相同,key不为null且相同,就进行覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是TreeNode,就是红黑树,就进行红黑树操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//遍历链表,如果循环到的Node的下一个节点为null,就创建一个新Node,将当前节点放到尾部节点的下一个节点
for (int binCount = 0; ; ++binCount) {
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和hash相等,如果相等就直接终止循环,后面覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//覆盖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;
}
而对于数组的初始化,实际上调用的是hashmap的扩容方法resize(),初始化数组后返回,然后继续看putVal()方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//对初始化而言,oldTab为null,所以oldCap为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//如果旧数组大小大于数组允许的最大值,就将扩容临界值改为int的最大数
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容时,默认是原数组大小*2变成32,如果乘2后的值满足不大于数组允许最大值,且原数组长度大于等于默认容量值16时,将扩容临界值也*2=24
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // oldCap为0时,初始化默认值。newCap=16;newThr=0.75*16为12
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);
}
//初始化时赋值扩容临界值为12,扩容后为24
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建数组,赋值给table属性并返回
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;
//旧数组节点中有数据的时候执行,比如咱们4节点中有数据,e为数组的head节点
if ((e = oldTab[j]) != null) {
//先将旧数组4节点的数据清空
oldTab[j] = null;
//如果head节点的next为空,也就是说只有这一个节点,不是链表。就计算这个节点的下标放入新数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果head节点是红黑树,就进行移动
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 普通链表移动
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
对于数组扩容及链表存值上面代码中已经添加注释。下面我们来看链表转红黑树逻辑
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果数组为空需要初始化;当前数组长度小于64,需要扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//数组的下标4不为空时,开始转换红黑树.当前的e,是拿到下标4的头节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将普通的Node节点信息存储到一个新建的TreeNode节点中
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);
}
}
以上就是put的实现逻辑,而get()就比较简单了。咱们也看一下
public V get(Object key) {
Node<K,V> e;
//1. 传入key的hash,和key本身。调用getNode方法取出Node,判断是否为空,不为空返回Node的值
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;
//数组不为空才查询,否则直接返回null
//(n - 1) & hash看过好多次了。就是计算这个key的下标位,取出数组中的Node,为空就不走这里的逻辑,直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // 如果hash和key相同,就直接返回这个节点
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果数组4的节点上有子节点,就执行这里的逻辑
if ((e = first.next) != null) {
//如果数组4节点是红黑树,就从红黑树中查找数据并返回找到的节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//不是红黑树就肯定是链表了。遍历链表去找,找到了返回节点
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
到这里就分析完HashMap的源码了。可是你觉得到这里就完了吗。下面我们简单记住就可以了。对于上面的源码,put数据到一个存在节点的时候,咱们是将数据存到链表尾部对吧。这也不一定,JDK8以前是头插法,JDK8后是尾插法,为什么要该成尾插呢
主要原因就在于其的扩容机制。
看1.8之前的扩容写法
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
//取出oldTablue中的Node,可以说是head节点
Entry<K,V> e = src[j];
//如果Node不为空
if (e != null) {
//将oldTable当前节点置为空
src[j] = null;
do {
//取出head的下一个节点
Entry<K,V> next = e.next;
//计算head的新下标
int i = indexFor(e.hash, newCapacity);
//现在newTable还是空的,将newTable的节点赋值给head节点的next,
//head的下一个节点为空 head - > null
e.next = newTable[i];
//head节点存入newTable中
newTable[i] = e;
//head的下一个节点变成head节点
e = next;
} while (e != null);
}
}
}
当只有一个线程时执行逻辑
HashMap多线程环境下的使用呢。HashMap是非线程安全的,所以在多线程环境下会出现问题,那么我们分析一下
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);
而线程一正常执行,完成了上面图的最终结果。此时线程一继续执行。说一下现在的情况。
线程二状态
线程一状态
实际上线程一唤醒的时候,实际上7的next已经指向3了,3指向null
然后线程一执行扩容逻辑
- 当前e指向3,执行e.next = newTable[i]; 将7 - > 3放在3的后面
- 将3放在数组下标3中,目前链表指向是3 -> 7 -> 3
- 然后next变成e,e现在是7,将数组中指向e的next变成7 -> 3 -> 7 -> 3 就成环了
官方本来也不推荐在多线程环境下使用HashMap,多线程下推荐使用ConcurrentHashMap
下面我们来了解下ConcurrentHashMap
和HashMap一样的例子
Map<String, String> hashMap = new ConcurrentHashMap<>();
hashMap.put(key,"valueTest");
hashMap.get(key);
首先看创建对象的构造方法
//Creates a new, empty map with the default initial table size (16).
public ConcurrentHashMap() {
}
和HashMap一样,可以初始化大小,也可以使用默认的初始化长度16
接着看put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
调用putVal()方法,和HashMap不同的是,这里并没有将key 的hash传到方法中,直接传递了key,继续看putVal()方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
//如果key为空,直接抛空指针
if (key == null || value == null) throw new NullPointerException();
//计算hash,细节就不看了。面试问我也答不上来
int hash = spread(key.hashCode());
//记录数据条数
int binCount = 0;
//自旋
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//初始化时tab为空,第一次调用put的时候初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//通过hash技术数组下标,并通过偏移量直接拿到tab内存中该下标的值,赋值给f。如果f为空,代表下标中还没有数据,创建一个Node节点,通过cas操作把null改成添加值,修改成功的话就终止循环,否则进入下次循环,再尝试添加元素
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
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else { //当第二次操作put方法时,假如计算的下标位还是这里,那么f就不为空,就需要给当前数组下标位的Node加锁操作,保证线程安全
V oldVal = null;
synchronized (f) {
//怕其它线程将这里修改,这里再取一次数组中的head,看看是否和之前取的一样,如果是一样的,就走下面逻辑
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果hash一样,key也一样,就覆盖value
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
//如果上面的不满足,有没有下一个节点了。就新建个Node节点,插入尾部
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
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);
if (oldVal != null)
return oldVal;
break;
}
}
}
//更新记录数条数。chm这里采用分段锁的机制实现,首先有一个baseCount,通过cas去增加。如果cas失败,生成随机数,随机存入CounterCell中,查询size()的时候,使用baseCount + CounterCell。CounterCell有一个数组,默认长度为2。分担拿不到锁,重新自旋的开销。如果竞争很激烈,CounterCell数组也会扩容, * 2倍扩容。代码实现太复杂,就不看了。看了也记不住。。。
addCount(1L, binCount);
return null;
}
具体初始化数组逻辑
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//因为可能有多个线程竞争。如果到这里拿到的tab为空,就一直循环等其它线程将tab初始化好
while ((tab = table) == null || tab.length == 0) {
//如果第一次进来,这里是不满足条件的,只有在多个线程环境下,一个线程已经通过cas操作修改共享变量sc的值为-1,就是通知其它线程,已经有其它线程在初始化容器了,当前线程可以不用继续循环了。让出时间片给其它线程
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//通过cas拿锁,将sc的值改为-1,如果发生多个线程竞争,保证只有一个线程去初始化容器
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//有可能当前线程到这里时,其它线程已经将容器初始化好了。这里是双重检查,防止重复创建容器
if ((tab = table) == null || tab.length == 0) {
//拿到初始长度,默认16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//创建16长度的数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//计算出扩容因子。当前n是16, 16 - (16/2^2) = 12
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
记录条数据及扩容逻辑实现
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
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);
}
//当前没有线程在扩容,扩容时,标记数量为+2
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
扩容实现看不下去了。找一篇网上的话
并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点,好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值,其含义如下:
sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
-1 代表table正在初始化
-N 表示有N-1个线程正在进行扩容操作
其余情况:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍
扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd,否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后
给旧table原位置赋值fwd。直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。