Hash算法(哈希散列)
Hash算法是一个广义的算法,也可以认为是一种思想,使用Hash算法可以提高存储空间的利用率,可以提高数据的查询效率,也可以做数字签名来保障数据传递的安全性。所以Hash算法被广泛地应用在互联网应用中。
Hash算法也被称为散列算法,Hash算法虽然被称为算法,但实际上它更像是一种思想。Hash算法没有一个固定的公式,只要符合散列思想的算法都可以被称为是Hash算法。
所有散列函数都有如下一个基本特性:如果两个散列值是不相同的(根据同一函数),那么这两个散列值的原始输入也是不相同的。这个特性是散列函数具有确定性的结果。但另一方面,散列函数的输入和输出不是一一对应的,如果两个散列值相同,两个输入值很可能是相同的,但不绝对肯定二者一定相等(可能出现哈希碰撞)。
常用HASH函数
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快地定位。常用Hash函数有:
- 直接寻址法。取关键字或关键字的某个线性函数值为散列地址。即H(key)=key或H(key) = a·key
+b,其中a和b为常数(这种散列函数叫做自身函数) - 数字分析法。分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
- 平方取中法。取关键字平方后的中间几位作为散列地址。
- 折叠法。将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。
- 随机数法。选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合。 除
- 留余数法。取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生碰撞。
处理冲突方法
- 开放寻址法;Hi=(H(key) + di) MOD
- 再散列法:Hi=RHi(key),i=1,2,…,k
RHi均是不同的散列函数,即在同义词产生地址冲突时计算另一个散列函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但增加了计算时间。 - 链地址法(拉链法)
- 建立一个公共溢出区
HashMap的结构
在Java中,HashMap采用数组+链表的方式来实现对数据的储存。
- 数组的特点是:寻址容易,插入和删除困难
- 链表的特点是:寻址困难,插入和删除容易
- HashMap链地址法:将数组和链表组合在一起,发挥了两者的优势,这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
HashMap的数据结构图如图所示
从外部拆开看它是一个数组,数组的每个成员是一个链表。该数据结构所容纳的所有元素均包含一个指针,用于元素间的链接。在插入一个元素时根据元素的自身特征把元素分配到不同的链表中去,获取元素的时候通过这些特征找到正确的链表,再从链表中找出正确的元素。
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
/**
* 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;
在HashMap内部定义着一个Node类,可以看出这个类实际上是一个Entry集合类,通过next属性指向下一个Node类对象形成一个单向的链表。
接着HashMap在下面定义了一个table属性,而这个table属性是一个Node类型的数组,从这样看上去,HashMap的数据结构已经很直观地呈现出来了。
HashMap重要变量
DEFAULT_INITIAL_CAPACITY
Table数组的初始化长度: 1 << 4
or 2^4=16
MAXIMUM_CAPACITY
Table数组的最大长度: 1<<30
or 2^30=1073741824
DEFAULT_LOAD_FACTOR
负载因子:默认值为0.75。 当元素的总个数>当前数组的长度 * 负载因子
。数组会进行扩容,扩容为原来的两倍.
TREEIFY_THRESHOLD
链表树化阙值: 默认值为 8 。表示在一个node(Table)节点下的值的个数大于8时候,会将链表转换成为红黑树。
UNTREEIFY_THRESHOLD
红黑树链化阙值: 默认值为 6 。 表示在进行扩容期间,单个Node节点下的红黑树节点的个数小于6时候,会将红黑树转化成为链表。
MIN_TREEIFY_CAPACITY = 64
最小树化阈值,当Table所有元素超过改值,才会进行树化(为了防止前期阶段频繁扩容和树化过程冲突)。
Java8 HashMap数组节点红黑树转化
在JDK1.8中,HashMap采⽤Entry数组来存储key-value对,每⼀个键值对组成了⼀个Entry实体,Entry类实际上是⼀个单向的链表结构,它具有Next指针,可以连接下⼀个Entry实体,当链表⻓度⼤于8的时候,链表会转成红⿊树。
HashMap存储过程
计算数组下标
在使用HashMap的时候,我们是根据关键字获取元素的
Object object;
Map hashMap = new HashMap();
hashMap.get("key");
hashMap.put("key",object);
但是已经知道HashMap实际上是一个Node<K,V>的数组,那么想知道在存取的过程中如何去定位元素,我们可以看HashMap的介绍:
HashMap 实现了 Map 接口,根据键的 HashCode 值存储数据,具有很快的访问速度。
HashMap是一个散列表,它存储的内容是键值对 (key-value)映射。
结合起来,我们可以获取关键字的HashCode值,使用这个HashCode值作为数组下标,那么,我们在存取元素的过程中,就不需要知道数组的下标,直接使用关键字操作对应的元素即可,但这样存在一个问题,HashCode值很大,直接使用并不合理,所以我们想到了对这个HashCode值进行取模。
key.hashCode()%table.length
我们试着直接使用关键字的HashCode值计算数组下标,这里给出一段代码,以"ABAAAAAA"作为关键字计算数组下标,假设数组长度为16
String ab = "ABAAAAAA";
//获取Object的hashCode值
int abhash = ab.hashCode();
//取模计算下标
int abmod = abhash % 16;
System.out.println("abhash:\n" + abhash+"\n"+Integer.toBinaryString(abhash));
System.out.println("abmod:\n" + abmod+"\n"+Integer.toBinaryString(abmod));
运行这段代码,分别输出关键字和取模后的十进制值和二进制值
abhash:
1982147521
1110110001001010010101111000001
abmod:
1
1
首先获得了关键字"ABAAAAAA"的HashCode值,是一个比较大的数字1982147521
,直接使用作为数组下标肯定不行了,进一步对HashCode值进行取模,计算数组下标 1
hash扰动算法
上面已经说了计算数组下标的方法,这么一看似乎没有问题,我们以"BCAAAAAA"作为关键字,再次计算数组下标
String bc= "BCAAAAAA";
//获取Object的hashCode值
int bchash=bc.hashCode();
//取模计算下标
int bcmod = bchash % 16;
System.out.println("bchash:\n" + bchash+"\n"+Integer.toBinaryString(bchash));
System.out.println("bcmod:\n" + bcmod+"\n"+Integer.toBinaryString(bcmod));
运行这段代码
bchash:
317494241
10010111011001001001111100001
bcmod:
1
1
结果发现计算出的下标值跟以"ABAAAAAA"为关键字计算出的下标值一样,我们先来看hashCode方法,该方法返回一个int值,Java中int占4个字节,二进制值的位数为32位,再对关键字"ABAAAAAA"和"BCAAAAAA"的hashCode值进行分析
关键字 | hashCode | length |
ABAAAAAA |
|
|
BCAAAAAA |
|
|
从2进制角度来看,X / 16相当于 X >> 4,即把X右移4位,此时得到了X / 16的商,而被移掉的部分(后四位),则是X % 16,也就是余数。
对hashCode进行取模key.hashCode()%16
,实际上得到的是后四位,也就是无论前面28位的值是多少,都不对结果产生影响,这导致了前面二进制位不同,但是只要后四位二进制位相同,就会发生hash碰撞。
那么Java是如何解决这种问题的,我们看看Java 7 和 Java 8 的代码
//java 7
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//java 8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
Java 7 和 Java 8的HashMap都有一个hash函数,这个函数主要是将Object转换成一个整型
在hash函数里,Object的hashCode与自身右移不同位数的结果进行异或,为什么这么做,我们先看二进制的位异或
二进制位异或
异或也叫半加运算,其运算法则相当于不带进位的二进制加法:二进制下用1表示真,0表示假,则异或的运算法则为:xor
0 ^ 0 = 0
1 ^ 0 = 1
0 ^ 1 = 1
1 ^ 1 = 0
(同为0,异为1),这些法则与加法是相同的,只是不带进位,所以异或常被认作不进位加法。
按位异或都会对结果产生影响
Java 7 和 Java 8 对hashCode右移了n位,从二进制来说就是把原来的高进制位向低进制位移动了n位,而因为按位异或都会对结果产生影响,所以原来的hashCode值与这个经过右移的hashCode值异或,就是提取了高进制位的特征对低进制的特征进行叠加,让原本的高进制位对低进制位产生影响,这样就解决了原本高进制位无论是什么结果都一样的问题,尽量做到任何一位的变化都能对最终得到的结果产生影响。
经过hash扰动计算后,能把高位的特征和低位的特征组合起来,降低哈希冲突的概率,使计算出的hashCode值分布均匀。
我们使用hash扰动计算hashCode值,对比关键字"ABAAAAAA"和"BCAAAAAA"的hashCode值
String ab = "ABAAAAAA";
String bc = "BCAAAAAA";
//获取Object的hashCode值
int abhash = ab.hashCode();
int bchash=bc.hashCode();
//对hashCode进行扰动
int abxor = abhash ^ abhash>>>16;
int bcxor = bchash ^ bchash>>>16;
//取模计算下标
int abxormod = abxor % 16;
int bcxormod =bcxor % 16;
System.out.println("abhash:\n" + abhash+"\n"+Integer.toBinaryString(abhash));
System.out.println("bchash:\n" + bchash+"\n"+Integer.toBinaryString(bchash));
System.out.println("abxor:\n" + abxor+"\n"+Integer.toBinaryString(abxor));
System.out.println("bcxor:\n" + bcxor+"\n"+Integer.toBinaryString(bcxor));
System.out.println("abxormod:\n" + abxormod+"\n"+Integer.toBinaryString(abxormod));
System.out.println("bcxormod:\n" + bcxormod+"\n"+Integer.toBinaryString(bcxormod));
运行这段代码
abhash:
1982147521
1110110001001010010101111000001
bchash:
317494241
10010111011001001001111100001
abxor:
1982160356
1110110001001010101110111100100
bcxor:
317489421
10010111011001000000100001101
abxormod:
4
100
bcxormod:
13
1101
经过对比,扰动后的hashCode值低进制位出现了不同的变化,取模运算得出的数组下标也变得不同了。
Java 8 使用HashMap验证
这里给出一段代码,通过反射获取HashMap的table属性,查看插入元素后的数组元素
//关键字
String ab = "ABAAAAAA";
String bc = "BCAAAAAA";
HashMap<Object, Object> hashMap = new HashMap<Object, Object>();
//向HashMap容器插入元素
hashMap.put(ab,"ab");
hashMap.put(bc,"bc");
//反射获取HashMap Class对象
Class hashMapClass = HashMap.class;
//需要反射获取的属性名称
String str ="table";
//获取属性对象
Field declaredField = hashMapClass.getDeclaredField(str);
//绕过权限机制,访问属性
declaredField.setAccessible(true);
//获取属性的值
Object[] o = (Object[]) declaredField.get(hashMap);
//遍历table属性,输出每个元素的index下标和值
for (int i =0; i<o.length;i++) {
System.out.println("index:"+i);
System.out.println("value:"+o[i]);
}
运行一下这段代码
index:0
value:null
index:1
value:null
index:2
value:null
index:3
value:null
index:4
value:ABAAAAAA=ab
index:5
value:null
index:6
value:null
index:7
value:null
index:8
value:null
index:9
value:null
index:10
value:null
index:11
value:null
index:12
value:null
index:13
value:BCAAAAAA=bc
index:14
value:null
index:15
value:null
经过比较,使用HashMap与上面我们手动计算的数组下标结果一致
并且可以得出一个结论,table数组的长度为16,HashMap容器默认的初始长度为16
Entry链表
经过hash扰动后,计算出的hashCode值分布比较均匀,但这只是降低了hash碰撞的概率,hash碰撞仍可能发生,这里给出一段代码,以关键字"AB"和"BC"计算数组下标
String ab = "AB";
String bc = "BC";
//获取Object的hashCode值
int abhash = ab.hashCode();
int bchash=bc.hashCode();
//对hashCode进行扰动
int abxor = abhash ^ abhash>>>16;
int bcxor = bchash ^ bchash>>>16;
//取模计算下标
int abxormod = abxor % 16;
int bcxormod =bcxor % 16;
System.out.println("abhash:\n" + abhash+"\n"+Integer.toBinaryString(abhash));
System.out.println("bchash:\n" + bchash+"\n"+Integer.toBinaryString(bchash));
System.out.println("abxor:\n" + abxor+"\n"+Integer.toBinaryString(abxor));
System.out.println("bcxor:\n" + bcxor+"\n"+Integer.toBinaryString(bcxor));
System.out.println("abxormod:\n" + abxormod+"\n"+Integer.toBinaryString(abxormod));
System.out.println("bcxormod:\n" + bcxormod+"\n"+Integer.toBinaryString(bcxormod));
运行这段代码
abhash:
2081
100000100001
bchash:
2113
100001000001
abxor:
2081
100000100001
bcxor:
2113
100001000001
abxormod:
1
1
bcxormod:
1
1
经过hash扰动后,计算出的hashCode值低进制位并未出现变化,这样就无法对结果产生影响,只要进行hashCode计算,就无法避免会出现这样的问题,尽管概率低,但不代表不会发生。
正常情况下,在不发生hash碰撞的情况,一个数组节点只存储一个<K,V>键值对,那么,对于hash碰撞的情况,Java怎么去解决这种情况
Java 8 hash冲突问题解决方案:链表O(n)+红黑树O(logn)
- 在不发生hash碰撞的情况,一个位置放一对key-value,冲突后存放两对或多对key-value
- 出现hash冲突的时候,会在这个数组节点位置上挂一个链表,在遍历时,时间复杂度是O(n)
- 如果链表长度达到一定长度后,这种挂链表的方式假设链表很长,会导致遍历链表性能较差,链表会转化为红黑树。使用红黑树的好处是,当遍历红黑树的时候,时间复杂度变为O(logn),性能较高
通过hash函数计算出的hashCode值,只是让hashCode值分布更加均匀,降低hash冲突概率,当经过hash扰动后,仍发生hash冲突的情况,使用同义词链表的方案解决冲突
Java 8 向HashMap插入元素
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with {@code key}, or
* {@code null} if there was no mapping for {@code key}.
* (A {@code null} return can also indicate that the map
* previously associated {@code null} with {@code key}.)
*/
public V put(K key, V value) {
//插入一个元素
return putVal(hash(key), key, value, false, true);
}
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
//hash扰动计算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* 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) {
//声明了一个局部变量 tab,局部变量 Node 类型的数据 p,int 类型 n,i
Node<K,V>[] tab; Node<K,V> p; int n, i;
//首先将当前 hashmap 中的 table(哈希表)赋值给当前的局部变量 tab,然后判断tab 是不是空或者长度是不是 0,实际上就是判断当前 hashmap 中的哈希表是不是空或者长度等于 0
if ((tab = table) == null || (n = tab.length) == 0)
//如果是空的或者长度等于0,代表现在还没哈希表,所以需要创建新的哈希表,默认就是创建了一个长度为 16 的哈希表
n = (tab = resize()).length;
//将当前哈希表中与要插入的数据位置对应的数据取出来,(n - 1) & hash])就是找当前要插入的数据应该在哈希表中的位置,如果没找到,代表哈希表中当前的位置是空的,否则就代表找到数据了, 并赋值给变量 p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);//创建一个新的数据,这个数据没有下一条,并将数据放到当前这个位置
else {//代表要插入的数据所在的位置是有内容的
//声明了一个节点 e, 一个 key k
Node<K,V> e; K k;
if (p.hash == hash && //如果当前位置上的那个数据的 hash 和我们要插入的 hash 是一样,代表没有放错位置
//如果当前这个数据的 key 和我们要放的 key 是一样的,实际操作应该是就替换值
((k = p.key) == key || (key != null && key.equals(k))))
//将当前的节点赋值给局部变量 e
e = p;
else if (p instanceof TreeNode)//如果当前节点的 key 和要插入的 key 不一样,然后要判断当前节点是不是一个红黑色类型的节点
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);//创建一个新的节点数据并放到当前遍历的节点的后面
if (binCount >= TREEIFY_THRESHOLD - 1) // 重新计算当前链表的长度是不是超出了限制
treeifyBin(tab, hash);//超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容,我个人感觉可能是因为觉得一个节点下面的数据都超过8 了,说明 hash寻址重复的厉害(比如数组长度为 16 ,hash 值刚好是 0或者 16 的倍数,导致都去同一个位置),需要重新扩容重新 hash
break;
}
//如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,下面替换内容
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { //如果当前的节点不等于空,
V oldValue = e.value;//将当前节点的值赋值给 oldvalue
if (!onlyIfAbsent || oldValue == null)
e.value = value; //将当前要插入的 value 替换当前的节点里面值
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//增加长度
if (++size > threshold)
resize();//如果当前的 hash表的长度已经超过了当前 hash 需要扩容的长度, 重新扩容,条件是 haspmap 中存放的数据超过了临界值(经过测试),而不是数组中被使用的下标
afterNodeInsertion(evict);
return null;
}