HashMap 的源码分析
2017-11-06 20:44
337 查看
一.java中的位运算符
在具体分析之前,先补充点基础知识1.1 算术位运算符
<< :代表左移 << 3 左移三位,即本来数值 乘于 2^3; 左移低位补0public void test(){ int x = 4; System.out.println(Integer.toBinaryString(x)); //100 int y = 4<<2; System.out.println(Integer.toBinaryString(y)); //10000 System.out.println(y); //16 = 4*2^2 }
>>:代表右移 >> 3 右移三位, 即本来数值处于2^3; 右移,最高符号位不变,其余位补0
public void test(){ int x = 16; System.out.println(Integer.toBinaryString(x)); //10000 int y = 16>>2; System.out.println(Integer.toBinaryString(y)); //100 System.out.println(y); //4 = 16/2^2 }
>>>:代表无符号右移 任何值都会移动。没有最高位作为符号位一说了。
public void test(){ // 为正数时,无符号右移 int x = 16; System.out.println(Integer.toBinaryString(x)); //10000 int y = 16>>>2; System.out.println(Integer.toBinaryString(y)); //100 System.out.println(y); //4 // 为负数时,无符号右移 int x = -16; System.out.println(Integer.toBinaryString(x)); //11111111111111111111111111110000 int y = -16>>>2; System.out.println(Integer.toBinaryString(y)); //00111111111111111111111111111100 System.out.println(y); //1073741820 }
由上面的代码可知:当一个数为正数时,>> 和 >>> 作用是一样的,也可以作为除于2 来表示。但当一个数为负数时,>> 和 >>> 就不能等价了。来分析一下上面的代码:
System.out.println(Integer.toBinaryString(-16)); //11111111111111111111111111110000
为什么-16的二进制码这样表示,在计算机中是这样表示的呢?
在计算机中,数据的存储和计算都是采用补码的形式,这样做的好处是在计算机中,加减都能变成加法: A-B=A+(-B补码)。因此-16的原码是1000/0000/0000/0000/0000/0000/0001/0000 它的补码按照规则:从低位开始,一直到第一个为1的位数,保留这个1,之后除符号位,所有的位数取反。 因此补码就如上所示。
int y = -16>>>2; 即无符号右移4位,因此二进制形式变成了
0011/1111/1111/1111/1111/1111/1111/1100
最高位符号位发生了改变,右移高位补0,所以直接变成了正数了。
可以看出来,>>>的作用并不是乘除,最典型的应用就是获取 int 类型的符号位。
通过这个式子 int y = (x>>>31) & 1 来获取符号位,如果 y = 1,负数,y = 0,正数。
1.2 逻辑位运算符
& 与:对二进制每一位进行逻辑与运算, 都为 1 才为 1。1100 & 0101 = 0100
与位运算的典型应用如下:
将数据清零 1101 & 0000 = 0000;
获取数据特定位,如,获取 101010 的低4位
101010 & (16-1) = 101010 & 1111 = 1010
保留数据特定位,如,保存 10110101 的 低3位
10110101 & 00000100 = 00000010
| 或:对二进制每一位进行逻辑或运算,有一个为 1 就为1 。
1100 | 0101 = 1101 或运算的运用不多,主要是对特定位置 1 如,把 11010100 的 低三位置 1 11010100 | 0x7 = 11010111
^ 异或 : 也叫半加法,即加了不进位 。相同为0,不同为1.
1010 ^ 1011 = 0001
异或的性质:
n ^ 0 = n; //任何数和0异或,为他本身; n ^ n = 0; // 任何数和自己异或,为0;
典型应用:
不交换也可以两个数互换:
a = a ^ b; b = b ^ a; //b = b ^ a ^ b = a a = a ^ b; //a = a ^ b ^ a = b
排除一个数组中出现次数为奇数的数字:
public int getOdd(int[] arr){ int x = 0; for(int i = 0; i < arr.length; i++){ x ^= arr[i]; } return x; }
将指定位取反
// 将第四位取反 1100101 ^ 0xf = 1101010
将内容加密解密
假设一篇文章 ,将所有字符都和一个密码psw 异或,加密; 然后再异或一次,就可以还原,解密。
位运算符的优先级: 优先级由高到低
~ << >> >>> & ^ |
二. 哈希函数:
2.1 概念:
Hash,也可以叫做散列,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。2.2 哈希函数的实现和讨论
hash转换一般是一种压缩映射,这里以哈希表的实现来进一步解释这句话:整个图是是HashMap的实现,下面会详细分析,但这并不是散列表,散列表只是图中左边那个有着固定长度的数组,而后面的链表是为了解决hash冲突而产生的。也就是被压缩成的固定长度,它的长度才是经过hash函数之后得到的值。
在看到这个图的时候,脑海中想一下,什么样的hash函数才能称作好的hash函数呢?
首先数据肯定得最好能均匀排列
hash转换的效率要高
先来看一个最简单的hash法:取余法
// m为table的长度 public int hash(int key){ return key % m; }
关键在于 m 的取值,最好是素数,这种设计能最大可能让数据均匀分布在数据表中。来实际证明一下,对0~20 进行hash
表1 : m = 6
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
但是,要记住一点原始数据不大会是真正的随机的,可能有某些规律,比如大部分是偶数,这时候如果HASH数组容量是偶数,容易使原始数据HASH后不会均匀分布。
比如 2 4 6 8 10 12这6个数,如果对 6 取余
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
2 | 4 | ||||
6 | 8 | 10 | |||
12 |
如果对 7 取余
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
2 | 4 | 6 | ||||
8 | 10 | 12 |
这就是取余法的取素数的好处,因为素数除了1,只有它本身能被整除。
key % m 这种简单的形式,会造成原始数据经过hash后,依然相邻,所以有一种改进方法。
(a * key + b)% m
三.HashMap的分析:
终于到这里了-。- 由于 java8 对于HashMap的改动非常大,这里就以 java8 的源码来分析。3.1变量定义部分:
/* HashMap 继承的是AbstractMap ,而HashTable 继承的是 Dictionary ,HashTable 在java8 中基本不使用了*/ public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { private static final long serialVersionUID = 362498820763181265L; /** * 默认的HashMap中散列表的长度,必须是2的指数倍(这里非常重要,因为这和HashTable中的哈希函数设计有关) */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; /** * 最大的容量。 * MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 默认的加载因子。 用来计算阀值的 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * The bin count threshold for using a tree rather than list * java8 之后,如果 HashMap 中元素较多,那么 HashMap 中的原来链表阶段, * 就会变成红黑树。 这里只默认的红黑树的阀值。 */ static final int TREEIFY_THRESHOLD = 8; /** * 默认的链表阀值。 */ static final int UNTREEIFY_THRESHOLD = 6; /** * The smallest table capacity for which bins may be treeified. * 当容量超过 64 之后,链表结构就变成红黑树结果。 * 这就是java8 的改变。 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * 当为链表时,采用Node节点,红黑树采用 TreeNode 节点 */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; java.util.HashMap.Node<K,V> next; /* 获取 Node 的hash值,这里采用的是将 key 和 value 的hash值 异或混合*/ public final int hashCode() { // 异或,相同都为0. return Objects.hashCode(key) ^ Objects.hashCode(value); } }
3.2 put方法:
public V put(K key, V value) { // 最终调用的putVal,并且传了一个 hash(key) 过去 return putVal(hash(key), key, value, false, true); } /** * 为了避免碰撞采取的一种新的 hash 策略 * 这里就用到了前面提到了 无符号右移 ,hash(key) * 本质是,把高16位和低16位混合。 这种处理方式叫做“扰动函数” */ 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) { java.util.HashMap.Node<K,V>[] tab; java.util.HashMap.Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) // 获取 散列表 tab 的长度。 n = (tab = resize()).length; /** * 这里出现了一个 (n-1) & hash 是一个非常巧妙的处理方式, * hash 为 key 经过 hashCode() 处理过 再经过 * hash() 处理后的值。 n 为 tab 的长度。 * 又因为 n = 2 ^ m ,则 n-1 化为二进制代表 m 位都是 1 * 如: 16 = 2 ^ 4 ,则 15 的二进制是 1111 * 前面有提到 & 有截取特定位数的能力。 * 这里(n - 1) & hash 就是截取了hash值的低4位。 * */ if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { java.util.HashMap.Node<K,V> e; K k; // 这里是比较 要添加的对象 是否和在 table 中的 p 的key值是一样的。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果节点是TreeNode 的话说明已经转化成红黑树 else if (p instanceof java.util.HashMap.TreeNode) e = ((java.util.HashMap.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) // -1 for 1st 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; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
总结:hashMap 中的 hash 函数的设计步骤如下:
将 key 调用 Object 自带的hashCode() 方法,获取初始hash值。
h = key.hashCode()
将初始hash值的高16位和低16位混合。
h ^ (h >>> 16);
截取相应位数的值
(n - 1) & hash
code | 说明 |
---|---|
0010/0010/1001/0010/0111/1010/1000/0001 | h=key.hashCode() |
0000/0000/0000/0000/0010/0010/1001/0010 | h >>> 16 |
0010/0010/1001/0010/0101/1000/0001/0011 | hash=h ^ h >>> 16 |
0011 | (2 ^ 4 - 1) & hash |
hash % n = (n - 1) & hash
3.3 散列表扩容方法
一般来说,在使用hashMap的时候,要大概估算一下 hash表的大小,且一般为 2 的幂方,因为hash扩容是一个非常损耗性能的行为。HashMap 在两种情况下会产生扩容:散列表初始值为 0 的时候
散列表的个数超过阀值的时候
来看一下其中的扩容方法:
final java.util.HashMap.Node<K,V>[] resize() { // 得到旧表 java.util.HashMap.Node<K,V>[] oldTab = table; // 旧表的大小,旧表为空 那么 =0 ; 否则等于 oldTab.length; int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧的阀值 int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 如果旧表长度大于0 if (oldCap >= MAXIMUM_CAPACITY) { // 再次判断旧表是否大于 最大容量 2^30 , // 如果大于,那么 把阀值定为 2^31-1,不会再扩容了,因为后面的扩容 // 策略会使得 长度为 2^31 ,溢出了。 threshold = Integer.MAX_VALUE; return oldTab; } // 如果表不大于最大容量,那么就把表长度扩大两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold // 新的阀值也扩大两倍 } // 下面是初始状态 即 oldCap = 0 的状态 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"}) java.util.HashMap.Node<K,V>[] newTab = (java.util.HashMap.Node<K,V>[])new java.util.HashMap.Node[newCap]; table = newTab; // 如果旧表不为空,说明有数据要转移。 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { java.util.HashMap.Node<K,V> e; // 把旧表的值赋给 e , 把 e 作为临时变量,进行操作 // 如果 e 不为空,就把e赋值给 新表 if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 给新表赋值的时候,需要重新计算hash值,但这里有一个 // 非常巧妙的地方,依然是用 原来的hash值 和 数组长度 & // 如果初始值是 16 ,那就是截取 4位 ,而扩展一倍,那么就 // 截取5位,以此作为 新的hash 值。 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof java.util.HashMap.TreeNode) ((java.util.HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order java.util.HashMap.Node<K,V> loHead = null, loTail = null; java.util.HashMap.Node<K,V> hiHead = null, hiTail = null; java.util.HashMap.Node<K,V> next; do { // 这里是节点为链表时的节点复制 } } } } return newTab; }
最后一个问题:
那么,为什么hashMap 没有采用前面的取余法,没有采用素数作为散列表的长度呢?
首先一个好的hash函数,必须兼顾均匀性 和 效率高,还有一点是安全性(比如MD5函数),取余法确实简单实用,做到了均匀性,但是在效率性上非常的低,安全性也不高。 在计算机中取模运算是效率非常低的,hashmap中实质也是采用了取余法,但是这里利用了 hash % n = (n - 1) & hash ,将取模运算变成了位运算,而这里不用 素数 作为散列表长度是因为要满足 n = 2 ^ m ,而素数带来的均匀性,也因为扰动函数的加入变得满足了。
相关文章推荐
- jdk1.8 HashMap源码分析(put函数)
- 源码分析HashMap
- 长文慎入 HashMap 源码分析 基于 JDK 1.8
- HashMap源码分析(JDK1.8)- 你该知道的都在这里了
- JDK1.8源码分析之ConcurrentHashMap
- hashMap--put(k,v)源码分析
- 4000 【源码分析】HashMap的put(K k,V v)方法
- Java 源码分析HashMap的工作原理及实现
- Java源码分析:关于 HashMap 1.8 的重大更新
- HashMap 源码详细分析(JDK1.8)
- HashMap 源码分析
- HashMap源码分析
- 目前看到的最棒的HashMap源码分析(基于java 8)--Java 8系列之重新认识HashMap
- HashMap源码分析
- HashMap源码分析(基于JDK1.6)
- JDK1.7 HashMap源码分析
- JDK源码-HashMap死锁分析
- JDK1.8源码分析之HashMap
- Java 集合框架源码分析(三)——HashMap 转载BridgeGeorge Java 集合框架源码分析(三)——HashMap
- HashMap源码分析