本文主要是从jdk源码入手, 结合常用操作, 图文并茂, 探讨Java中HashMap的一些设计与实现原理.
HashMap基于哈希表的Map接口实现,是以key-value存储形式存,及主要用来存放键值对. HashMap的实现不是同步的,这意味着它不是线程安全的. 它的key,value都可以为null.此外,HashMap中的映射不是有序的.
补充: 将链表转换成红黑树前会判断, 即使阈值大于8, 但是数组长度小于64, 此时并不会将链表变为红黑树. 而是选择进行数组扩容.
这样做的目的是因为数组比较小, 尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行 左旋,右旋, 变色 这些操作来保持平衡. 同时数组长度小于64时, 搜索时间要相对快些.
所以综上所述为了提高性能和减少搜索时间, 底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树.具体可以参考 treeifyBin
方法.
当然虽然增了红黑树作为底层数据, 结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变得更高效.
HashMap特点:
pulic static void main(String[] args){
//创建HashMap集合对象
HashMap<String, Integer> hm = new HashMap<>();
hm.put("柳岩",18);
hm.put("杨幂",28);
hm.put("刘德华",40);
//hm.put("柳岩",18);
hm.put("柳岩",20);
System.out.println(hm);
}
{杨幂=28, 柳岩=20, 刘德华=40}
HashMap
当创建HashMap集合对象的时候.
Entry[] table
用来存储键值对数据的.Node[] table
用来存储键值对数据的假设向hm中存储柳岩-18
数据,根据柳岩调用String类中重写hashCode()方法计算出值, 然后结合数组长度采用某种算法(散列算法)计算出向Node数组中存储数据的空间的索引值.
如果计算出的索引空间没有数据,则直接将柳岩-18
存储到数组中, 举例:计算出的索引位3
面试题: 哈希表底层采用何种算法计算hash值? 还有哪些算法可以计算出hash值?
底层采用的key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或^,按位与& 计算出索引号
还可以采用: 平方取中法,取余数,伪随机法
10%8 ==> 2, 11%8 ==>3
向哈希表中存储数据刘德华-40
,假设"刘德华"
计算出的hashCode方法结合数组长度计算出的索引值为3,那么此时数组空间不是null,此时底层会比较"柳岩"
和"刘德华"
的hash值是否一致, 若不一致,则在此空间上划出一个结点来存储键值对数据刘德华-40
(拉链法)
假设向哈希表中存储数据柳岩-20
,那么首先根据柳岩调用hashCode方法结合数组长度计算出的索引肯定是3. 此时比较后存储的数据柳岩
和已经存在的数据的hash值是否相等, 如果hash值相等,此时发生哈希碰撞
那么底层会调用柳岩所属类String
的equals方法比较两个内容是否相等:
相等: 则将后面添加的数据的value覆盖之前的value
不相等: 那么继续向下和其他的数据的key进行比较,若都不相等, 则划出一个结点存储数据
哪怕string不同也有可能hashCode方法值相等:
String a = "重地";
String b = "通话";
System.out.println(a.hashCode()+ " " + b.hashCode());
System.out.println(a.equals(b));
/*
1179395 1179395
false
*/
如果结点个数(链表长度)大于阈值8并且数组长度大于64 则将链表变为 红黑树.
2. 当两个对象的hashCode相等会怎么样?
产生冲突(哈希碰撞),如key值内容相同则替换就得value值,不然连接到链表后面,链表长度超过阈值8就转换为红黑树存储.
3. 何时发生哈希碰撞和什么是哈希碰撞?
只要两个元素的key计算的hashcode相同就会发生冲突.
jdk8前使用链表解决哈希碰撞.jdk8后使用链表+红黑树解决
4. 如果两个键的hashcode相同,如何存储键值对?
hashCode相等. 通过equals方法比较内容是否相等.
相同: 则新的value覆盖老的value值
不想同: 则将新的键值对添加到哈希表中.
在不断地添加数据的过程中, 会涉及到扩容的问题, 当超出临界值(且要存放的位置非空时)时,扩容 .默认的扩容方法为: 扩容为原来容量的2倍,并将原有的数据复制过来.
通过上述描述,当位于一个链表中元素众多,即hash值相等但是内容不等的元素较多时,通过key值依次查找的效率较低. 而jdk1.8中,哈希表存储在链表长度大于8并且数组长度大于64时将链表转换为红黑树.jdk8在hash表中引入红黑树主要是为了 查找效率更更高.
传统HashMap的缺点,1.8为什么引入红黑树? 这样结构不就变得更麻烦了嘛? 为何阈值大于8才换成红黑树?
1.8之前HashMap的实现是数组+链表, 即使哈希函数取得再好,也很难达到元素的百分百均匀分布.当HashMap中有大量的元素都放在同一个桶中时,这个桶下有一条长长的链表, 这个时候HashMap就相当于一个单链表, 假如单链表有n个元素, 遍历的时间就是O(n).
1.8为解决这一问题, 使用 `红黑树(查找时间复杂度为O(logn)) 来优化这个问题.当链表长度很小的时候,即使遍历,速度也很快,但是当链表长度不断变长,对查询也存在影响.
一些说明:
size
表示HashMap中K-V的实时数量, 注意这个不等于数组的长度.threshold
(临界值) = capacity
(容量) * loaFactor(加载因子). 这个值是当前已占用数组长度的最大值. size 超过这个临界值就会重新 reszie
. 扩容后的HashMap容量是之前容量的两倍.public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
说明
补充: 为甚HashMap基础AbstractMap而AbstractMap类实现了Map接口, 那为啥HashMap还要去实现Map接口呢? 同样ArrayList也是如此.
这是一个失误. 最开始写Java框架时, 以为会有一些1价值, 直到其意识到毫无价值.
private static final long serialVersionUID = 362498820763181265L;
由于实现了序列化接口, 所以需要一个默认的序列化版本号.
/**
* The default initial capacity - MUST be a power of two.
*/
// 1<< 4 相当于 1*(2^4)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
问题: 为啥是2的次幂?如果输入的值并非2的n次幂而是比如10 会怎样?
public HashMap(int initialCapacity) //构造一个带指定初始容量和默认加载因子(0.75)的空HashMap
根据上述我们知道了, 当向HashMap中添加一个元素的时候, 需要根据key的hash值,去确定其在数组中的具体位置.HashMap 为了存取高效 ,要尽量较少碰撞,就是要尽量把数据分配均匀, 每个链表长度大致相同, 这个实现就在把数据存到哪个链表上的算法.
这个算法实际就是取模, hash % length
, 计算机中直接求余的效率不如位运算. 所以源码中做了优化,使用 hash&(length -1)
, 而实际上 hash % length
等于hash&(length -1)
的前提就是length是2的 n 次幂.
为什么这样能均匀分布减少碰撞呢?
举例:
说明: 按位与运算: 相同的二进制位上都是1的时候,结果才为1, 否则为0
例如长度为8:
3 & (8-1) = 3
0000 0011
0000 0111
----------
0000 0011
13 & (8-1) = 5
0000 1101
0000 0111
---------
0000 0101
例如长度为9:
3 & (9-1)
0000 0011
0000 1000
---------
0
2 & (9-1)
0000 0010
0000 1000
---------
0 碰撞,而当length为8时不会
13 & (9-1)
0000 1101
0000 1000
---------
0000 1000
如果不是2的n次幂,计算出的索引特别容易相同, 及其容易发生哈希碰撞,造成其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率较低
小结:
由上可看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会有数据,浪费数组空间,加大冲突的可能.
一般我们会想通过 % 取余来确定位置, 这样也行, 只不过性能不如 & 运算.而且当n是2的幂次方时: hash & (length-1) =hash % length
因此, HashMap容量为2的n次方的原因,就是为了数据的均匀分布,减少hasn冲突. 毕竟hash冲突也多,代表数组中的一个链的长度就会越大,这样的话会降低hashmap的性能.
如果创建的HashMap对象输入的数组长度不是2的n次方时,HashMap会通过移位运算和或运算得到2的n次方数, 并且是距离那个数最近的数字(比如输入10, 获得16), 源代码如下:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) //最大2^30
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
说明:
如果给定了initialCapacity(假设为10), 由于HashMap的capacity必须都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的次幂(此处为16),然后返回.下面分析这个算法:
为什么要对cap减1操作呢? int n = cap - 1;
这是为了防止,cap本身就是2的n次幂, 若不进行此操作,则执行完该方法则会得到这个cap的二倍,比如输入8, 不进行-1的话返回16
现在来看这些个无符号右移. 若果n这时为0了(经过了cap-1),则经过后面几次无符号右移依然是0,最后返回capacity的值为1(最后有个n+1的操作). 这里讨论不为0的情况.
注意: | 按位或运算: 相同位置上都是0的时候才为0, 否则为1
cap = 10
int n =cap-1; == > 9
n |= n >>> 1
00000000 00000000 00000000 00001001 9 >>> 1
00000000 00000000 00000000 00000100 4
--------------------------------------
00000000 00000000 00000000 00001101 13 最高位右边相邻位为1
n=13
n |= n >>> 2
00000000 00000000 00000000 00001101 13 >>>2
00000000 00000000 00000000 00000011 3
---------------------------------------
00000000 00000000 00000000 00001111 15 最高两位右边相邻两位为1 -- 此时最高4位为1
n=15
00000000 00000000 00000000 00001111 15 >>> 4
00000000 00000000 00000000 00000000 0
----------------------------------------
00000000 00000000 00000000 00001111 15 最高位有8个连续的1, 但是这里没有8位,不变...
以此类推, 容量最大也就是32bit的正数, 最后一次 >>> 16
将变为连续的32个1(但这已经是负数了. 在执行tableSizeFor之前, 对initialCapacity做了判断, 如果大于MAXIMUM_CAPACITY = 2^30
,则取MAXIMUM_CAPACITY.
所以这里的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY. 30个1,加1后为2^30
综上, 10 变成 16就是这样得到的~
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//集合最大容量的上限是: 2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
当链表的值超过8, 则会转红黑树(1.8之后)
//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8
面试题: 为什么Map桶中结点个数超过8 才转为红黑树 ?
8 这个阈值定义在HashMap中, 在源码注释中只说明了8是bin(bin就是bucket桶)从链转换成红黑树的阈值,但是并没有说为什么是8:
在HashMap中174行有一段说明
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
因为树的结点大约是普通结点的两倍(有指向), 我们只在箱子包含足够多结点时才使用树结点(参考 TREEIFY_THRESHOLD
). 当他们变得太小(由于删除或者调整)时,就会被转换为普通的桶. 在使用分布良好的用户HashCodes时, 很少使用树箱.理想情况下,箱子中的结点的频率服从泊松分布
(http://en.wikipedia.org/wiki/Poisson_distribution) ,默认调整阈值为0.75,平均参数约为0.5 ,尽管由于调整粒度的差异很大.忽略方差,列表大小k的预期出现次数(exp(-0.5) * pow(0.5, k) / factorial(k)): 第一个值为:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
TreeNodes 占用空间是普通Nodes的两倍, 所以只有当bin包含足够多的结点时才会转成 TreeNodes
, 而是否足够多就是TREEIFY_THRESHOLD
决定的. 当bin中结点变少时(长度降到6)就又转为普通bin.
这样就解释了为什么不是一开始就转换为TreeNodes, 而是需要一定结点数才转为TreeNodes,说白了就是权衡,空间和时间
这段内容还说: 当HashCode离散性很好时,树形bin用到的概率很小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度达到阈值. 但是在随机hashcode下,离散性可能会变差,然而jdk又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布.不过理想情况下随机hashCode方法下所有bin中结点分布频率满足泊松分布.可以看到,一个bin中链表长度达到8个元素的概率为0.00000006
. 几乎是不可能事件.所以,之所以选择8,不是随便决定的,而是根据概率统计得到.
简而言之,选择8是因为符号泊松分布,超过8的时候,概率已经非常小了.所以选择8
另外还有如下说法:
红黑树的平均查找长度为
log(n)
, 如果长度为8,平均查找长度为log(8)=3,链表平均查找长度为n/2
,当长度为8时,平均查找长度为4,这才有转换为树的必要;链表长度若为小于等于6.6/2=3,而log(6)=2.6,虽然速度也快些,但转化为树和生成树的时间并不会太短.
//当桶bucket上的结点数小于这个值时树转换为链表
static final int UNTERRIFY_THRESHOLD = 6;
当前Map里面的数量超过这个值时, 表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容,树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESOLD(8)
//桶中结构转化为红黑树对应的数组长度最小值
static final int MIN_TREEIFY_CAPACITY = 64
重点
//存储元素的数组
transient Node<K,V>[] table;
table 在jdk8中我们了解到HashMap是由数组加链表加红黑树来组成的结构. 其中tale就是HashMap中的数组,8之前为
Entry
类型. 1.8之后只是换乐观名字Node
,都实现一样的接口: Map.Entry
负责村村键值对数据.
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySets;
重点
//存放元素的个数,注意这不等于数组的长度
transient int size;
size为HashMap中K-V的实时数量,不是table的长度.
//每次扩容和更改HashMap的修改次数
transient int modCount;
计算方式为(容量 *负载因子)
//临界值 当实际大小([容量capatocy=16]*[负载因子0.75])超过临界值[threshold]时,会进行扩容(翻倍)
int threshold;
重点
//加载因子
final float loadFactor;
说明:
loadFactor
加载因子,是用来衡量HashMap的满的程度, 表示HashMap的疏密程度, 影响hash操作到同一个位置的概率,计算HashMap的实时加载因子的方法为: size/capacity, 而不是占用桶的数量去除以capacity. capacity是桶的数量,也即是table.length
loadFactor太大导致查找元素效率低,太小导致数组利用率低,存放的数据会很分散. loadFactor的默认值0.75f是官方给出的比较好的临界值.
当HashMap里面容纳的元素达到HashMap数组长度的0.75时,表示HashMap太挤,需要扩容,而这个过程涉及到rehash,数据复制等操作,非常消耗性能. 所以开发中尽量减少扩容次数,可以通过创建集合对象时指定初始容量来尽量避免.
另外在HashMap构造器中也可以指定loadFactor
面试题:为啥默认0.75的threshold啊?
0.4 那么16*0.4 ---> 6 如果满6个就进行扩容会造成数组利用率太低
0.9 那么16*0.9 ---> 14 那么这样导致链表有点多了,导查找元素效率低
capacity(数组默认长度16)*loadFactor(负载因子默认0.75)
. 这个值是当前占用数组长度的最大值.当Size >= threshold时,那么就要考虑对数组进行扩容.也就是说,这个数用来衡量数组是否需要扩容的一个标准.HashMap中重要的构造方法如下:
1, 构造个空的HashMap,默认初始容量(16) 和默认负载因子(0.75)
/**
* Constructs an empty HashMap with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认因子0.75赋给loadFactor,并没有创建数组
}
2, 构造一个具有指定的初始容量和默认loadFactor的HashMap
/**
* Constructs an empty HashMap with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) { //指定容量
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3, 构件一个具有指定初始容量和loadFactor的hashMap
/**
* Constructs an empty HashMap 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) //大于 2^30
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor)) //小于0或者不是一个小数
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor; //检查完毕,局部变量传给成员变量
this.threshold = tableSizeFor(initialCapacity);//放进去10,threshold为16,照理应该是12,之后put()会修改
}
说明:对于 this.threshold = tableSizeFor(initialCapacity);
疑问解答:
tableSizeFor(initialCapacity)判断指定的初始化容量时候为2的n次幂,如果不是则变为最小的离它最近的那个2的n次幂.这点前面已经讲过.
但是注意,在taleSizeFor内部将计算后的数据直接返回赋给threshold,有人觉得应该这么写:
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
这样才符合threshold的意思(当HashMap的Size达到threshold这个阈值时会扩容).
但是注意, 在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中**,在put方法会对threshold重新计算**,put方法的具体实现下面会继续讲解
4, 包含另一个Map的构造函数
public HashMap(Map<? extends K, ? extends V> m) { //将原来的集合m内容放在新的集合里面
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
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; //思考这里为啥要 +1
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold) //这里threshold为0
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);
}
}
}
面试题: float ft = ((float)s / loadFactor) + 1.0F; //思考这里为啥要 +1f
?
s/loadFactor 结果是小数,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能保证更大容量,加大容量能够保证减少resize的调用次数. 所有+1.0f是为了获得更大的容量.
例如:原来集合的元素是6个,那么6/0.75是8,是2的n次幂,那么新的数组的大小就是8了. 然后原来数组的数据就会存储到长度为8的新的数组中,这样会导致在存储元素的时候, 容量不够,还得继续扩容, 那么性能降低了; 而如果+1呢, 数组长度直接变为16了,这样可以减少数组的扩容.
主要步骤:
具体方法如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
1) key=null:可以看出当key为null时hash值为0
1) key不等于null:
首先计算出key的hashCode赋值给h, 然后与h无符号右移16位的二进制进行按位异或^ 得到最后的hash值
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
if ((p = tab[i = (n - 1) & hash]) == null) //这里就是上面所说的求摸策略,这里的n表示数组长为16
...
}
上面可知HashMap是支持key为空的,而hashTable是直接用key来获取HashCode所有key为空会抛异常.
同时上面也就解释了 HashMap的长度为什么要是2的幂 .因为HashMap使用的方法很巧, 它通过hash & (table.length-1)来得到该对象的保存位,前面说过HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化. 当length总是2的n次方时, hash & (length-1)
运算等价于对length取模,也就是hash%length
,但是&比%具有更高的效率. 比如n%32 = n & (32 - 1)
下面是一些位运算解析: 注意:异或运算^: 相同为0,不同为1
(key.hashCode()) ^ (h>>>16)
i = (n - 1) & hash
1111 1111 1111 1111 1111 0000 1110 1010 h = key.hashCode()
0000 0000 0000 0000 1111 1111 1111 1111 h >>>16
----------------------------------------------- 异或操作
1111 1111 1111 1111 0000 1111 0001 0101 返回给: hash
0000 0000 0000 0000 0000 0000 0000 1111 n = 16-1
------------------------------------------------ 与操作
0000 0000 0000 0000 0000 0000 0000 0101 i=5 = i = (n - 1) & hash
简单来看就是:
为啥是这样操作呢?
如果当n即数组长度很小,假设是16的话,那么n-1为1111,这样的值与hashCode()直接按位与操作,实际上只使用了哈希值的后四位.如果hash值高位变化很大,低位变化很小,这样就很容易造成hash冲突了,所以这里吧高低位都利用起来,从而解决了一个问题.如下
1111 1111 1111 1111 1111 0000 1110 1010 h1 = key.hashCode()
1010 1011 0001 1111 1111 0000 1110 1010 h2 = key.hashCode() //高位变化很大
此时如果不进行右移16位再异或操作,而是直接和数字长度进行按位与, 则会h1和h2冲突
现在来详细看putVal()方法,看它到底做了什么
主要参数:
/**
* 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为null或者长度为0, 初始扩容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //这里就是上面所说的求摸策略,这里的n表示数组长为16
tab[i] = newNode(hash, key, value, null); //tab[i]为空,直接创建节点
else { //此处已有节点
Node<K,V> e; K k;
if (p.hash == hash && //tab[i]处的已存在节点处的hash == 新插入数据的hash
((k = p.key) == key || (key != null && key.equals(k)))) //并且 (比较两者地址是否相等 或者 key内容相等)
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) { //用for循环找到最后一个节点
if ((e = p.next) == null) { //如果p的后继e为null
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 加之前是否比阈值8小1,也就是说现在节点数是否等于8
treeifyBin(tab, hash); //链表转为红黑树
break;
}
if (e.hash == hash && //表名p的后继不为null
((k = e.key) == key || (key != null && key.equals(k)))) //比较地址 或者 内容
break;
p = e; //p后移, 遍历链表
}
}
if (e != null) { // existing mapping for key 替换策略
V oldValue = e.value; //得到旧的值
if (!onlyIfAbsent || oldValue == null)//如果 可更改 并且旧值不为null
e.value = value; //将新值赋到该处的value
afterNodeAccess(e);
return oldValue; //返回旧值
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
接下来来看看结点数大于等于8转换为红黑树的函数(上面30行,真的会转换吗?)
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) { //传入数组tab,和待插入元素结点的hash
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY=64
resize(); //不转化,而是扩容
else if ((e = tab[index = (n - 1) & hash]) != null) { //此时n=64, 根据hash获得桶中元素,赋给e
TreeNode<K,V> hd = null, tl = null; //头结点hd,尾节点tl
do {
TreeNode<K,V> p = replacementTreeNode(e, null); //将链表结点变为树节点p
if (tl == null) //第一次,p赋给头结点
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null); //循环至链表的最后一个结点
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
下面我结合图进行说明上述代码完成了怎样的一些操作:
查看9,10,12,13,14 行, 初次操作,第一次循环:
此时头结点hd,尾节点tl 同时指向tab[index]转换的结点p
如果e.next != null,继续循环,此时e指向下一个结点,
回到12 行,又将其变成p
接下来执行15~17行:
p.prev = tl; //P的前驱节点指向tl
tl.next = p;//tl的后继指向现在的p
}
tl = p; //尾节点变为p
经过上述操作变为如下:
好了,当e后面没有结点后将不再继续,从而执行到最后一行:
if ((tab[index] = hd) != null)
hd.treeify(tab); //旋转等一些操作
在此之前将链表上的每一个结点都转换为了TreeNode(不过left和right指针都没用), 同时相邻两个结点相互指向, 形式上来看更像是双向链表.
而最后一行, hd.treeify(tab);
就是构造红黑树的关键了.
/**
* Forms tree of the nodes linked from this node.
* @return root of tree
*/
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) { //将调用函数的hd命名为x,next指向下一个结点, 再依次向下进行遍历链表
next = (TreeNode<K,V>)x.next;
x.left = x.right = null; //x的left和right置空
if (root == null) { //第一次,表名根节点
x.parent = null;
x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色)
root = x; //现在这个 hd = x = root了, 即根节点指向到当前节点
}
else { //已经存在根节点了
K k = x.key; //当前链表节点的key
int h = x.hash; //取得当前链表节点的hash
Class<?> kc = null;// 定义key所属的Class
for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
// GOTO1
int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
K pk = p.key;// 当前树节点的key
if ((ph = p.hash) > h) // 如果当前树节点hash值 大于 当前链表节点的hash值
dir = -1;// 标识当前链表节点会放到当前树节点的左侧
else if (ph < h)
dir = 1;// 右侧
/*
* 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
* 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
* 如果还是相等,最后再通过tieBreakOrder比较一次
*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;// 保存当前树节点
/*
* 如果dir 小于等于0 :
* 当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。
* 如果dir 大于0 :
* 当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右孩子,也可能是右孩子的左孩子 或者 更深层次的节点。
* 如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点 再从GOTO1 处开始 重新寻找自己(当前链表节点)的位置
* 如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点的左或者右侧了。
* 挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。
*/
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp; // 当前链表节点 作为 当前树节点的子节点
if (dir <= 0)
xp.left = x; // 作为左孩子
else
xp.right = x; // 作为右孩子
root = balanceInsertion(root, x); //重新平衡
break;
}
}
}
}
// 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的
// 因为我们要基于树来做查找,所以就应该把 tab[N] 得到的对象一定根是节点对象,而目前只是链表的第一个节点对象,所以要做相应的处理。
//把红黑树的根节点设为 其所在的数组槽 的第一个元素
//首先明确:TreeNode既是一个红黑树结构,也是一个双链表结构
//下面这个方法里做的事情,就是保证树的根节点一定也要成为链表的首节点
moveRootToFront(tab, root);
}
小结一下putVal()
其主要完成了这几个事情:
(关于`红黑树数据结构的一些操作,之后会补上)
想要了解HashMap的扩容机制要有这两个问题
1.什么时候才需要扩容
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)
时,就会进行数组扩容,loadFactor的默认值(DEFAUTL_LOAD_FACTOR)是.75. 也就是说,默认情况下,数组大小为16, 那么当HashMap中的元素个数超过 16 * .75 = 12(这个值就是阈值或者边界值threshold)的时候,就把数组的大小扩展为2*16=32
, 然后重新计算每个元素在数组中的位置, 而这是个非常耗时的操作,所以如果我们已经预知HashMap中元的个数,那么是能够有效地提高HashMap性能的.
补充:
当HashMap其中的一个链表对象个数如果达到了8个, 此时如果数组长度没有达到64,那么HashMap会先扩容解决; 如果已经达到了64, 那么这个链表会变成红黑树,节点类型由Node变为TreeNode类型.当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数小于6, 也会再次把树转换为链表
综上::
数组大小(数组长度)*loadFactor(负载因子)
时,会进行数组扩容2.HashMap的扩容是什么?
进行扩容, 会伴随着一次**重新hash分配,**并且会遍历hash表中所有的元素,是非常耗时的. 在编写程序中,尽量避免resize
HashMap在进行扩容时,不需要重新计算hash值,1.8使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的(n-1) & hash
的结果相比,只是多了一个bit位,所以节点要么就在**原来的位置,**要么就被分配到"**原位置+旧容量"**这个位置
原数组长度: n=16
(n-1) & hash
0000 0000 0000 0000 0000 0000 0000 1111 15
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
0000 0000 0000 0000 0000 0000 0000 1111 15
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
数组长度扩容==> 16*2=32
(n-1) & hash
0000 0000 0000 0000 0000 0000 0001 1111 31
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
0000 0000 0000 0000 0000 0000 0001 1111 31
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 0101 索引:5+16=21
现在可以很好理解上面那句话了.
00101 --> 5
0101 ==resize扩容(16*2)=>
10101 --> 5+16(oldCap)
搞懂了核心机制后,下面来看看源代码:
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) { //oldCap不为空
if (oldCap >= MAXIMUM_CAPACITY) { //大于最大容量 2^30
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
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) { //j位置不空,将其赋给e
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) { //高位处为0,放在原位置不动(标记lo和hi)
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;//放在j+oldCap位置上
}
}
}
}
}
return newTab;
}
理解了put方法之后, remove方法已经没有什么难度, 重复的内容不做详细介绍
删除先是找到元素的位置,如果是链表就遍历链表找到元素后删除,如果用红黑树遍历后找到之后删除,树小于6的时候要转回成链表
//方法的具体实现在removeNode方法中, 所以重点看removeNode方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode方法:
/**
* Implements Map.remove and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 && //table不空 且 长度大于0
(p = tab[index = (n - 1) & hash]) != null) { //该索引赋给index, 该处节点赋给p,不能为空
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash && //p的hash 等于 传入的hash:
((k = p.key) == key || (key != null && key.equals(k))))//比较key内容相等
node = p; //表明tab[index]即为我们所要删除的: node 存放tab[index]
else if ((e = p.next) != null) { //tab[index]不是我们要删的
if (p instanceof TreeNode) //是红黑树
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else { //是链表
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null); //遍历链表,找出key内容相等
}
}
if (node != null && (!matchValue || (v = node.value) == value || //node不空
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)//是红黑树节点,红黑树删除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//tab[index]是需要删除的节点
tab[index] = node.next;
else //需要删除的节点在链表中
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
查找方法,通过元素的Key找到Value
代码如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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) { //first 存放所求到的元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))//hash等 并且 key内容也等
return first;
if ((e = first.next) != null) { //如果fist不为所求,但还有后继
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;
}
下面来关注下树的中寻找的getTreeNode(hash,key)
:
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
又调用了find方法:
/**
* Finds the node starting at root p with the given hash and key.
* The kc argument caches comparableClassFor(key) upon first use
* comparing keys.
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)//往左边找
p = pl;
else if (ph < h) //往右边找
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))//找到了
return p;
else if (pl == null)//左边为空
p = pr;
else if (pr == null)//右边为空
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null //递归调用(整个查找方式类似折半查找)
return q;
else
p = pl;
} while (p != null);
return null;
}
小结:
1.get方法实现的步骤:
通过hash值获取该key映射到的桶
桶上的key就是要查找的key,则直接找到并返回
桶上的key不是要找的key,则查看后续的节点:
a: 如果后续节点是红黑树节点,通过调用红黑树的方法根据key获取value
b:如果后续节点是链表节点,则通过遍历链表根据key获取value
3.查找红黑树.由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找
4.这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等直接返回; 不相等就从子树递归查找
创建实验用例
public static void main(String[] args) {
HashMap<String,Integer> hm = new HashMap<>();
hm.put("aaa",1000);
hm.put("bbb",1200);
hm.put("ccc",1400);
hm.put("aaa",1400);
...
}
分别获取Key和Values
简言之: hashmap.keySet()
获取keys, hashmap.values()
获取values
/**
* 分别获取Keys和Values
* @param hm
*/
private static void method1(HashMap<String, Integer> hm){
//获取所有key
Set<String> keys = hm.keySet();
for (String key : keys)
System.out.println(key);
//获取所有value
Collection<Integer> values = hm.values();
for (Integer value : values)
System.out.println(value);
}
通过iterator获取
简言之:先通过hashmap.entrySet
获取键值对集合entries
,再用Iterator逐一遍历
/**
* 使用iterator迭代器迭代
*
*/
private static void method2(HashMap<String,Integer> hm){
Set<Map.Entry<String , Integer>> entries = hm.entrySet();
for (Iterator<Map.Entry<String,Integer>> it = entries.iterator(); it.hasNext();){
Map.Entry<String,Integer> entry = it.next();
System.out.println(entry.getKey() + "---" + entry.getValue());
}
}
通过get(key)方式 (不建议使用–两次使用迭代器,不建议使用)
简言之:先用keySet()
获取所有的key,在通过hashmap.get(key)
获得value
keySet 其实是遍历2次,一次转为Iterator对象,另一次从hashmap中去除key所对应的value. 而entrySet只是遍历了一次就把key和value都放在了entry中.
/**
* 通过get(key)
*/
private static void method3(HashMap<String , Integer> hm){
Set<String> keys = hm.keySet();
for (String key : keys){
Integer value = hm.get(key);
System.out.println(key + "===" value);
}
}
jdk8 以后使用Map接口中的默认方法forEach(BiConsumer super K, ? super V> action)
使用很简单:
/**
* jdk8 以后使用Map接口中的默认方法
*/
private static void method4(HashMap<String, Integer> hm) {
hm.forEach((key,value)->{ //函数式接口
System.out.println(key + "----" + value);
});
}
进入forEach中看看:
//HashMap.java
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
BiConsumer
是个啥东东? 和Consumer有什么关系? 进去看看.
@FunctionalInterface
public interface BiConsumer<T, U> {
/**
* Performs this operation on the given arguments.
*
* @param t the first input argument
* @param u the second input argument
*/
void accept(T t, U u);
default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
Objects.requireNonNull(after);
return (l, r) -> {
accept(l, r);
after.accept(l, r);
};
}
}
和Consumer很像,其实是Consumer相关的函数式接口,Consumer
中有一个核心方法:
void accept(T t); //对给定的参数T执行定义的操作
说白了Comsumer就是给定义一个参数,对其进行(消费)处理,处理的方式可以是任意操作.(抽象方法嘛)
这里BiConsumer
莫不是给定两个参数进行操作.
void accept(T t, U u);
关于这个函数式接口再说两句, java.util.function中 Function, Supplier, Consumer, Predicate和其他函数式接口广泛用在支持lambda表达式的API中。这些接口有一个抽象方法,会被lambda表达式的定义所覆盖.
回到forEach
中可以看到, K,V或其父类, 经过一系列操作,将逐一打印出key和value; 换句话说, 传入给forEach函数的两个参数,即是每个entry的key和value.
补充一点: super T> 与 extend U>
,前者表示任何T泛型的父类或者T(T是下限),后者表示任何U泛型子类或者就是U(U是上限). 上面的函数实现参数BiConsumer super K, ? super V> action
使用super,表示K和V就是其下限(子类),能对K和V进行的操作就一定能不会蹦(子类能满足,父类也能行).
关于这一点, 有机会会专门写一写诸如
这样的…
如果我们确切知道有多少个键值对要进行存储,那么我们在初始化HashMap的时候就应该指定它的容量,以防止HashMap自动扩容,影响使用效率.
默认情况下HashMap的容量为16.但是若用户通过构造函数指定了一个数字作为其容量,那么其会选择大于该数字的第一个2的幂作为容量(3–>4 , 16–>32), 这是前面已经谈过的.
<阿里巴巴Java开发手册>建议我们设置HashMap的初始化容量
[推荐]集合初始化时,指定集合初始值大小
说明: HashMap使用HashMap(int initialCapacity)初始化.
为啥?
HashMap的扩容机制,就是当达到扩容条件时会进行扩容. HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时会自动扩容. threshold = loadFactor *capacity
so, 如果我们没有设置初始化容量大小,随着元素不断增加,HashMap可能会发生多次扩容,而HashMap中的扩容机制会在每次扩容是进行拷贝, 重新hash,很影响性能的.
不过, 设置初始化容量时,设置的数值不同也会很影响性能,那么当我们已知HasMap中即将存放KV个数时,容量设置为多少较好呢? 比如我们有20个KV时,是直接给20么?
正例: initialCapacity = (需要存储的元素个数 / 负载因子) + 1 . 负载因子loadFactor默认为0.75
仔细想想, 假如我们就有7个KV, 然后我们设置HashMap(7), 经过jdk处理后,会被设置成8. 但是,这个HashMap在元素个数达到8*0.75=6的时候就会扩容了, 这不是我们希望看到的, 我们应该尽量减少扩容.
也即是说, 如果我们通过 initialCapacity/0.75 + 1.0
计算: 7 / 0.75 + 1 =10, 经过jdk处理后, 会被变成16 , 这便大大减少了扩容几率.
简单说就是: 你有7个元素需要HashMap操作, 通过计算使用HashMap(10)
, 这时内部自动帮你扩容到16,threshold为12. 当然这么操作会牺牲一些内存.