HashMap 的源码分析

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

  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 ,而素数带来的均匀性,也因为扰动函数的加入变得满足了。

猜你喜欢

转载自blog.csdn.net/mooneal/article/details/78461692