集合框架 (第 07 篇) 源码分析:jdk1.7版 HashMap

一、集合框架源码分析

原文持续更新链接: https://github.com/about-cloud/JavaCore

正文


本文是基于 jdk1.7.0_79 分析

一、继承关系

HashMap1.7vExtend

二、数据结构

jdk1.7 HashMap的底层数据结构:数组 + 单向线性链表

HashMap的元素就是在 Map.Entry 的基础上实现的Entry项。

上一节分析了 哈希冲突 和 解决哈希冲突的算法,HashMap 就是基于 链表法 来解决哈希冲突的。

jdk1.7 HashMap图片

重要属性(请记住这些常量和变量的含义,下面源码分析会用到)

/** 默认初始容量 16 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 既 16
/** 最大容量 10.7亿+(这里也用到效率高的位运算) */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 默认负载因子 0.75 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 当 table 未填充的时候,共享这个空table */
static final Entry<?,?>[] EMPTY_TABLE = {};
/** (这个表又称为基本表)该 table 根据需要而调整大小。长度必须始终是2的次幂。 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/** map 中 存储 key-value 映射的数量 */
transient int size;
/** 下次调整大小时的阈值(容量 * 负载因子) */
int threshold;
/** 哈希表的负载因子 */
final float loadFactor;
/** 此 HashMap 修改的次数 */
transient int modCount;
/** 默认容量的最大值:Integer.MAX_VALUE */
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

HashMap 重点元素 项Entry<K, V>:之前文章已讲解了 Map.Entry 接口,下面就来分析一下 jdk1.7 HashMap实现Map.EntryEntry<K, V>

static class Entry<K,V> implements Map.Entry<K,V> {
    // final 修饰的 key,防止被重复赋值
    final K key;
    // 可被重复设置值的value
    V value;
    // 此项的下一项(用于链表。并没有类四的 Entry<K, V> prev,说名是单链表)
    Entry<K,V> next;
    int hash;

    /**
     * 构造方法用于注入 entry项 的属性值(或引用)
     * 参数从左至右依次是:key的哈希码,key,value,指向的下一个entry
     */
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    // getter & toString 方法
    public final K getKey() {
        return key;
    }
    public final V getValue() {
        return value;
    }
    public final String toString() {
        return getKey() + "=" + getValue();
    }
    
    // 设置节点的新值value
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    
    // 比较节点的方法
    public final boolean equals(Object o) {
        // 检查类型
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        // 当前项的key
        Object k1 = getKey();
        // 被比较对象的key
        Object k2 = e.getKey();
        // 这里说明一下 Object,它的 equals方法很简单,就是用 == 来做判断的
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }
    
    // 返回节点的哈希码
    public final int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    /**
     * 当entry被访问时,都会调用此方法
     * 这里只不过是一个空方法
     */
    void recordAccess(HashMap<K,V> m) {
    }

    /**
     * 每当entry项从表格中删除时,都会调用这个空方法
     */
    void recordRemoval(HashMap<K,V> m) {
    }
}

三、添加元素项

public V put(K key, V value) {
    // 判断是否为空表
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        // 如果key为null的情况下,将将键值对放在table[0]处
        // 如果table[0]已存在元素,则将替换value
        return putForNullKey(value);
    // key的哈希值
    int hash = hash(key);
    // 可以的哈希码对表的长度模运算,计算并得到哈希槽的位置
    int i = indexFor(hash, table.length);
    // 对哈希桶(链表)进行遍历,靠指针地址移动
    // 查找是否包含key的项,如果包含就将value换掉
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 该元素的哈希码与新增项的key的哈希码项相等,并且 key 也相同
        // 那么就会替换 value(因为key具有唯一性,相同的key要替换value)
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
           // 被替换的旧值
            V oldValue = e.value;
            // 选用新的value
            e.value = value;
            // 调用上面的空方法
            e.recordAccess(this);
            // 返回旧值
            return oldValue;
        }
    }

    // 如果上面for循环没有查找到key的存在(或者说没有找到相同的key),那么就新添加一项
    modCount++; // modCount加1
    // 添加entry项
    addEntry(hash, key, value, i);
    return null;
}

/**
 * 将具有指定key、value、key的哈希码、桶号(也就是哈希槽的位置)的条目(元素)(项)
 * 添加到指定的桶(哈希桶)。
 * 此方法会在适当的情况下调整桶的大小
 * 子类也可以重写此方法来改变put添加的行为
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果已添加元素项的实际大小达到了HashMap所承载的阈值,并且哈希槽的位置不为空
    // 那么就进行扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 扩容后的大小是2倍于原基本表的长度
        resize(2 * table.length);
        // 因为基本表已经扩容,那么对key重新计算哈希值
        // 如果 key 不为 null 那么计算key的哈希值,否则哈希值直接记为0
        hash = (null != key) ? hash(key) : 0;
        // 根据哈希码和基本表(数组)长度计算桶号
        // indexFor 方法并没有使用模运行,而是使用高性能的逻辑与&运算
        bucketIndex = indexFor(hash, table.length);
    }
    
    createEntry(hash, key, value, bucketIndex);
}

创建元素项的方法:

/**
 * 此方法与 addEntry 不同,只有在创建 entry项的时候使用此方法
 * 作为构建(或伪构建) Map 的一部分。"伪构建" 是指 克隆、反序列。
 * 使用此方法是不必担心扩容问题。
 * 子类可以重新此方法改变方法的功能,比如将单向链表改成双向链表等等。
 */
void createEntry(int hash, K key, V value, int bucketIndex) {
    // 原位置的元素项要下沉,新的元素放在哈希槽(桶顶)的位置
    Entry<K,V> e = table[bucketIndex];
    // 构造新的元素项放在哈西槽(桶顶),同步把原有的元素项链入其后
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    // 实际大小加1
    size++;
}

链表的时候讲要添加的元素项放到桶顶,那么先进的元素项位于链表的尾部,后进的元素项位于链表的头部。(这只是本版本 HashMap 的实现方式)

HashMap解决哈希冲突

扩容机制

/**
 * 扩容方法就是给 map 换一个更大容量的新数组。  
 * 前提前提条件是已添加元素项的实际大小达到了HashMap所承载的阈值。
 *
 * 如果当前容量达到最大容量, 此方法也能给map扩容。但是可以设置阈值为Integer类型的最大值。
 *
 * @param newCapacity 新的容量, 必须是2的次幂;
 *        必须大于当前容量,除非当前容量达到了最大值。
 *        (在这种情况下值是无关紧要的)
 */
void resize(int newCapacity) {
    // 当前table数组
    Entry[] oldTable = table;
    // 当前table数组长度
    int oldCapacity = oldTable.length;
    // 如果当前数组达到了最大容量(1<<30),那么赋值阈值为Integer的最大值,并直接返回🔙
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    // new 新的数组
    Entry[] newTable = new Entry[newCapacity];
    // 将当前的数组上索引元素项转移到新的数组上
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 当前数组指向新数组,完成扩容
    table = newTable;
    // 计算阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

/**
 * 将当前的数组上索引元素项转移到新的数组上
 */
void transfer(Entry[] newTable, boolean rehash) {
    // 新数组的长度(容量)
    int newCapacity = newTable.length;
    // 首先横向遍历数组
    for (Entry<K,V> e : table) {
        // 然后纵向遍历链表
        while(null != e) {
            // 链表中提前获取下一个元素项(节点)
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新计算存放元素
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

上面已经看出每次扩容时都伴随着所有元素项的重新 选址 存放,这不仅大大牺牲性能,而且耗时。所以在使用 HashMap 一定要评估存放元素项的数量,指定map的大小。

四、删除元素项

删除元素项的的思路基本和添加操作相似,只不过一个是添加,一个是删除。先根据 key 计算 哈希槽(桶的位置),然后循环链表比较判断,这里参考:telescope:之前关于 LinkedList 文章的删除节点操作吧。

其他问题

为什么 HashMap 的容量必须是2的次幂呢?

key的哈希码对 基本表 做 逻辑与 运算 h&(length-1),来确定此元素项的数组上的位置。 原因就出在 二进制与& 操作上了。

与& 运算规则:0&0=0; 0&1=0; 1&0=0; 1&1=1;

(指数形式)length (十进制)length (十进制)length - 1 (二进制)length - 1
1^2 2 1 1
2^2 4 3 11
3^2 8 7 111
4^2 16 15 1111
...^2 ... ... ...

这种情况下,length - 1 二进制的 最右位 永远是 1。这样 0 & 1 = 01 & 1 = 1 的结果既有 0 又有1。对于 哈希槽 的二进制最右位为 1 (十进制的奇数)的位置就有可能被填充,而不至于浪费存储空间。

如果 HashMap 的容量不是 2 的次幂,比如 容量length为 19length - 1 的二进制为 10010,任何常数与之作 与& 运算,二进制最右位都是 0,那么只能存放 (十进制)尾数为 偶数 的数组位置,这样会大大浪费空间。

原文持续更新链接: https://github.com/about-cloud/JavaCore

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容