通过 Java 将 “兔子” 存到 HashMap

908 阅读3分钟

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

HashMap 存储 “兔子” 的过程

首先我们先以以 JDK 1.8 为例 HashMap 的存储过程如下:

  1. 在创建 HashMap 调用构造方法的时候只会设置参数,不会初始化 table[] 存储数据对象。
  2. 在 put 方法调用的时候,设置值的时候才会进行初始化/拓容。
  3. 如果存在 hash 冲突的时候,判断 HashMap 中 table[index] 链表长度大于等于 8 触发红黑树转换之前先判断 HashMap 总长度是否大于等于 64,如果小于64优先选择拓容量(2倍拓容),否则创建链表/红黑树。
  4. 如果同一个 table[index] 下面的数据 >= 8 , 就会将链表转换为红黑树。
  5. 如果 HashMap 存储数据长度大于 length(总长度) * DEFAULT_LOAD_FACTOR(负载因子,默认 0.75)选择2倍扩容。
  6. 删除元素的时候,且 table[index] 下面是红黑树的结构,如果红黑树的长度 <= 6 那么就会转换为链表。

如果我们需要存储 k="兔子", v="20230201" 的数据到 Hashmap 中,那么会经历哪些过程呢?

  1. 计算 "兔子" 这两个字的字符串 hashCode 值 00000000000010100011010001111100
  2. 然后通过扰动函数计算 HashMap 键的 hash 值 00000000000010100011010001110110
  3. 通过 hash 值计算出 HashMap 存储的槽位(数组中的下标) 00000000000000000000000000000110
  4. 存入到 HashMap 中,后面也是可以这样通过槽位来进行读取。

计算 “兔子” 的 hash 值

HashMap 并不是直接获取 key 的 hashCode 作为 hash 值的,它会通过一个扰动函数进行二次计算。

以下代码叫做 “扰动函数”

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

hash 散列是一个 int 值,如果直接拿出来作为下标存储到 hashmap 的大数组中,考虑到二进制 32 位,取值范围在整个 int 值(-2147483648 ~ 2147483647 )范围。 大概有 40 亿个 key , 只要哈希函数映射比较均匀松散,一般很难出现碰撞。

存在一个问题是 40 亿长度的数组,JVM 内存是不能放下的。JDK 1.8 中 HashMap 的默认长度为 16 。所以这个 key 键值的 hashCode 是不能直接来使用的。 使用之前先做对数组长度的“与”运算,得到的值才能用来访问数组下标。

这里为什么要使用 n -1 ,来进行与运算,这里详单与是一个”低位掩码”, 以默认长度 16 为例子。 和某个数进行与预算,结果的大小是 < 16 的。如下所示:

    10000000 00100000 00001001
&   00000000 00000000 00001111
------------------------------
    00000000 00000000 00001001  // 高位全部归 0, 只保留后四位 

这个时候会有一个问题,如果本身的散列值分布松散,只要是取后面几位的话,碰撞也会非常严重。还有如果散列本省做得不好的话,分布上成等差数列的漏洞,可能出现最后几位出现规律性的重复。

这个时候“扰动函数”的价值值就体现出来了。如下所示:

image.png

在 hash 函数中有这样的一段代码: (h = key.hashCode()) ^ (h >>> 16) 右位移 16 位, 正好是32bit 的一半,与自己的高半区做成异或,就是为了混合原始的哈希码的高位和低位,以此来加大低位的随机性。并且混合后的低位掺杂了高位的部分特征,这样高位的信息变相保存下来。 其实按照开发经验来说绝大多数情况使用的时候 hashmap 的长度不会超过 1000,所以提升低位的随机性可以提升可以减少hash 冲突,提升程序性能。

在Peter Lawlay 的专栏《An introduction to optimising a hashing strategy》的一个实验:他随机选取了 352 个字符串,在散列值完全没有冲突的前提下,对低位做掩码,取数组下标。

image.png

结果显示, 当 hashmap 的数组长度为 512 的时候,也就是采用低位掩码取低 9 位的时候,在没有扰动函数的情况下,发生了 103 次碰撞,接近 30%。而在使用燃动函数之后只有 92 次碰撞。碰撞减少了将近10%。说明扰动函数确实有功效的。

明显 Java 8 觉得扰动函数做一次就够用了,做 4 次的话,可能边际效用也不大, 为了效率考虑就改成了一次。

完整 “兔子” 存储位计算代码

从 hashCode 到 hash 值,然后再到 table[index] 的 index 值计算,然后就是一个数组操作将“兔子”存入了 HashMap 中。代码如下:



import java.lang.reflect.Field;
import java.util.HashMap;

/**
 * HashMap 计算 hashKey
 * <p>
 * 演示:扰动函数
 *
 * @see HashMap#hash(Object)
 */
public class HashKeyTest {

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        HashMap<String, String> map = new HashMap<>();
        String k = "兔子";
        String v = "20230201";
        map.put(k, v);

        Field field = map.getClass().getDeclaredField("table");
        field.setAccessible(Boolean.TRUE);
        Object[] nodes = (Object[]) field.get(map);

        int h = k.hashCode();
        System.out.println("  h=" + h);
        System.out.println();
        // 调用 hashCode 结果
        System.out.println("  h=hashCode()    " + num0x(h) + "  调用 hashCode");
        // 无符号右移 16
        System.out.println("  h>>>16          " + num0x(h >>> 16));
        System.out.println("--------------------------------------------------");
        // 计算 hash
        System.out.println("  hash=h^(h>>>16) " + num0x(h ^ (h >>> 16)) + "  计算 hash");
        System.out.println("--------------------------------------------------");
        // 计算下标
        System.out.println("  (n-1)&hash      " + num0x(15 & (h ^ (h >>> 16))) + "  计算下标");
        System.out.println();
        int idx = (15 & (h ^ (h >>> 16)));
        // 输出下标
        System.out.println("  下标: " + idx);
        // 在下标中去获取数据
        System.out.println("  查询结果:" + nodes[idx]);
    }

    /**
     * 输入 int 转换为 二进制字符串
     *
     * @param num 数字
     * @return 二进制字符串
     */
    static String num0x(int num) {
        String num0x = "";
        for (int i = 31; i >= 0; i--) {
            num0x += (num & 1 << i) == 0 ? "0" : "1";
        }
        return num0x;
    }
}

参考资料: www.zhihu.com/question/20…