一般我们在构建一些高并发应用时,那我们一定会考虑的就是线程安全,那一些并发集合和原子类则是我们在一些场景避免不了的。本节就主要来说一下,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。
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里有两个核心实现:ArrayBlockingQueue和LinkedBlockingQueue两者区别不大,就是数组和链表的区别。我们这里主要来看一下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,来看一下具体的代码:
// 原子类的基石: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. 高并发场景下如何选择
这一块其实我也没有实战经验,只能从具体的集合和原子类的特点来考虑!
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
实际上高并发系统中没有什么“银弹”选择,具体场景具体分析,而且要实际压测来进行选择。根据具体场景选择核心数据结构,通过性能测试验证选择,监控生产环境表现,持续优化调整。