本文关键知识点
- 初始大小和加载因子是最重要的参数,new一个hashMap只需要对这两个field的赋值;
- initialCapacity K会被hashmap调整成为最小的大于K的2^N设置为真实的capacity,默认值为16最大值为2^30;对capacity的扩容不能破坏2^N这个基本设计;
- loadfactor默认为0.75,threshold=capcity*loadfactor于是默认值是12,当元素个数超过threshold之后map会进行resize;
- 内部的table采用基本数据结构array来代表buckets,每个bucket内采用单向链表或者红黑树来处理发生哈希冲突的数据;
- hashmap采用了懒加载的模式,在new对象时仅设置容量和加载因子,table相关的初始化发生在第一次putValue时;
- hashmap的capacity采用2^n是为了(1)快速index(2)rehash时最小化成本;也因此扩容的时候也是2倍的扩容,不能破坏这个基本的设计原则;
- hashmap中的hash方程采用了key原生hashCode高低位xor的设计思路;
- hashmap中重要的fields:modCount,table,size,threshold,loadfactor,entrySet;
- hashmap中三类默认配置:容量、加载因子、红黑树。
重要参数:initialCapacity,loadFactor;懒加载;
创建一个hashmap最基本的构造器需要指定一个hashmap的初始大小和loadfactor。当然这个构造器大家其实不常用,hashmap在这个本质的构造思路的基础上包装了:隐藏loadFactor(使用默认值0.75),隐藏initCapacity(默认16)和loadFactor的构造器。当然也可以根据一个map直接创建对象并putValue。
从最本质的构造器可以发现new一个map其实只需要给两个定义hashmap的关键内部变量赋值,其他的什么都不用多做。这其实是一种lazy的思想,new对象时只记录定义这个数据结构的关键参数,而不进行初始化,在真正需要使用时再进行初始化。(这里根据其他map来构造除外,因为这个时候已经有了elements to put)。
// 最本质的构造器
public HashMap(int initialCapacity, float loadFactor) {
// 核心参数initCapacity: 最小是0,有个最大限制1 << 30 (1,073,741,824大概是一百多万)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 核心参数loadFactor: 必须设置大于0的数,毕竟这个数代表了
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
// 包装或者变化之后更容易用的3个构造器
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
复制代码
关键的两个变量java doc解释非常清楚,这里简化翻译一下。
HashMap有两个影响其性能的变量,初始大小initial capacity和加载因子 load factor。- initial capacity和load factor。capacity代表了hash table中有多少个buckets,initial capacity指的是在hash table创建时的大小。
- load factor是指当hash table多满的时候会进行自动扩容并重新哈希(rehash),当一个hash table中的元素的个数超过 当前的capcity*loadfactor时,hash table会扩容成原来的2倍并重新哈希。
- 通用的load factor是0.75,这个取值比较好的平衡了时间和空间的成本。如果加载因子过高,可以节约内存空间,但是确加重了大部分hashmap提供操作的搜索的成本,比如get/put。(笔者补充,如果加载因子过小,会浪费空间并且会降低iterator的效率)。
初始容量调整为2^n
默认的hashmap的大小是16,如果用户指定了一个数n,在hashmap代码内部会把它修正成为不小于n的2的幂。在刚刚的构造器里可以看到这里的赋值并不是开发者传入的值,而是经过了转化:
this.threshold = tableSizeFor(initialCapacity); // 构造器中的哈希表容量修正
复制代码
这里涉及3个问题: 1) hashmap源码是如何计算容量的; 2) 为什么要让size是2的幂; 3) 为了回答第2个问题需要了解一个key是如何映射到一个bucket的;
capacity 如何调整为2^n
先看hashMap内部哈希表的大小是如何计算的。这里DougLea大哥直接使用了位运算,果然大佬们都是用位运算的(写redis的antirez也通篇用位运算)。 下面的无符号右移运算的结果是取得了一个11...111(二进制表示法)的数,二进制位数与cap的位数相同。大家可以自己写一个数严格按这个操作试试。
这里解释一下为什么右移的位数是每次*2的,因为第一次右移一定能保证n的最高位和次高位都是1(n的最高位一定是1,第一次按位或使得次高位无论是什么也一定是1),这样就保证了原来数的最前面两个bit是1,下次用这两个1去按位或就好,这样下一轮可以把2个bit确定为1,在下一轮4个。每次确认bit的个数是原来的2倍,这是一种增速算法。
在此基础上+1,就有了一个一定比initial cap大的2^k的容量。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
复制代码
capacity 为什么要调整为2^n
这里有两个原因(1)为了key能快速index到bucket;(2)为了减少resize之后rehash的成本;1-bitmask代替求余快速index
这里为什么要让cap是2^n,是因为hashmap这里采用了bitmask的方法来代替除法求余哈希。求余运算是一种CPU低效率的运算,相反位运算就快速的多了,所以bitmask就是一种常见的替代思路。bitmask正如其名,去mask掉(掩盖掉)bit值,留下需要的值。
举个栗子来说,一个hashtable的长度是4(2^2),那么buckets用二进制表示就是00,01,10,11。那么对key一个很简单的分配bucket的方法就是0000...00011&key,也就是看key的最后两位属于哪个范畴。这里hashmap源码也是这么实现的,
这里hash是key在经过hash function计算的值,tab是hashmap中的Node[](array)与n-1按位与计算得到index;
tab[i = (n - 1) & hash]
复制代码
2-resize之后低成本的rehash,类似一致性hash的思路
这里首先要知道resize是编程原来capacity的两倍,遵循了capacity是2整数次幂的设定。这里的设计和arrayList是不同的,arraylist看一下源码可以粗糙的认为扩容是1.5倍,采用了oldCap+oldCap>>1的方案。至于我看还有面试官喜欢问为什么两者不一样,原因主要是hashmap 2^n的设计不能因为扩容而破坏(当然可以进一步回答为什么要这么设计),此外hashmap扩容可以减少hash碰撞对查询效率是有帮助的,而arraylist扩容如果没有真的填写更多数据就造成了浪费,所以保守选了1.5倍。
一致性hash希望达到的目的是hash nodes发生变化时,rehash最小化需要迁移的数据。而通过使用上述2^n的table容量,和(n - 1) & hash的index定位方法,hashmap在resize到两倍时能保证:1)一部分数据不需要迁移;2)需要迁移和不需要迁移的数据能快速确认;(注意者不代表resize是低成本的,只是在一个比较高成本的操作上尽可能减少其成本)。
resize的源码分析放在后面,这里先讲一下原理:index=(n-1)&hash,当n-1的二进制表示从0011变成两倍的0111时,一个key的hash只有在新增的一个最高位1有值时才会被hash到新的位置,否则就保持不动。那么如何这个1呢?0100&hash就可以了,这个简单的位运算非常快,而0100其实就是原来的容量值old Capacity。
哈希函数xor of higher bits
刚刚在讲bitmask的方法确认index时,参与 index = a&(n-1) 的并不是key本身,而是其hash值。hashmap相关的数据结构最重要但是却最容易被忽略的就是hashfunction,如果hashfunction选择的不好往往这个数据结构设计的再好也没法用,然而hash往往是数据家研究的领域工程师们往往了解的比较粗浅。不过略懂也是好的。
看一下源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
这里第一个信息点事可以看到hash支持key是null,会hash到0。 第二个信息点是key的自定义的hash值进行位移后的异或操作,这里code的作者做了解释,这里翻译一下:
在前面bitmasking方案中,由于hashtable的长度关系,一个key的hashCode往往只有最低的一些bit位参与了运算,如果两个hashCode只有比mask高位bit有变化,那么在这种index方案下就一定会发生哈希碰撞。小的hashtale如果key是连续的浮点数的话,这种哈希碰撞的方案非常常见。所以我们提出了一个让高位bits参与到hash值的方案,这个方案权衡了计算速度、可用性和bit分散的程度。由于大部分hashCode可能已经足够分散了,并且我们也用了tree来处理哈希碰撞,所以我们就用了右移高位bits进行XOR运算这个廉价的方案来避免系统性的失败。
这里再总结一下从一个key到哈希表有以下过程: 1. key.hashCode() 2. 再次hash (>>>16, xor) 3. hash & (cap-1) 得到index 4. index中node的创建内部字段
-
hashmap的instance fields中首先是有前面讲过的loadfactor和threshold,loadfactor一般在初始化之后就固定了但是threshold在每次hash resize之后会进行重新计算,这两者的关系是threshold=cap*loadfactor。
-
size字段来记录key-value的个数,每次putVal发生时size++,维护这个字段方便程序判断是否size超过了threshold的限制需要进行扩容。
3)modCount就是一个常见且非常巧妙的设计了,这里有必要看一下源码大佬们的原话: modCount用来表示hashmap结构性的修改,结构性的修改指的是影响hashmap中mapping个数的修改,或者直接改变hashmap内部结构的修改(比如rehash)。这个字段的维护是为了帮助iterator和views能快速失败,避免读到不正确的数据(ConcurrentModificationException)。
这里看一下putVal过程就会改变刚刚讲过的size和modCount的数值。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...//中间省略了
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
复制代码
4) hashmap中哈希表的具体实现table内部使用了两种基本数据结构:array和基于node的单向链表。hashmap其实就两大类解决哈希冲突的方式,或者说两大类实现方式:碰撞采用链表或者tree的数据结构,或者碰撞采用开放寻址。而java使用了第一种的方案。
/* ---------------- Fields -------------- */
/** 这里表明table的初始化发生在第一次使用的时候,resize也发生在需要的时候,并不会提前操作。同时table size是2^n。
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
复制代码
5) node的定义就比较复杂了,直接间接涉及到hashmap的有三个:单向链表,双向链表,TreeNode。这里就不贴全部代码了,看一下核心字段: /** * 最基本的单向链表Node */ 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;
}
// 在linked hashmap中的双向链表,所以与Node唯一的区别只是指针的区别
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// treeNode是最复杂的结构,因为红黑树的算法比链表要复杂多了,本篇就不大篇幅展开分析了,需要在讲treefy和untreefy中尝试讲解。
复制代码
静态字段
static field主要有我们之前讲过的:initial_capacity默认值是16,最大的容量默认2^30大概是100多万,默认加载因子是0.75。上面对于这两个值已经充分讨论过了,就不多赘述了。
// 容量相关
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
// 加载因子相关
static final float DEFAULT_LOAD_FACTOR = 0.75f;
复制代码
剩下3个默认值都与红黑树相关。链表虽然搜索复杂度是O(n)比红黑树的O(logN)满,但是链表的操作简单,所以hashmap只有当一个bucket内部不断putValue直到nodes超过一定数量时才会考虑把链表转换为红黑树,这个默认数值TREEIFY_THRESHOLD是8;当然哈希碰撞变多的另一个原因是table太小;所以hashmap的另一个限制是table中buckets的个数也就是table的长度,如果table比较小那么优先选择对整个hashtable进行扩容而不是改造成树,这个默认的table大小是64。
如果一个bucket已经转换成红黑树,但是一个bucket内部数量不断减少,少于一个下下限时hashmap会认为链表更经济把树再转换为链表,这个下限默认是UNTREEIFY_THRESHOLD=6。
6个核心static字段
// 红黑树相关
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;(进行红黑树转换的最小table长度)
// treefy的判断前提
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
复制代码