JDK1.7中的ConcurrentHashMap底层原理详解

JDK1.7中的ConcurrentHashMap底层原理详解

温故而知新

既然聊到ConcurrentHashMap,那么肯定得聊一聊并发编程下的安全问题,众所周知,ConcurrentHashMap是专门为了解决并发时候HashMap出现的安全问题而推出的 ,该类位于 java.util.concurrent 包下。 那么先让我们看看下面这个问题。

HashMap为什么线程不安全?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NfVUrL3P-1650795174171)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\1650718079888.png)]

  • 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
  • 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
  • put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。

乍一看,这个答案似乎是十分的perfect,然后事实并不是这样的。这个答案存在一个很大错误,这也是为什么今天我在文章中增加了温故而知新这个部分。最近也是在坚持看面经,对于上述问题,我之前整理的答案也就是上面这个段落,但是很幸运在面试被问到这个问题之前,我发现了这个答案中的错误,就是在JDK1.8当中,HashMap也有可能存在环形链表的问题。

这里有一点需要注意,在JDK1.7当中环形单链表带来的是死循环的问题,而在JDK1.8当中存在环形双向链表带来的死循环问题,为什么是环形双向链表呢?因为在红黑树其实也采取了类似双向链表的结构进行存储和操作,因此如果面试官问到HashMap在JDK1.8中是否存在死循环?,那就可以肯定的回答:存在。如果面试官闻到HashMap在JDK1.8中是否存在环形链表的问题?那么最好先解释一下,这个环形链表具体指的是什么,然后再去回答,jdk1.8的HashMap在多线程的情况下也会出现死循环的问题,但是在JDK1.8中在链表转换成红黑树或者是对红黑树进行操作的时候,可能会对红黑树进行重构,并且是在并发操作的情况下,才有可能出现这个问题,与JDK1.7发生的原因不一样。

注意:这个红黑树和双向链表挂钩的结论只是博主个人的粗浅认识,如不恰当,欢迎各位指正!

综上所述,HashMap无论是JDK1.7还是JDK1.8,在并发环境下都会有很大的安全隐患,因此工作中基本不会使用HashMap,而是使用ConcurrentHashMap这个JUC包下面的并发编程类。

ConcurrentHashMap简单说明(JDK1.7)

ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。

分段锁示意图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

ConcurrentHashMap内部变量的含义

//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认容量
private static final int DEFAULT_CAPACITY = 16;
//最大的数组长度,toArray方法需要
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//链表树化的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树变成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表需要树化的最小容量要求
static final int MIN_TREEIFY_CAPACITY = 64;
//在进行扩容时单个线程处理的最小步长。
private static final int MIN_TRANSFER_STRIDE = 16;
//sizeCtl 中用于生成标记的位数。对于 32 位数组,必须至少为 6。
private static int RESIZE_STAMP_BITS = 16;
//可以帮助调整大小的最大线程数。必须适合 32 - RESIZE_STAMP_BITS 位。
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//在 sizeCtl 中记录大小标记的位移。
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//哈希表中的节点状态,会在节点的hash值中体现
static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash

//基本计数器值(拿来统计哈希表中元素个数的),主要在没有争用时使用,但也可作为表初始化竞赛期间的后备。通过 CAS 更新。
private transient volatile long baseCount;
//表初始化和调整大小控制。如果为负数,则表正在初始化或调整大小:-1 表示初始化,否则 -(1 + 活动调整大小线程的数量)。否则,当 table 为 null 时,保存要在创建时使用的初始表大小,或者默认为 0。初始化后,保存下一个元素计数值,根据该值调整表的大小。
private transient volatile int sizeCtl;
//调整大小时要拆分的下一个表索引(加一个)。
private transient volatile int transferIndex;
//调整大小和/或创建 CounterCell 时使用自旋锁(通过 CAS 锁定)。
private transient volatile int cellsBusy;
//计数单元表。当非空时,大小是 2 的幂。   与baseCount一起记录哈希表中的元素个数。
private transient volatile CounterCell[] counterCells;

ConcurrentHashMap的构造函数

首先看看JDK1.7中 ConcurrentHashMap 的无参构造方法,无参构造直接调用了一个有参构造方法,也就是我们没有传入参数时,它会利用默认值进行实例的创建。

简单说明:

有参构造方法:
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel);

第一个参数是initialCapacity:指所有的Segment对象里面的数组长度总和。默认为16;

第二个参数是loadFactor:负载因子,这个就不多说了;

第三个参数是concurrencyLevel:表示最多能支持多少个线程同时执行,也就是segment数组的长度,初始时默认值为16
    /**
     * Creates a new, empty map with a default initial capacity (16),
     * load factor (0.75) and concurrencyLevel (16).
     */
    public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

要点理解:

在JDK1.7中对并发问题的处理思想:把大的HashMap分成多个小的HashMap,一个Segment的对象里面有一个小的HashMap,然后对每个Segment加锁,这就是分段锁,因此源码中也会有两个主要的数组。
    
通过key的hashcode计算一次hash值,然后计算两次下标。
第一次是Segment数组的下标。第二次计算Segment对象中的内部的Entry数组的下标。

无论是Segment数组,还是HashEntry数组,对HashMap进行操作的过程中,计算元素对应在两个数组的下标都是通过与运算得到的,因此两个数组的长度都需要是2的n次幂。方法中通过两个while循环来进行长度规则的保证。
// Find power-of-two sizes best matching arguments  这个官方在源代码中的注释

为了简便Segment对象的创建(构造方法中各个参数的确定),初始化时会创建一个Segment对象放在Segment数组的第一个位置,也就是segment[0],起到类似模板的作用。

接着来看下这个有参构造函数的内部实现逻辑,具体还是看注释。

public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
    // 参数校验
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    // 校验并发级别大小,大于 1<<16,重置为 65536
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    // Find power-of-two sizes best matching arguments
    // 2的多少次方
    int sshift = 0;
    int ssize = 1;
    // 这个循环可以找到 大于等于 concurrencyLevel 的 2的次方值 的最小值
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    // 记录段偏移量
    this.segmentShift = 32 - sshift;
    // 记录段掩码(用作hash值计算)
    this.segmentMask = ssize - 1;
    // 设置容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 这里是计算每个 Segment 中 HashEntry 的个数  就是把HashMap分成很多个小的HashMap,每个小HashMap的容量
    // c = initialCapacity(总容量) / ssize(Segment的长度) ,默认情况下 16 / 16 = 1
    int c = initialCapacity / ssize;
    // 进行向上取整
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    // 这个循环可以找到 大于等于 c(每个小的HashMap容量) 的 2的次方值 的最小值
    while (cap < c)
        cap <<= 1;
    // create segments and segments[0]
    // 创建 Segment 数组,设置 segments[0],这个操作是为后续的Segment对象创建提供模板
    Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}

ConcurrentHashMap的put()方法和ensureSegment()方法

初始化各个参数之后,接下来查看 put() 方法源码。

大概说明:

从代码中,很明显可以看出来,这个方法没有进行加锁对并发进行控制,所以不同的线程可能同时执行ensureSegment()方法来生成segment对象,因此可能产生多个segment对象,但是实际上我们在数组中每个位置的segment对象只有一个。
public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask = 15 做与运算
    // 其实也就是把高4位与segmentMask(1111)做与运算
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        // 如果查找到的 Segment 为空,初始化生成该位置的Segment对象
        s = ensureSegment(j);
    // 调用Segment对象的put()方法
    return s.put(key, hash, value, false);
}

从这里可以看到,put方法中实现的功能其实不多,下面直接进入到ensureSegment()方法。

要点解析:

有一点在文章前面已经提到过,Segment数组中segment[0]中的Segment对象起到一个模板的作用,提供生成Segment对象的一些信息,比如内部Entry数组的大小。这里就很好的进行了利用。
// use segment 0 as prototype   官方注释的解释
    
这段代码看起来也没有通过lock方法或者synchronized进行加锁。但是注意看代码后面部分的while循环,最终通过CAS + 自旋(可以看作是乐观锁),保证Segment对象不会被覆盖(每个位置有且只有第一个创建的Segment对象)。

例:
假如有两个线程都进入自旋,先自旋成功的线程生成Segment对象,进入if语句通过break跳出循环,返回seg;
后面的线程由于if语句判断为false,再次循环,后来发现该位置已经存在Segment对象,也就是判断不为null,因此会直接获得Segment对象,不会进入循环体,而是直接返回seg(这个Segment对象就是前面的线程创建的)。
private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    // 判断 u 位置的 Segment 是否为null(在put()方法中判断过了,这里需要再次判断,实现了双重判断)
    // 如果为null,if语句内部执行,尝试生成Segment对象
    // 如果不为null,直接返回这个Segment对象
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        Segment<K,V> proto = ss[0]; // use segment 0 as prototype
        // 获取0号 segment 里的 HashEntry<K,V> 初始化长度
        int cap = proto.table.length;
        // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的
        float lf = proto.loadFactor;
        // 计算扩容阀值
        int threshold = (int)(cap * lf);
        // 创建一个 cap 容量的 HashEntry 数组
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck
            // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作(在自旋检查前,已经检查三次了)
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 自旋检查 u 位置的 Segment 是否为null
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                // 使用CAS 赋值,只会成功一次
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

下面来整理一下这部分代码的一个流程:

①计算要 put 的 key 在 Segment 数组中的位置,获取指定位置的 Segment 对象;

②如果指定位置的 Segment 对象为 null,则初始化这个 Segment 对象;

初始化 Segment 流程:

  1. 检查计算得到的位置的 Segment 是否为null;
  2. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组;
  3. 再次检查计算得到的指定位置的 Segment 是否为null;
  4. 使用创建的 HashEntry 数组初始化这个 Segment;
  5. 自旋判断指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为新创建的 Segment 对象。

③Segment调用它自己的put()方法, 传入 key,value 等相关参数值。(不要漏了这一步)

Segment的put()方法和scanAndLockForPut()方法

首先要注意一点,Segment继承了ReentrantLock类,因此是可以直接调用lock()tryLock()unLock()等方法的。

在这里插入图片描述
对于lock()tryLock()方法的比较。

tryLock()方法:不会对线程产生阻塞作用,如果加锁失败,返回false;加锁成功,返回true。

lock()方法:如果当前的锁被其他线程占有,那么当前线程会直接进入到阻塞状态。

在这里先多提一点:tryLock()的自旋,自旋可以起到阻塞的作用,但是并不是完全阻塞,这个逻辑可以处理一些其他的代码。
while(!tryLock()){
	处理逻辑
}

分段锁说明:

下面来到正题,我们终于在Segment的put()方法中看到了lock()方法,也就是加锁。
通过lock()加锁,是一个对象锁,也就是每个Segment对象都会有一把锁,因此不同的Segment对象拥有不同的锁,这也就是所谓的分段锁。只有相同的Segment对象调用它们的put()方法的时候,才存在锁的竞争。

具体的 Segmentput()方法的逻辑还是通过注释来梳理。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 获取 ReentrantLock 独占锁;获取不到锁,也就是加锁失败,调用scanAndLockForPut()方法。
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        // 计算要put的数据位置
        int index = (tab.length - 1) & hash;
        // CAS 获取 index 坐标的值
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else {
                // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 容量大于扩容阀值,小于最大容量,进行扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

流程梳理:

①首先通过tryLock()方法 获取锁,获取不到使用 scanAndLockForPut() 方法继续获取;

②计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 对象;

③遍历 put 新元素,因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,有两种情况需要处理;

​ a.如果这个位置上的 HashEntry对象不存在

​ 1、如果当前容量大于扩容阀值,小于最大容量,进行扩容

​ 2、直接通过头插法进行节点插入。

​ b.如果这个位置上的 HashEntry 存在

​ 1、判断链表当前元素 Key 和 hash 值是否和要 put 的 key 和 hash 值一致,如果有相同的 key 值则覆盖掉;

​ 2、如果不存在当前的 key 值,跟a情况一样。

​ 如果当前容量大于扩容阀值,小于最大容量,进行扩容

​ 直接链表头插法插入。

④如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null。

下面这部分是重点,比起HashTable简单使用synchronizied对方法进行加锁,为什么ConcurrentHashMap效率会更高?

这个方法里面其实利用了自旋锁的思想,在等待锁的过程中,可以遍历链表,可以先生成Entry对象,提高效率。

部分要点说明:

同一个key,不需要生成新的Entry对象,只需要修改原来Entry对象中的value值。

retries是一个计数器(标识符),当retries = -1,进行链表的遍历,当遍历结束后;retries = 0,此时充当计数器的作用,记录空转的次数。

(retries & 1) == 0 通过位运算判断retries是否为偶数

遍历完链表,并不一定当前准备好的HashEntry就是正确的,由于其他线程可能对链表产生修改,需要当前线程重新遍历链表。
// re-traverse if entry changed   官方注释的说明
    
空转次数过多,进入阻塞?
因为空转时,我们的线程需要一直运行但是又处理不了任何任务(链表早已遍历完),所以进行阻塞,减少资源浪费。

scanAndLockForPut()方法的详细分析请看注释。

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    // 获取该hash对应位置链表的头节点
    HashEntry<K,V> first = entryForHash(this, hash);
    // 链表第一个节点
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    // 充当一个计数器的作用
    int retries = -1; // negative while locating node
    // 自旋获取锁
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        
        // 进入这个if语句,就是遍历链表的实现
        if (retries < 0) {
            
            // 遍历到了链表尾部
            if (e == null) {
                if (node == null) // speculatively create node
                    node = new HashEntry<K,V>(hash, key, value, null);
                // 已经生成了新的Entry对象,不会走第一个if语句
                retries = 0;
            }
            
            // 已经存在相同key的Entry,也不会走第一个if语句,即不会再创建一个新的HashEntry
            else if (key.equals(e.key))
                retries = 0;
            else
                // 遍历链表的逻辑
                e = e.next;
        }
        
        // 记录空转的次数,也就是已经遍历完链表了,也没有其他事情需要处理,进行空转
        // MAX_SCAN_RETRIES根据CPU核心数赋值,多核64,单核1,也就是多核会空转64次,单核会空转1次
        else if (++retries > MAX_SCAN_RETRIES) {
            // 链表遍历完了,自旋达到指定次数后,阻塞等到只到获取到锁
            // 一直while也消耗性能
            lock();
            break;
        }
        // (retries & 1) == 0这个语句的逻辑就是判断retries是否为偶数
        // 如果是偶数,获取该hash对应位置链表的当前头节点,和之前获取到的first头节点比较,判断是否相等
        // 如果不相等,说明链表被修改了(抢到锁的线程修改了头节点),重新遍历链表(由于头节点修改了,之前做的就成了无用功,需要重新进行遍历)
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

小结:

该部分比较重要,因此在源代码的每一步基本都做了具体的说明,流程就不再梳理了。

简而言之:scanAndLockForPut()就是在不断的自旋,通过 tryLock() 方法尝试获取锁。并且在此期间遍历链表,获取下 hash 位置的 HashEntry。当自旋次数大于指定次数时,使用 lock() 阻塞获取锁。

rehash()方法和get()方法

最后这一部分比较简单,大概跟着源码+注释过一遍就差不多了。

要点注意:

ConcurrentHashMap 的扩容是翻倍的,这是为了方便通过 hash 值计算下标。

老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize。

rehash()方法分析

private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    // 老容量
    int oldCapacity = oldTable.length;
    // 新容量,扩大两倍
    int newCapacity = oldCapacity << 1;
    // 新的扩容阀值 
    threshold = (int)(newCapacity * loadFactor);
    // 创建新的数组
    HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩码,默认2扩容后是4,-1是3,二进制就是11。
    int sizeMask = newCapacity - 1;
    for (int i = 0; i < oldCapacity ; i++) {
        // 遍历老数组
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。
            int idx = e.hash & sizeMask;
            if (next == null)   //  Single node on list
                // 如果当前位置还不是链表,只是一个元素,直接赋值
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // 如果是链表了
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                // 新的位置只可能是不便或者是老的位置+老的容量。
                // 遍历结束后,lastRun 后面的元素位置都是相同的
                for (HashEntry<K,V> last = next; last != null; last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。
                newTable[lastIdx] = lastRun;
                // Clone remaining nodes
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    // 遍历剩余元素,头插法到指定 k 位置。
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 头插法插入新的节点
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

对于get()方法,那就更加简单了,首先通过 key 值找到对应目标存放的位置,然后遍历对应位置的链表,匹配 key 值找到对应的 value 值并返回。

get()方法分析

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 计算得到 key 的存放位置
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            // 如果是链表,遍历查找到相同 key 的 value。
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

相关问题

为什么用与操作而不用取余操作?

计算机最底层的操作是位运算。而取余操作有很多次的除法,属于比较高级的运算,而除法对应底层是通过减法实现的,减法最终也是通过位运算得到。相比起用取余操作,直接利用底层的位运算进行下标的计算,性能更好,但是这对数组的长度有要求,必须是2的幂次方。

ConcurrentHashMap在JDK1.7当中是怎么实现并发的需求的?

答:通过Segment数组和HashEntry数组对HashMap实现分段,并且对每个Segment对象都分配一把可重入锁,从而实现分段锁,以此确保线程安全。(这个答案没什么问题,但是通过本文分析,最好再加上,对于Segment对象创建时的并发情况,是通过自旋 + CAS实现的)

使用ConcurrentHashMap一定线程安全?

既然都这么问到了,那必然是不一定啦!

来看看下面这段代码,代码逻辑很简单,有一个ConcurrentHashMap类型的map,首先存了一个<“orange”,0>的键值对,然后在并发的条件下,对这个key进行a和b两个操作。

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ConcurrentHashMapDemo {
    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        while(count < 10) {
            ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
            map.put("orange", 1);
            ExecutorService executorService = Executors.newFixedThreadPool(100);
            for (int i = 0; i < 1000; i++) {
                executorService.execute(new Runnable() {
                    @Override
                    public void run() {
                        // a操作
                        int key = map.get("orange") + 1;
                        // b操作
                        map.put("orange", key);
                    }
                });
            }
            TimeUnit.MILLISECONDS.sleep(1000);
            System.out.println("第"+ ++count+"次实验,循环操作1000次后结果为:" + map.get("orange"));
            executorService.shutdown();
        }
    }
}

正常来说,最终结果输出的结果应该是等于1000的,那么下面我们来看看结果。

第1次实验,循环操作1000次后结果为:851
第2次实验,循环操作1000次后结果为:967
第3次实验,循环操作1000次后结果为:957
第4次实验,循环操作1000次后结果为:974
第5次实验,循环操作1000次后结果为:979
第6次实验,循环操作1000次后结果为:956
第7次实验,循环操作1000次后结果为:959
第8次实验,循环操作1000次后结果为:981
第9次实验,循环操作1000次后结果为:989
第10次实验,循环操作1000次后结果为:939

没有一次结果是正确的,这是为什么呢?

我们来分析一下,假如现在又一个线程1,进入方法执行了语句map.get("orange");拿到了对应的value值;这个时候,get()方法结束,这个Segment对象锁的使用权被线程2夺走了,线程2执行了语句map.get("orange");拿到了对应的value值,这个value值和线程1拿到的value值相等,那么就会导致结果出错。

解决方法有两种

方案一 通过synchronized锁解决

	// 对于两个非原子性操作的语句,利用synchronized对其进行加锁处理
    synchronized(this){
        // a操作
        int key = map.get("orange") + 1;
        // b操作
        map.put("orange", key);
    }

方案二 通过原子类进行解决

    AtomicInteger integer = new AtomicInteger(0);
    map.put("orange", integer);
...
...
...
    public void run() {
   		 map.get("orange").incrementAndGet();
	}

这里提醒我们,在使用ConcurrentHashMap,或者其他的线程安全的容器比如Vector,也会存在这样的问题,容器各个操作之间原子性问题也会导致我们程序出错,所以在使用这些容器的时候还是不能大意。

总结

感觉也没什么好说的了,本来准备看JDK1.8中ConcuurentHashMap的源码,奈何脑子不够用,只能够先啃一下JDK1.7的,最后还是上两张图进行总结吧。

ConcurrentHashMap底层结构图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gfbzzP7v-1650795174175)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\1650728528978.png)]

ConcurrentHashMap底层原理流程图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iTvst0Ps-1650795174176)(C:\Users\86135\Desktop\ConcurrentHashmap\ConcurrentHashMap底层原理 .png)]

补充:HashTable

HashTable直接通过synchronized修饰方法,即便不同线程没有在同一位置,都需要阻塞等待,进行排队,最后获得该对象锁的线程才能顺利往下执行,也就是并发度为1。但是很显然如果不同的线程操作的位置不同,是可以同时对这个哈希表进行操作的。比起HashMapConcurrentHashMap,性能都比较差。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iFKPDRjH-1650795174176)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\1650728215426.png)]

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值