Java大厂面试实录:Spring Boot、Redis缓存、Kafka消息队列、微服务与JVM实战问答

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注入风险。我平时都写#{},安全!

面试官老张:那如果电商后台需要一个动态排序功能,用户传入排序字段名,比如pricesales,你会怎么写?

谢飞机:我……我就直接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之后变成了元空间,用的是本地内存。一般我排查内存问题就jstatjmapjvisualvm,反正一通操作就完事了。

面试官老张:嗯。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类时,它会创建RedisTemplateStringRedisTemplate。我们可以通过自定义配置类或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 BYJOIN关联字段上的索引是否合理。
  • 避免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,在分布式环境下锁不生效。常用方案:

  • RedisDECR原子操作:每个用户抢购前,先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,降低数据库读压力。

以上便是这场“严肃与搞笑”交织的技术面试全记录。谢飞机虽然有些知识点回答得含糊,但整体方向没跑偏。对于广大同学来说,不仅要把基础概念背熟,更要在真实业务场景中总结解决方案,做到“知其然,也知其所以然”。面试官那句“回去等通知”虽然让人焦虑,但只要持续补全技术栈,下一场机会一定会稳稳落地。