JavaSE 面试知识点
本页只放面试标准回答、项目话术和知识点跳转。底层原理、流程图、Demo、为什么这样设计、不这样会怎样,都放在对应知识点文档中。
使用方式
mermaid
flowchart TD
A["先看本页面试回答"] --> B["知道面试怎么说"]
B --> C["点击知识点链接"]
C --> D["深入理解原理、流程图和 Demo"]
D --> E["回到项目场景组织自己的回答"]Java基础语义
| 面试题 | 标准回答 | 原理知识点 |
|---|---|---|
| 怎么判断 JavaSE 是否真正学懂 | 不能只会 API 和八股。真正学懂要能从源码编译运行、类型和值传递、面向对象、String、集合泛型、反射注解、IO/NIO、并发 JMM、线程池、CompletableFuture、JVM、GC、JIT、OOM 排查一路讲下来,并能给出 Demo、商业场景和不这样做的后果。 | JavaSE从零到精通验收清单 |
| JavaSE 怎么从零学到生产可用 | JavaSE 要按基础语法、面向对象、常用类型、集合泛型、反射注解、IO/NIO、并发、线程池、JVM 和生产排查这条线学习。基础语法解决代码怎么写,面向对象解决业务规则怎么组织,集合和泛型解决批量数据和类型安全,反射注解是 Spring/MyBatis 等框架基础,IO/NIO 解决文件和网络,JUC 和线程池解决并发控制,JVM 解决内存、类加载、GC 和性能排查。 | JavaSE从零到生产级掌握、JavaSE商业场景训练营 |
| 为什么 Java 学习不能只看 JDK 21 | 企业项目和面试里 JDK 7、JDK 8 仍然非常重要。JDK 7 代表传统 Java 写法成熟期,JDK 8 是 Lambda、Stream、Optional、java.time、CompletableFuture、HashMap/ConcurrentHashMap 变化的分水岭。JDK 11/17/21 要作为 LTS 演进学习,但不能跳过老项目写法和 JDK 8 高频原理。 | JDK版本差异 |
| JDK 8、11、17、21如何概括 | JDK 8引入Lambda、Stream、java.time和CompletableFuture;11是模块化后的LTS,提供标准HTTP Client和工程API;17提供Record、sealed、文本块、Switch表达式并强化内部封装;21正式提供虚拟线程、Record模式、Switch模式匹配、Sequenced Collections和分代ZGC。 | LTS版本演进 |
| JDK 11主要新增什么 | JDK 11当期重点是标准HTTP Client、String和Files便捷API、Optional.isEmpty、Predicate.not、单文件源码运行、JFR和TLS 1.3;从8升级还会累计获得9的JPMS和集合工厂、10的局部变量var,同时要处理Java EE/CORBA模块移除。 | JDK 11新增特性 |
| JDK 17主要新增什么 | 12~17累计正式提供Switch表达式、文本块、Record、instanceof模式匹配、sealed、Stream.toList和HexFormat;运行时强封装更严格,CMS、Nashorn等旧能力已移除。 | JDK 17新增特性 |
| JDK 21虚拟线程解决什么 | 它降低大量阻塞等待线程的资源成本,适合高并发IO,不提升CPU计算速度,也不增加数据库和下游容量。通常每任务创建虚拟线程,在下游用连接池、Semaphore、限流和超时控制并发,并关注JDK 21的pinning和ThreadLocal成本。 | JDK 21新增特性 |
| JDK 7 和 JDK 8 主要区别 | JDK 7 重点是 try-with-resources、switch String、菱形语法、多异常捕获、NIO.2;JDK 8 引入 Lambda、Stream、函数式接口、Optional、java.time、CompletableFuture、接口默认方法。集合和并发上,HashMap 增加红黑树优化,ConcurrentHashMap 从 Segment 分段锁改为 CAS + synchronized 的桶级并发控制。 | JDK版本差异 |
| Lambda 底层怎么理解 | Lambda 依赖函数式接口,编译器会根据目标接口的唯一抽象方法检查参数和返回值。JDK 8 通常通过 invokedynamic 和 LambdaMetafactory 在运行时创建函数对象,而不是简单生成匿名内部类。它让行为可以作为参数传递,常用于过滤、排序、回调和策略。 | JDK版本差异:Lambda原理 |
| Stream 一定比 for 循环快吗 | 不一定。Stream 的价值主要是声明式表达过滤、映射、分组、统计等数据处理流程,不保证性能一定更好。小数据量或简单循环下 for 可能更快;Stream 里不要做慢 SQL、远程调用,也不要滥用 parallelStream。 | JDK版本差异:Stream原理 |
| Optional 能彻底解决空指针吗 | 不能。Optional 主要用于返回值上显式表达“可能没有值”,提醒调用方处理缺失情况。它不能阻止你把 Optional 变量本身设为 null,也不能阻止你不判断就调用 get。实体字段和 DTO 字段通常不建议大量使用 Optional。 | JDK版本差异:Optional |
| 函数式接口是什么 | 函数式接口是只有一个抽象方法的接口,可以用 Lambda 表达式实现。@FunctionalInterface 不是必须的,但建议加上,编译器会帮你检查接口是否仍然满足函数式接口规则。 | Lambda与函数式接口 |
| Lambda 为什么只能访问 effectively final 变量 | Lambda 可能在当前方法结束后才执行,而局部变量存在线程栈中,生命周期很短。Java 要求被 Lambda 捕获的局部变量是 final 或 effectively final,保证捕获的是稳定值,避免生命周期和并发语义混乱。 | Lambda捕获变量 |
| Stream 为什么是惰性执行 | 中间操作只是描述流水线,不会立即遍历数据。只有遇到终止操作,比如 collect、count、forEach、findFirst,流水线才真正执行。惰性执行可以融合多个操作,并支持短路。 | Stream惰性执行原理 |
| map 和 flatMap 区别 | map 是一对一转换,一个元素变成另一个元素;flatMap 是把每个元素转换成一个流,再把多个流摊平成一个流,适合处理嵌套集合。 | map和flatMap |
| Collectors.toMap 有什么坑 | toMap 遇到重复 key 默认会抛异常。使用前要确认 key 是否唯一,如果不唯一必须显式提供 merge 函数,说明保留旧值、新值还是合并值。 | toMap重复key |
| Optional 的 orElse 和 orElseGet 区别 | orElse 的默认值会先计算,即使 Optional 有值也会执行;orElseGet 只有在没值时才调用 Supplier。默认值涉及查库、调接口、复杂计算时应使用 orElseGet。 | Optional常见坑 |
| parallelStream 为什么慎用 | 它默认使用公共 ForkJoinPool,线程池隔离、并发量、超时、监控都不明确。IO 调用、数据库访问、HTTP 请求放进 parallelStream 可能拖慢公共线程池甚至打爆下游。 | parallelStream风险 |
| JDK 8 为什么引入 CompletableFuture | JDK 7 的 Future 只能阻塞 get,组合多个异步任务很不方便。CompletableFuture 支持 thenApply、thenCompose、thenCombine、allOf 等异步编排,适合接口聚合、并行查询和异步任务组合。但生产必须指定线程池、设置超时和异常兜底。 | JDK版本差异:CompletableFuture、CompletableFuture全过程 |
| JDK 8 移除永久代后还会元空间 OOM 吗 | 会。JDK 8 移除 PermGen,类元数据主要放到本地内存的 Metaspace,但如果动态生成类太多、类加载器泄漏、反复热部署不释放,仍然可能出现 OutOfMemoryError: Metaspace。 | JDK版本差异:JVM差异、JVM内存结构 |
| 面向对象三大特性是什么 | 封装、继承、多态。封装把对象状态和行为放在一起,通过方法保护状态合法;继承表达稳定的 is-a 关系,复用父类能力;多态让父类或接口引用指向不同实现,运行时按真实对象的方法执行。 | Java面向对象、对象创建流程 |
| 封装为什么不只是 private 加 getter/setter | 真正的封装不是把字段藏起来再全部暴露 setter,而是把状态变化收敛到有业务含义的方法中,并在方法里校验规则。例如账户只能 deposit、withdraw,订单只能 pay、deliver、cancel。否则外部可以绕过规则直接改字段,业务状态会失控。 | Java面向对象:封装 |
| 重载和重写区别 | 重载发生在同一个类中,方法名相同但参数列表不同,编译期根据参数类型决定调用哪个;重写发生在继承关系中,子类覆盖父类方法,运行期通过动态绑定按真实对象类型执行。重载看“引用类型和参数”,重写看“真实对象”。 | Java面向对象:重载和重写 |
| 多态底层怎么理解 | 多态的核心是“编译期看父类或接口,运行期看真实对象”。业务代码依赖接口调用方法,具体实现可以是短信、邮件、企微等不同发送器。这样新增实现不需要改主流程,符合开闭原则。 | Java面向对象:多态 |
| 接口和抽象类怎么选 | 接口适合定义能力、协议和扩展点,一个类可以实现多个接口;抽象类适合抽取一类对象的公共状态、公共流程和模板方法。商业项目里一般优先接口和组合,只有公共状态和流程非常稳定时才使用抽象类。 | Java面向对象:接口和抽象类 |
| 为什么优先组合而不是继承 | 继承会把子类和父类强绑定,父类字段、构造、方法变化都可能影响所有子类;组合是“我需要什么能力就持有什么对象”,边界更清楚,替换也更容易。只有确实满足稳定 is-a 关系时才建议继承。 | Java面向对象:继承和组合 |
| 贫血模型和充血模型区别 | 贫血模型的实体只有字段和 getter/setter,业务规则散落在 Service;充血模型把核心状态变化放进对象方法里,让对象自己保护状态。普通 CRUD 可以简单贫血,但订单、支付、库存、审批这类状态规则复杂的领域,应至少收敛关键状态入口。 | Java面向对象:贫血模型和充血模型 |
| Java 程序从源码到运行经历什么 | .java 源码先由 javac 编译成 .class 字节码,JVM 再通过类加载器加载 class,做字节码验证,然后解释执行或对热点代码做 JIT 编译。本质上 Java 不是源码直接运行,也不是一次性编译成固定平台机器码,而是字节码加 JVM 运行时。 | Java基础语法:运行流程、源码到字节码再到机器码 |
| classpath 是什么 | classpath 是 JVM 查找 class 和资源文件的搜索路径。运行带包名的类时,-cp 指向的是包结构的根目录,运行类名要写全限定名,例如 java -cp out com.company.order.OrderApp。类找不到时优先检查 class 实际输出路径、包名和 classpath 根目录。 | Java基础语法:classpath |
| ClassNotFoundException 和 NoClassDefFoundError 区别 | ClassNotFoundException 通常是代码主动按类名加载时找不到类,例如 Class.forName;NoClassDefFoundError 通常是编译时有类、运行时缺类,或者类初始化失败。前者偏主动查找失败,后者偏运行依赖缺失或初始化失败。 | Java基础语法:常见错误 |
| 编译期错误和运行期错误区别 | 编译期错误在 javac 阶段发现,例如语法错误、类型不匹配、方法不存在;运行期错误发生在程序执行时,例如空指针、数组越界、类找不到、数据库连接失败。编译期问题靠类型系统拦截,运行期问题靠校验、异常、日志、监控和测试兜底。 | Java基础语法:编译期和运行期 |
== 和 equals 区别 | == 对基本类型比较值,对引用类型比较是否同一个对象;equals 默认也是比较对象地址,但很多类会重写它来比较业务内容,比如 String 比较字符内容。 | 数据类型与运算、String详解 |
| 基本类型和引用类型区别 | 基本类型变量直接保存值,例如 int、long、boolean;引用类型变量保存对象引用,真正对象通常在堆里。基本类型没有 null,包装类型和普通对象可以为 null。理解这个区别才能解释 ==、空指针、值传递和对象修改。 | 数据类型与运算:基本类型和引用类型 |
| Java 是值传递还是引用传递 | Java 只有值传递。基本类型传值的副本;引用类型传引用值的副本。方法里可以通过引用副本修改同一个对象的字段,但不能改变调用方变量本身指向哪个对象。 | 数据类型与运算:值传递 |
| 为什么金额不能用 double | double 是二进制浮点,很多十进制小数无法精确表示,例如 0.1 + 0.2 不等于精确的 0.3。金额应使用 BigDecimal 并明确舍入规则,或者用分为单位的 long 保存。 | 数据类型与运算:金额精度 |
| BigDecimal 的 equals 和 compareTo 区别 | equals 会同时比较数值和 scale,例如 1.0 和 1.00 equals 为 false;compareTo 只比较数值大小,通常金额大小比较用 compareTo。生产中还要统一 scale 和舍入规则。 | 数据类型与运算:BigDecimal比较 |
| 包装类型有什么坑 | 包装类型可以为 null,自动拆箱时如果是 null 会抛 NullPointerException;包装类型用 == 比较的是引用,还受 Integer 缓存影响。业务比较应使用 Objects.equals,布尔判断用 Boolean.TRUE.equals(value)。 | 数据类型与运算:包装类型 |
| DTO 字段为什么常用包装类型 | 包装类型能表达“没传”和“传了默认值”的区别。例如 Integer age 为 null 表示不更新年龄,0 表示更新为 0;Boolean enabled 为 null 表示不更新开关,false 表示关闭。如果用基本类型就会丢失这个语义。 | 数据类型与运算:类型选择 |
| 整数溢出怎么理解 | 整数类型位数固定,超过范围不会自动变成更大类型,而是按补码回绕,可能从最大正数变成负数。ID、金额分、库存、计数器要根据范围选择 long,必要时用 Math.addExact 暴露溢出。 | 数据类型与运算:整数溢出 |
为什么重写 equals 要重写 hashCode | 因为 HashMap、HashSet 会先用 hashCode 定位桶,再用 equals 判断桶内节点是否同一个 key。equals 相等的对象必须有相同 hashCode,否则可能落到不同桶里,HashMap 根本不会在正确桶里比较 equals,最终导致查不到或去重失败。 | HashMap全过程原理、集合框架 |
final、finally、finalize 区别 | final 是修饰符,表示类不可继承、方法不可重写、变量不可重新赋值;finally 是异常处理中的收尾代码块;finalize 是对象回收前可能被调用的方法,已不推荐使用。 | 包与修饰符、异常处理、JVM组成 |
| private、默认、protected、public 区别 | private 只在当前类可见;默认不写是包内可见;protected 同包可见,并给不同包子类提供继承访问;public 任意包可见。访问控制本质是封装边界,不是为了语法限制,而是控制哪些能力可以被外部依赖。 | 包访问控制与修饰符:访问控制 |
| protected 为什么不是任意地方都能访问 | 不同包子类可以在继承关系内部访问父类 protected 成员,但不能拿一个父类引用随便访问 protected 成员。protected 的目标是给子类扩展留入口,不是把成员半公开给外部任意调用。 | 包访问控制与修饰符:protected |
| static 变量有什么风险 | static 属于类,被所有对象和线程共享。可变 static 变量如果保存请求用户、租户、订单上下文,会发生串数据;如果保存全局缓存,要考虑线程安全、容量、过期和生命周期。 | 包访问控制与修饰符:static风险 |
| static 初始化顺序怎么理解 | 类首次主动使用时会执行类初始化,先准备 static 字段默认值,再按源码顺序执行 static 赋值和 static 代码块。源码顺序会影响结果,静态变量互相依赖时尤其容易出错。 | 包访问控制与修饰符:static初始化 |
| static final 常量为什么改了可能不生效 | 编译期常量可能被调用方编译器直接内联到 class 文件里。公共 jar 中常量从 v1 改成 v2 后,如果调用方没有重新编译,运行时可能仍使用旧值。频繁变化的配置不应做成跨模块编译期常量。 | 包访问控制与修饰符:常量内联 |
| final 修饰引用对象是不是绝对不可变 | 不是。final 修饰引用变量时,只能保证变量不能指向另一个对象,不能保证对象内部状态不变。真正不可变对象还需要 private final 字段、防御性拷贝、不暴露可变集合、类不允许子类破坏语义。 | 包访问控制与修饰符:不可变对象 |
| String 为什么不可变 | 不可变可以保证安全、复用、线程安全和 hash 稳定。类名、文件路径、URL、缓存 key、Map key 都大量使用 String,如果内容能被随意修改,会破坏安全边界和集合查找结果。不可变也让字符串常量池可以安全复用。 | String详解:不可变 |
| 字符串常量池是什么 | 字符串常量池是 JVM 对字符串字面量做复用的区域。多个相同字面量通常指向同一个池中对象;但 new String("abc") 会创建新对象,所以内容相同不等于引用相同。JDK 8 中字符串常量池在堆里。 | String详解:常量池 |
new String("abc") 创建几个对象 | 要看常量池里是否已经有 "abc"。如果没有,字面量会对应池中对象,new String 还会在堆上创建一个新 String;如果池里已有字面量对象,则通常只新建堆上的 String。面试重点是区分常量池对象和 new 出来的对象。 | String详解:内存关系 |
| intern 是什么 | intern() 返回字符串在常量池中的引用。池里已有相同内容就返回已有引用;没有则把该内容加入常量池并返回池中引用。它适合少量高重复字符串,不适合对海量动态用户输入、订单号、日志内容盲目使用。 | String详解:intern |
| StringBuilder 和 StringBuffer 区别 | StringBuilder 非线程安全,性能更好,适合方法内部局部拼接;StringBuffer 方法带同步,线程安全但开销更大,只有多个线程共享同一个拼接对象时才考虑。生产中绝大多数局部拼接优先 StringBuilder。 | String详解:StringBuilder和StringBuffer |
| 为什么会乱码 | 乱码的根因是编码和解码使用的字符集不一致。Java String、文件字节、HTTP 请求响应、数据库连接都有编码环节,任何一处不一致都可能乱码。生产建议统一 UTF-8,并在 IO、HTTP、数据库配置中显式声明。 | String详解:字符编码 |
| 数组为什么下标从 0 开始 | 数组访问可以理解为起始位置加偏移量。第一个元素相对起始位置偏移是 0,所以下标是 0;第 n 个元素位置可以按 起始位置 + n * 元素大小 理解。 | Java数组:下标 |
| 对象数组创建后元素对象也创建了吗 | 没有。new User[10] 只创建了一个长度为 10 的数组,数组每个位置默认是 null。必须给某个位置赋具体对象后,才能访问对象字段或方法,否则会空指针。 | Java数组:对象数组 |
| 数组拷贝是深拷贝还是浅拷贝 | 对基本类型数组复制的是值;对引用类型数组默认复制的是对象引用,也就是浅拷贝。两个数组可能指向同一批元素对象,修改对象内容会互相影响。需要完全独立时要手动创建新元素对象。 | Java数组:浅拷贝和深拷贝 |
| Arrays.asList 有什么坑 | 它返回固定长度 List,不支持 add/remove;背后仍然持有原数组,set 会影响原数组;基本类型数组传进去会被当成一个元素。需要独立可变集合时要用 new ArrayList<>(Arrays.asList(array))。 | Java数组:Arrays.asList |
| binarySearch 为什么要求先排序 | 二分查找每次比较后要能排除一半数据,这个前提依赖数组已经按同样规则有序。如果数组没排序,比较结果不能说明目标在左边还是右边,返回结果就不可靠。 | Java数组:binarySearch |
| 为什么推荐 Java 8 的 java.time | 旧的 Date 可变且语义不清,Calendar API 繁琐且月份从 0 开始,SimpleDateFormat 线程不安全。Java 8 的 java.time 类型更明确,大多不可变且线程安全,能更好表达日期、时间、时间点、时区和时间差。 | Java日期时间API:为什么推荐java.time |
| LocalDateTime 和 Instant 区别 | LocalDateTime 是不带时区的本地日期时间,不能单独代表全球唯一时间点;Instant 是 UTC 时间线上的一个瞬间,适合存储、比较和日志事件时间。跨时区系统应明确时区或使用 Instant。 | Java日期时间API:LocalDateTime |
| SimpleDateFormat 为什么线程不安全 | SimpleDateFormat 内部有可变状态,多线程共享同一个实例时,格式化和解析过程可能互相污染,导致错乱结果。生产新代码应使用线程安全的 DateTimeFormatter。 | Java日期时间API:SimpleDateFormat |
| Duration 和 Period 区别 | Duration 表示精确时间间隔,按秒和纳秒计算,适合 30 分钟、24 小时这种超时;Period 表示日历日期间隔,按年、月、日计算,适合一个月、一年这种自然日历周期。 | Java日期时间API:Duration和Period |
| 为什么时间会差 8 小时 | 通常是 UTC 和 Asia/Shanghai 没转换清楚。UTC 比北京时间少 8 小时,如果数据库、后端、前端有的按 UTC、有的按本地时区展示,就会出现 8 小时偏差。 | Java日期时间API:时区、线上排查 |
| 时间戳秒和毫秒混用会怎样 | 10 位左右通常是秒,13 位左右通常是毫秒。如果把秒当毫秒解析,时间会跑到 1970 附近;把毫秒当秒解析,时间会跑到很远的未来。接口文档必须明确 timestamp 单位。 | Java日期时间API:时间戳 |
| 泛型是什么 | 泛型是 Java 的类型参数机制,可以让类、接口、方法在定义时不写死具体类型,而由使用方传入类型。它的核心价值是编译期类型检查,减少强制类型转换,把类型错误尽早暴露。 | 泛型全过程原理、Java泛型 |
| Java 泛型怎么实现 | Java 泛型主要通过类型擦除实现。编译器先检查泛型类型,然后把类型参数擦除为上界,没有显式上界时擦除为 Object,并在必要位置插入强制类型转换。所以运行期很多对象并不知道完整泛型类型。 | 泛型全过程原理:类型擦除 |
? extends T 和 ? super T 区别 | ? extends T 表示未知类型是 T 的子类,适合读取,不适合写入;? super T 表示未知类型是 T 的父类,适合写入 T 或其子类,读取时通常只能当成 Object。口诀是 PECS:生产者用 extends,消费者用 super。 | 泛型全过程原理:PECS |
为什么不能 new T() | 因为 Java 泛型会类型擦除,运行时不知道 T 到底代表哪个具体类,也不知道它有没有无参构造。需要创建对象时通常显式传入 Class<T>,再通过反射创建。 | 泛型全过程原理:new T |
为什么 List<String> 不能赋值给 List<Object> | 因为泛型默认不变。如果允许赋值,就可以通过 List<Object> 往真实的 List<String> 里放入 Integer、Double 等对象,破坏类型安全。需要接收多种泛型列表时,应使用 List<?> 或上下界通配符。 | 泛型全过程原理:泛型不变性 |
| 泛型为什么不能用于重载区分 | 因为类型擦除后,List<String> 和 List<Integer> 在方法签名层面都会变成 List,两个方法擦除后签名相同,会发生冲突,所以不能只靠泛型参数不同来重载。 | 泛型全过程原理:泛型和重载 |
| 枚举本质是什么 | 枚举本质是继承 Enum 的特殊类,每个枚举值是固定的静态对象。枚举构造方法不能 public,外部不能 new 新枚举值,所以它适合表达有限、稳定的业务状态。 | 枚举工作原理 |
| 枚举为什么可以用 == 比较 | 每个枚举值在 JVM 中是固定单例对象,== 比较引用即可判断是不是同一个枚举常量。相比 equals,== 还能避免变量为 null 时空指针。 | 枚举比较 |
| 为什么不要用 ordinal 落库 | ordinal 依赖枚举声明顺序,一旦中间新增或调整顺序,历史数据对应的业务含义会错乱。落库、接口传输、MQ 消息应使用稳定的自定义 code。 | 枚举code和ordinal |
| EnumMap 和 EnumSet 适合什么场景 | 当 key 是枚举时,EnumMap 通常比 HashMap 更轻量;当集合元素是枚举时,EnumSet 可以用更紧凑的方式表示一组枚举值。它们适合枚举 key、权限集合、状态集合等场景。 | EnumMap和EnumSet |
| 注解是什么 | 注解是 Java 的元数据机制,可以给类、字段、方法、参数等代码元素添加额外信息。注解本身不直接执行业务逻辑,真正产生行为的是编译器、注解处理器、反射、框架扫描、AOP 或拦截器。 | 注解全过程原理、枚举与注解 |
@Retention 有哪些取值 | SOURCE 只保留在源码阶段,编译后丢弃,适合 @Override、Lombok 这类编译期工具;CLASS 会进入 class 文件,但运行时通常不能反射读取;RUNTIME 会进入 class 文件并且运行时可反射读取,Spring、JUnit、MyBatis 等运行时框架常用。 | 注解全过程原理:Retention |
| 为什么写了注解不生效 | 因为注解只是元数据。要生效必须有人读取它并执行逻辑,比如 Spring 扫描 @Service 注册 Bean,参数校验器读取 @NotBlank 做校验,AOP 读取权限注解做拦截。如果没有处理器,注解只是标记。 | 注解全过程原理:为什么不会自动生效 |
@Target 是什么 | @Target 用来限制注解可以标在哪里,比如类、字段、方法、参数、构造方法、类型使用位置等。写错后编译器会阻止你把注解放在不允许的位置。 | 注解全过程原理:Target |
@Inherited 为什么有时没用 | Java 原生 @Inherited 只对类上的注解生效,只支持类继承,不支持接口、方法、字段。方法注解、字段注解不会因为 @Inherited 自动继承。Spring 的合并注解查找是框架额外能力,不等同于 Java 原生继承规则。 | 注解全过程原理:Inherited |
| 运行期注解和编译期注解处理器区别 | 运行期注解通常使用 RUNTIME,应用启动或运行时通过反射读取,典型是 Spring、JUnit;编译期注解处理器在 javac 阶段工作,可以生成代码或做检查,典型是 Lombok、MapStruct。运行期更灵活,编译期运行成本更低。 | 注解全过程原理:编译期注解处理器 |
| 注解处理器和反射读取有什么区别 | 编译期注解处理器在 javac 阶段工作,可以生成代码或做编译检查;运行期反射在应用启动或运行时读取注解,常用于 Spring Bean 扫描、参数校验、AOP 拦截。前者运行期成本低,后者更灵活。 | 注解处理器和运行期反射 |
| 如何设计一个审计注解 | 注解只声明审计动作、资源类型等元数据,本身不记录日志。真正记录审计日志的是 AOP、拦截器或过滤器:读取方法上的注解,获取当前用户、参数和结果,再写入审计表或日志系统。 | 接口审计注解Demo |
| SPI 是什么 | SPI 是 Service Provider Interface,是 Java 的服务发现扩展机制。框架定义接口,第三方提供实现,并在 META-INF/services/接口全限定名 文件中声明实现类。运行时通过 ServiceLoader 扫描配置文件、加载实现类并创建对象,从而实现面向接口的可插拔扩展。 | SPI全过程原理 |
| API 和 SPI 有什么区别 | API 是别人提供能力给我调用,调用方是业务代码;SPI 是框架定义扩展接口,让别人按规则提供实现,调用方通常是框架。API 偏使用能力,SPI 偏扩展能力。 | SPI全过程原理:API和SPI区别 |
| Java SPI 的加载流程 | ServiceLoader.load 会根据接口全限定名拼出 META-INF/services/接口全限定名 路径,通过类加载器从 classpath 查找配置文件,读取实现类全限定名,遍历时再加载 Class,并通过反射调用无参构造创建实现对象,最后以接口类型返回。 | SPI全过程原理:ServiceLoader加载流程 |
| Java 原生 SPI 有哪些缺点 | 原生 SPI 只能按接口加载所有实现,不能按名称精确加载;缺少优先级、条件筛选、依赖注入、生命周期管理;配置文件只能写实现类名;实现类通常需要无参构造。大型框架通常会增强 SPI,比如 Dubbo SPI。 | SPI全过程原理:原生SPI缺点 |
| JDBC 驱动为什么可以自动发现 | JDBC 4.0 之后,数据库驱动 jar 可以在 META-INF/services/java.sql.Driver 中声明驱动实现类。DriverManager 通过 ServiceLoader 发现这些 Driver 实现,所以很多时候只要引入驱动依赖,不需要手写 Class.forName。 | SPI全过程原理:JDBC驱动 |
| 序列化是什么 | 序列化是把内存中的对象状态转换成可存储、可传输的数据,比如字节数组、JSON 或二进制协议;反序列化是把这些数据按协议重新组装成对象。它常用于文件存储、Redis 缓存、MQ 消息、RPC 调用和 Session 复制。 | 序列化全过程原理、Java序列化 |
| Java 原生序列化怎么用 | 类需要实现 Serializable,通常显式声明 serialVersionUID,然后用 ObjectOutputStream.writeObject 写出对象,用 ObjectInputStream.readObject 读回对象。对象图里被引用的对象也要能序列化,否则会抛 NotSerializableException。 | 序列化全过程原理:最小Demo |
serialVersionUID 有什么用 | 它是序列化版本号。反序列化时 JVM 会比较字节流里的 serialVersionUID 和当前类里的 serialVersionUID。一致才继续恢复字段,不一致会抛 InvalidClassException。显式声明它可以避免类结构轻微变化导致自动计算值变化。 | 序列化全过程原理:serialVersionUID |
transient 有什么用 | transient 修饰的字段不会参与默认序列化,反序列化后是默认值。它常用于密码、token、临时计算结果、本地资源句柄、大对象缓存等不应该或不能被持久化传输的字段。 | 序列化全过程原理:transient |
static 字段会被序列化吗 | 不会。static 字段属于类,不属于对象实例。默认序列化保存的是对象实例状态,所以不会把 static 字段作为对象字段写入字节流。 | 序列化全过程原理:static字段 |
Serializable 为什么没有方法 | Serializable 是标记接口,表示这个类允许被 Java 原生序列化。真正负责写入、读取、对象图遍历和字段恢复的是 ObjectOutputStream、ObjectInputStream 以及 JVM 序列化机制。 | Java序列化:Serializable标记接口 |
| 序列化会不会把引用对象也写进去 | 会。Java 原生序列化会沿对象图把被引用对象一起写入,除非字段被 transient 修饰或自定义序列化逻辑排除。因此对象图里的类也要能序列化,否则会抛 NotSerializableException。对象图越复杂,消息体和缓存体越大,越容易带出不该传输的内部字段。 | Java序列化:对象图和引用关系 |
| 反序列化会调用构造方法吗 | 对 Serializable 对象来说,反序列化通常不会像普通 new 一样执行业务类构造方法,而是通过特殊机制创建对象并恢复字段值。所以构造方法里的默认值、参数校验和业务保护不能替代反序列化后的校验。 | Java序列化:反序列化构造方法 |
writeObject、readObject、readResolve 有什么用 | writeObject/readObject 用来定制某个类的序列化和反序列化细节,例如加密字段、兼容旧字段、恢复 transient 字段;readResolve 可以在反序列化完成后替换返回对象,常用于保护单例。它们很强,但也会增加兼容性和安全复杂度。 | Java序列化:自定义序列化、readResolve |
| Redis、MQ、RPC 序列化怎么选 | Redis 缓存通常优先 JSON 或框架可控的二进制协议,便于排查和版本兼容;MQ 消息要使用稳定 DTO、带 version、避免直接序列化实体;RPC 要看跨语言、性能和治理能力,常见有 JSON、Protobuf、Hessian、Kryo。对外和跨语言优先开放协议,不建议直接使用 Java 原生序列化。 | Java序列化:协议选择 |
| 为什么 MQ 消息要带 version | MQ 是异步解耦,生产者和消费者经常不能同时发布。消息体带 version 后,消费者可以按版本兼容解析,灰度期间新旧消息能共存;不带 version 时,字段改名、类型变化、语义变化都可能导致消费者解析失败、重复重试甚至消息堆积。 | Java序列化:MQ消息设计 |
| 为什么不要直接序列化数据库实体 | 数据库实体常包含内部字段、懒加载代理、关联对象、审计字段和表结构细节。直接传给缓存、MQ 或外部接口会造成对象图过大、字段泄露、版本强耦合、反序列化失败和循环引用等问题。商业系统应使用专门 DTO。 | Java序列化:商业消息DTO Demo |
| 反序列化为什么有安全风险 | 反序列化会根据输入数据创建对象并可能触发 readObject、readResolve 等逻辑。如果数据来自不可信来源,并且 classpath 中存在可利用调用链,就可能导致远程代码执行等漏洞。生产环境不应对外部不可信数据使用 Java 原生反序列化。 | 序列化全过程原理:安全风险 |
| Error 和 Exception 区别 | Error 表示 JVM 或系统级严重问题,例如 OOM、StackOverflow,业务代码通常不应该局部捕获后继续执行;Exception 表示程序可处理的问题,分为受检异常和运行时异常。业务开发重点处理 Exception,但线上排查不能忽略 Error。 | Java异常处理:异常体系 |
| Checked Exception 和 RuntimeException 区别 | Checked Exception 编译器强制处理,适合调用方可能恢复或必须感知的外部失败,比如 IO;RuntimeException 编译器不强制处理,适合参数错误、状态错误、业务规则失败。Spring 默认事务回滚也和这个区别有关。 | Java异常处理:异常选择 |
| throw 和 throws 区别 | throw 在方法体里抛出一个具体异常对象;throws 在方法签名上声明这个方法可能抛出异常。前者是真正动作,后者是对调用方的契约说明。 | Java异常处理:throw和throws |
| finally 一定执行吗 | 通常会执行,但不是绝对。进程被 kill、机器断电、Runtime.halt()、无限循环等情况下可能没有机会执行。关键业务可靠性不能只靠 finally,要靠事务、状态表、消息表、补偿和对账。 | Java异常处理:finally细节 |
| 为什么不要在 finally 里 return | finally 的 return 会覆盖 try/catch 的返回值,甚至吞掉真实异常,导致线上明明失败却表现为成功。finally 只适合释放资源、清理上下文,不应该做业务返回。 | Java异常处理:finally return |
| try-with-resources 为什么更安全 | 它会自动按资源声明的反向顺序关闭资源,并把关闭异常作为 suppressed exception 挂到主异常上,避免关闭异常覆盖真正的业务异常。文件、网络流、数据库连接、游标等资源优先使用它。 | Java异常处理:try-with-resources |
| 异常转换为什么要保留 cause | 上层需要统一业务语义,但排查时仍要看到底层根因。转换异常时传入 cause,可以同时得到“业务上是什么失败”和“技术上为什么失败”。不保留 cause 会丢失连接超时、SQL 错误、解析失败等关键线索。 | Java异常处理:自定义异常 |
| Spring 事务遇到异常一定回滚吗 | 不一定。默认主要对 RuntimeException 和 Error 回滚;受检异常需要配置 rollbackFor。如果方法内部 catch 后吞掉异常,Spring 认为方法正常结束,也可能提交事务。 | Java异常处理:事务回滚、Spring事务 |
| 反射是什么 | 反射是 Java 在运行时获取类结构并动态操作对象的能力。它可以读取字段、方法、构造方法、注解,也可以动态创建对象、调用方法、读写字段。框架在编译期不知道用户会写哪些业务类,所以 Spring、MyBatis、Jackson、JUnit、RPC 框架都依赖反射做扫描、映射、注入和分发。 | 反射全过程原理、Java反射 |
Class 对象是什么 | Class 对象是 JVM 加载某个类后暴露给 Java 代码的类元信息入口,它描述类名、父类、接口、字段、方法、构造方法和注解。它不是业务对象;同一个类在同一个类加载器下通常只有一个 Class 对象,不同类加载器加载同名类会被 JVM 认为是不同类型。 | 反射全过程原理:Class对象 |
Class.forName 和 ClassLoader.loadClass 区别 | Class.forName 默认会加载并初始化类,可能触发静态代码块;ClassLoader.loadClass 通常只加载类,不主动初始化。框架扫描阶段一般希望读取类信息但不提前执行静态初始化,所以会谨慎控制是否初始化。 | 反射全过程原理:forName和loadClass |
getFields 和 getDeclaredFields 区别 | getFields 获取 public 字段,并包含父类 public 字段;getDeclaredFields 获取当前类声明的所有字段,包括 private,但不包含父类字段。框架处理 private 字段时常用 getDeclaredFields,如果还要父类字段,需要沿继承链遍历。 | 反射全过程原理:字段查找规则 |
setAccessible(true) 是什么 | 它表示关闭 Java 语言层面的访问检查,让反射可以访问 private、protected 等成员。但 Java 9 模块系统之后,强封装会让部分跨模块私有访问失败,可能抛 InaccessibleObjectException。 | 反射全过程原理:setAccessible |
Method.invoke 为什么包 InvocationTargetException | Method.invoke 自己可能因为权限、参数类型不匹配等反射问题失败;被调用业务方法内部也可能抛异常。为了区分这两类异常,业务方法抛出的异常会被包进 InvocationTargetException,真实异常要通过 getTargetException() 查看。 | 反射全过程原理:Method.invoke |
| 反射为什么慢,怎么优化 | 反射多了元信息查找、访问检查、参数检查、装箱拆箱和间接调用,JIT 也更难像普通调用那样优化。优化方式是缓存 Class、Field、Method、Constructor,避免热点循环重复解析;框架还可能用字节码生成、MethodHandle、代码生成降低运行期开销。 | 反射全过程原理:性能优化 |
| Spring 为什么能根据注解创建对象 | Spring 启动时扫描 classpath,读取类上的 @Component、@Service 等注解,注册 BeanDefinition,再通过构造方法或工厂方法创建对象,并读取字段、构造器、方法上的注入注解完成依赖注入。反射提供了读取类结构、读取注解、创建对象和写入字段的基础能力。 | 反射全过程原理:IOC Demo |
项目话术:
text
在采集平台里,数据去重、Map Key、HashSet 去重都依赖 equals 和 hashCode。如果患者 ID、就诊号、报告号组成的业务 key 写错,就可能造成重复入库或查不到已采集记录。集合容器
| 面试题 | 标准回答 | 原理知识点 |
|---|---|---|
| Java 集合怎么选型 | 先看业务语义:是否 key-value、是否去重、是否排序、是否并发、是否队列。按下标访问和普通批量处理优先 ArrayList;去重用 HashSet;键值映射用 HashMap;排序用 TreeSet/TreeMap;并发写用 ConcurrentHashMap;生产者消费者用 BlockingQueue。 | 集合框架:不同集合怎么选 |
| List、Set、Map 区别 | List 有序且可重复,适合按顺序保存一批数据;Set 不允许重复,适合去重;Map 保存 key-value,适合按 key 快速查找 value。选择时不要只背底层结构,要先看是否需要顺序、重复、key 查询和并发。 | 集合体系 |
| Iterator 遍历时为什么不能直接 list.remove | 增强 for 底层使用 Iterator。Iterator 创建时会记录集合结构修改次数,遍历时如果发现集合被非 Iterator 自己修改,就会尽力抛出 ConcurrentModificationException。遍历删除应使用 iterator.remove() 或 Java 8 的 removeIf。 | Iterator和fail-fast |
| fail-fast 是线程安全机制吗 | 不是。fail-fast 只是尽力检测遍历期间的结构修改,目的是尽早暴露错误,不保证一定发现所有并发问题,也不会让集合线程安全。多线程修改集合应使用并发容器、加锁或线程内局部集合。 | fail-fast原理、ArrayList全过程原理 |
| Comparable 和 Comparator 区别 | Comparable 表示对象自身具备自然顺序,比较逻辑写在类内部;Comparator 是外部比较器,同一个对象可以按不同规则排序。业务中多种排序规则更适合 Comparator,例如按创建时间、金额、优先级排序。 | 集合排序 |
| Collections.unmodifiableList 是不可变集合吗 | 它是只读视图,不是深不可变集合。不能通过这个 view 增删改,但原始集合变化后 view 也会跟着变化,元素对象如果可变也仍然能被修改。需要稳定快照时要先拷贝,再包装只读视图。 | Collections工具类 |
| ConcurrentHashMap 为什么不允许 null | 并发环境下 get(key) == null 必须有明确含义。如果允许 null value,就无法区分 key 不存在还是 value 本身就是 null;并发下再用 containsKey 二次判断也可能被其他线程修改打断。 | 集合null规则、ConcurrentHashMap全过程原理 |
| Queue 的 offer/poll 和 add/remove 区别 | offer 插入失败返回 false,poll 队列空返回 null;add 插入失败抛异常,remove 队列空抛异常。业务代码更常用 offer/poll/peek,因为它们通过返回值表达失败,更适合队列为空或满的正常场景。 | Queue和Deque |
| ArrayList 底层是什么 | ArrayList 底层是 Object 数组,elementData 表示数组容量,size 表示实际元素个数。按下标查询时可以直接访问数组位置,所以随机访问通常是 O(1)。尾部追加通常快,但容量不够会扩容复制,中间插入和删除需要移动元素。 | ArrayList全过程原理、集合框架:ArrayList |
| ArrayList 为什么扩容影响性能 | add 时如果容量不够,会创建更大的新数组,JDK 8 常见是约 1.5 倍扩容,然后把旧数组元素复制过去,再写入新元素。扩容涉及新数组分配和元素复制,所以大批量添加应预估容量,减少扩容次数。 | ArrayList全过程原理 |
| ArrayList 删除为什么慢 | 删除中间元素后,后面的元素必须整体左移,最后一个位置还要置 null 方便 GC 回收不再使用的对象。因此 ArrayList 适合随机访问和尾部追加,不适合大量头部或中间插入删除。 | ArrayList全过程原理 |
| HashMap 底层结构 | JDK 8 的 HashMap 底层是数组 + 链表 + 红黑树。数组用于按 hash 快速定位桶,链表用于解决哈希冲突,红黑树用于极端冲突下避免链表过长导致查询退化。Node 中保存 hash、key、value 和 next。 | HashMap全过程原理、集合框架:HashMap |
| HashMap 的 put 流程 | put 时先计算 key 的 hash,如果 table 还没初始化就 resize 初始化;然后用 (n - 1) & hash 定位桶。桶为空就直接放新节点;桶不为空先比较首节点,key 相同就覆盖;否则遍历链表或红黑树,找到相同 key 就覆盖,找不到就追加或插入树中。插入新节点后 size 增加,如果超过 threshold 就扩容。 | HashMap全过程原理 |
| HashMap 为什么容量是 2 的幂 | 容量是 2 的幂时,可以用 (n - 1) & hash 快速定位桶下标,效果类似取模但更高效。同时扩容翻倍后,节点只需要根据 hash & oldCap 判断留在原位置还是移动到 原位置 + oldCap,迁移更快。 | HashMap全过程原理 |
| HashMap 为什么线程不安全 | HashMap 的 put、resize、链表和红黑树结构修改都没有并发保护。多线程写可能出现覆盖、size 不准、结构异常,JDK 7 并发 resize 还可能形成环链表。多线程写应使用 ConcurrentHashMap 或加锁。 | HashMap全过程原理、ConcurrentHashMap全过程原理 |
| ConcurrentHashMap 为什么线程安全 | JDK 8 的 ConcurrentHashMap 使用 volatile 保证 table、Node value、next 等字段可见;空桶插入、初始化、计数和扩容任务领取使用 CAS;同一个桶发生冲突写入时只 synchronized 锁桶头节点;链表过长会转红黑树;扩容时用 ForwardingNode 和 helpTransfer 支持多线程协助迁移。所以它不是锁整个 Map,而是尽量降低锁粒度。 | ConcurrentHashMap全过程原理、并发集合与阻塞队列 |
| JDK7 和 JDK8 ConcurrentHashMap 区别 | JDK 7 使用 Segment 分段锁,每个 Segment 类似一个小 HashMap,写操作锁 Segment。JDK 8 去掉 Segment,使用 Node 数组 + 链表/红黑树,空桶 CAS 插入,冲突时 synchronized 锁桶头,读操作大多无锁,扩容支持多线程协助迁移。JDK 8 锁粒度更细,结构也更贴近 HashMap。 | ConcurrentHashMap全过程原理 |
| ConcurrentHashMap 的 put 流程 | put 时先检查 key/value 不能为 null,然后计算 hash。如果 table 未初始化,就 CAS 初始化;定位桶后,如果桶为空就 CAS 放入新节点;如果桶是 ForwardingNode,说明正在扩容,当前线程会协助迁移;如果桶非空,就 synchronized 锁桶头,在链表或红黑树中插入或更新,最后更新计数并判断是否需要扩容。 | ConcurrentHashMap全过程原理 |
| JDK 8 ConcurrentHashMap 锁的到底是什么 | 普通冲突桶锁的是当前桶头节点对象 f,源码是 synchronized (f);树桶的 f 是 TreeBin。拿锁后还必须校验 tabAt(tab, i) == f,避免等待锁期间桶已迁移或被替换。不同桶的桶头对象不同,可以并发写;不同 key 如果落在同一桶,仍会竞争同一把锁。空桶第一次插入使用 CAS,不需要 synchronized。 | ConcurrentHashMap全过程:锁的到底是谁、putVal源码分析 |
| ConcurrentHashMap 为什么不允许 null | 并发环境下 get(key) == null 必须有明确含义。如果允许 null value,就无法区分 key 不存在还是 value 本身就是 null。单线程 HashMap 可以用 containsKey 补充判断,但并发下 get 和 containsKey 之间可能被其他线程修改,判断不稳定。 | ConcurrentHashMap全过程原理 |
| BlockingQueue 是什么 | BlockingQueue 是支持阻塞插入和阻塞获取的线程安全队列,常用于生产者消费者模型。队列满时,put 会等待空间;队列空时,take 会等待元素。它可以解耦生产和消费、削峰、背压,也是线程池任务队列的重要基础。 | BlockingQueue全过程原理、并发集合与阻塞队列 |
| ArrayBlockingQueue 和 LinkedBlockingQueue 区别 | ArrayBlockingQueue 基于数组,必须指定固定容量,入队出队共用一把锁,适合明确有界背压。LinkedBlockingQueue 基于链表,可指定容量,不指定时容量非常大,通常有 putLock 和 takeLock 两把锁,吞吐性能更好,但无界使用有 OOM 风险。 | BlockingQueue全过程原理 |
项目话术:
text
采集平台批量处理数据时,ArrayList 常用于暂存一批记录,HashMap 常用于字段映射、标准编码映射和去重索引;如果多线程共享统计结果或任务状态,就不能用普通 HashMap 并发写,要用 ConcurrentHashMap 或者明确加锁。IO 与 NIO
| 面试题 | 标准回答 | 原理知识点 |
|---|---|---|
| 字节流和字符流区别 | 字节流处理原始二进制数据,适合图片、压缩包、网络报文;字符流处理文本,会涉及字符集编码和解码。文本读写必须显式指定编码,避免依赖平台默认编码。 | 传统IO、缓冲与字符编码 |
Java 一次 read() 的完整过程 | Java 调用 read() 后会进入 JVM 本地方法和操作系统内核,数据通常先从磁盘或网卡进入内核缓冲区,再复制到用户态缓冲区,最后进入 byte[] 或 Buffer。如果数据没准备好,传统阻塞 IO 的线程会挂起等待。 | IO与NIO全过程原理 |
| 为什么要用缓冲流 | 缓冲流把多次小 IO 合并成较少的大 IO,减少系统调用和底层磁盘/网络访问次数。输出缓冲还需要 flush 或 close 才能确保数据写到底层。 | 缓冲与字符编码 |
| BIO、NIO、AIO 区别 | BIO 是同步阻塞,通常一个连接一个线程;NIO 常见用法是同步非阻塞,通过 Selector 一个线程监听多个 Channel;AIO 是异步 IO,操作完成后通知回调。 | IO与NIO总览、Selector多路复用 |
| NIO 三大组件是什么 | Buffer 是缓冲区,Channel 是连接文件或网络的通道,Selector 是多路复用器,用来监听多个 Channel 的 accept、read、write 等事件。 | NIO Buffer与Channel、Selector多路复用 |
Buffer 的 flip、clear、compact 区别 | flip 从写模式切到读模式;clear 重置指针准备重新写,适合数据已处理完;compact 保留未读数据并切回写模式,适合半包数据没处理完。 | NIO Buffer与Channel |
| Selector 为什么能处理多个连接 | Channel 配成非阻塞后注册到 Selector,Selector 监听事件。线程阻塞在 select 上,有事件才处理,不需要每个连接一个线程。 | Selector多路复用 |
| NIO 是不是异步 | Java NIO 常见网络用法是同步非阻塞,不是异步。读写不会一直阻塞,但数据拷贝仍由调用线程执行;AIO 才是异步完成后回调。 | Selector多路复用 |
| TCP 为什么有半包和粘包 | TCP 是字节流协议,只保证字节有序到达,不保留业务消息边界。一次 write 不一定对应一次 read,一条消息可能被拆开,多条消息也可能被一次读到。解决方式是设计协议边界,例如固定长度、分隔符、长度字段。生产里通常用 Netty Decoder 处理。 | Selector多路复用:半包粘包 |
NIO 里 compact 适合什么场景 | compact 适合 Buffer 中还有未处理数据时使用,比如半包只读到一部分。它会把未读数据移动到前面,并切回写模式,等待下次继续读。如果直接 clear,半包数据会丢;如果不切回写模式,后续数据写不进来。 | Selector多路复用:长度字段拆包、NIO Buffer与Channel |
| 为什么 IO 线程不能做慢业务 | Selector/EventLoop 线程负责处理多个连接的 IO 事件。如果在 IO 线程里查数据库、调 HTTP、写大文件,一个慢操作会阻塞其他连接的读写事件,导致整体延迟升高。正确做法是 IO 线程只做读写和轻量解码,慢业务投递到有界业务线程池,并做好背压。 | Selector多路复用:IO线程和业务线程、Netty EventLoop |
| 什么是零拷贝 | 零拷贝是减少用户态和内核态之间数据复制的技术,Java 常见方式有 FileChannel.transferTo/transferFrom、mmap、DirectBuffer。它不是完全没有复制,而是减少不必要复制。 | 零拷贝与大文件 |
| DirectBuffer 为什么会 OOM | DirectBuffer 使用堆外直接内存,不受 -Xmx 直接限制,但受进程内存和 MaxDirectMemorySize 影响。创建过多、释放不及时、Netty ByteBuf 泄漏都可能导致 Direct buffer memory。 | 零拷贝与大文件、JVM内存结构 |
| 为什么生产高并发不用手写 NIO | 手写 NIO 不只是 Selector,还要处理半包粘包、心跳、写队列、背压、线程模型、Buffer 复用和异常连接清理。生产一般使用 Netty,让成熟框架处理这些复杂细节。 | IO与NIO全过程原理、Netty基础 |
| 一个 TCP 连接怎样唯一标识 | TCP 连接由源 IP、源端口、目的 IP、目的端口四元组标识。同一服务端端口可以接收大量连接,因为客户端 IP 或临时端口不同。监听 Socket 负责接收新连接,accept 返回的已连接 Socket 才承载具体会话。 | TCP四元组与Socket |
| TCP 为什么必须三次握手 | 握手要交换初始序号和 TCP 选项,并验证双方收发路径。两次后服务端无法确认客户端是否收到服务端 SYN,历史失效 SYN 也可能让服务端错误分配资源;第三次 ACK 证明客户端收到服务端序号。 | TCP三次握手全过程 |
| TCP 为什么可靠 | TCP 使用字节序号、累计 ACK、超时与快速重传、校验、乱序重排、接收窗口流量控制和拥塞控制,提供有序无重复字节流。但它不保证业务只执行一次,超时重试仍需业务幂等。 | TCP可靠传输原理 |
| 三次握手、四次挥手中的三和四是绝对的吗 | 握手逻辑上需要三步确认双方收发与初始序号;关闭是两个方向分别发送 FIN 和确认。关闭时 ACK 与 FIN 可合并,所以抓包不一定恰好四个报文;丢包还会触发重传,不能只机械数包。 | 三次握手、四次挥手 |
| CLOSE_WAIT 和 TIME_WAIT 有什么区别 | CLOSE_WAIT 位于被动关闭方,表示收到对端 FIN 但本地应用还未关闭,长期大量存在通常应查漏关 Socket、响应体或线程卡死;TIME_WAIT 通常位于主动关闭方,用于重发最后 ACK 并隔离旧报文,大量短连接应优先用连接池治理。 | TCP四次挥手与连接状态 |
| TCP 粘包和半包怎么解决 | TCP 是字节流,不保留业务消息边界,一次 write 不对应一次 read。应用协议必须使用固定长度、分隔符或长度字段定义帧,接收端累计到完整帧再解码,并限制最大帧长度,防止恶意长度导致 OOM。 | TCP粘包、半包与拆帧 |
| 连接超时、读超时和业务总超时区别 | 连接超时限制 DNS 之后 TCP 建连等待,读超时通常限制一次阻塞读等待,业务总超时限制整个调用链预算。只设置连接超时,建连后的读取仍可能一直占用线程;只设置 Future 超时,底层 I/O 也可能继续运行。 | Java网络超时与心跳 |
| TCP Keepalive 和业务心跳区别 | TCP Keepalive 由内核探测长时间空闲连接的网络存活,默认周期可能很长,不能证明业务线程健康;业务心跳由应用协议设计,可验证鉴权和业务处理,并应短于负载均衡或 NAT 的空闲超时。 | Keepalive与业务心跳 |
| 线上网络超时从哪里下手 | 先固定域名、解析 IP、端口、时间段和 traceId,再逐层判断 DNS、TCP 建连、请求发送、服务端处理和响应读取。用 ss -lntp/ant 查监听与状态,lsof 查 FD,jstack 查 Java 阻塞点,必要时用受限 tcpdump 看 SYN、RST、重传和零窗口。 | 网络故障排查Runbook |
项目话术:
text
采集平台里文件导入导出、接口调用、上传下载都和 IO 有关。小文件和普通文本处理我会用缓冲流、Files API 和 UTF-8;大文件会分块处理,避免 readAllBytes 造成 OOM;高并发长连接不会手写复杂 NIO,而会用 Netty。排查时重点看线程栈、文件句柄、编码、直接内存和下游 IO 耗时。并发与多线程
| 面试题 | 标准回答 | 原理知识点 |
|---|---|---|
| Thread 常用方法有哪些 | 常见有 start、run、sleep、yield、join、interrupt、isInterrupted、currentThread。重点区分 start 才会启动新线程,run 只是普通方法,interrupt 不是强制杀死线程。 | 多线程总览 |
| JMM是什么 | JMM是Java规范定义的多线程内存语义,规定共享变量读写的可见性、原子性、有序性和合法执行。主内存/工作内存是规范抽象,不等于物理DRAM/线程栈;volatile、锁、start/join和JUC通过happens-before让跨CPU/JVM的程序可推理。 | JMM完整原理 |
| happens-before是什么 | 若A happens-before B,A的内存效果必须对B可见且语义顺序在B之前。常见有程序顺序、同锁释放到后续获取、volatile写到后续读、Thread.start、join和传递性;它不是简单的墙上时钟先后。 | happens-before规则 |
| volatile为什么能让前面的普通写可见 | 发布线程的普通写程序顺序先于volatile写;volatile写happens-before获取线程读到该值;该读又先于后续普通读。通过传递性,前序普通写对获取线程可见,而不是简单地说“刷新整个主内存”。 | volatile消息发布 |
| 什么是安全发布 | 另一个线程不仅获得对象引用,还必须看到构造完成状态。可使用静态初始化、volatile引用、同一把锁、线程安全容器、start规则或不可变对象;构造期间不能让this逸出,多字段配置适合先构造不可变快照再一次发布。 | 安全发布对象 |
| 双重检查锁为什么要volatile | 没有volatile时,对象分配、构造初始化和引用发布可能被其他线程观察为引用先可见,得到未正确初始化状态。Java 5修订后的volatile语义使正确DCL成立,所以JDK7/8写DCL也必须保留volatile。 | 双重检查锁 |
| volatile 有什么作用 | volatile 主要保证可见性和有序性。对 volatile 变量的写 happens-before 后续对这个变量的读,所以一个线程写 volatile 前的普通写,对另一个读到该 volatile 值的线程可见。同时 volatile 会通过内存屏障限制关键重排序。但它不保证复合操作原子性,所以 volatile int count 的 count++ 仍然不安全。 | volatile与Atomic全过程原理、volatile与Atomic基础 |
| volatile 为什么不能保证 count++ | count++ 不是单次写,而是读取、加一、写回三步。多个线程可能同时读到相同旧值,再各自写回相同新值,导致丢失更新。volatile 只能保证每次读写的可见性,不能把三步合成不可打断的整体。 | volatile与Atomic全过程原理 |
| synchronized 原理 | synchronized 基于对象监视器 Monitor 实现互斥。同步代码块会编译成 monitorenter 和 monitorexit,同步方法通过 ACC_SYNCHRONIZED 隐式加锁。线程进入同步区域前要获取锁对象的 Monitor owner,拿不到就阻塞;同一线程重复进入会增加重入计数,所以它是可重入锁。释放锁和后续获取同一把锁之间还会建立 happens-before 关系,保证可见性。 | synchronized全过程原理、synchronized基础 |
| synchronized 锁升级过程 | 以 JDK 8 常见模型来说,锁会按竞争程度从无锁、偏向锁、轻量级锁逐步升级到重量级锁。偏向锁适合长期只有一个线程进入;轻量级锁适合轻微竞争,通过 CAS 和自旋减少阻塞;重量级锁适合竞争激烈时让线程进入 Monitor 阻塞等待。新版本里偏向锁逐步弱化或废弃,所以面试要说明版本差异。 | synchronized全过程原理 |
| CAS是什么 | CAS把“当前值仍等于期望旧值”和“写入新值”合成一次原子条件更新。JDK7/8 Atomic类通常通过volatile字段、Unsafe/JVM原子原语映射到目标CPU;失败后基于最新状态重算。它适合短小低冲突更新,但有ABA、自旋、缓存行竞争和饥饿边界。 | CAS完整原理 |
| AtomicInteger 为什么线程安全 | AtomicInteger 内部使用 volatile 保存值,用 CAS 保证更新原子性。自增时先读取旧值,计算新值,再用 CAS 判断内存当前值是否仍然等于旧值;如果是就写入新值,如果不是说明被其他线程改过,就重新读取并重试。 | volatile与Atomic全过程原理、CAS原理 |
| LongAdder 和 AtomicLong 怎么选 | AtomicLong 适合需要精确当前值和条件 CAS 更新的场景;LongAdder 适合高并发统计指标,它通过多个 Cell 分散竞争,吞吐通常更高,但 sum() 不是严格一致快照,所以不适合做强一致条件判断。 | volatile与Atomic全过程原理 |
| CAS一定比锁快吗 | 不一定。低竞争、短操作时CAS避免挂起/唤醒;高竞争时大量失败重试和缓存行迁移消耗CPU,还可能产生饥饿和高P99。复杂多字段、不允许重复副作用或高冲突临界区使用锁或串行队列通常更清晰稳定。 | CAS竞争与选型 |
| AtomicReference的CAS调用equals吗 | 不调用,比较的是当前引用是否与期望引用为同一对象。即使两个对象业务equals为true,只要不是同一引用,CAS也不会匹配。这用于确认“我基于读取到的哪版快照计算”,业务相等不能代替并发版本身份。 | CAS比较语义 |
| ABA是什么 | 线程读取A后暂停,其他线程把A改成B再改回A,原线程CAS只比较当前值仍可能成功;若算法依赖期间未变化、节点结构或业务状态历史,提交就基于过期前提。CAS没有实现错误,是比较状态不足。 | ABA完整原理 |
| AtomicStampedReference怎样解决ABA | 它将引用和int stamp作为同一原子状态比较,每次修改递增stamp;A→B→A会从(A,1)变为(A,3),旧线程期望(A,1)因此失败。读取引用和stamp也应通过get(holder)一起取,避免两次读取形成混合快照。 | 版本戳原理 |
| AQS 是什么 | AQS 是 JUC 并发组件的同步器基础框架。它用 volatile int state 表示同步状态,用 FIFO 双向队列保存等待线程,用 CAS 修改状态,用 LockSupport.park/unpark 阻塞和唤醒线程。具体工具只需要定义资源获取和释放规则:ReentrantLock 把 state 当作锁重入次数,Semaphore 把 state 当作许可证数量,CountDownLatch 把 state 当作剩余倒计数。 | AQS与JUC全过程原理、AQS源码笔记 |
| AQS 独占模式和共享模式区别 | 独占模式同一时刻只允许一个线程获取资源,典型是 ReentrantLock;共享模式允许多个线程同时获取资源,典型是 Semaphore 和 CountDownLatch。独占释放通常唤醒一个后继节点,共享释放可能继续传播唤醒更多等待节点。 | AQS与JUC全过程原理 |
| ReentrantLock 为什么可重入 | ReentrantLock 基于 AQS,第一次加锁时 CAS 把 state 从 0 改成 1 并设置 owner;同一个线程再次加锁时不会排队,而是把 state 加 1;释放时 state 减 1,只有减到 0 才真正清空 owner 并唤醒后继线程。所以加锁几次就必须 unlock 几次。 | ReentrantLock、AQS与JUC全过程原理 |
| ReentrantReadWriteLock 是什么 | ReentrantReadWriteLock 是基于 AQS 实现的可重入读写锁。读锁之间共享,写锁独占,读写互斥。它适合读多写少的共享数据场景,比如本地配置缓存、字典缓存、规则缓存;如果写多或锁内有慢 IO,维护读写状态和排队唤醒的成本可能反而让性能变差。 | 读写锁全过程原理、ReentrantReadWriteLock基础 |
| 读写锁的 state 怎么拆分 | 读写锁复用 AQS 的 volatile int state。低 16 位表示写锁重入次数,高 16 位表示读锁总次数。写锁加锁主要修改低位,读锁加锁主要让高位加 1 << 16,这样一个原子状态就能同时判断是否有人读、是否有人写、写锁重入几次。 | 读写锁全过程原理 |
| 什么是锁降级,为什么不建议锁升级 | 锁降级是先持有写锁,再获取读锁,然后释放写锁,继续持有读锁读取稳定数据,常用于缓存刷新。锁升级是持有读锁再申请写锁,写锁要求所有读锁释放,多个线程同时这么做容易互相等待,所以不推荐。 | 读写锁全过程原理 |
| 读写锁适合什么场景 | 读写锁适合读多写少、读操作短、写操作不频繁的共享数据,比如配置、字典、规则、本地缓存。不适合写多、锁内远程调用、锁内慢 SQL、读操作会修改对象的场景。 | 读写锁全过程原理 |
| CountDownLatch 和 Semaphore 区别 | CountDownLatch 用于等待多个任务完成,state 是剩余倒计数,countDown 减到 0 后唤醒等待线程,不能复用;Semaphore 用于限制并发数,state 是剩余许可证,acquire 扣许可证,release 归还许可证。前者解决等待完成,后者解决资源限流。 | JUC工具类、AQS与JUC全过程原理 |
| CountDownLatch 的 await 为什么能看到工作线程结果 | 工作线程在 countDown 前的操作 happens-before 等待线程从对应 await 成功返回后的操作。底层 state 归零触发 AQS 共享释放并传播唤醒等待者;共享结果若被多个工作线程并发写入,容器自身仍需线程安全。 | CountDownLatch全过程、JMM |
| Semaphore 为什么可能出现许可泄漏或膨胀 | 成功 acquire 后没在 finally release 会泄漏许可;未成功 acquire 却无条件 release 或重复 release 会增加不存在的许可。Semaphore 不记录线程对许可的所有权,所以必须用 acquired 标记只归还真正获得的许可。 | Semaphore全过程 |
| Semaphore 能不能直接做 QPS 限流 | 它限制的是同时执行数,不是单位时间请求数。相同 30 个许可,任务耗时 10ms 和 2s 的吞吐完全不同。QPS 通常使用令牌桶或漏桶,多实例全局限流还需要网关或分布式方案。 | Semaphore商业边界 |
| CountDownLatch 和 CyclicBarrier 有什么区别 | Latch 是等待者等待 N 个事件,完成者 countDown,归零后永久开放;Barrier 是固定数量参与者调用 await 互相等待,最后到达者推进 Generation,正常时可分代复用。一位 Barrier 参与者超时或中断会打破整代。 | CountDownLatch与CyclicBarrier比较 |
| CyclicBarrier 为什么会抛 BrokenBarrierException | 屏障表示本代所有参与者到齐才继续。一位等待者中断、超时、reset 或 barrierAction 失败,整代共同条件已经不成立,JDK 会把 Generation 标记 broken、signalAll,其他等待者抛 BrokenBarrierException,避免部分线程错误进入下一阶段。 | CyclicBarrier全过程 |
| CyclicBarrier 为什么可能和线程池形成饥饿 | 若 Barrier 需要 3 个参与者,但线程池只有 2 个线程,前两个任务会占着工作线程等待,第三个参与任务留在队列,永远不能到达屏障。要保证参与任务可同时得到执行资源,或调整协作模型。 | CyclicBarrier全过程 |
| Phaser 和 CyclicBarrier 怎么选 | 固定参与者、简单重复阶段用 CyclicBarrier;参与者需要动态注册、注销或存在复杂多阶段流程用 Phaser。只需要等待一批独立事件结束则用 CountDownLatch。Phaser 注册后必须在所有结束路径 arrive 或 deregister。 | Phaser全过程 |
| JUC 协作工具卡住怎样排查 | 连续取得多份 jstack 确认等待工具,再采集 Latch count、Semaphore 许可和等待数、Barrier parties/waiting/broken、Phaser registered/unarrived。关键不是只看谁在等,而是找到本应改变状态的线程为何未执行、失败或卡在 I/O。 | JUC工具生产排查Runbook |
| 线程安全容器为什么仍可能出现业务丢更新 | 单个 get 和 put 线程安全,不代表 get、计算、put 三步原子。两个线程可能读到同一旧值再相互覆盖;Map 结构没有损坏,但业务结果错误。应使用 putIfAbsent/replace/compute/merge、不可变对象 CAS、锁或数据库事务。 | 线程安全与复合原子性 |
| CopyOnWriteArrayList 原理是什么 | JDK 7/8 中它维护 volatile 数组。写线程获取独占锁、复制整个旧数组、在新数组修改,最后 volatile 发布新数组;读线程读取某一版稳定数组无需加锁。代价是每次写 O(n) 复制、短期双数组内存和读到旧快照。 | CopyOnWriteArrayList全过程 |
| CopyOnWriteArrayList 迭代器为什么看不到新数据 | 迭代器创建时保存当时数组引用,后续写操作发布的是另一个新数组,迭代器仍遍历旧数组。这是设计好的快照语义,不是可见性错误;也因此迭代器不能基于旧快照执行 remove。 | CopyOnWrite快照迭代 |
| CopyOnWriteArrayList 为什么不适合写多场景 | 每次 add/remove 通常要获取写锁、分配新数组并复制全部引用。集合大且频繁写会提高 CPU、分配率和 GC,旧快照被迭代器持有时还会延迟回收。它只适合规模可控、读极多写极少。 | CopyOnWrite适用边界 |
| ConcurrentLinkedQueue 原理是什么 | 它是无界非阻塞 FIFO。offer 找到真实尾节点并 CAS 链接新节点,tail 可以暂时落后并由线程协助推进;poll CAS 把有效节点 item 置 null 完成逻辑删除并推进 head。竞争失败重试,不依赖互斥锁持有者。 | ConcurrentLinkedQueue全过程 |
| lock-free 是否表示每个线程都不会等待 | 不是。lock-free 通常保证系统整体持续有线程取得进展,不会因一个持锁线程暂停而全部停住;某个线程仍可能反复 CAS 失败甚至饥饿。每个操作都有有界步骤保证属于更强的 wait-free。 | ConcurrentLinkedQueue非阻塞边界 |
| ConcurrentLinkedQueue 的 size 为什么不适合限流 | 它不维护强一致全局计数,size 要遍历链表,是 O(n),并发修改时也只是观察值;size < limit 与随后 offer 还不是原子操作。需要容量与背压应使用有界 BlockingQueue。 | ConcurrentLinkedQueue全过程 |
| ConcurrentSkipListMap 为什么适合范围查询 | 它维护按 key 排序的底层链表和多层随机稀疏索引,从高层跨步、逐层下降,预期查询、插入和删除 O(log n),并支持 first、ceiling、subMap 等导航 API,适合并发有序范围场景。 | ConcurrentSkipListMap全过程 |
| 并发容器弱一致迭代是什么意思 | CHM、ConcurrentLinkedQueue、SkipList 等迭代可与写操作并行,不保证全局同一时点快照;迭代期间的新增删除是否看到取决于时序。CopyOnWrite 迭代器不同,它固定遍历创建时数组快照。 | 弱一致性迭代 |
| 并发容器线上问题怎样排查 | 先确认容器、读写比、元素规模和复合调用,再用火焰图/JFR 查 CAS 或数组复制热点,用 GC/堆分析查旧快照保留,比较队列生产消费 TPS 和最老元素年龄。不能仅凭“容器线程安全”排除业务竞态。 | 并发容器生产排查Runbook |
| 线程池执行流程 | 提交任务后先判断当前工作线程数是否小于核心线程数,小于就创建 Worker 执行;否则尝试放入阻塞队列;队列满了再尝试创建到最大线程数;最大线程也满了就执行拒绝策略。入队后还会二次检查线程池状态,防止关闭后残留新任务。Worker 会先执行 firstTask,然后在循环中不断从队列取任务,所以线程池是复用 Worker 线程,不是一个任务一个线程。 | 线程池执行流程、线程池源码级生命周期 |
| 线程池参数怎么设置 | 先判断任务是 CPU 密集还是 IO 密集。CPU 密集型线程数通常接近 CPU 核心数;IO 密集型可以适当大于核心数,但必须受数据库连接池、HTTP 连接池、下游限流和超时时间约束。队列必须有界,拒绝策略要明确,最终要通过压测和线上监控验证,而不是套公式。 | 线程池参数估算与监控、线程池核心参数 |
| 为什么不推荐直接用 Executors | Executors.newFixedThreadPool 和 newSingleThreadExecutor 默认使用近似无界队列,任务可能无限堆积导致 OOM;newCachedThreadPool 最大线程数接近无限,流量高时线程数可能失控。生产更推荐显式创建 ThreadPoolExecutor,写清线程数、队列容量、线程名和拒绝策略。 | 线程池总览、队列与拒绝策略 |
| 线程池任务堆积怎么排查 | 先看提交 TPS、完成 TPS、队列长度、activeCount、拒绝次数和单任务耗时。如果提交速度长期大于完成速度,队列一定上涨。再用 jstack 看线程卡在哪里:socketRead 查下游和超时,等待数据库连接查连接池和慢 SQL,BLOCKED 查锁竞争,Future.get 查线程饥饿死锁,CPU 高查计算和上下文切换。不能盲目加线程,否则可能把下游打得更慢。 | 线程池生命周期与线上排查 |
| 什么是线程饥饿死锁 | 线程饥饿死锁通常是线程池里的任务同步等待同一个线程池里的子任务,导致工作线程都被父任务占住并等待结果,而子任务还在队列里没人执行。它不一定表现为 BLOCKED 锁等待,jstack 里常见大量线程卡在 FutureTask.get、CompletableFuture.join、CountDownLatch.await。治理方式是不要在同一个小线程池里同步等待子任务,拆分父子线程池,使用异步编排,所有等待都设置超时,并结合下游容量限流。 | 线程池关闭与常见坑:线程饥饿死锁、生命周期与排查 |
| execute 和 submit 区别 | execute 只提交 Runnable,没有返回值,任务异常通常会从工作线程抛出;submit 会把任务包装成 FutureTask,返回 Future,异常会保存到 Future 中,只有调用 get() 才能感知。异步任务如果用 submit 但不检查 Future,可能出现任务失败但业务无感知。 | 线程池执行流程、Callable与Future、生命周期与线上排查 |
| Callable、Future 和 FutureTask 什么关系 | Callable 描述有返回值且可抛受检异常的计算;Future 是调用方获取、等待和取消结果的句柄;FutureTask 同时实现 Runnable 与 Future,线程池的 submit 通常先把 Callable 包装成 FutureTask,再通过 execute 运行。 | submit包装FutureTask全过程 |
| FutureTask 有哪些状态 | JDK 7/8 FutureTask 有 NEW、COMPLETING、NORMAL、EXCEPTIONAL、CANCELLED、INTERRUPTING、INTERRUPTED 七个状态。正常、异常和两类取消都只能从 NEW 出发,任务只完成一次,中间状态用于安全发布结果或完成中断。 | FutureTask状态机 |
| Future.get 为什么会阻塞,怎样被唤醒 | 未完成时,调用 get 的线程加入 FutureTask 等待者链并通过 LockSupport.park 挂起;任务完成或取消后,finishCompletion 遍历等待者并 unpark。唤醒后还要再次检查状态,以处理完成、中断和超时竞态。 | Future get等待唤醒原理 |
| Future.get 抛出的几种异常有什么区别 | ExecutionException 表示任务本身失败,要看 cause;TimeoutException 只表示这次等待超时,任务可能仍运行;InterruptedException 表示等待线程被中断;CancellationException 表示 Future 已取消。isDone 为 true 也可能是异常或取消。 | Future get语义 |
| cancel(true) 能保证任务停止吗 | 不能。它成功改变 Future 状态后只会尝试 interrupt 运行线程;中断是合作机制,任务必须检查标记或响应可中断阻塞。底层 I/O 可能不响应,已经提交的数据库或远程业务也不会回滚,所以仍需底层超时、幂等和补偿。 | Future取消与中断 |
| Future 超时后任务会自动停止吗 | 不会。get(timeout) 只限制调用线程等待,任务可能仍在队列、执行 CPU 逻辑或阻塞于 I/O。超时后可尝试 cancel,但还必须给 HTTP、RPC、数据库配置自身超时,并使用统一 deadline 和业务幂等。 | Future超时不停止任务 |
| CompletionService 解决什么问题 | 按 Future 提交顺序逐个 get 时,最慢的第一个任务会阻塞后面已完成结果的消费。CompletionService 把完成 Future 放进完成队列,让调用方按完成顺序 take,适合批量查询和分片任务。 | CompletionService避免队头阻塞 |
| Future 为什么会造成线程饥饿死锁 | 固定小线程池中的父任务如果再向同一线程池提交子任务并同步 get,所有工作线程可能都被父任务占住,子任务只能留在队列,双方永久等待。它不一定被 JVM 锁死锁检测发现,线程栈常停在 FutureTask.get。 | Future线程饥饿死锁 |
| CompletableFuture 和 Future 区别 | Future 主要表示一个未来结果,通常通过 get() 阻塞等待,不擅长组合多个任务;CompletableFuture 既表示未来结果,也能通过 thenApply、thenCompose、thenCombine、allOf 组织异步依赖和并行聚合。它本身不是新线程,真正执行任务的仍然是线程池。 | CompletableFuture全过程、Callable与Future |
| thenApply、thenCompose、thenCombine 区别 | thenApply 用于把上一步结果转换成普通结果;thenCompose 用于上一步结果决定下一个异步任务,并把嵌套 Future 拉平;thenCombine 用于两个互不依赖的异步任务完成后合并结果。普通转换用 thenApply,依赖异步串联用 thenCompose,并行结果合并用 thenCombine。 | CompletableFuture全过程 |
| CompletableFuture 线上要注意什么 | 必须指定自定义线程池,避免阻塞公共 ForkJoinPool;先创建多个 Future 再统一等待,避免提前 join 退化成串行;每个下游任务要有超时和异常兜底;核心依赖失败不能假装成功;要监控线程池 active、queue、拒绝次数和子任务 P95/P99。 | CompletableFuture全过程、线程池生命周期与线上排查 |
| ThreadLocal 是什么 | ThreadLocal 是线程本地变量工具。它让同一个 ThreadLocal 对象在不同线程里保存不同值,常用于当前用户、traceId、租户、事务上下文等。数据不是存在 ThreadLocal 自己里面,而是存在每个 Thread 对象内部的 ThreadLocalMap 中,ThreadLocal 只是 key。 | ThreadLocal全过程原理、ThreadLocal基础 |
| ThreadLocal 为什么会内存泄漏 | ThreadLocalMap 的 Entry 中 key 是弱引用,value 是强引用。如果 ThreadLocal 对象没有外部强引用,GC 后 key 会变成 null,但 value 仍然被 Entry 强引用。在线程池里线程长期存活,ThreadLocalMap 也长期存在,如果不 remove,value 就可能一直无法释放。 | ThreadLocal全过程原理 |
| ThreadLocal 为什么会串数据 | 线程池会复用线程。上一个请求在线程 T 中设置了 ThreadLocal,如果结束时没有 remove,线程 T 回到线程池后又处理下一个请求,下一个请求就可能读到上一个请求的用户、租户或 traceId。所以 ThreadLocal 必须在 finally 中清理。 | ThreadLocal全过程原理 |
项目话术:
text
采集任务不能无限 new Thread,而要用线程池控制并发度。线程池参数要结合数据库连接池、HTTP 连接池和下游接口能力配置。任务上下文可以用 ThreadLocal 保存 traceId,但在线程池中必须 finally remove,否则可能泄漏或串数据。JVM
| 面试题 | 标准回答 | 原理知识点 |
|---|---|---|
| 类加载有哪五个阶段 | Loading取得Class字节并由定义加载器创建运行时类和Class镜像;Verification验证格式、类型、字节码和符号访问;Preparation为静态字段建立存储并设置零值或ConstantValue;Resolution把常量池符号引用连接到可定位目标;Initialization执行父类和本类的<clinit>。解析可以按实现延迟,不能机械理解成完全不重叠的五段时间。 | 类加载五阶段全过程 |
| 哪些场景会触发类初始化 | 首次创建实例、读写声明类的非编译期静态字段、调用静态方法、某些反射和MethodHandle主动使用,以及JVM启动主类时触发初始化。初始化子类前先初始化所需父类。读取内联编译期常量、创建元素类数组、类字面量、loadClass和Class.forName(..., false, loader)通常不触发目标类初始化。 | 主动使用与初始化触发 |
| 通过子类访问父类静态字段为什么不初始化子类 | getstatic最终解析到字段真正的声明类。字段定义在父类时,主动使用的是父类,父类必须初始化,但子类自己的<clinit>并非读取该字段所必需。之后如果执行new Child(),子类仍会初始化。 | 被动使用场景 |
| static final一定不触发类初始化吗 | 不一定。只有final基本类型或String由JLS常量表达式初始化、并被调用方内联时,运行时可以不getstatic定义类。Integer.valueOf、随机数、数组长度或方法调用得到的static final需要在<clinit>计算,读取它通常会触发初始化。 | 编译期常量与非编译期final |
| Class.forName和ClassLoader.loadClass区别 | 单参数Class.forName通常加载并初始化;三参数版本可用initialize=false只加载。ClassLoader.loadClass通常完成查找/定义但不主动初始化。两者还必须关注ClassLoader,因为不同定义加载器产生的同名类是不同运行时类型,并各自维护初始化状态。 | Class.forName与loadClass |
| 为什么第一次初始化报ExceptionInInitializerError,后面变成NoClassDefFoundError | 首次<clinit>抛出非Error异常时,JVM通常包装为ExceptionInInitializerError,并把该类在当前定义加载器中的状态标为Erroneous。后续主动使用不会自动重跑<clinit>,而是抛NoClassDefFoundError,常见消息为Could not initialize class,必须追溯第一次异常cause。 | 类初始化失败状态 |
| 多线程同时初始化一个类会怎样 | JVM保证同一运行时类的初始化同步,只有一个线程执行<clinit>,其他主动使用线程等待。若static初始化做无超时网络I/O、等待其他线程或两个类交叉初始化,可能让大量请求堵在类初始化锁甚至形成死锁;static初始化应保持快速、确定且无外部依赖。 | 类初始化锁 |
| 准备阶段和初始化阶段有什么区别 | 准备阶段不执行普通Java初始化代码,普通静态字段先得到0、false或null;带合法ConstantValue的static final基本类型/String可得到常量值。初始化阶段才执行编译器合成的<clinit>,包括方法调用、对象创建、普通静态字段赋值和static块。 | 准备阶段、初始化阶段 |
| Class对象在元空间吗 | Java代码拿到的java.lang.Class镜像是堆对象,它关联HotSpot内部类元数据;JDK7常由永久代承载大量类元数据,JDK8主要迁到本地内存Metaspace。ClassLoader泄漏会同时使Class镜像、类元数据、静态字段对象和相关实例无法回收。 | 类元数据与Class镜像 |
| 为什么同名类会出现ClassCastException | JVM运行时类型身份是二进制名加定义ClassLoader。两个插件加载器分别defineClass同名Plugin,即使字节完全相同也是两个类型,不能直接强转。共享API应由共同父加载器定义,插件只加载实现,避免重复打包接口类。 | 类身份与定义加载器 |
| 验证阶段验证什么,能发现所有Bug吗 | 验证包括Class文件格式、元数据语义、方法字节码的数据流/控制流,以及解析时符号和访问约束。JDK7/8常结合StackMapTable检查类型状态。它保证关键格式和类型安全,不会执行所有业务路径,所以仍不能提前发现空指针、SQL错误和业务规则错误。 | Class验证阶段 |
| Class文件由哪些部分组成 | 顶层顺序是magic、minor/major版本、常量池、访问标志、this/super/interfaces、字段表、方法表和Class属性表。字段与方法通过属性扩展,方法Code属性包含max_stack、max_locals、字节码、异常表和子属性。计数、索引和长度必须按大端序及JVMS结构解析。 | Class文件顶层结构 |
| Class常量池只保存常量吗 | 不是。它既保存数字/String等字面结构,也保存Class、Fieldref、Methodref、NameAndType、MethodHandle和InvokeDynamic等符号。字节码通过索引引用它们,解析阶段再建立运行时连接。索引从1开始,JDK7/8中的Long和Double占两个槽。 | Class常量池 |
| Code属性包含什么 | Code包含操作数栈最大深度max_stack、局部变量槽数max_locals、字节码数组、异常处理表以及LineNumberTable、LocalVariableTable、StackMapTable等子属性。abstract和native方法没有普通Java字节码Code属性。 | Code属性完整结构 |
泛型擦除后反射为什么还能看到List<String> | JVM执行描述符通常只保留擦除后的Ljava/util/List;,编译器可把List<String>写入额外Signature属性,反射泛型API读取它。因此执行类型主要是List,但部分泛型元数据仍存在;若Signature被删除或生成工具未写入,就不能恢复完整泛型。 | 泛型擦除与Signature |
| JDK8 Lambda为什么会出现invokedynamic | javac通常把Lambda调用点编译为invokedynamic,并通过BootstrapMethods关联LambdaMetafactory和MethodHandle;首次链接时引导方法建立调用点。它不是固定等价于生成一个普通匿名内部类Class,具体对象生成和缓存策略由运行时决定。 | JDK8 Lambda与invokedynamic |
| 解析完成后虚方法是不是已经固定实现 | 不是。解析把类、方法名、描述符和访问关系连接到合法目标体系;invokevirtual执行时仍根据接收者实际类型进行动态分派,例如Animal引用指向Dog时选择Dog.speak。invokestatic没有接收者动态分派,invokedynamic则由Bootstrap Method建立调用点。 | 解析与动态分派 |
| NoSuchMethodError为什么编译时没发现 | 编译classpath中存在目标方法,所以字节码成功写入符号引用;运行时却优先加载了另一个旧版本类,解析名称和描述符时找不到方法。应检查最终部署包、依赖树和实际类加载来源,而不是只看IDE依赖。 | 类加载错误与商业依赖冲突 |
| JVM参数最终值怎样确认 | 先从发布配置、脚本、systemd或容器inspect还原启动命令,再用jcmd pid VM.command_line查看目标进程命令、jcmd pid VM.flags查看最终Flags;离线可用PrintCommandLineFlags和PrintFlagsFinal。必须区分配置值、JVM最终值和jstat当前运行状态。 | 确认JVM最终参数 |
| Xmx为什么不能等于容器内存limit | cgroup限制的是整个进程,不只Java Heap,还包含Metaspace、Direct Memory、线程栈、Code Cache、GC/JIT本地结构、Agent和共享库。Xmx接近limit会没有峰值余量,Heap未满也可能被OOM Kill,必须建立总内存预算并同时监控Heap和RSS。 | 容器JVM内存预算 |
| jcmd、jstat、jstack、jmap怎样选 | jcmd是综合入口;jstat用于低成本连续观察GC、类加载和编译趋势;jstack/Thread.print用于线程状态、锁和CPU栈;jmap可做直方图和Dump,但histo:live可能Full GC,Heap Dump可能STW并产生大I/O。生产应从低风险证据开始,执行高成本命令前评估副本、磁盘、暂停和敏感数据。 | 诊断命令风险矩阵 |
| 为什么JVM诊断工具attach不上 | 常见原因是OS用户不同、PID命名空间不同、临时通信文件权限异常、目标JVM严重卡顿、极简镜像没有JDK工具、Attach被禁用或工具版本不匹配。应先确认真实PID、执行位置、用户和JDK版本,不应直接把生产容器改成privileged。 | Attach机制与失败原因 |
| 双亲委派机制 | 类加载器收到加载请求后,先委托父加载器尝试加载,父加载器加载不了才由子加载器加载。这样可以保护核心类并避免重复加载。 | 类加载器 |
| 双亲委派怎么被打破 | 标准 loadClass 先查已加载类,再委派父加载器,父找不到才执行当前加载器的 findClass。打破通常有两种:重写 loadClass 对特定业务类先 findClass,形成选择性子优先;或者使用线程上下文类加载器,让父层SPI代码反向发现子层实现。Tomcat、插件隔离和OSGi会调整可见性规则,但核心类与共享API仍需父优先。 | 类加载器:双亲委派怎么被打破 |
| 重写findClass算不算打破双亲委派 | 通常不算。标准 loadClass 在父加载器找不到类时本来就会调用当前加载器的 findClass。只有改变 loadClass 的父子查找顺序、绕过固定父链,或借助TCCL建立反向加载路径,才通常称为打破或调整双亲委派。 | 标准loadClass源码 |
| Tomcat为什么打破双亲委派 | Tomcat要让不同Web应用使用各自 WEB-INF/lib 中不同版本依赖,因此WebAppClassLoader对多数应用私有类选择WebApp优先;但Java核心类、Servlet API和容器关键类仍父优先。它是选择性调整,不是完全取消双亲委派。 | Tomcat类加载 |
| SPI为什么需要线程上下文类加载器 | 父层加载器加载的标准接口按正常可见性看不到应用层实现。ServiceLoader等机制通过线程上下文类加载器读取应用classpath中的 META-INF/services 并加载实现,形成父层代码反向使用子层加载器的路径。 | TCCL反向委派 |
| 自己定义一个java.lang.String会怎么样 | JDK 9+ 通常在编译阶段就因 java.lang 属于 java.base 报 package exists in another module。旧JDK即使生成class,标准双亲委派也会由Bootstrap返回JDK String;若Child First强行 defineClass,普通自定义加载器会因禁止定义 java.* 抛 SecurityException: Prohibited package name: java.lang。因此普通classpath类不能替换JDK String。 | 自定义java.lang.String全过程 |
| JVM 由哪些部分组成 | JVM 主要由类加载子系统、运行时数据区、执行引擎、垃圾回收器、本地方法接口和本地方法库组成。类加载负责把 .class 变成 JVM 可用的类元信息;运行时数据区保存堆、栈、方法区等运行数据;执行引擎解释执行或 JIT 编译字节码;GC 负责回收不可达对象;JNI 和本地库连接操作系统能力。 | JVM组成 |
| JVM 内存结构 | JVM 运行时数据区包括程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区/元空间。堆和方法区线程共享,栈和程序计数器线程私有。 | JVM组成、JVM内存结构 |
| JDK7 和 JDK8 在 JVM 内存上有什么区别 | HotSpot 在 JDK7 中主要用永久代 PermGen 实现方法区,常见 PermGen space;JDK8 移除永久代,类元数据主要进入本地内存的 Metaspace,常见 Metaspace。但 JDK8 不是不会 OOM,动态代理、类加载器泄漏、脚本引擎反复生成类仍然会撑爆元空间。 | JVM组成:JDK7和JDK8区别、JDK版本差异 |
| HotSpot对象由哪些部分组成 | 逻辑上由对象头、实例数据和对齐填充组成。对象头通常包含Mark Word和Klass Pointer,数组再保存length;Mark Word按锁/GC状态复用保存identity hash、年龄、锁记录或Monitor等信息。具体字节数取决于32/64位、CompressedOops/ClassPointers、数组和对象对齐,不能背固定12字节覆盖所有JVM。 | HotSpot对象创建与内存布局 |
| 为什么空对象常见16字节 | 在典型64位HotSpot、8字节对齐、压缩Klass Pointer配置下,Mark Word为8字节、类指针4字节,小计12,再填充4到16。但关闭压缩类指针或改变对象对齐后会变化,应该查看最终Flags并用JOL或Instrumentation实测。 | 典型对象布局与Agent测量 |
| CompressedOops和CompressedClassPointers区别 | CompressedOops主要压缩普通对象引用字段和引用数组元素;CompressedClassPointers主要压缩对象头中指向Klass元数据的指针。可编码范围与Heap布局、对象对齐和JDK实现相关,“超过32GB一定关闭”只是常见经验,必须看最终Flags。 | HotSpot压缩指针 |
| Shallow Size和Retained Size区别 | Shallow只包含对象头、自身字段和对齐,不包含引用目标;Retained表示该对象不可达后可连带释放的独占对象图。一个浅小的HashMap可能支配数GB Entry和DTO,泄漏分析更关注Dominator Tree、Retained Heap和Path To GC Roots。 | Shallow与Retained Size |
| 对象在堆里,引用一定在栈里吗 | 不一定。引用可能位于栈槽或JIT寄存器、另一个堆对象字段、引用数组、类静态字段或JNI结构,也可能被JIT优化消除。Java reference不是业务可做指针算术的裸地址,移动GC可以修正受管理引用并保持对象身份语义。 | 对象引用与直接访问 |
| 构造器执行完前对象会被其他线程看到吗 | 如果构造器把this注册到全局容器、启动线程或调用可重入外部代码,就可能发生this逸出,其他线程看到字段默认0/null或半初始化状态。构造器应避免发布this;final字段初始化安全也要求对象在构造完成后正确发布。 | this逸出与半初始化对象 |
| TLAB 是不是堆 | 是。TLAB 是 Eden 区里给线程预留的一小段线程本地分配缓冲区,用来减少多线程在 Eden 分配对象时的竞争。对象分配在 TLAB 里仍然是堆对象,仍然受 GC 管理。TLAB 和逃逸分析不是一回事,逃逸分析触发标量替换时才可能让完整对象不真正分配到堆上。 | JVM组成:TLAB、JIT逃逸分析 |
| 逃逸分析是不是对象分配到栈 | 不能简单这么说。逃逸分析是 JIT 优化,判断对象是否逃出方法或线程。如果对象不逃逸,JIT 可能做标量替换、锁消除、分配消除,让对象不以完整堆对象形式存在。但这不是 Java 语法层面的“手动栈分配”,也不是所有对象都能优化,只有热点代码被 JIT 证明安全时才可能发生。 | JIT逃逸分析 |
new一个对象的完整分配路径是什么 | JVM先解析类并计算对象大小;热点代码可能被逃逸分析和标量替换消除分配。仍需分配时,线程优先在Eden中的TLAB用指针碰撞分配,TLAB不足则申请新TLAB或进入共享Eden慢路径;空间不足触发GC、扩堆或最终OOM。拿到内存后零值初始化、设置对象头,再执行构造方法。 | 对象分配、TLAB、晋升与GC |
| TLAB满了就一定触发Minor GC吗 | 不一定。JVM可能先根据对象大小和TLAB浪费策略决定重新申请TLAB,或让该对象走共享Eden慢路径;只有整个年轻代分配压力达到条件时才触发收集。TLAB是线程私有的分配窗口,不是独立垃圾回收区域。 | TLAB快速路径与慢路径 |
| 对象什么时候进入老年代 | 常见路径包括对象在多次年轻代GC后年龄达到阈值、Survivor中同年龄对象占用触发动态年龄判断、Survivor容纳不下导致提前晋升,以及收集器特定的大对象路径。不能把MaxTenuringThreshold理解成所有对象都必须活满固定次数。 | 对象年龄、动态阈值和提前晋升 |
| 大对象一定直接进入老年代吗 | 不一定,行为取决于收集器、版本和参数。Serial/ParNew等场景可受PretenureSizeThreshold影响;G1把超过一定Region比例的对象作为Humongous对象处理,分配连续Region;不能把某个收集器的规则说成所有JVM通用规律。 | 大对象与收集器差异 |
| Promotion Failed和CMS Concurrent Mode Failure区别 | Promotion Failed表示年轻代回收时需要晋升的对象无法在老年代获得足够空间;CMS Concurrent Mode Failure表示CMS并发回收尚未完成,老年代已无法满足分配,需要退化到更重的停顿式回收。两者都要结合老年代占用、碎片、分配/晋升速率和GC日志判断。 | 晋升担保与失败 |
| Minor、Major、Mixed和Full GC怎么区分 | Minor/Young通常收集年轻代;Major在不同资料和收集器中的含义并不完全统一;G1 Mixed同时收集年轻代和部分老年代Region;Full GC通常覆盖更完整的堆及相关区域并伴随较重停顿。生产判断必须看收集器和实际GC日志,不能只凭术语。 | GC术语边界 |
| Serial、Parallel、CMS和G1怎样选择 | Serial以单线程STW和低协调成本适合小堆/少CPU;Parallel通过STW并行回收追求总体吞吐;CMS把老年代大部分标记清除与业务并发,降低停顿但有碎片、浮动垃圾和并发失败;G1用Region、并发标记和收益预测支持Young/Mixed GC。选型必须结合JDK版本、堆大小、CPU quota、分配/存活率和P99 SLO压测。 | HotSpot收集器完整原理与选型 |
| 并行GC和并发GC有什么区别 | 并行是多个GC线程一起工作,业务线程可能仍然全部STW;并发是GC某些阶段与业务线程同时推进。Parallel GC主要是STW并行,CMS和G1包含并发标记,但它们同样存在初始标记、Remark、Evacuation等STW阶段,所以并发不等于无停顿。 | 串行、并行、并发与STW |
| CMS为什么会Concurrent Mode Failure | CMS并发回收期间业务仍在分配和晋升;若回收完成前老年代可用空间已不足,就可能退化到长时间STW回收。原因可能是启动过晚、晋升洪峰、CPU受限、碎片、容量不足或泄漏,必须结合CMS周期、老年代基线、晋升率和CPU throttling判断。 | CMS完整执行过程 |
| G1有新生代和老年代吗 | 有逻辑分代。G1取消的是年轻代和老年代必须分别占一整段连续地址的物理布局;不同Region动态承担Eden、Survivor、Old和Humongous角色。Young GC回收年轻Region,Mixed GC还会加入部分回收收益高的Old Region。 | G1 Region模型 |
| Card Table、Remembered Set和写屏障是什么关系 | 堆被划成Card;业务线程写引用时,JIT写屏障将相关Card记脏。Young GC可扫描脏Card而不是整个老年代;G1再通过Remembered Set从目标Region角度记录外部哪些Card可能指向它。它们用额外CPU和元数据换取更小扫描范围,不是Java锁。 | Card Table与写屏障 |
| JDK7、JDK8和JDK9+收集器边界有什么不同 | JDK7/8常见Parallel组合,历史在线系统也大量使用CMS;G1在JDK7后期引入并在JDK8逐步成熟。JDK9起G1成为HotSpot常见默认,CMS被废弃并在JDK14移除;ZGC从JDK11以后才出现。默认仍受发行版、CPU和参数影响,应查看PrintCommandLineFlags或运行时Flags。 | JDK收集器版本地图 |
| GC如何判断对象可回收 | HotSpot主要从GC Roots做可达性分析。Roots包括活动线程栈/JIT已知引用、静态字段、JNI、锁和JVM内部结构。能沿强引用链到达的对象存活;无Root路径的循环对象也可回收。安全点和OopMap帮助JVM准确枚举栈槽/寄存器中的引用。 | GC Roots与可达性 |
| 安全点和OopMap解决什么 | JIT优化后引用可能位于栈槽、寄存器或被消除,GC不能把任意机器字猜成对象。OopMap描述特定位置的对象引用分布;需要全局一致性操作时,线程到达安全点/安全状态后暂停,JVM据此枚举Roots、移动对象和修正引用。长暂停还要区分Time To Safepoint与真正GC工作。 | 安全点与OopMap |
| 三色标记并发时为什么会漏标 | 若黑对象新指向白对象,同时灰对象删除通向该白对象的旧路径,GC可能永远不再扫描到仍存活的白对象。CMS增量更新记录新引用并在Remark重扫,G1 SATB在覆盖前记录旧引用,二者通过写屏障破坏漏标条件。 | 三色标记与并发漏标 |
| 强引用、软引用、弱引用、虚引用有什么区别 | 强可达对象不能按内存压力回收;软可达对象可在压力下清除但不适合可靠缓存;弱可达对象在相应GC引用处理时可清除;虚引用不延长对象可用生命周期,get()始终为null且必须关联ReferenceQueue,主要用于对象不可再使用后的清理通知。资源仍应显式close。 | 强软弱虚引用 |
| ReferenceQueue有什么用 | 创建Soft/Weak/PhantomReference时可关联队列;目标进入相应可达级别并经GC处理后,Reference对象可入队,清理线程通过poll/remove取得Reference并处理旁存资源句柄。必须保留Reference自身、避免反向强引目标,并治理清理线程异常、积压、停止和幂等。 | ReferenceQueue完整链路 |
| 为什么不推荐finalize | finalize依赖GC且不保证及时,Finalizer线程阻塞会导致队列积压,对象还可能一次复活并延长生命周期;同一对象不会被JVM自动finalize两次。JDK9起已Deprecated并继续走向移除,JDK7/8也应使用AutoCloseable和try-with-resources,Reference/Cleaner只做兜底。 | finalize问题 |
| 标记清除、复制和标记整理区别 | 标记清除不移动存活对象但会产生外部碎片;复制/Evacuation把存活对象移到目标空间并整块释放源区,适合低存活率但需要To-space;标记整理压紧存活对象得到连续空间,适合高存活区但移动和引用修正成本高。真实收集器还组合并行、并发、分代和Region。 | GC回收算法比较 |
| 分代GC为什么需要Card Table | Young GC若为寻找Old到Young引用而扫描整个Old会失去分代收益。JIT写屏障在Old对象写入Young引用时把对应Card标脏,Young GC扫描Roots和脏Card即可;G1进一步维护Region的Remembered Set。代价是业务写屏障CPU、队列和元数据。 | 分代与Card Table |
| JVM性能调优完整流程是什么 | 先定义吞吐、P99、错误率和资源SLO并保存基线,再用Metrics、Logs、Traces、CPU/Wall/Allocation/Lock Profile定位端到端瓶颈;区分CPU、GC、锁、队列、连接池、I/O、JIT和容器限速。提出单一假设,固定版本、数据与资源做预热和稳态压测,最后金丝雀并设置硬回滚条件。 | JVM性能分析与生产调优 |
| Little定律在性能分析中怎么用 | 稳态下L=λ×W,系统平均在途数等于吞吐/到达率乘平均停留时间。例如1000QPS、平均100ms约有100个请求在途。它可检查线程、内存和连接容量是否自洽,但过载队列持续增长时没有稳态,不能机械套公式得出线程池大小。 | Little定律与容量模型 |
| CPU高怎么定位到Java代码 | 先用top/pidstat确认进程,再用top-H找到热点OS线程,把十进制TID转十六进制并在多份jstack中匹配nid;随后用CPU火焰图或JFR确认长期热点占比,并结合GC CPU区分业务计算与GC。单份RUNNABLE线程栈不足以证明持续高CPU。 | CPU高完整排查 |
| CPU不高但接口很慢怎么排查 | 优先看连续线程栈和Wall Profile,定位SocketRead、数据库连接池、BLOCKED锁、Future等待或线程池队列,再用Trace和下游指标确认耗时阶段。还要检查容器CPU throttling,因为宿主机CPU低不代表该容器没有被配额暂停。 | CPU低但接口慢 |
| Young GC很频繁一定要调大Heap吗 | 不一定。要同时看分配率、单次暂停、GC CPU、对象存活率、晋升率、Old基线和业务P99。若暂停短、CPU可接受且Old稳定,可能只是大量短命对象;若晋升高、Old上涨或P99恶化,再查批量对象、Survivor、Heap和收集器。 | GC性能五指标 |
| 什么是Coordinated Omission | 封闭压测工具在服务暂停时会因为等待上一个响应而停止按计划发后续请求,结果只记录少量慢请求,漏掉本应在暂停窗口到达并排队的大量请求,低估P99。应使用开放到达率/遗漏修正,并同时观察服务端队列和实际完成率。 | 压测模型与Coordinated Omission |
| 宿主机CPU不高为什么容器仍然慢 | 容器受CPU quota限制时,短窗口用完配额会被CFS throttling到下一周期;宿主机还有空闲核也不能继续运行该cgroup。应看容器CPU limit、throttled periods/time、JVM感知核数,以及GC/JIT/业务线程竞争,不能只增加线程。 | 容器CPU Throttling |
| 怎么查看 GC 日志和排查 OOM | 可以通过 GC 日志、jstat、jmap、heap dump、MAT、对象直方图和引用链分析内存问题。 | JVM性能调优、JVM排查工具 |
| OOM 怎么完整排查 | OOM 先判断类型,是堆、元空间、直接内存、线程数还是 GC overhead。然后保留 GC 日志、错误日志和必要的 dump,用 jstat 看 GC 趋势,用 jmap 或 heap dump 分析对象占用和引用链,用 jstack 看线程是否堆积。堆 OOM 常见是集合无限增长、缓存不清理、大文件一次性读入;直接内存 OOM 常见于 NIO/Netty;线程 OOM 常见于无限创建线程或线程池配置错误。 | JavaSE从零到生产级掌握、JVM排查工具 |
| 场景题:线上 OOM 怎么定位,Java 和 Arthas 怎么查 | 先保现场,确认是 Java 堆、元空间、直接内存、线程、本地内存还是容器 OOMKilled。Java 原生命令用 jcmd VM.flags 看参数、jstat -gcutil 看 GC 趋势、jcmd Thread.print 抓线程栈、jcmd GC.class_histogram 看对象分布,必要时 jcmd GC.heap_dump 导出 dump 用 MAT 看 Dominator Tree 和 GC Roots。Arthas 在线查时用 dashboard、jvm、memory 看概况,thread 看阻塞和高 CPU,heapdump 导出堆,vmtool 查可疑类实例,ognl 只读看静态缓存,watch/trace 定位是谁持续写入缓存、集合或大查询。最终要落到代码根因和修复方案。 | 线上OOM定位场景题、JVM排查工具 |
| OOM 后系统崩溃和仍在运行怎么分别排查 | 先明确 OOM 不等于 JVM 必然退出。已经崩溃时无法再 attach 原进程,只能检查自动 hprof、GC 日志、应用日志、监控、hs_err、Pod previous log、OOMKilled 和退出码,并按时间线离线还原。系统仍运行时先判断是否只是“假存活”,有副本就摘流量,再低风险采集参数、GC、连续线程栈和对象直方图,评估磁盘与 STW 风险后才导出 dump。两种场景都要区分 Java OOM 与容器 OOMKilled,并最终落到代码或容量根因。 | OOM生产级手册:两种事故状态、系统崩溃、系统仍运行 |
| 收到 OOM 告警后第一步从哪里获取信息 | 先从监控记录服务、实例、版本、告警时间和部署环境。Kubernetes 用 get pod 定位实例、describe pod 查 Last State/OOMKilled/137、logs --previous 查旧容器;Docker 用 inspect 查 OOMKilled 和退出码;Linux/systemd 用 systemctl、journalctl、jcmd -l 和内核日志;Windows 用进程、服务、应用日志和事件日志。拿到完整错误文本后才区分 Heap、GC Overhead、PermGen/Metaspace、Direct Memory、native thread 或容器总内存,进程仍存活再执行 jstat、线程栈和直方图。 | 从告警开始的完整取证步骤、按错误文本准确分类 |
| 什么是火山图,Java 排障中要看什么图 | 火山图是用变化倍数和统计显著性筛选指标的统计散点图,多见于生物信息学。Java 性能排障通常说的是火焰图:它聚合调用栈采样,宽度表示样本占比,纵向表示调用深度。CPU 火焰图找 CPU 热点,Allocation 火焰图找分配热点;OOM 泄漏最终要看 MAT 的 Dominator Tree、Retained Heap 和 Path To GC Roots。 | 火山图与火焰图辨析、OOM生产级排查手册 |
| 火焰图的宽度、高度、颜色和横轴分别表示什么 | 宽度是采样窗口内该帧及子调用的样本占比,不是单次调用耗时;高度是调用栈深度;默认颜色多用于区分帧,不是越红越危险;普通火焰图横轴是聚合布局,没有先后时间意义。宽平顶更接近Exclusive热点,父帧宽要继续看上方子树。 | 火焰图读法 |
| CPU、Wall、Allocation和Lock火焰图区别 | CPU图看在CPU上执行的热点;Wall图包含运行和等待,适合接口慢但CPU不高;Allocation图看对象创建路径,不证明泄漏;Lock图看锁竞争/等待路径。事件选错会得出完全不同结论。 | 火焰图事件类型 |
| Inclusive和Exclusive样本有什么区别 | Inclusive包含方法本身及所有子调用,表示整个调用子树;Exclusive近似栈顶直接落在方法自身的样本。父方法很宽但上方几乎全是JSON子调用,真正热点更可能是JSON,而不是父方法本身。 | Inclusive与Exclusive |
| Allocation火焰图能证明内存泄漏吗 | 不能。它回答谁分配得快;短命对象分配很高也可能全部回收,慢性泄漏分配不高也会持续增长。要结合GC日志、Histogram、Heap Dump、Dominator Tree、Retained Heap和Path To GC Roots判断存活与持有链。 | 分配热点到泄漏判断链 |
| 什么是Safepoint Bias | 某些传统采样只能或偏向在JVM安全点取栈,长时间不经过安全点的热代码可能被低估。async-profiler等可降低这类偏差,但仍受平台、权限、符号和栈展开影响,应与线程CPU、JFR和业务指标交叉验证。 | Safepoint Bias |
| Java heap space 怎么排查 | 先确认错误文本是堆 OOM,再保留 GC 日志、对象直方图和 heap dump。用 MAT 看 Dominator Tree 找 Retained Heap 最大的对象,再用 Path To GC Roots 看对象为什么还活着。常见根因是无界集合、本地缓存无淘汰、大文件一次性读入、MQ 消费内存堆积、ThreadLocal 未清理和查询无分页。 | JVM排查工具:Heap OOM |
| Metaspace OOM 怎么排查 | Metaspace OOM 查的不是普通业务对象,而是类元数据。重点看动态代理、CGLIB、ByteBuddy、脚本引擎、热部署和 ClassLoader 泄漏。可以用 jcmd VM.classloader_stats、类直方图、NMT 辅助定位,并设置合理的 MaxMetaspaceSize 防止吃光容器内存。 | JVM排查工具:Metaspace OOM |
| Direct buffer memory 怎么排查 | 直接内存 OOM 常见于 NIO、Netty 和 ByteBuffer.allocateDirect。它不受 -Xmx 直接限制,要看 MaxDirectMemorySize、Netty ByteBuf 是否 release、DirectByteBuffer 是否被长期引用,以及容器总内存是否够。Netty 可临时开启 leak detector 辅助定位。 | JVM排查工具:Direct Memory、零拷贝与大文件 |
| 容器 OOMKilled 和 Java OOM 区别 | Java OOM 是 JVM 内部抛 OutOfMemoryError,通常有错误栈和可能的 dump;OOMKilled 是进程超过容器 cgroup 内存限制后被系统杀掉,常见退出码 137,可能没有 Java OOM 栈。排查要看 kubectl describe pod、logs --previous,并核算堆、元空间、直接内存、线程栈和 JVM 本身开销。 | JVM排查工具:容器OOMKilled |
项目话术:
text
采集平台最容易出现的 JVM 问题是批量数据导致堆上涨、线程池堆积导致线程数异常、下游慢导致请求堆住、GC 频繁导致接口变慢。排查时先看 CPU、线程栈、GC 日志、堆对象分布和下游耗时。常见追问
| 追问 | 回答方向 | 跳转 |
|---|---|---|
| HashMap 为什么要用 2 的幂 | 容量为 2 的幂时,(n - 1) & hash 才能等价于高效取模,并且扩容翻倍后节点只需要根据新增的那一位判断留在原位置还是移动到 原位置 + oldCap。 | HashMap全过程原理 |
| HashMap 扩容时节点怎么迁移 | 扩容通常容量翻倍。JDK 8 中旧桶链表会根据 hash & oldCap 拆成低位链表和高位链表,结果为 0 的节点留在原下标,不为 0 的节点移动到 原下标 + oldCap。这样不用对每个节点重新完整取模。 | HashMap全过程原理 |
| HashMap 链表长度到 8 为什么不一定树化 | 链表长度达到 8 只是触发树化检查。如果 table 容量小于 64,HashMap 会优先扩容,因为冲突可能只是数组太短导致的;只有容量足够大且冲突仍然严重时,才转红黑树。 | HashMap全过程原理 |
| HashMap 的 key 为什么最好不可变 | HashMap 放入 key 时按当时的 hash 定位桶。如果 key 参与 equals/hashCode 的字段被修改,后续 get 会按新 hash 去另一个桶查,可能取不到原来的数据。key 应优先使用 String、枚举或 JDK8 可用的不可变值对象;新版本 Java 也可以用 record 简化写法。 | HashMap全过程原理 |
| ArrayList 的 fail-fast 是什么 | ArrayList 的 Iterator 创建时会记录 expectedModCount,遍历过程中如果发现 expectedModCount 和 List 的 modCount 不一致,就抛 ConcurrentModificationException。它是尽力而为的并发修改检测,不是线程安全机制。 | ArrayList全过程原理 |
| subList 和 Arrays.asList 有什么坑 | subList 是原 List 的视图,不是独立新集合,修改会互相影响,原集合结构变化后子列表还可能异常;Arrays.asList 返回固定长度 List,不能 add/remove。需要独立可变集合时用 new ArrayList<>(...) 包一层。 | ArrayList全过程原理 |
| ConcurrentHashMap 的 computeIfAbsent 有什么坑 | computeIfAbsent 能保证同 key 或同桶相关更新的原子性,但 mapping function 里不要做慢 SQL、HTTP 调用、文件 IO 等慢操作,因为它可能在桶锁内执行,导致同桶其他更新阻塞。慢加载更适合用专门缓存组件或把慢 IO 移到锁外。 | ConcurrentHashMap全过程原理 |
| ConcurrentHashMap 的 size 能做强一致判断吗 | 不适合。高并发修改时 size 需要汇总分散计数,只能看作近似瞬时结果,不能用于“size 小于某值就提交”这类强一致判断。应使用独立原子计数、限流器或业务锁。 | ConcurrentHashMap全过程原理 |
| put、offer、take、poll 区别 | put 队列满时一直阻塞,offer 队列满时立即返回 false,offer(timeout) 最多等一段时间;take 队列空时一直阻塞,poll 队列空时返回 null,poll(timeout) 最多等一段时间。接口线程通常不要无限 put,要考虑超时和降级。 | BlockingQueue全过程原理 |
| SynchronousQueue 为什么容易让线程数变多 | SynchronousQueue 不存储元素,生产者必须直接把任务交给消费者。线程池使用它时,如果没有空闲线程接任务,就倾向于创建新线程,直到 maximumPoolSize。因此 cached 线程池在高流量下可能线程数暴涨。 | BlockingQueue全过程原理 |
| 队列堆积怎么排查 | 先比较生产 TPS 和消费 TPS,如果生产长期大于消费,队列一定上涨。再查消费者是否存活、单任务耗时、慢 SQL、远程调用、锁等待、下游限流和重试风暴。处理时不能只加消费者,要结合下游容量、队列容量、拒绝策略和降级策略。 | BlockingQueue全过程原理 |
| 线程池参数怎么定 | 结合 CPU/IO 类型、数据库连接池、下游接口能力、队列长度和拒绝策略。 | 线程池参数估算与监控 |
| 线程池堆积但 CPU 不高说明什么 | 大概率不是 CPU 算不过来,而是线程阻塞在下游 IO、数据库连接、锁等待、Future.get 或队列等待。要用 jstack 和下游指标定位卡点。 | 线程池生命周期与线上排查 |
| 被 unpark 的线程是不是马上拿到锁 | 不是。unpark 只是让等待线程从阻塞状态恢复为可竞争状态,它还要重新执行获取逻辑。非公平锁下,新来的线程甚至可能插队抢到锁。 | AQS与JUC全过程原理 |
| CAS 有哪些问题 | CAS 常见问题有 ABA、自旋开销和只能原子更新单个变量。ABA 可以通过版本号或 AtomicStampedReference 解决;自旋开销在高竞争下会导致 CPU 升高;多个字段一致性不能靠多个 CAS 随便拼,应该用锁或不可变对象整体替换。 | volatile与Atomic全过程原理、CAS与ABA问题 |
| volatile 开关为什么线上没生效 | 要先确认所有线程读的是同一个 JVM 内同一个字段,字段确实加了 volatile;如果线程卡在 socketRead、sleep、数据库调用里,volatile 不能主动中断阻塞;如果要跨服务生效,volatile 也不够,应该用配置中心、Redis、MQ 等外部机制。 | volatile与Atomic全过程原理 |
| 读写锁为什么写多场景不一定快 | 读写锁的收益来自读读共享。如果写操作频繁,读写会不断互斥,AQS 状态维护、CAS、排队和唤醒都有成本,吞吐可能不如普通互斥锁。 | 读写锁全过程原理 |
| StampedLock 和 ReentrantReadWriteLock 区别 | ReentrantReadWriteLock 支持可重入读写锁,模型更稳;StampedLock 支持乐观读,极端读多时可能更快,但不可重入,乐观读必须 validate,使用复杂度更高。 | 读写锁全过程原理 |
| CompletableFuture 超时后底层任务会停止吗 | 不一定。orTimeout 或 completeOnTimeout 只是让 Future 以异常或默认值完成,已经在线程中执行的 HTTP/DB 调用可能还在占用线程。生产必须同时设置编排层超时和客户端层超时。 | CompletableFuture超时控制 |
| OOM 怎么排查 | 先看错误文本分类,堆 OOM 查 heap dump、Dominator Tree 和 GC Roots 引用链;Metaspace 查动态类和 ClassLoader;Direct Memory 查 NIO/Netty 和堆外内存上限;线程 OOM 查线程数、线程池、-Xss 和 OS 限制;容器 OOMKilled 查退出码 137、Pod 事件和 JVM 总内存预算。不要先调大 -Xmx,否则可能掩盖泄漏或触发容器更快被杀。 | JVM排查工具 |
| ThreadLocal 为什么线程池更危险 | 普通线程结束后 ThreadLocalMap 会跟着线程回收;线程池线程长期复用,如果不 remove,旧 value 会一直挂在线程的 ThreadLocalMap 上,既可能造成内存泄漏,也可能让下一个任务读到上一个任务的用户、租户或 traceId。 | ThreadLocal全过程原理 |
| InheritableThreadLocal 能解决线程池上下文传递吗 | 不能可靠解决。InheritableThreadLocal 只在线程创建时复制父线程变量,而线程池线程通常提前创建并复用,提交任务时不会重新继承当前请求上下文。生产更推荐包装 Runnable、Callable 或 Executor,显式捕获、设置和清理上下文。 | ThreadLocal全过程原理 |
| 大文件导入为什么会 OOM | 一次性把文件读入堆内存,或者把所有解析结果放入集合。应该分块读取、分批入库,并区分堆 OOM 和直接内存 OOM。 | 缓冲与字符编码、零拷贝与大文件 |
| NIO 服务 CPU 高怎么查 | 先用线程栈定位高 CPU 线程,检查是否 Selector 空轮询、一直监听 OP_WRITE、业务死循环或在 IO 线程做慢业务。 | IO与NIO排查面试 |
| IO 阻塞导致接口慢怎么查 | 先看线程栈定位线程卡在文件、网络、数据库还是锁等待;如果卡在 read/write,要继续看下游响应、超时、线程池、连接池和是否在锁内做慢 IO。 | IO与NIO全过程原理 |
面试回答模板
text
这个问题我会先从项目场景说起。比如采集任务里需要批量暂存、去重和映射,所以会大量用集合;集合背后要理解 ArrayList 扩容、HashMap 哈希定位、equals/hashCode 规则。并发场景下还要区分普通集合和并发集合,否则多线程写入会有数据错乱。具体原理我会看集合框架和并发集合两个知识点。深度验收跳转
如果面试官继续追问“你说的这些到底为什么”,不要在本页硬背长答案,回到 JavaSE 从零到精通验收清单 按阶段复盘:
- 基础:源码、编译、classpath、类型、值传递、金额精度。
- 对象:封装、继承、多态、状态保护、贫血模型和充血模型。
- 集合:ArrayList、HashMap、equals/hashCode、JDK 7/8 差异。
- 框架基础:泛型擦除、注解、反射、SPI。
- IO:BIO、NIO、Buffer、Channel、Selector、大文件导入。
- 并发:JMM、volatile、synchronized、CAS、AQS、JUC。
- 线程池:核心参数、执行流程、队列、拒绝策略、监控、排查。
- JVM:组成、内存、类加载、GC、JIT、逃逸分析、OOM。
本章小结
面试页负责帮你组织回答,知识点页负责帮你真正理解。遇到 JavaSE 题,不要只背一句话,要能跳到对应知识点解释原理、Demo、风险和项目落地方式。
