【Java 多线程】5 - 10 ThreadLocal 和并发包中的其他工具类

§5-10 ThreadLocal 和并发包中的其他工具类

5-10.1 ThreadLocal 线程局部变量

ThreadLocal 是一个位于 java.lang 包下的一个类,用于表示线程的局部变量。该类会为每一条线程创建自己的副本,使得每一条线程都具有自己的变量,因而得名线程的局部变量。这虽然占用了更多的空间,但由于每条线程的变量互不干扰、相互隔离,是一种解决线程安全问题的手段。

synchronizedReentrantLock 不同,锁保护的是多线程的共享变量。而 ThreadLocal 使得每一条线程都具有自己的变量副本,适用于没一条线程都需要自己的独立实例的情况。

通常而言,会把 ThreadLocal 声明为类的 private static 变量,以供不同的线程通过该静态变量获取线程的局部变量。

构造方法

构造方法 描述
ThreadLocal() 创建一个线程局部变量

注意:该类是一个泛型类,泛型用于指定变量的值类型,只能为引用类型。

类的方法

方法 描述
T get() 获取当前线程下的线程局部变量的值
protected T initialValue() 为线程局部变量返回初始值
void remove() 移除当前线程局部变量的值
void set(T value) 将当前线程的局部变量值赋值为新值
static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) 创建一个线程局部变量

注意

  1. 线程初次获得线程局部变量后,在未赋值的情况下首次调用 get 方法取值,会先调用 initialValue 方法为变量赋初值,该方法为 protected 方法,是一种明显的子类覆盖设计。一般建议在声明 ThreadLocal 变量的同时重写该方法,定义初值,例如:

    private static ThreadLocal<Integer> counter = new ThreadLocal() {
        @Override
        protected Integer initialValue() {
            return 0;
        }
    }
    

    这在一些需要防止空指针异常的方法中显得尤为重要。

  2. 线程的局部变量实际存储在 Thread 所维护的变量 threadLocals 中,这是一张哈希表,类型为 ThreadLocal.ThreadLocalMap,该表的键值对 <K,V> 分别对应为 <ThreadLocal, Object>,该键值对由 ThreadLocal.ThreadLocalMap.Entry 存储,一个 ThreadLcoal 维护一个变量的值,存储在 Object 中;

  3. Entry 继承了 WeakReference,父类(WeakReference 的 父类 Reference)的 referent 变量存储该键值对中的键 ThreadLocal,继承关系使得 ThreadLcoal 成为一个弱引用,发生 GC 时一定会被垃圾回收器回收,避免内存泄漏。但并不意味着不会发生内存泄漏,一条线程的线程局部变量引用关系为:

    Thread --> ThreadLocalMap --> Entry --> ThreadLocal & Object(value)
    

    注意到,value 并没有被声明为弱引用,这会导致其无法被回收,导致内存泄漏。为避免这一问题,在不需要使用该线程局部变量时,调用 remove 方法移除变量值以解决这一问题。

    该部分引用自:

    Java并发系列番外篇:ThreadLocal原理其实很简单 - 掘金 (juejin.cn)

5-10.2 并发包中的并发集合

在集合框架一章中所介绍的所有集合类,都是线程不安全的类。在高并发场景下极其容易出现线程不安全问题。为了在并发场景下也能够使用并发集合,JUC 中提供了一些线程安全的并发集合。

并发集合接口 该接口的实现类(并发集合类) 描述
BlockingQueue LinkedBlockingQueue, ArrayBlockingQueue, PriorityBlockingQueue, DelayQueue,
LinkedBlockingDeque, LinkedTransferQueue, SynchronousQueue
阻塞队列
TransferQueue LinkedTransferQueue 传输队列
BlockingDeque LinkedBlockingDeque 阻塞双端队列
ConcurrentMap ConcurrentHashMap, ConcurrentSkipListMap 并发映射表
ConcurrentNavigableMap ConcurrentSkipListMap 并发可导航映射表
其他并发集合 描述
CopyOnWriteArrayList, CopyOnWriteArraySet 写入时复制数组列表和集合
ConcurrentSkipListSet 并发跳表集合

这些并发集合类也是集合框架的成员,提供了并发场景下线程安全的集合,由于篇幅受限,这里只挑出一些重要且常见的集合类加以说明。

5-10.2.1 BlockingQueue 阻塞队列

阻塞队列是一种额外支持等待操作的队列:等待队列非空时取出数据,等待队列具有可用空间时插入数据。

在生产者消费者模式那一篇文章中,就介绍过使用阻塞队列实现生产者消费者模式。在生产者消费者模式中,一对生产者和消费者共用同一条阻塞队列。若多个不同的生产者和消费者基于相同数据上操作,则这些消费者和生产者可以共用同一条阻塞队列。生产者尝试往队列中插入数据,消费者从队列中取出数据,这两个过程会根据队列的容量可能发生阻塞。故而得名阻塞队列。

阻塞队列也用于线程池中,用于存放等待执行的任务,又称为工作队列。线程池中的线程会不断地从工作队列中取出任务来执行,若取不到任务,空闲线程会被销毁。

阻塞队列提供了四种不同形式的插入、移除和检查的方法,以满足时在不同情况下的需要。为了满足在特殊值情况下的需要,该队列不支持插入空元素(null)。

方法 抛出异常 返回特殊值 阻塞 超时
插入元素 add(e) offer(e) put(e) offer(e, time, unit)
移除元素 remove() poll() take() take(time, unit)
检查元素(取出但不移除) element() peek() 不适用 不适用

该接口具有子接口 BlockingDeque 阻塞双端队列和 TranserQueue 传输队列。前者提供了双端队列的操作,后者用于生产者等待消费者获取元素(提供查询消费者的方法)。这两个子接口各自都只有一个实现类,分别为 LinkedBlockingDeueLinkedTransferQueue

而直接实现 BlockingQueue 的实现类有很多,这里简要地介绍一下:

  1. ArrayBlockingQueue 数组阻塞队列:基于数组的有界先入先出阻塞队列。位于队列头的元素总是队列中时间最长的,而位于队列尾的元素总是队列中时间最短的;
  2. LinkedBlockingQueue 链表阻塞队列:基于链表的可有界先入先出阻塞队列。位于队列头的元素总是队列中时间最长的,而位于队列尾的元素总是队列中时间最短的。与数组阻塞队列相比,链表阻塞队列具有更好的吞吐量,但在大多数并发应用中性能更不可预测;
  3. DelayQueue 延时队列:一个无界的阻塞队列。该队列存储 Delayed 接口的实现类,延时超时后原素材能够被移除。该接口是函数式接口,有且只有一个方法 getDelay(TimeUnit),返回指定时间单位下该对象的剩余延时。超时时间小于等于 0 纳秒的元素视为超时元素,具有最短超时时间的元素视为队列头。只有移除元素的方法会相应元素超时时间,只有超时队列头才能够被移除,否则,方法将会阻塞直至队列头超时。剩余方法不考虑元素超时;
  4. PriorityBlockingQueue 优先级阻塞队列:基于优先级堆的无界阻塞队列。元素按照优先级排序,默认情况下,按照自然排序顺序排序(元素实现 Comparable 接口),或调用构造器中指定的比较器当作优先级排序。队列中的元素必须实现 Comparable 接口,且不允许空元素插入队列。根据指定排序方式,队列中最小的元素视为队列头,取元素操作访问的是队列中的头元素;
  5. SynchronousQueue 同步队列:一个不存储任何元素的无界阻塞队列,每一个插入操作都必须等待一个对应的删除操作,反之亦然;

上述阻塞队列应当视不同情况酌情选择。

BlockingQueue 继承自 Queue,支持 Queue 接口的方法,这里不做说明。这里仅列出阻塞队列的其他方法:

方法 描述
int drainTo(Collection<? super E> c) 将队列中的所有元素移除,并转移至所给集合中,返回转移的元素个数
int drainTo(Collection<? super E> c, int maxElements) 将队列中最多指定个数元素移除,并转移至所给集合中,返回转移的元素个数

阻塞队列的阻塞方法源码分析:以 ArrayBlockingQueue 为例。

添加元素。

public void put(E e) throws InterruptedException {
    // 要求元素非空,否则抛出异常 NullPointerException
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    
    // 可中断取锁,线程被中断时取消取锁
    lock.lockInterruptibly();
    try {
        // 典型的生产者模型
        // 若队列已满,则开始等待,进入阻塞状态
        while (count == items.length)
            notFull.await();
        // 队列具有可用空间,将元素插入队列中
        // 该方法内部会调用 notEmpty.signal() 唤醒消费者
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

取出元素。

public E take() throws InterruptedException {
    // 可中断取锁,线程被中断时取消取锁
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 典型的消费者模型
        // 若队列为空,则开始等待,进入阻塞状态
        while (count == 0)
            notEmpty.await();
        // 队列具有元素,取出元素
        // 方法内部会调用 notFull.signal() 唤醒生产者
        return dequeue();
    } finally {
        lock.unlock();
    }
}

5-10.2.2 ConcurrentHashMap 并发映射表

在集合框架一章中所介绍的所有集合都是线程不安全的,但若想要获得对应的线程安全集合类,可以通过 Collections 工具类获取:

List syncArrayList 		= Collections.synchronizedList(new ArrayList<>());
List syncLinkedList 	= Collections.synchronizedList(new LinkedList<>());
Set syncHashSet 		= Collections.synchronizedSet(new HashSet<>());
Set syncLinkedHashSet	= Collections.synchronizedSet(new LinkedHashSet<>());
Set syncTreeSet			= Collections.synchronizedSet(new TreeSet<>());
Map syncHashMap			= Collections.synchronizedMap(new HashMap<>());
Map syncLinkedHashMap	= Collections.synchronizedMap(new LinkedHashMap<>());
Map syncTreeMap			= Collections.synchronizedMap(new TreeMap<>());

这些方法会对应返回 SynchronizedList, SynchronizedSet, SynchronizedMap 的实例,这些类都是 Collections 的内部类,其线程安全的实现方式是使用了 synchronized 关键字。但其所有方法都是通过监视器锁保证线程安全,在高并发场景下可能会折损性能。

为了满足高并发场景下的使用需求,使用 ConcurrentHashMap 更好。下面来看看该类的使用方法。

构造方法

构造方法 描述
ConcurrentHashMap() 构造一个新的空映射表,具有默认初始大小(16),默认加载因子(0.75)
ConcurrentHashMap(int initialCapacity) 构造一个新的空映射表,具有指定初始大小,默认加载因子(0.75)
ConcurrentHashMap(int initialCapacity, float loadFactor) 构造一个新的空映射表,具有指定初始大小,指定加载因子
ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) 构造一个新的空映射表,具有指定初始大小和指定加载因子,并指定并发等级(并发修改线程数)

ConcurrentHashMap 的一些参数的语义同 HashMap,由于该映射表支持并发修改,该类还具有字段 concurrencyLevel 指定并发修改线程数(并发级别)。

常用方法

方法 描述
void clear() 移除映射表中的所有映射关系
boolean isEmpty() 测试映射表是否为空
boolean contains(Object value) 测试映射表中是否由键映射到指定的值
boolean containsKey(Object key) 测试映射表中是否含有指定的键
boolean containsValue(Object value) 测试映射表中是否含有指定的值
Set<Map.Entry<K,V>> entrySet() 返回含有映射表中所有键值对的集合
ConcurrentHashMap.KeySetView<K,V> keySet() 返回含有银蛇表中所有键的集合
long mappingCount() 返回映射关系数
V put(K key, V value) 向映射表中添加映射关系(具有覆盖行为)
void putAll(Map<? extends K, V extends V> m) 向映射表中添加另一映射表中所有的映射关系
V get(Object key) 返回映射表中指定键的映射值
void forEach(long parallelismThreshold, BiConsumer<? super K, ? super V> action) 为每一个键值对施加操作,并行度阈值指定该操作并行执行时所需的元素数量(预计值)

一个十分简单的示例

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapDemo {
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(100);

        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                map.put(i + "", i + "");
            }
        }).start();

        new Thread(() -> {
            for (int i = 25; i < 51; i++) {
                map.put(i + "", i + "");
            }
        }).start();

        try {
            // 让主线程休眠,等待添加操作完成
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println(map);
    }
}

结果:

{10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16, 17=17, 18=18, 19=19, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 20=20, 21=21, 22=22, 23=23, 24=24, 25=25, 26=26, 27=27, 28=28, 29=29, 30=30, 31=31, 32=32, 33=33, 34=34, 35=35, 36=36, 37=37, 38=38, 39=39, 40=40, 41=41, 42=42, 43=43, 44=44, 45=45, 46=46, 47=47, 48=48, 49=49, 50=50}

ConcurrentHashMap 在 JDK 1.7 及以前版本的运行原理

在 JDK 1.7,ConcurrentHashMap 通过分段锁(Segment,继承了 ReentrantLock)的方式实现线程安全。它将一个大的 Map 分成了多个小的分段,每个分段都有自己的锁。不同的线程可以同时访问不同的分段锁,提高了并发性能。此外,ConcurrentHashMap 的读取操作不需要加锁,只有写入操作才需要加锁,也提高了并发性能。

使用空参构造器创建对象后,数组并未创建。第一次插入元素时,会创建一个长度为 16 的 Segment 数组(segments[],分段数组),这个数组长度固定不变,将整张哈希表分割成一个个的分段,写入数据时只会根据数据落入的分段而部分锁住这张哈希表,提高哈希表的并发性能。可以理解为 Segment 数组的长度就是这张哈希表所支持的最大并发数。

Segment 类中的字段存储了 count(分段中元素数量)、modCount(结构性修改次数)、threshold(扩容阈值)、loadFactor(加载因子)以及存储元素数据的数组 tableHashEntry<K,V>)。可以看到,元素真正要存储的位置是分段中的数组。

第一次插入元素,初始化 segments[] 后,该分段数组长度不可变,由构造器的参数 initialCapacity 决定(但最终会设置为最接近该值的 2 的幂,提高哈希效率)。但分段数组中的 table 长度可变。同时,segments[0]table 会被初始化为一个长度为 2 的 HashEntry 数组,这由 Unsafe 完成。在真正插入元素前,分段数组初始化完毕,除了 segments[0],其余索引处分段均为 null

但是,数据仍未插入到表中。先进行第一次哈希计算求出元素的分段位置。若该分段为 null,则会先按照模板创建 Segment 并初始化 table;否则,则进行第二次哈希计算求得元素在 table 中的索引位置。如果需要扩容,则 table 被扩容为原来的两倍;若不需要扩容,则会判断该位置是否发生哈希冲突。若不发生哈希冲突,直接将元素存入 table 中;否则,调用 equals 方法判断元素是否一致。若元素一致,则放弃存储;否则,在 table 的该索引处形成一张链表。

最终,ConcurrentHashMap 会形成如下图所示的数据结构(数组 + 链表):

image

JDK 1.7 的 ConcurrentHashMap 虽然提高了并发性能,但由于插入元素时需要进行二次哈希计算,这相较于 HashMap 而言,速度相对较慢。因此,若不是在高并发的场景下,不要使用 ConcurrentHashMap

除此之外,由于读取操作并没有用锁保护,虽然保证合理同步的同时具有很高的效率,但是若往 ConcurrentHashMap 插入大量数据时(例如调用 putAll),get 方法只能够获取成功插入的更新。

ConcurrentHashMap 在 JDK 1.8 及以后版本的运行原理

类似于 HashMap,若链表长度过长,遍历表的效率就会变得十分低下。因此,自 JDK 1.8 起,引入了红黑树的数据结构来解决之一问题。

同时,JDK 1.8 还对 ConcurrentHashMap 的数据结构做了调整,放弃了 Segment 分段锁的概念,转而使用 CAS + 监视器锁(synchronized)的方式实现。因此,在 JDK 1.8 及以后,键值对不再由 Segment 中的 HashEntry 存储,转而使用 Node 存储,其中,keyvalue 都被声明为 volatile,保证了可见性。

调用空参构造器,构造器什么也没做。第一次插入元素时,会先初始化哈希表,创建一个长度为 16 的数组。然后,计算元素在数组中的存储位置。若为空,则利用 CAS 算法插入到数组中,CAS 写入失败则自旋保证成功;否则,先判断是否需要扩容(由哈希值计算得出的存储位置判断是否需要扩容 hash == MOVED == -1);若不需要扩容,这是说明发生哈希冲突,使用 synchronized 写入数据。

发生哈希冲突时,获取监视器锁写入数据,将元素挂在已有元素后形成链表(如果已经是红黑树,则挂在红黑树中)。写入完成后,再判断是否具备转换红黑树的条件。当数组长度大于等于 64 且 链表长度大于 8 时,链表将转化为红黑树。

5-10.2.3 CopyOnWrite 写入时复制

写入时复制是一种策略,通俗的理解是,当尝试往一个容器中添加元素时,不直接往容器中添加,而是先复制出新的容器,然后往新容器中添加元素。添加完元素后,再将容器的引用指向新容器。这样看来,这实现了读写分离,使得读操作可以并发进行而不需要加锁。

JUC 中提供了两个写入时复制的类 CopyOnWriteArrayListCopyOnWriteArraySet,而这实际上就是施加了写入时复制的列表和集合,且 CopyOnWriteArraySet 在内部维护了一个 CopyOnWriteArrayList

因此,我们只需要看看 CopyOnWriteArrayList 的线程安全实现方式。所有写入方法 add(Element), add(int, Element), addAll(Collection<? extends E>), addAll(int, Collection<? extends E>) 都在方法内部使用了监视器锁保护数据。代码实现比较简单。

由于写入操作需要复制,因此会增大时间和空间上的开销。CopyOnWriteArrayList 适用于读多写少的并发场景,如黑白名单、商品类目的访问和更新。

使用 CopyOnWriteArrayList 时,要注意:

  1. 减少扩容开销:根据实际需要,初始化 CopyOnWriteArrayList 的大小,避免写入时的扩容开销;
  2. 使用批量添加:每次添加都会让容器进行复制,因此应尽可能地减少添加次数,可以减少容器复制次数;

5-10.3 CountDownLatch 闭锁

闭锁是一种同步辅助工具,允许一条或多条线程等待其他线程完成正在执行的操作。

闭锁会用一个所给的 count 参数初始化。await 方法会阻塞线程,直至其他线程调用 countDown 方法使得 count 最终减至 0 时,所有等待线程都会被释放,随后的所有 await 方法调用都会立即返回,不会阻塞。因此,这是一个一次性工具,count 不能够重置。若需要可重置计数器的版本,考虑使用 CyclicBarrier

闭锁是一个多用的同步工具,可用作多种不同用途。初始化 count 为 1 的闭锁可用作开关:所有调用了 await 的线程都会等待,直至该开关由另一线程调用 countDown 而打开。初始化 countN 的闭锁可用在让一条线程等待 N 条线程执行完毕或某个任务执行 N 次。

构造方法

构造方法 描述
CountDownLatch(int count) 初始化一个指定 count 的闭锁

成员方法

方法 描述
void await() 让当前线程等待,直至闭锁计数器降为 0,或线程被中断
boolean await(long timeout, TimeUnit unit) 让当前线程等待,直至闭锁计数器降为 0,或线程被中断,或超时
void countDown() 让闭锁计数器自减,计数器归零时释放所有的等待线程
long getCount() 返回闭锁计数器的值

一个简单的示例

package com.multithreading.utils;

import java.util.concurrent.CountDownLatch;

public class Consumer implements Runnable {
    private CountDownLatch latch;

    public Consumer(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        // 消费
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + ":正在吃第 " + (i + 1) + " 个饺子。");
        }
        // 消费完毕
        latch.countDown();
    }
}
package com.multithreading.utils;

import java.util.concurrent.CountDownLatch;

public class Cleaner implements Runnable {
    private CountDownLatch latch;

    public Cleaner(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        // 收拾碗筷
        try {
            latch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(Thread.currentThread().getName() + ":正在清理碗筷。");
    }
}
package com.multithreading.utils;

import java.util.concurrent.CountDownLatch;

public class LatchDemo {
    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(3);

        Thread cleaner = new Thread(new Cleaner(latch), "清洁人员");
        Thread consumerA = new Thread(new Consumer(latch), "消费者A");
        Thread consumerB = new Thread(new Consumer(latch), "消费者B");
        Thread consumerC = new Thread(new Consumer(latch), "消费者C");

        cleaner.start();
        consumerA.start();
        consumerB.start();
        consumerC.start();
    }
}

5-10.4 Semaphore 信号量

一个计数的信号量。概念上来说,一个信号量维护一组许可。每一个 acquire() 都会在必要时阻塞至有可用许可为止,然后获取许可。每一个 release() 都会添加许可,可能释放一个等待中的线程。但是实际上,并没有使用具体的许可对象;信号量只是维护可用数量并根据情况做出行动。

信号量通常用于限制访问某些数据(物理或逻辑数据)的线程数。和锁十分类似,其方法实现的语义也十分类似于锁,但不同于 ReentrantLock,信号量允许多条线程同时访问资源。

构造方法

构造方法 描述
Semaphore(int permits) 创建一个具有指定数量许可的非公平信号量
Semaphore(int permits, boolean fair) 出啊构建一个具有指定数量许可、指定公平性的信号量

成员方法

方法 描述
void acquire() 从信号量中获取一个许可,会阻塞至具有一个可用许可为止,或阻塞至线程被中断为止
void acquire(int permits) 从信号量中获取一定数量许可,阻塞至具有足够可用许可为止,或阻塞至线程被中断为止
void acquireUninterruptibly() 不可中断地从信号量中获取许可,阻塞至具有一个可用许可为止,获取时间可能发生变化
void acquireUninterruptibly(int permits) 不可中断地从信号量中获取一定数量许可,阻塞至具有足够可用许可为止,获取时间可能发生变化
int availablePermits() 返回信号量当前可用的许可数目
final int getQueuedLength() 返回信号量中等待中线程的估计数目
final boolean hasQueuedThreads() 查询是否有线程正在等待获取许可
boolean isFair() 查询信号量是否公平
void release() 释放许可,将许可归还至信号量
void release(int permits) 释放一定数量许可,并归还至信号量
boolean tryAcquire() 尝试从信号量中获取一个许可,仅尝试一次,若有可用许可立即取得,这会破坏公平性
boolean tryAcquire(long timeout, TimeUnit unit) 尝试从信号量中获取一个许可,仅尝试一次,若在限时内有可用则立即取得,这会破坏公平性
boolean tryAcquire(int permits) 尝试从信号量中获取一定数量许可,仅尝试一次,若全部可用则立即取得,这会破坏公平性
boolean tryAcquire(int permits, long timeout, TimeUnit unit) 尝试从信号量中获取一定数量许可,仅尝试一次,若在限时内全部可用则立即取得,这会破坏公平性

使用步骤

  1. 需要由管理员管理信号量通道;
  2. 当有 ”车“ 来了,发放许可证;
  3. 当有 ”车“ 出去了,收回许可证;
  4. 若通行证发完了,其他 ”车辆“ 只能够等待;

一个简单的示例

package com.multithreading.utils;

import java.util.conurrent.Semaphore;

public class ParkingLot implements Runnable {
    // 五个停车位
    Semaphore semaphore = new Semaphore(5);

    @Override
    public void run() {
        try {
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + ":已驶入停车场,余位 " + semaphore.availablePermits());
            semaphore.release();
            System.out.println(Thread.currentThread().getName() + ":以驶出停车场,余位 " + semaphore.availablePermits());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
package com.multithreading.utils;

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    public static void main(String[] args) {
        ParkingLot parkingLot = new ParkingLot();

        for (int i = 0; i < 100; i++) {
            new Thread(parkingLot).start();
        }
    }
}

5-10.5 CyclicBarrier 循环栅栏

一个同步辅助工具,允许一组线程相互等待,直到到达一个共同栅栏点。在涉及到固定数目线程的应用程序中,循环栅栏非常有用,这些线程必须偶尔相互等待。该类命名为循环栅栏,是因为它相较于闭锁而言,在所有线程被释放时仍能够继续使用。

也就是说,线程调用 await 方法后,线程就会来到栅栏旁等待。多条线程调用 await 可以实现多条线程都在栅栏旁相互等待。一旦有 N 条线程到达栅栏,所有线程都会被释放,继续运行,称为越过栅栏。栅栏点就是满足线程释放时,在栅栏处等待的线程数。

循环栅栏支持可选的 Runnable 栅栏行动,栅栏行动在每次到达栅栏点时执行一次。该栅栏行动在所有线程都到达栅栏,在越过栅栏前由最后一条到达栅栏的线程执行一次。栅栏行动在所有线程继续执行前更新共享状态时很实用。

另外,JDK 7 还引入了一个新的并发 API,Phaser(移相器),功能与 CountDownLatchCyclicBarrier 类似,但使用更灵活。

构造方法

构造方法 描述
CyclicBarrier(int parties) 创建一个循环栅栏,当有指定数量线程等待时越过栅栏,不执行任何预定义的栅栏行动
CyclicBarrier(int parties, Runnable barrierAction) 创建一个循环栅栏,当有指定数量线程等待时越过栅栏,会在越过栅栏时执行栅栏行动

成员方法

方法 描述
int await() 等待,直到所有线程都调用了 await 在栅栏旁等待时被释放
int await(long timeout, TimeUnit unit) 等待,直到所有线程都调用了 await 在栅栏旁等待时,或超时被释放
int getNumberWaiting() 返回当前正在栅栏旁等待的线程数目
int getParties() 返回越过栅栏所需的线程数目
boolean isBroken() 查询栅栏是否已损坏
void reset() 重置栅栏,让栅栏回到初始状态

注意

  1. await(), await(long, TimeUnit) 方法:只要有足够的线程同时在栅栏旁等待,就会先执行栅栏行动(如果有的话),然后一起释放线程,释放是自动进行的,直到下一次需要再次释放线程时,重复上述操作,以此往复;
  2. isBroken() 方法:若有线程从栅栏创建起或上次重置后,由于等待时被中断而抛出 InterruptedException 冲出栅栏,栅栏就被损坏;或者栅栏行动执行过程中抛出异常导致栅栏损坏,方法返回 true。该方法可用于测试线程或栅栏行动是否抛出异常;
  3. reset() 方法:若调用时有线程正在栅栏旁等待,线程会返回,抛出 BrokenBarrierException。且注意,出于某些原因栅栏损坏后调用 reset 会导致重置较难执行;线程需要以某种方式重新同步,并选择一条线程执行重置。相反,更可取的办法是创建一个新栅栏以供后续使用;

一个简单的示例

package com.multithreading.utils;

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

// 线程执行的任务
public class Task implements Runnable {
    private CyclicBarrier barrier;

    public Task(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName() + ":正在栅栏处等待。");
            barrier.await();

            System.out.println(Thread.currentThread().getName() + ":完成。");
        } catch (BrokenBarrierException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
package com.multithreading.utils;

import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
    public static void main(String[] args) {
        Runnable barrierAction = () -> System.out.println(Thread.currentThread().getName() + ":已越过栅栏。");
        CyclicBarrier barrier = new CyclicBarrier(3, barrierAction);

        Task task = new Task(barrier);
        Thread workerA = new Thread(task, "工作者A");
        Thread workerB = new Thread(task, "工作者B");
        Thread workerC = new Thread(task, "工作者C");

        workerA.start();
        workerB.start();
        workerC.start();
    }
}

运行后,三条线程会依次在栅栏处等待,最后一条到达栅栏的线程会执行栅栏行动,然后一同越过栅栏,线程继续执行接下来的任务。

5-10.6 Exchanger 交换器

交换器表示线程进行配对和交换任务的同步点。每条线程在执行 exchange() 方法时会先递交某个对象,然后与一条伙伴线程配对,最后获取伙伴线程的对象。交换器可视作一种双向形式的 SynchronousQueue(不存储任何元素的阻塞队列,每一个插入操作必须要有对应的删除操作,反之亦然)。交换器在使用遗传算法和流水线设计的应用程序中十分有用。

构造方法

构造方法 描述
Exchanger() 构造一个新的交换器

成员方法

方法 描述
V exchange(V x) 等待另一条线程到达该交换点(除非当前线程被中断),然后将所给对象传输给对方,并接收对方的对象
V exhcange(V x, long timeout, TinmeUnit unit) 等待另一条线程到达该交换点(除非当前线程被中断或超时),然后将所给对象传输给对方,并接收对方的对象

示例

package com.multithreading.utils;

import java.util.concurrent.Exchanger;

// 线程执行的任务
public class ExchangeTask implements Runnable {
    private final Exchanger exchanger;
    Object object;

    public ExchangeTask(Exchanger exchanger, Object object) {
        this.exchanger = exchanger;
        this.object = object;
    }

    @Override
    public void run() {
        try {
            final Object prev = this.object;
            this.object = exchanger.exchange(prev);
            System.out.println(Thread.currentThread().getName() + ":成功将 " + prev.toString() + " 交换为 " + this.object.toString());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
package com.multithreading.utils;

import java.util.concurrent.Exchanger;

public class ExchangerDemo {
    public static void main(String[] args) {
        Exchanger<Integer> exchanger = new Exchanger<>();

        ExchangeTask holderA = new ExchangeTask(exchanger, 10);
        ExchangeTask holderB = new ExchangeTask(exchanger, 20);

        System.out.println(holderA);
        System.out.println(holderB);

        Thread exchangerA = new Thread(holderA, "线程A");
        Thread exchangerB = new Thread(holderB, "线程B");

        exchangerA.start();
        exchangerB.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println(holderA);
        System.out.println(holderB);
    }
}

两条线程启动后,成功交换数据。

5-10.7 参考

一文读懂Java ConcurrentHashMap原理与实现 - 知乎 (zhihu.com)

Java并发编程笔记之ConcurrentHashMap原理探究 - 国见比吕 - 博客园 (cnblogs.com)

ConcurrentHashMap 源码分析 | JavaGuide(Java面试 + 学习指南)

Java并发包使用指南 - 知乎 (zhihu.com)

java.util.concurrent 并发包使用指南 - 掘金 (juejin.cn)

Phaser (Java SE 21 & JDK 21) (oracle.com)

posted @ 2023-10-04 16:56  Zebt  阅读(201)  评论(0)    收藏  举报