首页 / 资讯中心 / 文章详情

Java高频面试题拆解:Spring生命周期、SQL索引与线程池避坑

Java高频面试题拆解:Spring生命周期、SQL索引与线程池避坑 ★ FEATURED ARTICLE
简介这份《Java高频面试题.pdf》面向准备Java后端岗位面试的初中级开发者与系统性复习者把Spring框架、Spring MVC、Spring Boot与数据库优化四条主线的高频考点整理成条理清晰的问答式笔记适合面试前集中突击与知识盲点自查。内容从Spring Bean的实例化、依赖注入、初始化到销毁逐段拆解梳理InitializingBean、init-method等扩展点Spring MVC部分按客户端请求、DispatcherServlet、HandlerMapping、HandlerAdapter、Controller、视图解析直至响应客户端的顺序还原完整链路Spring Boot则围绕SpringBootApplication的三个核心注解与AutoConfigurationImportSelector的装配过程展开。数据库侧覆盖EXPLAIN执行计划各字段含义、常见索引失效场景、索引类型划分与B树结构特点并补充线程池四类创建方式、七个核心参数及拒绝策略。压缩包内仅1个PDF文件体积约396KB轻量易存已有180人学习便于通勤碎片时间或面试前一晚快速翻阅。1. Java 高频面试题 PDF 拆开看它其实是一张知识漏洞体检表面试里最容易被问穿的不是八股本身而是八股背后的执行顺序。比如 Spring Bean 生命周期能背出「实例化、属性注入、初始化、销毁」的人很多但被追问「PostConstruct 和 afterPropertiesSet 谁先执行」「构造注入的循环依赖为什么三级缓存也救不了」时答案就开始飘。这份 Java 高频面试题 PDF 的价值不在于给你标准答案而在于它把 Spring、SQL 优化、线程池、集合、事务、Redis 这几条主线上的高频追问点集中排了一遍你对着目录逐条自测能很快看出自己哪块是「背过」、哪块是「真懂」。适合两类人准备跳槽需要系统过一遍的初中级后端以及带人做技术复盘、想找一份提问清单的资深开发。下面按可复现的方式拆。2. Spring 三连问Bean 生命周期、MVC 流程与自动装配怎么答到点上Spring 这块内容在这份资料里占了将近三分之一也是面试里最容易被连环追问的部分。它的组织逻辑其实很清楚先讲一个 Bean 从创建到销毁的全过程再讲一次 HTTP 请求在框架里怎么走最后讲框架自己怎么把一堆配置类自动装进容器。这三件事对应的是「对象生命周期」「请求生命周期」「容器装配」三个不同层次混在一起背必然乱。我一般建议按这个顺序过先把 Bean 的回调顺序钉死再画 MVC 的单向链路最后才去看自动装配的源码调用链因为前两个是后一个的底座。2.1 从实例化到销毁Bean 生命周期里那两套回调谁先谁后资料里把生命周期概括成四段实例化、依赖注入、初始化、销毁。这个粗粒度没错但面试官往下问一层就会卡人。完整的顺序是构造器或工厂方法、反射创建实例然后填充属性完成依赖注入接着回调各类 Aware 接口拿到容器能力再进入初始化阶段最后容器关闭时走销毁。初始化阶段有三套钩子执行顺序是固定的PostConstruct→InitializingBean.afterPropertiesSet()→ 配置里声明的init-method。销毁阶段同理PreDestroy→DisposableBean.destroy()→destroy-method。把这段顺序敲一遍比背十遍管用。下面这个类把五个钩子全挂上跑一次就能看到真实顺序import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; Component public class OrderHolder implements InitializingBean, DisposableBean { private String name; // 1. 实例化构造器最先执行 public OrderHolder() { System.out.println(1. constructor 实例化); } public void setName(String name) { this.name name; System.out.println(2. setter 依赖注入); } // 3. 初始化第一钩子 PostConstruct public void postConstruct() { System.out.println(3. PostConstruct); } // 4. 初始化第二钩子 Override public void afterPropertiesSet() { System.out.println(4. InitializingBean.afterPropertiesSet); } // 5. 初始化第三钩子需在 Bean(initMethod initMethod) 中声明 public void initMethod() { System.out.println(5. init-method); } // 6. 销毁第一钩子 PreDestroy public void preDestroy() { System.out.println(6. PreDestroy); } // 7. 销毁第二钩子 Override public void destroy() { System.out.println(7. DisposableBean.destroy); } // 8. 销毁第三钩子需在 Bean(destroyMethod destroyMethod) 中声明 public void destroyMethod() { System.out.println(8. destroy-method); } }这段代码的关键在于initMethod和destroyMethod这两个方法名本身不会自动生效Component扫描出来的 Bean 默认只会调PostConstruct、afterPropertiesSet、PreDestroy、destroy这四个。要让后两个钩子参与得改用配置类显式声明import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class BeanConfig { // initMethod / destroyMethod 填的是方法名不是方法签名 Bean(initMethod initMethod, destroyMethod destroyMethod) public OrderHolder orderHolder() { OrderHolder holder new OrderHolder(); holder.setName(demo); return holder; } }参数说明initMethod和destroyMethod都是一个字符串值是目标 Bean 里无参方法的名称Spring 通过反射调用所以方法不能带参数也不能是 private。destroyMethod还有一个默认值(inferred)意思是容器会自动推断close和shutdown这两个方法名这也是为什么很多资源类只要写了close()就能被自动关闭。跑这段代码时记得用AnnotationConfigApplicationContext并显式close()否则销毁钩子不会触发很多人第一次验证时看不到后半段输出就是栽在这里。面试里还有一个高频追问Bean 是不是单例、什么时候创建。默认是单例且容器启动时创建Lazy除外原型作用域下销毁钩子不会被容器调用因为容器不持有原型 Bean 的引用。这一条在资料里没展开但它是最容易被问到的补充点。2.2 DispatcherServlet 一条主线九个组件各管哪一段资料把 MVC 执行流程拆成八步核心是「除了控制器处理请求和视图渲染其余环节都要把控制权交回前端控制器」。这句话点到了要害DispatcherServlet是一个调度中枢不是业务执行者。把九个组件按职责摊开看会更清楚组件职责常见实现出事时的典型现象DispatcherServlet接收所有请求统一调度框架内置404 但不报错说明映射没建立HandlerMappingURL 与处理器方法建立映射RequestMappingHandlerMapping明明写了注解却 404HandlerAdapter适配不同写法的处理器并执行RequestMappingHandlerAdapter参数绑不上、类型转换异常HandlerInterceptor前置/后置/完成回调自定义实现preHandle 返回 false 后无响应Controller执行业务逻辑组装模型业务代码返回视图名却渲染错页面ViewResolver视图名到实际视图的解析InternalResourceViewResolver返回字符串被当成路径拼接View渲染模型数据Thymeleaf、JSP页面空白但接口有数据HandlerExceptionResolver异常统一处理ExceptionHandlerExceptionResolver异常被吞前端只看到 500MultipartResolver文件上传解析StandardServletMultipartResolver上传大文件直接拒绝为什么要单独有 HandlerAdapter 这一层是面试常问的。原因是处理器的写法不止一种注解式控制器、实现Controller接口的老写法、HttpRequestHandler它们的调用方式完全不同。如果让 DispatcherServlet 直接调就得写一堆 if-else加一层适配器新增一种处理器只要多一个适配器实现调度逻辑不用改。拦截器这一段值得单独敲一遍因为三个回调的触发时机经常被记反import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class TraceInterceptor implements HandlerInterceptor { // 目标方法执行前返回 false 则中断后续链路 Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { long start System.currentTimeMillis(); request.setAttribute(start, start); return true; } // 目标方法执行后、视图渲染前异常时不会走这里 Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, org.springframework.web.servlet.ModelAndView modelAndView) { System.out.println(postHandle 耗时: (System.currentTimeMillis() - (Long) request.getAttribute(start))); } // 视图渲染完成后无论是否异常都会走 Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { System.out.println(afterCompletion 异常信息: ex); } }三个方法的差别不是顺序而是触发条件preHandle按注册顺序正序执行postHandle逆序执行afterCompletion也是逆序且只要preHandle返回过 true 就一定执行即使目标方法抛异常。所以资源清理、耗时统计这类动作要放在afterCompletion放postHandle会在异常链路上漏掉。注册时通过WebMvcConfigurer.addInterceptors()加进去addPathPatterns和excludePathPatterns的匹配是按 Ant 风格路径来的/**会连静态资源一起拦实际项目里通常要把/static/**、/error排掉否则登录校验拦截器会把静态资源也拦成 302。过滤器与拦截器的区别也是资料里列了但容易答浅的点。核心差别在层次过滤器基于 Servlet 规范在容器层连静态资源、编码、跨域这些都能管拦截器在框架层拿得到处理器方法对象能读方法上的注解和参数。执行顺序上过滤器在前因为请求要先过容器才进框架。2.3 EnableAutoConfiguration 的调用链与条件装配的判定顺序自动装配是这三个话题里最容易被问源码的。资料的描述是准确的SpringBootApplication拆开是三个注解SpringBootConfiguration声明配置类ComponentScan扫启动类所在包EnableAutoConfiguration开启自动配置后者通过Import导入AutoConfigurationImportSelector再调getAutoConfigurationEntry()和getCandidateConfigurations()拿到候选配置类清单。实际链路再往下走一层是这样getCandidateConfigurations()最终通过SpringFactoriesLoader去读类路径下META-INF/spring.factories里EnableAutoConfiguration这个 key 对应的全限定类名列表较新的版本改成了读META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。读回来之后不是全部生效要先过一遍去重和排除exclude、excludeName再交给条件注解逐条判定。条件判定的顺序值得记一下因为它决定了你自定义的 Bean 会不会被覆盖先看类路径上有没有对应的类ConditionalOnClass再看容器里有没有同类型 BeanConditionalOnMissingBean再看配置项开关ConditionalOnProperty最后看是否满足特定 Web 类型等环境条件。顺序不是绝对的但ConditionalOnMissingBean必须排在最后判否则你自己声明的 Bean 就没机会了。写一个自定义自动配置类验证一下这套机制比看源码注释直观import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration // 只有类路径上存在该类时才生效 ConditionalOnClass(name com.example.demo.MetricsClient) // 配置开关默认开启可用 demo.metrics.enabledfalse 关掉 ConditionalOnProperty(prefix demo.metrics, name enabled, havingValue true, matchIfMissing true) public class MetricsAutoConfiguration { // 容器里没有用户自定义的 MetricsTemplate 时才注册这个默认实现 Bean ConditionalOnMissingBean public MetricsTemplate metricsTemplate() { return new DefaultMetricsTemplate(); } }参数说明ConditionalOnClass这里用name属性而不是直接写MetricsClient.class是因为自动配置类可能在目标类不存在时先被加载直接引用会抛NoClassDefFoundError用字符串能避开类加载。havingValue表示只有当配置值等于这个字符串时才匹配matchIfMissing表示配置项缺失时是否算匹配默认 false这里设成 true 表示不配也能用。ConditionalOnMissingBean不加参数时按方法返回类型判断所以方法返回类型一定要写具体类而不是接口写接口会导致判定失效——这是个很隐蔽的坑后面还会提到。要让这套配置被发现还需要在被引用的模块里放META-INF/spring.factories或新版.imports文件写上org.springframework.boot.autoconfigure.EnableAutoConfigurationcom.example.demo.MetricsAutoConfiguration。这一步漏了配置类写得再对也不会生效而现象是「启动不报错、Bean 就是没有」很难查。3. SQL 执行计划与索引把 EXPLAIN 十个字段读成一张体检表SQL 优化这块资料给的密度很高从执行计划的字段一路讲到 B 树的页容量、再到索引失效的各种写法。它最有用的地方是把EXPLAIN的字段按「看什么、危险信号是什么」组织起来了。我的习惯是把EXPLAIN当成一次体检报告来读先看type定严重程度再看key和key_len看有没有用上索引、用了几列最后看Extra确认有没有回表或临时表。按这个顺序绝大多数慢查询在一分钟内就能定位到问题在索引还是在写法。3.1 type 字段的性能阶梯和 key 与 possible_keys 的差别type描述的是访问类型从好到坏大致是system→const→eq_ref→ref→range→index→ALL。这个阶梯不用死记理解「扫描范围」就自然记住了const是唯一索引等值命中一行eq_ref是 join 时被驱动表走唯一索引ref是走非唯一索引等值range是范围扫描index是全索引扫描扫的是索引树而不是数据比ALL略好ALL是全表扫描。看到ALL基本就可以确定这条 SQL 有优化空间除非表本身只有几百行。possible_keys和key的区别经常被答反。前者是优化器「认为可能用得上」的索引后者是「实际选中」的索引。possible_keys有值而key为 null说明索引存在但没被选中常见原因是选择性太低优化器算出来走索引还不如全表快、或者条件里对列做了隐式类型转换。key_len是实际用到的索引字节长度联合索引下这个值能直接告诉你用了几列这是排查「为什么只走了联合索引的第一列」最快的手段。下面这段用建表加查询的方式把这事说清楚可以直接在测试库跑-- 建一张订单表联合索引覆盖 user_id status created_at CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, status TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, KEY idx_user_status_time (user_id, status, created_at) ) ENGINEInnoDB; -- 场景一三列都用上key_len 应该最大 EXPLAIN SELECT id, amount FROM t_order WHERE user_id 1001 AND status 1 AND created_at 2024-01-01; -- 场景二跳过中间列只用到第一列 EXPLAIN SELECT id, amount FROM t_order WHERE user_id 1001 AND created_at 2024-01-01; -- 场景三条件里对列做函数运算索引直接失效 EXPLAIN SELECT id, amount FROM t_order WHERE DATE(created_at) 2024-01-01;逻辑说明第一条 SQL 的三列顺序和索引定义完全一致key_len会把user_id8 字节status1 字节可空则加 1created_at5 字节左右取决于精度都算进去。第二条跳过了statuscreated_at只能用于过滤而不能用于定位key_len会明显变小Extra里可能出现Using index condition。第三条是经典反例列上包了函数索引树里的值是按created_at原始值排的对函数结果没序索引只能放弃type直接掉到ALL。参数说明key_len的具体字节数取决于字段定义和字符集BIGINT固定 8 字节TINYINT1 字节DATETIME5 到 8 字节VARCHAR按字符集算utf8mb4 下每个字符 4 字节再加 2 字节长度可空字段额外加 1 字节标记 NULL。所以看key_len不能只看数值大小要和字段定义对得上才知道到底用了几列。3.2 B 树页容量推算三层为什么能撑住千万行资料里给了一个很具体的推算一个非叶子节点 16KB一个 key 约 8 字节一个指针约 6 字节所以一页大概能存一千条左右的索引项两层到百万三层到千万。这个推算值得自己动手验一遍因为它是理解「为什么索引列不能太长」的最好入口。按 16KB 页、key 8 字节、指针 6 字节算单页容量是 16384 / 14 ≈ 1170。两层非叶子节点能指向 1170 × 1170 ≈ 136 万行加上叶子层再乘一次就是十亿级实际因为叶子节点存的是整行数据或主键每页容纳的行数远少于 1170所以三层 B 树在千万级数据上是合理的量级。这也解释了两件事一是 B 树为什么把数据全放叶子节点因为非叶子节点只存索引项才能扇出这么大二是为什么联合索引的列数不能无限加每加一列 key 就变长一页能存的项就变少树就变高查询就多一次磁盘 IO。叶子节点之间用指针连成链表这是范围查询快的原因定位到起始值之后顺着链表往后走就行不用回到根节点重新找。面试里问「为什么用 B 树不用 B 树」时这两点就是标准答案的核心——扇出更大导致树更矮、叶子链表让范围扫描变成顺序遍历。3.3 六种索引失效写法与可落地的改写资料列了索引失效的几种情形列上使用函数或表达式、使用NOT或!、使用or、对索引列进行运算、like前置%、多列索引只用第一列之后的列。这些都对但实际项目里最常遇到的是前三种因为后几种写出来的人自己就有感觉。下面按「原写法 → 问题 → 改写」整理成表方便对照检查原写法失效原因改写方式WHERE DATE(created_at) 2024-01-01列上套函数索引无序WHERE created_at 2024-01-01 AND created_at 2024-01-02WHERE id 1 100列参与运算WHERE id 99WHERE name LIKE %张%前置通配符无法定位起点改为前缀匹配LIKE 张%或引入全文索引WHERE status ! 1不等于通常扫描大半张表改成枚举正向条件或拆成多个INWHERE user_id 1 OR amount 100or两侧有一侧无索引则全表拆成两条 SQL 用UNION ALL或给amount也建索引WHERE user_id 1 AND created_at ...联合索引为 user_id, status, created_at跳过中间列调整索引列顺序或补上status条件or这一条要特别说明不是所有or都会失效如果两侧条件都能走索引优化器会用index_merge合并结果。真正失效的是一侧有索引一侧没有这时优化器判断合并代价高于全表扫描直接放弃索引。还有几个资料里提到但容易被忽略的写法in的值不宜过多超过一定数量优化器可能放弃索引改走全表分页只有一页数据时用limit 1能让优化器提前终止扫描union all代替union能省掉去重排序的开销。排序字段如果没走索引会触发Using filesort这在Extra里能直接看到是慢查询的常见来源。提示EXPLAIN看到Using index才是覆盖索引说明查询的列全在索引里不用回表看到Using index condition是索引下推只表示条件下推到了存储引擎层仍然要回表。这两个词差一个单词含义完全不同面试里说反了会很尴尬。4. 线程池与并发七个参数怎么配、五种状态怎么看线程池这块资料覆盖得比较全从四种Executors工厂方法讲到七个核心参数、五种状态、三大特性、优雅退出。它的价值在于把这些点串成了一条「创建 → 提交 → 执行 → 关闭」的完整链路。实际工作中真正会出事的地方集中在两处参数配错导致的队列堆积或线程爆炸以及状态没管好导致的关闭丢失任务。所以看这块内容时别停在背七个参数的名字上要能把每个参数对应到一种故障现象。4.1 七个核心参数与任务提交的四道闸门七个参数是corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。任务提交进来之后的判定顺序是固定的一共四道闸门核心线程没满就新建核心线程执行核心线程满了就进队列排队队列满了且线程数没到最大值就新建救急线程救急线程也满了就触发拒绝策略。这个顺序和很多人的直觉相反——直觉上会以为线程数先涨到最大值再排队实际是先排队。这也解释了为什么用无界队列比如LinkedBlockingQueue不传容量时maximumPoolSize永远用不上因为队列永远不会满救急线程永远不会创建。Executors的四个工厂方法都踩在这个坑上newFixedThreadPool用无界队列任务堆积时内存涨到 OOMnewCachedThreadPool的最大线程数是Integer.MAX_VALUE任务暴涨时线程数失控newSingleThreadExecutor同样是无界队列。所以实际项目里我一般直接new ThreadPoolExecutor参数自己写清楚import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class PoolFactory { public static ThreadPoolExecutor build(String bizName) { int cores Runtime.getRuntime().availableProcessors(); AtomicInteger seq new AtomicInteger(1); return new ThreadPoolExecutor( cores, // 核心线程数按 CPU 核数起 cores * 2, // 最大线程数IO 密集型可到 2 倍 60L, // 救急线程空闲存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue(500), // 有界队列容量必须给 r - { // 线程工厂命名便于排查 Thread t new Thread(r, bizName - seq.getAndIncrement()); t.setDaemon(false); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略退给调用线程 ); } }逻辑说明核心线程数按 CPU 核数起是保守起点CPU 密集型任务这个值基本合适IO 密集型因为线程大部分时间在等待可以适当放大到 2 倍甚至更多但别凭感觉放大到几十要通过压测确认。队列容量必须显式给ArrayBlockingQueue是有界的满了才会触发扩容到最大线程数这才让maximumPoolSize有意义。线程工厂里给线程起名字是很划算的一笔投入出问题时jstack一眼就能看出是哪个业务池的线程。参数说明keepAliveTime只对超出核心线程数的部分生效核心线程默认不会回收除非调allowCoreThreadTimeOut(true)。拒绝策略四种里AbortPolicy是默认值直接抛RejectedExecutionExceptionCallerRunsPolicy把任务退回给提交任务的线程执行相当于给上游一个天然的限流反压DiscardOldestPolicy丢队列里最老的任务DiscardPolicy静默丢弃。生产环境我倾向CallerRunsPolicy因为它不会丢任务代价是提交线程被拖慢反而形成了背压。静默丢弃的那两种除非业务明确允许丢否则不要用出了事没有任何日志。4.2 五种状态、三个监控方法和优雅退出线程池状态是RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。RUNNING接受新任务并处理队列SHUTDOWN不再接受新任务但会把队列跑完STOP既不接受也不处理队列还会中断正在执行的任务TIDYING是所有任务结束、正在做清理的过渡态TERMINATED是终态。这五个状态不是独立设置的而是通过shutdown()和shutdownNow()两个方法推动的前者推到SHUTDOWN后者推到STOP。监控三个指标对应三个方法getPoolSize()当前线程数、getActiveCount()正在执行任务的线程数、getTaskCount()已提交任务总数这是个近似值。判断线程池是否健康看getActiveCount()和getPoolSize()的比值比看绝对值更有意义长期接近 1 说明线程不够用长期很低说明核心线程数配大了。再配合getQueue().size()看排队情况队列长期满就是容量或线程数有问题。优雅退出的写法有讲究shutdown()之后必须awaitTermination否则主线程直接退出队列里的任务会被丢掉public void stop(ThreadPoolExecutor pool) { pool.shutdown(); // 不再接新任务队列继续跑 try { // 给一个等待上限避免无限阻塞 if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { pool.shutdownNow(); // 超时则中断返回未执行的任务列表 } } catch (InterruptedException e) { pool.shutdownNow(); Thread.currentThread().interrupt(); // 恢复中断标记别吞掉 } }逻辑说明先shutdown()温和关闭再限时等待等不到才shutdownNow()强制中断。这个顺序不能反先shutdownNow()会直接丢掉队列里所有排队的任务。awaitTermination抛InterruptedException时要把中断标记恢复回去否则上层代码感知不到中断信号这是并发代码里的基本礼节。4.3 原子性、可见性、有序性各自的落地手段三大特性的问题根源不一样解决手段也不一样混着答就容易露怯。原子性的问题是复合操作被打断比如i实际是读、加、写三步解决办法是加锁synchronized、Lock或换成AtomicInteger这类 CAS 实现。可见性的问题是线程各自的工作内存里有一份副本一个线程改了另一个看不到解决办法是volatile保证读写直接走主内存或加锁锁的释放会把修改刷回主存。有序性的问题是编译器和 CPU 会重排指令volatile通过内存屏障禁止特定重排synchronized因为同一时刻只允许一个线程进入临界区天然保证了区内代码的顺序。volatile只能解决可见性和有序性解决不了原子性这是最常见的答错点。i加了volatile依然会丢更新因为它不是原子操作。反过来synchronized三个都能解决代价是性能。所以选型逻辑是只需要可见性比如状态标志位用volatile需要原子性用原子类或锁两者都要就用锁。线程状态那块BLOCKED是等锁WAITING是调了wait()、join()、park()等主动等待TIMED_WAITING是带超时的等待RUNNABLE包含了正在跑和就绪两种情况——这个细节面试里经常用来区分背过和真懂。5. 避坑与排查五个在真机上翻车的记录这些点资料里都有但只写结论不写现象实际用的时候还是会踩。下面五条是我按「现象 → 原因 → 解决」整理的都是能直接对号入座的。5.1 事务与循环依赖三个最容易答反的细节坑一异常被 try-catch 吞掉事务不回滚。现象是接口返回成功数据库里数据却只写了一半。原因是 Spring 事务默认只在抛出运行时异常和Error时回滚而且如果异常在方法内部被catch住没有再抛出去事务拦截器根本感知不到。解决办法是不要吞异常必须处理的话在 catch 块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。另外受检异常默认也不回滚需要Transactional(rollbackFor Exception.class)显式声明。坑二同类内部方法调用事务注解失效。现象是方法上明明加了Transactional日志里却看不到事务开启。原因是事务靠 AOP 代理实现同类内部调用走的是this绕过了代理对象。解决办法是把方法拆到另一个 Bean 里或者注入自己的代理对象再调用。这个坑和「非 public 方法加事务注解不生效」是同一个根因都是代理机制限制。坑三构造注入的循环依赖三级缓存救不了。现象是启动直接报BeanCurrentlyInCreationException。原因是三级缓存的设计前提是「先实例化、再注入属性」构造注入要求在实例化阶段就拿到依赖对象此时 Bean 还没被放进缓存无法提前暴露引用。解决办法是三条改成字段注入或 setter 注入、在其中一个注入点上加Lazy延迟解析、或者干脆重构消除循环。资料里把「重构」放在第一位是对的因为循环依赖本身就是设计问题的信号靠Lazy掩盖只会让后面更难改。三级缓存的查找顺序也要说清先查一级singletonObjects完整 Bean没有查二级earlySingletonObjects半成品再没有查三级singletonFactories创建工厂从三级拿到对象后放进二级并清掉三级。这个「升舱」过程是解决循环依赖的核心但只对单例、且非构造注入的 Bean 有效。5.2 并发与集合两个后悔没早知道的坑坑四HashMap在 JDK 1.7 扩容时可能形成环形链表。现象是 CPU 打满但业务无响应jstack看到线程卡在HashMap.get()上。原因是 1.7 用头插法迁移数据并发扩容时链表指针可能成环。1.8 改成尾插法解决了成环问题但并发写依然会丢数据或覆盖所以结论没变多线程场景必须用ConcurrentHashMap别指望版本升级能救。同理SimpleDateFormat是线程不安全的共享实例在高并发下会解析出错乱的日期换成DateTimeFormatter或者每次新建。坑五ConditionalOnMissingBean判定失效自定义 Bean 没生效。现象是自己在配置类里声明了一个同类型的 Bean容器里用的还是自动配置的默认实现。原因多半是条件注解配在返回接口类型的方法上容器按接口类型查找时没匹配到具体实现或者自动配置类比用户配置类先加载。解决办法是方法返回类型写具体类并且在自动配置类上确认加载顺序必要时用AutoConfigureBefore/AutoConfigureAfter调整。补充一条防重复提交的实现要点。资料给的方案是拦截器 Redis用「请求路径 参数 用户 id」拼 keyincr之后判断返回值等于 1 就设 15 秒过期大于 1 就拦。这个思路可行但要注意incr和expire不是原子的如果incr成功后进程挂了key 就永不过期用户会被永久拦住。稳妥做法是用SET key value NX EX 15一条命令搞定或者用 Lua 脚本把自增和设过期包在一起。6. 把 PDF 变成可跑工程三张自测表和一条验证路线PDF 最大的问题是看完就忘因为阅读是线性的而面试追问是发散的。我的做法是把它当索引每读一块就在本地建一个最小验证工程用可运行的代码把结论钉住。下面这三张表可以对着打勾能跑通说明这块是真懂了跑不通就是还需要补的地方。知识点验证方式判断标准Bean 生命周期挂五个钩子跑一次输出顺序与 2.1 节一致销毁钩子有输出循环依赖造一个 A↔B 的构造注入启动报BeanCurrentlyInCreationException加Lazy后能启动自动装配写一个自定义 starter去掉spring.factories后 Bean 消失加回后出现索引失效用 3.3 的六种写法逐条EXPLAINtype和key_len的变化符合预期线程池提交超过队列容量的任务观察拒绝策略是否触发线程名是否符合命名规则事务失效同类内部调用带事务的方法断点确认没有事务代理数据未回滚验证路线按两周排比较合适前四天过 Spring 三块每天一块每块写一个可运行的 Demo中间四天做 SQL重点是EXPLAIN的字段解读和索引改写找一张有数据的测试表实际跑后面四天做并发和事务线程池要压一次拒绝策略事务要把三个失效场景各复现一遍最后两天做交叉验证把「事务 线程池」和「事务 循环依赖」这种组合场景跑一遍因为真实故障往往出在组合处。有一个技巧值得单独说把所有验证代码放在同一个工程里用不同的SpringBootTest类隔离每个类只验证一个结论类名直接写成结论比如TransactionRollbackOnCheckedExceptionTest。这样半年后再回来看测试类名本身就是一份知识清单比笔记可靠得多。// 一个最小验证点的写法类名即结论 SpringBootTest class InnerCallBreaksTransactionTest { Autowired private OrderService orderService; Test void innerCallShouldNotRollback() { // 内部调用不走代理第二条插入不会回滚 assertThrows(RuntimeException.class, () - orderService.outerMethod()); // 断言第一条已落库、第二条未落库验证事务确实失效 assertEquals(1, orderService.countAll()); } }这段代码的逻辑说明outerMethod内部直接调this.innerMethod()innerMethod上的Transactional不生效所以异常抛出后第一条插入不会回滚。断言写成「第一条在、第二条不在」就同时验证了「事务失效」和「失效的具体表现」。参数说明assertThrows用来确认异常确实抛出了否则测试会误判为通过countAll要查真实库而不是 mock因为要验证的是事务行为本身。最后说个我自己的教训。早年准备面试时我把这类 PDF 从头到尾划了重点自我感觉良好结果一面问到「PostConstruct和afterPropertiesSet谁先执行」就卡住了因为我只记住了两个名字从没跑过。从那以后我每次看这类资料都强制走一遍「读一条、写一个能跑的验证、记下现象」的循环宁可看得慢也不留似是而非的点。这份 PDF 内容密度够但它的正确用法是当提问清单不是当答案册。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站