资讯专栏INFORMATION COLUMN

Java HashMap源码分析

flyer_dev / 1045人阅读

摘要:当哈希表中键值对的数量超过当前容量和装载因子的乘积后,哈希表重新散列也就是内部的数据结构重建了,并且哈希表的容量大约变为原来的两倍。下面的是根据哈希值得到元素在哈希表中的下标。一般在哈希表中是用哈希值对表长取模得到。

简介

HashMapMap接口下比较常用的一个类,我们都知道它存储的是键值对(key-value),可以高效地插入和删除。这篇文章分析一下它内部的实现,由于源码比较长,只看一些重要的。

存储结构

首先,HashMap是基于哈希表存储的。它内部有一个数组,当元素要存储的时候,先计算其key的哈希值,根据哈希值找到元素在数组中对应的下标。如果这个位置没有元素,就直接把当前元素放进去,如果有元素了(这里记为A),就把当前元素链接到元素A的前面,然后把当前元素放入数组中。所以在Hashmap中,数组其实保存的是链表的首节点。下面是百度百科的一张图:

如上图,每个元素是一个Entry对象,在其中保存了元素的keyvalue,还有一个指针可用于指向下一个对象。所有哈希值相同的key(也就是冲突了)用链表把它们串起来,这是拉链法。

内部变量
//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//哈希表
transient Entry[] table;
//键值对的数量
transient int size;
//扩容的阈值
int threshold;
//哈希数组的装载因子
final float loadFactor;

在上面的变量中,capacity是指哈希表的长度,也就是table的大小,默认为16。装载因子loadFactor是哈希表的“装满程度”,JDK的文档是这样说的:

The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.

大体意思是:装载因子是哈希表在扩容之前能装多满的度量值。当哈希表中“键值对”的数量超过当前容量(capacity)和装载因子的乘积后,哈希表重新散列(也就是内部的数据结构重建了),并且哈希表的容量大约变为原来的两倍。

从上面的变量定义可以看出,默认的装载因子DEFAULT_LOAD_FACTOR 0.75。这个值越大,空间利用率越高,但查询速度(包括getput)操作会变慢。明白了装载因子之后,threshold也就能理解了,它其实等于容量*装载因子

构造器
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)  //计算出大于指定容量的最小的2的幂
        capacity <<= 1;

    this.loadFactor = loadFactor;
    threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];   //给哈希表分配空间
    useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    init();
}

构造器有好几个,最终都会调用上面的这个。它接受两个参数,一个是初始容量,还有一个是装载因子。刚开始先判断值合不合法,有问题的话会抛出异常。重要的是下面的capacity的计算,它的逻辑是计算出大于initialCapacity的最小的2的幂。其实目的就是要让容量能大于等于指定的初始容量,但这个值还得是2的指数倍,这是一个关键的问题。这么做的原因主要是为了哈希值的映射。先来看一下HashMap中有关哈希的方法:

final int hash(Object k) {
    int h = 0;
    if (useAltHashing) {
        if (k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h = hashSeed;
    }
    h ^= k.hashCode();
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
    return h & (length-1);
}

hash()方法重新计算了key的哈希值,用了比较复杂的位运算,具体逻辑我也不清楚,反正肯定是比较好的方法,能减少冲突什么的。

下面的indexFor()是根据哈希值得到元素在哈希表中的下标。一般在哈希表中是用哈希值对表长取模得到。当length(也就是capacity)为2的幂时,h & (length-1)是同样的效果。并且,2的幂一定是偶数,那么减1之后就是奇数,二进制的最后一位一定是1。那么h & (length-1)的最后一位可能是1,也可能是0,可以均匀地散列。如果length是奇数,那么length-1就是偶数,最后一位是0。此时h & (length-1)的最后一位只可能是0,所有得到的下标都是偶数,那么哈希表就浪费了一半的空间。所以HashMap中的容量(capacity)一定要是2的幂。可以看到默认的DEFAULT_INITIAL_CAPACITY=16MAXIMUM_CAPACITY=1<<30都是这样的。

Entry对象

HashMap中的键值对被封装成Entry对象,这是HashMap中的一个内部类,看一下它的实现:

static class Entry implements Map.Entry {
    final K key;
    V value;
    Entry next;
    int hash;

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

    public final K getKey() {
        return key;
    }

    public final V getValue() {
        return 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;
        Object k1 = getKey();
        Object k2 = e.getKey();
        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 (key==null   ? 0 : key.hashCode()) ^
               (value==null ? 0 : value.hashCode());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }
    void recordAccess(HashMap m) {
    }
    void recordRemoval(HashMap m) {
    }
}

这个类的实现还是简洁易懂的。提供了getKey()getValue()等方法供调用,判断相等是要求keyvalue均相等。

put操作

put了才能get,所以先看一下put()方法:

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry e = table[i]; e != null; e = e.next) {
        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;
        }
    }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

在这个方法中,先判断key是否为null,是的话调用putForNullKey()方法,这说明HashMap允许keynull(其实value可以)。如果不是null,计算哈希值并且得到在表中的下标。然后到对应的链表中查询是否已经存在相同的key,如果已经存在就直接更新值(value)。否则,调用addEntry()方法进行插入。

看一下putForNullKey()方法:

private V putForNullKey(V value) {
    for (Entry e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

可以看到,keynull时直接在下标0处插入,同样是存在就更新值,否则调用addEntry()插入。

下面是addEntry()方法的实现:

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);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

首先判断是否要扩容(扩容会重新计算下标值,并且复制元素),然后计算数组下标,最后在createEntry()中使用头插法插入元素。

get操作
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
    for (Entry e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}
final Entry getEntry(Object key) {
    int hash = (key == null) ? 0 : hash(key);
    for (Entry e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

这个比put()简单一些,同样要判断key是不是null,然后就是链表的遍历查询。

总结

HashMap的基本实现就如上面所分析的,最后做下总结:

HashMap内部用Entry对象保存键值对,基于哈希表存储,用拉链法解决冲突。

HashMap的默认容量大小为16,默认装载因子为0.75。可以指定容量大小,容量最终一定会被设置为2的幂,这是为了均匀地散列。

HashMapkeyvalue都可以是null,当然只能有一个keynullvalue可以有多个。

HashMap的键值对数量超过容量*装载因子时会扩容,扩容后的容量大约是原来的两倍。扩容会重新散列,所以元素的位置可能发生会变化,而且这是一个耗时操作。

HashMap是线程不安全的。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/65356.html

相关文章

  • java源码

    摘要:集合源码解析回归基础,集合源码解析系列,持续更新和源码分析与是两个常用的操作字符串的类。这里我们从源码看下不同状态都是怎么处理的。 Java 集合深入理解:ArrayList 回归基础,Java 集合深入理解系列,持续更新~ JVM 源码分析之 System.currentTimeMillis 及 nanoTime 原理详解 JVM 源码分析之 System.currentTimeMi...

    Freeman 评论0 收藏0
  • hashmap源码分析( 基于java8)

    摘要:源码分析简介的和操作的时间复杂度是常量。可以存键值为,是线程不安全的。数组链表散列的数据结构实现桶,链表的实现桶的实现链表的实现值节点的键节点的值下一个节点链表构造方法方法是线程不安全的判断两个元素是否相等重要属性默认的桶初始容量。 hashmap源码分析 简介 hashmap的get和put操作的时间复杂度是常量。通过调用哈希函数将元素正确的分布到桶中。初始容量(capacity)的...

    Heier 评论0 收藏0
  • HashMap源码分析(一):JDK源码分析系列

    摘要:当一个值中要存储到的时候会根据的值来计算出他的,通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储在源码分析中解释过,但是这样如果链表过长来的话,会把这个链表转换成红黑树来存储。 正文开始 注:JDK版本为1.8 HashMap1.8和1.8之前的源码差别很大 目录 简介 数据结构 类结构 属性 构造方法 增加 删除 修改 总结 1.HashMap简介 H...

    wdzgege 评论0 收藏0
  • Java容器类框架分析(5)HashSet源码分析

    摘要:到此发现,实际上可以拆分成跟指的是,则是指实现了接口,这样看来,的实现其实就比较简单了,下面开始分析源码。 概述 在分析HashSet源码前,先看看HashSet的继承关系 showImg(https://segmentfault.com/img/bVWo4W?w=605&h=425); HashSet继承关系从上图可以看出,HashSet继承自AbstractSet,实现了Set接口...

    wslongchen 评论0 收藏0
  • Java容器】HashMap使用方法及源码分析

    摘要:当哈希表中的键值对的数量超过当前容量与负载因子的乘积时,哈希表将会进行操作,使哈希表的桶数增加一倍左右。只有散列值相同且相同才是所要找的节点。 HashMap容器 1. 简介 HashMap基于散列表实现了Map接口,提供了Map的所有可选操作,HashMap与Hashtable大致相同,区别在于HashMap不支持同步而且HashMap中存储的键值都可以为null。HashMap中不...

    ad6623 评论0 收藏0

发表评论

0条评论

flyer_dev

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<