大家好!我是程序员小王,刚结束了一轮大厂的面试马拉松(字节、阿里、腾讯、美团都去了一圈)。整整两个月,面了12场,走到终面7次,拿到4个offer。今天把我收集的高频Java面试题和踩过的坑分享给大家,希望能帮到正在备战的你!🚀
📚 目录
Java基础篇
1️⃣ Java中的值传递vs引用传递
这是几乎每次面试必问的基础问题,但很多人还是答不好。
结论:Java中只有值传递,没有引用传递!
来看一道面试原题:
public static void main(String[] args) {
Person p = new Person("张三");
change(p);
System.out.println(p.name);
}
public static void change(Person p) {
p.name = "李四";
p = new Person("王五");
}
问:最后输出的name是什么?
很多人会答"王五",错了!正确答案是"李四"。
因为Java中的参数传递都是值传递,对于对象参数,传递的是对象引用的副本,而非对象本身。在change方法中,p = new Person("王五")
只是让p指向了新的对象,而原来main方法中的p依然指向原来的对象,只是name被改成了"李四"。
💡 面试官视角:这个问题主要考察你对Java基本原理的理解。如果能结合JVM内存模型(栈帧、堆内存等)来解释,会加分不少!
2️⃣ equals和hashCode的关系
这个问题特别容易挖坑:
- 如果两个对象equals相等,那么它们的hashCode一定相等
- 如果两个对象hashCode相等,它们的equals不一定相等
我在阿里的面试中,面试官追问:为什么要重写equals方法时必须重写hashCode方法?
因为如果只重写equals而不重写hashCode,会破坏Java集合框架的一致性约定,导致HashMap、HashSet等集合无法正常工作。举个例子:
public class Person {
private String name;
private int age;
// 只重写equals,不重写hashCode
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
}
// 使用场景
public static void main(String[] args) {
HashSet<Person> set = new HashSet<>();
set.add(new Person("张三", 25));
// 理论上应该返回true,但因为没重写hashCode,返回false
System.out.println(set.contains(new Person("张三", 25)));
}
集合框架篇
3️⃣ HashMap底层实现及优化
HashMap是最常用的集合类之一,也是面试重点。下面是我整理的完整知识点:
JDK 1.8前后的实现区别
版本 | 数据结构 | 特点 |
---|---|---|
JDK 1.7 | 数组 + 链表 | 头插法(并发时可能导致循环链表) |
JDK 1.8 | 数组 + 链表 + 红黑树 | 尾插法 + 红黑树优化(链表长度>8时) |
HashMap的关键属性
// 初始容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化阈值8
static final int TREEIFY_THRESHOLD = 8;
// 链表化阈值6
static final int UNTREEIFY_THRESHOLD = 6;
// 树化的最小容量64
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap的put过程(JDK 1.8)
我用mermaid图表来展示这个过程:
graph TD
%% 定义节点
A([开始put操作]) --> B{hash位置<br>是否为空?}
B -- 是 --> C[/直接放入/]
B -- 否 --> D{key是否相同?}
D -- 是 --> E[/覆盖旧值/]
D -- 否 --> F{是否是<br>树节点?}
F -- 是 --> G[/红黑树插入/]
F -- 否 --> H[/链表尾部插入/]
H --> I{链表长度>=8?}
I -- 是 --> J{数组容量>=64?}
J -- 是 --> K[/链表转红黑树/]
J -- 否 --> L[/扩容/]
I -- 否 --> M([操作完成])
C --> N{是否需要扩容?}
N -- 是 --> O[/扩容/]
N -- 否 --> M
%% 连接线样式
linkStyle default stroke:#666,stroke-width:1.5px;
linkStyle 0,1,2,3,4,5,6,7,8,9,10,11,13,14 stroke-width:2px;
linkStyle 0,12 stroke:#4caf50,stroke-width:2px;
linkStyle 1,3,5,7,9,11,14 stroke:#2196f3,stroke-width:2px;
linkStyle 2,4,6,8,10,13 stroke:#f44336,stroke-width:2px;
%% 样式定义
classDef startEnd fill:#a1de93,stroke:#2d5e2a,stroke-width:2px,color:#1a3c17,font-weight:bold,border-radius:8px;
classDef process fill:#87cefa,stroke:#0077be,stroke-width:1.5px,color:#00008b,font-weight:bold;
classDef condition fill:#ffcc99,stroke:#ff8c00,stroke-width:1.5px,color:#804000,font-weight:bold;
classDef treeProcess fill:#c6aed7,stroke:#673ab7,stroke-width:1.5px,color:#330066,font-weight:bold;
classDef listProcess fill:#fad0c3,stroke:#e57373,stroke-width:1.5px,color:#c62828,font-weight:bold;
classDef resize fill:#ffe082,stroke:#ffa000,stroke-width:1.5px,color:#e65100,font-weight:bold;
%% 应用样式
class A,M startEnd;
class C,E process;
class B,D,F,I,J,N condition;
class G,K treeProcess;
class H listProcess;
class L,O resize;
源码分析的加分项
在美团面试时,我分析了HashMap源码中的几个巧妙设计:
-
容量总是2的幂:这样做的好处是计算index时可以用位运算代替取模:
index = hash & (capacity - 1)
-
红黑树的引入:当链表过长时性能恶化,引入红黑树使查询复杂度从O(n)变为O(log n)
-
容量和负载因子的权衡:空间和时间的平衡
线程安全问题
HashMap不是线程安全的,在并发环境下可能导致:
- 数据丢失
- 无限循环(JDK 1.7中可能出现)
- 数据覆盖
💡 真实项目经验:去年我们的订单系统在高并发下出现了HashMap的线程安全问题,导致部分订单丢失。解决方案是改用ConcurrentHashMap,但要注意它不支持null键和null值。
JVM进阶篇
4️⃣ 深入理解Java内存模型
这是字节跳动三面的高频题。Java内存模型(JMM)是理解并发编程的基础。
JMM的核心目标
保证多线程环境下的原子性、可见性和有序性。
什么是指令重排?
为了提高性能,编译器和处理器常常会对指令做重排序,例如:
// 代码顺序
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
// 可能的执行顺序
int b = 2; // 2
int a = 1; // 1
int c = a + b; // 3
这在单线程环境下没问题,但在多线程环境可能导致意外行为。
volatile关键字的作用
// 没有volatile的问题
public class NoVisibility {
private static boolean ready;
private static int number;
private static class Reader extends Thread {
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new Reader().start();
Thread.sleep(1000);
number = 42; // 1
ready = true; // 2
}
}
上面的代码可能永远不会打印出42,因为ready的更新对Reader线程不可见。
volatile的作用:
- 保证可见性:一个线程修改值后,其他线程立即可见
- 禁止指令重排:防止1和2的顺序颠倒
- 不保证原子性:自增操作(i++)仍不是线程安全的
如何解决原子性问题?
使用synchronized或java.util.concurrent.atomic包中的原子类。
面试中的实战问题
一道经典面试题:单例模式中双重检查锁定为什么要使用volatile?
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile的作用是防止指令重排序。因为instance = new Singleton()
实际上分三步:
- 分配内存空间
- 初始化对象
- 将instance指向分配的内存
如果发生重排序,可能导致其他线程看到一个未完全初始化的对象。
并发编程篇
5️⃣ 深度剖析ThreadLocal内存泄漏问题
ThreadLocal是一个经典面试题,特别容易挖深坑。
ThreadLocal工作原理
每个Thread内部有个ThreadLocalMap,其中保存了以ThreadLocal为key,Object为value的键值对:
public class Thread {
ThreadLocal.ThreadLocalMap threadLocals = null;
}
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
内存泄漏分析
注意到上面代码中Entry
继承了WeakReference
,key是弱引用,但value是强引用。
当ThreadLocal对象被回收后,key变为null,但value仍然被引用,如果线程不结束,这个value就永远无法被回收,造成内存泄漏。
下面是一个经典的内存泄漏案例:
public class ThreadLocalLeakDemo {
// 创建一个ThreadLocal变量
private static ThreadLocal<LargeObject> threadLocal = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
// 创建线程池,线程池中的线程不会自动销毁
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executorService.execute(() -> {
// 每个线程都存储一个大对象
threadLocal.set(new LargeObject());
System.out.println("运行中...");
// 关键:没有调用remove()
// threadLocal.remove();
});
Thread.sleep(100);
}
// 应用程序继续运行,线程池中的线程不会销毁
Thread.sleep(10000);
System.out.println("主线程结束");
}
// 模拟占用大量内存的对象
static class LargeObject {
private byte[] data = new byte[1024 * 1024]; // 1MB
}
}
正确使用ThreadLocal的方式
永远记住使用ThreadLocal的三板斧:
try {
// 1. 设置变量
threadLocal.set(value);
// 2. 使用变量
process();
} finally {
// 3. 删除变量
threadLocal.remove();
}
🚨 真实踩坑经历:我之前参与的一个项目,使用了ThreadLocal存储用户会话信息,结果在tomcat线程池环境下出现了用户信息错乱的严重bug,排查了一周才发现是因为没有及时调用remove(),新用户复用了线程池中的线程,看到了上一个用户的信息。教训深刻!
6️⃣ CompletableFuture实战
异步编程是现代Java应用的标配,CompletableFuture是Java 8引入的强大工具。
基本用法
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return "结果";
});
// 异步处理结果
future.thenAccept(System.out::println);
// 阻塞获取结果
System.out.println(future.get());
实际业务场景:并行调用多个微服务
public OrderInfo getOrderDetails(Long orderId) {
// 创建订单信息对象
OrderInfo orderInfo = new OrderInfo();
// 并行调用三个微服务
CompletableFuture<OrderDetail> orderFuture =
CompletableFuture.supplyAsync(() -> orderService.getOrderDetail(orderId));
CompletableFuture<UserInfo> userFuture =
CompletableFuture.supplyAsync(() -> userService.getUserInfo(orderId));
CompletableFuture<List<ProductInfo>> productFuture =
CompletableFuture.supplyAsync(() -> productService.getProductInfos(orderId));
// 组合所有异步调用结果
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
orderFuture, userFuture, productFuture);
// 等待所有异步调用完成
try {
allFutures.get(3, TimeUnit.SECONDS);
// 设置订单信息
orderInfo.setOrderDetail(orderFuture.get());
orderInfo.setUserInfo(userFuture.get());
orderInfo.setProductInfos(productFuture.get());
} catch (InterruptedException | ExecutionException | TimeoutException e) {
log.error("获取订单信息失败", e);
throw new RuntimeException("获取订单信息失败", e);
}
return orderInfo;
}
💡 性能提升:在我们的电商项目中,使用CompletableFuture替代串行调用后,接口响应时间从300ms降到了120ms,提升了60%!
异常处理
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("计算错误");
}
return "结果";
}).exceptionally(ex -> {
System.out.println("异常:" + ex.getMessage());
return "默认值";
});
System.out.println(future.get()); // 输出:默认值
Spring全家桶
7️⃣ Spring循环依赖的解决方案
这是高级Java面试必问的Spring核心问题。
什么是循环依赖?
@Component
public class A {
@Autowired
private B b;
}
@Component
public class B {
@Autowired
private A a;
}
A依赖B,B又依赖A,形成了循环依赖。
Spring如何解决循环依赖?
Spring使用三级缓存解决:
/** 一级缓存:完全初始化好的Bean */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** 二级缓存:提前曝光的对象(没完全初始化) */
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);
/** 三级缓存:对象工厂 */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
解决流程:
- A创建过程中需要B,于是A将自己放入三级缓存,去实例化B
- B创建过程中需要A,从三级缓存中取出A(虽然A还未完全初始化)
- B完成初始化,回来接着初始化A
为什么需要三级缓存?
- 一级缓存:存放完全初始化好的Bean
- 二级缓存:存放原始Bean对象(尚未填充属性)
- 三级缓存:存放Bean工厂对象,用于生成代理对象
如果没有AOP代理,二级缓存就够了。三级缓存的关键作用是保证AOP的正确性 - 不管有没有循环依赖,都能保证同一个Bean最终只被代理一次。
哪些循环依赖Spring解决不了?
- 构造器注入的循环依赖
- prototype作用域的循环依赖
- 使用@Async的循环依赖(某些场景)
@Component
public class C {
private D d;
// 构造器注入,Spring无法解决
@Autowired
public C(D d) {
this.d = d;
}
}
@Component
public class D {
@Autowired
private C c;
}
数据库与缓存
8️⃣ 深入理解MySQL索引
聚簇索引和非聚簇索引的区别
- 聚簇索引:数据和索引存储在一起,InnoDB的主键索引就是聚簇索引
- 非聚簇索引:索引和数据分开存储,InnoDB的二级索引是非聚簇索引
索引失效的典型场景
- 违反最左匹配原则:对于复合索引,必须按照从左到右的顺序使用
-- 假设有索引(name, age, address)
-- 有效:使用了索引
SELECT * FROM user WHERE name = '张三' AND age = 25;
-- 无效:没有使用索引
SELECT * FROM user WHERE age = 25 AND address = '北京';
- 使用函数或表达式
-- 无效:在索引列上使用了函数
SELECT * FROM user WHERE YEAR(birth_date) = 1990;
-- 正确写法
SELECT * FROM user WHERE birth_date BETWEEN '1990-01-01' AND '1990-12-31';
- 使用LIKE并以通配符开头
-- 无效:前缀模糊匹配
SELECT * FROM user WHERE name LIKE '%张';
-- 有效:后缀模糊匹配
SELECT * FROM user WHERE name LIKE '张%';
- 隐式类型转换
-- 无效:phone字段是varchar类型,这里会发生隐式转换
SELECT * FROM user WHERE phone = 13812345678;
-- 正确写法
SELECT * FROM user WHERE phone = '13812345678';
索引优化案例
我在上家公司优化过一个典型的慢查询,分享一下经验:
-- 原始SQL,执行时间8秒
SELECT * FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 10001
AND o.order_time BETWEEN '2023-01-01' AND '2023-01-31'
AND oi.status = 'PAID'
ORDER BY o.order_time DESC
LIMIT 10;
通过EXPLAIN分析发现,orders表上没有合适的索引,导致全表扫描:
+----+-------------+---------+------+---------------+------+---------+------+--------+------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra|
+----+-------------+---------+------+---------------+------+---------+------+--------+------+
| 1 | SIMPLE | o | ALL | PRIMARY | NULL | NULL | NULL | 100000 | ... |
| 1 | SIMPLE | oi | ref | order_id | ... | ... | ... | 5 | ... |
+----+-------------+---------+------+---------------+------+---------+------+--------+------+
优化方案:
- 为orders表创建联合索引:
CREATE INDEX idx_user_id_order_time ON orders(user_id, order_time);
- 调整查询,使用覆盖索引:
-- 优化后SQL,执行时间120ms
SELECT o.id, o.order_time, oi.product_id, oi.quantity, oi.price
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 10001
AND o.order_time BETWEEN '2023-01-01' AND '2023-01-31'
AND oi.status = 'PAID'
ORDER BY o.order_time DESC
LIMIT 10;
📈 优化效果:查询时间从8秒降到120ms,提升了66倍!系统QPS提升30%。
9️⃣ Redis实战与性能优化
Redis为什么这么快?
- 基于内存:数据存在内存中,访问速度快
- 单线程模型:避免了线程切换和锁竞争
- I/O多路复用:非阻塞I/O,处理大量连接
- 高效的数据结构:如跳表、压缩列表等
Redis集群方案对比
方案 | 特点 | 优点 | 缺点 |
---|---|---|---|
主从复制 | 一主多从 | 配置简单 | 故障转移需手动 |
Sentinel哨兵 | 监控+自动故障转移 | 高可用 | 不支持扩容 |
Cluster集群 | 分片+节点间通信 | 可扩展性强 | 配置复杂 |
Redis常见使用场景
- 缓存:最常见的用途,减轻数据库压力
- 分布式锁:
SET key value NX PX milliseconds
- 计数器:
INCR
和INCRBY
- 限流器:滑动窗口实现
- 排行榜:Sorted Set实现
- 消息队列:List实现简单队列,Pub/Sub实现发布订阅
- 位图操作:用于节省内存的计数,如用户签到
Redis常见性能问题及解决方案
-
缓存穿透:查询不存在的数据
解决方案:
- 布隆过滤器
- 缓存空对象(设置较短的过期时间)
public String getUser(String id) { // 1. 查询缓存 String user = redisTemplate.opsForValue().get("user:" + id); if (user != null) { return user.equals("") ? null : user; } // 2. 查询数据库 user = userDao.findById(id); // 3. 放入缓存(即使为空也缓存,但设置较短过期时间) if (user == null) { redisTemplate.opsForValue().set("user:" + id, "", 60, TimeUnit.SECONDS); } else { redisTemplate.opsForValue().set("user:" + id, user, 3600, TimeUnit.SECONDS); } return user; }
-
缓存击穿:热点key失效
解决方案:
- 互斥锁
- 热点数据永不过期
public String getDataWithMutex(String key) { // 1. 查询缓存 String value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } // 2. 获取互斥锁 String lockKey = "lock:" + key; boolean locked = tryLock(lockKey); if (!locked) { // 获取锁失败,等待一段时间后重试 Thread.sleep(50); return getDataWithMutex(key); } try { // 双重检查 value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } // 3. 查询数据库并更新缓存 value = getDataFromDb(key); redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS); return value; } finally { // 4. 释放锁 unlock(lockKey); } }
-
缓存雪崩:大量缓存同时失效
解决方案:
- 过期时间加随机值
- 服务熔断和降级
- 多级缓存
// 设置过期时间时添加随机值 private void setWithRandomExpire(String key, String value) { // 基础过期时间3600秒,增加0-600秒随机值 long timeout = 3600 + new Random().nextInt(600); redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS); }
🔥 实战案例:我在一个高并发电商系统中,通过引入布隆过滤器解决了缓存穿透问题,系统稳定性显著提升,数据库CPU使用率从90%降到了30%。
我的面试心得
经历了这么多面试,我总结了几点心得分享给大家:
-
基础是关键:无论技术多新,Java基础和数据结构算法永远是重点
-
项目经验很重要:面试官更看重你解决过的实际问题和性能优化
-
知其然知其所以然:不要只会用框架,要理解原理
-
态度决定一切:遇到不会的问题,坦诚表达思考过程比装懂更好
-
面试也是学习:每次面试都是成长的机会,即使失败了也要总结经验
以上就是我整理的Java面试核心知识点,希望对大家有帮助!欢迎关注我的微信公众号「绘问」,更多技术干货等你来撩!