Java ConcurrentMap 原理

Java ConcurrentMap 原理

HashMap在设计上是非线程安全的容器,当出现并发情况时会导致类似CPU占用100%等问题
Hashtable以及Collections.synchronizedMap实现的线程安全Map容器都只是在各个方法中加了synchronized同步锁,仅适合简单并发场景
ConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,基于分离锁的思想使得能够应对高并发场景并且又有非常高的效率,但与HashMap不同的是不支持null键和值

本文会省略与HashMap相同的一些内容,因此先了解HashMap原理更有助于理解本文

内部结构

// Java7使用的分段锁实现
static class Segment<K,V> extends ReentrantLock implements Serializable {...}

ConcurrentMap在Java7时使用的分段锁基于Segment数组,Segment内部则是桶数组(与HashMap类似,哈希冲突的也是以链表形式存放),Segment继承于ReentrantLock,简单说就是每个Segment都是一把锁,而每把锁都对应一个HashMap

...
// 存储元素的数组,长度总是2的幂数
transient Node<k,v>[] table;
// 单链表节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    ...
}

在Java8中ConcurrentMap的内部结构与HashMap非常相似,同样是桶数组与链表/红黑树结合的结构,为了保证可见性,val与next声明为volatile
相比Java7最大的改变就是抛弃了Segment,使用synchronized作为同步锁+头节点作为同步单位来实现分段锁,更加细化了锁的颗粒度,原先的Segment仅作为保证序列化兼容的用处,并且在一些特定场景使用CAS自旋实现无锁并发操作

Java ConcurrentMap 原理_第1张图片

private transient volatile int sizeCtl;

sizeCtl是ConcurrentMap中非常重要的一个变量,用来控制table的初始化和扩容的操作,不同的值有不同的含义
当为0时:代表当时的table还没有被初始化
当为正数时:表示初始化或者下一次进行扩容的大小
当为负数时:-1代表正在初始化,-N代表有N-1个线程正在进行扩容

初始化

由于不再使用Segment,初始化被改为懒加载的形式,无参构造函数的初始化与HashMap相同,会在添加数据时初始化桶数组并使用默认值
有参构造函数与HashMap也类似,会通过tableSizeFor找到离初始化容量最近的2的幂数后赋值给sizeCtl

public ConcurrentHashMap(int initialCapacity,
                            float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (initialCapacity < concurrencyLevel)   // Use at least as many bins
        initialCapacity = concurrencyLevel;   // as estimated threads
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;
}

无锁并发

ConcurrentHashMap中使用Unsafe类的相关方法来实现无锁并发优化,具体分为两个方面:CAS操作与volatile操作

tabAt与setTabAt两个方法中使用了Unsafe类的getObjectVolatile与putObjectVolatile方法,这两个方法有volatile read/write的语义,直接在主存中读写数据,保证可见性

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);
}

static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

CAS操作通过Unsafe类的compareAndSwapXXX相关方法来实现,由于CAS操作本身是原子性的,配合声明为volatile的变量实现的自旋锁,相比独占锁在耗时低的并发场景中效率更好

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)

对于CAS不熟悉的可以参考AtomicInteger原理

put

put方法会先对key和value判空,如果是空则抛异常
若数组未初始化则先初始化,然后计算key的哈希值定位该键值对在数组中的索引,无哈希冲突时直接添加节点,有哈希冲突时,则遍历链表或红黑树找到找到key相同的节点后更新或添加节点到尾部
Java8中分段锁更改为使用synchronized作为同步锁+头节点作为同步单位的方式,这种方式是一种非常好的优化手段,细化了锁的颗粒度,理论上支持桶容量级的并发数量,同时由于synchronized在不断被优化,相比原先使用ReentrantLock时减少了内存消耗并且在低并发场景下也更有优势(偏向锁,轻量级锁)
关于synchronized的改进可以参考synchronized原理这篇文章

public V put(K key, V value) {
    return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key和value不允许为空
    if (key == null || value == null) throw new NullPointerException();
    // 计算key的哈希值
    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) {
            // 利用CAS自旋添加新节点(无锁线程安全操作)
            if (casTabAt(tab, i, null,
                            new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // 判断当前是否有其他线程正在扩容
        else if ((fh = f.hash) == MOVED)
            // 帮助扩容
            tab = helpTransfer(tab, f);
        // 当前桶不为空表示有哈希碰撞,从链表表头中获取key相同的节点
        else {
            V oldVal = null;
            // 将桶头节点加锁,防止别的线程修改
            synchronized (f) {
                // 重新获取一次桶的头节点后判断当前桶是否在加锁前被其他线程修改过
                if (tabAt(tab, i) == f) {
                    // 省略遍历链表或红黑树后更新或添加节点的操作
                    ...
                }
            }
            if (binCount != 0) {
                // 链表长度如果大于树化阈值,就转为红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 节点插入完成后,更新节点总数
    addCount(1L, binCount);
    return null;
}

initTable

initTable负责数组的初始化,使用自旋锁来实现无锁并发,使用声明为volatile的sizeCtl作为互斥手段
如果发现竞争性的初始化,则不断自旋并让出CPU时间片,等待别的线程初始化完后退出自旋,否则利用CAS设置sizeCtl为-1作为排它标志,设置成功则进行初始化,否则自旋重试

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 判断当前是否已有线程正在初始化
        if ((sc = sizeCtl) < 0)
            // 当前线程让出CPU时间片,并自旋等待
            Thread.yield(); // lost initialization race; just spin
        // 使用CAS设置sizeCtl为-1,成功则开始初始化
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    // 若初始化未指定数组大小则使用默认值16,否则使用指定值
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    // 创建数组
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    // 计算下次扩容的大小,相当于n×0.75
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

扩容

addCount方法中做完计数操作会判断是否需要扩容,如果需要扩容就调用扩容方法,如果正在扩容就帮助扩容
扩容同样也是使用自旋锁实现的无锁并发,但是sizeCtl扩容时由两部分组成,第一部分是扩容戳,占据sizeCtl的高有效位,长度为RESIZE_STAMP_BITS位(默认16),剩下的低有效位长度为32-RESIZE_STAMP_BITS位(16),第一个扩容的线程会把扩容戳rs左移RESIZE_STAMP_SHIFT(默认16)位再加2更新设置到sizeCtl中(sizeCtl = (rs << 16) + 2),每次一个新线程来扩容时都令sizeCtl = sizeCtl + 1,直到所有的低有效位被占满,低有效位默认占16位(最高位为符号位),所以扩容线程数默认最大为65535

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        ...
        // 统计元素数量
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            // 判断是否正在扩容
            if (sc < 0) {
                // 判断是否符合帮助扩容条件
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                // 使用CAS将sizeCtl+1,表示增加一个协助扩容的线程
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 当前没有线程扩容,使用CAS设置sizeCtl为一个负值
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                            (rs << RESIZE_STAMP_SHIFT) + 2))
                // 设置成功则开始扩容
                transfer(tab, null);
            s = sumCount();
        }
    }
}

get

get方法比较简单,逻辑与HashMap大致相同,并没有什么同步逻辑,但需要使用tabAt方法保证可见性

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 计算key的哈希值
    int h = spread(key.hashCode());
    // 判断桶数组是否为空、哈希寻址定位到的桶中是否有元素
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 检查桶内的头节点,判断key是否相同
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 当前桶是红黑树,按照红黑树方式获取key相同节点
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 当前桶是链表,则遍历链表找到key相同的节点
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

size

size方法会调用sumCount方法,然后遍历CounterCell数组进行累加

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;
}

counterCells数组的添加是在addCount方法中,对CounterCell的操作是基于LongAdder进行的,是一种JVM利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
                U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
}

参考

Java核心技术面试精讲
http://pzblog.cn/article.html?articleId=5b4fa2cfd16b4c478bc0383967de5c46
https://www.jianshu.com/p/fc72281e529f

你可能感兴趣的:(Java,面试相关)