202508|技术日志
0804
Thread.interrupt()
导论
今天有一个需求是将原先单独部署的服务B迁移到现在的服务A中,A服务启动的时候顺带把B服务一起启动了,在服务B的核心启动逻辑中有这么一段:
1 |
|
Thread.currentThread().join()
这一行是将当前线程进行阻塞
因此不能直接串行式地照搬代码,否则会出现A服务启动一半开始启动服务B的时候,服务B确实启动了,然后就一直阻塞住了。
最佳实践的方式是:另起一个线程来启动服务B并进行阻塞
1 |
|
从系统资源安全性的角度出发,我们应当实现:当服务A停止之前,顺带把服务B先停止了
这个时候我们就需要用到 Thread.interrupt()
作用
threadA.interrupt() 方法用于向线程发送中断信号
从系统设计的角度出发,一个线程的停止(生命周期),不应该受其他线程的控制。因此在 Thread 类中 stop, destory 等方法都是被标记为过时的
也正因此,interrupt 并不保证会立刻终止线程,只是进行通知,通知线程应该被中断了
具体到底中断还是继续运行,应该由被通知的线程自己处理
1 |
|
真正的流程是,某个线程(比如 main 线程)调用了 某个线程 A 的 interrupt 方法,此时根据线程 A 所处的状态来决定后续的状态:
- 如果线程处于阻塞态,比如调用了
wait()
,join()
:阻塞的线程会立刻退出阻塞态并抛出InterruptedException
- 如果线程只是正常执行:线程只是修改当前 interrupt 的标记,不做任何操作
最佳实践
我们之前提到:
具体到底中断还是继续运行,应该由被通知的线程自己处理
这就是使用线程时的最佳实践:
- 如果线程执行逻辑有被中断的需求,那么就需要在运行时通过
Thread.interrupted()
检查当前的中断标志,如果已经中断自行跳转逻辑 - 在调用阻塞等方法时,尤其需要注意处理 throws 的
InterruptedException
,如果涉及到服务资源的,需要关闭资源;或是 catch 异常后结束线程逻辑
现在我们再看下面这些代码:
1 |
|
1 |
|
上面就是基于最佳实践编写的代码,其中第二部分在服务非阻塞启动后,阻塞当前线程,很好地处理了 InterruptedException
,在 finally 中释放资源
因此,回归最初导论中的问题:想实现当服务A停止之前,顺带把服务B先停止的操作
只需要在 A 服务中新增一个 hook
1 |
|
这里调用 webSocketThread.interrupt()
后,通知了跑着服务的B线程应该进行中断了。而B线程的实现逻辑中又catch了异常,在 finally 中停止了核心服务的资源
创建线程的方式
经常背八股的同学都知道,创建线程的方式有:
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 使用ThreadPoolExecutor线程池
- …
我们来看代码,以实现 Runnable 接口为例
1 |
|
1 |
|
这样调用我们会发现结果实际上还是 main: run
这是因为你实现了 Runnable 接口,但选择直接调用 Runnable 实现类的 run 方法,本质上还是在主线程进行方法调用,和普通的方法调用并无本质区别
真正想要在另外一个线程打印,只有一个入口:调用 thread 对象实例的 start()
1 |
|
线程体 & 线程
我们上面说的八股中常见的什么继承 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 |
|
总结
简单来说就是:
- 线程创建后进入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依赖提升
例如,我们现在的工程引入两个插件:toolA
和toolB
1 |
|
toolA:
1 |
|
toolB:
1 |
|
当执行npm install
后,npm 会尝试扁平化依赖:
- 由于
toolA
和toolB
依赖lodash
的不同版本,npm 会选择其中一个版本提升到顶层(假设是lodash@4.17.0
,因为版本更新); - 另一个版本(
lodash@3.10.0
)则保留在toolB
自己的node_modules
中(因为toolB
明确依赖 3.x)。
所以最终的目录结构如下:
1 |
|
如果toolA和toolB都定义了lodash
指定版本,那么都不会有问题
但是如果tooB忘记定义了,此时,toolB
引用lodash
时,npm 会默认去找顶层的lodash@4.17.0
,但4.x
中可能没有toolB依赖的代码版本(比如依赖的方法被删除了),执行的时候就会报错
因此如果使用 npm 时存在不同包依赖同一依赖的不同版本,则会导致:
- 若包未正确声明依赖,可能错误引用顶层的 “不兼容版本”;
- 依赖提升的不确定性可能导致部分包被迫使用非预期版本,引发兼容性问题。
pnpm符号连接
pnpm不采用依赖提升而是采用符号链接可以很好地解决上面的问题,如果采用 pnpm 那么最终的目录结构如下
1 |
|
还是上面出现问题的场景,如果 toolB 忘记在依赖中定义 lodash,那么 toolB 中的代码 require('lodash')
会直接失败(找不到模块),而不是像 npm 那样 “向上查找并意外引用顶层版本”。这强制开发者必须显式声明依赖,避免隐藏的版本问题。
0808
peerDependencies
peerDependencies
是 package.json
中的一个字段,用于声明当前包(通常是插件、工具库)与宿主环境(使用该包的项目)中其他依赖的兼容版本要求。
它的核心作用是:
- 告诉宿主项目:“我需要你提供这些依赖,且版本必须符合我的要求”
- 避免依赖重复安装(例如:React 插件不需要自己安装 React,而是使用宿主项目已有的 React)
- 确保兼容性(例如:插件明确要求 React ≥17,避免宿主使用 React 16 导致冲突)
0811
LockSupport
是一个用来创建锁和其他同步工具类的基本线程阻塞原语
和wait/notify对比
1 |
|
换成 LockSupport
1 |
|
可以发现 LockSupport 不像是 wait/notify 那样必须要在 synchronized 下才能使用
此外,LockSupport 相比于 wait/notify ,还解决了顺序问题,看下面这段代码:
1 |
|
上面这段代码,我们模拟先 notify 再 wait,结果就是 waitThread 永远地阻塞住了,程序无法正常结束
但是如果换用 LockSupport
1 |
|
我们先 unpark 再 park,结果可以发现虽然 unparkThread 先执行了 unpark 尝试唤醒,但是此时 parkThread 还在睡,还没 park 操作。但是不影响,2s 后 parkThread 调用 park 后立刻就被唤醒了
这也是 park/unpark
和 wait/notify
的另一个核心区别:不论调用顺序,只要成对出现,必然可以唤醒线程
总结
通过 LockSupport 的 park/unpark
和 wait/notify
的对比,我们可以简单总结:他们都可以实现线程的等待和唤醒。但是区别在于:
park/unpark
在任何地方都能用,而wait/notify
只能在 synchronized 下使用park/unpark
调用顺序无关,只要成对出现必然可以唤醒等待的线程,而wait/notify
必须按顺序,先 wait 然后 notify,如果先 notify 再 wait 会导致后来 wait 的线程一直无法被唤醒
0812
GSON忽略指定字段
偶现请求参数丢失
结合网上相同案例排查,可能是在异步操作前,将RequestContextHolder丢入线程中导致的,详见整理后的博客
0818
String的不可变性
如何做到的不可变性
1 |
|
直接看源码,源码中定义了 private 和 final 这两点需要重点关注
private
:外部无法直接访问该数组,避免了直接修改,而且String
类没有提供任何修改数组内部值的方法final
:数组引用 value 一旦初始化后,无法指向新的数组对象
如果只有 final 意味着数组本身的元素理论上可被修改,但是在 private 的作用下以及 String 类并未暴露 public 的方法提供修改 value 数组的方式,所以二者共同作用,限制了 String 类不可变
String 类暴露的任何看似修改字符串的操作,例如 concat
toUpperCase
实际上都是创建了新的 String 对象,而不是在原先的对象上进行了修改
为什么需要不可变
字符串常量池
JVM为了节省内存设计了字符串常量池,相同字面值内容的字符串,都会引用到字符串常量池中相同的那一份(只会存一份)
1 |
|
若字符串可变,修改 s1
会导致相同字面值的 s2
的值也被意外修改,破坏常量池的设计初衷
不可变性保证了字符串常量池中的对象可以安全共享
为了线程安全
常量因为不可变,天生线程安全,不存在并发问题
为了哈希表哈希计算可靠
String 常常被用作 HashMap 的Key,而 HashMap 的高效查询就是依赖其 key 的 hashCode
以 String 作为 key 保证了 key 不可变,在对象生命周期内的每一次 hash 索引都是准确稳定的
可变字符串
就是我们常说的 StringBuffer 和 StringBuilder
如果是频繁修改拼接字符串,String 的不可变性会导致产生很多临时的新的 String 对象
而可变字符串本质上会直接修改 value 数组的内容,不会产生临时 String 对象
0819
fastjson反序列化误判字段
问题描述
1 |
|
前端拿到响应的时候,结果如下
1 |
|
原因分析
当 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 修饰类常常用于设计不可变类
不可变类是指实例创建后,其内部状态(属性值)不能被修改的类(如 String
、Integer
)。其核心是 “状态不可变”,而非 “是否被 final 修饰”
final class 不一定是 不可变类
不可变类 也不一定是 final class
设计模式
开发中常见的设计模式:
单例:Calendar,配置文件映射类
模板方法:AQS
策略模式
观察者模式
工厂模式
装饰器模式
Serializable
导论
这是一个接口,没有定义任何的接口方法,意味着这个接口仅仅是起到了标记的作用
那么什么时候会需要使用到这个接口呢?这就需要提到序列化和反序列化了
序列化是指我们将程序中的 Java 对象转换为字节流的这个过程,比如写文件,发请求
反序列化则相反,是指字节流映射回 Java 实体对象的过程
很常见的业务场景就是:在将数据导出到文件(例如 Excel 文件)的时候,就是序列化;将本地文件导入到系统,就是对应文件到 JVM 内存的反序列化过程
为什么需要
简单粗暴的理解,源码中就是有这个标记的判断,额米有就是会报错
不管是用成熟的写文件框架(例如 EasyExcel )还是发请求,本质上都会调用 JDK 的 OutputStream.writeObject0()
1 |
|
因此如果有序列化对象的需求,需要在对象定义时实现java.io.Serializable
这个接口
serialVersionUID
在阅读JDK的一些源码的时候也经常会看到,只要是实现了Serializable
接口的对象,都会定义一串”意义不明”的随机UID
1 |
|
这个看似 “意义不明” 的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 |
|
@Bean
是方法级别注解,只能做用在方法上
@Bean
是 “编程式” 注解,作用是告诉 Spring:“这个方法的返回值需要被你管理,请将其作为 Bean 放入容器”。
@Bean
必须定义在 @Configuration
标注的配置类 或 @Component
标注的类 中(推荐前者,因为 @Configuration
会通过 CGLIB 增强,保证 Bean 的单例性),用于手动控制 Bean 的创建逻辑
1 |
|
使用场景最佳实践
@Component
与 @Bean
二者从设计上的初衷就不同
@Component:适用于 “自定义类” 的自动注册,当开发的是自己项目中的类(如 UserService
、OrderRepository
),且这些类的初始化逻辑简单(无复杂参数、无需调用第三方 API)时,使用 @Component
(或其衍生注解)+ 组件扫描,能让 Spring 自动完成 Bean 注册,减少手动配置代码
而 @Bean:适用于 “非自定义类” 或 “复杂初始化” 的手动注册,常常用在:
- 第三方类的 Bean 注册:因为我们无法修改第三方库的源码(如
RedisTemplate
、HttpClient
、MyBatis
的SqlSessionFactory
),不能在这些类上标注@Component
,此时必须通过@Bean
手动创建实例并注册到Spring容器中使用 - 复杂初始化逻辑:即使是自定义类,若初始化需要复杂逻辑(如动态参数、条件判断、调用其他服务获取配置),
@Component
无法满足(只能依赖默认构造或@Autowired
注入),而@Bean
可在方法内编写任意逻辑。
补充: 自定义类复杂初始化的Bean注入对比
这里针对【如果是自定义类复杂初始化逻辑】的情况,需要使用@Bean
的方式,下面是代码案例
需求描述
我们以 “自定义支付客户端” 为例:
假设现在这个自定义支付客户端在初始化的时候需要根据环境(开发 / 生产)动态选择支付网关地址、调用配置中心获取密钥、初始化连接池,且需支持 “是否启用沙箱模式” 的条件判断 —— 这些复杂逻辑用 @Component
难以实现,而 @Bean
可优雅应对
- 动态参数:支付网关地址(开发环境
dev-url
/ 生产环境prod-url
)从配置文件读取,而非硬编码 - 条件判断:若配置
pay.sandbox.enable=true
,则启用沙箱模式(跳过真实签名校验);否则启用生产模式(严格校验) - 依赖外部服务:支付密钥需从 “配置中心服务”动态获取,而非直接写在配置文件
- 资源初始化:初始化支付连接池(设置最大连接数、超时时间),确保客户端性能
假设我们的配置文件内容如下:
1 |
|
@Bean实现
自定义类
1 |
|
配置服务Service
这个配置服务同样注入到Spring容器中,我们的支付类在实例化时、注入之前需要调用这个配置服务设置字段值
1 |
|
实例化逻辑
在配置类中,我们使用 @Bean
实例化我们的自定义支付类
1 |
|
@Component实现
若强行用 @Component
标注 PayClient
,会面临以下不可解决的问题:
- 动态参数无法灵活选择:
@Component
只能通过@Value
直接注入单一值(如@Value("${pay.gateway.dev-url}")
),无法根据activeEnv
的值动态切换dev-url
/prod-url
。 - 条件判断无法嵌入:
@Component
无法在初始化时添加 “是否启用沙箱模式” 的逻辑,只能在业务方法中判断,导致客户端实例创建时就携带无效配置(如生产环境仍加载沙箱参数)。 - 依赖外部服务获取配置困难:
若用@Component
,为了实现调用配置中心服务,我们需在PayClient
中@Autowired
配置中心服务,再通过@PostConstruct
初始化密钥:
1 |
|
总结
@Bean
方式注入的Bean,本质上是Spring反射扫描并调用我们编写的方法后将方法的返回值作为bean注入的
相比于 @Component
,@Bean
更适合:
- 第三方包里不会自动注入的类
- 自定义的类、但是这个类由于业务关系,初始化的时候依赖比较多
虽然实现和配置较为复杂(因为还需要编写一个额外的@Configuration
的配置类供Spring扫描)但更灵活
(已整理为笔记)
0826
观察者模式
整理为博客
0829
复习单例模式,重新手写,最佳实践:静态内部类(写法简单,无并发问题,性能高)
重载和重写
重载 Overload
在一个类中(或者父类和子类之间),相同方法名,必须不同的参数列表(参数个数,参数类型)和可选不同的返回值,可选不同的访问修饰符,会构成方法的重载
所以判断两个方法是否构成重载的关键依据是:方法名相同,并且参数列表必须不同(其中包括参数个数或是不同参数类型的顺序,与参数名称无关)
例如如下的两段构成了重载
1 |
|
至于二者的访问修饰符、抛出的异常以及返回值无关,相同或者不同都无所谓
重写
子类中,对父类(接口)已有的方法进行重新实现
发生重写时,方法名称,参数列表、必须完全相同,返回值可以
总结
对比维度 | 重载(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