前戏:
hashcode是什么?
在了解知道hashmap之前,有个必不可少的就是了解hashcode,我们知道在jdk的Object对象里面有个方法叫hashcode(),通过官方文档https://docs.oracle.com/en/java/javase/12/docs/api/java.base/java/lang/Object.html#hashCode()可以知道如下情况:
- hashcode是一个公开的对象,就是说在任何地方都可以通过对象.hashCode()获取它的hashcode的值,并且返回的值是一个int类型的数字
- 这里明确指出了hashmap会使用到hashcode,这也是我为什么要先介绍hashcode 的原因
- 同一个jvm虚拟机中调用同一个对象,返回的值是一样的
- 这个跟equals的比较是没有关系的
- 不需要在两个应用程序中保持同步,对应第3点,只需要在同一个应用程序中保持同步,
- 两个对象使用equals比较是相同的,那么两个对象使用hashcode()方法比较也应该需要相同
- 两个对象是不相同的,那么两个对象的hashcode也就没必要相同
- 当产生不同的hashcode的值的话会增强hash表的性能
而我对jdk下的hashcode和equals在同个虚拟机下的了解如下:
首先是看equals,hashcode 的存在主要是为了给一个对象的一个识别的值,这个值是通过一系列的算法计算,官网的介绍说是equals和hashcode是两个不关联的东西,可是最终为了让hashcode变得更有意义我们都是要要求两个相同的内容的对象的hashcode值是相同的(因此要是重写了hashcode的方法的话也是要求重写equals方法的),就是上面说的equals相等,hashcode也必会相等.因为是hashcode是通过算法去计算出来的(其中有取余数,质数等等的方式去计算),所以避免不了的是会有重复的情况存在的,也就是说两个不相等的对象是有可能会出现相同的hashcode的.同理可得,hashcode相等的话是很有可能是相等的,也有可能是不相等的,所以是hashcode 相等的情况下equals不一定相等,如果连hashcode都不想等的话,equals肯定是不相等的.
开始:
了解:jdk7和jdk8是不一样的,jdk8是对hashmap做了优化的了,jdk7的话是通过数组+链表的方式去实现的,而jdk8则是在这基础上再次引入了红黑树,加快了查询的效率
JDK1.7下的HashMap:
假设我们现在有以下几个对象
然后有一条假设长度为7的数组
而现在就要把星期一到星期日的值放到数组里面去,这个时候就要有一个下标,通过源码可以知道数值下表是通过indexFor(hash,table.length)这个方法就可以拿到这个数组的下标,而hash可以通过key.hashcode()方法获取到hash的值而table就是定义的数组,
通过上面的思路,查看一下他们对应的hashcode的值是多少
从这可以知道这个hashcode的值是远大于我们的定义的这个的数组的长度的,那么为了能求一个尽量小的数去代表这个数组的下标.我们用求余数的方式去求得这个这个下标
回看源码的indexFor()方法
这个看起来是不是很懵,这个一开始的时候我也懵,因为我是惯于&为与的意思,int&int返回值是int是怎么情况.其实这个就是会把int转换成二进制的形式再进行&,二进制&就是同位都0,或1就1,非同则0,比如01111&11111=01111(这个是刚上大学的时候就开始讲的基础,长时间没用也不重视真的很难想起来),假设进来的h进来是7,length的值为16,那么计算就是7&(16-1)转换成二进制0111&1111,结果为0111,再转换成十进制是等于7的,而7%16也刚好是等于7的,
通过多次测试结果刚好都吻合,由此可知这个还是以求余的方式去拿到最后的下标的值
这里我是除以7的,因为这个数组的长度只有7,首位下标为0,末尾为6,要是除余6的话0和6的求余都是0,那么这个取得下标就会有问题(就当初0到6的求都是会有重复的2位,比如说0和6的求余都是0所以是不合的),所以我们就加大了一位,求余7,通过求余得
而通过这种方式放数据的话会发现其实是有重复的,一条数组的话不可能会把对应的下标的值都存进去
这个时候引入的就是链表的概念了链表就是各个是有通过一个点去一个一个的连接起来的,为了方便理解这个链表,我就写一个简单的代码模拟链表的概念
而为了解决以上重复下标的问题,就引入链表,再jdk7中,新的相同下标是放在原有的节点的前面的,就类似上面的链表案例,而jdk8则是刚好相反,是放到后面的
因为jdk7是放到前面的,就类似会变成这样
比如说要找到星期六,而根据下标找到对应的链表为星期三这条链,而星期六是在星期三的上一级,而我们看源码找这个链表的实现方法发现
它是没有上一级pre啊之类的链连接查询的,只有下一级next,那么查找上一级节点的链表又成了问题,这时候发现当把头节点放在数组的位置上就可以通过找到下级的方式找到链表的所有节点
那么操作就是在更新链表的时候把最新的一个链表节点替换旧的节点,然后旧的那条连放在新增的这个节点的后面,.这就解决了这个问题,我们只需要拿到下标就可以拿到节点了
根据这个思路我完成了以下一个HashMap的模拟
package cn.com.yangjianjun.hashmap;
/**
* 模拟的一个jdk7的简单hash实现思路
* @param <K>
* @param <V>
*/
public class MyHashMap<K, V> {
/**
* 定义一个数组
*/
private Entry[] table;
/**
* 给数组默认的数组长度
*/
private static Integer CAPACITY = 7;
/**
* 初始化一个size为0,每次进行新增的操作的时候都+1,这样就会完成了总size的计算了
*/
int size = 0;
/**
* 初始化数组
*
*/
public MyHashMap() {
this.table = new Entry[CAPACITY];
}
public int size() {
return size;
}
public Object get(Object key) {
int hash = key.hashCode();
//因为我测试的时候hashcode出现了负数的情况,所有我这里选择了取整的操作
int i = Math.abs(hash % CAPACITY);
//遍历原来的链表看看这个key是否是存在的
for (Entry<K, V> entry = table[i]; entry != null; entry = entry.next) {
if (entry.k.equals(key)) {
return entry.v;
}
}
return null;
}
public Object put(K key, V value) {
//找下标
int hash = key.hashCode();
//因为我测试的时候hashcode出现了负数的情况(如果hashcode的计算结果超过int的计算范围就会一处),所有我这里选择了取整的操作
int i = Math.abs(hash % CAPACITY);
//遍历原来的链表看看这个key是否是存在的,存在就进行更新
for (Entry<K, V> entry = table[i]; entry != null; entry = entry.next) {
if (entry.k.equals(key)) {
V oldValue = entry.v;
entry.v = value;
//模仿源码,把更新之前的数据返回
return oldValue;
}
}
addEntry(key, value, i);
return null;
}
/**
* 新增链表节点,这个就是相当于新增一个把下面的往下推,把新的放到数组上
* @param key
* @param value
* @param i 数组的下标
*/
private void addEntry(K key, V value, int i) {
Entry<K, V> kvEntry = new Entry(key, value, table[i]);
table[i] = kvEntry;
size++;
}
/**
* 链表类
*
* @param <K>
* @param <V>
*/
class Entry<K, V> {
private K k;
private V v;
private Entry<K, V> next;
public Entry(K k, V v, Entry<K, V> next) {
this.k = k;
this.v = v;
this.next = next;
}
}
}
按照源码 的求余操作(两种其实都一样,上面那种更好理解):
package cn.com.yangjianjun.hashmap;
/**
* 模拟的一个jdk7的简单hash实现思路
* @param <K>
* @param <V>
*/
public class MyHashMap<K, V> {
/**
* 定义一个数组
*/
private Entry[] table;
/**
* 给数组默认的数组长度
*/
private static Integer CAPACITY = 7;
/**
* 初始化一个size为0,每次进行新增的操作的时候都+1,这样就会完成了总size的计算了
*/
int size = 0;
/**
* 初始化数组
*
*/
public MyHashMap() {
this.table = new Entry[CAPACITY];
}
public int size() {
return size;
}
public Object get(Object key) {
int hash = key.hashCode();
//因为我测试的时候hashcode出现了负数的情况(如果hashcode的计算结果超过int的计算范围就会一处),所有我这里选择了取整的操作
//int i = Math.abs(hash % CAPACITY);
//源码求余
int i = indexFor(hash,CAPACITY);
//遍历原来的链表看看这个key是否是存在的
for (Entry<K, V> entry = table[i]; entry != null; entry = entry.next) {
if (entry.k.equals(key)) {
return entry.v;
}
}
return null;
}
/**
* 按照源码的拿下标
* @param h
* @param length
* @return
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
public Object put(K key, V value) {
//找下标
int hash = key.hashCode();
//因为我测试的时候hashcode出现了负数的情况(如果hashcode的计算结果超过int的计算范围就会一处),所有我这里选择了取整的操作
//int i = Math.abs(hash % CAPACITY);
//源码求余
int i = indexFor(hash,CAPACITY);
//遍历原来的链表看看这个key是否是存在的,存在就进行更新
for (Entry<K, V> entry = table[i]; entry != null; entry = entry.next) {
if (entry.k.equals(key)) {
V oldValue = entry.v;
entry.v = value;
//模仿源码,把更新之前的数据返回
return oldValue;
}
}
addEntry(key, value, i);
return null;
}
/**
* 新增链表节点,这个就是相当于新增一个把下面的往下推,把新的放到数组上
* @param key
* @param value
* @param i 数组的下标
*/
private void addEntry(K key, V value, int i) {
Entry<K, V> kvEntry = new Entry(key, value, table[i]);
table[i] = kvEntry;
size++;
}
/**
* 链表类
*
* @param <K>
* @param <V>
*/
class Entry<K, V> {
private K k;
private V v;
private Entry<K, V> next;
public Entry(K k, V v, Entry<K, V> next) {
this.k = k;
this.v = v;
this.next = next;
}
}
}
单个测试
多个debug测试也是没有问题的
这就是一个简单模拟一个jdk7的一个hashmap实现原理
接着我们可以可以去看一下它的一些属性情况
对于数组的默认长度源码是16的,并且要求长度得是2的幂次方,其实上面我的定义为7是不合理的,模拟一下思路,知道就可以了
也有一些源码是这样写的,1<<4的意思就是往左移4位(先转成二进制,然后往左移动4位,原来移动的位置要是是空的就用0补充,就像这个1的二进制是1,往左移动4位是10000,然后将转换后的值转换 成十进制,得到的值为16)
最大容量(同样是左移,可以自己计算一下)
定义一个数组
记载因子,用来计算阈值
阈值
弱哈希代码计算而导致的冲突发生率
.....
接着我们可以看它的初始化方法(构造函数),
首先加载,然后发现是调用了本身另外一个构造函数,当上了初始化数组容量和加载因子,这两个上面的属性上面可以看得到
然后调用的就是这个,首先就是验证信息,分别是数组长度是否小于0,数组长度超过最大值就变成最大值,加载因子是否是不能表达的值或者是小于等于0的值,
然后定义一个新的值.判断这个值是否是小于初始化数组长度的,是的话就先左移这个定义的值在赋值给这个值,然后定义加载因子,定义阈值,定义冲突率
最后的初始化方法其实是啥都没有的
从新增入手,我们可以看到其实hash的值是通过一个hash()方法去获取到hashcode 的值的,
hash()方法如下,下面的那么多的右移的目的是为了让 h,和数组长度更加影响一个hash的值,让下标更加的散列
hash方法进行一个计算的一个目的是这样的,就是为了防止过多重复下标在某一个链表上,甚至是都在同一个下标上,而hash 的目的是散列
散列的目的是为了让这些内容更均匀的散落到数组上,就像这样,就可以更快的获得我们要的数据了,所有就有了这个的hash()方法
这个就关系到数组的扩容了,就是判断内容的大小大于阈值并且加的这个下标下面是没有内容是话就不会进行扩容,要是满足扩容条件的话就扩容
这就是resize的扩容方法,这个新的长度就先变成原来的2倍,其实就是创建一个新的,然后把旧的通过一系列的拷贝到新的上面,在这里是会有一个死锁的问题
真正出现问题的在这里,
假设初始状态为这样
然后线程一拿到了资源,创建了新的数组,还没开始引入新的数据
然后线程二这个时候也抢到了资源,并且这个时候完成了扩容的操作变成了这样
这个时候线程一再进行扩容操作,
然后拿到的是A进来,这个A进来遍历拿到是下一级是更新后的B->A中的A的下一级,也就是null,就跳到下一个数组下标B中进行,而B中再内存的下一级是A,然后这个时候就开始了一个A->B,B->A的一个死循环里面
如果觉得绕的话看下图:
根据源码知道这里的链是通过next去连接下一级的.
为了方便理解,我就把这个写成另外一种形式
,而当线程一开始操作的时候第一次进来循环的时候是
,也就是说是这里的e已经确定了,可是这个时候线程二刚好把数据更新完
变成了这样
这个时候A的对象的next进来的时候是B,内存里的A的对象的next是null,这个时候就把内存A中的next的null改成B
这个时候内存中就变成了,
也就是等同于这样
然后源码中的遍历方式是.next的循环下去的,这样的话就会进入一个死循环的状态了
注意点:
为什么把数组长度默认为2的指数次幂?
1:效率,在扩容的时候,在进行一个长度的定义的时候是定义可以通过移动的方式去避免hashcode 的一个重新计算,就可以通过直接通过移动的方式拿到新扩容的数组的一个下标在哪,比如说初始是3,扩容后是6,那么就要重新计算,还要求余的方式去获取下一个下标在哪,而是4的话进行扩容的话就可以直接移动就可以了,
2:第二种原因在我上面模拟的那个案例就已经踩坑了,那时候取的是默认值是7,可是hashcode之后求得的h&(length-1)是不等于h%(length-1),那时候后者是一个负数,这个时候要是看作是求余的想法的是话就索引越界了
结尾:
上面是个人对jdk1.7的hashmap的理解,若有不正确请指出,谢谢各位