一、基础知识部分
Q:java面向对象三大特性?
A:封装、继承、多态
Q:反射原理以及使用场景
Q:Java中的浅拷贝和深拷贝
- 浅拷贝:只复制对象本身的基本数据类型字段,对于引用类型的字段,只复制内存地址(引用),新旧对象共享同一个子对象
- 深拷贝:不仅复制对象本身,还递归复制所有引用的子对象,新旧对象完全独立,互不影响。
使用场景,对一个List users集合进行拷贝,如果执行浅拷贝后生成新集合 新集合的元素和老的是共用对象引用的,修改新集合某个对象元素的内容,会导致老集合一起被更新
Q: String、StringBuffer、StringBuilder 的区别?
源码分析:csp1999.blog.csdn.net/article/det…
- String:被final关键字修饰不可变,线程安全;每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。
- StringBuilder:字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。线程不安全
- StringBuffer:线程安全,append()、length()等关键方法都被 synchronized 加锁。
Q:String#equals() 和 Object#equals() 有何区别?
String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。
Q: JDK 动态代理和 CGLIB 动态代理有什么区别?
| 对比维度 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 实现原理 | 基于接口,实现目标接口 | 基于继承,生成目标类的子类 |
| 要求 | 目标类必须实现接口 | 目标类无需接口,但不能是final类 |
| 生成方式 | 反射机制生成代理类 | ASM字节码框架生成子类 |
| 性能 | 创建快,调用稍慢(反射) | 创建稍慢,调用较快(直接字节码) |
| 依赖 | JDK内置,无需额外依赖 | 需要引入cglib库 |
| final方法 | 不影响(接口无final方法) | 无法代理final方法 |
┌─────────────────────────────────────
│ 有接口 → 两者都能用,JDK是默认选择
│ 无接口 → 只能用 CGLIB
│ final类 → 两者都不能代理
│ final方法→ CGLIB无法代理,JDK无此问题
└─────────────────────────────────────
JDK代理调用链:
proxy.save() → InvocationHandler.invoke() → Method.invoke()【反射】→ 目标方法
CGLIB调用链:
proxy.save() → MethodInterceptor.intercept() → MethodProxy.invokeSuper()【字节码】→ 目标方法
Q:BIO、NIO 和 AIO 的区别?
TODO
Q:IO多路复用
TODO
二、Java集合部分
Java集合体系
graph TB
A[Java集合框架] --> B[Collection\n单列集合]
A --> C[Map\n双列集合]
B --> D[List\n有序可重复]
B --> E[Set\n无序不重复]
B --> F[Queue\n队列]
D --> D1[ArrayList]
D --> D2[LinkedList]
D --> D3[Vector]
D3 --> D4[Stack]
E --> E1[HashSet]
E --> E2[LinkedHashSet]
E --> E3[TreeSet]
E --> E4[EnumSet]
F --> F1[ArrayDeque]
F --> F2[LinkedList]
F --> F3[PriorityQueue]
F --> F4[BlockingQueue]
F4 --> F5[ArrayBlockingQueue]
F4 --> F6[LinkedBlockingQueue]
F4 --> F7[PriorityBlockingQueue]
F4 --> F8[SynchronousQueue]
F4 --> F9[DelayQueue]
C --> C1[HashMap]
C --> C2[LinkedHashMap]
C --> C3[TreeMap]
C --> C4[Hashtable]
C --> C5[EnumMap]
C --> C6[ConcurrentHashMap]
C4 --> C7[Properties]
style A fill:#4A90D9,color:#fff
style B fill:#7B68EE,color:#fff
style C fill:#7B68EE,color:#fff
style D fill:#E8A838,color:#fff
style E fill:#E8A838,color:#fff
style F fill:#E8A838,color:#fff
1.ArrayList
1. 数据结构
ArrayList 底层是动态数组,本质上维护了一个 Object[] 数组。它支持随机访问,查询快,但插入删除慢。
2. 初始容量
在 JDK 8 中,new ArrayList() 时并不会立刻创建长度为 10 的数组,而是先使用容量为 0 的空数组。第一次添加元素时才扩容到默认容量 10。
如果使用 new ArrayList(int initialCapacity),则初始容量为指定值。
2. 扩容机制
-
当add新元素时,如果当前数组容量不足以容纳新元素,就会触发扩容。即当 size + 1 > elementData.length 时扩容。
-
每次扩容为原容量的 1.5 倍,即:
// >>1 右移一位相当于除 2
newCapacity = oldCapacity + (oldCapacity >> 1)
- 扩容出触发后,创建一个新的数组,然后把老的数据拷贝过去(Arrays.copyOf())
2.LinkedList
数据结构
LinkedList 基于双向链表实现,插入、删除、更新快,查询慢,不支持随机访问。
LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,不能实现 RandomAccess 接口。
3. HashMap
1. 数据结构
JDK1.7 及之前底层是数组和链表(链表散列)。
JDK1.8 之后底层是数组和链表加红黑树。
为什么JDK8链表改为尾插法?
- 扩容时保持链表顺序
- 避免头插法并发扩容时遇到死循环问题
2. 初始容量
// 为什么容量必须是2的幂次方?
// 方便用位运算代替取模:hash % capacity = hash & (capacity-1)
// 位运算效率远高于取模运算!
// tableSizeFor:找到大于等于cap的最小2的幂次方
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// 传入10 → 返回16
// 传入17 → 返回32
3. 扩容机制
-
HashMap扩容时每次容量变为原来的两倍;新的扩容阈值为新容量*0.75
-
当桶的数量小于64时不会进行树化,只会扩容;
-
当桶的数量大于64且单个桶中元素的数量大于8时,进行树化;
-
当单个桶中元素数量小于6时,进行反树化;
为什么树化阈值是8?
链表长度符合泊松分布长度为8的概率约为 0.00000006(极低)
红黑树查询O(logn) vs 链表O(n),转换有额外内存开销(TreeNode是Node的2倍大小)长度8时收益才大于成本。
为什么退化阈值是6而不是8?
避免频繁在8附近增删导致树与链表反复转换,6和8之间留有缓冲区。
扩容后旧数组迁移过程?
graph TB
A[遍历旧数组每个桶] --> B{桶中元素}
B -->|空桶| C[跳过]
B -->|单节点| D[直接rehash放入新桶]
B -->|链表| E[拆分低位链/高位链\n分别放入新桶]
B -->|红黑树| F[split拆分\n节点<=6转链表\n节点>6保持红黑树]
PS:搬移元素,原链表分化成两个链表,低位链表节点存储在原来桶的位置,高位链表搬移到原来桶的位置加旧容量的位置;目的是在扩容数组上,将原来链表上的hash冲突打散。
4. HashMap如何解决Hash冲突?如何定位桶位
graph LR
A[key] --> B[key.hashCode\n原始hash值]
B --> C[扰动函数\nhash ^ hash>>>16\n高16位异或低16位]
C --> D[桶位计算\nhash & n-1\nn为数组长度]
D --> E[定位到具体桶]
style C fill:#E8A838,color:#fff
style D fill:#4A90D9,color:#fff
// 第一步:计算hash(扰动函数)
static final int hash(Object key) {
int h;
// null的hash固定为0,放在桶0
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 为什么要高16位异或低16位?
// hashCode是32位,桶位计算只用低位(hash & n-1)
// 高位信息被浪费,容易产生冲突
// 扰动函数让高位参与运算,降低hash碰撞概率
// 第二步:计算桶位
int index = hash & (n - 1);
// 等价于 hash % n,但位运算更快
// 前提:n必须是2的幂次方!
// 举例:
// n=16,n-1=15=0000 1111
// hash=1010 1100 & 0000 1111 = 0000 1100 = 桶12
5. HashMap中put方法的执行流程?
6. HashMap vs Hashtable vs ConcurrentHashMap?
| 对比维度 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | ❌ 不安全 | ✅ 安全(方法级synchronized) | ✅ 安全(CAS+synchronized) |
| 锁粒度 | 无锁 | 整个对象锁(锁全表) | JDK7:分段锁 / JDK8:锁单个桶 |
| 性能 | 最高 | 最低(全表锁竞争激烈) | 较高(细粒度锁) |
| null Key | ✅ 允许1个 | ❌ 不允许 | ❌ 不允许 |
| null Value | ✅ 允许 | ❌ 不允许 | ❌ 不允许 |
| 底层结构 | 数组+链表+红黑树 | 数组+链表 | 数组+链表+红黑树 |
| 初始容量 | 16 | 11 | 16 |
| 扩容倍数 | 2倍 | 2倍+1 | 2倍 |
| 扩容时机 | 容量*0.75 | 容量*0.75 | 容量*0.75 |
| 链表转红黑树 | ✅ 链表长度≥8 | ❌ 不支持 | ✅ 链表长度≥8 |
PS:HashSet底层就是基于HashMap实现,不需要额外做比较
HashMap和TreeMap的区别?
7. HashMap vs TreeMap
| 对比维度 | HashMap | TreeMap |
|---|---|---|
| 底层结构 | 数组+链表+红黑树 | 红黑树 |
| 是否有序 | ❌ 无序 | ✅ 按Key自然排序或自定义排序 |
| 查询性能 | O(1) | O(log n) |
| 插入性能 | O(1) | O(log n) |
| null Key | ✅ 允许1个 | ❌ 不允许 |
| null Value | ✅ 允许 | ✅ 允许 |
| 排序方式 | 不支持 | 自然排序/Comparator自定义 |
| 适用场景 | 大多数键值对存储 | 需要范围查询/排序场景 |
4.CurrentHashMap
1. 数据结构
JDK1.7 的 ConcurrentHashMap 底层采用分段的数组 + 链表实现,固定位16分段,每一段都是一个独立的HashMap结构,可以独立进行扩容。
flowchart LR
M[ConcurrentHashMap]
M --> SA[Segment 数组]
SA --> S0[Segment 0]
SA --> S1[Segment 1]
SA --> S3[Segment 16]
S1 --> T0[HashEntry 数组]
T0 --> B00[桶0]
T0 --> B01[桶1]
T0 --> B02[桶2]
B01 --> E1[HashEntry]
E1 --> E2[HashEntry]
E2 --> E3[HashEntry]
JDK1.8 的 ConcurrentHashMap 内部的 map 结构和 HashMap 是一致的,都是由:数组 + 链表 + 红黑树构成。
ConcurrentHashMap 和 HashMap 区别就在于支持并发扩容,其内部通过加锁(CAS + synchronized)来保证线程安全。
flowchart LR
M[ConcurrentHashMap]
M --> T[Node 数组 table]
T --> B0[桶0]
T --> B1[桶1]
T --> B2[桶2]
T --> B3[桶3]
T --> B4[桶4]
B1 --> N1[Node]
N1 --> N2[Node]
N2 --> N3[Node]
B3 --> TB[TreeBin]
TB --> R[TreeNode 根节点]
R --> L[左子树]
R --> RR[右子树]
B4 --> N4[Node]
N4 --> N5[Node]
2. 初始容量
ConcurrentHashMap 默认初始容量是 16。在 JDK 8 中,默认构造不会立刻创建数组,而是在第一次 put 时延迟初始化,最终容量会调整为不小于指定值的 2 的幂。
3. 扩容机制
1.7与1.8版本扩容机制对比
| 对比维度 | JDK7 | JDK8 |
|---|---|---|
| 并发性能 | 低,单线程扩容阻塞写 | 高,多线程协同缩短扩容时间 |
| 扩容范围 | 单个Segment内扩容 | 整个Node数组扩容 |
| 扩容线程数 | 单线程 | 多线程协同并发扩容 |
| 锁机制 | ReentrantLock锁住Segment | CAS+synchronized锁住桶头节点 |
| 锁粒度 | Segment级别(1/16数组) | 单个桶头节点 |
| 其他线程行为 | 其他Segment不受影响,正常读写 | 发现MOVED标记,加入协助扩容 |
| 数据迁移方式 | 链表头插法重建 | 链表尾插法拆分低位链/高位链 |
| 读操作 | 扩容期间正常读旧Segment | 发现ForwardingNode转向新数组读 |
| 并发性能 | 低,单线程扩容阻塞写 | 高,多线程协同缩短扩容时间 |
4. ConcurrentHashMap 为什么 key 和 value 不能为 null?
ConcurrentHashMap 不允许 null key 和 null value。因为在并发环境下,get 返回 null 时无法区分到底是 key 不存在,还是 value 本身为 null,这会导致语义不明确。
5. ConcurrentHashMap 如何保证线程安全?
- JDK1.7版本是通过锁住分段数组的一个分段桶位,不允许其他线程进入,这个分段桶里的扩容和put流程与HashMap一致。分段数组最多有16个桶位,支持16个并发写入。
- JDK1.8以后细化锁粒度,通过CAS+synchronized锁住table数组的一个桶位来保证写入线程安全,通过 volatile 关键字保证内存可见性,读操作直接读主内存最新值,无需加锁。
6. ConcurrentHashMap 的 get() 需要加锁吗?为什么?
不需要加锁! 通过 volatile 关键字保证内存可见性,读操作直接读主内存最新值,无需加锁。
7. ConcurrentHashMap 的 put() 操作流程?
put 流程总共分为 7步:
第一步 校验,key 或 value 为 null 直接抛 NPE
第二步 用 spread() 计算 hash 值
第三步 进入 for 自旋,保证 put 最终一定成功
第四步 table 未初始化则 initTable,用 CAS 保证只有一个线程完成初始化
第五步 定位桶位,分三种情况:
- 空桶 → CAS 写入,成功结束,失败继续自旋
- MOVED → 协助扩容,完成后继续自旋重新 put
- 桶不为空 → synchronized 锁头节点,链表尾插或红黑树插入
第六步 检查链表长度,满足条件则树化或扩容
第七步 addCount 更新 size,超过阈值触发扩容
put核心链路流程图
flowchart LR
A([put]) --> B{key/value为空?}
B -->|是| C([抛出NPE])
B -->|否| D[计算hash]
D --> E{table已初始化?}
E -->|否| F[初始化table]
E -->|是| G[定位桶]
F --> G
G --> H{桶为空?}
H -->|是| I[CAS插入]
H -->|否| J[锁桶头]
I --> K([完成])
J --> L{链表或红黑树}
L -->|链表| M[插入或更新]
L -->|红黑树| N[插入或更新]
M --> O[树化检查]
N --> P[更新计数]
O --> P
P --> Q{需要扩容?}
Q -->|是| R[触发扩容]
Q -->|否| K
R --> K
style A fill:#2563eb,color:#fff,stroke:#1e3a8a,stroke-width:2px
style K fill:#16a34a,color:#fff,stroke:#166534,stroke-width:2px
style C fill:#dc2626,color:#fff,stroke:#991b1b,stroke-width:2px
style D fill:#3b82f6,color:#fff,stroke:#1d4ed8
style F fill:#3b82f6,color:#fff,stroke:#1d4ed8
style I fill:#3b82f6,color:#fff,stroke:#1d4ed8
style J fill:#f59e0b,color:#fff,stroke:#b45309
style M fill:#f59e0b,color:#fff,stroke:#b45309
style N fill:#f59e0b,color:#fff,stroke:#b45309
style O fill:#f59e0b,color:#fff,stroke:#b45309
style R fill:#7c3aed,color:#fff,stroke:#5b21b6
put时触发协助扩容流程图
flowchart LR
A[put定位到桶] --> B{桶头是否为 MOVED}
B -->|否| C[按正常 put 流程处理]
B -->|是| D[进入 helpTransfer]
D --> E{还有迁移任务吗}
E -->|有| F[认领一段桶区间]
F --> G[迁移旧桶数据到新 table]
G --> H[旧桶标记为 ForwardingNode]
H --> E
E -->|无| I[结束协助扩容]
I --> J[回到 put 主流程]
style A fill:#3b82f6,color:#fff,stroke:#1d4ed8,stroke-width:2px
style C fill:#60a5fa,color:#fff,stroke:#2563eb,stroke-width:2px
style D fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px
style F fill:#a78bfa,color:#fff,stroke:#7c3aed,stroke-width:2px
style G fill:#a78bfa,color:#fff,stroke:#7c3aed,stroke-width:2px
style H fill:#a78bfa,color:#fff,stroke:#7c3aed,stroke-width:2px
style I fill:#22c55e,color:#fff,stroke:#15803d,stroke-width:2px
style J fill:#16a34a,color:#fff,stroke:#166534,stroke-width:2px
style B fill:#f3f4f6,color:#111827,stroke:#9ca3af,stroke-width:1.5px
style E fill:#f3f4f6,color:#111827,stroke:#9ca3af,stroke-width:1.5px
8. ConcurrentHashMap 的 size 如何计算?
JDK1.7时锁住每个分段槽位,逐个每个槽位上的size和
JDK1.8时参考LongAdder原子类的计数设计,基于baseCount + cells数组方式实现,二者都被volatile关键词修饰(线程可见性)
具体原理:
flowchart LR
T1[线程1] -->|CAS| B[baseCount]
T2[线程2] -->|CAS| C1[CounterCell]
T3[线程3] -->|CAS| C2[CounterCell]
T4[线程4] -->|CAS| C3[CounterCell]
TN[线程N] -->|CAS| CN[CounterCell]
subgraph BOX1[低竞争 直接更新]
B
end
subgraph BOX2[高竞争 分段计数]
C1
C2
C3
CN
end
B --> S[sumCount]
BOX2 --> S
S --> R[总数 = baseCount + 所有CounterCell之和]
style T1 fill:#cfe8d5,stroke:#6b8f71,color:#222
style T2 fill:#cfe8d5,stroke:#6b8f71,color:#222
style T3 fill:#cfe8d5,stroke:#6b8f71,color:#222
style T4 fill:#cfe8d5,stroke:#6b8f71,color:#222
style TN fill:#cfe8d5,stroke:#6b8f71,color:#222
style B fill:#cfe7eb,stroke:#6d8d94,color:#222
style C1 fill:#ecd8c5,stroke:#8b7355,color:#222
style C2 fill:#ecd8c5,stroke:#8b7355,color:#222
style C3 fill:#ecd8c5,stroke:#8b7355,color:#222
style CN fill:#ecd8c5,stroke:#8b7355,color:#222
style S fill:#f7f1d5,stroke:#a59b63,color:#222
style R fill:#f7f1d5,stroke:#a59b63,color:#222
举例:
flowchart LR
T1[线程1] --> B0[baseCount = 10]
T2[线程2] --> B0
T3[线程3] --> B0
B0 --> D{CAS执行加1}
D -->|线程1成功| B1[baseCount = 11]
D -->|线程2失败| C1[CounterCell1 = 1]
D -->|线程3失败| C2[CounterCell2 = 1]
subgraph BOX[CounterCell数组 counterCells]
C1
C2
end
B1 --> S[sumCount]
BOX --> S
S --> R[总数 = 11 + 1 + 1 = 13]
style T1 fill:#cfe8d5,stroke:#6b8f71,color:#222
style T2 fill:#cfe8d5,stroke:#6b8f71,color:#222
style T3 fill:#cfe8d5,stroke:#6b8f71,color:#222
style B0 fill:#bff0b4,stroke:#6f9d64,color:#222
style B1 fill:#bff0b4,stroke:#6f9d64,color:#222
style C1 fill:#79d8e8,stroke:#3d8c98,color:#222
style C2 fill:#79d8e8,stroke:#3d8c98,color:#222
style S fill:#f7f1d5,stroke:#a59b63,color:#222
style R fill:#f7f1d5,stroke:#a59b63,color:#222
这样设计的目的:是分散线程竞争,低线程竞争情况下优先使用CAS,线程竞争多的情况下,则通过cells数组分散线程竞争。
9. ConcurrentHashMap的sizeCtl 有哪些含义?
private transient volatile int sizeCtl;
分别对应四种状态:
sizeCtl状态流转流程图:
flowchart LR
A([sizeCtl初始状态]) --> B{构造时是否指定容量}
B -->|否| C[sizeCtl = 0]
B -->|是| D[sizeCtl = 指定容量]
C --> E[首次put触发初始化]
D --> E
E --> F[CAS修改sizeCtl为 -1]
F --> G{是否抢到初始化权}
G -->|否| H[其他线程让步等待]
G -->|是| I[初始化table]
I --> J[sizeCtl = 扩容阈值]
H --> J
J --> K{元素个数是否超过阈值}
K -->|否| L[正常读写]
K -->|是| M[进入扩容]
M --> N[sizeCtl < -1]
N --> O{其他线程是否发现扩容中}
O -->|是| P[helpTransfer协助扩容]
O -->|否| Q[当前线程继续迁移]
P --> R{扩容是否完成}
Q --> R
R -->|否| N
R -->|是| S[sizeCtl = 新扩容阈值]
S --> L
style A fill:#3b82f6,color:#fff,stroke:#1d4ed8,stroke-width:2px
style C fill:#93c5fd,color:#1e3a8a,stroke:#3b82f6
style D fill:#93c5fd,color:#1e3a8a,stroke:#3b82f6
style F fill:#f59e0b,color:#fff,stroke:#b45309,stroke-width:2px
style I fill:#06b6d4,color:#fff,stroke:#0f766e,stroke-width:2px
style J fill:#22c55e,color:#fff,stroke:#15803d,stroke-width:2px
style M fill:#ef4444,color:#fff,stroke:#991b1b,stroke-width:2px
style N fill:#ef4444,color:#fff,stroke:#991b1b,stroke-width:2px
style P fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px
style Q fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px
style S fill:#16a34a,color:#fff,stroke:#166534,stroke-width:2px
10. 描述一下ConcurrentHashMap中的hash寻址算法? 节点的 Node.hash 字段一般情况下必须 >=0 这是为什么?
寻址算法:
// 1)先取 key 的 hashCode
int h = key.hashCode();
// 2)做 spread 扰动运算, 减少哈希冲突概率
(h ^ (h >>> 16)) & HASH_BITS
// 3)通过 (n - 1) & hash 定位桶下标
int idx = (n - 1) & hash
graph LR
A([key]) --> B[key.hashCode\n原始hashCode]
B --> C[spread方法\n扰动处理]
C --> D[hash & n-1\n定位桶位i]
D --> E([桶位i])
style B fill:#6366f1,color:#fff
style C fill:#E8A838,color:#fff
style D fill:#52C41A,color:#fff
节点hash值对照表:
| hash值 | 常量名 | 节点类型 | 含义 |
|---|---|---|---|
>= 0 | - | 普通Node | 正常链表节点,spread保证最高位=0 |
-1 | MOVED | ForwardingNode | 扩容占位,读写转向新数组 |
-2 | TREEBIN | TreeBin | 红黑树根节点包装类 |
-3 | RESERVED | ReservationNode | compute()方法占位节点 |
PS:RESERVED,防止并发操作同一个key,用来占位的。
11. ConcurrentHashMap 能完全保证线程安全吗?
ConcurrentHashMap 不能完全保证线程安全。
- ConcurrentHashMap 只保证自己API的原子性
- 业务代码的复合操作需要自己自己加锁控制!
例如:单独用ConcurrentHashMap的get/put这些API是线程安全的,但是在业务代码里如果,做了非原子操作,例如先get下,如果存在元素,然后在put更新下,这种场景下并发处理就可能出现线程不安全
12. ConcurrentHashMap 的并发扩容原理?
ConcurrentHashMap 并发扩容时,会先创建一个容量为table数组 2 倍的新数组nextTable;将 sizeCtl 设置为 -(1+n),表示已经处于并发扩容状态,其他线程执行put时,会根据 sizeCtl 值判断是否需要进来协助扩容。
协助扩容的流程是根据transferIndex,把旧table数组拆分成多个段,每个线程认领一段区间,将旧数组上的数据迁移到数组nextTable。旧数组上每个桶位执行迁移的时候,会标记为ForwardingNode。当每个线程完成自己的迁移任务后,再去执行自己的put逻辑,执行完后推出。
扩容结束后更新sizeCtl为新数组容量*0.75,表示下次扩容阈值。
几个关键变量:
- sizeCtl:作为扩容开关标识
- transferIndex:作为迁移任务分发器,记录还有哪些桶没迁移,哪些区间分给哪个线程
- ForwardingNode:作为桶迁移完成标记 + 访问转发标志,告诉其他线程这个桶已经搬走了,去新表查询
流程图:
graph TD
A([触发扩容]) --> B[创建新数组\n容量=旧数组*2]
B --> C[计算每个线程\n负责迁移的桶数stride]
C --> D{是否有空闲线程\n可以参与?}
D -->|第一个扩容线程| E[sizeCtl编码\n记录扩容线程数+1]
D -->|其他线程协助| F[helpTransfer\nsizeCtl+1注册]
E & F --> G[从transferIndex\n从后往前认领一段桶]
G --> H{遍历负责的\n每个桶}
H --> I{桶是否为空?}
I -->|是| J[CAS放入\nForwardingNode占位]
I -->|否| K[synchronized\n锁住桶头节点]
K --> L[链表/红黑树\n数据迁移到新数组]
L --> M[旧桶放入\nForwardingNode]
J & M --> N{负责的桶\n是否全部完成?}
N -->|否| H
N -->|是| O{是否还有\n未认领的桶?}
O -->|有| G
O -->|无| P[sizeCtl-1\n退出扩容]
P --> Q{是否最后\n一个线程?}
Q -->|否| R([线程退出])
Q -->|是| S[全部迁移完成\n更新table=新数组\nsizeCtl=新阈值]
S --> R
style A fill:#6366f1,color:#fff
style J fill:#4A90D9,color:#fff
style K fill:#E8A838,color:#fff
style S fill:#52C41A,color:#fff
style R fill:#52C41A,color:#fff
13. ConcurrentHashMap 和 HashTable区别?
并发锁粒度不同:
- HashTable 用 synchronized 锁住整张表,同一时刻只允许一个线程操作,性能差。
- ConcurrentHashMap 用 CAS + synchronized 锁单个桶,并发度高于 HashTable
graph TB
subgraph HashTable 锁整张表
T1[线程1 put] --> LOCK[锁住整个HashTable]
T2[线程2 get] --> WAIT[等待...]
T3[线程3 put] --> WAIT2[等待...]
LOCK --> UN[释放锁]
UN --> T2
end
subgraph ConcurrentHashMap 锁单个桶
P1[线程1 操作桶3] --> L1[锁桶3]
P2[线程2 操作桶7] --> L2[锁桶7 互不影响]
P3[线程3 操作桶12] --> L3[锁桶12 互不影响]
end
style LOCK fill:#E8A838,color:#fff
style WAIT fill:#999,color:#fff
style WAIT2 fill:#999,color:#fff
style L1 fill:#4A90D9,color:#fff
style L2 fill:#4A90D9,color:#fff
style L3 fill:#4A90D9,color:#fff
初始容量与扩容倍数不同:
- HashTable 初始11,扩容倍数2n+1
- ConcurrentHashMap 初始16,扩容倍数 2n,容量必须是2的幂次方
数据结构不同:
- HashTable 数组+链表
- ConcurrentHashMap 数组+链表+红黑树
二者都不允许null为key/value
三、JUC部分
1. 线程基础知识
Q: Thread#sleep() 方法和 Object#wait() 方法对比?
共同点:两者都可以暂停线程的执行。
| 对比项 | Thread.sleep() | Object.wait() |
|---|---|---|
| 所属类 | Thread | Object |
| 是否释放锁 | 否 | 是 |
| 是否必须在同步块中调用 | 否 | 是 |
| 主要用途 | 让当前线程休眠一段时间 | 线程间通信 / 条件等待 |
| 唤醒方式 | 到时间自动恢复 | notify/notifyAll、中断、超时 |
Q:什么是线程死锁?如何避免死锁?如何检测死锁?
死锁场景:两个或多个线程互相持有对方需要的锁,都在等待对方释放,导致所有线程永久阻塞,程序无法继续执行!
graph LR
T1([线程1]) -->|持有| L1[锁A]
T1 -->|等待| L2[锁B]
T2([线程2]) -->|持有| L2[锁B]
T2 -->|等待| L1[锁A]
style T1 fill:#6366f1,color:#fff
style T2 fill:#E8A838,color:#fff
style L1 fill:#ef4444,color:#fff
style L2 fill:#ef4444,color:#fff
代码案例:
public class DeadLockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1拿到 lockA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("线程1拿到 lockB");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2拿到 lockB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("线程2拿到 lockA");
}
}
});
t1.start();
t2.start();
}
}
如何避免:
- 尽量不要嵌套加锁
- ReentrantLock#tryLock() 抢锁超时放弃
如何检测和排查死锁?
- jstack工具
jstack <pid>
如果有死锁,通常会直接看到类似:能看到哪些线程死锁了,持有那些锁
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor ...
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor ...
which is held by "Thread-1"
根据下面的标识去看持有什么锁
locked <0x000000...>
根据下面的标识去看当前线程在等待那些锁
waiting to lock <0x000000...>
根据下面的命令可以看出当前死锁卡在那行代码:
at com.xxx.OrderService.doXxxx(OrderService.java:123)
Q:什么是乐观锁、悲观锁?有哪些例子
- 悲观锁:先加锁,然后再判断是否有线程冲突,例如 synchronized、ReentrantLock
- 乐观锁:先提交执行,如果有冲突在加锁。例如 CAS、版本号控制
Q:什么是CAS?如何解决ABA问题?
Java中的CAS在Unsafe类中有实现。
CAS操作包含三个操作数————内存位置(V)、期望值(A)和新值(B)。
如果内存位置的值与期望值匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不作任何操作。
2. Unsafe相关
Q:Unsafe有哪些功能?
| 分类 | 核心方法 | 典型应用 |
|---|---|---|
| 堆外内存 | allocateMemory / freeMemory | Netty、DirectByteBuffer |
| CAS操作 | compareAndSwapInt / compareAndSwapObject | AtomicXxx、AQS |
| 线程调度 | park / unpark | LockSupport |
| 对象操作 | allocateInstance / objectFieldOffset | 反序列化框架 |
| 类操作 | defineClass / defineAnonymousClass | Lambda、动态代理 |
| 数组操作 | arrayBaseOffset / arrayIndexScale | AtomicIntegerArray |
| 内存屏障 | loadFence / storeFence / fullFence | Disruptor、volidate |
| 系统信息 | addressSize / pageSize | 内存对齐 |
堆外内存使用场景?
在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。例如Netty就是这样做的。
内存屏障是什么?有什么作用?
内存屏障是一种 CPU 指令,用来解决多线程下的可见性和有序性问题。 它通过禁止指令重排序、强制写操作刷回主内存、强制读操作从主内存加载,来保证多线程间数据的正确性。在 Java 中 volatile 关键字底层就是通过插入内存屏障来实现的。
-
解决指令重排问题对多线程场景下的影响
-
Unsafe涉及的内存屏障方法如下:
graph TB
A[Unsafe 内存屏障] --> B[loadFence\n读屏障]
A --> C[storeFence\n写屏障]
A --> D[fullFence\n全屏障]
B --> B1[屏障前所有读操作必须完成\n禁止Load指令越过屏障重排]
C --> C1[屏障前所有写操作必须完成\n禁止Store指令越过屏障重排]
D --> D1[读写屏障的综合\n最强保证 开销最大]
style B fill:#4A90D9,color:#fff
style C fill:#E8A838,color:#fff
style D fill:#7B68EE,color:#fff
内存屏障如何解决指令重排问题:
sequenceDiagram
participant Code as 代码指令
participant Barrier as 内存屏障
participant Mem as 主内存
Note over Code,Mem: loadFence() 读屏障
Code->>Barrier: 屏障前所有读操作
Barrier->>Mem: 强制从主内存加载,不使用缓存
Barrier->>Code: 屏障后的指令才能继续执行
Note over Code,Mem: storeFence() 写屏障
Code->>Barrier: 屏障前所有写操作
Barrier->>Mem: 强制将缓存中的写刷回主内存
Barrier->>Code: 屏障后的指令才能继续执行
Note over Code,Mem: fullFence() 全屏障
Code->>Barrier: 屏障前所有读写操作
Barrier->>Mem: 读从主内存加载 + 写刷回主内存
Barrier->>Code: 屏障后的指令才能继续执行
Unsafe的CAS操作?
CAS(Compare And Swap)比较并交换,是一种无锁原子操作。 如果内存中的值等于期望值,则将其更新为新值,否则不做任何操作,整个过程是原子的。
graph LR
A[开始CAS操作] --> B{主内存值 == 期望值?}
B -->|是| C[更新为新值\n返回true]
B -->|否| D[不做任何操作\n返回false]
C --> E[结束]
D --> E
Unsafe的线程调度操作有哪些?
核心方法:
graph TB
A[Unsafe 线程调度] --> B[park\n阻塞线程]
A --> C[unpark\n唤醒线程]
A --> D[已废弃方法]
B --> B1[park false 0\n无限期阻塞]
B --> B2[park false ns\n相对时间阻塞 纳秒]
B --> B3[park true ms\n绝对时间阻塞 毫秒]
C --> C1[unpark thread\n唤醒指定线程]
D --> D1[monitorEnter\n获取锁 已废弃]
D --> D2[monitorExit\n释放锁 已废弃]
D --> D3[tryMonitorEnter\n尝试获取锁 已废弃]
style B fill:#4A90D9,color:#fff
style C fill:#E8A838,color:#fff
style D fill:#999,color:#fff
3. volatile关键词
volatile 两大作用
-
保证可见性
- 写:修改volatile变量 → 立即刷新到主内存
- 读:读取volatile变量 → 从主内存重新加载
-
禁止指令重排序(通过内存屏障实现)
- 写屏障:写操作前后插入屏障,保证之前操作不会重排到写之后
- 读屏障:读操作前后插入屏障,保证之后操作不会重排到读之前
注意:volatile不保证原子性!
volatile int count = 0;
count++; // 非原子!读-改-写三步,volatile救不了!
4. synchronized关键字
synchronized 是 Java 内置的互斥同步锁,基于 JVM 层面实现,保证同一时刻只有一个线程执行同步代码块,解决多线程原子性、可见性、有序性问题!
1. synchronized锁的是什么?
- 修饰成员方法时,锁的是对象实例
public synchronized void my_method() {}
- 修饰静态方法时,锁的是类Class
public static synchronized void my_method() {}
2. synchronized锁信息存放在哪里?
synchronized 锁信息存储在对象头的 Mark Word 中!JVM 在字节码层面会通过 Monitor(对象监视器): monitorenter、monitorexit 2个字节码命令实现同步代码块;
synchronized 锁信息存储在对象头的 Mark Word 中!
对象在内存中的结构:
┌─────────────────────────────────┐
│ 对象头 Header │
│ ┌───────────────────────────┐ │
│ │ Mark Word(8字节) │ │ ← 存锁状态/hashCode/GC年龄
│ │ Klass Pointer(4/8字节) │ │ ← 指向Class对象
│ └───────────────────────────┘ │
├─────────────────────────────────┤
│ 实例数据 Fields │
├─────────────────────────────────┤
│ 对齐填充 Padding │
└─────────────────────────────────┘
Mark Word 在不同锁状态下的内容:
锁信息中包含了,持有锁线程ID、锁状态、锁冲入次数、自选情况等等。
┌──────────────┬────────────────────────────────┬──────┐
│ 锁状态 │ Mark Word内容 │标志位│
├──────────────┼────────────────────────────────┼──────┤
│ 无锁 │ hashCode│GC年龄│偏向位=0 │ 01 │
│ 偏向锁 │ 线程ID │epoch│GC年龄│偏向位=1 │ 01 │
│ 轻量级锁 │ 指向栈帧中Lock Record的指针 │ 00 │
│ 重量级锁 │ 指向Monitor对象的指针 │ 10 │
│ GC标记 │ │ 11 │
└──────────────┴────────────────────────────────┴──────┘
3. synchronized 锁升级的过程?
JDK1.6后引入锁升级过程。
flowchart LR
A[无锁状态] -->|第一个线程进入同步块| B[偏向锁]
B -->|其他线程参与竞争<br/>偏向锁撤销| C[轻量级锁]
C -->|竞争加剧<br/>自旋超过阈值| D[重量级锁]
B -.特点.-> B1[Mark Word 记录线程ID<br/>同一线程再次进入时<br/>几乎无额外开销]
C -.特点.-> C1[通过 CAS + 自旋尝试抢锁<br/>尽量避免线程阻塞]
D -.特点.-> D1[竞争失败线程进入阻塞队列<br/>需要 OS 参与挂起与唤醒<br/>开销最大]
style A fill:#22c55e,color:#fff,stroke:#15803d,stroke-width:2px
style B fill:#3b82f6,color:#fff,stroke:#1d4ed8,stroke-width:2px
style C fill:#f59e0b,color:#fff,stroke:#b45309,stroke-width:2px
style D fill:#ef4444,color:#fff,stroke:#991b1b,stroke-width:2px
style B1 fill:#dbeafe,color:#1e3a8a,stroke:#60a5fa,stroke-width:1.5px
style C1 fill:#fef3c7,color:#92400e,stroke:#f59e0b,stroke-width:1.5px
style D1 fill:#fee2e2,color:#991b1b,stroke:#ef4444,stroke-width:1.5px
5. ReentrantLock锁
ReentrantLock 底层基于 AQS(AbstractQueuedSynchronizer) 实现,AQS 通过一个 volatile int state 表示同步状态,通过 CAS 修改 state,并维护一个 CLH 变体的双向等待队列来管理获取锁失败的线程。
ReentrantLock和synchronized关键词的区别?
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 类型 | Java 关键字 | JDK 锁类 |
| 实现层面 | JVM | JDK(AQS) |
| 加锁/释放 | 自动 | 手动 |
| 是否可重入 | 是 | 是 |
| 是否支持公平锁 | 否 | 是 |
| 是否支持可中断 | 否 | 是 |
| 是否支持超时获取锁 | 否 | 是 |
| 条件队列 | wait/notify,单一隐式队列 | Condition,支持多个条件队列 |
| 性能 | JDK6 后优化很好 | 高灵活性场景更有优势 |
6. AQS相关
AQS(AbstractQueuedSynchronizer) 是 Java 并发包的核心基础框架,Doug Lea 大神设计,提供了一套基于 FIFO 等待队列的同步器实现框架,ReentrantLock、Semaphore、CountDownLatch 等都基于它实现!
2. AQS锁的是什么?
AQS 本身不锁任何东西,它只是一个框架!它提供两个核心能力:
-
通过 CAS 原子管理 state 变量
-
通过 CLH 队列管理等待线程的 park/unpark
AQS 锁的本质:
graph TB
A[AQS到底锁的是什么?] --> B[AQS本身不锁任何东西]
B --> C[AQS只提供两个能力]
C --> D[能力1\n管理state变量\nCAS原子修改]
C --> E[能力2\n管理等待队列\npark/unpark线程]
D --> F[谁抢到state\n谁就算获取了同步器]
E --> G[抢不到的线程\n进队列挂起等待]
F & G --> H[具体state代表什么\n完全由子类定义]
style A fill:#6366f1,color:#fff
style H fill:#52C41A,color:#fff
不同子类实现中,state的含义不同:
graph LR
subgraph state语义对比
A[ReentrantLock\nstate=重入次数\n0无锁\n大于0已锁]
B[Semaphore\nstate=剩余许可证\n大于0可获取\n等于0阻塞]
C[CountDownLatch\nstate=计数器\n大于0阻塞等待\n等于0全部放行]
D[ReadWriteLock\nstate高16位=读锁\nstate低16位=写锁]
end
style A fill:#4A90D9,color:#fff
style B fill:#E8A838,color:#fff
style C fill:#52C41A,color:#fff
style D fill:#ef4444,color:#fff
3. AQS的主要组成部分,核心结构,AQS 核心变量有哪些?
AQS主要由三部分组成:
- state 同步状态
- CLH先进先出双向队列(包含头head和尾tail),线程从尾部入队,通过CAS方式修改tail
- Condition条件队列
/**
AQS
├── state:资源状态
├── Sync Queue:同步队列
│ ├── head
│ ├── tail
│ └── Node
│ ├── waitStatus
│ ├── prev
│ ├── next
│ ├── thread
│ └── nextWaiter
├── CAS:修改 state / 入队
└── park/unpark:阻塞与唤醒
*/
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 同步状态
private volatile int state;
// 同步队列头节点
private transient volatile Node head;
// 同步队列尾节点
private transient volatile Node tail;
static final class Node {
// 等待状态
volatile int waitStatus;
// 前驱节点
volatile Node prev;
// 后继节点
volatile Node next;
// 当前线程
volatile Thread thread;
// 条件队列 / 模式标记
Node nextWaiter;
}
}
4. CLH 等待队列是什么?有哪些作用?为什么是双向链表而不是单向链表?
CLH 队列是 AQS 中用来管理获取锁失败的等待线程的双向链表队列,线程获取锁失败后封装成 Node 入队并 park 挂起,锁释放后 unpark 唤醒队头线程重新竞争!用双向链表是因为取消节点、唤醒后继、从尾往前遍历这些操作都需要访问前驱节点,单向链表做不到!
CLH队列结构:
flowchart LR
LOCK[当前持锁线程 X] -->|unlock / release| WAKE[唤醒 head 的后继节点]
WAKE --> N1
subgraph Q[CLH 双向等待队列]
H[head<br/>哑节点]
N1[Node A<br/>线程A<br/>首个有效等待节点]
N2[Node B<br/>线程B]
N3[Node C<br/>线程C]
H -->|next| N1 -->|next| N2 -->|next| N3
N3 -.prev.-> N2
N2 -.prev.-> N1
N1 -.prev.-> H
end
style LOCK fill:#ef4444,color:#fff,stroke:#991b1b,stroke-width:2px
style WAKE fill:#22c55e,color:#fff,stroke:#15803d,stroke-width:2px
style H fill:#9ca3af,color:#fff,stroke:#6b7280,stroke-width:2px
style N1 fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px
style N2 fill:#60a5fa,color:#fff,stroke:#2563eb,stroke-width:2px
style N3 fill:#93c5fd,color:#1e3a8a,stroke:#3b82f6,stroke-width:2px
CHL队列的作用:
- 作用一:管理等待线程,保证有序,线程等待超时或被中断,需要从队列移除
- 作用二:实现公平性,队列中挂起的线程按照先来后到等待被唤醒
为什么用双向链表?
双向链表的话,当某个待被唤醒的节点线程是取消或者中断状态,需要从链表中移除,只需要移动前后指针即可。如果是单向链表的话需要遍历一次,拿到前或者后节点然后移动指针
如下图所示:
5.AQS 中 Node 的 waitStatus 有哪些值,分别表示什么?
waitStatus 共有 5个值:0(初始)、-1 SIGNAL、-2 CONDITION、-3 PROPAGATE、1 CANCELLED,记住规律:大于0就是取消,小于0都是有效状态!
static final class Node {
static final int CANCELLED = 1; // 已取消
static final int SIGNAL = -1; // 后继需要唤醒
static final int CONDITION = -2; // 条件队列等待
static final int PROPAGATE = -3; // 共享模式传播
// 0 // 初始状态
volatile int waitStatus;
}
图示如下:
graph TB
A[waitStatus] --> B[0\n初始状态]
A --> C[-1 SIGNAL\n最重要]
A --> D[-2 CONDITION\n条件队列]
A --> E[-3 PROPAGATE\n共享传播]
A --> F[1 CANCELLED\n已取消]
B --> B1[节点刚创建\n默认值]
C --> C1[后继节点需要被唤醒\npark之前必须设置]
D --> D1[节点在条件队列等待\nawait后变为此状态]
E --> E1[共享模式下\n传播唤醒后续节点]
F --> F1[超时或中断\n节点废弃不再竞争]
style B fill:#9ca3af,color:#fff
style C fill:#52C41A,color:#fff
style D fill:#E8A838,color:#fff
style E fill:#4A90D9,color:#fff
style F fill:#ef4444,color:#fff
6.Condition 条件队列的作用是什么?Condition 如何精确唤醒线程?Condition 原理?wait和notify、park和unpark?以及 Condition 和 CLH 两个队列的关系?
Condition 是什么?作用是什么?
Condition 的核心作用是线程间通信,解决 Object.wait/notify 只有一个等待集合、无法精确唤醒指定类型线程的问题!一个 Lock 可以创建多个 Condition,每个对应独立等待队列,实现精确唤醒!
Condition 是通过两个单向链表实现:
// ConditionObject 是 AQS 的内部类
public class ConditionObject implements Condition {
// 条件队列头节点
private transient Node firstWaiter;
// 条件队列尾节点
private transient Node lastWaiter;
// 核心方法
void await(); // 释放锁,进入条件队列等待
void signal(); // 唤醒条件队列头节点
void signalAll(); // 唤醒条件队列所有节点
void awaitUninterruptibly(); // 不响应中断的等待
boolean await(long time, TimeUnit unit); // 超时等待
}
Condition 和 CLH 两个队列的关系?
CLH队列是用来解决锁竞争问题的,所有线程都在抢同一把锁!但实际业务中,线程等待的原因不同,需要在不同条件满足时唤醒不同类型的线程,CLH队列做不到这个!Condition就是为了解决按条件精确唤醒的问题!
CLH 是等锁,Condition 是等条件,两种等待原因完全不同,所以需要两个队列分别管理!
Condition 是如何精确唤醒某个线程的?
每个 Condition 对象内部有一个独立的条件等待队列,不同 Condition 的队列完全隔离互不干扰!
流程与代码案例:
await():线程调用哪个 Condition 的 await(),就进哪个 Condition 的队列,同时释放锁 park 挂起。
signal():只操作当前 Condition 自己队列的 firstWaiter,把它转移到 CLH 同步队列,然后 unpark 唤醒。
public class BoundedBuffer {
private final ReentrantLock lock = new ReentrantLock();
// 两个独立条件队列
private final Condition notFull = lock.newCondition(); // 生产者等待队列
private final Condition notEmpty = lock.newCondition(); // 消费者等待队列
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity = 5;
// ===== 生产者 =====
public void produce(int item) throws InterruptedException {
lock.lock();
try {
// 队列满了,生产者去 notFull 队列等待
while (queue.size() == capacity) {
System.out.println(Thread.currentThread().getName()
+ " 队列满了,生产者等待...");
notFull.await(); // ← 进入 notFull 条件队列💤
}
queue.offer(item);
System.out.println(Thread.currentThread().getName()
+ " 生产:" + item + " 队列大小:" + queue.size());
// 生产了一个,通知消费者可以消费了
notEmpty.signal(); // ← 只唤醒 notEmpty 队列的消费者!
} finally {
lock.unlock();
}
}
// ===== 消费者 =====
public void consume() throws InterruptedException {
lock.lock();
try {
// 队列空了,消费者去 notEmpty 队列等待
while (queue.isEmpty()) {
System.out.println(Thread.currentThread().getName()
+ " 队列空了,消费者等待...");
notEmpty.await(); // ← 进入 notEmpty 条件队列💤
}
int item = queue.poll();
System.out.println(Thread.currentThread().getName()
+ " 消费:" + item + " 队列大小:" + queue.size());
// 消费了一个,通知生产者可以生产了
notFull.signal(); // ← 只唤醒 notFull 队列的生产者!
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
BoundedBuffer buffer = new BoundedBuffer();
// 3个生产者
for (int i = 0; i < 3; i++) {
int item = i;
new Thread(() -> {
for (int j = 0; j < 5; j++) {
try {
buffer.produce(item * 10 + j);
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "生产者-" + i).start();
}
// 3个消费者
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 5; j++) {
try {
buffer.consume();
Thread.sleep(150);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "消费者-" + i).start();
}
}
}
输出结果:
输出结果:
生产者-0 生产:0 队列大小:1
生产者-1 生产:10 队列大小:2
生产者-2 生产:20 队列大小:3
消费者-0 消费:0 队列大小:2
消费者-1 消费:10 队列大小:1
生产者-0 生产:1 队列大小:2
...
生产者-0 队列满了,生产者等待... ← 进notFull队列
消费者-0 消费:xx 队列大小:4 ← 消费后signal通知生产者
生产者-0 生产:xx 队列大小:5 ← 生产者被精确唤醒!不会唤醒消费者!
流程图:
graph TB
LOCK[一把Lock]
LOCK --> NF[notFull条件队列\n专门放生产者]
LOCK --> NE[notEmpty条件队列\n专门放消费者]
subgraph notFull队列
P1([生产者A]) -->|nextWaiter| P2([生产者C])
end
subgraph notEmpty队列
C1([消费者B]) -->|nextWaiter| C2([消费者D])
end
NF --> P1
NE --> C1
OPS1[消费了一个元素] -->|notFull.signal\n精确唤醒生产者| P1
OPS2[生产了一个元素] -->|notEmpty.signal\n精确唤醒消费者| C1
style P1 fill:#E8A838,color:#fff
style P2 fill:#E8A838,color:#fff
style C1 fill:#4A90D9,color:#fff
style C2 fill:#4A90D9,color:#fff
style OPS1 fill:#52C41A,color:#fff
style OPS2 fill:#52C41A,color:#fff
本质上是用业务代码控制线程去哪个队列:生产者调 notFull.await() 进生产者队列,消费者调 notEmpty.await() 进消费者队列,signal 时各取各的,天然精确!
7.AQS 获取锁失败后线程怎么处理?
获取锁失败后,线程被封装成 Node 节点 CAS 加入 CLH 队列尾部,然后进入自旋:如果前驱是 head 则再次 tryAcquire 尝试抢锁;抢不到则检查前驱的 waitStatus,如果前驱是 SIGNAL=-1 说明前驱释放锁时会唤醒自己,就放心调用 LockSupport.park 挂起;如果前驱已取消(CANCELLED=1)则跳过找有效前驱;如果前驱是 0 则 CAS 设为 SIGNAL 再重新判断。线程挂起后等待前驱节点释放锁时 unpark 唤醒,醒来后重新自旋竞争锁!
具体流程如下:
graph LR
A[获取锁失败] --> B[封装 Node]
B --> C[CAS 入队]
C --> D[进入自旋]
D --> E{前驱是否为 head}
E -->|是| F[再次 tryAcquire]
F --> G{是否成功}
G -->|成功| H[获取锁成功]
G -->|失败| I[检查前驱状态]
E -->|否| I
I --> J{waitStatus}
J -->|SIGNAL| K[park 挂起]
K --> L[unpark 后继续竞争]
L --> D
J -->|CANCELLED| M[跳过失效前驱]
M --> D
J -->|0| N[设为 SIGNAL]
N --> D
style A fill:#ef4444,color:#fff
style B fill:#3b82f6,color:#fff
style C fill:#3b82f6,color:#fff
style D fill:#6366f1,color:#fff
style E fill:#f3f4f6,color:#111827
style F fill:#2563eb,color:#fff
style G fill:#f3f4f6,color:#111827
style H fill:#22c55e,color:#fff
style I fill:#f59e0b,color:#fff
style J fill:#f3f4f6,color:#111827
style K fill:#ef4444,color:#fff
style L fill:#22c55e,color:#fff
style M fill:#f97316,color:#fff
style N fill:#eab308,color:#fff
8.AQS加锁和释放锁的流程?
加锁流程
graph TD
A([线程调用lock]) --> B[tryAcquire\n尝试获取锁]
B --> C([获取锁成功\nowner=当前线程\nstate=1])
B --> D([同一线程重入\nstate++])
B --> E[获取锁失败\naddWaiter\n封装Node加入CLH队列尾部]
E --> F[acquireQueued\n进入自旋]
F --> G[检查前驱节点是否是head]
G --> H[前驱是head\ntryAcquire再次尝试获取锁]
G --> I[前驱不是head\n检查前驱waitStatus]
H --> C
H --> I
I --> J[SIGNAL=-1\n前驱释放锁会唤醒我\n可以安心挂起]
I --> K[CANCELLED=1\n前驱已取消\n跳过找有效前驱]
I --> L[waitStatus=0\nCAS设前驱为SIGNAL=-1]
K --> F
L --> F
J --> M[LockSupport.park\n线程挂起 WAITING状态]
M --> N[线程被唤醒]
N --> O[正常unpark唤醒\n继续自旋竞争锁]
N --> P[interrupt中断唤醒\n记录中断标记继续自旋]
O --> F
P --> F
style C fill:#52C41A,color:#fff
style D fill:#52C41A,color:#fff
style M fill:#ef4444,color:#fff
style J fill:#4A90D9,color:#fff
style K fill:#E8A838,color:#fff
style L fill:#9ca3af,color:#fff
释放锁流程
graph TD
A([线程调用unlock]) --> B[tryRelease\nstate--]
B --> C[state是否等于0\n判断是否完全释放]
C --> D([state大于0\n存在重入\n继续持有锁])
C --> E[state等于0\n完全释放\nowner=null\n清空持有线程]
E --> F[检查CLH队列\nhead是否为空]
F --> G([head为空\n队列无等待线程\n释放完成])
F --> H[head不为空\n检查head的waitStatus]
H --> I[head.waitStatus=0\n无需唤醒\n没有等待线程]
H --> J[head.waitStatus=SIGNAL=-1\n有线程等待\n需要唤醒后继]
H --> K[head.waitStatus=PROPAGATE=-3\n共享模式\n需要传播唤醒]
I --> G
J --> L[检查head.next\n后继节点是否有效]
K --> L
L --> M[head.next有效\nwaitStatus小于等于0\n直接unpark唤醒]
L --> N[head.next无效\nCANCELLED=1已取消\n从tail往前遍历\n找最近有效节点]
N --> M
M --> O([LockSupport.unpark\n唤醒目标线程])
O --> P([线程醒来\n重新自旋竞争锁])
style D fill:#E8A838,color:#fff
style G fill:#52C41A,color:#fff
style O fill:#4A90D9,color:#fff
style P fill:#6366f1,color:#fff
style J fill:#4A90D9,color:#fff
style K fill:#9ca3af,color:#fff
style I fill:#9ca3af,color:#fff
9.AQS如何实现可重入锁?如何实现公平锁?
AQS实现可重入锁
graph TD
subgraph 释放锁流程
E([线程调用unlock]) --> F[tryRelease\nstate--]
F --> G[state是否等于0]
G --> H([state大于0\n还有重入未释放\n继续持有锁])
G --> I[state等于0\n完全释放\nowner=null]
I --> J([unpark唤醒\nCLH队列下一个等待线程])
end
subgraph 加锁流程
A([线程调用lock]) --> B[tryAcquire\n获取state值]
B --> C[state等于0\n无锁状态]
B --> D[state大于0\n已有线程持有锁]
C --> CA[CAS state 0到1\n设置owner=当前线程]
CA --> CB([获取锁成功\nstate=1])
D --> DA[判断owner\n是否是当前线程]
DA --> DB([不是当前线程\n获取锁失败\n进入CLH队列等待])
DA --> DC[是当前线程\n可以重入\nstate++]
DC --> DD([重入成功\nstate=n])
end
style CB fill:#52C41A,color:#fff
style DD fill:#52C41A,color:#fff
style DB fill:#ef4444,color:#fff
style H fill:#E8A838,color:#fff
style J fill:#52C41A,color:#fff
AQS实现公平锁
所有等待被唤醒的线程在CLH队列中按照先来后到顺序依次被唤醒,而不需要每次重新竞争抢锁。
graph TD
subgraph 非公平锁 NonfairSync
A1([线程调用lock]) --> B1[CAS直接抢锁\n不检查队列]
B1 --> C1[成功]
B1 --> D1[失败\n再走tryAcquire]
C1 --> E1([获取锁成功\n允许插队])
D1 --> F1[tryAcquire\n再次直接CAS]
F1 --> G1([成功获取锁])
F1 --> H1([失败入队等待])
end
subgraph 公平锁 FairSync
A2([线程调用lock]) --> B2[tryAcquire\n先检查队列]
B2 --> C2[hasQueuedPredecessors\n队列有等待线程?]
C2 --> D2[有等待线程\n不允许插队]
C2 --> E2[无等待线程\nCAS抢锁]
D2 --> F2([入队排队等待])
E2 --> G2([获取锁成功])
end
style E1 fill:#52C41A,color:#fff
style G1 fill:#52C41A,color:#fff
style G2 fill:#52C41A,color:#fff
style F2 fill:#4A90D9,color:#fff
style H1 fill:#ef4444,color:#fff
style D2 fill:#ef4444,color:#fff
10.AQS独占模式和共享模式区别?
独占模式:同一时刻只有一个线程能持有锁,其他线程全部等待,如 ReentrantLock!
共享模式:同一时刻允许多个线程同时持有,如 Semaphore、CountDownLatch!核心区别在于 state 的语义和锁释放后是否传播唤醒后续节点!
graph TD
A[独占模式] --> A1[ReentrantLock\n同一时刻只有一个线程\n适合写操作互斥场景]
A --> A2[ReentrantReadWriteLock写锁\n写时不允许任何读写\n保证写操作原子性]
B[共享模式] --> B1[Semaphore信号量\n控制同时访问资源的线程数\n适合限流场景]
B --> B2[CountDownLatch\n等待多个线程都完成\n适合线程协调场景]
B --> B3[ReentrantReadWriteLock读锁\n多个读线程同时读\n适合读多写少场景]
B --> B4[CyclicBarrier\n等待所有线程到达屏障\n适合分阶段任务场景]
style A fill:#4A90D9,color:#fff
style B fill:#52C41A,color:#fff
7. TreadLocal
ThreadLocal是一个全局对象,ThreadLocal是线程范围内变量共享的解决方案。
1. ThreadLocal 数据结构,ThreadLocal、Thread、ThreadLocalMap之间的关系?
一个Thread里面,有一份自己的threadLocalMap,他的key是某个ThreadLocal对象实例的弱引用,value是ThreadLocal对象中包含的数据(强引用)
TreadLocal自己没有table数组,而是用的Thread里的threadLocalMap中的table数组。
数据结构图解
graph TB
subgraph Thread1["🧵 同一个 Thread"]
TLMap["threadLocals\n(只有一个ThreadLocalMap)"]
end
subgraph ThreadLocalMap["📦 ThreadLocalMap (一个线程只有一个)"]
subgraph Table["Entry[] table"]
E1["Entry[i]\n✅ 有数据"]
E2["Entry[j]\n✅ 有数据"]
E3["Entry[k]\n✅ 有数据"]
E4["Entry[m]\n空 null"]
end
end
subgraph TLObjects["🔑 多个ThreadLocal实例 (可以有很多个)"]
TLA["ThreadLocal-A\n存储用户信息\nhashCode=1111"]
TLB["ThreadLocal-B\n存储事务ID\nhashCode=2222"]
TLC["ThreadLocal-C\n存储链路追踪ID\nhashCode=3333"]
end
subgraph Values["📦 各自存储的Value"]
VA["UserInfo\n{'name':'Tom'}"]
VB["TransactionId\n'TX-001'"]
VC["TraceId\n'TRACE-ABC'"]
end
%% Thread → Map
TLMap -->|"持有唯一Map"| ThreadLocalMap
%% Entry → Value
E1 -->|"value"| VA
E2 -->|"value"| VB
E3 -->|"value"| VC
%% ThreadLocal(key弱引用) → Entry
TLA -.->|"弱引用 key"| E1
TLB -.->|"弱引用 key"| E2
TLC -.->|"弱引用 key"| E3
style Thread1 fill:#4A90D9,color:#fff
style ThreadLocalMap fill:#7B68EE,color:#fff
style TLA fill:#50C878,color:#fff
style TLB fill:#50C878,color:#fff
style TLC fill:#50C878,color:#fff
style E1 fill:#E8A838,color:#fff
style E2 fill:#E8A838,color:#fff
style E3 fill:#E8A838,color:#fff
style E4 fill:#ccc,color:#666
style Values fill:#fff8e1,stroke:#f0ad4e
ThreadLocal 为什么线程安全?
ThreadLocal中存放的数据是使用Thread下的threadlocalMap作为容器的,即线程独享。
ThreadLocalMap 的 key 和 value 分别是什么?
key是某个ThreadLocal对象实例的弱引用,value是ThreadLocal对象中包含的数据(强引用)。
例如UserContext内定义了一个TreadLocal对象
UserInfo user = UserContext.get();
即向Thread获取TreadLocalMap,key为UserInfo内TreadLocal实例弱引用,value为UserInfo
图解关系:
graph TB
subgraph UserContext["📦 UserContext 类 (工具类)"]
TL["🔑 USER_THREAD_LOCAL\n= new ThreadLocal#lt;UserInfo#gt;()\n← ThreadLocal定义在这里!"]
end
subgraph UserInfo["📋 UserInfo 类 (纯数据类)"]
F1["String name = 'Tom'"]
F2["Integer age = 18"]
F3["String role = 'Admin'"]
Note["❌ UserInfo里没有ThreadLocal\n它只是普通的数据对象"]
end
subgraph Thread["🧵 Thread"]
subgraph TLMap["ThreadLocalMap"]
subgraph Entry["Entry[i]"]
K["🔑 key\n= WeakRef(USER_THREAD_LOCAL)\n弱引用指向UserContext中的TL实例"]
V["💾 value\n= UserInfo对象\n{ name='Tom',age=18 }"]
end
end
end
TL -.->|"弱引用 作为key"| K
V -->|"强引用 指向"| UserInfo
style UserContext fill:#4A90D9,color:#fff
style UserInfo fill:#ccc,color:#333
style Note fill:#FFE5E5,color:#FF4444
style Thread fill:#7B68EE,color:#fff
style K fill:#50C878,color:#fff
style V fill:#E8A838,color:#fff
style TL fill:#50C878,color:#fff
2. 初始容量,扩容机制
Tread中的TreadLocalMap初始table数组容量为16,必须为2的n次幂。扩容阈值为容量的2/3。扩容后新table数组大小为旧数组的2倍。
3. set、get核心流程
set流程
flowchart TD
Start(["🚀 threadLocal.set(value)"])
--> GetThread["① 获取当前线程\nThread t = Thread.currentThread()"]
--> GetMap["② 获取线程的Map\nmap = t.threadLocals"]
--> MapNull{"③ map 是否为null?\n首次使用?"}
MapNull -->|"是 首次使用"| CreateMap["createMap(t, value)\n创建ThreadLocalMap\ncapacity=16, threshold=10\n直接创建第一个Entry"]
MapNull -->|"否 已存在"| CalcIndex["④ 计算槽位\ni = hashCode & table.length-1"]
CreateMap --> SetEnd(["✅ set完成"])
CalcIndex --> LoopStart{"⑤ 检查 table[i]"}
LoopStart -->|"Entry.key == this\n命中当前ThreadLocal"| Update["直接更新 value\ne.value = value"]
LoopStart -->|"Entry == null\n找到空槽"| Insert["插入新 Entry\ntable[i] = new Entry(this,value)\nsize++\n检查是否扩容"]
LoopStart -->|"Entry.key != this\n哈希冲突"| NextIndex["线性探测\ni = (i+1) & len-1"]
LoopStart -->|"Entry.key == null\n脏Entry"| Clean["replaceStaleEntry()\n清理脏Entry后插入"]
NextIndex --> LoopStart
Update --> SetEnd
Insert --> CheckResize{"size >= threshold?"}
Clean --> SetEnd
CheckResize -->|"是"| Rehash["rehash()\n清理脏Entry\n必要时resize()"]
CheckResize -->|"否"| SetEnd
Rehash --> SetEnd
style Start fill:#4A90D9,color:#fff
style SetEnd fill:#50C878,color:#fff
style CreateMap fill:#50C878,color:#fff
style Clean fill:#FF8C00,color:#fff
style Rehash fill:#FF8C00,color:#fff
style Update fill:#4A90D9,color:#fff
style Insert fill:#4A90D9,color:#fff
get流程
flowchart TD
Start(["🚀 threadLocal.get()"])
--> GetThread["① 获取当前线程\nThread t = Thread.currentThread()"]
--> GetMap["② 获取线程的Map\nmap = getMap(t)\n即 t.threadLocals"]
--> MapNull{"③ map 是否为 null?\n线程从未调用过set()"}
MapNull -->|"是\n从未初始化"| InitValue["⑥ setInitialValue()\n调用 initialValue()\n默认返回 null\n可Override自定义"]
MapNull -->|"否\n已初始化"| GetEntry["④ 获取Entry\ne = map.getEntry(this)\nthis=当前ThreadLocal实例"]
GetEntry --> CalcIndex["计算槽位\ni = hashCode & table.length-1"]
CalcIndex --> DirectHit{"⑤ 直接命中?\ntable[i] != null\n且 key == this"}
DirectHit -->|"✅ 命中\nO(1)最优情况"| ReturnValue(["🎉 返回 entry.value"])
DirectHit -->|"❌ 未命中\n哈希冲突或key不同"| AfterMiss["getEntryAfterMiss()\n开始线性探测"]
AfterMiss --> ProbeLoop{"探测 table[i]"}
ProbeLoop -->|"Entry==null\n空槽 说明不存在"| ReturnNull(["返回 null\n或初始值"])
ProbeLoop -->|"key==this\n✅ 找到了"| ReturnValue
ProbeLoop -->|"key==null\n脏Entry"| Expunge["expungeStaleEntry(i)\n清理脏Entry\n重新哈希后续Entry"]
ProbeLoop -->|"key!=this\n其他ThreadLocal"| NextProbe["线性探测\ni=(i+1)&len-1"]
Expunge --> NextProbe
NextProbe --> ProbeLoop
InitValue --> CreateMap{"map是否为null"}
CreateMap -->|"是"| NewMap["createMap(t, initialVal)\n创建新ThreadLocalMap"]
CreateMap -->|"否"| SetInit["map.set(this, initialVal)"]
NewMap --> ReturnInit(["返回 initialValue"])
SetInit --> ReturnInit
style Start fill:#4A90D9,color:#fff
style ReturnValue fill:#50C878,color:#fff
style ReturnNull fill:#ccc,color:#333
style ReturnInit fill:#7B68EE,color:#fff
style Expunge fill:#FF8C00,color:#fff
style InitValue fill:#7B68EE,color:#fff
ThreadLocalMap 如何解决 hash 冲突?
Thread下的TreadLocalMap虽然是一个Map结构,但是实际上遇到hash冲突时不会形成链表,而是线形探测转移到另一个桶去,如果还是冲突就继续探测。
线形探测算法:
i = (i + 1) & (len - 1)
Q:如果线形探测到table数组末尾或者边界,怎么办?
A:自动回到数组头部,形成环形;
Q:如果探测了整个数组还没找到空桶位怎么办?
A:不可能出现这个情况,因为数组始终是不满状态,容量占用到临界阈值就自动扩容了,所以始终会有充裕,不会出现数组全满情况。
4. key 为什么设计成弱引用?
为什么key要为弱引用?
线程方法执行结束,可以使ThreadLocal的key引用在GC时被主动回收,不需要像强引用那样要主动清除key引用。当key变为null的时候,下次set/get时,会通过TreadLocal自己的清理逻辑将value也回收。
弱引用能完全解决内存泄漏吗?为什么?
不能。例如线程池场景、静态变量修饰等。参考下面问题的解释。
5. ThreadLocal 为什么会内存泄漏?如何正确避免内存泄漏?
什情况下会出现内存泄露?
- 1.线程池中使用TreadLocal,在线程任务完结的时候没有主动remove销毁。线程池回收这个线程实例,但是线程对象本身不会被销毁,所以TreadLocalMap中存放的数据也不会被回收,无效占用内存。
- 2.key 变 null 后 value 无法回收。
原理:key变为null后,弱引用被GC回收,无法通过get/set定位到数组桶位上的Entry(无法通过寻址算法定位桶位),但是这个ThreadLocal中存放的value对象还被Entry强引用着无法回收,无效占用内存。
解决办法:
- 等待下次set/get时TreadLocal自己的探测清理,但是不能保证一定可以清理
- 等待线程被回收
- 在线程方法中finally中提前调用remove。
- TheadLocal对象被静态变量修饰,key永远不会被GC回收,例如在UserContext中使用。
如何避免内存泄露?
- 1.TreadLocal存储信息用完后,在线程任务完结的时候主动remove销毁。例如登陆用户会话信息,在请求执行完结后再拦截器postProcessor中销毁。
- 2.避免使用线程池中的线程对象去使用TreadLocal,防止线程执行完后被线程池回收但线程并未销毁。
- 3.headLocal对象被静态变量修饰,key永远不会被GC回收,例如在UserContext中使用。
什么情况下key会变成null?
示例代码:
public class OrderService {
public void processOrder(Order order) {
// ⚠️ 局部变量!不是static!
ThreadLocal<Order> orderTL = new ThreadLocal<>();
orderTL.set(order);
doSomething();
// ❌ 忘记 remove()
// 方法结束,orderTL 局部变量销毁
// 强引用断开!
} // ← 方法结束,orderTL 出栈,强引用消失
}
流程图解:
flowchart TB
subgraph MethodRun["方法执行中"]
subgraph Stack["线程栈"]
LocalRef["局部变量\norderTL\n────强引用────→ ThreadLocal对象"]
end
subgraph Heap["堆内存"]
TLObj["ThreadLocal对象"]
EntryN["Entry\nkey ⇢ 弱引用 ⇢ ThreadLocal\nvalue → Order数据"]
end
LocalRef --> TLObj
EntryN -.-> TLObj
end
subgraph MethodEnd["方法结束后"]
subgraph Stack2["线程栈"]
LocalRef2["orderTL 出栈\n强引用消失!"]
end
subgraph Heap2["堆内存"]
TLObj2["ThreadLocal对象\n只剩弱引用指向它\nGC触发 → 被回收!"]
EntryN2["Entry\nkey = null ⚠️\nvalue → Order数据 仍存活!"]
end
LocalRef2 -.->|"强引用断开"| TLObj2
EntryN2 -.->|"弱引用\n已回收"| TLObj2
end
MethodRun -->|"方法结束"| MethodEnd
style LocalRef2 fill:#FF6B6B,color:#fff
style TLObj2 fill:#FF6B6B,color:#fff
style EntryN2 fill:#FF8C00,color:#fff
6. 父子线程能共享 ThreadLocal 吗?
ThreadLocal 不能,因为Tread的threadLocalMap是当前线程使用,不考虑父线程。
如果需要子父线程使用,可以用 InheritableThreadLocal
7. ThreadLocalMap过期 key 的清理流程?
什么是探测清理
触发时机:发现某个脏Entry后,从这个位置出发向后线性扫描。
清理动作:遇到 key=null 的脏Entry 清理。遇到正常Entry 重新哈希。
停止时机:遇到 null 空槽停止。
完整流程图解
启发式清理
触发时机:新写入一个Entry后,开始log₂(n) 次抽样
清理动作:遇到 key=null 的脏Entry 清理。遇到正常Entry 重新哈希。
停止时机:抽样结束
8. 线程池
TODO
四、JVM部分
1.运行时数据区
1.7和1.8版本差异
组成部分:
JVM 运行时数据区分为线程私有和线程共享两部分。
线程私有(生命周期与线程相同)包含三个区域:
- 程序计数器:记录当前线程执行的字节码指令地址,是唯一不会发生 OOM 的区域
- 虚拟机栈:每个方法调用对应一个栈帧,存放局部变量表、操作数栈、动态链接、方法返回地址
- 本地方法栈:为 native 方法服务,HotSpot 虚拟机将其与虚拟机栈合并为一个
线程共享包含两个区域:
- 堆:存放几乎所有对象实例和数组,分为新生代(Eden + 两个 Survivor)和老年代
- 方法区:存放类元信息、即时编译代码缓存、常量池、静态变量
JDK 1.7 vs 1.8 差异
两个版本最核心的差异在于方法区的实现方式不同:
- JDK 1.7 用永久代实现方法区,永久代位于堆内存中,受
-XX:MaxPermSize限制,默认大小较小,存放类元信息、运行时常量池、静态变量,容易触发OutOfMemoryError: PermGen space - JDK 1.8 废除永久代,改用元空间实现方法区,元空间位于本地内存(堆外),默认没有大小上限,受本地内存限制,只存放类元信息和即时编译代码,触发的是
OutOfMemoryError: Metaspace
同时伴随这个变化,常量池和静态变量的存放位置也发生了迁移:字符串常量池在 JDK 1.7 就已经从永久代移入堆中,静态变量在 JDK 1.8 随 Class 对象一起移入堆中。
之所以做这个改变,主要原因是永久代大小难以评估、GC 回收效率低,使用本地内存的元空间可以动态扩展,大幅降低了 OOM 的风险。
每个区域存放什么内容
直接内存的作用是?
TODO
字符串常量池的作用?
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
// 1.在字符串常量池中查询字符串对象 "ab",如果没有则创建"ab"并放入字符串常量池
// 2.将字符串对象 "ab" 的引用赋值给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象 "ab",赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true
2.JMM内存模型
JMM(Java Memory Model)是 Java 的并发内存访问模型,定义了线程工作内存与主内存之间的交互规则,用来屏蔽不同硬件和编译器的内存访问差异
在 JMM 里,每个线程都有自己独立的 工作内存(也常叫本地内存),线程之间的工作内存彼此不可直接访问;而 主内存 中存放共享变量,是所有线程共享的。
如下图所示:
- Java程序运行时,每开辟一个线程都会分配一个固定大小的线程栈区域
- JVM命令参数控制线程栈内存大小:
-Xss1m,默认是512k或1M - 新开线程执行时,会将当线程执行代码所需要用到相关变量,从主内存中拷贝到工作内存中,作为变量副本。
- 线程执行过程中,更新或读取变量副本都是在工作内存执行的,并不会立刻将配置刷新回主内存。
graph TB
subgraph 线程1工作区
T1([线程1]) <-->|读写| LM1[本地内存1\n共享变量副本\nx=1]
end
subgraph 线程2工作区
T2([线程2]) <-->|读写| LM2[本地内存2\n共享变量副本\nx=0]
end
subgraph 主内存 Main Memory
X[共享变量x=1]
Y[共享变量y]
Z[共享变量z]
end
LM1 <-->|同步| X
LM2 <-->|同步| X
style T1 fill:#6366f1,color:#fff
style T2 fill:#6366f1,color:#fff
style LM1 fill:#fca5a5
style LM2 fill:#fca5a5
style X fill:#fde68a
style Y fill:#fde68a
style Z fill:#fde68a
JMM三大特性:可见性、原子性、有序性
3.java类加载过程?
4.对象创建过程?
5.垃圾回收机制
6.JVM核心参数
五、数据库部分
1. 分库分表相关
Q:为什么要分库分表?
分库分表,即把原来存放在一个数据库的一张大表里的数据,拆分到多个数据库、多个表中去,降低单表数据量过大查询效率降低。(数据量太大单库单表扛不住了)
常用分表拆分方式:
- 按照用户ID取模分表
- 按照时间范围分表
- 按业务类型分表
例如按 user_id % 4 分表:
user_id % 4 = 0 放 user_order_0
user_id % 4 = 1 放 user_order_1
...
user_order_0
user_order_1
user_order_2
user_order_3
常用分库方式:
- 按照用户ID取模分库
- 按照单元区域分库
- 按业务类型分库
分库分表带来的问题?
- 分页查询成本增加
- 分布式事务问题
- 扩容和数据迁移困难
分库分表场景下,如何做分页查询?
- 场景1: 单分片分页,根据用户ID取模或者按天分表,命中单个分片表,等同于单库单表分页
- 场景2: 跨分片分页,查询数据量小,每个分片取少量数据(例如按照查询条件取每个分片前10条),应用层业务逻辑聚合。
- 场景3: 跨分片分页,查询数据量大,使用游标分页。
排序字段唯一:
order by create_time desc, id desc
每次查询,基于上一页最后一条数据的排序字段值(如 create_time 或 id)作为游标进行查询
-- 假设上一页最后一条数据的 create_time 是 '2023-10-01 12:00:00',id 是 1005
SELECT * FROM order_table
WHERE create_time > '2023-10-01 12:00:00'
OR (create_time = '2023-10-01 12:00:00' AND id > 1005)
ORDER BY create_time, id
LIMIT 10;
在每个分片,从游标位置开始往后查询,不需要每个分片下全表查询。
应用场景:Feed 流、消息列表、无限滚动加载。
- 场景4: 跨分片分页,任意跳转某一页,使用二次查询法
第一次查询:查各分片 count
shard_0:80 条
shard_1:120 条
shard_2:50 条
第二步:根据页码推算目标区间
假设:
第 3 页 每页 20 条
那么目标区间就是:
全局第 41 ~ 60 条
第三步:根据各分片数量,算目标区间落在哪些分片
如果全局顺序等于分片顺序拼接,例如:
shard_0 -> shard_1 -> shard_2
那么全局位置区间就是:
shard_0:第 1 ~ 80 条
shard_1:第 81 ~ 200 条
shard_2:第 201 ~ 250 条
第 41~60 条显然就在 shard_0。
如果命中多个分片,需要在每个分片上过滤到业务数据,应用层聚合
2. 索引实效场景
- 条件中有 or,且 or 两边不能同时命中索引:使用 OR 连接条件时,只要其中一个条件没有索引,整个查询就会失效,导致全表扫描。
- 对索引列做函数、运算、类型转换
- 联合索引不满足最左前缀原则:比如索引是 (a,b,c),但查询条件直接写 b=1 and c=2,由于跳过了最左列 a,通常无法有效使用这个联合索引。
- 使用 !=、<>、not in、not like 等负向条件
- like 以 % 开头
案例:
索引:name
失效 SQL:WHERE name LIKE '%张%' 或 WHERE name LIKE '%张'
原因:B+ 树是从左向右构建的,前缀未知无法定位起点,只能全表扫描。
六、中间件部分
1. Redis相关
2. RPC相关
TODO
3. MQ相关
TODO
4. Netty与网络IO
TODO
七、Spring部分
Q:Spring AOP中的动态代理如何实现?
八、AI部分
TODO
九、场景题
TODO