HashMap源码分析

在这里插入图片描述

本文基于JDK1.7 进行解析,因为JDK1.8的版本,HashMap进行了优化,这个在最后会再进行分析。

一、概述


  • 一个存储key-value键值对的容器,key和value支持null值;
  • 通过hash算法来计算hascode值,用hashCode标识Entry在table中存储的位置,内部是哈希表来实现的数据结构;
  • 在存储的时候是无序的;
  • 同时它也是线程不安全的;

继承关系如下:
在这里插入图片描述

二、数据结构


在这里插入图片描述

transient Entry[] table;

HashMap 的内部,是通过哈希表的方式来实现的,哈希表其实就是数组+链表的形式。在HashMap的内部,有一个成员变量table,它是一个Entry类型的数组。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;	//下一个结点
    final int hash;		//对应的hash值
    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 (key==null   ? 0 : key.hashCode()) ^
                   (value==null ? 0 : value.hashCode());
        }

	public final String toString() {
            return getKey() + "=" + getValue();
	}
    void recordAccess(HashMap<K,V> m) {}
    
    void recordRemoval(HashMap<K,V> m) {}
    }

Entry里面有几个比较重要的成员变量,相应的含义如下:

名称类型用途
keyK键值对的key
valueV键值对的value
nextEntry下一个键值对
hashint当前键值对的hash值

在我们插入一个数据的时候,会先根据key按照hash算法计算获取应该存储的位置,然后在数组的相应位置存储插入数据,如果在不同的key按照hash 算法得到的位置是一样的时候,这个时候就是我们平常所说的哈希冲突,hashmap采用拉链法来解决冲突,也就是将所有的哈希地址相同的元素都放到到同一个链表中。

三、数据操作


通过源码来进一步深入分析。

1、增&改
   public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        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;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

在插入数据的时候,首先调用判断key 是否为null,如果为null的话,调用putForNullKey方法进行存储,null 键哈希值为 0,也就是固定存储在table[0]的,putForNullKey方法首先会先判断,table[0]是否已经有数据了,如果有的话,遍历对应的链表,如果找到了key 为null的数据,直接覆盖其value,如果遍历完整个链表都没有的话,会调用 addEntry 方法,替换掉table[i]的数据。
如果key不为null的话,在插入数据的时候,需要知道插入的数据应该放在table数据的那个位置,而计算位置的算法就是 hash 算法

  • 1)、hash算法
    static int hash(int h) {
        // 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);
    }

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

indexFor的作用就是确定在table数组中的位置,因此只需要将hash值对length进行取余%s运算即可,但看到源码我们不禁有些疑惑,它是将hash值跟 length-1进行与运算,其实效果是一致的,并且这样子实现能够提高运算效率。为什么可以这样子替换这里有详细介绍。
传入indexFor的参数,不是key的hashCode所对应的值,而是通过调用hash方法(以key的hashCode为参数)进行再次散列得到的值,hash方法的实现,其实就是进行移位和异或运算。为啥要多这一步操作而不是直接使用key的hashCode 呢?这么做的原因是,通过移位和异或运算,可以提高hash的复杂度,让其有更好的分布性,进而降低hash冲突的概率。

hashCode是一个int值(32位),其范围正负21亿多,而我们一开始hashMap默认的长度一般都是16,一般hash值需要对16进行取余运算(HashMap是与运算,length-1的二进制就是00001111),得到的才是table数组的下标,假设某个key的hashCode是0AAA0001,如果不经过hash函数的处理:
在这里插入图片描述
进行取与运算之后,获取的值是1,可以看到hash 只有低4位参与了计算,高位的计算可以认为是无效的,这样导致了计算结果只与低位信息有关,高位数据并没发挥作用。如果有另外一个key的hashCode为0BBB0001,其计算的结果也是1,两个数明明非常大,但存储的位置却都是同一个,这样子的算法明显是很差的。因此hash函数的实现是通过若干次的移位、异或运算,把hashcode的变得更“散列”。【如hash ^ (hash >>> 4)就是将 hash 高4位数据与低4位数据进行异或运算,通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中】

尽管通过再次hash 能够降低冲突率,但是冲突的情况还是会发生,那么HashMap是怎样解决冲突的呢?

  • 2)、解决hash冲突的方法
    在发生冲突的时候,HashMap会创建一个新的Entry,并且其next 指针 指向原来 table[i] 的值,令table[i]的值等于新创建的Entry,也就是冲突的时候,table[i]存储的,会一直都是最后插入的数据。

  • 3)、例子

在这里插入图片描述
例如我们想要存储key为16,value为A的元素,我们忽略 hash 的过程,插入过程如下:
1)、确认在table数组中的位置:16 % 16 = 0
2)、获取在table[0]的数据,发现为空,则直接新建Entry1插入;
继续插入key为34,value为B的元素:
3)、确认在table数组中的位置:32 % 16 = 0
4)、获取在table[0]的数据,发现不为空,说明发生了冲突碰撞
5)、遍历table[0]的链表,发现key为32的数据不存在,所以新创建一个新的Entry2,同时为了保证旧值不丢失,会将新的Entry的next指向旧值。这便实现了在一个数组索引空间内存放多个数值项。

2、删
public V remove(Object key) {
     Entry<K,V> e = removeEntryForKey(key);
     return (e == null ? null : e.value);
 }

 final Entry<K,V> removeEntryForKey(Object key) {
     int hash = (key == null) ? 0 : hash(key.hashCode());
     int i = indexFor(hash, table.length);
     Entry<K,V> prev = table[i];
     Entry<K,V> e = prev;

     while (e != null) {
         Entry<K,V> next = e.next;
         Object k;
         if (e.hash == hash &&
             ((k = e.key) == key || (key != null && key.equals(k)))) {
             modCount++;
             size--;
			// 如果第一个就是要删除的节点(第一个节点没有上一个节点,所以要分开判断)
             if (prev == e)
	             // 则将下一个节点放到table[i]位置(要删除的节点被覆盖)
                 table[i] = next;
             else
			// 否则将上一个节点的next指向当前判断结点的下一个(要删除的节点去除了引用)
                 prev.next = next;
             e.recordRemoval(this);
             return e;
         }
         prev = e;
         e = next;
     }
     return e;
 }

删除的实现很容易理解,首先根据key找到对应的在table 中的位置i,然后遍历table[i]位置的链表,如果hash 相等且key 相等,则进行链表的替换操作。

3、查
public V get(Object key) {
	 if (key == null)
	      return getForNullKey();
	  int hash = hash(key.hashCode());
	  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.equals(k)))
	          return e.value;
	  }
	  return null;
  }

查询的实现更加简单,同样是获取在table数组中的位置i,然后遍历链表,找到hash相等并且key值相等的数据返回。

四、扩容机制


在Java中,数组的长度是固定的,也就是说数组只能存储固定数量的数据,但在开发的过程中,一般我们都无法知道要多大的才合适,小了不够用,大了用不完(浪费空间),因此我们需要一种能够动态变长的数组,HashMap内部已经帮我们实现了这样子的功能,在解释HashMap的扩容机制之前,需要先了解HashMap的几个成员变量。

名称用途默认值
initialCapacity初始化容量16
loadFactor负载因子0.75
threshold当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容16 * 0.75 = 12
/** The default initial capacity - MUST be a power of two. */
static final int DEFAULT_INITIAL_CAPACITY = 16;

/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/** The load factor used when none specified in constructor.  */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**  The table, resized as necessary. Length MUST Always be a power of two. */
transient Entry[] table;

/**  The number of key-value mappings contained in this map.  */
transient int size;

/** The next size value at which to resize (capacity * load factor).  */
int threshold;

/**The load factor for the hash table. */
final float loadFactor;

/** The number of times this HashMap has been structurally modified  */
transient int modCount;

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);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

/**
 * Transfers all entries from current table to 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有几个常用的构造函数如下,我们在平常使用的过程中,都是调用new HashMap来获得一个HashMap对象,这个无参购买函数使用的都是默认值去初始化哈希表,也就是一开始初始化的时候,table数组的大小是16,

    public HashMap(int initialCapacity, float loadFactor) {
        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);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }

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

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

因此,在插入的数据大于阈值 threshold的时候,就需要进行扩容了。扩容的实现是新建了一个Entry数组,长度为原来的两倍,然后调用transfer方法,将旧的数组的全部元素添加到新的Entry数组中(注意,要重新计算元素在新的数组中的索引位置),可以看到扩容是一个相当耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。

    void resize(int newCapacity) {
        Entry[] oldTable = table;	//存储旧table数组
        int oldCapacity = oldTable.length;	//旧table数组的长度
        if (oldCapacity == MAXIMUM_CAPACITY) {	
        	//如果大于MAXIMUM_CAPACITY,使阈值为Integer.MAX_VALUE,然后返回
            threshold = Integer.MAX_VALUE;
            return;
        }
        //新建一个长度为旧数组长度两倍的新数组
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);	//做数组迁移
        table = newTable;	//使用新数组替换掉旧数组
        threshold = (int)(newCapacity * loadFactor);	//更新阈值
    }

    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;	// 使用next 变量,暂时存储e的next 位置数据
                    int i = indexFor(e.hash, newCapacity);	//重新计算e在新的table数组中的位置(因为length变了)
                    e.next = newTable[i];	//将新数组的i位置的数据存储在e的next位置
                    newTable[i] = e;		//让新table数组第i个位置的数据为e
                    e = next;	//将一开始暂存的next 位置的数据赋值给e,继续循环
                } while (e != null);
            }
        }
    }

五、JDK1.8的修改

1、底层实现采用数组+链表+红黑树

在JDK1.7,我们知道,当冲突率较高的时候,查询数据其实就是遍历链表,效率是非常低的,在这一方面,JDK1.8做了一些优化。当链表的长度超过阈值8的时候,会将链表转换为红黑树,在性能上会有一些提升。
在这里插入图片描述
其put方法的实现如下,在实现上跟JDK1.7有一些不同的是,在当链表的长度超过阈值8的时候,会转换成为红黑树,并且在hash的实现算法也进行了优化修改。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object var0) {
        int var1;
        return var0 == null ? 0 : (var1 = var0.hashCode()) ^ var1 >>> 16;
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判断table是否已经初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //计算存储的索引位置,如果没有元素,直接存储在tab对应的位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))		//节点若已经存在
            e = p;
        else if (p instanceof TreeNode)	//判断是否是红黑树
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);	//红黑树对象操作
        else {
            for (int binCount = 0; ; ++binCount) {		//链表
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //链表长度8,将链表转化为红黑树存储
                    if (binCount >= TREEIFY_THRESHOLD - 1) // 大于阈值8,则执行转换操作
                        treeifyBin(tab, hash);
                    break;
                }
                //key存在,直接覆盖
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //记录修改次数
    ++modCount;
    //判断是否需要扩容
    if (++size > threshold)
        resize();	//扩容
    afterNodeInsertion(evict);
    return null;
}

六、小结

整个HashMap的数据删除和数据获取比较简单,比较复杂的就是插入数据put的实现,按照JDK1.8的源码,整个操作的流程图如下。好了,这个HashMap的分析就先到这,限于篇幅问题,另外一些经典的问题会新开一篇文章来讲。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值