一、线程与进程

1 理解线程与进程区别:

进程:电脑中时会有很多单独运行的程序,每个程序有一个独立的进程,而进程之间是相互独立存在的。比如同时打开微信、网易云音乐、电脑管家等等。
线程:进程想要执行任务就需要依赖线程。换句话说,就是进程中的最小执行单位就是线程,并且一个进程中至少有一个线程。

2 多线程与单线程区别:

单线程是串行执行任务的。我们就拿下载文件来举个例子:当我们下载多个文件时,在串行中它是按照一定的顺序去进行下载的,也就是说,必须等下载完A之后才能开始下载B,它们在时间上是不可能发生重叠的。

java多线程 转单线程 java多线程和单线程的区别_优先级


而多线程是并行执行任务的。同样以下载文件为例,下载多个文件,开启多条线程,多个文件同时进行下载,这里是严格意义上的,在同一时刻发生的,并行在时间上是重叠的。

java多线程 转单线程 java多线程和单线程的区别_java多线程 转单线程_02

3 多线程使用场景:

抢票,秒杀,取钱…

二、线程有哪些调度机制?

java 1.1的时候线程是由JVM调度的,Java 1.2之后所有Java线程会被1:1地映射到OS上面,线程的调度完全取决于操作系统。
抢占式调度:一个线程的堵塞不会导致整个进程堵塞。Java 中线程会按优先级分配 CPU 时间片运行, 且优先级越高越优先执行,但优先级高并不代表能独自占用执行时间片。
什么情况下 线程让出 cpu ?

  1. 调用 yield()方法让当前运行线程主动放弃 CPU。
  2. 当前运行线程因为某些原因进入阻塞状态,例如阻塞在 I/O 上。
  3. 当前运行线程结束,即运行完 run()方法里面的任务。

协作式调度:某一线程执行完后主动通知系统切换到另一线程上执行,类似于接力赛,一个线程阻塞可能就直接堵死了。

java多线程 转单线程 java多线程和单线程的区别_线程池_03


说到线程调度机制就不得不跟大家探讨下CPU的问题。大家觉得单核CPU最多能同时跑几个线程?有人会说一个,有人会说可以跑多个。其实都是对的,只是看问题的角度不同。为什么呢?请接着往下看:

因为各个线程都是不断切换轮流执行的,它们每个线程轮流占用的时间片很短很短,人是察觉不到的。而且并不是每个线程都必须执行完才发生切换,比如A,B两个线程;

1.A执行到某一时间段要切换了,可A任务没完成,系统就会把A当前执行的位置和数据以入栈的方式保存起来(如果是进程,没个进程都有自己的进程栈,线程我不太清楚)

2.然后B线程执行,B执行时间到了,它的位置状态等也会被系统保存到B的栈中。

3.系统自动找到A的栈,将A之前保存的数据恢复,又可以从A之前断开的状态继续执行下去,如此循环。

单核多线程

单核多线程指的是单核CPU轮流执行多个线程,通过给每个线程分配CPU时间片来实现,只是因为这个时间片非常短(几十毫秒),所以在用户角度上感觉是多个线程同时执行。

多核多线程

可以把多线程分配给不同的核心处理,其他的线程依旧等待,相当于多个线程并行的在执行。

三、线程创建方式

1 继承Thread类,重写run()方法,调用start()方法启动线程
/**
 * Java实现多线程的方式1
 * 继承Thread类,重写run方法
 */
@SpringBootTest
public class StartThread extends Thread{
    @Override
    public void run(){
        //此处为thread执行的任务内容
        for (int i = 0; i < 200; i++) {
            System.out.println("多线程测试"+i+":"+currentThread().getName());
        }
    }

    public static void main(String[] args) {
        StartThread st = new StartThread();
        System.out.println("线程状态:"+st.getName()+st.getState());
        //调用start方法启动多线程
        st.start();
        System.out.println("线程状态:"+st.getName()+st.getState());
        for (int i = 0; i < 1000; i++) {
            System.out.println("主线程测试"+i+":"+currentThread().getName()+currentThread().getState());
        }
        System.out.println("线程状态:"+st.getName()+st.getState());
    }
}
2 实现Runnable接口,重写run()方法,调用start()方法启动线程
/**
 * Java实现多线程的方式2
 * 实现Runnable接口,重写run方法
 */
@SpringBootTest
public class StartRunnable implements Runnable{
    @Override
    public void run(){
        //此处为thread执行的任务内容
        for (int i = 0; i < 200; i++) {
            System.out.println("多线程测试"+i);
        }
    }

    public static void main(String[] args) {
        StartRunnable sr = new StartRunnable();
        new Thread(sr).start();
        for (int i = 0; i < 1000; i++) {
            System.out.println("主线程测试"+i);
        }
    }
}
3 实现Callable接口,重写call()方法,需要抛出异常
/**
 * Java实现多线程的方式3
 * 实现Callable接口,重写call方法
 */
public class StartCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        //此处为thread执行的任务内容
        for (int i = 0; i < 200; i++) {
            System.out.println("多线程测试"+i);
        }
        System.out.println(Thread.currentThread().getName());
        return null;
    }

    public static void main(String[] args) {
        //创建StartCallable实例
        Callable<Integer> c = new StartCallable();
        //获取FutureTask
        FutureTask<Integer> ft = new FutureTask<Integer>(c);
        //使用FutureTask初始化Thread
        Thread t = new Thread(ft);
        t.start();
    }

}
4 使用线程池(必须掌握)
背景:

经常创建和销毁,使用量特别大的资源,比如高并发情况下的线程,对性能影响很大。

思路:

提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁,实现重复利用,类似于数据库连接池的用法

好处:

1 减少了创建新线程的时间,提高了响应速度。
2 重复利用线程池中线程,不需要每次都创建,降低资源消耗。
3 便于线程管理

线程池参数:

corePoolSize(必需):核心线程数。默认情况下,核心线程会一直存活,但是当将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
maximumPoolSize(必需):线程池所能容纳的最大线程数。当活跃线程数达到该数值后,后续的新任务将会阻塞。
keepAliveTime(必需):线程闲置超时时长。如果超过该时长,非核心线程就会被回收。如果将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
unit(必需):指定 keepAliveTime 参数的时间单位。常用的有:TimeUnit.MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分)。
workQueue(必需):任务队列。通过线程池的 execute() 方法提交的 Runnable 对象将存储在该参数中。其采用阻塞队列实现。
任务队列是基于阻塞队列实现的,即采用生产者消费者模式,在 Java 中需要实现 BlockingQueue 接口。但 Java 已经为我们提供了 7 种阻塞队列的实现:
ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列(数组结构可配合指针实现一个环形队列)。
LinkedBlockingQueue: 一个由链表结构组成的有界阻塞队列,在未指明容量时,容量默认为 Integer.MAX_VALUE。
PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列,对元素没有要求,可以实现 Comparable 接口也可以提供 Comparator 来对队列中的元素进行比较。跟时间没有任何关系,仅仅是按照优先级取任务。
DelayQueue:类似于PriorityBlockingQueue,是二叉堆实现的无界优先级阻塞队列。要求元素都实现 Delayed 接口,通过执行时延从队列中提取任务,时间没到任务取不出来。
SynchronousQueue: 一个不存储元素的阻塞队列,消费者线程调用 take() 方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回;生产者线程调用 put() 方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。
LinkedBlockingDeque: 使用双向队列实现的有界双端阻塞队列。双端意味着可以像普通队列一样 FIFO(先进先出),也可以像栈一样 FILO(先进后出)。
LinkedTransferQueue: 它是ConcurrentLinkedQueue、LinkedBlockingQueue 和 SynchronousQueue 的结合体,但是把它用在 ThreadPoolExecutor 中,和 LinkedBlockingQueue 行为一致,但是是无界的阻塞队列。
注意有界队列和无界队列的区别:如果使用有界队列,当队列饱和时并超过最大线程数时就会执行拒绝策略;而如果使用无界队列,因为任务队列永远都可以添加任务,所以设置 maximumPoolSize 没有任何意义。
threadFactory(可选):线程工厂。用于指定为线程池创建新线程的方式。线程工厂指定创建线程的方式,需要实现 ThreadFactory 接口,并实现 newThread(Runnable r) 方法。该参数可以不用指定,Executors 框架已经为我们实现了一个默认的线程工厂.
handler(可选):拒绝策略。当达到最大线程数时需要执行的饱和策略。
当线程池的线程数达到最大线程数时,需要执行拒绝策略。拒绝策略需要实现 RejectedExecutionHandler 接口,并实现 rejectedExecution(Runnable r, ThreadPoolExecutor executor) 方法。不过 Executors 框架已经为我们实现了 4 种拒绝策略:
AbortPolicy(默认):丢弃任务并抛出 RejectedExecutionException 异常。
CallerRunsPolicy:由调用线程处理该任务。
DiscardPolicy:丢弃任务,但是不抛出异常。可以配合这种模式进行自定义的处理方式。
DiscardOldestPolicy:丢弃队列最早的未处理任务,然后重新尝试执行任务。

线程池工作原理:

线程池最大可容纳线程数:核心线程数+任务队列数+(最大线程数-核心线程数)

1 判断核心线程数是否达到最大?

2 如果核心线程数没有达到最大,执行任务。

如果核心线程数已经达到最大,检查任务队列是否已满?

3 如果任务队列未满,将任务放入队列等待执行。

如果任务队列已满,检查线程数是否达到最大线程数?

4 如果线程数没有达到最大线程数,创建非核心线程执行任务。

如果线程数已达到最大线程数,执行饱和拒绝策略。

java多线程 转单线程 java多线程和单线程的区别_java多线程 转单线程_04

线程池使用流程:

线程池的实现接口是ExecutorService,ExecutorService的默认实现类是ThreadPoolExecutor。

java多线程 转单线程 java多线程和单线程的区别_优先级_05

// 创建线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(CORE_POOL_SIZE,
                                             MAXIMUM_POOL_SIZE,
                                             KEEP_ALIVE,
                                             TimeUnit.SECONDS,
                                             sPoolWorkQueue,
                                             sThreadFactory);
// 向线程池提交一个没有返回值任务
threadPool.execute(new Runnable() {
    @Override
    public void run() {
        ... // 线程执行的任务
    }
});
//提交一个线程任务,可以接受回调函数的返回值,适用于需要处理返回着或者异常的业务场景(可以有返回值也可以不要返回值)
threadPool.submit(new Runnable() {
    @Override
    public void run() {
        ... // 线程执行的任务
    }
});
// 关闭线程池
threadPool.shutdown(),表示不再接受新任务,但不会强行终止已经提交或者正在执行中的任务 
threadPool.shutdownNow(),对于尚未执行的任务全部取消,正在执行的任务全部发出interrupt(),停止执行

为了方便使用,JUC包下还特别提供了4种功能线程池。

功能线程池:4种(虽然方便,不建议使用,最好直接用ThreadPoolExecutor,这样的处理方式让大家可以更加明确线程池的运行规则,规避资源耗尽的风险)

定长线程池(FixedThreadPool)

特点:只有核心线程,线程数量固定,执行完立即回收,任务队列为链表结构的有界队列。

应用场景:控制线程最大并发数。

定时线程池(ScheduledThreadPool )

特点:核心线程数量固定,非核心线程数量无限,执行完闲置 10ms 后回收,任务队列为延时阻塞队列。

应用场景:执行定时或周期性的任务。

可缓存线程池(CachedThreadPool)

特点:无核心线程,非核心线程数量无限,执行完闲置 60s 后回收,任务队列为不存储元素的阻塞队列。

应用场景:执行大量、耗时少的任务。

单线程化线程池(SingleThreadExecutor)

特点:只有 1 个核心线程,无非核心线程,执行完立即回收,任务队列为链表结构的有界队列。

应用场景:不适合并发但可能引起 IO 阻塞性及影响 UI 线程响应的操作,如数据库操作、文件操作等。

java多线程 转单线程 java多线程和单线程的区别_多线程_06


参考源码:https://gitee.com/qiugantao/java-spring-quick-start/tree/master/java-thread/src/test/java/com/thread/pool

四、线程方法

1.sleep(ms):强迫一个线程睡眠N毫秒。线程不会释放锁。到时间继续执行原来任务。
2. isAlive(): 判断一个线程是否存活。
3. join(): 谁join谁插队。等待线程终止。在线程中插入执行另一个线程,该线程被阻塞,直到插入执行的线程完全执行完毕以后,该线程才继续执行下去。
4. activeCount(): 程序中活跃的线程数。
5. enumerate(): 枚举程序中的线程。
6. currentThread(): 得到当前线程。
7. isDaemon(): 一个线程是否为守护线程。
8. setDaemon(): 设置一个线程为守护线程。 (用户线程和守护线程的区别在于,是否等待主线程依赖于主线程结束而结束)
9. setName(): 为线程设置一个名称。
getName(): 获取当前线程的名字
10. wait(): 强迫一个线程等待。线程会释放锁,需要调用notify()方法唤醒才能重新竞争CPU执行时间片。
11. Stop(): 过时方法。当执行此方法时,强制结束当前线程。不建议使用。
12. Thread.currentThread().getName() 获取当前线程的名字
13. notify(): 通知一个线程继续运行。
14. setPriority(): 设置一个线程的优先级。
15. getPriority(): 获得一个线程的优先级。
16. interrupt():中断线程,不建议使用。
17. yield():暂停当前正在执行的线程对象,并执行其他线程。

五、线程有哪些状态?什么情况下进入这些状态?

New新建:new创建,JVM分配内存并初始化成员变量值

Runnable就绪:调用start方法之后处于就绪状态,JVM会为其创建方法调用栈和程序计数器,等待调度运行。

Running运行:处于就绪状态的线程获得CPU,开始执行run方法的线程执行体,则该线程处于运行状态。

Blocked阻塞

等待阻塞:wait

同步阻塞:lock

其它阻塞:sleep/join

Dead死亡

正常结束:run()或者call()方法执行完成,线程正常结束。

异常结束:线程抛出一个未捕获的Exception或Error。

调用stop:直接调用stop方法结束线程,该方法通常容易导致死锁,不推荐使用。

可以通过Thread.getState()方法查看线程状态。

java多线程 转单线程 java多线程和单线程的区别_线程池_07

六、线程优先级

Java提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程,线程调度器按照优先级决定应该调用哪个线程来执行。

//线程优先级用数字表示,范围1-10
Thread.MIN_PRIORITY=1;
Thread.MAX_PRIORITY=1;
Thread.NORM_PRIORITY=5;
//使用一下方式改变或获取优先级
Thread.getPriority() 
Thread.setPriority(int xxx)

线程优先级高不一定先执行,只是先执行的概率大。就像买彩票,优先级高中奖概率大。
优先级低只是意味着获得调度的概率低,并不是优先级低就一定后被调用,这些都要看CPU的调度。

七、Java内存模型

1 内存模型的相关概念

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存

也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。如下图所示:

java多线程 转单线程 java多线程和单线程的区别_java多线程 转单线程_08


举个简单的例子,比如下面的这段代码:

i = i + 1;

当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量

也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

1)通过在总线加LOCK#锁的方式

2)通过缓存一致性协议

这2种方式都是硬件层面上提供的方式。

java多线程 转单线程 java多线程和单线程的区别_java多线程 转单线程_09

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

八、并发编程3大特性

1.原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行往账户B加上1000元的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

2.可见性

可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;     //语句2
a = a + 3;     //语句3
r = a*a;       //语句4

这段代码有4个语句,那么可能的一个执行顺序是:语句2 语句1 语句3 语句4
那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
答案是不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction2必须用到Instruction1的结果,那么处理器会保证Instruction1会在Instruction2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

九、线程同步/安全

在学习完上述知识点后,我们又需要去了解一个使用多线程不得不考虑的问题——线程安全。当多个线程操作同一个资源时,如何保证线程同步与安全,是我们必须要考虑的问题。举个秒杀的例子,当前秒杀产品有100个库存,开启了多个线程去执行秒杀任务,如果不控制线程同步,就会出现超卖的情况。再比如食堂打饭,每个人都想吃饭,最天然的解决办法就是排队,一个一个来。
1 线程同步形成条件:队列+锁(举例:排队打饭/上厕所)>为了安全
2 锁机制:
2.1 synchronized是隐式锁,出了作用域自动释放。默认锁的是this,synchronized(obj)块可以锁任何对象。
2.2 Lock是显式锁,需要手动开启和关闭。JVM将花费较少的时间来调度线程,性能更好。并且提供了更多的子类,具有更好地扩展性。
3 锁的对象就是变化的量,需要CUD的对象。
4 sleep可以放大问题的发生性。
5 性能与安全,二者取其一,鱼和熊掌不能兼得。

十、线程通信

线程之间通信,来确定不同线程之间的执行顺序。
方法:
1 基于join:
join是Thread类的方法,底层基于wait+notify,你可以把这个方法理解成插队,谁调用谁插队,但有局限性,适用于线程较少的场景,如果线程多了会造成无限套娃,有点麻烦,不够优雅。
2 基于volatile共享变量:
这种实现比较简单,也很好理解,但是性能不咋地,会抢占很多cpu资源,如非必要,不要用。
3 基于synchronized、wait()、notify()三件套:
wait() 和 notify()都是Object类的通讯方法,注意一点,wait和 notify需搭配synchronized使用,注意,notify不会释放锁。
4 基于reentrantLock:
ReentrantLock是juc包下的并发工具,也能实现,但相对复杂,需结合Condition的await和signal,底层原理有点像上面的wait和notify。