文章目录
前言
Java开发工程师去面试的过程中经常会被面试官问道HashMap的基础知识点和底层实现原理,比如:HashMap1.7和1.8版本的区别有哪些?说一说HashMap底层的配置参数有哪些?说一下HashMap扩容机制,越细越好?等等。如果平时不注意积累,没看过HashMap的底层实现原理,肯定这些基础的问题就不知道如何回答,那么这次面试肯定过的机会等于零了。所以,这篇文章结合Java中HashMap源码来学习下底层实现原理。
一、概述,HashMap是什么?
HashMap是以Key-Value形式存储数据的数据结构,允许Key-Value为null,key只能一次为null。
HashMap区别最大的版本在1.8之前和1.8之后,JDK1.8版本之前HashMap底层的数据结构使用数组+链表实现的,对于有一个元素的插入先考虑它在数组中的位置,数组里面每个地方都存储了Key-value这样的实例,1.7是以Entry的形式,1.8是以node形式。数组初始的容量是null,在put插入数据的时候会根据key的hash去计算一个index值。
每个数组元素上都一个链表结构,当put插入数据被Hash计算之后后,得到该数据的数组下标,就把数据放在对应下标元素的链表上。数组的容量是有限的,在put计算hash值可会出现值一样的概率,这样就会形成了链表。
链表插入的区别有哪些?
1.8版本之前链表存储是头插法,1.8版本是尾插法。那么为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。提高了插入和查询性能。
二、HashMap底层源码
1.put()方法源码
1.8版本put方法源代码如下:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
* 将指定值与此映射中的指定键关联。如果之前的映射包含键的映射,那么旧的值将被替换
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
* 实现Map,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;
}
2.get()方法源码
实现get()方法源代码如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// always check first node 总是检测第一个节点
if (first.hash == hash &&
((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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.扩容resize()源码
/**
* The default initial capacity - MUST be a power of two.
* 默认初始容量-必须是2的幂。
* 阈值是16,扩容必须是2次幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
*最大容量,如果任意一个带参数的构造函数隐式指定了更高的值,则使用该值。必须是2的幂<= 1<<30。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表转化为红黑树的阈值8
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转化为链表阈值是6
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
* 最小红黑树容量
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
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
}
// initial capacity was placed in threshold
// 初始容量设置为阈值
else if (oldThr > 0)
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;
}
三、常见的问题及答案
HashMap底层数据结构?
HashMap在1.7版本中底层数据结构是数组+链表,为了提高1.7版本中的性能在1.8版本中做了很多的更新。1.8版本HashMap底层数据结构是:数组+链表+红黑树。
HashMap在put()和get()的时候都做了什么?
HashMap在put方法调用了putVal方法,传入了key的hash值,key和value。通过上面的put()源码可以看出大致流程分为以下几步:
1.判断数组是否为空,为空进行初始化。
2.不为空是进行key的hash值计算,通过(n-1)& hash计算应当存放在数组下标index。
3.查看tab[index]中是否存在数据,如果不存在就创建新的node节点存储tab[index]。
4.如果存在数据,说明hash冲突,继续判断Key是否相等。如果相等就替换新的value值。
5.不相等就判断是不是树节点,是树形节点就插入红黑树中。
6.不是树形节点,创建node节点加入链表。
7.判断链表长度是不是大于8,大于8的话(且Node数组的数量大于64)把链表转换为红黑树。
8.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold
9.判断是不是最大阈值,超过阈值就行扩容。
HashMap在get方法调用了getNode(int hash, Object key)方法,根据上面的get()源码取值步骤分为以下几点:
1.首先判断table是否为空。并且通过hash计算当前key下面的node节点是否有值。
2.如果第一个节点hash相等,key也相等,就直接返回数据。
3.如果第一个节点hash不相等,就进行判断是否为红黑树类型,在红黑树中取数据返回。
4.如果不是红黑树类型。进行while节点遍历下一个节点,直到对比查询到相同的hash值和key值返回。
5.为空时直接返回null。
HashMap扩容机制是什么?
HashMap扩容机制内容全部在resize()方法中,看完上面的resize()方法就了解到了是什么实现的了。具体大概有以下几点:
1.在resize()方法中,定义了oldCap参数,记录了原table的长度。
2.定义了newCap参数,记录新table长度,newCap是oldCap长度的2倍,同时扩展点也乘2。
3.循环原table,把原table中的每个链表中的每个元素放入新table。
4.当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。
5.如果没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容。
注意:HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
HashMap中链表转红黑树的条件是什么?
HashMap链表转红黑树,并不是达到8个Node节点的阈值就进行转换,而是要判断一下整个数据结构中的Node数量是否大于64,大于才会转红黑树,小于就会用扩容数组的方式代替红黑树的转换。
默认初始化大小是多少?为啥是这么多?
HashMap默认初始大小是16,之所以设置为16,是为了服务于从key映射到index的hash算法。符合Hash算法均匀分布的原则。
HashMap负载因子是多少?为什么是0.75不能是1或者是0.5呢?
HashMap负载因子是0.75
这段源码大致意思是说负载因子是0.75的时候,空间利用率比较高,理想情况下,在随机哈希码下
箱中的节点遵循泊松分布,避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
如果负载因子是1的时候会出现大量的hash冲突,也就是数组长度在161=16,链表在81=8的时候才会进行转化为红黑树或者进行扩容,底层hash就变得更复杂,查询效率就会降低。这样就是时间效率降低了,空间利用率提高了。
如果负载因子是0.5的时候,当数据插入数组容量达到一半的时候就进行了扩容操作,hash冲突会减少,查询效率提升。但是空间利用率降低了,查询效率提高了。
所以,HashMap负载因子为0.75是空间和时间成本的一种折中的选择。
HashMap是怎么处理hash碰撞的?
根据源码中了解到,发生hash碰撞时,我们只是对箱子中的一些移位进行异或运算减少系统性损失的最便宜的方法。
HashMap是线程安全的吗?
HashMap是线程不安全的,不要在并发的环境中操作HashMap
有什么线程安全的类代替么?
建议并发环境下使用ConcurrentHashMap。
总结
HashMap知识点总结到这里了,大家给个三连呗
下一篇来看看ConcurrentHashMap的知识点。对于技术知识欢迎的朋友们在评论区留言讨论交流,在技术的路线一起继续学习前进。