基础知识收集

基础知识收集
mengnankkzhouRedis
1.lua脚本如何实现原子性操作
Lua 脚本实现原子性的根本原因,在于 Redis 的单线程命令执行模型。
- 排他性执行(阻塞队列): 当 Redis 接收到一个
EVAL或EVALSHA命令来执行 Lua 脚本时,它会将整个脚本视为一个**“超级命令”**。在单线程的主事件循环中,一旦这个脚本开始执行,Redis 就会阻塞其他所有客户端的请求。 - 不可中断: 在脚本完全执行完毕(或达到超时时间)之前,任何其他命令都无法插队到执行队列中。这就保证了脚本内部的
Get、If、Set等一系列操作是被打包成一个绝对隔离的整体执行的。 - 对比 Redis 传统事务 (
MULTI/EXEC): Redis 传统的事务只是把命令按顺序放进队列,虽然执行时也不会被打断,但它缺乏逻辑判断能力。你无法在MULTI中“根据第一步GET的结果,决定第二步是否执行SET”。而 Lua 是图灵完备的编程语言,完美弥补了这一缺陷。
2.lock 与 tryLock 的核心区别
lock():阻塞式重试
- 语义:一定要拿到锁,拿不到就死等。
- 底层机制:
- 尝试通过 Lua 脚本获取锁(保证原子性)。
- 如果获取失败,会订阅(Subscribe)Redis 的一个 Channel,进入等待状态。
- 当锁释放时,Redis 发布消息通知订阅者,线程再次唤醒尝试竞争。
- 适用场景:对业务成功率要求极高,且能容忍一定延迟的任务,如每日凌晨的财务结算。
tryLock():非阻塞/限时尝试
- 语义:能拿就拿,拿不到就算了,或者在规定的时间内试一下。
- 底层机制:
- 立刻尝试获取锁,失败则返回
false(无参版本)。 - 带参数版本(如
tryLock(10s, 30s))会在设定的waitTime内通过 循环 + 信号量 的方式反复尝试。
- 立刻尝试获取锁,失败则返回
- 适用场景:高并发下的秒杀、抢购。与其让大量线程阻塞耗尽资源,不如直接告知用户“系统繁忙”。
3.看门狗
如果我们手动设置锁的过期时间(例如 30s),但业务逻辑因为 GC 抖动或网络延迟运行了 35s,锁就会提前释放,导致其他线程进入临界区,引发并发安全问题。
工作流程
看门狗本质上是一个后台定时任务(TimeWheel 时间轮)。
- 自动续期:当线程成功获取锁且未显式指定过期时间时,看门狗会生效。它默认设置锁的
leaseTime为 30s。 - 三分之一触发:看门狗每隔
internalLockLeaseTime / 3(默认 10s)检查一次。 - 续命:如果当前线程还持有锁,看门狗会发送 Lua 脚本将 Redis 中的 key 过期时间重置回 30s。
- 自动终止:当业务执行完毕调用
unlock(),或者进程宕机,看门狗任务停止,锁最终会自然过期释放。
MQ
1.消息的可靠性如何保证
一条消息的生命周期拆解为三个阶段:生产阶段、存储阶段、消费阶段,分别进行针对性的设计。
生成阶段:
业务代码执行成功了(比如订单落库了),但消息发到 MQ 的半路上因为网络原因丢了。
- 基础防线:开启确认机制(ACK / Confirm)
- 机制:不要使用默认的“发后即忘(Fire-and-Forget)”模式。必须开启生产者的确认机制(如 RabbitMQ 的
Publisher Confirms或 Kafka 的acks=all)。 - 原理:消息发送后,阻塞等待(或注册异步回调)MQ Broker 返回的确认回执。只有收到明确的
ACK,才认为发送成功;如果收到NACK或超时,则进行重试。
- 核心大招:本地消息表(Outbox Pattern) / 事务消息
如果单纯用 Confirm 机制,依然解决不了“写数据库和发消息不是原子操作”的问题(比如数据库事务提交了,但刚好 JVM 宕机,没来得及发消息)。
- 本地消息表(经典方案): 在业务数据库中建一张
message_log表。业务操作和记录消息日志在同一个本地事务中提交。后台启一个定时任务(或使用 Canal 监听 Binlog),不断扫描状态为“未发送”的消息投递到 MQ,收到 MQ 的 ACK 后再将状态改为“已发送”。 - RocketMQ 事务消息(原生支持): 利用 RocketMQ 的半消息(Half Message)和回查机制,MQ 服务端会主动来询问业务系统的事务执行状态,从而保证本地事务与消息发送的最终一致性。
存储阶段:
这个阶段的痛点是:消息安全到达 MQ 了,但 MQ 刚存进内存还没落地,服务器就断电了。
- 持久化机制(Persistence)
必须将队列、交换机以及消息本身都设置为持久化。
- 权衡:为了极致的可靠性,可以配置为同步刷盘(写进磁盘才返回 ACK),但这会导致吞吐量断崖式下跌。在绝大多数互联网场景下,我们会选择异步刷盘(先写进 OS Cache 就返回 ACK,由操作系统决定何时刷入磁盘),牺牲极小概率的可靠性来换取成倍的性能。
- 高可用集群与副本复制(Replication)
单机持久化依然防不住磁盘损坏。
- 机制:必须采用集群部署。比如 Kafka 的 ISR(In-Sync Replicas)机制,设置
min.insync.replicas > 1,并且配合生产端的acks=all。这意味着消息不仅要写入主节点,还必须同步到至少一个从节点,才给生产者返回成功。即便主节点瞬间物理毁灭,数据也依然存在。
消费阶段:
- 坚决关闭自动 ACK
- 机制:大多数框架默认是开启自动 ACK 的(只要把消息推给你,MQ 就删数据)。在核心业务中,必须改为手动 ACK(Manual ACK)。
- 原理:只有当消费者这边的业务逻辑(比如扣减库存、更新数据库)全部成功提交事务后,才向 MQ 发送确认。如果抛出异常,则拒绝该消息(
Nack/Reject),让 MQ 重新投递。
- 兜底方案:死信队列(DLQ - Dead Letter Queue)
- 如果一条消息因为数据格式错误,导致消费者每次重试都报错,就会形成无限死循环(毒药消息)。
- 机制:我们需要设置重试次数上限(比如 3 次)。达到上限后,不再重试,而是将这条消息丢入一个专门的“死信队列”。由人工介入排查 bug,或者由专门的降级程序去处理这个 DLQ。
2.消息的幂等性
第零步:确立唯一凭证(绝对不信任 MQ 自带的 Message ID)
很多初级开发会直接用 RocketMQ 或 RabbitMQ 生成的 Message ID 来做去重。这是个巨大的坑!
- 原因:在某些重试场景下(例如客户端超时未收到 ACK 而重发),MQ 可能会生成一个全新的
Message ID,但它装载的业务数据依然是同一份。 - 资深做法:强依赖业务侧生成的全局唯一 ID(Biz ID)。 例如
业务类型_订单号_流水号(如PAY_ORDER_20231024001)。我们将这个 Biz ID 贯穿整个防线。
第一道防线:并发拦截层(Redis 分布式锁)
这道防线的目的是挡住同一毫秒内涌入的极端并发重复请求(例如用户手抖疯狂点击,前端防抖失效,导致多条相同消息瞬间到达)。
- 实现机制:利用 Redis 的
SETNX(或 Redisson)加锁。- 消费者拿到消息后,第一时间执行:
setnx(biz_id, "LOCKED", expire_time)。 - 如果返回 1(加锁成功),说明是第一次处理,放行到后端业务逻辑。
- 如果返回 0(加锁失败),说明当前已经有另一个线程在处理这条消息了,直接抛出异常,让 MQ 稍后重试(因为前一个线程可能执行失败,不能立刻默默丢弃)。
- 消费者拿到消息后,第一时间执行:
- 防坑经验:锁的过期时间(expire_time)必须大于业务逻辑的最大执行时间,通常设置为 5-10 分钟。
第二道防线:业务逻辑层(状态机与版本号校验)
当请求穿透 Redis(比如第一条消息处理完释放了锁,一小时后 MQ 又把这条消息重投了一次),就会来到第二道防线。这里完全依赖业务逻辑来判断。
- 针对 Update(更新)操作:引入状态机或乐观锁。
- 状态机流转:例如订单发货逻辑。在执行发货前,先查数据库判断
if (order.status != 'PAID')。如果状态已经是SHIPPED(已发货),说明重复消费了,直接 return success,告诉 MQ 消费成功,终止流程。 - 乐观锁:扣减库存时带上版本号或旧值。
UPDATE stock SET amount = amount - 1, version = version + 1 WHERE sku = 1001 AND version = old_version。如果更新行数为 0,说明被处理过了。
- 状态机流转:例如订单发货逻辑。在执行发货前,先查数据库判断
第三道防线:持久化兜底层(数据库唯一索引 / 去重表)
这是最坚固的物理防线。即便代码逻辑写出了 Bug,数据库也必须守住底线。
- 针对 Insert(插入)操作:唯一索引(Unique Key)。
- 在核心流水表(如
payment_record)上,为biz_id建立唯一索引。 - 当重复消息试图插入同样的
biz_id时,数据库会抛出DuplicateKeyException。我们在代码中捕获这个异常,不作为报错,而是打印一条警告日志后,向 MQ 返回 ACK 消费成功。
- 在核心流水表(如
- 通用解法:防重表(Deduplication Table)。
- 专门建一张
message_dedup表,以biz_id为唯一主键。 - 将“插入防重表”和“执行核心业务”放在同一个本地数据库事务中。重复消费时,插入防重表必报主键冲突,导致整个事务回滚,确保业务数据绝对安全。
- 专门建一张
JVM
1.频繁发生Full GC如何解决?
Full GC 频繁意味着老年代(Old Generation)空间迅速填满。我们要从“内存泄漏”和“内存溢出/配置不当”两个维度去排查。
在重启应用之前,必须获取关键的诊断信息,否则问题原因会随进程消失。
- dump 堆内存:
jmap -dump:format=b,file=heap.hprof <pid>。这是分析内存泄漏最直接的证据。 - 查看 GC 日志:确认是哪种回收(CMS、G1 还是 Parallel)。
- 实时查看内存状态:
jstat -gcutil <pid> 1000 10。观察各个分区的增长速度。
通常 Full GC 频繁由以下四个核心原因导致:
A. 内存泄漏 (Memory Leak)
- 现象:每次 Full GC 之后,老年代回收掉的内存越来越少。
- 根源:长生命周期的对象(如静态 Map、未关闭的资源、ThreadLocal)持有了短生命周期对象的引用。
- 工具:使用 MAT (Memory Analyzer Tool) 或 JProfiler 打开 dump 文件,查看
Dominator Tree(支配树),找出占用内存最大的对象路径。
B. 大对象直接进入老年代
- 现象:Young GC 并不频繁,但 Full GC 突然增加。
- 根源:代码中产生了巨大的数组或长字符串。由于 Eden 区放不下,或者超过了
-XX:PretenureSizeThreshold配置,这些对象直接跳过新生代进入老年代。 - 对策:检查代码中是否有单次查询数据库返回几十万条记录的情况。
C. 晋升失败 (Promotion Failed)
- 现象:Young GC 后,幸存者区(Survivor)太小,导致对象过早晋升到老年代。或者老年代碎片化严重(CMS 特有)。
- 根源:
-XX:MaxTenuringThreshold设置过小,或者 Survivor 区空间被挤占。 - 对策:适当调大 Survivor 区,或调整晋升年龄限制。
D. 内存分配不合理 (Parameter Mismatch)
- 现象:堆总大小设置太小,导致稍微有一点流量波动就触发 Full GC。
- 对策:检查
-Xms和-Xmx是否一致(建议设为一致,避免频繁动态伸缩),并确保堆大小满足业务并发量的需求。
方案一:JVM 参数调优
如果是因为 CMS 碎片化:
- 开启
-XX:+UseCMSCompactAtFullCollection,在 Full GC 后进行碎片整理。 - 如果是 G1 收集器,尝试调整
-XX:MaxGCPauseMillis(设置合理的停顿目标)。
方案二:代码重构
- 减少临时对象:避免在循环内创建大量对象。
- 缓存控制:使用带过期时间的缓存(如 Caffeine 或 Redis),严禁使用无界
HashMap做本地缓存。 - 数据库查询:增加
limit,避免大表全扫描。
方案三:排查外部触发
System.gc():检查代码或第三方库是否显式调用了它。- 对策:配置
-XX:+DisableExplicitGC来禁用它。
观察趋势:用 jstat 确认是老年代(O)还是持久代/元空间(M)触发的 GC。
定位代码:如果是 O 区,看是否有大对象;如果是 M 区,看是否动态生成了过多的类(如反射、CGLIB 滥用)。
灰度验证:修改参数或代码后,在单台机器观察 GC 频率是否下降,确认无效再扩大部分。
2.JVM 的核心组成架构
- 类加载子系统 (Class Loader Subsystem)
这是 JVM 的“入口”。它负责从文件系统或网络中加载 .class 文件,并将其转化为内存中的数据结构。
- 加载 (Loading):通过双亲委派机制寻找并读入字节码。
- 链接 (Linking):分为 验证(确保格式正确)、准备(为静态变量分配内存并赋初始零值)、解析(将符号引用转为直接引用)。
- 初始化 (Initialization):执行类构造器
<clinit>方法,为静态变量赋真实的业务初始值。
- 运行时数据区 (Runtime Data Areas)
这就是你提到的“那五个部分”,它们是 JVM 的“存储仓库”:
- 线程私有:程序计数器、Java 虚拟机栈、本地方法栈。
- 线程共享:堆(Heap)、方法区(Method Area / 元空间 Metaspace)。
除了上面的五个还包括:
执行引擎 (Execution Engine)
如果说数据区是仓库,执行引擎就是“加工厂”。它负责解释或编译执行字节码:
- 解释器 (Interpreter):逐条将字节码翻译成机器码执行,启动快,但运行效率相对较低。
- JIT 编译器 (Just-In-Time Compiler):这是 Java 高性能的核心。它会将热点代码(经常执行的代码)编译成本地机器码并缓存,极大提升运行速度。
- 垃圾回收器 (Garbage Collector):自动清理堆内存中不再使用的对象。它是执行引擎中最复杂的模块(如 G1, ZGC)。
本地方法接口 & 库 (JNI & Native Method Libraries)
- JNI (Java Native Interface):它是 Java 与 C/C++ 沟通的桥梁。Java 无法直接操作底层硬件,必须通过 JNI 调用操作系统提供的本地库(如文件 I/O、网络通信)。
- 本地方法库:C/C++ 编写的动态链接库(.dll 或 .so 文件),支撑 Java 核心 API 的底层实现。
直接内存 (Direct Memory)
- 虽然它不属于 JVM 运行时数据区规范的一部分,但被 Java 程序频繁使用(如 NIO 的
DirectByteBuffer)。它直接在操作系统内存中分配,绕过了 JVM 堆,减少了数据拷贝开销。
执行时控制组件
- 线程调度器:负责分配 CPU 时间片给各个 Java 线程。
- 信号处理/错误处理:处理如
StackOverflowError或系统中断信号。
3.说一下 JAVA 中的垃圾回收机制
Java 的垃圾回收(GC)机制就是 JVM 自动管理内存的过程,它负责识别并回收堆内存中那些不再被使用的对象,从而避免内存泄漏。
在 Java 中,我们不使用引用计数法(因为它无法解决循环引用的死锁问题),而是使用 可达性分析算法 (Reachability Analysis)。
- 核心逻辑:JVM 会选取一些被称为 GC Roots 的对象作为起点(比如线程栈中的局部变量、方法区中的静态变量和常量等)。从这些起点开始向下搜索,搜索走过的路径称为“引用链”。
- 判定标准:如果一个对象到 GC Roots 没有任何引用链相连(即不可达),那么这个对象就被判定为“垃圾”,即将被回收。
找到垃圾后,JVM 提供了三种基础的清理策略,它们各有优劣,是后续所有复杂垃圾回收器的基石:
- 标记-清除 (Mark-Sweep):把垃圾标记出来,直接清理掉。缺点:会产生大量不连续的内存碎片,导致后续大对象找不到足够的连续空间。
- 标记-复制 (Copying):将内存分为大小相等的两块,每次只使用其中一块。回收时,把存活的对象整齐地复制到另一块上,然后清空当前块。优点:没有碎片。缺点:内存利用率低(浪费了一半)。
- 标记-整理 (Mark-Compact):结合了前两者的优点。标记出存活对象后,将它们全部向内存空间的一端移动(压缩),然后直接清理掉边界以外的垃圾。优点:没有碎片,且充分利用内存。缺点:移动对象非常耗时(会引起应用程序的长时间暂停,即 STW)。
因为 JVM 发现了一个客观规律:“绝大多数对象都是朝生夕灭的,而熬过越多次垃圾收集的对象,就越难被回收。” 因此,JVM 将堆内存物理划分为不同的区域,因地制宜地采用不同的回收算法:
- 年轻代 (Young Generation):绝大多数新创建的对象都在这里。因为存活率极低,这里采用标记-复制算法(进一步优化为 Eden 区和两个 Survivor 区,比例通常是 8:1:1,极大提高了内存利用率)。这里的回收非常频繁,称为 Minor GC。
- 老年代 (Old Generation):存放生命周期长、占用空间大的对象(比如 Spring 里的单例 Bean、长连接对象)。当年轻代的对象熬过了多次 GC(默认 15 次),就会“晋升”到老年代。因为这里对象存活率高,通常采用标记-整理或标记-清除算法。这里的回收发生频率低,但耗时长,称为 Major GC / Full GC。
你好!这个问题非常切中 JVM 内存管理的要害。JVM 之所以要将堆内存划分为“年轻代”和“老年代”,根本原因是基于一个客观事实(弱分代假说):绝大多数对象都是“朝生夕灭”的,而熬过多次垃圾回收的对象通常会存活很久。
因为这两个区域里对象的“寿命”特征完全不同,所以 JVM 采用了不同的垃圾回收算法(这被称为“分代收集理论”)。简单来拆解一下:
- 年轻代 (Young Generation) —— 核心策略:标记-复制算法 (Copying)
年轻代是新对象诞生的地方。这里的特点是:每次垃圾回收时,都有大批对象死去(存活率通常不到 10%)。
- 采用算法:标记-复制算法。
- 具体怎么做:
- JVM 将年轻代内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间(默认比例是 8:1:1)。
- 每次只使用 Eden 和其中一块 Survivor。
- 当发生垃圾回收(Minor GC)时,JVM 会把 Eden 和那块正在使用的 Survivor 中依然存活的对象,一次性全部分配(复制)到另一块空闲的 Survivor 空间上。
- 最后直接清空 Eden 和刚才用过的 Survivor 空间。
- 为什么用这个算法? 因为年轻代存活的对象极少,复制这些少量对象的成本非常低。而且复制过去后是整齐排列的,绝对不会产生内存碎片,下一次分配大对象时速度极快。
- 老年代 (Old Generation) —— 核心策略:标记-清除 (Mark-Sweep) 或 标记-整理 (Mark-Compact)
老年代存放的是那些在年轻代熬过了多次 GC 依然存活的“老顽固”(比如 Spring 容器里的单例 Bean、长连接对象、大数组)。这里的特点是:对象存活率极高。
- 采用算法:不能用复制算法(因为存活对象太多,复制起来不仅极度消耗 CPU,而且还要浪费一半的空间来做备份)。因此采用 标记-清除 或 标记-整理 算法。
- 具体怎么做:
- 标记-清除 (如 CMS 收集器):先把没用的垃圾对象标记出来,然后直接原地把它们清理掉。优点是速度较快,停顿时间短;缺点是清理后会留下像“马蜂窝”一样的内存碎片。
- 标记-整理 (如 Parallel Old 收集器):标记出存活的对象后,让所有存活的对象都向内存空间的一端移动(像推土机一样压缩),然后直接清理掉边界以外的内存。优点是没有内存碎片;缺点是移动大量对象非常耗时,会导致较长的系统停顿(STW)。
- 为什么用这个算法? 因为老年代空间宝贵且对象稳定,只能采用这种空间利用率更高、尽量少移动对象的原地回收策略。
BASE
1.Java 进程之间怎么通信
首先我们先去说说进程间的通信方式:
管道,信号量,共享内存,消息队列,信号,套接字 mmap
然后JAVA呢?
跨网络/分布式进程通信:
RESTful API (HTTP/JSON):
- 特点:协议简单,跨语言支持好,生态丰富。
- 适用场景:外部接口暴露,或者对性能要求不是极高的内部微服务调用。
RPC (Remote Procedure Call):
- 代表技术:Dubbo、gRPC、Thrift。
- 深度剖析:gRPC 基于 HTTP/2,使用 Protocol Buffers 序列化,比 JSON 更小更快。其核心思想是让调用远程方法像调用本地方法一样透明。
消息队列 (MQ):
- 代表技术:Kafka、RabbitMQ、RocketMQ。
- 特点:解耦、异步、削峰填谷。通过发布/订阅模型实现进程间数据的最终一致性。
单机跨进程通信:
A. 共享内存 (Shared Memory) —— 最快
Java 本身没有直接的共享内存概念,但可以通过 MappedByteBuffer(内存映射文件)来实现。
- 原理:两个进程映射同一个物理文件到自己的虚拟内存地址空间。
- 库支持:Chronicle Queue 或 Aeron。这些高性能工具常用于量化交易系统,延迟可以达到微秒级。
B. Unix Domain Socket (UDS)
- 原理:在 Linux 环境下,UDS 不需要经过环回网络接口(Loopback),直接在内核空间交换数据。
- Java 支持:从 Java 16 开始,标准库正式支持
UnixDomainSocketAddress;在此之前通常使用 Netty 的EpollServerDomainSocketChannel。 - 优势:性能优于 TCP 本地回环(127.0.0.1),安全性更高。
基础/传统的 IPC 方式:
Socket (TCP/UDP):最底层的方式。即便在同一台机器,也可以通过 127.0.0.1 进行通信。
管道 (Pipes):
- 在 Java 中可以通过
ProcessBuilder创建子进程,并通过InputStream/OutputStream与子进程进行输入输出重定向。
RMI (Remote Method Invocation):
- Java 原生的远程调用。
- 资深评价:由于安全性问题和过度耦合(强依赖 Java 序列化),在现代开发中已基本被 RPC 框架取代,不推荐在新项目中使用。
2.哈希表冲突怎么解决
- 链地址法(Chaining / 拉链法)
- 核心原理:哈希表的每个槽位不仅可以存元素,还可以是一个链表的头节点。发生冲突时,将新元素追加到该槽位对应的链表中。
- Java 源码印证:
java.util.HashMap和ConcurrentHashMap都是最典型的代表。 - 资深视角:传统的拉链法有个致命伤——如果发生了严重的哈希碰撞(甚至是被恶意攻击),某个槽位的链表会无限变长,导致查询性能从 O(1) 暴跌到 O(n)。所以 JDK 1.8 做出了史诗级优化:当链表长度超过 8 且整个哈希表容量达到 64 时,链表会转化为红黑树,将最坏情况下的查询复杂度控制在 O(log n)。
- 开放寻址法(Open Addressing)
- 核心原理:整个哈希表就是一个大数组,不借助外部的链表。一旦发生冲突,就顺着数组往后找,直到找到下一个空的槽位。寻找空位的方法包括:线性探测(挨个找)、二次探测(跳跃找)和双重哈希。
- Java 源码印证:
ThreadLocal内部的定制版哈希表ThreadLocalMap使用的就是基于线性探测的开放寻址法。 - 优缺点剖析:
- 优势:由于数据全集中在一个数组里,非常契合现代 CPU 的缓存局部性原理(Cache Line),内存读取极快;而且省去了链表节点带来的指针内存开销。
- 劣势:容易产生“堆积(Clustering)”现象,即冲突的元素连成一片,导致后续插入越来越慢。同时,删除元素极其痛苦,不能直接清空,必须引入“墓碑(Tombstone)”标记,否则会切断其他冲突元素的探测路径。
- 再哈希法(Rehashing)
- 核心原理:预先准备好一组不同的哈希函数。当第一个哈希函数计算的位置冲突时,换第二个哈希函数再算一次,依次类推,直到找到空位。
- 特点:这种方法使得数据分布更加均匀,极大地减少了聚集现象,但代价是消耗了更多的 CPU 计算时间。
- 公共溢出区法
- 核心原理:在内存中划分为两块区域:“基本表”和“溢出表”。所有没有冲突的元素存在基本表,凡是发生冲突的元素,不管它原本的哈希值是多少,一律扔进溢出表里顺序存放。
- 特点:实现极其简单,但在高冲突率的场景下,溢出表的顺序遍历会成为性能的绝对瓶颈。
如何降低冲突概率才真正体现基本功。以 Java HashMap 为例,它做了两招极佳的预防:
- 扰动函数 (Perturbation Function):源码中的
(h = key.hashCode()) ^ (h >>> 16)。它将特征值的高 16 位和低 16 位进行异或,确保即使在哈希表很小的情况下,对象哈希值的高位特征也能参与到寻址运算中,极大降低了低位碰撞的概率。 - 负载因子的考量:默认负载因子设定为
0.75。这是 JDK 开发者在大量测试后得出的空间与时间的黄金折中。根据泊松分布,在 0.75 的负载下,链表长度达到 8 的概率不足千万分之一。
3.ThreadLocalMap 为什么不像 HashMap 那样使用拉链法,而是选择了开放寻址法
- 核心原因:与“弱引用(WeakReference)”的完美契合
这是最致命、也是最根本的原因。
ThreadLocalMap 的核心职责是为线程保存私有变量。为了防止哪怕开发者忘记清理导致的严重内存泄漏,ThreadLocalMap.Entry 被设计为继承自 WeakReference<ThreadLocal<?>>。也就是说,Key(ThreadLocal 实例本身)是弱引用的,一旦外部没有强引用指向它,下一次 GC 就会把 Key 回收掉,此时 Entry 的 Key 就变成了 null(被称为 Stale Entry,即“陈旧条目”)。
- 如果使用拉链法:当链表(或红黑树)深处的某个节点的 Key 被 GC 掉变成了
null,你要如何清理它?你需要遍历所有槽位,再遍历每条链表,维护前驱节点和后继节点的指针关系来删除它,这使得清理逻辑极其复杂且性能低下。 - 使用开放寻址法:数据全部在同一个一维数组里。
ThreadLocalMap顺水推舟地实现了一套极其优雅的“顺手牵羊清理机制(启发式清理)”。在执行get()、set()或remove()时,如果顺着数组往下找,遇到了 Key 为null的槽位(陈旧条目),内核会自动触发expungeStaleEntry()方法,把该位置以及附近连续的陈旧条目全部清空,并将后面的元素往前挪(Rehash)。线性探测天然适合这种连续内存的段落扫描与清理。
- 极致的 CPU 缓存亲和性(Cache Locality)
资深工程师写代码,眼睛里不仅有内存,还要有 CPU 缓存(L1/L2/L3 Cache)。
- 拉链法:需要额外的
Node对象包装数据,Node之间通过next指针连接。这些节点在堆内存中是零散分布的。CPU 每次顺着指针去读下一个节点,大概率会发生 Cache Miss。 - 开放寻址法:
ThreadLocalMap是一个纯粹的一维数组。根据 CPU 的空间局部性原理(Cache Line,通常一次加载 64 字节),当 CPU 命中数组的一个元素时,会顺便把相邻的元素也加载到高速缓存中。因为线性探测就是挨个往下找,这种结构在发生哈希冲突时的寻址速度极快。并且,省去了next指针的内存开销。
- 数据规模决定了不需要“重武器”
技术选型永远不能脱离业务场景。
- HashMap:是通用数据结构,面向的是海量数据,经常要存数万、数十万的键值对。如果用开放寻址法,冲突堆积(聚集效应)会导致性能灾难。
- ThreadLocalMap:它的宿主是具体的某一个 Thread。在绝大多数真实的业务场景中,一个线程所挂载的
ThreadLocal变量通常只有几个到十几个(比如存一下 UserContext、TraceId、Connection 等)。 对于这种极小数据量的哈希表,拉链法的指针开销和复杂性变成了累赘,而开放寻址法的轻量、快速则展现得淋漓尽致。
- 神奇的斐波那契散列(黄金分割)
你可能会问:开放寻址法最怕的就是哈希冲突引发的“聚集效应”(多个元素连成一片),ThreadLocalMap 怎么解决?
答案在 ThreadLocal 源码里的一个魔法数字:0x61c88647(HASH_INCREMENT)。 每创建一个新的 ThreadLocal,它的 threadLocalHashCode 就会在上一个的基础上累加这个魔法值。这个数字与黄金分割律(Fibonacci Hashing)密切相关。
它的神奇之处在于:以这个步长生成的哈希值,无论以任何 2 的幂次方作为数组大小进行取模(& (len - 1)),其散列结果都能完美且极其均匀地分布在数组的各个位置。既然天然的哈希算法已经让冲突概率降到了极低,那么开放寻址法最大的痛点(聚集效应)也就被巧妙化解了。
4.红黑树结构
痛点起源:最基础的**二叉查找树(BST)**在极端情况下(比如顺序插入 1, 2, 3, 4, 5),会退化成一条单向链表,查询时间复杂度从 $O(\log n)$ 暴跌到 $O(n)$。
初步解决:为了防止退化,前人发明了 AVL 树(严格平衡二叉树)。它要求左右子树的高度差不能超过 1。虽然查询极快,但“过于严苛”的平衡条件导致在频繁插入和删除时,需要进行大量的左旋、右旋操作,性能损耗严重。
终极折中(红黑树诞生):红黑树(Red-Black Tree)是一种弱平衡(或称近似平衡)的二叉查找树。它通过引入节点的“颜色”和一组精妙的规则,保证了从根到叶子的最长路径不会超过最短路径的两倍。
- 核心优势:它在查询速度(接近 AVL)和插入/删除速度(旋转次数少于 AVL)之间找到了完美的工程折中。它的增删改查时间复杂度都稳定在 $O(\log n)$。
节点本身在内存中多了一个标志位(通常用一个 boolean 值表示,true 为红,false 为黑)。红黑节点的意义在于支撑红黑树的自平衡规则。
在面试中,我会向面试官清晰地阐述这五大绝对原则:
- 颜色属性:每个节点要么是红色,要么是黑色。
- 根节点守则:根节点必须是黑色。
- 叶子节点守则:所有的叶子节点(这里指的是隐式的空节点,即 NIL 节点)都是黑色。
- 红色不相连:如果一个节点是红色的,那么它的两个子节点必须是黑色的。(即:一条路径上不能出现相邻的两个红色节点)。
- 完美黑色平衡(最核心):从任意一个节点出发,到达其所有后代 NIL 节点的简单路径上,包含的黑色节点数量必须绝对相同。
“红黑树,本质上是 2-3-4 树(一种多叉 B 树)的二叉树等价实现。”
在 2-3-4 树中,一个节点可以容纳 1 到 3 个元素。由于计算机内存更适合处理二叉结构,我们需要把多叉树映射为二叉树:
- 黑色节点:代表 2-3-4 树中的普通节点。
- 红色节点:代表它与其父节点是强绑定关系的,它们在逻辑上共同构成了一个 2-3-4 树中的“大节点”。红色节点其实是用来模拟 2-3-4 树内部的水平链接的。
通过将红色节点上提,红黑树在进行插入、删除导致的不平衡时,可以通过变色(消耗极低)来优先解决问题;只有当变色无法解决时,才会动用旋转(消耗较高)。这就是它在写入性能上碾压 AVL 树的根本原因。
JUC
1.Java 线程池有了解吗
corePoolSize (核心线程数):工厂的“正式员工”,即使没事做也不会被辞退。
maximumPoolSize (最大线程数):正式工忙不过来时,工厂能雇佣的最大员工数(含临时工)。
keepAliveTime (空闲存活时间):临时工没活干时,等待多久会被解雇。
unit (时间单位):存活时间的时间单位。
workQueue (任务队列):存放等待处理任务的“仓库”。
threadFactory (线程工厂):用于创建线程,通常我们会在这里给线程起个有意义的名字,方便线上排查问题。
handler (拒绝策略):仓库满了且人手也招满了,再来任务时的处理方案
workQueue 的选型直接影响系统的负载能力:
ArrayBlockingQueue:有界队列,可以防止资源耗尽。LinkedBlockingQueue:默认无界(长度为Integer.MAX_VALUE)。资深警示:在生产环境慎用默认无界队列,因为这可能导致任务积压,最终引发 OOM。SynchronousQueue:不存储任务的队列。每次插入必须等待一个移除操作。CachedThreadPool使用它,吞吐量极高。
AbortPolicy (默认):直接抛出异常,简单粗暴。
CallerRunsPolicy:让提交任务的线程(比如主线程)自己去执行。这是一种降级策略,既能减缓任务提交速度,又能保证任务不丢失。
DiscardOldestPolicy:丢弃队列里最老的任务,腾位子给新任务。
DiscardPolicy:直接丢掉,不留痕迹。
2.synchronized 的底层原理是什么,为什么这个关键字可以实现防并发冲突呢
我们在 Java 代码中写下 synchronized,经过 javac 编译后,在字节码中会有两种不同的表现形式:
- 同步代码块: 编译器会在代码块的开始和结束位置,分别插入
monitorenter和monitorexit指令。- 值得注意的是,通常会有一个
monitorenter对应两个monitorexit。一个是正常退出时释放锁,另一个是发生异常时(编译器自动生成的异常表)释放锁,确保不会死锁。
- 值得注意的是,通常会有一个
- 同步方法: 编译器并不会使用指令,而是在方法的常量池中设置一个
ACC_SYNCHRONIZED访问标志。JVM 在调用方法时,只要看到这个标志,就会隐式地去获取锁。
无论是指令还是标志,底层的核心都指向了一个东西:Monitor(管程)。
在 HotSpot 虚拟机的 C++ 源码中,每个 Java 对象在创建时,都会在对象头(Object Header)中隐式关联一个 ObjectMonitor 对象。这个 ObjectMonitor 就是实现并发安全的关键,它内部有几个核心属性:
_owner:指向当前持有该锁的线程。_EntryList:一个队列,存放所有被阻塞、等待获取锁的线程。_WaitSet:一个队列,存放调用了wait()方法被挂起的线程。_count:记录锁的重入次数(所以synchronized是可重入锁)。
防并发冲突的执行逻辑: 当多线程并发访问时,都会尝试执行 monitorenter:
- 如果
_count为 0,说明锁空闲。线程将_owner指向自己,并将_count加 1,成功获取锁。 - 如果
_owner已经是自己,说明是锁重入,直接将_count加 1。 - 如果
_owner是其他线程,当前线程就会被放入_EntryList阻塞排队,交出 CPU 执行权。
结论:正是因为 ObjectMonitor 这种排他性的设计,保证了同一时刻只能有一个线程执行临界区代码,从而实现了原子性。同时,JVM 规范还规定了锁的可见性语义:在释放锁(monitorexit)之前,必须将本地缓存中的共享变量刷新回主内存;在获取锁时,必须从主内存重新读取,这就解决了多线程的数据可见性问题。
因为早期的 synchronized 被称为重量级锁,它的阻塞和唤醒依赖操作系统的 Mutex Lock(互斥量),这需要将线程从用户态频繁切换到内核态,性能开销极其巨大。
为了提升性能,JDK 1.6 引入了基于对象头(Mark Word)*的*锁升级机制。这也是 synchronized 现在性能非常强悍的原因。锁的状态会随着竞争的激烈程度逐步升级,且不可逆**:
- 无锁状态:对象刚创建,没有线程来抢。
- 偏向锁 (Biased Locking):
- 场景:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。
- 原理:当线程第一次拿锁时,利用 CAS 操作将自己的 Thread ID 记录在对象的 Mark Word 中。以后这个线程再来,只要对比 ID 匹配,连 CAS 操作都不用做,直接放行。此时加锁开销几乎为 0。
- 轻量级锁 (Lightweight Locking):
- 场景:有其他线程来竞争锁了,偏向锁被打破。但竞争不激烈,大家只是交替执行。
- 原理:线程会在自己的栈帧中创建一个 Lock Record(锁记录),然后通过 CAS 自旋(循环重试)的方式去尝试将对象的 Mark Word 更新为指向自己锁记录的指针。这种方式不阻塞线程,不发生内核态切换,只是消耗一点 CPU 空转时间。
- 重量级锁 (Heavyweight Locking):
- 场景:自旋失败多次,或者竞争非常激烈(大量线程涌入)。
- 原理:轻量级锁膨胀为重量级锁,底层回归到
ObjectMonitor机制。未抢到锁的线程不再自旋消耗 CPU,而是直接被操作系统挂起阻塞。





