基础知识收集

Redis

1.lua脚本如何实现原子性操作

Lua 脚本实现原子性的根本原因,在于 Redis 的单线程命令执行模型

  1. 排他性执行(阻塞队列): 当 Redis 接收到一个 EVALEVALSHA 命令来执行 Lua 脚本时,它会将整个脚本视为一个**“超级命令”**。在单线程的主事件循环中,一旦这个脚本开始执行,Redis 就会阻塞其他所有客户端的请求
  2. 不可中断: 在脚本完全执行完毕(或达到超时时间)之前,任何其他命令都无法插队到执行队列中。这就保证了脚本内部的 GetIfSet 等一系列操作是被打包成一个绝对隔离的整体执行的。
  3. 对比 Redis 传统事务 (MULTI/EXEC): Redis 传统的事务只是把命令按顺序放进队列,虽然执行时也不会被打断,但它缺乏逻辑判断能力。你无法在 MULTI 中“根据第一步 GET 的结果,决定第二步是否执行 SET”。而 Lua 是图灵完备的编程语言,完美弥补了这一缺陷。

2.lock 与 tryLock 的核心区别

lock():阻塞式重试

  • 语义:一定要拿到锁,拿不到就死等。
  • 底层机制
    1. 尝试通过 Lua 脚本获取锁(保证原子性)。
    2. 如果获取失败,会订阅(Subscribe)Redis 的一个 Channel,进入等待状态。
    3. 当锁释放时,Redis 发布消息通知订阅者,线程再次唤醒尝试竞争。
  • 适用场景:对业务成功率要求极高,且能容忍一定延迟的任务,如每日凌晨的财务结算。

tryLock():非阻塞/限时尝试

  • 语义:能拿就拿,拿不到就算了,或者在规定的时间内试一下。
  • 底层机制
    1. 立刻尝试获取锁,失败则返回 false(无参版本)。
    2. 带参数版本(如 tryLock(10s, 30s))会在设定的 waitTime 内通过 循环 + 信号量 的方式反复尝试。
  • 适用场景:高并发下的秒杀、抢购。与其让大量线程阻塞耗尽资源,不如直接告知用户“系统繁忙”。

3.看门狗

如果我们手动设置锁的过期时间(例如 30s),但业务逻辑因为 GC 抖动或网络延迟运行了 35s,锁就会提前释放,导致其他线程进入临界区,引发并发安全问题。

工作流程

看门狗本质上是一个后台定时任务(TimeWheel 时间轮)

  1. 自动续期:当线程成功获取锁且未显式指定过期时间时,看门狗会生效。它默认设置锁的 leaseTime 为 30s。
  2. 三分之一触发:看门狗每隔 internalLockLeaseTime / 3(默认 10s)检查一次。
  3. 续命:如果当前线程还持有锁,看门狗会发送 Lua 脚本将 Redis 中的 key 过期时间重置回 30s。
  4. 自动终止:当业务执行完毕调用 unlock(),或者进程宕机,看门狗任务停止,锁最终会自然过期释放。

MQ

1.消息的可靠性如何保证

一条消息的生命周期拆解为三个阶段:生产阶段、存储阶段、消费阶段,分别进行针对性的设计。

生成阶段:

业务代码执行成功了(比如订单落库了),但消息发到 MQ 的半路上因为网络原因丢了。

  1. 基础防线:开启确认机制(ACK / Confirm)
  • 机制:不要使用默认的“发后即忘(Fire-and-Forget)”模式。必须开启生产者的确认机制(如 RabbitMQ 的 Publisher Confirms 或 Kafka 的 acks=all)。
  • 原理:消息发送后,阻塞等待(或注册异步回调)MQ Broker 返回的确认回执。只有收到明确的 ACK,才认为发送成功;如果收到 NACK 或超时,则进行重试。
  1. 核心大招:本地消息表(Outbox Pattern) / 事务消息

如果单纯用 Confirm 机制,依然解决不了“写数据库和发消息不是原子操作”的问题(比如数据库事务提交了,但刚好 JVM 宕机,没来得及发消息)。

  • 本地消息表(经典方案): 在业务数据库中建一张 message_log 表。业务操作和记录消息日志在同一个本地事务中提交。后台启一个定时任务(或使用 Canal 监听 Binlog),不断扫描状态为“未发送”的消息投递到 MQ,收到 MQ 的 ACK 后再将状态改为“已发送”。
  • RocketMQ 事务消息(原生支持): 利用 RocketMQ 的半消息(Half Message)和回查机制,MQ 服务端会主动来询问业务系统的事务执行状态,从而保证本地事务与消息发送的最终一致性。

存储阶段:

这个阶段的痛点是:消息安全到达 MQ 了,但 MQ 刚存进内存还没落地,服务器就断电了。

  1. 持久化机制(Persistence)

必须将队列、交换机以及消息本身都设置为持久化。

  • 权衡:为了极致的可靠性,可以配置为同步刷盘(写进磁盘才返回 ACK),但这会导致吞吐量断崖式下跌。在绝大多数互联网场景下,我们会选择异步刷盘(先写进 OS Cache 就返回 ACK,由操作系统决定何时刷入磁盘),牺牲极小概率的可靠性来换取成倍的性能。
  1. 高可用集群与副本复制(Replication)

单机持久化依然防不住磁盘损坏。

  • 机制:必须采用集群部署。比如 Kafka 的 ISR(In-Sync Replicas)机制,设置 min.insync.replicas > 1,并且配合生产端的 acks=all。这意味着消息不仅要写入主节点,还必须同步到至少一个从节点,才给生产者返回成功。即便主节点瞬间物理毁灭,数据也依然存在。

消费阶段:

  1. 坚决关闭自动 ACK
  • 机制:大多数框架默认是开启自动 ACK 的(只要把消息推给你,MQ 就删数据)。在核心业务中,必须改为手动 ACK(Manual ACK)
  • 原理:只有当消费者这边的业务逻辑(比如扣减库存、更新数据库)全部成功提交事务后,才向 MQ 发送确认。如果抛出异常,则拒绝该消息(Nack / Reject),让 MQ 重新投递。
  1. 兜底方案:死信队列(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 的核心组成架构

  1. 类加载子系统 (Class Loader Subsystem)

这是 JVM 的“入口”。它负责从文件系统或网络中加载 .class 文件,并将其转化为内存中的数据结构。

  • 加载 (Loading):通过双亲委派机制寻找并读入字节码。
  • 链接 (Linking):分为 验证(确保格式正确)、准备(为静态变量分配内存并赋初始零值)、解析(将符号引用转为直接引用)。
  • 初始化 (Initialization):执行类构造器 <clinit> 方法,为静态变量赋真实的业务初始值。
  1. 运行时数据区 (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 采用了不同的垃圾回收算法(这被称为“分代收集理论”)。简单来拆解一下:

  1. 年轻代 (Young Generation) —— 核心策略:标记-复制算法 (Copying)

年轻代是新对象诞生的地方。这里的特点是:每次垃圾回收时,都有大批对象死去(存活率通常不到 10%)。

  • 采用算法标记-复制算法
  • 具体怎么做
    1. JVM 将年轻代内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间(默认比例是 8:1:1)。
    2. 每次只使用 Eden 和其中一块 Survivor。
    3. 当发生垃圾回收(Minor GC)时,JVM 会把 Eden 和那块正在使用的 Survivor 中依然存活的对象,一次性全部分配(复制)到另一块空闲的 Survivor 空间上。
    4. 最后直接清空 Eden 和刚才用过的 Survivor 空间。
  • 为什么用这个算法? 因为年轻代存活的对象极少,复制这些少量对象的成本非常低。而且复制过去后是整齐排列的,绝对不会产生内存碎片,下一次分配大对象时速度极快。
  1. 老年代 (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 QueueAeron。这些高性能工具常用于量化交易系统,延迟可以达到微秒级。

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.哈希表冲突怎么解决

  1. 链地址法(Chaining / 拉链法)
  • 核心原理:哈希表的每个槽位不仅可以存元素,还可以是一个链表的头节点。发生冲突时,将新元素追加到该槽位对应的链表中。
  • Java 源码印证java.util.HashMapConcurrentHashMap 都是最典型的代表。
  • 资深视角:传统的拉链法有个致命伤——如果发生了严重的哈希碰撞(甚至是被恶意攻击),某个槽位的链表会无限变长,导致查询性能从 O(1) 暴跌到 O(n)。所以 JDK 1.8 做出了史诗级优化:当链表长度超过 8 且整个哈希表容量达到 64 时,链表会转化为红黑树,将最坏情况下的查询复杂度控制在 O(log n)。
  1. 开放寻址法(Open Addressing)
  • 核心原理:整个哈希表就是一个大数组,不借助外部的链表。一旦发生冲突,就顺着数组往后找,直到找到下一个空的槽位。寻找空位的方法包括:线性探测(挨个找)、二次探测(跳跃找)和双重哈希。
  • Java 源码印证ThreadLocal 内部的定制版哈希表 ThreadLocalMap 使用的就是基于线性探测的开放寻址法
  • 优缺点剖析
    • 优势:由于数据全集中在一个数组里,非常契合现代 CPU 的缓存局部性原理(Cache Line),内存读取极快;而且省去了链表节点带来的指针内存开销。
    • 劣势:容易产生“堆积(Clustering)”现象,即冲突的元素连成一片,导致后续插入越来越慢。同时,删除元素极其痛苦,不能直接清空,必须引入“墓碑(Tombstone)”标记,否则会切断其他冲突元素的探测路径。
  1. 再哈希法(Rehashing)
  • 核心原理:预先准备好一组不同的哈希函数。当第一个哈希函数计算的位置冲突时,换第二个哈希函数再算一次,依次类推,直到找到空位。
  • 特点:这种方法使得数据分布更加均匀,极大地减少了聚集现象,但代价是消耗了更多的 CPU 计算时间。
  1. 公共溢出区法
  • 核心原理:在内存中划分为两块区域:“基本表”和“溢出表”。所有没有冲突的元素存在基本表,凡是发生冲突的元素,不管它原本的哈希值是多少,一律扔进溢出表里顺序存放。
  • 特点:实现极其简单,但在高冲突率的场景下,溢出表的顺序遍历会成为性能的绝对瓶颈。

如何降低冲突概率才真正体现基本功。以 Java HashMap 为例,它做了两招极佳的预防:

  1. 扰动函数 (Perturbation Function):源码中的 (h = key.hashCode()) ^ (h >>> 16)。它将特征值的高 16 位和低 16 位进行异或,确保即使在哈希表很小的情况下,对象哈希值的高位特征也能参与到寻址运算中,极大降低了低位碰撞的概率。
  2. 负载因子的考量:默认负载因子设定为 0.75。这是 JDK 开发者在大量测试后得出的空间与时间的黄金折中。根据泊松分布,在 0.75 的负载下,链表长度达到 8 的概率不足千万分之一。

3.ThreadLocalMap 为什么不像 HashMap 那样使用拉链法,而是选择了开放寻址法

  1. 核心原因:与“弱引用(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)。线性探测天然适合这种连续内存的段落扫描与清理。
  1. 极致的 CPU 缓存亲和性(Cache Locality)

资深工程师写代码,眼睛里不仅有内存,还要有 CPU 缓存(L1/L2/L3 Cache)。

  • 拉链法:需要额外的 Node 对象包装数据,Node 之间通过 next 指针连接。这些节点在堆内存中是零散分布的。CPU 每次顺着指针去读下一个节点,大概率会发生 Cache Miss。
  • 开放寻址法ThreadLocalMap 是一个纯粹的一维数组。根据 CPU 的空间局部性原理(Cache Line,通常一次加载 64 字节),当 CPU 命中数组的一个元素时,会顺便把相邻的元素也加载到高速缓存中。因为线性探测就是挨个往下找,这种结构在发生哈希冲突时的寻址速度极快。并且,省去了 next 指针的内存开销。
  1. 数据规模决定了不需要“重武器”

技术选型永远不能脱离业务场景。

  • HashMap:是通用数据结构,面向的是海量数据,经常要存数万、数十万的键值对。如果用开放寻址法,冲突堆积(聚集效应)会导致性能灾难。
  • ThreadLocalMap:它的宿主是具体的某一个 Thread。在绝大多数真实的业务场景中,一个线程所挂载的 ThreadLocal 变量通常只有几个到十几个(比如存一下 UserContext、TraceId、Connection 等)。 对于这种极小数据量的哈希表,拉链法的指针开销和复杂性变成了累赘,而开放寻址法的轻量、快速则展现得淋漓尽致。
  1. 神奇的斐波那契散列(黄金分割)

你可能会问:开放寻址法最怕的就是哈希冲突引发的“聚集效应”(多个元素连成一片),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 为黑)。红黑节点的意义在于支撑红黑树的自平衡规则

在面试中,我会向面试官清晰地阐述这五大绝对原则

  1. 颜色属性:每个节点要么是红色,要么是黑色。
  2. 根节点守则:根节点必须是黑色
  3. 叶子节点守则:所有的叶子节点(这里指的是隐式的空节点,即 NIL 节点)都是黑色
  4. 红色不相连:如果一个节点是红色的,那么它的两个子节点必须是黑色的。(即:一条路径上不能出现相邻的两个红色节点)。
  5. 完美黑色平衡(最核心):从任意一个节点出发,到达其所有后代 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 编译后,在字节码中会有两种不同的表现形式:

  1. 同步代码块: 编译器会在代码块的开始和结束位置,分别插入 monitorentermonitorexit 指令。
    • 值得注意的是,通常会有一个 monitorenter 对应两个 monitorexit。一个是正常退出时释放锁,另一个是发生异常时(编译器自动生成的异常表)释放锁,确保不会死锁。
  2. 同步方法: 编译器并不会使用指令,而是在方法的常量池中设置一个 ACC_SYNCHRONIZED 访问标志。JVM 在调用方法时,只要看到这个标志,就会隐式地去获取锁。

无论是指令还是标志,底层的核心都指向了一个东西:Monitor(管程)

在 HotSpot 虚拟机的 C++ 源码中,每个 Java 对象在创建时,都会在对象头(Object Header)中隐式关联一个 ObjectMonitor 对象。这个 ObjectMonitor 就是实现并发安全的关键,它内部有几个核心属性:

  • _owner:指向当前持有该锁的线程。
  • _EntryList:一个队列,存放所有被阻塞、等待获取锁的线程。
  • _WaitSet:一个队列,存放调用了 wait() 方法被挂起的线程。
  • _count:记录锁的重入次数(所以 synchronized可重入锁)。

防并发冲突的执行逻辑: 当多线程并发访问时,都会尝试执行 monitorenter

  1. 如果 _count 为 0,说明锁空闲。线程将 _owner 指向自己,并将 _count 加 1,成功获取锁。
  2. 如果 _owner 已经是自己,说明是锁重入,直接将 _count 加 1。
  3. 如果 _owner 是其他线程,当前线程就会被放入 _EntryList 阻塞排队,交出 CPU 执行权。

结论:正是因为 ObjectMonitor 这种排他性的设计,保证了同一时刻只能有一个线程执行临界区代码,从而实现了原子性。同时,JVM 规范还规定了锁的可见性语义:在释放锁(monitorexit)之前,必须将本地缓存中的共享变量刷新回主内存;在获取锁时,必须从主内存重新读取,这就解决了多线程的数据可见性问题。

因为早期的 synchronized 被称为重量级锁,它的阻塞和唤醒依赖操作系统的 Mutex Lock(互斥量),这需要将线程从用户态频繁切换到内核态,性能开销极其巨大。

为了提升性能,JDK 1.6 引入了基于对象头(Mark Word)*的*锁升级机制。这也是 synchronized 现在性能非常强悍的原因。锁的状态会随着竞争的激烈程度逐步升级,且不可逆**:

  1. 无锁状态:对象刚创建,没有线程来抢。
  2. 偏向锁 (Biased Locking)
    • 场景:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。
    • 原理:当线程第一次拿锁时,利用 CAS 操作将自己的 Thread ID 记录在对象的 Mark Word 中。以后这个线程再来,只要对比 ID 匹配,连 CAS 操作都不用做,直接放行。此时加锁开销几乎为 0。
  3. 轻量级锁 (Lightweight Locking)
    • 场景:有其他线程来竞争锁了,偏向锁被打破。但竞争不激烈,大家只是交替执行。
    • 原理:线程会在自己的栈帧中创建一个 Lock Record(锁记录),然后通过 CAS 自旋(循环重试)的方式去尝试将对象的 Mark Word 更新为指向自己锁记录的指针。这种方式不阻塞线程,不发生内核态切换,只是消耗一点 CPU 空转时间。
  4. 重量级锁 (Heavyweight Locking)
    • 场景:自旋失败多次,或者竞争非常激烈(大量线程涌入)。
    • 原理:轻量级锁膨胀为重量级锁,底层回归到 ObjectMonitor 机制。未抢到锁的线程不再自旋消耗 CPU,而是直接被操作系统挂起阻塞。