Java SE
集合框架
Java 集合框架主要分为两大体系:Collection 单列集合用于存储独立的元素序列,Map 双列集合用于存储键值对(Key-Value)映射关系。
Collection (单列集合)
├── List (有序、可重复) ── ArrayList / LinkedList / Vector
└── Set (无序、不可重复) ── HashSet / TreeSet
Map (双列集合)
└── Map (键值对映射) ── HashMap / TreeMap / ConcurrentHashMapArrayList
扩容机制
ArrayList 底层基于动态数组实现。无参构造初始化时,默认容量在首次添加元素时扩展为 10。当数组容量不足以容纳新元素时,会触发扩容机制:默认扩容为原容量的 1.5 倍(底层通过 oldCapacity + (oldCapacity >> 1) 位运算实现)。
每次扩容都需要申请新的连续内存空间创建新数组,并通过 Arrays.copyOf 将旧数组中的数据复制到新数组中。1.5 倍的扩容因子是空间与时间上的权衡:如果扩容因子设置过大,容易导致申请的内存远超实际需求,造成内存闲置浪费;如果扩容因子设置过小,则会导致添加元素时频繁触发数组扩容与数据拷贝,带来明显的性能损耗。
线程安全分析
当 ArrayList 作为多线程共享变量(例如单例对象的成员变量或静态变量)时,在并发修改场景下是线程不安全的。常见的问题包括:
- 元素覆盖:两个线程同时执行
add()操作,在未加同步控制的情况下同时通过了容量检查,并向同一个数组下标位置写入数据,导致后写入的数据覆盖先写入的数据。 - 扩容数据丢失或数组越界:一个线程正在执行扩容操作的过程中,另一个线程同时修改或扩容数组,导致底层数组引用被覆盖,或者下标自增后超出当前数组实际边界,抛出
ArrayIndexOutOfBoundsException。
针对并发场景下的线程安全问题,常见的解决方案有两种:一是使用 synchronized 代码块或 Collections.synchronizedList 对操作进行加锁同步;二是在读多写少的并发场景下,使用 java.util.concurrent 包下的 CopyOnWriteArrayList。
CopyOnWriteArrayList
CopyOnWriteArrayList 底层同样基于数组实现,其核心思想是写时复制(Copy-On-Write)与读写分离。
在向 CopyOnWriteArrayList 写入(添加、修改、删除)元素时,线程并不会直接修改原数组,而是先获取独占锁,防止多个线程并发写入导致数据覆盖;随后将原数组复制出一份长度加 1 的新数组,所有的写操作都在新数组上完成。当写操作执行完毕后,再将底层的数组引用(volatile 修饰)指向新数组,最后释放锁。而在整个过程中,所有的读操作都直接在原数组上进行,不需要加任何锁。
这种设计的优缺点非常明显:
- 优势:读操作完全无锁,且读写操作互不阻塞,极大提升了高并发读取时的吞吐性能,非常适合读多写少的应用场景(如白名单配置、监听器列表等)。
- 局限性:首先,每次写操作都需要完整复制一份底层数组,内存占用较高,若数组较大且写操作频繁,容易引发频繁的 GC;其次,它只能保证数据的最终一致性,无法保证实时一致性——在写操作完成引用切换之前,读线程读取到的依然是旧数组中的数据,因此不适合对数据实时性要求严苛的场景。
HashMap
HashMap 在 JDK 1.7 中采用 数组 + 链表 的数据结构;到了 JDK 1.8,为了解决哈希冲突严重时链表过长导致查询退化为 O(n) 的问题,将其升级为 数组 + 链表 + 红黑树。
添加元素流程
当调用 map.put(key, value) 插入键值对时,底层执行逻辑如下:
- 计算下标:首先根据 key 计算出哈希值(结合高 16 位异或扰动),再通过
(n - 1) & hash(其中n为数组长度)计算出该元素在数组中对应的桶(Bucket)下标位置。 - 空桶直接插入:如果数组该下标位置为
null,说明没有发生哈希冲突,直接构建节点对象放入该位置即可。 - 哈希冲突处理:如果该位置不为
null,说明发生了哈希冲突,此时需要遍历该桶位置挂载的链表或红黑树,通过hash值及equals()方法逐一比对 key:- 若找到相同的 key,则直接使用新的 value 覆盖原有节点的 value 值,并返回旧值。
- 若遍历完整个链表或红黑树仍未找到相同的 key,则将新元素插入到结构中(在链表插入方式上,JDK 1.7 采用头插法,JDK 1.8 改为尾插法)。
- 树化与扩容判断:在 JDK 1.8 中,链表插入新节点后会检查链表长度。当链表长度达到 8 且当前数组长度达到 64 时,会将该链表转化为红黑树以提升检索效率(若数组长度小于 64,则会优先触发
resize()扩容,而非直接树化)。最后检查当前元素总数是否超过扩容阈值,若超过则进行扩容。
负载因子为什么是 0.75
HashMap 的默认负载因子(Load Factor)为 0.75,即当集合中的元素数量达到数组总长度的 0.75 倍时,就会触发扩容。
负载因子定为 0.75 是基于 泊松分布(Poisson Distribution) 的统计规律与工程折中:
- 如果负载因子过大(例如设为 1.0),虽然空间利用率极高,但只有在数组被填满时才扩容,这会导致发生哈希冲突的概率急剧上升,链表变长,查询时间复杂度退化。
- 如果负载因子过小(例如设为 0.5),虽然哈希冲突减少、查询速度快,但数组刚使用一半就会触发扩容,造成大量的内存空间闲置浪费。
经过大量测试,当负载因子为 0.75 时,哈希冲突的概率被控制在较低水平(单个桶内链表长度达到 8 的概率不足千万分之一),能够在维持 O(1) 查询时间复杂度与控制内存空间开销之间取得最佳平衡。
为什么容量与扩容必须是 2 的次幂
HashMap 的初始容量默认为 16,每次扩容都会变为原来的 2 倍,即保证数组长度 n 始终是 2 的次幂(2^k)。这样设计的核心原因在于优化下标计算的效率与哈希分布的均匀性。
常规情况下,将哈希值映射到数组下标需要取模运算 hash % n,但在计算机底层,位运算(&)的效率远高于取模运算(%)。当且仅当 n 为 2 的次幂时,满足以下数学等价关系:
更关键的是,当数组长度 n 是 2 的次幂时,n - 1 的二进制表示低位全部为 1。此时进行按位与(&)运算,最终下标的结果完全取决于 key 自身哈希值的低位,能够保证计算出的下标均匀分布在数组各个位置上,降低哈希碰撞概率:
假设数组长度 n = 16 (即 2^4, 符合 2 的次幂):
n - 1 = 15 (二进制低位全为 1,结果完全取决于 hash 值本身,分布均匀)
0000 1111 (n - 1 = 15)
& 1010 0101 (key1 的 hash) -> 结果: 0000 0101 (下标 5)
& 1010 0100 (key2 的 hash) -> 结果: 0000 0100 (下标 4)
假设数组长度 n = 10 (非 2 的次幂):
n - 1 = 9 (二进制包含多个 0,按位与后始终是 0,部分数组下标永远无法被映射,碰撞剧增)
0000 1001 (n - 1 = 9)
& 1010 0101 (key1 的 hash) -> 结果: 0000 0001 (下标 1)
& 1010 0100 (key2 的 hash) -> 结果: 0000 0000 (下标 0)线程安全问题
当 HashMap 作为多线程共享数据时,并发修改会导致严重的线程安全问题,不同 JDK 版本的具体表现有所不同:
- JDK 1.7 扩容死循环与数据丢失:JDK 1.7 在扩容迁移链表节点时采用头插法,这会改变原链表上节点的相对顺序。在多线程并发扩容时,容易导致节点之间的引用形成环形链表(死循环),后续查询该桶时会导致 CPU 飙升至 100%;同时并发迁移也会造成部分节点数据丢失。
- JDK 1.8 数据覆盖:JDK 1.8 改用尾插法解决了扩容死循环问题,但在
put操作中依然没有加锁。当两个线程同时插入元素并计算出相同的空桶下标时,如果线程 A 刚判断完该桶为空准备写入时时间片耗尽,线程 B 完成了写入;随后线程 A 恢复执行,由于不会再次判断桶是否为空,会直接将自己的节点写入该位置,导致线程 B 写入的数据被覆盖丢失。
在并发场景下,可以使用 Collections.synchronizedMap 或 synchronized 加锁,但更推荐直接使用高并发容器 ConcurrentHashMap。
ConcurrentHashMap 的实现原理
ConcurrentHashMap 在 JDK 1.7 和 JDK 1.8 中的并发控制架构经历了一次重大演进:
| 对比维度 | JDK 1.7 实现 | JDK 1.8 实现 |
|---|---|---|
| 底层数据结构 | Segment 分段数组 + HashEntry 数组 + 链表 |
Node 数组 + 链表 + 红黑树 |
| 并发锁机制 | 分段锁(Segment),继承自 ReentrantLock |
CAS + synchronized |
| 锁的粒度 | 锁住整个 Segment(包含多个 Hash 桶) |
仅锁住当前发生冲突的单个桶的头节点 |
| 并发度限制 | 默认最多支持 16 个线程并发写(初始化后不可扩容) | 并发度与数组桶的数量一致,随数组扩容动态提升 |
在 JDK 1.7 中,底层默认将整个数组拆分为 16 个段(Segment),每个段独立配有一把 ReentrantLock 锁。多线程操作不同段的数据时互不影响,最多支持 16 个线程同时并发写入,以此降低锁竞争。
在 JDK 1.8 中,摒弃了分段锁设计,改为采用 CAS(Compare And Swap)+ synchronized 实现更细粒度的并发控制。从 putVal 源码来看,其写入流程如下:
- 判断底层数组是否为空,若为空则通过 CAS 竞争初始化数组。
- 计算 key 对应的桶下标,判断该桶位置是否为空。若为空,说明当前没有哈希冲突,直接通过 CAS 将新节点设置为该桶的头节点。
- 若该桶位置不为空(或 CAS 写入失败),说明存在冲突或并发竞争。此时使用
synchronized关键字锁住该桶位置的头节点,随后在锁内部遍历链表或红黑树,完成节点的替换或新增,并在结束后判断是否需要将链表转化为红黑树。
为什么结合使用 CAS 与 synchronized
在 JDK 1.8 的 ConcurrentHashMap 中,没有单纯全部使用 CAS,也没有全部使用 synchronized,而是根据操作特性的不同进行了组合使用。这取决于两种操作在底层所执行的任务耗时差异:
- CAS 负责的是初始化数组或向空桶写入头节点:这类操作只涉及单次内存引用赋值,逻辑极其简单、执行耗时极短。使用 CAS 无锁操作可以避免线程阻塞与唤醒的开销;即便发生冲突,重试成本也很低。
synchronized负责的是发生哈希冲突后的桶内操作:此时需要遍历链表或红黑树、逐个调用equals()比较 key、插入或更新节点,甚至可能触发链表转红黑树的重构。这一系列操作步骤多、执行耗时相对较长。
如果对耗时的桶内遍历操作也使用 CAS,在高并发冲突下会导致大量线程长时间自旋重试,严重消耗 CPU 资源;而使用 synchronized 锁住头节点(且 JDK 1.6 后 synchronized 引入了偏向锁、轻量级锁、重量级锁的升级优化),可以让竞争失败的线程在自旋一定次数后挂起休眠,释放 CPU 资源。这种设计体现了现代并发编程中 “无锁优先,细粒度锁为补充” 的精细化控制思想。
迭代器 (Iterator)
在 Java 中,遍历集合常见的方式有三种:迭代器遍历、增强 for 循环遍历(底层依然编译为迭代器)以及 Lambda 表达式(forEach)遍历。
不同的集合底层数据结构截然不同(例如 ArrayList 是连续内存数组,LinkedList 是离散的双向链表,HashSet 是哈希表)。如果由调用方直接根据底层结构编写遍历代码,会导致业务代码与集合的具体实现强耦合。迭代器(迭代器设计模式)的核心作用,就是向外部提供一套统一的标准接口来遍历各类集合,而无需暴露集合内部底层数据结构的实现细节。
// 通过 Iterator 接口标准化集合的遍历行为
public interface Iterator<E> {
boolean hasNext(); // 检查序列中是否还有下一个元素
E next(); // 返回序列中的下一个元素,并将游标后移
void remove(); // 删除迭代器最后一次返回的元素(可选操作)
}在底层实现上,各个集合类通过内部类实现 Iterator 接口,并结合自身的数据结构定制具体的游标推进逻辑:
ArrayList的迭代器实现:内部定义了Itr内部类,底层维护一个整型索引游标cursor,通过数组下标的递增来实现hasNext()和next()。LinkedList的迭代器实现:内部定义了ListItr等内部类,底层维护指向当前链表节点的引用指针next,通过双向链表节点的next引用跳转来实现遍历。
泛型 (Generics)
泛型是 JDK 5 引入的语言特性,允许在定义类、接口和方法时使用类型参数(Type Parameter),声明的类型参数在实际使用时再由具体的引用类型替换(即参数化类型)。
泛型的引入主要解决了两个核心问题:
- 编译期类型安全检查:在没有泛型之前,集合类(如
ArrayList)内部只能使用Object类型保存元素。这意味着任何类型的数据都可以塞入同一个集合,而在取出元素时必须由开发者手动进行强制类型转换,极易在运行期引发ClassCastException。引入泛型后,编译器会在编译阶段强制检查元素类型是否匹配,并自动插入安全的类型转换,将运行时错误提前暴露在编译期。 - 提升代码复用率:开发者可以为多种不同的数据类型编写同一套通用的算法或数据结构逻辑,而无需为每种类型重复编写代码。
类型擦除机制
Java 的泛型属于“伪泛型”,其底层通过 类型擦除(Type Erasure) 实现。编译器在编译阶段会对泛型代码进行类型检查,检查通过后,会将代码中所有的泛型类型参数信息全部擦除,替换为原生类型(Raw Type,通常默认为 Object,若指定了上限如 <T extends Number> 则擦除为 Number),并在必要的位置自动插入强制类型转换字节码。因此,在编译生成的 .class 字节码文件以及运行期的 JVM 中,通常不再保留泛型参数的实例类型信息。
Java 之所以选择类型擦除方案,核心原因是为了保证向后兼容性(二进制兼容)。Java 5 引入泛型时,市场上已经存在海量基于旧版本编写的非泛型代码和已部署的 JVM。采用类型擦除可以在不修改 JVM 底层指令集、不重新编译旧有类库的前提下,让新编写的泛型代码直接运行在旧版 JVM 上,同时允许泛型代码与历史遗留的非泛型代码无缝互操作。
反射 (Reflection)
在常规的面向对象编程中,类的类型信息在编译期就已经确定(编译时绑定)。而反射机制允许程序在 运行期(Runtime) 借助 Class 对象,动态地获取任意类的内部结构信息(包括成员变量、构造器、方法、注解等),并能够在运行时动态创建对象、调用方法或修改属性值。
反射机制打破了编译时绑定的限制,使程序能够适配在编译期未知或动态变化的类型,极大地提升了架构的灵活性与通用性。它是现代 Java 框架的基石,常见应用场景包括:
- 主流框架开发:如 Spring 的 IoC 容器(解析配置或注解后,通过反射动态实例化 Bean 并完成依赖注入)、Spring AOP 及 MyBatis 的 Mapper 接口代理。
- 动态加载与插件化架构:如 JDBC 通过
Class.forName()动态加载不同数据库厂商的驱动实现类。 - 序列化与反序列化:如 Jackson、Fastjson 等 JSON 库,通过反射在运行时扫描对象的属性名与属性值,将其转化为字符串或还原为对象。
- 单元测试与开发工具:如 JUnit 框架在运行时动态扫描并调用带有
@Test注解的测试方法。
虽然反射提供了极高的灵活性,但也伴随着明显的代价:
- 性能开销:反射涉及动态类型解析、方法查找以及安全检查,无法享受 JVM 诸多编译期优化(如方法内联),因此反射调用的速度明显慢于直接代码调用。
- 破坏封装性:通过反射的
setAccessible(true)可以强行访问和修改类的私有(private)属性与方法,打破了面向对象的封装约束,可能引发安全隐患。 - 维护成本高:过度使用反射会使代码的调用链路隐藏在字符串配置与动态调用中,降低了代码的可读性,使得 IDE 的静态检查失效,增加了排查和调试的难度。
动态代理 (Dynamic Proxy)
动态代理是一种在程序运行期动态生成代理类与代理对象的技术。它允许在不修改原始目标类(Target)任何代码的前提下,通过代理对象拦截对目标对象方法的调用,并在目标方法执行前后织入自定义的控制或增强逻辑(这也是 Spring AOP 的底层核心实现机制)。
Java 生态中主流的动态代理实现方式主要有两种:JDK 动态代理与 CGLIB 动态代理。
| 对比维度 | JDK 动态代理 | CGLIB 动态代理 |
|---|---|---|
| 提供方 | JDK 原生自带(java.lang.reflect.Proxy) |
第三方字节码库(底层依赖 ASM 框架) |
| 实现机制 | 基于接口实现:动态生成目标接口的实现类 | 基于子类继承:动态生成目标类的子类并重写方法 |
| 使用限制 | 目标类必须实现至少一个接口 | 目标类及目标方法不能被 final 修饰 |
| 调用方式 | 代理类执行增强逻辑后,通过反射调用目标方法 | 代理类执行增强逻辑后,通过 FastClass 索引机制直接调用父类方法 |
JDK 动态代理
JDK 动态代理依赖 java.lang.reflect.Proxy 类与 InvocationHandler 接口。它在运行时动态为目标类所实现的接口生成一个代理实现类(如 $Proxy0),该代理类默认继承自 Proxy 并实现了目标接口的所有方法。由于 Java 单继承机制的限制,使用 JDK 动态代理的前提是目标类必须实现接口。
当外部通过代理对象调用某个接口方法时,调用请求会被代理类拦截,并统一转发给 InvocationHandler.invoke() 方法。在 invoke() 方法内部执行完前置增强逻辑后,再通过 反射机制(Method.invoke) 调用目标对象的实际方法。
CGLIB 动态代理
当目标类没有实现任何接口时,就需要使用 CGLIB(Code Generation Library)动态代理。CGLIB 的底层基于一个小而快的字节码处理框架 ASM,它通过在运行时直接操作字节码,动态生成目标类的一个子类作为代理类,并重写(Override)父类中所有非 final 的方法,在重写的方法中织入增强拦截逻辑。
正因为 CGLIB 是通过继承的方式实现代理的,所以如果目标类本身被 final 修饰,或者目标方法被 final / private 修饰(无法被子类继承和重写),则无法使用 CGLIB 进行代理。在调用阶段,CGLIB 拦截到方法调用并执行增强逻辑后,会利用 FastClass 机制(为方法建立索引)直接调用父类的目标方法,避免了传统反射的开销。不过,随着 JDK 8 之后 JVM 对反射调用与字节码生成的持续优化,JDK 原生动态代理的执行性能已大幅提升,在多数场景下甚至优于 CGLIB(Spring Boot 2.x 之后默认采用 CGLIB 主要是为了规避没有接口时代理失败的问题,而非性能原因)。
字符串 String
底层实现与结构
String 类在 Java 中用于表示不可变的字符序列,其底层数据结构在 JDK 9 经历了一次重要的内存优化演进:
- JDK 1.8 及以前:底层使用
char[]字符数组存储数据(private final char value[])。Java 中的char采用 UTF-16 编码,每个字符固定占用 2 个字节。但在实际应用中,绝大多数字符串仅包含拉丁字符(Latin-1,只需 1 个字节即可表示),固定使用 2 字节存储造成了大量的内存空间浪费。 - JDK 1.9 及以后:底层改为使用
byte[]字节数组存储数据(private final byte[] value),并引入了一个编码标识字段private final byte coder(即 Compact Strings 紧凑字符串特性)。当字符串仅包含 Latin-1 字符时,以单字节编码存储;当包含中文或其他多字节字符时,则切换为 UTF-16 编码存储。这一改动在完全透明的前提下,显著降低了 JVM 堆内存中字符串对象的空间占用。
不可变性 (Immutability) 的实现机制
String 对象一旦在内存中创建,其内部包含的字符序列就无法被修改。这种不可变性并非仅仅依靠单个 final 关键字实现,而是通过多项语言机制共同保障的结果:
- 底层存储数组私有且不可重新指向:保存字符串内容的
value数组同时被private和final修饰。final保证了value引用一旦初始化就无法指向其他数组对象(注:final本身只限制引用地址不可变,不限制数组内部元素不可变);而private保证了外部无法直接访问该数组,且String类内部没有暴露任何修改该数组元素的方法,所有看似修改字符串的操作(如substring、replace、concat)本质上都是新建并返回一个全新的String对象。 - 类本身禁止被继承:
String类被声明为public final class String,final修饰的类无法被继承。这从根本上杜绝了外部通过编写子类重写String方法,进而绕过限制破坏其不可变性的可能。
字符串的长度限制
String 存储的字符串长度是有限制的,且在编译期与运行期受到不同机制的约束:
- 编译期限制(65535 字节):在代码中直接使用双引号定义的字符串字面量(如
String s = "..."),在编译为.class字节码文件时,需要存储在常量池的CONSTANT_Utf8_info结构中。该结构使用一个 16 位无符号整数(u2类型,length字段占 2 个字节)来记录字符串的字节长度,因此最大长度为 2^{16} - 1 = 65535 个字节(考虑到编译器内部约束,实际最大允许 65534 个字节),超出该限制会导致编译报错。 - 运行期限制(2^{31} - 1):在运行期动态构建的字符串(如通过文件读取、网络流拼接、
new String()创建),不再受编译期常量池结构的限制。此时字符串长度取决于底层数组的索引上限以及String.length()方法的返回值类型int,因此运行期理论最大长度为Integer.MAX_VALUE,即 2^{31} - 1(约 21 亿个字符,约占 2GB~4GB 连续堆内存,实际受限于 JVM 可分配的最大堆内存空间)。
为什么将 String 设计为不可变
将 String 设计为不可变类,是 Java 语言设计者在内存缓存效率、系统安全性、并发线程安全三个核心维度上做出的综合考量:
- 支撑字符串常量池,节省内存与提升性能:字符串是 Java 程序中使用最频繁的对象类型。如果每次声明都创建独立的堆对象,会产生巨大的内存开销。正因为字符串不可变,JVM 才得以设计字符串常量池(String Pool)——让多个内容相同的字符串引用安全地指向常量池中的同一个对象实例(同时,由于内容不可变,
String的hashCode在首次计算后即可直接缓存复用,作为HashMap的 Key 时检索效率极高)。假设字符串是可变的,当变量s1和s2指向同一个常量对象时,修改s1的内容就会导致s2的值被意外篡改,常量池共享机制将彻底失效。 - 保障系统底层与业务数据的安全性:在 Java 应用中,字符串广泛用于传递敏感参数,如数据库连接 URL、账号密码、文件系统路径、网络 Socket 主机名与端口等;此外,JVM 类加载器(ClassLoader)在按全限定类名加载类时也依赖字符串传参。不可变性保证了字符串参数在传递过程中绝对可信,调用方无需担心参数在传递链路上被恶意代码或后续逻辑中途篡改,从而保障了整个应用程序与 JVM 类加载机制的安全。
- 天然具备线程安全性:由于不可变对象的内部状态在创建后永远不会发生改变,多个线程同时读取和共享同一个
String实例时,不会产生任何竞态条件(Race Condition)。当某个线程对字符串进行拼接或修改时,底层只会在堆或常量池中生成新的字符串对象,而不会影响原对象的值,因此在多线程并发环境下无需额外加锁即可安全使用。
字符串常量池 (String Pool)
为了减少重复字符串对象的创建开销,JVM 在堆内存(JDK 1.7 及以后由永久代移至堆内存中)专门开辟了一块区域,即字符串常量池。
当代码中通过字面量形式(如 String s1 = "Hello")声明字符串时,JVM 会首先检查字符串常量池中是否已经存在内容为 "Hello" 的对象:如果不存在,则在常量池中创建该对象并返回其引用;如果已经存在,则不会创建新对象,而是直接返回常量池中已有对象的引用。对于通过 new String("Hello") 在普通堆区显式创建的对象,也可以调用 intern() 方法手动将其纳入常量池管理(若常量池中已有相等字符串,则直接返回池中对象的引用)。
String s1 = "Hello";
String s2 = "Hello";
// new String 会在堆中创建新对象,但调用 intern() 后会返回字符串常量池中 "Hello" 的引用
String s3 = new String("Hello").intern();
System.out.println(s1 == s2); // true,s1 与 s2 指向常量池中的同一个字符串实例
System.out.println(s1 == s3); // true,s3 通过 intern() 指向了常量池中的同一个字符串实例String、StringBuffer 与 StringBuilder 的区别
针对不同的字符串操作场景,Java 提供了 String、StringBuffer 和 StringBuilder 三个核心类,三者的差异主要体现在可变性、线程安全性以及执行性能上:
| 对比维度 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变:每次修改都会生成新的字符串对象 | 可变:直接在原对象的底层字符数组上进行修改 | 可变:直接在原对象的底层字符数组上进行修改 |
| 线程安全 | 线程安全:基于不可变性天然支持多线程共享 | 线程安全:核心操作方法均使用 synchronized 同步加锁 |
非线程安全:方法未加同步锁,多线程并发修改不安全 |
| 执行性能 | 频繁修改时性能最差(产生大量临时对象增加 GC 负担) | 频繁修改时性能较好,但存在同步锁的获取与释放开销 | 频繁修改时性能最高(无锁开销,原地修改) |
| 适用场景 | 字符串内容极少修改、常量定义、作为 Map 的 Key | 多线程共享同一个字符串缓冲区并进行并发拼接/修改 | 单线程环境下的高频字符串拼接、动态 SQL 构建等 |
对象拷贝:深拷贝与浅拷贝
在 Java 中复制一个对象时,根据对对象内部引用类型成员变量处理方式的不同,分为浅拷贝(Shallow Copy)与深拷贝(Deep Copy)。无论哪种拷贝方式,拷贝生成的新对象(如 user2)与原对象(如 user1)本身都是堆内存中两个独立的实例(即 user1 != user2),核心区别在于它们内部的成员属性是否真正隔离。
浅拷贝 (Shallow Copy)
浅拷贝在创建新对象时,会将原对象的所有字段值精确复制一份(Java 中 Object.clone() 方法的默认实现即为浅拷贝):
- 如果成员变量是基本数据类型(如
int、double),则直接复制其具体的数值,修改新对象的基本类型字段不会影响原对象。 - 如果成员变量是引用数据类型(如自定义类对象、集合、数组),浅拷贝仅仅复制该字段的引用地址(内存指针),而不会复制引用所指向的实际堆内存对象。这意味着新对象与原对象内部的引用字段指向的是堆内存中的同一个子对象,一旦通过其中一个对象修改了该子对象的内部状态,另一个对象读取到的数据也会随之改变。
深拷贝 (Deep Copy)
深拷贝不仅会在堆内存中创建一个新的顶层对象,还会沿着引用链路递归地复制原对象内部所有引用类型字段所指向的对象实例,直至所有可达的对象都被复制出全新的独立副本(常见实现方式包括重写 clone() 方法对各级引用属性逐层克隆,或通过字节流/JSON 序列化与反序列化实现)。在深拷贝完成后,新对象与原对象在内存结构上完全解耦,对新对象内部任何层级的修改都不会影响到原始对象。
以下面的 User 与 Address 结构为例:
public class User {
private String name;
private String password;
private Address address; // 引用类型成员变量
}
public class Address {
private String province;
private String city;
private String area;
}当基于 user1 拷贝生成 user2 时,浅拷贝与深拷贝在内存中的引用关系差异如下:
[浅拷贝 Shallow Copy] —— 共享内部引用对象,修改 address 互相影响
user1 (0x100) ──┐
├──> Address 实例 (0x500) [province, city, area]
user2 (0x200) ──┘
[深拷贝 Deep Copy] —— 递归复制内部对象,内存完全独立,互不影响
user1 (0x100) ─────> Address 实例 1 (0x500) [province, city, area]
user2 (0x200) ─────> Address 实例 2 (0x600) [province, city, area]异常处理机制
Error 与 Exception 的区别
在 Java 的异常体系中,所有错误与异常的顶层父类均为 java.lang.Throwable,其下直接派生出两大分支: Error(错误) 与 Exception(异常)。
java.lang.Throwable
├── Error (系统级严重错误,无需也不应由程序捕获)
│ ├── OutOfMemoryError (OOM 内存溢出)
│ └── StackOverflowError (栈内存溢出)
│
└── Exception (程序级异常,可通过代码捕获处理或规避)
├── 受检异常 (Checked Exception,编译期强制检查)
│ ├── IOException
│ ├── SQLException
│ └── ClassNotFoundException
│
└── 运行时异常 / 非受检异常 (RuntimeException,运行期触发)
├── NullPointerException
├── ArrayIndexOutOfBoundsException
└── ClassCastExceptionError(错误):表示由 JVM 自身或底层硬件、操作系统资源耗尽引发的系统级严重问题,例如堆内存溢出(OutOfMemoryError)、虚拟机栈溢出(StackOverflowError)等。这类错误超出了应用程序代码的控制和恢复能力,程序通常只能终止运行,因此在业务开发中不应使用try-catch去捕获处理。Exception(异常):表示由程序逻辑缺陷、不合规的参数输入或外部环境波动(如网络抖动、文件缺失)引发的程序级可处理问题。这类异常属于应用程序可以预见并应当处理的范畴,开发者可以通过完善代码逻辑予以规避,或使用try-catch捕获并进行降级恢复。
受检异常与非受检异常
根据编译器是否强制要求在代码中显式处理,Java 中的异常体系又被划分为受检异常(Checked Exception)与非受检异常(Unchecked Exception):
- 受检异常(编译时异常):指除
RuntimeException及其子类之外的所有Exception子类。这类异常通常与外部环境交互有关,编译器在编译阶段会强制检查:如果一段代码可能抛出受检异常,开发者必须显式使用try-catch进行捕获处理,或者在方法签名上使用throws向上声明抛出,否则代码无法通过编译。常见代表包括:IOException:文件读写或网络输入/输出流操作时发生的异常。SQLException:数据库访问或执行 SQL 时发生的异常。ClassNotFoundException:动态加载类时找不到对应类文件的异常。InterruptedException:处于阻塞或休眠状态的线程被中断时触发的异常。
- 非受检异常(运行时异常):主要指继承自
RuntimeException的所有子类(广义上Error也属于非受检范畴)。这类异常在编译阶段不会强制要求显式捕获或声明,只有在程序运行期间实际触发时才会暴露,若未捕获则会中断当前调用栈的执行。运行时异常绝大多数是由代码逻辑不够严谨导致的,只要编写代码时做好边界与空值校验即可完全避免。常见代表包括:NullPointerException:尝试调用空对象引用(null)的属性或方法。ArrayIndexOutOfBoundsException:访问数组时下标超出合法范围。ClassCastException:将对象强制转换为不兼容的类型时触发。
throw 与 throws 的区别
throw 和 throws 是 Java 异常处理中成对出现的两个关键字,二者在声明位置与作用机制上完全不同:
| 对比维度 | throw 关键字 |
throws 关键字 |
|---|---|---|
| 使用位置 | 编写在方法体内部,作为一条独立的执行语句 | 声明在方法签名(参数列表)之后 |
| 核心作用 | 当检测到特定错误条件时,主动构建并抛出一个具体的异常实例 | 向调用方声明当前方法可能抛出的异常类型,将异常处理责任向上转移 |
| 后接内容 | 只能跟一个具体的异常对象实例(如 throw new RuntimeException("参数非法")) |
可以跟一个或多个异常类的类型名称,用逗号分隔(如 throws IOException, SQLException) |
| 执行影响 | 执行 throw 语句后,当前方法立即终止,后续代码不再执行 |
仅作方法契约声明;若声明了受检异常,调用方必须捕获或继续向上声明抛出 |
finally 代码块一定会执行吗
在标准的异常处理流程中,无论 try 块中的代码是正常执行完毕、中途抛出异常,还是执行了 return 语句,finally 块中的代码通常都会在方法返回前被执行(因此常用于释放锁、关闭流或数据库连接)。
但在以下几种特殊情况或极端环境下,finally 块中的代码将不会被执行:
- 还未进入
try块便已返回或抛出异常:如果在try代码块之前程序就已经因为参数校验执行了return,或者在进入try块之前引发了未捕获异常,由于执行流根本未进入try的监控范围,对应的finally自然不会执行。 - 调用
System.exit()终止 JVM 进程:如果在try或catch块中调用了System.exit(int status),该方法会触发 JVM 的关闭序列并直接终止当前正在运行的 Java 虚拟机进程,后续的finally代码块将没有机会执行。 - 调用
Runtime.getRuntime().halt()强行关停 JVM:在try块中调用halt(int status)会直接强行终止正在运行的 JVM。与System.exit()不同,halt()甚至不会触发 JVM 的关闭钩子(Shutdown Hooks),进程瞬间退出,finally同样不会执行。
public class FinallyTerminateExample {
public static void main(String[] args) {
try {
System.out.println("执行 try 代码块...");
// 触发 JVM 进程退出(使用 Runtime.getRuntime().halt(0) 效果相同)
System.exit(0);
} finally {
// 由于 JVM 进程已终止,该行代码永远不会被执行
System.out.println("执行 finally 代码块...");
}
}
}- 线程在
try块中陷入死循环或死锁:如果执行线程在try块内部遭遇了无限循环(while(true))或因锁竞争陷入永久死锁,导致执行流永远无法跳出try代码块,那么finally块也就永远无法被触发。 - 守护线程(Daemon Thread)随主线程退出被销毁:如果执行
try-finally的是一个守护线程,当系统中所有非守护线程(用户线程)全部执行完毕时,JVM 会直接退出,此时守护线程会被立即中断销毁,可能来不及执行finally块。 - 不可抗力的物理或底层崩溃:例如服务器突然断电、操作系统直接通过
kill -9强制杀死 JVM 进程,或者 JVM 底层 C++ 代码发生崩溃(Crash),程序运行环境直接消失,finally自然无法执行。
I/O 模型演进:BIO、NIO 与 AIO
核心概念辨析:同步与异步、阻塞与非阻塞
在剖析 Java 的 I/O 模型之前,需要先从操作系统与线程调度的维度厘清两组核心概念:同步与异步、阻塞与非阻塞。
- 同步(Synchronous)与异步(Asynchronous)——关注的是任务完成状态的协作与结果获取机制:
- 同步:调用方发起一个操作后,必须等待被调用的任务彻底执行完成并拿到结果(或由调用方自己去轮询、搬运数据),整个操作才算结束。任务的执行具备严格的线性依赖关系,状态保持一致。
- 异步:调用方发起一个操作指令后,无需等待被依赖的任务执行完成,调用立即返回,调用方可以继续向下执行后续逻辑。被依赖的任务(如数据准备与从内核态到用户态的拷贝)完全交由后台操作系统异步完成;待任务彻底完成后,操作系统会通过 事件通知或回调函数(Callback) 主动告知调用方处理结果。
- 阻塞(Blocking)与非阻塞(Non-Blocking)——关注的是等待结果期间调用线程的状态与 CPU 消耗:
- 阻塞:在慢操作(如网络数据尚未到达网卡)彻底完成之前,当前调用线程会被操作系统直接挂起(让出 CPU 进入休眠状态),线程停留在原地空等,直到数据就绪后才被唤醒继续往下执行。
- 非阻塞:在慢操作尚未准备就绪时,调用不会挂起当前线程,而是立即返回一个状态值。在此期间线程不会被阻塞,CPU 可以去执行该线程的其他任务。需要注意的是,虽然非阻塞避免了线程挂起,但如果应用程序频繁轮询检查状态,也会带来额外的 CPU 消耗或上下文切换成本,实际工程中需结合多路复用器进行权衡。
关键结论:同步与阻塞并非绑定关系。同步不一定阻塞(例如同步非阻塞 NIO:线程不挂起,但仍需主动轮询状态并同步读取数据),这两组概念分别描述了通信机制与线程状态两个不同的维度。
BIO、NIO 与 AIO 的架构演进
Java 在不同版本中先后提供了三种核心 I/O 模型,以应对不断增长的网络并发规模:
| 对比维度 | BIO (Blocking I/O) | NIO (Non-Blocking I/O) | AIO (Asynchronous I/O) |
|---|---|---|---|
| 引入版本 | JDK 1.0 (java.io) |
JDK 1.4 (java.nio) |
JDK 1.7 (java.nio.channels,即 NIO.2) |
| 通信模型 | 同步阻塞 I/O | 同步非阻塞 I/O(多路复用) | 异步非阻塞 I/O |
| 数据处理单位 | 面向流(Stream),单向字节/字符流读写 | 面向缓冲区(Buffer),通过 Channel 双向读写 | 面向缓冲区(Buffer),基于事件与回调机制 |
| 线程与连接关系 | 一个连接对应一个独立线程(1:1) | 单个或少量线程通过 Selector 管理成千上万个连接(1:N) | 由操作系统内核完成后回调通知,应用线程不参与等待 |
| 适用场景 | 连接数少且固定的传统架构,代码简单直观 | 高并发、短连接或长连接(如 Netty、消息中间件、网关) | 连接数较少但单次 I/O 数据传输量极大、耗时长的场景 |
1. BIO (Blocking I/O,同步阻塞 I/O)
BIO 即传统的 java.io 包,基于 流模型(Stream) 实现,采用同步、阻塞的交互方式。在线程调用输入流(InputStream.read())或输出流进行读写时,在读写动作彻底完成之前,当前线程会一直阻塞在原地等待,调用具备严格的线性顺序。
- 特点:代码编写简单、逻辑直观;但每个客户端连接都需要独占一个服务端线程,当并发连接数急剧上升时,大量线程因等待网络 I/O 而阻塞,导致系统扩展性极差,极易成为性能瓶颈。
2. NIO (Non-Blocking I/O,同步非阻塞 I/O)
NIO 是 JDK 1.4 引入的 java.nio 包,其核心在于引入了 Channel(通道)、Buffer(缓冲区)、Selector(多路复用器) 三大核心抽象,用于构建基于I/O 多路复用的同步非阻塞程序。
- 核心机制:客户端连接不再直接绑定独立工作线程,而是将所有的
Channel注册到同一个Selector多路复用器上。单个线程通过Selector轮询监听所有通道上的 I/O 事件(如连接就绪、读就绪、写就绪),只有当某个通道真正有数据可读/可写时,才分配线程去执行缓冲区的数据读写。这从根本上解决了 BIO 线程资源随连接数线性膨胀的问题,提供了接近操作系统底层(如 Linuxepoll)的高性能数据操作能力。
3. AIO (Asynchronous I/O,异步非阻塞 I/O)
AIO 是 JDK 1.7 引入的升级特性(又称 NIO.2),提供了真正的异步非阻塞 I/O 操作方式。
- 核心机制:AIO 基于 事件驱动与回调机制(Callback / Future) 实现。当应用程序发起读写操作后,方法会立即返回,不会阻塞当前线程,也不需要应用线程通过 Selector 去轮询就绪状态;底层操作系统会全权接管数据的等待与内核态到用户态的拷贝工作。当后台读写操作彻底完成后,操作系统会主动通知 JVM 并触发注册的回调函数进行后续处理(注:由于 Linux 底层对原生异步 I/O 的支持成熟度问题,加上
epoll多路复用模型已足够高效,目前工业界主流的高并发网络框架如 Netty 依然以 NIO 作为核心实现)。
JDK 8 核心新特性
JDK 8 是 Java 语言演进中的重要里程碑,其核心在于引入了函数式编程思潮与声明式数据处理 API,显著改善了代码的表达力:
- Lambda 表达式:Java 引入函数式编程的核心特性。其核心思想是将函数(行为)作为方法参数进行传递。配合函数式接口(
@FunctionalInterface),Lambda 表达式极大简化了传统匿名内部类的模板代码,使代码更加紧凑、可读性更强。 - Stream API(流):一套用于对集合数据进行高效、声明式处理的 API。Stream 本身不存储数据,而是将数据源(如集合、数组)的元素放入流水线中,通过中间操作(如
filter过滤、map转换、sorted排序)和终端操作(如collect收集、reduce规约)完成复杂的数据聚合计算,天然支持链式调用与并行处理(parallelStream)。 - Optional 容器类:一个用于封装可能为
null的对象的容器类。它的设计初衷是改变开发者过去散落在代码各处的if (obj != null)防御式检查,通过提供orElse()、orElseThrow()、map()等链式 API,强制或引导调用方显式处理空值情况,从而在编码层面大幅减少NullPointerException的发生。
JDK 21 核心新特性:虚拟线程 (Virtual Threads)
虚拟线程(Project Loom 的核心成果)于 JDK 19 首次作为预览特性引入,并在 JDK 21 中正式发布(GA)。虚拟线程也被称为轻量级线程或用户态线程(对应 Go 语言中的协程 Goroutine),由 JVM 而非操作系统负责管理与调度。
线程模型的核心概念
要理解虚拟线程的价值,首先需要厘清操作系统线程、传统平台线程与虚拟线程三者之间的演进关系:
- 操作系统线程(OS Thread): 操作系统内核直接维护和调度的内核态线程。这类线程属于昂贵的系统资源,数量非常有限(例如 Linux 系统下单进程可创建的线程上限通常受内存与系统参数限制,一般在数万级别)。其创建与销毁均涉及系统调用(System Call),且线程阻塞与唤醒时的上下文切换需要从用户态陷入内核态,性能开销很大。
- 平台线程(Platform Thread):
传统 JVM 层面的线程(例如直接通过
new Thread()或传统线程池创建的线程)。平台线程本质上是对操作系统线程的 1:1 严格包装,一个平台线程固定绑定一个操作系统线程。因此,平台线程同样继承了高开销的特性:默认栈内存固定分配约 1MB,创建、销毁与上下文切换成本高昂,必须依赖线程池进行复用与数量管控。 - 虚拟线程(Virtual Thread):
由 JVM 自身管理与调度的轻量级线程。虚拟线程不再与操作系统线程 1:1 绑定,而是采用 M:N 调度模型——成千上万甚至百万个虚拟线程,共享并复用底层的少量平台线程(此时被复用的平台线程称为载体线程 Carrier Thread)。
- 轻量级特性:虚拟线程的栈内存按需动态分配在 JVM 堆上(初始仅需数百字节),上下文切换完全在 JVM 用户态内完成,无需陷入操作系统内核态。由于创建和销毁成本极低,虚拟线程完全不需要池化,随用随建、用完即毁即可。
[传统平台线程模型 1:1]
平台线程 1 <===> OS 内核线程 1
平台线程 2 <===> OS 内核线程 2 (单机受限于内存与内核句柄,数量上限约数万)
[JDK 21 虚拟线程模型 M:N]
虚拟线程 1 \
虚拟线程 2 +===> [ JVM 调度器 ] ===> 载体平台线程 1 <===> OS 内核线程 1
... / (用户态极速切换) 载体平台线程 2 <===> OS 内核线程 2
虚拟线程 100w/为什么需要虚拟线程
在没有虚拟线程的传统架构中,以 Tomcat 为代表的 Web 容器普遍采用 Thread-Per-Request(一个请求对应一个平台线程) 的处理模型:每当有一个客户端请求到达,容器就从线程池中分配一个平台线程专门负责处理该请求,直到响应结束才归还线程。
这种模型在面对高并发 I/O 密集型业务时存在明显的性能瓶颈:
- 并发规模受限:由于单进程可创建的平台线程数量是有限的(假设极限为 6 万个,实际业务中线程池通常仅配置数百个),在传统模型下,单机应用同一时刻能够并发受理的请求上限被死死限制在平台线程的数量上。
- 阻塞 I/O 导致线程资源枯竭:在真实的业务请求链路中,往往包含大量的阻塞式 I/O(BIO)操作,例如查询数据库、调用第三方 HTTP 接口、读写 Redis 等。当请求触发阻塞 I/O 时,当前的平台线程(以及底层绑定的 OS 线程)就会被挂起进入阻塞等待状态。
在高并发场景下,海量请求同时触发阻塞 I/O,会导致容器内的平台线程瞬间全部处于阻塞等待状态。最终的结果是:系统的线程资源被彻底耗尽,后续请求无法进入,但此时服务器的 CPU 利用率却非常低(CPU 大量时间在空等 I/O 返回)。
由此可见,平台线程资源的稀缺性成为了限制系统高并发吞吐量的核心瓶颈。虚拟线程的出现正是为了解决这一问题:让极其廉价的虚拟线程去承担阻塞 I/O 的等待工作,从而避免底层平台线程资源的耗尽。
虚拟线程的上下文切换机制
任何线程的运行都离不开线程栈(保存局部变量与方法调用帧)和程序计数器(记录当前执行到的指令位置)。虚拟线程之所以能够在少量平台线程上并发调度百万级任务,核心在于 JVM 在用户态实现的挂载(Mount)与卸载(Unmount)切换机制:
- 正常运行:当一个虚拟线程被调度执行时,JVM 会将其挂载(Mount)到一个空闲的载体平台线程上运行,此时拥有独立的虚拟线程栈和程序计数器。
- 遇到阻塞保存上下文:当虚拟线程在执行过程中触发了阻塞 I/O 操作(如网络读写、休眠等),JVM 会自动拦截该阻塞调用,将当前虚拟线程的虚拟线程栈、程序计数器等上下文信息保存(冻结)到 JVM 堆内存中,并将该虚拟线程从载体平台线程上卸载(Unmount)下来。
- 切换执行其他任务:卸载完成后,底层的载体平台线程并不会被阻塞,而是立即解放出来,由 JVM 调度器直接切换去挂载并执行其他处于就绪状态的虚拟线程。
- I/O 就绪恢复执行:当之前的阻塞 I/O 操作完成、数据准备就绪后,JVM 调度器会重新将该虚拟线程挂载到某个空闲的载体平台线程上,从堆内存中恢复之前保存的虚拟线程栈与程序计数器上下文,继续向下执行后续代码。
虚拟线程 VT-1: [执行业务代码] ──(触发阻塞 I/O)──> [保存栈与 PC 到堆内存,挂起等待 I/O] ──(I/O 完成)──> [恢复栈与 PC,继续执行]
│ (卸载 Unmount) ▲ (重新挂载 Mount)
▼ │
载体平台线程: [正在运行 VT-1] ─────────────────> [立即切换并运行 VT-2 / VT-3 ...] ────────────────────────┘
(底层平台线程始终保持运转,绝不阻塞空等)通过这种用户态的上下文保存与切换机制,开发者依然可以编写最直观、易于调试的同步阻塞式代码,而 JVM 在底层自动将其转化为了非阻塞的高并发调度,在大幅提升系统吞吐量的同时彻底榨干 CPU 性能。目前,包括 Tomcat、Jetty、Netty、Spring Boot(3.2+) 在内的主流 Java 框架与中间件均已原生支持虚拟线程。