Hash
哈希,译作散列,或哈希。就是把任意长度的输入,通过散列算法(hash算法),变换成固定长度的输出,这个输出的值就是哈希值。显然这是一个映射的过程。
hashCode()
再来看一看HashCode,这是一个方法,该方法返回一个特殊的值,在java中会返回一个整数,用来判断是否是两个相同的对象,和equals方法有紧密的联系:
- HashCode主要用于提供快捷的查找,在HashTable和HashMap中都有使用,HashCode是用来在散列存储结构中确定对象的存储地址的(之所以这样说是因为index的计算与hashCode息息相关)
- 如果使用equals(Objetc)方法,两个对象相等,那么这两个对象调用hashCode方法返回的值一定是相等的
- 如果两个对象中的equals方法被重写了,那么一定也要按照同样的方法来重写hashCode方法(这是为了保持hashCode方法的常规协定,规定了相等对象必须有相同的hashCode值)
- 借用网上看来的文章的一句话:两个对象的hashCode相同(其实更应该说成通过hashCode计算出的index相同),不代表就是同一个对象/两个对象相同,在hash存储结构中,这只说明了两个对象发生了冲突,被分配在了同一个桶里面。java判断两个对象是否相同还会判断对象引用中存储的地址是否相同(默认)
Hash函数
hash函数,用来计算出哈希值的函数,通常情况下,每一个对象都有自己单独的哈希值,通过hash函数计算出后,可以做到唯一识别。虽然有可能会有冲突的情况出现,出现了同一个hash值,但概率是微乎其微再来n个微乎其微…..
hash函数的用途有这么几个:可以这么说,hash就是找到一种数据内容和数据存放地址之间的映射关系。
- 文件校验:通过对文件摘要,可以对文件进行校验,一定程度上能检测并纠正数据传输中的信道误码,但不能防止对数据的恶意破坏
- 数字签名:在数字签名协议中,用的最多的单向散列函数可以产生一个机构的数字签名
- 数据结构中提供快速查找的功能:常用的数据结构HashMap和HashTable会使用到Hash函数来产生hash值,是组成HashMap优越性能必不可少的一环
HashMap
在分析这个HashMap之前我们先来看一看数组和链表,我们都知道,数组提供了很好的查找性能,因为数组空间是连续的,查找起来很方便,但是在数据的插入和删除时,性能就不佳了;再看链表,它的存储空间是离散的,所以在数据的插入删除时,性能很高,但是当论到查找时,其性能就不行了。
综上所述,我们总是在面对问题时,根据自己的需求来使用不同的数据结构,这是权衡和妥协的结果。那么我们如果能使用到一种数据结构,它提供良好的查找性能,又可以很方便的插入删除。于是乎,把这两种数据结构组合起来就有了我们这个HashTable。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
从图中可以看出,这是由数组和链表组成的数据结构,在数组中每个元素存储的是一个链表的头指针,把一个个数据存放到相应的位置,就需要由hash函数来计算了,一般是采用index = hash(value)%length计算出元素应该放到对应下标的数组中的位置。比如,如果value为5,数组长度为10,则计算出的下标位置就是5%10=5,这个值应该放到下标为5的元素中。当然了,如果俩个值计算出存放的位置相同了,就以后存入的值为头节点,以链表的形式存入,以此类推
现在回过头来看看HashMap,它其实也是一个线性的数组实现的,所以可以理解为其存储的数据结构就是一个线性数组。但是有一点我们需要注意的就是,HashMap是按照键值对来存取数据的,这一点怎么可能通过数组或是链表来实现呢?
深入到HashMap的源码中去看,对照着资料,发现在HashMap中存取数据的关键有一个叫做Map.Entry的内部接口很是关键,再去看Entry,发现它被定义为Entry<Key,Value>
,而Map.Entry
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table;
- 1
- 2
- 3
- 4
HashMap存取的实现
在“线性数组”的基础上如何做到随机存储呢:重点是确定键值对的存储位置,这里是希望HashMap里面的元素尽量离散分布,使每个位置上的元素只有一个。当使用hash算法求出这个位置时,马上就可以获取对应位置的值,而不用取遍历链表。也与hash方法的离散性能密切相关
// hash jdk1.8
static final int hash(Object key) {
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
简单说起来,这里的Hash算法本质上就是三步:取key的hashCode的值、高位运算、取模运算
对于任意对象,只要hashCode返回值相同,那么程序调用方法所计算的Hash码时一样的,把hash值对数组的长度取模运算,这样元素的分布相对来说是比较均匀的。在上面的方法中,通过把hashCode返回值高16位和低16位与计算,达到了hashCode返回值取模数组长度的效果。因为在HashMap底层数组中,length总是2的n次方(不够的用null填充),此时使用hashCode返回值与数组长度进行与运算依然达到了上述的效果,这是jdk1.7中的实现方法,在1.8中高16位与低16位进行与运算是优化的算法,能保证在hashCode返回值很大时,高低Bit都会参与到hash运算中,并且不会产生较大的开销
put
我们知道HashMap中键 Key一定是唯一的,那么当再次往HashMap中存入键相同的键值对时,上一次存入的键值对就会被覆盖。但是如果两个键值对的index值一样时,HashMap会把先存入的值放入链表的尾部,最新加入的值则是该线性数组中每个下标对应的链表的首元素,以此类推。
需要注意到的是,jdk1.8新增了HashMap链表中节点的个数对于8个时,转为红黑树的存储方式
查看HashMap中的put方法源码:
public V put(K key, V value) {
// 进行hash运算
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断键值对数组table是否为空或null,否则进行resize扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根据键值key计算hash得到插入位置的索引
if ((p = tab[i = (n - 1) & hash]) == null)// p被赋值
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 判断键值对中key是否存在(相同),存在直接覆盖,相同指hashCode和equals
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);
// 如果链表的长度大于8就 转化为红黑树处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在,直接覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 存在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;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
reszie的源码是将原来数组的容量扩大一倍,这个过程是一个十分消耗性能的过程,所以在使用中最好定一个预定的最大值,避免HashMap进行频繁的扩容。默认的负载因子是0.75
注意
还一个小细节就是,每次put入键值对时,都是先比较key的hashCode,再去使用equals比较key,这样可以节省查重的效率
get
首结点都是Entry类型的键值对
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) {
if (first.hash == hash && // 先检查链表中的首结点
((k = first.key) == key || (key != null && key.equals(k))))
return first; // 判断出了与key相同(hashCode和equals)
if ((e = first.next) != null) {
// 继续根据hash查找
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;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
null key
null key总是放在Entry[]数组的第一个元素
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
获得索引值index
HashMap存取时需要计算索引index来确认到Entry[]数组取元素的位置,也就是获取数组下标的过程
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
- 1
- 2
- 3
- 4
- 5
- 6
按位取并,作用上相当于取模:index = hashcode % table.length
hashtable初始大小
在调用HashMap的无参构造方法时,初始大小是16。当后续大小改变时,table初始大小总是2的n次方(没有填充满就空着)
Hash冲突
我们总是希望整个HashMap是一个尽量离散的优秀结构,用尽量少的空间存储尽量多的数据,且其查找增删的性能依据很高效。这个是一个复杂的平衡过程,和负载因子相关,和解决hash冲突的办法相关:hash冲突是指两个key被分配到了同一个桶中
- 开放定址法(线性探查再散列、二次探查再散列、为随机探查再散列)
- 再哈希法
- 链地址法(拉链法)
- 建立一个公共的溢出桶
java中的HashMap使用的就是拉链法,如前面图所示
再散列过程 rehash
当哈希表的容量超过默认的大小时,就需要将所有的元素换一个新的“桶”来存储,这个新的桶中的键值对存放的位置会发生改变,需要重新根据新桶的大小来重新计算各个键值对的索引位置,这个过程就叫做rehash
谈一谈血与泪
之所以新加上这个片段就是因为真是彻底的被自己的记性教育了,这真是血淋林的教训啊,已经不记得有几次面试时答错了,这里总结记录一下:
- HashMap是非线程安全的,HashTable才是线程安全的
- HashMap中允许有
null
键值对,HashTable不允许
总结
此次深入探究java中的HashMap查阅了不少资料和源码,感谢先行者的指引,这里仅是个人愚见,如有异议,欢迎联系
HashMap实现原理分析
java8重新认识HahsMap