在Java的集合框架中,ArrayList、HashMap、HashSet等常用容器本身并不是线程安全的。它们的设计目标是单线程环境下的高性能,例如ArrayList的add方法内部涉及数组容量检查、元素写入和长度自增等多个步骤,这些步骤组合起来并不是一个原子操作。当多个线程同时对一个共享的ArrayList执行add时,可能出现两种典型问题:一是两个线程同时读到同一个size值,后写入的线程覆盖先写入的线程,造成元素丢失;二是数组扩容过程中另一个线程写入,可能引发数组越界异常。Java同步容器正是为了解决这类共享可变数据问题而出现的,它通过对公共读写方法加锁,把原本可被多个线程交叉执行的复合操作串行化,从而在语言层面提供最简单的线程安全保证。

多线程共享容器会遇到哪些具体问题
为了直观理解为什么需要同步容器,可以先看一个典型场景。创建两个线程,分别向同一个ArrayList执行一千次add操作。理想情况下,最终列表大小应当是2000,但实际运行中经常出现结果小于2000,甚至抛出ArrayIndexOutOfBoundsException。原因在于ArrayList.add的源码逻辑并不只是简单地在数组末尾放一个元素,它先调用ensureCapacityInternal确认容量,再执行elementData[size++] = e。这个过程中size++也不是线程安全的,多个线程可能读取到同一个旧值,导致一次写入被覆盖。数组扩容时,新数组创建和旧数据复制之间,其他线程如果继续写入旧数组引用,就可能访问越界。
除了数据丢失和越界,还存在内存可见性问题。Java内存模型中,每个线程可以把共享变量缓存到自己的本地内存,一个线程对集合内部数组和size字段的修改,另一个线程不一定能立即看到。即使某些巧合下没发生覆盖,读线程也可能长期读到过期的size值。同步容器通过synchronized关键字同时解决互斥和可见性:加锁时强制读取主内存最新值,解锁时把工作内存中的修改刷新回主内存。因此,把ArrayList替换为Vector,或者使用Collections.synchronizedList包装后,相同的并发add测试就能得到稳定的2000结果。
// ArrayList 的并发写入通常不安全
List<String> list = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add("value");
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(list.size()); // 可能小于 2000
上面的代码没有任何同步措施,list.size()输出值在每次运行时都可能变化。如果把list声明为Vector,或者通过Collections.synchronizedList(new ArrayList<>())包装,每个add方法会被加锁保护,两个线程不能同时进入add逻辑,最终就能得到2000。这说明同步容器的核心价值不在于性能,而在于用最简单的API让共享集合在多线程下具备基本的可预测行为。
同步容器的加锁机制是怎样的
Java同步容器主要有三类:早期的Vector、Hashtable,以及Collections工具类提供的synchronizedList、synchronizedMap、synchronizedSet等包装方法。它们的实现思路相似:在每一个可能修改或读取内部数据结构的方法上添加synchronized关键字,锁对象通常是容器实例本身。以Vector.add为例,方法签名是public synchronized boolean add(E e),进入方法后先记录modCount,再检查容量,最后把元素写入elementData数组并增加elementCount。因为锁是Vector对象,无论哪个线程调用add、remove、get,都必须先获得同一把锁,所以单个方法调用是原子的。
Hashtable的实现同样使用synchronized方法,put、get、remove、size都加锁。Collections.synchronizedList则采用了另一个常见做法:内部维护一个mutex对象,所有包装方法都使用synchronized(mutex)代码块。例如它的add方法会转为mutex锁保护的list.add调用。这种设计允许在创建包装器时指定锁对象,方便在客户端进行更大范围的同步。从JVM层面看,synchronized不管是加在方法上还是代码块上,本质都是进入对象监视器monitor,通过monitorenter和monitorexit指令完成加锁与解锁。同一个线程重复进入同一把锁时,计数器会递增,离开时递减,这就是可重入性,它保证了一个同步方法调用另一个同步方法不会自己把自己锁死。
// Collections.synchronizedList 的典型包装方式
List<String> normalList = new ArrayList<>();
List<String> syncList = Collections.synchronizedList(normalList);
syncList.add("a");
syncList.add("b");
System.out.println(syncList.get(0)); // 方法级同步保护
还有一个容易被忽略的点是同步容器对fail-fast机制的影响。ArrayList内部有一个modCount字段,每次结构变化都会自增。迭代器在遍历时会检查modCount是否发生变化,如果变化则抛出ConcurrentModificationException。同步容器虽然每个方法加锁,但迭代器本身并不持有容器锁,因此遍历期间另一个线程修改集合仍会触发异常,这也解释了为什么同步容器不意味着所有操作都安全。
同步容器有哪些使用陷阱
同步容器最容易让人误判的地方是:单个方法线程安全不等于组合操作线程安全。一段很常见的代码是if (!list.contains(x)) { list.add(x); }。在单线程下没有问题,但在多线程下,两个线程可能同时执行contains并都返回false,然后先后调用add,导致x被加入两次。同步容器只能保证contains和add各自执行时不被其他线程打断,无法保证两个方法之间的判断和写入作为一个整体不被插入。如果业务逻辑要求元素唯一,这种写法依然会出错。
解决组合操作问题需要在客户端手动加锁,并且锁对象必须和容器内部使用的锁一致。对于Vector和Hashtable来说,锁就是容器实例本身;对于Collections.synchronizedList包装出来的列表,锁是包装器内部的mutex。由于mutex不是公开的,最稳妥的做法是直接锁定包装后的同步列表对象,例如synchronized(syncList) { if (!syncList.contains(x)) syncList.add(x); }。这样可以保证整个复合操作期间,其他线程无法调用该列表的任意同步方法。
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
public void addIfAbsent(String x) {
synchronized (syncList) {
if (!syncList.contains(x)) {
syncList.add(x);
}
}
}
同样的要求适用于迭代操作。对同步容器进行遍历时,如果其他线程可能修改集合,就必须把整个迭代过程放在同一把锁的保护下。例如synchronized(syncList) { for (String s : syncList) { ... } }。如果不在外部加锁,迭代器和容器内部的修改操作互不排斥,遍历中极有可能抛出ConcurrentModificationException。这个异常本身是一种快速失败信号,提示集合结构已经发生变化,不能继续安全迭代。因此,使用同步容器并不意味着可以省略所有并发控制,而是需要识别哪些操作是复合操作,并针对这些复合操作添加额外的锁粒度。
同步容器与并发容器如何选择
同步容器通过独占锁把所有访问串行化,优点是实现简单、行为容易理解,适合并发度不高、集合操作不频繁的小型应用或老代码维护。但在高并发场景下,整把锁会变成性能瓶颈。比如一个线程正在遍历Vector,其他所有读写线程都要阻塞等待。当集合很大时,这种串行化会导致明显的上下文切换和线程阻塞成本。因此从JDK 5开始,Java引入了更细粒度的并发容器,如CopyOnWriteArrayList、ConcurrentHashMap、ConcurrentLinkedQueue等。
CopyOnWriteArrayList的思路是写时复制:每次修改都复制底层数组,在副本上写入,再切换引用;读操作完全无锁。它适合读多写少的场景,比如事件监听器列表、配置项缓存。如果写操作很频繁,复制成本会非常高。ConcurrentHashMap在JDK 7中使用分段锁,JDK 8以后改为CAS加synchronized对单个哈希桶加锁,极大降低了锁竞争。比起Hashtable每次put都锁整张表,ConcurrentHashMap允许多个线程同时写不同桶的数据,吞吐量有明显提升。
但并发容器也并非万能。CopyOnWriteArrayList的迭代器不支持remove操作,ConcurrentHashMap的size返回值不是精确值,某些条件判断需要接受弱一致性。同步容器虽然性能较差,但它的强一致性和简单语义在低并发、小集合场景下仍然有存在价值。选择时应先评估共享集合的读写比例、数据量、线程数量和一致性要求。如果只是十几个线程偶尔访问一个小列表,Vector或同步包装器足够;如果是高并发高频写入,优先考虑ConcurrentHashMap或BlockingQueue等并发容器。理解同步容器的加锁机制和局限,是理解Java并发容器演进路线的基础。