Java开发工程师笔试经验贴【高频知识】

一、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 最重要的两个语义:

  1. 可见性
  2. 一定程度上的禁止重排序

例如:

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% 怎么查?
↓
如果涉及数据库怎么办?
↓
如果通知服务失败怎么办?
↓
如何设计成高并发、可恢复的生产系统?

掌握这条完整链路,比单独记住某一道选择题的答案更有价值。

相关推荐
木井巳1 小时前
【记忆化搜索】最长递增子序列
java·算法·leetcode·深度优先·剪枝·推荐算法
杨杨杨大侠2 小时前
知识库已经有了,Java 程序员还要做什么?Spring AI RAG 实战
java·openai·ai编程
伊信2 小时前
Spring AI 基础学习与应用
java·后端
夜郎king2 小时前
基于 Java + Playwright 实现网站自动访问与数据采集(以腾讯云开发者社区为例)
java·playwright·网页数据获取
Anastasiozzzz2 小时前
重新定义 Agent 基建:Redis 在现代 AI 与智能体系统中的工程实践
java·人工智能·redis·ai
careathers2 小时前
【数据结构】队列
java·数据结构
步行cgn2 小时前
Spring 通过 factory-method 实例化 Bean 详解与常见错误排查
java·后端
lv__pf2 小时前
seata【实战 msb】
java·开发语言·后端
cxoptics2 小时前
偏振片(起偏器/检偏器)怎么区分?
java