上一篇文章讲了Java的常见容器,相信大家对Java中容器的继承关系有了大致了解了。今天我们将聚焦HashMap,从Java中HashMap源代码实现开始,来对HashMap进行剖析(妈妈再也不用担心我的面试)。本文将回答下列几个问题:什么是HashMap?有哪些应用?Hash碰撞是什么?如果我们自己写一个MyHashMap,应该怎样去实现?
一、HashMap的概念
哈希表(hash table)也叫散列表,是一种非常重要的数据结构(说了和没说好像没什么区别)。在具体介绍HashMap之前,我们先来探讨一下为什么需要HashMap。
数据结构 | 优点 | 缺点 |
数组 | 查找方便,索引访问时间复杂度为O(1) | 增、删性能较慢 |
列表 | 增、删元素性能较快 | 访问链表元素需要从头遍历,速度较慢 |
树 | 对于相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn) | 占用的空间较大 |
经过对比上述三种结构,单纯地用某种数据结构来进行存储数据都不能应对大多数的数据存储情景,能否有一种结构将它们的优点都结合起来,成为一种比较完美的结构呢?HashMap诞生了。
HashMap是基于哈希表的Map接口的非同步实现,存入HashMap的数据以key-value的形式进行存储。
(图片出自:https://www.cnblogs.com/chengxiao/p/6059914.html#t1)
由于数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
哈希表的主干就是数组!
哈希表的主干就是数组!
比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
存储位置 = f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。如果不同的元素都映射在同一个位置,我们称之为“哈希冲突”,此时则在此位置上会生出一条链表(也叫“拉链法”)。由于一个好的哈希函数会让冲突发生的可能性降低,所以生成链表后需要完全遍历的几率就会小很多,此时HashMap访问的时间复杂度仅仅比数组的略高,远远低于访问数组的复杂度。
在JDK1.7以前,HashMap是数组+链表实现的;在JDK1.8以后,如果发生哈希冲突的次数>8次,则会调用HashMap的方法自动将列表转化为“红黑树”。红黑树是平衡二叉树的一种,能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍,因此能在数据较多时,也能有高于链表的访问性能(O(logn))。
二、源码分析
(一)继承的接口、类
可以看到,HashMap继承了一个类和两个接口:
AbstractMap<K,V>:作为Map接口的抽象类,实现了Map接口的大部分方法
Cloneable:表示可以拷贝
Serializable:序列化(表示可以把对象保存在本地)
上述三个类或者接口更详细的作用不是本文的重点,请大家自行百度。
(二)关键属性:
1. static final int DEFAULT_INITIAL_CAPACITY = 1<<4 ;//初始容量为16,采用位运算,速度更快
注意:(1)如果我们自己要存的数据量很大,必须把这里的数字改大,否则HashMap性能会因为一直做扩容操作而下降;
(2)自定义最大容量时,一定要是2的整数次幂。(具体原因读下去你就明白了)
最大容量:1<<30 //即可以存储2的30次个K-V对
2.static int DEFAULT_LOAD_FACTOR = 0.75f; //扩容因子
即:当hashmap中的元素个数/hashmap的容量 = 0.75 时,将发生扩容操作
3.static final int TREEIFY_THRESHOLD = 8 ; 当同个hash桶中的元素个数>8时,链表->RB tree;
static final intUNTREEFY_THRESHOLD = 3 ; 当同个hash桶中的元素个数>8时,RB tree -> 链表;
4.1 transient Node <K,V> [ ] table ; //hash桶,存储元素的数组
当必要的时候回进行扩容(即hashmap中的元素个数/hashmap的容量==扩容因子时);而且每次的扩容总是让数组的长度增长两倍。
4.2 transient SET<Map,Entry> entrySet ; // 存储实际元素的集合
HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能
(二)几个关键方法
1.构造方法:上文提过,initialCapacity可随着自己的需要自行更改(否则会多次扩容而减慢速度),loadFactor也可自己预设。
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);
}
2.hash运算:为即将存入HashMap的元素的Key计算一个hash值后,以此hash值为索引放入哈希桶中。
请注意返回语句中的(h = key.hashCode()) ^ (h >>> 16),正因为这里需要进行hashcode()和位运算,才要求自己设置的Capacity才必须得是2的整数次幂,否则就不能有最快的运行速度。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3.Put方法
putVal(hash,key,value,onlyIfabsent,evict) —— 此方法是一系列Put操作进行时调用的方法,非常长,不过我们可以将它可大致分为几个板块进行解读,就不会觉得很长了。具体见注释。
put(key,value)——开放给用户使用的方法,直接输入键值对即可。
注:put中的key和hash桶中的key不同,前者是用户的原数据,而后者是原数据经过哈希计算后的值。
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;
//p即将要存放的hash桶位置上是空的,直接存入即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//p要放的位置在哈希桶中已经被占了,拉链法
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//p即将要存放的位置是二叉树上的结点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//p即将要存放的位置是在链表上的结点
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果达到了转化为二叉树的数量,调用treeifyBin方法
if (binCount >= TREEIFY_THRESHOLD - 1)
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;
}
}
//元素增加后,,若HashMap内部结构发生变化,快速响应失败
++modCount;
//size+1,如果>threshold,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
4.Get方法:get()同样是开放给用户使用的方法,其实它内部是调用了GetNode()方法的。从上文我们知道,在HashMap中可能会有数组+链表+树存在的情况,所以Get方法自然也会根据不同的数据结构来进行操作。
如果是树的结点,调用getTreeNode()方法继续找,由于getTreeNode()涉及到的代码过多,此处不一一列出。
源代码具体分析如下:
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) {
//总是从hash桶开始找,如果第一个结点就是所要的结点,则直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果第一个结点不是,且还有后继,则分情况继续找
if ((e = first.next) != null) {
//如果是树节点,调用getTreeNode()方法
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);
}
}
//没找到,返回null
return null;
}
注:1.请注意源代码中的“==”和“equals”何处使用的:
==:用于算key是否相等,涉及到在内存中的位置;
equals:用于算value是否相等,涉及到和值的比较有关的操作(如果相等则返回或者存储等等等)。
2.hash桶中存的是key的hash值而不是key本身。
(三)MyHashMap的实现
分析了HashMap的实现原理后,我们自己也可以动手来写自己的HashMap,只是我们的性能并没有原版那么快而已。
作为第一个版本,实现是最重要的为了简明扼要:1.我们的hash运算直接照搬原版,性能比直接模运算快一些;2.hash冲突>8时我们也不进行链表转红黑树的变化。以后我们可以继续进行优化,让它更像一个真正的哈希表。
package MyHash;
public class MyHashMap<K,V> {
private static final int DEFAULT_SIZE=1<<4; //默认大小为16
private Entry<K,V> data[]; //hash桶,这里存的就是table表
private int capacity; //必须是2的整数次幂
private int size; //HashMap存放的数据个数
public MyHashMap(){
this(DEFAULT_SIZE);
}
public MyHashMap(int cap){
data = new Entry[cap];
size=0;
this.capacity=cap;
}
private int hash(K key){ //照搬源码
int h;
h = (key==null) ? 0 : (h=key.hashCode())^(h >>> 16); //直接return可能会越界,mod
return h % capacity; //此处是防止越界
}
public void put(K key,V value){
int hash = hash(key);
Entry<K,V> newE = new Entry<K,V>(key,value,null);
Entry<K,V> hasM = data[hash];
//!!! 注:此处还有扩容 >8时链表转红黑树没写
while(hasM != null){ //当hasM的位置有元素时,即发生了hash冲突,遍历到链表的末尾
if(hasM.key.equals(key)){ //怕有一样的
hasM.value = value;
}
hasM = hasM.next;
}
newE.next = data[hash];
data[hash] = newE;
size++; //表示成功插入一个数据
}
public V get(K key){
int hash = hash(key);
Entry<K,V> entry = data[hash];
while (entry != null ){ //如果data[hash]处有值,开始遍历
if(entry.key.equals(key)){ //注意是equals,而不是==
return entry.value;
}
entry = entry.next;
}
return null; // 没找到的情况。 注:null 也可以作为泛型的返回值
}
private class Entry<K,V>{
K key;
V value;
Entry<K,V> next;
int cap; //表示起hash冲突的个数
public Entry(){
}
public Entry(K key,V value,Entry<K,V> next){
this.key = key;
this.value = value;
this.next = next;
}
}
}
运行结果如下:
符合预期。
(四)HashMap和HashTable的区别
其实这个问题上一篇文章里已经有提到,为了内容的完整性,再次叙述一遍。
父类 | 线程安全性 | null值 | 遍历方式 | 初始容量 | 计算Hash值的方式 | |
HashMap | AbstractMap类 | 不安全,需要自己增加同步处理 | 可以有null(key只能有一个null) | 根据不同结构采取不同遍历方式 | 16(每次扩容为2n) | 位运算(更快) |
HashTable | Dictionary(已被废弃,详情看源代码) | 线程安全 | key、value均不能为null | Iterator | 11(每次扩容2n+1) | 直接使用对象的hashCode |
注:HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,可能是 HashMap中没有该键,也可能使该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()方法来判断。
2019-3-13 2:09