JDK 1.8 的 HashMap 详解: 为什么并发会出问题?甚至出现死循环导致系统不可用?...

HashMap 是非线程安全的,在多线程处理场景下,严禁使用。多线程要用ConcurrentHashMap。

大家都知道,相比于HashTable,HashMap是一个非线程安全的实现类。

为什么说HashMap是非线程安全的呢?因为在高并发情况下,HashMap在一些操作上会存在问题,如死循环问题,导致CPU使用率较高。

下面来看下怎么复现这个问题。如下代码所示,我们创建10个线程,这10个线程并发向一个HashMap种添加元素。

package com.light.sword

import java.util.*
import java.util.concurrent.atomic.AtomicInteger


/**
 * @author: Jack
 * 2020-02-21 13:52
 */

fun main() {
    val map = HashMap<Int, Int>()
    val atomicInt = AtomicInteger(0)

    for (i in 0..9) {
        Thread {
            while (atomicInt.get() < 1000000) {
                map[atomicInt.get()] = atomicInt.get()
                atomicInt.incrementAndGet()
            }
        }.start()
    }
}

我们运行main方法后,发现代码一直卡死并没有退出。CPU 飙到了 892% 。

1233356-538694d8e26e434e.png

接下来我们 jpsjstack 命令看下这个进程的状态。

$ jps
20642 Launcher
28630 Jps
23430 Launcher
6233 
28618 KotlinCompileDaemon
28621 Launcher
28622 HashMapLockDemoKt
$ jstack 28622
2020-02-21 18:36:50
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.40-b25 mixed mode):

"Attach Listener" #22 daemon prio=9 os_prio=31 tid=0x00007fdfda8c2000 nid=0x9b07 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"DestroyJavaVM" #21 prio=5 os_prio=31 tid=0x00007fdfdf044800 nid=0x2603 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Thread-8" #19 prio=5 os_prio=31 tid=0x00007fdfda891000 nid=0x9d03 runnable [0x000070000c461000]
   java.lang.Thread.State: RUNNABLE
    at java.util.HashMap$TreeNode.root(HashMap.java:1808)
    at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:1963)
    at java.util.HashMap.putVal(HashMap.java:637)
    at java.util.HashMap.put(HashMap.java:611)
    at com.light.sword.HashMapLockDemoKt$main$1.run(HashMapLockDemo.kt:19)
    at java.lang.Thread.run(Thread.java:745)

"Thread-7" #18 prio=5 os_prio=31 tid=0x00007fdfda890000 nid=0x5d03 runnable [0x000070000c35e000]
   java.lang.Thread.State: RUNNABLE
    at java.util.HashMap$TreeNode.root(HashMap.java:1808)
    at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:1963)
    at java.util.HashMap.putVal(HashMap.java:637)
    at java.util.HashMap.put(HashMap.java:611)
    at com.light.sword.HashMapLockDemoKt$main$1.run(HashMapLockDemo.kt:19)
    at java.lang.Thread.run(Thread.java:745)
。。。

"Service Thread" #10 daemon prio=9 os_prio=31 tid=0x00007fdfdd831800 nid=0x5603 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread3" #9 daemon prio=9 os_prio=31 tid=0x00007fdfda875800 nid=0x5503 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

看代码堆栈:

    at java.util.HashMap$TreeNode.root(HashMap.java:1808)
    at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:1963)
    at java.util.HashMap.putVal(HashMap.java:637)
    at java.util.HashMap.put(HashMap.java:611)
    at com.light.sword.HashMapLockDemoKt$main$1.run(HashMapLockDemo.kt:19)
    at java.lang.Thread.run(Thread.java:745)

从上面看到,在 HashMapTreeNode.root() 方法的第 1808 行出现了问题。堆栈入口是 HashMap.put(HashMap.java:611)

1233356-9c4ffb3c343665a0.png

HashMap 数据结构

1233356-4881bf7bea4a8d61.png

Java8中对HashMap进行了优化,如果链表中元素超过8个时,就将链表转化为红黑树,以减少查询的复杂度,将时间复杂度降低为O(logN)。

HashMap没有对多线程的场景下做任何的处理,不用说别的,就两个线程同时put,然后冲突了,两者需要操作一个链表/红黑树,这肯定就会有错误发生,所以HashMap是线程不安全的。

Node 和 TreeNode

Java 8 中使用 Node 模型来代表每个 HashMap 中的数据节点,都是 key,value,hash 和 next 这四个属性。Node 用于链表,红黑树用 TreeNode。

1233356-c1098784d383f9af.png

HashMap 中使用 Node[] table 数组来存储元素。可以看出,HashMap的底层还是数组(数组会在 put 的时候通过 resize() 函数进行分配),数组的长度为2的N次幂。

1233356-2c01cab4d7c3a640.png

TreeNode 中封装了对红黑树的基本操作:


1233356-181ab8bc6761d8ca.png

HashMap、Hashtable、ConccurentHashMap 三者的区别

HashMap线程不安全,数组+链表+红黑树
Hashtable线程安全,锁住整个对象,数组+链表
ConccurentHashMap 线程安全,CAS+同步锁、数组+链表+红黑树
HashMap的key,value均可为null,其他两个不行。

ConccurentHashMap 在 JDK1.7 和 JDK1.8 中的区别:

1233356-040bc938474b07c6.png
1233356-bf38e90682718326.png

这是Java7中实现线程安全的思路,ConcurrentHashMap由16个segment组成,每个segment就相当于一个HashMap(数组+链表)。

segment最多16个,想要扩容,就是扩充每个segment中数组的长度。

然后只要实现每个segment是线程安全的,就让这个Map线程安全了。每个segment是加锁的,对修改segment的操作加锁,不影响其他segment的使用,所以理想情况下,最多支持16个线程并发修改segment,这16个线程分别访问不同的segment。

同时,在segment加锁时,所有读线程是不会受到阻塞的。

这样设计,put与get的基本操作就是先找segment,再找segment中的数组位置,再查链表。

在JDK1.8主要设计上的改进有以下几点:

1、不采用segment而采用node,锁住node来实现减小锁粒度。
2、设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。
3、使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。
4、sizeCtl的不同值来代表不同含义,起到了控制的作用。
采用synchronized而不是ReentrantLock

java.lang.Thread.State 类

public static enum Thread.Stateextends Enum<Thread.State>线程状态。线程可以处于下列状态之一:

1.NEW
至今尚未启动的线程的状态。

2.RUNNABLE
可运行线程的线程状态。处于可运行状态的某一线程正在 Java 虚拟机中运行,但它可能正在等待操作系统中的其他资源,比如处理器。

3.BLOCKED
受阻塞并且正在等待监视器锁的某一线程的线程状态。处于受阻塞状态的某一线程正在等待监视器锁,以便进入一个同步的块/方法,或者在调用 Object.wait 之后再次进入同步的块/方法。

4.WAITING
某一等待线程的线程状态。某一线程因为调用下列方法之一而处于等待状态:

不带超时值的 Object.wait
不带超时值的 Thread.join

LockSupport.park
处于等待状态的线程正等待另一个线程,以执行特定操作。 例如,已经在某一对象上调用了 Object.wait() 的线程正等待另一个线程,以便在该对象上调用 Object.notify() 或 Object.notifyAll()。已经调用了 Thread.join() 的线程正在等待指定线程终止。

5.TIMED_WAITING具有指定等待时间的某一等待线程的线程状态。某一线程因为调用以下带有指定正等待时间的方法之一而处于定时等待状态:

Thread.sleep
带有超时值的 Object.wait
带有超时值的 Thread.join
LockSupport.parkNanos
LockSupport.parkUntil

6.TERMINATED
已终止线程的线程状态。线程已经结束执行。

注意:在给定时间点上,一个线程只能处于一种状态。这些状态是虚拟机状态,它们并没有反映所有操作系统线程状态。

参考资料
https://blog.csdn.net/majinggogogo/article/details/80036544
https://www.jianshu.com/p/5dbaa6707017

发布了1571 篇原创文章 · 获赞 627 · 访问量 61万+

猜你喜欢

转载自blog.csdn.net/universsky2015/article/details/104473921