ConcurrentHashMap JDK 1.7 源码解析

本文详细解析了JDK1.7中ConcurrentHashMap的实现,包括其构造方法、内部结构、分段锁机制以及put()、get()和扩容方法。ConcurrentHashMap通过Segment数组和HashEntry链表实现并发安全的键值对存储,避免了HashTable的全局锁,提高了并发性能。其在保证线程安全的同时,通过分段锁和Unsafe类实现了高效的并发控制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

ConcurrentHashMap 也是一种存储键值对的数据结构,和 HashMap 相比,它可以保证同步

HashTable 虽然也能保证同步,但由于它通过 synchronized 直接对方法加锁,并且没有引入红黑树等效率更高的数据结构,导致整体效率较差,一般很少用到

在 JDK 1.7 中,ConcurrentHashMap 的构造方法如下:

// 默认容量,通过它计算每个 Segment(段)中数组的长度
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默认支持并发等级,这里表示最多16个线程并发操作
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 无参构造方法
public ConcurrentHashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}

// 包含初始容量的构造方法
public ConcurrentHashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}

// 包含初始容量和负载因子的构造方法
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
    this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}

// 通过其它 Map 初始化 ConcurrentHashMap
// initialCapacity: 默认大小和 Map 大小除以默认加载因子的最大值
// loadFactor:默认负载因子
// concurrencyLevel:默认并发等级
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
	// 实际还是调用三参构造方法
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                  DEFAULT_INITIAL_CAPACITY),
         DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    // 将原 Map 所有元素放入 ConcurrentHashMap
    putAll(m);
}

// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 最大并发等级数
static final int MAX_SEGMENTS = 1 << 16;
// 每个 Segment(段)对应 HashEntry[] 数组的最小长度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

// 最终都会执行的构造方法
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
	// 确保负载因子大于0,容量大于等于0,并发等级大于0
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    int sshift = 0;
    int ssize = 1;
    // sshift:第一个大于等于 concurrencyLevel 2 次幂数字的二进制有效位长度
    // ssize:第一个大于等于 concurrencyLevel 2 次幂数字的值
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    // segmentShift:第一个大于等于 concurrencyLevel 2 次幂数字的二进制无效位长度
    this.segmentShift = 32 - sshift;
	// 减 1 保证二进制总是 11...1 这种格式,保证分配均匀
    this.segmentMask = ssize - 1;
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // initialCapacity 表示最大容量,ssize 表示有多少段
    // 每段中 HashEntry[] 的数组长度
    int c = initialCapacity / ssize;
	// ConcurrentHashMap initialCapacity 没有转换为对应 2 次幂
	// 如果存在余数,这里要加一,也就是每段数组长度加 1
    if (c * ssize < initialCapacity)
        ++c;
    // 要想分配均匀,HashEntry[] 数组长度必须是 2 次幂
    // cap(默认是2):第一个大于等于 c 的 2 次幂数字的值
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;
    // 创建 Segment 对象
    Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]);
    // 初始化 Segment 数组,数组大小为大于 concurrencyLevel 的第一个 2 次幂
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    // UNSAFE 直接操作内存,ss 就是数组对象,SBASE 值的计算下面介绍,s0 表示新创建的 Segment 对象
    UNSAFE.putOrderedObject(ss, SBASE, s0);
    this.segments = ss;
}

UNSAFE.putOrderedObject(Object arg1, long arg2, Object agr3),在 arg1 内存起始位置 arg2 的偏移量处保存 agr3 对象,putOrderedObject 保存完毕后会有些许延时,不保证可见性,被操作的对象必须 volatile 修饰

ConcurrentHashMap 在 JDK 1.7 基于分段锁实现,每一段实际就是一个 Segment 数组,Segment 数组的长度实际就是支持最大并发的线程数,每段又通过 HashEntry[] 数组保存元素:

  • Segments[] 长度:基于 concurrencyLevel 计算得来,值为大于等于 concurrencyLevel 的第一个 2 次幂的值
  • HashEntry[] 长度:基于 initialCapacity 和 Segments 长度得来,值为大于 initialCapacity / (Segments[] 长度) 的第一个 2 次幂的值

举个例子,initialCapacity 为 32,concurrencyLevel 为 4,此时 Segments[] 数组的长度就是 4,也就是最多支持 4 个线程并发操作,每个 Segment 包含一个长度为 8 的 HashEntry[] 数组,HashEntry 才是保存具体 key-value 的数据结构

保证 segments[] 数组和 HashEntry[] 数组长度总是 2 的幂次方都是为了提高效率,保证计算出的散列相对均匀,hash 结构,越均匀,查询效率越高,由于 JDK 1.7 一般采用头插的原因,对效率影响不大


segements[] 在 ConcurrentHashMap 中这样定义:

final Segment<K,V>[] segments;

回头再看 ConcurrentHashMap 构造方法中的 SBASE属性, 它的计算逻辑如下所示:

private static final long SBASE;
static {
	Class sc = Segment[].class;
	// 获取 Segment[] 数组第一个元素的偏移量
	SBASE = UNSAFE.arrayBaseOffset(sc);
}

也就是说,ConcurrentHashMap 在初始化 Segment[] 数组同时,还会初始化 Segment[0] 位置的 Segment 对象和该对象所对应的 HashEntry[] 数组,这样做的好处在于有个原型,方便其它 Segment 节点的初始化,关于这块代码在下面源码中会看到

接下来看 Segment 类源码:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
	// 具体保存 key-value 键值对的数组
	transient volatile HashEntry<K,V>[] table;
	// 负载值
	transient int threshold;
	// 负载因子
    final float loadFactor;
	
    Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
        this.loadFactor = lf;
        this.threshold = threshold;
        this.table = tab;
    }
}

由于 Segment 继承 ReentrantLock 锁对象,也就是说 Segment 对象本身可以实现加解锁操作,保证每一段同时最多只能有一个线程操作。这也是为什么一般使用 ConcurrentHashMap 而不是 HashTable 的主要原因:HashTable 由于直接对 put() 方法加锁,导致最多只能有一个线程并发执行,而 ConcurrentHashMap 支持多个线程并发执行,效率更高


接下来看 HashEntry 类源码:

static final class HashEntry<K,V> {
	// hash 值
    final int hash;
    final K key;
    volatile V value;
	// HashEntry 可以通过 next 组成链表
    volatile HashEntry<K,V> next;

    HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

到这里和 HashMap 实际已经很像了:数组 + 链表,ConcurrentHashMap 只是多维护了一层 Segments[] 数组,通过 ReentrantLock、UnSafe 保证同步,分段支持多线程,效率更高


关联类和属性介绍完了,我们开始看具体实现:ConcurrentHashMap put() 方法源码如下:

以下内容存在大量 unsafe 内存处理,这块我就不详细说明了,不然篇幅太长,全部看懂了也容易忘,大家知道整体逻辑就好

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    // 计算 hash 值
    int hash = hash(key);
	// 实际计算分到哪个 Segment,也就是哪一段,获取对应下标
    int j = (hash >>> segmentShift) & segmentMask;
	// unsafe 获取对应下标 Segment 对象,判断是否初始化
    if ((s = (Segment<K,V>)UNSAFE.getObject
         (segments, (j << SSHIFT) + SBASE)) == null)
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

// 特别复杂,总得来说就是让 hash 值尽可能饱满一些,这些分配更均匀
private int hash(Object k) {
	// hashSeed 是根据 ConcurrentHashMap 对象本身计算出的随机值
	// hashSeed 一般只计算一次,否则相同 key 计算出的 hash 值不同了
    int h = hashSeed;
    // 字符串类型特殊处理
    if ((0 != h) && (k instanceof String)) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();
    h += (h <<  15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h <<   3);
    h ^= (h >>>  6);
    h += (h <<   2) + (h << 14);
    return h ^ (h >>> 16);
}

ConcurrentHashMap put() 方法主要计算 hash 值,判断对应下标的 Segment 对象是否初始化,初始化完毕后调用 Segement 对象的 put() 方法,这里先看 Segment 对象初始化方法源码:

private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
	// 计算应处理节点的偏移量
    long u = (k << SSHIFT) + SBASE; 
    Segment<K,V> seg;
    // 这里改用 getObjectVolatile,确保可见性,防止两个线程同时初始化同一个 Segment
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
    	// 使用下标 0 位置元素作为原型,这里可以回想原型模式,也是个学习的过程
    	// 初始化 HashEntry 数组 Segment
        Segment<K,V> proto = ss[0]; 
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        // 再次判断是否其它线程已经初始化对应下标
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { 
        	// 创建 Segment 对象
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 循环判断还没有初始化
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            	// CAS 循环处理,防止二次初始化
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

初始化 segment 对象后,调用它的 put() 方法保存 key-value 键值对,下面看 Segment 的 put() 方法源码:

// onlyIfAbsent 主要用来判断是否已经存在,默认 put() 会覆盖旧值,putIfAbsent() 方法不会覆盖
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
	// tryLock() 方法尝试获取锁,它不会阻塞,lock() 方法会阻塞当前线程,等待锁资源
	// 获取锁成功啥都不做,否则执行 scanAndLockForPut() 方法
    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 对象
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                // key 相等 或 hash 相等,key 相等
                // 前面针对基础类型值比较,后面针对引用类型
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                    	// 如果存在覆盖旧值并返回旧值
                        e.value = value;
                        // modCount 记录当前 Segment 有效操作次数
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else {
            	// 初始化新的 HashEntry
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                // count 表示当前 Segment 的元素数量
                int c = count + 1;
                // 是否需要扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                	// 通过 unsafe 在指定位置头插对象
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

put() 方法执行前会尝试获取锁,获取锁失败执行 scanAndLockForPut() 方法,否则开始遍历,找到相同 key 覆盖旧值,否则创建新的 HashEntry 对象,采用头插的方式保存,同时判断是否需要扩容

这里有两个很容易搞混的概念需要区分:

  • count:当前 Segment 保存的元素数量
  • modCount:当前 Segment 有效操作的次数,用于 ConcurrentHashMap isEmpty() 方法,这个最后再说

回头看获取锁失败执行的 scanAndLockForPut() 方法,它的源码如下:

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;
    // 假如一直没有抢到锁
    // 模拟自旋过程
    while (!tryLock()) {
        HashEntry<K,V> f;
        // 默认执行的代码块
        // 如果对应下标存在或不存在相等key都跳出
        // 不存在时初始化 HashEntry 对象
        if (retries < 0) {
        	// 遍历到链表结尾仍没有初始化
            if (e == null) {
                if (node == null)
                    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;
            retries = -1;
        }
    }
    // 返回自旋期间初始化的节点
    return node;
}

scanAndLockForPut() 方法主要就是自旋,如果不存在相同 key,提前初始化 HashEntry 对象,自旋达到一定次数仍没获取到锁就阻塞,没做啥特殊处理

总得来说 put() 操作需要知道以下几点:

  • put() 开始时需要加锁,防止多个线程同时处理同一个 Segment
  • segment 初始化采用 double check 以及 CAS 的方式保证同步
  • segment 和 hashEntry 的下标计算都是通过唯一 hash 值,只是计算方法不同

put() 看完了,再看看相对简单的 get() 方法:

public V get(Object key) {
    Segment<K,V> s; 
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 对应 segment 不为空,segment 对应 hashEntry[] 不为空
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
    	// 获取 hashEntry 对应下标首个元素,循环遍历,全程使用 getObjectVolatile 保证可见性
        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;
}

get() 方法相对简单,一个方法就全写完了,无须加锁,主要基于 unsafe 类直接操作内存,保证每次取到最新值


get()、put() 都说完了,再看看 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];
    // 方便计算原节点在新数组的下标
    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)
                newTable[idx] = e;
            else {
            	// 找出链表最后几个新下标相等的元素,lastRun记录它们中第一个
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                newTable[lastIdx] = lastRun;
                // 其它节点依次计算,直到 lastRun
                // lastRun 及其以后的元素新下标相同并且已处理
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    // 获取对应下标头节点
                    HashEntry<K,V> n = newTable[k];
                    // 替换头节点,新节点 next 指向原来的头节点
                    // 妥妥的头插法
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 处理引起扩容的元素
    int nodeIndex = node.hash & sizeMask;
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

从这也就可以看出,ConcurrentHashMap 采用分段扩容的思想,也就是说每个 Segment 独立处理,满足扩容条件就扩容为原来的两倍,如果下标 0 位置扩容,新初始化的 Segment 也将以扩容后的 Segment 为原型初始化

需要注意的一点是整个扩容过程并不完全是头插,而是部分头插,举个例子:

HashEntry[] 数组长度为 2,下标 1 位置存在链表 a>b>c>d>e>f,扩容后 a 的下标为 3,b 为 1,c 为 3,d、e、f 都为1。根据上面的算法,首先最后三个元素 d、e、f 新下标相等,直接放到新下标位置,前面的三个元素依次采用头插的方式处理,新数组元素顺序为:

下标 1:b(头插来)-> d -> e -> f(后三个元素直接移动过来)
下标 3:c(头插来)-> a(头插来)a 先插

a、b、c 是满足头插的,d、e、f 是直接移动过来的,这样做肯定也是出于效率考虑

最后需要注意 ConcurrentHashMap Segments[] 不扩容,扩容的是 HashEntry[] 数组,也就是说最大线程数初始化后就不变了,ConcurrentHashMap 的容量也只是用来计算 Segments 初始容量,后续每个 Segments 随着元素数量增多扩容


最最后我们来看 modCount 的作用:它只在 isEmpty() 方法中用到,具体代码如下:

public boolean isEmpty() {
    long sum = 0L;
    final Segment<K,V>[] segments = this.segments;
    for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
        	// segment count 不为0,说明一定不为空
            if (seg.count != 0)
                return false;
           	// 为 0 时也不一定为空,通过 modCount 决定
            sum += seg.modCount;
        }
    }
    if (sum != 0L) {
        for (int j = 0; j < segments.length; ++j) {
            Segment<K,V> seg = segmentAt(segments, j);
            if (seg != null) {
                if (seg.count != 0)
                    return false;
                sum -= seg.modCount;
            }
        }
        if (sum != 0L)
            return false;
    }
    return true;
}

如果两个 for() 循环前后,modCount 相等,说明这段时间 ConcurrentHashMap 没有任何有效操作,并且每个 Segment 数组 count 为 0,两者合起来才能说明 ConcurrentHashMap 为空,否则说明仍存在有效操作,存在有效操作说明还有元素


好了,到这里 JDK 1.7 版本 ConcurrentHashMap 源码就全部介绍完毕了。总结也懒得写了,因为实际每行注释都是干货。只看总结容易知其然而不知其所以然,还是建议大家能全篇读下来,如果有问题欢迎评论区指针

本来打算 1.8 一起写的,篇幅实在太长就算了。下篇 1.8 我就不逐行逐行解释了,太耗费脑力,挑重点说说算了,也轻松一点

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值