对HashMap的初始化,保存,获取,扩容的理解

初始化

        在了解初始化前需要了解一下HashMap内部的一些基本定义变量,这些变量能更好的帮助我们理解后面的流程。

    //默认初始长度16
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
	
	//HashMap的最大长度2的30次幂
	static final int MAXIMUM_CAPACITY = 1 << 30;
	
	//HashMap的默认加载因子0.75
	static final float DEFAULT_LOAD_FACTOR = 0.75f;
	
	//HashMap链表升级成红黑树的临界值
	static final int TREEIFY_THRESHOLD = 8;
	
	//HashMap红黑树退化成链表的临界值
	static final int UNTREEIFY_THRESHOLD = 6;
	
	//HashMap链表升级成红黑树第二个条件:HashMap数组(桶)的长度大于等于64
	static final int MIN_TREEIFY_CAPACITY = 64;
	
	//HashMap底层Node桶的数组
	transient Node<K,V>[] table;
	
	//扩容阈值,为数组长度*加载因子。当你的hashmap中的元素个数超过这个阈值,便会发生扩容
	int threshold;

进入初始化流程,hashMap的初始化一共有两个方法分别是传入初始长度的构造方法和无参的构造方法

// 设定初始容量的hashMap方法
HashMap<String, String> map = new HashMap<>(3);
// 无参,默认容量的HashMap方法
HashMap<String, String> mapa = new HashMap();

这俩的区别就是设定了初始化长度的HasMap在初始化时会比无参的初始化多出几个步骤:

没有设定初始长度的定义方法

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

设定初始长度的定义方法:

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

    public HashMap(int initialCapacity, float loadFactor) {
        // 判断自定义长度是否小于0,是则抛出移仓
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        // 判断自定义长度是否大于int类型最大长度,是则赋值最大长度
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 如果传入的扩容因子小于0或者为nan,也抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);
        // 保存扩容因子
        this.loadFactor = loadFactor;
        // 计算初始化长度(这里的才是真正的长度)
        this.threshold = tableSizeFor(initialCapacity);
    }

    // 计算长度方法,求传入长度比它大或相等的最小2次幂

    static final int tableSizeFor(int cap) { // 返回一个大于或等于容器大小的最小二次幂
        // 将大小-1 
        int n = cap - 1;
        // 将n 于n向右移动1位(无符号右移位,无论正负都在高位补0)的值进行或运算(两个位都为0时输出0,否则1。)
        n |= n >>> 1;
        // n右移两位于上面的值做或运算
        n |= n >>> 2;
        // n右移4位于上面的值做或运算
        n |= n >>> 4;
        // n右移8位于上面的值做或运算
        n |= n >>> 8;
        // n右移16位于上面的值做或运算
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

        以上就是在new HashMap时发生的事情,各位稍微了解一点的应该都清楚,hashMap的底层结构是数组+链表+红黑树,但在这里可以发现在这里并没有真正进行数组的定义,定义的长度也不是我们输入的长度,而是比他大或相等的最小二次幂。为什么是这个值我们后面说。所以HashMap的真正初始化不在这里。

       在这里先说一下tableSizeFor()方法,他的作用是求所输入数的他大或相等的最小二次幂这里详细解释一下这个方法里的计算:

        上面的右移或运算是为了将int的32位除了第一个符号位之外全部移动遍,将最高位的1之后的几位全部变成1。 例如:我设置的cap是9 ,减1变成8 8的二进制是:1000通过上面的右移或运算之后,n的值变为:1111就是15,最后返回的值 +1为16

这里有我自己测试时候的代码,大家可以自己跑一下来感受一下这个方法的功能:

    public static void main(String[] args) {
        int a = 126;
        int n = a - 1;
        System.out.println("开始:");
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
        n |= n >>> 1;
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
        n |= n >>> 2;
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
        n |= n >>> 4;
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
        n |= n >>> 8;
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
        n |= n >>> 16;
        System.out.println(n + ":二进制:"+Integer.toString(n,2));
    
        System.out.println("最后结果:"+ (n + 1));
        
    }

PUT方法

其实真正的数组初始化是在put中,先看一个我画的流程图,可以更好的帮助理解put方法:

 然后是代码,看注释应该都能看懂什么意思:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    // tab用来临时存放数组table引用   p用来临时存放数组table桶中的bin
    // n存放HashMap容量大小   i存放当前put进HashMap的元素在数组中的位置下标
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        // e记录当前节点  k记录key值
        Node<K,V> e; K k;
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
                e = p;
        // hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
        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 ),执行 treeifyBin 方法
                    // 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
                    // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。对数组长度的判断在treeifyBin()方法中
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
					// 树化操作
                        treeifyBin(tab, hash);
                    // 跳出循环  此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
                    break;
                }
                // 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 若相等,则不用将其插入了,直接跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        // 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
        if (e != null) {
            // 记录e的value,也就是旧value值
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 替换旧值时会调用的方法(默认实现为空)
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
    ++modCount;
    // 实际大小大于阈值则扩容    ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
    if (++size > threshold)
        resize();
    // 插入成功时会调用的方法(默认实现为空)
    afterNodeInsertion(evict);
    // 没有找到原有相同key和hash的元素,则直接返回Null
    return null;
}

        我们在第一次进行put时,会先判断数组长度,如果长度为0或者null,就会调用扩容resize()方法,在这方法里,如果是一开始自定义了长度,则会建一个自定义长度计算后真正长度的数组,没有自定义就是默认长度的数组。然后通过hashcode按位与数组长度-1的值来判断位置。如果当前位置已经存在值,则通过尾插发放入链表中,然后判断是否符合树化条件,进行树化(红黑树本人还不是很了解,所以本篇文章就不提及)。最后判断数组长度是否大于扩容阈值。大于就进行扩容。

hashCode计算方法

static final int hash(Object key) {
    int h;
    // 如果key不为null,就计算他的hashcode并于code值右移16位的结果进行异或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

        这里右移16位在按位与hashCode的原因是正常来说我们的数组长度最大不会超过2的16次方,那么在计算数组位置时hashcode的前16位是用不上的。如果过存在这么两个值,后16位相同,前16为不同那么就会产生冲突。所以为了减小冲突概率,加入前16位进行运算。这种方式叫做扰动。

hashMap的长度为什么是2的次幂

        在计算数组下标的时候会发现使用的公式是hashCode & 数组长度减一。这么做是为了使数据分布更均匀。但在最开始出现的HashTable中,计算下标位置是通过hashCode 取余数组长度。然而在计算机中,除法需要转换成其他计算方式。降低了效率,而将数组长度设置为2次幂的时候,取余操作等价于&(数组长度 -1) &是可以直接通过计算机计算的,大大提高了效率。所以这就是为什么后面出现的hashMap的长度为2的次幂的原因。

扩容方法

     

    final Node<K,V>[] resize() {
        // 旧数组
        Node<K,V>[] oldTab = table;
        // 旧数组长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 旧阈值
        int oldThr = threshold;
        // 新长度,新阈值
        int newCap, newThr = 0;
        // 如果就数组长度大于0
        if (oldCap > 0) {
        	// 如果就数组长度大于等于hashmap的最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
            	// 阈值 = integer最大长度
                threshold = Integer.MAX_VALUE;
                // 返回就数组
                return oldTab;
            }
            // 不是初始化,设置新数组长度,就数组长度左移一位,就是二倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 数组长度为0,进入初始化逻辑,先判断阈值是否大于0(前面自定义初始化长度)
        else if (oldThr > 0) // initial capacity was placed in threshold
            // 新数组长度 = 自定义长度
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            // 没有自定义长度,设置为默认长度16
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 计算扩容阈值  = 默认长度 * 扩容因子
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 如果新数组扩容阈值为0 (前面只定义了长度,没有定义阈值)
        if (newThr == 0) {
            // 计算新数组的扩容阈值 = 新数组长度 * 自定义时的扩容因子(0.75)
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 保存扩容阈值
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        // 新数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // 将map内保存的地址指向新数组地址
        table = newTab;
        // 如果旧数组不为空,将旧数组内的值重新计算位置放入新数组中
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                // 旧数组
                Node<K,V> e;
                // 如果旧数组不为空
                if ((e = oldTab[j]) != null) {
                    // 将旧数组对应位置数据置空
                    oldTab[j] = null;
                    // 判断当前数据是否已经存在链表数据
                    if (e.next == null)
                        // 不存在,重新计算这个数据在新数组中的位置
                        newTab[e.hash & (newCap - 1)] = e;
                    // 存在链表数据,判断是否已经树化
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 没有树化,正常链表数据处理
                        // 新数组低部分的链表数据头     ,新数组低部分链表数据的尾部
                        Node<K,V> loHead = null, loTail = null;
                        // 新数组高部分的链表数据头    , 新数组高部分链表数据尾部
                        Node<K,V> hiHead = null, hiTail = null;
                        // 要迁移的数据
                        Node<K,V> next;
                        do {
                            // 获得当前位置的数据
                            next = e.next;
                            // 如果当前数据的hash 与旧长度为0
                            if ((e.hash & oldCap) == 0) {
                                // 表示数据在新数组位置中处于低位置,如果尾数据为空
                                if (loTail == null)
                                    // 将数据放入头部中
                                    loHead = e;
                                else
                                    // 尾部有数据,意味着头部已经存在数据,将当前数据放入最后的位置
                                    loTail.next = e;
                                // 将当前数据放入到尾数据中,保持这个变量中的数据一直为最后一个
                                loTail = e;
                            }
                            else {
                                // 表示数据在新数组位置中处于高位置,逻辑与上面的低位置处理相同
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                            // 循环这个位置的链表数据
                        } while ((e = next) != null);
                        // 如果低位置尾数据不为空,表示旧链表迁移到新
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 如果存在高位数据,则将他放到旧数组位置 + 旧数组长度位置的地方
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

 代码中红黑树部分因本人不了解,暂不予解释。

        正常数组扩容应该很容易看懂,就是正常数进行按位与运行计算下标。但是链表的迁移是一种新的计算方式,可以看到在计算链表数据的下标时会用当前数据的hash值按位与旧数组长度判断是否等于0,如果等于0,放入定义的尾部节点中,存放在新数组中的位置下标和旧数组中位置一样。如果计算不等0,则放入定义的头部节点中。存放位置是当前旧位置+旧数组长度。个人认为这个计算方式和put时的与运算操作一样,是为了提高效率作者想出的巧思。经过本人多次测试,当一个数据的hash值取模旧数组长度=0时,通过正常put计算下标的位置,和旧数组所在位置相同。而取模不等于0的值,他在新数据中的位置等于数据在旧数组所在位置加旧数组大小。很奇妙的操作。

GET方法

也是直接看代码和注释:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    
    //Node数组不为空,数组长度大于0,数组对应下标的Node不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        //也是通过 hash & (length - 1) 来替代 hash % length 的
        (first = tab[(n - 1) & hash]) != null) {
        //先和第一个结点比,hash值相等且key不为空,key的第一个结点的key的对象地址和值均相等
        //则返回第一个结点
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果key和第一个结点不匹配,则看.next是否为空,不为null则继续,为空则返回null
        if ((e = first.next) != null) {
            //如果此时是红黑树的结构,则进行处理getTreeNode()方法搜索key
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //是链表结构的话就一个一个遍历,直到找到key对应的结点,
            //或者e的下一个结点为null退出循环
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

get方法还是很简单的,就是计算位置,然后获取值。

以上就是我对hashMap的一些简单理解。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值