剖析 HashMap

Map接口

基本概念

Map有键和值的概念,一个键映射到一个值,Map按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。使用Map可以方便地处理需要根据键访问对象的场景,比如:

  • 一个词典应用,键可以为单词,值可以为单词信息类,包括含义、发音、例句等。
  • 统计和记录一本书中所有单词出现的次数,可以以单词为键,出现次数为值。
  • 管理配置文件中的配置项,配置项是典型的键值对。
  • 根据身份证号查询人员信息,身份证号为键,人员信息为值。

数组、ArrayList、LinkedList可以视为一种特殊的Map,键为索引,值为对象。

接口定义

Map接口的定义为:

public interface Map<K,V> {
    V put(K key, V value);//保存键值对
    V get(Object key);
    V remove(Object key);
    int size();
    boolean isEmpty();
    boolean containsKey(Object key);
    boolean containsValue(Object value);
    void putAll(Map<? extends K, ? extends V> m);//批量保存,保存参数m中的所有键值对到当前Map
    void clear();
    Set<K> keySet();//获取Map中键的集合,Set没有重复的元素集合
    Collection<V> values();//获取Map中所有值的集合,可以重复
    Set<Map.Entry<K, V>> entrySet();//获取Map中的所有键值对
    interface Entry<K,V> {
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
    }
    boolean equals(Object o);
    int hashCode();
}

HashMap

构造方法

除了默认构造方法,HashMap还有如下构造方法:

public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)
public HashMap(Map<? extends K, ? extends V> m)

实现原理

内部组成

HashMap内部有如下几个主要的实例变量:

//被transient关键字修饰的变量不会被序列化
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;//一个Entry类型的数组,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对,Entry是一个内部类
transient int size;//实际键值对的个数
int threshold;//阈值,当键值对个数size大于等于threshold时考虑进行扩展
final float loadFactor;//负载因子,表示整体上table被占用的程度,是一个浮点数,默认为0.75,可以通过构造方法进行修改
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next//指向下一个Entry节点
    int hash;//key的哈希值,直接存储hash值是为了在比较的时候加快计算

    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
} 

table的初始值为EMPTY_TABLE,是一个空表,具体定义为:

static final Entry<?,?>[] EMPTY_TABLE = {};

当添加键值对后,table就不是空表了,它会随着键值对的添加进行扩展,扩展的策略类似于ArrayList,添加第一个元素时,默认分配的大小为16,不过,并不是size大于16时再进行扩展,下次什么时候扩展与threshold有关。一般而言,threshold等于table.length乘以loadFactor,比如,如果table.length为16,loadFactor为0.75,则threshold为12。

默认构造方法

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);//(16,0.75)
}

public HashMap(int initialCapacity, float loadFactor) {
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
}

保存键值对

下面,我们来看HashMap是如何把一个键值对保存起来的,代码为:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);//计算key的哈希值
    int i = indexFor(hash, table.length);//计算应该将这个键值对放到table的哪个位置
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {//保存位置i,table[i]指向一个单向链表,在这个链表中逐个查找是否已经有这个键了
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;//如果找到了已经存储的key,直接修改后返回
        }
    }

    modCount++;//记录修改次数,方便在迭代中检测结构性变化
    addEntry(hash, key, value, i);//未存储过,则新增
    return null;
}

//如果是第一次保存,首先会调用inflateTable()方法给table分配实际的空间
private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    //默认情况下,capacity的值为16,threshold会变为12,table会分配一个长度为16的Entry数组
}

static int indexFor(int h, int length) {
    return h & (length-1);
    //HashMap中,length为2的幂次方,h&(length-1)等同于求模运算:h%length
}

//在给定的位置添加一条
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);//空间不够,要扩展
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);//如果空间是够的,不需要resize,调用createEntry添加一条数据
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);//这里实际上是相当于添加到了链表头
    size++;
}

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//将原来的键值对移植过来
    table = newTable;//更新内部的table变量
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

//遍历原来的每个键值对,计算新位置,并保存到新位置
void transfer(Entry[] newTable, boolean rehash) {//rehash一般为false
    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;
        }
    }
}

以上,就是保存键值对的主要代码,简单总结一下,基本步骤为:

  1. 计算键的哈希值
  2. 根据哈希值得到保存位置(取模)
  3. 插到对应位置的链表头部或更新已有值
  4. 根据需要扩展table大小

图示说明

以上描述可能比较抽象,我们通过一个例子,用图示的方式说明,代码是:

Map<String,Integer> countMap = new HashMap<>();
countMap.put("hello", 1);
countMap.put("world", 3);

countMap.put("position", 4);

在通过new HashMap()创建一个对象后,内存中的图示结构大概是:

image.png

接下来执行countMap.put("hello", 1);

"hello"的hash值为96207088,模16的结果为0,所以插入table[0]指向的链表头部,内存结构会变为:

image.png

"world"的hash值为111207038,模16结果为15,所以保存完"world"后,内存结构会变为:

image.png

"position"的hash值为771782464,模16结果也为0,table[0]已经有节点了,新节点会插到链表头部,内存结构会变为:

image.png

实现原理小结

以上就是HashMap的基本实现原理,内部有一个数组table,每个元素table[i]指向一个单向链表,根据键存取值,用键算出hash,取模得到数组中的索引位置buketIndex,然后操作table[buketIndex]指向的单向链表。

存取的时候依据键的hash值,只在对应的链表中操作,不会访问别的链表,在对应链表操作时也是先比较hash值,相同的话才用equals方法比较,这就要求,相同的对象其hashCode()返回值必须相同,如果键是自定义的类,就特别需要注意这一点。这也是hashCode和equals方法的一个关键约束

HashMap特点分析

HashMap实现了Map接口,内部使用数组链表和哈希的方式进行实现,这决定了它有如下特点:

  • 根据键保存和获取值的效率都很高,为O(1),每个单向链表往往只有一个或少数几个节点,根据hash值就可以直接快速定位。
  • HashMap中的键值对没有顺序,因为hash值是随机的。

如果经常需要根据键存取值,而且不要求顺序,那HashMap就是理想的选择。

不过HashMap没有顺序,如果要保持添加的顺序,可以使用HashMap的一个子类LinkedHashMap,Map还有一个重要的实现类TreeMap,它可以排序。

ref:Java编程的逻辑 (40) - 剖析HashMap

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

推荐阅读更多精彩内容