JUC-上-灵析社区

菜鸟码转

一、 并发容器

当涉及到 Java 多线程编程时,JUC(Java Util Concurrent)是非常重要的库之一,JUC 是 JDK5 引入的并发工具包,它提供了许多高效且易于使用的并发编程工具,可以帮助开发者更加方便、高效地开发并发程序,提高系统的性能和可靠性。

Java 的并发容器解决了什么问题

Java 的并发容器解决了多线程环境下的线程安全性问题和数据一致性问题。在多线程环境下,多个线程同时访问和操作共享数据时,如果不进行合适的同步和互斥控制,就容易出现数据竞争、死锁、数据不一致等问题。

Java 的并发容器充分利用了多核 CPU 的优势,提高了多线程编程的性能和效率,也提供了一些方便的方法和接口,可以使得多线程编程更加灵活和易于使用。

ConcurrentHashMap

  • 我们知道 Hashtable 是一个线程安全的哈希表,它是在 JDK1.0 的时候出现的,但它性能较差、不支持 null 值作为 key 和 value、当 Hashtable 被修改时,迭代器可能会抛出 ConcurrentModificationException 异常;
  • ConcurrentHashMap 是一个线程安全的哈希表实现,适用于多线程环境下的并发访问。与 Hashtable 不同,ConcurrentHashMap 不是通过全局锁来实现同步的,而是通过分段锁的机制来保证数据的一致性和性能;
  • ConcurrentHashMap 可以用于实时统计,例如统计网站的访问量或者用户活跃度等,多个线程可以同时更新 ConcurrentHashMap 中的数据,而不需要加锁。

示例代码:

package cn.leetcode.juc;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> hashMap = new ConcurrentHashMap<>();
        hashMap.put("AC", "Accepted");
        hashMap.put("WA", "Wrong Answer");
        hashMap.put("TLE", "Time Limit Exceed");
        hashMap.put("OLE", "Output Limit Exceed");
        hashMap.put("MLE", "Memory Limit Exceed");

        Set<Map.Entry<String, String>> entries = hashMap.entrySet();
        for (Map.Entry<String, String> entry : entries) {
            System.out.printf("key = %s, value = %s \n", entry.getKey(), entry.getValue());
        }

        String ac = hashMap.get("AC");
        System.out.println("ac = " + ac);

        String wa = hashMap.remove("WA");
        System.out.println("wa = " + wa);

        boolean containsKeyAC = hashMap.containsKey("AC");
        System.out.println("containsKeyAC = " + containsKeyAC);
    }

}

ConcurrentHashMap 的原子操作

在 ConcurrentHashMap 中,有一些方法,虽然「看上去有好几个步骤」,但它们实际上是原子操作,可以保证线程安全,常用的原子操作有:

这些原子操作都能够保证线程安全,并且在多线程环境下能够正确地完成操作。在使用 ConcurrentHashMap 时,我们应该尽量使用这些原子操作,避免自己编写线程不安全的代码。

CopyOnWriteArrayList 和 CopyOnWriteArraySet

  • CopyOnWrite 的意思是「写时复制」,它的基本思想是在进行写入操作时,先复制一份当前的数据结构,然后在新的数据结构上进行修改操作,最后将新的数据结构替换为旧的数据结构。通过复制来实现线程安全,避免了显式锁的使用,同时也避免了锁的竞争,从而提高了并发性能;
  • CopyOnWriteArrayList 是一个线程安全的动态数组,适用于多线程环境下的 读多写少 场景。CopyOnWriteArrayList 每次写操作都会创建一个新的数组,因此读操作不会被阻塞。它的写操作比较耗费时间和空间,因此 CopyOnWriteArrayList 在写操作频繁的场景下不适用;
  • CopyOnWriteArraySet 是线程安全的集合类,它内部使用 CopyOnWriteArrayList 来存储元素。它的特点是可以保证元素的唯一性,同时也可以提供与 HashSet 类似的高效查询性能。但是,由于每次写操作都需要复制整个数组,因此在写多的场景中,性能会受到较大影响。CopyOnWriteArraySet 内部的数组是无序的,不支持随机访问,只能通过迭代器进行遍历操作。

并发容器的局限性

虽然并发容器可以提高程序在多线程环境下的性能和可靠性,但是它们也有一些局限性:

  • 内存消耗:并发容器通常需要占用更多的内存空间来保证线程安全。比如,ConcurrentHashMap 会占用更多的内存空间来存储桶的锁对象,CopyOnWriteArrayList 则需要在写操作时复制整个数组,消耗更多的内存;
  • 遍历性能:由于并发容器的内部数据结构通常是多个节点或桶组成的链表或数组,因此在进行遍历操作时会比普通容器慢。特别是在 ConcurrentHashMap 中,由于需要在多个桶中查找数据,因此在遍历时的性能比较低;
  • 更新操作效率低下:并发容器在设计时为了保证线程安全,通常采用了加锁、CAS 等机制,这些机制会导致更新操作的性能下降。因此,对于频繁进行更新操作的场景,如需要高效的插入、删除、更新操作,可能需要选择其他的数据结构。

综上所述,虽然并发容器可以提供线程安全的数据访问,但是在使用时需要权衡其性能和局限性,根据实际需求选择合适的并发容器或其他数据结构。

常见的面试问题

1、ConcurrentHashMap 和 HashTable 的区别是什么?

ConcurrentHashMap 和 HashTable 都是 Java 中线程安全的哈希表实现,但它们在实现方式、性能和扩容方式存在一些差异。

  • 实现方式不同:

ConcurrentHashMap 是基于 分段锁 的并发哈希表实现,它将整个哈希表分成多个小的哈希表段,每个段上有自己的锁,可以支持多个线程同时进行读写操作,从而提高了并发性能;

HashTable 是基于全局锁的并发哈希表实现,它在进行读写操作时需要获取整个哈希表的锁,因此并发性能比 ConcurrentHashMap 差。

  • 扩容方式不同:

ConcurrentHashMap 的扩容是基于分段锁的实现,它可以在多线程环境下动态地调整哈希表的大小,而不需要显式地使用锁来保护共享资源,从而提高了并发性能;

HashTable 的大小是固定的,在哈希表填满时需要进行扩容操作。在并发场景下,HashTable 的扩容操作需要获取整个哈希表的锁,因此会导致锁的竞争,降低并发性能;

  • 迭代器支持不同:

ConcurrentHashMap 的迭代器支持弱一致性,即迭代器返回的元素可能已经被删除或添加了;

HashTable 的迭代器不支持弱一致性,如果在迭代过程中进行修改操作,会抛出 ConcurrentModificationException 异常;

  • null 值支持不同:

ConcurrentHashMap 支持 null 值。在 ConcurrentHashMap 中,null 被视为一个特殊的值,可以被添加到哈希表中,在获取操作时需要特殊处理。

HashTable 不支持 null 值。

2、请简述并发容器的实现原理

并发容器是为了实现多线程环境下的安全读写而设计的。它的实现原理可以通过以下几个方面来理解:

  • 锁机制:有些并发容器在实现上采用了传统的锁机制,比如 synchronized、ReentrantLock 等。当一个线程需要对容器进行操作时,它需要先获得相应的锁,以保证其它线程不能同时进行操作,从而保证线程安全。锁机制虽然可以保证线程安全,但是对性能会有一定的影响,特别是在高并发的场景下。
  • 分段锁机制:有些并发容器采用了分段锁机制,比如 ConcurrentHashMap。ConcurrentHashMap 中的数据结构由多个桶组成,每个桶又由多个节点组成。每个桶都可以看作是一个独立的数据结构,因此可以为每个桶设置一个独立的锁。这样,当一个线程需要对容器进行操作时,只需要获取对应桶的锁,而不需要获取整个容器的锁,从而提高了并发度,减少了锁的竞争;
  • CAS 机制:有些并发容器采用了无锁机制,比如 ConcurrentLinkedQueue。在无锁机制下,线程之间并不需要进行同步,而是通过 CAS(Compare and Swap)等原子操作来实现对容器的并发访问。在高并发的场景下,无锁机制可以大大提高容器的并发度和性能;
  • Copy-On-Write 机制:有些并发容器采用了 Copy-On-Write 机制,比如 CopyOnWriteArrayList。在 Copy-On-Write 机制下,写操作并不是直接对容器进行修改,而是先将容器复制一份,在副本上进行修改,然后再用新副本替换旧副本。读操作则直接在旧副本上进行,从而保证了读操作的线程安全性。

综上所述,不同的并发容器在实现上采用了不同的机制来保证线程安全和提高性能。我们需要根据不同的场景选择不同的并发容器来满足需求。

二、 阻塞队列

阻塞队列是一种特殊的队列,其特点是当队列为空时,消费者线程会被阻塞,直到队列中有新的元素被加入;当队列已满时,生产者线程会被阻塞,直到队列中有元素被消费。阻塞队列会阻塞线程的执行,直到特定的条件满足,因此称之为阻塞队列。

阻塞队列在多线程编程中被广泛应用,可以用来解决生产者-消费者问题,可以有效地协调生产者和消费者之间的操作,平衡生产者和消费者的速度。

阻塞队列的实现通常依赖于锁和 条件变量。阻塞队列的实现需要考虑线程安全和性能等因素,不同的阻塞队列实现可能会采用不同的算法和数据结构来实现阻塞操作。常见的阻塞队列实现包括 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue 等。其中,ArrayBlockingQueue 和 LinkedBlockingQueue 都采用了锁和条件变量来实现阻塞操作,而 SynchronousQueue 则使用了其他的同步机制,如信号量等。

锁 + 队列 = 阻塞队列

可以简单这样理解:「锁 + 队列 = 阻塞队列」。通过阻塞操作实现线程之间的协作和同步。阻塞队列是一个重要的并发编程工具,在多线程编程中广泛应用于生产者消费者模式、线程池、任务调度等场景。

阻塞队列接口

  • BlockingQueue 是 Java 中的一个线程安全的阻塞队列的接口,它可以在多线程环境下进行数据的读取和写入操作,而不需要开发人员显式地使用锁来保护共享资源;

  • BlockingQueue 内部实现是基于锁的,它使用了 ReentrantLock 和 Condition 来保证线程安全。当队列为空时,读取操作会进入等待状态,当队列满时,写入操作会进入等待状态,通过 Condition 的 await() 和 signal() 方法可以实现等待和唤醒操作,从而保证了线程安全;
  • BlockingQueue 提供的一些操作,例如 put()、take()、offer()、poll() 等方法,它们都是原子操作,可以保证在多线程环境下正确地更新队列中的元素;
  • BlockingQueue 提供了多种阻塞的方式,例如 put() 方法在队列满时会阻塞线程,take() 方法在队列为空时会阻塞线程;
  • 通过使用 BlockingQueue,开发人员可以避免显式地使用锁和 synchronized 来保护共享资源,从而简化了并发编程的复杂度。

阻塞队列实现

Java 提供了多种阻塞队列的实现,包括:

  • ArrayBlockingQueue:基于数组实现的有界阻塞队列;
  • LinkedBlockingQueue:基于链表实现的可选有界/无界阻塞队列;
  • SynchronousQueue:不存储元素的阻塞队列,生产者线程插入元素时必须等待消费者线程取出元素,反之亦然;
  • PriorityBlockingQueue:具有优先级的无界阻塞队列;
  • DelayQueue:基于优先级队列实现的延迟队列,可以用于定时任务调度等场景。

阻塞队列是 Java 多线程编程中的重要工具,能够简化线程同步的实现,提高代码的可读性和可维护性。

下面是一个多线程读取和写入的实例,使用阻塞队列作为共享数据结构

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Main {
    public static void main(String[] args) {
        // 定义阻塞队列,用于线程间的数据共享
        BlockingQueue<Long> sharedQueue = new LinkedBlockingQueue<>();

        // 创建生产者线程,往阻塞队列写入数据
        Thread producerThread = new Thread(() -> {
            while (true) {
                // 生成数据
                Long data = System.currentTimeMillis();
                try {
                    // 向队列中添加数据
                    sharedQueue.put(data);
                    System.out.println("Producer added data: " + data);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 创建消费者线程,从阻塞队列读取数据
        Thread consumerThread = new Thread(() -> {
            while (true) {
                try {
                    // 从队列中取出数据
                    Long data = sharedQueue.take();
                    System.out.println("Consumer read data: " + data);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 启动生产者和消费者线程
        producerThread.start();
        consumerThread.start();
    }
}

在这个示例中,我们使用 LinkedBlockingQueue 作为阻塞队列,它是一个线程安全的阻塞队列实现。在生产者线程中,我们生成数据并使用 put() 方法将数据添加到队列中。在消费者线程中,我们使用 take() 方法从队列中获取数据。如果队列为空,则消费者线程将被阻塞,直到有数据可用。

常见的面试问题

1、什么是阻塞队列?

阻塞队列是一种线程安全的队列,在队列已满或为空时,入队和出队操作会阻塞线程,直到队列不为空或不满。阻塞队列通常被用于生产者-消费者模型中,以实现线程间的同步和通信。

2、ArrayBlockingQueue 与 LinkedBlockingQueue 有何不同?

如果对数组和链表这两个数据结构有所了解,这个问题很好回答。

  • ArrayBlockingQueue 是一个有界阻塞队列,它的内部实现是一个定长的数组。它在创建时需要指定队列的容量,一旦队列已满,进一步的入队操作将被阻塞,直到队列中有元素被取出;
  • LinkedBlockingQueue 是一个无界阻塞队列,它的内部实现是一个链表,如果创建 LinkedBlockingQueue 时没有指定容量,则创建的是一个无界队列,即队列的容量可以无限增长。如果指定了容量,则创建的是一个有界队列,即队列的容量是固定的,无法超过指定的容量。如果尝试在已满的有界队列中添加元素,则会阻塞直到队列中有空间可用。
  • 当队列为空时,出队操作会被阻塞,当队列已满时,入队操作会被阻塞。

3、阻塞队列如何避免线程安全问题?

阻塞队列通过内部实现的同步机制(例如 ReentrantLock、Condition 等)来避免线程安全问题。它们确保在队列已满或为空时,入队和出队操作只能由一个线程进行,从而避免了多线程并发访问队列时可能发生的竞态条件问题。

阅读量:2054

点赞量:0

收藏量:0