前言
这一篇呢 , 我们来手写一个简单的HashMap,所谓HashMap,就是一个映射表。然后底层是数组加链表形式(Jdk1.7) 的存储形式 , 在Jdk1.8的时候 , 将这种存储形式更改成为了数组加链表加红黑树形式 , 相较于之前的改动是在链表达到一定阈值的时候 ,链表长度超过8时树化 . 将链表更改为查询速度更快的红黑树.
今天我们主要从Jdk1.7入手 , 来实现一个简易版的HashMap , 至于为什么是简易版的, 这里稍微做下说明, 原因是因为 , HashMap的源码中针对 put get 还有扩容时候 , 采用了很多 位移、与运算、异或运算, 本篇咱们只需要做一个基础的即可 , 旨在了解HashMap的一个底层大致思路.
HashMap
HashMap是Java中一中非常常用的数据结构,也基本是面试中的“必考题”。它实现了基于“K-V”形式的键值对的高效存取。JDK1.7之前,HashMap是基于数组+链表实现的,1.8以后,HashMap的底层实现中加入了红黑树用于提升查找效率。
手撸HashMap
定义一个Map的接口类
/**
* @author hxk
* @version IMap: IMap.java, v 0.1 2022-07-07 14:49 hxk Exp $
*/
public interface IMap<K, V> {
/**
* put
*
* @param k key
* @param v value
* @return value 如果是已存在值 , 则返回 oldValue
*/
V put(K k, V v);
/**
* get
*
* @param k key
* @return value
*/
V get(K k);
/**
* remove
*
* @param k key
* @return value
*/
V remove(K k);
int size();
/**
* Map中数组结构主要数据类
* @param <K>
* @param <V>
*/
interface Entry<K, V> {
K getKey();
V getValue();
V setValue(V v);
}
}
然后我们需要一个自定义实现类来继承该接口 , 并逐步实现里面的函数 , 在这之前我们先来看下,Jdk1.7的源码中的一些重要参数设定.
//初始容量是16,且容量必须是2的倍数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量是2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final Entry<?,?>[] EMPTY_TABLE = {};
//HashMap的主干是一个Entry数组,在需要的时候进行扩容,长度必须是2的被数
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//放置的key-value对的个数
transient int size;
//进行扩容的阈值,值为 capacity * load factor,即容量 * 负载因子
int threshold;
//负载因子
final float loadFactor;
这里说一下threshold和loadFactor,threshold = capacity * load factor,即扩容的阈值=数组长度 * 负载因子,如果hashmap数组的长度为16,负载因子为0.75,则扩容阈值为16*0.75=12
- 负载因子越小,容易扩容,浪费空间,但查找效率高
- 负载因子越大,不易扩容,对空间的利用更加充分,查找效率低(链表拉长)
自定义IHashMap类 , 并实现IMap接口
@Getter
public class IHashMap<K, V> implements IMap<K, V>, Serializable {
// 初始默认容量大小
final static int DEFAULT_CAPACITY = 8;
// 数组大小
private int size = 0;
// 数据存放处
private Entry<K, V>[] entries = null;
// 构造器, 初始化HashMap时指定HashMap默认初始容量大小
public IHashMap() {
entries = new Entry[DEFAULT_CAPACITY];
}
@Override
public int size() {
// HashMap 定义一个size 属性 , 每当put||remove时候对size值进行加减
return size;
}
// 内部类 , 用来作为数组的实际存放数据的对象
static class Entry<K, V> implements IMap.Entry<K, V> {
K k;
V v;
Entry<K, V> next;
public Entry(K k, V v, Entry<K, V> next) {
this.k = k;
this.v = v;
this.next = next;
}
@Override
public final K getKey() {
return k;
}
@Override
public final V getValue() {
return v;
}
@Override
public V setValue(V v) {
V oldV = this.v;
this.v = v;
return oldV;
}
}
}
put方法
一下是简化版的put方法 , 部分去掉/部分简化 , 还是那句话 , 该篇文章旨在了解HashMap底层原理
@Override
public V put(K k, V v) {
// 拿key的hashcode 对entries.length -1 取模
int index = k.hashCode() % (entries.length - 1);
// 拿到当前下标的entry
Entry<K, V> curEntry = entries[index];
// 如果当前位置有值
if (curEntry != null) {
// 遍历当前索引位置链表
while (curEntry != null) {
if (k.equals(curEntry.k)) {
V oldV = curEntry.v;
curEntry.v = v;
return oldV;
}
curEntry = curEntry.next;
}
//链表中没有相同的元素,采用头插法,直接插在头部
//元素进来的时候,让他先指向原来的数组上的值,然后再
//把当前数组赋值给我们新的元素的next,这样就达到了插在头部的操作。
entries[index] = new Entry<K, V>(k, v, entries[index]);
size++;
return v;
}
// 如果当前位置不存在侧直接进行插入,直接插入的数据是没有next的
entries[index] = new Entry<K, V>(k, v, null);
size++;
return v;
}
首先我们要明白 , put方法中,我们通过传入的K-V值构建一个Entry对象,然后根据key的hash 对 数组长度-1 取模 后的index下标来判断它应该被放在数组的那个位置 , 但是 当两个key算出来HashCode相同的情况时,就会产生冲突, 也就是Hash冲突.
目前我们的Map类中,底层的数组长度默认值20(真正的HashMap默认值是16),当存入的数据足够多并且不进行扩容的话,Hash碰撞是必然的。所谓Hash碰撞,就是比如说两个key明明是不同的,但是经过hash算法后,hash值竟然是相同的。那么另一个key的value就会覆盖之前的,从而引起错误。
这个时候就运用到了链表 , 当出现Hash冲突时 , 就直接在链表中加一个节点, 当下次来取元素时候, 我们首先遍历这个链表(长度为1也视作链表),如果存在key与我们存入的key相等,则替换并返回旧值;如果不存在,则将新节点插入链表。
但是如果想要提高HashMap的效率,最重要的就是尽量避免生成链表,或者说尽量减少链表的长度 , 想要达到这一点,我们需要Entry对象尽可能均匀地散布在数组table中,且index不能超过table的长度,很明显,之前所说的取模运算就很符合我们的需求 int index = k.hashCode() % table.length。 关于这一点,Jdk1.7的HashMap中也使用了一种效率更高的方法——通过&运算完成key的散列.
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
插入链表又有两种做法:头插法和尾插法。
如果使用尾插法,我们需要遍历这个链表,将新节点插入末尾;如果使用头插法,我们只需要将table[index]的引用指向新节点,然后将新节点的next引用指向原来table[index]位置的节点即可,这也是jdk1.7的HashMap做法。而jdk1.8则使用的是尾插法.
原因是JDK1.7中HashMap在扩容时,对每个元素的rehash之后,都会插入到新数组对应索引的链表头,这就导致原链表顺序为A->B->C,扩容rehash之后的链表可能为C->B->A,元素的顺序发生了变化。如果在并发场景下,这种扩容时可能会导致出现循环链表的情况。所以JDK1.8从头插入改成尾插入元素的顺序不变,是为了避免出现循环链表的情况。
Get方法
调用get方法时,我们根据key的hashcode计算它对应的index,然后直接去table中的对应位置查找即可,如果有链表就遍历。整体思路跟put基本一致, 其实从一个put就能看出来一大部分HashMap对于底层数据的处理方式了 , get只不过是反转了一下.
@Override
public V get(K k) {
// 拿key的hashcode 对entries.length -1 取模
int index = k.hashCode() % (entries.length - 1);
// 拿到当前下标的entry
Entry<K, V> curEntry = entries[index];
// 遍历链表
while (curEntry != null) {
if (k.equals(curEntry.k)) {
return curEntry.v;
}
// 指针下移
curEntry = curEntry.next;
}
return null;
}
Remove方法
@Override
public V remove(K k) {
// k 的hashcode 对 entries.length 取模
int i = k.hashCode() & (entries.length - 1);
// 定位到所在位置
Entry<K, V> entry = entries[i];
// 如果k直接命中当前索引位置的值, 那么直接拿next覆盖当前值, 无论之null 或者有值都可以覆盖这里
if (entry.k.equals(k)) {
// 指针后移
entries[i] = entries[i].next;
size--;
return entry.v;
}
// 遍历链表
while (entry.next != null) {
if (entry.next.k.equals(k)) {
V oldV = entry.v;
entry.next = entry.next.next;
size--;
return oldV;
}
// 指针后移
entry = entry.next;
}
return null;
}
移除某个节点时,如果该key对应的index处没有形成链表,那么直接置为null。如果存在链表,我们需要将目标节点的前驱节点的next引用指向目标节点的后继节点。由于我们的Entry节点没有previous引用,因此我们要基于目标节点的前驱节点进行操作,即:
current.next = current.next.next;
current代表我们要删除的节点的前驱节点。
还有一些简单的isEmpty()等方法都很简单,这里就不再赘述。现在,我们自定义的IHashMap基本可以使用了。