十 一. 如何保证集合是线程安全的?
11.1 典型回答
11.1.1 Java提供了不同层面的线程安全支持。
除了Hashtable等容器,还提供了同步包装器(synchronized Wrapper),我们可以调用 Collections 工具类提供的包装的方法,进行获取一个同步的包装容器(如Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能较低。
普遍选择利用并发包提供的线程安全容器类。
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
同步包装器是Java中用于实现线程安全的容器类的一种机制。它通过在底层容器上添加同步操作来确保多个线程之间的数据访问同步。
Java提供了Collections工具类来创建各种类型的同步包装器,如synchronizedList、synchronizedSet、synchronizedMap等。这些同步包装器可以将非线程安全的集合或映射转换为线程安全的。
11.1.1.1 例子:
同步包装器采用了较为粗粒度的同步方式,即在对整个容器对象进行同步操作。当一个线程获取容器对象的锁时,其他线程需要等待该线程释放锁才能进行操作。这样做的好处是简单直接,不需要额外的复杂的同步逻辑。然而,在高并发情况下,由于所有操作都需要竞争同一个锁,可能会导致性能下降。
import java.util.*; public class SynchronizedWrapperExample { public static void main(String[] args) { // 创建一个非线程安全的ArrayList List<String> list = new ArrayList<>(); // 使用同步包装器将其转换为线程安全的 List<String> synchronizedList = Collections.synchronizedList(list); // 创建并启动多个线程同时对同一个列表进行操作 for (int i = 0; i < 5; i++) { int threadNumber = i; new Thread(() -> { for (int j = 0; j < 100; j++) { synchronizedList.add("Thread " + threadNumber); } }).start(); } // 等待所有线程执行完毕 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 输出列表元素个数(预期为500) System.out.println(synchronizedList.size()); } }
在上面的示例中,我们首先创建一个非线程安全的ArrayList对象,并使用Collections.synchronizedList()方法将其转换为线程安全的同步包装器。然后,我们创建了5个线程,每个线程向列表中添加100个元素。由于使用了同步包装器,多个线程对列表的操作会依次进行同步,确保线程安全。最后,我们输出列表的元素个数,预期结果为500。
11.1.2 并发容器:
Java提供了ConcurrentHashMap和CopyOnWriteArrayList等并发容器,它们采用了更加高效的并发实现方式,在高并发情况下性能表现更好。
ConcurrentHashMap,CopyOnWriteArrayList。
ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>(); CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
11.1.2.1 ConcurrentHashMap
我们创建了一个ConcurrentHashMap对象,并使用多个线程同时向映射中添加元素。由于ConcurrentHashMap采用了分段锁的机制,不同的线程可以同时访问和修改不同的段,从而提高了并发性能。最后,我们输出映射的大小。
import java.util.concurrent.ConcurrentHashMap; public class ConcurrentHashMapExample { public static void main(String[] args) { ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); // 创建并启动多个线程同时对同一个映射进行操作 for (int i = 0; i < 5; i++) { int threadNumber = i; new Thread(() -> { for (int j = 0; j < 100; j++) { map.put("Key" + j, threadNumber); } }).start(); } // 等待所有线程执行完毕 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 输出映射的大小 System.out.println(map.size()); } }
11.1.2.2 CopyOnWriteArrayList
我们创建了一个CopyOnWriteArrayList对象,并使用多个线程同时向列表中添加元素。CopyOnWriteArrayList通过实现写时复制(Copy-On-Write)机制,在写操作时创建并复制一个新的数组,从而避免了线程冲突和数据不一致的问题。这使得在高并发读取的情况下,CopyOnWriteArrayList具有很好的性能表现。最后,我们输出列表的大小。
import java.util.concurrent.CopyOnWriteArrayList; public class CopyOnWriteArrayListExample { public static void main(String[] args) { CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(); // 创建并启动多个线程同时对同一个列表进行操作 for (int i = 0; i < 5; i++) { int threadNumber = i; new Thread(() -> { for (int j = 0; j < 100; j++) { list.add(threadNumber); } }).start(); } // 等待所有线程执行完毕 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 输出列表的大小 System.out.println(list.size()); } }
11.1.2.3 注意:
虽然ConcurrentHashMap和CopyOnWriteArrayList在高并发场景下性能较好,但它们也有自己的适用场景和限制。在选择并发容器时,应根据具体的需求和并发访问模式来选择合适的容器。
11.1.2.4 ConcurrentHashMap和CopyOnWriteArrayList实现方式和场景精讲:
- 11.1.2.4.1 实现方式的区别:
- ConcurrentHashMap:ConcurrentHashMap采用了分段锁(Segment Locking)的机制来实现并发访问。内部使用类似于哈希表的数据结构,将整个数据集分成多个段(Segment),每个段都拥有自己的锁,不同的线程可以同时访问和修改不同的段,从而提高了并发性能。
- CopyOnWriteArrayList:CopyOnWriteArrayList采用写时复制(Copy-On-Write)的机制。在写操作时,会创建并复制一个新的数组,从而避免了读写冲突和数据不一致的问题。读操作不需要加锁,因此可以实现高并发的读取。
11.1.2.4.2 适用场景的区别:
- ConcurrentHashMap:适用于多线程环境下需要频繁进行更新操作的情况。由于ConcurrentHashMap采用分段锁的机制,不同的线程可以同时对不同的段进行操作,因此在高并发的情况下,能够获得较好的性能表现。它通常用于替代Hashtable或使用synchronized关键字保护的传统HashMap。
- CopyOnWriteArrayList:适用于多线程环境下以读操作为主、更新操作较少的情况。由于CopyOnWriteArrayList的读操作不需要加锁,因此在高并发的读取场景下,能够提供较好的性能。但是,写操作会创建并复制一个新的数组,因此写操作的性能相对较低。它通常用于代替同步的ArrayList,并且适用于读多写少的场景。
11.1.2.5 CopyOnWriteArrayList解析:
ConcurrentHashMap适用于频繁的更新操作和较高的并发度,通过分段锁实现线程安全和较高的并发性能;而CopyOnWriteArrayList适用于读操作较多、写操作较少的场景,通过写时复制机制实现线程安全和较好的读性能。选择合适的并发容器应根据具体需求和并发访问模式来决定。
在CopyOnWriteArrayList中,每次写操作都会创建一个新的数组,并将原数组保留为只读状态。因此,随着写操作的频繁发生,会产生多个不同版本的原数组。
对于读取操作,它会选择最近一次写操作完成后的原数组进行读取。也就是说,读取操作会选择最新版本的原数组来获取数据,以确保数据的一致性。
需要注意的是,即使有多个版本的原数组存在,读取操作仍然可以安全地进行。这是因为每个版本的原数组都是只读的,不会发生数据修改。而且,读取操作不需要加锁,可以并发地进行。
CopyOnWriteArrayList中,读取操作会选择最近一次写操作完成后的原数组进行读取,以保证数据的一致性。多个版本的原数组不会影响读取操作的进行
11.1.3 线程安全队列(Queue/Deque):
Java提供了多种线程安全的队列(Queue/Deque)实现,其中包括ArrayBlockingQueue和SynchronousQueue。这些线程安全队列可以在多线程环境下安全地进行元素的插入、删除和检索操作。
ArrayBlockingQueue,SynchronousQueue。
ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue<>(10); SynchronousQueue<String> synchronousQueue = new SynchronousQueue<>();
11.1.3.1 ArrayBlockingQueue
我们创建了一个ArrayBlockingQueue对象,并使用多个线程同时向队列中添加元素。
ArrayBlockingQueue使用固定大小的数组作为底层数据结构,在写入元素时会阻塞直到有空间可用,从而保证线程安全和有界性。最后,我们输出队列的大小。
import java.util.concurrent.ArrayBlockingQueue; public class ArrayBlockingQueueExample { public static void main(String[] args) { ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10); // 创建并启动多个线程同时向队列中添加元素 for (int i = 0; i < 5; i++) { int threadNumber = i; new Thread(() -> { try { for (int j = 0; j < 100; j++) { queue.put(threadNumber); } } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } // 等待所有线程执行完毕 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 输出队列的大小 System.out.println(queue.size()); } }
11.1.3.1.1 详细:
ArrayBlockingQueue是Java中的一个线程安全、有界的阻塞队列。它使用固定大小的数组作为底层数据结构,可以在写入元素时阻塞直到有空间可用,从而保证线程安全和有界性。
当我们创建一个ArrayBlockingQueue对象时,需要指定队列的容量大小。这个容量决定了队列中可以存放的元素数量的上限。
多个线程同时向ArrayBlockingQueue添加元素时,如果队列已满(即达到了容量上限),写入操作将会被阻塞,直到有空间可用。这意味着写入操作会等待其他线程从队列中取出元素或者扩容后再继续执行。这样可以确保线程安全,避免了多个线程同时写入导致的数据竞争和不一致性。
当线程成功地将元素添加到ArrayBlockingQueue中后,其他线程可以继续读取或写入元素,只要队列没有达到容量上限。读取操作不会被阻塞,只有当队列为空时,读取操作会被阻塞,直到有新的元素加入。
最后,输出队列的大小即为当前队列中的元素个数。注意,由于ArrayBlockingQueue具有有界性,队列的大小永远不会超过初始化时指定的容量。
ArrayBlockingQueue是一个线程安全、有界的阻塞队列,使用固定大小的数组作为底层数据结构。它可以保证在写入元素时阻塞直到有空间可用,从而实现了线程安全和有界性。通过输出队列的大小可以获取当前队列中的元素个数。
11.1.3.2 SynchronousQueue
我们创建了一个SynchronousQueue对象,并使用多个线程同时向队列中添加和移除元素。SynchronousQueue是一个没有容量的队列,它要求插入操作必须等待另一个线程进行相应的删除操作,从而实现了线程之间的同步。每个插入操作都会阻塞直到有另一个线程进行相应的删除操作,反之亦然。
import java.util.concurrent.SynchronousQueue; public class SynchronousQueueExample { public static void main(String[] args) { SynchronousQueue<Integer> queue = new SynchronousQueue<>(); // 创建并启动多个线程同时向队列中添加和移除元素 for (int i = 0; i < 5; i++) { int threadNumber = i; new Thread(() -> { try { queue.put(threadNumber); // 向队列中添加元素 int element = queue.take(); // 从队列中取出元素 System.out.println("Element: " + element); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } } }
11.1.3.2.1 详细小结:
ynchronousQueue是一个特殊的无容量队列,它实现了线程之间的同步。在SynchronousQueue中,每个插入操作都必须等待另一个线程进行相应的删除操作,反之亦然。这种机制确保了只有在有消费者线程等待接收元素时,生产者线程才能成功将元素插入到队列中,而且只有在有生产者线程插入元素时,消费者线程才能成功从队列中移除元素。
具体来说,当一个线程调用SynchronousQueue的put()方法尝试将一个元素插入队列时,如果此时没有其他线程正在等待删除操作,则插入操作会被阻塞,直到另一个线程调用take()方法来获取并删除该元素为止。同样地,当一个线程调用SynchronousQueue的take()方法尝试从队列中获取并删除一个元素时,如果此时没有其他线程正在等待插入操作,则获取操作也会被阻塞,直到另一个线程调用put()方法来插入一个元素为止。
由于SynchronousQueue没有容量限制,因此它不会保存任何元素,仅充当了一个传递数据的通道。这种特性使得SynchronousQueue非常适合于一些场景,例如在生产者和消费者线程之间进行高效的数据交换。
需要注意的是,SynchronousQueue对于多个生产者和消费者线程之间的同步是一对一的关系。也就是说,每个插入操作都必须等待一个相应的删除操作,反之亦然。如果存在多个生产者线程或多个消费者线程,它们之间的调度和顺序将由线程调度器来决定。
11.1.3.3 注意:
ArrayBlockingQueue适用于有界队列的场景,而SynchronousQueue适用于无界队列且对吞吐量有较高要求的场景。选择合适的线程安全队列应根据具体需求和并发访问模式来决定。
11.1.4 各种有序容器的线程安全版本:
Java提供了一些有序容器的线程安全版本,如ConcurrentSkipListMap和ConcurrentSkipListSet等。这些容器在保证线程安全的同时,能够维护元素的有序性。
ConcurrentSkipListMap<Integer, String> concurrentSkipListMap = new ConcurrentSkipListMap<>(); ConcurrentSkipListSet<Integer> concurrentSkipListSet = new ConcurrentSkipListSet<>();
11.1.4.1 详细解释:
Java中的ConcurrentSkipListMap
和ConcurrentSkipListSet
是线程安全的有序容器,它们在保证多线程环境下的安全性的同时,能够维护元素的有序性。
ConcurrentSkipListMap是基于跳表(Skip List)数据结构实现的有序映射,底层通过链表层级结构实现快速查找、插入和删除操作。它支持键值对的存储,并且根据键的自然顺序或自定义比较器进行排序。由于使用了跳表的特点,在并发情况下,ConcurrentSkipListMap可以提供较好的性能。
11.1.4.1.1 ConcurrentSkipListMap使用:
import java.util.concurrent.ConcurrentSkipListMap; public class ConcurrentSkipListMapExample { public static void main(String[] args) { // 创建一个ConcurrentSkipListMap实例 ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>(); // 向map中插入键值对 map.put(3, "Apple"); map.put(1, "Banana"); map.put(2, "Orange"); map.put(4, "Grapes"); // 遍历并输出map中的元素 for (Integer key : map.keySet()) { System.out.println(key + ": " + map.get(key)); } } }
输出:
1. 1: Banana 2. 2: Orange 3. 3: Apple 4. 4: Grapes
11.1.4.1.2 ConcurrentSkipListSet使用:
ConcurrentSkipListSet
是基于ConcurrentSkipListMap
实现的有序集合,它使用了与ConcurrentSkipListMap
相同的跳表数据结构。它可以存储不重复的元素,并且根据元素的自然顺序或自定义比较器进行排序。
import java.util.concurrent.ConcurrentSkipListSet; public class ConcurrentSkipListSetExample { public static void main(String[] args) { // 创建一个ConcurrentSkipListSet实例 ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>(); // 向set中添加元素 set.add(3); set.add(1); set.add(2); set.add(4); // 遍历并输出set中的元素 for (Integer element : set) { System.out.println(element); } } }
输出:
1 2 3 4
ConcurrentSkipListMap和ConcurrentSkipListSet提供了线程安全的有序容器,能够在多线程环境下保证安全性,并且能够维护元素的有序性。这使得它们在需要同时满足线程安全和有序性的场景下非常有用。
具体保证线程安全的方式,包括从简单的synchronize方式,到基于更加细化,基于分离锁实现的ConcurrentHashMap等并发实现,具体选择要看开发的场景需求,并发包内提供的容器通用场景,远优于早期的简单同步实现
11.2 考点分析
线程安全和并发,是Java面试必考的点。
前面几篇重点解读了HashMap,ConcurrentHashMap,以及涉及的底层CAS这些机制
11.3 趣谈部分
11.3.1 为什么需要ConcurrentHashMap?
Hashtable 本身比较低效,因为它的实现基本上是将put,get,size等各种方法加上"synchronized"。
导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作的时候,其他线程只能等待,大大降低了并发操作的效率。
HashMap不是线程安全的,并发情况会导致CPU占用100%这些情况。
如果利用Collection提供的同步包装器来解决问题。
同步包装版本采用了更细粒度的锁机制。例如,Collections.synchronizedMap()方法返回的同步包装版本中,只对每个单独的操作方法进行了同步,不需要在整个对象上加锁。这样,多个线程可以同时对不同的键值对进行操作,提高了并发性能,并没有真正的改进。
11.3.2 Hashtable和同步包装器的底层源码部分解释:
从源码角度讲解区别。
11.3.2.1 Hashtable的put方法源码片段:
在Hashtable的put方法中,使用了synchronized关键字对方法进行同步。这意味着每次只能有一个线程访问put方法,其他线程必须等待。
public synchronized V put(K key, V value) { // 具体插入操作 // ... return oldValue; }
11.3.2.2 同步包装器的put方法源码片段:
在同步包装器中,使用了一个私有的辅助对象mutex
作为锁。通过synchronized
关键字对mutex
进行同步,确保每个操作方法都是线程安全的。
public V put(K key, V value) { synchronized (mutex) { // 具体插入操作 // ... return oldValue; } }
11.3.2.2.1 同步包装器实现原理 例子:
import java.util.HashMap; import java.util.Map; public class SynchronizedWrapperExample { public static void main(String[] args) { // 创建一个非线程安全的HashMap对象 Map<String, Integer> hashMap = new HashMap<>(); // 使用同步包装器创建一个线程安全的Map对象 Map<String, Integer> synchronizedMap = synchronizedMap(hashMap); // 创建多个线程对集合进行操作 Thread thread1 = new Thread(() -> { for (int i = 0; i < 1000; i++) { synchronizedMap.put("Key" + i, i); } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 1000; i++) { synchronizedMap.get("Key" + i); } }); // 启动线程 thread1.start(); thread2.start(); // 等待线程执行完毕 try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } // 打印集合大小 System.out.println(synchronizedMap.size()); } // 同步包装器的实现 public static <K, V> Map<K, V> synchronizedMap(Map<K, V> map) { return new SynchronizedMap<>(map); } // 自定义的同步包装器类 private static class SynchronizedMap<K, V> implements Map<K, V> { private final Map<K, V> map; // 内部维护一个非线程安全的Map对象 public SynchronizedMap(Map<K, V> map) { this.map = map; } // 对所有操作进行同步,使用this作为锁 @Override public synchronized int size() { return map.size(); } @Override public synchronized boolean isEmpty() { return map.isEmpty(); } @Override public synchronized boolean containsKey(Object key) { return map.containsKey(key); } @Override public synchronized boolean containsValue(Object value) { return map.containsValue(value); } @Override public synchronized V get(Object key) { return map.get(key); } @Override public synchronized V put(K key, V value) { return map.put(key, value); } @Override public synchronized V remove(Object key) { return map.remove(key); } // 其他方法省略... } }
在上面的代码中,我们创建了一个非线程安全的HashMap对象 hashMap,然后通过 synchronizedMap() 方法将其包装成线程安全的 synchronizedMap 对象。
自定义的 SynchronizedMap 类实现了 Map 接口,并对所有的操作方法进行了同步化处理,即使用 synchronized 关键字修饰方法,以保证在多线程环境下的线程安全性。
当多个线程同时对 synchronizedMap 进行操作时,只有一个线程能够获得锁并执行操作,其他线程需要等待。这样就确保了数据一致性和线程安全性。
需要注意的是,虽然同步包装器确保了线程安全性,但在高度并发的场景下,性能可能会受限。因为当一个线程持有锁进行操作时,其他线程需要等待,从而降低了整体的吞吐量。因此,在高并发场景中,可以考虑使用专门为高并发设计的集合类,如ConcurrentHashMap,以提供更好的性能。
Hashtable使用了粗粒度的锁,对整个对象进行同步,而同步包装器采用了细粒度的锁,只对具体的操作方法进行同步,并使用一个私有的辅助对象作为锁。这使得同步包装器在多线程环境下能够更高效地处理并发操作。