手写 Redis 系列

java从零手写实现redis(一)如何实现固定大小的缓存?

java从零手写实现redis(三)redis expire 过期原理

java从零手写实现redis(三)内存数据如何重启不丢失?

java从零手写实现redis(四)添加监听器

java从零手写实现redis(五)过期策略的另一种实现思路

java从零手写实现redis(六)AOF 持久化原理详解及实现

java从零手写实现redis(七)LRU 缓存淘汰策略详解

java从零开始手写redis(八)朴素 LRU 淘汰算法性能优化

java从零开始手写redis(九)LRU 缓存淘汰算法如何避免缓存污染

java从零开始手写redis(十)缓存淘汰算法 LFU 最少使用频次

java从零开始手写redis(十一)缓存淘汰算法 COLOK 算法

java从零开始手写redis(十二)过期策略如何实现随机 keys 淘汰

java从零开始手写redis(十三)redis渐进式rehash详解

java从零开始手写redis(十四)JDK HashMap 源码解析

java从零开始手写redis(十四)JDK ConcurrentHashMap 源码解析

java从零开始手写redis(十五)实现自己的 HashMap

java从零开始手写redis(十六)实现渐进式 rehash map

回顾

我们在 从零手写缓存框架(14)redis渐进式rehash详解 中已经介绍了 redis 的渐进式 rehash 的原理。

从零开始手写缓存框架 redis(13)HashMap 源码原理详解 中详细讲解了 HashMap 的源码和设计思想。

本节就让我们一起来实现一个 HashMap,为后续实现渐进式 rehash 打下基础。

本文思维导图如下:

手写HashMap

简易版本 HashMap 的实现

我们先循序渐进的实现,第一步首先实现一个简易版本的 HashMap。

简单的准则

我们主要学习的是 rehash 的过程,所以实现尽可能的简单,主要是实现 rehash 的相关操作。

为了实现方便,我们继承 jdk 的 AbstractMap,使用 ArrayList+LinkedList 的方式实现。

hashmap

核心代码

核心代码主要选自我自己去年写的一段代码,可能会有部分调整,但是整体思路不变。

类的定义

注释可以参考,这次我们会做一些调整,主要是针对 rehash 的条件部分。

我们的类继承自 AbstractMap<K,V>,可以让我们专注于实现 put() 和 remove() 这两个方法。

/**
 * 自己实现的 hash map
 *
 * (1)所有的 hash 值相同的元素,放在同一个桶中
 * (2)新增时
 * 2.1 hash 值相同,且 equals() 的,则认为相同,使用替换。
 * 2.2 不同的,则使用链表,新增。
 * 新增时,进行判断,如果 size() 超出阈值,且 hash 的位置有元素,则进行 rehash()
 *
 * 这样是为了避免 hash 的桶数太少,导致的查找性能下降。
 * 个人觉得,现在觉得这种方式很浪费。
 * 比如:容量=8 当 size=6 的时候可能就要进行 rehash
 * (size gte 阈值,且 hash 的位置,已经存在元素。)
 * 某种角度而言,这是没有必要的。
 * 因为即使在同一个桶上的数据出现重复,如果桶中链表的数量小于8,其实遍历性能并不差。
 *
 * 优化思路:可以考虑将 rehash 的条件调整为,当前 hash 的桶位置有元素,且当前桶的链表数量已经达到了8。
 *
 * rehash 的优化思路:
 * 可以参考 redis,进行渐进式 rehash。
 *
 * (3)hash 的简化
 * jdk 实现中,对于 null 值做了特殊处理。其实感觉没必要,直接 null 的 hash 为0,比较的时候认为相等即可。
 * @author binbin.hou
 * @since 0.0.1
 * @param <K> key 泛型
 * @param <V> value 泛型
 * @see HashMap
 * @author binbin.hou
 */
public class MyHashMap<K,V> extends AbstractMap<K,V> implements Map<K,V> {
}

私有属性

private static final Log log = LogFactory.getLog(MyHashMap.class);

/**
 * 容量
 * 默认为 8
 */
private int capacity;

/**
 * 统计大小的信息
 */
private int size = 0;

/**
 * 阈值
 * 阈值=容量*factor
 * 暂时不考虑最大值的问题
 *
 * 当达到这个阈值的时候,直接进行两倍的容量扩充+rehash。
 */
private final double factor = 1.0;

/**
 * 用来存放信息的 table 数组。
 * 数组:数组的下标是一个桶,桶对应的元素 hash 值相同。
 * 桶里放置的是一个链表。
 *
 * 可以理解为 table 是一个 ArrayList
 * arrayList 中每一个元素,都是一个 DoubleLinkedList
 */
private List<List<Entry<K, V>>> table;

/**
 * 是否开启 debug 模式
 * @since 0.0.3
 */
private boolean debugMode = false;

我们定义了 capacity、size 保存容量和元素的大小。

factor 主要用于判断何时进行 rehash。

table 是一个 arrayList + linkedList 的数据结构。

log+debugMode 主要是为了便于读者理解,添加了 rehash 等日志信息。

构造器

public MyHashMap() {
    this(8);
}
/**
 * 初始化 hash map
 * @param capacity 初始化容量
 */
public MyHashMap(int capacity) {
    this(capacity, false);
}
/**
 * 初始化 hash map
 * @param capacity 初始化容量
 * @param debugMode 是否开启 debug 模式
 * @since 0.0.3
 * @author binbin.hou
 */
public MyHashMap(int capacity, boolean debugMode) {
    this.capacity = capacity;
    // 初始化最大为容量的个数,如果 hash 的非常完美的话。
    this.table = new ArrayList<>(capacity);
    // 初始化为空列表
    for(int i = 0; i < capacity; i++) {
        this.table.add(i, new ArrayList<Entry<K, V>>());
    }
    this.debugMode = debugMode;
}

这里注意下,我们需要对 arrayList 进行初始化(底层是一个 array),不然后面设置 index 会导致越界问题。

hash 的算法不是本节重点,后续有时间讲一讲完美哈希,一致性哈希等哈希算法。

put() 方法

put 方法的实现流程如下:

(1)根据 key 计算 hash 值,根据容器容量,计算对应的下标。

(2)判断是否为替换操作,如果 key 已经存在,则认为是一个替换操作。

替换相对简单,不涉及到 size 的变更,也不涉及到 rehash。

(3)新增元素

需要判断是否需要扩容,如果要,则执行扩容,最后增加 size。

/**
 * 存储一个值
 * @param key 键
 * @param value 值
 * @return 值
 * @author binbin.hou
 */
@Override
public V put(K key, V value) {
    // 计算 index 值
    int hash = HashUtil.hash(key);
    int index = HashUtil.indexFor(hash, this.capacity);

    // 判断是否为替换
    List<Entry<K,V>> entryList = new ArrayList<>();
    if(index < this.table.size()) {
        entryList = this.table.get(index);
    }

    // 遍历
    for(Entry<K,V> entry : entryList) {
        // 二者的 key 都为 null,或者二者的 key equals()
        final K entryKey = entry.getKey();
        if(ObjectUtil.isNull(key, entryKey)
            || key.equals(entryKey)) {
            // 更新新的 entry
            entry.setValue(value);
            if(debugMode) {
                log.debug("put 为替换元素,table 信息为:");
                printTable();
            }
            return value;
        }
    }

    // 新增:
    this.createNewEntry(hash, index, key, value);
    this.size++;
    if(debugMode) {
        log.debug("put 为新增元素,table 信息为:");
        printTable();
    }
    return value;
}

printTable() 实现

这是一个便于用户理解的辅助类,我们会将 table 信息输出出来,实现如下:

/**
 * 打印 table 信息
 * @since 0.0.3
 * @author binbin.hou
 */
private void printTable() {
    for(List<Entry<K, V>> list : this.table) {
        for(Entry<K,V> entry : list) {
            System.out.print(entry+ " ") ;
        }
        System.out.println();
    }
}

为了便于日志打印,我们实现的 DefaultMapEntry 的 toString() 方法重写如下:

@Override
public String toString() {
    return "{" +key +
            ": " + value +
            '}';
}

createNewEntry 实现

/**
 * 创建一个新的明细
 * (1)获取当前 index 对应的链表
 * (2)判断是否需要 rehash
 * 如果当前链表存在,且大小超过阈值8,则进行 rehash。
 * (3)rehash 之后,或者不需要 rehash
 *
 * 都将最后的 index,然后将新元素放在对应的链表中。
 * @param hash hash 值
 * @param tableIndex 下标
 * @param key key
 * @param value value
 * @author binbin.hou
 */
private void createNewEntry(int hash,
                            int tableIndex,
                            final K key,
                            final V value) {
    Entry<K,V> entry = new DefaultMapEntry<>(key, value);
    // 是否需要扩容
    if(isNeedExpand()) {
        this.capacity = this.capacity * 2;
        rehash(this.capacity);
        // 重新计算 tableIndex
        tableIndex = HashUtil.indexFor(hash, this.capacity);
    }
    //  添加元素
    List<Entry<K,V>> list = new ArrayList<>();
    if(tableIndex < this.table.size()) {
        list = table.get(tableIndex);
    }
    list.add(entry);
    if(debugMode) {
        log.debug("Key: {} 对应的 tableIndex: {}", key, tableIndex);
    }
    this.table.set(tableIndex, list);
}

穿件明细会判断是否需要扩容。

是否需要扩容

实际上这个方法比较简单,直接计算一下比例即可:

/**
 * 是否需要扩容
 * @return 是否
 * @since 0.0.3
 * @author binbin.hou
 */
private boolean isNeedExpand() {
    // 验证比例
    double rate = size*1.0 / capacity*1.0;
    return rate >= factor;
}

rehash 实现

终于到我们今天的重头戏了。

/**
 * 直接 rehash 的流程
 *
 * 当然这个 rehash 的方法可以抽象,和 put 复用,暂时不做处理。
 * @param newCapacity 新的 table 的容量
 * @since 0.0.3
 * @author binbin.hou
 */
private void rehash(final int newCapacity) {
    List<List<Entry<K, V>>> newTable = new ArrayList<>(newCapacity);
    for(int i = 0; i < newCapacity; i++) {
        newTable.add(i, new ArrayList<Entry<K, V>>());
    }

    // 遍历元素,全部放置到新的 table 中
    for(List<Entry<K, V>> list : table) {
        for(Entry<K, V> entry : list) {
            int hash = HashUtil.hash(entry);
            int index = HashUtil.indexFor(hash, newCapacity);
            //  添加元素
            // 获取列表,避免数组越界
            List<Entry<K,V>> newList = new ArrayList<>();
            if(index < newTable.size()) {
                newList = newTable.get(index);
            }
            // 添加元素到列表
            // 元素不存在重复,所以不需要考虑更新
            newList.add(entry);
            newTable.set(index, newList);
        }
    }
    // 将新的 table 赋值到原来的 table 上
    this.table = newTable;
    if(debugMode) {
        log.debug("rehash: {} 完成,table 内容为:", newCapacity);
        printTable();
    }
}

rehash 的流程如下:

(1)创建一个新的 table,大小为原来的两倍,并且初始化。

(2)遍历原来的元素,重新 hash 计算下标之后,放到新的 table 中

(3)将 table 赋值为新的 table 信息。

最后为了便于读者理解,我们做了 table 信息的输出。

测试

put() 的测试

MyHashMap<String, String> map = new MyHashMap<>(2, true);
map.put("1", "1");
map.put("1", "2");

日志如下:

[DEBUG] [2020-10-11 18:01:27.466] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 1 对应的 tableIndex: 1
[DEBUG] [2020-10-11 18:01:27.466] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:

{1: 1} 
[DEBUG] [2020-10-11 18:01:27.468] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为替换元素,table 信息为:

{1: 2} 

第一次 put 是新增,第二次为替换。

rehash 测试

MyHashMap<String, String> map = new MyHashMap<>(2, true);
map.put("1", "1");
map.put("2", "2");
map.put("3", "2");

日志如下:

[DEBUG] [2020-10-11 18:03:46.797] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 1 对应的 tableIndex: 1
[DEBUG] [2020-10-11 18:03:46.798] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:

{1: 1} 
[DEBUG] [2020-10-11 18:03:46.798] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 2 对应的 tableIndex: 0
[DEBUG] [2020-10-11 18:03:46.799] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:
{2: 2} 
{1: 1} 
[DEBUG] [2020-10-11 18:03:46.800] [main] [c.g.h.d.s.c.u.m.MyHashMap.rehash] - rehash: 4 完成,table 内容为:

{1: 1} 
{2: 2} 

[DEBUG] [2020-10-11 18:03:46.801] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 3 对应的 tableIndex: 3
[DEBUG] [2020-10-11 18:03:46.801] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:

{1: 1} 
{2: 2} 
{3: 2} 

比较符合我们的预期。

缩容的实现

实现思路

实现了扩容之后,实际上缩容的实现就变得非常简单。

二者是非常类似的,只是触发的时机和条件做了一点调整。

java 实现

属性定义

我们定义一下常量,便于后面判断使用

/**
 * 缩容
 *
 * @since 0.0.4
 */
private final double reduceFactor = 0.5;

/**
 * 最小大小
 *
 * @since 0.0.4
 */
private static final int MIN_CAPACITY = 2;

我们指定缩容的 factor=0.5,最小的容器大小为2。毕竟再小就没有意义了。

是否满足缩容条件

/**
 * 是否需要缩容
 * (1)元素比例为一半
 * (2)当前容量大于2
 *
 * @return 是否
 * @since 0.0.4
 */
private boolean isNeedReduce() {
    // 验证比例
    double rate = size * 1.0 / capacity * 1.0;
    return rate <= reduceFactor && (this.capacity > MIN_CAPACITY);
}

remove 的实现

删除的逻辑相对比较简单,我们删除之后判断是否需要缩容即可。

rehash 的方法和扩容时一样的,容器大小变为原来的而一半。

我了便于读者理解,我们输出了对应的 debug 日志信息。

/**
 * 删除一个元素
 * (1)元素不存在,直接返回 null
 * (2)元素存在,移除元素,判断是否需要缩容
 *
 * @param key 元素信息
 * @return 结果
 * @since 0.0.3
 */
@Override
public V remove(Object key) {
    // 计算 index 值
    int hash = HashUtil.hash(key);
    int index = HashUtil.indexFor(hash, this.capacity);
    // 遍历
    List<Entry<K, V>> entryList = this.table.get(index);

    for (Entry<K, V> entry : entryList) {
        // 二者的 key 都为 null,或者二者的 key equals()
        final K entryKey = entry.getKey();
        if (ObjectUtil.isNull(key, entryKey)
                || key.equals(entryKey)) {
            if (isNeedReduce()) {
                if (debugMode) {
                    log.debug("触发缩容操作");
                }
                this.capacity = this.capacity / 2;
                rehash(this.capacity);
            }

            if (debugMode) {
                log.debug("移除元素,table 信息为:");
                printTable();
            }
        }
    }
    return null;
}

验证

测试代码

MyHashMap<String, String> map = new MyHashMap<>(2, true);
map.put("1", "1");
map.put("2", "2");
map.put("3", "3");

map.remove("3");
map.remove("2");
map.remove("1");

日志

[DEBUG] [2020-10-12 20:57:53.871] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 1 对应的 tableIndex: 1
[DEBUG] [2020-10-12 20:57:53.871] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:
{1: 1} 
[DEBUG] [2020-10-12 20:57:53.872] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 2 对应的 tableIndex: 0
[DEBUG] [2020-10-12 20:57:53.872] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:
{2: 2} 
{1: 1} 
[DEBUG] [2020-10-12 20:57:53.874] [main] [c.g.h.d.s.c.u.m.MyHashMap.rehash] - rehash: 4 完成,table 内容为:
{1: 1} 
{2: 2} 
[DEBUG] [2020-10-12 20:57:53.874] [main] [c.g.h.d.s.c.u.m.MyHashMap.createNewEntry] - Key: 3 对应的 tableIndex: 3
[DEBUG] [2020-10-12 20:57:53.875] [main] [c.g.h.d.s.c.u.m.MyHashMap.put] - put 为新增元素,table 信息为:
{1: 1} 
{2: 2} 
{3: 3} 
[DEBUG] [2020-10-12 20:57:53.875] [main] [c.g.h.d.s.c.u.m.MyHashMap.rehash] - rehash: 2 完成,table 内容为:
{2: 2} 
{1: 1} 

[DEBUG] [2020-10-12 20:57:53.876] [main] [c.g.h.d.s.c.u.m.MyHashMap.remove] - 缩容完成
[DEBUG] [2020-10-12 20:57:53.876] [main] [c.g.h.d.s.c.u.m.MyHashMap.remove] - 删除后的 table 信息
{2: 2} 
{1: 1} 
[DEBUG] [2020-10-12 20:57:53.877] [main] [c.g.h.d.s.c.u.m.MyHashMap.remove] - 删除后的 table 信息
{1: 1} 
[DEBUG] [2020-10-12 20:57:53.877] [main] [c.g.h.d.s.c.u.m.MyHashMap.remove] - 删除后的 table 信息

可见当元素为2个的时候,触发了一次缩容。

后续在变小就不会再触发了,因为我们设置的最小容量为2。

小结

本节我们自己动手实现了一个简易版本 HashMap。

麻雀虽小五脏俱全,我们已经搞清楚了扩容、缩容的实现机制,为我们后面实现渐进式 rehash 做好铺垫。

相对而言,渐进式 rehash 要比这个麻烦的多。

本来想全部放在一起的,考虑到篇幅问题,拆分为两节,避免读者朋友读的太累。

本节的内容较长,书写也花费了大量的时间,一切都是值得的。希望你喜欢。

下一节我们将共同来实现一个渐进式 rehash 的 HashMap,感兴趣的伙伴可以关注一波,即使获取最新动态~

开源地址:https://github.com/houbb/cache

觉得本文对你有帮助的话,欢迎点赞评论收藏关注一波。你的鼓励,是我最大的动力~

不知道你有哪些收获呢?或者有其他更多的想法,欢迎留言区和我一起讨论,期待与你的思考相遇。