HashMap
2020年12月16日
21:15
目录 |
- - 1. 基本原理 - 1.1 底层数据结构 - 1.2 hash机制 - 1.2.1 下标计算 - - 1.2.2 hash函数部分 - 1.2.3 loadFactor - 1.2 扩容机制 - 2. 源码实现 - 2.1 构造方法 - 2.2 get和put - 2.3 resize 概述 map,dict用了这么久,到底实现是怎么样的?
|
1. 基本原理
1.1 底层数据结构
HashMap基本的原理,即底层数据结构是数组加链表,数组以hash的方式获得下表,拉链法解决hash冲突的问题。jdk 1.8之后,HashMap作了一定的优化,当链表长度大于8时,改链表为转化为红黑树,减少检索时间。
红黑树原理可以参考红黑树。
HashMap本身实现并不算复杂,有几处hash寻下标的细节比较有意思。
内部类基础是两个节点Node和TreeNode的,都非常常规。
注意:HashMap的容量默认是2的倍数-1,即使你指定大小,在后文详细介绍。
1.2 hash机制
在计算机和数学上,hash有很多研究,主要是两个方面,hash函数设计,以及hash空间大小与冲突性能研究。
1.2.1 下标计算
基本公式是:
1 (n - 1) & hash |
n是初始容量,注意n默认会被设置为2的倍数,于是这个公式其实就是直接对n-1求模。
1.2.2 hash函数部分
其中hash函数的设计,理念就是尽可能减少hash冲突,细节嘛,不需要知道。
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 } |
1.2.3 loadFactor
hash空间与冲突性能方面,主要是基于一种事实,当hash空间被填满到一定程度的时候,hash冲突就会剧烈上升。所以,一般使用一个loadFactor来控制HashMap的容量大小。thredhold的原理即是:
1 thredhold = loadFactor * capacity |
超过thredhold就扩容。
1.2 扩容机制
扩容很简单,每次都会直接左移一位,也就是容量翻倍。
有意思是的初始容量设置,一旦设置了容量,它默认都会转化成2的整数倍(取最小的),例如指定100,实际上初始化结果是128。代码如下,细节不重要。
1 static final int tableSizeFor(int cap) { 2 int n = cap - 1; 3 n |= n >>> 1; 4 n |= n >>> 2; 5 n |= n >>> 4; 6 n |= n >>> 8; 7 n |= n >>> 16; 8 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 9 } |
2. 源码实现
2.1 构造方法
都很简单,唯一需要注意的是指定容量的构造方法,默认会调用上面的tableSizeFor
1 public HashMap(int initialCapacity, float loadFactor) { 2 if (initialCapacity < 0) 3 throw new IllegalArgumentException("Illegal initial capacity: " + 4 initialCapacity); 5 if (initialCapacity > MAXIMUM_CAPACITY) 6 initialCapacity = MAXIMUM_CAPACITY; 7 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 8 throw new IllegalArgumentException("Illegal load factor: " + 9 loadFactor); 10 this.loadFactor = loadFactor; 11 this.threshold = tableSizeFor(initialCapacity); 12 } |
2.2 get和put
实际上put才是核心,但也很简单,就是需要注意链表和红黑树之间的转化。这里只保留put的逻辑,其实非常简单
1. 判断底层table是否为空
2. hash计算下标
无元素,插入
树节点,插入红黑树
普通节点插入,或者key相同替换
3. 是否扩容
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 Node<K,V>[] tab; Node<K,V> p; int n, i; 4 // 1. 判断是否为空 5 if ((tab = table) == null || (n = tab.length) == 0) 6 n = (tab = resize()).length; 7 // 2. 定位为空直接插入 8 if ((p = tab[i = (n - 1) & hash]) == null) 9 tab[i] = newNode(hash, key, value, null); 10 // 2. 定位非空 11 else { 12 Node<K,V> e; K k; 13 // key相同,替换之 14 if (p.hash == hash && 15 ((k = p.key) == key || (key != null && key.equals(k)))) 16 e = p; 17 // 树节点,红黑树插入 18 else if (p instanceof TreeNode) 19 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 20 // 普通节点,遍历尾插入,大于8则转为红黑树 21 else { 22 for (int binCount = 0; ; ++binCount) { 23 if ((e = p.next) == null) { 24 p.next = newNode(hash, key, value, null); 25 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 26 treeifyBin(tab, hash); 27 break; 28 } 29 // key相同替换之 30 if (e.hash == hash && 31 ((k = e.key) == key || (key != null && key.equals(k)))) 32 break; 33 p = e; 34 } 35 } 36 if (e != null) { // existing mapping for key 37 V oldValue = e.value; 38 if (!onlyIfAbsent || oldValue == null) 39 e.value = value; 40 afterNodeAccess(e); 41 return oldValue; 42 } 43 } 44 ++modCount; 45 if (++size > threshold) 46 resize(); 47 afterNodeInsertion(evict); 48 return null; 49 } |
2.3 resize
resize涉及到扩容,复制原表,再hash的过程。所以要尽可能的避免。扩容之后,正常来说节点的下标要改变(hash % cap)。jdk1.8之后,人们发现,当容量是2的倍数,扩容之后下标要么是原来的值,要么是原来的值 +原容量。
其实并不是人们发现,而是java对于HashMap的实现本身就是这么设计的,容量是2的倍数 + hash取下标的方式,目的是取消resize的时候重hash。
看不懂上面的说法没有关系,下面会从1.7的机制开始讲起,回头看一遍就懂了。
假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
来自 <https://tech.meituan.com/2016/06/24/java-hashmap.html>
有没有发现key=5的不变之外,其他都变成了 原下标+原容量。为什么呢?
从二进制码的角度就非常好理解了。
a,b分别是hash1和hash2两个值,在4容量和16容量下的下标计算,注意计算公式是(n - 1) & hash,所以就特别好懂了。11111比起 1111多出了1位,所以有的hash值就会变成原下标 + 原容量(idx + n,注意上述1111是n-1).