HashMap

注:主要记录自己学习过程中的心得,以Java8(jdk1.8)为主,另外在比较过程中会标明jdk1.7

一、定义

1.1 综述

HashMap基于Map接口实现,是一个用于存储Key-Value键值对的集合(允许null键和null值,但key不允许重复,故只能有一个键为null),每一个键值对也叫做Entry。HashMap不能保证放入元素的顺序,是无序的。

1.2 继承关系

public class HashMap<k,v>extends AbstractMap<k,v> implements Map<k,v>,Cloneable,Serializable

1.2基本属性

static final int DEFAULT_INITIAL_CAPACITY =1<<4;//默认初始化大小16

static final float DEFAULT_LOAD_FACTOR = 0.75f; //负载因子0.75

static final Entry<?,?>[] EMPTY_TABLE={}; //初始化默认数组

transient int size; //HashMap中元素的数量           

1.3底层实现

  Java8之后:HashMap 由数组+链表+红黑树的形式构成   

   put方法:大致思路为:

            1.对key的hashCode()做hash,再计算index;

            2.如果没碰撞直接放到bucket里;如果碰撞了,以链表的形式存在buckets后 

            3,.如果碰撞导致链表过长(>=TREEIFY_THRESHOLD),就把链表转换成红黑树

            4.如果节点已经存在就替换old value 

            5.数据存放后,判断当前存入的对象个数,如果大于阈值(负载因子*HashMap的容量),则进行扩容。

*hash碰撞:hashCode相同,key不同         

二、hashCode算法与HashMap的hash算法                             

 2.1.hashCode:产生的依据:哈希码并不是完全唯一的,它是一种算法,让同一个类的对象按照自己不同的特征尽量的有不同的哈希码,                                                                                                        

2.2.HashMap中的hash算法(java8):

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

*为什么hash算法要逻辑右移16位,为什么用位异或

原因就在于HashMap是通过tab[(n - 1) & hash]),即数组长度减一 位与 hash值的方式来计算数组下标的,而n的大小一般不会超过2^16(甚至更小) 此时 只有hash值的低16位甚至更低的位置参与位与运算,造成的结果就是冲突加剧,很明显这样不是一个好的散列算法。

但是如果将hashCode的值右移16位,即取int类型的一半,并且使用异或运算,那么hashCode的更多位值参与到运算里,降低了冲突的产生,使结果更加均匀。

至于为什么用位异或而不是位与或者位或,原因是&会使结果偏向0,|使结果偏向1。

hashMap容量为什么建议是2的幂次方

hash算法的目的就是让hash值尽量均匀的分布,再来看hashMap计算index的方法tab[(n - 1) & hash]),当容量为2的幂次方的时候,n-1转换为二进制所有位置都是1,这样进行位与运算时,是0或者是1完全取决于hash值对应位置的数值(位数取决于n-1)。

eg: 当容量为9时,有两个key其hash值分别为

10110010 11110010 11001110 00101011 , 11010101 01000100 00011111 01011101 

且(9-1)转换为二进制为1000

tab[(n - 1) & hash])运算的结果都为1000,因为位与的原因 hash值的低三位完全没起到作用,这背离了设计初衷。

2.3..HashMap中的hash算法(java7):

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        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);
    }

可以看到Java8与7中的hash算法都用到了hashCode值,但对其处理过程却完全不同,Java8的hash算法已经说的很清楚了,在这来说说Java7的hash算法,可以看到在下图中做了好几次逻辑右移和异或,目的是明确的:就是尽量让每一位都参与运算,让相近的数最后通过hash能分散开并减少碰撞(但是为什么要这么做,为什么选择20,12,7,4,查阅了很多资料,最终还是没得到一个特别有说服力的答案,感兴趣的可以看看https://stackoverflow.com/questions/9335169/understanding-strange-java-hash-function)

在自定义容量的时候最好是多少呢?

HashMap h=new HashMap(n);

由源码知,hashMap的容量大于阈值(负载因子*HashMap的容量)时,会扩容,而扩容会重新计算数据的位置,性能损失严重,故n必须大于预计数据量的1.33333(4/3)倍(默认负载因子0.75) ,另外hashmap并不是用户输入多少,初始化的时候容量就是多少,而是会对用户输入的n值进行判断,取第一个比(n-1)大的2的幂。

三、Java7与Java8扩容

3.1 java7:

      扩容需要满足两个条件:1.存放新值之前 当前已有元素的个数必须大于等于阈值

                                              2.发生hash冲突

public V put(K key, V value) {
     //省略部分代码。。。。

    //计算当前key的哈希值    
    int hash = hash(key);
    //通过哈希值和当前数据长度,算出当前key值对应在数组中的存放位置
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
      Object k;
      //如果计算的位置有值,且key值一样,则覆盖原值value,并返回原值value
      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;
  }

put方法里面调用了addEntry(),很关键的一行代码if ((size >= threshold) && (null != table[bucketIndex]))

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);
  }

通过代码可知,Java7中 HashMap当同时满足两个条件时才会扩容,同时从代码中也能看出:先扩容再执行插入操作

仔细看这个if条件,会出现一个比较有意思的事,因为扩容必须两个条件都满足,我们假设前11个元素hashcode相同,那么他们会被放入同一个桶里,当put第12个元素的时候,如果hashcode还相同,此时if条件(11>=12)不满足,不会触发扩容。即如果接下来的15个元素每一个占一个桶,那么就会出现容量为16的hashmap存储了27个元素还没有进行扩容(默认负载因子为0.75,容量为16),当然这种极端情况现实中应该不会出现。。。

满足扩容条件之后会调用resize()方法。

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //是否超出扩容的最大值,达到则不执行扩容操作
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        //transfer()将原数组里面的值放到新数组里
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        //设置新的阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

关键在于resize方法中的transfer()方法。

/**
     * Transfers all entries from current table to newTable.
     */
    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;
            }
        }
    }

if(rehash)这行代码,true则重新进行计算key的hash值,false则不用计算,而rehash的来源在resize方法中可以看到rehash=initHashSeedAsNeeded(newCapacity)。

/**
     * Initialize the hashing mask value. We defer initialization until we
     * really need it.
     */
    final boolean initHashSeedAsNeeded(int capacity) {
        //如果hashSeed!=0,表示当前正在使用备用哈希
        boolean currentAltHashing = hashSeed != 0;
        //如果VM启动了且map的容量大于阈值,则使用备用哈希
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        //异或操作
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            //把hashSeed设置为随机值
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
        return switching;
    }

仔细看这个方法: boolean currentAltHashing,我们可以记住一点 ,刚开始hashSeed初始化的时候是赋值0的,也只有下面的if(switching)里面才会更改hashSeed的值,所以currentAltHashing的值为false。再看useAltHashing的值,它是由两个值的逻辑与运算决定的:sun.misc.VM.isBooted一般VM启动的时候他是为true的,而ALTERNATIVE_HASHING_THRESHOLD的值为integer的最大值,所以使用过程中capacity一般是小于后者的,为false,结果就是useAltHashing为false,最终导致的就是switching的值一般是false。

(感兴趣的可以看看这个,从14:25开始看起,讲的很清楚https://www.bilibili.com/video/BV1vE411v7cR?p=4)

回到上面的transfer方法,if(rehash),很明显rehash为false,所以得出结论:

一般情况下,触发扩容的时候,不会去重新计算key的hash值,只会重新计算在数组中的位置

                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;

    有意思的点在于扩容的时候需要将旧的元素移到扩容后的newTable里:

           同一个链表里最先put的元素会放到链表头,链表翻转。(比如:以前是A->B->C->D,扩容后变为D->C->B->A).扩容一次,翻转一次。

           扩容结束之后或者不满足扩容条件之后就会执行插入操作 (区别在于扩容后时会重新计算元素在表中的位置index)。

而当不满足扩容条件时,会去执行插入操作createEntry(int  hash,K key,V value,int bucketIndex);

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++;
    }

3.2 Java8

 java 8扩容条件与7不同,在两种情况下都会扩容,先说结论(先插入再判断是否扩容):

    (1)存放新值之后,目前元素(包括新加入的这个值)的个数大于阈值,

    (2)发生hash冲突,存入链表长度(不包括新加入的key,虽然此时链尾的next指针已指向新key)>=8并且hash表的数组长度<64

 

为了说明这两种扩容情况,我们从put方法说起吧:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        /**
         * 关键从这块开始,
         * Java8并不是在new的时候对对象初始化,(jdk7是在new的时候初始化)
         * 而是在这里当对象为空的时候才会调用resize进行初始化。
         */
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //插入的新元素没有冲突,则在对象存放位置创建新node,该新节点将作为这个位置链表的头结点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            //如果将要插入的位置已有元素
            Node<K,V> e; K k;
            //并且该元素与将要插入的元素相同(==,equals),则直接覆盖
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果不相等,则分两种情况:判断所要存储的位置是否为红黑树结构,是则调用putTreeVal
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //不是红黑树,则为链表,进行循环遍历该链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    /**
                     * 如果存入位置所在链表下一个位置为空(从头结点下一个位置开始)
                     * 则将新节点直接存入(即头结点的next指针指向newNode)
                     * 接着binCount>=7(即判断这个链表的长度是否>=8,此时链表的长度计算不包括newNode)
                     * 满足条件,则调用treeifyBin方法(该方法会判断map长度是否<64,小于则触发扩容)
                     * 存入位置链表下一位置不为空,则判断是否相同,是则覆盖,不是则继续循环
                     */
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //在判断处理流程中,如果key相同(==,equals),则直接覆盖,将旧值返回
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //size此时是不包括新元素的,所以++size包括新加入的元素,即目前键值对数>阈值,触发扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

有耐心的可以看上面代码说明,附上如果binCount>7之后调用的treeifyBin方法:

/**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        /**
         * 这里MIN_TREEIFY_CAPACITY=64,
         * tab.length等于hashmap的容量,是一个2的幂(而不是有的博主说的map的size)
         * 即当满足binCount>7且length<64时,触发扩容,不满足时转红黑树
         */
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        //既然都已经进入treeifyBin方法了,这边为什么还要进行一次判断没搞懂,没搞懂
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

:java7插入元素的方式是头插法,即往HashMap里面put元素时,此时新增的元素在链表上的头部

四、get方法的流程

 

未完。。。

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值