1. Long的长度和范围,为什么要减一?
在Java中,Long类型是64位有符号整数,其范围是 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807,即 -2⁶³ 到 2⁶³ - 1。
为什么要减一?
-
二进制表示特性
对于有符号整数的最高位是符号位(0表示正,1表示负),剩下的63位表示数值。- 最大正数是符号位为0,其余63位全为1,对应的十进制值为 2⁶³ - 1(全1的二进制数等于2ⁿ - 1,n是位数)。
- 最小负数是符号位为1,其余63位全为0,对应值为 -2⁶³(由补码规则决定)。
-
补码规则
Java使用补码表示整数,补码的设计使得负数比正数多一个值。例如,64位中:- 正数范围:0 到 2⁶³ - 1(减一因为全1的二进制值比2ⁿ小1)。
- 负数范围:-2⁶³ 到 -1(无需减一,补码允许额外的最小值)。
总结
- 长度:64位(8字节)。
- 范围:
-2⁶³到2⁶³ - 1。 - 减一原因:二进制全1的数值等于2ⁿ - 1,而补码规则下正数最大值需要减一,负数最小值则不需要。
2. Java异常层次结构
Java的异常层次结构以Throwable类为根,分为两大分支:Error和Exception。它们的核心区别在于是否需要程序显式处理以及代表的错误类型。以下是详细的结构和关键点:
1. 异常层次结构
Throwable
├── Error // 严重系统错误,程序无法处理
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── ...
└── Exception // 程序可能处理的异常
├── RuntimeException // 非检查型异常(Unchecked)
│ ├── NullPointerException
│ ├── IndexOutOfBoundsException
│ └── ...
└── 其他检查型异常 // 检查型异常(Checked)
├── IOException
├── SQLException
└── ...
2. 核心分类
(1) Error
- 定义:表示JVM运行时内部的严重错误(如资源耗尽),程序无法处理。
- 特点:
- 非检查型异常(Unchecked),无需显式捕获或声明。
- 通常由JVM抛出,如
OutOfMemoryError(内存溢出)、StackOverflowError(栈溢出)。
- 应对策略:通常直接终止程序,无需尝试捕获。
(2) Exception
- 定义:程序运行时可能出现的异常,程序可以尝试处理。
- 分类:
- 检查型异常(Checked Exceptions):
- 继承自
Exception但不属于RuntimeException。 - 必须显式处理:通过
try-catch捕获或在方法签名中用throws声明。 - 常见类型:
IOException(文件操作)、SQLException(数据库操作)。 - 设计意义:强制开发者处理外部环境的不确定性(如文件不存在、网络中断)。
- 继承自
- 非检查型异常(Unchecked Exceptions):
- 继承自
RuntimeException。 - 无需显式处理,通常由代码逻辑错误导致。
- 常见类型:
NullPointerException(空指针)、IllegalArgumentException(非法参数)、ArrayIndexOutOfBoundsException(数组越界)。 - 设计意义:提醒开发者修复代码逻辑而非捕获处理。
- 继承自
- 检查型异常(Checked Exceptions):
3. 核心面试问题
Q1: 检查型异常 vs 非检查型异常?
- 检查型异常:必须显式处理,代表外部环境可能出现的错误(如IO、数据库)。
- 非检查型异常:无需处理,代表程序逻辑错误(如空指针、数组越界)。
Q2: 为什么要有RuntimeException?
- 设计目的:将“代码逻辑错误”与“外部环境错误”分离。
- 优势:避免对大量逻辑错误进行冗余的
try-catch,使代码更简洁。
Q3: 如何自定义异常?
- 继承
Exception创建检查型异常:public class MyCheckedException extends Exception { public MyCheckedException(String message) { super(message); } } - 继承
RuntimeException创建非检查型异常:public class MyUncheckedException extends RuntimeException { public MyUncheckedException(String message) { super(message); } }
4. 最佳实践
- 避免捕获
Throwable或Error:这些是严重错误,无法恢复。 - 不要忽略异常:空的
catch块会隐藏错误,导致调试困难。 - 优先使用非检查型异常:除非明确需要外部处理(如IO),否则用
RuntimeException减少代码冗余。 - 异常链(Cause):抛出异常时保留原始异常信息:
try { // 可能抛出IOException的代码 } catch (IOException e) { throw new MyException("操作失败", e); // 传递原始异常e作为cause }
总结
- 异常体系:
Throwable→Error(不可处理)和Exception(可处理)。 - 关键分类:检查型异常强制处理外部错误,非检查型异常提示代码逻辑问题。
- 核心原则:合理选择异常类型,确保代码健壮性和可维护性。
3. Java的集合类有了解吗?
在Java中,集合框架(Java Collections Framework, JCF)是用于存储、操作和管理一组对象的核心工具。以下是其核心层次结构、分类及关键实现类的总结:
一、集合框架的核心接口
- 根接口:
Collection:所有单列集合的根接口。List:有序、可重复(通过索引访问)。Set:无序、不可重复(唯一性由equals()和hashCode()保证)。Queue:队列,支持先进先出(FIFO)或优先级排序。
Map:双列集合(键值对),键唯一。
二、核心实现类及特性
1. List(有序、可重复)
-
ArrayList:- 底层实现:动态数组。
- 特点:
- 随机访问快(
O(1))。 - 增删中间元素慢(需要移动元素,
O(n))。 - 扩容机制:默认初始容量10,扩容为原容量的1.5倍(
oldCapacity + (oldCapacity >> 1))。
- 随机访问快(
-
LinkedList:- 底层实现:双向链表。
- 特点:
- 增删元素快(
O(1))。 - 随机访问慢(需遍历,
O(n))。 - 实现了
Deque接口,可用作栈或队列。
- 增删元素快(
-
Vector(已过时):- 线程安全(方法用
synchronized修饰)。 - 扩容机制:默认扩容为原容量的2倍。
- 线程安全(方法用
2. Set(无序、唯一)
-
HashSet:- 底层实现:基于
HashMap(哈希表)。 - 特点:
- 元素唯一性通过
hashCode()和equals()保证。 - 插入和查询快(
O(1),哈希冲突时退化为链表或红黑树)。
- 元素唯一性通过
- 底层实现:基于
-
LinkedHashSet:- 继承自
HashSet,底层使用链表维护插入顺序。 - 适合需要按插入顺序遍历的场景。
- 继承自
-
TreeSet:- 底层实现:基于
TreeMap(红黑树)。 - 特点:
- 元素按自然顺序或自定义
Comparator排序。 - 插入和查询复杂度
O(log n)。
- 元素按自然顺序或自定义
- 底层实现:基于
3. Queue(队列)
LinkedList:可用作普通队列。PriorityQueue:优先级队列(堆实现),元素按优先级排序。ArrayDeque:双端队列(基于数组),适合高频头尾操作。
4. Map(键值对)
-
HashMap:- 底层实现:数组+链表/红黑树(JDK8优化)。
- 特点:
- 键唯一,允许
null键和null值。 - 插入和查询快(平均
O(1))。 - 扩容机制:默认容量16,负载因子0.75(容量达到阈值时扩容2倍)。
- 哈希冲突处理:链表长度≥8时转为红黑树,≤6时退化为链表。
- 键唯一,允许
-
LinkedHashMap:- 继承
HashMap,通过双向链表维护插入顺序或访问顺序。 - 适合需要按顺序遍历的场景(如LRU缓存)。
- 继承
-
TreeMap:- 底层实现:红黑树。
- 特点:键按自然顺序或自定义
Comparator排序,操作复杂度O(log n)。
-
Hashtable(已过时):- 线程安全(方法用
synchronized修饰)。 - 不允许
null键和null值。
- 线程安全(方法用
-
ConcurrentHashMap(推荐替代Hashtable):- 线程安全(分段锁/CAS,JDK8后使用
Node + synchronized)。 - 高并发下性能优异。
- 线程安全(分段锁/CAS,JDK8后使用
三、关键面试问题
1. ArrayList vs LinkedList?
- 随机访问:
ArrayList(数组)快(O(1)),LinkedList(链表)慢(O(n))。 - 增删元素:
- 头部/中间插入:
LinkedList快(O(1)),ArrayList需移动元素(O(n))。 - 尾部插入:两者性能接近(
ArrayList可能触发扩容)。
- 头部/中间插入:
2. HashMap的工作原理?
- 哈希函数:通过
key.hashCode()计算桶位置。 - 冲突解决:链表法(JDK8优化为链表+红黑树)。
- 扩容:当元素数超过
容量×负载因子时,扩容为2倍并重新哈希。
3. 为什么HashMap的负载因子是0.75?
- 平衡空间和时间成本:
- 负载因子过小(如0.5):减少哈希冲突,但空间浪费。
- 负载因子过大(如1.0):空间利用率高,但冲突概率增加。
- 0.75是经验值,在时间和空间之间取得平衡。
4. ConcurrentHashMap如何保证线程安全?
- JDK7:分段锁(Segment),每个段独立加锁。
- JDK8:
Node数组 + CAS + synchronized(锁粒度更细)。
5. TreeMap如何排序?
- 键必须实现
Comparable接口,或构造时传入Comparator。
四、最佳实践
-
选择集合的原则:
- 需要唯一性:
Set。 - 需要键值对:
Map。 - 需要排序:
TreeSet/TreeMap。 - 高并发场景:
ConcurrentHashMap。
- 需要唯一性:
-
避免在循环中直接删除元素:
- 使用
Iterator.remove()防止ConcurrentModificationException。
- 使用
-
初始化时指定容量(如
ArrayList、HashMap):- 减少扩容开销。
总结
- 核心接口:
Collection(List/Set/Queue)和Map。 - 实现类特点:
ArrayList:动态数组,适合随机访问。LinkedList:链表,适合频繁增删。HashMap:哈希表,高性能键值存储。ConcurrentHashMap:高并发场景下的线程安全Map。
- 设计思想:通过接口与实现分离,提供灵活、高效的数据结构。
4. ArrayList和LinkedList区别
在Java中,ArrayList和LinkedList是List接口的两种核心实现,但它们的底层实现和适用场景存在显著差异。以下是它们的对比总结:
1. 底层数据结构
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 内存占用 | 连续内存空间,预留容量可能浪费内存 | 每个节点存储前后指针,内存开销更大 |
| 扩容机制 | 默认初始容量10,扩容为1.5倍(如15→22) | 无需扩容,按需动态添加节点 |
2. 操作性能对比
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 随机访问 | O(1):直接通过索引访问数组元素 | O(n):需从头或尾遍历链表到目标位置 |
| 头部插入/删除 | O(n):需移动后续所有元素 | O(1):直接修改头节点指针 |
| 尾部插入/删除 | O(1)(不扩容时),扩容时触发复制(O(n)) | O(1):直接修改尾节点指针 |
| 中间插入/删除 | O(n):需移动后续元素 | O(n):需遍历到目标位置,但插入/删除仅需O(1) |
| 内存局部性 | 好(连续内存,适合CPU缓存预读) | 差(节点分散,缓存不友好) |
3. 功能扩展
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 额外接口 | 无 | 实现了Deque接口,可作为队列、双端队列或栈使用 |
| 线程安全 | 非线程安全(需用Collections.synchronizedList) | 非线程安全(需外部同步) |
4. 适用场景
| 场景 | 推荐选择 | 原因 |
|---|---|---|
| 高频随机访问 | ArrayList | 数组的索引访问时间复杂度为O(1) |
| 频繁头尾操作 | LinkedList | 链表头尾插入/删除时间复杂度为O(1) |
| 中间频繁增删 | LinkedList(但需权衡遍历开销) | 插入/删除仅需O(1),但遍历到目标位置需O(n) |
| 内存敏感场景 | ArrayList | 链表节点额外指针占用更多内存 |
5. 代码示例对比
(1) 尾部插入
// ArrayList尾部插入(触发扩容时性能下降)
List<Integer> arrayList = new ArrayList<>();
arrayList.add(1); // O(1)(不扩容时)
// LinkedList尾部插入(始终O(1))
List<Integer> linkedList = new LinkedList<>();
linkedList.add(1); // O(1)
(2) 中间插入
// ArrayList中间插入(需移动元素)
arrayList.add(1000, 5); // O(n)
// LinkedList中间插入(需遍历到位置)
linkedList.add(1000, 5); // 遍历O(n),插入O(1)
6. 常见误区
-
误区1:“LinkedList在任何位置插入都比ArrayList快”
纠正:只有头尾插入是O(1),中间插入仍需遍历到位置(O(n)),实际性能可能不如ArrayList(因数组内存连续,移动元素可能更快)。 -
误区2:“ArrayList插入一定慢”
纠正:尾部插入不触发扩容时是O(1),性能与LinkedList相当;批量插入时ArrayList更高效(因内存连续)。
总结
- 选型原则:
- 优先选择
ArrayList(大多数场景更优,尤其是读多写少)。 - 仅在频繁头尾操作或需要实现队列/栈时使用
LinkedList。
- 优先选择
- 设计思想:
ArrayList以空间换时间(动态数组),适合快速访问。LinkedList以时间换空间(链表结构),适合动态增删。
5. HashMap有了解么,它的底层实现,为什么线程不安全,想要线程安全有什么措施
在Java中,HashMap是最常用的键值对存储结构,但其设计并非线程安全。以下是其底层实现、线程不安全原因及线程安全解决方案的详细分析:
一、HashMap的底层实现(JDK8+)
1. 数据结构
- 核心结构:数组(
Node<K,V>[] table)+ 链表 + 红黑树。 - 哈希桶定位:
index = (n - 1) & hash // n为数组长度,hash由key.hashCode()高16位异或低16位得到 - 链表转树条件:
- 链表长度 ≥ 8。
- 数组长度 ≥ 64(否则优先扩容而非转树)。
- 树退化为链表条件:树节点数 ≤ 6。
2. 关键流程
- 插入(put):
- 计算哈希值,定位桶位置。
- 若桶为空,直接插入新节点。
- 若桶为链表/树,遍历处理哈希冲突(更新值或新增节点)。
- 扩容(resize):
- 触发条件:元素数量 > 容量 × 负载因子(默认0.75)。
- 新容量 = 旧容量 × 2,重新哈希分布元素。
- JDK8优化:利用高位掩码判断元素位置是否需要移动(避免全量重新哈希)。
3. 核心参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| 初始容量 | 16 | 必须为2的幂,方便位运算计算索引 |
| 负载因子 | 0.75 | 平衡空间利用率和哈希冲突概率 |
| 树化阈值 | 8 | 链表长度超过此值转为红黑树 |
| 退化阈值 | 6 | 树节点数低于此值退化为链表 |
二、HashMap线程不安全的原因
1. 数据覆盖(并发put)
- 场景:多线程同时调用
put(),哈希计算到同一位置且该位置为空。 - 问题:
线程A和线程B同时判断桶为空,均尝试插入节点,导致其中一个线程的数据被覆盖。
2. 扩容死循环(JDK7及之前)
- 原因:JDK7使用头插法,并发扩容时链表反转导致循环引用。
- 示例:
结果:后续调用// JDK7扩容代码片段(多线程执行时可能形成环状链表) void transfer(Entry[] newTable) { for (Entry<K,V> e : table) { while (e != null) { Entry<K,V> next = e.next; int i = indexFor(e.hash, newTable.length); e.next = newTable[i]; // 头插法 newTable[i] = e; e = next; } } }get()时可能陷入死循环。
3. size统计不准确
- 场景:多线程同时修改
HashMap(如put()或remove())。 - 问题:
size字段未原子更新,导致统计值与实际元素数不一致。
4. JDK8的改进与残留问题
- 改进:尾插法替代头插法,避免扩容死循环。
- 残留问题:数据覆盖和
size不一致仍存在。
三、线程安全的替代方案
1. 使用ConcurrentHashMap(推荐)
- 实现原理:
- JDK7:分段锁(
Segment数组),每段独立加锁,并发度=段数。 - JDK8:
Node数组 + CAS + synchronized,锁粒度细化到桶级别。- 插入流程:
- 若桶为空,通过CAS插入新节点。
- 若桶非空,使用
synchronized锁住桶头节点,处理链表/树。
- 插入流程:
- JDK7:分段锁(
- 优势:高并发下性能优异,读操作无锁(基于
volatile可见性)。
2. 使用Collections.synchronizedMap
- 原理:通过包装类对所有方法加
synchronized锁。Map<K,V> syncMap = Collections.synchronizedMap(new HashMap<>()); - 缺点:全表锁导致并发性能差。
3. 使用Hashtable(已过时)
- 问题:所有方法使用
synchronized修饰,性能低下,不推荐使用。
四、对比与选型建议
| 方案 | 锁粒度 | 并发性能 | 适用场景 |
|---|---|---|---|
ConcurrentHashMap | 桶级别(JDK8+) | 高 | 高并发读写场景 |
synchronizedMap | 全表锁 | 低 | 低并发或少量写操作场景 |
Hashtable | 全表锁 | 最低 | 遗留代码兼容,不推荐新项目 |
五、面试问题示例
Q1: 为什么HashMap的容量是2的幂?
- 答:通过位运算
(n - 1) & hash快速定位桶,2的幂保证n-1的二进制全为1,哈希分布更均匀。
Q2: ConcurrentHashMap如何保证线程安全?
- 答:JDK8使用CAS和
synchronized锁桶头节点,减少锁竞争;读操作无锁,通过volatile保证可见性。
Q3: 负载因子为什么是0.75?
- 答:经验值,平衡哈希冲突概率(负载因子高)和空间利用率(负载因子低)。
总结
- HashMap线程不安全根源:并发修改导致数据覆盖、扩容死循环(JDK7)及
size不一致。 - 线程安全方案:优先选择
ConcurrentHashMap,避免使用Hashtable。 - 设计思想:通过细粒度锁(如桶锁)和CAS操作提升并发性能,减少锁竞争。
6. CoucurHashMap和HashTable
1. 线程安全实现机制
| 特性 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 锁粒度 | 分段锁(JDK7)或桶锁(JDK8+) | 全表锁(每个方法用synchronized修饰) |
| 并发度 | 高(多线程可同时读写不同段/桶) | 低(同一时间仅一个线程操作) |
- JDK7:ConcurrentHashMap 使用分段锁(
Segment),每个段独立加锁,并发度等于段数(默认16段)。 - JDK8+:改用
Node数组 + CAS + synchronized,仅锁住当前操作的桶(链表头节点或树根节点),锁粒度更细。
2. 性能对比
| 场景 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 高并发读 | 无锁(通过volatile保证可见性) | 同步阻塞,性能差 |
| 高并发写 | 多线程可同时操作不同桶,竞争少 | 全局锁导致串行化,性能极低 |
| 扩容开销 | 分段/桶独立扩容,影响局部 | 全局扩容,阻塞所有操作 |
- 示例:10个线程并发写入,ConcurrentHashMap 允许同时操作不同桶,而 Hashtable 必须串行执行。
3. Null值支持
| 特性 | ConcurrentHashMap | Hashtable |
|---|---|---|
| Key/Value是否可为null | 否(设计上避免歧义) | 否(直接抛NullPointerException) |
- 原因:并发场景下,允许null会导致难以区分“键不存在”和“键的值为null”。
4. 迭代器特性
| 特性 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 一致性 | 弱一致性(迭代期间可能反映部分修改) | 强一致性(迭代期间其他线程无法修改) |
| 异常风险 | 不会抛ConcurrentModificationException | 不会抛ConcurrentModificationException(但通过全表锁保证强一致) |
- ConcurrentHashMap 迭代器:遍历时基于创建时的快照,后续修改可能不会立即可见。
- Hashtable 迭代器:遍历时其他线程无法修改,数据完全一致。
5. 内部数据结构优化
| 特性 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 冲突解决 | 链表 + 红黑树(JDK8+,链表≥8转树) | 链表(无树化优化) |
| 哈希计算 | 扰动函数优化哈希分布(高16位异或低16位) | 直接使用key.hashCode() |
- 红黑树优化:当链表过长时,ConcurrentHashMap 转换为红黑树,查询效率从
O(n)提升到O(log n)。
6. 初始容量与扩容机制
| 参数 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 默认初始容量 | 16(总是2的幂) | 11(非2的幂) |
| 扩容规则 | 容量翻倍(2的幂) | 旧容量×2 +1(如11→23) |
| 负载因子 | 0.75(可自定义) | 0.75(不可自定义) |
- ConcurrentHashMap:容量为2的幂,便于位运算计算索引(
(n-1) & hash)。 - Hashtable:非2的幂容量,索引计算通过取模(
hash % length),效率较低。
7. 方法扩展
| 特性 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 原子操作 | 提供putIfAbsent()、compute()等原子方法 | 仅基础方法(如put()、get()) |
- 示例:
ConcurrentHashMap.putIfAbsent(key, value)实现“不存在则插入”的原子操作。
总结与选型建议
- 为什么选择ConcurrentHashMap?
- 高并发性能:细粒度锁(JDK8+)或分段锁(JDK7)减少竞争。
- 扩展功能:支持原子复合操作(如
replace()、computeIfAbsent())。 - 优化数据结构:红黑树提升查询效率。
- Hashtable的缺点:
- 全表锁导致性能瓶颈。
- 过时的API设计,缺乏现代并发优化。
- 替代方案:
- 低并发场景:可使用
Collections.synchronizedMap(new HashMap<>()),但性能仍不如ConcurrentHashMap。 - 高并发场景:ConcurrentHashMap是标准选择。
- 低并发场景:可使用
面试问题示例
Q1: 为什么ConcurrentHashMap的读操作不需要加锁?
- 答:通过
volatile修饰的Node.val和next字段保证可见性,读操作直接访问内存最新值。
Q2: JDK8中ConcurrentHashMap如何实现线程安全?
- 答:对桶头节点使用
synchronized锁,插入空桶时使用CAS,结合volatile保证内存可见性。
Q3: ConcurrentHashMap的size()方法是否完全准确?
- 答:不一定。JDK8中通过分段计数(
CounterCell)累加,并发更新时可能存在短暂误差,但最终会趋于准确。
7. 线程池有了解吗,讲一下?
Java线程池详解
1. 线程池的核心作用
- 资源复用:减少线程创建和销毁的开销,提升性能。
- 控制并发:限制线程数量,避免资源耗尽。
- 任务管理:统一管理任务的提交、执行和监控。
2. 线程池的核心参数(ThreadPoolExecutor)
| 参数 | 说明 |
|---|---|
corePoolSize | 核心线程数,即使空闲也不会销毁(除非设置allowCoreThreadTimeOut)。 |
maximumPoolSize | 最大线程数,当队列满时允许创建的最大线程数。 |
keepAliveTime | 非核心线程的空闲存活时间(超时未使用将被回收)。 |
unit | 存活时间的单位(如秒、毫秒)。 |
workQueue | 任务队列,用于保存等待执行的任务(如ArrayBlockingQueue、LinkedBlockingQueue)。 |
threadFactory | 线程工厂,用于创建线程(可自定义线程名称、优先级等)。 |
rejectedHandler | 拒绝策略,当任务超出处理能力时的处理方式。 |
3. 线程池的工作流程
- 提交任务:调用
execute()或submit()方法提交任务。 - 核心线程处理:
- 若当前线程数 <
corePoolSize,立即创建新线程执行任务。
- 若当前线程数 <
- 入队等待:
- 若核心线程已满,任务进入工作队列。
- 扩展线程:
- 若队列已满且线程数 <
maximumPoolSize,创建新线程执行任务。
- 若队列已满且线程数 <
- 拒绝策略:
- 若队列满且线程数已达最大值,触发拒绝策略处理新任务。
4. 常见线程池类型(Executors工厂类)
| 线程池类型 | 特点 | 适用场景 |
|---|---|---|
newFixedThreadPool | 固定线程数,使用无界队列(LinkedBlockingQueue)。 | 负载稳定的并发任务。 |
newCachedThreadPool | 线程数弹性(0到Integer.MAX_VALUE),空闲线程60秒回收。 | 短时异步任务或低并发峰谷场景。 |
newSingleThreadExecutor | 单线程,保证任务顺序执行。 | 需顺序执行任务的场景。 |
newScheduledThreadPool | 支持定时或周期性任务,使用DelayedWorkQueue。 | 定时任务、周期性执行(如心跳检测)。 |
5. 拒绝策略(RejectedExecutionHandler)
| 策略 | 行为 |
|---|---|
AbortPolicy(默认) | 抛出RejectedExecutionException,中断任务提交。 |
CallerRunsPolicy | 由提交任务的线程直接执行该任务(降低提交速度,避免雪崩)。 |
DiscardPolicy | 静默丢弃新任务,不抛异常。 |
DiscardOldestPolicy | 丢弃队列中最旧的任务,重新尝试提交新任务(可能丢失关键任务)。 |
6. 线程池的合理配置
- CPU密集型任务:线程数 ≈ CPU核心数(避免过多线程导致频繁上下文切换)。
- IO密集型任务:线程数 ≈ CPU核心数 × (1 + 平均等待时间/计算时间)。
- 混合型任务:拆分为CPU密集和IO密集部分,分别配置线程池。
示例:
- 4核CPU处理IO密集型任务(如Web请求):
线程数 = 4 * (1 + 50/10) = 24(假设50ms等待,10ms计算)。
7. 线程池的监控与调优
- 关键指标:
getPoolSize():当前线程数。getActiveCount():活跃线程数。getCompletedTaskCount():已完成任务数。getQueue().size():队列中等待的任务数。
- 工具支持:
- Spring Boot Actuator:通过
/actuator/metrics监控线程池状态。 - 自定义监控:继承
ThreadPoolExecutor并重写beforeExecute()和afterExecute()。
- Spring Boot Actuator:通过
8. 线程池的关闭
- 优雅关闭:
shutdown():停止接收新任务,等待已提交任务执行完成。shutdownNow():尝试停止所有正在执行的任务,返回未执行的任务列表。
- 注意事项:
- 避免强制终止,确保任务状态一致性。
- 结合
awaitTermination()等待线程池完全关闭。
9. 常见问题与解决方案
Q1: 为什么避免使用Executors创建线程池?
- 风险:
FixedThreadPool和SingleThreadExecutor使用无界队列,可能导致OOM。CachedThreadPool允许无限创建线程,可能耗尽资源。
- 建议:手动创建
ThreadPoolExecutor,明确控制参数。
Q2: 如何防止任务长时间阻塞线程池?
- 方案:
- 设置合理的超时时间,使用
Future.get(timeout, unit)。 - 分离阻塞任务到独立线程池,避免影响核心业务。
- 设置合理的超时时间,使用
Q3: 线程池中线程异常如何处理?
- 方案:
- 在任务内部捕获异常,避免线程提前终止。
- 通过
Thread.UncaughtExceptionHandler统一处理未捕获异常。
总结
- 核心思想:通过池化技术提升资源利用率和系统稳定性。
- 关键实践:合理配置参数、选择拒绝策略、监控线程池状态。
- 推荐做法:根据业务场景手动创建线程池,避免使用默认工厂方法的风险。
8. 线程池配置无界队列了之后,拒绝策略怎么搞,什么时候用到无界队列?
作为Java后端程序员,理解线程池的队列与拒绝策略的协作关系是优化高并发系统的关键。以下是针对问题的结构化回答:
一、线程池配置无界队列时,拒绝策略的实际效果
1. 线程池的工作流程
当使用ThreadPoolExecutor并配置无界队列(如LinkedBlockingQueue未指定容量)时,其任务处理逻辑如下:
- 提交任务后,优先使用核心线程处理。
- 若核心线程已满,任务进入无界队列等待。
- 最大线程数配置失效(因队列无界,永远不会触发创建非核心线程)。
- 拒绝策略永不触发(队列无法被填满,不会达到触发拒绝的条件)。
2. 拒绝策略的无效性
- 根本原因:无界队列的容量无限,任务永远可以入队,线程池永远不会进入“队列满 + 线程数达最大值”的状态。
- 拒绝策略(如
AbortPolicy):代码中配置的策略不会生效,仅当队列有界时才有意义。
3. 潜在风险
- 内存溢出(OOM):若任务持续积压且处理速度不足,队列无限增长,最终导致
OutOfMemoryError。 - 系统崩溃:无界队列掩盖资源过载问题,延迟暴露系统瓶颈。
二、何时使用无界队列?
1. 适用场景
- 任务量可控:任务提交速率长期低于处理能力,如低频后台异步任务(日志记录、数据清洗)。
- 允许延迟处理:不要求实时响应,但需保证任务最终执行(如离线报表生成)。
- 资源充足:系统内存足够大,能容忍队列的潜在增长。
2. 实际案例
// 示例:使用无界队列的线程池(谨慎使用!)
ExecutorService executor = new ThreadPoolExecutor(
5, // 核心线程数
5, // 最大线程数(与核心线程数相同,避免创建非核心线程)
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>() // 无界队列
);
三、替代方案:有界队列 + 合理拒绝策略
1. 推荐配置
- 有界队列(如
ArrayBlockingQueue)限制队列容量,避免内存失控。 - 合理拒绝策略:根据业务需求选择策略,例如:
CallerRunsPolicy:任务回退到提交线程执行(降低提交速度,避免雪崩)。- 自定义策略:记录日志、降级处理或转入死信队列。
2. 示例代码
ExecutorService safeExecutor = new ThreadPoolExecutor(
5,
10, // 允许非核心线程扩容
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 有界队列(容量100)
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:提交线程自己执行
);
四、面试扩展:线程池参数设计原则
- 核心线程数:参考CPU密集型(N+1)或IO密集型(2N+1)任务类型。
- 最大线程数:根据系统资源(内存、连接数)和任务特性设定。
- 队列选择:
- 无界队列:仅用于任务量绝对可控的场景。
- 有界队列:需配合拒绝策略,平衡吞吐量与稳定性。
- 同步队列(SynchronousQueue):直接传递任务,适用于高吞吐且瞬时任务可快速处理的场景。
总结
- 无界队列的拒绝策略无效:因队列永不满,配置的拒绝策略不会触发,需警惕内存风险。
- 使用场景:仅适用于任务量小、处理速度快且资源充足的场景。
- 最佳实践:优先选择有界队列+拒绝策略,通过系统压测确定合理参数,保障高并发下的稳定性。
9. MVCC讲一下
作为Java后端程序员,理解MVCC(Multi-Version Concurrency Control,多版本并发控制)是掌握数据库高并发场景下事务隔离机制的关键。以下是针对面试的回答要点:
一、MVCC的核心思想
MVCC通过数据多版本和快照读(Snapshot Read)实现并发控制,核心目标是在保证一定隔离级别的前提下,减少读写操作的锁冲突,提升并发性能。
- 典型应用:MySQL InnoDB引擎的默认隔离级别(
REPEATABLE READ)即基于MVCC实现。 - 对比锁机制:传统锁(如行锁、表锁)会导致读写阻塞,MVCC通过版本链实现非阻塞读。
二、MVCC的实现原理
1. 数据版本管理
- 隐藏字段:InnoDB每行记录包含两个隐藏字段:
DB_TRX_ID:最近修改该行的事务ID(事务提交时生成)。DB_ROLL_PTR:回滚指针,指向Undo Log中的旧版本记录,形成版本链。
- Undo Log:存储历史版本数据,用于构建版本链和事务回滚。
2. ReadView(一致性视图)
事务在第一次执行查询时生成一个ReadView,用于决定当前事务能看到哪些版本的数据。ReadView包含:
trx_list:当前活跃(未提交)的事务ID列表。up_limit_id:活跃事务中最小的事务ID。low_limit_id:当前系统最大事务ID+1。
3. 可见性判断规则
遍历版本链时,根据以下规则判断数据是否可见:
- 如果记录的
DB_TRX_ID < up_limit_id→ 该版本由已提交事务生成,可见。 - 如果
DB_TRX_ID ≥ low_limit_id→ 该版本由未来事务生成,不可见。 - 如果
DB_TRX_ID在trx_list中 → 该版本由未提交事务生成,不可见。 - 否则,检查
DB_TRX_ID是否等于当前事务ID → 是则可见,否则不可见。
三、MVCC解决的问题
1. 不同隔离级别的实现
-
读已提交(READ COMMITTED):
每次查询都生成新的ReadView,能看到其他事务已提交的最新数据。
解决的问题:脏读。
未解决的问题:不可重复读、幻读(但InnoDB通过间隙锁避免幻读)。 -
可重复读(REPEATABLE READ):
事务内首次查询生成ReadView,后续复用该视图,确保多次读取一致性。
解决的问题:脏读、不可重复读、部分幻读(通过MVCC+间隙锁)。
2. 读写并发优化
- 读操作(快照读):基于版本链和ReadView读取历史版本,无需加锁。
- 写操作(当前读):通过行锁(X锁)或间隙锁保证数据一致性。
四、MVCC的关键流程
1. 查询流程
1. 根据查询条件找到符合条件的行(可能通过索引)。
2. 检查行的最新版本是否对当前事务可见(通过ReadView判断)。
3. 若不可见,沿版本链(通过DB_ROLL_PTR)找到满足可见性的版本。
2. 更新/删除流程
1. 对目标行加排他锁(X Lock)。
2. 将当前行数据拷贝到Undo Log,生成旧版本。
3. 修改当前行的数据,更新DB_TRX_ID和DB_ROLL_PTR。
五、MVCC的优缺点
优点
- 高并发读:读操作不阻塞写操作,写操作不阻塞读操作。
- 避免部分锁竞争:减少死锁概率,提升系统吞吐量。
缺点
- 额外存储:Undo Log占用存储空间,需定期清理(通过Purge线程)。
- 历史版本管理复杂度:版本链过长可能影响查询性能。
六、Java中的实际应用
在Spring中,通过配置事务隔离级别间接使用MVCC:
@Transactional(isolation = Isolation.REPEATABLE_READ) // 触发MVCC的快照读
public void queryData() {
// 使用SELECT语句(默认快照读)
}
@Transactional
public void updateData() {
// 使用SELECT ... FOR UPDATE(当前读,加锁)
}
七、面试扩展点
-
快照读 vs 当前读
- 快照读:普通
SELECT,基于ReadView读取历史版本。 - 当前读:
SELECT ... FOR UPDATE、UPDATE、DELETE,读取最新版本并加锁。
- 快照读:普通
-
MVCC与锁的关系
MVCC并非完全替代锁,而是与锁协同工作(如写操作仍需加锁)。 -
不同数据库的实现差异
- PostgreSQL:通过多版本堆表实现。
- Oracle:通过Undo表空间实现类似机制。
总结
MVCC通过数据多版本和一致性视图(ReadView)实现了高效的读写并发控制,是支撑高并发场景下数据库性能的核心技术。理解其版本链、可见性规则及隔离级别的实现差异,是回答此类问题的关键。
10. 事务特性、隔离级别
作为Java后端程序员,理解事务的特性和隔离级别是数据库和分布式系统设计的核心基础。以下是精简清晰的回答:
一、事务的四大特性(ACID)
-
原子性(Atomicity)
事务是一个不可分割的最小操作单元,要么全部成功(提交),要么全部失败回滚。
实现机制:通过Undo Log记录操作前的数据状态,支持回滚。 -
一致性(Consistency)
事务执行后,数据库从一个一致性状态转换到另一个一致性状态(如约束、触发器等规则不被破坏)。
本质:是原子性、隔离性、持久性共同作用的结果。 -
隔离性(Isolation)
多个并发事务执行时,彼此不可见中间状态,防止数据混乱。
实现机制:通过锁或MVCC(多版本并发控制)实现。 -
持久性(Durability)
事务提交后,数据永久保存到数据库,即使系统崩溃也不丢失。
实现机制:通过Redo Log持久化到磁盘,崩溃恢复时重放日志。
二、事务隔离级别(由低到高)
隔离级别本质是平衡并发性能与数据一致性的设计。以下是SQL标准定义的4个级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 适用场景 |
|---|---|---|---|---|
| 读未提交 (Read Uncommitted) | ✔️ | ✔️ | ✔️ | 低一致性要求,高并发场景(极少用) |
| 读已提交 (Read Committed) | ❌ | ✔️ | ✔️ | 默认级别(如Oracle),平衡性能与一致性 |
| 可重复读 (Repeatable Read) | ❌ | ❌ | ✔️ | 默认级别(如MySQL InnoDB),通过MVCC避免部分问题 |
| 串行化 (Serializable) | ❌ | ❌ | ❌ | 强一致性要求,牺牲并发性能(如金融交易) |
关键问题解释
- 脏读(Dirty Read):读到其他事务未提交的数据(如事务A修改后未提交,事务B读到修改值,A回滚后B数据无效)。
- 不可重复读(Non-Repeatable Read):同一事务内多次读取同一数据,结果不同(因其他事务修改了该数据并提交)。
- 幻读(Phantom Read):同一事务内多次查询同一范围,结果集数量不同(因其他事务插入或删除了符合条件的数据)。
三、Java中的事务隔离级别配置
在Spring中,可通过@Transactional注解指定隔离级别:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void updateData() {
// 业务逻辑
}
常见隔离级别选项
Isolation.DEFAULT:使用数据库默认级别(如MySQL默认REPEATABLE_READ)。Isolation.READ_COMMITTED:读已提交(推荐多数场景)。Isolation.REPEATABLE_READ:可重复读(MySQL默认,通过MVCC实现)。Isolation.SERIALIZABLE:串行化(性能差,慎用)。
四、实际开发中的注意事项
- 默认隔离级别:不同数据库默认值不同(如MySQL默认
REPEATABLE_READ,Oracle默认READ_COMMITTED)。 - MVCC与锁的权衡:
- MVCC通过版本快照(如MySQL的
ReadView)避免读写冲突,提升并发性能。 - 锁机制(如行锁、间隙锁)解决幻读,但可能引发死锁。
- MVCC通过版本快照(如MySQL的
- 业务场景适配:
- 金融交易需高隔离级别(如串行化)。
- 互联网高并发场景可适当降低隔离级别(如读已提交)。
总结
- ACID是事务的核心特性,隔离级别是平衡一致性与性能的关键设计。
- 隔离级别需结合业务场景选择,理解脏读、不可重复读、幻读的差异是回答问题的核心。
- Java中通过Spring注解灵活配置隔离级别,但需结合底层数据库的默认行为和实现机制(如MySQL的MVCC)。
11. redis有了解过吗。zset的底层数据结构(压缩列表+跳表),zrange的时间复杂度
Redis的有序集合(Zset)底层采用了两种数据结构:压缩列表(ziplist)和跳跃表(skiplist),具体使用哪种结构取决于元素的数量和大小。
1. 底层数据结构
-
压缩列表(ziplist):
当元素数量较少(默认≤128,通过zset-max-ziplist-entries配置)且每个元素的成员长度较小(默认≤64字节,通过zset-max-ziplist-value配置)时,Zset使用压缩列表。压缩列表是连续内存存储的紧凑结构,节省内存,但增删操作的效率较低,适合小数据场景。 -
跳跃表(skiplist):
当元素数量或大小超过阈值时,Zset转为跳跃表+字典的组合。跳跃表通过多级索引实现平均O(log n) 的查找效率,且支持高效的范围查询。字典用于快速查找成员对应的分数(O(1))。
2. ZRANGE 的时间复杂度
-
压缩列表:
由于元素是连续存储的,范围查询(如ZRANGE)需要从头遍历到起始位置,再顺序读取元素。时间复杂度为O(k + m)(k为起始偏移量,m为返回元素数)。当数据量小时(如默认128内),k较小,整体效率较高。 -
跳跃表:
查找起始节点的时间为O(log n),之后沿最底层的双向链表遍历m个元素(O(m)),总时间复杂度为O(log n + m)。这是ZRANGE的主要时间复杂度,因为跳跃表是处理大数据量的标准结构。
3. 总结
- Zset的底层结构选择:根据数据规模和配置阈值动态切换,平衡内存和性能。
- ZRANGE的复杂度:跳跃表下为O(log n + m),压缩列表下为O(k + m),但后者在数据量小时依然高效。
此设计使得Zset在不同场景下均能兼顾性能和内存效率,适合实现排行榜、范围查询等高频操作。
算法题:反转链表Ⅱ
为了反转链表中从位置 left 到 right 的部分,我们可以使用以下方法:
方法思路
- 使用虚拟头节点:创建一个虚拟头节点
dummy,这样可以统一处理头节点可能被反转的情况。 - 定位关键节点:
pre:指向left的前一个节点。start:指向left位置的节点。end:指向right位置的节点。succ:指向right的后一个节点。
- 反转子链表:反转从
start到end的部分。 - 重新连接链表:将反转后的子链表与原始链表的前后部分连接起来。
解决代码
class ListNode {
int val;
ListNode next;
ListNode() {}
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
public class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
if (head == null || left == right) {
return head;
}
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy;
// 移动到 left 的前一个节点
for (int i = 0; i < left - 1; i++) {
pre = pre.next;
}
ListNode start = pre.next;
ListNode end = start;
// 计算反转的节点数
int k = right - left + 1;
for (int i = 0; i < k - 1; i++) {
end = end.next;
}
ListNode succ = end.next;
// 反转 start 到 end 的部分
ListNode prev = null;
ListNode curr = start;
for (int i = 0; i < k; i++) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
// 重新连接链表
pre.next = prev;
start.next = succ;
return dummy.next;
}
}
代码解释
- 虚拟头节点:
dummy节点用于简化头节点可能被反转的情况处理。 - 定位
pre和start:通过循环移动pre到left的前一个位置,start即为pre.next。 - 确定反转范围:计算反转的节点数
k,并找到end和其后继节点succ。 - 反转子链表:使用迭代法反转从
start到end的链表部分。 - 重新连接:将反转后的子链表头连接到
pre,并将反转后的子链表尾连接到succ。
该方法时间复杂度为 O(n),空间复杂度为 O(1),高效地解决了问题。