HashMap源码剖析

概述

把HashSet和HashMap放在一起讲解,是因为二者在Java里面有着相同的实现,前者仅仅是对后者做了一层包装,也就是说HashSet里面有一个HashMap(适配器模式)。

HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。
HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap。
HashMap 实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

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

源码

属性

   // 默认的初始容量(容量为HashMap中槽的数目)是16,且实际容量必须是2的整数次幂。    
    static final int DEFAULT_INITIAL_CAPACITY = 16;    

    // 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)    
    static final int MAXIMUM_CAPACITY = 1 << 30;    

    // 默认加载因子为0.75   
    static final float DEFAULT_LOAD_FACTOR = 0.75f;    

    // 存储数据的Entry数组,长度是2的幂。    
    // HashMap采用链表法解决冲突,每一个Entry本质上是一个单向链表    
    transient Entry[] table;    

    // HashMap的底层数组中已用槽的数量    
    transient int size;    

    // HashMap的阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*加载因子)    
    int threshold;    

    // 加载因子实际大小    
    final float loadFactor;    

    // HashMap被改变的次数    
    transient volatile int modCount;      

构造函数

HashMap提供了三个构造函数

HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。

public HashMap(int initialCapacity,float loadFactor){
    if(initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); 
    if(initialCapacity > MAXMUM_CAPACITY)
        initalCapacity = MAXIMUM_CAPACITY;
    //加载因子不能小于0
    if(loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor); 
    //找出“大于initialCapacity”的最小二次幂
    int capacity = 1;
    while(capacity < initialCapacity)
        capacity << 1;
    //设置加载因子
    this.loadFactor = loadFactor;
    //设置"HashMap阈值",当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍
    threshold = (int)(capacity * loadFactory);
    //创建Entry数组,用来保存数据
    table = new Entry[capacity]
    init(); 
} 

HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap

    public HashMap(int initialCapacity) {    
        this(initialCapacity, DEFAULT_LOAD_FACTOR);    
    }    

HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

    public HashMap() {    
        // 设置“加载因子”为默认加载因子0.75    
        this.loadFactor = DEFAULT_LOAD_FACTOR;    
        // 设置“HashMap阈值”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。    
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);    
        // 创建Entry数组,用来保存数据    
        table = new Entry[DEFAULT_INITIAL_CAPACITY];    
        init();    
    }    

在这里提到了两个参数:初始容量,加载因子。
这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的

数据结构

我们知道在Java中最常用的两种结构是数组模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现,HashMap也是如此。实际上HashMap是一个“链表散列”,如下是它数据结构:

这里写图片描述

从上图我们可以看出HashMap底层实现还是数组,只是数组的每一项都是一条链。其中参数initialCapacity就代表了该数组的长度。下面为HashMap构造函数的源码:

//初始化table数组
table = new Entry[capacity];

可以看出,每新建一个HashMap视,都会初始化一个table数组。table数组的元素为Entry(Node)节点。

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

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

其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值。

快速存储put(key,value)

put会首先对mao做一次检查,看是否包含该元组,如果已经包含则直接返回;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法
这里写图片描述

public V put(K key, V value){
    if(key = null)
        return putForNullKey(value);
    //计算key的hash值
    int hash = hash(key,hasCode()); ------(1)
    //计算key hash值在table数组中的位置
    int i= indexFor(hash, table.length); -------(2)
    //从i开始迭代e,找到key保存的位置
    for(Entry<K,V> e = table[i]; e!=null; e=e.next){
 Object k;
        //判断该条链上是否有hash值相同的:在同一条链上
        //若存在相同,则直接覆盖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;     //返回旧值
        }
    }
    //修改次数增加1
    modCount++;
    //将key、value添加至i位置处
    addEntry(hash, key, value, i);
    return null;    
}

1、 迭代出,防止存在相同的key值,若发现两个hash值相同且euqal()。则用新的value替换旧的value,这里并没有处理key
2、 再看(1)、(2)处。这里是HashMap的精华所在。首先是hash方法,该方法为一个纯粹的数学计算,就是计算h的hash值。

static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

我们知道对于HashMap的table而言,数据分布需要均匀,不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。怎么才能保证均匀分布呢?

static int indexFor(int h, int length) {
        return h & (length-1);
    }

HashMap的底层数组长度总是2的n次方,在构造函数中存在:capacity <<= 1;这样做总是能够保证HashMap的底层数组长度为2的n次方。当length为2的n次方时,h&(length - 1)就相当于对length取模,而且速度比直接取模快得多,这是HashMap在速度上的一个优化。至于为什么是2的n次方下面解释。

length为16(2^n)和15,h为5、6、7。
这里写图片描述
当n=15时,6和7的结果一样,这样表示他们在table存储的位置是相同的,也就是产生了碰撞,6、7就会在一个位置形成链表,这样就会导致查询速度降低。诚然这里只分析三个数字不是很多,那么我们就看0-15。
这里写图片描述
这里可以直观的看出来,总共发生了8此碰撞,同时发现浪费的空间非常大,有1、3、5、7、9、11、13、15处没有记录,也就是没有存放数据。这是因为他们在与14进行&运算时,得到的结果最后一位永远都是0,即0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的,空间减少,进一步增加碰撞几率,这样就会导致查询速度慢。而当length = 16时,length – 1 = 15 即1111,那么进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

现在再来复习一下put的流程
1. 如果key为null,则将其添加到table[0]对应的链表中。
2. 如果key部位null,则同样先求出key的hash值,根据hash值得出在table中的索引,然后遍历相应的单链表,如果单链表中存在于目标key相等的键值对,则将新的value覆盖旧的value。

记住,key为null的键值对永远都放在以table[0]为头结点的链表中,当然不一定是存放在头结点table[0]中。

// 新增Entry。将“key-value”插入指定位置,bucketIndex是位置索引。    
void addEntry(int hash, K key, V value, int bucketIndex) {    
    // 保存“bucketIndex”位置的值到“e”中    
    Entry<K,V> e = table[bucketIndex];    
    // 设置“bucketIndex”位置的元素为“新Entry”,    
    // 设置“e”为“新Entry的下一个节点”    
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);    
    // 若HashMap的实际大小 不小于 “阈值”,则调整HashMap的大小    
    if (size++ >= threshold)    
        resize(2 * table.length);    
}    

这里设置”e”为新Entry的下一个节点,就是将其next指向元素e,这便将key-value放在了头结点中,并将之间的头结点接在了它后面。

额外注意最后两行代码,每次加入键值对时,都要判断当前已用的槽的数目是否大于等于阀值(容量*加载因子),如果大于等于,则进行扩容,将容量扩为原来容量的2倍。

扩容risez()

// 重新调整HashMap的大小,newCapacity是调整后的单位    
void resize(int newCapacity) {    
    Entry[] oldTable = table;    
    int oldCapacity = oldTable.length;    
    if (oldCapacity == MAXIMUM_CAPACITY) {    
        threshold = Integer.MAX_VALUE;    
        return;    
    }    

    // 新建一个HashMap,将“旧HashMap”的全部元素添加到“新HashMap”中,    
    // 然后,将“新HashMap”赋值给“旧HashMap”。    
    Entry[] newTable = new Entry[newCapacity];    
    transfer(newTable);    
    table = newTable;    
    threshold = (int)(newCapacity * loadFactor);    
}    

很明显,是新建了一个HashMap的底层数组,而后调用transfer方法,将就HashMap的全部元素添加到新的HashMap中(要重新计算元素在新的数组中的索引位置)。transfer方法的源码如下:

// 将HashMap中的全部元素都添加到newTable中    
void transfer(Entry[] newTable) {    
    Entry[] src = table;    
    int newCapacity = newTable.length;    
    for (int j = 0; j < src.length; j++) {    
        Entry<K,V> e = src[j];    
        if (e != null) {    
            src[j] = null;    
            do {    
                Entry<K,V> next = e.next;    
                int i = indexFor(e.hash, newCapacity);    
                e.next = newTable[i];    
                newTable[i] = e;    
                e = next;    
            } while (e != null);    
        }    
    }    
}    

很明显,扩容是一个相当耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。因此,我们在用HashMap的时,最好能提前预估下HashMap中元素的个数,这样有助于提高HashMap的性能。

containskey与containsValue方法

前者直接可以通过key的哈希值将搜索范围定位到指定索引对应的链表,而后者要对哈希数组的每个链表进行搜索。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值