美文网首页map相关
HashMap小探(一) — 基本属性

HashMap小探(一) — 基本属性

作者: AlanKim | 来源:发表于2017-11-09 14:11 被阅读36次

hashmap里面的重要字段及方法:

capacity & size

capacity是指当前hashmap的容量,注意是当前,因为hashmap本身存在扩容。

size则是指当前hashmap中的实际元素个数。

注意这里两个值都不是table数组的长度,而是hashmap 数组 + 链表作为一个整体的元素。

其在hashmap.java中的定义如下:

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

    .......
        
    final int capacity() {
        return (table != null) ? table.length :
            (threshold > 0) ? threshold :
            DEFAULT_INITIAL_CAPACITY;
    }

    // 这里实现用到的table\threshold\DEFAULT_INITIAL_CAPACITY详见下文 

下面代码说明:

        hashmap map = new hashmap();
        map.put("jin", "alan");

        // 获取map的类-类型
        Class mapClazz = map.getClass();

        /*
            capacity,当前容量,是个方法,其实现是:
        */
        // 通过反射获取其当前的capacity,默认会是16
        Method capacityMethod = mapClazz.getDeclaredMethod("capacity");
        // 设置访问权限
        capacityMethod.setAccessible(true);

        System.out.println("map capacity = " + capacityMethod.invoke(map));


        // 通过反射获取其 size,也就是目前hashmap中有多少元素,刚刚put了一个,所以会是1
        // 也可以用Method,因为有一个 int size()方法
        Field sizeField = mapClazz.getDeclaredField("size");
        sizeField.setAccessible(true);
        System.out.println("map size = " + sizeField.get(map));

通过反射取到其方法、字段,执行后得到对应的值,印证了注释中预估的结果。

但是需要注意,如果刚开始指定了大小,那么threshold的值就是tableSizeFor的值,也就是初始化的hashmap容量(2^n),jdk中源代码如下:

    /**
     * Constructs an empty <tt>hashmap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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);
    }

看最后一行代码对threshold的赋值就知道了,下面写测试代码验证下:

        Map giveSizeMap = new hashmap(14);
        System.out.println("giveSizeMap capacity = " + capacityMethod.invoke(giveSizeMap)); //16
        System.out.println("giveSizeMap size = " + sizeField.get(giveSizeMap)); // 0
        System.out.println("giveSizeMap loadFactor = " + loadFactorField.get(giveSizeMap)); // 0.75
        System.out.println("giveSizeMap threldold =" + thresholdField.get(giveSizeMap)); // ?  = 16

不出所料,打印出来threshold的值是16.

但是下面往这个map中添加值,如下:

        giveSizeMap.put("1", "a");
 
        System.out.println("now giveSizeMap capacity = " + capacityMethod.invoke(giveSizeMap)); //16
        System.out.println("now giveSizeMap size = " + sizeField.get(giveSizeMap)); // 0
        System.out.println("now giveSizeMap loadFactor = " + loadFactorField.get(giveSizeMap)); // 0.75
        System.out.println("now giveSizeMap threldold =" + thresholdField.get(giveSizeMap)); // 12

就会看到,一但向其中put数据,threshold的值就会变成 capacity * loadFactor,具体put的实现下面会详细分析。

loadFactor & threshold

loadFactor的意思是装载因子,是一个小于等于1的小数,其作用就是定义扩容阈值百分比。默认值0.75。

threshold就是具体的扩容阈值了,threshold = loadFactor * capacity。

loadFactor的值为什么是0.75呢?capacity的值是2的N次方,所以当N大1时,后续3/4 * capacity,得到的会一直是整数。

默认的threshold = 16 * 0.75 = 12,当hashmap中的size值大于threshold时,会触发扩容。

下面使用代码验证hashmap的扩容过程。

        hashmap map3 = new hashmap(3);

        // 通过反射获取其当前的capacity,默认会是16
        Method capacityMethod = mapClazz.getDeclaredMethod("capacity");
        // 设置访问权限
        capacityMethod.setAccessible(true);
        
        // size
        Field sizeField = mapClazz.getDeclaredField("size");
        sizeField.setAccessible(true);

        // loadFactor
        Field loadFactorField = mapClazz.getDeclaredField("loadFactor");
        loadFactorField.setAccessible(true);

        // threshold
        Field thresholdField = mapClazz.getDeclaredField("threshold");
        thresholdField.setAccessible(true);

        // 使用map3 做扩容的验证过程
        map3.put("1","a");
        map3.put("2","a");
        map3.put("3","a");

        // 获取capacity、size、threshold、loadFactor
        System.out.println("map capacity = " + capacityMethod.invoke(map3)); //4
        System.out.println("map size = " + sizeField.get(map3)); // 3
        System.out.println("map loadFactor = " + loadFactorField.get(map3)); // 0.75
        System.out.println("map threldold ="+ thresholdField.get(map3)); // 4 * 0.75 = 3

        // 再增加一个元素,大于threshold,触发扩容
        System.out.println("capacity resize");
        map3.put("4","a");
        System.out.println("map capacity = " + capacityMethod.invoke(map3)); //8
        System.out.println("map size = " + sizeField.get(map3)); // 4
        System.out.println("map loadFactor = " + loadFactorField.get(map3)); // 0.75
        System.out.println("map threldold ="+ thresholdField.get(map3)); // 8 * 0.75 = 6
table
    /**
     * 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;

table其实就是hashmap的存储底层,也就是一个数组,table.length总是2的N次方,不过<key,value>被封装到了Node对象里面,详细的可以看下面关于Node的内容。

获取table的长度

        // 获取table的长度
        Field tableField = mapClazz.getDeclaredField("table");
        tableField.setAccessible(true);
    
        System.out.println("map table length = " + ((HashMap.Entry[])tableField.get(map3)).length);

测试后可以发现,table.length与capacity的值总是一样,也就是 table是所有数据的存储容器。

tableSizeFor

这个方法用来计算扩容时hashmap的容量值,可以看到,hashmap的扩容并不会以用户指定的值为准,而是去寻找一个大于指定值的最小2的N次方。而计算的实现就是用这个方法来做的,先看代码:

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;   // 第一步:先把指定的值减1
        // 第二步,向右移位 并与当前值按位或;其目的对于一个数字的二进制,从第一个不为0的位开始,把后面的所有位都设置成1。
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        // 第三步,计算极限值,最后+1 返回
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

第一步为什么减1呢?先卖个关子,看核心的第二步

        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;

先说下右移位,也就是将二进制数向右移对应的位数。

按位或,就是将两个参与运算的二进制数,任意为1的位设置为1,否则设置为0.

先根据第一行举个例子,假设n=2,其对应的二级制数为 10,那么会有如下操作:

0010 >>> 1 =  0001 (右移1位)   ---- 第一行,右移1位

0010 | 0001 = 0011 (按位或),

0011 >>> 2 = 0000 (右移2位)    ---- 第二行,右移2位

0011 | 0000 = 0011 (按位或)

下面右移4位、8位、16位,最终得到的结果都是 0011 ,也就是十进制的3

最后第三步,当小于极限值的时候,需要+ 1,3+ 1 = 4

考虑到第一步,n = cap -1,也就是cap=3

也就是,十进制数3 的最近满足需要的容量是 4

这个不过瘾,n来个大点儿的数,比如18,二进制数是:0001 0010

0001 0010 >>> 1 = 0000 1001       ---- 第一行,右移1位
0001 0010 | 0000 1001 = 0001 1011  

0001 1011 >>> 2 = 0000 0110   ---- 第二行,右移两位
0001 1011 | 0000 0110 = 0001 1111   
到这里已经把第一位不为0,后面所有的0都设置为了1,再移位4、8、16,都是一样的结果

所以这里的值是 0001 1111 = 16+8+4+2+1 = 31

设置的值:cap = n + 1 = 19

其对应的实际容量是 31 + 1= 32

到这里,第二步和第三步都已经解释清楚了,那么第一步的 n = cap -1 是干啥的呢?

当cap本身就是2的N次方时,上面的算法是有点问题的,比如传入的capacity是4,那么得到的结果本应该是4,结果得到了8

假设没有 n = cap - 1 这一行
n = cap = 4,换算成二进制就是 0100,下面进行换算:
0100 >>> 1 = 0010
0010 | 0100 = 0110

0110 >> 2 = 0001
0001 | 0110 = 0111  --- 都变成了1,结束

执行第三步,实际的容量是 4+2+1  +1 = 8,其实是多设置了一倍。

所以刚开始就把n的值设置为4 - 1 =3,最终得到的容量会是4,完美解决。这就是n = cap - 1 的由来。

所以这里tableSizeFor只是做单一的事情:寻找距离最近的2的N次方。

而实际上,如果设置了cap=4,得到的实际capacity值也是4的话,马上就会触发一次扩容,反而性能不好。不过这里jdk的开发人员这么做,只是为了一个方法只做一件事情,无可厚非。所以在初始化设置就需要参考下一节的内容。

初始化时指定容量

如果在初始化hashmap时传入预估的容量值,hashmap会把值设置为大于指定值最近的一个2的N次方,比如1 —>1, 3—>4, 6—>8, 55—>64。下面进行代码测试:

        hashmap map = new hashmap(1); // 指定初始化值

        // 获取map的类-类型
        Class mapClazz = map.getClass();

        Method capacityMethod = mapClazz.getDeclaredMethod("capacity");
        // 设置访问权限
        capacityMethod.setAccessible(true);

        System.out.println("map capacity =" + capacityMethod.invoke(map1));  // 1

        hashmap map3 = new hashmap(3);
        System.out.println("map capacity = " + capacityMethod.invoke(map3)); // 4

        hashmap map55 = new hashmap(55);
        System.out.println("map capacity = " + capacityMethod.invoke(map55)); // 64

执行后结果印证了tableSizeFor的作用。另外注意,这里只用了一个反射获取对应的method,通过invoke时传入不同的对象来获取对应的值。

关于初始化值的设置:

如果知道了对应的数据个数为7,那么是不是直接传入7就可以了呢?

直接设置7,tableSizeFor得到的值是8,而threshold的值是6,7 >6,所以数据设置之后马上就会触发一次扩容,hashmap的capacity从8变成16,会有一定的性能损耗。

所以设置的初始值应该是: 数据长度 / loadFactor + 1, 也就是 7 / 0.75 + 1 = 10,那么tableSizeFor得到的就是16,不需要进行扩容。

hashmap的hash

先看下JDK1.8里面的实现:

    /**
     * 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;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

可以看到,这里的hash取值,将key.hashCode右移16位 然后与其本身进行按位 异或((h = key.hashCode()) ^ (h >>> 16)),目的是对hashCode进行混淆,避免出现一些key对应的hashcode高位不同、低位相同,但是在取模(需要找hashmap中数组的下标)的时候低位相同就可能出现碰撞的问题。

这里JDK1.8 跟JDK1.7的差别还是蛮大的,看下jdk7的代码:

    /**
     * Applies a supplemental hash function to a given hashCode, which
     * defends against poor quality hash functions.  This is critical
     * because hashmap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     */
    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

这里的参数h是指key的hashcode(), 调用时是这样:int hash = hash(key.hashCode());

而一系列针对h的右移位、按位异或(^),不过是跟jdk8中的 (h = key.hashCode()) ^ (h >>> 16) 一样的目的,扰乱hashcode的正常值,尽可能的避免hash碰撞。

注意jdk中的 indexFor方法,这个方法是获取hash值对应的数组下标的,不过这里为什么用了一个按位与(&)操作呢?

jdk7中indexFor为什么使用按位与

按位与在这里的作用,其实与取模的结果是一样的,indexFor方法本来就是为了根据hash值获取对应的数组下标。那么为什么不用取模呢?性能,一切为了性能!

在1.7的HashTable中,其实取下标是用了取模的,模是hashTable的长度:

int index = (hash & 0x7FFFFFFF) % tab.length; (这里&只是为了保证为正数,毕竟首位为0 代表正数,首位为1代表负数)。

不过HashTable在初始化的时候默认值是11,每次扩容是 2N +1 ,也就是始终都是奇数个,使用取模来处理更简单,而且按位与也无法保证结果与取模相同。

而hashmap则始终是2的N次方长度,这就为&操作提供了便利,记住下面这个公式:

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=18,也就是 0001 0010,一起看下结果:
对 2的n次方 取模: 18 % 8 = 2
对 2的n次方-1 按位与: 0001 0010 & 0111 = 0000 0010 = 2

不过这个要求是, tab.length必须是2的N次方,而hashmap的capacity设计的就是2的N次方长度,此时按位与刚好满足需要,又可以提高性能。

至于HashTable为什么这么设计,个人猜想是本来内部就采用了synchronized机制,性能偏低,实现层面就简单点好了。另外,hashTable也确实不常用…...

JDK8中消失的indexFor

在jdk8的hashmap源码中,indexFor方法消失了。不过同样的代码其实包含在了具体的使用场景,比如get方法中引用的getNode实现:

first = tab[(n - 1) & hash]

其实就是将indexFor这个一行代码的方法合并到具体实现中了。

相关文章

网友评论

    本文标题:HashMap小探(一) — 基本属性

    本文链接:https://www.haomeiwen.com/subject/snwpmxtx.html