云计算百科
云计算领域专业知识百科平台

Java 并发集合:锁的众生相

Java 集合类里,大部分日常用的(ArrayList、HashMap、HashSet)都是无锁的,多线程直接共享它们等于自找麻烦。真正"上了锁"的线程安全集合,其实是一小撮。

本文按锁的类型把它们分门别类,一个一个过原理、看代码。


一、synchronized 派:老牌选手,整表一把锁

1. Vector —— 数组列表的"初代安全版"

概念:Vector 就是 ArrayList 的同步祖先。它的实现方式和 ArrayList 几乎一样,区别只有一个:所有方法都用 synchronized 修饰。

public class Vector<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

// 扩容方法:整个方法加锁
protected synchronized void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 默认扩容为原来的 2 倍(ArrayList 是 1.5 倍)
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}

public synchronized boolean add(E e) {
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}

public synchronized E get(int index) {
// 检查越界后直接取,加锁保证读到完整数据
if (index >= elementCount) throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
}

原理讲解:get、add 这种最简单的操作也加锁,意味着读读之间也要互斥——两个线程只是各自读元素,却互相阻塞。这就是"整表一把锁"的代价。

2. Hashtable —— 古老的哈希表

概念:Hashtable 之于 HashMap,正如 Vector 之于 ArrayList。它的所有方法也都是 synchronized,且不允许 null 键值。

public class Hashtable<K,V> extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {

public synchronized V put(K key, V value) {
// 不允许 null,这是和 HashMap 的一个差异点
if (value == null) throw new NullPointerException();

int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;

// 遍历拉链上的节点
for (Entry<?,?> e = table[index]; e != null ; e = e.next) {
if (e.hash == hash && e.key.equals(key)) {
V old = (V)e.value;
e.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}

public synchronized V get(Object key) {
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
for (Entry<?,?> e = table[index]; e != null ; e = e.next) {
if (e.hash == hash && e.key.equals(key)) return (V)e.value;
}
return null;
}
}

原理讲解:put 和 get 加同一把对象锁(即 Hashtable 实例本身),所以任何一个线程的写,都会阻塞所有线程的读写。并发度再高,也只有一个线程能碰到这张表。

3. Stack —— 随 Vector 一起陪葬的栈

概念:Stack 继承自 Vector,所有方法都是 synchronized。栈语义只有 push、pop、peek 几个操作,完全可以用 ArrayDeque(无锁)替代。

public class Stack<E> extends Vector<E> {

public synchronized E push(E item) {
addElement(item); // 底层就是 Vector 的加锁 addElement
return item;
}

public synchronized E pop() {
E obj = peek(); // peek 内部也加锁,可重入锁允许这么写
int len = size();
removeElementAt(len – 1); // Vector 的加锁删除
return obj;
}

public synchronized E peek() {
int len = size();
if (len == 0) throw new EmptyStackException();
return elementAt(len – 1);
}
}

原理讲解:synchronized 是可重入锁——pop 里调用 peek,锁已经被本线程持有,可以直接进来,不会自己锁死自己。不过 Stack 现在基本只出现在面试题和教科书里了。

4. Collections.synchronizedXxx —— 换皮的同步包装

概念:Collections 提供一组静态工厂,把任何普通集合包装成线程安全的。原理和 Vector 类似:每个方法外包一层 synchronized。

List<String> list = Collections.synchronizedList(new ArrayList<>());

// 实际返回的是内部类 SynchronizedList
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
final List<E> list;

public void add(int index, E element) {
synchronized (mutex) { // 对 mutex 加锁,而不是对 list 本身
list.add(index, element);
}
}

public E get(int index) {
synchronized (mutex) {
return list.get(index);
}
}
}

原理讲解:mutex 默认就是包装类自身。但这层包装有个著名陷阱——复合操作要自己加锁:

synchronized (list) { // 必须手动同步,否则下面的复合操作是两次调用
if (!list.contains(x)) { // 第一次调用
list.add(x); // 第二次调用
}
}

contains 和 add 各自加锁、各自返回,之间可能插进别的线程。这点即使不用 synchronizedXxx,在面试中也经常被追问。


二、ReentrantLock 派:精细化锁控

5. CopyOnWriteArrayList —— 写时复制,读永远不锁

概念:写操作加锁(ReentrantLock),复制出新数组再修改;读操作完全无锁,读的是修改前的旧快照。适合读极多、写极少的场景。

public class CopyOnWriteArrayList<E> implements List<E> {
private final transient ReentrantLock lock = new ReentrantLock();
private volatile Object[] array; // 用 volatile 保证替换后读线程立即可见

public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 只有写才加锁
try {
Object[] elements = array;
Object[] newElements = Arrays.copyOf(elements, elements.length + 1); // 复制新数组
newElements[elements.length] = e;
array = newElements; // 一次性替换引用
return true;
} finally {
lock.unlock();
}
}

public E get(int index) {
return elementAt(array, index); // 无锁读,返回此刻的快照内容
}
}

原理讲解:写一次就复制整个数组,数据量大了代价很高;但读线程拿到的数组引用是永远不变的,所以 get 无需任何同步。volatile 保证数组引用替换后,读线程下一次 get 能看到新引用。这正是"读多写少"设计哲学的极致体现。

6. ArrayBlockingQueue —— 有界队列:一把锁,两个哨兵

概念:一个有固定容量的队列,一把 ReentrantLock 管读写,配两个 Condition:notEmpty(空时让消费者等待)、notFull(满时让生产者等待)。

public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
private final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length) return false; // 队列满,直接失败
enqueue(e);
return true;
} finally {
lock.unlock();
}
}

private void enqueue(E x) {
items[putIndex] = x;
putIndex++;
count++;
notEmpty.signal(); // 唤醒一个等待取数据的消费者
}

public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly(); // 可响应中断地加锁
try {
while (count == 0) notEmpty.await(); // 队列空则等待
return dequeue();
} finally {
lock.unlock();
}
}

private E dequeue() {
E x = items[takeIndex];
items[takeIndex] = null;
takeIndex++;
count—;
notFull.signal(); // 腾出一个位置,唤醒一个生产者
return x;
}
}

原理讲解:核心是 Condition 的等待/唤醒机制。await() 会释放锁进入等待,signal() 唤醒一个等待线程后再竞争锁。这就是为什么队列可以同时实现"满时阻塞、空时阻塞",而不用调用者自己写 wait/notify。

7. LinkedBlockingQueue —— 无界队列:读写分锁

概念:和 ArrayBlockingQueue 类似,但内部是链表。它把读锁和写锁分开了:putLock 只管入队,takeLock 只管出队,两个条件变量 notFull 和 notEmpty 各归各管。

public class LinkedBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFull = putLock.newCondition();
private final AtomicInteger count = new AtomicInteger(); // 计数用原子类,跨两把锁

public void put(E e) throws InterruptedException {
int c = –1;
Node<E> node = new Node<>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) notFull.await(); // 满则等待
enqueue(node);
c = count.getAndIncrement(); // 先自增前计数加1
if (c + 1 < capacity) notFull.signal(); // 还有空位,唤醒下一个生产者
} finally {
putLock.unlock();
}
if (c == 0) signalNotEmpty(); // 队列从空变非空,唤醒等待的消费者
}

public E take() throws InterruptedException {
E x;
int c = –1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) notEmpty.await(); // 空则等待
x = dequeue();
c = count.getAndDecrement(); // 先自增后计数减1
if (c > 1) notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity) signalNotFull(); // 队列从满变不满,唤醒等待的生产者
return x;
}
}

原理讲解:读读、写写、读写的锁竞争都大幅减小——入队只和入队者抢锁,出队只和出队者抢锁。代价是 count 得用 AtomicInteger 来跨两把锁计数,因为两把锁没法安全地共享一个普通 int。

8. PriorityBlockingQueue —— 优先级队列:一把锁加无锁扩容

概念:一个基于数组堆结构的优先级阻塞队列,只有一把 ReentrantLock,内部还做了一个"扩容时先释放锁、扩完再锁"的优化。

public class PriorityBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
private final ReentrantLock lock;
private final Condition notEmpty;

public boolean offer(E e) {
if (e == null) throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
int n, cap;
Object[] array;
while ((n = size) >= (cap = (array = queue).length)) {
// 队列已满:扩容需要较长时间,先释放锁再扩
tryGrow(array, cap);
}
try {
siftUp(n, e, array); // 堆上浮操作
size = n + 1;
notEmpty.signal(); // 唤醒等待的消费者
} finally {
lock.unlock();
}
return true;
}

private void tryGrow(Object[] array, int oldCap) {
lock.unlock(); // 释放锁再扩容,让其他线程继续干活
try {
int newCap = oldCap + ((oldCap < 64) ? (oldCap + 2) : (oldCap >> 1));
queue = Arrays.copyOf(array, newCap);
} finally {
lock.lock(); // 扩完重新加锁
}
}
}

原理讲解:扩容是个纯分配内存的操作,不影响数据本身,所以暂时放掉锁让别的线程继续存取,扩完再回来。这是"把长耗时操作排除在临界区之外"的经典示范。注意 PriorityBlockingQueue 是无界的,所以只有 notEmpty 一个 Condition。


三、synchronized + CAS 派:并发 HashMap 的混搭

9. ConcurrentHashMap —— 锁桶头,读无锁

概念:JDK 1.8 的 ConcurrentHashMap 放弃了 1.7 的 Segment 分段锁,改为:桶为空时用 CAS 抢槽位,桶非空时 synchronized 锁住桶的头节点,读操作全程无锁。

final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0) {
tab = initTable(); // 延迟初始化:第一次 put 时才分配数组
} else if ((f = tabAt(tab, i = (n – 1) & hash)) == null) {
// 槽位是空的:CAS 直接放,成功就完事,不加锁
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
break;
} else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f); // 遇到正在扩容的桶,先帮它迁完
} else {
V oldVal = null;
synchronized (f) { // 槽位非空:只锁这个桶的头节点
if (tabAt(tab, i) == f) { // 再确认一次头节点没变(防止拿到过时数据)
if (fh >= 0) { // 普通链表节点
for (Node<K,V> e = f;;) {
if (e.hash == hash && e.key.equals(key)) {
oldVal = e.val;
e.val = value;
break;
}
if (e.next == null) { e.next = new Node<>(...); break; }
e = e.next;
}
} else if (f instanceof TreeBin) {
oldVal = ((TreeBin<K,V>)f).putTreeVal(...); // 红黑树分支
}
}
}
if (oldVal != null) return oldVal;
addCount(1L, binCount); // 计数,可能触发扩容
break;
}
}
return null;
}

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
// 用 Unsafe.getObjectVolatile 保证读取到最新值
return (Node<K,V>) U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

原理讲解:

  • 写分散到一个个桶头锁上,只有操作同一个桶的线程才互斥,并行度 ≈ 桶数;
  • 读完全不拿锁,靠 tabAt 的 volatile 语义读,get 基本等同 HashMap;
  • 扩容由多个线程协作迁移(helpTransfer),每个线程每次负责几个桶,速度比 HashMap 1.7 的单线程扩容快得多;
  • size() 用 baseCount + CounterCell[](类似 LongAdder)分散计数,多线程计数也几乎无竞争。

四、无锁但值得一提:CAS 的极端派

10. ConcurrentSkipListMap —— 跳表并发,无锁有序

概念:一个并发有序 Map,用 CAS 实现无锁并发,完全没有锁。数据结构是跳表,有序性和 TreeMap 相同,但并发性远好于 synchronized 包装的 TreeMap。

public class ConcurrentSkipListMap<K,V> extends AbstractMap<K,V>
implements ConcurrentNavigableMap<K,V>, Cloneable, java.io.Serializable {

private boolean doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z; // 新增节点
for (Index<K,V> q = head, r = q.right, t = z;;) {
if (r == null || r.node.key.compareTo(key) > 0) {
// 需要跳到下一层,或直接落到底层插入
if (q.down != null) { q = q.down; r = q.right; }
else {
// 找到插入位置,CAS 插入
if (t.key.compareTo(key) > 0) {
if (t.casNext(key, value)) { /* 插入成功 */ }
return false; // 或重来
}
}
}
}
}
}

原理讲解:跳表通过多层索引实现 O(log n) 查找。所有修改靠 CAS 乐观并发——遇到竞争就重试,而不是挂起等待。在高并发写场景下它的无冲突 CAS 操作几乎不占 CPU 锁时间。


五、总结速查

集合锁类型读操作写操作
Vector synchronized 方法 加锁 加锁
Hashtable synchronized 方法 加锁 加锁
Stack synchronized 方法(继承Vector) 加锁 加锁
Collections.synchronizedXxx synchronized 包装 加锁 加锁
CopyOnWriteArrayList ReentrantLock(写时复制) 无锁 加锁
ArrayBlockingQueue 一把 ReentrantLock + 两个 Condition 加锁 加锁
LinkedBlockingQueue 读写分锁(2 把 ReentrantLock) 加锁 加锁
PriorityBlockingQueue 一把 ReentrantLock(扩容时短暂释放) 加锁 加锁
ConcurrentHashMap synchronized(桶头)+ CAS 无锁 锁单个桶
ConcurrentSkipListMap 无锁(CAS + 跳表) 无锁 无锁

选型建议:

  • 需要 Map 并发:无脑 ConcurrentHashMap,别碰 Hashtable
  • 需要 List 并发、读多写少:CopyOnWriteArrayList;否则 Collections.synchronizedList
  • 生产者-消费者:LinkedBlockingQueue(吞吐高)或 ArrayBlockingQueue(防 OOM)
  • 需要排序且并发:ConcurrentSkipListMap
赞(0)
未经允许不得转载:网硕互联帮助中心 » Java 并发集合:锁的众生相
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!