第七周_S- HashMap 源码分析上

50 阅读9分钟

HashMap 可谓是 Java 必学、必考、必八股文的一个类了。也从侧面说明了这个类里面的代码确实很吊。

写一个简单的 HashMap

我们知道,HashMap 是使用 O(1) 的时间复杂度获取数据的。

问题

假设我们有一个 7 个字符串,需要存放到数组中,但要求在获取每个元素的时候时间复杂度是 O(1) 。也就是不能像数组那样遍历获取,而是定位到数组 ID 直接获取对应的元素。

方案

每个字符串生成一个数字,相关的信息那应该是 hashCode 了。但是 hashCode 是 int 型的,范围太大了 [-2147483648, 2147483647],不能直接使用,需要 hashCode 和数组长度做与运算:得到一个可以出现在数组中的位置。 如果有两个元素得到同样的 ID .那这个位置就存放两个元素。

代码

public class HashMapDemo {
    public static void main(String[] args) {
        // 模拟 hashMap 的存数
        moniHashMap();
    }

    /**
     *  1.初始化一组字符串集合,这里初始化了 7 个
     *  2.定义长度为 8 的数组;这样的数组长度才会出现一个 0111 除高位以外都是1的特征,也是为了散列。
     *  3.循环存放数据,计算出每个字符在数组中的位置
     *  4.如果遇到同一个位置的,模拟链表存放
     *  5.输出结果
     */
    public static void moniHashMap(){
        List<String> list = new ArrayList<>();
				list.add("a");
        list.add("b");
        list.add("测试");
        list.add("c");
        list.add("aa");
        list.add("aabb");
        list.add("ab");

        String[] tab = new String[8];
        for (String key : list) {
            int idx = key.hashCode()&(tab.length-1);  //计算索引位置
            /**
             *  下面两行是 HashMap 取的 hash 值
             */
//            int hash = (hash = key.hashCode()) ^ (hash >>> 16);
//            int idx = ( n - 1 ) & hash; // n 是 HashMap 的长度
            System.out.println(String.format("key值=%s,idx=%d",key,idx));
            if(tab[idx]==null){
                tab[idx]=key;
                continue;
            }
            tab[idx]=tab[idx]+"->"+key;
        }
        System.out.println(JSON.toJSONString(tab));
    }
}

测试结果:

key值=a,idx=1
key值=b,idx=2
key值=测试,idx=2
key值=c,idx=3
key值=aa,idx=0
key值=aabb,idx=0
key值=ab,idx=1
["aa->aabb","a->ab","b->测试","c",null,null,null,null]

简单HashMap的问题

  • 元素位置不够散列,碰撞严重,选取什么散列算法?扰动函数
  • 获取索引 ID 计算公式中,需要数组长度是 2 的幂次方。那么如何初始化数组大小?初始化容量
  • 数组大小如何取舍?负载因子
  • 链表长度如何优化?链表转红黑树
  • 元素扩容,如何处理?扩容方法

扰动函数

java8 HashMap 的散列值扰动函数,用于优化散列效果。

   static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

为啥使用扰动函数

增加随机性,让数据元素更加均衡的散列,减少碰撞

hashMap 这里不是直接获取 hashCode 值,而是进行了一次扰动计算。把哈希值右移 16 位,正是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。

实验验证

  • 10w 个单词库
  • 定义长为 128 的数组格子
  • 分别计算扰动/不扰动下,10w个单词分配到 128 个格子中的数量
  • 统计各个格子数量,生成波动曲线。哪个相对平稳,那么这个效果就好
package com.practice.thinkinbasic.HashDemo;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * 结论:使用扰动函数,数据分配的更均匀。即散列效果更好,减少了 hash 碰撞,让数据存取和获取效率更佳
 */
public class 扰动函数验证 {
    public static void main(String[] args) {
        Map<Integer, Integer> map = new HashMap<>(16);
        //获取10w个不重复的单词
        Set<String> str = readWordList("103976个英语单词库.txt");
        for (String word : str) {
            //有扰动函数;128个格子,相当于 128 长度的数组
            int idx = disturbHashIdx(word, 128);
            //没有扰动函数
//           int idx=hashIdx(word,128);
            if (map.containsKey(idx)) {
                Integer integer = map.get(idx);
                map.put(idx, ++integer);
            } else {
                map.put(idx, 1);
            }
        }
        System.out.println(map.values());
    }


    public static int disturbHashIdx(String key, int size) {
        return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
    }

    public static int hashIdx(String key, int size) {
        return (size - 1) & key.hashCode();
    }

    /**
     * 读取文件中的 10w 个单词
     */
    public static Set<String> readWordList(String url) {
        Set<String> list = new HashSet<>();
        try {
            InputStreamReader isr = new InputStreamReader(new FileInputStream(url), StandardCharsets.UTF_8);
            BufferedReader br = new BufferedReader(isr);
            String line = "";
            while ((line = br.readLine()) != null) {
                String[] ss = line.split("\t");
                list.add(ss[1]);
            }
            br.close();
            isr.close();
        } catch (Exception ignore) {
            return null;
        }
        return list;
    }
    
}

这是我自己电脑跑出来的数据。很明显,扰动之后的分布更均衡。

PS:这里打印出来的数据是已逗号分隔的数字。我的处理方法是:

1.将打印数据保存到 txt 文件

2.改名为 csv 文件

3.复制到 excel 中的一行,然后插入图表即可

初始化容量和负载因子

//默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

计算容量大小的方法:

   static final int tableSizeFor(int cap) {
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    public static int numberOfLeadingZeros(int i) {
        // HD, Count leading 0's
        if (i <= 0)
            return i == 0 ? 32 : 0;
        int n = 31;
        if (i >= 1 << 16) { n -= 16; i >>>= 16; }
        if (i >= 1 <<  8) { n -=  8; i >>>=  8; }
        if (i >= 1 <<  4) { n -=  4; i >>>=  4; }
        if (i >= 1 <<  2) { n -=  2; i >>>=  2; }
        return n - (i >>> 1);
    }

初始化容量和负载因子在初始化 HashMap 的时候都可以指定。

扩容元素拆分

jdk7 中会重新计算 hash 值,但是 jdk8 已经优化,不在需要重新计算,提升了拆分性能。

package com.practice.thinkinbasic.HashDemo;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class 初始化容量和负载因子 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("jlkk");
        list.add("lopi");
        list.add("jmdw");
        list.add("e4we");
        list.add("io98");
        list.add("nmhg");
        list.add("vfg6");
        list.add("gfrt");
        list.add("alpo");
        list.add("vfbh");
        list.add("bnhj");
        list.add("zuio");
        list.add("iu8e");
        list.add("yhjk");
        list.add("plop");
        list.add("dd0p");
        for (String key : list) {
            int hash = key.hashCode() ^ (key.hashCode() >>> 16);
            System.out.println("字符串: " + key + " \tIdx(16):" + ((16 - 1) & hash) +
                    "\t Bit值" + Integer.toBinaryString(hash) + " - " +
                    Integer.toBinaryString(hash & 16) + " \t\tIdx(32): " +((32 - 1) & hash) +"  "+((
           Integer.toBinaryString(key.hashCode()) + " " +
                   Integer.toBinaryString(hash) + " " +
                   Integer.toBinaryString((32 - 1) & hash))));
        }
    }
}
字符串: lopi 	Idx(16):14	 Bit值1100101100011010001110 - 0 		Idx(32): 14  1100101100011010111100 1100101100011010001110 1110
字符串: jmdw 	Idx(16):7	 Bit值1100011101010100100111 - 0 		Idx(32): 7  1100011101010100010110 1100011101010100100111 111
字符串: e4we 	Idx(16):3	 Bit值1011101011101101010011 - 10000 		Idx(32): 19  1011101011101101111101 1011101011101101010011 10011
字符串: io98 	Idx(16):4	 Bit值1100010110001011110100 - 10000 		Idx(32): 20  1100010110001011000101 1100010110001011110100 10100
字符串: nmhg 	Idx(16):13	 Bit值1100111010011011001101 - 0 		Idx(32): 13  1100111010011011111110 1100111010011011001101 1101
字符串: vfg6 	Idx(16):8	 Bit值1101110010111101101000 - 0 		Idx(32): 8  1101110010111101011111 1101110010111101101000 1000
字符串: gfrt 	Idx(16):1	 Bit值1100000101111101010001 - 10000 		Idx(32): 17  1100000101111101100001 1100000101111101010001 10001
字符串: alpo 	Idx(16):7	 Bit值1011011011101101000111 - 0 		Idx(32): 7  1011011011101101101010 1011011011101101000111 111
字符串: vfbh 	Idx(16):1	 Bit值1101110010111011000001 - 0 		Idx(32): 1  1101110010111011110110 1101110010111011000001 1
字符串: bnhj 	Idx(16):0	 Bit值1011100011011001100000 - 0 		Idx(32): 0  1011100011011001001110 1011100011011001100000 0
字符串: zuio 	Idx(16):8	 Bit值1110010011100110011000 - 10000 		Idx(32): 24  1110010011100110100001 1110010011100110011000 11000
字符串: iu8e 	Idx(16):8	 Bit值1100010111100101101000 - 0 		Idx(32): 8  1100010111100101011001 1100010111100101101000 1000
字符串: yhjk 	Idx(16):8	 Bit值1110001001010010101000 - 0 		Idx(32): 8  1110001001010010010000 1110001001010010101000 1000
字符串: plop 	Idx(16):9	 Bit值1101001000110011101001 - 0 		Idx(32): 9  1101001000110011011101 1101001000110011101001 1001
字符串: dd0p 	Idx(16):14	 Bit值1011101111001011101110 - 0 		Idx(32): 14  1011101111001011000000 1011101111001011101110 1110

看实验数据的结论:

  • 随机使用一些字符串计算他们分别在 16 位和 32 位长度数组的索引分配情况,看那些数据被重新路由
  • 原哈希值与扩容新增出来的长度 16,进行 & 运算,如果值为 0 .则下标不败你。如果不为 0 ,那么新位置则是原来位置上加 16.
  • 这样就不需要重新计算每一个数组中元素的哈希值了。

参考文章:

bugstack.cn/md/java/int…