一、Java 基础
1. Java 基础:面试最容易"看起来简单,实际上有坑"的部分
1. ==、equals() 和 hashCode()
这是 Java 面试中的经典三件套。
==
对于基本类型:
java
int a = 10;
int b = 10;
System.out.println(a == b); // true
比较的是值。
对于对象:
java
User a = new User();
User b = new User();
System.out.println(a == b); // false
比较的是对象引用是否相同。
equals()
默认情况下,Object.equals() 本质上也是比较引用。
但是很多类重写了它,例如:
java
String a = new String("hello");
String b = new String("hello");
System.out.println(a.equals(b)); // true
因为 String 重写了 equals(),比较的是内容。
hashCode()
如果对象要放进:
java
HashMap
HashSet
就必须理解:
equals()决定逻辑上是否相等,hashCode()决定哈希容器首先去哪个桶找。
最重要的契约:
text
a.equals(b) == true
↓
a.hashCode() == b.hashCode()
反过来不成立:
text
hashCode 相同
≠
equals 一定相同
典型错误
java
class K {
int id;
K(int id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
return o instanceof K
&& ((K) o).id == id;
}
}
然后:
java
Map<K, String> map = new HashMap<>();
map.put(new K(1), "hello");
System.out.println(
map.get(new K(1))
);
可能得到:
text
null
原因:
text
new K(1)
↓
equals 相等
但是两个对象没有重写 hashCode():
text
hashCode 不一定相同
↓
HashMap 定位到了不同桶
↓
找不到
正确:
java
@Override
public int hashCode() {
return Integer.hashCode(id);
}
2. Java 泛型:类型擦除
下面代码:
java
public static void m(List<String> list) {}
public static void m(List<Integer> list) {}
无法编译。
因为 Java 泛型存在类型擦除。
编译后类似:
java
m(List)
m(List)
签名冲突。
可以这样重载
java
m(List<String> list)
m(Set<Integer> set)
因为擦除后:
text
m(List)
m(Set)
不同。
返回值不能用于重载
不能:
java
int m() {}
double m() {}
因为方法签名不包含返回值。
3. Java 初始化顺序
例如:
java
class Test {
int x = init();
int y = 10;
int init() {
return y + 5;
}
}
很多人会认为:
text
x = 15
实际上:
text
x = 5
y = 10
原因是对象初始化过程中,实例字段首先获得默认值:
text
int → 0
reference → null
boolean → false
然后按照字段声明顺序执行显式初始化。
因此:
text
x = init()
执行时:
text
y = 0
所以:
text
x = 0 + 5 = 5
然后:
text
y = 10
4. finally 与 return
这是非常典型的 Java 面试题。
java
int[] f() {
int[] x = {0};
try {
return x;
} finally {
x[0] = 100;
}
}
返回:
text
[100]
因为:
text
return x
保存的是:
text
对象引用
之后 finally 修改的仍然是同一个对象。
但是:
java
int f() {
int x = 0;
try {
return x;
} finally {
x = 100;
}
}
返回:
text
0
因为 primitive 的返回值已经计算出来了。
另外一个经典坑
java
try {
return 1;
} finally {
return 2;
}
最终:
text
2
因为 finally 中的 return 会覆盖之前的 return。
实际工程中:
尽量不要在 finally 中 return。
5. i = i++ + ++i
例如:
java
int i = 0;
i = i++ + ++i;
Java 按照操作数从左到右求值。
第一步:
text
i++
表达式值:
text
0
但是 i 变成:
text
1
第二步:
text
++i
先加:
text
i = 2
表达式值:
text
2
所以:
text
0 + 2 = 2
最后:
text
i = 2
二、集合
1. HashMap、HashSet 与集合的坑
1. HashSet 判断的是 equals/hashCode,不是 ==
例如:
java
List<Integer> a = Arrays.asList(1, 2, 3);
List<Integer> b = Arrays.asList(1, 2, 3);
Set<List<Integer>> set = new HashSet<>();
set.add(a);
System.out.println(a == b); // false
System.out.println(set.contains(b)); // true
原因:
text
a == b
比较引用,所以 false。
但是:
text
a.equals(b)
比较 List 中的元素,所以 true。
因此:
text
HashSet
↓
hashCode
↓
equals
最终认为 b 已经存在。
2. 数组是一个经典反例
java
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(a.equals(b)); // false
数组没有按照内容重写 equals()。
需要:
java
Arrays.equals(a, b)
哈希值需要:
java
Arrays.hashCode(a)
所以看到:
java
int[] a
int[] b
千万不要下意识认为:
text
内容一样 → equals 一样
2. HashMap 为什么不能直接用于多线程?
例如线上:
java
Map<String, User> cache = new HashMap<>();
多个线程同时:
java
cache.get(key);
cache.put(key, value);
这是不安全的。
主要可以从三个层次理解。
1. 可见性问题
线程 A:
java
cache.put("u1", newUser);
线程 B:
java
cache.get("u1");
没有同步机制时,不能依赖线程 B 及时看到线程 A 的修改。
根本原因:
没有建立正确的 happens-before 关系。
这可能表现为:
text
偶发读取旧数据
2. 数据竞争与更新覆盖
多个线程同时修改:
java
cache.put("u1", userA);
cache.put("u1", userB);
最终结果可能取决于并发时序。
更复杂的业务逻辑:
java
if (!cache.containsKey(key)) {
cache.put(key, value);
}
即使换成:
java
ConcurrentHashMap
这两个操作仍然不是一个原子操作。
应该使用:
java
cache.putIfAbsent(key, value);
或者:
java
cache.computeIfAbsent(key, k -> load(k));
3. HashMap 内部结构并发损坏
HashMap 内部涉及:
text
table
↓
bucket
↓
Node
↓
链表 / 红黑树
并发修改,尤其是扩容时,可能导致结构异常。
历史 JDK 实现中极端情况下甚至可能形成链表环:
text
A → B → C
↑ ↓
└───┘
导致遍历异常甚至 CPU 长时间占用。
所以线上看到:
text
CPU 突然 400%
同时发现:
java
HashMap
被多线程共享,绝对值得重点调查。
3. HashMap 多线程场景应该怎么解决?
方案一:synchronizedMap
java
Map<String, User> cache =
Collections.synchronizedMap(new HashMap<>());
优点:
- 修改成本低
- 使用简单
- 线程安全
缺点:
text
大量并发
↓
同一把锁
↓
锁竞争
适合低并发、简单场景。
4. ConcurrentHashMap
java
Map<String, User> cache =
new ConcurrentHashMap<>();
适合高并发读写。
例如:
java
cache.put(key, value);
cache.get(key);
cache.remove(key);
都可以安全并发使用。
更重要的是使用原子 API:
java
cache.putIfAbsent(key, value);
cache.computeIfAbsent(key, k -> loadUser(k));
cache.compute(key, (k, v) -> ...);
一个重要原则
ConcurrentHashMap 线程安全 ≠ 你的业务逻辑自动线程安全。
例如:
java
User user = cache.get(id);
user.setName("Tom");
Map 是安全的。
但是:
text
User 本身
可能仍然被多个线程同时修改。
5. Java 增强 for 循环删除元素
例如:
java
List<Integer> list =
new ArrayList<>(Arrays.asList(1, 2, 3, 4));
for (Integer x : list) {
if (x == 3) {
list.remove(x);
}
}
这里有两个知识点。
第一:x == 3
x 是:
java
Integer
3 是:
java
int
这里会发生自动拆箱:
text
Integer → int
所以:
java
x == 3
判断的是值。
第二:remove 到底删除什么?
因为:
java
x
是 Integer,所以:
java
list.remove(x)
调用的是:
java
remove(Object)
不是:
java
remove(int index)
所以删除的是:
text
值 3
而不是:
text
下标 3
第三:为什么会 ConcurrentModificationException?
增强 for 本质上使用 Iterator:
java
Iterator<Integer> it = list.iterator();
遍历过程中直接:
java
list.remove(...)
修改了集合结构。
Iterator 检测到:
text
modCount != expectedModCount
于是可能抛:
text
ConcurrentModificationException
正确:
java
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
Integer x = it.next();
if (x == 3) {
it.remove();
}
}
或者:
java
list.removeIf(x -> x == 3);
三、Java 并发
1. volatile:到底解决什么问题?
volatile 最重要的两个语义:
- 可见性
- 一定程度上的禁止重排序
例如:
java
volatile boolean running = true;
线程 A:
java
running = false;
线程 B:
java
while (running) {
}
可以保证线程 B 能观察到更新。
为什么 volatile 不能解决所有并发问题?
例如:
java
volatile int count;
count++;
看起来:
text
count++
是一步。
实际上:
text
读取 count
↓
+1
↓
写回 count
两个线程可能:
text
T1:读取 10
T2:读取 10
T1:写 11
T2:写 11
最终:
text
11
而不是:
text
12
所以:
volatile 解决"看得见",不能解决复合操作的原子性。
2. volatile + 不可变快照为什么又可以?
例如:
java
private volatile Map<String, User> cache =
Map.of();
不能这样:
java
cache.put(...);
而是:
java
Map<String, User> newCache = new HashMap<>();
// 完整构造
newCache.put(...);
cache = Map.copyOf(newCache);
流程:
text
构造新对象
↓
对象构造期间没人读写
↓
volatile 一次性发布
↓
读线程读取完整快照
所以:
volatile + mutable object不等于线程安全;
volatile + immutable snapshot可以构造一种非常优秀的读多写少模型。
特别适合:
- 配置
- 字典
- 路由表
- 黑白名单
- 规则
3. ExecutorService:submit 为什么不直接打印异常?
java
ExecutorService es =
Executors.newFixedThreadPool(1);
es.submit(() -> {
throw new RuntimeException("boom");
});
很多人认为会直接看到:
text
RuntimeException
但 submit() 通常会把异常捕获并放进 Future。
因此:
java
Future<?> future = es.submit(...);
future.get();
才会通过:
text
ExecutionException
暴露出来。
execute 和 submit 的区别
java
executor.execute(task);
异常通常会交给线程的:
text
UncaughtExceptionHandler
而:
java
executor.submit(task);
异常被封装在 Future 中。
面试可以直接记:
text
execute
→ 异常直接走线程异常处理机制
submit
→ 异常进入 Future
→ get() 时通过 ExecutionException 抛出
4. Java 并发:volatile 与 synchronized
例如:
java
class Task {
boolean running = true;
void start() {
new Thread(() -> {
while (running) {
}
System.out.println("stopped");
}).start();
}
void stop() {
running = false;
}
}
问题:
stop()执行后,线程是否一定能看到false?
不一定。
改成:
java
volatile boolean running = true;
即可建立可见性保证。
为什么 sleep/yield 不可靠?
有人可能会写:
java
while (running) {
Thread.sleep(1);
}
或者:
java
Thread.yield();
这些都不能作为正确的内存同步机制。
正确的解决方案应该是:
text
volatile
synchronized
Lock
Atomic*
等建立 happens-before 的机制。
四、JVM 与线上排障
1. Java 线上 CPU 400%:完整排查体系
假设:
text
8 核机器
Java CPU = 400%
首先要知道:
text
Linux top:
100% ≈ 一个 CPU 核心
所以:
text
400%
≈ 4 个核心
2. 第一步:找哪个进程
bash
top
或者:
bash
ps -eo pid,ppid,cmd,%cpu,%mem --sort=-%cpu | head -20
找到:
text
PID = 12345
3. 第二步:找 Java 内部哪个线程
bash
top -H -p 12345
或者:
bash
ps -Lp 12345 -o pid,tid,pcpu,stat,comm --sort=-pcpu | head
例如:
text
PID TID %CPU
12345 12367 198
12345 12368 101
12345 12369 95
此时:
text
12367
是操作系统线程 ID。
4. 第三步:TID 转成 jstack 的 nid
Linux:
text
TID = 12367
转换:
bash
printf "%x\n" 12367
结果:
text
303f
然后:
bash
jstack 12345 > /tmp/jstack.txt
搜索:
bash
grep -A 20 -B 5 "nid=0x303f" /tmp/jstack.txt
因为:
text
Linux TID
→ 十进制
jstack nid
→ 十六进制
这是线上排查必须熟悉的操作。
5. 判断是不是 GC 导致 CPU 高
执行:
bash
jstat -gcutil 12345 1000
重点观察:
text
YGC
YGCT
FGC
FGCT
GCT
如果:
text
YGC
FGC
GCT
在短时间内快速增长,同时 GC 线程 CPU 很高:
text
GC Thread
G1 Conc#
VM Thread
那么应该重点调查 GC。
6. 判断是不是死循环/热点代码
连续获取线程栈:
bash
jstack 12345 > /tmp/jstack1.txt
sleep 3
jstack 12345 > /tmp/jstack2.txt
sleep 3
jstack 12345 > /tmp/jstack3.txt
如果高 CPU 线程始终:
text
RUNNABLE
并且一直停留在:
text
OrderService.java:128
例如:
text
calculate()
↓
process()
↓
OrderService.java:128
那么非常值得怀疑:
text
死循环
或者
CPU 密集型热点计算
7. async-profiler:进一步定位 CPU 热点
如果环境允许,可以使用:
bash
./profiler.sh -d 30 -e cpu -f /tmp/cpu.html 12345
得到 CPU 火焰图。
如果大量 CPU 集中:
text
Service.calculate()
就说明业务代码是热点。
如果大量集中:
text
GC Thread
G1
VM Thread
则应该继续调查 JVM/GC。
五、数据库(MySQL / InnoDB)
1. 数据库:InnoDB 复合索引
假设:
sql
INDEX idx(a, b)
查询:
sql
SELECT *
FROM t
WHERE a > 10
AND b = 5;
很多人会机械地记:
最左匹配,所以 a、b 都能用。
这并不准确。
为什么?
索引:
text
(a, b)
实际上是按照:
text
a
↓
b
的字典序排列。
类似:
text
(1, 1)
(1, 5)
(1, 9)
(2, 1)
(2, 5)
(3, 5)
...
现在条件:
text
a > 10
意味着:
text
从 a=10 之后的一大段区域
而 b=5 并不能在整个这个区域中形成一个连续的全局范围。
因此:
sql
(a,b)
对于:
sql
a > 10 AND b = 5
通常无法像:
sql
b = 5 AND a > 10
那样充分利用 b 来缩小索引范围。
2. 为什么 (b,a) 更适合?
如果索引:
sql
INDEX idx(b, a)
查询:
sql
WHERE b = 5
AND a > 10
索引顺序:
text
b = 5
↓
只进入 b=5 的区域
↓
a > 10
↓
继续做范围扫描
这符合非常经典的索引设计原则:
等值条件尽量放在范围条件之前。
当然最终是否使用某个索引仍由优化器根据:
- 数据分布
- 基数
- 选择性
- 表大小
- 统计信息
综合判断。
3. InnoDB 为什么 SELECT * 不一定是覆盖索引?
假设:
sql
INDEX(a,b)
查询:
sql
SELECT *
FROM t
WHERE a = 1;
二级索引中一般保存:
text
a
b
主键
但:
text
其他列
不一定存在。
所以:
text
二级索引
↓
找到主键
↓
回表
↓
聚簇索引获取完整记录
因此:
有索引 ≠ 不需要回表。
只有查询所需的全部列都能从索引中直接得到时,才可能形成覆盖索引。
4. 数据库并发更新:为什么 UPDATE 不容易丢失?
假设:
sql
UPDATE account
SET balance = balance - 100
WHERE id = 1;
另一个事务:
sql
UPDATE account
SET balance = balance - 50
WHERE id = 1;
在 InnoDB 下,UPDATE 是当前读并涉及行级锁。
例如初始:
text
1000
可能执行:
text
T1:锁住 id=1
T1:1000 - 100 = 900
T2:等待
T1:commit
T2:获得锁
T2:900 - 50 = 850
最终:
text
850
而不是:
text
900
5. 真正危险的是"应用层读改写"
例如:
sql
SELECT balance
FROM account
WHERE id = 1;
应用程序:
java
balance = balance - 100;
再:
sql
UPDATE account
SET balance = 900
WHERE id = 1;
如果两个线程都读取:
text
1000
可能:
text
T1:读 1000
T2:读 1000
T1:写 900
T2:写 950
最终:
text
950
T1 的更新丢失。
6. 解决数据库 Lost Update
悲观锁
sql
SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;
然后在事务内修改。
乐观锁
增加:
text
version
例如:
sql
UPDATE account
SET balance = ?,
version = version + 1
WHERE id = 1
AND version = ?;
如果:
text
affected rows = 0
说明发生并发冲突,需要:
text
重试 / 返回失败
六、网络(HTTP / HTTPS)
1. HTTP / HTTPS:为什么第一次请求慢?
一次典型 HTTPS 请求可能经历:
text
DNS
↓
TCP 三次握手
↓
TLS 握手
↓
HTTP 请求
↓
服务器处理
↓
HTTP 响应
因此第一次访问比连接复用慢很正常。
2. TLS 1.2 和 TLS 1.3
粗略记忆:
text
TLS 1.2
→ 完整握手通常需要更多 RTT
TLS 1.3
→ 完整握手通常减少到 1 RTT
TLS 1.3 Session Resumption
→ 可以使用 0-RTT Early Data
注意:
0-RTT 并不是说 TCP 三次握手消失了。
它指的是 TLS 层可以更早发送应用数据。
3. HTTP Keep-Alive
如果每次请求都重新:
text
TCP
↓
TLS
↓
HTTP
开销很大。
HTTP Keep-Alive / HTTP/2 连接复用可以:
text
建立一次连接
↓
多个请求复用
减少:
text
TCP handshake
TLS handshake
4. DNS 也可能影响首次请求
DNS:
text
域名
↓
DNS 查询
↓
IP
如果 DNS 慢,用户感知到的首次请求时间也会变长。
实际性能分析时要区分:
text
DNS
TCP
TLS
TTFB
Content Download
不要把所有延迟都归因于服务器业务代码。
5. HTTP 502 与 504
502 Bad Gateway
核心含义:
网关/代理从上游服务那里没有获得一个有效的响应。
例如:
text
Nginx
↓
Java
Java:
text
进程崩溃
连接被重置
启动失败
返回异常协议
Nginx 可能返回:
text
502
504 Gateway Timeout
核心含义:
网关等待上游服务响应超时。
所以:
text
502
→ 上游响应/连接异常
504
→ 上游响应太慢 / 超时
不要记成:
text
502 = 所有网关错误
七、算法(滑动窗口)
1. 算法:最长连续子数组
题目:
text
nums
maxKinds
maxPerValue
要求找到最长连续 [left,right]:
text
不同数字 <= maxKinds
并且:
每个数字出现次数 <= maxPerValue
这是标准:
滑动窗口 + 哈希表
核心代码
cpp
pair<int, int> longestSubarray(
const vector<int>& nums,
int maxKinds,
int maxPerValue
) {
unordered_map<int, int> freq;
int left = 0;
int kinds = 0;
int bestLeft = 0;
int bestRight = -1;
for (int right = 0; right < nums.size(); ++right) {
if (freq[nums[right]] == 0) {
++kinds;
}
++freq[nums[right]];
while (kinds > maxKinds ||
freq[nums[right]] > maxPerValue) {
--freq[nums[left]];
if (freq[nums[left]] == 0) {
--kinds;
}
++left;
}
int len = right - left + 1;
int bestLen = bestRight - bestLeft + 1;
if (len > bestLen ||
(len == bestLen && left < bestLeft)) {
bestLeft = left;
bestRight = right;
}
}
return {bestLeft, bestRight};
}
2. 为什么这个算法是 O(n)?
表面看:
text
for
+
while
可能觉得:
text
O(n²)
实际上:
text
right
→ 最多走 n 次
left
→ 最多走 n 次
所以:
text
总移动次数 ≤ 2n
因此:
O(n) O(n) O(n)
哈希表平均:
text
get / put / erase
→ O(1)
额外空间:
O(n) O(n) O(n)
3. 滑动窗口为什么保证连续?
因为整个算法只维护:
text
[left, right]
移动方式只有:
text
left++
right++
不会从中间删除元素。
所以永远不会出现:
text
A B C D E
变成:
text
A C E
再把它们拼起来。
这是:
连续子数组问题
和:
子序列问题
的重要区别。
八、系统设计
1. 订单支付后的代码为什么需要设计模式?
原始代码:
java
void afterOrderPaid(Order o) {
stockService.deduct(o);
notifyService.push(o);
if (o.hasPromotion()) {
pointService.grant(o);
}
log.info("order done {}", o.getId());
}
问题核心:
text
一个方法
↓
太多职责
↓
不同失败策略
↓
不同事务边界
↓
不同执行方式
2. 策略模式拆分订单动作
定义:
java
public interface OrderPaidAction {
void execute(Order order);
}
不同动作:
text
OrderPaidAction
│
├── DeductStockAction
├── GrantPointAction
├── NotifyAction
└── CouponAction
这样新增:
text
CouponAction
而不是继续往:
text
afterOrderPaid()
里面塞代码。
3. 最重要的是事务边界
库存:
text
必须成功
↓
失败回滚
通知:
text
失败不能影响主流程
↓
异步
因此不能简单:
java
@Transactional
void afterOrderPaid() {
stockService.deduct();
notifyService.push();
pointService.grant();
}
因为:
text
通知失败
↓
整个事务 rollback
可能导致库存也被回滚。
4. 正确的业务划分
核心事务
例如:
text
订单状态
库存
账户余额
核心积分
使用:
java
@Transactional
例如:
java
@Transactional
public void executeCore(Order order) {
stockService.deduct(order);
pointService.grant(order);
}
如果:
text
stock success
point fail
则:
text
ROLLBACK
5. 非核心操作
例如:
text
短信
Push
邮件
埋点
通知
更适合:
text
事务提交
↓
Event / MQ
↓
异步消费者
↓
执行
↓
失败重试
这样:
text
通知失败
不会影响:
text
订单核心事务
6. 生产环境进一步考虑 Outbox
仅仅:
java
eventPublisher.publish(...)
还存在:
text
数据库 COMMIT
↓
程序突然宕机
↓
事件没发出去
于是:
text
订单成功
但通知丢失
可以采用:
Transactional Outbox
事务中:
text
订单更新
+
Outbox Event
一起提交:
sql
BEGIN;
UPDATE orders ...;
UPDATE stock ...;
INSERT INTO outbox_event (...);
COMMIT;
后台:
text
Outbox
↓
发送 MQ
↓
成功
↓
标记 DONE
失败
↓
Retry
这是一种非常经典的:
本地事务 + 最终一致性
方案。
面试思维框架:最值得记住的判断原则
与其背几十道题,不如记住下面这些判断原则。
看到并发问题
先问:
text
有没有共享数据?
↓
有没有同步?
↓
有没有可见性问题?
有没有原子性问题?
有没有竞态条件?
看到 HashMap
先问:
text
单线程?
↓
HashMap
多线程?
↓
ConcurrentHashMap
读多写少、整体刷新?
↓
volatile + immutable snapshot
真正的缓存?
↓
Caffeine
看到数据库并发更新
先问:
text
是不是"读 → 修改 → 写"?
↓
可能 Lost Update
↓
FOR UPDATE / 乐观锁 / 原子 UPDATE
看到联合索引
先问:
text
索引顺序是什么?
↓
等值条件在哪里?
↓
范围条件在哪里?
↓
是否需要回表?
↓
是否形成覆盖索引?
看到线上 CPU 飙高
永远按:
text
机器
↓
进程
↓
线程
↓
TID
↓
十进制 → 十六进制
↓
jstack nid
↓
线程栈
↓
GC / 业务代码
↓
profiler
看到业务代码越来越长
问:
text
职责是否过多?
↓
是否可以抽象 Action / Strategy?
↓
哪些是核心事务?
哪些可以异步?
↓
失败是否需要回滚?
↓
是否需要幂等?
↓
是否需要 MQ / Outbox?
最终复习重点
如果你接下来准备 Java 后端/开发岗面试 ,这一轮题目里我认为最应该真正掌握、而不是死记答案的是这 10 个核心知识点:
| 优先级 | 知识点 | 必须掌握到什么程度 |
|---|---|---|
| ⭐⭐⭐⭐⭐ | HashMap / ConcurrentHashMap |
为什么 HashMap 线程不安全、CHM 如何解决 |
| ⭐⭐⭐⭐⭐ | volatile / JMM |
可见性、原子性、happens-before |
| ⭐⭐⭐⭐⭐ | JVM CPU 排查 | top → top -H → jstack → nid → GC/profiler |
| ⭐⭐⭐⭐⭐ | MySQL 事务并发 | 当前读、锁、Lost Update、乐观锁 |
| ⭐⭐⭐⭐ | 联合索引 | (a,b) 为什么遇到范围条件后有区别 |
| ⭐⭐⭐⭐ | 事务边界 | 什么应该同步、什么应该异步 |
| ⭐⭐⭐⭐ | equals/hashCode |
HashMap/HashSet 的底层逻辑 |
| ⭐⭐⭐⭐ | 滑动窗口 | 为什么是 O(n),为什么保证连续 |
| ⭐⭐⭐ | ExecutorService | execute vs submit |
| ⭐⭐⭐ | 设计模式 | Strategy + 事务/MQ/Outbox 的组合 |
其中尤其建议把这条链路练熟:
Java 并发 → JVM → 数据库并发 → 分布式事务 → 线上排障
因为这几个知识点不是孤立的。真正的后端面试往往会从:
text
"这个代码线程安全吗?"
一路追问到:
text
为什么?
↓
JMM 怎么保证?
↓
线上出现 CPU 400% 怎么查?
↓
如果涉及数据库怎么办?
↓
如果通知服务失败怎么办?
↓
如何设计成高并发、可恢复的生产系统?
掌握这条完整链路,比单独记住某一道选择题的答案更有价值。