ConcurrentHashMap 加锁机制:JDK 1.7 与 JDK 1.8 对比

ConcurrentHashMap 加锁机制深度剖析:JDK 1.7 与 JDK 1.8 对比

一、引言

在多线程编程领域,ConcurrentHashMap是一个被广泛使用的线程安全的哈希表实现。它提供了高效的并发访问性能,满足了众多高并发场景下的数据存储和检索需求。然而,理解其内部的加锁机制对于开发者充分发挥其性能优势以及编写正确的并发代码至关重要。本文将深入探讨ConcurrentHashMap在 JDK 1.7 和 JDK 1.8 版本中插入数据和读数据时的加锁机制,并通过详细的源码分析进行对比。

二、JDK 1.7 中 ConcurrentHashMap 的加锁机制

2.1 数据结构概述

在 JDK 1.7 中,ConcurrentHashMap采用了分段锁(Segment)的设计。整个ConcurrentHashMap由多个 Segment 组成,每个 Segment 类似于一个独立的HashMap,并且每个 Segment 都拥有自己独立的锁。这种结构使得在并发操作时,不同 Segment 之间的操作可以并行执行,只有当多个线程同时访问同一个 Segment 时才会发生锁竞争,从而在一定程度上提高了并发性能。

2.2 插入数据时的加锁流程

  1. 定位目标 Segment
    public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }
    
    首先,通过对键的哈希值进行特定的位运算,计算出该键应该存储在哪个 Segment 中。这里使用了segmentShiftsegmentMask这两个常量来进行精确的定位。如果定位到的 Segment 为空,则调用ensureSegment方法来创建该 Segment。
  2. Segment 的 put 操作
    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        HashEntry<K,V> node = tryLock()? null :
            scanAndLockForPut(key, hash, value);
        V oldValue;
        try {
            HashEntry<K,V>[] tab = table;
            int index = (tab.length - 1) & hash;
            HashEntry<K,V> first = entryAt(tab, index);
            for (HashEntry<K,V> e = first;;) {
                if (e!= null) {
                    // 处理键已存在的情况,遍历链表更新值
                    K k;
                    if ((k = e.key) == key ||
                        (e.hash == hash && key.equals(k))) {
                        oldValue = e.value;
                        if (!onlyIfAbsent) {
                            e.value = value;
                        }
                        break;
                    }
                    e = e.next;
                } else {
                    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
                        setEntryAt(tab, index, node);
                    count = c;
                    oldValue = null;
                    break;
                }
            }
        } finally {
            unlock();
        }
        return oldValue;
    }
    
    在获取到目标 Segment 后,首先尝试使用tryLock()方法获取该 Segment 的锁。tryLock()方法会立即返回,如果获取锁成功,则继续后续操作;如果获取锁失败,则调用scanAndLockForPut方法进行自旋等待锁。
    在获取到锁后,会在该 Segment 的哈希表中查找键对应的节点。如果键已存在,则更新节点的值;如果键不存在,则创建一个新的节点并插入到链表头部。同时,会检查当前 Segment 的元素数量是否超过阈值,如果超过且哈希表的容量未达到最大值,则进行 rehash 操作以扩大哈希表的容量。
    private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
        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 (retries < 0) {
                if (e == null) {
                    if (node == null) // speculatively create node
                        node = new HashEntry<K,V>(hash, key, value, null);
                    retries = 0;
                } else if (key.equals(e.key))
                    retries = 0;
                else
                    e = e.next;
            } else if (++retries > MAX_SCAN_RETRIES) {
                lock();
                break;
            } else if ((retries & 1) == 0 &&
                     (f = entryForHash(this, hash))!= first) {
                e = first = f; // re - traverse if entry changed
                retries = -1;
            }
        }
        return node;
    }
    
    scanAndLockForPut方法会不断尝试获取锁,在自旋过程中,如果发现当前遍历的链表节点发生了变化(例如其他线程在插入或删除节点),则重新遍历链表。如果自旋次数超过MAX_SCAN_RETRIES,则调用lock()方法阻塞等待锁,直到成功获取锁为止。

2.3 读数据时的加锁情况

在 JDK 1.7 中,读操作一般不需要加锁。ConcurrentHashMap通过使用volatile关键字来保证数据的可见性。

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;
    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) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

读操作首先通过位运算定位到目标 Segment,然后使用UNSAFE.getObjectVolatile方法获取该 Segment 的哈希表以及表中的节点。由于HashEntry中的nextvalue字段都被声明为volatile,所以在读取链表时,可以保证读取到的数据是最新的,即使在其他线程进行插入或删除操作的情况下,也不会出现数据不一致的情况。

三、JDK 1.8 中 ConcurrentHashMap 的加锁机制

3.1 数据结构变革

在 JDK 1.8 中,ConcurrentHashMap对数据结构进行了重大改进,放弃了分段锁机制,而是采用了CAS(Compare and Swap)操作和synchronized关键字相结合的方式。同时,当链表的长度超过一定阈值(8)时,会将链表转换为红黑树,以提高查询和插入的性能。

3.2 插入数据时的加锁流程

  1. 初始化或扩容操作
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                           new Node<K,V>(hash, key, value, null)))
                    break;                   // 这里使用CAS操作添加新节点
            } else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) { // 对链表或红黑树的头节点加锁
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek!= null && key.equals(ek)))) {
                                    oldVal = e.value;
                                    if (!onlyIfAbsent)
                                        e.value = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value, null);
                                    break;
                                }
                            }
                        } else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value))!= null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount!= 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal!= null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }
    
    首先,通过spread方法对键的哈希值进行处理,以减少哈希冲突。如果当前哈希表为空(tab == nulltab.length == 0),则调用initTable方法进行初始化。
    如果定位到的数组位置为空,则使用casTabAt方法(基于CAS操作)尝试将新节点直接插入到该位置。如果插入成功,则结束操作。
    如果定位到的数组位置正在进行扩容(fh == MOVED),则调用helpTransfer方法帮助进行扩容操作。
    如果定位到的数组位置不为空且不是正在扩容,则对该位置的头节点使用synchronized关键字加锁。在加锁后,判断该节点是链表节点(fh >= 0)还是红黑树节点(f instanceof TreeBin)。如果是链表节点,则遍历链表查找键是否存在,若存在则更新值,若不存在则在链表尾部插入新节点;如果是红黑树节点,则调用红黑树的插入方法putTreeVal进行插入操作。
    最后,检查链表的长度是否达到转换为红黑树的阈值(TREEIFY_THRESHOLD),如果达到则调用treeifyBin方法将链表转换为红黑树。
  2. CAS 操作的原理
    CAS操作是一种无锁的原子操作,它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当内存位置 V 的值等于预期原值 A 时,CAS操作才会将内存位置 V 的值更新为新值 B,否则不执行任何操作。在ConcurrentHashMap的插入操作中,casTabAt方法就是利用CAS操作来尝试将新节点插入到数组的指定位置,只有在该位置为空时才能插入成功,从而避免了使用锁带来的性能开销。

3.3 读数据时的加锁情况

在 JDK 1.8 中,读操作同样不需要加锁。

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table)!= null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h))!= null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek!= null && key.equals(ek)))
                return e.val;
        } else if (eh < 0)
            return (p = e.find(h, key))!= null? p.val : null;
        while ((e = e.next)!= null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek!= null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

读操作首先通过spread方法计算哈希值,然后定位到数组的相应位置。使用tabAt方法通过Unsafe类的getObjectVolatile操作获取该位置的节点。由于Node类中的valnext字段都被声明为volatile,所以在读取链表或红黑树时,可以保证读取到的数据是最新的。对于红黑树节点,通过调用find方法在红黑树中查找键对应的值。

四、JDK 1.7 与 JDK 1.8 加锁机制对比

  1. 锁的粒度
    • JDK 1.7:采用分段锁机制,锁的粒度相对较粗,每个 Segment 是一个较大的锁单元,同一时间只有一个线程可以访问同一个 Segment 中的数据。虽然不同 Segment 之间的操作可以并发执行,但在高并发场景下,如果多个线程频繁访问同一个 Segment,仍然可能会出现锁竞争的情况,从而影响性能。
    • JDK 1.8:放弃了分段锁,使用CAS操作和synchronized关键字相结合的方式。CAS操作可以在无锁的情况下进行一些原子性的更新操作,而synchronized关键字只在对链表或红黑树的头节点进行操作时才使用,锁的粒度更细,大大减少了锁竞争的可能性,提高了并发性能。
  2. 数据结构与性能
    • JDK 1.7:在数据结构上,每个 Segment 内部是一个链表结构,在处理大量数据时,链表的查询和插入性能会随着链表长度的增加而下降,容易出现哈希冲突。
    • JDK 1.8:引入了红黑树结构,当链表长度超过一定阈值时,会将链表转换为红黑树。红黑树的查询和插入性能在平均情况下为 O(log n),相比链表的 O(n)性能有了显著提升,尤其是在数据量较大且哈希冲突较多的情况下,性能优势更加明显。
  3. 读操作的实现
    • JDK 1.7:读操作利用volatile关键字保证数据的可见性,通过UNSAFE.getObjectVolatile方法获取数据,在读取链表时,由于链表节点的nextvalue字段是volatile的,所以可以保证读取到的数据是最新的。
    • JDK 1.8:读操作同样利用volatile关键字和Unsafe类的getObjectVolatile操作保证可见性,在读取链表或红黑树时,也能保证数据的一致性。并且,在红黑树的实现中,通过find方法进行查找,进一步优化了查询性能。

五、总结

ConcurrentHashMap在 JDK 1.7 和 JDK 1.8 中通过不同的加锁机制和数据结构设计,实现了高效的并发访问性能。JDK 1.7 的分段锁机制在一定程度上提高了并发性能,但锁的粒度较粗,在高并发场景下可能存在性能瓶颈。而 JDK 1.8 采用的CAS操作和synchronized关键字相结合的方式,以及引入红黑树的数据结构,使得锁的粒度更细,性能得到了显著提升。深入理解这些加锁机制和数据结构的变化,有助于开发者在实际应用中更好地使用ConcurrentHashMap,编写高效、正确的并发代码。

无论是在高并发的网络服务器应用中,还是在大数据处理等领域,ConcurrentHashMap都发挥着重要的作用。通过不断优化和改进,ConcurrentHashMap也在持续适应着日益增长的并发编程需求。希望本文的分析能够帮助读者更好地掌握ConcurrentHashMap的内部实现原理,从而在实际工作中能够灵活运用,解决各种并发编程的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值