Java大厂面试实录:Spring Boot、Redis缓存、Kafka消息队列、微服务与JVM实战问答
Java大厂面试实录:Spring Boot、Redis缓存、Kafka消息队列、微服务与JVM实战问答
周五下午,西二旗某互联网大厂会议室。面试官老张面无表情地翻着简历,对面坐着一位穿着格子衫、头发略显凌乱的年轻人谢飞机。他搓了搓手,露出一个自信的笑容。
面试官老张:谢飞机是吧?看了你的项目,有电商秒杀经验?那咱们直接聊技术。
谢飞机:好嘞!面试官您放心,我平时最爱研究技术了,尤其喜欢研究Spring Boot,用它就像坐飞机一样顺滑!
面试官老张:行,那我们开始第一轮。你先说说Java 8和Java 11有哪些主要区别?
谢飞机:这个简单!Java 8有Lambda表达式和Stream流,还有新的日期时间API。Java 11的话……嗯,是长期支持版本,还新增了字符串的repeat()、isBlank()这些方法,还直接支持运行单文件Java源码。我记得Java 9开始就有模块化了,Java 11把一些旧的东西删了,比如Java EE模块。
面试官老张:不错,基本功还可以。那Spring Boot的自动配置是怎么实现的?
谢飞机:这个也懂!Spring Boot启动时会加载META-INF/spring.factories里的自动配置类,通过@EnableAutoConfiguration注解导入,再配合一堆@Conditional条件注解,比如@ConditionalOnClass、@ConditionalOnMissingBean,只有当classpath里有对应的类和配置时才会生效。真的,我对这个可熟了。
面试官老张:好。那MyBatis里#{}和${}有什么不同?
谢飞机:#{}是预编译,会生成?占位符,防止SQL注入;${}就是直接拼接字符串,有SQL注入风险。我平时都写#{},安全!
面试官老张:那如果电商后台需要一个动态排序功能,用户传入排序字段名,比如price或sales,你会怎么写?
谢飞机:我……我就直接ORDER BY ${sortField},因为排序字段没法用占位符啊。不过这个字段我只允许传一个白名单,比如先校验是不是price或者sales,不是就默认用id……呃,这样应该没问题吧?
面试官老张:嗯,知道做白名单还算有救。我们再聊点数据库运行的场景。假设一个订单表数据量很大,查询某个用户的订单列表很慢,你会怎么排查?
谢飞机:加索引!先看看where条件,给用户ID加索引。如果还慢就explain看看执行计划,是不是没走索引,是不是全表扫描了。嗯,还可以分库分表!不过分库分表我没实际做过……但我看过好多文章!
面试官老张:行,基础还行。我们进入第二轮,聊聊电商秒杀。你项目里用过Redis吧?说说Redis常用数据结构。
谢飞机:用过!有String、Hash、List、Set、ZSet,还有Bitmap、HyperLogLog、Geo这些高级的。秒杀我一般用String做计数器,用Hash存商品信息,用ZSet做排行榜。
面试官老张:那缓存穿透、击穿、雪崩分别是什么?怎么解决?
谢飞机:缓存穿透是查询一个不存在的key,每次都会打到数据库。解决方法是缓存空值,或者用布隆过滤器。缓存击穿是一个热点key失效,大量请求同时打到数据库,解决方法是加互斥锁,或者把热点key的过期时间设长一点。缓存雪崩是很多key同时过期,解决方法是给过期时间加随机值,别让他们一起过期。
面试官老张:背得挺熟。那秒杀场景下,你怎么防止商品超卖?
谢飞机:我用分布式锁!哦不,我……我当时在单机上用synchronized锁住库存扣减方法,但在分布式下肯定不行,我就用Redis的decr命令扣减库存,这样是原子的,不会超卖。然后……如果库存不够了就返回失败。
面试官老张:如果Redis本身是单点,Redis挂了怎么办?
谢飞机:啊……挂了?那就……就数据库扛?不行,缓存雪崩了。可以搞Redis集群,哨兵模式,或者用RedLock?但RedLock也有争论……嗯,我很纠结。
面试官老张:行了。那Kafka如何保证消息不丢失?
谢飞机:消息不丢失……生产者端要acks=all,消费者端要手动提交offset,不要自动提交。还有Broker要配置副本数大于1,副本同步……大概是这样吧。我只用过自动提交,手动提交我了解一点。反正丢消息别找我,我发消息都是靠玄学。
面试官老张:好,第三轮,我们聊微服务和JVM。先说说JVM内存划分。
谢飞机:JVM内存分为堆、方法区、虚拟机栈、本地方法栈、程序计数器。堆里面又分新生代和老年代,新生代有Eden和两个Survivor区。方法区在Java 8之后变成了元空间,用的是本地内存。一般我排查内存问题就jstat、jmap、jvisualvm,反正一通操作就完事了。
面试官老张:嗯。Spring Cloud微服务之间调用,你用什么?
谢飞机:用OpenFeign!声明式HTTP客户端,写个接口加个注解就能调用。配合Nacos或者Eureka做服务发现,用Ribbon做负载均衡。虽然现在Ribbon有点老了,但Spring Cloud LoadBalancer也可以。
面试官老张:那如果订单服务调用库存服务,库存服务突然挂了,你怎么处理?
谢飞机:我……我用try-catch包起来,捕获异常后返回一个"当前库存服务繁忙",然后给用户提示请稍后再试。如果请求特别多,我还可以重试几次,用@Retryable,但这样可能会更糟……嗯,应该用Resilience4j做服务降级和熔断,比如走个降级方法,快速失败,别让调用方一直等。
面试官老张:那你懂双亲委派模型吗?
谢飞机:懂的!防止重复加载,也保证核心类不被篡改。加载一个类时,先让父加载器加载,父加载器加载不了才由子加载器加载。比如我们自定义一个java.lang.String,也加载不了,因为启动类加载器已经把它加载了。
面试官老张:最后一个问题,如果数据库查询慢,除了加索引,还能怎么优化?
谢飞机:还能……优化SQL写法?避免select *,使用覆盖索引,小表驱动大表。如果是分页查询,深分页很慢的话,可以用子查询或者游标。还可以加缓存,比如把热点数据放到Redis里。嗯,还有读写分离,主库写,从库读。总之方法很多,具体问题具体分析吧。
面试官老张:好的,你这三轮表现还可以,但也有不少模糊的地儿。回去等通知吧,我们有结果会告知你。
谢飞机:好嘞!那我回去等您好消息了!希望贵厂能给我这架小飞机一个跑道!
面试官老张:……门在那边。
详细答案解析:让小白也能读懂
第一轮:Java基础与Spring/MyBatis
1. Java 8 和 Java 11 主要区别
- Java 8 是2014年发布的里程碑版本,带来了Lambda表达式、Stream API、Optional、新的日期时间API(LocalDateTime)、接口默认方法和静态方法。
- Java 11 是2018年发布的LTS(长期支持)版本,基于Java 10。主要变化:
- 新增
String方法:isBlank()、strip()、repeat()、lines()。 - 本地变量类型推断
var(Java 10引入,Java 11继续支持)。 - 支持直接运行
.java源文件(java Test.java),无需先编译。 - 将Java EE模块(如JAXB、JAX-WS)从JDK中移除。
- 增加了HTTP Client标准API(替代
HttpURLConnection)。
- 新增
2. Spring Boot自动配置原理
Spring Boot的核心是@EnableAutoConfiguration注解。它会通过SpringFactoriesLoader加载META-INF/spring.factories文件(新版是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports)中声明的所有自动配置类。每个自动配置类通常带有条件注解:
@ConditionalOnClass:classpath中是否存在某个类。@ConditionalOnMissingBean:容器中是否还没有某个Bean。@ConditionalOnProperty:配置文件中是否设置了指定属性。
例如RedisAutoConfiguration,当classpath中有RedisOperations类时,它会创建RedisTemplate和StringRedisTemplate。我们可以通过自定义配置类或application.properties来覆盖这些默认行为。
3. MyBatis中#{}和${}的区别
#{}:预编译占位符,MyBatis会将其解析成?,然后使用PreparedStatement设置参数,防止SQL注入,性能也更好。${}:字符串直接拼接,不会使用预编译,存在SQL注入风险。
在实际开发中,能用#{}就不用${}。但如果需要动态指定表名、列名(如排序字段),由于SQL语法不允许占位符出现在这些位置,此时必须用${}。正确做法是:对用户输入做严格白名单校验,只允许预定义的字段名。例如:
String safeField = "price".equals(sortField) ? "price" : "id"; // 再用 ${safeField} 拼接4. 大表查询慢的排查思路
- 先用
EXPLAIN查看SQL执行计划,重点关注type(是否全表扫描)、key(实际使用的索引)、rows(扫描行数)。 - 确认
WHERE条件、ORDER BY、JOIN关联字段上的索引是否合理。 - 避免
SELECT *,只查询需要的列;使用覆盖索引减少回表。 - 深分页问题:
LIMIT 100000, 20会扫描十万行。可以改用游标分页或子查询:
WHERE id < (SELECT id FROM orders WHERE ... ORDER BY id LIMIT 1 OFFSET 100000) ORDER BY id LIMIT 20;- 如果单表数据量过大,考虑分库分表(如按用户ID取模)、读写分离、引入Redis缓存热点数据。
第二轮:缓存与消息队列
1. Redis常用数据结构
- String:最基础,可用于计数器、缓存、分布式锁。
- Hash:存储对象,如商品详情,可单独修改一个字段。
- List:消息队列(简单)、最新列表。
- Set:去重、共同好友、抽奖。
- ZSet(有序集合):排行榜、按分数排序。
- Bitmap:签到、布隆过滤器底层。
- HyperLogLog:UV统计。
- Geo:地理位置计算。
2. 缓存穿透、击穿、雪崩
- 缓存穿透:查询一个缓存和数据库都不存在的数据(如恶意请求ID=-1),每次都打到数据库。解决:
- 缓存空值(即使查不到也缓存一个空对象,设置短期过期时间)。
- 使用布隆过滤器,将存在的ID提前放入,过滤掉不存在的请求。
- 前端增加参数校验,非法参数直接拒绝。
- 缓存击穿:某个热点key过期瞬间,大量并发请求同时打到数据库。解决:
- 使用互斥锁(分布式锁),只允许一个请求去数据库加载,其他请求等待或返回旧值。
- 热点数据设置不失效,后台异步更新。
- 缓存雪崩:大量key在同一个时刻过期,导致所有请求都打到数据库。解决:
- 过期时间加上随机数,避免同时过期。
- 使用Redis集群提高可用性。
- 服务端做熔断降级。
3. 秒杀防超卖
不能简单用synchronized,在分布式环境下锁不生效。常用方案:
- Redis
DECR原子操作:每个用户抢购前,先DECR一个库存key,如果返回值大于等于0,则抢购成功;否则失败。原子操作天然防止并发超卖。 - 使用数据库
UPDATE stock SET num = num - 1 WHERE num > 0,返回受影响行数判断是否成功。这是乐观锁模式,避免应用层加锁。 - 更严格的做法是:利用Redis预扣减,异步发送消息到MQ,由MQ消费者最终落地数据库,保证最终一致性。
4. Kafka保证消息不丢失
消息不丢失需要从三个环节解决:
- 生产者:设置
acks=all,表示分区副本都收到消息才确认。同时设置retries重试次数(大于0)。 - Broker:设置
replication.factor(副本因子)大于1,且min.insync.replicas大于1,保证至少一个副本同步。 - 消费者:使用手动提交offset(
enable.auto.commit=false)。处理完业务逻辑后再提交offset,避免消息还没处理就提交,导致程序崩溃时丢消息。
注意:Kafka保证的是“至少一次”投递,可能重复消费,因此消费端需要处理幂等性(如使用唯一业务ID去重)。
第三轮:微服务与JVM
1. JVM内存划分
- 程序计数器:当前线程执行的字节码行号指示器。
- 虚拟机栈:每个方法调用对应一个栈帧,存储局部变量、操作数栈、方法返回地址,可能抛出StackOverflowError。
- 本地方法栈:为native方法服务。
- 堆:对象实例和数组。分新生代(Eden/S0/S1)和老年代。GC主要关注堆。
- 方法区/元空间:存储类信息、常量、静态变量。Java 8之前是永久代(堆内),Java 8改为元空间(本地内存),避免永久代溢出。
- 排查工具:
jps查看进程,jstat查看GC,jmap导出堆,jvisualvm可视化分析。
2. Spring Cloud服务调用
- 常用组件:服务注册中心(Nacos/Eureka/Consul)、远程调用(OpenFeign)、负载均衡(Spring Cloud LoadBalancer)、熔断降级(Resilience4j/Sentinel)、网关(Gateway/Zuul)。
- OpenFeign用法:定义接口,添加
@FeignClient(name="inventory-service"),方法上使用@GetMapping("/reduce"),注入后直接调用。Feign内置Ribbon负载均衡,新版使用Spring Cloud LoadBalancer。
3. 服务调用失败的处理
不能只用try-catch,因为如果下游服务已经过载,大量请求等待超时会导致线程池耗尽,进而拖垮调用方。应该使用:
- 超时配置:HTTP连接和读取超时设置较短。
- 熔断器:使用Resilience4j,连续失败率达到阈值时开启熔断,后续请求快速失败走降级方法。
- 降级:定义
fallback方法返回兜底数据,比如返回一个“默认库存不足”的提示。 - 限流:对入口进行限流,防止大流量冲击。
- 异步/消息队列:对于非实时操作,可以发消息到MQ异步处理,解耦调用方和下游。
4. 双亲委派模型
类加载器从上到下分为:启动类加载器(Bootstrap)、扩展类加载器(Platform/Extension)、应用类加载器(Application)。当一个类需要被加载时,首先会交给父类加载器加载,父类加载器无法加载时,才由子类加载器尝试加载。
好处:
- 避免核心类(如
java.lang.String)被重复加载。 - 防止核心类被篡改,保证Java类库的安全性。
反例:如果自己写一个java.lang.String类,由于父类加载器已经加载过真正的String,自定义类永远无法被加载(或者抛出SecurityException)。
5. SQL优化思路扩展
除了加索引,还有以下手段:
- 覆盖索引:查询列包含在索引中,避免回表。例如
CREATE INDEX idx_user_id ON orders(user_id, status),查询SELECT status FROM orders WHERE user_id=?。 - 减少关联查询:拆分成多次简单查询,或者冗余字段。
- 使用分页优化:避免大Limit,使用WHERE条件定位游标。
- 索引失效场景注意:对列进行函数运算、隐式类型转换、
LIKE '%xxx'、OR连接非索引列等都可能导致索引失效。 - 读写分离:主库负责写,从库负责读,配合MySQL主从复制。
- 数据库连接池:使用HikariCP并合理配置最大连接数。
- 避免大事务:及时提交,减少锁冲突。
- 分库分表:数据量千万级以上时,按业务维度分片。需要解决分布式ID、跨库查询、分布式事务等问题。
- 缓存:将热点数据放到Redis,降低数据库读压力。
以上便是这场“严肃与搞笑”交织的技术面试全记录。谢飞机虽然有些知识点回答得含糊,但整体方向没跑偏。对于广大同学来说,不仅要把基础概念背熟,更要在真实业务场景中总结解决方案,做到“知其然,也知其所以然”。面试官那句“回去等通知”虽然让人焦虑,但只要持续补全技术栈,下一场机会一定会稳稳落地。