JAVA学习之并发容器

242 阅读8分钟

1、并发容器

2、同步容器

为什么需要【并发容器】?

Hashtable 的实现基本就是将 put、get、size 等各种方法加上“synchronized”,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率

来自《Java并发编程实战》:

同步容器】(如 HashTable,Vector)将所有容器对象的访问都【串行化】,以实现它们的线程安全性。这种方法的代价是【严重降低并发性】,当多个线程竞争容器的锁时,吞吐量严重减低。

【同步包装器】只是利用输入 Map 构造了另一个同步版本,所有操作内部使用synchronized 语句块,利用【this】作为互斥的 mutex,没有真正意义上的改进!

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    return new SynchronizedMap<>(m);
}


private static class SynchronizedMap<K,V> implements Map<K,V> {
    
    private final Map<K,V> m; 
    
    // Object on which to synchronize
    final Object      mutex;       


    SynchronizedMap(Map<K,V> m) {
        this.m = Objects.requireNonNull(m);
        mutex = this;
    }


    public int size() {
        synchronized (mutex) {return m.size();}
    }
    
    public boolean isEmpty() {
        synchronized (mutex) {return m.isEmpty();}
    }
    
    public boolean containsKey(Object key) {
        synchronized (mutex) {return m.containsKey(key);}
    }
    
    public boolean containsValue(Object value) {
        synchronized (mutex) {return m.containsValue(value);}
    }
    
    public V get(Object key) {
        synchronized (mutex) {return m.get(key);}
    }


    public V put(K key, V value) {
        synchronized (mutex) {return m.put(key, value);}
    }
    public V remove(Object key) {
        synchronized (mutex) {return m.remove(key);}
    }
} 

3、ConccurentHashMap

3-1、JDK1.7之前 ConcurrentHashMap 实现

实现方法

  • 【分段锁】,也就是将内部进行【分段(Segment)】,里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。
  • HashEntry 内部使用 【volatile】 的 value 字段来保证【可见性】,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。
  • 在构造的时候,Segment 的数量由所谓的 concurrentcyLevel 决定,默认是 16,也可以在相应构造函数直接指定。注意,Java 需要它是 2 的幂数值,如果输入是类似 15 这种非幂值,会被自动调整到 16 之类 2 的幂数值。
  • CocurrentHashMap也是基于【散列】的Hash;
  • 迭代器不会抛出ConcurrentModificationException;ConccurentHashMap返回的迭代器具有弱一致性,可以容忍并发修改;

关于锁分段

在某些情况下,可以将【锁分解技术】进一步扩展为对一组独立对象上的锁进行分解,这种情况称为锁分段。

ConcurrentHashMap的实现使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶有第(N mod 16)个锁来保护,使得ConcurrentHashMap能够支持多达16个并发的写入器;分段锁的数量可以增加。

锁分段的【劣势】在于:与采用【单个锁】来实现独占访问相比,要获取多个锁来实现独占访问将更加困难且开销更高。

get方法源码分析

public V get(Object key) {
    // manually integrate access methods to reduce overhead
    Segment<K,V> s; 
    HashEntry<K,V>[] tab;
    // 两次Hash
    int h = hash(key.hashCode());
    //利用位操作替换普通数学运算
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 以Segment为单位,进行定位
    // 利用Unsafe直接进行volatile access
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) 
            != null && (tab = s.table) != null) {
           //省略
    }
    return null;
}

put方法源码分析

通过【二次哈希】避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作:

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    // 二次哈希,以保证数据的分散性,避免哈希冲突
    int hash = hash(key.hashCode());
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>) UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

核心逻辑:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // scanAndLockForPut会去查找是否有key相同Node
    // 无论如何,确保获取锁
    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;
                // 更新已有value...
            } else {
                // 放置HashEntry到特定位置,如果超过阈值,进行rehash
                // ...
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}
  • ConcurrentHashMap 会获取再入锁,以保证数据一致性,Segment 本身就是基于 ReentrantLock 的扩展实现,所以,在并发修改期间,相应 Segment 是被锁定的。
  • 在最初阶段,进行重复性的扫描,以确定相应 key 值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是 ConcurrentHashMap 的常见技巧。

3-2、JDK1.8之后 ConcurrentHashMap 实现

  • 总体结构上,它的内部存储变得和 HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。
  • 其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。
  • 因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
  • 数据存储利用 volatile 来保证可见性。
  • 使用 CAS 等操作,在特定场景进行无锁并发操作。
  • 使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。

1、内部数据结构

Key 是 final 的,因为在生命周期中,一个条目的 Key 发生变化是不可能的;与此同时 val,则声明为 volatile,以保证可见性。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
}

2、put方法源码分析

优化点:

  • 使用CAS获取对象;

  • 使用Lazy-Load方式进行数组初始化;

    /**

    • Table initialization and resizing control. When negative, the

    • table is being initialized or resized: -1 for initialization,

    • else -(1 + the number of active resizing threads). Otherwise,

    • when table is null, holds the initial table size to use upon

    • creation, or 0 for default. After initialization, holds the

    • next element count value upon which to resize the table. */ private transient volatile int sizeCtl;

    /**

    • The default initial table capacity. Must be a power of 2

    • (i.e., at least 1) and at most MAXIMUM_CAPACITY. */ private static final int DEFAULT_CAPACITY = 16;

    /**

    • The bin count threshold for using a tree rather than list for a

    • bin. Bins are converted to trees when adding an element to a

    • bin with at least this many nodes. The value must be greater

    • than 2, and should be at least 8 to mesh with assumptions in

    • tree removal about conversion back to plain bins upon

    • shrinkage. */ static final int TREEIFY_THRESHOLD = 8;

    static final int HASH_BITS = 0x7fffffff;

    public V put(K key, V value) { return putVal(key, value, false); }

    static final int spread(int h) { // 使用高16位进行hash取值,因为越是高位的Hash值冲突较少 return (h ^ (h >>> 16)) & HASH_BITS; }

    // CAS无锁操作 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); }

    // 使用 Unsafe 进行了优化,直接利用getObjectVolatile,避免间接调用的开销。 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); }

    /**

    • 使用lazy-load的方式初始化数组 */ private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // sizeCtl<0,说明在初始化或者调整大小,如果发现冲突,进行spin等待 // sizeCtl用volatile修饰,保证并发情况下可见性 if ((sc = sizeCtl) < 0) Thread.yield(); else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // CAS成功返回true,则进入真正的初始化逻辑 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; }

    final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) // key 或 value 为 null 抛出NPE; 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) { // 利用CAS去进行无锁线程安全操作 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break;
    } else if ((fh = f.hash) == MOVED) // 需要移动时进行转移 tab = helpTransfer(tab, f); else { V oldVal = null; // 细粒度加锁 synchronized (f) { // CAS加锁操作 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; }

    /**

    • Helps transfer if a resize is in progress. */ final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { Node<K,V>[] nextTab; int sc; if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) { int rs = resizeStamp(tab.length); while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) { if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { transfer(tab, nextTab); break; } } return nextTab; } return

3-3、为什么ConcurrentHashMap和HashTable的key、value不能为null

因为HashMap是非线程安全的,默认单线程环境中使用,如果get(key)为null,可以通过containsKey(key) 方法来判断这个key的value为null,还是不存在这个key,

而ConcurrentHashMap,HashTable是线程安全的, 在多线程操作时,因为get(key)、containsKey(key) 两个操作合在一起不是一个原子性操作, 可能在执行中间,有其他线程修改了数据,所以无法区分value的值为null还是不存在key。containsValue也是如此。

4、CopyOnWriteArrayList

4-1、什么是写入时复制(Copy-On-Write)?

  • 【写入时复制(Copy-On-Write)】容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么访问该对象时就不再需要进一步的同步。
  • 在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性;
  • 写入时复制,容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此对进行同步时,只需要确保数组内容的可见性(用volatile表示);
  • 因此,多线程可以同时对这个容器进行迭代,不会彼此干扰或者与修改容器的线程相互干扰;
  • 【写入时复制】容器,返回的迭代器,不会抛出ConcurrentModificationException,返回的元素与迭代器创建时的元素完全一致,不必考虑之后修改操作带来的影响;
  • 每当修改时,都会复制底层数组,会造成一定开销,因此仅当【迭代操作远远多于修改操作】时,才应该使用【写入时复制】容器,也就是【读多于写】的场景;

CopyOnWriteArrayList是一个线程安全的ArrayList,其中使用 ReentrantLock作为锁保证线程安全;

CopyOnWriteArrayList新增元素时,会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将 array 指向这个新的数组。

getArray() 赋给 elements,在复制出来的数组上进行添加,然后再重新赋值;

CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致;

写入时,同时先复制数组,写入完成后,再给数组赋值,使用volatile修饰数组,保证多线程情况下的可见性;