文章目录
前言:
HashMap在Java7到Java8中进行了修改与提升。那么那么咱们先说说Java7到Java8中的HashMap到底哪里进行了修改与提升。
Java7 | Java8 | |
---|---|---|
底层存储 | 数组+链表 | 数组+链表+红黑树 |
数组中的存储的对象类型 | Entry | Node |
何时创建底层数组 | 实例化即创建 | 第一次调用put方法时即添加。 |
扩容机制 | 只要满足当前底层数组的元素大于临界值且下一个要添加的元素位置已经有元素 | 数组上某一个以链表形式存储的个数大于8且当前数组长度大于64时,此时该索引上的所有元素采用红黑树存储 |
Java7的HashMap链接:
如果面试官问你:
说说你对HashMap的理解?
是不是现在感觉有些摸不着头绪,没事,接下来,笔者就带着大家一起深度剖析Java7和Java8(本文主要描述Java8的HashMap,Java7的HashMap源码分析,请点击下方链接)中HashMap的底层实现。
版本声明:本文的Java版本:jdk-8u131-windows-x64
【Java集合篇】jdk7的HashMap源码分析【Java集合篇】jdk7的HashMap源码分析
一.HashMap底层是怎么存储的?Entry是什么?
1.Node是什么(JDK7中的Entry)?
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;
}
//部分代码省略。。。。
}
上面的代码来自Java源码,在jdk8中,用Node替换了Entry,但是他们实际的成员变量依旧是相同的。
2.默认大小:
下面的代码就是Java中HashMap的底层实现——一个Node类型的数组,默认长度是16.
transient Node<K,V>[] table;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
3.为什么Java8的HashMap是数组+链表+红黑树?
在这里笔者就默认大家读过jdk7的源码分析了。因为jdk8在jdk7的基础上加了红黑树,知识是一点一点积累下来的,所以建议大家先读一读jdk7的源码:链接:【Java集合篇】jdk7的HashMap源码分析
这样可以使读者了解更多:
- 了解HashMap的演变
- 深层次的理解HashMap
- jdk8中HashMap为什么要改变?
- 改变的好处在哪里?
二、构造器
1.调用无参构造器,底层table实例化了吗?
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
源码分析:在Jdk8中,实例化的时候,并没有对底层构造器进行了开辟内存空间 (即new一个数组)。
2.调用有参构造器,底层table实例化了吗?
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
源码分析:当调用一个参数的构造器的时候,其实际上还是调用了两个参数的构造器,只不过,提供了一个默认的加载因子:即0.75
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);
}
源码分析:
- 第一步:对输入的数据进行合法性的判断
- 如果大于最大值,就让他取最大值
- 如果小于0或者是一个非数(即NaN),就抛出异常
- 第二步:将形参的默认加载因子赋值给成员变量
- 第三步:调用tableSizeFor方法,将返回值赋值给临界值。
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;
}
3.总结
看完了两个构造器,发现当我们创建HashMap的时候,底层并没有对HashMap的底层Node类型的数组table进行内存空间的开辟,那么什么时候开辟的呢?(笔者先偷偷告诉你,是在你第一次调用put方法的时候添加的)
三、put方法:底层数组的实例化,就在这里啊!!!!
声明:因为在【Java集合篇】jdk7的HashMap源码分析中,已经讲解了如下问题
- 1.为什么HashMap可以添加null元素?
- 2.为什么HashMap中的元素一定要重写hashCode()和equals()?
这里就不讲解了,有兴趣的可以点进去看看,但是笔者还是强烈建议读者进去看看,因为学习不能只知其然,而不知道其所以然。相信如果你点开了我写的这篇关于源码分析的文章,必然是想深度学习的。你看着很难,但是笔者写的也很累,但是让我们一起加油。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
源码分析:
我们调用添加的put方法,他其实调用的是putVal方法,并且携带了五个参数,接下来,咱们就看看这五个参数是什么?
- 第一个参数:调用hash方法,将要添加的对象的key传入进去。
- 发现该方法中调用了key的hashCode方法,并进行了一些处理。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 第二个参数:要添加元素的key值
- 第三个参数:要添加元素的value值
- 第四个参数:false
- 第五个参数: true
那我们就带着这五个参数,继续往下看。。。。
1.调用无参构造器后,第一次调用put方法
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)
n = (tab = resize()).length;
//代码块二:
if ((p = tab[i = (n - 1) & hash]) == null)
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);
//代码块九:
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;
}
}
//代码块十一:
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;
}
源码分析:
-
因为前面在调用构造器的时候,并没有对底层数组table赋值,所以当执行第一个代码块一时,将table赋值给tab,且为null,所以进入if判断中,调用resize()方法,并且resize()方法返回一个Node类型的数组,然后将数组的长度赋值给(其实resize方法是对底层的数组进行了扩容,咱们先继续往下看,看完这一块代码,在看resize方法)当且仅当实例化HashMap后第一次调用put方法才会进导入代码块一
-
代码块二中,通过某种预算(即用当前元素与hash进行与运算),如果该位置上有元素为null,则直接创建一个元素添加到上面。
-
如果要添加的数组上有元素,则进入代码块三。注意:代码块四,代码块五,代码块六到代码块十一,是并列关系,即三者只能进入一个。
- 代码块四如果两个要添加的元素哈希值相等,并且(两个元素key的地址相同,或者 两个元素equals方法返回true),则将p赋值给e
- 代码块五即判断是否是一个红黑树,(这里暂且不考虑)
- 代码块六,中for循环,即遍历数组元素上对应的链表,那我们继续往下看。
- 代码块八:判断p.next(当前元素的next成员遍历,即链表上p后面的成员)是否为空,(说明:如果为null,证明该数组上不存在链表),直接创建一个元素。代码块九在下一部分说,这里先留着。(代码块九的主要含义是如果满足某正条件,就使用红黑树。)
- 代码块十,判断方式和代码块四相同,,如果相同,就直接跳出循环,如果不相同,就把e复制给p,相当于p变成了下一个元素,然后,继续循环然后再取p.next成员遍历,这样就可以往复循环。
-
代码块十一,如果e不为null,将新的value覆盖旧的value,并且返回旧的value,
-
代码块十二,判断添加后是否需要扩容,然后返回null。
final Node<K,V>[] resize() {
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
}
//代码块五:
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"})
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) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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;
}
源码分析:
当我们第一次调用put方法时,
- 当执行第一个代码块一时,
- 第一步:底层数组还没有初始化,所以将table(此时为null),赋值为oldTab(此时也为null),oldTab为null,所以oldCap=0, 因为调用的无参构造器,所以threshold也为0.
- 第二步:因为oldCap=0,所以不走代码块二 和代码块五 ,执行代码块六,然后newCap=初始长度=16,newThr等于16*0.75=12(临界值=默认负载因子 * 当前数组的长度)
- 第三步:因为代码块六中给newTh进行了赋值,所以代码块七也不走,走到代码块八,然后将新的临界值赋值给HashMap成员变量,实例化一个newCap (即第二步复制的变量:16) 长度的Node类型数组赋值给table
*第四步:因为oldTab依然为null,所以不走代码块九,直接返回。
2.何时采用红黑树?
该代码块,来自于put代码块九putVal方法
其中用到了一些常量,我们来看看都是什么?
- binCount:结合上下文,该变量是一个索引,其实也是当前链表中对应的是第几个元素。
- TREEIFY_THRESHOLD:一个静态常量,下面是从源码中copy过来的。
static final int TREEIFY_THRESHOLD = 8;
源码解析:
当binCount(当前链表中对应的第几个元素)>8-1时,调用 treeifyBin方法
接下来,我们就看一看treeifyBin方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
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);
}
}
static final int MIN_TREEIFY_CAPACITY = 64;
源码分析:
- 第一步:如果形参tab是否为null 或者 形参的tab 的长度小于MIN_TREEIFY_CAPACITY(即64),就调用resize方法(即:扩容方法)
- 那么形参tab是什么呢? :根据下面的图,可以看出,形参tab其实是底层的Node类型的数组table
- 那么形参tab是什么呢? :根据下面的图,可以看出,形参tab其实是底层的Node类型的数组table
- 第二步:如果形参tab是否为null 或者 形参的tab 的长度小于MIN_TREEIFY_CAPACITY(即64),结果为false,则就改用 红黑树
四.jdk8中的几个重要的常量
- DEFAULT_LOAD_FACTOR :HashMap的默认加载因子
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- DEFAULT_INITIAL_CAPACITY :HashMap的默认容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- 扩容的临界值;=容量加载因子:160.75=12
int threshold;
- 链表的长度大于该默认值,转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
- Node被转换为树,最小的容量是64
static final int MIN_TREEIFY_CAPACITY = 64;
五.jdk7到jdk8为什么要进行修改?
- 第一点:jdk8中,第一次添加的时候,再创建底层的table数组,这样更有利于节省内存空间。
- 第二点:jdk8中,使用的是数组+链表+红黑树,再jdk7的基础上,增加了红黑树,因为红黑树,相比链表有优秀的查询性能,所以当链表足够长的时候,变成红黑树,有利于提高查询的效率。
六.总结
学习是一个往复的过程,因为笔者能力有限,所以部分代码,理解的还不是很清楚,后续笔者的能力提升上来,还会继续更新该文章,当然,学习底层原理,不仅仅是为了应对面试官,更重要的是,再开发的过程中,考虑上时间和空间复杂度,提高产品的性能,提高用户的体验,努力做到全心全意为用户服务。
另外,推荐几篇有关于集合的文章:
【Java集合篇】jdk7的HashMap源码分析