【Java 多线程】5 - 4 线程同步

5-4 线程同步

5-4.1 生产者与消费者问题

生产者消费者问题是一个十分经典的线程同步问题

抢占式调度机制会让不同线程执行顺序随机,这会导致一定条件满足的情况下才能正确执行的线程在执行过程中发生意外问题。生产者消费者模式可以协调线程间的执行顺序问题,即能够解决线程同步问题。解决线程同步问题的关键在于对临界资源的控制。

生产者线程负责生产数据,消费者线程负责消费数据。

在理想状况下,生产者先抢到 CPU 执行权,生产数据;随后消费者抢到 CPU 执行权,消费数据;接着再由生产者抢占 CPU 以此往复。

但是,由于线程调度具有随机性,上述情况并不能百分百保证。有两种情况:

  • 情况一 - 消费者等待

    消费者优先抢占到 CPU,若不存在数据,消费者只能先等待(wait);

    消费者线程处于阻塞状态,生产者抢到 CPU 后,生产者就会生产数据,并唤醒(notify)等待的消费者,由消费者消费数据;

    反之,若存在数据,消费者则消费数据,并唤醒等待的生产者;

  • 情况二 - 生产者等待

    生产者优先抢占到 CPU,若存在数据,生产者只能先等待(wait);

    生产者线程处于阻塞状态,消费者抢到 CPU 后,消费者就会消费数据,并唤醒(notify)等待的生产者,由生产者生产数据;

    反之,若不存在数据,生产者则生产数据,并唤醒等待的消费者;

简而言之,取得锁的线程在执行过程中由于条件不满足而无法继续执行,需要其他线程先完成特定的工作,该线程才可能继续执行。这种情况下,就需要用到等待唤醒机制。

除了生产者消费者问题之外,哲学家就餐问题、读者-写者问题等也是十分经典的线程或进程同步问题。

5-4.2 使用 Object 提供的管程同步线程

与等待唤醒有关的成员方法位于 Object 中:

方法 描述
void wait()
void wait(long timeoutMillis)
void wait(long timeoutMillis, int nanos)
让当前线程等待,直至被唤醒(通常为通知打断
void notify() 唤醒在该对象监视器上等待的一个线程
void notifyAll() 唤醒在该对象监视器上等待的所有线程

注意

  • 上述方法都由 final 修饰,不可被重写;

  • wait 方法执行后,线程会被标记为 WAITING 状态,自动释放锁,不参加线程调度竞争,处于阻塞状态;

  • 被唤醒的线程会从 wait 方法处(wait 方法返回)继续执行,线程唤醒后应当首先检查是否满足继续执行任务的条件,然后再判断是否需要继续等待(再次调用 wait);

  • 为防止程序发生死锁,考虑使用 notifyAll 而不是 notify

    使用 notify 随机唤醒一条线程,若被唤醒的线程发现仍然无法满足执行条件重新进入等待状态时,程序很有可能因为线程阻塞而发生死锁;

  • 方法依赖于监视器锁(管程),只能搭配 synchronized 关键字使用;

一个管程(monitor)定义了一个数据结构和能被并发进程(在该数据结构上)所执行的一组操作,这组操作能同步进程和改变管程中的数据。Java 的管程由关键字 synchronized 提供,使用面向对象的设计方法,实现了线程的互斥、同步的功能。synchronized 关键字的同步功能由其底层以 C/C++ 实现的监视器锁(monitor)实现。

使用同步关键字配合等待唤醒机制

package com.multithreading.waitandnotify;

import java.util.ArrayList;

public class Setup {
    // 使用 Java synchronized 关键字提供的管程控制线程同步(监视器锁)
    //锁
    private static final Object lock = Setup.class;
    // 队列上限
    private static final int TOTAL = 10;
    // 队列
    private static final ArrayList<Integer> buffer = new ArrayList<>();

    public static void main(String[] args) {
        //测试等待唤醒机制 notify and wait

        //创建线程对象并启动线程
        new Thread(new Setup.Producer()).start();
        new Thread(new Setup.Consumer()).start();
    }

    // 生产者
    static class Producer implements Runnable {
        private String name = "Producer";
        @Override
        public void run() {
            while (true) {
                synchronized (lock) {
                    // 判断缓存区是否已满
                    if (buffer.size() >= TOTAL) {
                        try {
                            lock.wait();
                            continue;
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    // 存入缓冲区
                    int product = (int) Math.floor(Math.random() * 10);
                    buffer.add(product);
                    System.out.println(this.name + ": Put " + product + " into the buffer.");
                    // 通知消费者
                    lock.notifyAll();
                }
            }
        }
    }

    // 消费者
    static class Consumer implements Runnable {
        private String name = "Consumer";
        @Override
        public void run() {
            while (true) {
                synchronized (lock) {
                    // 判断缓冲区是否为空
                    if (buffer.isEmpty()) {
                        try {
                            lock.wait();
                            continue;
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    // 访问缓冲区
                    System.out.println(this.name + ": Take " + buffer.removeFirst() + " from the buffer");
                    // 通知生产者
                    lock.notifyAll();
                }
            }
        }
    }
}

5-4.3 使用 BlockingQueue 阻塞队列解决同步问题

阻塞队列好比是连接生产者和消费者之间的“管道”。生产者将生产的数据放入管道中,由消费者根据先入先出的规则,依次从管道中消费数据。既然是队列,那就可以同时存放多个数据。Java 的集合框架中提供了阻塞队列的单列集合接口。

Queue<E> 简介

一个用于在处理数据前存放元素的集合。除了 Collection 中的运算,队列还提供了插入、提取和检查运算。这些方法都有两种形式:一种在运算失败时抛出异常,另一种返回一个特殊值(取决于运算,返回 nullfalse)。后一种形式的插入运算特别用于限制容量的队列实现;在大多数实现中,插入操作不会失败。

队列方法概要:

抛出异常 返回特殊值
插入 add(e) offer(e)
删除 remove() poll()
检查 element() peek()

队列通常都会以先入先出的顺序为元素排序,但这并不是必要的。优先级队列是一个例外,它根据所提供的比较器或元素的自然排序对元素排序。后入先出队列会将元素以后入先出顺序排序。无论采用什么顺序,队列头元素都会被 remove()poll() 删除。在先入先出队列中,所有新元素都会添加到队列尾部。其它形式的队列可能采用不同的放置规则。每个队列都必须指定排序属性。

Queue 接口并没有定义在并发编程中常见的阻塞队列方法。这些方法由其子接口 BlockingQueue 定义。

队列实现通常不允许插入 null 元素。尽管有些实现,例如 LinkedList,并不阻止 null 插入队列,null 也不应当插入到队列中,,因为 null 作为 poll 方法的特殊返回值,表示队列不含元素。

方法摘要:

方法 描述
boolean add(E e) 若可能,在不违反容量限制的情况下立即向队列中插入元素。成功则返回 true,在当前无可用空间时抛出异常 IllegalStateException
boolean offer(E e) 若可能,在不违反容量限制的情况下立即向队列中插入元素
E element() 取出,但不删除队列中的首个元素;若队列为空,则抛出异常 NoSuchElementException
E peek() 取出,但不删除队列中的首个元素;若队列为空,则返回 null
E remove() 取出并删除队列的首个元素;若队列为空,则抛出异常 NoSuchElementException
E poll() 取出并删除队列中的首个元素;若队列为空,则返回 null

BlockingQueue<E> 简介

BlockingQueue<E> 接口位于 java.util.concurrent,继承自 Queue<E>,是集合框架中的成员。阻塞队列是一个线程安全的队列,支持在队列里取出元素时,等待队列至非空;也支持在队列里添加元素时,等待队列存在可用空间。

阻塞队列的方法有四种形式,处理操作的方式各不相同,无法立即满足,但可能在未来的某个点得到满足:一种抛出异常,第二种返回特殊值(nullfalse,取决于操作),第三种无限期地阻塞当前线程,直到操作成功,第四种在达到给定的最大时间限制后放弃。

抛出异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e, time, unit)
删除 remove() poll() take() poll(time, unit)
检查 element() peek() 不适用 不适用

阻塞队列并不接受 null 元素,添加 null 元素都会抛出异常 NullPointerExceptionnull 用作一个警告值,表示 poll 操作失败。

阻塞队列可能是有界的,在任何时候超出 remainingCapacity 的元素都无法添加到队列中。无任何内部容量的阻塞队列总是有最大容量 Integer.MAX_VALUE

阻塞队列实现首要由于生产者消费者模式,但额外支持 Collection 接口。例如,可以使用 remove(x) 删除队列中的任意一个元素。但是,这种操作通常高效,且应当在个别情况使用,例如一个入列消息被取消时。

以下是一个使用示例,基于生产者消费者模式。注意,一个阻塞队列可安全地由多对生产者和消费者使用。一堆生产者消费者应当使用同一个阻塞队列。

class Producer implements Runnable {
   private final BlockingQueue queue;
   Producer(BlockingQueue q) { queue = q; }
   
   @Override
   public void run() {
     try {
       while (true) { queue.put(produce()); }
     } catch (InterruptedException ex) { ... handle ...}
   }
   Object produce() { ... }
 }

 class Consumer implements Runnable {
   private final BlockingQueue queue;
   Consumer(BlockingQueue q) { queue = q; }
     
   @Override
   public void run() {
     try {
       while (true) { consume(queue.take()); }
     } catch (InterruptedException ex) { ... handle ...}
   }
   void consume(Object x) { ... }
 }

 class Setup {
   void main() {
     BlockingQueue q = new SomeQueueImplementation();
     Producer p = new Producer(q);
     Consumer c1 = new Consumer(q);
     Consumer c2 = new Consumer(q);
     new Thread(p).start();
     new Thread(c1).start();
     new Thread(c2).start();
   }
 }

通常会考虑使用 ArrayBlockingQueueLinkedBlockingQueue,前者有界,必须在构造器中指定容量,后者无界,但实际上最大支持 Integer.MAX_VALUE

puttake 方法在内部使用了 ReentrantLock,因此无需在外部再用 synchronized 包围,避免死锁。

5-4.4 ReentrantLock 与条件变量 Condition 解决同步问题

使用 ReentrantLock 可以替代监视器锁,也可以替代监视器锁中的 wait, notifynotifyAll 方法。

自 JDK 5 起,Java 提供了一个接口 Condition,用于替代监视器锁的 wait, notifynotifyAll 方法。

Condition 简介

ConditionObject 监视器方法(wait, notifynotifyAll)分解为不同对象,通过将每个对象与任意的 Lock 实现相结合,达到每个对象具有多个等待集的效果。Lock 取代了同步方法和同步语句,Condition 取代了监视器方法。

Condition 实现可提供不同于 Object 监视器方法的行为和语义,例如保证通知排序,或者在执行通知时不需要持有锁。若某个实现提供了这样的语义,则该实现必须记录这些语义。

注意,Condition 实例只是普通的对象,它们本身也可用作同步语句的目标,并且它们也可调用自身的监视器方法。获取 Condition 实例的监视器锁,或调用其监视器方法,都与获取与该 Condition 关联的锁、调用等待和信号方法无具体关系。为避免混淆,不建议这样使用 Condition 实例。

获取 Condition:在 ReentrantLock

方法 描述
Condition newCondition() 返回一个可供该 Lock 实例使用的 Condition 实例

注意

  • 条件必须与一把锁固有地绑定到一起,通过可重入锁实例获取条件实例;
  • 可以多次调用该方法获取多个不同的条件实例,不同的条件实例应当具备不同的名称以区别不同的阻塞队列;

方法

方法 描述
void await() 让当前线程等待,直至被告知或被打断
boolean await(long time, TimeUnit unit) 让当前线程等待,直至被告知或被打断,或超时
long awaitNanos(long nanosTimeout) 让当前线程等待,直至被告知或被打断,或超时
void awaitUninterruptibly() 让当前线程等待,直至被告知,若被中断,则继续等待
boolean awaitUntil(Date deadline) 让当前线程等待,直至被告知或被打断,或超时
void signal() 唤醒一个等待的线程
void signalAll() 唤醒全部等待线程

注意

  • ReentrantLockCondition 搭配使用实现等待唤醒机制,用法同监视器锁的等待唤醒机制的实现;

  • 注意监视器锁的 wait, notifynotifyAllConditionawait, signalsignalAll 方法,这些方法在语义上意义相同,但具有不同的方法名称加以区分;

  • await 方法执行后,线程会进入等待集合中,自动释放锁,不参加线程调度竞争,处于阻塞状态;

  • 被唤醒的线程会从 await 方法返回处继续执行,线程唤醒后应当首先检查是否满足继续执行任务的条件,然后再判断是否需要继续等待(再次调用 await);

  • 为了防止程序发生死锁,考虑使用 signalAll 而不是 signal

    使用 signal 随机唤醒一条线程,若被唤醒的线程发现仍然无法满足执行条件重新进入等待状态时,程序很有可能因为线程阻塞而发生死锁;

  • 方法依赖于 ReentrantLockCondition,只有锁的持有者才能够执行 Condition 的方法;

可以看到,使用 ReentrantLockCondition 能够提供比 synchronized 更灵活、强大的锁控制功能。建议使用 ReentrantLockCondition 而不是监视器锁。

示例:使用 Condition 条件变量修改上述示例。

package com.multithreading.waitandnotify;

import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionalSetup {
    // 缓冲区上限
    private static final int TOTAL = 10;
    // 缓冲区
    private static final ArrayList<Integer> buffer = new ArrayList<>();
    // 缓冲区互斥锁
    private static final Lock lock = new ReentrantLock();
    // 缓冲区满条件
    private static final Condition queueFull = lock.newCondition();
    // 缓冲区空条件
    private static final Condition queueEmpty = lock.newCondition();

    public static void main(String[] args) {
        // 创建一对生产者和消费者
        new Thread(new ConditionalSetup.Producer()).start();
        new Thread(new ConditionalSetup.Consumer()).start();
    }

    // 生产者
    static class Producer implements Runnable {
        private String name = "Producer";
        @Override
        public void run() {
            while (true) {
                // 竞争,获得缓冲区的独占权
                lock.lock();
                try {
                    // 判断缓冲区是否已满
                    if (buffer.size() >= TOTAL) {
                        // 若缓冲区已满,则等待消费者消费
                        queueFull.await();
                    }
                    // 向缓冲区中添加元素
                    int product = (int) Math.floor(Math.random() * 10);
                    System.out.println(this.name + ": Put " + product + " into the buffer.");
                    buffer.add(product);
                    // 通知消费者
                    queueEmpty.signalAll();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    lock.unlock();
                }
            }
        }
    }

    // 消费者
    static class Consumer implements Runnable {
        private String name = "Consumer";
        @Override
        public void run() {
            while (true) {
                // 竞争,获得缓冲区独占权
                lock.lock();
                try {
                    // 判断缓冲区是否为空
                    if (buffer.isEmpty()) {
                        // 若缓冲区为空,则等待生产者生产
                        queueEmpty.await();
                    }
                    // 消费
                    System.out.println(this.name + ": Take " + buffer.removeFirst() + " from buffer.");
                    // 通知生产者
                    queueFull.signalAll();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    lock.unlock();
                }
            }
        }
    }
}

5-4.5 链接

求求你,别再用wait和notify了! - 磊哥|www.javacn.site - 博客园 (cnblogs.com)

Lock的await/singal 和 Object的wait/notify 的区别 - Mr.Aaron - 博客园 (cnblogs.com)

posted @ 2023-09-02 22:48  Zebt  阅读(34)  评论(0)    收藏  举报