八股-Java 基础
Java 基础相关面试题
======= Java 集合 =======

ArrayList 源码
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
@java.io.Serial
private static final long serialVersionUID = 8683452581122892189L;
/**
* 默认的初始容量。
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 用于空实例的共享空数组实例。
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 用于默认容量的空实例的共享空数组实例。
* 我们将其与 EMPTY_ELEMENTDATA 区分开来,
* 以便在添加第一个元素时知道要扩容多少。
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 用于存储 ArrayList 元素的数组缓冲区。
* ArrayList 的容量就是这个数组缓冲区的长度。
* 任何 elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA 的空 ArrayList,
* 在添加第一个元素时都会扩容到 DEFAULT_CAPACITY。
*/
transient Object[] elementData; // 非 private 以简化嵌套类的访问
/**
* ArrayList 的大小(包含的元素数量)。
*
* @serial
*/
private int size;
}
📒 transient
transient 是 Java 中的一个 关键字,用于标记 不希望被序列化的字段
🧠 什么是序列化?
序列化(Serialization)是指将对象的状态转换为字节流,以便:
- 保存到磁盘
- 通过网络传输
反序列化(Deserialization)是指从字节流中恢复出对象
Java 中支持序列化的类通常实现 java.io.Serializable 接口
🧩 transient 是干什么的?
当一个字段被标记为 transient,在序列化时,它的值不会被保存
也就是说,它不会被写入字节流中,在反序列化时这个字段会变成默认值(如 null 或 0)
✅ 示例:
class User implements Serializable { private String username; private transient String password; // 不想被序列化 // 构造函数 + getter/setter }
当你把 User 实例序列化时:
- username 会被写入
- password 不会被写入(反序列化后它是 null)
🧠 为什么使用 transient ?
常见原因如下:
场景 原因 安全性 不希望将敏感信息(如密码、身份证号)序列化保存 无意义 字段是临时用的,序列化后也没用(如缓存数据、连接池、日志等) 不可序列化 字段类型本身不可序列化,比如Thread,Socket,InputStream等 性能优化 避免序列化体积变大 🔎 ArrayList 中的 transient Object[] elementData
transient Object[] elementData;
这是为了:
- 防止 elementData 被直接序列化(因为它可能比实际大小要大)
- 在 ArrayList 自定义了 writeObject() 和 readObject() 方法,它会只序列化有效数据(size 个元素),而不是整个数组
- 这样可以 节省空间、提高性能
✅ 总结
特性 含义 transient关键字 表示该字段不参与序列化 序列化时会忽略 字段不会写入字节流 反序列化后为默认值 null、0、false等 常用于 安全、临时变量、不可序列化对象
ArrayList 构造函数
带初始化容量的构造函数
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
无参构造函数,默认创建空集合
public ArrayList() {
this.elementData = DEFAULT_CAPACITY_EMPTY_ELEMENTDATA;
}
说明:无参构造函数,默认创建空集合
将 collection 对象转换成数组,然后将数组的地址的赋给 elementData
public ArrayList(Collection<?> extends E c) {
Object[] a = c.toArray();
if ((size = a.length) != 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// replace with empty array.
elementData = EMPTY_ELEMENTDATA;
}
}
说明:将 collection 对象转换成数组,然后将数组的地址的赋给 elementData
ArrayList 添加和扩容操作(第1次添加数据)
添加操作
public boolean add(E e)
:添加元素到ArrayList中-
ensureCapacityInternal(size + 1);
:确保内部容量 -
elementData[size++] = e;
:将元素添加到数组中 -
return true;
:返回添加成功
-
确保内部容量
private void ensureCapacityInternal(int minCapacity)
:确保内部容量-
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
:计算容量
-
计算容量
DEFAULT_CAPACITY=10
:默认容量为10
private static int calculateCapacity(Object[] elementData, int minCapacity)
:计算容量-
if (elementData == DEFAULT_CAPACITY_EMPTY_ELEMENTDATA)
:如果elementData为默认空数组,则返回最大值和minCapacity的较大者 -
return minCapacity;
:返回minCapacity
-
扩容方法
private void grow(int minCapacity)
:扩容方法-
int oldCapacity = elementData.length;
:获取当前容量 -
int newCapacity = oldCapacity + (oldCapacity >> 1);
:增加原来容量的1.5倍 -
newCapacity - minCapacity < 0
:如果新容量小于minCapacity,则设置为minCapacity -
if (newCapacity - MAX_ARRAY_SIZE > 0)
:如果新容量超过最大数组大小,则设置为hugeCapacity(minCapacity)
-
elementData = Arrays.copyOf(elementData, newCapacity);
:数组拷贝
-
ArrayList 底层的实现原理
ArrayList底层是用动态的数组实现的
ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
ArrayList在添加数据的时候
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
- 返回添加成功布尔值
ArrayList list = new ArrayList(10)
中的list扩容几次
0 次
该语句只是声明和实例化了一个ArrayList,指定了容量为10,未扩容
如何实现数组和 List 之间的转换
- 数组转List,使用JDK中java.util.Arrays工具类的asList方法
- List转数组,使用List的toArray方法。无参toArray方法返回Object数组,传入初始化长度的数组对象,返回该对象数组
用Arrays.asList转换List后,如果修改了数组的内容,list受影响吗
- Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的是Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
List用toArray转数组后,如果修改了List内容,数组受影响吗
- list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
数组转List
public static void testArray2List(){
String[] strs = {"aaa","bbb","ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
}
public static void testArray2List(){
String[] strs = {"aaa","bbb","ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
strs[1]="ddd";
System.out.println("=================");
for (String s : list) {
System.out.println(s);
}
}
aaa
bbb
ccc
=================
aaa
ddd
ccc
List转数组
public static void testList2Array(){
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
}
public static void testList2Array(){
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
list.add("ddd");
System.out.println("=================");
for (String s : array) {
System.out.println(s);
}
}
aaa
bbb
ccc
=================
aaa
bbb
ccc
二叉树
满二叉树
完全二叉树
二叉搜索树(BST)
红黑树
- 什么是二叉树
二叉树中,每个节点最多拥有两个“叉”,即左子节点和右子节点。不强制要求每个节点都必须有两个子节点,有的节点可能仅有左子节点,有的节点可能仅有右子节点。二叉树的每个节点的左子树和右子树也各自满足二叉树的定义 - 什么是二叉搜索树
二叉搜索树(Binary Search Tree,BST),又称二叉查找树或有序二叉树。在树中的任意节点,其左子树中所有节点的值均小于该节点的值,而右子树中所有节点的值均大于该节点的值。二叉搜索树中不存在键值相等的节点。通常情况下,二叉搜索树搜索的时间复杂度为O(log n)
红黑树 (自平衡的二叉搜索树)
红黑树(Red-Black Tree):这是一种自平衡的二叉搜索树(BST),之前被称为平衡二叉B树(Symmetric Binary B-Tree)
🌳 什么是红黑树?
红黑树是一种 自平衡的二叉查找树(BST) ,它通过在每个节点添加一个“颜色位”(红或黑)并定义一组规则来保持平衡,确保最坏情况下的操作时间复杂度为 O(log n)
🧩 红黑树的特点
红黑树满足以下 5 个性质(核心):
- 每个节点是红色或黑色
- 根节点是黑色
- 所有叶子节点(NIL)是黑色(注意:红黑树中的 NIL 是逻辑上的 null,不是真正的节点)
- 红色节点不能有红色子节点(即不能连续两个红色节点)
- 从任一节点到其所有后代叶子节点的路径都包含相同数目的黑色节点
🎯 红黑树的目的
- 保证树的 高度不会过高,从而提高查找效率
- 在最坏情况下,红黑树的高度为 2 * log(n+1),相比 AVL 树更“宽松”,因此插入/删除效率更高
print("code-tabs: temp block")
📏 红黑树 vs AVL 树
对比项 | 红黑树 | AVL 树 |
---|---|---|
平衡度 | 相对平衡(黑高差距 ≤ 1) | 严格平衡(左右子树高度差 ≤ 1) |
查询效率 | 一般略低 | 更高 |
插入/删除效率 | 更高(旋转次数更少) | 较低(更频繁的旋转) |
应用场景 | 插入/删除频繁(如 TreeMap) | 查找频繁但修改少 |
🔄 红黑树的核心操作
1. 插入(Insert)
步骤:
新节点初始为红色
插入后可能违反红黑树的性质,需要通过:
- 颜色变换(recolor)
- 旋转(rotate)
来修复
可能需要多次修复,直到满足所有红黑性质
👉 常见的修复场景如下:
- 父亲是黑色:不需要处理
- 父亲是红色,叔叔是红色:变色
- 父亲是红色,叔叔是黑色或 null:旋转 + 变色
2. 删除(Delete)
删除更复杂,核心点在于:
删除黑色节点后可能会破坏黑高(路径黑色数量相等)的性质
需要使用“双重黑”的概念来修复平衡
修复操作包括:
- 兄弟是红色 → 旋转 + 变色
- 兄弟是黑色,子节点也是黑 → 兄弟变红,向上递归修复
- 兄弟是黑色,有红色子节点 → 合理旋转/变色解决双重黑
🔧 常见操作时间复杂度
操作 | 时间复杂度 |
---|---|
查找(Search) | O(log n) |
插入(Insert) | O(log n) |
删除(Delete) | O(log n) |
📚 应用场景
红黑树被广泛应用于各种需要有序存储、快速增删查的场景:
- TreeMap(有序键值对)
- TreeSet(有序集合)
- Linux 的 CFS 调度器(红黑树用于进程调度)
- Java 的 HashMap 在链表过长后也会转换为红黑树(提高查询效率)
🟩 一、插入过程(Red-Black Tree Insertion)
我们依次插入以下数字:10, 20, 30, 15, 25, 23
✅ 步骤 1:插入 10(根节点)
10(B)
- 红黑树要求根节点是黑色
✅ 步骤 2:插入 20(红)
10(B)
\
20(R)
- 父节点是黑色,插入红色,✅ 无需调整
✅ 步骤 3:插入 30(红)
10(B)
\
20(R)
\
30(R)
❌ 违反规则:红色节点 20 有红色子节点 30
- 父(20)是红,叔叔为空(黑)
- 属于「右右」结构 → 左旋 + 父子变色
修复后:
20(B)
/ \
10(R) 30(R)
✅ 步骤 4:插入 15(红)
20(B)
/ \
10(R) 30(R)
\
15(R)
❌ 再次违反规则(10 和 15 都是红色)
- 父(10)红,叔叔(30)红 → 变色处理
修复后:
20(B)
/ \
10(B) 30(B)
\
15(R)
✅ 步骤 5:插入 25(红)
20(B)
/ \
10(B) 30(B)
\ /
15(R)25(R)
- 插入在黑色节点 30 下,父红叔叔红 ❌ → 变色
修复后:
20(B)
/ \
10(B) 30(B)
\ /
15(R)25(R)
✅ 仍然符合红黑树规则
✅ 步骤 6:插入 23(红)
20(B)
/ \
10(B) 30(B)
\ /
15(R)25(R)
/
23(R)
❌ 25 是红色,其子节点 23 也是红色 → 违反规则(红红冲突)
- 属于「左右」结构 → 先右旋成右右,再左旋 + 变色
修复后结构如下:
20(B)
/ \
10(B) 25(B)
\ / \
15(R)23(R)30(R)
✅ 插入修复完毕,红黑树仍平衡
🟥 二、删除过程(Red-Black Tree Deletion)
我们现在从上一步的树中 删除节点 25(黑色) ,它是内部节点,有两个子节点(23 和 30)
🪓 步骤 1:删除 25,找到中序后继 30 替代
20(B)
/ \
10(B) 30(B)
\ /
15(R)23(R)
- 30 替代 25,原本 30 的子节点 23 是红色
- 删除了一个黑色节点,需要修复
🔄 步骤 2:修复「双黑」问题
我们会根据兄弟节点的颜色进行如下操作:
- 兄弟(23)是红色 → 旋转 + 变色
- 或兄弟为黑 + 无红子 → 变色并向上传递
- 最终恢复平衡
修复过程较复杂,但关键是通过旋转 + 变色使路径黑色节点数重新一致
散列表
散列函数的基本要求:
- 散列函数计算得到的散列值必须为大于等于0的正整数,因为hashValue需作为数组的下标
- 若key1等于key2,则经过散列后得到的哈希值也应相同,即:hash(key1) == hash(key2)
散列冲突
在实际情况下,寻找一个散列函数,使其对于不同的key
计算所得的散列值皆不相同,几乎是不可能实现的。即便如著名的MD5、SHA等哈希算法,也无法避免这种情况。这种现象被称为散列冲突,或称哈希冲突、哈希碰撞,即多个key
映射到同一个数组下标位置
在散列表中,数组的每个下标位置可称为“桶”(bucket)或“槽”(slot)。每个桶(槽)都对应一条链表,所有散列值相同的元素均被放置于相同槽位对应的链表中
当查找或删除一个元素时,通过散列函数计算出的槽,用于遍历链表查找或删除
平均情况下,基于链表法解决冲突时,查询的时间复杂度为O(1)
散列表可能会退化为链表,此时查询的时间复杂度将从O(1)退化为O(n)
将链表法中的链表改造为其他高效的动态数据结构,如红黑树,查询的时间复杂度将降至O(log n)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
HashMap 实现原理
HashMap的数据结构:底层使用hash表数据结构,即数组和链表或红黑树
- 当向HashMap中插入元素时,利用key的hashCode方法重新hash计算出当前对象在数组中的下标
- 存储时,若出现hash值相同的key,存在以下两种情况:
a. 若key相同,则覆盖原始值;
b. 若key不同(出现冲突),则将当前的key-value对放入链表或红黑树中 - 获取时,直接定位到hash值对应的下标,进一步判断key是否相同,从而找到对应值

jdk1.7 和 jdk1.8的 HashMap 有什么区别
JDK1.8之前采用拉链法。拉链法:将链表与数组相结合。即创建一个链表数组,其中每一格代表一个链表。若遇哈希冲突,则将冲突的值添加至链表中即可
JDK1.8在解决哈希冲突方面有所变化,当链表长度超过阈值(默认为8)且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。在扩容resize()
过程中,若红黑树拆分后的节点数不超过临界值6个,则退化成链表
HashMap 的 put 方法
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
根据键值key计算hash值得到数组索引
判断table[i] == null,条件成立,直接新建节点添加
如果table[i] != null,不成立
- 4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
- 4.2 判断table[i] 是否为TreeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
- 4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在直接覆盖value
插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold(数组长度*0.75),如果超过,进行扩容
🌟 一、源码结构入口
在 HashMap.java 中,调用 put(K key, V value) 实际会触发以下核心逻辑:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
🔢 二、hash() 方法 — 计算键的哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
🧠 解析:
- 使用 key.hashCode() 生成原始哈希值
- 与高 16 位右移做异或,提高哈希分布均匀性
- 结果作为 putVal 的 hash 入参
🚪 三、putVal 方法解析入口
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
按步骤解读其主要逻辑
✅ 1. 初始化 table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
- 如果当前数组为空,则执行 resize() 创建初始容量(默认 16)
✅ 2. 计算索引位置
int i = (n - 1) & hash;
- 通过 hash 值与 (n - 1) 做与运算,快速求模定位数组下标
- & 替代 %,效率更高
✅ 3. 没有碰撞,直接插入
if ((p = tab[i]) == null)
tab[i] = newNode(hash, key, value, null);
- 目标桶位为空,直接插入新节点
✅ 4. 发生哈希冲突
else {
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
情况分类处理:
情况 | 描述 | 处理方式 |
---|---|---|
✅ Key 相同 | 用新 value 替换旧值 | e = p; |
❌ Key 不同,链表结构 | 遍历链表末尾添加 | for (Node<K,V> e = p; e != null; e = e.next) |
❌ Key 不同,红黑树结构 | 插入红黑树 | ((TreeNode<K,V>)p).putTreeVal(...) |
✅ 5. 链表长度超限转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8
treeifyBin(tab, hash);
- 如果链表长度超过 8 且数组长度大于 64 → 转红黑树
✅ 6. 插入完成后处理返回值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
- 如果已存在键,则替换值,并返回旧值
- 否则返回 null
📈 四、自动扩容机制(resize)
当实际元素个数 > 阈值(threshold = 容量 * 负载因子)时触发:
if (++size > threshold)
resize();
resize() 过程:
- 扩容为原来的 2 倍
- 对原数据重新计算 hash
- 低位不变,高位迁移:保证高效分布
📌 五、时间复杂度总结
操作 | 时间复杂度 |
---|---|
插入 put | O(1) 平均,O(n) 最坏(hash 冲突极多) |
查找 get | O(1) 平均,O(logn) 最坏(红黑树) |
删除 remove | O(1) 平均 |
📋 六、完整流程图简述:
put(key, value)
↓
计算 hash
↓
hash & (n - 1) 定位索引
↓
[ ] 空桶 ——> 直接插入
[ ] 冲突 ——> 比较 key:
↓
[=] 相同 ——> 替换值
[≠] 不同 ——> 链表 / 红黑树插入
↓
检查链表长度 ——> 超 8 转红黑树
↓
检查 size 是否超过 threshold
↓
是 ——> resize 扩容

HashMap 的扩容机制
🧩 HashMap 的基本结构
- HashMap 底层是一个数组 + 链表/红黑树的结构
- 当发生哈希冲突时,会将相同哈希值的键值对存储在同一个桶的链表(或红黑树)中
🚀 触发扩容的条件
当 HashMap 中的元素数量超过 负载因子(load factor) × 当前数组容量(capacity) 时,会触发扩容
- 默认初始容量:16
- 默认负载因子:0.75
- 即当 size > 16 × 0.75 = 12 时触发扩容
🔄 扩容机制过程详解
1. 创建新数组
- 新数组容量是原来的两倍(capacity × 2)
2. 重新计算位置(Rehashing)
- 将原数组中的每个元素重新计算在新数组中的位置
- 新位置要么是原位置 index,要么是 index + oldCap
- 原因:HashMap 中使用的是 (n - 1) & hash 计算位置,扩容后 n 翻倍,导致高位发生变化
int newIndex = (oldHash & oldCap) == 0 ? oldIndex : oldIndex + oldCap;
3. 链表拆分重分布
- 每个链表不会重新计算每个节点的 hash,而是直接分成两个链表放到新数组的不同位置
- 如果原链表长度超过树化阈值,在新数组中如果仍然超过阈值,保留为红黑树;否则退化为链表
📌 为什么扩容是 2 倍?
因为 2 的幂次方可以简化 hash 定位逻辑 (n - 1) & hash,大大提高效率,避免使用取模 % 运算
⚠️ 扩容代价高
- 扩容是一个 O(n) 的操作(需要遍历旧数组中所有元素)
- 所以建议在已知数据量时提前设定初始容量,减少扩容次数
int expectedSize = 1000;
Map<String, String> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);
🧠 Rehashing 基本背景
- HashMap 使用 (n - 1) & hash 计算数组下标(即 bucket index)
- 这是因为容量 n 通常是 2 的幂,所以 (n - 1) & hash 等价于 hash % n 的高效实现
- 扩容时,n 变成原来的 2 倍,假设原容量为 oldCap,扩容后就是 newCap = oldCap * 2
🧮 关键点:位运算分析
扩容前后的 index 如何变化
扩容前
oldIndex = hash & (oldCap - 1);
扩容后
newIndex = hash & (newCap - 1); // newCap = 2 * oldCap
= hash & (2 * oldCap - 1)
= hash & (oldCap * 2 - 1)
由于 newCap = oldCap << 1,那么 (newCap - 1) 的二进制会多一位:
- 假设 oldCap = 16 (0b10000),oldCap - 1 = 0b01111
- 扩容后 newCap = 32 (0b100000),newCap - 1 = 0b11111
所以:
hash & 0b01111 -> 旧索引 oldIndex
hash & 0b11111 -> 新索引 newIndex
观察:
- newIndex 与 oldIndex 的差别就在于 多了高一位的判断(hash & oldCap)
✅ 核心结论:
- 如果 (hash & oldCap) == 0,说明新高位是 0,newIndex = oldIndex
- 否则,新高位是 1,newIndex = oldIndex + oldCap
🔁 举个例子:
假设:
- oldCap = 16,oldCap - 1 = 0b01111
- 某个键的 hash = 0b10011(十进制 19)
则:
- oldIndex = 0b10011 & 0b01111 = 0b00011 = 3
- hash & oldCap = 0b10011 & 0b10000 = 0b10000 != 0 ⇒ 高位是 1
- 所以 newIndex = oldIndex + oldCap = 3 + 16 = 19
🌲 好处
- 不需要重新计算 hash 值,只判断 hash & oldCap,即可得出元素应该留在原位置,还是移动到新位置(+oldCap)
- 保证 hash 分布的均匀性,避免过多冲突
- 极大提高了扩容过程的效率
总结
阶段 | 操作 |
---|---|
触发扩容 | size > capacity × loadFactor |
扩容策略 | 新容量 = 原容量 × 2 |
重定位元素 | 根据 hash & newCapacity 重新分布 |
性能建议 | 尽量减少扩容次数,预估大小提前设定 |
HashMap 的寻址算法
计算对象的hashCode()
,随后调用hash0
方法进行二次哈希
将hashCode
值右移16位后进行异或运算,以使哈希分布更加均匀
最终通过(capacity-1) & hash
获取索引
为何HashMap的数组长度一定是2的次幂?
- 扩容时重新计算索引的效率更高:当hash & oldCap == 0时,元素留在原位置;否则,新位置 = 旧位置 + oldCap
HashMap 在 1.7 下的多线程死循环问题
😨 问题描述
在 Java 1.7 中,如果多个线程在并发 put(),并且触发了 扩容(resize) 操作,可能会导致 链表成环(形成自引用),从而导致死循环(死链表) ,最终:
- CPU 占用飙升(死循环)
- 程序卡死甚至宕机
- HashMap.get() 永远得不到返回值
🧩 背景知识:1.7 中 HashMap 扩容过程
Java 1.7 中 HashMap 的底层结构是 数组 + 链表(无红黑树)
当发生扩容时,主要步骤是:
- 创建一个新数组,容量为原来的 2 倍
- 遍历旧数组,把旧链表中的元素复制到新数组中
- 复制链表时是头插法(头部插入) ,不是尾插!
❗ 问题出现的原因 并发 put() 时多个线程同时触发 resize()
例如:
- 线程 A 和 线程 B 同时触发扩容
- 线程 A 正在遍历并迁移某个桶里的链表
- 线程 B 把链表头部插入到了新数组中(因为是头插法)
- 两个线程交叉修改链表指针,最终可能导致环形链表(A -> B -> C -> A)!
这就出现了链表的自引用,即 循环引用(loop)
📉 死循环具体表现
当你之后使用 .get() 遍历这个链表时:
while (entry != null) {
if (entry.key.equals(key)) {
return entry.value;
}
entry = entry.next;
}
由于链表变成了一个“环”,entry.next 永远不为 null,导致程序进入无限循环,CPU 飙高
🛠️ Java 1.8 的修复方式
Java 1.8 做了两件关键改进:
- 迁移链表节点时改为尾插法(保持原顺序)
- 引入了 synchronized 控制线程安全的迁移过程
- 之后还引入了 红黑树优化链表性能
这些改动使得 HashMap 在多线程下虽然依旧不是线程安全,但至少 不会出现结构性损坏(如链表成环)
✅ 正确建议
在多线程环境下,绝对不要使用非线程安全的 HashMap
应该使用以下替代方案:
- ConcurrentHashMap
- Collections.synchronizedMap(new HashMap<>())(加外部锁)
- Hashtable(已过时,不推荐)
🧪 真实案例模拟(简略版)
以下代码在高并发下可能会复现死循环问题:
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
final int key = i;
new Thread(() -> map.put(key, key)).start();
}
运行后,有概率程序 hang 住、CPU 飙升
📌 总结
版本 | 是否会死循环 | 原因 |
---|---|---|
Java 1.7 | ✅ 有概率 | 多线程扩容 + 头插法导致链表成环 |
Java 1.8+ | ❌ 不会 | 尾插法 + 同步控制,结构更安全 |
String、StringBuffer、StringBuilder
可变性:
-
String
是不可变的(Immutable),一旦创建,内容无法修改,每次修改都会生成一个新的对象 -
StringBuilder
和StringBuffer
是可变的(Mutable),可以直接对字符串内容进行修改而不会创建新对象
-
线程安全性:
-
String
因为不可变,天然线程安全 -
StringBuilder
不是线程安全的,适用于单线程环境 -
StringBuffer
是线程安全的,其方法通过synchronized
关键字实现同步,适用于多线程环境
-
性能:
-
String
性能最低,尤其是在频繁修改字符串时会生成大量临时对象,增加内存开销和垃圾回收压力 -
StringBuilder
性能最高,因为它没有线程安全的开销,适合单线程下的字符串操作 -
StringBuffer
性能略低于StringBuilder
,因为它的线程安全机制引入了同步开销
-
使用场景:
- 如果字符串内容固定或不常变化,优先使用
String
- 如果需要频繁修改字符串且在单线程环境下,使用
StringBuilder
- 如果需要频繁修改字符串且在多线程环境下,使用
StringBuffer
- 如果字符串内容固定或不常变化,优先使用
情况 | 推荐写法 | 原因 |
---|---|---|
一次性拼接(少量) | "a" + b + c | 编译器自动优化为StringBuilder |
多次拼接(循环中) | StringBuilder | 避免创建多个临时字符串对象 |
特性 | String | StringBuilder | StringBuffer |
---|---|---|---|
不可变性 | 不可变 | 可变 | 可变 |
线程安全 | 是(因不可变) | 否 | 是(同步方法) |
性能 | 低(频繁修改时) | 高(单线程) | 中(多线程安全) |
适用场景 | 静态字符串 | 单线程动态字符串 | 多线程动态字符串 |
// String的不可变性
String str = "abc";
str = str + "def"; // 新建对象,str指向新对象
// StringBuilder(单线程高效)
StringBuilder sb = new StringBuilder();
sb.append("abc").append("def"); // 直接修改内部数组
// StringBuffer(多线程安全)
StringBuffer sbf = new StringBuffer();
sbf.append("abc").append("def"); // 同步方法保证线程安全
======= Java 异常 =======
✅ 一、Java 异常的整体分类
Java 中所有异常都继承自 java.lang.Throwable
Throwable
/ \
Error Exception
/ \
CheckedException UncheckedException

📚 二、详细解释 1. Error (错误)❗不可恢复,不需要处理
系统级严重问题,开发者 不应尝试捕获
典型代表:
- OutOfMemoryError:内存溢出
- StackOverflowError:栈溢出
- NoClassDefFoundError:类找不到
2. Exception (异常)✅ 需要/可以处理
✅ 2.1. Checked Exception(受检查异常)
- 编译时必须处理(要么 try-catch,要么 throws)
- 不处理编译就报错
典型例子:
异常类型 | 描述 |
---|---|
IOException | IO 错误 |
SQLException | 数据库错误 |
ClassNotFoundException | 找不到类 |
❗2.2. Unchecked Exception(运行时异常)
- 编译时 不强制处理
- 程序逻辑出错,运行时抛出
典型例子:
异常类型 | 描述 |
---|---|
NullPointerException | 空指针异常 |
ArrayIndexOutOfBoundsException | 数组越界 |
ArithmeticException | 除以 0 |
IllegalArgumentException | 参数非法 |
🎯 三、为什么要分 Checked 和 Unchecked?
- Checked 异常:编译器要求你必须考虑和处理 → 更稳健
- Unchecked 异常:太常见,强制处理会让代码臃肿 → 自己负责逻辑合理性
✍️ 四、代码示例
public void readFile(String path) throws IOException {
FileReader reader = new FileReader(path); // Checked Exception
}
public void divide(int a, int b) {
int result = a / b; // Unchecked Exception,b=0 会报错
}
🧠 五、异常分类顺口溜
异常顶层 Throwable,分成错误与异常;
Error 致命不捕捉,OOM 栈爆很常见;
Exception 再细看,Checked 编译需处理;
运行异常不强求,空指针越界打头炮;
IOException 要捕捉,数据库错别放掉;
逻辑漏洞靠你写,捕获抛出要分明!
✅ 六、面试重点小结
问题 | 答案概要 |
---|---|
Java 异常分哪几类? | Throwable → Error / Exception |
Checked 和 Unchecked 区别? | 编译是否强制处理 |
RuntimeException 是哪类异常? | Unchecked 异常 |
什么异常必须 try-catch? | Checked 异常 |
======= Java 新特性 =======
Java 8 主要新特性
特性名称 | 描述 | 示例或说明 |
---|---|---|
Lambda 表达式 | 简化匿名内部类,支持函数式编程 | (a, b) -> a + b 代替匿名类实现接口 |
函数式接口 | 仅含一个抽象方法的接口,可用 @FunctionalInterface 注解标记 | Runnable, Comparator , 或自定义接口 @FunctionalInterface interface MyFunc { void run(); } |
Stream API | 提供链式操作处理集合数据,支持并行处理 | list.stream().filter(x -> x > 0).collect(Collectors.toList()) |
Optional 类 | 封装可能为 null 的对象,减少空指针异常 | Optional.ofNullable(value).orElse("default") |
方法引用 | 简化 Lambda 表达式,直接引用现有方法 | System.out::println 等价于 x -> System.out.println(x) |
接口的默认方法与静态方法 | 接口可定义默认实现和静态方法,增强扩展性 | interface A { default void print() { System.out.println("默认方法"); } } |
并行数组排序 | 使用多线程加速数组排序 | Arrays.parallelSort(array) |
重复注解 | 允许同一位置多次使用相同注解 | @Repeatable 注解配合容器注解使用 |
类型注解 | 注解可应用于更多位置(如泛型、异常等) | List<@NonNull String> list |
CompletableFuture | 增强异步编程能力,支持链式调用和组合操作 | CompletableFuture.supplyAsync(() -> "result").thenAccept(System.out::println) |
Lambda 表达式
Java 的 Lambda 表达式 是 Java 8 引入的一大亮点,是函数式编程风格在 Java 中的体现。可以更简洁、更优雅地写匿名内部类,尤其在集合操作、并发编程、事件监听等场景非常常见
Lambda 表达式是“匿名函数”的简洁写法,本质是实现函数式接口的一个实例
🔹 基本语法
(参数列表) -> { 方法体 }
传统写法:
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
Lambda 写法:
Runnable r = () -> System.out.println("Hello");
🔸 Lambda 的完整结构
(参数1, 参数2, ...) -> {
// 方法体
return 返回值;
}
- 如果只有一个参数,() 可以省略
- 如果只有一条语句,{} 和 return 都可以省略
🧩 Lambda 使用条件:函数式接口
一个只包含一个抽象方法的接口(可以有默认方法)
@FunctionalInterface
interface MyFunction {
int apply(int x, int y);
}
就可以用 Lambda 表达式来实现它:
MyFunction add = (a, b) -> a + b;
System.out.println(add.apply(2, 3)); // 输出 5
🛠️ 常见场景举例
List<String> list = Arrays.asList("A", "B", "C");
list.forEach(s -> System.out.println(s));
list.forEach(System.out::println);
list.sort((a, b) -> a.compareToIgnoreCase(b));
new Thread(() -> System.out.println("Running")).start();
🔍 Java 内置函数式接口(java.util.function)
接口名 | 参数 | 返回值 | 功能 |
---|---|---|---|
Function<T, R> | T | R | 接收 T,返回 R |
Consumer<T> | T | void | 消费一个 T |
Supplier<T> | 无 | T | 提供一个 T |
Predicate<T> | T | boolean | 判断是否满足条件 |
Lambda表达式使得Java支持函数式编程范式,允许将函数作为参数传递,从而可以编写更灵活、可复用的代码。比如定义一个通用的计算函数
Lambda 表达式使得 Java 支持函数式编程范式,允许将函数作为参数传递,从而可以编写更灵活、可复用的代码。比如定义一个通用的计算函数
interface Calculator {
int calculate(int a, int b);
}
public class FunctionalProgrammingExample {
public static int operate(int a, int b, Calculator calculator) {
return calculator.calculate(a, b);
}
public static void main(String[] args) {
// 使用 Lambda 表达式传递加法函数
int sum = operate(3, 5, (x, y) -> x + y);
System.out.println("Sum: " + sum);
// 使用 Lambda 表达式传递乘法函数
int product = operate(3, 5, (x, y) -> x * y);
System.out.println("Product: " + product);
}
}
虽然 Lambda 表达式优点蛮多的,不过也有一些缺点,比如会增加调试困难,因为 Lambda 表达式是匿名的,在调试时很难定位具体是哪个 Lambda 表达式出现了问题。尤其是当 Lambda 表达式嵌套使用或者比较复杂时,调试难度会进一步增加
Stream API
Stream 是对集合元素的高效处理工具,它支持链式操作,如过滤(filter)、映射(map)、聚合(reduce)等,可以让你像写 SQL 一样处理集合
- 不是集合本身,而是数据管道(pipeline)
- 一次性消费,不可复用
- 惰性执行:只有终止操作才真正执行
🎯 使用流程(3 步):
collection.stream() // 1. 创建 Stream
.filter(...) // 2. 中间操作(可链式)
.map(...)
.sorted(...)
.collect(...) // 3. 终止操作(触发执行)
✨ 常用操作举例
假设我们有如下列表:
List<String> names = Arrays.asList("Tom", "Jerry", "Tim", "Alice", "Tina");
过滤 filter()
List<String> result = names.stream()
.filter(name -> name.startsWith("T"))
.collect(Collectors.toList());
// [Tom, Tim, Tina]
映射 map()
List<Integer> lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
// [3, 5, 3, 5, 4]
mapToInt()
mapToInt() 是 Stream API 中的一个非常重要的特化方法,主要用于处理基本数据类型流(如 int、long、double),以提高性能并提供更多数值操作
mapToInt() 会将对象流(Stream<T>)转换为 IntStream,也就是一个 int 类型的专用流,提供如 sum()、average()、max() 等数值方法
假设有一个字符串列表,代表人的名字:
List<String> names = Arrays.asList("Tom", "Jerry", "Tim", "Alice", "Tina");
计算所有名字的总长度:
int totalLength = names.stream()
.mapToInt(String::length) // 将每个 String 映射为它的长度(int)
.sum(); // IntStream 中的求和方法
System.out.println(totalLength); // 输出总长度
使用 mapToInt / mapToLong / mapToDouble 可以:
✅ 避免自动装箱(性能更高)
✅ 使用更多数值操作(如 sum、average)
✅ 适用于数值统计场景
排序 sorted()
List<String> sorted = names.stream()
.sorted()
.collect(Collectors.toList());
// [Alice, Jerry, Tim, Tina, Tom]
去重 distinct()
List<String> distinct = names.stream()
.distinct()
.collect(Collectors.toList());
匹配 anyMatch / allMatch / noneMatch
boolean anyT = names.stream().anyMatch(s -> s.startsWith("T")); // true
boolean allT = names.stream().allMatch(s -> s.startsWith("T")); // false
聚合 reduce()
Optional<String> reduced = names.stream()
.reduce((a, b) -> a + "-" + b);
// Tom-Jerry-Tim-Alice-Tina
统计 count() / max() / min()
long count = names.stream().filter(s -> s.length() > 3).count();
Optional<String> max = names.stream().max(Comparator.comparing(String::length));
📦 终止操作 collect()
收集结果的最常用方式是使用 Collectors 工具类:
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
String joined = stream.collect(Collectors.joining(","));
Map<String, Integer> map = stream.collect(Collectors.toMap(s -> s, String::length));
💡 小贴士
注意点 | 描述 |
---|---|
Stream 是一次性的 | 调用终止操作后不能再用 |
惰性求值 | 只有终止操作才会触发执行 |
不改变原集合 | 所有操作都是“非破坏性”的 |
Stream 流的并行 API
并行流 = 多线程版的 Stream,会将数据分段后用多个线程处理,从而提升执行效率
并行流(ParallelStream)就是将源数据分为多个子流对象进行多线程操作,然后将处理的结果再汇总为一个流对象,底层是使用通用的 fork/join 池
来实现,即将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合并成总的计算结果
使用: 只需把 stream()
换成 parallelStream()
或在已有流上调用 .parallel()
示例: 求 1~10 亿的平方和
long start = System.currentTimeMillis();
long sum = LongStream.rangeClosed(1, 1_000_000_000)
.parallel() // 开启并行流
.map(x -> x * x)
.sum();
System.out.println("结果:" + sum);
System.out.println("耗时:" + (System.currentTimeMillis() - start) + " ms");

收集器也支持并行 partitioningBy()
Map<Boolean, List<Integer>> result = IntStream.range(1, 100)
.parallel()
.boxed()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
让并行流顺序输出 forEachOrdered()
list.parallelStream().forEachOrdered(System.out::println);
对CPU密集型的任务来说,并行流使用ForkJoinPool线程池,为每个CPU分配一个任务,这是非常有效率的,但是如果任务不是CPU密集的,而是I/O密集的,并且任务数相对线程数比较大,那么直接用ParallelStream并不是很好的选择
completableFuture
CompletableFuture是由Java 8引入的,在Java8之前一般通过Future实现异步
- Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8之前若要设置回调一般会使用guava的ListenableFuture,回调的引入又会导致臭名昭著的回调地狱
- CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题
回调地狱:
通过ListenableFuture、CompletableFuture来实现异步的差异。假设有三个操作step1、step2、step3存在依赖关系,其中step3的执行依赖step1和step2的结果
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
ListeningExecutorService guavaExecutor = MoreExecutors.listeningDecorator(executor);
ListenableFuture<String> future1 = guavaExecutor.submit(() -> {
//step 1
System.out.println("执行step 1");
return "step1 result";
});
ListenableFuture<String> future2 = guavaExecutor.submit(() -> {
//step 2
System.out.println("执行step 2");
return "step2 result";
});
ListenableFuture<List<String>> future1And2 = Futures.allAsList(future1, future2);
Futures.addCallback(future1And2, new FutureCallback<List<String>>() {
@Override
public void onSuccess(List<String> result) {
System.out.println(result);
ListenableFuture<String> future3 = guavaExecutor.submit(() -> {
System.out.println("执行step 3");
return "step3 result";
});
Futures.addCallback(future3, new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
System.out.println(result);
}
@Override
public void onFailure(Throwable t) {
}
}, guavaExecutor);
}
@Override
public void onFailure(Throwable t) {
}
}, guavaExecutor);
}
CompletableFuture的实现如下:
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行step 1");
return "step1 result";
}, executor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行step 2");
return "step2 result";
});
cf1.thenCombine(cf2, (result1, result2) -> {
System.out.println(result1 + " , " + result2);
System.out.println("执行step 3");
return "step3 result";
}).thenAccept(System.out::println);
}

CompletableFuture
实现了两个接口:Future
、CompletionStage
- Future表示异步计算的结果,
CompletionStage
用于表示异步执行过程中的一个步骤(Stage),这个步骤可能是由另外一个CompletionStage
触发的,随着当前步骤的完成,也可能会触发其他一系列CompletionStage
的执行 - 可以根据实际业务对这些步骤进行多样化的编排组合,
CompletionStage
接口正是定义了这样的能力,我们可以通过其提供的thenAppy
、thenCompose
等函数式编程方法来组合编排这些步骤
Java 21 新特性
新新语言特性:
- Switch 语句的模式匹配:该功能在 Java 21 中也得到了增强。它允许在
switch
的case
标签中使用模式匹配,使操作更加灵活和类型安全,减少了样板代码和潜在错误。例如,对于不同类型的账户类,可以在switch
语句中直接根据账户类型的模式来获取相应的余额,如case savingsAccount sa -> result = sa.getSavings();
- 数组模式:将模式匹配扩展到数组中,使开发者能够在条件语句中更高效地解构和检查数组内容。例如,
if (arr instanceof int[] {1, 2, 3})
,可以直接判断数组arr
是否匹配指定的模式 - 字符串模板(预览版) :提供了一种更可读、更易维护的方式来构建复杂字符串,支持在字符串字面量中直接嵌入表达式。例如,以前可能需要使用
"hello " + name + ", welcome to the geeksforgeeks!"
这样的方式来拼接字符串,在 Java 21 中可以使用hello {name}, welcome to the geeksforgeeks!
这种更简洁的写法
新并发特性方面:
- 虚拟线程:这是 Java 21 引入的一种轻量级并发的新选择。它通过共享堆栈的方式,大大降低了内存消耗,同时提高了应用程序的吞吐量和响应速度。可以使用静态构建方法、构建器或
ExecutorService
来创建和使用虚拟线程 - Scoped Values(范围值) :提供了一种在线程间共享不可变数据的新方式,避免使用传统的线程局部存储,促进了更好的封装性和线程安全,可用于在不通过方法参数传递的情况下,传递上下文信息,如用户会话或配置设置
序列化
序列化:对象在 JVM 之间传输的实现方式
如何将对象从一个 JVM 传输到另一个 JVM:
- 使用序列化和反序列化:对象转为字节流,通过网络发送到另一个 JVM,接收方反序列化还原对象。常用 Java 的 ObjectOutputStream 和 ObjectInputStream 实现
- 使用消息传递机制:借助消息队列(如 RabbitMQ、Kafka)或网络通信传递序列化后的对象
- 使用远程方法调用(RPC) :通过 gRPC 等框架在 JVM 之间远程调用方法,实现对象传输
- 使用共享数据库或缓存:将对象存入共享数据库(如 MySQL、PostgreSQL)或缓存(如 Redis),供多个 JVM 访问
自己实现序列化/反序列化需要考虑的问题:
Java 默认序列化的缺点:
- 不支持跨语言,其他语言难以解析 Java 的序列化格式
- 安全性低,readObject() 可能被利用执行恶意代码
- 效率低,占用空间大,传输慢
建议使用更高效、安全的序列化方式:
推荐:FastJson、Protobuf 等跨语言、高性能序列化方案
Protobuf 优势:
- 使用 .proto 文件定义结构
- 编码压缩率高、传输效率高
- 可跨语言使用,性能优秀
双重检查锁实现单例
public class SingleTon {
// volatile 关键字修饰变量 防止指令重排序
private static volatile SingleTon instance = null;
private SingleTon(){}
public static SingleTon getInstance(){
if(instance == null){
//同步代码块 只有在第一次获取对象的时候会执行到 ,第二次及以后访问时 instance变量均非null故不会往下执行了 直接返回啦
synchronized(SingleTon.class){
if(instance == null){
instance = new SingleTon();
}
}
}
return instance;
}
}
正确的双重检查锁定模式需要需要使用 volatile。volatile主要包含两个功能
- 保证可见性。使用 volatile 定义的变量,将会保证对所有线程的可见性
- 禁止指令重排序优化
由于 volatile 禁止对象创建时指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性
======= Java IO =======
具体看 Netty
NIO
三个组件:channel、buffer、selector
Channel 有一点类似于 Stream,它便是读写数据的双向通道
数据可从 Channel 读取入 Buffer,亦可将 Buffer 的数据写入 Channel
而 Stream,要么是输入,要么是输出,Channel 相较于 Stream 来说更为底层
selector
- 内存占用高(每个线程都要占一定的内存)
- 线程上下文切换成本高
- 只适合连接数少的场景
- 阻塞模式下,线程仅能处理一个 socket 连接(如果 socket1 不发送消息,线程就无法对 socket3 提供服务)
- 仅适合短连接场景
selector 的功能在于与一个线程协同,以管理多个 channel,并捕获这些 channel 上发生的事件。这些 channel 在非阻塞模式下运作,确保线程不会因某个 channel 而陷入停滞。适用于连接数众多但流量较低的场景(low traffic)
调用selector
的select()
方法会阻塞,直至channel
发生了读写就绪事件。一旦这些事件发生,select
方法将返回这些事件,并交由相应的thread
进行处理
多路复用
单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用
多路复用仅针对网络 IO,普通文件 IO 没法利用多路复用
如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证
有可连接事件时才去连接
有可读事件才去读取
有可写事件才去写入
- 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件
一个线程配合 selector 就可以监控多个 channel 的事件,事件发生线程才去处理。避免非阻塞模式下所做无用功
让这个线程能够被充分利用
节约了线程的数量
减少了线程上下文切换
NIO vs BIO
stream vs channel
- stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区(更为底层)
- stream 仅支持阻塞 API,channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用
- 二者均为全双工,即读写可以同时进行
IO 模型
网络I/O模型分类
- 同步阻塞、同步非阻塞、同步多路复用、异步非阻塞(无此情况)
- 同步:线程自行获取结果(单线程)
- 异步:线程不自行获取结果,而是由其他线程传递结果(至少两个线程)
当执行一次 channel.read
或 stream.read
调用后,会切换至操作系统内核态以完成实际的数据读取。读取过程分为两个阶段,分别为:
- 等待数据阶段
- 复制数据阶段

阻塞 IO
非阻塞 IO
多路复用
信号驱动
异步 IO
阻塞 IO vs 多路复用
🔖 参考
UNIX 网络编程 - 卷 I
零拷贝
传统 IO 问题
传统的 IO 将一个文件通过 socket 写出
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()];
file.read(buf);
Socket socket = new Socket(host, port);
socket.getOutputStream().write(buf);
内部工作流程是这样的:

Java 本身不具备 IO 读写能力,因此 read 方法调用后,需从 Java 程序的用户态切换至内核态,调用操作系统的读能力,将数据读入内核缓冲区。在此期间,用户线程会阻塞,操作系统通过 DMA(Direct Memory Access)实现文件读操作,且不使用 CPU
DMA 也可理解为一种硬件单元,用于解放 CPU 完成文件 IO
从内核态切换回用户态,数据从内核缓冲区读入用户缓冲区(即 byte[] buf),此过程中 CPU 会参与数据拷贝,DMA 无法被利用
调用 write 方法时,数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,CPU 会参与这一数据拷贝过程
接下来,需向网卡写入数据,Java 同样不具备这一能力,因此需再次从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,CPU 不会参与此过程
中间环节较多,Java 的 IO 实际上并非物理设备级别的读写,而是缓存数据的复制,底层的真正读写操作由操作系统完成
- 用户态与内核态的切换共发生 3 次,这一操作较为重量级
- 数据总共进行了 4 次拷贝
NIO 优化
通过 DirectByteBuffer
- ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存
- ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存

大部分步骤与优化前相同,唯有一点:java 可以使用 DirectByteBuffer 将堆外内存映射到 jvm 内存中来直接访问使用
这块内存不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
java 中的 DirectByteBuffer 对象仅维护了此内存的虚引用,内存回收分成两步
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
减少了一次数据拷贝,用户态与内核态的切换次数没有减少
进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据

- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 cpu
- 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到
- 只发生了一次用户态与内核态的切换
- 数据拷贝了 3 次
进一步优化(linux 2.4)

- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 cpu
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中,零拷贝的优点有
- 更少的用户态与内核态的切换
- 不利用 cpu 计算,减少 cpu 缓存伪共享
- 零拷贝适合小文件传输