hashmap的源码在java的面试中一直是一个很关键的部分,在搜索面试题的时候我们经常会看到类似这样的题目:
- hashmap的底层数据结构是什么?
- 描述一下hashmap put方法的过程?
很多时候我们好像都只顾着使用它,而不清楚底层实现,还有的时候我们知道一鳞半爪,但是表示不出来具体的内容。
今天我准备从源码理一遍,HashMap的实现细节。
一. 什么是Map?
根据Map源码上的注释可以得到:
1.Map是一个接口,他是key-value的键值对,一个map不能包含重复的key,并且每一个key只能映射一个value;
2.Map接口提供了三个集合视图:key的集合,value的集合,key-value的集合;
3.Map内元素的顺序取决于Iterator的具体实现逻辑,获取集合内的元素实际上是获取一个迭代器,实现对其中元素的遍历;
4.Map接口的具体实现中存在三种Map结构,其中HashMap和TreeMap都允许存在null值,而HashTable的key不允许为空,但是HashMap不能保证遍历元素的顺序,TreeMap能够保证遍历元素的顺序。
/**
* An object that maps keys to values. A map cannot contain duplicate keys;
* each key can map to at most one value.
*
* <p>This interface takes the place of the <tt>Dictionary</tt> class, which
* was a totally abstract class rather than an interface.
*
* <p>The <tt>Map</tt> interface provides three <i>collection views</i>, which
* allow a map's contents to be viewed as a set of keys, collection of values,
* or set of key-value mappings. The <i>order</i> of a map is defined as
* the order in which the iterators on the map's collection views return their
* elements. Some map implementations, like the <tt>TreeMap</tt> class, make
* specific guarantees as to their order; others, like the <tt>HashMap</tt>
* class, do not.
*/
二. HashMap的概念
1. 什么是哈希表
哈希表(HashTable,散列表)是根据key-value进行访问的数据结构,他是通过把key映射到表中的一个位置来访问记录,加快查找的速度,其中映射的函数叫做散列函数,存放记录的数组叫做散列表,哈希表的主干是数组。
上面的图中就是一个值插入哈希表中的过程,那么存在的问题就是不同的值在经过hash函数之后可能会映射到相同的位置上,当插入一个元素时,发现该位置已经被占用,这时候就会产生冲突,也就是所谓的哈希冲突,因此哈希函数的设计就至关重要,一个好的哈希函数希望尽可能的保证计算方法简单,但是元素能够均匀的分布在数组中,但是数组是一块连续的且是固定长度的内存空间,不管一个哈希函数设计的多好,都无法避免得到的地址不会发生冲突,因此就需要对哈希冲突进行解决。
(1)开放定址法:当插入一个元素时,发生冲突,继续检查散列表的其他项,直到找到一个位置来放置这个元素,至于检查的顺序可以自定义;
(2)再散列法:使用多个hash函数,如果一个发生冲突,使用下一个hash函数,直到找到一个位置,这种方法增加了计算的时间;
(3)链地址法:在数组的位置使用链表,将同一个hashCode的元素放在链表中,HashMap就是使用的这种方法,数组+链表的结构。
---------------------
作者:qq_41786692
来源:CSDN
原文:https://blog.csdn.net/qq_41786692/article/details/79685838
版权声明:本文为博主原创文章,转载请附上博文链接!
2. 什么是HashMap?
HashMap是基于哈希表的Map接口的实现,提供所有可选的映射操作,允许使用null值和null键,存储的对象时一个键值对对象Entry<K,V>;
HashMap是由数组,链表和红黑树实现的(jdk1.8为了优化查找性能将一定数量的链表转化为红黑树,使得时间复杂度下降为O(logn))。如下图就是HashMap的数据结构。
当然红黑树是要有一定数量才能从链表转化的,这个图只是简单示意在HashMap的数据结构大概上是这样的。
而这个数据结构是如何通过代码实现的呢?我们可以从源码中来寻找答案:
a. HashMap的初始化
Map<String,String> map = new HashMap<String, String>();
多数情况我们是通过这样的方法来创建hashmap的,而还有些时候我们会使用ConcureentHashMap来新建Map,但是这两种方法是不完全一样的,因为通过ConcurrentHashMap是另一种HashMap,这我们之后细究它。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
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;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
在map对象初始化的时候,只是初始化一些常数。
b. put方法。
初始化完对象以后,接着我们就要向map中存入元素,以一次插入的经过来探究HashMap上面画的哪些数据结构究竟是如何实现的。
map.put("put", "putvalue");
/**
* 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.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在put 方法里,调用了HashMap的putVal方法。在这个方法传参的时候,使用了hash(key)这个方法根据key生成一个数,这个数是做什么用的之后会详细介绍,此时我们接着往里看代码。
因为在java中一切皆对象,所以当我们要存一个键值对的时候,当然也是作为一个对象把它存进去,而我们的数组的存放类型自然也是键值对的对象Node<K,V>。table是类的成员变量,他的作用域是整个对象,并且用transient关键字修饰(这个关键字的意思是被这个关键字修饰的对象无法序列化)。
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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赋值,判断他是否为空或者里面没有任何数据。
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;
}
从我们之前的数据结构图里可以看出我们的Node对象自然是首先需要被存放在数组里的,但是如果我们没有一个数组或者数组的大小为0,那么我们首先需要对数组进行初始化。在java中数组和list的差别在于,数组首先是有一个给定的大小,那么我们先看看初始化的过程是怎么进行的。
需要事先说明的是,resize()这个方法不仅进行数组的初始化,还可以进行数组的扩容,现在我们先看初始化的部分。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //把我们现有的数组赋值给oldTab
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 如果oldTab为空则oldCap(旧容量)为0,不然旧容量的值就是oldTab的实际大小。
int oldThr = threshold; // 旧的边界值就是现有的threshold(边界值),超过这个值就需要扩容。
int newCap, newThr = 0;// 初始化新数组的容量和边界值都为0
if (oldCap > 0) {
// 如果旧数组的容量大于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
// 如果旧边界值大于0 且旧容量为0,那么就用旧边界值来直接赋值新容量(这种场景我们此时也不讨论)
newCap = oldThr;
else {
// zero initial threshold signifies using defaults
// 如果旧容量和旧边界值都为0,说明这是一个空map,需要被初始化
newCap = DEFAULT_INITIAL_CAPACITY;
//从上面的代码中我们可以看出DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//即1向左位移4位即,1==>10000,这是16的二进制表示,即新容量为16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
//而新边界值则需要经过计算并且转化为整型DEFAULT_LOAD_FACTOR在代码里可以看到是0.75
//即0.75*16=12,只要容量被占用超过12,我们就需要进行扩容了
}
if (newThr == 0) { // 如果此时新边界值还未0则做下面操作(此时我们不多做研究)
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;// 将计算完成的新边界值赋值给成员变量threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//用新容量来初始化新数组
table = newTab; // 将新的数组赋值给成员变量数组
if (oldTab != null) { //不仔细也能看出是如果原来的map里就有数据,扩容后需要对数据进行搬迁,这个我们也暂时不讨论,这次我们就只讨论初始化
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;//返回新数组
}
首先如果目前map是空map,我们要对他做的是初始化,则先要给他确定一个初始的容量(即16)和一个初始的边界值(即12),用这个容量生成新数组,并把新数组和新边界值都传递给成员变量,这就完成了初始化。
让我们继续看回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)
n = (tab = resize()).length; // 初始化现在已经结束
if ((p = tab[i = (n - 1) & hash]) == null) //判断 n-1与hash做与运算得到的数值对应数组下标的位置是否为空,并把数组该下标的值取给p
tab[i] = newNode(hash, key, value, null); // 如果该下标位置为空,则生成一个新的node插入
else { // 如果不为空即发生了hash冲突,则分三种情况
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 1. put的key与节点中的key完全相同,则用p赋值node
e = p;
else if (p instanceof TreeNode)
// 2. put的key和节点中的key不完全相同,且节点上保存了一个红黑树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 对红黑树进行插入节点操作,如果返回值为null则说明红黑树里没有key值和插入的key值相等的节点,插入已经成功(具体插入步骤之后讨论)
else {
// 3. put的key和节点中的key不完全相同,且节点上保存一个链表或者还没有链表
for (int binCount = 0; ; ++binCount) {// 循环链表
if ((e = p.next) == null) { //先从p的子节点出发,并将e定位到p的子节点,如果p没有子节点
p.next = newNode(hash, key, value, null);//用新的键值对创建一个节点并把这个节点赋值为p.next,即作为p的子节点
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果此时的链表长度已经超过一个给定的值,那么将链表转化为红黑树,并跳出循环。
treeifyBin(tab, hash);
break;
}
// 若e的hash等于传入hash,key与传入Key完全相等,则跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//否则,将e赋值给当前节点p并进入下一次循环,此时binCount会增大一位
p = e;
}
}
//从上面的代码可以看出,如果e为null则,key在map中并不存在,直接生成新节点进行插入了,但是如果key存在,e就会是那个存在的节点。
// 如果onlyIfAbsent为false或者节点原来的value为空,就用新值进行覆盖,覆盖后返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//实际上并没有进行任何操作
return oldValue;
}
}
// 上面说如果是覆盖会直接返回,如果是新插入节点,则会增加节点数,并当当前数组利用的长度超过边界值(即之前计算的12,如果扩容会发生改变,但是基本上都是容量*0.75),则进行扩容。
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);//此处没有进行任何操作
return null;//如果是插入了新节点,那么会返回空
}
if ((p = tab[i = (n - 1) & hash]) == null) :这步实际需要详细解释,正好在这个位置也说一下hash这个值是怎么来的。在我们进入put方法的时候,实际上我们的第一个参数是由hash(key)计算得到,正好就是此处的hash变量。那么这个参数是做什么用的呢?
从上面的初始化过程可知,我们初始化了一个容量为16的数组,数组是不能改变长度的,那么我们的必须为每一个插入的对象指定一个小于16的下标,这个下标是怎么计算的呢?是用n-1与hash这个值做且运算得到的,我们知道&是位运算,于是我们先把n-1转换为二进制,即是01111。如果一个数和01111进行且预算是怎么保证这个得到的数一定是小于等于15的呢?
因为我们做且运算,所以01111左边的位全部填充为0。又因为0&1或0都是0,所以其实最多只有右边的四位数会生效,总之就是截取了这个二进制数的最后四位。之前我们看到了,16的二进制为10000,则四位二进制数,一定小于16,即小于等于15,1111是这个方法能取到的最大二进制数。
看起来这个计算方法和hash%16异曲同工。
而我们需要看一下hash这个值是如何取得的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们知道,java中的上帝对象Object有一个方法是hashCode(),这个方法会返回一个对象对应的32位数。这里的方法是先获取key的32位hashCode,然后赋值给h,再对h右位移16位,即去掉最左的16位数,用右移后的结果对h本身进行异或预算,即用高16位和低16位进行异或运算。
为什么这样做呢?因为既然我们已经确定了数组的长度为16,那么我们就要尽可能的利用这16个空间,至少让我们的数据能够尽量平均的分布,也就是最后下标最好不要集中在某几个,其他几个下标完全没有反应。这就是我们为什么要使用异或^的原因。因为&和|都会让数值倾向1或0,只有异或^才能让1和0平均分布。如下图:
这样我们最后会获得一个32位数,来和n-1进行且运算,此时会得到一个小于等于15的下标。
我们首先判断我们给这个key计算的下标对应的空间是否已经存放了节点:
1. 如果没有,那么我们直接新建一个节点存放到该下标对应的空间中。
2. 如果有,那么分三种情况:
a. put的key与节点中的key完全相同,则用p赋值node
b. put的key与节点中的key不完全相同,且p中存放着红黑树,此时对红黑树做节点put,如果已有对应节点则取出对应节点,如果没有则将新节点插入红黑树并返回空。
c. put的key与节点中的key不完全相同,且p中存放着链表或者还没有链表,遍历列表,如果已有对应节点则取出节点,如果没有则将新节点作为最后一个子节点插入链表且返回空(此处需要判断是否需要变链表为红黑树)。
如果我们找到了key相同的对应节点,则对对应节点的值做覆盖,并把老值作为结果返回。
如果我们插入了新节点,我们首先判断是否需要扩容,需要则扩容。
最后我们再来说一下扩容是如何操作的,回到resize():
/**
* 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) { // 如果原有容量大于0,说明我们是做扩容
if (oldCap >= MAXIMUM_CAPACITY) { // 如果此时容量已经达到最大,那实在没办法了,只能保持原有容量
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) // 如果没有达到最大,那么新容量就是就容量的左移一位,即旧容量*2(二进制很好理解,10000(16)变成了100000(32)),并且新容量也小于最大容量,就把就边界值左移一位变成新的边界值。如果新容量已经等于最大容量暂且为0,之后重新计算。
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 如果旧边界值大于0 且旧容量为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) {
// 如果新边界为0则用新容量重新计算(如果新容量等于最大值也属于这种情况)
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
//如果新容量等于最大值,就用Integer.MAX_VALUE做边界。
}
threshold = newThr; // 把新边界赋值给成员变量threshold
@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) { //用节点的hash与原容量进行且运算得到的值如果为0
if (loTail == null) // 如果loTail就为空,则把e赋值给loHead
loHead = e;
else
loTail.next = e; //否则就把loTail的子节点指向e
loTail = e; // 最后把e赋值给loTail,即数组原来的下标
}
else {//用节点的hash与原容量进行且运算得到的值如果不为0
if (hiTail == null) // 如果hiTail为空则将e赋值给hiHead
hiHead = e;
else
hiTail.next = e; //否则就把hiTail的子节点指向e
hiTail = e;// 最后把e赋值给hiTail高位的尾巴,即数组的原来位置+旧容量
}
} while ((e = next) != null); // 直到遍历完成
if (loTail != null) {//如果链表放在lo系列里,那么原下标不变进行搬迁
loTail.next = null
newTab[j] = loHead;
}
if (hiTail != null) {//如果链表放在hi系列里,那么下标变为元下标+旧容量
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;//返回新数组
}
大概理一下整个过程:
1. 如果是扩容,则判断现有的容量是否已经最大,最大则直接返回旧容量。
2. 如果现有容量没有到达最大,就把容量继续左移一位作为新容量,当新容量没有到达最大值是,边界值也是左移一位。
3. 如果新容量到达了边界值,那么新边界就设为最大整数。
4. 对现有数据进行搬迁。
搬迁过程如下:
1. 遍历链表,依次用节点的hash值与旧容量做且运算。
2. 如果且运算结果为0那么放入低位链表。
3. 如果且运算结果不为0那么放入高位链表。
4. 低位链表存入原下标,高位链表存入原下标+旧容量的下标中。
那么让我们来研究一下为什么是这么计算的:
首先假设我们的旧容量为10000(16),那么新容量则为旧容量左移一位即100000(32)。
此时旧的n-1为01111(15),那么新的n-1为011111(31)。
让我们看看旧n-1&hash和新n-1&hash有什么差别呢?
对就只是多了第五位这一位二进制。
那么我们用旧容量10000&hash就可以把这一位数单独提出来了,如果hash右往左数第五位为0,那么此时计算结果也为0.哪怕用新n-1&hash也是得到就下标,而如果计算结果不为0,那么理所当然的,新下标即为旧下标左边多出一位1,即下标增加了一个原有容量的数。
这样我们就能理解这个为什么这么写了。
三. 总结
分析到这里,我们应该已经能理解hashmap是如何由数组、链表和红黑树构成的,并且put操作是经过了哪些步骤吧。