Skip to content

九、IO/NIO、设计模式与并发深化(J76–J85) ​

J76 · 知识点:字节流与字符流 ​

【题目】下列关于 Java IO 流的说法,正确的是?

A. FileInputStream 属于字符流,适合直接读取文本文件中的中文内容 B. Reader 体系以字节为单位读取,底层不依赖字节流 C. System.out 的类型是 InputStream D. BufferedReader 属于字符流,可按行读取文本,适合处理与字符编码相关的文本数据

答案:D

【考点】Java IO 中字节流(InputStream/OutputStream)与字符流(Reader/Writer)的体系划分。

【结论】选 D。BufferedReader 是 Reader 体系中的缓冲字符流,提供 readLine() 按行读取文本,能正确处理字符编码。

【逐项辨析】

  • A 错误:FileInputStream 继承 InputStream,属于字节流,按字节读取;直接读中文文本可能因编码切分出现乱码,字符读取应使用 FileReader 或 InputStreamReader 并指定 Charset。
  • B 错误:Reader 体系以字符为单位读取;其底层实现(如 FileReader)内部通常包装字节流 + CharsetDecoder,并非“不依赖字节流”。
  • C 错误:System.out 的类型是 java.io.PrintStream,属于字节输出流(父类为 FilterOutputStream),不是 InputStream。
  • D 正确:BufferedReader 包装 Reader,提供缓冲与 readLine(),是按行处理文本的标准选择。

【知识点】 Java IO 分为两大体系:

体系基类抽象单位典型类
字节流InputStream / OutputStream8-bit 字节FileInputStream、BufferedInputStream、DataInputStream
字符流Reader / Writer字符(Unicode)FileReader、BufferedReader、InputStreamReader

关键要点:

  1. 一切文件底层都是字节;字符流是在字节流之上叠加 Charset 编解码。
  2. 转换流:InputStreamReader / OutputStreamWriter 是字节流通往字符流的桥梁,构造时应明确 Charset(推荐 StandardCharsets.UTF_8)。
  3. 缓冲流:BufferedReader / BufferedWriter / BufferedInputStream 等通过内部缓冲区减少系统调用。
  4. 装饰器模式:Java IO 经典设计——new BufferedReader(new InputStreamReader(new FileInputStream(f), UTF_8)) 层层包装。

【记忆锚点】「字节 File/Input,字符 Reader/Writer;桥靠 InputStreamReader,按行就用 BufferedReader。」

【易混对比】

  • 易混:FileInputStream(字节)vs FileReader(字符);System.out 是 PrintStream(字节输出),System.in 是 InputStream。
  • 进阶:NIO 的 Channel 传输基于 Buffer(字节缓冲),不走 Reader/Writer 字符体系。

【自测】 用哪种组合可以按 UTF-8 逐行读取文本文件,并避免大文件一次读入内存?

答:try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8))。与 J77/J85 连考。

【知识关联】

  • 同库关联:与 J50(try-with-resources 管理流资源)、J77/J84/J85(NIO 与 Buffer)构成 IO 题群;与计算机二级 Java「文件操作」考点对应。字符集/编码话题在本库只在 J23(String 的 byte[] 与 coder)出现——J25–J27 讲字符串拼接与编译期优化、J28–J30 讲包装类缓存与比较,都不涉编码,别错引。Reader/Writer 体系也与 J25–J27(String 编码相关)呼应——文本乱码本质是 Charset 选择错误。
  • 实现层:一切文件与 Socket 在 OS 层都是字节流;InputStreamReader 内部持有 StreamDecoder(CharsetDecoder),按 Charset 把字节序列解码为 char;BufferedReader 在 Reader 之上加 char[] 缓冲,默认 8192 字符,readLine() 在缓冲内搜换行符,避免每次 read 触发系统调用。装饰器模式(Decorator)是 Java IO 的骨架:FilterInputStream/FilterReader 通过持有底层流引用层层增强。
  • 面试追问:① 为何读中文用 FileReader 容易乱码?(依赖平台默认 Charset,Linux 常为 UTF-8、旧 Windows 常为 GBK)② 字节流如何安全转字符流?(必须经 InputStreamReader/OutputStreamWriter 并显式指定 Charset)③ System.out.println 打印中文乱码可能原因?(控制台编码与 PrintStream 默认编码不一致) 【拓展延伸】
  • 变式问法:① 给出 new BufferedReader(new InputStreamReader(new FileInputStream(f))) 问最外层类型(BufferedReader,字符流);② 问 System.in/System.out/System.err 的声明类型(均为字节流:InputStream/PrintStream/PrintStream);③ 问「按 UTF-8 读取大文件并逐行统计」的推荐 API(Files.newBufferedReader / Files.lines)。
  • 版本差异:JDK 1.0 即有 Stream 体系;JDK 1.1 引入 Reader/Writer 与 Charset;Java 7 的 java.nio.file.Files 提供 newBufferedReader/newBufferedWriter/newInputStream 等工具;Java 11 的 Reader.transferTo(Writer)、InputStream.readAllBytes()(注意大文件勿用)进一步简化拷贝;Java 21 虚拟线程下阻塞式 BIO 风格写法重新具备高并发可行性。
  • 工程注意点:① 禁止依赖 new FileReader(path) 的平台默认编码,统一 StandardCharsets.UTF_8;② 网络/文件文本处理优先 try-with-resources,避免 finally 忘关;③ 大文件用缓冲流或 Files.lines 流式处理,禁止一次性 readAllBytes/readAllLines;④ 日志、配置解析中注意 BOM 与换行符差异;⑤ 二进制协议(图片、序列化)必须用字节流,字符流会破坏字节边界。

J77 · 知识点:NIO 三大核心组件 ​

【题目】Java NIO(New I/O)的三大核心组件是?

A. Channel、Buffer、Selector B. Stream、Pipe、File C. Socket、ServerSocket、DatagramSocket D. Reader、Writer、Charset

答案:A

【考点】NIO 与 BIO 在组件模型上的根本差异。

【结论】选 A。NIO 三大核心为 Channel(通道)、Buffer(缓冲区)、Selector(多路复用选择器)。

【逐项辨析】

  • A 正确:Channel 负责双向数据传输,Buffer 负责暂存数据,Selector 负责监听多个 Channel 事件。
  • B 错误:Stream 是 BIO 的核心抽象;Pipe 不是 NIO 三大核心之一。
  • C 错误:Socket/ServerSocket/DatagramSocket 属于 BIO 网络 API(java.net)。
  • D 错误:Reader/Writer 是 BIO 字符流体系,Charset 是编码工具。

【知识点】

  1. Channel:双向通道,常见实现有 FileChannel、SocketChannel、ServerSocketChannel。数据必须经 Buffer 读写。
  2. Buffer:核心属性 position / limit / capacity;写后 flip() 切到读模式。
  3. Selector:多路复用器,单线程可监听大量非阻塞 Channel(Reactor 基础)。事件:OP_ACCEPT / OP_CONNECT / OP_READ / OP_WRITE。
  4. 对比 BIO:BIO 是“一连接一线程”阻塞模型;NIO 适合高连接、低活跃场景。

【记忆锚点】「NIO 三剑客:Channel 传数据,Buffer 装数据,Selector 盯事件。」

【易混对比】

维度BIONIO
核心抽象StreamChannel + Buffer + Selector
阻塞模型阻塞 IO非阻塞 + 多路复用
线程模型连接:线程 ≈ 1:1少量线程处理大量连接

【自测】 下列哪段代码体现了 NIO 读数据的基本顺序?

答:channel.read(buffer); buffer.flip(); while (buffer.hasRemaining()) { /* consume */ }。与 J84 连考。

【知识关联】

  • 同库关联:与 J76(BIO 流体系)、J84(Buffer 的 flip/clear/compact)、J85(BIO vs NIO 优势)构成完整 IO 对照题群;与 J51–J59(线程与线程池)连带考查——NIO 的价值正是「少线程扛高并发」。面试中常把本题与 Netty 主从 Reactor 线程模型一起追问。
  • 实现层:Channel 对应 OS 文件描述符/套接字,双向且非阻塞(configureBlocking(false));Buffer 是覆盖在数组上的状态机,靠 position/limit/capacity 三下标描述可读可写区间;Selector 在 Linux 上基于 epoll(Windows 实现有差异),一次系统调用可返回多个就绪事件,对应 Java 的 SelectionKey 集合。Netty 对 Selector 空轮询 bug 做了重建 selector 的修复,并把 Buffer 抽象为读写指针分离的 ByteBuf。
  • 面试追问:① 为什么 Channel 读写必须经过 Buffer,不能直接拿数组?(统一缓冲模型、便于零拷贝与批量就绪处理)② Selector 适合什么连接模型?(高连接、低活跃:聊天、推送、网关)③ 为何业务代码很少手写 NIO?(协议编解码、半包粘包、线程模型复杂,通常交给 Netty) 【拓展延伸】
  • 变式问法:① 问「下列哪组不是 NIO 核心组件」;② 给出 ServerSocketChannel.register(selector, OP_ACCEPT) 问 Selector 监听的事件类型;③ 问 DirectByteBuffer 与 HeapByteBuffer 的区别(堆外分配、减少一次拷贝、分配回收更贵)。
  • 版本差异:NIO 于 JDK 1.4 引入;JDK 7 的 NIO.2(AsynchronousFileChannel、Files/Paths)补充异步文件与更完整的文件系统 API;JDK 11 引入 HttpClient(内部仍走 NIO);JDK 21 虚拟线程使得「线程-per-连接」写法性能回升,但 Selector 多路复用在超高连接数场景仍是主流方案(Netty/gRPC-Java)。
  • 工程注意点:① 业务层优先使用 Netty/spring-webflux 等成熟框架,避免手写 NIO 半包逻辑;② 文件批量传输用 FileChannel.transferTo(零拷贝);③ 注意 Selector 必须处理 OP_WRITE 与取消注册,防止 busy-loop;④ Direct Buffer 要关注堆外内存上限(-XX:MaxDirectMemorySize)与泄漏排查(NMT);⑤ 单线程 Selector 不适合重 CPU 业务,应把计算丢到业务线程池。

J78 · 知识点:单例模式与双重检查锁 ​

【题目】下列关于 Java 单例模式双重检查锁(DCL)写法的说法,正确的是?

A. 只要方法上使用了 synchronized,实例变量就不需要 volatile B. DCL 中第一次判空是多余的,删掉后语义完全不变 C. 实例变量必须用 volatile 修饰,防止指令重排导致其他线程读到未初始化完成的对象 D. 单例类只要把类声明为 final 就一定线程安全,无需关注创建过程

答案:C

【考点】双重检查锁单例的正确写法与 volatile 的必要性。

【结论】选 C。DCL 中 instance 必须 volatile,避免 new 操作重排导致“半初始化”对象被发布。

【逐项辨析】

  • A 错误:synchronized 只保证临界区互斥与可见性,不能禁止 new 内部步骤(分配内存 → 初始化 → 赋值引用)的指令重排。
  • B 错误:第一次判空是无锁快速路径,删掉后每次获取都要进入 synchronized,性能显著下降。
  • C 正确:volatile 禁止相关重排,并保证写入对其他线程立即可见。
  • D 错误:final 只限制类不可被继承,与对象创建过程的线程安全无关。

【知识点】 经典 DCL 写法:

java
public class Singleton {
    private static volatile Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
写法特点线程安全
饿汉式类加载即创建是
懒汉 + 同步方法每次调用加锁是
DCL + volatile懒加载 + 低开销是
静态内部类利用类初始化锁是
枚举单例防反射与反序列化是

【记忆锚点】「DCL 两次判空一 volatile,new 不原子才要防重排。」

【易混对比】

  • 易混:synchronized 管互斥;volatile 管禁止重排 + 内存可见性。
  • 静态内部类单例利用 JVM 类初始化锁,写法更简洁。

【自测】 去掉 volatile 后,DCL 单例在多线程下可能出现什么问题?

答:线程 B 可能看到非 null 但未初始化完成的对象,读到默认值字段。与 J80/J82 连考。

【知识关联】

  • 同库关联:与 J54(volatile 可见性与禁止重排)、J53(synchronized 互斥)、J19(static 成员与类初始化)、J82(锁升级)同属并发基础题群;与 S16(Bean 作用域,默认 singleton)对照;S35/S36 讲的是 AOP 代理方式与自调用失效——Spring Bean 默认单例是「容器保证每容器一个实例」,不依赖 DCL。
  • 实现层:instance = new Singleton() 在字节码上可分为三步:① allocate 分配内存;② invokespecial 调用构造器初始化;③ putstatic 将引用写入 instance。JMM 允许把 ②③ 重排,其他线程可能看到非 null 但字段仍是默认值的「半初始化」对象。volatile 通过内存屏障禁止该重排,并建立写-读 happens-before。静态内部类单例则利用 JVM 的类初始化锁(clinit 只执行一次),天然懒加载且无需 volatile。
  • 面试追问:① 为什么饿汉式不需要 volatile?(类加载阶段由 ClassLoader 锁保证,不存在半初始化发布)② 枚举单例为何防反射与反序列化?(反射无法 new 枚举;序列化走 Enum.valueOf 而非新建)③ DCL 在 Java 5 之前为何不可靠?(旧内存模型下 volatile 语义不完整,JSR-133 后才可靠) 【拓展延伸】
  • 变式问法:① 给出去掉 volatile 的 DCL 代码,问可能现象;② 比较「DCL」「静态内部类」「枚举」三种写法的线程安全与懒加载;③ 问 Spring @Bean 默认作用域与 GoF 单例差异(容器级、可被 AOP 代理、随容器销毁)。
  • 版本差异:Java 5(JSR-133)后 DCL + volatile 语义正确;JDK 15+ 偏向锁默认关闭并不影响 DCL 正确性,只影响锁竞争性能;Java 14+ 枚举仍是《Effective Java》推荐的单例实现;record/Sealed 类不能直接做可变单例字段宿主,但不妨碍 static holder 写法。
  • 工程注意点:① 新代码优先枚举或静态内部类,少写 DCL;② Spring 环境直接注入 Bean,不要手写单例;③ 单例持有可变状态(缓存、计数器)仍需额外同步或用 ConcurrentHashMap/LongAdder;④ 分布式场景「单例」只保证单 JVM,跨进程要靠 Redis/ZooKeeper 等;⑤ 测试时注意单例静态状态污染,可用 @DirtiesContext 或设计无状态服务。

J79 · 知识点:工厂模式 ​

【题目】关于简单工厂与工厂方法模式,下列说法正确的是?

A. 简单工厂是 GoF 23 种设计模式中正式收录的一种 B. 工厂方法模式通过定义创建对象的抽象接口,将具体实例化延迟到子类,符合开闭原则 C. 简单工厂完全符合开闭原则,新增产品无需修改工厂类 D. 工厂模式的唯一作用是隐藏 new 关键字,与解耦无关

答案:B

【考点】简单工厂 vs 工厂方法:扩展性与是否符合开闭原则。

【结论】选 B。工厂方法把“创建什么”交给子类实现,新增产品通常只需新增工厂子类与产品类。

【逐项辨析】

  • A 错误:GoF 正式模式含工厂方法与抽象工厂;简单工厂不在 23 种正式名录中。
  • B 正确:Creator 定义 create() 抽象,ConcreteCreator 决定实例化哪个产品。
  • C 错误:简单工厂通常用 if/switch 分支,新增产品必须改工厂,违背开闭原则。
  • D 错误:工厂核心价值是解耦创建与使用、集中管理依赖,不只是“藏 new”。

【知识点】

模式结构扩展方式开闭原则
简单工厂一个工厂类 + 分支改工厂类否
工厂方法抽象工厂 + 每产品一工厂子类加工厂子类是
抽象工厂创建一系列相关产品族加工厂维度族内可扩展
java
interface Product { void use(); }
interface ProductFactory { Product create(); }
class MouseFactory implements ProductFactory {
    public Product create() { return new Mouse(); }
}

Spring 的 BeanFactory / @Bean / FactoryBean 是“容器化工厂”思想。

【记忆锚点】「简单工厂改分支,工厂方法加子类;抽象工厂管一族,开闭只在后者站得住。」

【易混对比】

  • 工厂方法(一种产品)vs 抽象工厂(一组相关产品)。
  • 静态工厂方法(如 Integer.valueOf)≠ 简单工厂模式。

【自测】 支付系统将增加多种支付方式,简单工厂还是工厂方法更合适?

答:工厂方法(或注册表式工厂),新增产品不必修改中心工厂。与 S02(自动配置 @Import 选择器)、S40(自动配置细节)连考。

【知识关联】

  • 同库关联:与 J16/J17(接口与 default,工厂依赖抽象)、J13(多态是工厂的运行期基础)、S01/S02/S40(Spring 自动配置可视为「容器化工厂 + 条件装配」)构成「抽象与创建」题群。设计模式题也常与 J22(equals/hashCode)一起考「产品对象比较」。
  • 实现层:工厂方法的 UML 核心是 Product + Creator 两套平行抽象;调用方只依赖 ProductFactory.create(),编译期不绑定具体产品类,符合依赖倒置(DIP)。简单工厂的 if/switch 把「产品类型 → 类」的映射写死在工厂内,新增产品必须改工厂源码并重新编译/发布。Spring 中 BeanFactory.getBean、@Bean 方法、FactoryBean.getObject 都是工厂思想的容器化:产品创建被委托给容器,再通过类型/名称/条件解析。静态工厂方法(如 Integer.valueOf、List.of)只是「返回实例的静态方法」,不构成 GoF 工厂模式。
  • 面试追问:① 工厂方法与抽象工厂的本质差别?(单产品等级 vs 产品族)② 工厂方法与模板方法如何协作?(模板方法定义算法骨架,在某步骤调用工厂方法创建对象)③ 项目中哪些地方真实用了工厂?(支付渠道、消息推送、解析器策略、多数据源路由是高频答案) 【拓展延伸】
  • 变式问法:① 给出类图判断是简单工厂/工厂方法/抽象工厂;② 给出一段 switch 创建支付渠道的代码,问违背了什么原则(开闭);③ 问「新增一种产品需要改哪些类」——工厂方法答「新增产品类 + 新增工厂子类」,简单工厂答「修改工厂类」。
  • 版本差异:GoF 1994 提出经典分类;Java 8+ 接口 default/static 丰富了产品与工厂抽象;现代 DI 容器(Spring/Guice)与 Supplier、ObjectProvider 大幅降低手写工厂频率;Java 21 record + sealed interface 可让产品等级更易穷举,便于在抽象工厂中做穷举分支。
  • 工程注意点:① 产品种类长期稳定、逻辑简单时,简单工厂/枚举分发足够,不必过度设计;② 产品经常新增、或创建过程复杂(连接池、重试、配置解析)时用工厂方法或注册表(Map<Class, Handler>);③ 与策略模式组合:工厂负责创建策略,策略负责行为;④ 避免「伪工厂」——工厂类里写满业务 if;⑤ 单测中用工厂替换为 mock/fake 产品是重要可测性手段。

J80 · 知识点:ThreadLocal 原理与内存泄漏 ​

【题目】关于 ThreadLocal,下列说法正确的是?

A. ThreadLocal 变量是多个线程共享的,使用时必须加锁 B. 每个线程通过 ThreadLocalMap 以 ThreadLocal 实例为 key 保存副本,key 是弱引用,用完应调用 remove() 防止内存泄漏与脏数据 C. 线程池场景下 ThreadLocal 不调用 remove 也没有任何风险 D. ThreadLocal 能保证同一时刻多个线程并行修改同一份数据

答案:B

【考点】ThreadLocal 线程封闭、ThreadLocalMap 结构与泄漏风险。

【结论】选 B。ThreadLocal 实现线程封闭;map 的 key 为弱引用,线程池复用时必须 remove()。

【逐项辨析】

  • A 错误:提供的是每线程独立副本,不是共享变量,通常无需加锁。
  • B 正确:Entry 的 key 是弱引用、value 是强引用;线程池中不 remove 可能泄漏并串数据。
  • C 错误:线程池放大风险——脏数据可能被下一个任务读到。
  • D 错误:目标是避免共享,不是并行改同一份数据。

【知识点】

  1. 结构:Thread.threadLocals → ThreadLocalMap → Entry[]。
  2. 典型用途:事务上下文、用户会话、SimpleDateFormat 线程封闭、traceId。
  3. 泄漏条件:线程存活 + key 被回收 + 未 remove + value 仍被强引用。
  4. InheritableThreadLocal:线程池提交时不会自动传递,需 TransmittableThreadLocal 等。

【记忆锚点】「ThreadLocal 不共享,每线程一份 Map;key 弱 value 强,线程池里必须 remove。」

【易混对比】

方案目标风险
synchronized/Lock共享数据互斥死锁、性能
volatile可见/有序不保证复合原子
ThreadLocal线程封闭泄漏、脏上下文

【自测】 线程池任务设置了 userId 且未 remove,下一个任务会怎样?

答:可能读到残留 userId;应在 finally 中 remove()。与 J81 连考。

【知识关联】

  • 同库关联:与 J51–J62(线程与线程池,尤其是 J58/J81 线程池复用)、J83(弱引用,ThreadLocalMap 的 key 正是 WeakReference)、J65(GC Roots 与可达性)紧密相连。Spring 的事务同步、SecurityContext、MDC 日志 traceId 都建立在 ThreadLocal 之上,故本题也与 事务题群实际是 S13/S14(传播与回滚)与 S31/S32(REQUIRES_NEW/NOT_SUPPORTED);S15–S20 是作用域、注入、AOP、定时任务与 Actuator,别当事务题群引用。
  • 实现层:每个 Thread 持有 ThreadLocal.ThreadLocalMap threadLocals;Map 的 Entry 继承 WeakReference<ThreadLocal<?>>,key 为 ThreadLocal 实例,value 为业务副本。get/set 时先取当前线程的 Map,再以 threadLocalHashCode 定位槽位。泄漏链路:线程池线程长期存活 → ThreadLocal 外部强引用消失后 key 被 GC 清空 → Entry 变成 key 为空、value 仍被强引用的脏槽 → 无法回收;若业务再 get 同一逻辑键,还可能读到旧 value(脏数据)。remove() 会把整条 Entry 置空,从数组中清除。
  • 面试追问:① 为何 key 用弱引用而 value 不用?(key 靠弱引用在 ThreadLocal 本身不可达时自动清空,避免 Map 膨胀;value 若也弱引用,业务还没读就可能被回收)② 父子线程如何传递上下文?(InheritableThreadLocal 仅在线程创建时复制;线程池复用线程不会触发 inherit,需 TransmittableThreadLocal 或手动传递)③ ThreadLocal 与 synchronized 如何选?(能封闭则封闭:无共享就无锁) 【拓展延伸】
  • 变式问法:① 给出线程池 + ThreadLocal 未 remove 的代码,问下一个任务读到什么;② 问 Entry 的 key/value 引用类型;③ 问「把数据库 Connection 放进 ThreadLocal 且线程池复用」的风险(连接泄漏、事务边界混乱、脏连接)。
  • 版本差异:ThreadLocalMap 与弱引用 key 的设计自 Java 早期以来稳定;JDK 8+ 无结构变化;阿里《Java 开发手册》明确要求在线程池场景必须 remove;JDK 21 虚拟线程下 ThreadLocal 仍可用,但大量虚拟线程各自持有大 value 会放大堆占用,推荐改用 ScopedValue(孵化特性)做只读上下文传递。
  • 工程注意点:① 统一封装 ContextHolder,在 filter/interceptor 的 finally 中 clear;② 禁止往 ThreadLocal 塞大对象(List、大 JSON);③ 监控线程池线程上的 ThreadLocal 数量,排查泄漏;④ 跨线程提交任务时显式捕获/回放上下文(装饰 Runnable);⑤ 与事务模板配合时注意:新线程/REQUIRES_NEW 挂起后,部分上下文是否仍应可见需要设计约定。

J81 · 知识点:线程池任务提交流程 ​

【题目】线程池 corePoolSize=2,maximumPoolSize=4,workQueue 容量为 3。当前已有 2 个核心线程且均在执行任务,此时提交 6 个新任务。下列描述正确的是?

A. 立即创建 4 个新线程执行其中 4 个任务,其余 2 个直接被拒绝 B. 6 个任务全部进入队列等待,不会创建非核心线程 C. 先创建非核心线程,队列放不下再入队(与官方流程相反) D. 先有 3 个任务入队,再创建 2 个非核心线程(总线程数达到 4),最后 1 个任务触发拒绝策略

答案:D

【考点】ThreadPoolExecutor 提交顺序:核心线程 → 入队 → 非核心线程 → 拒绝。

【结论】选 D。核心线程占满后先入队;队列满后再扩容到 maximumPoolSize;仍无法容纳则拒绝。

【逐项辨析】

  • A 错误:不会绕过队列直接建满线程,也不会在队列还能容纳时优先拒绝。
  • B 错误:队列容量仅 3,后续任务会尝试创建非核心线程。
  • C 错误:官方 execute 流程是“先队列、后扩容”。
  • D 正确:前 3 个任务入队(队列满);第 4、5 个各创建 1 个非核心线程;第 6 个触发拒绝策略。

【知识点】ThreadPoolExecutor.execute(task) 标准流程:

  1. workerCount < corePoolSize → 创建核心线程立即执行;
  2. 否则 workQueue.offer(task);
  3. 入队失败且 workerCount < maximumPoolSize → 创建非核心线程;
  4. 否则执行拒绝策略。

与 J58(七大参数)、J59(拒绝策略)连起来记。

【记忆锚点】「核心满了先排队,队列满了再扩非核心,扩到 max 才拒绝。」

【易混对比】

  • newFixedThreadPool 使用无界队列,几乎走不到扩容与拒绝(直到 OOM)。
  • CallerRunsPolicy:拒绝时让提交线程自己跑,反压调用方。

【自测】 若 workQueue 无界,提交 6 个新任务会怎样?

答:全部入队,不扩非核心、不拒绝;可能堆积 OOM。与 J58/J59 连考。

【知识关联】

  • 同库关联:与 J58(七大参数)、J59(四种拒绝策略)、J51(线程创建)、J80(线程池复用带来的 ThreadLocal 风险)构成线程池核心题群;JDK21 虚拟线程(JEP 444)提供新选项,但 ThreadPoolExecutor 提交语义仍是笔试面试主考点。
  • 实现层:ThreadPoolExecutor.execute 源码逻辑可概括为四步:① workerCountOf(c) < corePoolSize → addWorker(command, true) 创建核心线程;② workQueue.offer(command) 成功 → 再次检查线程数,防止池已关闭时任务滞留;③ addWorker(command, false) 扩容到 maximumPoolSize;④ 失败则 reject(command)。本题:已有 2 核心且忙碌 → 6 个新任务的前 3 个 offer 进入容量 3 的队列(队列满)→ 第 4、5 个 addWorker(..., false) 创建 2 个非核心线程(总数 4)→ 第 6 个 offer 失败且已到 max → 拒绝策略。无界队列(如 LinkedBlockingQueue 无容量参数)会让 ② 永远成功,线程数被卡在 core,堆积直至 OOM。
  • 面试追问:① 为何官方是「先入队再扩容」而不是相反?(核心线程贵,队列是廉价缓冲;避免突发流量打满线程)② CPU 密集与 IO 密集如何配参数?(CPU:N+1;IO:N×(1+等待/计算))③ 如何监控?(getActiveCount/getQueue.size/getCompletedTaskCount + 拒绝计数) 【拓展延伸】
  • 变式问法:① 改 core/max/queue 数值,问最终线程数与拒绝数;② 问「队列无界时扩容还会发生吗」(不会);③ 给出 CallerRunsPolicy 场景问提交线程行为(自己执行任务,形成反压)。
  • 版本差异:ThreadPoolExecutor 自 Java 5(JUC)语义稳定;Java 8 增加 CompletableFuture 异步编排;Java 21 虚拟线程可用 Executors.newVirtualThreadPerTaskExecutor(),适合大量短 IO 任务,不再依赖庞大平台线程池;但经典池参数计算仍是社招/校招高频题。
  • 工程注意点:① 阿里规约禁止 Executors.newFixedThreadPool/newCachedThreadPool 等快捷工厂直接用于生产(无界队列/无界线程风险);② 必须显式 core/max/queue/rejectedHandler/threadFactory;③ 线程池隔离:不同业务用不同池,避免慢任务拖垮主链路;④ 关闭时 shutdown + awaitTermination,防止任务丢失;⑤ 结合监控告警队列积压,必要时动态调参。

J82 · 知识点:synchronized 锁升级 ​

【题目】JDK 6 及以后,synchronized 锁的升级路径通常是?

A. 重量级锁 → 轻量级锁 → 偏向锁 → 无锁 B. 无锁 → 重量级锁 → 轻量级锁 → 偏向锁 C. 偏向锁 → 无锁 → 重量级锁 → 轻量级锁 D. 无锁 → 偏向锁 → 轻量级锁 → 重量级锁

答案:D

【考点】HotSpot 对 synchronized 的锁优化:偏向锁、轻量级锁、重量级锁。

【结论】选 D。典型升级路径为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。(版本口径:这条链描述 JDK 6~11 的 HotSpot。JDK 15 起偏向锁默认关闭(JEP 374),JDK 18 起彻底移除,在 15+ 上实际路径是无锁 → 轻量级锁 → 重量级锁;题干写「JDK 6 及以后」,按教材与笔试主流口径仍选 D。)

【逐项辨析】

  • A 错误:方向完全颠倒。
  • B 错误:轻量级锁不应出现在重量级之后又退回。
  • C 错误:不存在“偏向锁→无锁”的常规升级路径。
  • D 正确:与 HotSpot mark word 状态流转一致。

【知识点】 JDK 6 之后 HotSpot 对 synchronized 做了锁升级优化,目标是「无竞争几乎零成本、轻度竞争用户态解决、真正冲突才进内核」。升级路径为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。

  1. 无锁:对象刚分配,Mark Word 存 hash/分代年龄,未被任何线程锁定。
  2. 偏向锁:第一个进入同步块的线程,用 CAS 把自己的线程 ID 写入 Mark Word;后续同一线程再进入时只需比对 ID,几乎无同步开销。适合单线程反复进入的场景。JDK 15(JEP 374)起默认关闭,因为无竞争场景收益有限,而启动时的偏向撤销/批量重偏向仍有代价。
  3. 轻量级锁:出现少量线程交替进入(无真正并行争抢)时,JVM 在当前线程栈帧中建立 Lock Record,将 Mark Word 拷入并 CAS 将对象头指向该 Record;失败则自旋重试。自旋次数由 JVM 自适应调节,避免无谓空转。
  4. 重量级锁:自旋失败、竞争激烈时膨胀为 ObjectMonitor(依赖操作系统 mutex/futex)。未抢到锁的线程进入 EntryList 阻塞,由内核调度唤醒,涉及用户态/内核态切换与上下文切换,吞吐显著下降。
  5. 降级与例外:一般只升不降(GC 等 safepoint 可能触发批量撤销/重偏向);wait/notify 必须在重量级锁的 Monitor 上工作;volatile、CAS 原子类并不走这套锁状态机。 记忆时抓住动机:能 CAS 就不进内核,能偏向就完全免 CAS。

【记忆锚点】「无锁偏向轻量重,竞争越大锁越重;用户态里先自旋,抢不动了才阻塞。」

【易混对比】

锁状态适用场景开销
偏向锁单线程反复进入最低
轻量级锁交替、少冲突中
重量级锁高并发真冲突最高
  • volatile 不是锁,不走升级路径。

【自测】 为何高并发下过度 synchronized 可能导致吞吐下降?

答:膨胀为重量级锁后阻塞/唤醒与上下文切换开销大。与 J42/J53 连考。

【知识关联】

  • 同库关联:与 J53(synchronized 锁对象)、J54(volatile 不是锁)、J60(CAS 与原子类)、J42(ConcurrentHashMap 分段/桶锁)、J78(DCL 依赖 volatile 而非锁升级)构成并发锁优化题群。
  • 实现层:HotSpot 在对象头 Mark Word 中记录锁状态:无锁/偏向 → 轻量级 → 重量级位模式不同。偏向锁在 Mark Word 记录线程 ID 与 epoch,进入时 CAS 比对;轻量级锁把 Mark Word 指向线程栈中的 Lock Record,自旋 CAS 争抢;重量级锁膨胀为 ObjectMonitor(含 _owner/_EntryList/_WaitSet),依赖操作系统 mutex/futex,失败线程进入阻塞。升级动机是「尽量在用户态用 CAS 解决,真正冲突才付内核态阻塞代价」。锁可以膨胀但一般不降级。JDK 15 JEP 374 默认禁用偏向锁,因为无竞争场景收益下降而启动/同步开销仍在。
  • 面试追问:① 偏向锁默认关闭后对代码有何影响?(语义无变化,仅无竞争 synchronized 的微优化消失)② 自旋次数如何决定?(Adaptive Self-Spinning,JVM 根据历史成功率调整)③ synchronized 与 ReentrantLock 如何选?(可中断/超时/公平/多条件队列需要时用 Lock) 【拓展延伸】
  • 变式问法:① 给出 Mark Word 位图问当前锁状态;② 问「无锁→重量级→轻量级」是否可能(非常规路径,基本不会作为正确选项);③ 问 volatile 是否经历锁升级(否,volatile 根本不是锁)。
  • 版本差异:JDK 6u23+ 引入偏向锁/轻量级锁成熟实现;JDK 8 仍是面试最常引用的版本基线;JDK 15+ 偏向锁默认关,后续版本相关代码继续精简;JDK 21 虚拟线程与 synchronized 交互需注意 pinning(JEP 491 之前),推荐用 ReentrantLock 或确保虚拟线程友好。
  • 工程注意点:① 不要为「锁升级」去重构代码,先 profiling;② 缩小同步块、减小锁粒度、读写分离比依赖 JVM 锁优化更有效;③ 高竞争计数用 LongAdder;④ 集合并发用 ConcurrentHashMap 而非全局锁 HashMap;⑤ 线上 CPU 高时用 jstack 看 BLOCKED 热点,而不是只猜锁膨胀。

J83 · 知识点:Java 四种引用 ​

【题目】关于 Java 的四种引用类型,下列说法正确的是?

A. 软引用(SoftReference)适合缓存:内存充足时保留,内存不足时才可能被回收 B. 强引用对象无论是否可达,GC 都一定会立即回收 C. 弱引用(WeakReference)对象必须等 Full GC 才可能被回收 D. 虚引用(PhantomReference)可以直接 get() 出对象并安全使用

答案:A

【考点】强/软/弱/虚引用的回收时机与典型用途。

【结论】选 A。软引用常用于内存敏感缓存:内存够则保留,不足时 GC 可回收。

【逐项辨析】

  • A 正确:SoftReference 的标准用途与回收语义。
  • B 错误:强引用对象在仍可达时不会被回收;“一定会立即回收”完全错误。
  • C 错误:弱引用在下一次 GC 发现不可达即可回收,不要求 Full GC。
  • D 错误:PhantomReference.get() 总是返回 null;虚引用用于跟踪回收时机(配合 ReferenceQueue)。

【知识点】

引用类型类回收时机典型用途
强引用普通赋值失去可达性才回收默认业务对象
软引用SoftReference内存不足时回收缓存
弱引用WeakReference下次 GC 即可回收ThreadLocalMap key、WeakHashMap
虚引用PhantomReference随时可回收,须配 ReferenceQueue跟踪回收、堆外内存释放

【记忆锚点】「强用才活着,软靠内存脸,弱在下次 GC 见,虚只排队不露面。」

【易混对比】

  • 软引用 vs 弱引用:软“内存紧张才走”,弱“GC 一来就可能走”。
  • ThreadLocalMap 的 Entry key 正是弱引用(呼应 J80)。

【自测】 图片内存缓存选哪种引用?监听对象何时被 GC 选哪种?

答:缓存选软引用(或 Caffeine 等);监听回收选虚/弱引用 + ReferenceQueue。与 J80/J65 连考。

【知识关联】

  • 同库关联:与 J65(GC Roots 可达性分析)、J64(堆分区与回收)、J80(ThreadLocalMap 的 WeakReference key)、J66/J67(GC 算法与 G1)构成 JVM 内存题群。软/弱引用是连接「语言层 API」与「GC 行为」的桥梁考点。
  • 实现层:可达性分析从 GC Roots(栈局部变量、静态字段、JNI、活跃线程等)出发;引用对象本身是 java.lang.ref.Reference 子类,其指示对象(referent)的可达性被弱化。回收判定并非简单「软引用就删」:SoftReference 通常在内存不足、即将 OOM 前的 GC 中被回收,不同收集器策略略有差异;WeakReference 在下一次 GC 时若发现仅被弱引用可达即可回收;PhantomReference.get() 恒为 null,必须与 ReferenceQueue 联用,以便在对象已终结/已回收时收到通知,Netty/Cleaner 堆外内存释放即此模式。
  • 面试追问:① WeakHashMap 适用什么场景?(元数据、监听器注册表,避免缓存拖住 key)② 软引用缓存有何缺点?(回收时机不确定、可能在压力下集中失效造成穿透,生产更常用 Caffeine)③ 强引用对象何时会被回收?(不可达时,与是否「刚用完」无关) 【拓展延伸】
  • 变式问法:① 给出 SoftReference + 循环内分配的代码,问 OOM 风险;② 问「缓存图片用哪种引用」;③ 问「监听对象何时被 GC」的 API 组合(Phantom/Weak + ReferenceQueue)。
  • 版本差异:四种引用自 Java 1.2 引入;Java 9 将 Finalizer 标记为 deprecated,推荐 java.lang.ref.Cleaner;Java 21 仍可用软/弱引用,但 ZGC/Shenandoah 等低延迟收集器下软引用回收策略更激进或可调,缓存应依赖明确容量/过期策略而非软引用「碰运气」。
  • 工程注意点:① 生产缓存选 Caffeine/Guava Cache(基于弱/软键 + 过期 + 统计),不要裸用 SoftReference;② 注册回调/监听时用 WeakReference 防泄漏;③ 堆外/Direct Buffer 清理用 Cleaner 或 Netty 的引用计数;④ 排查泄漏时 MAT/OQL 可过滤 SoftReference/WeakReference 实例;⑤ 不要在业务关键路径上依赖「GC 可能很快回收」来做资源释放。

J84 · 知识点:NIO Buffer 核心操作 ​

【题目】向 NIO 的 Buffer 写入数据后,若要开始从中读取数据,必须调用的方法是?

A. flip() B. clear() C. compact() D. rewind() 与 clear() 同时调用

答案:A

【考点】Buffer 读写模式切换:position/limit/capacity 与 flip()。

【结论】选 A。写完调用 flip() 将 limit 置为当前 position、position 置 0,切换到读模式。

【逐项辨析】

  • A 正确:flip() = limit = position; position = 0;。
  • B 错误:clear() 重置为“准备重新写入”,不是写转读。
  • C 错误:compact() 用于部分读取后继续写。
  • D 错误:无此标准用法;rewind() 只把 position 置 0,不收紧 limit。

【知识点】

方法作用
put()写入,position++
flip()写→读:limit=position, position=0
get()读取,position++
clear()清空状态以便重写
compact()压缩未读数据,继续写
java
buffer.clear();
while (channel.read(buffer) > 0) {
    buffer.flip();
    while (buffer.hasRemaining()) {
        out.write(buffer.get());
    }
    buffer.clear();
}

【记忆锚点】「写完 flip 读,读完 clear 写;compact 半读继续填,rewind 从头再看。」

【易混对比】

  • flip vs clear:前者切到读,后者切到(重新)写。
  • flip vs rewind:flip 会收紧 limit;rewind 不改 limit。

【自测】put 后不 flip 直接 channel.write(buffer) 可能怎样?

答:可写区间错误/写出 0 字节。应先 flip。与 J77 连考。

【知识关联】

  • 同库关联:与 J77(NIO 三组件)、J85(BIO/NIO 对比)、J76(字节/字符流)同属 IO 题群;Buffer 状态机也是理解 Netty ByteBuf(读写双指针,无需 flip)的前置知识。
  • 实现层:java.nio.Buffer 核心字段是 position(下一个要读/写的位置)、limit(第一个不可读/写的下标)、capacity(容量)。写模式:从 0 写到 position;flip() 做 limit = position; position = 0; 切到读模式。clear() 做 position = 0; limit = capacity;,逻辑清空以便重写(不擦数据)。compact() 把未读完数据拷到缓冲区头部,position 置为数据末尾、limit 置 capacity,适合「读了一半又要继续写」。rewind() 仅 position = 0,limit 不变,用于重复读。堆缓冲(HeapByteBuffer)受 JVM 管理;直接缓冲(DirectByteBuffer)分配在堆外,channel.read/write 可能减少一次拷贝。
  • 面试追问:① 为什么设计 flip 而不是自动切换?(显式状态机,避免隐式语义;也让批量 IO 代码路径清晰)② hasRemaining 与 remaining 含义?(还有没有可读字节 / 剩余数量)③ ByteBuf 如何避免 flip?(独立 readerIndex/writerIndex) 【拓展延伸】
  • 变式问法:① 给出 capacity=10,put 3 个字节后问 position/limit,再问 flip 后数值(position=0, limit=3);② 问 clear() 是否清空数组内容(否,只重置下标);③ 问何时用 compact(读未完成又需继续填充)。
  • 版本差异:Buffer 语义自 JDK 1.4 起稳定;JDK 9+ 讨论过更好的 Buffer API,但核心三下标模型未变;Netty 4.x ByteBuf 成为网络框架事实标准;Java 22+ Foreign Memory API(MemorySegment)在新式本地内存访问上提供更安全的替代,但经典 NIO 题仍考 position/limit/capacity。
  • 工程注意点:① 手写 NIO 务必成对:clear/compact 后 read,flip 后 write,漏调会导致 0 字节或重复处理;② 复用 Buffer 对象时注意 limit 残留;③ 大文件优先 MappedByteBuffer/transferTo,而非手动循环;④ 排查「写出为空」先检查是否 flip;⑤ 框架层直接用 ByteBuf/Netty,业务层很少裸操作 Buffer。

J85 · 知识点:BIO 与 NIO 对比 ​

【题目】相比传统 BIO,NIO 在高并发网络编程中的主要优势是?

A. NIO 的 API 更简单,学习成本更低 B. NIO 一定是同步阻塞模型,吞吐优于 BIO C. NIO 只能用于文件读写,不能用于网络 D. 基于 Selector 多路复用,少量线程即可管理大量连接,减少线程切换与内存开销

答案:D

【考点】BIO 一连接一线程 vs NIO 非阻塞 + 多路复用。

【结论】选 D。NIO + Selector 用少量线程监听大量 Channel,显著降低线程数量与切换成本。

【逐项辨析】

  • A 错误:NIO 组件模型比 BIO 更复杂。
  • B 错误:NIO 核心是非阻塞 + 多路复用;“同步阻塞”描述的是 BIO。
  • C 错误:NIO 同时覆盖文件与网络。
  • D 正确:这是 NIO 服务端的典型价值。

【知识点】

维度BIONIO
模型同步阻塞同步非阻塞 + 多路复用
线程1 连接 ≈ 1 线程少量线程管大量连接
编程难度低高
适用连接少、逻辑简单高连接、IO 密集

NIO 仍属同步 IO;Selector 空轮询是经典坑,Netty 有修复手段。

【记忆锚点】「BIO 一线程一连接,NIO 一选择器盯一片;难在编程,赢在并发。」

【易混对比】

  • 非阻塞 ≠ 异步。
  • 多线程 BIO 只能缓解,连接上万后模型瓶颈仍在。

【自测】 为何 10 万长连接通常不用 BIO + 大线程池?

答:线程内存与切换成本不可接受,且大量线程阻塞空耗;NIO 更可控。与 J77/J84 连考。

【知识关联】

  • 同库关联:与 J76(BIO 字符/字节流)、J77(NIO 三组件)、J84(Buffer 操作)构成 IO 对照三连;与 J58/J81(线程池参数与提交流程)连考——BIO 高并发下往往误用巨大线程池。JDK21 虚拟线程话题常与本题一起出现在「模型演进」追问中。
  • 实现层:BIO 的 accept/read/write 都是阻塞系统调用,每个连接绑一个线程时,线程栈(默认 1MB)与上下文切换成本随连接数线性上升,且大量线程阻塞在 read 上空耗调度。NIO 用 Selector 一次等待多路 Channel 就绪(Linux epoll),事件循环线程只处理就绪连接,再把业务甩给少量 worker。NIO 仍是同步模型,但"同步"与"阻塞"是两个正交的维度:这里的"同步"指数据由调用线程自己从内核缓冲区搬进 Buffer(区别于 AIO 由内核搬完后通知),而 configureBlocking(false) 的 Channel 在无可读数据时 read() 不会阻塞,直接返回 0(JDK 17 实测:连接已建立、对端未发送时 ch.read(buf) 返回 0,调用立即可返回),所以要靠循环轮询或 Selector 等就绪事件驱动,而不是"read 仍可能短暂阻塞"。真正异步的是 AIO(AsynchronousSocketChannel,读由内核完成后经 Future/回调交付结果)或 io_uring 一类接口。Selector 空轮询是经典线上坑,Netty 通过计数重建 selector 规避。
  • 面试追问:① 为何说 NIO 是同步非阻塞?② 10 万连接网关选型?③ 虚拟线程是否意味着 NIO 过时?(降低手写 NIO 必要性,但 Netty 等多路复用栈仍主流) 【拓展延伸】
  • 变式问法:① 问「适合网关/IM 长连接的 IO 模型」;② 对比 select/poll/epoll 的连接数上限与时间复杂度;③ 问 AIO/IOCP 与 NIO 的差异(内核完成通知 vs 就绪通知)。
  • 版本差异:BIO 自 JDK 1.0;NIO JDK 1.4;NIO.2/AIO JDK 7;JDK 21 虚拟线程(JEP 444)让「thread-per-connection + 阻塞 IO」重新可行,适合大量并发 IO 的业务服务;但超高连接、统一事件驱动的基础设施(网关、MQ 客户端)仍广泛使用 Netty NIO。
  • 工程注意点:① 连接数 < 数百且模型简单时,BIO 线程池写法简单可靠;② 高并发网络层交给 Netty/spring-webflux/gRPC,而不是项目内手写 Selector;③ 注意连接数 ulimit、backlog、TIME_WAIT;④ 监控就绪事件延迟、队列长度与 GC;⑤ 文档中写清同步/非阻塞/异步术语,避免团队沟通歧义。

持续学习,持续积累。