文章目录
谈谈HashMap
HashMap
是基于哈希表的Map
接口的非同步实现。此实现提供所有可选的映射 操作,并允许使用null
值和null
键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
HashMap底层存储原理
谈到底层存储,这里要聊的就是两个地方,一个是存储的数据结构,另一个就是存储的算法
HashMap的数据结构:
在Java
编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap
也不例外。 HashMap
实际上是一个“链表散列”的数据结构,即数组和链表的结合体,关于数组和链表,这里不作过多解释,如有需要可见我的另一篇文章,数组和链表的区别。
HashMap的Hash算法
Hash
算法也被称为散列算法,就是把任意长度值(Key
)通过散列算法变换成固定长度的key
(地址)通过这个地址进行访问的数据结构,它通过把关键码映射到表中一个位置来访问记录,以加快查找的速度,下面这张图。
- 过程是先将
lies
中的四个字母的ASCII
码进行求和,得到429
,然后将429
进行取模20
,得到9
,然后就把lies
这个值放到这一段连续的存储单元的9
号位置上面
那么这里引申出两个问题:既然进行了hash算法算出来这个这个hash值,直接放到对应的下标位置就好了,为什么要取个模?如果当一个新的key经过计算取模后得到的下标位置已经有值了怎么办?
- 第一个问题,首先我们要知道的是,数组是用一段连续的存储单元来存储数据的,通常我们通过散列算法得到的值都会比较大,如果我们给每一个值都分配一块存储空间,那么我们需要的连续的存储空间就会更加的大,数组对内存要求又会比较高,所以这样不可取,需要取个模才行。
- 第二个问题,取模后,原本比较松散的数据就会变得紧凑,如果出现了目标地址已经有了数据怎么办,其实这就是大家常说的哈希碰撞或哈希冲突,下图解释了如果出现了哈希碰撞,是怎么解决的。
存储时,如果出现
hash
值相同的key
,此时有两种情况。
- 如果
key
相同,则覆盖原始值; - 如果
key
不同(出现冲突),则将当前的key-value
放入链表中- 获取时,直接找到
hash
值对应的下标,在进一步判断key
是否相同,从而找到对应值。 - 理解了以上过程就不难明白
HashMap
是如何解决hash
冲突的问题,核心就是使用了数组的 存储方式,然后将冲突的key
的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
- 获取时,直接找到
手写一个自己的HashMap
在知道了HashMap的底层实现原理后,我们接下来手写一个自己的HashMap
首先定义一个Map接口
/**
* @author PengHuAnZhi
* @createTime 2021/1/28 15:28
* @projectName JavaSEReview
* @className Map.java
* @description TODO
*/
public interface Map<K, V> {
//这里实现基础的put,get和size方法
V put(K k, V v);
V get(K k);
int size();
//定义一个内部接口,用于哈希冲突后定义链表节点
interface Entry<K, V> {
K getKey();
V getValue();
}
}
定义HashMap类实现Map
/**
* @author PengHuAnZhi
* @createTime 2021/1/28 15:30
* @projectName JavaSEReview
* @className HashMap.java
* @description TODO
*/
public class HashMap<K, V> implements Map<K, V> {
private Entry<K, V> table[] = null;
private int size = 0;
public HashMap() {
this.table = new Entry[16];
}
/**
* @param k key
* @param v value
* @return 返回当前节点
* @description 通过hash算法算出key的哈希值,再对取取模算出index数组下标,找到下标对象,判断当前对
* 象是否为空,如果为空,说明可以直接存储,如果不为空,表示出现hash冲突,就需要用到链表
*/
@Override
public V put(K k, V v) {
int index = hash(k);
Entry<K, V> entry = table[index];
if (null == entry) {
//可以直接存储
table[index] = new Entry<>(k, v, index, null);
//新增数据,扩容
size++;
} else {
//出现冲突,直接将当前节点作为新节点的next,将新节点插入到原始位置
table[index] = new Entry<>(k, v, index, entry);
//新增数据,扩容
size++;
}
return table[index].getValue();
}
/**
* @param k key
* @return 返回key应该存储位置的下标
* @description 计算出key应该存储位置的下标
*/
private int hash(K k) {
/*
这里直接取模即可,但是真正的HashMap底层是通过移位来实现取余操作的,移位的性能会比取模操作高很多,
但是这里就用取模即可,由于hashcode算出来的值可能为负数,数组下标我们应该将其变为正数。
*/
int index = k.hashCode() % 16;
//判断是否小于0
return index >= 0 ? index : -index;
}
/**
* @param k key
* @return value
* @description 通过key算出hash值对应的数组下标,判断当前对象是否为空,如果不为空,判断是否相等,如果不相等,判断next是否相等,直到找到相等的值或者next为null
*/
@Override
public V get(K k) {
if (size == 0) {
return null;
}
int index = hash(k);
Entry<K, V> entry = findValue(table[index], k);
return entry == null ? null : entry.getValue();
}
/**
* @param entry 对应KV的节点
* @param k key
* @return 返回
* @description
*/
private Entry<K, V> findValue(Entry<K, V> entry, K k) {
if (k.equals(entry.getKey()) || k == entry.getKey()) {
return entry;
} else {
//递归遍历是否有指定节点
if (entry.next != null) {
return findValue(entry.next, k);
}
}
return null;
}
/**
* @return 返回map大小
*/
@Override
public int size() {
return size;
}
class Entry<K, V> implements Map.Entry<K, V> {
K k;
V v;
int hash;
Entry<K, V> next;
public Entry(K k, V v, int hash, Entry<K, V> next) {
this.k = k;
this.v = v;
this.hash = hash;
this.next = next;
}
@Override
public K getKey() {
return k;
}
@Override
public V getValue() {
return v;
}
}
}
测试
public static void main(String[] args) {
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("Phz", "我是彭焕智");
hashMap.put("Rfz", "我是人贩子");
System.out.println(hashMap.get("Phz"));
System.out.println(hashMap.get("Rfz"));
}
探索性能
循环插入大量重复数据
public static void main(String[] args) {
HashMap<String, String> hashMap = new HashMap<>();
for (int i = 0; i < 1000; i++) {
hashMap.put("Phz", "我是彭焕智");
}
System.out.println(hashMap);
}
断点分析
说明了一个什么问题?我们的数组仅仅只有16个位置,但是在2号位置上面存储的链表长度是巨大的,也就是说这个哈希冲突也是非常严重的,就会造成一种什么问题呢?
回顾链表和数组的差异,数组有查询快插入慢,链表有插入快查询慢的特点。如果HashMap
链表十分巨大,那我们的HashMap
的存在的意义又在哪呢?
这也是jdk 1.8
以后HashMap
用到了红黑树的根本原因所在!也就是为了解决链表过长查询效率过低的问题。
查询
HashMap
源码寻找蛛丝马迹
找到
treeifyBin
方法,发现有一个TREEIFY_THRESHOLD
常量,也就是当节点个数超过这个值以后,执行treeifyBin
方法
在
treeifyBin
方法中发现一个TreeNode
对象
找到这个类就能看到我们的红黑树节点了
那么为什么会定义一个阈值8来限制使用红黑树,而不在一开始就使用呢?
- 那么我们这时候就要知道红黑树的实现原理了,“左中右,对应小中大”
- 每一个节点的左叶子节点必定比父节点小,右节点必定比父节点大,根据这个特点我们很明显能看出查询数据会很快,下面这个动图可以很明显感觉到插入数据的繁琐。
- 所以结论就是,“鱼与熊掌不可兼得”