Java多线程初阶
文章目录
- 线程与进程
- 背景
- 进程的局限
- 典型示例
- 线程引入
- 底层概念
- 进程与线程的描述
- 包含关系
- 基本单位
- 举例
- 线程比进程更轻量的原因
- 线程能提高效率的原因
- 线程冲突问题
- 进程与线程的概念和区别
- 在 Java 中编写多线程程序
- 使用 `Thread` 标准库
- 创建线程的写法
- 匿名内部类实现线程
- 基于lambda表达式来创建线程
- Thread类,属性和方法
- 前台线程和后台线程
- **1. 前台线程(User Thread)**
- **2. 后台线程(Daemon Thread)**
- 3. 操作系统中的前台进程和后台进程
- 线程的核心操作
- 创建线程start()
- 一个经典的面试题:start和run之间的差别
- 线程的终止
- Java线程终止与中断机制
- 协作式中断机制
- 线程中断的实现
- 代码示例
- 关键点说明
- interrupt()方法的双重作用
- 为什么阻塞方法会清除中断标志位?
- 正确终止线程
- 线程的等待
- 线程的调度
- 线程等待与阻塞
- Thread.join()方法
- Thread.sleep()方法
- 线程状态
- 进程状态
- Java线程的状态
- 线程安全
- 1. 线程安全问题的根源
- 2. 线程安全问题的原因
- 3. 解决方案——加锁
- 代码示例
- 加锁原理
- 为什么锁对象可以随便设置?
- 4. 加锁与 join 的区别
- 5. Java中的加锁语法
- 1. synchronized代码块
- 2. synchronized方法
- 3. synchronized静态方法
- 6. synchronized机制优势
- 7. 适用场景与注意事项
- 死锁
- 一、死锁的定义
- 二、死锁的常见场景
- 1. 单线程重复加锁(可重入锁)
- 2. 多线程多锁互相等待(经典死锁)
- 3. N线程M锁(哲学家就餐问题)
- 三、死锁的四个必要条件(重点)
- 四、死锁的解决方法
- 避免循环等待:**统一加锁顺序**
- 代码示例(修改后无死锁):
- 五、可重入锁(Reentrant Lock)原理
- Java 集合类的线程安全性
- 内存可见性
- 一、什么是内存可见性问题?
- 二、代码示例
- 问题代码
- 三、问题原因分析
- 1. JVM/编译器优化
- 2. 单线程与多线程区别
- 四、如何解决内存可见性问题?
- 1. 加入`Thread.sleep`
- 2. 使用`volatile`关键字(推荐)
- 原理
- 3. 其他方法
- 五、编译器优化的目的与权衡
- wait / notify
- 一、为什么需要 wait/notify?
- 二、wait/notify 的基本原理
- 三、使用方法和注意事项
- 1. 必须在同步(synchronized)代码块中使用
- 2. wait 的三大作用
- 3. notify/notifyAll
- 四、常见错误及其原因
- 1. 未加锁调用 wait/notify
- 2. wait 必须释放锁
- 3. notify 只唤醒一个线程
- 4. notify 没有线程等待
- 五、典型代码示例
- 1. 基本用法
- 2. 多线程 wait,notify 唤醒一个
- 3. notifyAll 唤醒全部
- 六、wait 的超时版本
- 七、原子性问题
- 单例模式
- 一、什么是单例模式?
- 二、设计模式与框架
- 三、单例模式实现方式
- 1. 饿汉式(类加载时就创建实例)
- 2. 懒汉式(第一次使用时才创建实例)
- 四、线程安全问题分析
- 五、线程安全优化方案
- 1. 加锁(同步方法/同步代码块)
- 2. 指令重排序问题
- 3. 使用 volatile 关键字
- 六、面试答题套路
- 阻塞队列
- 一、阻塞队列是什么?
- 二、应用场景:生产者-消费者模型
- 1. 生产者-消费者模型
- 2. 分布式系统中的应用
- 3. 消息队列
- 三、标准库实现:BlockingQueue
- 1. BlockingQueue 的主要方法
- 2. 代码示例
- 四、自定义阻塞队列实现
- 1. 基本原理
- 2. 代码示例
- 五、实现细节与面试要点
- 1. 循环队列的写法
- 2. wait/notify 的使用细节
- 3. InterruptedException
- 4. volatile 的作用
- 六、阻塞队列的优缺点
- 优点
- 缺点
- 七、面试答题套路
- 线程池
- 一、为什么要用线程池?
- 二、线程池的底层原理
- 内核态与用户态
- 线程创建过程
- 三、Java标准库中的线程池
- 1. ThreadPoolExecutor(核心类)
- 参数说明
- 拒绝策略(RejectedExecutionHandler)
- 2. Executors 工厂类(简化版)
- 3. 线程池用法示例
- 四、线程池参数如何设置?
- 五、工厂设计模式(ThreadFactory)
- 六、自定义线程池实现
- 七、面试答题套路
- 定时器(Timer)
- 一、标准库定时器用法
- 二、定时器的实现原理
- 1. 定时任务的描述
- 2. 定时任务的管理
- 三、数据结构选择:为什么不用 List?
- 四、自定义定时器实现(代码示例)
- 1. 定时任务类
- 2. 定时器类
- 3. 使用示例
- 五、线程安全与性能优化
- 六、定时器的局限与扩展
- 七、面试答题套路
线程与进程
背景
虽然多进程能够解决一些问题,但在高效率的要求下,我们希望有更好的编程方式。
进程的局限
多进程的主要缺点是进程相对较重。创建和销毁进程的开销(包括时间和空间)都比较大。当需求场景需要频繁创建进程时,这种开销就会变得非常明显。
典型示例
最典型的场景是服务器开发。在这种情况下,针对每个发送请求的客户端,通常会创建一个单独的进程,由该进程负责为客户端提供服务。
线程引入
为了解决这个问题,发明了线程——一种更轻量级的进程。线程同样能够处理并发编程的问题,但其创建和销毁的开销要低于进程。因此,多线程编程逐渐成为当下主流的并发编程方式。
底层概念
进程与线程的描述
- 进程是通过 PCB(进程控制块) 结构体来描述的,并以链表形式组织。
- 在 Linux 系统中,线程同样是通过 PCB 来描述。
- 一个进程实际上是一组 PCB,而一个线程对应一个 PCB。
包含关系
- 存在一个“包含关系”:一个进程可以包含多个线程。
- 在这种情况下,每个线程可以独立地被 CPU 调度执行。
- 同一个进程中的线程共享同一份系统资源。
基本单位
- 线程是系统“调度执行”的基本单位。
- 进程是系统“资源分配”的基本单位。
举例
当一个可执行文件被点击时,操作系统会:
- 创建进程,并分配资源(如 CPU、内存、硬盘、网络等)。
- 在该进程中创建一个或多个线程,这些线程随后会被调度到 CPU 上执行。
- 如果在一个进程中有多个线程,每个线程都会有自己的状态、优先级、上下文和记账信息,且每个线程都会各自独立地在 CPU 上调度执行。
线程比进程更轻量的原因
主要在于创建线程省去了“分配资源”过程,销毁线程也省去了“释放资源”过程。一旦创建进程,同时会创建第一个线程,该线程负责分配资源。后续创建第二个、第三个线程时,就不必重新分配资源了。
线程能提高效率的原因
开销较大的操作并不容易实现。能够提高效率的关键在于充分利用多核心进行“并行执行”。如果只是“微观并发”,速度没有提升;真正能提升速度的是“并行”。如果线程数目太多,超出了 CPU 核心数目,就无法在微观上完成所有线程的“并行”执行,势必会存在严重的“竞争”。
线程冲突问题
当线程数目多了之后,容易发生“冲突”。由于多个线程使用同一份资源(如内存资源),如果多个线程针对同一个变量进行读写操作(尤其是写操作),就容易发生冲突。
当一个进程中有多个线程时,一旦某个线程抛出异常,如果处理不当,可能导致整个进程崩溃,其他线程也会随之崩溃。
进程与线程的概念和区别
- 进程包含线程:一个进程里可以有一个或多个线程,不能没有线程。
- 资源分配单位:进程是系统资源分配的基本单位,线程是系统调度执行的基本单位。
- 资源共享:同一个进程里的线程之间共享同一份系统资源(内存、硬盘、网络带宽等),尤其是内存资源。
- 并发编程的主流方式:线程是实现并发编程的主流方式,通过多线程可以充分利用多核 CPU。
- 相互影响:多个线程之间可能会相互影响,线程安全问题,一个线程抛出异常,也可能会把其他线程一起带走。
- 进程的隔离性:多个进程之间一般不会相互影响,一个进程崩溃不会影响到其他进程。
在 Java 中编写多线程程序
线程是操作系统提供的概念,操作系统提供 API 供程序员调用。不同系统提供的 API 是不同的(Windows 和 Linux 的线程创建 API 差别很大)。Java(JVM)将这些系统 API 封装好了,我们只需关注 Java 提供的 API。
使用 Thread
标准库
class MyThread extends Thread {@Overridepublic void run() {// 即将创建出的线程要执行的逻辑System.out.println("hello thread");}
}public class Demo1 {public static void main(String[] args) {MyThread t = new MyThread();t.start();}
}
注意:上述代码中,run
方法并没有手动调用,但最终也执行了。这种方法称为“回调函数”(callback)。
该代码运行时是一个进程,但这个进程包含了两个线程:调用 main
方法的线程(主线程)和新创建的线程。
创建线程的写法
- 继承
Thread
,重写run
。
class MyThread extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}public class Demo1 {public static void main(String[] args) {MyThread t = new MyThread();t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}
多个线程之间的调度执行顺序是不确定的,取决于操作系统的调度器实现。我们只能将这个过程近似视为“随机”的“抢占式执行”。
- 实现
Runnable
接口,重写run
。
class MyRunnable implements Runnable {@Overridepublic void run() {while (true) {System.out.println("hello thread!");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}public class Demo2 {public static void main(String[] args) {MyRunnable myRunnable = new MyRunnable();Thread t = new Thread(myRunnable);t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}
通过 Runnable
描述任务,而不是通过 Thread
自己来描述,有助于解耦合。后续执行这个任务的载体,可以是线程,也可以是其他(如线程池、虚拟线程)。
匿名内部类实现线程
public static void main(String[] args) throws InterruptedException {Thread t=new Thread(){@Overridepublic void run(){while (true){System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}};t.start();while (true){System.out.println("hello main");Thread.sleep(1000);}
}
- 定义匿名内部类,该类是
Thread
的子类。 - 在类内部重写父类
run
方法。 - 创建子类实例并将其引用赋值给
t
。
public static void main(String[] args) throws InterruptedException {Thread t =new Thread(new Runnable() {@Overridepublic void run() {while (true){System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}});t.start();while (true){System.out.println("hello main");Thread.sleep(1000);}
}
- 定义匿名内部类,该类实现了
Runnable
接口。 - 在类内部重写
Runnable
接口的run
方法。 - 创建匿名内部类的实例,并将其引用传递给
Thread
。
基于lambda表达式来创建线程
public static void main(String[] args) throws InterruptedException {Thread t=new Thread(()->{System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}});t.start();while (true){System.out.println("hello main");Thread.sleep(1000);}
}
- 没有显式写
Runnable
是因为编译器通过Thread(Runnable)
的参数类型自动推断。 - 没有写
run()
是因为 Lambda 只针对函数式接口的唯一方法,方法名可省略。 - Lambda 不是内部类,但效果等价,且更高效(不生成额外类文件)。
简单来说:Lambda 是 Runnable.run()
的“语法糖”,编译器帮你补全了细节
Thread类,属性和方法
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
Thread(ThreadGroup group, Runnable target) | 线程可以被用来分组管理,分好的组即为线程组(目前了解即可) |
属性 | 获取方法 | 说明 |
---|---|---|
ID | getId() | 线程的唯一标识,不同线程不会重复。 |
名称 | getName() | 用于调试工具,可自定义线程名(如 Thread-0 )。 |
状态 | getState() | 表示线程当前状态(如 RUNNABLE 、WAITING ),后续详细说明。 |
优先级 | getPriority() | 优先级高的线程理论上更容易被调度(范围:1~10,默认5)。 |
是否后台线程 | isDaemon() | JVM 在所有非后台线程(用户线程)结束后才会退出。默认是 false (非后台)。 |
是否存活 | isAlive() | run() 方法是否正在执行(未结束返回 true )。 |
是否被中断 | isInterrupted() | 线程的中断状态,需手动处理中断逻辑,后续详细说明。 |
ID是jvm自动分配的
不能手动设置通常情况下,一个 Thread 对象对应系统内部的一个线程(PCB),但也可能存在 Thread 对象存在而系统内部线程已经销毁或尚未创建的情况。
isAlive()
代码中,创建的new Thread 对象,生命周期,和内核中实际的线程,是不一定一样的.
可能会出现,Thread对象仍然存在,但内核中的线程不存在了这样的情况.
- 调用start之前,内核中,还没创建线程
- 线程的run执行完毕了,内核的线程就无了,但是Thread对象,仍然存在
前台线程和后台线程
1. 前台线程(User Thread)
-
特点:
- JVM会等待所有前台线程执行完毕才会退出。
- 默认创建的线程都是前台线程(包括
main
主线程)。
-
示例:
Thread userThread = new Thread(() -> {System.out.println("前台线程运行中"); }); userThread.start(); // 默认是前台线程
2. 后台线程(Daemon Thread)
-
特点:
- JVM不会等待后台线程结束,只要所有前台线程终止,JVM会立即退出,后台线程会被强制终止。
- 需要通过
setDaemon(true)
显式设置为后台线程(必须在start()
前调用)。 - 常用于辅助任务(如垃圾回收、心跳检测等)。
- 前台进程要结束,无法阻止
- 后台进程结束,不影响前台进程
-
示例:
Thread daemonThread = new Thread(() -> {while (true) {System.out.println("后台线程持续运行");} }); daemonThread.setDaemon(true); // 设置为后台线程 daemonThread.start();
3. 操作系统中的前台进程和后台进程
- 前台进程 (Foreground Process)
- 直接与用户交互(占用终端输入/输出),会阻塞Shell直到进程结束。
- 例如:在终端直接运行
vim file.txt
,此时Shell被阻塞,无法输入其他命令。
- 后台进程 (Background Process)
- 不占用终端,在后台运行(通过
&
或bg
命令启动)。 - 例如:
python script.py &
,进程运行时用户仍可操作Shell。
- 不占用终端,在后台运行(通过
线程的核心操作
创建线程start()
- 初始状态(NEW)
- 线程对象创建后(未调用
start()
)处于NEW
状态,此时允许调用start()
。
- 线程对象创建后(未调用
- 禁止重复启动
- 一旦调用
start()
,线程状态从NEW
转为其他状态(如RUNNABLE
),再次调用start()
将抛出IllegalThreadStateException
。
- 一旦调用
一个经典的面试题:start和run之间的差别
start调用系统函数,真正再系统内核中,创建线程.
此处start,会根据不同的系统,分别调用不同的api(windows,linux,mac…)
创建好新的线程,再来单独执行run
run描述线程要执行的任务,也可以称为线程的入口
一个Thread对象,只能调用一次start.
如果多次调用start就会出现问题.
(一个Thread对象,只能对应系统中的一个线程)
线程的终止
当然可以!下面是对你关于Java线程终止与中断机制的整理与归纳,结构清晰,便于理解和复习:
Java线程终止与中断机制
协作式中断机制
- Java采用协作式中断(Cooperative Interruption),即线程终止由线程自身决定,而不是被外部强制终止。
- 其他线程只能通过调用
interrupt()
方法请求目标线程“自愿”终止。
线程中断的实现
代码示例
public static void main(String[] args) throws InterruptedException {Thread t=new Thread(()->{Thread currentThread=Thread.currentThread();while (!currentThread.isInterrupted()){System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}});t.start();Thread.sleep(3000);t.interrupt();
}
- 由于判定
isInterrupted()
和执行打印这两个操作执行速度很快,整个循环的主要时间都消耗在sleep(1000)
上。 - 当main线程调用
interrupt()
时,目标线程t大概率正处于sleep状态。 interrupt()
操作不仅能设置中断标志位,还能唤醒正在sleep的线程。例如:- 如果线程刚sleep了100ms,还剩900ms
- 此时调用
interrupt()
- sleep会立即被唤醒,并抛出
InterruptedException
异常
- 由于catch块中的默认代码会再次抛出异常,而这次抛出的异常没有被捕获,最终会传递到JVM层,导致进程异常终止。
所以需要把抛出异常改掉
public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {Thread currentThread = Thread.currentThread();while (!currentThread.isInterrupted()) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {System.out.println("执行到catch操作");break; // 或 return,确保线程能终止}}});t.start();Thread.sleep(3000);t.interrupt();
}
关键点说明
- 循环条件为
!currentThread.isInterrupted()
,即只要未被中断就继续运行。 Thread.sleep(1000)
是阻塞方法,主线程3秒后调用t.interrupt()
:- 立即唤醒
t
线程,并抛出InterruptedException
异常。 - sleep等阻塞方法被中断唤醒时,会清除中断标志位。
- 立即唤醒
- 如果在catch块里不做break/return处理,循环会继续,线程不会退出。
interrupt()方法的双重作用
作用 | 说明 |
---|---|
设置中断标志位 | 通过isInterrupted() 可检测 |
唤醒阻塞线程 | 如sleep() 、wait() 、join() 等,直接抛出InterruptedException |
为什么阻塞方法会清除中断标志位?
- 如果阻塞方法不清除中断标志位,外层循环会立即检测到中断,线程必然退出。
- 这样开发者无法灵活选择让线程继续运行或终止。
- 实际设计允许开发者在catch块中决定线程是否继续运行或退出,增加灵活性。
正确终止线程
- 不要在catch块直接抛出异常,否则会导致线程异常终止。
- 推荐在catch块中使用
break
或return
,主动退出循环,使线程正常结束。
线程的等待
线程的调度
-
操作系统对多个线程采用
随机调度
和
抢占式执行
。
- 随机调度:线程的执行顺序由操作系统决定,具有不确定性。
- 抢占式执行:操作系统可随时暂停某个线程,把CPU分配给其他线程,实现多线程“并发”效果。
线程等待与阻塞
- 假设有两个线程A和B,若需要B在线程A结束后再结束,则可以让B“等待”A。
- 这时,B会进入阻塞状态,直到A结束,B的阻塞才会解除。
Thread.join()方法
方法 | 说明 |
---|---|
public void join() | 等待目标线程结束,阻塞当前线程 |
public void join(long millis) | 最多等待 millis 毫秒,超时解除 |
public void join(long millis, int nanos) | 最多等待指定毫秒+纳秒,超时解除 |
join()
无参数时,当前线程会一直等待目标线程结束,如果目标线程迟迟不结束,当前线程会一直阻塞。
Thread.sleep()方法
Thread.sleep(time)
让调用线程阻塞指定时间,在此期间线程不会参与CPU调度。- 这样可以主动让出CPU资源,让其他线程有机会执行。
sleep
是主动等待,而join
是被动等待其他线程结束。
线程状态
进程状态
就绪:在cpu上执行,或者随时可以去cpu上执行
阻塞:暂时不能参与cpu执行
Java线程的状态
状态 | 触发条件 | 典型场景 |
---|---|---|
NEW | 线程被创建但未启动 (start() 未调用) | Thread t = new Thread(); |
RUNNABLE | 线程可运行(包括正在运行或就绪等待CPU调度) | 执行中、或等待系统资源(如CPU时间片) |
BLOCKED | 线程等待获取监视器锁(synchronized 阻塞) | 竞争同步锁时被阻塞 |
WAITING | 无限期等待其他线程唤醒(不设超时) | object.wait() 、thread.join() |
TIMED_WAITING | 有限时间等待(设定了超时) | Thread.sleep(ms) 、object.wait(timeout) 、thread.join(timeout) |
TERMINATED | 线程执行完毕 (run() 方法结束) | 线程任务完成或异常终止 |
线程安全
1. 线程安全问题的根源
private static int count=0;public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for (int i=0;i<50000;i++){count++;}});Thread t2=new Thread(()->{for (int i=0;i<50000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count="+count);
}
-
多线程并发执行时,若同时修改同一个变量,且修改操作非原子性,就容易产生线程安全问题。
-
以
count++
为例,实际由三步组成:- load:从内存读到CPU寄存器
- add:寄存器值+1
- save:写回内存
cpu调度执行线程的时候,不知道什么时候就会把线程调度走(抢占执行,随机调度)
指令是cpu执行的最基本单位,至少要把当前执行完,不会执行一半调度走
但是count++是三个指令,会出现执行几个后调度走的情况
基于上面的情况,两个线程同时对线程进行++,就容易出现bug
-
操作系统采用随机调度、抢占式执行,导致多个线程的指令可能交错执行,出现数据错乱。
2. 线程安全问题的原因
- 随机调度,抢占式执行(根本原因)
- 多线程同时修改同一个变量
- 修改操作不是原子的
- 内存可见性问题
- 指令重排序问题
3. 解决方案——加锁
- 主要思路:把非原子的操作变成原子的。
- Java通过
synchronized
关键字实现加锁,保证同一时刻只有一个线程能执行被保护的代码块。
代码示例
private static int count = 0;
private static Object locker = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + count);
}
加锁原理
- 锁对象 locker:多个线程针对同一个对象加锁时,才会产生互斥效果,保证同步。
- 若锁对象不同,则各自加锁互不干扰,仍是并发执行。
- 锁的本质是将并发执行变成串行执行,从而避免数据竞争。
为什么锁对象可以随便设置?
- 锁对象仅用于区分不同线程是否针对同一个资源加锁。
- 只要所有相关线程用的是同一个锁对象,就能实现互斥。
- 锁对象本身内容无关紧要,关键是“是不是同一个对象”。
4. 加锁与 join 的区别
操作 | 作用 |
---|---|
加锁 | 只让一小段代码串行执行,其他部分仍可并发 |
join | 让整个线程等待另一个线程结束,阻塞当前线程 |
5. Java中的加锁语法
1. synchronized代码块
synchronized (locker) {// 受保护的代码
}
2. synchronized方法
synchronized public void add() {count++;
}
- 等价于:
public void add() {synchronized (this) {count++;}
}
- 锁对象是当前实例对象
this
。
3. synchronized静态方法
synchronized public static void func() {// 静态方法
}
- 锁对象是类对象
Counter.class
,与实例无关。
等价于:
public static void func() {synchronized (Counter.class) {// 代码}
}
6. synchronized机制优势
- 操作系统原生API / C++ / Python:加锁和解锁是分开的函数调用(如
lock()
和unlock()
)。- 原生做法(分开加锁/解锁)的最大问题是
unlock()
可能执行不到(比如因异常或代码逻辑遗漏导致未解锁),从而引发死锁等问题。
- 原生做法(分开加锁/解锁)的最大问题是
- Java:通过
synchronized
关键字同时完成加锁和解锁,这种方式相对少见。- 进入代码块时自动加锁,退出代码块时自动解锁。
- 无论是通过
return
正常返回,还是因抛出异常退出,都能确保锁被释放。 - 有效避免了手动调用
unlock()
可能遗漏的问题,防止死锁风险。
7. 适用场景与注意事项
- 不是所有场景都需要加锁,无脑加锁会影响性能。
- 只有在多线程共享数据且存在竞争时才需要加锁。
- StringBuffer、Vector、Hashtable等类因“无脑加锁”不推荐使用,JDK后续可能移除。
死锁
一、死锁的定义
死锁指的是两个或多个线程在执行过程中,因争夺资源而造成一种互相等待的现象,导致所有线程都无法继续运行。
二、死锁的常见场景
1. 单线程重复加锁(可重入锁)
void func(){synchronized(this){synchronized(this){// ...}}
}
- 在Java中,上述代码不会死锁,因为
synchronized
实现了可重入锁(Reentrant Lock)。 - 原理:同一线程多次获得同一把锁时,只要是自己持有的锁,可以直接进入临界区,不会阻塞。内部通过“计数器”实现,只有最外层释放时才真正释放锁。
- 对比:C++/Python的原生锁不是可重入锁,上述代码会死锁。
2. 多线程多锁互相等待(经典死锁)
private static Object locker1 = new Object();
private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println("t1 加锁 locker1 完成");try { Thread.sleep(1000); } catch (InterruptedException e) {}synchronized (locker2) {System.out.println("t1 加锁 locker2 完成");}}});Thread t2 = new Thread(() -> {synchronized (locker2) {System.out.println("t2 加锁 locker2 完成");try { Thread.sleep(1000); } catch (InterruptedException e) {}synchronized (locker1) {System.out.println("t2 加锁 locker1 完成");}}});t1.start();t2.start();
}
- t1先锁locker1,t2先锁locker2,然后分别请求对方持有的锁,相互等待,形成死锁。
3. N线程M锁(哲学家就餐问题)
- 多个线程(哲学家)同时竞争多个锁(筷子),如果每个人都先拿左边再拿右边,极易形成循环等待,导致死锁。
三、死锁的四个必要条件(重点)
- 互斥条件:资源(锁)是互斥的,每次只能被一个线程占用。
- 不可抢占条件:资源一旦被线程占有,其他线程不能强行夺取,只能等待持有线程释放。
- 请求与保持条件:线程已经持有至少一个资源,在等待获取其他资源的同时不释放已持有的资源。
- 循环等待条件:存在一个线程—资源的循环等待链。
四个条件同时满足时,才会出现死锁。只要破坏其中任意一个条件,就可以避免死锁。
四、死锁的解决方法
避免循环等待:统一加锁顺序
一个简单有效的方法:给锁编号,1, 2, 3…N。
约定所有的线程在加锁的时候,都必须按照一定的顺序来加锁。
(比如,必须先针对编号小的锁加锁,后针对编号大的锁加锁)
代码示例(修改后无死锁):
public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println("t1 加锁 locker1 完成");try { Thread.sleep(1000); } catch (InterruptedException e) {}synchronized (locker2) {System.out.println("t1 加锁 locker2 完成");}}});Thread t2 = new Thread(() -> {// t2也先锁locker1,再锁locker2synchronized (locker1) {System.out.println("t2 加锁 locker1 完成");try { Thread.sleep(1000); } catch (InterruptedException e) {}synchronized (locker2) {System.out.println("t2 加锁 locker2 完成");}}});t1.start();t2.start();
}
-
两个线程加锁顺序一致,不会形成循环等待,不会死锁。
-
破坏请求与保持条件:每次只能申请一把锁,用完立即释放。
-
破坏不可抢占条件:如果获取不到新锁,主动释放已持有的锁,过一段时间重试(如
tryLock
机制)。 -
死锁检测与恢复:如银行家算法(实际开发中较少用,主要用于操作系统等底层场景)。
五、可重入锁(Reentrant Lock)原理
Java 为了减少程序员写出死锁的概率,引入了特殊机制,解决上述的死锁问题。“可重入锁”
对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何加锁操作,也不会进行任何的“阻塞操作”,而是直接放行,往下执行代码。
假设这里的加锁有嵌套7、8层,如何知道当前释放锁的操作是最外层需要真正释放的呢?
(类比OJ题中判定括号是否匹配→使用栈)
字符串包含多种括号。
针对该问题,可引入计数器:
- 初始计数器为0
- 每遇到
{
(加锁),计数器+1 - 每遇到
}
(释放锁),计数器-1 - 若某次-1后计数器为0,则此次释放为最外层,需真正释放锁。
引用计数
加锁时,需要判断当前锁是否已被占用。
可重入锁的实现方式是在锁中额外记录当前是哪个线程对其进行了加锁。
Java 集合类的线程安全性
线程安全的集合类 (内置了 synchronized
同步机制):
Vector
Stack
Hashtable
StringBuffer
线程不安全的集合类:
ArrayList
Queue
HashMap
TreeMap
LinkedList
PriorityQueue
注意:
- Java 中的大部分集合类都是线程不安全的。
- 如果多个线程修改同一个集合类里的数据,需要特别注意线程安全问题。
String
类是不可变的,因此是线程安全的。
内存可见性
一、什么是内存可见性问题?
内存可见性问题指的是在多线程环境下,一个线程对共享变量的修改,另一个线程无法及时“看到”最新的值,导致程序出现逻辑错误。
二、代码示例
问题代码
private static int n = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (n == 0) {// 循环等待}System.out.println("t1 线程结束循环");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入一个整数: ");n = scanner.nextInt();});t1.start();t2.start();
}
- 用户输入1后,t1线程可能依然在循环,无法跳出。
三、问题原因分析
1. JVM/编译器优化
- 在循环中,
n==0
每次都需要从内存读取n
的值,速度较慢。 - JVM/编译器为了提升效率,可能将
n
的值缓存到寄存器或CPU缓存,后续循环直接用缓存数据,不再从内存读取。 - 这样,即使其他线程修改了
n
的值,当前线程也无法感知,导致内存可见性问题。
2. 单线程与多线程区别
- 单线程下,这种优化不会影响程序逻辑。
- 多线程下,编译器/JVM的优化可能导致数据不同步,出现bug。
四、如何解决内存可见性问题?
1. 加入Thread.sleep
while (n == 0) {try {Thread.sleep(10);} catch (InterruptedException e) {throw new RuntimeException(e);}
}
- 加入
sleep
后,循环开销变大,JVM不再做缓存优化,每次都会重新读取内存,问题得到缓解。
2. 使用volatile
关键字(推荐)
private static volatile int n = 0;
volatile
告诉编译器和JVM:这个变量是易变的,禁止优化缓存,必须每次都从内存读取最新值。- 保证了多线程之间的内存可见性。
原理
- 编译器会在读/写
volatile
变量时,插入内存屏障指令(Memory Barrier),确保数据同步。 - 保证所有线程都能看到最新的值。
3. 其他方法
- 使用
synchronized
加锁,也能保证内存可见性,但会带来性能损耗。 AtomicInteger
等原子类也有类似效果。
五、编译器优化的目的与权衡
- 编译器/JVM优化是为了提升代码运行效率,但在多线程场景下可能带来数据不同步的问题。
- 通过
volatile
等关键字,程序员可以主动干预优化行为,确保多线程正确性。
wait / notify
一、为什么需要 wait/notify?
- 多线程环境下,线程的调度是随机的,有时需要控制线程之间执行某些逻辑的顺序。
- 通过
wait
和notify
,可以让线程协调配合,实现“条件满足才执行”、“先后顺序控制”等功能。 - 还可以解决线程饿死等问题。
二、wait/notify 的基本原理
wait()
和notify()
是Object
类的方法,每个对象都可以作为等待/通知的锁。- 线程调用
wait()
后,会释放锁并进入阻塞等待状态,直到被其他线程用notify()
或notifyAll()
唤醒。 - 唤醒后,线程会重新竞争锁,拿到锁后才继续执行。
三、使用方法和注意事项
1. 必须在同步(synchronized)代码块中使用
- 直接调用
wait()
或notify()
会抛出IllegalMonitorStateException
,因为只有获得锁的线程才能调用这两个方法。
synchronized (obj) {obj.wait(); // 释放锁并等待// ...被唤醒后继续执行
}synchronized (obj) {obj.notify(); // 唤醒等待在 obj 上的一个线程
}
2. wait 的三大作用
- 释放锁
- 进入阻塞等待,准备接受通知
- 收到通知后,唤醒并重新竞争锁
3. notify/notifyAll
notify()
:随机唤醒一个等待在该对象上的线程。notifyAll()
:唤醒所有等待在该对象上的线程。
四、常见错误及其原因
1. 未加锁调用 wait/notify
Object obj = new Object();
obj.wait(); // 抛出 IllegalMonitorStateException
原因:没有获得锁,不能 wait/notify。
2. wait 必须释放锁
- wait 的本质是“释放锁并等待”。如果只是 sleep,则不会释放锁,其他线程无法获得锁。
3. notify 只唤醒一个线程
-
多个线程 wait 时,notify 只唤醒一个,唤醒哪个是随机的。
-
使用 notifyAll 可以唤醒全部。
-
在 notify 中,也需要确保,先加锁,才能执行。
在多线程中,一个线程加锁,另一个线程不加锁,是无意义的,不会有任何的阻塞效果。
4. notify 没有线程等待
- 如果没有线程在 wait,notify 调用不会有任何副作用。
五、典型代码示例
1. 基本用法
public static void main(String[] args) {Object locker = new Object();Thread t1 = new Thread(() -> {synchronized (locker) {System.out.println("t1 wait 之前");try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1 wait 之后");}});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);scanner.next();synchronized (locker) {locker.notify();}System.out.println("t2 notify 之后");});t1.start();t2.start();
}
2. 多线程 wait,notify 唤醒一个
Thread t1 = ... // wait
Thread t2 = ... // wait
Thread t3 = ... // wait
Thread t4 = new Thread(() -> {Scanner scanner = new Scanner(System.in);scanner.next();synchronized (locker) {locker.notify(); // 随机唤醒一个}
});
3. notifyAll 唤醒全部
synchronized (locker) {locker.notifyAll(); // 唤醒所有等待线程
}
六、wait 的超时版本
wait(long timeout)
:等待指定时间后自动唤醒,超时未被 notify 也会继续执行。
七、原子性问题
- wait 的内部实现是原子的,即“释放锁+进入等待”是一个不可分割的操作,防止错过通知。
- 假设不是原子的,可能在释放锁之后,notify,再进入等待,然后永远等待下去
以下是关于多线程环境下单例模式实现的系统整理,涵盖设计思路、线程安全问题、优化方案及面试答题套路,条理清晰,便于学习和复习。
单例模式
一、什么是单例模式?
- 单例模式是一种常用的设计模式,保证一个类在整个进程中只有一个实例。
- 应用场景:配置信息管理器、连接池、日志系统等。
二、设计模式与框架
- 设计模式:软性约束,是代码编写的“套路”,让代码更稳健、可维护,减少 bug。
- 框架:硬性约束,大部分逻辑已实现,开发者只需填充自定义部分。
三、单例模式实现方式
1. 饿汉式(类加载时就创建实例)
class Singleton {private static Singleton instance = new Singleton();public static Singleton getInstance() {return instance;}private Singleton() {}
}
- 优点:简单,线程安全。
- 缺点:类加载时就创建实例,浪费资源(如果实例很大,但实际用不到)。
2. 懒汉式(第一次使用时才创建实例)
class SingletonLazy {private static SingletonLazy instance = null;public static SingletonLazy getInstance() {if (instance == null) {instance = new SingletonLazy();}return instance;}private SingletonLazy() {}
}
- 优点:只有用到时才创建实例,节省资源。
- 缺点:线程不安全,多线程下可能创建多个实例。
四、线程安全问题分析
- 多线程环境下,多个线程同时执行
getInstance()
,可能导致多次创建实例,违背单例原则。
五、线程安全优化方案
1. 加锁(同步方法/同步代码块)
class SingletonLazy {private static SingletonLazy instance = null;private static final Object locker = new Object();public static SingletonLazy getInstance() {if (instance == null) { // 外层判断:是否需要加锁synchronized (locker) {if (instance == null) { // 内层判断:是否需要创建实例instance = new SingletonLazy();}}}return instance;}private SingletonLazy() {}
}
- 双重检查锁定(Double-Checked Locking):
- 外层 if:避免每次都加锁,提高性能。
- 内层 if:防止多个线程同时进入创建实例。
2. 指令重排序问题
-
Java 对对象实例化的底层步骤:
- 分配内存空间
- 执行构造方法
- 将对象引用赋值给变量
-
编译器可能重排序为 1-3-2,导致另一个线程拿到未初始化完成的对象。
3. 使用 volatile 关键字
class SingletonLazy {private static volatile SingletonLazy instance = null;private static final Object locker = new Object();public static SingletonLazy getInstance() {if (instance == null) {synchronized (locker) {if (instance == null) {instance = new SingletonLazy();}}}return instance;}private SingletonLazy() {}
}
- volatile 的作用:
- 保证内存可见性
- 禁止指令重排序(确保对象初始化顺序正确)
六、面试答题套路
- 先写最简单的懒汉式单例(不考虑线程安全)。
- 面试官追问:线程安全吗?
- 回答:不安全,多线程下可能创建多个实例。
- 加锁(同步方法或同步代码块)。
- 面试官追问:性能如何?
- 回答:加锁后性能下降(每次都加锁)。
- 优化为双重检查锁定(减少不必要的加锁)。
- 面试官追问:还有问题吗?
- 回答:可能存在指令重排序问题。
- 加上 volatile,彻底解决多线程下的单例模式。
阻塞队列
一、阻塞队列是什么?
- 在普通队列(如 FIFO 队列)基础上,增加了线程安全和阻塞特性。
- 线程安全:多个线程并发访问队列时不会出现数据错乱。
- 阻塞特性:
- 队列为空时,出队操作会阻塞,直到有元素入队。
- 队列为满时,入队操作会阻塞,直到有元素出队。
二、应用场景:生产者-消费者模型
1. 生产者-消费者模型
- 生产者线程不断生产数据,放入队列。
- 消费者线程不断从队列取数据进行处理。
- 队列为空时,消费者阻塞;队列满时,生产者阻塞。
2. 分布式系统中的应用
- 多台服务器之间解耦合:A服务器只和队列通信,B服务器也只和队列通信,彼此不知道对方存在。
- 削峰填谷:应对流量激增,保护下游服务器不被冲垮。
3. 消息队列
- 阻塞队列的思想被广泛应用于消息队列(如RabbitMQ、Kafka等),用于服务间通信、异步处理。
三、标准库实现:BlockingQueue
1. BlockingQueue 的主要方法
put(E e)
:将元素放入队列,如果队列满则阻塞。take()
:从队列取元素,如果队列空则阻塞。- 这两个方法可能会抛出
InterruptedException
。
2. 代码示例
public static void main(String[] args) {BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1000);// 生产者线程Thread producer = new Thread(() -> {int i = 1;while (true) {try {queue.put(i);System.out.println("生产元素 " + i);i++;} catch (InterruptedException e) {throw new RuntimeException(e);}}});// 消费者线程Thread consumer = new Thread(() -> {while (true) {try {Integer item = queue.take();System.out.println("消费元素 " + item);Thread.sleep(1000); // 模拟消费速度} catch (InterruptedException e) {throw new RuntimeException(e);}}});producer.start();consumer.start();
}
四、自定义阻塞队列实现
1. 基本原理
- 基于数组实现循环队列。
- 用
synchronized
保证线程安全。 - 用
wait
/notify
实现阻塞和唤醒。
2. 代码示例
class MyBlockingQueue {private String[] data;private volatile int head = 0;private volatile int tail = 0;private volatile int size = 0;public MyBlockingQueue(int capacity) {data = new String[capacity];}public void put(String s) throws InterruptedException {synchronized (this) {while (size == data.length) { // 队列满,阻塞this.wait();}data[tail] = s;tail++;if (tail >= data.length) {tail = 0;}size++;this.notify();}}public String take() throws InterruptedException {String ret;synchronized (this) {while (size == 0) { // 队列空,阻塞this.wait();}ret = data[head];head++;if (head >= data.length) {head = 0;}size--;this.notify();}return ret;}
}
五、实现细节与面试要点
1. 循环队列的写法
- 取模运算:
tail = (tail + 1) % data.length;
- 条件判断:
tail++; if (tail >= data.length) tail = 0;
- 推荐:条件判断更直观,可读性好,执行效率高(避免除法运算)。
2. wait/notify 的使用细节
- 建议用 while 而不是 if,防止虚假唤醒(Spurious Wakeup)。
- wait 可能因 notify、notifyAll、interrupt 或虚假唤醒被唤醒。
3. InterruptedException
- put/take 方法可能抛出 InterruptedException,需正确处理。
4. volatile 的作用
- 队列指针和计数器加 volatile,保证多线程下的可见性。
六、阻塞队列的优缺点
优点
- 解耦合:生产者和消费者逻辑分离,代码更易维护和扩展。
- 削峰填谷:应对流量突发,保护下游服务。
缺点
- 增加服务器部署和硬件成本。
- 增加通信延迟,不适合对响应时间要求极高的场景。
七、面试答题套路
- 先说 BlockingQueue 的标准用法(put/take)。
- 能说出生产者消费者模型的应用场景(解耦合、削峰填谷)。
- 能自己手写一个阻塞队列(synchronized + wait/notify)。
- 能说出 while 防虚假唤醒,volatile 保证可见性,异常处理细节。
- 能分析优缺点,适用场景。
以下是关于线程池的系统整理,涵盖原理、标准库用法、参数详解、工厂设计模式、拒绝策略、优化建议、自定义实现、面试答题套路等,条理清晰,便于学习和复习。
线程池
一、为什么要用线程池?
- 线程池本质上是提前创建好一批线程,后续需要执行任务时直接复用这些线程,而不是频繁创建/销毁线程。
- 优势:
- 避免频繁创建/销毁线程的系统开销。
- 统一管理线程资源,提升系统性能和稳定性。
- 控制最大并发线程数,防止系统资源耗尽。
- 支持任务排队,便于实现生产者-消费者模型。
二、线程池的底层原理
- 线程池维护一组工作线程和一个任务队列。
- 用户提交任务(Runnable/Callable),线程池从队列取任务分配给空闲线程执行。
- 用完的线程不会销毁,而是继续等待下一个任务。
效率对比:
- 从线程池取线程是用户态操作,速度快、可控。
- 直接创建线程需要内核参与,开销大,效率低。
为什么从线程池里取线程,比从系统申请,来的更高效?
内核态与用户态
- 内核态:操作系统的核心部分,负责管理系统资源和执行关键任务。
- 用户态:应用程序运行的状态,用户代码在此执行,直接与内核交互。
操作系统由内核和配套的应用程序组成,内核负责管理和服务所有应用程序的请求。
线程创建过程
从系统申请线程
- 通过系统API创建新线程时,涉及到内核态的操作。
- 系统内核需要执行一系列复杂的逻辑来分配资源、设置线程状态等。
- 这个过程可能会引起上下文切换,增加了系统负担和延迟。
从线程池获取线程
- 从线程池中获取线程的过程完全在用户态中完成。
- 线程池预先创建了一定数量的线程,避免了频繁的线程创建和销毁。
- 整个过程是可控的,不涉及内核的复杂逻辑,减少了上下文切换。
效率对比
- 控制性:使用线程池,开发者可以更好地控制线程的生命周期和资源分配。
- 性能:纯用户态操作通常比内核态操作效率更高,因为用户态不需要频繁切换到内核态,减少了系统调用的开销。
- 资源管理:线程池管理线程的创建和复用,降低了系统资源的消耗,提升了应用程序的响应速度。
三、Java标准库中的线程池
1. ThreadPoolExecutor(核心类)
构造方法参数详解(经典面试题):
ThreadPoolExecutor(int corePoolSize, // 核心线程数int maximumPoolSize, // 最大线程数long keepAliveTime, // 非核心线程最大空闲时间TimeUnit unit, // 时间单位BlockingQueue<Runnable> workQueue, // 任务队列ThreadFactory threadFactory, // 线程工厂RejectedExecutionHandler handler // 拒绝策略
)
参数说明
- corePoolSize:核心线程数,线程池始终保有的最少线程数。
- maximumPoolSize:最大线程数,线程池允许的最大线程数量(包括核心+非核心)。
- keepAliveTime & unit:非核心线程空闲多久后被销毁。
- workQueue:任务队列,通常是阻塞队列(如 ArrayBlockingQueue),决定任务排队方式和容量。
- threadFactory:线程工厂,用于定制线程属性(如名字、优先级等),一般用默认即可。
- handler:拒绝策略,任务无法处理时的应对措施(标准库提供4种)。
拒绝策略(RejectedExecutionHandler)
- AbortPolicy:直接抛出异常(默认)。
- CallerRunsPolicy:由提交任务的线程自己执行任务。
- DiscardPolicy:直接丢弃任务,不抛异常。
- DiscardOldestPolicy:丢弃队列中最旧的任务,尝试提交新任务。
2. Executors 工厂类(简化版)
- newFixedThreadPool(n):固定大小线程池。
- newCachedThreadPool():可扩容线程池,线程数无限制。
- newSingleThreadExecutor():单线程池,任务串行执行。
- newScheduledThreadPool(n):定时/周期任务线程池。
- newWorkStealingPool():工作窃取线程池。
3. 线程池用法示例
public static void main(String[] args) {ExecutorService service = Executors.newFixedThreadPool(4);for (int i = 0; i < 100; i++) {int id = i;service.submit(() -> {System.out.println("hello thread " + id + ", " + Thread.currentThread().getName());});}Thread.sleep(2000); // 等待任务执行完service.shutdown(); // 关闭线程池System.out.println("程序退出");
}
- 线程池默认创建的是前台线程,主线程结束后线程池仍在运行。
- 使用
shutdown()
正确关闭线程池,防止资源泄漏。
四、线程池参数如何设置?
- 线程数多少合适?
- 受限于主机CPU核心数、内存、其他资源。
- 任务类型不同,最佳线程数不同:
- 纯计算型任务:线程数 ≈ CPU核心数。
- IO密集型任务:线程数可略多于CPU核心数(因为线程会主动让出CPU)。
- 推荐通过实际测试、性能监控,选取最优线程数。
五、工厂设计模式(ThreadFactory)
-
工厂设计模式用于解决构造方法不灵活的问题。
构造方法,特殊方法. 必须和类名一样
多个版本的构造方法,必须是通过“重载”(overload)
class Point { public Point(double x, double y) { .... } public Point(double r, double a) { .... } }
这两方法没办法构成重载
使用构造方法创建实例,就会存在上述局限性
为了解决上述问题,引入了"工厂设计模式"
通过"普通方法"(通常是静态方法)完成对象构造和初始化的操作
class Point { public static Point makePointByXY(double x, double y) { Point p; p.setX(x); p.setY(y); return p; }public static Point makePointByRA(double r, double a) {Point p;p.setR(r);p.setA(a);return p;} }
-
通过静态方法/工厂类创建实例,便于定制初始化逻辑。
-
线程池中的 ThreadFactory 用于批量定制线程属性。
六、自定义线程池实现
class MyThreadPool {private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);public MyThreadPool(int n) {for (int i = 0; i < n; i++) {Thread t = new Thread(() -> {while (true) {try {Runnable runnable = queue.take();runnable.run();} catch (InterruptedException e) {throw new RuntimeException(e);}}});t.start();}}public void submit(Runnable runnable) {try {queue.put(runnable);} catch (InterruptedException e) {throw new RuntimeException(e);}}
}public class Demo31 {public static void main(String[] args) {MyThreadPool pool = new MyThreadPool(4);for (int i = 0; i < 1000; i++) {int id = i;pool.submit(() -> {System.out.println("执行任务 " + id + ", " + Thread.currentThread().getName());});}}
}
- 仅实现了任务提交和执行,未实现线程池销毁(可通过
interrupt
等方式扩展)。
七、面试答题套路
- 为什么要用线程池?(性能、资源控制、任务排队)
- ThreadPoolExecutor参数含义及作用。
- 拒绝策略有哪些?各自适用场景。
- 如何设置线程池线程数?(计算型 vs IO型任务)
- Executors与ThreadPoolExecutor的区别,为什么推荐后者?
- 能否手写一个简单线程池?
- shutdown/资源释放细节。
- 工厂设计模式在线程池中的作用。
以下是关于定时器(Timer)实现原理与优化的系统整理,涵盖标准库用法、定时器任务管理的数据结构选择、线程安全与性能优化、业界经典实现、面试答题套路等,便于学习和复习。
定时器(Timer)
一、标准库定时器用法
public static void main(String[] args) {Timer timer = new Timer();timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("hello");}}, 3000); // 延迟3秒执行System.out.println("程序开始运行");
}
- 可以安排多个任务,任务类继承自
TimerTask
。 schedule
方法的参数是延迟时间(delay)。
二、定时器的实现原理
1. 定时任务的描述
- 每个任务需包含任务内容和任务的执行时间(建议用绝对时间戳,而不是delay)。
2. 定时任务的管理
- 需要一个数据结构存储所有待执行任务。
- 任务调度线程负责不断检查任务列表,将到时的任务执行掉。
三、数据结构选择:为什么不用 List?
问题:
- 用 List 保存任务,查找最早要执行的任务时,需遍历整个列表,效率低。
- 删除已执行任务也很繁琐。
解决方案:
- 用优先队列(PriorityQueue)或堆(Heap),按任务的执行时间排序。
- 这样每次只需取队头元素,效率高(O(1)取最早任务,O(logN)插入/删除)。
四、自定义定时器实现(代码示例)
1. 定时任务类
class MyTimerTask implements Comparable<MyTimerTask> {private Runnable runnable;private long time; // 绝对时间戳public MyTimerTask(Runnable runnable, long delay) {this.runnable = runnable;this.time = System.currentTimeMillis() + delay;}public void run() { runnable.run(); }public long getTime() { return time; }@Overridepublic int compareTo(MyTimerTask o) {return Long.compare(this.time, o.time); // 小堆}
}
2. 定时器类
class MyTimerTask implements Comparable<MyTimerTask> {private Runnable runnable;// 这里的 time, 通过毫秒时间戳,表示这个任务具体啥时候执行private long time;public MyTimerTask(Runnable runnable,long delay){this.runnable=runnable;this.time=System.currentTimeMillis()+delay;}public void run(){runnable.run();}public long getTime(){return time;}@Overridepublic int compareTo(MyTimerTask o) {// 此处这里的 - 的顺序,就决定了这是大堆还是小堆return (int) (this.time-o.time);}
}class MyTimer{private PriorityQueue<MyTimerTask> queue= new PriorityQueue<>();public MyTimer(){// 创建线程,负责执行上述队列中的内容Thread t = new Thread(()->{while (true){if (queue.isEmpty()){continue;}MyTimerTask current = queue.peek();if (System.currentTimeMillis()>=current.getTime()){current.run();queue.poll();}else{continue;}}});t.start();}public void schedule(Runnable runnable,long delay){MyTimerTask myTimerTask=new MyTimerTask(runnable,delay);queue.offer(myTimerTask);}
}
这里的代码有两个问题
while (true){if (queue.isEmpty()){continue;}
-
初始情况下,如果队列中,没有任何元素
此处的逻辑,就会在短时间内进行大量的循环
这些循环的,都是没什么意义的
一直在争抢锁,类似于线程的饿死
if (System.currentTimeMillis()>=current.getTime()){current.run();queue.poll();
}else{continue;
}
-
假设队列中,已经包含元素了
12:00 执行,现在是10:45
就会一直反复循环检查是否是12:00,没有什么意义
改进后
class MyTimerTask implements Comparable<MyTimerTask> {private Runnable runnable;// 这里的 time, 通过毫秒时间戳,表示这个任务具体啥时候执行private long time;public MyTimerTask(Runnable runnable,long delay){this.runnable=runnable;this.time=System.currentTimeMillis()+delay;}public void run(){runnable.run();}public long getTime(){return time;}@Overridepublic int compareTo(MyTimerTask o) {// 此处这里的 - 的顺序,就决定了这是大堆还是小堆return (int) (this.time-o.time);}
}class MyTimer{private PriorityQueue<MyTimerTask> queue= new PriorityQueue<>();private Object locker = new Object();public MyTimer(){// 创建线程,负责执行上述队列中的内容Thread t = new Thread(()->{});t.start();}public void schedule(Runnable runnable,long delay){synchronized (locker){MyTimerTask myTimerTask=new MyTimerTask(runnable,delay);queue.offer(myTimerTask);locker.notify();}}
}
每次来新的任务,都会把wait唤醒,重新设定等待时间
3. 使用示例
public class Demo33 {public static void main(String[] args) {MyTimer myTimer = new MyTimer();myTimer.schedule(() -> System.out.println("hello 3000"), 3000);myTimer.schedule(() -> System.out.println("hello 2000"), 2000);myTimer.schedule(() -> System.out.println("hello 1000"), 1000);System.out.println("程序开始执行");}
}
五、线程安全与性能优化
- 优先队列本身不线程安全,需用锁(synchronized)保护。
- 调度线程和添加任务线程要协作,使用 wait/notify 实现阻塞和唤醒。
- 性能优化点:
- 只需唤醒到最近任务的时间点,无需频繁循环检查。
- 队列空时调度线程阻塞,省CPU。
- 每次新任务加入,唤醒调度线程,重新计算等待时间。
六、定时器的局限与扩展
- 单线程调度:所有任务串行执行,若某任务耗时长,会影响后续任务执行时间。
- 可通过多线程并发执行任务提升吞吐量。
- 死锁风险:如果设计成多把锁,需注意加锁顺序,避免死锁。
- 调试难点:多线程程序不建议用断点调试,推荐打日志分析。
七、面试答题套路
- 定时器的核心原理是什么?(任务+调度线程+任务队列)
- 为什么不用 List?为什么选 PriorityQueue?
- 如何实现线程安全和高效唤醒?
- 如果任务很多,如何优化调度效率?
- 优先队列和时间轮的区别和适用场景?
- 代码细节:wait/notify、锁的使用、任务时间戳的设计。
- 多线程调度的潜在问题及解决方案。