HashMap扩展问题:HashMap如何实现线程安全?

发布时间:2023年12月25日

HashMap如何实现线程安全?

方法一:java.util.Collections.synchronizedMap(Map<K,V> m)

底层实际上是将hashMap又封装了一层,变成SynchronizedMap<K,V>,并在每一个对HashMap的操作方法上添加了synchronized修饰。代码如下:(扩展:synchronized的原理是什么?synchronized和ReentrantLock有什么区别?)

private static class SynchronizedMap<K,V>
        implements Map<K,V>, Serializable {
        private static final long serialVersionUID = 1978198479659022715L;

        private final Map<K,V> m;     // Backing Map
        final Object      mutex;        // Object on which to synchronize

        SynchronizedMap(Map<K,V> m) {
            this.m = Objects.requireNonNull(m);
            mutex = this;
        }
        public Set<K> keySet() {
            synchronized (mutex) {
                if (keySet==null)
                    keySet = new SynchronizedSet<>(m.keySet(), mutex);
                return keySet;
            }
        }

        public Set<Map.Entry<K,V>> entrySet() {
            synchronized (mutex) {
                if (entrySet==null)
                    entrySet = new SynchronizedSet<>(m.entrySet(), mutex);
                return entrySet;
            }
        }
		//部分代码省略
}

方法二:ConcurrentHashMap

1. jdk1.8中数据结构由Node数组+链表实现,而1.7中数据结构由Segement数组+Entry数组+链表组成。

1.7:
1.7版本的ConcurrentHashMap采?了分段锁(Segment)技术,其中Segment继承了ReentrantLock。(扩展:AQS) 在插?ConcurrentHashMap元素时,先尝试获得Segment锁,先是?旋获锁,如果?旋次数超过阈值,则转为ReentrantLock上锁。

ConcurrentHashMap 的扩容是仅仅和每个Segment元素中HashEntry数组的?度有关,但需要扩容时,只扩容当前Segment中HashEntry数组即可。也就是说ConcurrentHashMap中Segment[]数组的?度是在初始化的时候就确定了,后?扩容不会改变这个?度。
在这里插入图片描述1.8:
1.8版本放弃了Segment,跟HashMap?样,?Node描述插?集合中的元素。但是Node中的val和next使?了volatile来修饰,保存了内存可?性。与HashMap相同的是,ConcurrentHashMap 1.8版本使?了数组+链表+红?树的结构。
同时,ConcurrentHashMap使?了CAS+Synchronized保证了并发的安全性。

对每一个node节点的head头节点加锁。Synchronized主要用于扩容,CAS用来做查找,替换,赋值等操作。

读操作无锁:Node节点的val和next通过volatile修饰。数组通过volatile修饰。保证了数据的可见性。
在这里插入图片描述为什么弃用Segement而用Synchroniized?
· 减少内存开销,如果使用ReentrantLock则需要节点继承AQS来获取同步支持,增加内存开销,而1.8只有头部节点需要进行同步

· 内部优化:synchronized是jvm直接支持的,jvm能够运行时做出相应的优化措施,锁粗化,锁消除,锁自旋,这使得synchronized能够随着JDK版本升级而不用改动代码前提下获得性能提升。

2.采用了 (h ^ (h >>> 16)) & HASH_BITS 计算节点的hash。

h ^ (h >>> 16) 参考这里。我们可以看到,ConcurrentHashMap中,相比于HashMap多了一个 & HASH_BITS ,这个计算保证了计算出来的Hash一定是一个正数。而要保证是正数的原因是因为负数在ConcurrentHashMap中存在特殊含义,通过保证正数来防止产生冲突。代码如下:

	/*
     * Encodings for Node hash fields. See above for explanation.
     */
    static final int MOVED     = -1; // 标识该节点正在进行复制/移动(也就是数组在扩容)
    static final int TREEBIN   = -2; // 用于该节点是红黑树中的节点
    static final int RESERVED  = -3; // 标识这个节点正在被重新计算 只用于ConcurrentHashMap.compute方法和ConcurrentHashMap.computeIfAbsent方法
    static final int HASH_BITS = 0x7fffffff; // 用于spread()方法计算哈希值。保证计算出来的hash是个正数

3.使用了helpTransfer方法帮助扩容。(扩展:ConcurrentHashMap如何实现帮助扩容的?)

4.使用了((long)i << ASHIFT) + ABASE来获取某个元素在数组中的值,涉及到偏移量的计算,原理是二分查找可以参考这里的 ASHIFT偏移量计算

通过计算

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

如果只是为了背书,那么你看到这里就可以了。因为下面的东西很繁琐又臭又长。写出来只是为了加深我自己的印象和理解。


首先我是从put方法往下捋的,在通过int hash = spread(key.hashCode());计算出哈希之后,进入循环将当前入参封装成Node节点塞值。

循环中分成了三种情况处理:

循环中情况1:

  • 当table==null,也就是还没有初始化的时候,那么先初始化。
    这里涉及到的参数是transient volatile int sizeCtl;sizeCtl为-1的时候表示正在执行初始化操作,sizeCtl>0的时候表示扩容的阈值(比如容量为16,扩容因子为0.75时,sizeCtl = 12)。在第一次初始化完,没有做任何操作的时候,sizeCtl是等于2的N次幂的(也就是数组的容量),但当执行过put操作之后,sizeCtl就会变成扩容的阈值而不是容量。
初始化同样分了两种情况:
初始化情况1:
  • 如果sizeCtl<0,那么表示当前ConcurrentHashMap已经有线程正在执行初始化操作,那么当前线程就直接yield(),
    你可能会奇怪那我这里暂停了之后,我这次操作的数据难道不插入了? (扩展:线程的几种状态)
    所以我试了一下在循环里yield,如下:
while (true){
   System.out.println("111");
   Thread.yield();
}

在这里插入图片描述
所以我们知道,循环并不会因为线程的yield而结束。因此,当前线程只是从运行回到了就绪,然后再次被CPU调用重新执行循环。

初始化情况2:
  • 如果sizeCtl>=0,那么首先通过CAS将sizeCtl设置为-1(与上一点对应)。然后初始化一个容量为16的数组,设置扩容阈值sizeCtl 为12。
    sc = n - (n >>> 2)在这里等价于 n*0.75(扩容因子)。
	private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

循环中情况2:

  • (f = tabAt(tab, i = (n - 1) & hash)判断当前节点计算出的索引在数组中是否已经存在其他节点,如果不存在那么通过CAS将当前节点塞进去。
    (n - 1) & hash计算出当前节点在数组中的位置。
    (f = tabAt(tab, i = (n - 1) & hash)是获取tab数组中 i位置的值。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

((long)i << ASHIFT) + ABASE含义是计算索引为i的元素在内存地址中的偏移量。

循环中情况3:

  • 如果索引位置已经存在了节点,那么判断该节点的状态是否是正在被移动或者复制(也就是判断当前ConcurrentHashMap是否正在进行扩容)。如果索引位置的节点处于移动或复制的状态下,那么当前线程帮助扩容。(扩展:ConcurrentHashMap如何实现帮助扩容的?)

循环中情况4:

  • 索引位置已经存在了节点,并且不处于扩容状态,那么通过synchronize锁这个节点,然后又有两个判断if (tabAt(tab, i) == f) 和 if (fh >= 0)(扩展:synchronize双重校验锁)。如果上述判断都满足,则执行插入操作。

    判断是否ConcurrentHashMap中已经存在了相同Key的Node,如果存在那么根据入参onlyIfAbsent(默认是false,也就是新值会替换旧值)判断是否覆盖值。

    如果不存在,那么将当前节点插入到索引位置的链表的最后一个元素。如果是二叉树步骤和链表一样。最后判断链表长度是否>8,如果大于8那么将链表转换为二叉树。

在 putVal 最后调用 addCount方法

addCount方法涉及到的就是CounterCell数组。
CounterCell的设计很巧妙,它的背后其实就是JDK1.8中的LongAdder。核心思想是:在并发较低的场景下直接采用baseCount累加,否则结合counterCells,将不同的线程散列到不同的cell中进行计算,尽可能地确保访问资源的隔离,减少冲突。LongAdder相比较于AtomicLong中无脑CAS的策略,在高并发的场景下,能够减少CAS重试的次数,提高计算效率。(这一句话是抄过来的哈哈 这个文章写的很好很好推荐!)

ConcurrentHashMap通过维护了一个CounterCell[]数组和BASECOUNT来得到Node数组中元素的个数,也就是ConcurrentHashMap.size()方法获取的值。addCount方法维护个数的同时,会重新校验是否需要扩容,并在需要扩容的情况下扩容。

在这里插入图片描述

x 表示这次需要在表中增加的元素个数,check 参数表示是否需要进行扩容检查,大于等于 0 都需要进行检查。
在 putVal 最后调用 addCount方法,传递了两个参数,分别是 1 和 binCount(链表长度)。

private final void addCount(long x, int check) {...}

size()方法就是获取BASECOUNT,并且在CounterCell[]数组不为空时遍历数组获取每一个value累加得到最后的Size。

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

大家好,我是小羊炒饭。感谢大家的关注,有任何问题可以提出,我们一起学习共同进步。公司的前辈告诉我,想涨工资人情>技术,我不懂人情,所以我想深耕技术,希望有一天能涨工资哈哈。


这个ConcurrentHashMap里面真的各种CAS各种锁标识+状态。一行一行看下来真的太费劲了。

简单归纳一下我学到了啥叭:

1.通过维护一个数组,将多个线程对一个值的CAS转成了对一个值+数组中各个节点的CAS来提高CAS的效率。(CounterCell[] as的作用)
2.分段锁思想。
3.使用CAS的流程,或者说加锁的流程,我们可以考虑从悲观锁到乐观锁,从粗粒度锁(源码中是synchronized锁Node节点),再到细粒度锁(CAS)。
4.偏移量那里我理解是为了加快CAS性能的,但是底层不明白,因为调用的是native方法,还需要再研究研究。

文章来源:https://blog.csdn.net/qq_45948401/article/details/135123774
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。