JDK1.7HashMap源码解析

    xiaoxiao2022-07-07  156

    HashMap已经看了很多篇文章了,今天还是自己解析一遍吧。


    我先大致介绍下HashMap的内部结构再跟着源码解读一番 众所周知HashMap的内部就是一个哈希表

    什么是哈希表? 如果我们利用数组可随机访问的特性,将要存入的键通过一种哈希算法转换成一个数字,并把这个数字转换成数组的下标,然后将键和他对应value放入数组中。那么我们再通过这个键查找时,只需要继续利用这个算法,那么我们就只需要用O(1)的时间复杂度就可以迅速找到这个键在数组中的位置从而找到键对应的value。 不过O(1)只是理想情况 为什么?,

    因为我们可以存入无数种可能的键,但是这个哈希算法 算出来的数字 是有限的,并且还需要转换成数组的下标,所以不同键转换的下标值就会有很大可能相同,这种问题叫做哈希冲突

    怎么解决哈希冲突?

    举个例子: 数组 array[4] 处已经有一个键值对 “wmh”-20, 此时想再放一个键值对"wyy"-18 不幸的是 "wyy"这个键转换的下标也是4 那么怎么办呢? 我们只需要将新的键值对放在原先键值对的后面形成一个单链表来存储就可以了 这种解决哈希冲突的办法叫做链地址法

    所以我们就知道如果我们访问数组的某个位置中有多个键值形成了单链表时,我们还需要遍历这个链表找到我们所需要的键值对,此时的时间复杂度就不是O(1)了而是O(n)

    如果因为哈希冲突过多导致一个单链表中的结点有很多时,我们查询的速度就会大大下降,所以在适当的时候我们必须将原先数组中的键值对移到一个更大的数组中 这个动作叫做扩容,

    适当的时候?指的是数组中的键值对到达一定的数量的时候(阀值)

    HashMap中定义了一个变量叫做加载因子 阀值 = 数组大小 * 加载因子 当我们每次往HashMap中put一个元素时就会检查当前键值对数量是否大于阀值,如果大于阀值则会进行扩容动作 怎么扩容?

    我们需要遍历老数组中的元素 并且遍历每个新的单链表 将每个键值对重新哈希后计算他们在新数组中的位置,全部放入新数组后,再将老数组指向新的数组。

    这个扩容的速度是十分慢的,所以定义一个正确的加载因子十分重要

    当加载因子过大,这意味着单链表可能会越长 查找一个键值对的速度会变慢

    当加载因子过小,这意味着数组还有很多空间就会进行扩容,这样又会浪费大量的存储空间

    HashMap选择了一个最适合的加载因子为0.75

    接下来开始解析Java1.7HashMap源码

    主要解析 构造器 put resize这几个方法

    先来看下HashMap中有哪些成员变量

    // 默认初始化容量 static final int DEFAULT_INITIAL_CAPACITY = 16; // 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 默认的加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // Entry数组 transient Entry[] table; // key-vlue结点数量 transient int size; // 需要扩容时的容量 (阀值 = 容量 / 加载因子) int threshold; // 加载因子 final float loadFactor; // 被修改的次数 transient volatile int modCount; static class Entry<K,V> implements Map.Entry<K,V> { // 键 final K key; // 值 V value; // 哈希冲突时当前结点的后继结点 Entry<K,V> next; // 键的hash值 final int hash; }

    成员变量中包含上面介绍中所提到的各种,比如数组、加载因子、阀值

    HashMap用一个静态内部类Entry表示 一个键值对,即Entry数组中的一个结点,每个结点中又有一个hash变量表示 键的hash值,和一个next指针指向单链表中的下一个结点

    先来看构造方法 当我们new一个无参的HashMap时,会初始化默认加载因子0.75 和一个默认容量16 的Entry数组

    public HashMap() { // 设置加载因子为默认的加载因子为0.75 this.loadFactor = DEFAULT_LOAD_FACTOR; // 此时阀值 = 默认初始化容量 16 * 默认加载因子 threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR); // 初始化Entry数组(默认容量16) table = new Entry[DEFAULT_INITIAL_CAPACITY]; // 这是个空方法 在这里没用 init(); }

    当我们new一个带指定容量和加载因子的HashMap时

    首先检查2个值是否合法把传进来的容量变成最接近这个容量的2的n次方倍计算阀值初始化数组

    可以发现2点:

    无论我们指定多大的容量,这个容量都会变成一个2的n次方的数1.7的HashMap并没有进行延迟初始化 public HashMap(int initialCapacity, float loadFactor) { // 初始化容量 < 0 报错 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 初始化容量 > 最大容量 报错 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // 加载因子必须是大于0的数字 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 这2步是把传进来的容量变成最接近这个容量的2的n次方倍 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 给加载因子赋值 this.loadFactor = loadFactor; // 阀值 = 容量 * 加载因子 threshold = (int)(capacity * loadFactor); // 初始化entry数组 // 注意:这里的capacity已经变成最接近传进来容量的2的n次方倍 table = new Entry[capacity]; init(); }

    接下来看put方法:不想解释了 直接看注释。。 不过需要注意几点

    当键为NULL时 直接插入到数组下标为0的位置中HashMap没有直接把键的HashCode值转换成下标,会将HashCode值经过一次再哈希的过程,再转换成下标 至于为什么,在下面可以看到结点插入到链表中不是在尾部追加,而是直接在头部插入的 这样插入的速度是很快的,不需要遍历到链表尾部进行插入 public V put(K key, V value) { // 当键为Null时直接插入到下标为0的位置中 if (key == null) return putForNullKey(value); // 得到哈希Code值并且进行再哈希 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; } } // 修改次数加1 modCount++; // 进行到这里 有2种情况 // 1.该位置上根本就没有结点 // 2.该位置结点形成的链表中没有与要插入的键相等的键 // 这两种情况都直接往该位置上创建新的结点 next指针指向原来该位置上的结点 // 如果该位置上有结点 就说明是在这个链表的头部插入的。 addEntry(hash, key, value, i); return null; }

    计算下标方法 这个方法将键的哈希值和 数组的长度-1 按位相与得到数组下标,为什么不直接和长度取模计算呢? 利用位运算代替取模运算,可以大大提高程序的计算效率。位运算可以直接对内存数据进行操作,不需要转换成十进制,因此效率要高得多。 但是这个位运算 只有在数组的长度是2的n次方的时候 才会有效 所以这个数组的长度永远会被保证在2的n次方

    // 计算该键在Entry数组中对应的位置 (h为该键对应的哈希值) // 当 length为2的n次方时 等价于 h % length static int indexFor(int h, int length) { return h & (length-1); }

    再哈希方法

    为什么要经过这个再哈希? 直接拿键的HashCode值计算下标不就可以了吗? 因为如果直接拿键的HashCode值计算 哈希冲突的概率会大大增加, 当数组长度不高时,HashCode值和数组长度-1 按位相与的时候 只会和HashCode值的低位相关 高位参与不进来 哈希冲突大大增加 经过这个扰动函数进行再哈希后,会使HashCode值的高位参与计算,哈希冲突概率大大降低

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

    扩容相关方法

    注释写的很清楚 主要看transfer方法: transfer方法 会遍历老数组 并且遍历每个结点形成的链表,将每个结点重新哈希计算他们在新数组的下标 然后直接将结点的next指针指向老数组的下标位置上的结点。 所以可以发现 原来结点所形成的链表,如果这些结点恰好在新数组中还是一个位置,那么相对原来是逆序连接的

    void resize(int newCapacity) { // 获取老的数组 Entry[] oldTable = table; int oldCapacity = oldTable.length; // 如果已经到了最大容量 没办法扩容直接return if (oldCapacity == MAXIMUM_CAPACITY) { 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) { // 当前位置指向null 方便gc src[j] = null; // 遍历结点所形成的链表 do { // 获取当前结点的next结点 Entry<K,V> next = e.next; // 根据新数组的容量计算该结点在新数组中的位置 int i = indexFor(e.hash, newCapacity); // 将该节点链接到新位置的头部 e.next = newTable[i]; // 新数组的位置指向为头节点 newTable[i] = e; // 继续下个结点 e = next; } while (e != null); } } }
    最新回复(0)