面试题解五

133 阅读33分钟

image.png

1. Long的长度和范围,为什么要减一?

在Java中,Long类型是64位有符号整数,其范围是 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807,即 -2⁶³ 到 2⁶³ - 1

为什么要减一?

  1. 二进制表示特性
    对于有符号整数的最高位是符号位(0表示正,1表示负),剩下的63位表示数值。

    • 最大正数是符号位为0,其余63位全为1,对应的十进制值为 2⁶³ - 1(全1的二进制数等于2ⁿ - 1,n是位数)。
    • 最小负数是符号位为1,其余63位全为0,对应值为 -2⁶³(由补码规则决定)。
  2. 补码规则
    Java使用补码表示整数,补码的设计使得负数比正数多一个值。例如,64位中:

    • 正数范围:0 到 2⁶³ - 1(减一因为全1的二进制值比2ⁿ小1)。
    • 负数范围:-2⁶³ 到 -1(无需减一,补码允许额外的最小值)。

总结

  • 长度:64位(8字节)。
  • 范围-2⁶³2⁶³ - 1
  • 减一原因:二进制全1的数值等于2ⁿ - 1,而补码规则下正数最大值需要减一,负数最小值则不需要。

2. Java异常层次结构

Java的异常层次结构以Throwable类为根,分为两大分支:ErrorException。它们的核心区别在于是否需要程序显式处理以及代表的错误类型。以下是详细的结构和关键点:


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(数组越界)。
      • 设计意义:提醒开发者修复代码逻辑而非捕获处理。

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. 最佳实践

  1. 避免捕获ThrowableError:这些是严重错误,无法恢复。
  2. 不要忽略异常:空的catch块会隐藏错误,导致调试困难。
  3. 优先使用非检查型异常:除非明确需要外部处理(如IO),否则用RuntimeException减少代码冗余。
  4. 异常链(Cause):抛出异常时保留原始异常信息:
    try {
        // 可能抛出IOException的代码
    } catch (IOException e) {
        throw new MyException("操作失败", e); // 传递原始异常e作为cause
    }
    

总结

  • 异常体系ThrowableError(不可处理)和Exception(可处理)。
  • 关键分类:检查型异常强制处理外部错误,非检查型异常提示代码逻辑问题。
  • 核心原则:合理选择异常类型,确保代码健壮性和可维护性。

3. Java的集合类有了解吗?

在Java中,集合框架(Java Collections Framework, JCF)是用于存储、操作和管理一组对象的核心工具。以下是其核心层次结构、分类及关键实现类的总结:


一、集合框架的核心接口

  1. 根接口
    • 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)。
    • 高并发下性能优异。

三、关键面试问题

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),每个段独立加锁。
  • JDK8Node数组 + CAS + synchronized(锁粒度更细)。

5. TreeMap如何排序?

  • 键必须实现Comparable接口,或构造时传入Comparator

四、最佳实践

  1. 选择集合的原则

    • 需要唯一性:Set
    • 需要键值对:Map
    • 需要排序:TreeSet/TreeMap
    • 高并发场景:ConcurrentHashMap
  2. 避免在循环中直接删除元素

    • 使用Iterator.remove()防止ConcurrentModificationException
  3. 初始化时指定容量(如ArrayListHashMap):

    • 减少扩容开销。

总结

  • 核心接口Collection(List/Set/Queue)和Map
  • 实现类特点
    • ArrayList:动态数组,适合随机访问。
    • LinkedList:链表,适合频繁增删。
    • HashMap:哈希表,高性能键值存储。
    • ConcurrentHashMap:高并发场景下的线程安全Map。
  • 设计思想:通过接口与实现分离,提供灵活、高效的数据结构。

4. ArrayList和LinkedList区别

在Java中,ArrayListLinkedListList接口的两种核心实现,但它们的底层实现和适用场景存在显著差异。以下是它们的对比总结:


1. 底层数据结构

特性ArrayListLinkedList
数据结构动态数组双向链表
内存占用连续内存空间,预留容量可能浪费内存每个节点存储前后指针,内存开销更大
扩容机制默认初始容量10,扩容为1.5倍(如15→22)无需扩容,按需动态添加节点

2. 操作性能对比

操作ArrayListLinkedList
随机访问O(1):直接通过索引访问数组元素O(n):需从头或尾遍历链表到目标位置
头部插入/删除O(n):需移动后续所有元素O(1):直接修改头节点指针
尾部插入/删除O(1)(不扩容时),扩容时触发复制(O(n))O(1):直接修改尾节点指针
中间插入/删除O(n):需移动后续元素O(n):需遍历到目标位置,但插入/删除仅需O(1)
内存局部性好(连续内存,适合CPU缓存预读)差(节点分散,缓存不友好)

3. 功能扩展

特性ArrayListLinkedList
额外接口实现了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. 关键流程

  1. 插入(put)
    • 计算哈希值,定位桶位置。
    • 若桶为空,直接插入新节点。
    • 若桶为链表/树,遍历处理哈希冲突(更新值或新增节点)。
  2. 扩容(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数组),每段独立加锁,并发度=段数。
    • JDK8Node数组 + CAS + synchronized,锁粒度细化到桶级别。
      • 插入流程
        1. 若桶为空,通过CAS插入新节点。
        2. 若桶非空,使用synchronized锁住桶头节点,处理链表/树。
  • 优势:高并发下性能优异,读操作无锁(基于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. 线程安全实现机制

特性ConcurrentHashMapHashtable
锁粒度分段锁(JDK7)或桶锁(JDK8+)全表锁(每个方法用synchronized修饰)
并发度高(多线程可同时读写不同段/桶)低(同一时间仅一个线程操作)
  • JDK7:ConcurrentHashMap 使用分段锁(Segment),每个段独立加锁,并发度等于段数(默认16段)。
  • JDK8+:改用 Node数组 + CAS + synchronized,仅锁住当前操作的桶(链表头节点或树根节点),锁粒度更细。

2. 性能对比

场景ConcurrentHashMapHashtable
高并发读无锁(通过volatile保证可见性)同步阻塞,性能差
高并发写多线程可同时操作不同桶,竞争少全局锁导致串行化,性能极低
扩容开销分段/桶独立扩容,影响局部全局扩容,阻塞所有操作
  • 示例:10个线程并发写入,ConcurrentHashMap 允许同时操作不同桶,而 Hashtable 必须串行执行。

3. Null值支持

特性ConcurrentHashMapHashtable
Key/Value是否可为null(设计上避免歧义)(直接抛NullPointerException
  • 原因:并发场景下,允许null会导致难以区分“键不存在”和“键的值为null”。

4. 迭代器特性

特性ConcurrentHashMapHashtable
一致性弱一致性(迭代期间可能反映部分修改)强一致性(迭代期间其他线程无法修改)
异常风险不会抛ConcurrentModificationException不会抛ConcurrentModificationException(但通过全表锁保证强一致)
  • ConcurrentHashMap 迭代器:遍历时基于创建时的快照,后续修改可能不会立即可见。
  • Hashtable 迭代器:遍历时其他线程无法修改,数据完全一致。

5. 内部数据结构优化

特性ConcurrentHashMapHashtable
冲突解决链表 + 红黑树(JDK8+,链表≥8转树)链表(无树化优化)
哈希计算扰动函数优化哈希分布(高16位异或低16位)直接使用key.hashCode()
  • 红黑树优化:当链表过长时,ConcurrentHashMap 转换为红黑树,查询效率从O(n)提升到O(log n)

6. 初始容量与扩容机制

参数ConcurrentHashMapHashtable
默认初始容量16(总是2的幂)11(非2的幂)
扩容规则容量翻倍(2的幂)旧容量×2 +1(如11→23)
负载因子0.75(可自定义)0.75(不可自定义)
  • ConcurrentHashMap:容量为2的幂,便于位运算计算索引((n-1) & hash)。
  • Hashtable:非2的幂容量,索引计算通过取模(hash % length),效率较低。

7. 方法扩展

特性ConcurrentHashMapHashtable
原子操作提供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.valnext字段保证可见性,读操作直接访问内存最新值。

Q2: JDK8中ConcurrentHashMap如何实现线程安全?

  • :对桶头节点使用synchronized锁,插入空桶时使用CAS,结合volatile保证内存可见性。

Q3: ConcurrentHashMap的size()方法是否完全准确?

  • :不一定。JDK8中通过分段计数(CounterCell)累加,并发更新时可能存在短暂误差,但最终会趋于准确。

7. 线程池有了解吗,讲一下?

Java线程池详解

1. 线程池的核心作用

  • 资源复用:减少线程创建和销毁的开销,提升性能。
  • 控制并发:限制线程数量,避免资源耗尽。
  • 任务管理:统一管理任务的提交、执行和监控。

2. 线程池的核心参数(ThreadPoolExecutor

参数说明
corePoolSize核心线程数,即使空闲也不会销毁(除非设置allowCoreThreadTimeOut)。
maximumPoolSize最大线程数,当队列满时允许创建的最大线程数。
keepAliveTime非核心线程的空闲存活时间(超时未使用将被回收)。
unit存活时间的单位(如秒、毫秒)。
workQueue任务队列,用于保存等待执行的任务(如ArrayBlockingQueueLinkedBlockingQueue)。
threadFactory线程工厂,用于创建线程(可自定义线程名称、优先级等)。
rejectedHandler拒绝策略,当任务超出处理能力时的处理方式。

3. 线程池的工作流程

  1. 提交任务:调用execute()submit()方法提交任务。
  2. 核心线程处理
    • 若当前线程数 < corePoolSize,立即创建新线程执行任务。
  3. 入队等待
    • 若核心线程已满,任务进入工作队列。
  4. 扩展线程
    • 若队列已满且线程数 < maximumPoolSize,创建新线程执行任务。
  5. 拒绝策略
    • 若队列满且线程数已达最大值,触发拒绝策略处理新任务。

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()

8. 线程池的关闭

  • 优雅关闭
    • shutdown():停止接收新任务,等待已提交任务执行完成。
    • shutdownNow():尝试停止所有正在执行的任务,返回未执行的任务列表。
  • 注意事项
    • 避免强制终止,确保任务状态一致性。
    • 结合awaitTermination()等待线程池完全关闭。

9. 常见问题与解决方案

Q1: 为什么避免使用Executors创建线程池?

  • 风险
    • FixedThreadPoolSingleThreadExecutor使用无界队列,可能导致OOM。
    • CachedThreadPool允许无限创建线程,可能耗尽资源。
  • 建议:手动创建ThreadPoolExecutor,明确控制参数。

Q2: 如何防止任务长时间阻塞线程池?

  • 方案
    • 设置合理的超时时间,使用Future.get(timeout, unit)
    • 分离阻塞任务到独立线程池,避免影响核心业务。

Q3: 线程池中线程异常如何处理?

  • 方案
    • 在任务内部捕获异常,避免线程提前终止。
    • 通过Thread.UncaughtExceptionHandler统一处理未捕获异常。

总结

  • 核心思想:通过池化技术提升资源利用率和系统稳定性。
  • 关键实践:合理配置参数、选择拒绝策略、监控线程池状态。
  • 推荐做法:根据业务场景手动创建线程池,避免使用默认工厂方法的风险。

8. 线程池配置无界队列了之后,拒绝策略怎么搞,什么时候用到无界队列?

作为Java后端程序员,理解线程池的队列与拒绝策略的协作关系是优化高并发系统的关键。以下是针对问题的结构化回答:


一、线程池配置无界队列时,拒绝策略的实际效果

1. 线程池的工作流程

当使用ThreadPoolExecutor并配置无界队列(如LinkedBlockingQueue未指定容量)时,其任务处理逻辑如下:

  1. 提交任务后,优先使用核心线程处理。
  2. 若核心线程已满,任务进入无界队列等待。
  3. 最大线程数配置失效(因队列无界,永远不会触发创建非核心线程)。
  4. 拒绝策略永不触发(队列无法被填满,不会达到触发拒绝的条件)。

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()  // 拒绝策略:提交线程自己执行
);

四、面试扩展:线程池参数设计原则

  1. 核心线程数:参考CPU密集型(N+1)或IO密集型(2N+1)任务类型。
  2. 最大线程数:根据系统资源(内存、连接数)和任务特性设定。
  3. 队列选择
    • 无界队列:仅用于任务量绝对可控的场景。
    • 有界队列:需配合拒绝策略,平衡吞吐量与稳定性。
    • 同步队列(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. 可见性判断规则

遍历版本链时,根据以下规则判断数据是否可见:

  1. 如果记录的DB_TRX_ID < up_limit_id → 该版本由已提交事务生成,可见
  2. 如果DB_TRX_ID ≥ low_limit_id → 该版本由未来事务生成,不可见
  3. 如果DB_TRX_IDtrx_list中 → 该版本由未提交事务生成,不可见
  4. 否则,检查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(当前读,加锁)
}

七、面试扩展点

  1. 快照读 vs 当前读

    • 快照读:普通SELECT,基于ReadView读取历史版本。
    • 当前读:SELECT ... FOR UPDATEUPDATEDELETE,读取最新版本并加锁。
  2. MVCC与锁的关系
    MVCC并非完全替代锁,而是与锁协同工作(如写操作仍需加锁)。

  3. 不同数据库的实现差异

    • PostgreSQL:通过多版本堆表实现。
    • Oracle:通过Undo表空间实现类似机制。

总结

MVCC通过数据多版本和一致性视图(ReadView)实现了高效的读写并发控制,是支撑高并发场景下数据库性能的核心技术。理解其版本链、可见性规则及隔离级别的实现差异,是回答此类问题的关键。

10. 事务特性、隔离级别

作为Java后端程序员,理解事务的特性和隔离级别是数据库和分布式系统设计的核心基础。以下是精简清晰的回答:


一、事务的四大特性(ACID)

  1. 原子性(Atomicity)
    事务是一个不可分割的最小操作单元,要么全部成功(提交),要么全部失败回滚。
    实现机制:通过Undo Log记录操作前的数据状态,支持回滚。

  2. 一致性(Consistency)
    事务执行后,数据库从一个一致性状态转换到另一个一致性状态(如约束、触发器等规则不被破坏)。
    本质:是原子性、隔离性、持久性共同作用的结果。

  3. 隔离性(Isolation)
    多个并发事务执行时,彼此不可见中间状态,防止数据混乱。
    实现机制:通过锁或MVCC(多版本并发控制)实现。

  4. 持久性(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:串行化(性能差,慎用)。

四、实际开发中的注意事项

  1. 默认隔离级别:不同数据库默认值不同(如MySQL默认REPEATABLE_READ,Oracle默认READ_COMMITTED)。
  2. MVCC与锁的权衡
    • MVCC通过版本快照(如MySQL的ReadView)避免读写冲突,提升并发性能。
    • 锁机制(如行锁、间隙锁)解决幻读,但可能引发死锁。
  3. 业务场景适配
    • 金融交易需高隔离级别(如串行化)。
    • 互联网高并发场景可适当降低隔离级别(如读已提交)。

总结

  • 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在不同场景下均能兼顾性能和内存效率,适合实现排行榜、范围查询等高频操作。

算法题:反转链表Ⅱ

为了反转链表中从位置 leftright 的部分,我们可以使用以下方法:

方法思路

  1. 使用虚拟头节点:创建一个虚拟头节点 dummy,这样可以统一处理头节点可能被反转的情况。
  2. 定位关键节点
    • pre:指向 left 的前一个节点。
    • start:指向 left 位置的节点。
    • end:指向 right 位置的节点。
    • succ:指向 right 的后一个节点。
  3. 反转子链表:反转从 startend 的部分。
  4. 重新连接链表:将反转后的子链表与原始链表的前后部分连接起来。

解决代码

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;
    }
}

代码解释

  1. 虚拟头节点dummy 节点用于简化头节点可能被反转的情况处理。
  2. 定位 prestart:通过循环移动 preleft 的前一个位置,start 即为 pre.next
  3. 确定反转范围:计算反转的节点数 k,并找到 end 和其后继节点 succ
  4. 反转子链表:使用迭代法反转从 startend 的链表部分。
  5. 重新连接:将反转后的子链表头连接到 pre,并将反转后的子链表尾连接到 succ

该方法时间复杂度为 O(n),空间复杂度为 O(1),高效地解决了问题。