HashMap小探(一) — 基本属性

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这个一行代码的方法合并到具体实现中了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,546评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,224评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,911评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,737评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,753评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,598评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,338评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,249评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,696评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,888评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,013评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,731评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,348评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,929评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,048评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,203评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,960评论 2 355

推荐阅读更多精彩内容