⚒️JDK源码分析与数据结构基础
JDK源码分析
I. HashMap 与 ConcurrentHashMap 的底层实现
这是重灾区,一定要分 JDK 1.7 和 1.8 两个版本回答,体现深度。
HashMap
底层结构:
JDK 1.7: 数组 + 链表。
JDK 1.8: 数组 + 链表 + 红黑树。
- 转换条件: 当链表长度大于 8 且 数组长度大于 64 时,链表转为红黑树(查找从
O(n)优化为O(logn));当节点少于 6 时退化为链表。
- 转换条件: 当链表长度大于 8 且 数组长度大于 64 时,链表转为红黑树(查找从
扩容机制:
默认初始容量 16,加载因子 0.75。
当元素个数 > 容量 * 加载因子时,扩容为原来的 2倍。
线程安全性: 不安全。多线程下扩容可能导致死循环(1.7)或数据覆盖(1.8)。
ConcurrentHashMap (CHM)
JDK 1.7(分段锁):
结构: Segment 数组 + HashEntry 数组。
锁机制: Segment 继承自 ReentrantLock。将数据分成一段一段存储,给每一段数据配一把锁。
并发度: 默认 16(即支持 16 个线程并发写)。
JDK 1.8(CAS + synchronized):
结构: 与 HashMap 1.8 一致(数组 + 链表 + 红黑树)。
锁机制: 抛弃了分段锁。使用 CAS 添加新节点,使用 synchronized 锁定链表或树的首节点。
优势: 锁粒度更细(只锁当前桶),并发性能更高。
II. ArrayList 的底层实现
底层数据结构: 动态数组(Object[] elementData)。
初始容量: 默认为 10(注意:JDK 1.8+ 是懒加载,第一次 add 时才初始化数组)。
扩容机制:
当容量不足时,扩容为原来的 1.5 倍(oldCapacity + (oldCapacity >> 1))。
扩容本质是 System.arraycopy,将老数组元素复制到新数组,比较耗时。
特点:
支持随机访问(根据下标查询),时间复杂度 O(1)
。
插入/删除效率低(因为要移动后续元素),时间复杂度 O(n)
。
线程安全: 不安全。并发场景建议使用 CopyOnWriteArrayList。
III. 哈希冲突(Hash Collision)怎么解决?
面试官通常想听前两种,特别是第一种。
链地址法(拉链法 - Chaining):
原理: 数组的每个槽位指向一个链表,所有哈希值相同的元素都存在链表中。
应用: Java 的 HashMap 就是用的这种(1.8 后链表过长转红黑树)。
开放寻址法(Open Addressing):
原理: 发生冲突时,去寻找下一个空的槽位。常见方式有线性探测(+1, +2...)或二次探测。
应用: Java 的 ThreadLocalMap。
再哈希法(Re-Hashing):
- 准备多个哈希函数,第一个冲突了就用第二个,直到不冲突为止。
公共溢出区法:
- 建立一个公共的溢出区(通常也是数组),凡是冲突的元素都扔进去。
IV. 其它
1. String、StringBuffer、StringBuilder 的区别?
String: 不可变字符序列(底层是 final char[],JDK9改为 byte[])。每次修改都会创建新对象,适合少量操作。
StringBuilder: 可变字符序列。线程不安全,效率最高。适合单线程大量字符串拼接。
StringBuffer: 可变字符序列。线程安全(方法加了 synchronized),效率较低。
2. == 和 equals 的区别?
==:
基本数据类型:比较值。
引用数据类型:比较内存地址。
equals:
默认情况(Object类):等价于 ==,比较内存地址。
重写后(如 String, Integer):比较对象的内容。
追问:为什么要重写 hashCode?
约定: 如果两个对象 equals 为 true,它们的 hashCode 必须相同。
后果: 如果只重写 equals 不重写 hashCode,放在 HashMap 中会无法根据 Key 找到 Value(因为 HashMap 先算 Hash 确定位置)。
3. 接口(Interface)和抽象类(Abstract Class)的区别?
语法层面:
接口只能有 public abstract 方法(JDK 8+ 可以有 default/static 方法),变量只能是 public static final 常量。
抽象类可以有普通方法和普通成员变量。
设计层面:
接口是对行为的抽象(Can-Do,比如“会飞”)。支持多实现。
抽象类是对事物的抽象(Is-A,比如“是鸟”)。只能单继承。
并发编程(JUC)基础
I. volatile 关键字的作用?
保证可见性: 一个线程修改了变量,其他线程立刻可见(强制刷回主内存)。
禁止指令重排序: 保证代码执行顺序,防止编译器/CPU 乱序执行(经典案例:单例模式的双重检查锁 DCL)。
注意: volatile 不保证原子性(比如 count++ 操作即使加了 volatile 也是不安全的)。
II. 线程池的 7 个核心参数(ThreadPoolExecutor)?
这是必背题,顺序不能乱:
corePoolSize: 核心线程数(常驻线程)。
maximumPoolSize: 最大线程数。
keepAliveTime: 非核心线程空闲存活时间。
unit: 时间单位。
workQueue: 阻塞队列(存放任务的地方,如 ArrayBlockingQueue)。
threadFactory: 线程工厂(用于给线程命名)。
handler: 拒绝策略(队列满且线程数达到最大时怎么处理)。
AbortPolicy(默认):抛异常。
CallerRunsPolicy:谁调用的谁去执行(比如主线程)。
DiscardPolicy:直接丢弃。
DiscardOldestPolicy:丢弃队列里最老的。
III. ThreadLocal 是什么?有没有内存泄漏问题?
作用: 线程本地变量。每个线程有一份独立的副本,互不干扰(空间换时间)。
底层: 每个 Thread 内部维护了一个 ThreadLocalMap。Key 是 ThreadLocal 实例本身,Value 是存储的值。
内存泄漏问题:
原因: ThreadLocalMap 的 Key 是弱引用,Value 是强引用。如果 ThreadLocal 对象被回收,Key 变成 null,但 Value 还在,且被线程持有,导致 Value 无法回收。
解决: 使用完必须手动调用 remove() 方法。
Java IO 模型
I. BIO、NIO、AIO 的区别?
BIO (Blocking I/O - 同步阻塞):
- 模型: 传统 IO,一个连接一个线程。
- 流程: 客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。
- 场景: 适用于连接数目比较小且固定的架构。
NIO (Non-blocking I/O - 同步非阻塞):
- 模型: 多路复用。一个线程处理多个连接。
- 核心: Selectors(选择器)、Channels(通道)、Buffers(缓冲区)。
- 流程: 客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求时才启动线程处理。
- 场景: 适用于连接数目多且连接比较短(轻操作)的架构,如聊天服务器、弹幕系统、Netty。
AIO (Asynchronous I/O - 异步非阻塞):
- 模型: Proactor 模式。
- 流程: 读写操作由操作系统完成后,再回调通知应用。
- 场景: 适用于连接数目多且连接比较长(重操作)的架构。
II. 什么是 IO 多路复用?
- 概念: 单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流。
- 底层实现 (Linux):
- select: 轮询方式,监视文件描述符。缺点:最大连接数受限 (1024),轮询效率低 O(N)。
- poll: 也是轮询,虽然解决了连接数限制(链表存储),但效率依然是 O(N)。
- epoll: 事件驱动。只管“活跃”的连接,无需遍历所有连接。效率 O(1)。是目前 Linux 下最高效的 IO 模型。
III. 零拷贝 (Zero Copy) 是什么?
- 传统 IO: 数据需要在用户态和内核态之间多次全量拷贝 (磁盘 -> 内核 -> 用户 -> 内核 -> 网卡),CPU 消耗大。
- 零拷贝: 减少上下文切换和数据拷贝次数。
- mmap (内存映射): 将文件映射到内核缓冲区,用户空间共享这块内存,减少一次拷贝。
- sendfile: 数据直接从内核缓冲区复制到 Socket 缓冲区 (或利用 DMA 直接传给网卡),完全不需要经过用户态。
- 应用: Kafka、Netty 极其依赖零拷贝技术来提升吞吐量。