Java-并发-wait()、notify()和notifyAll()以及线程的状态

0.是什么(What)

wait(), notify(), 和 notifyAll()方法都是Object类的一部分,用于实现线程间的协作。

1.为什么(Why)

线程的执行顺序是随机的(操作系统随机调度的,抢占式执行),但是有时候,我们希望的是它们能够顺序的执行。

所以引入了这几个方法,使得我们能保证一定的顺序。

1.1 Objec类

在 Java 中,所有对象都可以作为同步的监视器锁,即:何对象都可以在 synchronized 块或方法中被用作锁。

通过继承的特性,放在Object类中,无论哪个类的实例,都可以使用这些方法进行线程间通信。

1.2 历史原因

在 Java 1.0 版本发布时,Java 的并发机制主要依赖于 synchronized 关键字和 Object 类中的 wait(), notify(), 和 notifyAll() 方法。

这种设计虽然在高级并发控制工具引入后显得基础,但依然是 Java 语言设计的一部分,保持了向后兼容性和面向对象设计的一致性。

2.怎么用(How)

查看Object类,可以看到这几个方法。

  • notify()、notifyAll()、wait(long timeout)都是final + native

  • wait()和wait(long timeout, int nanos)则是基于wait(long timeout)的重载

    public final native void notify();
	
	public final native void notifyAll();

	public final native void wait(long timeout) throws InterruptedException;

	public final void wait() throws InterruptedException {
        wait(0);
    }

    public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos > 0) {
            timeout++;
        }

        wait(timeout);
    }

由于Object是顶层父类,所以任意变量实例化后,都会自动继承其中的方法,wait()、notify()、notifyAll()亦是如此,任何实例对象都会自动具备。

image-20240511220815239

2.1 线程的状态

在Thread中,定义了一个枚举State,描述了线程的几种状态。

image-20240515233813626

    public enum State {
        NEW,
        
        RUNNABLE,
        
        BLOCKED,

        WAITING,

        TIMED_WAITING,

        TERMINATED;
    }

6态关系,如下图所示。

怎么记忆:

  • 正常情况下:新建 -> 运行 -> 终止
  • 涉及到2种等待状态:等待 or 超时等待
  • 1种阻塞状态
序号 线程状态 描述
1 新建(New) 线程对象被创建后,但尚未调用 start() 方法。
2 可运行(Runnable) 调用了 start() 方法后,线程进入就绪状态,等待CPU调度执行。
3 阻塞(Blocked) 线程等待获取监视器锁,试图进入同步方法或同步块时。
4 等待(Waiting) 线程等待另一个线程显式地唤醒它,例如调用 Object.wait()Thread.join() 方法。
5 计时等待(Timed Waiting) 线程等待指定时间或被唤醒,例如调用 Thread.sleep(long millis)Object.wait(long timeout) 方法。
6 终止(Terminated) 线程执行完毕或因异常退出,线程生命周期结束。

image-20240515233529943

看上面的图,我们很好理解到,核心点在于Runnable向其他状态的转义。

开始、运行和终止很好理解,关键在于理解等待、超时等待和阻塞。

2.1.1 New

  • 描述:线程对象已经创建,但尚未启动。

  • 特点:线程处于新建状态,此时还未调用 start() 方法。

    Thread thread = new Thread(() -> {
        // 线程任务
    });
    

2.1.2 Runnable

  • 描述:线程已经启动,正在运行或准备运行。

  • 特点:调用 start() 方法后,线程进入 Runnable 状态。此状态下的线程可能正在运行,也可能在等待操作系统为其分配CPU时间片。

    thread.start(); // 启动线程
    

2.1.3 Terminated

  • 描述:线程已经完成执行。

  • 特点:线程的 run() 方法执行完毕或线程因为异常而终止。

    // 线程任务执行完毕
    

2.1.4 Waiting

  • 描述:线程正在等待其他线程显式地唤醒。

  • 特点:线程进入等待状态需要调用以下方法之一:Object.wait()Thread.join()LockSupport.park()

    synchronized (obj) {
        obj.wait(); // 进入等待状态
    }
    

2.1.5 Timed_Waiting

  • 描述:线程正在等待一段指定的时间。

  • 特点:线程进入超时等待状态需要调用带有超时参数的方法,如 Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long deadline)

    Thread.sleep(2000); // 休眠2秒
    

2.1.6 Blocked

  • 描述:线程试图获取一个被其他线程持有的锁而被阻塞。

  • 特点:线程在进入同步块或同步方法时,如果锁被其他线程持有,会进入 Blocked 状态,直到获取到锁。

    synchronized (obj) {
        // 尝试获取锁
    }
    

2.1.7 扩展

2.1.7.1 等待和超时等待

在Java中,等待状态(Waiting)和超时等待状态(Timed Waiting)都是线程的非运行状态,意味着线程不会占用CPU时间。

1.等待Waiting

  • 触发条件

    线程进入等待状态是因为调用了以下几种方法,而这些方法要求另一个线程显式地唤醒它:

    • Object.wait():线程在调用 wait() 方法后进入等待状态,直到其他线程调用 notify()notifyAll() 方法来唤醒它。
    • Thread.join():调用 join() 方法的线程将等待目标线程完成。
    • LockSupport.park():线程调用 park() 方法后进入等待状态,直到被其他线程调用 unpark() 方法唤醒。
  • 特点

    • 无法自动唤醒:线程进入等待状态后,必须依赖其他线程显式地唤醒它。
    • 无超时:线程将一直处于等待状态,直到被唤醒。

2.超时等待Timed Waiting

  • 触发条件

    线程进入超时等待状态是因为调用了带有超时参数的方法,这些方法会使线程等待一段指定的时间或者被唤醒。常见的方法包括:

    • Thread.sleep(long millis):线程将休眠指定的毫秒数。
    • Object.wait(long timeout):线程在调用 wait() 方法时等待指定的时间,如果超时未被唤醒则自动唤醒。
    • Thread.join(long millis):调用 join(long millis) 的线程将等待目标线程完成或者超时。
    • LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long deadline):线程等待指定的纳秒时间或直到特定时间。
  • 特点

    • 自动唤醒:线程进入超时等待状态后,如果没有被显式唤醒,会在指定时间到后自动唤醒。
    • 有限等待时间:线程将在设定的时间期限到达后自动从超时等待状态中退出。

3.对比理解

  • 依赖其他线程唤醒
    • 等待状态(Waiting)依赖于其他线程的显式唤醒操作
    • 超时等待状态(Timed Waiting)在设定时间到达后自动唤醒。
  • 持续时间
    • 等待状态可以无限期地等待,直到被显式唤醒。
    • 超时等待状态有一个明确的时间期限,线程在期限到达后会自动唤醒。

4.适用场景

序号 状态 特点 场景
1 等待状态(Waiting) 需要其他线程显式唤醒,线程将无限期等待,直到被唤醒 - 生产者-消费者模型中消费者等待生产者通知
- 多线程协作,如等待其他线程完成任务
2 超时等待状态(Timed Waiting) 指定等待时间,时间到达或被唤醒后自动退出等待状态 - 网络编程中的超时操作
- 定时任务
- 获取锁时的超时重试

下面是一个示例:

@Slf4j
public class WaitNotifyExample {

    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                try {
                    log.info("线程1:等待通知");
                    lock.wait(); // 进入等待状态,并释放锁
                    log.info("线程1:收到通知");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                log.info("线程2:持有锁并休眠");
                try {
                    Thread.sleep(2000); // 持有锁2秒钟
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                lock.notify(); // 通知等待的线程
                log.info("线程2:通知等待的线程");
            }
        });

        t1.start();
        t2.start();
    }

}

这段代码中:

  • 线程1和线程2持有同一个对象锁lock,线程1进入wait()后,释放当前锁。
  • 线程2拿到锁后,休眠2s后主动醒过来,然后唤醒线程1,最后线程1输出收到了通知。

image-20240516210128514

2.1.7.2 等待和阻塞

2.1.1.1 等待(Waiting)

  • 触发条件

    线程进入等待状态是因为调用了以下几种方法,而这些方法要求另一个线程显式地唤醒它:

    • Object.wait():线程在调用 wait() 方法后进入等待状态,直到其他线程调用 notify()notifyAll() 方法来唤醒它。
    • Thread.join():调用 join() 方法的线程将等待目标线程完成。
    • LockSupport.park():线程调用 park() 方法后进入等待状态,直到被其他线程调用 unpark() 方法唤醒。
  • 特点

    • 无法自动唤醒:线程进入等待状态后,必须依赖其他线程显式地唤醒它。
    • 无超时:线程将一直处于等待状态,直到被唤醒。

2.1.1.2 阻塞(Block)

  • 触发条件

    线程进入阻塞状态是因为它试图获取一个被其他线程持有的锁。

    • 例如,当一个线程试图进入一个同步块或同步方法,但锁被其他线程持有时,线程进入阻塞状态。
  • 特点

    • 不释放锁:线程被阻塞时,不会释放它已经持有的锁。
    • 自动唤醒:当持有锁的线程释放锁时,阻塞的线程会自动被唤醒。

2.1.1.3 对比理解

  • 触发条件

    • 等待(Waiting):调用 wait()join()park() 方法。
    • 阻塞(Blocked):试图获取一个被其他线程持有的锁。
  • 释放锁

    • 等待(Waiting):线程调用 wait() 方法时,会释放当前持有的锁。
    • 阻塞(Blocked):线程被阻塞时,不会释放它已经持有的锁。
  • 唤醒方式

    • 等待(Waiting):需要其他线程显式调用 notify()notifyAll(),或者 unpark()
    • 阻塞(Blocked):当持有锁的线程释放锁时,阻塞的线程会自动被唤醒。
  • 线程状态转换

    • 等待(Waiting):线程从运行状态(RUNNABLE)转换到等待状态(WAITING),然后回到运行状态(RUNNABLE)。
    • 阻塞(Blocked):线程从运行状态(RUNNABLE)转换到阻塞状态(BLOCKED),然后回到运行状态(RUNNABLE)。

2.1.1.4 适用场景

序号 状态 特点 场景
1 等待状态(Waiting) 需要其他线程显式唤醒,线程将无限期等待,直到被唤醒 - 生产者-消费者模型中消费者等待生产者通知
- 多线程协作,如等待其他线程完成任务
2 阻塞状态(Blocked) 线程被阻塞时,不会释放已持有的锁,当持有锁的线程释放锁时,阻塞的线程会自动被唤醒。 - 线程试图进入同步块或同步方法,但锁被其他线程持有。

下面是一个例子:

@Slf4j
public class BlockedExample {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                log.info("线程1:持有锁并休眠");
                try {
                    Thread.sleep(3000); // 持有锁3秒钟
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                log.info("线程1:释放锁");
            }
        });

        Thread t2 = new Thread(() -> {
            log.info("线程2:等待获取锁");
            synchronized (lock) {
                log.info("线程2:获取到锁");
            }
        });

        t1.start();
        t2.start();
    }
}

这段代码中:

  • 线程1持有锁后,带着锁睡眠3s,之后释放锁。
  • 线程2在进入同步代码块前,需要等待线程1释放锁,之后才能输出。

image-20240516210828539

2.1 状态切换

由上文可知,等待状态(Waiting)如果不主动唤醒,的线程可能会一直处于等待状态,永远不会被唤醒。

这意味着该线程将一直保持阻塞状态,不会继续执行其后续代码,且不会释放它所持有的任何锁。

这种情况可能会导致以下问题:

  • 线程泄漏:等待状态的线程如果没有被唤醒,可能会占用系统资源而不做任何有用的工作,导致线程泄漏。

  • 死锁:如果一个线程在等待状态中持有某些关键资源(例如锁),其他线程可能无法获取这些资源,导致死锁。

  • 程序挂起:如果程序中有重要任务依赖于等待状态的线程被唤醒,那么程序的某些部分可能会永远无法执行,导致程序挂起或不响应。

2.1.1 wait()、notify()、notifyAll()

序号 方法 定义 使用场景
1 wait() 使当前线程等待,直到其他线程调用notify()notifyAll()方法,或线程被中断 线程等待某个条件满足
2 notify() 唤醒在此对象监视器上等待的单个线程。
如果有多个线程在等待,则随机选择其中一个被唤醒。
通知一个等待线程的条件已经满足
3 notifyAll() 唤醒在此对象监视器上等待的所有线程。
被唤醒的线程会去竞争对象的锁,只有获得锁的线程才能继续执行
通知所有等待线程的条件已经满足

2.1.2 对象的锁状态

wait(), notify(), 和 notifyAll() 的使用主要是为了实现线程间的协作和同步,具体是否需要使用这些方法取决于是否需要当前线程进行等待和唤醒,与锁的具体类型没有直接关系。

附:在Java-线程-synchronized这篇文章中,我们描述到Java对象的锁状态有以下几种。

img

序号 锁类型 场景 实现 位置 优点 缺点
0 无锁 - 对象没有被任何线程持有锁,所有线程都能自由访问对象,无需任何同步机制。 - - -
1 偏向锁 同一线程多次进入同步块时(没有竞争) 在对象头中记录偏向线程的ID,如果同一线程再次进入同步块,直接进入,无需CAS操作。 用户态 锁开销极低,适用于无竞争的情况 一旦有其他线程竞争,需等待偏向撤销,适用于线程间少量竞争的场景。
2 轻量级锁 锁定时间短,发生竞争时使用 线程尝试通过CAS操作获取锁,如果失败则自旋等待一段时间。 用户态 避免了线程切换的开销,自旋等待有机会快速获得锁。 自旋等待会消耗CPU时间,不适用于长时间持锁的情况。
3 重量级锁 线程竞争严重或持锁时间较长 通过操作系统的互斥量(Mutex)实现,线程竞争锁失败时会被挂起。 内核态 线程挂起等待锁释放,不消耗CPU时间 线程挂起和恢复的开销较大,适用于长时间持锁的情况。

3.常见问题

3.1 sleep方法跟那个wait方法有什么区别啊?

序号 特性 sleep() wait()
1 所属类 Thread Object
2 是否释放锁
3 是否需要在同步块中使用
4 用途 暂停当前线程指定时间 等待其他线程的通知或超时唤醒
5 是否用于线程间通信
6 调用方式 静态方法 实例方法
  • 关于是否释放锁

    • sleep() 方法
      • 不释放锁:当一个线程调用 sleep() 方法时,线程会进入休眠状态,但它依然持有任何已经持有的锁。这意味着其他线程无法获得这些锁,直到休眠线程醒来并释放锁。
      • 影响:由于 sleep() 方法不释放锁,使用它进行线程间的协调时需要小心。如果一个线程在持有锁的情况下调用 sleep() 方法,可能会导致其他需要相同锁的线程被阻塞,进而导致性能问题或死锁。
    • wait() 方法
      • 释放锁:当一个线程调用 wait() 方法时,它会释放当前持有的锁,并进入等待状态。这样其他线程可以获取到这个锁并执行相应的操作。
      • 影响wait() 方法的设计初衷是用于线程间的通信和协调。通过释放锁,其他线程可以继续执行,从而实现资源共享和线程间协作。例如,在生产者-消费者模型中,消费者在等待新的数据时调用 wait() 方法释放锁,让生产者可以继续生产数据。
  • 关于是否需要在同步块中使用

    • sleep() 方法
      • 不需要在同步块中使用sleep() 方法可以在任何地方调用,不需要在同步块或同步方法中。它只是让当前线程暂停执行指定时间,与线程同步机制无关。
      • 影响:由于 sleep() 方法不涉及锁的释放与获取,它在设计上与同步机制无关。你可以在同步块内外调用 sleep() 方法,但需要注意的是,如果在同步块内调用 sleep() 方法,线程在休眠期间依然持有锁。
    • wait() 方法
      • 必须在同步块中使用wait() 方法必须在同步块或同步方法中调用。调用 wait() 方法的线程必须持有调用对象的监视器锁(即对象锁),否则会抛出 IllegalMonitorStateException 异常。
      • 影响wait() 方法的设计初衷是用于线程间的通信和协调。只有在同步块中调用 wait() 方法,才能确保线程在进入等待状态之前持有对象的监视器锁,并在调用 wait() 时释放该锁,使其他线程能够获得该锁进行相应操作。

好,这里你就可以简单的理解为,因为sleep不释放锁,而wait释放锁,所以呢,为了保证wait能有锁来释放,所以说wait必须在同步代码块中。

参考下我这篇文章,了解下锁机制:Java-线程-synchronized

3.2 多线程的wait、notify、notifyAll方法为何放在Object上而不是Thread上

嗯,这个经过前面的分析,我们已经很好理解了。

关键点在于,锁是与对象关联的。

  • Java中的每一个对象都可以作为一个锁,称为监视器锁。synchronized 关键字就是基于对象锁来实现同步的。
  • 线程在进入同步块时会获取对象的锁,并在退出同步块时释放锁。
  • wait(), notify(), 和 notifyAll() 方法是与对象锁关联的,而不是与线程本身关联的。调用这些方法必须持有相应对象的锁,这也就是为什么这些方法在 Object 类中定义的原因。
posted @ 2024-05-16 21:43  羊37  阅读(18)  评论(0编辑  收藏  举报