集合之HashMap

Java集合是面试java基础知识的重点,而HashMap既是集合中使用频率相当高的一个工具,而且也是面试集合问题中最常考且挖的很深的知识点,对HashMap的理解不能只是了解,还需要深入理解其底层原理。

先展示下Map家族的关系层级,有助于我们更好的理解后面的内容。在这里插入图片描述

JDK7中的HashMap底层实现

不管是1.7,还是1.8,HashMap的实现框架都是哈希表(数组 )+ 链表的组合方式。但1.8在链表中新增了红黑树的结构。下面先看看1.7中的hashmap实现。

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实所谓Map其实就是保存了两个对象之间的映射关系的一种集合)

//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂。
//至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类。代码如下

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;//储存key键
        V value;//储存value值
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 

所以,HashMap的总体结构如下:在这里插入图片描述可以看到HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,每个数组和链表节点都是一个Entry类对象,都包含有键,值,hash和指向下一个Entry的引用next。
如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;
如果定位到的数组包含链表,对于添加键值对的put操作,首先遍历链表,key存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

在具体看源码前,我们先关注几个域变量:
在这里插入图片描述
再多说一下,最后一个变量modCount,记录了map新增/删除k-v对,或者内部结构做了调整的次数,其主要作用,是对Map的iterator()操作做一致性校验,如果在iterator操作的过程中,map的数值有修改,直接抛出ConcurrentModificationException异常。
还需要说明的是,上面的域变量中存在一个等式:

threshold(阈值) = 容量* loadFactor;

我们先来看看HashMap的构造是什么样的,HashMap有4个构造器,有一个入参为指定Map的构造器,一个使用指定的初始容量和装载因子的构造器,一个使用指定的初始容量和默认的装载因子(0.75)的构造器,和如果用户没有传入initialCapacity (初始容量)和loadFactor(加载因子)这两个参数,则会调用无参构造器使用默认值(initialCapacity默认为16,loadFactory默认为0.75),threshold 阈值=容量*加载因子。默认为12。

一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的2倍。
HashMap的容量是有上限的,必须小于MAXIMUM_CAPACITY=1<<30(2的30次方),即1073741824。如果容量超出了这个数,则不再增长,且阈值会被设置为Integer.MAX_VALUE(2^31-1,即永远不会超出阈值了)。

1.7源码分析:
我们看下其中一个构造方法:使用指定的初始容量和装载因子构造一个空HashMap。

public HashMap(int initialCapacity, float loadFactor) {
     //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(1073741824)
        if (initialCapacity < 0)//传入的初始容量小于0抛出异常
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;//传入的初始容量大于MAXIMUM_CAPACITY=1<<30最大容量,则设为最大容量
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
			//装载因子要大于0,且不能为not a number,也就是说不能为正无穷
        this.loadFactor = loadFactor;
        threshold = initialCapacity;//初始容量设置为阈值
     
        init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
    }

从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组

接下来我们来看看put操作的实现:

public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold阈值,
        //此时threshold为initialCapacity初始容量,这是构造时设置的,initialCapacity默认是16
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//为Entry数组table分配空间
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀,计算得到的hash为目标键值对的下标,可用于获取在table中的实际位置
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {//遍历Entry结点链表
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            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++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        addEntry(hash, key, value, i);//若未找到目标Entry结点,则新增一个entry
        return null;
    }

第5行的inflateTable方法用于为主干数组table在内存中分配存储空间,这里就实现了HashMap的容量capacity的设置,通过roundUpToPowerOf2(toSize)方法可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。代码如下

private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
        /**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
        capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];//此时容量capacity就是Entry数组table的大小length
        initHashSeedAsNeeded(capacity);
    }

put操作的第10行hash函数:

/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀*/
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

put操作的第11行indexFor操作,通过以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

/**
     * 返回数组下标
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

h是指传过来的hash值,length是指table.length,h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为index=2。位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

所以最终存储位置的确定流程是这样的:
在这里插入图片描述HashMap 的长度为什么是2的幂次方
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),

hash%length==hash&(length-1)的前提是 length 是2的 n 次方; length -1的二进制会全为1,位运算时可以充分散列,避免不必要的哈希冲突

再来看看第23行,新增一个entry对象addEntry的实现:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。

我们来继续看上面提到的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(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

如果数组进行扩容,数组长度length发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,transfer这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

有两个核心点:
a) 扩容后大小是扩容前的2倍;
b) 数据搬迁,从旧table迁到扩容后的新table。
为避免碰撞过多,先决策是否需要对每个Entry链表结点重新hash,然后根据hash值计算得到bucket下标,然后使用头插法做结点迁移。

扩容引发的线程不安全问题:
1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

1.7:HashMap的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,它定义了HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。形成循环后,还有未插入的数据就会丢失。

1.8:根据JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,1.8的源码已经找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。但在1.8中HashMap的put操作中,假设两个线程都在执行put操作,并且hash函数计算出的插入下标是相同的,当第线程A的put操作执行到一半时时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,然后线程A获得时间片,直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

至于具体是怎么产生死循环、数据丢失、数据覆盖这些问题的,请看博客:JDK1.7和JDK1.8中HashMap为什么是线程不安全的?

get方法:

 public V get(Object key) {
     //如果key为null,则直接去table[0]处去检索即可。
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);//
        return null == entry ? null : entry.getValue();
 }

get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。不为null的话就调用getEntry方法,我们再看一下getEntry这个方法

final Entry<K,V> getEntry(Object key) {
            
        if (size == 0) {
            return null;
        }
        //通过key的hashcode值计算hash值
        int hash = (key == null) ? 0 : hash(key);
        //indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
        for (Entry<K,V> 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;
    }    

可以看出,get方法的实现相对简单,key(hashcode)–>hash–>indexFor–>最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。

JDK8中的HashMap底层实现(待续)

ConcurrentHashMap1.7,1.8的实现

在jdk1.7是 分段的数组+链表 ,jdk1.8的时候跟HashMap1.8的时候一样都是基于数组+链表/红黑树。

ConcurrentHashMap是线程安全的:
(1)在jdk1.7的时候是使用分段所segment,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
(2)在jdk1.8的时候摒弃了 Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用CAS和 synchronized 来操作。在 CAS 操作失败时使用内置锁 synchronized,synchronized只锁定当前链表或红黑二叉树的首节点。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值