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



