Java面试必刷题总因细节出错这份清单带你逐项攻克集合并发与框架核心考点避开无效死记硬背
很多人刷Java面试题,背得滚瓜烂熟,一到现场就被面试官一句“那如果多线程环境下呢?”或者“底层是怎么保证线程安全的?”问得哑口无言。其实真正拉开差距的从来不是那些大而全的概念,而是藏在API边缘、底层实现和边界条件里的细节。咱们不绕弯子,直接按集合、并发、框架三条主线,把高频踩坑点拆解开,配上能直接跑通的代码和调试思路,让你下次遇到同类问题,脑子里自动弹出完整链路。
集合:别只背HashMap,要懂它怎么“变”和“崩”的
HashMap是面试常客,但90%的人只会回答“数组+链表+红黑树”。面试官真正想听的是:扩容阈值为什么是8?负载因子0.75是怎么算出来的?遍历时修改集合为什么会抛异常?自定义Key忘了重写hashCode会怎样?
先看一个极易翻车的场景。很多开发者习惯用自定义对象作为HashMap的Key,觉得只要重写equals就行。实际上,一旦对象在放入Map后被修改了哈希相关的字段,原来的Entry就永远找不到了。这就像你把钥匙插进锁孔,出门后把钥匙齿磨平,回来再插肯定打不开。
import java.util.HashMap;
import java.util.Map;
public class MapKeyDetail {
public static void main(String[] args) {
// 模拟一个作为Key的对象
KeyObject key = new KeyObject("user_01", 10);
Map<KeyObject, String> cache = new HashMap<>();
// 正常插入
cache.put(key, "Alice");
System.out.println("插入后: " + cache.get(key)); // 输出 Alice
// 致命细节:修改Key对象的hash敏感字段
key.setAge(20);
System.out.println("修改Key后查找: " + cache.get(key)); // 输出 null!链表断裂
System.out.println("原始对象引用查找: " + cache.get(new KeyObject("user_01", 20))); // 也是 null
// 正确做法:Key对象必须不可变,或确保放入Map后绝不修改影响hashCode的字段
}
static class KeyObject {
private String id;
private int age;
public KeyObject(String id, int age) { this.id = id; this.age = age; }
public void setAge(int age) { this.age = age; }
@Override
public int hashCode() {
return id.hashCode() * 31 + age; // 简单演示,实际建议Objects.hash()
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof KeyObject)) return false;
KeyObject that = (KeyObject) o;
return age == that.age && id.equals(that.id);
}
}
}
遍历修改导致ConcurrentModificationException是另一个高频细节。它的底层机制叫“快速失败(fail-fast)”,HashMap内部维护了一个modCount计数器,每次结构性修改都会自增。迭代器初始化时会快照记录这个值,后续检查不一致就直接抛异常。这不是Bug,而是设计上的安全策略。
如果你需要在遍历时动态删除元素,别用map.remove(k)硬刚,正确姿势是用Iterator自带的remove(),或者直接用Map.computeIfPresent()配合过滤逻辑。对于高并发场景,别再用Hashtable或synchronizedMap了,ConcurrentHashMap的段锁(JDK7)到CAS+synchronized(JDK8)演进,核心就是“读完全无锁,写尽量细粒度”。记住一个细节:ConcurrentHashMap的size()方法返回的是近似值,因为它是分段统计后累加,中间可能有其他线程写入,所以它永远不等于绝对精确的实时数量。
并发:线程池不是new出来就能用的,锁的细节决定生死
JUC(java.util.concurrent)包里的坑,往往藏在参数配置和底层状态机里。很多人背线程池参数能倒背如流,但一问到“为什么核心线程数设成CPU核数+1就不对”,或者“拒绝策略选错了线上会直接雪崩”,就卡壳了。
先说线程池的拒绝策略。默认AbortPolicy是直接抛异常,生产环境绝对不能这么干。正确的做法是结合业务降级:比如用CallerRunsPolicy让调用线程自己执行(适合非核心任务),或者自定义DiscardOldestPolicy丢弃最老的任务保活最新请求。细节在于:队列满了才会触发拒绝策略,而队列满的前提是corePoolSize线程都在忙,且maximumPoolSize也达到了。很多新人把队列设成无界(如LinkedBlockingQueue),导致maximumPoolSize形同虚设,内存直接OOM。
import java.util.concurrent.*;
public class ThreadPoolDetail {
public static void main(String[] args) {
// 生产环境推荐写法:显式创建,带拒绝策略和命名线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(100), // 有界队列,防OOM
new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-worker-" + counter.getAndIncrement());
t.setDaemon(false); // 守护线程不能用来跑业务任务
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 关键细节:降级策略
);
executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务");
});
executor.shutdown();
}
}
锁的细节更是重灾区。volatile能保证可见性和有序性,但绝不保证原子性。i++看起来是一行代码,底层拆成读取、计算、写回三步,多线程下必乱。很多人误以为volatile int count加个同步块就行,其实直接上AtomicInteger或LongAdder更优雅。LongAdder的底层是分片累加(Cell数组),高竞争时性能碾压synchronized,这是JDK8给出的并发细节答案。
再看ReentrantLock的可重入和公平锁。可重入意味着同一个线程可以多次获取同一把锁而不死锁,内部靠AQS维护了一个state计数器和owner线程引用。公平锁每次排队,非公平锁直接CAS抢。细节陷阱:非公平锁虽然吞吐量高,但可能导致某些线程长期饥饿;公平锁在极端高并发下反而容易引发频繁的上下文切换。面试时被问到“怎么选”,别只背定义,要说出你的权衡依据:短连接高频请求偏非公平,长连接或对响应时间抖动敏感的金融类业务偏公平。
框架:Spring的代理和事务,90%的人栽在“自我调用”和“默认回滚规则”
Spring面试不问Bean生命周期等于白问,但大多数人只能答出“实例化-属性填充-初始化-销毁”这八股文。真正加分的是:Aware接口怎么注入环境信息?BeanPostProcessor在哪个阶段介入?@PostConstruct和InitializingBean的执行顺序谁先谁后?
顺序是:@PostConstruct → afterPropertiesSet() → 自定义init-method。底层原理是Spring在初始化阶段通过反射扫描注解,然后按规范调用接口。细节在于:如果afterPropertiesSet()里抛了异常,后续自定义init方法根本不会执行,容器会直接终止该Bean的创建。这在调试时很容易被忽略,日志里只显示一句BeanCreationException,根本不知道是哪一步炸的。
事务传播行为是框架部分的另一座大山。REQUIRED是默认值,表示如果当前没有事务就新建,有就加入。但很多人不知道REQUIRES_NEW会挂起当前事务,独立提交。细节坑点:如果外层事务回滚,内层REQUIRES_NEW已经提交的数据不会自动回滚,这时候需要手动补偿或分布式事务。NESTED则是基于保存点(Savepoint),内层回滚不影响外层,但外层回滚会连坐内层。数据库必须支持Savepoint(MySQL InnoDB默认支持)。
最经典的代理失效场景:@Transactional方法被类内部其他方法调用。Spring AOP是基于动态代理实现的,内部调用走的是this.method(),根本不走代理对象,事务注解自然失效。解决细节有三:一是把事务方法拆到独立Service;二是用AopContext.currentProxy()强制拿代理;三是开启expose-proxy=true。生产环境强烈建议第一种,架构清晰且无副作用。
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
// 细节1:默认只在RuntimeException及子类回滚,CheckedException不会触发
// 细节2:public方法才生效,private/protected默认被AOP拦截器跳过
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void createOrder(OrderDTO dto) {
// 模拟扣库存
deductStock(dto.getSkuId(), dto.getCount());
// 模拟下单
saveOrder(dto);
// 细节3:如果这里抛出的是自定义CheckedException,事务不会回滚!
// 正确做法:要么catch后throw new RuntimeException(),要么显式指定rollbackFor
}
private void deductStock(Long skuId, int count) {
// 数据库操作...
}
private void saveOrder(OrderDTO dto) {
// 数据库操作...
}
}
怎么把细节吃透,而不是死记硬背
刷题最怕的是“看懂了→背下来→换种问法又懵”。破解的方法是建立“问题-现象-根因-验证”的闭环。比如看到ConcurrentHashMap,别光记“线程安全”,去源码里看tabAt()怎么用Unsafe.getObjectVolatile保证可见性,看treeifyBin()怎么判断链表转红黑树的阈值是8还是6(注意:转树是≥8,但退化成链表是,防止频繁转换)。亲手写个压测脚本,对比HashMap、Hashtable、Collections.synchronizedMap()和ConcurrentHashMap在读写比例7:3时的吞吐量,数据比文字记得牢得多。
并发部分多玩jstack和Arthas。线程死锁、CPU飙高、锁竞争,命令行里敲几行命令就能定位到具体代码行。框架部分直接断点跟Spring启动流程,看DefaultListableBeanFactory怎么解析注解,看TransactionInterceptor怎么织入around逻辑。细节不是背出来的,是跑出来、调出来、改出来的。
面试说到底是一场技术沟通。你能把HashMap的扩容讲出负载因子和哈希扰动的数学权衡,能把线程池队列满时的拒绝策略和业务降级挂钩,能把Spring事务的传播行为和数据库锁机制串联起来,面试官自然会认为你不仅写过代码,还真正思考过系统怎么稳定运行。把清单里的每个坑都亲手填一遍,下次面对同类问题,你交出的就不再是标准答案,而是带着实战温度的技术洞察。
