小蔡学Java

HashMap之线程安全(ConcurrentHashMap)(三)

2024-01-08 23:02 969 0 Java基础 集合Java

HashMap线程安全吗?

当然不安全了,原因如下:

  1. put 方法中的非原子性操作。
  2. 扩容时的非原子性操作。

put 方法中的非原子性操作

在 HashMap 的 put 方法中,会涉及到多个步骤,包括计算键的哈希值找到对应的桶处理哈希冲突等。这些步骤在没有外部同步的情况下不是原子的,所以在多线程并发时可能会出现问题。

假设有两个线程 A 和 B 同时尝试 put 同一个键到 HashMap 中:

  1. 线程 A 计算了哈希值并找到了对应的桶。
  2. 线程 B 在线程 A 还未完成 put 操作时,也计算了相同的哈希值并尝试修改同一个桶。
  3. 如果线程 A 在线程 B 修改桶之后才完成 put 操作,那么线程 B 的修改可能会被线程 A 的操作覆盖,从而导致数据丢失。

扩容时的非原子性操作

当 HashMap 中的元素数量超过阈值时,会触发扩容操作。扩容涉及到以下几个步骤:

  1. 创建一个新的数组。
  2. 重新计算所有键的哈希值。
  3. 将原数组中的元素迁移到新数组中。

这个过程也是非原子的,所以在多线程并发时也可能会出现问题。

假设有两个线程 A 和 B:

  • 线程 A 检测到 HashMap 需要扩容,并开始创建新的数组。
  • 线程 B 在线程 A 完成扩容之前,向 HashMap 中添加了一个新的元素。
  • 线程 A 完成扩容后,可能没有考虑到线程 B 添加的新元素,从而导致数据丢失。

当然以上是表面,下面我们来专研一下吧,这主要分为JDK1.7 和 JDK 1.8 的情况 JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap#transfer(),具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。 JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap#putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

改善: 数据丢失、死循环已经在在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到HashMap#transfer(),因为JDK1.8直接在HashMap#resize()中完成了数据迁移。

2、HashMap线程不安全的体现: JDK1.7 HashMap线程不安全体现在:死循环、数据丢失 JDK1.8 HashMap线程不安全体现在:数据覆盖

二、HashMap线程不安全、死循环、数据丢失、数据覆盖的原因

JDK1.7 扩容引发的线程不安全

HashMap的线程不安全主要是发生在扩容函数中,其中调用了JDK1.7 HshMap#transfer():

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> 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的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。

扩容造成死循环和数据丢失 假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:

正常扩容后的结果是下面这样的:

但是当线程A执行到上面transfer函数的第11行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:

此时线程A中:e=3、next=7、e.next=null

当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移

重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table都是最新的,也就是说:7.next=3、3.next=null。

随后线程A获得CPU时间片继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:

接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,此时next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:

此时没任何问题。

上轮next=3,e=3,执行下一次循环可以发现,3.next=null,所以此轮循环将会是最后一轮循环。

接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示:

上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。 并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。

JDK1.8中的线程不安全

上面的扩容造成的数据丢失、死循环已经在在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到HashMap#transfer(),因为JDK1.8直接在HashMap#resize()中完成了数据迁移。

为什么说 JDK1.8会出现数据覆盖的情况? 我们来看一下下面这段JDK1.8中的put操作代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                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;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。

补充

解析以下我对JDK1.7源码中10-12行的理解:

示列代码:

public class HashMapTransferTest {

    static class Entry {
        Integer value;
        Entry next;

        Entry(Integer value, Entry next) {
            this.value = value;
            this.next = next;
        }
    }


    public static void main(String[] args) {
        Entry entry = new Entry(1, new Entry(2, new Entry(3, null)));
        Entry[] table = new Entry[] {entry};

        Entry entry2 = new Entry(4, new Entry(5, new Entry(6, null)));
        Entry[] newTable = new Entry[] {entry2};

        for (Entry e : table) {
            while(null != e) {
                Entry next = e.next;

                int i = 0; // 此处暂且固定为0,以便显示出链表顺序的翻转

                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }

        // 输出 1->4->5->6->null,因第1次执行while循环时,table[0]=1已经和链表2->3->null断开,table[0]和newTable[0]重组成新链表,
        // 第2次while循环的是断开的链表表2->3->null,和table[0]没有关系,所有table不再发生变化,所以table最终输出为1->4->5->6->null。
        System.out.println(table);

        // 输出 3->2->1->4->5->6->null
        System.out.println(newTable);
    }
}

table

newTable

下面是演变过程 因为table数组长度为1,所以for循环只循环1次 因为table[0]的链表长度为3,所以while循环要循环3次

第1次while循环

第2次while循环

第3次while循环

如何使HashMap在多线程情况下进行线程安全操作?

使用 Collections.synchronizedMap(map),包装成同步Map,原理就是在HashMap的所有方法上synchronized。

例如:Collections.SynchronizedMap#get()

public V get(Object key) {
    synchronized (mutex) {
        return m.get(key);
    }
}

但是这个效率太低了,有没有高效一点的方法呢?

ConcurrentHashMap

实现原理

JDK1.7 ConcurrentHashMap的实现原理:

使用了Segment(分段锁)+ReentrantLock的技术,Segment继承ReentrantLock锁,是用于存放数组HashEntry[],Segment可以理解为一个桶,段,或者区域,简单的说就是ConcurrentHashMap中维护了多个段(Segment),默认为16个,每个段中都有HashEntry[],那么在使用put放法时,就不用将整个Map加锁了,通过Hash算法得到Key应该存入哪个段中,对该段进行加锁即可(这样一来,ConcurrentHashMap可以最大容纳16个线程并发操作),Segment对象的put方法会先加锁,然后根据再key计算出在段中对应的hashEntry[]的数组下标,然后将key和value封装为HashEntry对象放入该位置,此过程和JDK7中的HashMap的put方法一样,然后解锁。

此外ConcurrentHashMap的get()方法是没有加锁的

因为使用了volatile关键字,保证读取的是最新的数据

JDK1.8 ConcurrentHashMap的实现原理:

其摒弃了之前的Segment(分段锁)的概念,而是采用Node。它沿用了与它同期的HashMap版本的思想采用数组+链表+红黑树来实现,利用CAS+Synchronized来保证并发更新的安全。JDK1.8的ConcurrentHashMap结构基本上和JDK1.8的HashMap一样,不过保证线程安全性。

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

CAS(Compare-And-Swap): 比较并交换,CAS就是通过一个原子操作,用预期值去和实际值做对比,如果实际值和预期相同,则做更新操作。 如果预期值和实际不同,我们就认为,其他线程更新了这个值,此时不做更新操作。 而且这整个流程是原子性的,所以只要实际值和预期值相同,就能保证这次更新不会被其他线程影响。

ConcurrentHashMap的put方法关键流程: 1.做插入操作时,首先进入乐观锁

2.然后,在乐观锁中判断容器是否初始化,如果没初始化则初始化容器,

3.如果已经初始化,则判断该hash位置的节点是否为空,如果为空,则通过CAS操作进行插入。

4.如果该节点不为空,再判断容器是否在扩容中,如果在扩容,则帮助其扩容。

如果没有扩容,则进行最后一步,先加锁,然后找到hash值相同的那个节点(hash冲突),循环判断这个节点上的链表,决定做覆盖操作还是插入操作。循环结束,插入完毕。

ConcurrentHashMap总结:

1.实现方式:1.7使用了Segment(分段锁)+ReentrantLock的技术,1.8摒弃了Segment(分段锁)的概念,而是采用Node。它沿用了与它同期的HashMap版本的思想采用数组+链表+红黑树来实现,利用CAS+Synchronized来保证并发更新的安全
2.锁的粒度:1.7是对需要进行数据操作的Segment加锁,1.8调整为对每个数组元素加锁(Node)
3.数据结构:1.7底层是数组+链表,1.8数组+链表+红黑树
4.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录