吐槽
今天天气好冷啊,真的是冻死了,自己下午出去吃了一顿羊肉泡,美滋滋。回来啃下HashMap的源码。
前置条件
在看HashMap源码之前我们有两个前置条件:
- Hash的概念,Hash函数的概念,Hash表的概念
- Map接口的源码分析
当我们看完前置条件,然后再去看下HashMap的源码,解决以下问题。
- 什么时候使用HashMap?简单的介绍下HashMap
- HashMap的工作原理
- HashMap里面的get()和Put的原理?equals()和hashCode()的都有什么作用?
- hash的实现吗?为什么要这样实现?
- 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
哈希 哈希函数 哈希表
之前大二数据结构课上自己还学过这块,然后自己现在已经忘完了233,现在重新看下这块。
哈希
hash又称为散列
散列是什么啊233
当然就是不经过排序,然后把一类数据放在一起的东西,和排序是刚好相反的操作。
然后这块散列到底是什么意思啊,举个简单的例子
从前你要去一个班里面找个小孩子,你只知道他叫狗蛋,然后你去教室之后问班主任,班主任拿着班级学生的花名册挨个看,发现没有狗蛋这个学生的名字,告诉你他们班没有狗蛋这个人。但是你坚信狗蛋在这个班,你找个小屁孩问下他们有没有狗蛋这个人啊,他们就会告诉你狗蛋在哪里,因为狗蛋因为在班里傻乎乎的,人们就只叫他的外号狗蛋,他的本名叫王二哈233
分析下上面的故事,我们发现
老师是通过花名册进行查找的,类似顺序表查找,依赖的是名字的匹配,挨个挨个进行查找。
但是我们去问小孩子的时候,没有遍历没有比较,直接就根据王二哈的外号狗蛋直接找到他,效率高。
散列技术是记录的存储位置和它的关键字(外号)之间确定一个对应的关系f,使得每个关键字(外号)key对应一个存储位置。当我们进行查找的时候,找到key,根据f(key)就找到存储位置了。
这个里面f是散列函数,又称为哈希函数
再举个例子
下面有4 个数 {2,5,9,13},需要查找 13 是否存在。
第一种方式,建立一个数组,然后遍历进行查找,判断其是否存在。
第二种方式,存储时先使用哈希函数进行计算,这里我随便用个函数H[key] = key % 3;
然后在存的时候
H[2] = 2 % 3 = 2;
H[5] = 5 % 3 = 2;
H[9] = 9 % 3 = 0;
H[13] = 13 % 3 = 1;
2存在2的位置,5存在2的位置(冲突了),9存在0号位置,13存在1号位置
当我们要查询13在不在的时候,去他的1号位置看下是否存在
哈希 其实是随机存储的一种优化,先进行分类,然后查找时按照这个对象的分类去找。
哈希通过一次计算大幅度缩小查找范围,自然比从全部数据里查找速度要快
哈希函数
如何给别人起外号呢,还不能重复,要突出特色哈哈哈
哈希函数就是干这个的
哈希的过程中需要使用哈希函数进行计算。
哈希函数是一种映射关系,根据数据的关键词 key ,通过一定的函数关系,计算出该元素存储位置的函数。
表示为:
address = H [key]
常见的哈希函数的构造方法
- 直接定制法 关键字或关键字的某个线性函数值为散列地址 类似H(key) = key 或 H(key) = a*key + b,其中a和b为常数。
- 除留余数法 取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。 H(key) = key % p
- 数字分析法 键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
- 平方取中法 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
- 随机数法 选择一个随机函数,把关键字的随机函数值作为它的哈希值
但是我们不论怎么样用什么规则给别人取外号,都有一定几率会起到重复的名字233,那么我们又如何去解决这块呢?
解决hash冲突的四种办法
哈希表//散列表
哈希表是实现关联数组(associative array)的一种数据结构,广泛应用于实现数据的快速查找。
链地址法的原理时如果遇到冲突,他就会在原地址新建一个空间,然后以链表结点的形式插入到该空间。我感觉业界上用的最多的就是链地址法。下面从百度上截取来一张图片,可以很清晰明了反应下面的结构。比如说我有一堆数据{1,12,26,337,353…},而我的哈希算法是H(key)=key mod 16,第一个数据1的哈希值f(1)=1,插入到1结点的后面,第二个数据12的哈希值f(12)=12,插入到12结点,第三个数据26的哈希值f(26)=10,插入到10结点后面,第4个数据337,计算得到哈希值是1,遇到冲突,但是依然只需要找到该1结点的最后链结点插入即可,同理353
Map接口的源码
Map是集合的另一大帮派的老大
看下他的继承关系
它有四大得力干将:
- Hashtable 古老,线程安全
- HashMap 速度快,没顺序
- TreeMap 有序的,效率比HashMap低
- LinkedHashMap 有序的,结合上面两个的特点
然后我们去看下这块Map的源码:
public interface Map<K, V> {
int size();
boolean isEmpty();
boolean containsKey(Object var1);
boolean containsValue(Object var1);
V get(Object var1);
V put(K var1, V var2);
V remove(Object var1);
void putAll(Map<? extends K, ? extends V> var1);
void clear();
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
------
public interface Entry<K, V> {
K getKey();
V getValue();
V setValue(V var1);
boolean equals(Object var1);
int hashCode();
static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K, V>> comparingByKey() {
return (Comparator)((Serializable)((var0x, var1x) -> {
return ((Comparable)var0x.getKey()).compareTo(var1x.getKey());
}));
}
static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K, V>> comparingByValue() {
return (Comparator)((Serializable)((var0x, var1x) -> {
return ((Comparable)var0x.getValue()).compareTo(var1x.getValue());
}));
}
static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> var0) {
Objects.requireNonNull(var0);
return (Comparator)((Serializable)((var1x, var2x) -> {
return var0.compare(var1x.getKey(), var2x.getKey());
}));
}
static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> var0) {
Objects.requireNonNull(var0);
return (Comparator)((Serializable)((var1x, var2x) -> {
return var0.compare(var1x.getValue(), var2x.getValue());
}));
}
里面看了下就三个重要的东西
KeySet
是map里面所有的key的集合,以set的形式保存,不允许重复
Values
是一个 Map 中值 (value) 的集合,以 Collection 的形式保存,因此可以重复
Entry
Entry 是 Map 接口中的静态内部接口,表示一个键值对的映射
通过 Map.entrySet() 方法获得的是一组 Entry 的集合,保存在 Set 中,所以 Map 中的 Entry 也不能重复。
public Set<Map.Entry<K,V>> entrySet();
Map的遍历方式
看下别人的博客
Map的遍历方式
HashMap源码分析
上面介绍的时候也讲过为了防止哈希碰撞,HashMap采用拉链法,就是上面这种样子保存数据的
所以我们看到他存储的时候就是底层实现用的是数组+链表的方式
成员变量
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
//又是序列号
private static final long serialVersionUID = 362498820763181265L;
//默认的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1073741824;
//加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75F;
//树形阈值 当使用 树 而不是列表来作为桶时使用。必须必 2 大
static final int TREEIFY_THRESHOLD = 8;
//非树形阈值:也是 1.8 新增的,扩容时分裂一个树形桶的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//数的最小容量 避免冲突
static final int MIN_TREEIFY_CAPACITY = 64;
//链表数组
transient HashMap.Node<K, V>[] table;
//缓存的键值对集合
transient Set<Entry<K, V>> entrySet;
//键值对的数量
transient int size;
//修改次数
transient int modCount;
//这块是阈值 用于判断是否需要调整HashMap的容量(threshold = 容量*加载因子)
int threshold;
//哈希表的加载因子
final float loadFactor;
然后我们看下这个加载因子到底是什么鬼啊
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子就是表示Hash表中元素的填满程度
加载因子越大,填充的元素越多,空间利用率就越高,但是冲突就越多
反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
所以,这个很矛盾emmmmm
HashMap有一个初始容量大小,默认是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为了减少冲突的概率,当hashMap的数组长度到了一个临界值就会触发扩容,把所有元素rehash再放到扩容后的容器中,这是一个非常耗时的操作。
而这个临界值由【加载因子】和当前容器的容量大小来确定:DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR ,即默认情况下是16x0.75=12时,就会触发扩容操作。
所以,我们还是不知道为啥是0.75 emmmmmm
然后网上查下资料发现这块还和统计学有关233
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring varian/ce, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
简单翻译一下就是在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
简单的说,Capacity就是bucket的大小,Load factor就是bucket填满程度的最大比例。如果对迭代性能要求很高的话,不要把capacity设置过大,也不要把load factor设置过小。当bucket中的entries的数目大于capacity*load factor时就需要调整bucket的大小为当前的2倍。
关键字是泊淞分布
科普地址d
构造方法
//创建一个空的哈希表,然后指定容量和加载因子
public HashMap(int var1, float var2) {
//输入判断
if (var1 < 0) {
throw new IllegalArgumentException("Illegal initial capacity: " + var1);
} else {
if (var1 > 1073741824) {
var1 = 1073741824;
}
//输入判断
if (var2 > 0.0F && !Float.isNaN(var2)) {
this.loadFactor = var2;
this.threshold = tableSizeFor(var1);
} else {
throw new IllegalArgumentException("Illegal load factor: " + var2);
}
}
}
//指定的容量的哈希表,加载因子的0.75
public HashMap(int var1) {
this(var1, 0.75F);
}
//空的哈希表 初始容量是16 加载因子是0.75
public HashMap() {
this.loadFactor = 0.75F;
}
//创建一个内容为参数var1的哈希表
public HashMap(Map<? extends K, ? extends V> var1) {
this.loadFactor = 0.75F;
this.putMapEntries(var1, false);
}
在自己设置容量和加载因子的时候,它调用这个 tableSizeFor(var1);
static final int tableSizeFor(int var0) {
int var1 = var0 - 1;
var1 |= var1 >>> 1;
var1 |= var1 >>> 2;
var1 |= var1 >>> 4;
var1 |= var1 >>> 8;
var1 |= var1 >>> 16;
return var1 < 0 ? 1 : (var1 >= 1073741824 ? 1073741824 : var1 + 1);
}
根据指定的容量设置阈值,这个方法经过若干次无符号右移、求异运算,得出最接近指定参数 cap 的 2 的 N 次方容量
反正因为使用了位运算,所以这个方法可能不能明确的知道结果,但是只要知道不管输入什么值,它的最后结果都会是0,1,2,4,8,16,32,68… 这些数字中的一个就对了(其实是有规律的),对于以下输入值有:
tableSizeFor(16) = 16
tableSizeFor(32) = 32
tableSizeFor(48) = 64
tableSizeFor(64) = 64
tableSizeFor(80) = 128
tableSizeFor(96) = 128
tableSizeFor(112) = 128
tableSizeFor(128) = 128
tableSizeFor(144) = 256
也即是说,对于容量的初始值16来说,其初始阈值便是16,与JDK 1.7中初始阈值相同,而其resize函数中,threshold的计算源码如下
final HashMap.Node<K, V>[] resize() {
HashMap.Node[] var1 = this.table;
int var2 = var1 == null ? 0 : var1.length;
int var3 = this.threshold;
int var5 = 0;
int var4;
//如果有容量,表示map已经有元素了
if (var2 > 0) {
if (var2 >= 1073741824) {
this.threshold = 2147483647;
return var1;
}
//容量翻倍
if ((var4 = var2 << 1) < 1073741824 && var2 >= 16) {
var5 = var3 << 1;
}
} else if (var3 > 0) {
var4 = var3;
} else {
var4 = 16;
var5 = 12;
}
if (var5 == 0) {
float var6 = (float)var4 * this.loadFactor;
var5 = var4 < 1073741824 && var6 < 1.07374182E9F ? (int)var6 : 2147483647;
}
this.threshold = var5;
HashMap.Node[] var14 = (HashMap.Node[])(new HashMap.Node[var4]);
this.table = var14;
//最开始没往里面加东西的时候
//上面的操作就是如果你初始化的时候带了参数的时候HashMap(int initialCapacity, float loadFactor))
//newCap就是你的initialCapacithreshold就是 (int)(initialCapacity*loadFactor)
//如果没带参数的话 按照默认的算initialCapacity = 16,threshold = 12
// 如果已经有元素了,那么直接扩容2倍,如果oldCap >= DEFAULT_INITIAL_CAPACITY了,那么threshold也扩大两倍
if (var1 != null) {
for(int var7 = 0; var7 < var2; ++var7) {
HashMap.Node var8;
if ((var8 = var1[var7]) != null) {
var1[var7] = null;
if (var8.next == null) {
var14[var8.hash & var4 - 1] = var8;
} else if (var8 instanceof HashMap.TreeNode) {
//原来的结点转换成红黑树
((HashMap.TreeNode)var8).split(this, var14, var7, var2);
} else {
HashMap.Node var9 = null;
HashMap.Node var10 = null;
HashMap.Node var11 = null;
HashMap.Node var12 = null;
HashMap.Node var13;
do {
var13 = var8.next;
if ((var8.hash & var2) == 0) {
if (var10 == null) {
var9 = var8;
} else {
var10.next = var8;
}
var10 = var8;
} else {
if (var12 == null) {
var11 = var8;
} else {
var12.next = var8;
}
var12 = var8;
}
var8 = var13;
} while(var13 != null);
if (var10 != null) {
var10.next = null;
var14[var7] = var9;
}
if (var12 != null) {
var12.next = null;
var14[var7 + var2] = var11;
}
}
}
}
}
return var14;
}
emmmm看的这块的简直是脑壳痛,,,jdk的版本不一样计算阈值也不一样
JDK 1.6 当数量大于容量 * 负载因子即会扩充容量。
JDK 1.7 初次扩充为:当数量大于容量时扩充;第二次及以后为:当数量大于容量 * 负载因子时扩充。
JDK 1.8 初次扩充为:与负载因子无关;第二次及以后为:与负载因子有关。其详细计算过程需要具体详解。//涉及到红黑树
这个函数的解释看下大佬的解释,,红黑数搞不定啊啊啊
resize的解释
再看下整个集合添加到哈希表中
final void putMapEntries(Map<? extends K, ? extends V> var1, boolean var2) {
int var3 = var1.size();
if (var3 > 0) {
//如果数组为空的情况,初始化参数
if (this.table == null) {
float var4 = (float)var3 / this.loadFactor + 1.0F;
int var5 = var4 < 1.07374182E9F ? (int)var4 : 1073741824;
if (var5 > this.threshold) {
this.threshold = tableSizeFor(var5);
}
//数组不为空,超过阈值就扩容
} else if (var3 > this.threshold) {
this.resize();
}
Iterator var8 = var1.entrySet().iterator();
while(var8.hasNext()) {
Entry var9 = (Entry)var8.next();
Object var6 = var9.getKey();
Object var7 = var9.getValue();
//通过hash()计算位置,然后复制
this.putVal(hash(var6), var6, var7, false, var2);
}
}
}
HashMap的结点
static class Node<K, V> implements Entry<K, V> {
//哈希值,就是位置
final int hash;
//键
final K key;
//值
V value;
//下个结点指针
HashMap.Node<K, V> next;
Node(int var1, K var2, V var3, HashMap.Node<K, V> var4) {
this.hash = var1;
this.key = var2;
this.value = var3;
this.next = var4;
}
public final K getKey() {
return this.key;
}
public final V getValue() {
return this.value;
}
public final String toString() {
return this.key + "=" + this.value;
}
public final int hashCode() {
return Objects.hashCode(this.key) ^ Objects.hashCode(this.value);
}
public final V setValue(V var1) {
Object var2 = this.value;
this.value = var1;
return var2;
}
public final boolean equals(Object var1) {
if (var1 == this) {
return true;
} else {
if (var1 instanceof Entry) {
Entry var2 = (Entry)var1;
if (Objects.equals(this.key, var2.getKey()) && Objects.equals(this.value, var2.getValue())) {
return true;
}
}
return false;
}
}
}
put函数
public V put(K var1, V var2) {
//对key做hashCode()做hash
return this.putVal(hash(var1), var1, var2, false, true);
}
final V putVal(int var1, K var2, V var3, boolean var4, boolean var5) {
HashMap.Node[] var6 = this.table;
int var8;
//如果当前哈希表为空,新建一个,var8指桶的最后一个位置
if (this.table == null || (var8 = var6.length) == 0) {
var8 = (var6 = this.resize()).length;
}
Object var7;
int var9;
//如果要插入的位置没有元素,新建一个结点
if ((var7 = var6[var9 = var8 - 1 & var1]) == null) {
var6[var9] = this.newNode(var1, var2, var3, (HashMap.Node)null);
} else {
//如果要插入的桶已经有元素,替换
Object var10;//被替换的元素
label79: {
Object var11;
if (((HashMap.Node)var7).hash == var1) {
var11 = ((HashMap.Node)var7).key;
if (((HashMap.Node)var7).key == var2 || var2 != null && var2.equals(var11)) {
var10 = var7;
break label79;
}
}
if (var7 instanceof HashMap.TreeNode) {
var10 = ((HashMap.TreeNode)var7).putTreeVal(this, var6, var1, var2, var3);
} else {
int var12 = 0;
while(true) {
var10 = ((HashMap.Node)var7).next;
if (((HashMap.Node)var7).next == null) {
((HashMap.Node)var7).next = this.newNode(var1, var2, var3, (HashMap.Node)null);
//如果这个桶的链表的个数大于等于8,数化
if (var12 >= 7) {
this.treeifyBin(var6, var1);
}
break;
}
//如果找到要替换的结点就停止
if (((HashMap.Node)var10).hash == var1) {
var11 = ((HashMap.Node)var10).key;
if (((HashMap.Node)var10).key == var2 || var2 != null && var2.equals(var11)) {
break;
}
}
var7 = var10;
++var12;
}
}
}
if (var10 != null) {
Object var13 = ((HashMap.Node)var10).value;
if (!var4 || var13 == null) {
((HashMap.Node)var10).value = var3;
}
this.afterNodeAccess((HashMap.Node)var10);
return var13;
}
}
//超出阈值,扩容
++this.modCount;
if (++this.size > this.threshold) {
this.resize();
}
this.afterNodeInsertion(var5);
return null;
}
过程真的是很神奇唉
- 1先对key的hashCode()做hash,然后再计算下标的值
- 2 如果没有碰撞就弄到桶里面
- 3 碰撞了就以链表的形式放在那个桶的链表里面
- 4 如果这个桶的链表里面接了7个结点后,就把链表弄成红黑树
- 5 如果结点已经有了,就替换旧的值,保证key的唯一性
- 6 如果满了就扩容
get函数
public V get(Object key) {
Node<K,V> e;
//先计算hash值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
//tab是hash表,n为hash的长度
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 第一个直接命中
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未命中,挨个找
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
这个过程就是拿着key找值的
- 1还是先计算hash值
- 2先在第一个桶里面找下,看下能否直接命中
- 3如果用冲突的话,挨个遍历
- 4计算出桶的位置
- 5在桶的链表或者树里面挨个找
hash函数
这个函数更是神奇,很短,但是emmmmm头大
1 对key的值首先进行hashCode()运算//通用方法,根据对象的内存地址,返回一个特定的哈希码
2 二次处理哈希码 //进行无符号右移16位,求得键对应的真正的hash值
3 计算存储的数组的位置 二次处理的哈希码和数组长度减一 进行&运算
static final int hash(Object var0) {
int var1;
return var0 == null ? 0 : (var1 = var0.hashCode()) ^ var1 >>> 16;
}
HashMap 中通过将传入键的 hashCode 进行无符号右移 16 位,然后进行按位异或,得到这个键的哈希值。
由于哈希表的容量都是 2 的 N 次方,在当前,元素的 hashCode() 在很多时候下低位是相同的,这将导致冲突(碰撞),因此 1.8 以后做了个移位操作:将元素的 hashCode() 和自己右移 16 位后的结果求异或。
由于 int 只有 32 位,无符号右移 16 位相当于把高位的一半移到低位:
直接看网上的图吧
这块真的是神乎奇迹令人窒息的操作,真的是可怕啊
- 先是将key的值右移动16位,这个数的前16位都是0了,后16位是之前的前16位
- 然后和之前的数字进行异或,相同为0,不同为1
- 获取到值后再和桶的长度减一进行异或,获取下标
为撒要和桶的长度减一异或呢?
由于哈希表的容量都是 2 的 N 次方,在当前,元素的 hashCode() 在很多时候下低位是相同的,这将导致冲突(碰撞)//这个是别人的解释
我们还是按实际出发,如果哈希表的长度是8的话
如果和我们之前计算的值进行与运算的话,可能很多时候要碰撞
所以,为了不大于桶的长度,要减一
0000 0000 0000 0111
然后再进行计算,实际作用的只有低4位,很容易碰撞
所以,才将key进行高16位和低16位的异或操作,仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。
如果还是产生了频繁的碰撞,会发生什么问题呢?
作者注释说,他们使用树来处理频繁的碰撞233真的强
我们看get函数的时候
首先,先根据hashCode()做hash,然后确定bucket的index;
然后,再找到桶之后再接着匹配下key,然后找值
在Java 8之前的实现中是用链表解决冲突的,在产生碰撞的情况下,进行get时,两步的时间复杂度是O(1)+O(n)。因此,当碰撞很厉害的时候n很大,O(n)的速度显然是影响速度的。
因此在Java 8中,利用红黑树替换链表,这样复杂度就变成了O(1)+O(logn)了,这样在n很大的时候,能够比较理想的解决这个问题
话说这些骚操作到底是干了什么,为啥要这样干emmmmm
提高 存储key-value的数组下标位置 的随机性 & 分布均匀性,尽量避免出现hash值冲突emmmmmm
首先经过hashCode()处理的哈希码为什么不能直接当桶的下标
很简单,,,因为哈希码和数组的大小范围不匹配,就是直接计算出来的哈希码可能不在数组范围里面
为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突
为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题
扩容函数
在在HashMap中有两个很重要的参数,容量(Capacity)和负载因子(Load factor)
Capacity就是bucket的大小,Load factor就是bucket填满程度的最大比例。如果对迭代性能要求很高的话,不要把capacity设置过大,也不要把load factor设置过小。当bucket中的entries的数目大于capacity*load factor时就需要调整bucket的大小为当前的2倍。
我们来看下这块的扩容函数的过程
当put时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中
当超过限制的时候会resize,然而又因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结问题
问: 什么时候会使用HashMap?他有什么特点?
答:是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象,把这个当作一个结点,进行存储。
问:HashMap线程安全吗?为什么?
答:线程不安全,因为HashMap没有使用sychronized同步关键字,在添加数据put()时,无法做到线程同步,当多个线程在插入数据时,如果发生了哈希碰撞,可能会造成数据的丢失,然后在在扩容resize()时,也无法做到线程同步,当多个线程同时开启扩容,会各自生成新的数组进行拷贝扩容,最终结果只有一个新数组被赋值给table变量,其他的线程均会丢失。
问:如何保证HashMap线程安全
答:三种方式,Hashtable,ConcurrentHashMap,Synchronized Map
链接详解
问:HashMap的工作原理吗?
通过hash的方法进行存储结构构建hash表,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
问:你知道get和put的原理吗?equals()和hashCode()的都有什么作用?
答:对结点的key进行hash运算操作,计算下标,也就是要放到那个桶里面,如果碰撞的话,就在这个桶的链表或者树里面进行查询结点。
问:hash的实现方式
答:在jdk1.8后,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
问:扩容时候机制
答:这个分情况讨论这块,因为jdk的版本问题分情况讨论
JDK 1.6 当数量大于容量 * 负载因子即会扩充容量。
JDK 1.7 初次扩充为:当数量大于容量时扩充;第二次及以后为:当数量大于容量 * 负载因子时扩充
我们看的是jkd1.8这块,当发现现在的桶的占有量已经超过了容量 * 负载因子时候,进行扩容,直接扩容桶的数量为之前的2倍,并且重新调用hash方法,重新计算下标,然后再把之前的结点放到数组里面。
问:HashMap里面的红黑树
答:这块,,,自己还在看emmmmm目前回答不上来,当一个桶链表结点数量大于8的时候,就把链表结构转换成红黑树结构,这样的操作查找效率更高,这块就是红黑树和链表的区别emmmmm