202508|技术日志

0804

Thread.interrupt()

导论

今天有一个需求是将原先单独部署的服务B迁移到现在的服务A中,A服务启动的时候顺带把B服务一起启动了,在服务B的核心启动逻辑中有这么一段:

1
2
3
4
5
6
7
8
try {
server.start();
Thread.currentThread().join();
} catch (DeploymentException | InterruptedException e) {
e.printStackTrace();
} finally {
server.stop();
}

Thread.currentThread().join() 这一行是将当前线程进行阻塞

因此不能直接串行式地照搬代码,否则会出现A服务启动一半开始启动服务B的时候,服务B确实启动了,然后就一直阻塞住了。

最佳实践的方式是:另起一个线程来启动服务B并进行阻塞

1
2
3
4
5
6
7
8
9
10
11
// 另起一个线程
webSocketThread = new Thread(() -> {
try {
webSocketLanguageServer.doStart();
} catch (Exception e) {
log.error("服务启动失败", e);
}
}, "groovy-websocket-server-thread");

webSocketThread.setDaemon(false);
webSocketThread.start();

从系统资源安全性的角度出发,我们应当实现:当服务A停止之前,顺带把服务B先停止了

这个时候我们就需要用到 Thread.interrupt()

作用

threadA.interrupt() 方法用于向线程发送中断信号

从系统设计的角度出发,一个线程的停止(生命周期),不应该受其他线程的控制。因此在 Thread 类中 stop, destory 等方法都是被标记为过时的

也正因此,interrupt 并不保证会立刻终止线程,只是进行通知,通知线程应该被中断了

具体到底中断还是继续运行,应该由被通知的线程自己处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 @Deprecated
public final void stop() {
SecurityManager security = System.getSecurityManager();
if (security != null) {
checkAccess();
if (this != Thread.currentThread()) {
security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
}
}
// A zero status value corresponds to "NEW", it can't change to
// not-NEW because we hold the lock.
if (threadStatus != 0) {
resume(); // Wake up thread if it was suspended; no-op otherwise
}

// The VM can handle all thread states
stop0(new ThreadDeath());
}

@Deprecated
public final synchronized void stop(Throwable obj) {
throw new UnsupportedOperationException();
}

@Deprecated
public void destroy() {
throw new NoSuchMethodError();
}

真正的流程是,某个线程(比如 main 线程)调用了 某个线程 A 的 interrupt 方法,此时根据线程 A 所处的状态来决定后续的状态:

  • 如果线程处于阻塞态,比如调用了 wait(), join():阻塞的线程会立刻退出阻塞态并抛出 InterruptedException
  • 如果线程只是正常执行:线程只是修改当前 interrupt 的标记,不做任何操作

最佳实践

我们之前提到:

具体到底中断还是继续运行,应该由被通知的线程自己处理

这就是使用线程时的最佳实践:

  • 如果线程执行逻辑有被中断的需求,那么就需要在运行时通过 Thread.interrupted() 检查当前的中断标志,如果已经中断自行跳转逻辑
  • 在调用阻塞等方法时,尤其需要注意处理 throws 的 InterruptedException,如果涉及到服务资源的,需要关闭资源;或是 catch 异常后结束线程逻辑

现在我们再看下面这些代码:

1
2
3
4
5
Thread needInterruptThread = new Thread(() -> {
while (!Thread.interrupted()) {
//do something
}
}, "needInterruptThread");
1
2
3
4
5
6
7
8
9
try {
//非阻塞启动服务
server.start();
Thread.currentThread().join();
} catch (DeploymentException | InterruptedException e) {
e.printStackTrace();
} finally {
server.stop();
}

上面就是基于最佳实践编写的代码,其中第二部分在服务非阻塞启动后,阻塞当前线程,很好地处理了 InterruptedException,在 finally 中释放资源

因此,回归最初导论中的问题:想实现当服务A停止之前,顺带把服务B先停止的操作

只需要在 A 服务中新增一个 hook

1
2
3
4
5
6
7
@PreDestroy
public void destroy() {
if (webSocketThread != null && webSocketThread.isAlive()) {
log.info("停止 WebSocket 服务...");
webSocketThread.interrupt(); //通知服务线程中断阻塞,停止服务
}
}

这里调用 webSocketThread.interrupt() 后,通知了跑着服务的B线程应该进行中断了。而B线程的实现逻辑中又catch了异常,在 finally 中停止了核心服务的资源

创建线程的方式

经常背八股的同学都知道,创建线程的方式有:

  • 继承Thread类
  • 实现Runnable接口
  • 实现Callable接口
  • 使用ThreadPoolExecutor线程池

我们来看代码,以实现 Runnable 接口为例

1
2
3
4
5
6
class PrintThreadName implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run");
}
}
1
2
3
4
public static void main(String[] args) {
PrintThreadName task = new PrintThreadName();
task.run(); //main: run
}

这样调用我们会发现结果实际上还是 main: run

这是因为你实现了 Runnable 接口,但选择直接调用 Runnable 实现类的 run 方法,本质上还是在主线程进行方法调用,和普通的方法调用并无本质区别

真正想要在另外一个线程打印,只有一个入口:调用 thread 对象实例的 start()

1
2
3
4
5
6
7
public static void main(String[] args) {
PrintThreadName task = new PrintThreadName();
task.run(); //main: run

Thread anotherThread = new Thread(task, "anotherThread");
anotherThread.start(); //anotherThread: run
}

线程体 & 线程

我们上面说的八股中常见的什么继承 Thread 类等等操作,严格意义上来说是 创建线程体 而不是创建线程

线程体定义了线程执行时具体应该执行的任务,是线程执行任务的承载体,是一个接口(标准)

所以才需要我们继承 Thread 类重写 run 方法/ 实现 Runnable/Callable 接口,这些都是为了定义线程跑起来的时候应该做什么,本质上并没有创建线程

唯一创建线程的只有一个入口:调用 thread 对象实例的 start()只是我们可以用多种构造线程体的方式,在实例化 thread 对象的时候以多种形式向构造函数传参

想要让程序片段在另起的线程上跑,不管是 JDK 源码库的实现逻辑(例如使用线程池)还是我们之前提到的实现 Runnable 接口,本质上只有唯一一种,就是先实例化 Thread 类实例,然后调用 start()

0805

线程的生命周期

参考

前置内容:需要了解原生的 synchronized 以及 Object.wait() / Object.notify()

synchronized

synchronized 的核心作用是给代码块或方法加锁,确保同一时间只有一个线程能执行被锁定的代码。它有两个关键特性:

  • 互斥性:同一时间只有一个线程能持有锁(其他线程会阻塞等待)。
  • 可见性:线程释放锁时,会将修改后的共享变量同步到主内存;其他线程获取锁时,会从主内存读取最新的变量值。

synchronized 的语法有两种:

  • 修饰方法(如 synchronized void method()):锁对象是当前实例(非静态方法)或类对象(静态方法)。
  • 修饰代码块(如 synchronized(lockObj) { ... }):锁对象是括号里的 lockObj(必须是一个对象,不能是基本类型)。

wait-notify

Object.wait() / Object.notify() 是 JDK 提供的多线程环境下线程的 等待————同步 机制,用于实现线程间的协作(比如一个线程等待某个条件,另一个线程满足条件后唤醒它)

需要注意的是必须在 synchronized 中定义,不然会抛出 IllegalMonitorStateException

  • wait():让当前线程释放锁并进入Waiting 状态(被动等待),直到被其他线程通过 notify()notifyAll() 唤醒。其中释放锁是关键:如果不释放锁,其他线程永远无法获取锁,会导致死锁。
  • notify():随机唤醒一个正在等待当前锁的线程(使其从 Waiting 状态退出),但被唤醒的线程不会立即执行,而是需要重新竞争获取锁(获取到锁后才会继续执行)。

测试demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class ThreadLifeStatusTest {

private static final Object waitLock = new Object();

public static void main(String[] args) {
//Thread.State.NEW
Thread newStatusThread = new Thread(() -> {
//do something
}, "newStatusThread");

//Thread.State.RUNNABLE
//RUNNABLE态,是否执行取决于操作系统是否将CPU时间片分到线程
newStatusThread.start();

//阻塞态,此时不会占用CPU执行权
createBlockedThread();

//等待态
createWaitThread();

//超时等待态
createTimedWaitingThread();

//终止态
//线程中方法执行完毕或是抛出异常自动进入终止状态
}

private static void createBlockedThread() {
Thread blocked1 = new Thread(LocalTest::acquire, "blocked1");
Thread blocked2 = new Thread(LocalTest::acquire, "blocked2");
//肯定有一个会进入Thread.State.BLOCKED
blocked1.start();
blocked2.start();
}


private static void createWaitThread() {
Thread waitingThread = new Thread(() -> {
synchronized (waitLock) { //获取锁
try {
waitLock.wait(); // 释放锁并进入Waiting状态(需被notify唤醒)
System.out.println("等待线程被唤醒");
} catch (InterruptedException e) {
System.out.println("等待线程被中断");
}
}
}, "waitingThread");

waitingThread.start();
// 确保线程进入Waiting状态
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 主线程唤醒等待线程
synchronized (waitLock) {
waitLock.notify(); // 唤醒后,waitingThread会先进入Blocked(抢锁),再进入Runnable
}
}

private static void createTimedWaitingThread() {
Thread timedWaitingThread = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "timedWaitingThread");

timedWaitingThread.start();
}

private static synchronized void acquire() {
try {
//模拟获取共享资源,持有锁后睡5s让另一个线程BLOCKED
Thread.sleep(5000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

}

总结

简单来说就是:

  • 线程创建后进入NEW状态
  • 调用thread.start()后进入RUNNABLE状态,此时还需要等待OS的资源分配,例如CPU执行权。只不过JVM中把有CPU执行权和无CPU执行权统一都叫做RUNNABLE,没有在进行细分,只要在JVM中执行的都叫做RUNNABLE
  • 线程抢锁但是没有抢到的为BLOCKED状态
  • 线程抢到了锁,但是通过调用Object.wait() / Thread.join() / LockSupport.park() 来释放锁并主动进入WAITING态等待被唤醒,唤醒后不代表立刻执行,还是需要抢锁,因为可能有多个线程同时竞争
  • 线程也可以通过调用 Thread.sleep() / Thread.jon(time) / Object.wait(time) / LockSupport.parkNanos() / LockSupport.parkUntil() 进入超时等待,时间到了会退出TIMED_WAITING,超时后自动唤醒,同样需要重新竞争锁才能继续执行
  • 线程执行完所有逻辑或是抛出异常后自动进入TERMINATED状态

0806

pnpm 和 npm 区别

核心区别在于二者的依赖存储方式不同

  • npm 采用 “扁平化 node_modules”,可能导致依赖版本混乱(同一依赖的不同版本可能被提升)。
  • pnpm 采用 “内容可寻址存储”,通过硬链接和符号链接管理依赖,相同依赖只存一份,节省磁盘空间,且安装速度更快。

npm依赖提升

例如,我们现在的工程引入两个插件:toolAtoolB

1
2
3
4
5
6
7
{
"name": "my-project",
"dependencies": {
"toolA": "1.0.0",
"toolB": "1.0.0"
}
}

toolA:

1
2
3
4
5
6
7
{
"name": "toolA",
"version": "1.0.0",
"dependencies": {
"lodash": "^4.17.0" // 依赖4.x版本
}
}

toolB:

1
2
3
4
5
6
7
{
"name": "toolB",
"version": "1.0.0",
"dependencies": {
"lodash": "^3.10.0" // 依赖3.x版本
}
}

当执行npm install后,npm 会尝试扁平化依赖:

  • 由于toolAtoolB依赖lodash的不同版本,npm 会选择其中一个版本提升到顶层(假设是lodash@4.17.0,因为版本更新);
  • 另一个版本(lodash@3.10.0)则保留在toolB自己的node_modules中(因为toolB明确依赖 3.x)。

所以最终的目录结构如下:

1
2
3
4
5
6
7
8
my-project/
├── node_modules/
│ ├── toolA/ # 依赖lodash@4.x
│ ├── toolB/ # 依赖lodash@3.x
│ │ └── node_modules/
│ │ └── lodash/ # 实际安装的3.10.0版本
│ └── lodash/ # 被提升到顶层的4.17.0版本
└── package.json

如果toolA和toolB都定义了lodash指定版本,那么都不会有问题

但是如果tooB忘记定义了,此时,toolB引用lodash时,npm 会默认去找顶层的lodash@4.17.0,但4.x中可能没有toolB依赖的代码版本(比如依赖的方法被删除了),执行的时候就会报错

因此如果使用 npm 时存在不同包依赖同一依赖的不同版本,则会导致:

  • 若包未正确声明依赖,可能错误引用顶层的 “不兼容版本”;
  • 依赖提升的不确定性可能导致部分包被迫使用非预期版本,引发兼容性问题。

pnpm符号连接

pnpm不采用依赖提升而是采用符号链接可以很好地解决上面的问题,如果采用 pnpm 那么最终的目录结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
my-project/
├── node_modules/
│ ├── toolA -> .pnpm/toolA@1.0.0/node_modules/toolA # 符号链接
│ ├── toolB -> .pnpm/toolB@1.0.0/node_modules/toolB # 符号链接
│ └── .pnpm/ # pnpm 内部管理的依赖存储区
│ ├── toolA@1.0.0/
│ │ └── node_modules/
│ │ ├── toolA/ # 实际的 toolA 代码
│ │ └── lodash -> ../../lodash@4.17.0/ # 指向 4.x 版本
│ ├── toolB@1.0.0/
│ │ └── node_modules/
│ │ ├── toolB/ # 实际的 toolB 代码
│ │ └── lodash -> ../../lodash@3.10.0/ # 指向 3.x 版本
│ ├── lodash@4.17.0/ # 全局存储的 4.x 版本(硬链接)
│ └── lodash@3.10.0/ # 全局存储的 3.x 版本(硬链接)

还是上面出现问题的场景,如果 toolB 忘记在依赖中定义 lodash,那么 toolB 中的代码 require('lodash') 会直接失败(找不到模块),而不是像 npm 那样 “向上查找并意外引用顶层版本”。这强制开发者必须显式声明依赖,避免隐藏的版本问题。

0808

peerDependencies

peerDependenciespackage.json 中的一个字段,用于声明当前包(通常是插件、工具库)与宿主环境(使用该包的项目)中其他依赖的兼容版本要求

它的核心作用是:

  • 告诉宿主项目:“我需要你提供这些依赖,且版本必须符合我的要求”
  • 避免依赖重复安装(例如:React 插件不需要自己安装 React,而是使用宿主项目已有的 React)
  • 确保兼容性(例如:插件明确要求 React ≥17,避免宿主使用 React 16 导致冲突)

202508|技术日志
http://example.com/2025/08/04/202508-技术日志/
作者
Noctis64
发布于
2025年8月4日
许可协议