大家好!我是程序员小王,刚结束了一轮大厂的面试马拉松(字节、阿里、腾讯、美团都去了一圈)。整整两个月,面了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面试核心知识点,希望对大家有帮助!欢迎关注我的微信公众号「绘问」,更多技术干货等你来撩!