关于HashMap(整理)

本篇用来整理在网上学习到的关于HashMap的一些知识点。

目录

关于初始容量

为什么有负载因子?为什么是3/4?

为什么选择链表长度大于8才转化为红黑树?

如何计算存放位置?

1.7中HashMap扩容死循环

1.8中是怎样解决扩容死循环的

1.8的扩容优化

1.8中HashMap就不会发生死循环了么?


1.7中HashMap为 数组+链表;1.8中增加了 红黑树,当数组长度大于64,链表长度超过8(不包括)使用红黑树。(为什么是超过8变成红黑树?那什么时候会从红黑树退化成链表呢?)

关于初始容量

初始容量的默认大小为16,Map的容量永远会是2的次幂(为什么是2的次幂呢?待会儿解释),Map的容量是可以手动进行设置的,你甚至可以设置为0(但是不能为负数),设置为7的话,Map会自动变为大于7的一个2的次幂,也就是8。

负载因子:0.75f(也是可以自定义设置的;为什么有负载因子的存在?为什么负载因子就是0.75?待会儿解释)

例如容量为16的时候,当你要放入的元素是第 16*0.75f = 12 个元素时,Map便会扩容;

HashMap只有在第一次put的时候才会创建,懒加载机制;

根据要存的数据量去设置初始容量是一个好习惯,假如我们要装80个元素的时候,128大于80,128*0.75f也大于80,容量可以直接设置为128,减少存数据时HashMap扩容的次数。这只是举例,数据量小的时候提现不出来,在数据量很大是时候,效率会有明显提升。阿里巴巴开发手册中建议定义集合的容量,而不是置空。

为什么有负载因子?为什么是3/4?

假如有一个HashMap的容量现在为16,没有负载因子的情况下(也可以理解负载因子为1),当数据存到第16个数据的时候,HashMap才会进行扩容,假设我们要存入第15个数据,此时容量为16,已经放入了14个数据,那么此时的哈希冲突概率为14/16,HashMap要求低哈希冲突,这样显然是不符合的;所以,需要有负载因子的存在,去降低哈希冲突。负载因子的值为什么是0.75f呢?

上述可得,负载因子越大,哈希冲突的概率就越大,但是扩容次数会减少,反之负载因子越小,哈希冲突的概率就越小,但是扩容次数会因此增加;0.75f是在前辈们不断的测试中得出的一个折中(降低hash冲突的同时尽可能提升空间使用率)的数字。

在HashMap源码中,判断是否需要扩容,需要一行代码:oldTab.length * 3/4
oldTab就是执行resize之前的table,也就是扩容前的HashMap,由于HashMap会经常扩容,所以这行代码会经常执行;table.length * 3/4可以被优化为(table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.lenght >> 2),位运算的效率更高,3/4还兼顾了效率(前辈的代码写的真的太好了!)。

为什么选择链表长度大于8才转化为红黑树?

HashMap源码中有一段注释

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million

0代表着,当桶中有0个数据的概率为:0.60653066;
……
8代表着,当桶中有8个数据的概率为:0.00000006。

也就是说,因hash冲突存放在同一个桶中的数据量为8的概率为0.00000006,这是一个很小的数字,当出现这种情况的时候,该桶中的长链表才会转化为红黑树;这样做是为了降低树出现在hashmap中的概率,毕竟长度为7的长链表也算不上很长。

当桶中存放的长链表已经变为树的时候,该树存放的数据并不是小于8的时候又会降为链表,而是在树中数据量小于6的时候降为链表;这样是为了避免在增减一个数据的时候节点数据量在8上下反复横跳造成频繁的链表和树的相互转化,所以将这个阈值错开了。

如何计算存放位置?

用key和(容量-1)做 & 运算

此时HashMap的容量为2的次幂的作用就会完美体现出来,例如容量为16,二进制为10000,减1就是01111;

 可以看出,此时进入的4个不同的key值的数据,分别存入了四个不同的位置,有效降低了hash冲突,若是当容量不为2的次幂的时候呢?情况会是怎样的?

假如,HashMap的容量现在为10,10的二进制为01010,减1就是01001

可以看出,当容量为10的HashMap在存入四个不同key值的数据的时候,有多个计算出了在同一个位置(hash冲突),这样会使得三个数据存放在同一个数组节点中,会使得hash冲突严重,降低效率。

1.7中HashMap扩容死循环

HashMap是一个线程不安全的容器,在最坏的情况下,所有元素全部定位到同一个位置,形成一个长链表。

死循环只会出现在多线程情况下

JDK1.8以前采用头插法插入链表,即每次都在链表头部插入添加的数据;以下是1.7resize过程中的transfer(),此方法用来将扩容时旧链表中的元素倒插到新的链表中,也是导致HashMap扩容死循环的代码。

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

此时有一个HashMap如下图所示

扩容前:

若有两个线程需要插入新节点,恰好达到了loadFactor,两个线程中的HashMap都需要做resize;现在,线程1拿到了时间片进行resize,线程2在等待线程 1完成resize后resize。

这是线程1resize后的:

注意,头插法使得a和b节点的顺序反转;这个时候的HashMap中的 a.next = null,b.next = a,newTable[i] = b。

在线程2的视角内,HashMap还是最开始没有扩容的状态,但是实际上已经变为上图的状态了;

线程2开始执行resize,获取a节点next = null 之后执行 a.next = new Table[i],即变为 a.next = b 这样就出现a-b-a的环形链表 c节点因为a.next = null 的原因,直接丢失了 此时还不会出现内存占用百分百的情况,当我们进行查询的时候,就会进入循环。

1.8中是怎样解决扩容死循环的

使用尾插法,之前使用头插法,会使得链表倒插,现在只需要将新数据存放到链表尾部

1.8的扩容优化

1.7时,需要重新计算每一个hash值

1.8时,通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap(加旧容量)的位置,省去了rehash的步骤

 old:
   26: 0001 1010
   16: 0001 0000      (容量)
    &: 0001 0000    
    
new:
   26: 0001 1010
   32: 0010 0000      (扩容后的容量)
    &: 0000 0000

 两次的差别,只在于左边第一位从1变为0,这个变化的1则是oldCap(例如上述例子,变化的1就是十进制的16,旧容量);若第一位从0变为1,则当前位置加上容量,就是resize后该数据应存放的位置。

这样的设计既省去了重新计算hash值的时间,又因为最左位是0还是1可以认为是随机的,这样resize的过程中,还会吧之前冲突的节点分散开。

1.8中HashMap就不会发生死循环了么?

不多说,直接上代码

public class TestHashMap extends Thread{

    private static Map<Integer, Integer> map = new HashMap<Integer, Integer>(0);
    private static AtomicInteger at = new AtomicInteger(0);

    @Override
    public void run() {
        while(at.get()<1000000){
            map.put(at.get(), at.get());
            at.incrementAndGet();
        }
    }

    public static void main(String[] args) {
        for(int i=0;i<10;i++){
            Thread thread = new TestHashMap();
            thread.start();
        }
    }
}

如果没死循环,就多试几次

当程序不结束的时候,打开任务管理器,找到占用最高的java程序,找到PID号码,之后打开cmd,输入 jstack PID ,这样就可以得到报错信息

 

 

我试出了四个不同的报错信息(可能不止),将报错信息列出来

/**
 * java.lang.Thread.State: RUNNABLE
 *     at java.util.HashMap$TreeNode.root(HashMap.java:1824)
 *     at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:1978)
 *     at java.util.HashMap.putVal(HashMap.java:638)
 *     at java.util.HashMap.put(HashMap.java:612)
 *     at com.luck.ejob.server.MainTest$1.run(MainTest.java:151)
 *     at java.lang.Thread.run(Thread.java:748)
 *
 *
 *
 *     java.lang.Thread.State: RUNNABLE
 *     at java.util.HashMap$TreeNode.balanceInsertion(HashMap.java:2239)
 *     at java.util.HashMap$TreeNode.treeify(HashMap.java:1945)
 *     at java.util.HashMap$TreeNode.split(HashMap.java:2180)
 *     at java.util.HashMap.resize(HashMap.java:714)
 *     at java.util.HashMap.putVal(HashMap.java:663)
 *     at java.util.HashMap.put(HashMap.java:612)
 *     at com.luck.ejob.server.MainTest$1.run(MainTest.java:151)
 *     at java.lang.Thread.run(Thread.java:748)
 *
 *
 *
 * java.lang.Thread.State: RUNNABLE
 *         at java.util.HashMap$TreeNode.treeify(HashMap.java:1948)
 *         at java.util.HashMap$TreeNode.split(HashMap.java:2180)
 *         at java.util.HashMap.resize(HashMap.java:714)
 *         at java.util.HashMap.putVal(HashMap.java:663)
 *         at java.util.HashMap.put(HashMap.java:612)
 *         at main.TestHashMap.run(TestHashMap.java:22)
 *
 *
 *
 * java.lang.Thread.State: RUNNABLE
 *         at java.util.HashMap$TreeNode.split(HashMap.java:2144)
 *         at java.util.HashMap.resize(HashMap.java:714)
 *         at java.util.HashMap.putVal(HashMap.java:663)
 *         at java.util.HashMap.put(HashMap.java:612)
 *         at main.TestHashMap.run(TestHashMap.java:22)
 *
 */

大家有兴趣的可以去看一下,我粗略看了一下,大概是红黑树出现环了。

多线程情况下,一定要使用 ConcurrentHashMap!!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值