Java高并发编程核心:并发集合与原子类详解

在当今高并发、高吞吐的分布式系统中,Java并发编程已成为开发者必备的核心能力。当线程如潮水般涌来,如何确保数据安全?如何避免死锁陷阱?如何实现无阻塞的高效运算?答案就隐藏在并发集合原子类这两大基石之中。

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

1.1 ConcurrentHashMap

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

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

基础存储单元:

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

核心数据结构:

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

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

private final void transfer(Node[] tab, Node[] 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[] nt = (Node[])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 ln, hn; // 链表迁移(高低位拆分) if (fh >= 0) { int runBit = fh & n; Node lastRun = f; for (Node 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 Node tabAt(Node[] tab, int i) { return (Node)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE); } // CAS更新表元素 static final boolean casTabAt(Node[] tab, int i, Node c, Node v) { return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v); }

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

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 extends AbstractQueue implements BlockingQueue, 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,来看一下具体的代码:

// 原子类的基石: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 { private static class Pair { final T reference; final int stamp; // 版本戳 private Pair(T reference, int stamp) { this.reference = reference; this.stamp = stamp; } } private volatile Pair pair; // 带版本戳的 CAS public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } }

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

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

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

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

你可能感兴趣的:(java,开发语言,后端,并发编程)