八股基础-JAVA
前言
感觉八股背诵的针对性不强,来对简历里面已写的进行一个排查。
专业技能描述
掌握 Java 语法,熟悉集合、反射、IO,了解 JUC 并发编程,熟悉 JVM 垃圾回收、内存模型,熟悉线程
池原理,掌握常⻅的设计模式。
内容
集合
HashMap
的底层实现原理是什么?JDK 1.8 做了哪些重要优化?HashMap
是线程安全的吗?如果不安全,有哪些替代方案?原理:
数组 + 链表/红黑树
。通过key
的hashCode()
计算哈希值,再通过扰动函数(高 16 位异或低 16 位)和(n-1) & hash
确定数组下标。哈希冲突时,JDK 1.7
采用头插法形成链表,JDK 1.8
采用尾插法。当链表长度超过阈值(默认 8)且
数组长度 >= 64 时,链表转化为红黑树(提升查找效率);当红黑树节点数小于阈值(默认 6)时,退化为链表。JDK 1.8 优化:
数据结构: 引入红黑树,解决长链表查询效率低的问题(O(n) -> O(log n))。
插入方式: 链表由头插法改为尾插法(避免多线程扩容时可能的死循环问题,虽然 HashMap 本身线程不安全)。
扩容机制: 优化了 resize()方法逻辑,利用高位 1/0 直接确定新位置(原位置或原位置+旧容量),避免重新计算哈希。
hash() 计算简化: 直接用 key.hashCode()的高 16 位异或低 16 位作为扰动,计算更简单。
线程安全:
HashMap
不是线程安全的。替代方案:Hashtable
:古老,所有方法加synchronized
,性能差。Collections.synchronizedMap(Map m)
:包装器,性能也较差。ConcurrentHashMap
(推荐): JDK 1.7 使用分段锁(Segment),JDK 1.8 改为synchronized + CAS 锁桶
(Node 数组的头节点),锁粒度更细,并发度更高。读操作通常无锁(volatile 保证可见性)。
ConcurrentHashMap
在 JDK 1.7 和 JDK 1.8 的实现有什么区别?JDK 1.7: 采用 分段锁 (
Segment
) 机制。整个Map
被分成多个Segment
(默认 16 个),每个Segment
是一个独立的ReentrantLock
。put
操作只锁住目标Segment
,get
操作通常不加锁(使用volatile
变量保证可见性)。size
操作需要尝试不加锁统计两次,如果两次结果一致则返回,否则锁住所有Segment
再统计。JDK 1.8: 摒弃了
Segment
,采用synchronized
+CAS
+volatile
实现。数据结构与HashMap 1.8
类似(数组+链表/红黑树)。锁的粒度是桶(数组元素/链表头节点/树根节点)。put
操作:如果桶为空,用
CAS
尝试写入头节点。如果桶不为空(链表或树),则
synchronized
锁住该桶的头节点进行操作。get
操作无锁(依赖volatile
读)。size
使用LongAdder
思想(baseCount + CounterCell[]
)统计,避免锁竞争。
核心区别: 锁粒度(Segment vs 桶)、锁实现(ReentrantLock vs synchronized)、并发度(受 Segment 数量限制 vs 理论上与桶数量一致)、数据结构(1.7 桶只有链表 vs 1.8 链表/红黑树)。
反射
什么是 Java 反射机制?反射的主要优缺点是什么?如何获取一个类的 Class 对象?
概念: 反射机制允许程序在运行时动态加载类、获取类的信息(属性、方法、构造器等)、创建对象、调用方法、访问/修改字段。核心 API 在 java.lang.reflect 包中(Class, Field, Method, Constructor)。
优点:
灵活性/动态性: 可以在运行时根据条件动态加载和操作类,实现框架、动态代理等。
代码通用性: 可以编写通用的工具类(如序列化、BeanUtils)。
缺点:
性能开销: 反射操作比直接调用慢很多(JIT 无法优化,方法调用需要检查访问权限等)。
安全性问题: 可以访问和修改私有成员,破坏了封装性。
代码可读性和维护性降低: 反射代码通常比较晦涩。
获取 Class 对象的 3 种常见方式:
Class.forName("全限定类名")
(常用,需要处理ClassNotFoundException
)。对象.getClass()
(如:”hello”.getClass())。类名.class
(如:String.class)。这种方式最安全,性能最好,在编译时检查。
IO
Java 中的 BIO、NIO、AIO 有什么区别?谈谈你对 NIO 核心组件(Channel, Buffer, Selector) 的理解。
区别:
BIO
(Blocking I/O, 同步阻塞): 一个连接对应一个线程。线程发起 I/O 请求后,会一直阻塞等待数据就绪和数据拷贝完成。模型简单,但线程开销大,并发连接数受限。适用于连接数少且固定的场景。NIO
(Non-blocking I/O / New I/O, 同步非阻塞): 核心是 Selector、Channel 和 Buffer。线程将 Channel 注册到 Selector 上,Selector 轮询这些 Channel。当某个 Channel 上有事件(如连接就绪、读就绪、写就绪)时,Selector 通知线程处理。一个线程可以处理多个连接(多路复用)。线程在数据就绪前可以做其他事(非阻塞),但数据就绪后的数据拷贝过程(从内核到用户空间)仍然是同步/阻塞的。AIO
(Asynchronous I/O, 异步非阻塞): 应用发起 I/O 操作(如 read)后立即返回,操作系统完成整个 I/O 操作(包括数据就绪和数据拷贝)后,再通知应用处理结果(通过回调函数或 Future)。真正的异步。在 Linux 上底层基于 epoll,但实现不成熟,应用较少。
NIO 核心组件:
Channel
(通道): 数据的双向传输通道(区别于流的单向)。代表与实体(文件、Socket 等)的连接。常见的有 FileChannel, SocketChannel, ServerSocketChannel, DatagramChannel。Buffer
(缓冲区): 数据的载体。本质是一个数组。读写数据都需要经过 Buffer。核心属性:capacity(容量), position(当前位置), limit(可操作上限), mark(标记)。读写模式切换:flip() (写->读), clear() (读->写, 不清数据), compact() (读->写, 保留未读数据)。Selector
(选择器): NIO 多路复用的核心。一个 Selector 可以监听多个 Channel 上的事件(OP_ACCEPT, OP_CONNECT, OP_READ, OP_WRITE)。线程调用 select() 方法阻塞等待事件发生(也可设置超时或非阻塞),然后通过 selectedKeys() 获取发生事件的 SelectionKey 集合进行处理。
JUC
volatile
关键字有什么作用?它的底层实现原理是什么?它能保证原子性吗?作用:
保证可见性: 当一个线程修改了 volatile 变量的值,新值会立即刷新到主内存。其他线程读取该变量时,会强制从主内存重新读取最新值。
禁止指令重排序: 编译器、运行时和处理器会进行指令重排序优化。volatile 通过插入内存屏障 (Memory Barrier) 来禁止特定类型的重排序。
底层原理 (内存屏障):
写屏障 (Store Barrier / StoreStore Barrier / StoreLoad Barrier): 在 volatile 写操作之前和之后插入屏障。确保写操作前的所有普通写对其他线程可见(刷新到主存),并防止写屏障前的指令与 volatile 写重排序。
读屏障 (Load Barrier / LoadLoad Barrier / LoadStore Barrier): 在 volatile 读操作之前和之后插入屏障。确保 volatile 读之后的操作能读到最新值(清空本地缓存/无效化缓存行),并防止 volatile 读之后的指令重排序到读操作之前。
具体实现: 主要依赖 CPU 的 lock 指令前缀(如 x86 的 lock; addl $0x0, (%esp))或 MESI 缓存一致性协议来实现内存可见性。禁止重排序依赖 JVM 在编译器生成的字节码和 JIT 生成的机器码中插入屏障指令。
原子性: volatile 不能保证原子性。它只保证了单次读/写操作的原子性(long/double 的读写在 64 位 JVM 上也具有原子性),但对于复合操作(如 i++)无效。i++ 实际上是 read-modify-write 三步操作,volatile 无法保证这三步作为一个整体不被其他线程打断。需要原子性时,应使用 synchronized 或 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger,基于 CAS)。
synchronized
和ReentrantLock
有什么区别?本质:
synchronized
是 JVM 层面的关键字,依赖于底层监视器锁(Monitor)。ReentrantLock
是 JDK 层面的 API (java.util.concurrent.locks.Lock 接口的实现类)。使用:
synchronized
使用简洁(隐式加锁解锁)。ReentrantLock
需要显式调用 lock() 和 unlock()(必须在 finally 中解锁),更灵活但也更易出错。功能:
公平锁:
synchronized
只有非公平锁。ReentrantLock
可以指定为公平锁(new ReentrantLock(true))或非公平锁(默认)。等待可中断:
synchronized
等待不可中断(除非异常)。ReentrantLock
提供lockInterruptibly()
方法,允许线程在等待锁时响应中断。尝试获取锁:
ReentrantLock
提供tryLock()
方法(立即返回是否成功)和tryLock(long time, TimeUnit unit)(超时等待)
。条件队列 (
Condition
):synchronized
只关联一个隐式的等待/通知队列 (wait()/notify()/notifyAll())。ReentrantLock
可以关联多个Condition
对象(newCondition()),实现更精细的线程等待/唤醒控制(如生产者-消费者模型中区分空/满条件)。
性能: 在 JDK 1.6 及之后,
synchronized
经过大量优化(偏向锁、轻量级锁、适应性自旋锁、锁消除、锁粗化等),两者性能差距已很小,甚至synchronized
在某些场景下可能更优。选择时优先考虑synchronized
(简洁安全),需要高级功能时再用ReentrantLock
。
JVM
垃圾回收
常见的垃圾回收器有哪些?CMS 和 G1 的主要区别是什么?
常见回收器:
新生代: Serial, ParNew, Parallel Scavenge
老年代: Serial Old, Parallel Old, CMS
整堆: G1, ZGC, Shenandoah
CMS vs G1:
目标: CMS 目标是低停顿时间。G1 目标是可预测的停顿时间模型 (STW 停顿时间可控),同时兼顾高吞吐量。
区域划分: CMS 基于传统的 新生代 (Young Gen) + 老年代 (Old Gen) 物理连续划分。G1 将堆划分为多个大小相等的 Region (区域),逻辑上保留了 Eden、Survivor、Old 的概念,但物理上不要求连续。
算法: CMS 老年代收集使用 标记-清除 (Mark-Sweep),会产生碎片。G1 整体上是 标记-整理 (Mark-Compact) 算法(局部 Region 之间是复制算法),整体上减少了碎片问题。
工作过程:
CMS
: 1. 初始标记 (STW) -> 2. 并发标记 -> 3. 重新标记 (STW) -> 4. 并发清除。G1
: 1. 初始标记 (STW) -> 2. 并发标记 -> 3. 最终标记 (STW) -> 4. 筛选回收 (STW - Evacuation)。G1 的回收阶段(Evacuation)是选择回收价值最高(垃圾最多)的几个 Region 进行复制清理,控制每次停顿的时间。
适用场景: CMS 适用于对停顿敏感、老年代不太大的应用(关注服务响应)。G1 适用于大内存(>=6GB)、要求停顿可控、高吞吐的应用。CMS 在 JDK 9 被标记为废弃,JDK 14 中被移除。G1 是 JDK 9+ 的默认回收器。
内存模型
简述 Java 的内存区域(运行时数据区)。哪些区域是线程共享的,哪些是线程私有的?哪些区域会发生 OutOfMemoryError (OOM)?
内存区域 (JDK 8):
程序计数器 (Program Counter Register): 线程私有。记录当前线程执行的字节码指令地址(分支、循环、跳转、异常处理、线程恢复都依赖它)。唯一不会发生 OOM 的区域。
Java 虚拟机栈 (Java Virtual Machine Stacks): 线程私有。生命周期与线程相同。每个方法执行会创建一个栈帧 (Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法调用对应栈帧入栈,方法结束对应栈帧出栈。
局部变量表: 存放编译期可知的基本数据类型、对象引用 (reference)、returnAddress 类型。
可能发生的异常: StackOverflowError (线程请求的栈深度 > 虚拟机允许深度)、OutOfMemoryError (如果栈可动态扩展,扩展时无法申请到足够内存)。
本地方法栈 (Native Method Stack): 线程私有。为 JVM 调用本地 (native) 方法服务。规范未强制规定,HotSpot 将其与虚拟机栈合并。异常同虚拟机栈。
Java 堆 (Java Heap): 线程共享。JVM 管理的最大一块内存。唯一目的就是存放对象实例和数组(“几乎”所有对象都在堆上分配)。是垃圾收集器管理的主要区域 (GC Heap)。可以物理上不连续,但逻辑上连续。可细分为 Eden、Survivor (S0, S1)、老年代。
- 可能发生的异常: OutOfMemoryError (堆中没有足够内存完成实例分配,并且堆无法再扩展)。
方法区 (Method Area): 线程共享。存储已被 JVM 加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。JDK 7 及之前通常被称为“永久代”(PermGen)。JDK 8 及之后,元空间 (Metaspace) 取代了永久代,使用本地内存 (Native Memory) 实现。
- 可能发生的异常: OutOfMemoryError (元空间/永久代无法满足新的内存分配需求)。
运行时常量池 (Runtime Constant Pool): 方法区的一部分。存放编译期生成的各种字面量(整数、浮点数、字符串字面量)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。也允许在运行期间将新的常量放入池中(如 String.intern())。
- 可能发生的异常: OutOfMemoryError (当常量池无法再申请到内存时)。
线程共享: Java 堆、方法区(元空间)。
线程私有: 程序计数器、Java 虚拟机栈、本地方法栈。
发生 OOM 的区域: Java 堆、虚拟机栈、本地方法栈、方法区(元空间/永久代)、运行时常量池(作为方法区的一部分)。
线程池
线程池的 ThreadPoolExecutor 的核心构造参数有哪几个?它们分别代表什么含义?线程池的饱和策略有哪些?
核心构造参数 (7 个):
corePoolSize
(核心线程数): 线程池长期维持的线程数量,即使它们处于空闲状态。除非设置了 allowCoreThreadTimeOut。maximumPoolSize
(最大线程数): 线程池允许创建的最大线程数量。keepAliveTime
(空闲线程存活时间): 当线程数大于 corePoolSize 时,非核心线程空闲超过此时间将被终止销毁。unit
(时间单位): keepAliveTime 的时间单位。workQueue
(工作队列): 用于保存等待执行任务的阻塞队列。常见的有 LinkedBlockingQueue (无界队列,慎用), ArrayBlockingQueue (有界队列), SynchronousQueue (直接移交队列), PriorityBlockingQueue (优先级队列)。threadFactory
(线程工厂): 用于创建新线程的工厂。可以设置线程名、优先级、守护线程等。handler
(拒绝策略/饱和策略): 当线程池已关闭,或线程池达到最大线程数且工作队列已满时,新提交的任务将如何处理的策略。
线程池工作流程:
提交任务 (
execute()/submit()
)。如果当前运行线程数 <
corePoolSize
,则立即创建新线程执行任务(即使有空闲核心线程)。如果运行线程数 >=
corePoolSize
,则尝试将任务放入workQueue
排队。如果 workQueue 已满,则尝试创建新的非核心线程执行任务(前提是线程数 <
maximumPoolSize
)。如果线程数已达
maximumPoolSize
且workQueue
已满,则触发拒绝策略 (handler)。
饱和策略 (拒绝策略) -
RejectedExecutionHandler
接口实现:AbortPolicy
(默认策略): 直接抛出RejectedExecutionException
异常。推荐策略,让调用者感知到异常并进行处理。CallerRunsPolicy:
由提交任务的线程自己来执行这个任务(谁提交谁执行)。这既不会丢弃任务,又降低了新任务提交速度,给线程池缓冲时间。DiscardPolicy:
静默丢弃无法处理的新任务,不抛异常。DiscardOldestPolicy:
丢弃工作队列中等待最久(队头)的任务,然后尝试重新提交当前任务。
自定义策略: 实现
RejectedExecutionHandler
接口,根据业务需求定制(如记录日志、持久化任务等)。
- 如何合理地配置线程池参数?
没有绝对标准,需要结合实际场景和监控调优:
CPU 密集型任务: corePoolSize ≈ CPU 核数 (或 核数+1)。maximumPoolSize 可设稍大点(如 corePoolSize * 2),但避免过大导致过多上下文切换。workQueue 可设小点(如 ArrayBlockingQueue 容量 100)或使用 SynchronousQueue(配合较大的 maximumPoolSize),防止任务堆积。
IO 密集型任务: corePoolSize 可以设大些(如 CPU 核数 _ 2 或更高),因为线程大部分时间在阻塞(IO 等待)。maximumPoolSize 可以更大(如 corePoolSize _ 2 或更高)。workQueue 容量也可稍大(如 LinkedBlockingQueue 设 1000+),缓冲突增请求。
混合型任务: 拆分为 CPU 密集和 IO 密集队列,或根据监控动态调整。
考虑因素:
任务性质: CPU/IO/混合?执行时间长短?是否有依赖?
系统资源: CPU 核数、内存大小、IO 带宽。
性能目标: 吞吐量优先?响应时间优先?
监控工具: 观察线程池运行指标(jstack, jvisualvm, metrics 等):线程数、活动线程数、队列大小、任务完成数、拒绝任务数、平均等待/执行时间等。根据监控数据持续调整 corePoolSize, maximumPoolSize, workQueue 容量。
推荐: 使用有界队列(避免 OOM)配合合理的拒绝策略(如 CallerRunsPolicy 或自定义降级策略)。不要使用 Executors 的 newFixedThreadPool (无界队列) 或 newCachedThreadPool (无界线程数) 这些隐藏风险的方法,推荐手动 new ThreadPoolExecutor。
设计模式
单例模式有哪些实现方式?请写出线程安全的双重检查锁 (Double-Checked Locking, DCL) 实现,并解释为什么需要 volatile 关键字。
常见实现方式:
饿汉式 (Eager Initialization): 类加载时就初始化实例。线程安全但可能造成资源浪费(如果实例一直不用)。
懒汉式 (Lazy Initialization): 第一次使用时才初始化。非线程安全。
同步方法懒汉式: 在 getInstance() 方法上加 synchronized。线程安全但效率低(每次获取都要同步)。
双重检查锁 (DCL): 推荐方式。结合了懒加载和效率(只在第一次创建时同步)。需要 volatile。
静态内部类 (Holder): 利用类加载机制保证线程安全(Holder 类只在 getInstance() 第一次调用时加载并初始化 INSTANCE)。简洁高效,推荐。
枚举 (Enum): Joshua Bloch 在《Effective Java》中推荐的方式。写法最简单,且天生线程安全,还能防止反序列化和反射破坏单例。最佳实践。
双重检查锁 (DCL) 实现及 volatile 的必要性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class Singleton {
// volatile 修饰符至关重要!!!
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;
}
}为什么需要 volatile?
问题出在 instance = new Singleton(); 这一行。这并非一个原子操作,它大致分为 3 步:
为对象分配内存空间。
初始化对象(调用构造方法,设置字段初始值)。
将 instance 引用指向分配的内存地址(此时 instance != null)。
由于步骤 2 和 3 可能发生指令重排序(JIT 优化)。如果线程 A 执行完步骤 1 和 3(此时 instance 已不为 null),但尚未执行步骤 2(对象未初始化)。此时线程 B 进入第一个 if (instance == null) 检查,发现 instance 不为 null(因为步骤 3 已执行),于是直接返回 instance。但此时 instance 指向的是一个尚未初始化完成的对象!线程 B 使用这个未初始化的对象就会出错。
volatile 的作用: 禁止 JIT 和处理器对 instance 变量的写操作与其之前的写操作进行重排序(通过内存屏障),确保步骤 3(赋值)一定发生在步骤 2(初始化)完成之后。这样其他线程看到的 instance 要么是 null,要么就是一个完全初始化好的对象。保证了 DCL 的正确性。