HashMap详解

更正一下:之前对于hashmap初始容量这一块的知识,我在理解上出了问题,初始容量应该指的是哈希表中能存放的元素的数量,而并非是hashmap实例创建时哈希表中数组的长度。


目录

1、HashMap简介

1.1、HashMap的继承关系

1.2、HashMap的数据结构

2、HashMap源码分析(使用的是JDK1.7版本的源码)

2.1、HashMap的四种构造函数

2.2、HashMap新增元素的过程

2.3、HashMap扩容

2.4、HashMap获取元素

3、HashMap存在的问题

3.1、数据丢失

3.2、死链问题


1、HashMap简介

1.1、HashMap的继承关系

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable

这里有一个疑问,HashMap继承自AbstractMap,而AbstarctMap已经实现过Map接口,那为什么HashMap还要实现Map接口呢???Stackoverflow上面有一个答案是这么说的stackoverflow链接,大意就是说Josh Bloch(Java集合框架的创造者)自己也认为这是一个错误,并且会在未来的版本中修复它。

1.2、HashMap的数据结构

HashMap是由哈希表实现的,哈希表又是由链表和数组构成的,先来了解下哈希类集合的三个基本存储概念,如下图表所示:

名称说明
table存储所有节点数据的数组
slot哈希槽,即table[i]这个位置
bucket哈希桶,即table[i]上所有元素形成的表或树的集合

  • table:table是一个数组。(黄色部分的数组长度就是table.length)
  • slot:哈希槽是一个位置标识,对应于数组的下标。
  • bucket:虚线框内的哈希桶是包含头结点在内,在哈希槽上形成的链表或树上(JDK8中在HashMap中加入了红黑树,当链表的长度大于8的时候,会将链表转化为红黑树的数据结构进行存储)的所有元素的集合。(所有哈希桶的元素总和即为HashMap的size)

HashMap的实例有两个参数影响它的性能:

  • 初始容量:初始容量就是HashMap实例被创建时数组的初始长度,初始容量为16。
  • 负载因子:是哈希表在其容量自动增加以前能够达到多满的一种尺度,默认的负载因子是0.75。(设置成0.75是在时间和空间成本上的一种折中,负载因子过高虽然减少空间开销,但同时增加了查询成本,从get和put操作中都反映了这一点)

每当HashMap中保存的元素数量达到threshold(HashMap的阀值,用于判断是否需要扩容)=16 * 0.75的时候,就会自动执行rehash方法,让数组的长度翻倍,数组的长度翻倍以后,会将原数组中的元素重新计算下标并且添加到新数组中,这个扩容的过程是一个非常“消耗”的过程,所以如果我们在定义HashMap的时候,就清楚地知道我们有多少个元素要保存进去的话,就能极大程度的提高存储效率。

在每个哈希桶中都是用一个链表来存储数据元素,链表中的节点Entry就是真正存放键值对<K,V>的地方,请看下面JDK1.7中Entry的源码(在JDK1.8中,Entry改成了Node,但是类的构造大同小异):

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

    /**
     * Creates new entry.
     */
    Entry(int h, K k, V v, Entry<K,V> 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 Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }

    /**
     * This method is invoked whenever the value in an entry is
     * overwritten by an invocation of put(k,v) for a key k that's already
     * in the HashMap.
     */
    void recordAccess(HashMap<K,V> m) {
    }

    /**
     * This method is invoked whenever the entry is
     * removed from the table.
     */
    void recordRemoval(HashMap<K,V> m) {
    }
}

2、HashMap源码分析(使用的是JDK1.7版本的源码)

2.1、HashMap的四种构造函数

//默认构造函数
HashMap(){
   
}

//指定容量大小的构造函数
HashMap(int capacity){

}

//指定容量大小和负载因子的构造函数
HashMap(int capacity, float LoadFactor){

}

//包含子Map的构造函数
HashMap(Map<? extends K, ? extends V>map){

}

2.2、HashMap新增元素的过程

public V put(K key, V value){
        //通过键值得到hash值
        int hash = hash(key);

        //通过indexFor()方法来计算Entry应该保存在数组中哪个位置,i就是数组中保存位置的下标
        int i = indexFor(hash, table.length);

        //此循环通过hashCode返回值找到对应的数组下标位置
        //如果equals结果为真,则覆盖原值,如果窦唯false,则添加元素
        for(Entry<K, V> e = table[i]; e != null; e = e.next){
            Object k;
            //如果Key的hash是相同的,那么再进行如下判断
            //Key是同一个对象或者key.equals(e.key)返回为真,则覆盖原来的Value
            if (e.hash == hash && ((k == e.key) == key || key.equals(k))){
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }

        //还没添加元素就进行modCount++,将为后续留下很多隐患
        //modCount记录了某个list改变大小的次数,如果modCount改变的不符合预期,就抛出异常
        //modCount与fail-fast机制相关(快速失败原则是jdk在面对迭代遍历的时候为了避免不确定性而采取的一种措施)
        modCount++;
        //添加元素,注意最后一个参数i是table数组的下标
        addEntry(hash, key, value, i);
        return null;
    }

    void addEntry(int hash, K key, V vlaue, int bucketIndex){
        //如果元素的个数达到threshold的扩容阀值且数组下标位置(我认为应该说的是哈希槽的位置)已经存在元素,则进行扩容
        if ((size >= threshold) && (null != table[bucketIndex])){
            //扩容两倍,size是实际存放元素的个数,而length是数组的容量大小,即数组的长度
            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){
        //不管原来的数组对应的下标元素是否为null,都作为Entry的bucketIndex的next值
        Entry<K, V> e = table[bucketIndex];                                   //第一处
        //即使原来是链表,也把整条链都挂在新插入的节点上
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

总结一下,新增元素的过程大致分为以下几个步骤:

  1. 计算Key的hash值,并通过indexFor(hash,table.length)方法(是将hash值与数组长度的二进制数进行位运算)确定元素在数组中保存位置的下标 i;
  2. 在table[i]的这个哈希桶中遍历其中已经存在的元素,如果有Key相同的元素,则用新的value替换掉旧的value;
  3. 如果没有,则增加一个新的Entry元素,增加新元素时,如果元素的数量已经到了阀值且哈希槽的位置不为空,就进行扩容,扩容后将进行数据迁移;
  4. 在链表的头部添加新元素,size+1。

注意:如果两个线程同时执行到代码中第一处时,那么一个线程的赋值就会被另一个覆盖掉,这是造成对象丢失的原因之一。

2.3、HashMap扩容

先来熟悉下与扩容有关的几个概念:

名称说明
lengthtable数组的长度
size成功通过put方法添加到HashMap中的所有元素的个数
hashCodeObject.hashCode()返回的int值,尽可能地离散均匀分布
hashObject.hashCode与当前集合的table.length进行位运算的结果,以确定哈希槽的位置

理想的哈希集合对象的存放应该符合以下条件:

  • 只要对象不一样,hashCode就不一样;
  • 只要hashCode不一样,得到的hashCode与hashSeed位运算的hash就不一样;
  • 只要hash不一样,存放在数组上的slot就不一样。

引用码出高效中给的一个例子:公司开个圆桌会议,有12把椅子,必须按照某种规则,把12个位置坐满。如果hashCode按照公司职务来计算,但公司只设置了P1-P8八个等级,则百分之百会有碰撞;如果hashCode按工号来计算,虽然hashCode是唯一的,但是以员工号与12进行取模后,工号为1和工号为13的员工恰好会被分配到同一把椅子上。哈希碰撞的概率取决于hashCode计算方式和空间容量大小

如果公司给12个员工安排100把椅子行不行呢?这当然可以解决哈希碰撞的问题,但是同时是不是又造成了极大的空间资源浪费呢?那么到底准备多少把椅子合适呢?负载因子就是用来权衡利用率与分配空间的系数。默认的负载因子是0.75,即12个人的会议,相当于需要12 / 0.75 = 16把椅子,mod16比mod12的冲突概率要小一些,也不会像mod100那样浪费资源。随着会议范围变大,参加会议的人数越来越多,当人数  > (椅子数量 x 负载因子 )的时候就要进行扩容。在HashMap中,每次进行resize操作都会将容量扩充为原来的2倍。

下面是HashMap中非常重要的resize()和非常重要的transfer()数据迁移源码:

void resize(int newCapacity){
    //定义一个新的Entry数组newTable
    Entry[] newTable = new Entry[newCapacity];
    //JDK8中移除了hashSeed计算,因为计算时会调用Random.nextInt(),存在性能问题
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //在此步骤完成之前,旧表上依然可以进行元素的增加操作,这就是对象丢失的原因之一
    table = newTable;
    //注意.MAX是 1<<30,如果1<<31则成Integer的最小值:-2147483648
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

//从旧表迁移数据到新表,寥寥几行,但是极为重要
void transfer(Entry[] newTable, boolean rehash){
    //外部参数传入时,指定新表的大小为:2 * oldTable.length
    int newCapacity = newTable.length;
    //使用foreach方式遍历整个数组下标
    for (Entry<K,V> e : table){
        //如果此slot上存在元素,则进行遍历,直到e==null,退出循环
        while(null != e){
            Entry<K, V> next = e.next;

            //当前元素总是直接放在数组下标的slot上,而不是放在链表的最后
            if(rehash){
                e.hash == null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);

            //把原来slot上的元素作为当前元素的下一个
            e.next = newTable[i];
            //新迁移过来的节点直接放置在slot位置上
            newTable[i] = e;

            //链表继续向下遍历
            e = next;
        }
    }
}

 总结一下,扩容的过程大致分为以下几个步骤:

  1. 定义一个新的Entry数组newTable,调用数据迁移方法transfer()进行数据迁移;
  2. 遍历整个table数组下标,如果table[i]的哈希槽slot上存在元素则对该哈希桶进行遍历,直到e == null退出循环;
  3. 遍历table[i]的哈希桶时,当前元素总是要放在数组下表的哈希槽slot上。最先遍历的元素最后会被放在链表的尾部,而最后遍历的元素最后会被当做链表的头结点,放置在哈希槽slot上;
  4. 等遍历完成以后,当所有的元素都被复制迁移到newTable上,再令table = newTable;
  5. 最后更新阀值threshold。

JDK7的扩容条件是:

(size >= threshold) && (null != table[bucketIndex])

即达到阀值,并且当前需要存放元素的slot上不为空。从代码上看,JDK7是先扩容再进行新增元素操作,而JDK8是增加元素之后再扩容。

2.4、HashMap获取元素

    //通过key寻找对应的Entry<K,V>
    public V get(Object key) {
        //如果key为null,则返回getForNullKey()的返回值
        if (key == null)
            return getForNullKey();
        //key不为null的时候,调用getEntry()方法查找对应的Entry对象
        Entry<K,V> entry = getEntry(key);
        //查找出来的Entry对象为空返回null,不为空则返回Entry对象的value
        return null == entry ? null : entry.getValue();
    }
    //遍历table[0]上的所有元素,如果有元素的key为null,则返回该元素的value
    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

    final Entry<K,V> getEntry(Object key) {
        //计算key的hash值
        int hash = (key == null) ? 0 : hash(key);
        //通过key的hash值找到对应的存储位置table[i],并遍历table[i]
        for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {
            Object k;
            //当hash值相等且key.equals(e.key)为true,则返回该Entry对象
            if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

3、HashMap存在的问题

HashMap不是线程安全的,码出高效中,作者的原话是:“除局部方法或绝对线程安全的情形外,优先使用ConcurrentHashMap。两者的性能相差无几,但后者解决了高并发下的线程安全问题。”

3.1、数据丢失

HashMap在高并发场景中,新增对象丢失的原因如下:

  • 并发赋值时被覆盖:在createEntry()方法中,新添加的元素直接放在slot槽上,可以使新添加的元素在下次提取时更快地被访问到。如果两个线程同时执行到第一处时,那么一个线程的赋值就会被另一个覆盖掉,这是对象丢失的原因之一。
    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++;
    }

     

  • 已遍历区间新增元素会丢失:tranfer()数据迁移方法在数组非常大时会非常消耗资源,当线程迁移过程中,其他线程新增的元素有可能会落在已经遍历过的哈希槽上,在遍历完成以后,table数组引用指向了newTable,这时新增元素就会丢失,被无情地垃圾回收。(代码可参见扩容那一段的源码)

  • “新表“”被覆盖:如果resize完成,执行了table = newTable,则后续的元素就可以在新表上进行插入操作。但是如果多个线程同时执行resize,每个线程又都会new Entry[newCapacity],这时线程内的局部数组对象,线程之间是不可见的。前已完成之后,resize的线程会赋值给table线程共享变量,从而覆盖其他线程的操作,因此在“新表”中进行插入操作的对象会被无情地丢弃。(代码可参见扩容那一段的源码)

  • 迁移丢失。若数据迁移过程中,有并发时,next被提前置成null

3.2、死链问题

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值