1、哈希算法
将任意长度的二进制串映射为固定长度的二进制串,这个映射规则就是哈希算法
通过原始数据映射之后得到的二进制值串就是哈希值
两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
需要满足的要求:
- 从哈希值不能反向推导出原始数据(单向哈希算法)
- 原始数据有一点变化,哈希值都会发生巨大不同
- 散列冲突要很小,对于不同的原始数据,哈希值相同的概率要小
- 计算效率要高,对于很长的文本,也要很快计算出哈希值
常见的散列函数:
- 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
- 数据分析法:提取关键字中取值比较均匀的数字作为哈希地址。
- 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
- 伪随机数法:采用一个伪随机数当作哈希函数。
衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本都无法彻底避免碰撞(如MD5、SHA、CRC等哈希算法),常见的解决碰撞的方法有以下几种:
1、开放寻址法 :如果出现了冲突,就重新探测一个空闲位置,将其插入。如何从新探测呢?
- 线性探测,当我们插入数据时发现经过散列函数后,存储的位置被占用,就从当前位置开始,依次向后查找,看是否有空闲位置,直到找到为止
- 二次探测 hash(key) +0,hash(key)+1^2,hash(key)+2^2
- 双重散列 第一个散列函数位置被占用就再用一个散列函数
2、链地址法
将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
3、再哈希法
当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
4、建立公共溢出区
将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
2、HashMap简介
JDK 1.8对HashMap进行了较大的优化,底层实现由之前的 “数组+链表” 改为 “数组+链表+红黑树”,如果链表的长度大于阈值(TREEIFY_THRESHOLD = 8) 其结构改变成红黑树 ,小于某个阈值(UNTREEIFY_THRESHOLD = 6)就变成链表
值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。
Map map = Collections.synchronizedMap(new HashMap());
3、HashMap 的数据结构
1、JDK7及之前
2、JKD8及之后
由图可知,左边是数组,数组的每个成员是链表;HashMap通过key.hashCode()来计算哈希值,只要哈希值相同,计算出来的数组下标就相同,就会出现碰撞,因此这里采用链地址法来解决冲突的
4、HashMap属性
//默认初始化容量 <<是算术左移,移几位就是*2的几次方
//必须是2的倍数,下面会有讲解
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大所能容纳的key-value 个数
static final int MAXIMUM_CAPACITY = 1 << 30;
//加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//如果链表的长度大于阈值 8 其结构改变成红黑树
static final int TREEIFY_THRESHOLD = 8;
//小于阈值 6 就变成链表
static final int UNTREEIFY_THRESHOLD = 6;
//最小TreeNode的容量为64
static final int MIN_TREEIFY_CAPACITY = 64;
//存储数据的Node数组,长度是2的幂。
transient Node<K,V>[] table;
//keyset 方法要返回的结果
transient Set<Map.Entry<K,V>> entrySet;
//map中保存的键值对的数量
transient int size;
//hashmap 对象被修改的次数
transient int modCount;
//容量乘以装在因子所得结果,如果key-value的 数量等于该值,则调用resize方法,扩大容量,同时修改threshold的值。 英文是 阈值,门槛的意思
int threshold;
//加载因子,初始值=0.75,与扩容有关
final float loadFactor;
5、HashMap构造方法
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);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 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;
}
使用指定的初始容量和默认的加载因子初始化HashMap,这里需要注意的是,并不是你指定的初始容量是多少那么初始化之后的HashMap的容量就是多大,例如new HashMap(20,0.8); 那么实际的初始化容量是32,因为tableSizeFor()方法会严格要求把初始化的容量是以2的次方数成长只能是16、32、64、128...
6、hash方法
简单分析: hash方法的功能是根据key来定位这个键值对在Node<K,V>[] table中的位置的,也就是hash方法输入应该是Object类型的key,输出是int类型的数组下标
实现:我们只需要调用Object对象的hashCode()返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了
1、JDK7
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);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
hash :该方法主要是将Object转换成一个整型(通过key获得哈希值)。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。
分析indexFor方法:
h & (length-1),就是取模。之所以使用位运算& 来代替取模运算% ,主要是的考虑是效率。位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快
为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:
X % 2^n = X & (2^n – 1)
2^n表示2的n次方,也就是说,一个数对2^n取模 = 一个数和(2^n – 1)做按位与运算
假设n为3,则2^3 = 8,表示成2进制就是1000。2^3-1 = 7 ,即0111
此时X & (2^3 – 1) 就相当于取X的2进制的最后三位数
补充:
&运算:
1与任何数,都等于其本身
0与任何数,都等于0
|(或)运算:
1和任何数,都等于1
0和任何数,都等于其本身
从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。
例子:
6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2
为什么数组长度要是2的幂次方?
1、由上面可知,
只要保证length的长度是2^n
的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。
2 、其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 – 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
3、可以控制算出来的数组下标不会超出数组本身定义的长度
原因:
数组长度是二的次方,2的2次方,二进制是 100,3次方 1000,4次方 10000。
那么按照这个规律,那么长度 - 1, 刚好是 011, 0111, 01111。这个刚好就可以当做掩码,来计算数组下标。那么就用掩码和hash做个与运算。
011 & 101010100101001001101 = 01 下标=1,数组长度=4
0111 & 101010100101001001101 = 101 下标=5,数组长度=8
01111 & 101010100101001001101 = 1101 下标=13,数组长度=16
可以发现,通过 掩码 & hash,得出的数组下标不会越界。而数组的总长度总是2的次方,就是为了方便取得掩码的。
分析hash 方法
数组下标: (数组长度 - 1) & hash
上述计算中,hash 值的高位,没有参与数组下标计算,而是被掩码给掩盖掉了。假如有一类 hash,特点是低位都是 0,高位才有变化
两个不同的键值,在对数组长度进行按位与运算后得到的结果相同,这不就发生了冲突吗。那么如何解决这种冲突呢,来看下Java是如何做的。
主要代码:
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。这就是需要重新计算hash值的原因
2、JDK8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
和Java7中原理相同。Java 8 这一步做了优化,将低16位和高16位做个异或运算,高16位保持不变。以上方法得到的int的hash值,然后再通过h & (table.length -1)
来得到该对象在数据中保存的位置。
3、HashTable In Java 8
在Java 8的HashTable中,已经不在有hash方法了。但是哈希的操作还是在的,比如在put方法中就有如下实现:
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
为啥要把hash值和0x7FFFFFFF做一次按位与操作呢,主要是为了保证得到的index的第一位为0,也就是为了得到一个正数。因为有符号数第一位0代表正数,1代表负数。
这其实和Java 7中的实现几乎无差别
4、ConcurrentHashMap In Java 8
Java 8 里面的求hash的方法从hash改为了spread。实现方式如下:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。
7、put方法
执行流程图:
执行逻辑:
1.首先进入put方法,会通过hash(key)方法(高16位和低16位进行异或运算来使哈希值更加随机和散列来减少碰撞)来获取一个散列值
2.然后判断数组是否为空,和数组长度是否为零,是就进行reSize()方法初始化(还可以扩容)。
3.进入reSize()中,判断数组容量是否大于0,是就进行初始化,数组容量初始化为DEFAULT_INITIAL_CAPACITY=16,阈值为16*0.75(加载因子);
4.接下来数组长度-1和hash值进行&运算来计算出数组下标
该位置为空:新建节点,直接存入该下标位置
该位置不为空,出现冲突:
- 遍历链表,发现key值相同,则将对应的value更新
- key值不同,放到链表尾部。超过阈值8变为红黑树
5、将当前的key-value 数量标识size自增,然后和threshold对比,如果大于threshold的值,则调用resize()方法,扩大当前HashMap对象的存储容量。
6、返回oldValue或者null。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key 通过hash(key)得到的hash值
* @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 返回前面的值,如果没有返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//定义一个数组,一个链表,n永远存放数组长度,i用于存放key的hash计算后的值,即key在数组中的索引
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否为空,和数组长度是否为零,如果位为空,就进行reSize()方法初始化(还可以扩容)一个数组并将tab作为其引用,n为实例化后的数组长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n - 1) & hash 得到数组下标,并且将当前索引上的Node赋予给p并判断是否该Node是否存在,如果数组当前索引位置为null,直接存入当前索引位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果该位置存在数据,即发生冲突
else {
Node<K,V> e; K k; ////重新定义一个Node,和一个k
//如果哈希值相等,key也相等,则是覆盖value操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //将当前节点引用赋值给e
else if (p instanceof TreeNode) //判断当前桶利息是否是TreeNode
//ture,进行红黑树插值法,写入数据
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与要存放的key的相同,直接跳出,不做任何操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不是null,说明有需要覆盖的节点,
if (e != null) { // existing mapping for key
//则覆盖节点值,并返回原oldValue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//这是一个空实现的函数,用作LinkedHashMap重写使用。
afterNodeAccess(e);
return oldValue;
}
}
//如果执行到了这里,说明插入了一个新的节点,所以会修改modCount,以及返回null。
//修改modCount
++modCount;
////更新size,并判断是否需要扩容。
if (++size > threshold)
resize();
//更新size,并判断是否需要扩容。
afterNodeInsertion(evict);
return null;
}
resize()
1)如果当前数组为空,则初始化当前数组
2)如果当前table数组不为空,则将当前的table数组扩大两倍,同时将阈值(threshold)扩大两倍
数组长度和阈值扩大成两倍之后,将之前table数组中的值全部放到新的table中去
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //未扩容时数组的容量
int oldThr = threshold;
int newCap, newThr = 0;//定义新的容量和临界值
//当前Map容量大于零,非第一次put值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { //超过最大容量:2^30
//临界值等于Integer类型的最大值 0x7fffffff=2^31-1
threshold = Integer.MAX_VALUE;
return oldTab;
}
//当前容量在默认值和最大值的一半之间
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //新临界值为当前临界值的两倍
}
//当前容量为0,但是当前临界值不为0,让新的容量等于当前临界值
else if (oldThr > 0)
newCap = oldThr;
//当前容量和临界值都为0,让新的容量为默认值,临界值=初始容量*默认加载因子
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的临界值为0
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
//临界值赋值
threshold = newThr;
//扩容table
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) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;//此时newCap = oldCap*2
else if (e instanceof TreeNode) //节点为红黑树,进行切割操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //链表的下一个节点还有值,但节点位置又没有超过8
//lo就是扩容后仍然在原地的元素链表
//hi就是扩容后下标为 原位置+原容量 的元素链表,从而不需要重新计算hash。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循环链表直到链表末再无节点
do {
next = e.next;
//e.hash&oldCap == 0 判断元素位置是否还在原位置
if ((e.hash & oldCap) == 0) {
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);
//循环链表结束,通过判断loTail是否为空来拷贝整个链表到扩容后table
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}