并发编程-Java并发集合与原子类

100 阅读7分钟

一般我们在构建一些高并发应用时,那我们一定会考虑的就是线程安全,那一些并发集合和原子类则是我们在一些场景避免不了的。本节就主要来说一下,Java 并发包(java.util.concurrent)里的内容。另外,我们这节内容是基于1.8版本来说的。

1. 并发集合:线程安全的容器

1.1 ConcurrentHashMap

我在最开始学习这个容器的时候当时会记住它的特点是:线程安全,允许多个线程进行读和写。null值和键:ConcurrentHashMap不允许null值作为键或值。但是如何实现线程安全的呢?我们来看一下源码的实现:

首先来看一下它的数据结构设计:

基础存储单元:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;       // 保证可见性
    volatile Node<K,V> next; // 保证可见性
    // ...
}

核心数据结构:

transient volatile Node<K,V>[] table;       // 主哈希表
private transient volatile Node<K,V>[] nextTable; // 扩容时的新表
private transient volatile int sizeCtl;      // 控制状态的核心变量

我们在探讨ConcurrentHashMap的数据结构的时候就不得不提它的扩容机制。它的扩容主要是在transfer方法里,我截取一部分重要的来看一下。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    
    // 计算每个线程处理的桶区间
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;
    
    // 初始化新表(2倍扩容)
    if (nextTab == null) {
        try {
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n; // 从后向前迁移
    }
    
    // 多线程协同迁移
    while (advance) {
        // 分配迁移任务区间
    }
    
    // 迁移数据
    synchronized (f) {
        if (tabAt(tab, i) == f) {
            Node<K,V> ln, hn;
            // 链表迁移(高低位拆分)
            if (fh >= 0) {
                int runBit = fh & n;
                Node<K,V> lastRun = f;
                for (Node<K,V> p = f.next; p != null; p = p.next) {
                    int b = p.hash & n;
                    if (b != runBit) {
                        runBit = b;
                        lastRun = p;
                    }
                }
                if (runBit == 0) {
                    ln = lastRun;
                    hn = null;
                }
                else {
                    hn = lastRun;
                    ln = null;
                }
                // ... 构建高低位链表
            }
            // 树节点迁移
            else if (f instanceof TreeBin) {
                // ... 树拆分逻辑
            }
        }
    }
    // 设置ForwardingNode
    setTabAt(tab, i, fwd);
}

整个扩容机制的触发条件是,当元素数量超过sizeCtl阈值,就会扩容,这个扩容是重新new一个,然后进行复制。整个数据结构在数据量大的时候会变成红黑树的数据结构。

另外ConcurrentHashMap保证内存可见性也是通过CAS来保证:

// 原子获取表元素
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}

// CAS更新表元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

为什么ConcurrentHashMap能够保证线程安全?那更细粒度的锁一定是关键所在,ConcurrentHashMap使用的是桶锁,它利用更加细粒度的锁能够保证高效的读写操作。最后我们来看一下经常会拿来比较的HashMap

image.png

1.2 CopyOnWriteArrayList

其实CopyOnWriteArrayList和CopyOnWriteArraySet可以放在一起说一下,他们有共同的特点:

  • 写时复制:修改操作复制原数组并替换,保证读写分离
  • 所有读操作无需加锁,写操作通过ReentrantLock保证串行

因为核心思想就是写时复制,所以我们来看一下添加元素核心操作:

public boolean add(E e) {
    synchronized (lock) { // 加锁保证写操作原子性
        Object[] es = getArray(); // 获取当前数组快照
        int len = es.length;
        
        // 创建新数组(长度+1)
        Object[] newElements = Arrays.copyOf(es, len + 1);
        
        // 添加新元素
        newElements[len] = e;
        
        // 原子切换数组引用
        setArray(newElements);
        return true;
    }
}

从这里可以看出,添加元素时首先就是创建新数组,然后整个复制过去,所以理所应当我们可以得知其实此容器是不适合频繁写操作的,只适合频繁读的场景来用。

1.3 BlockingQueue

BlockingQueue里有两个核心实现:ArrayBlockingQueueLinkedBlockingQueue两者区别不大,就是数组和链表的区别。我们这里主要来看一下ArrayBlockingQueue。它的数据结构没什么好看的,这里我们主要来看一下它的put()方法(阻塞插入):

public void put(E e) throws InterruptedException {
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly(); // 可中断获取锁
    try {
        // 当队列满时等待
        while (count == items.length)
            notFull.await();
        enqueue(e); // 入队
    } finally {
        lock.unlock();
    }
}

// 入队核心方法
private void enqueue(E e) {
    final Object[] items = this.items;
    items[putIndex] = e;
    // 循环数组处理
    if (++putIndex == items.length) putIndex = 0;
    count++;
    notEmpty.signal(); // 唤醒等待的消费者
}

关键设计是:

  • 单锁设计:所有操作共用一把锁
  • 循环数组:高效利用内存空间
  • 双条件变量:notEmpty 和 notFull 分离等待队列
  • 公平性支持:通过构造参数选择公平/非公平锁

我们再来看一个比较常用的实现类:PriorityBlockingQueue (优先级队列)

public class PriorityBlockingQueue<E> extends AbstractQueue<E>
    implements BlockingQueue<E>, java.io.Serializable {
    
    // 基于堆的优先级队列
    private transient Object[] queue;
    
    // 单锁设计
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    
    // 无界队列,put 永不阻塞
    public void put(E e) {
        offer(e); // 永不阻塞
    }
}

2. 原子类

原子类就是位于java.util.concurrent.atomic包下,整个类是基于CAS操作实现的无锁线程安全类。我们知道CAS的全称是compare and swap,来看一下具体的代码:

image.png

// 原子类的基石:Unsafe 类提供硬件级原子操作
public final class Unsafe {
    // CAS 方法(核心)
    public final native boolean compareAndSwapInt(Object o, long offset, 
                                                 int expected, int x);
    public final native boolean compareAndSwapLong(Object o, long offset,
                                                  long expected, long x);
    public final native boolean compareAndSwapObject(Object o, long offset,
                                                    Object expected, Object x);
    
    // 获取字段偏移量
    public native long objectFieldOffset(Field f);
}

那我们来看一个实现类

2.1 AtomicInteger (整型原子类)

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE;
    
    static {
        try {
            // 获取 value 字段的偏移量
            VALUE = U.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }
    
    private volatile int value; // volatile 保证可见性
    
    // 核心 CAS 操作
    public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
    }
    
    // 原子递增并返回新值
    public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }
    
    // JDK 8 优化后的原子加法
    public final int getAndAdd(int delta) {
        return U.getAndAddInt(this, VALUE, delta);
    }
}

可以看到原子类的原理就是基于CAS构建的,但其实原子类有一个重要的ABA问题,所谓的ABA就是变量在中间变化了但是在最后又变为预期值。那一般解决ABA问题就是采用时间戳、版本号等方法。来看一下版本号等解决方式:

public class AtomicStampedReference<V> {
    private static class Pair<T> {
        final T reference;
        final int stamp; // 版本戳
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
    }
    
    private volatile Pair<V> pair;
    
    // 带版本戳的 CAS
    public boolean compareAndSet(V expectedReference, V newReference,
                                 int expectedStamp, int newStamp) {
        Pair<V> current = pair;
        return expectedReference == current.reference &&
               expectedStamp == current.stamp &&
               ((newReference == current.reference && newStamp == current.stamp) ||
                casPair(current, Pair.of(newReference, newStamp)));
    }
}

3. 高并发场景下如何选择

这一块其实我也没有实战经验,只能从具体的集合和原子类的特点来考虑!

deepseek_mermaid_20250609_e4cfb2.png

3.1 Map类型选择

这里我只说三个比较常见的Map类型集合:

  • ConcurrentHashMap:它的场景就是高频读写,优势在于是分段锁/桶设计,这样能够更细粒度的控制。缺点就是因为是非公平锁,然后扩容的时候会消耗大量资源则就会导致短暂堵塞。
  • ConcurrentSkipListMap:使用场景就是需要排序的键值对,优势在于基于跳表实现,能够支持有序遍历。缺点就是内存占用高,写入性能不及ConcurrentHashMap。
  • CopyOnWriteMap:无锁读就是最大优势。但缺点很明显,极其不适合写操作,开销很大。

3.2 List/Set 类型选择

  • CopyOnWriteArrayList/Set:这个也和上面一样,写操作复制整个数组,则开销很大
  • LinkedBlockingDeque:这个场景就是需要队列性质的列表,优势在于具有阻塞特性,缺点也是内存占比大
  • Collections.synchronizedList():这个用起来简单,但问题是因为基于synchronized来实现,所以全局锁的性能差

3.3 Queue类型选择

因为Queue类型一般多用于生产者/消费者,以及优先级的情况,那我们就说一下这几个。

  • ArrayBlockingQueue:规定大小,有界,一般就用于有界的生产者-消费者模式
  • PriorityBlockingQueue:优势就是按优先级排序,但需要注意因为是无界队列,所以需要容量控制

3.4 原子类选用

至于原子类的选用,我们也说几个常用的:

  • AtomicInteger/AtomicLong:一般使用场景就是一些简单的计数器,优势在于它是轻量级CAS实现的
  • AtomicReference:这个就是对象引用更新的时候来用,但是要注意ABA问题
  • AtomicStampedReference:这个就是通过版本戳来解决ABA问题
  • AtomicBoolean:比volatile+sychronized更轻量,很适合用来管理状态

4. 总结

到这里我们就可以总结一下:

  • 读多写少 → CopyOnWrite 系列
  • 写多读少 → ConcurrentHashMap + 原子类
  • 超高写入 → LongAdder + 分片设计
  • 任务调度 → 根据特性选择 BlockingQueue
  • 精确控制 → Lock + Condition 精细同步
  • 简单状态 → AtomicBoolean/AtomicReference

实际上高并发系统中没有什么“银弹”选择,具体场景具体分析,而且要实际压测来进行选择。根据具体场景选择核心数据结构,通过性能测试验证选择,监控生产环境表现,持续优化调整。