• 面试官:为什么ConcurrentHashMap要放弃分段锁?


    今天我们来讨论一下一个比较经典的面试题就是 ConcurrentHashMap 为什么放弃使用了分段锁,这个面试题阿粉相信很多人肯定觉得有点头疼,因为很少有人在开发中去研究这块的内容,今天阿粉就来给大家讲一下这个 ConcurrentHashMap 为什么在 JDK8 中放弃了使用分段锁。

    什么是分段锁

    我们都知道 HashMap 是一个线程不安全的类,多线程环境下,使用 HashMap 进行put操作会引起死循环,导致CPU利用率接近100%,所以如果你的并发量很高的话,所以是不推荐使用 HashMap 的。

    而我们所知的,HashTable 是线程安全的,但是因为 HashTable 内部使用的 synchronized 来保证线程的安全,所以,在多线程情况下,HashTable 虽然线程安全,但是他的效率也同样的比较低下。

    所以就出现了一个效率相对来说比 HashTable 高,但是还比 HashMap 安全的类,那就是 ConcurrentHashMap,而 ConcurrentHashMap 在 JDK8 中却放弃了使用分段锁,为什么呢?那他之后是使用什么来保证线程安全呢?我们今天来看看。

    什么是分段锁?

    其实这个分段锁很容易理解,既然其他的锁都是锁全部,那分段锁是不是和其他的不太一样,是的,他就相当于把一个方法切割成了很多块,在单独的一块上锁的时候,其他的部分是不会上锁的,也就是说,这一段被锁住,并不影响其他模块的运行,分段锁如果这样理解是不是就好理解了,我们先来看看 JDK7 中的 ConcurrentHashMap 的分段锁的实现。

    在 JDK7 中 ConcurrentHashMap 底层数据结构是数组加链表,这也是之前阿粉说过的 JDK7和 JDK8 中 HashMap 不同的地方,源码送上。

    1. //初始总容量,默认16
    2. static final int DEFAULT_INITIAL_CAPACITY = 16;
    3. //加载因子,默认0.75
    4. static final float DEFAULT_LOAD_FACTOR = 0.75f;
    5. //并发级别,默认16
    6. static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    7. static final class Segment extends ReentrantLock implements Serializable {
    8. transient volatile HashEntry[] table;
    9. }

    在阿粉贴上的上面的源码中,有Segment,这个类才是真正的的主要内容, ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成.

    我们看到了 Segment,而他的内部,又有HashEntry数组结构组成. Segment 继承自 RentrantLock 在这里充当的是一个锁,而在其内部的 HashEntry 则是用来存储键值对数据.

    图就像下面这个样子

    也就是说,一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁。

    最后也就出现了,如果不是在同一个分段中的 put 数据,那么 ConcurrentHashMap 就能够保证并行的 put ,也就是说,在并发过程中,他就是一个线程安全的 Map 。

    为什么 JDK8 舍弃掉了分段锁呢?

    这时候就有很多人关心了,说既然这么好用,为啥在 JDK8 中要放弃使用分段锁呢?

    这就要我们来分析一下为什么要用 ConcurrentHashMap ,

    1.线程安全。

    2.相对高效。

    因为在 JDK7 中 Segment 继承了重入锁ReentrantLock,但是大家有没有想过,如果说每个 Segment 在增长的时候,那你有没有考虑过这时候锁的粒度也会在不断的增长。

    而且前面阿粉也说了,一个Segment里包含一个HashEntry数组,每个锁控制的是一段,那么如果分成很多个段的时候,这时候加锁的分段还是不连续的,是不是就会造成内存空间的浪费。

    所以问题一出现了,分段锁在某些特定的情况下是会对内存造成影响的,什么情况呢?我们倒着推回去就知道:

    1.每个锁控制的是一段,当分段很多,并且加锁的分段不连续的时候,内存空间的浪费比较严重。

    大家都知道,并发是什么样子的,就相当于百米赛跑,你是第一,我是第二这种形式,同样的,线程也是这样的,在并发操作中,因为分段锁的存在,线程操作的时候,争抢同一个分段锁的几率会小很多,既然小了,那么应该是优点了,但是大家有没有想过如果这一分块的分段很大的时候,那么操作的时间是不是就会变的更长了。

    所以第二个问题出现了:

    2.如果某个分段特别的大,那么就会影响效率,耽误时间。

    所以,这也是为什么在 JDK8 不在继续使用分段锁的原因。

    既然我们说到这里了,我们就来聊一下这个时间和空间的概念,毕竟很多面试官总是喜欢问时间复杂度,这些看起来有点深奥的东西,但是如果你自己想想,用自己的话说出来,是不是就没有那么难理解了。

    什么是时间复杂度

    百度百科是这么说的:

    1. 在计算机科学中,时间复杂性,又称时间复杂度,算法的时间复杂度是一个函数,它定性描述该算法的运行时间,
    2. 这是一个代表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数

    其实面试官问这个 时间复杂度 无可厚非,因为如果你作为一个公司的领导,如果手底下的两个员工,交付同样的功能提测,A交付的代码,运行时间50s,内存占用12M,B交付的代码,运行时间110s,内存占用50M 的时候,你会选择哪个员工提交的代码?

    A 还是 B 这个答案一目了然,当然,我们得先把 Bug 这种因素排除掉,没有任何质疑,肯定选 A 员工提交的代码,因为运行时间快,内存占用量小,那肯定的优先考虑。

    那么既然我们知道这个代码都和时间复杂度有关系了,那么面试官再问这样的问题,你还觉得有问题么?

    答案也很肯定,没问题,你计算不太熟,但是也需要了解。

    我们要想知道这个时间复杂度,那么就把我们的程序拉出来运行一下,看看是什么样子的,我们先从循环入手,

    1. for(i=1; i<=n; i++)
    2. {
    3.    j = i;
    4.    j++;
    5. }

    它的时间复杂度是什么呢?上面百度百科说用大O符号表述,那么实际上它的时间复杂度就是 O(n),这个公式是什么意思呢?

    线性阶 O(n),也就是说,我们上面写的这个最简单的算法的时间趋势是和 n 挂钩的,如果 n 变得越来越大,那么相对来说,你的时间花费的时间也就越来越久,也就是说,我们代码中的 n 是多大,我们的代码就要循环多少遍。这样说是不是就很简单了?

    关于时间复杂度,阿粉以后会给大家说,话题跑远了,我们回来,继续说,JDK8 的 ConcurrentHashMap 既然不使用分段锁了,那么他使用的是什么呢?

    JDK8 的 ConcurrentHashMap 使用的是什么?

    从上面的分析中,我们得出了 JDK7 中的 ConcurrentHashMap 使用的是 Segment 和 HashEntry,而在 JDK8 中 ConcurrentHashMap 就变了,阿粉现在这里给大家把这个抛出来,我们再分析, JDK8 中的 ConcurrentHashMap 使用的是 synchronized 和 CAS 和 HashEntry 和红黑树。

    听到这里的时候,我们是不是就感觉有点类似,HashMap 是不是也是使用的红黑树来着?有这个感觉就对了,

    ConcurrentHashMap 和 HashMap 一样,使用了红黑树,而在 ConcurrentHashMap 中则是取消了Segment分段锁的数据结构,取而代之的是Node数组+链表+红黑树的结构。

    为什么要这么做呢?

    因为这样就实现了对每一行数据进行加锁,减少并发冲突。

    实际上我们也可以这么理解,就是在 JDK7 中,使用的是分段锁,在 JDK8 中使用的是 “读写锁” 毕竟采用了 CAS 和 Synchronized 来保证线程的安全。

    我们来看看源码:

    1. //第一次put 初始化 Node 数组
    2. private final Node[] initTable() {
    3.         Node[] tab; int sc;
    4.         while ((tab = table) == null || tab.length == 0) {
    5.             if ((sc = sizeCtl) < 0)
    6.                 Thread.yield(); // lost initialization race; just spin
    7.             else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    8.                 try {
    9.                     if ((tab = table) == null || tab.length == 0) {
    10.                         int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    11.                         @SuppressWarnings("unchecked")
    12.                         Node[] nt = (Node[])new Node[n];
    13.                         table = tab = nt;
    14.                         sc = n - (n >>> 2);
    15.                     }
    16.                 } finally {
    17.                     sizeCtl = sc;
    18.                 }
    19.                 break;
    20.             }
    21.         }
    22.         return tab;
    23.     }
    1. public V put(K key, V value) {
    2.         return putVal(key, value, false);
    3.     }
    4.     /** Implementation for put and putIfAbsent */
    5.     final V putVal(K key, V value, boolean onlyIfAbsent) {
    6.         if (key == null || value == nullthrow new NullPointerException();
    7.         int hash = spread(key.hashCode());
    8.         int binCount = 0;
    9.         for (Node[] tab = table;;) {
    10.             Node f; int n, i, fh;
    11.             if (tab == null || (n = tab.length) == 0)
    12.                 tab = initTable();
    13.                 //如果相应位置的Node还未初始化,则通过CAS插入相应的数据
    14.             else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    15.                 if (casTabAt(tab, i, null,
    16.                              new Node(hash, key, value, null)))
    17.                     break;                   // no lock when adding to empty bin
    18.             }
    19.             else if ((fh = f.hash) == MOVED)
    20.                 tab = helpTransfer(tab, f);
    21.             ...
    22.            //如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点
    23.            else if (f instanceof TreeBin) {
    24.            Node p;
    25.            binCount = 2;
    26.            if ((p = ((TreeBin)f).putTreeVal(hash, key,value)) != null) {
    27.                oldVal = p.val;
    28.                if (!onlyIfAbsent)
    29.                    p.val = value;
    30.            }
    31.            //如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,返回旧值
    32.            if (binCount != 0) {
    33.                if (binCount >= TREEIFY_THRESHOLD)
    34.                    treeifyBin(tab, i);
    35.                if (oldVal != null)
    36.                    return oldVal;
    37.                break;
    38.            }
    39.        }
    40.         addCount(1L, binCount);
    41.         return null;
    42.     }

    put 的方法有点太长了,阿粉就截取了部分代码,大家莫怪,如果大家有兴趣,大家可以去对比一下去 JDK7 和 JDK8 中寻找不同的东西,这样亲自动手才能收获到更多不是么?

     

    如果本文对你有帮助,别忘记给我个3连 ,点赞,转发,评论,

    学习更多JAVA知识与技巧,关注博主学习JAVA 课件,源码,安装包,还有最新大厂面试资料等等等

    咱们下期见。

    收藏 等于白嫖,点赞才是真情。

     

  • 相关阅读:
    不使用辅助变量的前提下实现两个变量的交换
    ElasticSearch启动该正常无法连接或无法正常启动排查方案
    Spring6学习技术|IoC+基于xml管理bean
    丁鹿学堂:js之函数式编程的优势
    js中的原型链理解
    【code】Java List数据量大分批次操作
    Python性能测试框架Locust实战教程!
    String类
    Spring cloud 集成 SkyWalking 实现性能监控、链路追踪、日志收集
    【ARC133E】Cyclic Medians(数学)
  • 原文地址:https://blog.csdn.net/uuqaz/article/details/126249721