HashMap 的源码分析

来源:互联网 发布:pssproject监控软件 编辑:程序博客网 时间:2024/06/08 00:01

一.java中的位运算符

在具体分析之前,先补充点基础知识

1.1 算术位运算符

<< :代表左移 << 3 左移三位,即本来数值 乘于 2^3; 左移低位补0

public 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 的 低三位置 111010100 | 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函数呢?

  1. 首先数据肯定得最好能均匀排列
  2. 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

表2 : m = 7

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

得到 2 4 0 /2 4 0 只会得到3种HASH值,冲突会很多。

如果对 7 取余

0 1 2 3 4 5 6 2 4 6 8 10 12

得到 2 4 6 1 3 5 得到6种HASH值,没有冲突。

这就是取余法的取素数的好处,因为素数除了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 函数的设计步骤如下:

  1. 将 key 调用 Object 自带的hashCode() 方法,获取初始hash值。
    h = key.hashCode()

  2. 将初始hash值的高16位和低16位混合。
    h ^ (h >>> 16);

  3. 截取相应位数的值
    (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

通过上表可以看出来最后取到 0011 = 3 ,这里有个细节就是散列表的长度为 2 ^ m ,那么就取低 m 位。这样hash值的变化最大不过散列表的长度。可推出 当 n = 2 ^ m 的时候
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 ,而素数带来的均匀性,也因为扰动函数的加入变得满足了。