集合

为什么数组索引要从 0 开始?从 1 开始不行吗?

回答大纲

  • 数组在内存中是连续存储的,CPU 通过寻址公式访问元素
  • 从 0 开始:a[i]_address = base_address + i * typeSize
  • 从 1 开始:base_address + (i-1) * typeSize,多一次减法

正式回答

数组在内存中是连续存储的,CPU 通过寻址公式来访问指定下标的元素:a[i] 的地址 = 数组首地址 + i * 元素大小。当数组下标从 0 开始时,直接套用上述公式即可计算偏移量;如果下标从 1 开始,则需要先执行 i - 1 再做乘法,多了一次减法运算。虽然单次访问的差异可以忽略不计,但数组通常会出现在循环、遍历等高频访问的场景中,这笔额外开销会被无限放大。这也是 C 语言沿用下来的历史约定,Java 作为一门追求高效的系统级语言,继承了这一设计。

时间复杂度如何计算的?

回答大纲

  • 口诀:常 对 幂 指 阶
  • 常见复杂度
    • 常数复杂度 O(1):不随 n 变化
    • 对数复杂度 O(logN):二分查找、树操作

正式回答

时间复杂度用于衡量算法执行时间随数据规模 n 增长的变化趋势,是大 O 表示法下对最坏情况的抽象描述。常见的大小关系可用口诀"常对幂指阶"来记忆,即 O(1) < O(logN) < O(N) < O(N*logN) < O(N²) < O(2ⁿ) < O(n!),越靠后算法越慢。

  1. 常数复杂度 O(1):只要代码执行次数不随 n 的增大而增大,都是常数复杂度。比如简单的赋值、算术运算、HashMap 在不发生哈希冲突时的查找。
  2. 对数复杂度 O(logN):典型场景包括二分查找、堆的插入、平衡二叉搜索树的查找等。每次操作都把问题规模缩减为原来的一部分。需要注意的是,对数的底在大 O 表示法下并无差异,O(log₂N) 和 O(log₁₀N) 等价。

分析一段代码的时间复杂度时,要先剥离常数项和低阶项,只保留最高阶项,并关注最坏情况下的复杂度。

ArrayList 底层的实现原理是什么?

回答大纲

  • 动态数组 Object[] elementData
  • 默认容量 10,懒加载
  • 扩容为原容量的 1.5 倍
  • 通过 Arrays.copyOf 实现扩容

正式回答

ArrayList 是基于动态数组实现的 List,其核心是一个 Object[] elementData 数组。它具有以下特点:

  1. 默认容量:使用无参构造创建时,并不会立即分配容量为 10 的数组,而是采用懒加载策略,在第一次执行 add 时才把数组容量初始化为 10(JDK 8 行为)。
  2. 扩容机制:当元素个数超过当前数组容量时触发扩容,新容量约为旧容量的 1.5 倍,计算方式为 oldCapacity + (oldCapacity >> 1)。扩容过程通过 Arrays.copyOf 将旧数组元素复制到新数组,因此扩容操作本身是 O(N) 的时间复杂度,应尽量预估容量避免频繁扩容。
  3. 随机访问高效:底层是连续数组,支持 O(1) 时间复杂度的随机访问。
  4. 插入和删除低效:在中间位置插入或删除元素时,需要把该位置之后的所有元素整体后移或前移,最坏时间复杂度为 O(N)。
  5. 线程不安全:所有操作均未加同步控制,多线程环境下应使用 Collections.synchronizedListCopyOnWriteArrayList

ArrayList list = new ArrayList(10) 中的数组扩容几次?

回答大纲

  • 该构造器直接初始化长度为 10 的数组
  • 没有调用 add,因此扩容 0 次
  • 只有在添加第 11 个元素时才会触发第一次扩容(10 → 15)

正式回答

new ArrayList(10) 构造器被调用时,会立即创建一个长度为 10 的 Object[] elementData 数组,这与无参构造的懒加载行为不同。题目中只调用了构造器,并未执行任何 add 操作,因此扩容次数为 0 次

这道题考察的是对 ArrayList 构造器参数语义和扩容触发时机的理解:

  • new ArrayList():使用空数组,第一次 add 时才创建容量为 10 的数组。
  • new ArrayList(10):直接创建容量为 10 的数组,再次 add 时超过 10 才会扩容至 15。
  • 如果题目改为"调用了 11 次 add",则扩容次数为 1 次。

如何实现数组和 List 之间的转换?

回答大纲

  • 数组转 List:Arrays.asList(array)
  • List 转数组:list.toArray()list.toArray(T[])
  • 转换后的影响
    • asList 返回的是 Arrays 内部类视图,底层直接引用原数组
    • 修改原数组会影响 List;但无法 add / remove
    • toArray 返回的是新数组,修改不影响 List

正式回答

  1. 数组转 ListArrays.asList(T... a),返回一个固定大小的 List。注意以下几点:

    • 该 List 底层直接引用原数组,不是新数组。因此修改原数组的元素,List 中对应位置的元素也会随之变化。
    • 但是它并未实现 addremove 方法,调用会抛出 UnsupportedOperationException
    • 如果需要一个可变的 List,应该使用 new ArrayList<>(Arrays.asList(array)) 再封装一层。
  2. List 转数组list.toArray()list.toArray(T[] a)

    • 返回的是新数组,修改新数组不会影响原 List。
    • 建议使用 list.toArray(new T[list.size()]),预先分配与 List 等长的数组,性能略优于 new T[0]
    • 不传参数调用的 toArray() 返回的是 Object[],强转为具体类型会有 ClassCastException 风险。

ArrayList 和 LinkedList 的区别是什么?

回答大纲

  • 底层数据结构:动态数组 vs 双向链表
  • 操作效率:随机访问 O(1) vs O(N);插入删除 O(N) vs O(1)
  • 内存占用:ArrayList 更紧凑,LinkedList 需要额外指针
  • 线程安全性:均为线程不安全

正式回答

对比维度 ArrayList LinkedList
底层数据结构 动态数组 Object[] 双向链表 Node<E>
随机访问 O(1),索引直达 O(N),需要遍历
头插 / 尾插 O(N)(尾插均摊 O(1)) O(1)
中间插入 / 删除 O(N),需要移动元素 O(1)(前提是已定位节点)
内存占用 连续内存,浪费少 每个节点额外存前后指针
线程安全 不安全 不安全

ArrayList 基于动态数组实现,支持 O(1) 的随机访问,但插入删除需要移动元素。LinkedList 基于双向链表实现,定位后插入删除很快,但随机访问需要遍历。在现代 JVM 中,由于 CPU 缓存预读机制,ArrayList 的连续内存布局在遍历时缓存命中率更高,实际性能常常优于 LinkedList。因此实际开发中应优先使用 ArrayList,只有在频繁头插或者已经持有迭代器进行中间删除时才考虑 LinkedList。

二叉搜索树数据结构

回答大纲

  • 性质:左子树 < 根 < 右子树
  • 时间复杂度
    • 查找 O(logN)
    • 插入 O(logN)
    • 删除 O(logN)
    • 极端情况退化为 O(N)

正式回答

二叉搜索树(Binary Search Tree, BST)的核心性质是:对于每个节点,其左子树上所有节点的值都小于该节点的值,右子树上所有节点的值都大于该节点的值。基于这种有序结构,它有以下时间复杂度特性:

  1. 查找 O(logN):从根节点开始比较,每次比较都能排除掉一半的子树。若节点在第 n 层,则最多需要比较 n-1 次,时间复杂度为 O(logN)。
  2. 插入 O(logN):遵循查找路径定位到合适位置后插入,整体过程与查找类似。
  3. 删除 O(logN):先查找到目标节点,然后根据子节点情况分类处理:无子节点直接删除;单子节点让孩子接替;双子节点则需找到右子树的最小节点(或左子树的最大节点)填补。
  4. 极端情况:当插入有序序列(如 1、2、3、4、5)时,BST 会退化为链表,此时所有操作的时间复杂度退化为 O(N)。

为解决退化问题,引入了自平衡的二叉搜索树,如 AVL 树和红黑树,它们通过旋转等手段维持树的平衡。

红黑树数据结构

回答大纲

  • 自平衡的二叉搜索树
  • 五大性质:节点红黑 / 根黑 / 叶黑 / 红不连续 / 黑高相同
  • 时间复杂度 O(logN)

正式回答

红黑树(Red-Black Tree)是一种自平衡的二叉搜索树,它在每个节点上增加了一个颜色标志位(红色或黑色),通过对从根到叶子路径的颜色施加约束来保证树的"大致平衡"。其五大性质如下:

  1. 节点颜色:每个节点要么是红色,要么是黑色。
  2. 根节点颜色:根节点是黑色。
  3. 叶子节点:所有叶子节点(NIL 空节点)都是黑色。
  4. 红色节点不连续:红色节点的子节点必须是黑色,即不能出现两个连续的红色节点。
  5. 黑高平衡:从任一节点到其所有叶子节点的路径上,包含的黑色节点数目相同(也称黑高相同)。

这五条性质保证了最长路径不超过最短路径的 2 倍,因此树始终维持平衡,查找、插入、删除的最坏时间复杂度均为 O(logN)。相比 AVL 树,红黑树牺牲了部分严格平衡性以换取更少的旋转次数,插入删除性能更稳定,因此被广泛用于 JDK 的 TreeMapHashMap(链表长度 ≥ 8 时树化)、ConcurrentHashMap 等核心数据结构中。

散列表(Hash 表)

回答大纲

  • 由数组演化而来
  • 哈希函数将 key 映射到数组下标
  • 哈希冲突与处理:链表法 / 开放地址法
  • 时间复杂度 O(1)

正式回答

散列表(Hash Table)也称哈希表,是基于数组结构演化而来的高效查找结构。它通过哈希函数将任意大小的 key 映射到固定范围的数组下标,从而实现近似 O(1) 的查找、插入和删除。

但是,由于 key 的取值空间往往远大于数组容量,哈希冲突不可避免。当多个 key 被映射到同一个下标时,需要采用合适的策略处理。常见的两类冲突处理方式是:

  1. 链表法(拉链法):在每个桶位维护一个链表,所有哈希冲突的元素挂在同一个链表中。Java 的 HashMap 即采用此方案,链表长度过大时会进一步转换为红黑树。
  2. 开放地址法:当发生冲突时,按照某种探测序列(如线性探测、二次探测)在数组其他空位上存储元素。ThreadLocalMap 即采用此方案。

理想情况下,散列表各项操作时间复杂度为 O(1),极端情况下会退化为 O(N)。

说一下 HashMap 的实现原理?

回答大纲

  • JDK 1.7:数组 + 链表
  • JDK 1.8:数组 + 链表 + 红黑树
  • 哈希算法定位桶位
  • 链表法解决哈希冲突

正式回答

HashMap 是基于哈希表实现的 Map 接口的核心实现类,用于存储键值对。

  1. 底层数据结构
    • JDK 1.7:采用 数组 + 链表,所有哈希冲突元素依次挂载在同一个桶位的链表上。
    • JDK 1.8:在数组 + 链表的基础上引入红黑树。当链表长度 ≥ 8 且当前数组长度 ≥ 64 时,链表会转换为红黑树,将该桶位上的查询时间复杂度从 O(N) 提升到 O(logN);当红黑树节点数 ≤ 6 时退化为链表。
  2. 哈希计算:通过 key.hashCode() 得到哈希值,再进行二次扰动 (h = key.hashCode()) ^ (h >>> 16),最后通过 (n - 1) & hash 计算数组下标。
  3. 冲突处理:使用链表法,相同下标的元素挂在同一链表中,链表过长时升级为红黑树。
  4. 重要属性:默认初始容量 16,默认加载因子 0.75,扩容时容量翻倍。

HashMap 允许 null 键和 null 值,但只能有一个 null 键。它不是线程安全的,多线程环境下应使用 ConcurrentHashMap

JDK 1.7 和 JDK 1.8 HashMap 的区别?

回答大纲

  • 数据结构:1.7 数组+链表;1.8 数组+链表+红黑树
  • 插入方式:1.7 头插法;1.8 尾插法
  • 哈希扰动:1.7 4 次位运算 + 4 次异或;1.8 1 次位运算 + 1 次异或
  • 扩容时链表处理:1.7 全部重哈希;1.8 高低位链表拆分
  • 死循环:1.7 多线程下可能形成环形链表;1.8 已修复

正式回答

对比项 JDK 1.7 JDK 1.8
数据结构 数组 + 链表 数组 + 链表 + 红黑树
链表转红黑树 链表长度 ≥ 8 且数组长度 ≥ 64
插入方式 头插法 尾插法
哈希扰动 4 次位运算 + 5 次异或 1 次位运算 + 1 次异或
扩容链表处理 全部重新哈希 高低位链表拆分
多线程死循环 可能形成环形链表 已修复

详细说明:

  1. 数据结构:JDK 1.8 引入红黑树,避免哈希冲突严重时链表过长导致的查询性能退化,是 HashMap 性能改进的核心。
  2. 插入方式:JDK 1.7 使用头插法,多线程扩容时会形成环形链表导致死循环;JDK 1.8 改用尾插法,从根本上避免了链表的反转。
  3. 哈希扰动:JDK 1.8 简化为 h ^ (h >>> 16),仅 1 次右移和 1 次异或,计算更快,且因红黑树的引入,对分布性的依赖也降低了。
  4. 扩容策略:JDK 1.8 通过 e.hash & oldCap 判断元素在新数组中的位置是原位置还是原位置 + 旧容量,从而拆分为低位链表高位链表,避免重新计算 hash。

HashMap 的 put 方法的具体流程?

回答大纲

  • 计算哈希、定位桶位
  • 初始化检查(空表则扩容)
  • 桶位判空(直接放入 / 进入冲突流程)
  • 处理冲突(覆盖 / 红黑树 / 链表)
  • 值覆盖:发现旧 Key 则用新值覆盖
  • 扩容检查:超过阈值则触发 resize()

正式回答

HashMap 的 put 方法核心流程可以概括为以下几步:

  1. 计算哈希:对 Key 的 hashCode() 进行二次扰动计算,得到最终哈希值,进而定位桶位(数组下标)。
  2. 初始化检查:如果内部数组为空,先调用 resize() 进行初始化或扩容。
  3. 桶位判空
    • 如果定位到的桶位为空,直接创建新节点放入。
    • 如果不为空(发生哈希冲突),则进入冲突处理流程。
  4. 处理冲突
    • Key 相同:若首节点的 Key 与待插入 Key 完全相同(equals 为 true),记录该节点以便后续覆盖。
    • 红黑树:若首节点是 TreeNode(已树化),则调用红黑树的插入逻辑。
    • 链表:若为普通链表,遍历至尾部插入。遍历时若发现 Key 相同则跳出;插入后若链表长度 ≥ 8 且数组长度 ≥ 64,则将链表转为红黑树。
  5. 值覆盖:若发现存在的旧 Key,用新值覆盖旧值,并返回旧值。
  6. 扩容检查:插入新节点后,size 加 1。当 size 超过扩容阈值(capacity * loadFactor)时,触发 resize() 自动扩容。
flowchart TD
    A["开始: put(key, value)"] --> B["计算hash: key.hashCode() ^ (hash >>> 16)"]
    B --> C{"table是否为空 或 长度=0?"}
    C -->|是| D["resize() 初始化/扩容"]
    C -->|否| E["通过 (n-1)&hash 计算索引 i"]
    D --> E

    E --> F{"table[i] 是否为空?"}
    F -->|是| G["创建新节点插入 table[i]"]
    F -->|否| H{"table[i] 是否为树节点?"}

    H -->|是| I["向红黑树中插入节点"]
    H -->|否| J["遍历链表"]

    J --> K["遍历链表每个节点"]
    K --> L{"节点hash和key是否和目标相等?"}
    L -->|是| M["找到已有key 记录旧值"]
    L -->|否| N{"是否到达链表末尾?"}
    N -->|否| K
    N -->|是| O["在链表尾部插入新节点"]

    O --> P{"链表长度 >= 8?"}
    P -->|是| Q{"数组长度 >= 64?"}
    P -->|否| R{"是否找到已有key?"}
    Q -->|是| Q2["将链表转为红黑树"]
    Q -->|否| D

    M --> S{"是否允许覆盖旧值?"}
    S -->|是| T["用新值替换旧值 返回旧值"]
    S -->|否| U["返回旧值 不修改"]

    I --> R
    G --> V["modCount++"]
    Q2 --> V
    R -->|否| V
    R -->|是| S

    V --> W["size++"]
    W --> X{"size > threshold?"}
    X -->|是| Y["resize() 扩容"]
    X -->|否| Z["返回 null"]
    Y --> Z

    T --> AA["结束"]
    U --> AA
    Z --> AA

HashMap 的扩容机制?

回答大纲

  • 计算新容量与阈值(正常扩容翻倍 / 初始化特殊处理)
  • 创建新数组
  • 数据迁移(高低位链表拆分,单节点 / 红黑树 / 链表三种情况)

正式回答

HashMap 的扩容(resize 方法)核心流程可简述为以下三步:

  1. 计算新容量与阈值
    • 正常扩容:若数组已初始化,新容量和新阈值通常直接翻倍(左移 1 位),即 newCap = oldCap << 1newThr = oldThr << 1。若超过最大容量则不再扩容,阈值设为 Integer.MAX_VALUE
    • 初始化:若数组为空(oldTab == null),根据是否带参构造将容量初始化为指定值或默认值 16,阈值设为 容量 * 加载因子
  2. 创建新数组:根据计算出的新容量,实例化一个新的 Node 数组。
  3. 数据迁移(高低位拆分):遍历旧数组的每个桶位:
    • 单节点:重新计算下标 (e.hash & (newCap - 1)) 直接放入新数组。
    • 红黑树:调用 split 方法拆分并迁移,必要时退化为链表。
    • 普通链表:通过 hash & oldCap 是否为 0,把链表拆分为低位链表(留在原位置)和高位链表(新位置 = 原位置 + 旧容量),整体搬运到新数组。这种方式避免了 JDK 7 中重新计算 hash 的开销,也规避了头插法导致的死循环问题。
graph TD
    A[开始扩容 resize] --> B{OldCap > 0?}

    B -- 是 (数组已初始化) --> C{OldCap >= MAXIMUM_CAPACITY?}
    C -- 是 --> D[阈值设为 Integer.MAX_VALUE, 返回旧数组]
    C -- 否 --> E[新容量 = 旧容量 << 1 
新阈值 = 旧阈值 << 1] B -- 否 (未初始化) --> F{OldThr > 0?} F -- 是 (带参构造) --> G[新容量 = 旧阈值] F -- 否 (无参构造) --> H[新容量 = 16
新阈值 = 16 * 0.75] E --> I[创建新数组 newtable] G --> I H --> I I --> J[遍历旧数组的每个桶位] J --> K{桶位是否为空?} K -- 是 --> L[跳过] K -- 否 --> M{是否为单节点?} M -- 是 --> N[直接计算新位置: e.hash & newCap - 1
放入新数组] M -- 否 --> O{是否为树节点 TreeNode?} O -- 是 --> P[调用 split 方法拆分红黑树] O -- 否 --> Q[链表拆分: 高低位链表
根据 e.hash & oldCap == 0] Q --> R[低位链表: 位置保持不变
高位链表: 新位置 = 原位置 + oldCap] L --> S{是否遍历完所有桶?} N --> S P --> S R --> S S -- 否 --> J S -- 是 --> T[返回新数组, 扩容完成]

HashMap 的寻址算法?

回答大纲

  • 扰动函数:hash = key.hashCode() ^ (key.hashCode() >>> 16)
  • 索引计算:(n - 1) & hash,等价于 hash % n
  • n 必须是 2 的次幂以保证取模效果

正式回答

HashMap 的寻址算法分为两步:

  1. 哈希扰动(JDK 1.8)
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    将 hashCode 的高 16 位与低 16 位进行异或,混合高低位的特征,避免数组容量较小时(仅低位有效)发生严重的哈希冲突。
  2. 索引计算
    int index = (n - 1) & hash;  // 等价于 hash % n
    
    通过位与运算代替取模运算,前提是 n 是 2 的次幂,n - 1 的二进制是全 1,& hash 即等同于 hash % n
  3. 为什么使用位运算:位运算比取模运算快得多;且当 n 为 2 的次幂时,二者结果完全等价。

为什么 HashMap 的数组长度一定是 2 的次幂?

回答大纲

  • 位运算代替取模运算
  • 扩容时高效(仅需左移一位)
  • 数据分布均匀
  • 非 2 次幂容量会被 tableSizeFor 自动调整

正式回答

HashMap 在构造和扩容时,会通过 tableSizeFor 方法保证数组容量始终是 2 的次幂。这样设计主要有三个原因:

  1. 位运算代替取模:当数组长度为 2 的次幂时,(n - 1) & hashhash % n 等价,而位运算效率远高于取模运算,在高频调用下能显著提升性能。
  2. 扩容高效:扩容时新容量直接 oldCap << 1(左移一位,即翻倍),(n - 1) & hash 中的 n - 1 也只需多一位全 1,避免了重新计算 hash,并支持高低位链表拆分的高效迁移策略。
  3. 数据分布均匀:哈希扰动 + 容量为 2 的次幂,可以让元素均匀分布,减少哈希冲突。

如果用户传入的初始容量不是 2 的次幂(如 10),HashMap 会通过 tableSizeFor 方法自动调整为大于等于该值的最小 2 的次幂。例如传入 10 会被调整为 16,传入 17 会被调整为 32。

HashMap 在 JDK 1.7 下的多线程死循环问题

回答大纲

  • 头插法 + 并发扩容导致环形链表
  • 触发条件:多线程同时触发 put + resize
  • 现象:get 时死循环,CPU 飙升至 100%
  • 解决:JDK 1.8 改用尾插法 + 高低位拆分;或使用 ConcurrentHashMap

正式回答

JDK 1.7 中 HashMap 在多线程并发扩容时可能产生死循环问题,核心原因是头插法并发 resize 共同导致的。

问题复现过程

  1. 假设线程 A 和线程 B 同时对 HashMap 进行 put 操作,并先后触发扩容。
  2. 线程 A 已经遍历到某个桶位的链表(例如 key=3 → key=7 → null),正准备按头插法迁移。
  3. 线程 B 同时开始执行相同的迁移流程,由于头插法会反转链表顺序。
  4. 当两个线程交替执行 next = e.next; e.next = newTable[i]; newTable[i] = e; e = next; 这几行代码时,可能形成环形链表key=3.next = key=7, key=7.next = key=3
  5. 此后调用 get 查询落在该桶位的元素时,就会陷入无限循环,CPU 使用率飙升至 100%。

修复方案

JDK 1.8 改用了尾插法配合高低位链表拆分机制,从根本上避免了链表的反转过程,从而消除了环形链表的形成条件。但是尾插法并不解决数据丢失等更复杂的并发问题,因此对于真正的并发场景,应使用 ConcurrentHashMap 替代 HashMap。

JDK 1.8 是如何修复该问题的?

回答大纲

  • 尾插法代替头插法
  • 扩容时使用高低位链表拆分,避免链表反转
  • 仍存在其他并发问题(数据丢失等),需用 ConcurrentHashMap

正式回答

JDK 1.8 主要从以下几个方面对 JDK 1.7 的多线程死循环问题进行了修复:

  1. 改用尾插法:新元素插入到链表的尾部,而不是头部。在扩容迁移时,由于始终是尾插法,链表的节点顺序不会发生反转,从根本上消除了环形链表的形成条件。
  2. 高低位链表拆分:在 resize 过程中,通过 e.hash & oldCap 把链表拆分为低位链表(位置不变)和高位链表(位置 = 原位置 + 旧容量),整体搬运到新数组。这种方式不依赖于节点指针的反转,而是节点顺序的直接搬运,进一步降低了并发风险。
  3. HashMap 仍未解决所有并发问题:例如并发 put 时仍可能出现数据丢失、size 不一致等,因此仍需在并发场景下使用 ConcurrentHashMapConcurrentHashMap 在 JDK 1.7 中采用分段锁,在 JDK 1.8 中改为 CAS + synchronized 实现,锁粒度更细,并发性能更优。

多线程

进程和线程的区别

回答大纲

  • 进程
    • 是资源分配的基本单位,管理指令、内存、IO
    • 不同进程拥有独立的内存空间
    • 进程下的线程共享内存空间
  • 线程
    • 是 CPU 调度的基本单位,是指令流
    • 线程更轻量,上下文切换成本低
    • 同一进程内的线程共享进程的内存和资源

正式回答

进程是操作系统进行资源分配的基本单位,每启动一个进程,操作系统会为其分配独立的内存地址空间、IO 通道等资源。进程负责加载指令、管理内存、管理 IO;不同进程之间拥有各自独立的内存空间,进程内的所有线程共享这块内存空间。

线程是 CPU 调度执行的基本单位,是一条有序的指令流。线程自身只负责把指令一条条交给 CPU 执行,并不拥有独立的系统资源(如内存、文件句柄)。因此线程更轻量,线程切换(上下文切换)的成本远低于进程切换。

简单说,进程是"资源容器",线程是"执行单元",一个进程至少包含一个线程(主线程)。这也是 Java 中 Thread.start() 不一定立刻执行,而是由 JVM 线程调度器决定的原因。

并行和并发的区别

回答大纲

  • 并发
    • 多个线程轮流使用 CPU(单核也可发生)
    • 微观串行,宏观像并行
  • 并行
    • 必须多核 CPU,每个核心真正同时执行任务
    • 微观也是同时进行

正式回答

**并发(Concurrency)**指多个任务在同一时间段内交替执行,但在某一时刻通常只有一个任务真正占用 CPU。它强调的是"处理能力",单核 CPU 上也能发生并发,因为 CPU 通过时间片轮转调度让多个线程轮流使用处理器,从微观上看是串行的,但从用户视角看像是同时进行的。

并行(Parallelism)指多个任务在同一时刻真正同时执行,它要求系统具备多个 CPU 核心,每个核心各自执行不同的任务,从微观上看也是同时的。

并发是逻辑上的同时,并行是物理上的同时。前者关注结构(如何编写可处理多任务的程序),后者关注执行(如何让任务真正同时运行)。现代高并发系统往往会结合两者:先用并发设计程序结构,再借助多核 CPU 实现并行执行,最大化利用硬件资源。

创建线程的方式有哪些?

回答大纲

  • 继承 Thread
  • 实现 Runnable 接口
  • 实现 Callable 接口(有返回值、可抛异常)
  • 通过线程池(项目中使用)

正式回答

Java 创建线程主要有以下四种方式:

  1. 继承 Thread:定义类继承 Thread,重写 run 方法,调用 start() 启动。简单直观,但 Java 是单继承的,占用了继承名额,扩展性较差。
  2. 实现 Runnable 接口:定义类实现 Runnable 接口,重写 run 方法,将其实例作为参数传给 new Thread(runnable)。推荐方式,因为不影响类继承其他父类,且天然支持 Lambda 表达式。
  3. 实现 Callable 接口:与 Runnable 类似,但 call 方法可以有返回值,并能抛出受检异常,通常与 Future 配合使用拿到异步结果:FutureTask<T> task = new FutureTask<>(callable); new Thread(task).start();
  4. 通过线程池(ExecutorService:实际项目中强烈推荐。将任务作为 Runnable/Callable 提交给线程池,由线程池统一管理线程的生命周期。能避免频繁创建销毁线程的开销,支持任务队列、线程数控制、拒绝策略等高级特性。常见工厂类有 Executors.newFixedThreadPool 等,但不推荐直接使用 Executors,而是手动创建 ThreadPoolExecutor 以明确参数语义。

runnable 和 callable 的区别

回答大纲

  • 返回值
    • Runnable.run() 无返回值
    • Callable.call() 有返回值,可通过 Future 获取
  • 抛异常
    • run 方法不能抛出受检异常
    • call 方法可以抛出受检异常
  • 实现方法名:run() vs call()

正式回答

RunnableCallable 都是描述"一段可执行任务"的接口,二者主要有两个区别:

  1. 返回值Runnablerun() 方法没有返回值;而 Callablecall() 方法有返回值(类型由泛型参数决定),可通过 Future.get() 同步获取结果。
  2. 异常run() 方法被设计为不能抛出任何受检异常,只能通过 try-catch 自行处理;而 call() 方法可以抛出受检异常,由调用方通过 Future.get() 时统一包装为 ExecutionException

此外,Callable 接口定义的方法名是 call,而 Runnablerun。两者均能被 ExecutorService 接收并执行,但 Callable 通常配合 FutureTask 使用,是构建异步任务和并行计算(如 parallelStream 底层、CompletableFuture)的基础。

在启动线程的时候,可以使用 run 方法吗?run 和 start 的区别?

回答大纲

  • 不能用 run 启动线程,应使用 start
  • run 是普通方法调用,在当前线程同步执行
  • start 会启动新线程,由 JVM 调用新线程的 run 方法
  • start 只能调用一次(线程状态不能回到 NEW)

正式回答

启动线程必须调用 start() 方法,不能直接调用 run() 方法

  1. run() 方法:只是 Thread 类的普通成员方法,直接调用时,是在当前线程同步执行其中的代码,并不会启动新线程,等价于普通方法调用。
  2. start() 方法:会触发 JVM 创建一个新的操作系统线程,并在新线程中由 JVM 回调该线程的 run() 方法,这才是真正意义上的多线程并发。
  3. 线程状态start() 只能被调用一次,因为线程启动后状态从 NEW 变为 RUNNABLE,再次调用会抛出 IllegalThreadStateException

简而言之,run 定义任务内容,start 启动新线程。混用两者是初学者常见错误,会导致程序"看起来在多线程,但实际是顺序执行"的隐性 bug。

线程包括哪些状态,状态之间是如何变化的?

回答大纲

  • 六种状态:NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED
  • 状态转换:start() → RUNNABLE → 各种阻塞状态 → RUNNABLE → TERMINATED

正式回答

Java 中线程有 6 种状态,定义在 Thread.State 枚举中:

  1. NEW(新建):线程对象已创建,但尚未调用 start()
  2. RUNNABLE(可运行):调用 start() 后,线程处于就绪或正在运行的状态。注意:Java 的 RUNNABLE 同时包含了"就绪"和"运行中"两种含义。
  3. BLOCKED(阻塞):线程等待获取 monitor 锁(如 synchronized 块竞争失败),被放入 EntryList。
  4. WAITING(无限等待):调用 wait()join()LockSupport.park() 后进入,需要其他线程 notify/notifyAllunpark 才能唤醒。
  5. TIMED_WAITING(限时等待):调用 sleep(long)wait(long)join(long)LockSupport.parkNanos() 等带超时参数的方法进入,超时后会自动唤醒
  6. TERMINATED(终止)run() 方法执行完毕或抛出未捕获异常后进入,不可再启动

状态流转图(核心链路):

NEW --start()--> RUNNABLE --获得锁--> RUNNABLE
                  ↓ wait()
              WAITING --notify()/超时--> BLOCKED(若需重获锁) -> RUNNABLE
                  ↓ sleep(50)
              TIMED_WAITING --超时--> RUNNABLE
                  ↓ 竞争 monitor 失败
              BLOCKED --获得锁--> RUNNABLE
                  ↓ run() 结束
              TERMINATED

新建 T1,T2,T3 三个线程如何保证执行顺序?

回答大纲

  • 使用 Thread.join() 让后启动的线程等待先启动的执行完
  • 也可以使用 CountDownLatchCyclicBarrier
  • 最简单:依次 t1.start(); t1.join(); t2.start(); t2.join(); t3.start();

正式回答

若要保证三个线程严格按照 T1 → T2 → T3 的顺序执行,可以有以下几种方案:

  1. Thread.join()(最常用)
    t1.start(); t1.join();
    t2.start(); t2.join();
    t3.start(); t3.join();
    
    join() 的作用是让当前线程等待被调用 join 的线程执行完毕。因此主线程会先等 t1 跑完,再启动 t2 并等待,再启动 t3 并等待,从而保证顺序。
  2. CountDownLatch:初始化 CountDownLatch latch = new CountDownLatch(1),每个线程 await,启动顺序由"先 latch.countDown() 再启动下一个"控制。
  3. ExecutorService + Future:提交任务返回 Future,在主线程依次调用 future.get(),实现串行效果。
  4. 单线程线程池Executors.newSingleThreadExecutor(),任务会按提交顺序串行执行。

实际业务中若只是需要"按顺序",最推荐 join(),简单直观;若涉及并发协调则可用 CountDownLatchCompletableFuture

notify() 和 notifyAll() 有什么区别?

回答大纲

  • notify():唤醒等待同一锁的一个线程(具体由 JVM 决定)
  • notifyAll():唤醒等待同一锁的全部线程
  • 推荐优先使用 notifyAll(),避免信号丢失
  • 二者均必须在 synchronized 块内调用,且只能唤醒调用同一锁对象的线程

正式回答

notify()notifyAll() 都是 Object 类的方法,用于在 synchronized 块中唤醒因调用 wait() 而进入 WAITING 状态的线程。

  1. notify():唤醒一个正在等待该对象 monitor 的线程,具体唤醒哪个由 JVM 实现决定(通常是等待时间最久的那个,但并不保证)。被唤醒的线程需要重新竞争 monitor 锁才能继续执行。
  2. notifyAll():唤醒所有正在等待该对象 monitor 的线程。它们将全部进入阻塞队列,重新竞争 monitor 锁,只有抢到锁的线程能继续执行。

核心差异notify() 的风险在于"信号丢失"。如果唤醒的线程并不满足继续执行的条件,而其他线程的等待条件又不会被唤醒,就会出现所有线程都在等待的"假死"状态。因此实际开发中推荐优先使用 notifyAll(),以避免此类问题。notify() 仅在能精确控制等待条件(例如只有一种类型的等待者)时才使用。

java 中 wait 和 sleep 方法的不同

回答大纲

  • 共同点
    • 都能让线程进入阻塞状态(等待/睡眠)
    • 都可以被 interrupt 打断
  • 不同点
    • 方法归属:wait 属于 Object;sleep 属于 Thread
    • 锁特性:wait 会释放锁;sleep 不释放锁
    • 唤醒方式:wait 需要 notifysleep 超时自动醒
    • 调用位置:wait 必须在 synchronized 中;sleep 任意

正式回答

wait()Thread.sleep() 都能暂停线程的执行,但有重要差异:

  1. 方法归属wait()Object 的实例方法,sleep()Thread 的静态方法。
  2. 是否释放锁wait() 在等待时会释放当前持有的 monitor 锁,让其他线程能进入临界区;sleep() 仅仅让线程休眠,不会释放任何锁,可能导致其他线程长时间阻塞。
  3. 唤醒方式wait() 必须由其他线程在同一对象上调用 notify()/notifyAll() 才能唤醒(无参 wait() 无超时);sleep(long) 超时后会自动恢复到 RUNNABLE。
  4. 使用场景wait()/notify() 用于线程间协作(如生产者-消费者);sleep() 用于简单的延时控制。
  5. 调用位置wait() 必须在 synchronized 块内调用(因为要操作 monitor 队列),否则抛 IllegalMonitorStateExceptionsleep() 可以在任何地方调用。
  6. 所属类与异常wait() 在 Object 中声明,会抛 InterruptedExceptionsleep() 在 Thread 中,也会抛 InterruptedException

如何停止一个正在运行的线程

回答大纲

  • 设置退出标志位(推荐)
  • 已过时的 stop() / destroy()
  • interrupt() 机制
    • 阻塞线程:抛出 InterruptedException
    • 正常线程:通过 Thread.interrupted() 检查并自行退出

正式回答

Java 中停止线程主要有三种方式:

  1. 退出标志位(推荐):定义一个 volatile boolean flag = true;,线程内每次循环检查 flag,外部修改 flag = false 后线程自然退出。这种方式安全、协作式,符合"线程不应被强制中断"的设计哲学。
  2. Thread.stop():已过时。它会直接抛出 ThreadDeath 异常强制终止线程,可能导致共享数据不一致、锁未释放等问题,因此不要再用。
  3. interrupt() 机制:通过 t.interrupt() 给目标线程打上中断标记,分两种情况处理:
    • 阻塞线程(处于 wait()/sleep()/join() 等):会抛出 InterruptedException,并在抛出后清除中断标记,需要在 catch 中妥善处理(例如 Thread.currentThread().interrupt() 重新设置标记)。
    • 正常运行的线程:不会立即响应,需要在代码中主动通过 Thread.interrupted()Thread.currentThread().isInterrupted() 检查标记并自行退出。

最佳实践是标志位 + interrupt 组合:调用方先设置标志位再调用 interrupt(),线程内部既检查标志位也捕获 InterruptedException,确保退出路径清晰且响应及时。

synchronized 关键字的底层原理

回答大纲

  • 基于对象头中的 Mark Word 和 Monitor 监视器
  • Monitor 结构
    • Owner:持有锁的线程
    • EntryList:等待获取锁的线程
    • WaitSet:调用 wait() 后等待的线程
  • 锁升级过程
    • 无锁 → 偏向锁 → 轻量级锁 → 重量级锁

正式回答

synchronized 是 Java 的关键字,用于实现方法/代码块的互斥访问,底层依赖 JVM 的对象头(Object Header)和 Monitor 监视器机制。

  1. 对象头 Mark Word:每个 Java 对象在内存中都有一个对象头,其中 Mark Word 用于存储对象的哈希码、GC 分代年龄、锁状态以及指向 Monitor 的指针。
  2. Monitor 监视器:JVM 为每个对象关联一个 Monitor(通过 C++ 实现,类似于操作系统互斥量)。Monitor 内部有三个关键区域:
    • Owner:记录当前持有锁的线程,初始为 null。
    • EntryList:等待获取锁的线程队列。
    • WaitSet:已经获得锁但调用 wait() 后释放锁进入等待的线程队列。
  3. 加锁过程:线程执行 monitorenter 指令时,检查对象的 Monitor Owner 是否为 null。若为 null,则将 Owner 设为当前线程,锁计数 +1;若不为 null,则进入 EntryList 阻塞等待。
  4. 锁升级(重要):JDK 1.6 后为优化性能,引入了偏向锁 → 轻量级锁 → 重量级锁的升级机制:
    • 偏向锁:只有一个线程反复进入同一同步块时,几乎无开销。
    • 轻量级锁(CAS):多个线程交替执行但无竞争时,通过 CAS 避免操作系统互斥。
    • 重量级锁:竞争激烈时升级为基于操作系统互斥量的重量级锁,会阻塞并切换线程。

谈一谈 JMM(Java Memory Model)

回答大纲

  • 抽象模型:线程 - 工作内存 - 主内存
  • 主内存:所有线程共享,存放共享变量
  • 工作内存:每个线程私有,存放用到的变量的副本
  • 八大原子操作:read / load / use / assign / store / write / lock / unlock

正式回答

**JMM(Java Memory Model,Java 内存模型)**是 Java 虚拟机规范中定义的一套抽象模型,用于屏蔽各种硬件和操作系统的内存访问差异,让 Java 程序在各种平台下都能达到一致的内存访问效果。它定义了线程、工作内存、主内存之间的交互关系。

  1. 主内存:所有线程共享的内存区域,存放所有的共享变量(实例字段、静态字段等)。
  2. 工作内存:每个线程私有的内存区域,保存该线程使用到的变量的副本。线程对变量的所有操作(读写、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量。
  3. 八大原子操作(简化):
    • read:从主内存读取变量到工作内存。
    • load:将 read 得到的值放入工作内存的变量副本。
    • use:把工作内存中的变量值传递给执行引擎。
    • assign:把执行引擎返回的值赋给工作内存中的变量。
    • store:把工作内存中的变量值传送到主内存。
    • write:把 store 得到的值写入主内存的变量。
    • lock / unlock:作用于主内存的变量,锁定或解锁。
  4. 三大特性:原子性、可见性、有序性。JMM 通过 synchronizedvolatilefinal 等关键字围绕这三大特性建立 happens-before 规则,保证多线程的正确性。

CAS 你知道吗

回答大纲

  • Compare And Swap,一种无锁并发技术
  • 三个操作数:内存位置 V、预期原值 A、新值 B
  • 底层实现
    • Unsafe
    • native 修饰,本地方法,由 C/C++ 实现
    • 调用 CPU 硬件指令(如 cmpxchg

正式回答

CAS(Compare And Swap,比较并交换)是一种无锁并发技术,用于在多线程环境下实现变量的原子更新。它的核心思想是:内存位置 V 当前的预期值是 A,才将其更新为 B,否则不做任何修改。整个操作是硬件层面的原子操作

CAS 的三个关键要素:

  1. 内存位置 V:要修改的变量在内存中的地址。
  2. 预期原值 A:调用方认为 V 当前应该的值。
  3. 新值 B:希望将 V 更新为的值。

CAS 在 Java 中的实现主要依赖 sun.misc.Unsafe 类(AtomicIntegerAtomicLong 等原子类的底层都依赖它)。Unsafe 中的 compareAndSwapInt 等方法被 native 修饰,会通过 JNI 调用操作系统底层,最终映射到 CPU 的 cmpxchg 指令,由硬件保证原子性。

优点:无锁,避免了线程阻塞和上下文切换的性能损耗。缺点

  • ABA 问题:线程 A 看到 V = A,期间 V 被其他线程改为 B 又改回 A,A 仍会 CAS 成功。可以通过引入版本号(AtomicStampedReference)解决。
  • 循环开销:高竞争下循环 CAS 会浪费 CPU。
  • 只能保证单个变量原子:不能保证代码块原子,需要配合 AtomicReference 等。

乐观锁和悲观锁的区别

回答大纲

  • 悲观锁:总是假设最坏情况,每次操作都先加锁
    • synchronized / ReentrantLock
  • 乐观锁:假设数据一般不会冲突,更新时才检查
    • CAS / 版本号机制
  • 适用场景:竞争激烈用悲观锁;竞争少用乐观锁

正式回答

**悲观锁(Pessimistic Lock)**总是假设最坏的情况,认为并发操作一定会发生冲突,因此每次操作共享资源时都会先加锁,确保同一时刻只有一个线程能操作,其他线程阻塞等待。Java 中典型的悲观锁实现是 synchronizedReentrantLock优点是安全性高、不会出现 ABA 等问题;缺点是加解锁开销大、可能导致线程阻塞和上下文切换,并发度低。

**乐观锁(Optimistic Lock)**则假设数据一般情况下不会发生冲突,因此不会提前加锁,只在更新时检查版本是否被修改过。如果版本(version)或预期值匹配,则更新;否则重试或报错。Java 中的 AtomicIntegerAtomicReference 等原子类以及 ConcurrentHashMap 的部分操作基于 CAS 实现乐观锁。优点是无锁、并发度高;缺点是高竞争下循环重试浪费 CPU,且需要解决 ABA 问题。

适用场景

  • 悲观锁适合写多读少、竞争激烈、临界区执行时间长的场景。
  • 乐观锁适合读多写少、竞争较少的场景,最大化提升吞吐量。

实际应用中两者经常结合使用,例如先乐观尝试,失败后再退化为悲观锁。

谈一谈对 volatile 的理解

回答大纲

  • 两大语义
    • 可见性:线程间对共享变量的修改立即可见
    • 有序性:通过内存屏障禁止指令重排序
  • 内存屏障(Memory Barrier)
    • 写屏障:阻止上方其他写操作越过本 volatile 写
    • 读屏障:阻止下方其他读操作越过本 volatile 读
  • 不保证原子性(i++ 不是原子的)
  • 最佳实践:状态标志、双重检查单例的配置/状态字段

正式回答

volatile 是 Java 提供的轻量级同步关键字,主要作用有两个:

  1. 保证可见性:当一个线程修改了被 volatile 修饰的共享变量时,其他线程能立即看到最新的值。其实现原理是:对 volatile 变量的写操作会立刻刷回主内存(插入 Store 屏障 + StoreLoad 屏障),读操作会从主内存重新读取最新的值(插入 Load 屏障)。注意:synchronizedLock 也能保证可见性,但开销远大于 volatile。
  2. 禁止指令重排序:JVM 为了优化性能会对字节码进行重排序,但对 volatile 变量的读写操作会插入内存屏障,限制重排序。具体规则:
    • 写 volatile 变量:禁止其上方的普通写操作重排到下面。
    • 读 volatile 变量:禁止其下方的普通读操作重排到上面。

需要特别强调的是:volatile 不保证原子性。例如 i++ 实际包含"读 - 加 - 写"三步,并不原子,如需原子计数应使用 AtomicInteger

最佳实践:将 volatile 用于线程间共享的状态标志、配置刷新、双重检查单例中的实例引用等场景;不要用它替代 synchronized/Lock,也不能用于需要复合原子操作的场景(如计数器)。

AQS

回答大纲

  • 抽象队列同步器(AbstractQueuedSynchronizer)
  • 内部维护一个 FIFO 双向队列(CLH 变体)
  • 核心:state 同步状态 + CAS 设置 state 保证原子性
  • 可实现公平锁 / 非公平锁
    • 公平:按 FIFO 顺序获取
    • 非公平:新线程可与队头线程竞争

正式回答

**AQS(AbstractQueuedSynchronizer)**是 java.util.concurrent.locks 包下的核心抽象类,是 JUC 中许多同步器(ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock 等)的基础框架。

  1. 核心结构
    • volatile int state:同步状态,是实现锁/同步器的关键。state = 0 表示未占用,state > 0 表示被占用或剩余许可数。
    • FIFO 双向队列:未抢到资源的线程被打包成 Node 节点进入该队列排队。
  2. 核心思想
    • 资源获取:通过 compareAndSetState(CAS)保证 state 修改的原子性。
    • 资源等待:获取失败的线程会构造 Node 加入队尾,并通过自旋 + LockSupport.park() 阻塞自己。
    • 资源释放:当前持有者释放资源后,会唤醒队头节点(unpark),被唤醒的线程再次尝试 CAS 抢资源。
  3. 公平性
    • 公平锁:严格按照 FIFO 顺序获取,新线程加入队尾。
    • 非公平锁:新线程可直接与队头线程竞争(ReentrantLock 默认)。
  4. 资源共享模式:AQS 支持独占模式(如 ReentrantLock)和共享模式(如 SemaphoreCountDownLatch),分别在 tryAcquire/tryReleasetryAcquireShared/tryReleaseShared 中实现。

ReentrantLock 的实现原理

回答大纲

  • 可重入锁:一个线程可多次获取同一把锁
  • 底层基于 AQS + CAS
  • 特点
    • 可中断
    • 可设置超时时间
    • 可设置公平锁
    • 支持多个条件变量
    • 支持重入

正式回答

ReentrantLock 是 JUC 中常用的可重入互斥锁,底层基于 AQS + CAS 实现。它通过 state 记录重入次数:每次重入 state + 1,释放时 state - 1,直到 state = 0 才真正释放锁。

主要特点

  1. 可重入:同一个线程可以多次获取同一把锁(递归调用同一方法不会死锁),通过记录线程持有者实现。
  2. 可中断lockInterruptibly() 让线程在等待锁时能响应中断。
  3. 可设置超时时间tryLock(long timeout, TimeUnit unit) 等待指定时间后无论是否获得锁都返回,避免无限阻塞。
  4. 可设置公平锁:通过 new ReentrantLock(true) 构造公平锁,严格按 FIFO 顺序获取;默认是非公平锁。
  5. 支持多个条件变量Conditionsynchronized + wait/notify 更灵活,可以精确唤醒特定条件队列上的线程(signal()/signalAll())。

实现原理:在 AQS 的 tryAcquire 中,先用 CAS 修改 state,再判断当前线程是否已经持有,是则允许重入;tryRelease 时递减 state,直到归零释放。

synchronized 和 Lock 有什么区别?

回答大纲

  • synchronized
    • 关键字,基于 JVM Monitor,由 C++ 实现
    • 自动加解锁,不可中断
    • 悲观锁
  • LockReentrantLock
    • 基于 AQS + CAS 的 API 类
    • 可手动加解锁、公平/非公平、响应中断
    • 竞争激烈时性能更好

正式回答

对比维度 synchronized Lock(ReentrantLock)
类型 关键字 类(API)
实现 JVM Monitor(C++) AQS + CAS(Java)
加解锁 自动(进入/退出同步块) 手动 lock()/unlock()
公平性 仅非公平 可选公平 / 非公平
可中断 不支持 lockInterruptibly()
超时 不支持 tryLock(timeout, unit)
多条件 单一 wait/notify 多个 Condition
性能 JDK 1.6 后大幅优化,常规场景接近 Lock 竞争激烈时更优

ReentrantLocksynchronized 更灵活,但也意味着必须在 finally 中释放锁避免死锁。JDK 1.6 之后 synchronized 引入了偏向锁、轻量级锁、锁消除等优化,两者性能差距已经很小。日常推荐使用 synchronized(语法简洁、不会忘记释放);只有在需要公平锁、可中断、超时、多条件等高级特性时使用 ReentrantLock

死锁产生的条件是什么?

回答大纲

  • 四个必要条件(同时满足时才会死锁)
    • 互斥:资源同一时刻只能被一个线程占用
    • 占有并等待:线程持有资源的同时等待其他资源
    • 不可剥夺:资源只能由线程主动释放,不能被强制剥夺
    • 循环等待:线程之间形成等待环路
  • 破坏任意一个条件即可避免死锁

正式回答

死锁是指两个或两个以上的线程在执行过程中,因互相持有对方需要的资源而导致的相互等待,程序无法继续推进的状态。死锁的产生需要同时满足以下四个必要条件

  1. 互斥条件:资源一次只能被一个线程占用。
  2. 占有并等待条件:线程在持有至少一个资源的同时,去请求其他被占用的资源。
  3. 不可剥夺条件:线程已经获得的资源,在未使用完之前,不能被强制剥夺,只能由持有线程主动释放。
  4. 循环等待条件:存在一条线程资源等待环路,例如线程 A 等线程 B 持有的资源,线程 B 又等线程 A 持有的资源。

只要破坏以上任意一个条件,就可以避免死锁。实际开发中常用的做法是破坏循环等待条件:所有线程按相同顺序申请锁,例如总是先锁 A 再锁 B。

如何进行死锁诊断?

回答大纲

  • 命令行工具
    • jps 列出 Java 进程
    • jstack <pid> 查看线程堆栈,定位死锁
  • 图形化工具
    • jconsole
    • JVisualVM / VisualVM

正式回答

排查死锁主要有以下几种手段:

  1. jps:JDK 自带命令,列出当前机器上所有 Java 进程及其 PID。
  2. jstack <pid>:查看指定进程中各线程的堆栈信息。jstack 会自动检测是否存在死锁,并提示"Found one Java-level deadlock"以及死锁线程、持有的锁和等待的锁。
  3. jconsole:JDK 自带的 GUI 监控工具,连接目标进程后切换到"线程"页签,可以直观看到死锁线程和锁持有情况。
  4. VisualVM:功能更强大的 GUI 工具,能查看线程运行状态、堆栈、CPU 占用、内存等,在线程页签点击"检测死锁"按钮即可识别死锁。

排查思路一般是:先 jstack 看到死锁线程,再回到代码里结合日志确认具体场景,然后检查锁申请顺序或者是否存在锁内调用外部方法等问题,针对性修复(如调整为统一加锁顺序、使用 tryLock 设置超时等)。

聊一聊 ConcurrentHashMap

回答大纲

  • JDK 1.7:分段锁(Segment 数组 + HashEntry)
  • JDK 1.8:CAS + synchronized,锁粒度更细(锁住单个桶位的头节点)
  • 读操作无锁;写操作根据桶位状态选择 CAS 或 synchronized
  • size 通过 baseCount + CounterCell[] 求和

正式回答

ConcurrentHashMap 是 JUC 下的线程安全哈希表实现,替代了同步开销大的 HashtableCollections.synchronizedMap。它在 JDK 1.7 和 1.8 中采用了完全不同的实现思路。

JDK 1.7:分段锁(Segment)

  1. 内部维护一个 Segment[] 数组,每个 Segment 继承自 ReentrantLock,相当于一小段 HashMap。
  2. 每个 Segment 内部是一个独立的哈希表(HashEntry[]),同一时刻多个线程可以分别抢占不同 Segment 的锁并发写入。
  3. 默认并发度(concurrencyLevel)为 16,即最多支持 16 个线程同时写入。读操作不加锁(volatile 保证可见性)。
  4. 缺点:分段粒度仍然较粗,扩容时影响整个 Segment,且查询时需要两次哈希。

JDK 1.8:CAS + synchronized + 红黑树

  1. 取消 Segment,底层结构与 HashMap 类似(Node[] + 链表/红黑树),并发度由 Node 粒度决定。
  2. 插入流程
    • 桶位为空:用 CAS 插入新节点,避免加锁。
    • 桶位不为空:用 synchronized 锁住该桶位的头节点(链表头/树根),锁粒度极细。
    • 链表长度 ≥ 8 且数组 ≥ 64 时树化。
  3. 读操作无锁:通过 volatile 保证 Node 中 valnext 的可见性。
  4. 统计 size:不维护全局 size 变量,而是通过 baseCount 加上 CounterCell[] 累加,最后 sumCount() 求和,避免 size 竞争。

总体上 JDK 1.8 的实现更轻量,查询遍历性能更优,是面试中应重点掌握的版本。

导致并发程序出现问题的根本原因是什么(Java 如何保证多线程的安全)

回答大纲

  • 并发编程三大特性
    • 原子性:一个操作或多个操作要么全部执行且不被中断,要么全部不执行
    • 可见性:一个线程修改了共享变量,其他线程能立即看到
    • 有序性:程序执行的顺序按照代码的先后顺序执行(防止指令重排序)
  • Java 通过 synchronized / Lock(原子性、可见性)、volatile(可见性、有序性)、final、happens-before 规则等保证

正式回答

并发程序出现问题的根本原因是三大特性被破坏

  1. 原子性(Atomicity):一个操作或一系列操作要么全部执行,要么全部不执行,不能被中断。例如 i++ 在字节码层面实际是 4 步操作,非原子性导致丢失更新。Java 通过 synchronizedLockAtomicXxx(CAS)等保证。
  2. 可见性(Visibility):一个线程修改了共享变量的值,其他线程能立即看到该修改。由于每个线程有工作内存,可能存在本地副本未及时同步到主内存的情况,导致可见性问题。Java 通过 volatilesynchronizedLockfinal 等保证。
  3. 有序性(Ordering):程序执行的顺序按代码的先后顺序执行。但 JVM/CPU 可能为优化进行指令重排序,从而破坏有序性。Java 通过 volatilesynchronizedLock 以及 happens-before 原则保证。

Java 内存模型(JMM)围绕这三大特性建立了一套 happens-before 规则,只要操作 A happens-before 操作 B,那么 A 的结果就对 B 可见。

线程池的核心参数?

回答大纲

  • 7 个核心参数(ThreadPoolExecutor 构造器)
    • corePoolSize:核心线程数
    • maximumPoolSize:最大线程数
    • keepAliveTime:救急线程空闲存活时间
    • unit:时间单位
    • workQueue:工作队列
    • threadFactory:线程工厂
    • handler:拒绝策略

正式回答

ThreadPoolExecutor 是线程池的核心实现类,它的构造器有 7 个参数:

  1. corePoolSize(核心线程数):线程池中长期保留的线程数,即使这些线程处于空闲状态,也不会被回收(默认 allowCoreThreadTimeOut=false)。
  2. maximumPoolSize(最大线程数):线程池允许创建的最大线程数,等于核心线程数 + 救急线程数。当工作队列已满时,会创建救急线程执行任务,救急线程在空闲超过 keepAliveTime 后会被回收。
  3. keepAliveTime(存活时间):救急线程的最大空闲时间,超过该时间会被回收。
  4. unit(时间单位)keepAliveTime 的单位,如 TimeUnit.SECONDS
  5. workQueue(工作队列):用于保存等待执行任务的阻塞队列,常见有 ArrayBlockingQueueLinkedBlockingQueueSynchronousQueueDelayedWorkQueue
  6. threadFactory(线程工厂):用于创建线程的工厂,可以设置线程名、守护线程、异常处理器等,便于排查问题。
  7. handler(拒绝策略):当线程池和工作队列都已满,无法再接受新任务时的处理策略,JDK 内置 4 种:抛异常 AbortPolicy(默认)、让调用者执行 CallerRunsPolicy、丢弃 DiscardPolicy、丢弃最早任务 DiscardOldestPolicy

线程池的执行原理知道吗?

回答大纲

  • 提交任务 → 核心线程是否已满
    • 未满:交给核心线程执行
    • 已满:进入工作队列等待
      • 队列未满:入队等待
      • 队列已满:创建救急线程执行
        • 救急线程达到 max:执行拒绝策略

正式回答

当向线程池提交一个任务时,线程池按以下顺序处理:

  1. 判断核心线程:当前工作线程数是否小于 corePoolSize
    • 是:创建核心线程执行任务(即使有空闲核心线程也会创建新线程)。
    • 否:进入下一步。
  2. 判断工作队列:尝试将任务放入 workQueue
    • 成功入队:等待核心线程空闲时取出执行。
    • 入队失败(队列已满):进入下一步。
  3. 判断最大线程:当前工作线程数是否小于 maximumPoolSize
    • 是:创建救急线程立即执行该任务。
    • 否:进入下一步。
  4. 执行拒绝策略:根据 handler 处理新任务(抛异常、调用者执行、丢弃等)。

此外,线程空闲时回收机制:核心线程默认不会被回收;救急线程在空闲超过 keepAliveTime 后会被回收(如果设置了 allowCoreThreadTimeOut(true),核心线程也会被回收)。

线程池中常见的阻塞队列

回答大纲

  • ArrayBlockingQueue:数组、有界、FIFO、一把锁
  • LinkedBlockingQueue:链表、可有界、FIFO、两把锁
  • SynchronousQueue:不存储元素、只做中转
  • DelayedWorkQueue:按延迟时间排序,调度型

正式回答

线程池的工作队列都是 BlockingQueue 的实现,不同队列对线程池的行为影响很大:

  1. ArrayBlockingQueue:基于数组实现,有界队列,FIFO 顺序。使用一把锁(ReentrantLock),并发性能相对一般,但内存紧凑。
  2. LinkedBlockingQueue:基于链表实现,默认无界Integer.MAX_VALUE),也可在构造时指定容量。FIFO 顺序。使用两把锁takeLockputLock),并发性能优于 ArrayBlockingQueue。注意:默认无界可能导致 OOM。
  3. SynchronousQueue不存储元素的阻塞队列,每次插入必须等待一个对应的取出操作。适合任务密集且执行快的场景,如 CachedThreadPool
  4. DelayedWorkQueue:按任务的延迟时间排序的堆结构,用于 ScheduledThreadPoolExecutor,能按延迟或周期执行任务。
  5. PriorityBlockingQueue:带优先级的无界队列,按比较器排序。
  6. LinkedBlockingDeque:双端队列,支持从两端操作。

如何确定核心线程数?

回答大纲

  • CPU 密集型N + 1,减少上下文切换
  • IO 密集型2N + 1,最大化利用线程(等待 IO 时 CPU 不忙)
  • 实际还需要根据压测和业务调整

正式回答

核心线程数的设置没有标准答案,需要根据任务类型来估算:

  1. CPU 密集型任务:CPU 使用率高,几乎没有阻塞。
    • 推荐公式:核心线程数 = CPU 核数 + 1
    • 多出的 1 个线程是为了在某个线程偶发缺页中断或其他小阻塞时,CPU 仍能保持繁忙。
  2. I/O 密集型任务:包含大量阻塞操作(数据库、HTTP、网络、文件 IO 等)。
    • 推荐公式:核心线程数 = CPU 核数 × 2 + 1,也可以进一步按 IO 等待时间与 CPU 计算时间的比例放大。
    • 核心思想:阻塞时 CPU 闲置,配置更多线程可以让 CPU 一直有事可做。
  3. 混合型任务:可以拆分为 CPU 密集阶段和 IO 密集阶段,分别交给不同线程池处理。

但是公式只是起点,实际生产环境要结合压测(JMeter、wrk、ab)和监控(JDK 自带的线程池指标)进行调优,找到平衡点。

线程池的种类有哪些?

回答大纲

  • FixedThreadPool:固定线程数
  • SingleThreadExecutor:单线程
  • CachedThreadPool:可缓存,无核心线程
  • ScheduledThreadPool:支持延迟和周期执行
  • WorkStealingPool:ForkJoinPool,工作窃取

正式回答

Executors 工厂类提供了几种常见的线程池。但生产环境不推荐直接使用 Executors,因为容易产生 OOM 问题,应当手动创建 ThreadPoolExecutor

  1. newFixedThreadPool(n):固定大小的线程池。
    • 核心线程数 = 最大线程数 = n。
    • 工作队列为无界的 LinkedBlockingQueue
    • 适用场景:已知任务量、任务耗时相对固定的长任务(如数据导入、报表生成)。
  2. newSingleThreadExecutor():单线程线程池。
    • 保证所有任务按提交顺序串行执行。
    • 适用场景:需要保证顺序执行、避免并发问题的任务流(如日志写入)。
  3. newCachedThreadPool():可缓存的线程池。
    • 没有核心线程,只有救急线程(60 秒存活时间)。
    • 工作队列为 SynchronousQueue(不存储元素)。
    • 适用场景:任务数密集但每个任务执行时间短的场景,大量短任务容易创建大量线程,注意 CPU 占用和 OOM。
  4. newScheduledThreadPool(n):支持延迟和周期执行的线程池。
    • 内部基于 ScheduledThreadPoolExecutor,使用 DelayedWorkQueue
    • 适用场景:定时任务、心跳检测、周期统计。
  5. newWorkStealingPool():基于 ForkJoinPool 的工作窃取线程池,适合大任务并行拆分(如并行流 parallelStream 的默认实现)。

为什么不建议使用 Executors 创建线程池?

回答大纲

  • 主要原因:可能 OOM
    • newFixedThreadPool / newSingleThreadExecutor 使用无界 LinkedBlockingQueue,任务堆积可能导致 OOM
    • newCachedThreadPool 允许创建 Integer.MAX_VALUE 个线程,线程数过多导致 OOM
  • 建议手动创建 ThreadPoolExecutor,明确 7 个参数

正式回答

阿里《Java 开发手册》等业界规范明确指出禁止使用 Executors 创建线程池,主要原因在于容易引发 OOM(OutOfMemoryError)

  1. newFixedThreadPool(int)newSingleThreadExecutor() 的工作队列都是 LinkedBlockingQueue,而默认容量为 Integer.MAX_VALUE,可视为无界队列。如果任务提交速度持续大于处理速度,队列会不断堆积,最终撑爆堆内存,产生 OOM。
  2. newCachedThreadPool() 允许创建的线程数上限为 Integer.MAX_VALUE。在突发大量任务时,会瞬时创建大量线程,每个线程都会占用一定栈空间(默认 1MB),叠加后引发 OOM。

正确做法:根据业务场景手动创建 ThreadPoolExecutor,明确指定核心线程数、最大线程数、队列容量、拒绝策略等参数,使线程池的行为可预测、可监控。

你们项目中哪里使用了线程池?

回答大纲

  • 常用技术
    • CountDownLatch:主线程等待多个子任务完成
  • 使用场景
    • ES 数据批量导入(数据汇总)
    • 将串行业务改造为并行(数据汇总、报表汇总)
    • 异步调用
    • 异步通知

正式回答

在生产项目中,线程池主要用于解耦异步执行和提升并发处理能力。常见场景包括:

  1. 异步调用:用户操作触发的非关键路径(如发送通知、记录日志、更新缓存)通过 @Async 或手动 submit 提交到线程池,避免阻塞主流程。
  2. 数据汇总 / 报表统计:将原本串行执行的多个耗时统计任务,并行提交到线程池,最后合并结果(通常配合 CountDownLatchCompletableFuture.allOf() 等待全部完成)。
  3. ES 数据批量导入:从数据库读取大量数据后,分批次提交到线程池并发写入 ES,配合 ThreadPoolExecutor 限制并发度保护 ES。
  4. 批量任务并行化:例如批量发送短信、批量调用三方接口,将 N 个独立子任务并行处理后再聚合。
  5. 定时任务 / 心跳检测:使用 ScheduledThreadPool 执行周期任务。

常用工具类包括 CountDownLatch(协调多任务合并)、CompletableFuture(组合多个异步任务)等。

如何控制某个方法允许并发访问线程的数量?

回答大纲

  • 使用 Semaphore(信号量)
  • 构造时传入许可数 permits
  • acquire() 获取许可,release() 释放许可
  • 也可使用 Hystrix / Resilience4j 等限流框架

正式回答

如果希望控制同时访问某段代码的线程数量,可以使用 JUC 的 Semaphore(信号量)

Semaphore semaphore = new Semaphore(3); // 最多 3 个线程同时执行
try {
    semaphore.acquire();
    // 核心业务逻辑
} finally {
    semaphore.release();
}

Semaphore 内部基于 AQS 实现:

  1. 构造时传入许可数 permits,表示允许的最大并发线程数。
  2. acquire():获取许可,如果许可数为 0 则阻塞(或响应中断、带超时)。
  3. release():释放许可,把许可数加 1,唤醒等待的线程。

除了 Semaphore,还可以:

  • 使用 Guava RateLimiter 实现 QPS 级别的令牌桶限流。
  • 在分布式场景下使用 Redis + Lua / Sentinel / Resilience4j 实现分布式限流。
  • 在网关层使用 Nginx / Sentinel 做接口级限流。

谈谈你对 ThreadLocal 的理解

回答大纲

  • 每个线程持有一份独立的变量副本,互不干扰
  • 底层数据结构:Thread.threadLocals,类型为 ThreadLocalMap
  • 内存泄漏风险:ThreadLocalMap 中 Entry 的 Key 是弱引用,Value 是强引用
  • 最佳实践:使用完毕后调用 remove()

正式回答

ThreadLocal 是 JDK 提供的线程本地变量工具,能为每个线程提供独立的变量副本,使多个线程之间互不干扰。常见用法:

private static final ThreadLocal<User> USER_HOLDER = new ThreadLocal<>();
USER_HOLDER.set(currentUser);   // 每个线程都有一份
User u = USER_HOLDER.get();      // 取自己线程的副本
USER_HOLDER.remove();            // 用完清理

底层原理

  1. 每个 Thread 对象内部都有一个 ThreadLocalMap threadLocals,它是 ThreadLocal 的静态内部类。
  2. set(value) 时,以当前 ThreadLocal 为 key、value 为值写入当前线程的 ThreadLocalMap
  3. get() 时,在当前线程的 ThreadLocalMap 中查找对应的 entry。
  4. ThreadLocalMapEntry 继承自 WeakReference<ThreadLocal>,因此 key 是弱引用,value 是强引用

内存泄漏 OOM 风险:当 ThreadLocal 变量被回收后(key 变 null),如果线程一直存活(典型场景是线程池中的核心线程),那么 ThreadLocalMap 中就会出现 key=null 但 value 仍强引用的 entry。这部分 value 既无法被访问也无法被回收,最终可能导致内存泄漏。

最佳实践:每次使用完 ThreadLocal 后,主动调用 remove() 方法清理 entry,特别在线程池场景下尤其重要。这也是阿里等公司代码规范的强制要求。

JVM 组成

什么是 JVM?

回答大纲

  • Java 虚拟机,Java 二进制字节码的运行环境
  • 负责加载字节码并解释/编译执行
  • 提供自动内存管理和垃圾回收
  • 主要组成部分:类加载器、运行时数据区、执行引擎、本地方法接口、垃圾收集器

正式回答

**JVM(Java Virtual Machine,Java 虚拟机)**是 Java 平台的基石,是运行 Java **字节码(.class)**的虚拟计算机。它主要完成以下工作:

  1. 字节码加载:通过类加载器子系统将 .class 文件加载到运行时数据区。
  2. 字节码执行:通过执行引擎中的解释器逐条解释执行,或通过 JIT(即时编译器)将热点代码编译成本地机器码提升性能。
  3. 自动内存管理:在堆内存中为对象分配空间,并通过垃圾回收器自动回收不再使用的对象,避免手动内存管理的风险。
  4. 跨平台性:JVM 针对不同操作系统有不同的实现(Windows、Linux、macOS),同一份字节码可以在任何安装了 JVM 的机器上运行,实现了"Write Once, Run Anywhere"。

image-20260711171532545

什么是程序计数器?

回答大纲

  • 线程私有,生命周期与线程相同
  • 存储当前线程执行的字节码指令地址(行号)
  • 唯一一个不会出现 OOM 的内存区域
  • Native 方法时计数器为 undefined

正式回答

程序计数器(Program Counter Register,PC Register)是 JVM 运行时数据区的一部分,是线程私有的内存区域。每一个线程都有自己的程序计数器,彼此独立。

  1. 作用:在当前线程执行的字节码中,记录下一条要执行的指令的地址(即行号)。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  2. 唯一一个不会出现 OOM 的内存区域:因为它存储的是指令地址,不需要 GC,也不需要扩展,所以是 JVM 规范中唯一不会抛出 OutOfMemoryError 的区域。
  3. Native 方法:如果线程执行的是 Native 方法,程序计数器的值为 undefined,因为本地方法由操作系统调度,不由 JVM 字节码执行。
  4. 线程上下文切换:当线程被挂起再次切回时,需要通过程序计数器恢复到正确的执行位置,因此每个线程都有自己的 PC。

可以用 javap -v Test.class 查看 class 文件,能在 Code 区看到每一行字节码对应程序计数器的偏移值。

详细介绍 Java 的堆

回答大纲

  • 线程共享的区域
  • 存放对象实例和数组
  • Java 7 与 Java 8 内存结构对比
    • Java 7:新生代 + 老年代 + 永久代(PermGen)
    • Java 8:新生代 + 老年代 + 元空间(Metaspace),元空间使用本地内存
  • 堆是 GC 主要管理区域

正式回答

堆(Heap)是 JVM 运行时数据区中线程共享的一块内存区域,是 JVM 所管理的内存中最大的一块。

  1. 存储内容:几乎所有的对象实例数组都在堆上分配(部分逃逸分析优化后可在栈上分配)。

  2. GC 主战场:堆是垃圾回收器主要管理的区域,因此也被称为"GC 堆"。现代 GC(如 G1、ZGC)几乎所有的回收算法都围绕堆展开。

  3. 分代划分:基于对象生命周期假设,堆被划分为:

    • 新生代(Young Gen):刚创建的对象,新生代又分为 Eden 区和两个 Survivor 区(S0、S1)。
    • 老年代(Old Gen):经过多次 GC 仍存活的对象,会被晋升到这里。
  4. Java 7 vs Java 8 内存结构对比

    版本 主要划分
    Java 7 新生代 + 老年代 + 永久代(PermGen):存放类元数据
    Java 8 新生代 + 老年代 + 元空间(Metaspace):使用本地内存

    将永久代改为元空间的主要原因是:永久代大小受 JVM 参数限制(-XX:MaxPermSize),容易出现 java.lang.OutOfMemoryError: PermGen space;而元空间使用本地内存(默认仅受系统可用内存限制),可以显著降低 OOM 概率,且便于调优。

什么是虚拟机栈?

回答大纲

  • 线程私有,生命周期与线程相同
  • 每个线程运行时需要的内存,称为虚拟机栈
  • 由多个**栈帧(Stack Frame)**组成
  • 活动栈帧:当前正在执行的方法对应的栈帧
  • 每个栈帧包含:局部变量表、操作数栈、动态链接、方法返回地址

正式回答

**虚拟机栈(VM Stack)**是 JVM 运行时数据区的一部分,线程私有,生命周期与线程相同。

  1. 栈帧(Stack Frame):每个方法执行时,JVM 都会创建一个栈帧并压入栈中。方法执行完毕后栈帧出栈。
  2. 栈帧的组成
    • 局部变量表:存放方法参数和方法内部定义的局部变量(包括基本类型和对象引用)。
    • 操作数栈:方法执行过程中用于数据传递和计算的临时区域。
    • 动态链接:指向运行时常量池中该方法的引用,支持多态调用。
    • 方法返回地址:方法正常返回或异常退出后,回到调用者的位置。
  3. 活动栈帧:栈顶的栈帧对应的是正在执行的(最里层)方法,又称"当前栈帧"。
  4. 栈的运作:方法调用 → 栈帧入栈;方法返回 → 栈帧出栈(不管正常 return 还是抛异常)。
  5. 线程安全:由于栈是线程私有的,栈帧内部的局部变量天生线程安全,无需额外同步。

垃圾回收是否涉及栈内存?

回答大纲

  • 不涉及
  • 栈内存由方法调用和返回自动管理,不需要 GC 介入
  • GC 指的是堆内存的回收

正式回答

垃圾回收(GC)不涉及栈内存

因为虚拟机栈描述的是 Java 方法执行的内存模型,栈帧的入栈和出栈是严格遵循"先进后出"原则的:方法调用时入栈,方法返回时出栈(无论是正常返回还是抛异常)。栈帧的整个生命周期与方法的执行流程绑定,在编译期就能确定大小(局部变量表、操作数栈深度都可知)。所以栈内存不需要 GC 介入,也不需要考虑复杂的内存回收问题。

GC 的主要对象是堆内存,尤其是新生代;其他区域如方法区/元空间也会有少量 GC。栈内存溢出(StackOverflowError)和堆内存溢出(OOM)是性质完全不同的两类异常。

栈内存分配越大越好吗?

回答大纲

  • 不是
  • 栈内存越大,可支持的递归/调用深度越大,但单个线程占用内存也越大
  • 多线程场景下总内存会膨胀,可能挤占堆内存
  • 一般默认 -Xss 1024k 就足够;推荐根据实际调用深度设置

正式回答

并不是越大越好

栈内存由 -Xss 参数控制(例如 -Xss1024k)。盲目调大栈内存有两个潜在问题:

  1. 单线程占用增加:每个线程都会分配独立的栈空间。栈越大,可支持的调用深度(递归)越深,但单线程占用的内存也越多。
  2. 总内存膨胀,挤占堆内存:操作系统能创建的线程数受总内存限制。如果每个线程栈都是 1MB,1000 个线程就占用 1GB,挤占了堆的可用内存,反而可能更容易引起 OOM。
  3. 收益递减:大部分应用的调用深度不过几十层到几百层,1MB 足够;过度调大栈并不能换来等比例的调用深度提升。

最佳实践:保持默认值(一般 512KB ~ 1MB 即可),只有在确实遇到 StackOverflowError(例如复杂递归)时才有针对性地调大,并同步评估对线程数和堆内存的影响。

方法内的局部变量是否线程安全?

回答大纲

  • 是线程安全
  • 局部变量保存在每个线程的栈帧中,是线程私有的
  • 注意:局部变量引用对象本身如果在方法外被共享,则对象操作不线程安全
  • 注意:将局部变量返回出去后可能被外部多线程访问,不再线程安全

正式回答

方法内的局部变量本身是线程安全的,因为:

  1. 栈帧私有:每个线程调用方法时都会在自己的虚拟机栈中创建一个独立的栈帧,局部变量保存在各自栈帧的局部变量表中,天然不与其他线程共享
  2. 不并发:线程 A 的局部变量不会影响线程 B 的同名局部变量。

但是需要注意两种"反例":

  1. 返回局部变量:如果方法返回了一个局部对象引用,外部多个线程可能持有同一引用并进行操作,那么对对象成员的操作就不再线程安全,需要额外同步。
  2. 对象逃逸:局部变量指向的对象如果在方法外还有其他引用(例如通过参数传入),那么对象本身可能被多线程访问,操作其字段也要考虑同步。

简而言之:局部变量本身是线程安全的,但局部变量引用的对象是否线程安全,取决于它的作用域是否被多个线程共享

方法外的局部变量是否线程安全?

(说明:Java 中没有严格意义上的"方法外局部变量"。这里通常指成员变量/实例变量。)

回答大纲

  • 这里的"方法外局部变量"通常指成员变量(实例变量 / 静态变量)
  • 成员变量保存在中,是线程共享
  • 多线程同时读写同一个成员变量,需要同步保证线程安全

正式回答

Java 中"方法外"的变量指成员变量(实例字段/静态字段),它们与局部变量有本质区别:

  1. 成员变量存储在堆中(实例字段随对象在堆中,静态字段在方法区/元空间),是所有线程共享的。
  2. 如果多个线程同时读写同一个成员变量,就会产生竞态条件(Race Condition),必须通过 synchronizedLockvolatileAtomicXxx 等手段保证线程安全。
  3. 每条规则都有例外:如果对象本身是线程封闭的(例如只在单个线程中使用),那么它的成员变量也不存在多线程访问问题,天然安全。例如 ThreadLocal 把对象封闭在线程内,或 Swing 的事件派发线程(EDT)。

什么情况下会导致栈内存溢出?

回答大纲

  • 抛出 java.lang.StackOverflowError
  • 常见场景
    • 递归调用过深(最常见)
    • 栈帧过大(极少见)
  • 可通过 -Xss 调大栈内存缓解,但不能根治

正式回答

当线程请求的栈深度超过了 JVM 允许的最大深度,就会抛出 java.lang.StackOverflowError

  1. 最常见:递归调用过深。例如没有正确收敛条件的递归,会无限压栈直至栈耗尽。在实际项目中,常常是因为循环引用结构的 toString / hashCode 触发(如 A → B → A)。
  2. 栈帧过大:单个栈帧的局部变量表非常大或操作数栈深度非常深(极端情况)。现代 JVM 编译期基本不会出现。
  3. 线程栈设置过小:操作系统给每个线程分配的栈内存过小,跑深度递归时容易溢出。

解决方法

  • 排查并修复递归逻辑(如加上正确的收敛条件)。
  • 用循环 + 栈(Stack)或尾递归优化代替深递归。
  • 必要时通过 -Xss 调大每个线程的栈空间,但不要盲目调大。
  • 使用 @SneakyThrows 之类的工具只能掩盖错误,并不能解决根本问题。

堆和栈的区别?

回答大纲

  • 存储内容:堆存对象实例/数组;栈存栈帧(局部变量、操作数栈等)
  • 线程私有性:堆线程共享;栈线程私有
  • 异常:堆 OOM;栈 StackOverflowError
  • 空间大小:堆通常很大(GB 级);栈相对较小(MB 级 / 线程)
  • GC:堆是 GC 主战场;栈不需要 GC

正式回答

对比维度 堆(Heap) 虚拟机栈(VM Stack)
线程私有 线程共享 线程私有
存储内容 对象实例、数组 栈帧:局部变量、操作数栈、动态链接、返回地址
空间大小 通常较大(GB 级),由 -Xms / -Xmx 控制 较小(默认 1MB 左右),由 -Xss 控制
异常 OutOfMemoryError StackOverflowError
GC 主要 GC 区域 不参与 GC
生命周期 JVM 启动时创建,关闭时释放 与线程生命周期相同
性能关注 关注 GC 算法、内存分代、对象分配率 关注调用深度、栈帧大小

实际开发中常见的内存问题主要是:堆 OOM(对象过多或内存泄漏)和栈 StackOverflow(递归过深)。

能不能解释一下方法区?

回答大纲

  • 线程共享的内存区域
  • 存储已被加载的类信息、字段、方法、常量、静态变量
  • Java 8 之前叫永久代(PermGen),使用 JVM 内存
  • Java 8 之后改为元空间(Metaspace),使用本地内存
  • 是 JVM 规范中的概念,JVM 各版本实现不同

正式回答

方法区(Method Area)是 JVM 规范定义的运行时数据区之一,是线程共享的内存区域,用于存储已被虚拟机加载的类信息、字段、方法、常量、静态变量、即时编译器编译后的代码缓存等数据。

  1. JVM 规范 vs 实现:方法区是 JVM 规范中定义的概念,具体的实现因 JVM 版本而异。
  2. Java 7 之前:永久代(PermGen):HotSpot 用一块 JVM 堆内的内存实现方法区,通过 -XX:PermSize / -XX:MaxPermSize 设置大小。容易因为动态加载大量类(如 JSP、动态代理、OSGi)而出现 PermGen OOM
  3. Java 8 之后:元空间(Metaspace):HotSpot 把方法区实现改为元空间,使用本地内存(Native Memory)。默认情况下只受系统可用内存限制,极大降低了 OOM 概率。元空间通过 -XX:MetaspaceSize / -XX:MaxMetaspaceSize 调优。
  4. 演进的好处:合并类元数据和堆,独立管理元数据;为后续支持更大动态类的项目(如 GraalVM)做准备。
graph TB
    subgraph JVM["🖥️ JVM 运行时数据区"]
        direction TB

        subgraph Thread_Shared["🧵 线程共享区"]
            direction LR
            Heap["🗑️ 堆内存
(Heap)
存放:对象实例、数组
GC 主要回收区"] Meta["📚 元空间
(Metaspace)
存放:类元数据、方法信息
常量池、JIT编译代码
使用本地内存"] end subgraph Thread_Private["👤 线程私有区"] direction LR Stack["📦 Java虚拟机栈
(VM Stack)
存放:栈帧(局部变量表、
操作数栈、方法出口)"] NativeStack["⚙️ 本地方法栈
(Native Stack)
存放:native方法调用信息"] PC["📟 程序计数器
(PC Register)
存放:当前执行的
字节码指令地址"] end DirectMem["💾 直接内存
(Direct Memory)
NIO 使用的堆外内存"] end ClassLoader["📂 类加载器
(ClassLoader)"] --> Meta Meta -->|类元数据引用| Heap Heap -->|对象引用| Stack NativeStack -->|调用| NativeMethod["🛠️ 本地方法库"] DirectMem -->|缓冲区| Heap

解释一下常量池

回答大纲

  • 分为运行时常量池字符串常量池
  • 运行时常量池:每个类一份,存储字面量、符号引用
  • 字符串常量池:全局共享,存放字符串字面量,避免重复创建
  • 字符串常量池在 Java 7 移到堆中,Java 8 仍在堆中
  • intern() 方法可以把堆中字符串加入常量池

正式回答

JVM 的常量池主要分两类:

  1. 运行时常量池(Runtime Constant Pool):每个类被加载后,class 文件中的"常量池表"会被加载到运行时常量池中(属于方法区/元空间)。它存放编译期生成的各种字面量(字符串、final 常量)和符号引用(类、方法、字段的符号)。运行期间也可以通过 ldc / invokedynamic 把新的常量放入池中(如 lambda 表达式生成的引导方法)。
  2. 字符串常量池(String Pool):是运行时常量池的一部分,专门用于存放字符串字面量(如 "hello")。在 Java 7 之前位于方法区(PermGen),在 Java 7 及之后移到中。作用是避免重复创建相同字符串,因为 String 重写了 equalshashCode,可以用 HashMap 结构快速查重。
  3. String.intern():可以把堆中的 String 对象加入字符串常量池。如果池中已有相同内容的字符串则返回池中的引用;否则把当前引用加入池中。这正是 "a" + "b" == "ab" 在某些情况下为 true 的原因。

注意:运行时常量池与字符串常量池不是同一个东西。前者是类级别的元数据概念,后者是 JVM 共享的字符串表。

你听过直接内存吗?

回答大纲

  • 直接内存不属于 JVM 运行时数据区
  • 操作系统管理
  • 通过 NIOByteBuffer.allocateDirect 分配
  • 适合大文件读写、网络数据传输
  • 可通过 -XX:MaxDirectMemorySize 配置上限

正式回答

直接内存(Direct Memory)是 JVM 之外的、由操作系统直接管理的内存区域,并不属于 JVM 运行时数据区(Java 规范之外的部分)。

  1. 分配方式:在 JDK 的 NIO 中,可以通过 ByteBuffer.allocateDirect() 申请直接内存,本质上是在 JVM 堆外申请一块 native 内存。
  2. NIO vs BIO:传统 BIO 的 FileInputStream/FileOutputStream 等使用堆内字节数组作为缓冲区,读写文件时需要先把数据从磁盘拷贝到内核缓冲区,再拷贝到 JVM 堆;如果用户空间再进行二次处理,可能还有一次到用户缓冲区的拷贝。NIO 的直接内存模式(FileChannel + DirectByteBuffer)能减少一次用户空间到内核空间的拷贝,提高 I/O 效率。
  3. 典型案例 - 文件拷贝
    • FileInputStream + ByteArrayOutputStream 拷贝:磁盘 → 内核缓冲区 → JVM 堆 → 内核缓冲区 → 磁盘。
    • 用 NIO FileChannel + DirectByteBuffer 拷贝:磁盘 → 内核缓冲区 → 磁盘,减少中间两次 JVM 堆缓冲的拷贝。
  4. 配置:可通过 -XX:MaxDirectMemorySize 配置上限。超出系统可用内存时也会 OOM,但比堆 OOM 排查更困难。
  5. 回收:DirectByteBuffer 是虚引用 + Cleaner 机制回收,使用时应当注意显式释放或控制规模。

JVM 类加载器

什么是类加载器,类加载器有哪些?

回答大纲

  • 类加载器负责将 .class 文件加载到 JVM
  • Java 8 及以前四类加载器
    • BootstrapClassLoader(启动类加载器)
    • ExtClassLoader(扩展类加载器)
    • AppClassLoader(应用类加载器)
    • 自定义类加载器
  • 父子关系通过 getParent() 体现(除 Bootstrap 外)

正式回答

**类加载器(ClassLoader)**是 JVM 的一个核心组件,负责将 .class 字节码文件加载到内存中,并生成对应的 Class 对象,使得程序能使用该类。

Java 内置了以下几种类加载器:

  1. BootstrapClassLoader(启动类加载器):由 C++ 实现,是 JVM 自身的一部分。负责加载 JDK 核心类库,如 rt.jarjava.langjava.util 等。开发者无法直接获取该加载器的引用(getParent() 返回 null)。
  2. ExtClassLoader(扩展类加载器):负责加载 JDK 扩展目录 jre/lib/ext 中的类(Java 9 之后改为模块系统 PlatformClassLoader)。
  3. AppClassLoader(应用类加载器):负责加载 classpath 下的类,是程序默认使用的类加载器,加载开发者自己编写的类和第三方 jar。
  4. 自定义类加载器:通过继承 ClassLoader 实现,常用于 Tomcat 热加载、模块隔离、加密解密等场景。

这些类加载器之间形成了父子关系(通过 getParent()),但不是继承关系,而是组合(parent 字段)。

什么是双亲委派模型?

回答大纲

  • 加载类时,优先委派给父加载器尝试加载
  • 父加载器无法加载时,才由自己尝试加载
  • 体现了"先向上,再向下"的加载链
  • 通过 loadClass 中的 parent.loadClass() 实现

正式回答

**双亲委派模型(Parents Delegation Model)**是 JVM 默认的类加载行为机制,它的核心原则是:

当一个类加载器收到类加载请求时,它不会自己先尝试加载,而是先委派给父加载器去尝试加载;每一层都是如此;只有当所有父加载器都无法加载该类时(在自己的搜索范围内没有找到对应的 .class),子加载器才尝试自己加载。

源码体现在 ClassLoader.loadClass()

protected Class<?> loadClass(String name, boolean resolve) {
    // 1. 检查是否已被加载
    Class<?> c = findLoadedClass(name);
    if (c == null && parent != null) {
        // 2. 委派给父加载器
        c = parent.loadClass(name, false);
    }
    if (c == null) {
        // 3. 父加载器无法加载时才自己 findClass
        c = findClass(name);
    }
    return c;
}

例如一个 AppClassLoader 收到加载 com.example.User 的请求:

  1. AppClassLoader 委派给 ExtClassLoader;
  2. ExtClassLoader 委派给 BootstrapClassLoader;
  3. BootstrapClassLoader 在 rt.jar 中找不到,进入下一层;
  4. ExtClassLoader 在 ext 目录中也找不到,进入下一层;
  5. AppClassLoader 在 classpath 下找到并加载。

为什么使用双亲委派机制?

回答大纲

  • 类的唯一性:保证同一个类在 JVM 中只被一个加载器加载一次
  • 安全性:防止核心 API 库被篡改
    • 用户自定义 java.lang.String 不会被加载
    • 防止通过自定义类加载器植入恶意代码

正式回答

双亲委派机制的存在有两个核心目的:

  1. 保证类的唯一性:同一份 .class 文件无论在 JVM 中哪个位置被引用,最终都会被同一个类加载器加载,避免出现"同一个类有多个 Class 对象"导致的类型混乱(ClassNotFoundException、类型比较失败等)。这种唯一性是 JVM 安全沙箱的基础。
  2. 保证 JDK 核心 API 的安全性:通过双亲委派,核心 API(如 java.lang.Stringjava.util.HashMap)只会被 BootstrapClassLoader 加载一次。攻击者即便写了一个 java.lang.String 类,由于加载请求会先到 BootstrapClassLoader,最终加载的是 JDK 自带的 String,用户写的同名类根本没有机会被执行。这从根本上防止了核心 API 被恶意篡改的可能性。

Spring 等框架为了实现某些高级功能(如 Tomcat 的 Web 应用隔离、OSGi 模块化、热部署),会破坏双亲委派模型,采用"线程上下文类加载器(Thread Context ClassLoader)“或自定义类加载器来实现。

类装载的过程?

回答大纲

  • 三大阶段
    • 加载(Loading)
    • 连接(Linking):验证、准备、解析
    • 初始化(Initialization)
  • 准备阶段:static 变量分配内存 + 设默认初始值
  • 解析阶段:符号引用 → 直接引用

正式回答

类装载的全过程分为加载、连接、初始化三大阶段,其中连接又分为验证、准备、解析。

  1. 加载

    • 通过类的全限定名获取 .class 字节流(不限定来源,文件、网络、动态生成都行)。
    • 把字节流转化为方法区的运行时数据结构。
    • 在堆中生成一个代表该类的 Class 对象,作为方法区数据的访问入口。
  2. 连接

    • 验证(Verify):校验 .class 文件格式、字节码语义、引用合法性等,确保不会危害 JVM 安全。可以通过 -Xverify:none 关闭(不推荐)。
    • 准备(Prepare):为类变量(static 变量)分配内存并设置默认初始值。需要注意的是:
      • static final 常量在准备阶段就会完成赋值(因为 final 字段在编译期就内联了)。
      • 普通 static 变量准备阶段赋的是零值(如 int=0boolean=false),赋值要到初始化阶段。
      • 引用类型的 static 变量,准备阶段仅分配内存并赋 null,初始化阶段赋值。
    • 解析(Resolve):将符号引用转换为直接引用
      • 符号引用:java/lang/System.out.println() 这种基于方法名/字段名的字符串引用。
      • 直接引用:直接指向目标的指针、句柄或偏移量,能在内存中定位到目标。
  3. 初始化:执行类的 <clinit> 方法(类构造器),按照源码中的顺序对类变量静态语句块进行真正的赋值操作。这是类装载过程的最后一步,多个线程并发初始化同一个类时,JVM 会加锁保证只执行一次。

静态代码块存在的意义

回答大纲

  • 典型场景
    • 加载本地库:JNI 加载 .so / .dll
    • 读取配置文件:把配置加载到内存
    • 注册驱动:JDBC DriverManager 注册驱动
    • 单例模式:Holder 模式按需创建单例
    • 预置缓存:把高频访问的数据提前加载

正式回答

静态代码块(static initializer)是伴随类初始化阶段自动执行的代码块,常用于执行类级别的”一次性“准备工作。它的核心价值在于:保证这些初始化操作有且仅有一次,且发生在类被第一次使用时(线程安全)。典型应用场景包括:

  1. 加载本地库:JNI 调用前用 System.loadLibrary("xxx") 加载 .so.dll 文件。
  2. 读取配置文件:将 application.ymlconfig.properties 等加载到内存中的某个工具类。
  3. 注册驱动:JDBC 中 Class.forName("com.mysql.jdbc.Driver") 会触发 DriverManager.registerDriver,新版 JDBC 也支持 SPI 自动注册,仍有类似静态初始化机制。
  4. 单例模式:借助 static 内部类(Holder 模式)实现线程安全的懒加载单例,例如 private static class Holder { static final Singleton INSTANCE = new Singleton(); },外部访问 Holder.INSTANCE 触发内部类的初始化。
  5. 预置缓存:把字典项、枚举映射、热门数据等预置到静态集合中,避免每次请求都重新构造。

注意:静态代码块只在类首次主动使用时执行,且只执行一次;如果出现异常会导致类初始化失败,后续访问该类时抛 ExceptionInInitializerError

JVM 垃圾回收

对象什么时候可以被垃圾回收器回收?

回答大纲

  • 核心判断原则:对象没有任何引用指向它时,可以被回收
  • 两种主流判定算法
    • 引用计数法:维护引用计数
    • 可达性分析算法:从 GC Roots 出发搜索引用链
  • 判定为可回收的时机:成为"不可达"对象后并非立即被回收,要经过两次标记
  • finalize() 方法只能"自救"一次

正式回答

当一个对象没有任何引用指向它时,它就可以被垃圾回收器回收(前提是已经经过了一次可达性分析判定为不可达)。判断一个对象是否可回收主要有两种算法:

  1. 引用计数法:对象维护一个引用计数器,每次被引用就 +1,引用失效就 -1。当计数器为 0 时,判定为可回收。

    • 优点:简单高效,判定过程可以穿插在程序运行中。
    • 致命缺点:无法解决循环引用问题。例如对象 A 持有 B 的引用,B 也持有 A 的引用,二者计数器永不为 0,永远不会被回收。因此主流 JVM 都不采用该算法
  2. 可达性分析算法(Tracing):从一系列称为 GC Roots 的根对象出发,向下搜索所有走得到的引用链,能走到的对象是"可达的”,否则是"不可达的"。

    • GC Roots 主要包括:虚拟机栈中的局部变量、native 方法引用的对象、方法区中的静态字段 / 常量、被同步锁持有的对象、虚拟机内部的引用(如 Class 对象)等。
    • HotSpot、JVM 等主流虚拟机采用的判定算法。

即便被判定为不可达,对象也不会被立即回收。它会先被标记并放进一个"F-Queue"队列,由一条低优先级的 Finalizer 线程去执行 finalize() 方法(最后一次自救的机会),如果 finalize 中重新建立了到 GC Roots 的链,则对象"复活"。但 Finalizer 线程并发性能差且行为不可预测,Java 9 起已被标记为 deprecated。

JVM 垃圾回收的算法有哪些?

回答大纲

  • 标记清除算法
  • 标记整理算法
  • 复制算法

正式回答

JVM 的垃圾回收算法主要有三种。

  1. 标记清除算法(Mark-Sweep)将垃圾回收分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。这种方法效率较高,但由于不再使用的对象被回收后,内存中会产生大量不连续的内存碎片,导致后续申请大对象时无法找到足够的连续内存而不得不触发另一次 GC。
  2. 标记整理算法(Mark-Compact)在标记清除算法的基础上做了改进。标记阶段相同,但在清除阶段不是直接清除垃圾对象,而是把存活的对象都向内存的一端移动,然后直接清理边界以外的内存。这种方式没有内存碎片,但因为需要移动对象,所以效率较低,且移动对象时需要暂停所有用户线程(STW)。
  3. 复制算法(Copying)把可用的内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完时,把还存活的对象复制到另一块内存中,再把已使用的那块内存全部清空,并交换两块内存的角色。这种方式没有内存碎片,但因为始终只有一块内存可用,所以内存利用率只有 50%

现代 JVM 通常结合使用多种算法(即分代收集理论):新生代使用复制算法,老年代使用标记清除或标记整理算法,以扬长避短。

什么是分代回收?各代的回收策略?

回答大纲

  • 分代依据:大部分对象生命周期短
  • 新生代:复制算法(Eden + Survivor)
  • 老年代:标记整理 / 标记清除
  • Minor GC / Major GC / Full GC
  • 对象晋升:年龄阈值、动态年龄判断、大对象直接进老年代

正式回答

分代回收(Generational Collection)是当前商业虚拟机普遍采用的垃圾回收策略,其核心思想是基于 “弱分代假说”:绝大多数对象都是"朝生夕死"的,而熬过越多次 GC 的对象就越难消亡。基于此,JVM 将堆划分为新生代老年代两块,分别采用不同的回收算法。

  1. 新生代(Young Gen)
    • 存放新创建的对象。
    • 划分为 Eden 区(80%)+ Survivor To + Survivor From(各 10%)
    • 采用复制算法:Minor GC 时,Eden + From 中存活的对象被复制到 To 区,然后 From 和 To 互换角色。
    • 触发频率高,但单次 STW 时间短。
  2. 老年代(Old Gen)
    • 存放长期存活的对象。
    • 采用标记清除标记整理算法。
    • 触发频率低,但单次 STW 时间通常更长,Full GC 会导致应用明显卡顿。
  3. 晋升机制
    • 对象每熬过一次 Minor GC,年龄 +1,默认 15 岁时晋升老年代(-XX:MaxTenuringThreshold)。
    • Survivor 区中同龄对象总大小超过 Survivor 50% 时,比该年龄大的对象直接晋升(动态年龄判断)。
    • 大对象(如长字符串、大数组)会直接进入老年代,避免在 Eden 和 Survivor 间来回复制。
  4. 回收触发
    • Minor GC:Eden 区满时触发,仅回收新生代。
    • Major GC / Old GC:老年代空间不足时触发,仅回收老年代。
    • Full GC:回收整个堆 + 方法区,会暂停所有用户线程,应尽量避免。
  5. 跨代引用:通过 Card Table(卡表) + Remembered Set 解决,避免扫描整个老年代。

什么是 GC Roots?

回答大纲

  • 可达性分析的起点
  • 常见 GC Roots
    • 虚拟机栈中的局部变量
    • 静态字段引用的对象
    • 常量池中的引用
    • native 方法引用的对象
    • synchronized 持有的对象
    • JVM 内部引用(如基本类型的 Class 对象、异常对象)

正式回答

GC Roots 是可达性分析算法中作为搜索起点的对象集合。从这些起点出发向下搜索,搜索经过的链路称为"引用链",当一个对象到 GC Roots 没有任何引用链相连时(即不可达),就判定为可被回收。

常见的 GC Roots 包括

  1. **虚拟机栈(栈帧中的局部变量表)**中引用的对象。例如方法参数、局部变量。
  2. 方法区中静态属性引用的对象。例如 static User user = new User() 中的 user
  3. 方法区中常量引用的对象。例如 "abc" 这种字符串常量池中的引用。
  4. 本地方法栈(JNI)中 native 方法引用的对象
  5. 被同步锁(synchronized)持有的对象
  6. JVM 内部引用:如基本数据类型的 Class 对象、异常对象(NullPointerExceptionOutOfMemoryError)、系统类加载器、预分配的内存对象等。
  7. 其他:如被加入到 “GC Roots Set” 的引用,记录在 CardTable / RSet 中的来自老年代的跨代引用。

判断一个对象能否被回收,只看它是否能从这些根被搜索到,跟其他业务字段无关。这也是 Java 内存泄漏的常见根源之一:通过静态集合意外持有了不再需要的对象,使对象无法被回收。

分代回收

MinorGC , MixedGC , FullGC 的区别

JVM 有哪些垃圾回收器

  • 串行
    • 新生代用 Serial(复制算法),老年代用 Serial Old(标记-整理算法)。
  • 并行(JDK8)
    • 新生代用 Parallel Scavenge(复制算法,多线程),老年代用 Parallel Old(标记-整理算法,多线程)。
  • CMS(只负责老年代)
  • G1(JDK9+默认)

聊一聊G1垃圾回收器

强引用、软引用、弱引用、虚引用的区别?

JVM调优参数可以在哪里设置参数值

  • war包 - tomcat
  • jar包 - 启动参数

JVM调优参数有哪些?

说一说JVM调优的工具?

java内存泄漏的排查思路?

  • 获取内存快照
  • jmap获取dump文件
  • vm参数获取dump文件
  • VisualVM 分析 dump文件
  • 查看堆信息情况
  • 找到对应代码进行分析

CPU飙高的排查方案与思路

  • top
  • ps H -eo pid, tid, %cpu | grep 40940
  • jstack 40940
  • printf “%x\n” 40955

设计模式

工厂设计模式

  • 简单工厂模式
    • 具体产品
    • 具体工厂
  • 抽象工厂模式

策略模式

  • 策略+工厂
  • 只要有if-else,冗长的switch分支,都可以使用策略模式

责任链设计模式

单点登录这块是如何实现的

权限认证是如何实现的

  • RBAC

上传数据的安全性你们是怎么控制的

  • 对称加密
  • 非对称加密

负责项目时遇到了哪些比较棘手的问题,如何解决的

  • 设计模式
    • 工厂
    • 策略
    • 责任链
  • 线上BUG
    • CPU飙高
    • 内存泄漏
    • 线程死锁
  • 调优
    • 接口慢
    • 慢SQL
    • 缓存方案
  • 组件封装
    • 分布式锁
    • 接口幂等
    • 分布式事务
    • 支付通用

你们项目中日志是怎么采集的?

  • 采集日志的手段
    • ELK
      • ElasticSearch
      • Logstash
      • Kibana
    • 常规采集

查看日志的命令?

LINUX

tail -f xx.log

tail -n 100 xx.log

head -n 100 xx.log

cat -n xx.log | tail -n + 100 | head -n 100

cat -n xx.log | grep “debug”

按日期查询 sed -n …

生产环境下的问题怎么排查

  1. 分析日志
  2. 远程debug(生产不允许debug)
    1. 远程代码和本地代码保持一致

怎么快速定位系统的瓶颈

  • 压测
  • 监控工具
    • Prometheus + Grafana
    • Skywalking, Zipkin
  • 线上诊断工具 Arthas