文章目录
- 单例模式
- 阻塞队列
- 线程池
-
- 什么是线程池
- ThreadPoolExecutor
- 常考面试题:ThreadPoolExecutor构造函数的参数的意思
-
- [int corePoolSize 核心线程数量](#int corePoolSize 核心线程数量)
- [int maximumPoolSize 最大线程数](#int maximumPoolSize 最大线程数)
- [long keepAliveTime 非核心线程最大空闲时间](#long keepAliveTime 非核心线程最大空闲时间)
- [TimeUnit unit 规定时间单位](#TimeUnit unit 规定时间单位)
- [BlockingQueue<Runnable> workQueue工作队列](#BlockingQueue<Runnable> workQueue工作队列)
- [ThreadFactory threadFactory 工厂模式](#ThreadFactory threadFactory 工厂模式)
- [RejectedExecutionHandler handler 拒绝策略★★](#RejectedExecutionHandler handler 拒绝策略★★)
- Executors
- 自己实现一个线程池
- 定时器
这里是@那我掉的头发算什么
刷到我,你的博客算是养成了😁😁😁
单例模式
设计模式
设计模式是软件开发中针对反复出现的问题总结出的可复用解决方案,它是一套经过验证的、优化的设计思想和实践经验,而非具体代码。其核心目标是提高代码的复用性、可维护性、可扩展性,并降低模块间的耦合度。
简单来说,设计模式是一种"经验",并不是具体的代码,也不是强制要求格式的"框架",可以自己选择用不用模式。
单例模式就是设计模式的一种。
为什么使用单例模式
首先,校招的时候它经常考,嘻嘻🤭。
单例模式:强制要求某个类,在某个程序中,只有唯一一个实例(不允许被多次new)。
只有一个示例这样的要求在开发中是一种常见的场景。在编程中,实例的含义往往有很多。比如一个服务器可以称为是一个实例,一个服务器中运行的一个程序,也可以成为一个实例。当然,创建出来的一个对象当然也可以称为一个实例。
典型场景:JDBC(java database connection)
JDBC 的基本代码流程
创建DataSource(记录数据库服务的连接信息)
调用dataSource.getConnection()建立数据库连接
用Statement或PreparedStatement拼装 SQL 语句
执行 SQL:通过execute/executeQuery(查询)/executeUpdate(增删改)方法
用ResultSet(迭代器)遍历查询结果集
关闭资源(顺序:ResultSet → Statement → Connection)
JDBC 第一步要创建的DataSource(用于存储数据库信息:url、user、password),正好适合作为单例(只需一个实例统一管理数据库配置)。因为数据库只有一个,即使搞再多的这个对象也没有任何意义。
再次回到定义上来:单例模式是强制要求一个类不能创建多个对象,这份强制,并不是靠程序员记住别多创建,而是靠程序和编程使得这个类就是只能创建一个对象。这样的编程技巧有两个:懒汉模式,饿汉模式。
饿汉模式
总体框架:

首先,使用private修饰构造方法,使得类外无法通过构造函数实例化对象。但是内部又创建了一个类对象,并且这个类对象是静态的,也提供了一个静态的方法来获取到这个类对象。因为静态成员变量在类加载的时候就会被创建,且是所有类对象共有的成员变量,所以成为了唯一的实例。
不过感觉直接把这个唯一的实例设置成public,把getInstance方法删除,直接用类名也可以拿到实例。
总之,此时类外的一切使用new得到新实例的代码都会报错

你用"两个对象"来同时调用getInstance方法得到的仍然是一个实例


何为"饿"?
饿汉模式的饿的意思是:急迫。也就是说尽早地把实例创建出来。与之相对应,懒汉模式我们可以推断就是晚一点把实例创建出来。
懒汉模式
我们在使用小说软件时,当我们翻页时,页面会暂时空白一下下,随后加载出来。
我们在火车/高铁上刷抖音时,进入隧道会没网,但是你往下再刷几个视频可能不会有影响,但是多刷几个就会发现卡住不动了。
我们在玩我的世界的时候,如果你使用tp指令tp到一个你从来没去过的地方,tp一瞬间方块是没有加载的,需要等待一段时间加载区块。
以上这三个常见的生活场景其实都是运用了"懒汉模式":什么时候需要,什么时候加载。
反之,如果使用饿汉模式,打开小说软件,把一本书都加载好要花很长时间,页面会一直卡住,而且看一次小说也看不了那么多。用抖音,就想刷一个视频打发时间,结果光加载后面的视频就把内存占满了。我的世界要是一次性加载所有的区块,估计显卡炸个几百次都不够。
所以,本着什么时候需要什么时候加载,懒汉模式随之而来。
java
class SingleTon2{
private static SingleTon2 instance = null;
public static SingleTon2 getInstance(){
if(instance == null){
instance = new SingleTon2();
}
return instance;
}
private SingleTon2(){
}
}
懒汉模式下,第一次创建实例是在第一次调用getInstance方法时,并不是加载类的时候了。
不过,不知道大家还记不记得我们之前学的反射这一内容,它可以通过类名拿到目标类的私有的构造方法,通过这种方式确实可以打破懒汉饿汉模式的规则。
线程安全与单例模式
由于本章节的大标题是多线程,谈到多线程就要谈到线程安全。那么单例模式下是否满足线程安全呢?
首先,这两种模式唯一有可能发生线程安全问题的部分就是getInstance代码部分:

首先,饿汉模式是读操作,读操作不会发生线程安全问题,而懒汉模式就会涉及到写操作了,确实可能导致线程不安全。
比如:

如果是上面这种逻辑,就会出现两个线程都认为此时instance没有被初始化,结果整出了两个实例:实例A ,实例B。不过,假如先实例化的是实例A,后实例化的是实例B。最终确实实例B会覆盖掉实例A,实例A最终会被GC回收。但是首先出现两个实例已经打破单例模式的规则了,其次实际应用中,我们创建出来的实例可能消耗很大(比如是一个服务器),所以我们必须要关注线程安全问题。
java
package Thread11_17;
class SingleTonLazy{
private static SingleTonLazy instance = null;
public synchronized static SingleTonLazy getInstance(){
if(instance == null){
instance = new SingleTonLazy();
}
return instance;
}
private SingleTonLazy(){
}
}
public class demo25 {
}
加了锁之后,在判断instance时只有一个线程可以拿到锁,不会出现同时两个线程进入的错误。
加锁引发的问题
此时的程序确实不会涉及到线程安全问题,因为每次操作都是判断(仅仅是读操作)+return 。但是安全性问题没了,我们要开始考虑效率问题,只有第一次实例化的时候才会满足if条件引发线程安全问题,后面不会有问题。那么此时这个锁加着没用不说,如果线程多还会造成阻塞,而且就算线程不多这个时间消耗也是白消耗。所以,我们考虑优化代码。
java
package Thread11_17;
class SingleTonLazy{
private static Object locker = new Object();
private static SingleTonLazy instance = null;
public static SingleTonLazy getInstance(){
if(instance == null){
synchronized (locker){
if(instance == null){
instance = new SingleTonLazy();
}
}
}
return instance;
}
private SingleTonLazy(){
}
}
public class demo25 {
}
如果instance不为null,被实例化过了,那就直接返回就好了,别再拿锁释放锁了,大大增加了效率。
指令重排序
不过,就算优化了,这个代码可能还会有问题。

其实这句简单的实例对象的代码可以分为三个部分:
1.在内存中开辟一个内存空间
2.在内存空间上初始化构造对象
3.将内存空间的首地址赋值给对象
正常来说是按照1 2 3 的顺序来进行的,但是jvm可能会把顺序优化为1 3 2。
在单线程逻辑中1 2 3 和 1 3 2的顺序其实没什么不同,并且在单线程中,先执行步骤 3(让instance指向内存地址),再慢慢执行步骤 2(初始化对象),最终结果和 "先 2 后 3" 完全一致(因为单线程中后续代码必须等步骤 2 完成后才会使用instance)。这种情况下,先完成步骤 3 可以让instance的赋值操作 "提前",避免 CPU 在等待步骤 2 完成时闲置,从而提升效率。
不过在多线程中,这样的重排序却有可能导致线程不安全。比如说当线程A执行到1 3 时,此时没有执行2,也就是说instance并没有被真正的初始化(构造),然而,因为步骤3将内存地址赋值给了对象,此时的对象由Null变为Not Null。这个时候如果另一个线程B开始执行,在第一步判断if(instance == null)时就会直接跳过执行return instance。但是我们知道,这个时候的instance没有被初始化,如果此时B拿着这个没初始化的对象去执行任务,程序就会乱了套。
那么,如果想要解决这个问题,就要使用volatile关键字--这个我们在前面解决内存可见性问题提到过.
volatile的作用有两个方面:
1.保证每次读操作都是读取内存
2.关于该变量的读取和修改操作,不会造成重排序

阻塞队列
什么是阻塞队列
阻塞队列是一个优先级队列。
阻塞队列是线程安全的,并且满足:
当队列满的时候,继续入队列会阻塞,直到有其他线程从队列中取走元素。
当队列空的时候,继续出队列会阻塞,知道有其他线程向队列中传入元素。
阻塞队列的一个常见的应用场景就是"生产者消费者模型"
生产者消费者模型
此模型的主要逻辑是:生产者负责生产数据,将生产后的数据放到一个容器内,消费者负责使用数据,使用的数据都从这个容器里面拿,这个容器就是阻塞队列。
相较于直接交互,生产者消费者模型的优点如下:
1.解耦合。
生产者不必过问消费者是如何消费的,它只需要把数据生产出来即可,消费者同理。如果消费者或者生产者被替换了,与之对应的生产者或者消费者相关的逻辑不用发生改变。
2."削峰填谷"
我们说的这个生产者消费者并不一定指的是两个线程,也有可能指的是两个服务器。在实际开发中,作为生产者的服务器如果在某一时刻接收到了大量的用户请求,可以通过阻塞队列将请求慢慢的传给消费者,防止请求过多使得服务器崩溃。一般来说作为生产者的服务器处理的任务更简单,单个请求消耗少,作为消费者的服务器消耗大,一般在实际情况下作为消费者的服务器配置也更好一点,但是也很难保证她能够承担更多的访问量。
缺点:
1.引入队列后,整体的结构会更复杂,此时就需要更多的机器进行部署,生产环境的结构会更复杂,管理起来更麻烦
2.效率肯定不如直接交互快。
BlockingQueue
java中将阻塞队列封装成了一个BlockingQueue的接口,通过这个接口建立不同实现方式的阻塞队列。


有很多种入队列出队列的方法,但是只有put和take有阻塞的功能

java
package Thread11_17;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class demo26 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(100);
for (int i = 0; i < 100; i++) {
blockingQueue.put("aaa");
}
System.out.println("===========");
blockingQueue.put("aaa");
}
}


比如上述代码,当阻塞队列被塞满之后,程序便被阻塞在第一百零一次操作处,此时程序没有结束,处于waiting状态。
如果不用put的话,就会触发异常而非阻塞:
java
package Thread11_17;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class demo26 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(100);
for (int i = 0; i < 100; i++) {
blockingQueue.add("aaa");
}
System.out.println("===========");
blockingQueue.add("aaa");
}
}

如果建立阻塞队列时没有传入参数作为容量,那么容量默认是一个很大的值,一般在使用时建议设置一个容量,防止队列容量过大导致不必要的消耗。
ArrayBlockingQueue要求必须传参,LinkedBlockingQueue可以不传参,不传参时默认是:


21亿左右
阻塞队列模拟生产者消费者模型
直接运行,生产者消费者生产速度,旗鼓相当,很难看到阻塞效果。
java
package Thread11_17;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class demo27 {
public static void main(String[] args) {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(1000);
Thread t1 = new Thread(()->{
int n = 0;
while(true){
try {
blockingQueue.put(n);
System.out.println("生产者生产了数据"+n);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
n++;
}
});
Thread t2 = new Thread(()->{
while(true){
try {
int ret = blockingQueue.take();
System.out.println("消费者消费了一个数据" + ret);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
}
}

我们在生产者或者消费者线程中加sleep,可以让结果变得更加直观:




模拟实现BolckingQueue
模拟的时候主要要注意对线程安全问题的处理,增加阻塞操作。
java
class MyBlockingQueue{
private String[] data = null;
private int head = 0;
private int tail = 0;
private int size = 0;
public MyBlockingQueue(int capacity){
data = new String[capacity];
}
public Boolean put(String str) throws InterruptedException {
synchronized (this){
while(size == data.length){
//return false;
this.wait();
}
data[tail] = str;
tail = (tail + 1) % data.length;
size++;
this.notify();
return true;
}
}
public String take() throws InterruptedException {
synchronized (this){
while(size == 0){
this.wait();
}
String ret = data[head];
head = (head + 1)% data.length;
size--;
this.notify();
return ret;
}
}
}


为什么用while而不是用if?
一般来说这里用if就可以,用while是为了二次检验 :wait阻塞不一定是被notify唤醒,如果被interruped打断强制性唤醒,此时队列仍然是满(空),应该再检验一次条件再继续往下运行。
更极端情况:三个线程put时都被阻塞了,此时有了一个take操作,取出一个数据并且执行到notify,此时会唤醒一个put线程,这个线程将put执行完毕后,最后也会执行notify ,此时可能就会把阻塞的那两个给唤醒,如果此时没有二次检验,也会出问题。
用自己实现的阻塞队列处理模拟实现生产者消费者模型:
java
public class demo28 {
public static void main(String[] args) {
MyBlockingQueue queue = new MyBlockingQueue(1000);
Thread producer = new Thread(() -> {
int n = 0;
while (true) {
try {
queue.put(n + "");
System.out.println("生产元素 " + n);
n++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread consumer = new Thread(() -> {
while (true) {
String n = null;
try {
n = queue.take();
System.out.println("消费元素 " + n);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
producer.start();
consumer.start();
}
}

线程池
什么是线程池
在前面的博客中我们谈到过字符串常量池,字符串常量在java最初构建的时候就已经准备好了,常量就加载到内存中了,省去了创建和销毁的开销。
线程池的作用也是如此。还记得一开始引入线程的原因是进程创建和销毁的开销太大了,线程的开销小一点。但是随着需求的增加,线程的开销虽然没变,但是我们想要更高的性能,开始想办法减少开销,线程池就是一个方法。
线程池相当于把线程提前创建好,放到一个地方(类似于数组),当需要使用的时候就去这里取出来,用完了再放回去。
ThreadPoolExecutor
java标准库里提供了可以直接使用的线程池ThreadPoolExecutor。
核心方法:submit
通过Runnable描述一个任务,通过submit把任务放到线程池中,此时线程池里的线程会执行这个任务。
但是这个类的构造方法比较麻烦,因为他的参数特别多。


常考面试题:ThreadPoolExecutor构造函数的参数的意思
int corePoolSize 核心线程数量
核心线程在线程池创建时随之创建,直到整个线程池销毁,他们才会销毁。
int maximumPoolSize 最大线程数
线程池里的线程分为核心线程+非核心线程,非核心线程的数目是自适应的,繁忙的时候就多创建几个,不忙的时候就销毁。
long keepAliveTime 非核心线程最大空闲时间
非核心线程的数目是动态的,如果超过了要求的时间之内这个线程什么活儿都没干,说明确实不需要这个线程了,就可以把这个线程销毁了。
TimeUnit unit 规定时间单位
TimeUnit是一个枚举类:

unit使用时是和keepAliveTime配合使用的,keepAliveTime规定时间,unit规定时间的单位,比如一个传入10,一个传入TimeUnit.SECONDS就代表最大空闲时间是10秒。

BlockingQueue workQueue工作队列
线程池其实本质也是生产者消费者模型,调用submit其实就是在生产任务,线程池里的线程就是在消费任务。传入的阻塞队列可以指定容量以及比较规则等。
ThreadFactory threadFactory 工厂模式
工厂模式,也是一种设计模式,与单例模式是一种并列关系。是用来弥补构造方法的缺陷的。
比如说,我们想生成一个类,用来表示坐标系上某一个点的坐标,就拿二维坐标系来说,在构造方法时,只需要传入横纵坐标两个参数即可。不过,还有一种表示坐标的方法是极坐标系,比如x = r * cos(a), y = r * sin(a)。但是此时构造方法就会出一些问题:

我们知道方法是可以重载的,但是重载的前提是方法名一样,返回类型或者参数应该不一样才可以。首先构造方法返回类型肯定是固定的,当我们传入相同类型的参数时,此时没法构成重载,会产生报错。
java
class Point{
//省略一些属性
}
class PointFactory{
public static Point makePointXy(double x,double y){
Point point = new Point();
//对x y给point进行属性设置
return point;
}
public static Point makePointRa(double r,double a){
Point point = new Point();
//用r a对point进行属性设置
return point;
}
}
工厂模式的核心是通过静态方法把构造对象实例化的过程封装起来,可以提供多种方法实现不同情况的构造。
这些方法称为工厂方法,提供工厂方法的类称为工厂类(PointFactory)
RejectedExecutionHandler handler 拒绝策略★★
我们知道线程池任务队列是阻塞队列,当队列满了之后,再添加,就会发生阻塞,我们肯定不希望阻塞太多线程。一般来说,调用submit的也是线程,这个线程要响应用户的请求,如果这个线程阻塞了,用户就迟迟拿不到请求的响应,直观上来说是"卡了"。与其是卡了其实倒不如程序告诉我失败了。所以对于线程池来说,队列满的时候,再次入队列,不会触发阻塞,而是执行拒绝策略相关的代码。
RejectedExecutionHandler是一个接口,通过它我们可以实例化四种拒绝策略。

AbortPolicy:线程池直接抛出异常:线程池可能无法继续工作了。
CallerRunsPolicy:线程池不接收新任务,调用submit传入任务的时候让调用submit的线程自行执行任务。
DiscardOldestPolicy:丢弃队列中最老的任务
DiscardPolicy:丢弃队列中最新的任务:就是当前submit的这个任务
Executors
因为ThreadPoolExecutor太过麻烦,需要传入的参数太多,所以java对其进一步封装,简化线程池的使用。
Executors 提供的常用方法如下,每种方法对应一种特定类型的线程池:
- newFixedThreadPool(int nThreads)
创建一个固定线程数的线程池。
特点:
核心线程数 = 最大线程数 = nThreads(线程数固定);
空闲线程不会被回收(keepAliveTime = 0);
任务队列是 LinkedBlockingQueue(无界队列,容量为 Integer.MAX_VALUE)。
适用场景:任务数量已知、且需要长期稳定运行的场景(如服务器处理固定数量的并发任务)。
注意:无界队列可能导致任务堆积过多,耗尽内存(OOM)。 - newCachedThreadPool()
创建一个可缓存的线程池。
特点:
核心线程数 = 0,最大线程数 = Integer.MAX_VALUE(理论上可创建无限线程);
空闲线程存活时间为 60 秒(超时后回收);
任务队列是 SynchronousQueue(无容量,提交任务时必须有线程接收,否则立即创建新线程)。
适用场景:处理大量短期、轻量级任务(如临时的网络请求处理)。
注意:最大线程数过大,可能因创建过多线程导致 CPU 过载或 OOM。 - newSingleThreadExecutor()
创建一个单线程的线程池。
特点:
核心线程数 = 最大线程数 = 1(仅一个线程执行任务);
任务队列是 LinkedBlockingQueue(无界队列);
所有任务按提交顺序串行执行(避免并发问题)。
适用场景:需要保证任务顺序执行的场景(如日志写入、单线程处理的事务)。
与 newFixedThreadPool(1) 的区别:前者返回的线程池不可修改线程数(即使强制转换为 ThreadPoolExecutor 也会报错),后者可以通过修改参数调整线程数,因此前者更安全。 - newScheduledThreadPool(int corePoolSize)
创建一个支持定时 / 周期性任务的线程池。
特点:
核心线程数为 corePoolSize,最大线程数 = Integer.MAX_VALUE;
返回 ScheduledExecutorService 接口(扩展了 ExecutorService,支持定时任务);
常用方法:schedule()(延迟执行一次)、scheduleAtFixedRate()(固定频率重复执行)等。
适用场景:定时任务(如定时备份、周期性数据同步)。
优势:相比 Timer(单线程,一个任务异常会影响所有任务),它是线程池实现,更稳定。 - newWorkStealingPool()(Java 8+)
创建一个工作窃取线程池(基于 ForkJoinPool)。
特点:
并行度默认等于当前处理器核心数(Runtime.getRuntime().availableProcessors());
线程可 "窃取" 其他线程的任务(平衡负载),适合处理可分解的大任务。
适用场景:计算密集型任务(如大数据分片处理)。
java
package Thread11_19;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class demo31 {
public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
int id = i;
threadPool.submit(()->{
System.out.println("hello " + id + " "+Thread.currentThread().getName() );
});
}
}
}

自己实现一个线程池
java
package Thread11_19;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
class MyThreadPool{
private BlockingQueue<Runnable> queue = null;
public MyThreadPool(int n){
queue = new LinkedBlockingQueue<>(1000);
for (int i = 0; i < n; i++) {
Thread thread = new Thread(()->{
while(true){
try {
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
}
}
public void submit(Runnable task) throws InterruptedException {
queue.put(task);
}
}
public class demo32 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(10);
// 向线程池提交任务
for (int i = 0; i < 100; i++) {
int id = i;
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " id=" + id);
});
}
}
}
定时器
定时器相当于一个闹钟,当时间到了就会自动执行一些逻辑。
标准库中的定时器
java标准库中将定时器分装成Timer类,将定时器任务封装成TimerTask。(继承于Runnable,核心还是重写run方法)。

Timer类的一个重要的方法是schedule,包含两个参数,一个参数是任务(TimerTask),另一个参数是指定多长时间之后执行(单位是毫秒)。
java
package Thread11_20;
import java.util.Timer;
import java.util.TimerTask;
public class demo33 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 3000");
}
},3000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello 2000");
}
},2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 1000");
}
},1000);
}
}

注意:
lambda表达式只有用来代替函数式接口,例如Runnable,Comparator等。此处的TimerTask是一个抽象类,不可以用lambda表达式
最后我们看到,进程没有正常结束,因为和线程池一样,Timer也包含前台线程,阻止进程结束。
模拟实现定时器
1.需要创建一个类,表示定时器任务。
2.需要使用一个集合类来管理多任务。
此处优先级队列最合适,采用时间作为优先级。不适用阻塞队列,因为定时器是精准按照时间来等待执行任务,并非依赖阻塞。
3.实现schedule方法将任务添加到队列中
4.额外创建一个线程负责执行队列中的任务。此处的线程与线程池的不同,线程池是只要队列不为空就一直执行任务。此处则是检查队首元素的时间限制,到了时间就执行,没到时间就不执行。
此处的时间采用时间戳:
时间戳是 "以某个基准时间点为起点,到目标时刻的时间间隔数值",最常用的是Unix 时间戳。
核心信息:
基准点:Unix 时间戳的基准是「1970 年 1 月 1 日 00:00:00 UTC」(格林威治标准时间)。
常用单位:
秒级(10 位数字):比如 2025 年 11 月 20 日的秒级时间戳约为 1750406400;
毫秒级(13 位数字):同时间的毫秒级时间戳约为 1750406400000。
时区无关性:Unix 时间戳本身是 UTC 时间的间隔,和时区无关;转换为 "年 / 月 / 日" 等本地时间时,才会受时区影响。
举个代码里的例子:
Java 中System.currentTimeMillis()返回毫秒级Unix 时间戳;
java
package Thread11_20;
import java.util.PriorityQueue;
class MyTimerTask implements Comparable<MyTimerTask>{
private long time;
private Runnable task;
@Override
public int compareTo(MyTimerTask o) {
return (int)(this.time - o.time);
}
public MyTimerTask(long time,Runnable task){
this.time = time;
this.task = task;
}
public long getTime(){
return time;
}
public void run(){
task.run();
}
}
class Timer{
PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
private Object locker = new Object();
public void submit(Runnable task,long time){
synchronized (locker){
MyTimerTask timerTask = new MyTimerTask(System.currentTimeMillis() + time,task);
queue.offer(timerTask);
locker.notify();
}
}
public Timer(){
Thread t = new Thread(()->{
while(true){
try {
while (true) {
synchronized (locker) {
// 取出队首元素
// 还是加上 while
while (queue.isEmpty()) {
// 这里的 sleep 时间不好设定!!
locker.wait();
}
MyTimerTask task = queue.peek();
if (System.currentTimeMillis() < task.getTime()) {
// 当前任务时间, 如果比系统时间大, 说明任务执行的时机未到
locker.wait(task.getTime() - System.currentTimeMillis());
} else {
// 时间到了, 执行任务
task.run();
queue.poll();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
public class demo34 {
}
代码解析:
案例1:
java
public void schedule(Runnable task,long time){
synchronized (locker){
MyTimerTask timerTask = new MyTimerTask(System.currentTimeMillis() + time,task);
queue.offer(timerTask);
locker.notify();
}
}
schedule这里MyTimerTask timerTask = new MyTimerTask(System.currentTimeMillis() + time,task);这句话可以写在锁里面,也可以写在锁外面,不同之处:写在锁里面会因为synchronized可能发生的阻塞导致时间上有偏差,实际执行时间以这个任务实际加入到队列中为基准。如果写在外面则表明,实际执行时间以这个任务被创建时为基准,可以根据需求不同灵活处理。
案例2:

此处采用wait notify的原因:
其实可以wait和notify都去掉,这样案例1 的问题同时也就不用考虑

此时代码只需写成上面这样即可。但这个时候又会涉及忙等的问题:
忙等(也叫 "自旋等待")是 线程在等待某个条件满足时,不释放 CPU 资源,而是通过循环不断检查条件 的一种等待方式 ------ 本质是 "CPU 空转",线程看似在 "忙",但实际没做任何有效工作,只是反复检查等待条件。
弊端
CPU 资源浪费严重 :这是最核心的问题。如果等待时间较长(比如几秒、几分钟),忙等的线程会一直 "霸占" CPU,导致其他线程无法获得 CPU 时间片,系统整体性能下降(比如电脑卡顿);无意义的功耗消耗:CPU 空转时会持续消耗电力,尤其在嵌入式设备、移动端等对功耗敏感的场景,忙等会大幅增加功耗。
忙等只适用于等待时间很短的场景,像我们这里很有可能出现很长一段时间没有输入,cpu一直空转,线程一直在忙,但其实什么都没做。
案例3:

此处的wait替换成sleep似乎也很合理。定时器等待任务的时间是精准的,并不涉及到条件阻塞,所以似乎sleep就可以满足这里的一切需求。但是,有这样的一种特殊情况:
此时是12.00,目前队列中最先要处理的任务是12.20的一个任务,所以代码会在wait这里阻塞,阻塞时间为20min。如果在12.05的时候,队列中submit了一个新任务,这个新任务执行时间是12.10。如果此时是sleep,那么程序会继续阻塞,直到12.20执行任务,那么12.10要求完成的任务就被耽误了。如果是wait,那么我们submit时的notify操作会将wait唤醒,然后线程相关的任务代码会重新运行,此时取到的队首元素变成了12.10的任务。
多线程定时器
我们自己模拟实现的定时器跟标准库提供的差不多,都是通过一个线程去扫描优先级队列,依次执行任务。
但是这只适用于任务少/比较分散的情况。如果任务过多,并且都是在同一个时刻执行,那么一个线程可能会忙不过来,所以,java也提供了多线程版本的定时器,本质上是定时器与线程池的结合。
java
package Thread11_20;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class demo35 {
public static void main(String[] args) {
ScheduledExecutorService service = Executors.newScheduledThreadPool(4);
service.schedule(()->{
System.out.println("hello 3000");
},3000, TimeUnit.MILLISECONDS);
service.schedule(()->{
System.out.println("hello 2000");
},2000, TimeUnit.MILLISECONDS);
service.schedule(()->{
System.out.println("hello 1000");
},1000, TimeUnit.MILLISECONDS);
}
}

但是单线程也不是不能处理多任务,可以使用"时间轮"的方法,不仅可以处理多任务,还可以高精度处理任务,大体逻辑如下:

总结
本文围绕 Java 多线程编程的核心工具与设计模式展开,核心聚焦线程安全与效率优化两大关键,依次讲解了单例模式(饿汉 / 懒汉模式,解决实例唯一性问题,需注意线程安全与反射破坏风险)、阻塞队列(线程安全的缓冲组件,支持阻塞入队 / 出队,是生产者消费者模型的核心,标准库提供BlockingQueue接口及实现类,模拟实现需通过同步锁与等待 / 唤醒机制保证安全)、线程池(提前创建并复用线程减少开销,ThreadPoolExecutor的 7 个核心参数是面试重点,Executors简化创建但需规避 OOM 风险,本质是生产者消费者模型的应用)与定时器(实现定时 / 周期性任务调度,Timer为单线程版本,ScheduledExecutorService基于线程池更稳定,模拟实现依赖优先级队列与精准等待机制),这些知识点既是校招面试高频考点,也是实际开发中处理并发、任务调度、性能优化的核心工具,掌握其原理与实现能有效提升多线程程序的设计合理性与可维护性。
