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.
- 这样就不需要重新计算每一个数组中元素的哈希值了。
参考文章: