深入理解HashMap(JDK1.7 )

一、HashMap的基本结构

        HashMap是Map接口的实现类,是一个双列集合,内部使用的是“键值对”存储数据,允许null做为“键”。这个是以前在上学的时候,可以摇头晃脑的说出来的。今天,我们就来探索一下HashMap的源码,解开HashMap神秘的面纱。

        首先大致描述一下HashMap在JDK1.7版本及之前的数据结构。HashMap内部使用“数组+链表”的数据结构。在Java中,链表上的每一个节点,它都是一个对象。对象是用来封装数据的,当我们存储数据的时候,数据被封装到这个对象中。这个对象是HashMap的一个内部类Entry。它是一个嵌套类,在其构造的时候需要给定它下一个Entry类的对象的内存地址,这些就会形成一个像单向的链表。每一个Entry类对象可以视为一个节点。每个链表的头节点存放在数组中,这个数组我们通常叫它“哈希表”。哈希表在代码中的体现是Entry<K,V>[]  table。下面的图片可以帮助理解。图片来源:点击打开链接

        紫色代表着“哈希表”。绿色代表的是Entry对象。Entry对象形成的链表的第一个头节点存放在“哈希表”中,也就是存放在table数组中。可以清楚的知道每一个Entry对象拥有指向下一个Entry对象的指针。

        

        

        下面这张图也能很好的解释HashMap内部的结构。Entry[]数组就是“哈希表”,Entry链表的头节点存放在数组中。图片来源:点击打开链接

        不同的KEY计算出来不同的Hash值。而对Hsah值的特殊计算决定了 K-V 在哈希表数组中的位置。如图,哈希表数组有5个位置。存放在哈希表数组中的Entry都是最新的元素,旧的元素挂在最新Entry上。

        

        Entry类究竟是什么样子的?上代码。可以看到,用来封装数据的实体类Entry,它的构造函数接收四个参数。分别是通过key计算出来的hash值、键、值、下一个Entry节点对象的引用。    

static class Entry<K, V> implements Map.Entry<K, V> {
        final K key;//键值对的 “键”
        V value;    //键值对的 “值”
        Entry<K, V> next; //指向下一个节点的指针
        int hash;   //通过key计算出来的hash值

        /**
         * 构造方法,创建一个Entry
         * 参数:哈希值h,键值k,值v和下一个节点n
         */
        Entry(int h, K k, V v, Entry<K, V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        //省略部分代码
    }

二、HashMap的核心参数

//默认初始容量是16,“<<”代表左移四位,二进制下左移四位相当于乘以2^4=16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // default init capacity = 16    
  
//默认加载因子,加载因子是用于衡量哈希表的元素个数的饱满程度,默认达到75%就进行扩容。如果是默认的情况,初始化一个HashMap,它的初始容量是16,如果表中的元素已经达到了16*0.75 = 12个,就必须进行扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;  
  
//存储Entry的默认空数组  
static final Entry<?,?>[] EMPTY_TABLE = {};  
  
// 存储Entry的数组,这个就是哈希表table,看到了吧,使用的是 “数组数据结构”
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  
  
// HashMap的实际元素个数,即HashMap存储的键值对数量  
transient int size;  
  
//HashMap的阈值,如果实际元素个数size到达此阙值,那么HashMap就需要进行扩容。
int threshold;  
  
//加载因子实际大小,由构造函数传入。 
final float loadFactor;  
  
//一个标记,用于多线程下的“快速失败”机制。
transient int modCount;  

         加载因子在API文档中的解释:“加载因子” 是哈希表在扩容之前可以达到多满的一种尺度。知道了加载因子的解释,我们就知道了加载因子对HashMap的影响。当加载因子变大的时候,HashMap的空间利用率增加,但是代价就是每一个链表的长度也随着增加,那么我们去查询某个“键值对”的时候,所付出的时间也随之增加。如果加载因子变小,HashMap将会在并没有多少元素的时候就去扩容,虽然查询速度变快了,但是很多数组的内存空间没有得到使用。这就是有名的“时-空矛盾”。

        关于“加载因子”影响的精辟解释:若加载因子设置过大,则填满的元素越多,无疑空间利用率变高了,但是冲突的机会增加了,冲突的越多,链表就会变得越长,那么查找效率就会变得更低;若加载因子设置过小,则填满的元素越少,那么空间利用率变低了,表中数据将变得更加稀疏,但是冲突的机会减小了,这样链表就不会太长,查找效率变得更高。(出自:点击打开链接

        接着分析构造函数。当我读到这段代码的时候,真的很佩服作者。大师的手笔,虽然看似十分简单,但是这三种构造函数的复用性非常高。在空参构造函数中使用默认值调用有参构造函数。在自定义初始容量和加载因子的构造函数中,进行了健壮性判断,并且抛出异常。以前的自己只知道抛出RuntimeException,但是跟着大师去学代码,真的收获很多。IllegalArgumentException是非法参数的异常。

        在第一个构造函数中,先进行了严格的健壮性判断。我们可以看到我们自定义的容量被当成了阙值,并没有去初始化“哈希表”table数组。

public HashMap(int initialCapacity, float loadFactor) {  //健壮性判断有条不紊,不是在一个if语句里面进行全部的判断,抛出异常十分到位。
        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);  
  
        this.loadFactor = loadFactor;  
        threshold = initialCapacity;  //初始化容量 赋给 阙值
        
    }  
      
    public HashMap(int initialCapacity) {  
        this(initialCapacity, DEFAULT_LOAD_FACTOR);  
    }  
      
    public HashMap() {  
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  //空参构造函数调用有参构造函数
    }  

三、核心方法put方法

         首先先说一下结论,因为哈希表位置有限,不同的 hash值 可能算出来的 index相同,那么把最新的K-V 放在 内部数组上,以前的数据挂在最新的K-V键值对上。

        存放元素方法 put(K key, V value)。先讲一下存放思路。首先通过put方法获取到要被存放的key-value。参数中,键是key,值是value。然后把key的哈希值hash计算出来,根据hash值找到此键值对应该存放在哈希表table中的位置,即根据hash值找到键值对应该存放在数组中的索引。将key和value封装成键值对Entry对象,存放到table数组中。

        添加流程:key ==> hash ==>index ==>封装成Entry ==> Entry存放到 哈希表table 中

通过key计算出hash值

通过hash值算出在内部数组中存放的索引

遍历头节点为指定索引的链表,检查当前的key是否已经存在。若存在,则替换value。

不存在,则添加新的节点,存放到链表的头部,也就是数组的中,并用一个引用指向以前的链表。

    public V put(K key, V value) {
        //table为空,就先初始化
        if (table == EMPTY_TABLE) {
            //初始化哈希表table数组,将{} 初始化为 {null,null,....,null},threshold在构造函数中被定义为initialCapacity
            inflateTable(threshold);
        }

        //key 为null的情况, 只允许有一个为null的key
        if (key == null)
            //此函数效果:将哈希表table数组的第一个元素table[0]用于存放 key==null的元素,并且此Entry的hash值是0
            return putForNullKey(value);
        //根据key计算出它的hash值,算法就不需要我们关心了
        int hash = hash(key);
        //根据指定hash,找出在table数组中的索引的位置。具体算法我不想关心,有兴趣的可以查看源码研究。
        int i = indexFor(hash, table.length);
        //循环遍历Entry数组,若该key对应的键值对已经存在,则用新的value取代旧的value
        //如果key已经存在,那么直接设置新值
        for (Entry<K,V> 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;

                return oldValue;
            }
        }
        //快速失败机制标记
        modCount++;
        //如果哈希表中没有当前的key,那么就创建新的Entry对象,将其插入到哈希表中
        addEntry(hash, key, value, i);
        return null;
    }

        真正初始化HashMap的时刻是第一次向HashMap中添加元素的时候。就在此inflateTable方法中进行了初始化。有new就有内存空间的分配。

//初始化哈希表table数组 
private void inflateTable(int toSize) {  
    //省略部分代码
    //int capacity 是最接近toSize的一个2的若干次幂
    table = new Entry[capacity]; //用该容量初始化table  
}  

    允许key为null。处理key为null的情况。效果是将key为空的元素,存放到哈希表table数组的第一个,并且其它的哈希值是0。

//当key为null 的处理情况
    private V putForNullKey(V value) {
        //哈希表的第一个元素用于存放key==null的Entry
        for (Entry<K, V> e = table[0]; e != null; e = e.next) {
            //如果有key为null的Entry,用新值替换旧值,并返回旧值
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        modCount++;、
        //哈希表的第一个元素当前什么都没有,那么创建一个新的Entry放到table数组的角标为0的位置
        //第一个0代表的是key=null的Entry的hash值是0,第二个0代表的是key=null的Entry在table数组中的索引
        addEntry(0, null, value, 0);
        return null;
    }

    在哈希表中添加Entry的 addEntry方法。

//在哈希表table中增加Entry,四个参数分别是hash值,键,值,应在索引
    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);
    }
    //创建一个Entry,这个方法有点意思,也是为什么会产生链表的原因。
    void createEntry(int hash, K key, V value, int bucketIndex) {
        //先获取原来的指定索引上的键值对Entry
        Entry<K, V> e = table[bucketIndex];
        //再新创建一个键值对Entry对象放到哈希表的指定索引上,并且将原来的Entry挂到该Entry的next
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        //所以table中的每个位置永远只保存一个最新加进来的Entry,其他Entry是一个挂一个,这样挂上去的
        //实际元素+1
        size++;
    }

四、根据HashMap源码来分析其特点

        首先上我的终极口诀:“序重步+数据结构”。

        HashMap存放的元素是无序的。在put的代码中,我们知道HashMap是通过计算key的哈希值,再通过哈希值来计算索引的,与元素放入的先后顺序没有什么关系。key ==>hash ==>index。

        HashMap中元素不可以重复。在上述put方法代码的第17行开始可以证明。如果key已经存在,那么就用新值替代旧值。如果新值跟旧值相同的话,那么“新的”键值对与“旧的”键值对一模一样。

        HashMap是不同步的。源代码中处处可见到modCount这样的“快速失败”标记。HashMap是在java.util包下的。此包下的所有集合都是不同步的。

        HashMap底层使用的是“数组+链表”的数据结构。使用链地址法解决哈希冲突。链地址法的表现就在于相同的hash值的键值对组成一个链表,每一个键值对都有它后一个键值对的引用。

五、要点

        无序,不可重复,不同步。采用“数组+链表”数据结构,并使用“链地址法”解决哈希冲突。

        影响HashMap的两个参数是“初始容量”和“加载因子"。

        加载因子 是哈希表在扩容之前可以达到多满的一种尺度。

        HashMap每次扩容,新长度是旧长度的两倍。(在addEntry方法中的第5行可证明)

        真正初始化HashMap的时刻是第一次向HashMap中添加元素的时候。

        HashMap允许存放键为null的元素,并且此键值对的hash值是0,放在哈希表数组table的第一个。

        存放流程: key ==> hash ==>index ==>封装成Entry ==> Entry存放到 哈希表table 中 。

        哈希表中的每个位置永远只保存一个最新加进来的键值对,其他键值对是一个挂一个,这样挂上去的。
 

 

posted @ 2022-07-17 12:16  小大宇  阅读(91)  评论(0编辑  收藏  举报