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 导致冲突)

0811

LockSupport

是一个用来创建锁和其他同步工具类的基本线程阻塞原语

和wait/notify对比

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
public static void waitNotify() throws InterruptedException {

Object lock = new Object();

Thread waitThread = new Thread(() -> {
synchronized (lock) {
try {
lock.wait(); //释放锁并进入阻塞态
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("waitThread get notified");
}, "waitThread");

waitThread.start();

//确保waitThread先拿到锁
Thread.sleep(500L);

Thread notifyThread = new Thread(() -> {
System.out.println("notifyThread notify waitThread");
synchronized (lock) {
lock.notify();
}
}, "notifyThread");
notifyThread.start();

}

换成 LockSupport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void parkUnpark() throws InterruptedException {

Thread parkThread = new Thread(() -> {
System.out.println("parkThread blocked by park");

LockSupport.park();

System.out.println("parkThread notified by unparkThread");
}, "parkThread");
parkThread.start();

//确保parkThread先拿到锁
Thread.sleep(500L);

Thread unparkThread = new Thread(() -> {

System.out.println("unparkThread notify parkThread");

LockSupport.unpark(parkThread);

}, "unparkThread");
unparkThread.start();

}

可以发现 LockSupport 不像是 wait/notify 那样必须要在 synchronized 下才能使用

此外,LockSupport 相比于 wait/notify ,还解决了顺序问题,看下面这段代码:

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
public static void unorderedWaitNotify() throws InterruptedException {

Object lock = new Object();

Thread waitThread = new Thread(() -> {
//模拟先notify再wait
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock) {
try {
lock.wait(); //释放锁并进入阻塞态
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("waitThread get notified");
}, "waitThread");
waitThread.start();

Thread notifyThread = new Thread(() -> {
System.out.println("notifyThread notify waitThread");
synchronized (lock) {
lock.notify();
}
}, "notifyThread");
notifyThread.start();

}

上面这段代码,我们模拟先 notify 再 wait,结果就是 waitThread 永远地阻塞住了,程序无法正常结束

但是如果换用 LockSupport

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

public static void unorderedParkUnpark() throws InterruptedException {

Thread parkThread = new Thread(() -> {
//先unpark再park
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("parkThread blocked by park");

LockSupport.park();

System.out.println("parkThread notified by unparkThread");
}, "parkThread");
parkThread.start();



Thread unparkThread = new Thread(() -> {

System.out.println("unparkThread notify parkThread");

LockSupport.unpark(parkThread);

}, "unparkThread");
unparkThread.start();

}

我们先 unpark 再 park,结果可以发现虽然 unparkThread 先执行了 unpark 尝试唤醒,但是此时 parkThread 还在睡,还没 park 操作。但是不影响,2s 后 parkThread 调用 park 后立刻就被唤醒了

这也是 park/unparkwait/notify 的另一个核心区别:不论调用顺序,只要成对出现,必然可以唤醒线程

总结

通过 LockSupport 的 park/unparkwait/notify 的对比,我们可以简单总结:他们都可以实现线程的等待和唤醒。但是区别在于:

  • park/unpark 在任何地方都能用,而 wait/notify 只能在 synchronized 下使用
  • park/unpark 调用顺序无关,只要成对出现必然可以唤醒等待的线程,而 wait/notify 必须按顺序,先 wait 然后 notify,如果先 notify 再 wait 会导致后来 wait 的线程一直无法被唤醒

0812

GSON忽略指定字段

详见

偶现请求参数丢失

结合网上相同案例排查,可能是在异步操作前,将RequestContextHolder丢入线程中导致的,详见整理后的博客

0818

String的不可变性

如何做到的不可变性

1
2
3
4
5
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}

直接看源码,源码中定义了 private 和 final 这两点需要重点关注

  • private:外部无法直接访问该数组,避免了直接修改,而且String类没有提供任何修改数组内部值的方法
  • final:数组引用 value 一旦初始化后,无法指向新的数组对象

如果只有 final 意味着数组本身的元素理论上可被修改,但是在 private 的作用下以及 String 类并未暴露 public 的方法提供修改 value 数组的方式,所以二者共同作用,限制了 String 类不可变

String 类暴露的任何看似修改字符串的操作,例如 concat toUpperCase 实际上都是创建了新的 String 对象,而不是在原先的对象上进行了修改

为什么需要不可变

字符串常量池

JVM为了节省内存设计了字符串常量池,相同字面值内容的字符串,都会引用到字符串常量池中相同的那一份(只会存一份)

1
2
3
String s1 = "foo"; //char[3]@509
String s2 = "foo"; //char[3]@509
String s3 = new String(s1); //char[3]@509

若字符串可变,修改 s1 会导致相同字面值的 s2 的值也被意外修改,破坏常量池的设计初衷

不可变性保证了字符串常量池中的对象可以安全共享

为了线程安全

常量因为不可变,天生线程安全,不存在并发问题

为了哈希表哈希计算可靠

String 常常被用作 HashMap 的Key,而 HashMap 的高效查询就是依赖其 key 的 hashCode

以 String 作为 key 保证了 key 不可变,在对象生命周期内的每一次 hash 索引都是准确稳定的

可变字符串

就是我们常说的 StringBuffer 和 StringBuilder

如果是频繁修改拼接字符串,String 的不可变性会导致产生很多临时的新的 String 对象

而可变字符串本质上会直接修改 value 数组的内容,不会产生临时 String 对象

0819

fastjson反序列化误判字段

问题描述

1
2
3
4
5
6
7
8
9
10
11
12
public class Foo {

//省略 getter setter
private String code;

private String name;

public boolean isIllegal() {
//根据自身属性值判断数据是否非法
}

}

前端拿到响应的时候,结果如下

1
2
3
4
5
{
"code": "foo",
"name": "bar",
"illegal": false
}

原因分析

当 Controller 返回时,Spring MVC 并不会直接将 Java 对象返回给前端,而是需要经过序列化处理(将 Java 对象转换为 JSON 字符串),这个过程由 JSON 序列化器(如 FastJSON、Jackson 等)完成。

序列化器的工作逻辑:扫描对象中的所有 getter 方法,根据方法名推断属性名,并将方法返回值作为属性值写入 JSON

对于Foo类中的isIllegal()方法:

  • 序列化器会按照 “isXxx() → 属性名为xxx” 的规则,推断存在一个名为illegal的属性。
  • 调用isIllegal()方法得到返回值(false),于是在序列化后的 JSON 中添加"illegal": false字段。

因此,可以得出最佳实践:返回给前端的响应体以及代码中任何的响应体,在设计方法的时候需要考虑到 Java Bean 的规范,bool 类型的变量,就叫做 xxx, getter 叫做 isXxx;而任何非属性的 getter/setter,都不要以 is 开头,否则会导致在序列化的时候出现误判,多出一个属性的情况

0820

CountDownLatch

见博客笔记整理

0821

final

修饰变量时,变量无法被赋值,具体表现为:

  • 基础数据类型:无法改变值
  • 引用数据类型:无法修改引用,但是可以修改引用对象的属性值

修饰方法时,方法无法被重写,例如 Thread.isAlive()

修饰类时,类无法被继承,例如 String

不可变类

final 修饰类常常用于设计不可变类

不可变类是指实例创建后,其内部状态(属性值)不能被修改的类(如 StringInteger)。其核心是 “状态不可变”,而非 “是否被 final 修饰”

final class 不一定是 不可变类

不可变类 也不一定是 final class

设计模式

开发中常见的设计模式:

单例:Calendar,配置文件映射类

模板方法:AQS

策略模式

观察者模式

工厂模式

装饰器模式

Serializable

导论

这是一个接口,没有定义任何的接口方法,意味着这个接口仅仅是起到了标记的作用

那么什么时候会需要使用到这个接口呢?这就需要提到序列化和反序列化了

序列化是指我们将程序中的 Java 对象转换为字节流的这个过程,比如写文件,发请求

反序列化则相反,是指字节流映射回 Java 实体对象的过程

很常见的业务场景就是:在将数据导出到文件(例如 Excel 文件)的时候,就是序列化;将本地文件导入到系统,就是对应文件到 JVM 内存的反序列化过程

为什么需要

简单粗暴的理解,源码中就是有这个标记的判断,额米有就是会报错

不管是用成熟的写文件框架(例如 EasyExcel )还是发请求,本质上都会调用 JDK 的 OutputStream.writeObject0()

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
// 判断对象是否为字符串类型,如果是,则调用 writeString 方法进行序列化
if (obj instanceof String) {
writeString((String) obj, unshared);
}
// 判断对象是否为数组类型,如果是,则调用 writeArray 方法进行序列化
else if (cl.isArray()) {
writeArray(obj, desc, unshared);
}
// 判断对象是否为枚举类型,如果是,则调用 writeEnum 方法进行序列化
else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
}
// 判断对象是否为可序列化类型,如果是,则调用 writeOrdinaryObject 方法进行序列化
else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
}
// 如果对象不能被序列化,则抛出 NotSerializableException 异常
else {
if (extendedDebugInfo) {
throw new NotSerializableException(
cl.getName() + "\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}

因此如果有序列化对象的需求,需要在对象定义时实现java.io.Serializable这个接口

serialVersionUID

在阅读JDK的一些源码的时候也经常会看到,只要是实现了Serializable接口的对象,都会定义一串”意义不明”的随机UID

1
2
3
4
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;
//AbstractQueuedSynchronizer 实现了 java.io.Serializabel
}

这个看似 “意义不明” 的UID实际上在反序列化的过程中起到关键作用

JVM会在反序列化时,比较字节流中定义的 serialVersionUID 和代码中目前数据结构里的是否相同,如果不同,会抛出InvalidClassException异常

但是如果我们没有显式定义呢?JVM 会在序列化和反序列化时自动根据对象的数据结构,生成随机值(类似 hashCode)只要对象的结构没有变,这个随机值就不会变。因此如果导出到文件时数据结构和现在导入读取时的结构保证统一,就不会报错

但是有的时候我们往往需要保证当前的数据结构修改更新时,也能保证程序读取时不出错

这种情况下,显式定义serialVersionUID就显得很重要了:我们可以修改当前程序的数据结构,保证 serialVersionUID 的值不改动,这样读取时 JVM 字节流比对 serialVersionUID 还是以前的值,保证读取流程不出错

最佳实践

java.io.Serializable 接口是 JVM 在序列化和反序列化时底层执行的重要标识,当我们有序列化和反序列化的业务需求时,需要在程序数据结构上实现这个接口

对于 java.io.Serializable 的所有实现类,推荐显式定义 serialVersionUID 字段来固定版本号,以增强数据结构的兼容性和版本扩展性,避免反序列化时数据因 Java 映射类的结构变化而导致的反序列化失败

0822

策略模式

已整理成博客

0825

@Component 和 @Bean

在Spring的应用中都很常见到这两个注解

这两个注解的核心作用都是将对象(Bean)纳入 Spring 容器管理

但它们的设计初衷、使用场景、底层逻辑有显著区别

理解二者的差异,是掌握 Spring 依赖注入(DI)和控制反转(IoC)的关键

作用对象与作用方式

@Component:类级别的自动注册

@Component 是 “声明式” 注解,作用是告诉 Spring:“这个类需要被你管理,请自动创建它的实例并放入容器”

Spring会在启动的时候使用默认扫描策略或是@ComponentScan定义的策略(如果有)来通过反射扫描所有标注了@Component以及类似衍生注解的类,实例化这些类,并自动注入到容器中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 标注在类上,Spring 自动扫描后创建 userService Bean
@Component
public class UserService {
// 类的业务逻辑
public void getUserInfo() {
System.out.println("获取用户信息");
}
}

// Spring Boot 启动类(默认扫描当前包及子包下的 @Component 类)
@SpringBootApplication
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class, args);
// 从容器中获取 UserService 实例(已自动注册)
UserService userService = context.getBean(UserService.class);
userService.getUserInfo(); // 输出:获取用户信息
}
}

@Bean 是方法级别注解,只能做用在方法上

@Bean 是 “编程式” 注解,作用是告诉 Spring:“这个方法的返回值需要被你管理,请将其作为 Bean 放入容器”

@Bean 必须定义在 @Configuration 标注的配置类@Component 标注的类 中(推荐前者,因为 @Configuration 会通过 CGLIB 增强,保证 Bean 的单例性),用于手动控制 Bean 的创建逻辑

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
// 第三方类(无法修改源码,不能用 @Component 标注)
public class ThirdPartyHttpClient {
private String baseUrl;
// 有参构造,初始化逻辑复杂
public ThirdPartyHttpClient(String baseUrl) {
this.baseUrl = baseUrl;
// 可能还有其他复杂初始化(如连接池配置、超时设置)
}
public void sendRequest() {
System.out.println("向 " + baseUrl + " 发送请求");
}
}

// 配置类:用 @Bean 手动注册第三方类的 Bean
@Configuration
public class BeanConfig {
// 方法返回值作为 Bean,Bean 名称默认是方法名 "httpClient"
@Bean
public ThirdPartyHttpClient httpClient() {
// 手动控制初始化逻辑:传入参数、配置细节
return new ThirdPartyHttpClient("https://api.example.com");
}
}

// 测试:从容器中获取 @Bean 注册的 Bean
@SpringBootApplication
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class, args);
ThirdPartyHttpClient httpClient = context.getBean(ThirdPartyHttpClient.class);
httpClient.sendRequest(); // 输出:向 https://api.example.com 发送请求
}
}

使用场景最佳实践

@Component@Bean 二者从设计上的初衷就不同

@Component:适用于 “自定义类” 的自动注册,当开发的是自己项目中的类(如 UserServiceOrderRepository),且这些类的初始化逻辑简单(无复杂参数、无需调用第三方 API)时,使用 @Component(或其衍生注解)+ 组件扫描,能让 Spring 自动完成 Bean 注册,减少手动配置代码

@Bean:适用于 “非自定义类” 或 “复杂初始化” 的手动注册,常常用在:

  1. 第三方类的 Bean 注册:因为我们无法修改第三方库的源码(如 RedisTemplateHttpClientMyBatisSqlSessionFactory),不能在这些类上标注 @Component,此时必须通过 @Bean 手动创建实例并注册到Spring容器中使用
  2. 复杂初始化逻辑:即使是自定义类,若初始化需要复杂逻辑(如动态参数、条件判断、调用其他服务获取配置),@Component 无法满足(只能依赖默认构造或 @Autowired 注入),而 @Bean 可在方法内编写任意逻辑。

补充: 自定义类复杂初始化的Bean注入对比

这里针对【如果是自定义类复杂初始化逻辑】的情况,需要使用@Bean的方式,下面是代码案例

需求描述

我们以 “自定义支付客户端” 为例:

假设现在这个自定义支付客户端在初始化的时候需要根据环境(开发 / 生产)动态选择支付网关地址、调用配置中心获取密钥、初始化连接池,且需支持 “是否启用沙箱模式” 的条件判断 —— 这些复杂逻辑用 @Component 难以实现,而 @Bean 可优雅应对

  1. 动态参数:支付网关地址(开发环境 dev-url / 生产环境 prod-url)从配置文件读取,而非硬编码
  2. 条件判断:若配置 pay.sandbox.enable=true,则启用沙箱模式(跳过真实签名校验);否则启用生产模式(严格校验)
  3. 依赖外部服务:支付密钥需从 “配置中心服务”动态获取,而非直接写在配置文件
  4. 资源初始化:初始化支付连接池(设置最大连接数、超时时间),确保客户端性能

假设我们的配置文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 激活的环境(dev/prod)
spring:
profiles:
active: dev

# 支付客户端配置
pay:
# 网关地址(分环境)
gateway:
dev-url: https://dev-pay-gateway.example.com
prod-url: https://prod-pay-gateway.example.com
# 沙箱模式配置(开发环境启用,生产环境禁用)
sandbox:
enable: ${spring.profiles.active == 'dev' ? true : false}
# 连接池配置
connection:
max: 10
timeout: 5000

@Bean实现

自定义类
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
@Getter
@Setter
public class PayClient {
// 1. 动态参数:支付网关地址(开发/生产环境不同)
private String gatewayUrl;
// 2. 条件参数:是否启用沙箱模式
private boolean sandboxEnable;
// 3. 外部依赖:支付密钥(从配置中心获取)
private String apiKey;
// 4. 资源初始化:连接池配置
private int maxConnections; // 最大连接数
private int connectTimeout; // 连接超时时间(毫秒)

/**
* 业务方法:发起支付请求
*/
public String doPay(String orderId, BigDecimal amount) {
// 根据沙箱模式判断是否跳过签名校验
String sign = sandboxEnable ? "sandbox-sign" : generateRealSign(orderId, amount);
return String.format(
"支付请求已发送 -> 网关:%s,订单号:%s,金额:%s,沙箱模式:%s,签名:%s",
gatewayUrl, orderId, amount, sandboxEnable, sign
);
}

// 模拟生产环境的真实签名逻辑
private String generateRealSign(String orderId, BigDecimal amount) {
return "real-sign-" + orderId + "-" + amount + "-" + apiKey;
}
}
配置服务Service

这个配置服务同样注入到Spring容器中,我们的支付类在实例化时、注入之前需要调用这个配置服务设置字段值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.stereotype.Service;

/**
* 模拟外部配置中心服务(非自定义类/第三方服务)
*/
@Service
public class ConfigCenterService {
/**
* 根据key从配置中心获取配置值
*/
public String getConfig(String key) {
// 模拟配置中心返回数据(实际可能是HTTP调用、Nacos/Apollo获取)
switch (key) {
case "pay.api.key":
return "prod_8a7b6c5d4e3f2a1b"; // 生产环境密钥
default:
throw new IllegalArgumentException("未知配置key:" + key);
}
}
}
实例化逻辑

在配置类中,我们使用 @Bean 实例化我们的自定义支付类

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
/**
* 支付客户端配置类:用@Bean处理复杂初始化
*/
@Configuration
public class PayClientConfig {
// 1. 注入外部依赖:配置中心服务(用于获取密钥)
@Autowired
private ConfigCenterService configCenterService;

// 2. 读取动态参数(从application.yml/properties配置文件)
@Value("${pay.gateway.dev-url}")
private String devGatewayUrl; // 开发环境网关
@Value("${pay.gateway.prod-url}")
private String prodGatewayUrl; // 生产环境网关
@Value("${spring.profiles.active}")
private String activeEnv; // 当前激活的环境(dev/prod)
@Value("${pay.sandbox.enable:false}")
private boolean sandboxEnable; // 是否启用沙箱模式(默认false)
@Value("${pay.connection.max:5}")
private int maxConnections; // 连接池最大连接数(默认5)
@Value("${pay.connection.timeout:3000}")
private int connectTimeout; // 连接超时时间(默认3000ms)

/**
* 3. 用@Bean创建PayClient实例:包含所有复杂初始化逻辑
*/
@Bean
public PayClient payClient() {
// 步骤1:动态选择支付网关地址(根据当前环境)
String gatewayUrl = "dev".equals(activeEnv) ? devGatewayUrl : prodGatewayUrl;
System.out.println("当前环境:" + activeEnv + ",选择网关:" + gatewayUrl);

// 步骤2:调用外部服务(配置中心)获取支付密钥
String apiKey = configCenterService.getConfig("pay.api.key");
System.out.println("从配置中心获取密钥:" + apiKey);

// 步骤3:条件判断(是否启用沙箱模式)
System.out.println("沙箱模式启用状态:" + sandboxEnable);

// 步骤4:初始化PayClient实例(设置所有参数+资源)
PayClient payClient = new PayClient();
payClient.setGatewayUrl(gatewayUrl);
payClient.setSandboxEnable(sandboxEnable);
payClient.setApiKey(apiKey);
payClient.setMaxConnections(maxConnections);
payClient.setConnectTimeout(connectTimeout);

// 步骤5:额外资源初始化(如连接池预热)
initConnectionPool(payClient);

return payClient;
}

/**
* 辅助方法:初始化支付连接池(模拟复杂资源初始化)
*/
private void initConnectionPool(PayClient payClient) {
System.out.println("初始化支付连接池:最大连接数=" + payClient.getMaxConnections()
+ ",超时时间=" + payClient.getConnectTimeout() + "ms");
// 实际场景:可能初始化HttpClient连接池、数据库连接池等
}
}

@Component实现

若强行用 @Component 标注 PayClient,会面临以下不可解决的问题:

  1. 动态参数无法灵活选择
    @Component 只能通过 @Value 直接注入单一值(如 @Value("${pay.gateway.dev-url}")),无法根据 activeEnv 的值动态切换 dev-url/prod-url
  2. 条件判断无法嵌入
    @Component 无法在初始化时添加 “是否启用沙箱模式” 的逻辑,只能在业务方法中判断,导致客户端实例创建时就携带无效配置(如生产环境仍加载沙箱参数)。
  3. 依赖外部服务获取配置困难
    若用 @Component,为了实现调用配置中心服务,我们需在 PayClient@Autowired 配置中心服务,再通过 @PostConstruct 初始化密钥:
1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class PayClient {
@Autowired
private ConfigCenterService configCenterService;
private String apiKey;

@PostConstruct
public void init() {
this.apiKey = configCenterService.getConfig("pay.api.key");
// 但动态网关、条件判断仍无法实现
}
}

总结

@Bean方式注入的Bean,本质上是Spring反射扫描并调用我们编写的方法后将方法的返回值作为bean注入的

相比于 @Component,@Bean更适合:

  • 第三方包里不会自动注入的类
  • 自定义的类、但是这个类由于业务关系,初始化的时候依赖比较多

虽然实现和配置较为复杂(因为还需要编写一个额外的@Configuration的配置类供Spring扫描)但更灵活

(已整理为笔记)

0826

观察者模式

整理为博客

0829

复习单例模式,重新手写,最佳实践:静态内部类(写法简单,无并发问题,性能高)

重载和重写

重载 Overload

在一个类中(或者父类和子类之间),相同方法名,必须不同的参数列表(参数个数,参数类型)和可选不同的返回值,可选不同的访问修饰符,会构成方法的重载

所以判断两个方法是否构成重载的关键依据是:方法名相同,并且参数列表必须不同(其中包括参数个数或是不同参数类型的顺序,与参数名称无关)

例如如下的两段构成了重载

1
2
3
public void add (String s1, int num) {}

public void add (int num, String s1) {}

至于二者的访问修饰符、抛出的异常以及返回值无关,相同或者不同都无所谓

重写

子类中,对父类(接口)已有的方法进行重新实现

发生重写时,方法名称,参数列表、必须完全相同,返回值可以

总结

对比维度 重载(Overload) 重写(Override)
定义 同一个类中,方法名相同但参数列表不同的多个方法 子类中,对父类已有的方法进行重新实现(方法名、参数列表、返回值完全相同)
作用场景 同一类内,为相似功能提供不同参数的调用方式 子类对父类的方法进行扩展或修改(多态的核心)
方法名 必须相同 必须与父类方法名完全相同
参数列表 必须不同(参数类型、个数、顺序至少有一个不同) 必须与父类方法的参数列表完全相同(类型、个数、顺序一致)
返回值类型 可以不同(与重载无关) 必须与父类方法的返回值类型相同(或其子类,即 “协变返回”)
访问修饰符 无限制(可任意修改) 子类方法的访问权限不能低于父类(如父类是protected,子类不能是private
异常声明 无限制 子类方法不能抛出比父类更多、更宽泛的异常(可减少或缩小)
调用依据 编译期根据参数列表确定调用哪个方法(静态绑定) 运行期根据对象实际类型确定调用哪个方法(动态绑定)

0831

ArrayList相关时间复杂度

查询指定位置元素:O(1)

新增:

  • 头插:O(n)
  • 尾插:
    • 不需要扩容:O(1)
    • 需要扩容:此时会需要把原先的内容复制到新的数组,因此是O(n)
  • 指定位置插入:此位置后面的所有元素需要向后移动一格,O(n)

删除:

  • 头删:O(n) 所有元素向前移动一格
  • 尾删:O(1)
  • 指定位置删除:此位置后的所有元素需要向前移动一格,O(n)

Warp up

TODO


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