场景题,实践题目居多,考察实际能力。
1.Java基础功底
public class Test {
public static void main(String[] args) {
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); // 输出?
System.out.println(c == d); // 输出?
}
}
请问上面两行输出分别是什么?为什么?
考察对于Integer包装类的理解,Integer 缓存池范围是 -128~127
-
a == b为true:因为 127 在 Integer 缓存池范围内,二者指向同一个对象。 -
c == d为false:128 超出了缓存池范围,会分别创建两个新对象,地址不同。
深入细节:
Integer a = 127; 实际上会被编译器转换为:
Integer a = Integer.valueOf(127);
所以关键就在于 Integer.valueOf(int i) 的源码实现。
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) {
return IntegerCache.cache[i + (-IntegerCache.low)];
}
return new Integer(i);
}
-
如果参数在缓存范围内,直接从缓存数组中取同一个对象返回。
-
如果不在范围内,直接
new Integer(i)创建一个新对象。
缓存范围源码:
private static class IntegerCache {
static final int low = -128; // 固定下限
static final int high; // 可配置上限
static final Integer cache[];
static {
// 默认上限 = 127
int h = 127;
// 可通过 JVM 参数修改:-XX:AutoBoxCacheMax=<size>
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
}
high = h;
// 初始化缓存数组 [-128, 127] 的 Integer 对象
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++) {
cache[k] = new Integer(j++);
}
}
}
补充:new Integer(127) == Integer.valueOf(127) 结果?
false。new 一定在堆上创建新对象,valueOf() 从缓存取,地址不同。
2.单例设计模式深入
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码是 DCL(双重检查锁)单例,它有什么问题?
这段代码在没有 volatile 的情况下,可能会因为指令重排序 返回一个未完全初始化好的对象。
具体细节:
instance = new Singleton(); 这行代码在 JVM 层面并不是一个原子操作,它大致分为三步:
-
分配内存 :在堆上为
Singleton对象分配内存空间。 -
初始化对象 :调用
Singleton的构造方法,完成内部字段的初始化。 -
将引用指向内存 :将
instance变量指向刚分配的内存地址。
在单线程 中,即使 2 和 3 被重排序,也不影响最终结果。但在多线程 下,如果发生 2 和 3 的重排序,就会产生严重问题:
重排序后顺序:
① 分配内存
③ 建立引用 (instance 不再为 null)
② 初始化对象
假设有两个线程 A 和 B ,同时调用 getInstance():
| 时间线 | 线程 A(首次创建) | 线程 B(并发获取) |
|---|---|---|
| T1 | 进入 getInstance(),看到 instance == null 为真。 |
--- |
| T2 | 获取到 synchronized 锁,进入同步块。 |
--- |
| T3 | 再次检查 instance == null 为真,开始执行 instance = new Singleton();。 恰好发生指令重排序 :先分配内存、将引用赋给 instance,但还没有调用构造函数。 |
--- |
| T4 | --- | 此时线程 B 也进入 getInstance(),执行第一个 if (instance == null)。 因为 A 已经将 instance 指向了未初始化完毕的内存,instance 此时 != null! |
| T5 | --- | 线程 B 直接跳过 synchronized 块 ,返回了一个 半成品对象。 |
| T6 | 线程 A 继续执行完构造函数,对象才真正初始化完毕。 | 线程 B 已经拿着一个未初始化完全的对象开始调用了,可能导致 NPE 或逻辑错误。 |
这就是典型的对象逸出问题:线程 B 读到了不为 null、但实际上构造还没跑完的"残缺"对象。
volatile 在这里起到两个关键作用:
-
禁止指令重排序(最关键)
JMM 规定,在
volatile变量的写操作之前,不能将其后的指令(即对象的初始化)与写操作重排序。也就是说,必须严格按照 分配内存 → 初始化 → 建立引用 的顺序执行。这样线程 B 看到instance != null时,对象一定已经初始化完毕。 -
保证内存可见性
volatile变量的写操作会立即刷入主存,读操作会直接从主存中拿,保证所有线程看到的都是最新状态,不会出现从本地线程缓存里读到过期值的情况。
主包再总结一下:如果不加 volatile,第 2 步和第 3 步可能被 重排序,导致线程 A 刚执行完 3(引用已赋值但对象还没初始化),线程 B进来检测到 instance != null,直接返回了一个 半初始化的对象------此时访问成员变量可能读到默认值(0/null),引发奇怪的线上bug
3.线上问题排查
某线上服务突然 CPU 飙升到100%,你没有任何监控平台,只能上机器用命令行排查。请说出你从登录服务器到定位问题的完整排查步骤
1、ssh登录服务器
2、确认整体状况------top
top -c
-
作用:查看系统整体 CPU、内存、负载,并按 CPU 使用率排序,显示完整命令行。
-
关注:
-
第一行
load average如果很高,说明系统压力大。 -
找到 CPU 或内存占用最高的进程 PID。
-
通常按
P可以按 CPU 排序(默认就是)。
-
假设我们看到 PID 31415 的 Java 进程 CPU 100%。
3、定位到高 CPU 的线程------top -H
top -H -p 31415
-
-H:显示线程级别信息。 -
-p:指定进程。 -
按
P确保按 CPU 排序,找出 CPU 占用最高的线程 ID(TID ),比如 31416。
补充:也可以使用
ps -eLf | grep 31415或ps -T -p 31415查看线程。
4、线程 ID 转十六进制
printf "%x\n" 31416
假设输出为 7ab8。
- 原因:
jstack输出的线程栈中,nid字段是十六进制的,需要转换。
5.用 jstack 打印线程堆栈
jstack 31415 > jstack.log
- 查看该线程的 状态 和 代码调用栈。
常见高 CPU 线程状态及对应原因:
| 状态 | 可能原因 |
|---|---|
RUNNABLE |
线程正在执行代码,可能是死循环、大量计算、正则回溯等 |
BLOCKED |
等待获取对象锁,说明锁竞争严重 |
WAITING / TIMED_WAITING |
线程在等待其他资源,CPU 高可能是其他线程导致的?但本身不会占 CPU |
| GC 线程 | 如果是 VM 线程如 GC task thread#... 占用高 CPU,说明 GC 频繁 |
- 如果看到的是
VM Thread或Gang worker#...,说明是 GC 线程,需要转向 GC 排查。
6、分析堆栈代码
假设看到的是一个业务线程,处于 RUNNABLE,栈顶指向某个方法:
"high-cpu-thread" #34 prio=5 os_prio=0 tid=0x00007f9a0c009800 nid=0x7ab8 runnable [0x00007f9a1f4e5000]
java.lang.Thread.State: RUNNABLE
at com.water.service.DataAnalyzer.analyze(DataAnalyzer.java:88)
at com.water.service.DataAnalyzer.run(DataAnalyzer.java:43)
...
- 立即检查对应代码:是否存在死循环、大正则、递归、大量无缓存的字符串操作等。
7、检查 GC 状况------jstat
jstat -gcutil 31415 1000 10
-
参数:
1000毫秒一次,打印 10 次。 -
关注
FGC(Full GC 次数)和FGCT(Full GC 总耗时)。如果 FGC 增长快、耗时大,说明内存不足,GC 线程消耗大量 CPU。 -
同时看
E(Eden)、O(Old)、M(Metaspace)使用率是否接近 100%。
如果是 GC 频繁导致 CPU 高,需要进一步:
jmap -heap 31415 # 查看堆配置和使用详情
jmap -histo 31415 | head -30 # 查看对象实例数量排名,找出可能的内存泄漏对象
jmap -dump:live,format=b,file=heap.bin 31415 # 生成堆转储(慎用,会挂起应用)
4.SQL优化实操
有一张订单表 t_order(order_id, user_id, status, create_time, amount),数据量 2000 万行。现在这个查询很慢:
SELECT * FROM t_order
WHERE status = 'PAID'
ORDER BY create_time DESC
LIMIT 100000, 20;
请说说为什么慢?你能给出哪些优化方案?(至少说出 2 种)
方案一:使用"覆盖索引 + 延迟关联"【最推荐】
原理:先用一个紧凑的覆盖索引快速定位到主键,再用主键去取完整数据,避免大量回表。
SELECT o.*
FROM t_order o
INNER JOIN (
SELECT order_id
FROM t_order
WHERE status = 'PAID'
ORDER BY create_time DESC
LIMIT 100000, 20
) AS tmp ON o.order_id = tmp.order_id;
所需索引 :(status, create_time, order_id)
因为子查询只用到 status, create_time, order_id,这个联合索引完全覆盖了子查询,不需要回表,索引体积小,扫描 100020 行极快。外层再用主键 order_id 精确关联取 20 行完整数据。
方案 2:将 LIMIT 大分页改为"游标分页"(基于上次位置)
原理 :不再使用 LIMIT offset, size 这种逐页跳转,而是记住上一页最后一条的 create_time 和主键,用条件筛选下一页。
-- 第1页
SELECT * FROM t_order
WHERE status = 'PAID'
ORDER BY create_time DESC, order_id DESC
LIMIT 20;
-- 记下上一页最后一条的 create_time 和 order_id,比如 '2025-01-01 10:00:00' 和 123456
-- 第2页
SELECT * FROM t_order
WHERE status = 'PAID'
AND (create_time < '2025-01-01 10:00:00'
OR (create_time = '2025-01-01 10:00:00' AND order_id < 123456))
ORDER BY create_time DESC, order_id DESC
LIMIT 20;
5.Redis高并发
一个电商系统的商品详情页,QPS 大约是 8000,主要用 Redis 做缓存。某天活动中,一个热门商品 key在失效的瞬间,突然有大量请求同时穿透到数据库,导致数据库连接池被打满。
这是什么问题?
如果不解决,下一次该 key 失效时还会复现吗?为什么?
请给出至少两种解决思路。
这是典型的缓存击穿(Cache Breakdown)。
定义 :
缓存中某个热点 Key(高并发访问的 Key)在过期的瞬间,大量请求同时涌入,绕过缓存直接打到数据库,导致数据库压力骤增,甚至宕机。
与另外两个易混淆概念的区分:
-
缓存雪崩 :大量 Key 在同一时间段集体过期,或缓存服务宕机,导致请求大面积打到 DB。
-
缓存穿透 :请求的数据在缓存和数据库中都不存在,导致每次请求都打到 DB(通常用布隆过滤器或空值缓存解决)。
你这里的现象是单个热门商品 Key 过期,完全符合缓存击穿特征。
如果不解决,下一次该 Key 失效时还会复现吗?为什么?
会,一定会复现。
这个热门 Key 仍然会被设置 TTL(比如 10 分钟)。到期后,Key 自动删除,缓存再次为空。
互斥锁(分布式锁)------ 简单粗暴,强一致性
原理 :
当缓存失效时,不允许多个请求同时去查数据库,而是用一把分布式锁 (如 Redis 的 SETNX),保证只有一个请求能去加载 DB 并回写缓存,其他请求则等待或重试。
流程:
-
请求发现缓存未命中。
-
尝试获取分布式锁(例如
SET lock:product:123 1 NX EX 10)。 -
获取锁成功的请求:查询数据库 → 写回缓存 → 释放锁。
-
获取锁失败的请求:休眠几十毫秒后重新查缓存(此时缓存可能已被更新),或者返回一个降级数据(如"请稍后重试")。
-
回写缓存时,依然要设置合理的过期时间(通常加上随机抖动,避免再次同时过期)。
优点 :实现简单,数据一致性强。
缺点:有短暂的等待时间;锁需要处理好死锁和超时。
逻辑过期(永不过期)------ 性能最优,允许最终一致
原理 :
Redis 中缓存的 Key 不设置 TTL (物理上永不过期),而是在缓存的 Value 中增加一个 逻辑过期时间(如一个时间戳字段)。所有读请求都能直接拿到缓存值(旧值也没关系),而缓存的异步刷新由一个后台线程或定时任务完成。
流程:
-
缓存 Value 结构示例:
{ "data": {...}, "expireAt": 1715692800000 } -
请求永远能从缓存中读到这个 Value。
-
代码中判断:如果当前时间 >
expireAt,说明数据已经"逻辑过期"。 -
此时返回旧的缓存数据同时 ,触发一个异步任务(或尝试获取互斥锁)去更新缓存:查询数据库,写回缓存(同时更新
expireAt)。 -
在异步更新完成前,所有请求依旧返回旧数据,完全不会穿透到 DB。
优点:
-
请求零等待,完全不阻塞。
-
彻底杜绝了瞬时穿透,性能最好。
缺点:
-
会短暂返回过期数据,适用于对一致性要求不是"绝对实时"的场景(商品详情页完全适用)。
-
需要一个异步线程池或消息队列来执行刷新任务。