博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
HashMap的算法解析及高并发下死循环分析
阅读量:2443 次
发布时间:2019-05-10

本文共 5686 字,大约阅读时间需要 18 分钟。

HashMap是一个以空间换时间,内部以数组+链表\红黑树实现的散列表。HashMap的具体原理我们不做深入仔细分析,这类文章网上较多,且HashMap在面试中命中率极高。本文以jdk1.8为例,只分析里面我认为值得拿出来分析的有关数据结构和算法的部分来讲解。

HashMap的长度

JDK1.8实现

HashMap的初始默认长度是16.HashMap在jdk1.8上做了一层优化,创建时并没有创建Node数组,只有首次put元素的时候才创建。但是我们在创建时也可以指定数组的长度,HashMap会将长度重新转化为2的n次方。具体为什么是2的n次方,后面再分析。

初始长度源码及案例分析

//java.util.HashMap#tableSizeFor/**     * Returns a power of two size for the given target capacity.     */    static final int tableSizeFor(int cap) {        int n = cap - 1;        n |= n >>> 1;        n |= n >>> 2;        n |= n >>> 4;        n |= n >>> 8;        n |= n >>> 16;        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;    }

这里主要运用了位或操作和无符号右移操作。

位或操作:两个位都为0时,结果才为0
无符号右移:不论正负,高位均补0.可参考博客
tableSizeFor方法很简单,但是却充分用到了我们大一学到的大学计算机基础内容,主要是位运算和移位运算符,关于其他的可自行扩展(如左移( << )、右移( >> ) 、无符号右移( >>> ) 、位与( & ) 、位或( | )、位非( ~ )、位异或( ^ ))。
首先cap为我们传入的想要初始化数组的值,这里会对cap进行一个减一的操作,然后依次进行无符号右移和或运算。现假定我们传入默认长度16.

static final int tableSizeFor(int cap) {        //cap:10000        //n:1111        int n = cap - 1;        //n >>> 1:111   1111|111:1111        n |= n >>> 1;        //n >>> 2:11    1111|10:1111        n |= n >>> 2;        //n >>> 4:0     1111|0:1111        n |= n >>> 4;        //n >>> 8:0     1111|0:1111        n |= n >>> 8;        //n >>> 16:0    1111|0:1111        n |= n >>> 16;        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//return 16    }

假定传入13

static final int tableSizeFor13(int cap) {        //cap:1101        //n:1100        int n = cap - 1;        //n >>> 1:110   1100|110:1110        n |= n >>> 1;        //n >>> 2:10    1110|11:1111        n |= n >>> 2;        //n >>> 4:0     1111|0:1111        n |= n >>> 4;        //n >>> 8:0     1111|0:1111        n |= n >>> 8;        //n >>> 16:0    1111|0:1111        n |= n >>> 16;        //return 16        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;    }

从传入的16和13流程中得到的每一个结果,我们可以知道这里使用位或运算和无符号右移,就是把各个位都变成1。首先对于大于8但是小于等于16的数字,减去1之后,左起第4为肯定是1,这样通过不断的无符号右移,一位一位的把右边的0和1数字编程1,位或运算后,又把移位后的数字填充到当前n。

再详细说一下:对于大于8但是小于等于16,一定满足1*的位(代表0或1的任意数字)。
当进行第一步 n |= n >>>1 时,n >>> 1 = 1
,则 n |= n >>>1 = 11
.
这时n至少有前2个最高位均为1,那么就可以进行2位无符号右移。n >>> 2 = 1111,则n |= n >>>1 = 1111.
这时n至少有前4个最高位为1,那么就可以进行4位的无符号右移等等往下操作。所以这里必然可以得到一个2的n次方减1的数字,最后返回 一个2的n次方减1加1等于2的n次方。逻辑结束.

JDK1.7实现

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

jdk1.7中直接从2的0次方开始,每次翻倍,直到得到大于等于传入的initialCapacity的值,即table的size.

为什么是2的n次方

在java.util.HashMap#putVal方法中

if ((p = tab[i = (n - 1) & hash]) == null)            tab[i] = newNode(hash, key, value, null);

这里在计算key的hash值具体放在数组的哪个元素时,用到了位与运算,用hash值与(n-1)位与运算得到一个范围在(0-n-1)及数组下标范围的具体下标,效率比hash mod n 要快。除此之外,还有一个好处就是2的n次方减1的所有位均为1,这是可以进行位与操作的非常关键的信息,所以其值才等价于取模。

private static void getMod() {        long times = Integer.MAX_VALUE;        int n = (int) Math.pow(2d, 6d);        int index = 0;        long start = System.currentTimeMillis();        for (int i = 0; i < times; i++) {            index = i & (n - 1);        }        long end1 = System.currentTimeMillis();        for (int i = 0; i < times; i++) {            index = i % n;        }        long end2 = System.currentTimeMillis();        System.out.println("& cost : " + (end1 - start));        System.out.println("% cost : " + (end2 - end1));        //& cost : 1611        //% cost : 5933    }

扩容过程中的高并发问题

JDK1.7在高并发的死循环问题

先看下put过程中,对于有冲突时,会将新元素放在链表的最前面

void createEntry(int hash, K key, V value, int bucketIndex) {        Entry
e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }

JDK1.7正常的put过程中的resize中的transfer方法实现,会从链表的最前面往后开始复制到新数组中。

/**     * Transfers all entries from current table to newTable.     */    void transfer(Entry[] newTable, boolean rehash) {        int newCapacity = newTable.length;        for (Entry
e : table) { while(null != e) { Entry
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的原数组元素一次取出,并遍历链表,对每个元素重新选取新数组的下标,并放在新数组元素的最前面。正常情况下,假设原数组长度为16,扩容后数组长度为32,假设元素63和31均在原数组下标为15,63的next是31。resize后63和31均在下标为31的元素上,且31的next为63.所以正常情况下,resize后,可能会出现这种元素先后关系倒置的链表。

那么高并发情况下,假设有两个线程同时对上述场景进行resize操作,线程一在运行到if (rehash)这步时,e=63,next=31,即63->31,线程一被调度挂起。线程二现在也进行resize操作,且正常完成,则线程二的关系为31指向63,即31->63。

线程一继续运行

  1. 当前线程一状态e=63,next=31
  2. 继续运行,newTable[i]=e=63,e=next=31,由于e不为空,继续运行while循环
  3. e=31,next=63,则newTable[i] = e = 31,则运行e.next=newTable[i]=63后,线程一的newTable[i]指向关系为31->63
  4. 继续运行e=next=63,next=null, 运行e.next=newTable[i],则63.next=newTable[i]=31->63,即63->31->63,形成了一个环。
  5. resize过程正常结束,但是链表中形成了一个环
  6. 此时调用map.get(127),则一直会在会在newTable[31]处一直死循环e=e.next,引起CPU飙升到100%

JDK1.8怎么解决这个问题

JDK1.8的处理方式是

for (int binCount = 0; ; ++binCount) {                    if ((e = p.next) == null) {                        p.next = newNode(hash, key, value, null);                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st                            treeifyBin(tab, hash);                        break;                    }                    if (e.hash == hash &&                        ((k = e.key) == key || (key != null && key.equals(k))))                        break;                    p = e;                }

这里只有当p.next为null时,才创建新的Node节点作为链表最后一个元素。

if ((e = p.next) == null) {                        p.next = newNode(hash, key, value, null);

这时在resize并发的情况下,及时会从链表前面开始复制到新数组中,也不会出现链表元素相互指向next形成环的问题了。

转载地址:http://rqnqb.baihongyu.com/

你可能感兴趣的文章
“管家婆”软件用于维修管理 (转)
查看>>
第13章 术 语 大 全 (8) (转)
查看>>
第13章 术 语 大 全 (9) (转)
查看>>
人月神话读书笔记(二) (转)
查看>>
附录 UML元模 (转)
查看>>
A Brief Look at C++ 中文版 (转)
查看>>
JBuilder Editor中光标不能正确定位问题的解决 (转)
查看>>
XML加ASP实现网页“本地化” (转)
查看>>
Java中的异步网络编程 (转)
查看>>
用于核心模式驱动程序的网络体系结构(1) (转)
查看>>
More Effective C++ 条款20 (转)
查看>>
一个程序员的爱恋 (转)
查看>>
足球战术->边锋之Decorator篇 (转)
查看>>
编写优质无错代码(1) (转)
查看>>
MySQL 4.1.0 中文参考手册 --- 6.3 用于 SELECT 和 WHERE 子句的函数 (1) (转)
查看>>
vs.net beta 2中利用DataGrid分页详解 (转)
查看>>
Process-Display-Process (PDP) pattern (转)
查看>>
基于构件复用的软件方法与COM支持 (转)
查看>>
DELPHI中使用API函数详解 (转)
查看>>
Single Entry Point to EJB Layer (转)
查看>>