一、引言
本期我们将探讨 wait 与 notify 方法的应用,并结合多线程典型案例进行分析,包括饿汉模式与懒汉模式的实现。此外,还将深入讲解阻塞队列的生产者-消费者模型、线程池的使用以及定时器的实现原理。下面让我们正式开始本期内容。
二、wait AND notify
在多线程开发中,不仅仅只是让线程并发执行,很多场景下我们需要线程之间互相配合、互相通信,一个线程等待、另一个线程唤醒,这就需要用到 Object 类中的 wait、notify、notifyAll 方法。
首先大家一定要记住一个硬性规则:wait、notify、notifyAll 必须在 synchronized 同步代码块中使用,必须持有当前对象的锁,否则会直接抛出异常。
我给大家通俗解释一下三个方法的作用:
wait() :让当前线程进入等待状态,并且主动释放当前持有的锁,让其他线程可以抢到锁执行代码,直到被其他线程唤醒。
notify():随机唤醒一个正在等待该对象锁的线程。
notifyAll():唤醒所有正在等待该对象锁的线程。
这里有个重点区别:sleep 和 wait 完全不一样。sleep 只是让线程休眠,不会释放锁;而 wait 会释放锁,这也是线程之间能够协作的核心原因。
简单总结使用流程:
一个线程拿到锁后,判断条件不满足,调用 wait 进入等待并释放锁;另一个线程抢到锁,修改条件后,调用 notify 唤醒等待线程,两个线程完美配合,这就是最基础的线程通信模型,也是后面阻塞队列、生产者消费者模型的底层原理。
三、单例模式
单例模式是我们开发中最常见的设计模式,核心目的就是:保证整个程序中,一个类只有一个实例对象,全局唯一。主要分为饿汉模式和懒汉模式,两种模式在单线程和多线程下表现完全不同。
3.1 饿汉模式
饿汉模式的特点就是:类加载的时候就直接创建对象,不管你后续用不用,对象提前造好。
这种方式最大的优点就是天然线程安全,因为对象在类加载阶段就已经初始化完成,不存在多线程竞争创建的问题。代码如下
java
//饿汉单例,单例是指一个类只能实例化一个对象。
class sing{
private static sing instance = new sing();
private sing(){}
public static sing getInstance(){
return instance;
}
}
缺点也很明显:如果这个对象很大、全程不用,也会提前占用内存,造成内存浪费。适合小对象、常驻内存的场景。
3.2 懒汉模式
懒汉模式和饿汉模式刚好相反,核心思想是懒加载:不用不创建,第一次使用的时候才创建实例,节省内存空间。
3.2.1 懒汉单线程
在单线程环境下,懒汉模式完全没问题。每次获取实例先判断对象是否为空,为空就创建,不为空直接返回,保证全局只有一个对象,代码简单、效率高。
3.2.2 懒汉多线程
一旦到了多线程环境,普通懒汉模式就会出现严重的线程安全问题。
当多个线程同时判断对象为空时,所有线程都会进入创建对象的逻辑,最终会创建多个实例,彻底违背单例的设计初衷。
解决办法就是我们经典的 DCL 双重校验锁懒汉单例:配合 synchronized 加锁、双重判断、volatile 禁止指令重排序,既保证了线程安全,又保证了懒加载,同时效率也很高,是工作中最常用的单例写法。下面是代码
java
class lucy{
private static volatile lucy instance = null;
private lucy(){}
public static lucy getInstance(){
if(instance == null){
synchronized (sing.class){
if(instance == null){
instance = new lucy();
}
}
}
return instance;
}
}
四、阻塞队列(BlockingDeque)
阻塞队列是多线程开发中非常核心的工具,普通队列就是正常存、正常取,而阻塞队列最大的特点就是自带阻塞特性,完美适配多线程并发场景。
核心规则:
队列空了,消费者线程取数据会阻塞等待;队列满了,生产者线程放数据会阻塞等待。
4.1 生产者消费者模型
生产者消费者模型是阻塞队列最经典的应用场景,也是面试高频考点。
简单理解:生产者线程负责往队列里存数据,消费者线程负责从队列里取数据处理。
通过阻塞队列解耦了生产和消费,让生产速度和消费速度自动平衡,不会因为生产太快溢出、消费太快空取报错。底层完全依靠我们前面讲的 wait、notify 实现线程等待与唤醒。
下面是代码实现生产者消费者模型
java
public class Test9 {
public static void main(String[] args) throws InterruptedException {
BlockingDeque<Integer> queue = new LinkedBlockingDeque<>();
Thread t1=new Thread(()->{
while(true){
try {
int val=queue.take();
System.out.println("消费"+val);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
Thread t2=new Thread(()->{
while(true){
int i=new Random().nextInt(1000);
try {
System.out.println("生产"+i);
queue.put(i);
//Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t2.start();
t1.join();
t2.join();
}
}
4.2 阻塞队列的手动实现
为了吃透原理,我们可以手动实现一个简易阻塞队列。
基于数组结合 wait、notify 机制:队列满时,生产者 wait 阻塞;队列空时,消费者 wait 阻塞。新增数据唤醒消费者,取出数据唤醒生产者。
java
//模拟实现阻塞队列
class MyBlockingQueue{
private int[] index=new int[100];
private int size;
private int head;
private int tail;
public void put(int n) throws InterruptedException{
synchronized (this){
//添加的时候反复判断队列是不是满了
while (size==index.length){
wait();
}
index[tail]=n;
tail=(++tail)%index.length;
size++;
notifyAll();
}
}
public int take() throws InterruptedException{
int n;
synchronized (this){
while (size==0){
wait();
}
n=index[head];
head=(head+1)%index.length;
size--;
notifyAll();
}
return n;
}
public int getSize(){
return size;
}
五、线程池
在没有线程池之前,我们是用到线程就 new,用完就销毁。但是线程的创建和销毁是非常消耗系统资源的,频繁创建销毁线程会严重降低程序性能。
所以线程池的核心思想就是:提前创建好一批线程,统一管理、重复复用,任务来了直接分配线程执行,任务结束线程不销毁,等待下一个任务,极大减少资源开销,提高并发效率。
5.1 ThreadPoolExecutor
Java里为我们提供了ThreadPoolExecutor的类一共有七个参数,七7 个核心参数,每一个参数都决定了线程池的运行规则、任务处理策略和资源占用情况,下面我用通俗的方式逐一拆解,保证大家一看就懂。在讲解之前先来一段代码演示一下使用
java
import java.util.concurrent.*;
public class TestPool {
public static void main(String[] args) {
// 手动七大参数创建线程池
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 核心线程
5, // 最大线程
3L, TimeUnit.SECONDS, // 非核心空闲时间3秒
new ArrayBlockingQueue<>(10), // 阻塞队列容量10
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略:抛异常
);
// 提交5个任务
for (int i = 0; i < 5; i++) {
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + "正在执行任务");
});
}
pool.shutdown();
}
}
5.1.1. corePoolSize(核心线程数)
核心线程是线程池里的常驻线程,不会被轻易回收。线程池初始化时没有线程,任务提交后才会逐步创建线程,当线程数量小于核心线程数时,无论任务多少,都会持续创建核心线程。即使任务执行完毕,核心线程也会保留在线程池中,等待后续任务,保障响应速度。
5.1.2. maximumPoolSize(最大线程数)
代表线程池能容纳的最大线程总数。当核心线程全部忙碌、任务队列也已满,无法承载新任务时,线程池会创建非核心线程处理任务。最终线程总数不会超过 maximumPoolSize,这也是线程池控制最大并发量的关键参数。
5.1.3. keepAliveTime(空闲存活时间)
专门作用于非核心线程。非核心线程处理完任务后,不会立刻销毁,会等待指定的 keepAliveTime 时长。如果这段时间内没有新任务分配,非核心线程就会被自动销毁,释放系统资源;如果有新任务,则继续复用线程。
5.1.4. unit(时间单位)
配合 keepAliveTime 使用,用于指定空闲时间的时间单位,支持毫秒、秒、分钟、小时等,常用的是 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)。
5.1.5. workQueue(任务阻塞队列)
用来存储等待执行的任务,必须是阻塞队列。当所有核心线程都在忙碌时,新提交的任务会进入该队列排队,队列满了之后,才会触发非核心线程的创建。常用队列有 ArrayBlockingQueue、LinkedBlockingQueue 等。
5.1.6. threadFactory(线程工厂)
专门用来创建线程的工厂类,我们可以通过它自定义线程的名称、优先级、是否为守护线程等。规范的线程工厂可以方便后续日志排查、线程问题定位,是实际开发中必备的配置。这里和单例模式一样是另一种模式叫工厂模式工厂模式是 Java 最常用的创建型设计模式 ,核心思想就一句话: **统一用 "工厂类" 造对象,不直接 new 对象。**下面用代码来更直观的来感受一下工厂模式
java
// 1.抽象产品:手机父类
abstract class Phone {
public abstract void showBrand();
}
// 2.具体产品:华为手机
class HuaWei extends Phone{
@Override
public void showBrand() {
System.out.println("生产华为手机");
}
}
// 具体产品:小米手机
class XiaoMi extends Phone{
@Override
public void showBrand() {
System.out.println("生产小米手机");
}
}
// 3.工厂类:唯一工厂,负责所有手机实例创建【简单工厂核心】
class PhoneFactory{
// 根据标识生产对应手机
public static Phone createPhone(String type){
if("huawei".equalsIgnoreCase(type)){
return new HuaWei();
}else if("xiaomi".equalsIgnoreCase(type)){
return new XiaoMi();
}
return null;
}
}
// 测试客户端
public class Test {
public static void main(String[] args) {
// 客户端只找工厂拿对象,不出现new HuaWei()、new XiaoMi()
Phone p1 = PhoneFactory.createPhone("huawei");
Phone p2 = PhoneFactory.createPhone("xiaomi");
p1.showBrand();
p2.showBrand();
}
}
5.1.7. handler(拒绝策略)
这是线程池的兜底机制。当核心线程满、任务队列满、最大线程数也满,线程池已经没有能力处理新任务时,就会触发拒绝策略。JDK 内置了四种拒绝策略,分别是直接抛出异常、丢弃最新任务、丢弃最旧任务、主线程自行执行任务,可根据业务场景灵活选择。以下是四个策略
AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调⽤者负责处理多出来的任务.
DiscardOldestPolicy(): 丢弃队列中最⽼的任务.
DiscardPolicy(): 丢弃新来的任务
5.2 Executors 快速创建线程池
上面我们学的 ThreadPoolExecutor 是原生底层构造方式,参数多、配置灵活,但写起来比较繁琐。所以 JDK 官方给我们封装了一个工具类 Executors ,它相当于打包好的线程池工具,帮我们提前预设好七大参数,开发者只需一行代码就能快速创建线程池,使用非常简单。
Executors 最常用的一共有 四种线程池,下面我逐个带大家看懂用法、底层原理和适用场景。先用一段代码来给大家直观的演示使用方法。
java
import java.util.concurrent.*;
public class ExecutorsTest {
public static void main(String[] args) {
//1.固定线程池
ExecutorService fixed = Executors.newFixedThreadPool(3);
//2.缓存线程池
ExecutorService cache = Executors.newCachedThreadPool();
//3.单一线程池
ExecutorService single = Executors.newSingleThreadExecutor();
//4.定时线程池
ScheduledExecutorService schedule = Executors.newScheduledThreadPool(2);
//提交任务测试
fixed.submit(()-> System.out.println("固定池执行任务"));
cache.submit(()-> System.out.println("缓存池执行任务"));
single.submit(()-> System.out.println("单线程池执行任务"));
//延迟1秒执行
schedule.schedule(()-> System.out.println("定时任务执行"),1,TimeUnit.SECONDS);
fixed.shutdown();
cache.shutdown();
single.shutdown();
schedule.shutdown();
}
}
5.2.1. newFixedThreadPool(固定数量线程池)
特点:线程数量固定,不会新增、不会销毁,属于常驻线程池。
底层原理:核心线程数=自定义固定值,最大线程数=核心线程数,无空闲回收时间,队列使用无界阻塞队列。
适用场景:任务量稳定、长期并发执行的业务,保证并发数可控。
java
// 创建固定3个线程的线程池
ExecutorService pool = Executors.newFixedThreadPool(3);
5.2.2. newCachedThreadPool(缓存线程池)
特点:线程数量不固定,可无限扩容,线程空闲60秒自动回收。
底层原理:核心线程数=0,最大线程数=Integer.MAX_VALUE,空闲存活60秒,队列为同步队列。
适用场景:大量短时、瞬时突发任务,线程随用随创建、空闲自动回收。
java
ExecutorService pool = Executors.newCachedThreadPool();
5.2.3. newSingleThreadExecutor(单线程线程池)
特点:池子里永远只有一个线程,所有任务串行执行,保证任务执行顺序。
底层原理:核心线程数、最大线程数都为1,无空闲回收时间。
适用场景:需要保证任务有序执行、串行执行的业务场景。
java
ExecutorService pool = Executors.newSingleThreadExecutor();
5.2.4. newScheduledThreadPool(定时线程池)
特点:支持延时执行、周期性定时执行任务,替代传统Timer定时器。
适用场景:定时任务、延迟任务、循环执行的业务。
java
// 创建核心线程数为2的定时线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
5.2.5 重点:为什么阿里规范禁止使用 Executors?
虽然 Executors 使用简单,但工作和面试中都不推荐使用 ,核心原因就是隐藏风险太大:
-
newFixedThreadPool / newSingleThreadExecutor:使用无界队列,任务无限堆积,最终会导致 OOM 内存溢出。
-
newCachedThreadPool:最大线程数无限大,高并发下会创建海量线程,导致 CPU 爆满、程序卡死。
总结一句话:Executors 是简化版线程池,封装了参数、隐藏了风险;ThreadPoolExecutor 是原生完整版,参数可控、安全稳定,生产环境必须手动使用 ThreadPoolExecutor!
5.3 模拟实现线程池
我们可以手动模拟一个简易线程池,核心结构就两个:工作线程 + 任务队列。
程序启动预先创建固定数量的工作线程,所有线程循环去任务队列里取任务执行。当没有任务时,线程阻塞等待;当提交新任务,唤醒线程执行任务。
通过手写线程池,我们就能理解线程池的核心原理:线程复用、任务缓冲、统一管控,这也是为什么工作中严禁手动 new Thread 的原因。下面是代码实现
java
class MyThreadpool{
//需要一个阻塞队列来实现线程池,用来放任务接受任务
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
public void submit(Runnable r) throws InterruptedException{
//添加任务
queue.put(r);
}
//指定创建线程的数量
public MyThreadpool(int n) throws InterruptedException{
for(int i=0;i<n;i++){
Thread t=new Thread((()->{
//把阻塞队列的任务取出来并执行
Runnable runnable= null;
try {
runnable = queue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
runnable.run();
}));
t.start();
}
}
}
六、定时器
在日常开发中,我们经常需要处理延时执行或定时循环执行的任务需求,这就需要使用定时器来实现。Java 提供了内置的 Timer 定时器类,它是 Java 标准库中用于调度任务执行的一个简单工具。
Timer 的基本使用方式是通过创建一个 Timer 实例,然后调用其 schedule() 或 scheduleAtFixedRate() 方法来安排任务。这些任务被封装在 TimerTask 对象中,需要开发者重写其 run() 方法来实现具体的业务逻辑。例如:
java
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时任务执行:" + new Date());
}
}, 1000, 2000); // 延迟1秒后执行,之后每隔2秒执行一次
6.1 模拟实现定时器
我们可以手写一个简易定时器,核心思路:创建一条专属定时线程,维护一个有序的定时任务队列。线程不断轮询判断任务是否到执行时间,到点就自动执行任务,没到时间就阻塞等待。
手写完成后可以清晰理解定时器的底层逻辑:本质就是延时阻塞 + 任务轮询调度。下面是具体的代码实现。
java
class MyTask implements Callable<MyTask> {
public Runnable r;
public long time;
public MyTask(Runnable r, long time){
this.r = r;
this.time=time;
}
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
}
public long getTime(){
return time;
}
public void run(){
r.run();
}
@Override
public MyTask call() throws Exception {
return null;
}
}
class Mytimer {
private PriorityQueue<MyTask> queue=new PriorityQueue<>();
public void schedule (Runnable task,long time){
synchronized (this){
MyTask task1=new MyTask(task,System.currentTimeMillis()+time);
//*添加时间进入队列
queue.offer(task1);
this.notify();
}
}
public Mytimer(){
Thread t1=new Thread(()->{
try {
while(true){
synchronized (this){
while(queue.isEmpty()){
this.wait();
}
MyTask task=queue.peek();
if(System.currentTimeMillis()<task.getTime()){
this.wait(task.getTime()-System.currentTimeMillis());
}else {
task.run();
queue.poll();
}
}
}
}catch (InterruptedException e){
e.printStackTrace();
}
});
t1.start();
}
}
七、总结
这期内容我们从头到尾梳理了多线程进阶一系列重点内容,知识点由底层往上层逐步延伸。
最先学习了 wait、notify 线程等待唤醒机制,分清了 wait 和 sleep 的关键差别,wait 会释放锁也是后续阻塞队列能够实现的根基。紧接着聊了单例模式,饿汉加载即创建天然线程安全但容易浪费内存,普通懒汉单线程没问题,多线程环境下会创建多个对象,依靠 DCL 双重校验 + volatile 就能写出安全的懒汉单例。
之后围绕阻塞队列展开,搞懂了空阻塞、满阻塞的特性,借助阻塞队列实现生产者消费者模型,还自己手写简易阻塞队列,落地了 wait 和 notify 的实际用法。
线程池是本篇重中之重,先吃透 ThreadPoolExecutor 七个参数、四种拒绝策略,顺带了解线程工厂也就是简单工厂设计模式;又学习 Executors 提供的四种快捷创建线程池方式,清楚四种池子各自特点,同时记住阿里规范,生产禁用 Executors,优先手动创建 ThreadPoolExecutor,最后手写简易线程池理解线程复用原理。
末尾学习 Timer 定时器用法,手动自研定时器,明白底层靠优先级队列 + 线程延时等待完成任务调度。
整套知识点串联下来,从底层线程通信,到设计模式、并发模型再到开发常用工具,都是面试和日常开发高频考点,吃透之后对多线程的使用会有更清晰的认知。