HashMap中的数据结构与get,put源码解析

时间:2021-05-10 10:17:07

HashMap 执行流程:

 

首先构造方法:

public HashMap() {

        this.loadFactor =DEFAULT_LOAD_FACTOR;// all otherfields defaulted

    }

public HashMap(intinitialCapacity) {

        this(initialCapacity,DEFAULT_LOAD_FACTOR);

}

 

public HashMap(intinitialCapacity,floatloadFactor) {

      

}

public HashMap(Map<?extends K, ?extends V>m) {

    

    }

通过重载方法HashMap传入两个参数,1.初始化容量,2.装载因子

那么就介绍下几个名词:

1.      capacity,表示的是hashmap中桶的数量,初始化容量initCapacity为16,第一次扩容会扩到64,之后每次扩容都是之前容量的2倍,所以容量每次都是2的次幂,

(为什么HashMap的容量是2的次幂呢?        

因为在源码中我们发现在通过hash值寻找putindex时进行的是一个位运算(n-1)&hash,位运算是基于二进制的,所以是2的次幂;

通过位运算可以很快的找到putindex位置,所以hashMap的插入效率很高。

比如初始容量为16,一个待插入元素hash值为6,那么我们一般要插入的位置就是index=6,也就是6%16我们进行的是取余操作。那么试试位运算(16-1&6

&位与运算:有00

0111   15

0110   6

_______

0110   6

通过取余和位与运算我们都得到了想要的结果,那么位运算的高效率肯定会被采纳。

当容量为2n次幂时,减1后与任何数进行与运算都可以快速的得到取余结果,也就是index的值。

)

//默认初始化容量为16

static final int DEFAULT_INITAL_CAPACITY=1<<4;  //aka 16

//中间会进行扩容操作,但是最大容量为2的30次方

static final int MAXIMUM_CAPACITY = 1 << 30;

 

2.  loadFactor,装载因子,衡量hashmap一个满的程度,初始化为0.75

//默认的装载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

    实时装载因子是size/capacity;

3.  threshold,hashmap扩容的一个标准,每当size大于这个标准时就会进行扩容操作,threeshold等于capacity*loadfacfactor

/**

 The next sizevalue at which to resize (capacity * load factor).

**/

int threshold;

 

4.  size,表示HashMap中存放Node的数量,就是所有的键值对数量。

                String str="abc";
String str1=new String("abc");
Map<String, String> map=new HashMap<>();
map.put("11", "22");
map.put("11", "22");
map.put(null, "22");
map.put(str,"1");
map.put(str1,"1");

System.out.println(map.size());

HashMap中的数据结构与get,put源码解析

这个程序运行结果是3,从这里我们可以看出键已经有了的话是不会在添加而是覆盖的,而且可以允许null做值,null做键,当然值得注意的是str,str1,在这里他size不考虑地址,只考虑内容,尽管str和str1指向的地址不同,但是put进去仍然是覆盖不是添加。马上我们会根据源码进行分析。

 

 /**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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会抛出异常,大于了2的30次方也就是最大容量时,会定为最大容量,判断装载因子,如果小于等于0或者他不是一个数字(通过Float.isNaN(loadFactor)),抛出异常,在最后也初始化了threshold的值

当然如果通过传Map进行初始化

    public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

 /**
* Implements Map.putAll and Map constructor
*
* @param m the map
* @param evict false when initially constructing this map, else
* true (relayed to method afterNodeInsertion).
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}

HashMap中的数据结构与get,put源码解析

会调用putMapEntries(Map<?extends K, ? extends V> m, boolean evict)方法

在这里面首先通过获取map的size,来判断是否需要扩容,之后循环便利每一个元素放入hashmap中,使用的方法是putVal(hash(key), key, value, false, evict);,这个方法也是我们平时调用map.put(Kkey,V value)的核心。

public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}

我们也会看到,putAll方法的原理也是这个函数。

在第一个参数我们传的是hash(Key),

HashMap中的数据结构与get,put源码解析

 static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


不同的key有着不同的hashCode(),只要hashCode()相同,hash一定相同,但是反之不成立,不同对象的hashCode()的hash是可能相同的,这就是所谓的hash冲突

那么为什么会出现相同的hash呢?

那就是(h=key.hashCode())^(h>>>16)

虽然每个元素的hashCode()是唯一的,但是他的二进制右移(>>>是带符号,>>不带符号)16位就会出现重复的。h>>>16这样只有超过2的16次方hash(key)才会有作用,也就是说在2的16次方内都为0。之后和hashCode进行异或。hash()就是为了让均匀分布,他会让1111 0000 0000 0000变得1111 1110 1110 1111

让”1”变得均匀点

 

下面我们看下hashmap的结构示意图

HashMap中的数据结构与get,put源码解析

 HashMap中的数据结构与get,put源码解析

我们可以看到 每一个元素就是一个Node<K,V>,这个Node<K,V>实现了Map.Entry<K,V> 接口。在jdk1.7中,它是一个HashMapEntry<K,V>

在JDK1.8之前,HashMap采用桶+链表实现,本质就是采用数组+单向链表组合型的数据结构。它之所以有相当 快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap通过key的hashCode来计算hash值,不同的hash值就存在数组中不同的位置,当多个元素的hash值相同时(所谓hash冲突),就采用链表将它们串联起来(链表解决冲突),放置在该hash值所对应的数组位置上。在JDK1.8中,HashMap的存储结构已经发生变化,它采用数组+链表+红黑树这种组合型数据结构。当hash值发生冲突时,会采用链表或者红黑树解决冲突。当同一hash值的结点数小于8时,则采用链表,否则,采用红黑树。

我们现在对hashmap的存储结构现在应该有了一个初步了解了吧,那么我们就来看下,我们每次进行put(key)时到底是hashMap是如何处理的。

 

 public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

HashMap中的数据结构与get,put源码解析

put中调用了 

final VputVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)

 

 /**
* 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)
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<K,V>[]tab; Node<K,V> p;

tab就是数组,而p是每个桶

如果tab刚开始是null或者大小为0,则进行扩容操作resize(),返回值为Node<K,V>[],直接赋值给tab,初始化tab。

初始化之后通过位与运算(求余)找到put的index,如果该位置没有元素也就是tab[index]==null,那么tab[i] =newNode(hash, key, value, null);即put成功

 

当然我们知道hash冲突是有的,所以当tab[index]!=null时,也就发生了hash冲突

 if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;

 if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}

第一个if其实考虑的是重复键,第二个if我们可以看到绿色的注释说的是在map中已经存在key了,所以这两步是对于已有key情况下的节点put的一个处理。

如果不是重复的,那么就看p是否是树节点,因为jdk1.8中采用的是红黑树,所以要考虑树节点,如果是树节点就进行树节点的put,e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key,value);对于树节点的插入我们这里就不多做解释了

如果上述情况都不是,那就是hash冲突并且使用链表处理了;。

 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;
}

HashMap中的数据结构与get,put源码解析

通过e=p.next进行一个链表遍历,,

如果等于null也就是说遍历到了末尾也没发现重复的key,那么就是就执行一个插入操作,是一个尾插法,jdk1.8之前是头插法,jdk1.8是尾插法,

那么为什么jdk1.8是头插,之前为头插法呢

1.8为什么尾插我觉得大家通过上面这段话应该都可以在知道原因吧,因为我已经遍历到了链表尾部了,尾插不就更省事吗?

可是有些人问了1.7是单独的数组加链表,那不应该也尾插吗?这就有一个效率问题了,因为jdk1.8每当节点>8时就会变为树,而树的遍历会更加快速,

而链表遍历最多也就是7次,效率还是很高的,可是1.7就不是这样了,如果你有10000个节点,那你如果尾插的话就需要遍历10000次,这是非常耗时的,所以1.7采用的是头插法。                                                                                                                   

         再插入过程中,如果桶中节点个数大于树的阈值TREEIFY_THRESHOLD-1,就会进行树化。从链表变为红黑树

HashMap中的数据结构与get,put源码解析

 ++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;

最后进行一个判断,看size是否到达了扩容标准,如果达到了进行扩容resize();

resize():

如果为空,则按threshold分配空间,(默认是数组=16,装载因子=0.75f,阈值=16*0.75),否则,加倍后,每个容器中的元素在新table中要么呆在原索引处,

要么有一个2的次幂的位移(这也是保证了hashmap中的元素分配均匀)

HashMap中的数据结构与get,put源码解析 HashMap中的数据结构与get,put源码解析                                                                                                                                                                                                                                                                                      

到这里我们平常所用的put方法就结束了

 

下面我们看下get方法

HashMap中的数据结构与get,put源码解析

HashMap中的数据结构与get,put源码解析

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) {
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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}


get方法比较简单

主要是getNode(int hash,Object key)

直接判断hashmap中的桶是否为空,并且看tab[index]是否为空,如果为空则返回null

否则检查tab[index]处Node的属性,看key是否相等,相等返回改Node,不是则遍历该桶中的节点。

利用first.next遍历,如果是树节点则getTreeNode(hash,key),是链表节点的话遍历链表寻找。