JDK、JRE、JVM 区别
这三个是 Java 开发最基础的概念,层层包含、各司其职,一句话先总结:
JDK = JRE + 开发工具 JRE = JVM + 核心类库 JVM = Java 虚拟机(核心运行引擎)
- 逐一定义(最简单理解)
① JVM(Java Virtual Machine)Java 虚拟机
作用 :真正运行 Java 代码的核心
它是一个虚拟计算机,能把编译好的
.class字节码翻译成你电脑(Windows/Mac/Linux)能看懂的机器指令特点 :跨平台的关键 → 一次编译,到处运行
不包含:编译器、调试器等开发工具
给谁用:只负责运行,不负责开发
② JRE(Java Runtime Environment)Java 运行环境
作用 :让 Java 程序能跑起来
包含:
JVM(运行引擎)
核心类库(Java 自带的工具包、基础功能)
不包含:编译器(javac)等开发工具
给谁用 :只想运行 Java 程序,不写代码的普通用户
③ JDK(Java Development Kit)Java 开发工具包
作用 :开发 + 运行 Java 程序
包含:
JRE(能运行)
开发工具(编译器
javac、调试器、打包工具等)给谁用 :写 Java 代码的开发者
包含关系(最直观)
JDK (最大,开发者用)
└── JRE (运行环境)
└── JVM (最小,核心运行引擎)核心区别对比表
表格
名称 中文 核心功能 包含内容 使用人群 JVM Java 虚拟机 运行 .class字节码仅虚拟机 底层运行 JRE 运行环境 让 Java 程序运行 JVM + 核心类库 普通用户 JDK 开发工具包 开发 + 运行 Java JRE + 编译器 / 工具 开发者
- 最通俗的比喻
把 Java 程序 比作 一辆车:
JVM = 发动机(车能跑的核心)
JRE = 整车(能开,但不能造)
JDK = 汽车工厂 + 整车(能造车,也能开车)
- 实际使用场景
你写代码、做开发 → 必须装 JDK
你只运行别人写好的 Java 程序 → 装 JRE 就够
JVM 不会单独安装,它被包含在 JRE/JDK 里
总结
JVM:运行代码的核心引擎
JRE:运行 Java 程序的最小环境(JVM + 类库)
JDK:Java 开发全套工具(JRE + 编译器等)
包含关系:JDK ⊃ JRE ⊃ JVM
== 和 equals 的区别
一句话总结:
== 比较地址,equals 默认也比较地址,但常用类(String、Integer 等)被重写后比较内容。
==是什么?
是运算符
基本数据类型 :比较值是否相等
引用数据类型 :比较内存地址是否相同(是不是同一个对象)
int a = 10;
int b = 10;
System.out.println(a == b); // true,值相等String s1 = new String("abc");
String s2 = new String("abc");
System.out.println(s1 == s2); // false,两个不同对象,地址不同
equals()是什么?
是
Object类里的方法默认实现 :和
==完全一样,也是比较地址但很多类重写了 equals:
String、Integer、Date、包装类等
重写后:比较内容是否相同,不再比地址
String s1 = new String("abc");
String s2 = new String("abc");System.out.println(s1.equals(s2)); // true,内容相同
- 核心区别对比
表格
比较项 == equals() 类型 运算符 Object 中的方法 基本类型 比较值 不能使用(基本类型没有方法) 引用类型 比较内存地址 默认比地址,重写后比内容 能否重写 不能 可以自己重写
经典面试题(String)
String a = "hello";
String b = "hello";
String c = new String("hello");System.out.println(a == b); // true(常量池里同一个对象)
System.out.println(a == c); // false(c 是 new 出来的新对象)
System.out.println(a.equals(c)); // true(内容一样)简单记法
== :看是不是同一个东西
equals :看长得一不一样
hashCode 与 equals 关系
一句话核心:
equals 相等的两个对象,hashCode 必须相等; hashCode 相等的两个对象,equals 不一定相等。 重写 equals 必须重写 hashCode,否则 HashMap、HashSet 会出 bug。
- 先搞清楚两个方法的作用
① equals()
用来判断两个对象是否逻辑相等
默认比较地址,重写后比较内容
② hashCode()
返回一个int 类型的哈希值
作用:快速定位对象在哈希表中的位置(HashMap / HashSet / HashTable)
本质是个粗略的快速判断
官方规定的契约(必须遵守)
同一个对象,多次调用 hashCode,必须返回相同值
如果两个对象 equals 为 true ,它们的 hashCode 必须相同
如果两个对象 equals 为 false,hashCode 可以相同也可以不同(最好不同,减少冲突)
简单记:
equals 相等 → hashCode 一定相等
hashCode 相等 → equals 不一定相等(哈希冲突)
- 为什么重写 equals 必须重写 hashCode?
场景:把对象放进 HashSet / HashMap
哈希表的工作流程是:
先算 hashCode,确定存到哪个桶
再用 equals 对比桶里的对象,判断是否重复
如果你只重写 equals,不重写 hashCode:
两个逻辑相等的对象(equals = true)
但它们的 hashCode 不同
会被放进哈希表的不同桶里
结果:HashSet 会认为它们是两个不同对象,允许重复存入
→ 破坏了 Set 不重复的特性,HashMap 也会出现重复 key
举个例子
你自定义一个 User 类,只重写 equals,不重写 hashCode:
User u1 = new User("张三"); User u2 = new User("张三"); u1.equals(u2) → true u1.hashCode() → 1234 u2.hashCode() → 5678放进 HashSet:
set.add(u1); set.add(u2);结果:set 里会同时存在 u1 和 u2,这明显错误。
- 反过来:只重写 hashCode 不重写 equals 可以吗?
可以运行,但毫无意义:
hashCode 相同
equals 还是默认比较地址 → false
哈希表依然认为是两个不同对象
超简洁总结
equals 相等 → hashCode 必须相等(契约)
hashCode 是给哈希表快速查找用的
只改 equals 不改 hashCode → HashSet/HashMap 失效,出现重复对象
规范做法:要么都不重写,要么一起重写
final、finally、finalize 区别
这三个长得像,但用途、场景、生命周期完全不一样,是 Java 经典面试题。
一句话总结
final :修饰符,用来限制类、方法、变量,表示不可变、不可继承、不可重写。
finally :异常处理关键字,和 try/catch 一起用,无论是否异常,代码一定执行。
finalize :Object 里的方法,对象被垃圾回收前会调用,现在基本废弃不用。
- final(关键字・修饰符)
用来表示 "最终、不可变"。
三种用法
修饰变量
变量变成常量,只能赋值一次
基本类型:值不可变
引用类型:地址不可变(对象内容仍可变)
修饰方法
- 方法不能被子类重写(override)
修饰类
类不能被继承
如 String、Integer 都是 final 类
finally(关键字・异常处理)
配合
try...catch使用:
try { // 代码 } catch (Exception e) { // 异常 } finally { // 无论是否异常、是否 return,这里**一定执行** }作用:
关闭资源(流、连接、锁)
做必须执行的清理工作
- finalize(方法・已废弃)
是
Object类中的方法:protected void finalize() throws Throwable作用:对象被 GC 回收前,会调用一次
问题:
调用时机不确定
性能差
可能导致对象复活、内存泄漏
现状:Java 9 已标记为废弃,不建议使用
核心区别对比表
表格
关键字 类型 作用 使用场景 final 修饰符 不可变、不可继承、不可重写 常量、工具类、禁止重写方法 finally 流程控制关键字 异常中必须执行的代码块 关闭 IO、数据库连接、释放资源 finalize Object 方法 对象被垃圾回收前调用(已废弃) 几乎不用 最简单记忆口诀
final:关住(不让变、不让继承)
finally:兜底(一定执行)
finalize:临死前喊一声(GC 前调用,已废弃)
接口 vs 抽象类
先给你一句面试标准答案:
接口侧重行为规范、能力扩展 ,支持多实现;抽象类侧重模板、共性属性与逻辑 ,只能单继承;Java 8 后接口可以有默认方法、静态方法,但依然不能有构造方法、不能维护普通成员变量。
一、核心区别(直接背)
继承 vs 实现
抽象类:
extends继承,只能单继承接口:
implements实现,可以多实现成员变量
抽象类:可以有普通成员变量、常量、静态变量
接口:只能是
public static final常量(默认就是)构造方法
抽象类:有构造方法(子类初始化调用)
接口:没有构造方法
方法权限
抽象类:方法可以是
public/protected/default/private接口:默认
public,不能随便改权限设计思想
抽象类:is-a(是什么)
接口:can-do(具备什么能力)
二、Java 8+ 接口新增(重点)
Java 8 之后接口不再只能是纯抽象方法,可以有:
默认方法 :
default void method() {}静态方法 :
static void method() {}默认方法作用
给接口增量扩展功能,不强迫所有实现类重写
解决接口升级时,大量实现类要改代码的问题
但接口依然不是抽象类
接口不能维护状态(不能有普通成员变量)
接口不能有构造方法
接口依然是多实现,抽象类是单继承
三、一张表搞定
表格
对比项 抽象类 接口(Java 8+) 继承方式 extends 单继承 implements 多实现 成员变量 任意变量 只能 public static final 常量 构造方法 有 无 抽象方法 可以有 可以有 普通方法 可以有 可以有 default /static 方法 设计目的 模板、共性代码 定义规范、能力扩展 四、怎么选?
想定义一组行为规范 ,让多个无关类实现 → 用 接口
想抽取子类公共代码、属性、模板逻辑 → 用 抽象类
想给接口加新方法又不想改所有实现类 → 用 默认方法
重载 vs 重写
一句话记:
重载:同一个类里,方法名相同、参数不同 重写:子类继承父类,方法签名完全相同
- 重载(Overload)
位置:同一个类中
方法名:必须相同
参数列表:必须不同
个数不同
类型不同
顺序不同
返回值:可以相同,也可以不同
修饰符:可以随意
异常:无限制
典型例子:
public int add(int a, int b) { ... } public double add(double a, double b) { ... }口诀:同名不同参
- 重写(Override)
位置:子类继承父类 / 实现接口
方法名:必须相同
参数列表:必须完全相同
返回值:必须相同(或其子类型,协变返回)
修饰符 :子类权限 不能更严格
父类 public → 子类只能 public
父类 protected → 子类可以 protected /public
异常 :子类抛出异常 不能更大、更多
典型例子:
class Father { public void say() {} } class Son extends Father { @Override public void say() {} }口诀:同名同参,子类改实现
- 一张表秒懂
表格
对比项 重载 Overload 重写 Override 位置 同一个类 子类 / 实现类 方法名 相同 相同 参数列表 必须不同 必须完全相同 返回值 无要求 必须相同(或协变) 修饰符 无要求 不能更严格 异常 无要求 不能更大 / 更多 注解 无 建议 @Override 核心思想 一个方法多种用法 子类重新实现父逻辑
- 最简单记忆
重载:编译时多态
重写:运行时多态
装箱、拆箱 & Integer 缓存
一、什么是装箱、拆箱?
- 基本概念
基本数据类型 :
byte、short、int、long、float、double、char、boolean包装类 :
Byte、Short、Integer、Long、Float、Double、Character、Boolean**装箱(Autoboxing)**基本类型 → 包装类对象
Integer a = 10; // 底层:Integer.valueOf(10)**拆箱(Unboxing)**包装类对象 → 基本类型
int b = a; // 底层:a.intValue()Java 5 之后自动完成,所以叫自动装箱 / 自动拆箱。
二、Integer 缓存机制(重点)
Integer.valueOf(int i)内部有缓存池,用来复用对象,提高性能。
- 缓存范围
默认缓存:-128 ~ 127
这个范围内的数字,会复用同一个对象
超出范围,会
new Integer(...)新建对象
经典面试题
Integer a = 100;
Integer b = 100;
Integer c = 200;
Integer d = 200;System.out.println(a == b); // true
System.out.println(c == d); // false原因:
100 在缓存范围内 → 同一个对象
200 超出范围 → 两个不同对象
==比较地址,所以结果不同三、其他包装类缓存
Byte:全部缓存(-128~127)
Short:-128~127
Integer:-128~127
Long:-128~127
Character:0~127
Float / Double:没有缓存四、一句话总结
装箱:基本类型 → 包装类
拆箱:包装类 → 基本类型
Integer 缓存:-128~127 复用对象,超出新建对象
比较包装类值是否相等,一定要用 equals ()
String、StringBuilder、StringBuffer 区别
一句话总结:
String 不可变,StringBuilder 可变且线程不安全最快,StringBuffer 可变且线程安全稍慢。
- 核心区别
① String
不可变字符序列 ,底层
final char[](Java 9+ 是 byte [])每次修改(拼接、替换)都会生成新对象,效率低
线程安全(不可变自然安全)
② StringBuilder
可变字符序列
线程不安全,没有 synchronized
执行速度最快,适合单线程大量拼接
③ StringBuffer
可变字符序列
线程安全 ,方法带
synchronized速度比 StringBuilder 慢一点
适合多线程环境拼接字符串
- 对比表
表格
特性 String StringBuilder StringBuffer 可变性 不可变 可变 可变 线程安全 安全(不可变) 不安全 安全(加锁) 性能 最差(频繁生成对象) 最快 中等 适用场景 少量字符串、不修改 单线程大量拼接 多线程大量拼接
怎么选?
字符串很少修改、只赋值 → 用 String
单线程频繁拼接 (循环拼接)→ 用 StringBuilder(最常用)
多线程环境下拼接 → 用 StringBuffer
一句记忆口诀
String 不可变
StringBuilder 快但不安全
StringBuffer 安全但慢点
String 为什么不可变?有什么好处?
一、String 为什么不可变?
从源码设计上看,有 3 个关键点:
底层字符数组被 final 修饰
JDK 8 及以前:
private final char value[]JDK 9+ 优化为:
private final byte[] valuefinal 数组 → 地址不能改,且数组是 private,没有对外修改方法。String 类本身是 final不能被继承,避免子类破坏不可变特性。
没有提供修改内部数组的方法 没有 set 方法,所有 substring、replace、concat 等操作,都返回新 String 对象,不修改原对象。
所以:
String 不可变,是设计出来的,不是天生的。
二、不可变有什么好处?
- 线程安全
不可变对象 = 只读,多线程随便用,不用加锁、不用同步。
- 字符串常量池复用,节省内存
相同字符串只存一份,多个引用指向同一个对象。
String s1 = "abc"; String s2 = "abc";如果可变,s1 一改,s2 也跟着变,常量池就废了。
- 哈希值可以缓存
String 会缓存 hashCode,只计算一次。HashMap 里大量用 String 做 key,速度非常快。
- 安全可靠
类加载器用 String 作为类名、路径
网络连接、URL、文件名也常用 String如果 String 可变,容易被恶意篡改,引发安全漏洞。
- 避免副作用
方法里传入 String,不用担心被内部修改,代码更稳定。
三、一句话总结
String 不可变:因为底层 private final char [] + 类 final + 无修改方法。
好处 :线程安全、常量池复用、缓存 hashCode、安全稳定、无副作用。
反射是什么?应用场景
一句话总结:**反射就是在程序运行时,拿到类的完整信息(类名、字段、方法、构造器),并动态操作它们的机制。**不用提前 new 对象,不用硬编码调用方法。
一、反射是什么?
正常写代码:
java
运行
User user = new User(); user.setName("张三");编译时就确定了类、方法,叫编译期绑定。
反射代码:
// 运行时才加载类、创建对象、调用方法 Class<?> cls = Class.forName("com.example.User"); Object obj = cls.newInstance(); Method method = cls.getMethod("setName", String.class); method.invoke(obj, "张三");这就是运行期动态绑定,也就是反射。
反射能做什么:
运行时获取 Class 对象
创建对象
获取 / 修改成员变量(包括 private)
调用任意方法(包括 private)
获取父类、接口、注解等
二、核心 API(简单记)
Class.forName("全类名"):获取 Class 对象
cls.newInstance()/getConstructor(...):创建对象
getField / getDeclaredField:获取字段
getMethod / getDeclaredMethod:获取方法
method.invoke(obj, args):执行方法
setAccessible(true):暴力访问私有成员三、应用场景(面试常考)
Spring IOC 容器(创建 Bean) 读取 xml / 注解里的类名,反射创建对象,完成依赖注入。
MyBatis / ORM 框架数据库结果集 → 通过反射映射到实体类的字段。
**动态代理(AOP 底层)**JDK 动态代理完全基于反射实现。
通用工具类比如通用 JSON 序列化、Bean 拷贝、对象转 Map 等。
框架注解解析 如 @Controller、@Autowired、@Test 等,都是反射读取注解实现的。
JDBC 加载驱动
Class.forName("com.mysql.cj.jdbc.Driver");破坏封装、单元测试测试 private 方法、修改私有变量。
四、优点 & 缺点
优点:
高度灵活,解耦,代码可配置化
框架必备核心技术
缺点:
性能比直接调用慢
破坏封装,可以访问私有成员
代码可读性差、复杂
五、极简记忆
反射 = 运行时动态操作类
核心作用:解耦 + 动态
场景:Spring、MyBatis、动态代理、JSON、注解驱动
JDK 动态代理 vs CGLIB 动态代理
一句话总结:JDK 代理面向接口,用反射;CGLIB 面向类,继承子类,用字节码生成。
一、JDK 动态代理
基于接口
被代理类必须实现接口
没有接口 → 不能用 JDK 代理
原理
运行时生成一个实现了相同接口的代理类
通过反射调用目标方法
核心类
InvocationHandler
Proxy.newProxyInstance()二、CGLIB 动态代理
基于类继承
被代理类不需要接口
直接生成目标类的子类作为代理
原理
用 ASM 框架修改字节码,生成子类
重写非 final 方法进行增强
核心类
MethodInterceptor
Enhancer三、核心区别对比表
表格
对比项 JDK 动态代理 CGLIB 实现基础 接口 类继承 是否需要接口 必须有接口 不需要接口 代理方式 实现相同接口 生成目标类子类 方法限制 接口方法 不能代理 final 类、final 方法 性能 JDK 8 以前较慢JDK 8 后大幅优化,接近 CGLIB 旧版 JDK 下更快 底层机制 反射 字节码生成(ASM) 四、Spring 里的选择规则(必考)
目标类实现了接口 → 默认使用 JDK 动态代理
目标类没有接口 → 使用 CGLIB
可以强制 Spring 全局用 CGLIB:
@EnableAspectJAutoProxy(proxyTargetClass = true)五、一句话记忆
JDK 代理:有接口才能用,靠反射
CGLIB:没接口也能用,靠生成子类
Spring:有接口用 JDK,无接口用 CGLIB
深拷贝 vs 浅拷贝
一句话核心:浅拷贝只复制引用,深拷贝复制整个对象结构;浅拷贝共享成员对象,深拷贝完全独立。
- 浅拷贝(Shallow Copy)
复制规则:
基本类型:直接复制值
引用类型:只复制内存地址,不复制对象本身
结果 :原对象和拷贝对象共用同一个成员对象,改一个,另一个也会变。
实现方式 :实现
Cloneable接口,重写clone(),默认就是浅拷贝。
- 深拷贝(Deep Copy)
复制规则:
基本类型:复制值
引用类型:递归复制整个对象,创建新对象
结果 :拷贝对象与原对象完全独立,互不影响。
实现方式:
重写
clone(),递归拷贝所有引用成员序列化 / 反序列化(最简单通用)
JSON 序列化
- 一张表看懂区别
表格
对比项 浅拷贝 深拷贝 基本类型 复制值 复制值 引用类型 复制地址,共享对象 复制整个对象,完全独立 修改引用成员 互相影响 互不影响 速度 快 慢(递归 / 序列化) 实现难度 简单 复杂
- 经典记忆口诀
浅拷贝:复制外壳,共用内脏
深拷贝:连壳带内脏,全部复制一份新的
- 典型场景
对象只有基本类型 + 不可变对象(如 String)→ 浅拷贝足够
对象包含可变引用类型(自定义类、集合)→ 必须用深拷贝
ArrayList 和 LinkedList 区别
一句话总结:ArrayList 是数组,查询快、增删慢;LinkedList 是链表,查询慢、增删快。
- 底层结构
ArrayList :动态数组
LinkedList :双向链表
- 访问效率
ArrayList :支持随机访问 ,通过下标直接定位,查询极快
LinkedList :不支持随机访问,查找要从头 / 尾遍历,查询慢
- 增删效率
ArrayList :中间 / 头部增删需要移动元素,慢尾部追加快
LinkedList :已知节点情况下增删只需改引用,极快适合频繁插入、删除
- 内存占用
ArrayList:内存连续,占用较少,有一定预留空间
LinkedList :每个节点存数据 + 前驱 + 后继,内存开销更大
- 线程安全
两者都线程不安全多线程用:
CopyOnWriteArrayList或自己加锁
- 对比表
表格
特性 ArrayList LinkedList 底层结构 动态数组 双向链表 随机访问 支持,快 不支持,慢 增删(中间) 慢(需移动元素) 快(改引用) 内存占用 较小 较大(节点开销) 适用场景 大量查询、遍历 频繁增删、队列 / 栈
- 最简单记忆
查多用 ArrayList
增删多用 LinkedList
日常开发 90% 用 ArrayList
ArrayList 扩容机制
一句话总结
ArrayList 默认初始容量 0,第一次添加变为 10; 满了之后按 1.5 倍扩容,底层用 Arrays.copyOf 复制数组。
- 初始化(JDK 1.8+)
无参构造:
new ArrayList<>();初始底层数组为 空数组
{},容量 0指定容量:
new ArrayList<>(20);直接创建长度为 20 的数组
- 第一次添加元素
第一次调用
add()时,才真正初始化容量为 10
- 什么时候扩容?
当 元素个数 size == 数组长度 时,再添加就会触发扩容。
- 扩容多少?
新容量 = 旧容量 + 旧容量 >> 1 ≈ 旧容量 × 1.5
例如:
10 → 15
15 → 22
22 → 33
- 扩容怎么做?
底层调用:
Arrays.copyOf(oldArray, newCapacity);创建一个新数组,把原数组内容复制过去,原数组被丢弃。
面试常考要点
JDK 7 vs JDK 8
JDK 7:一开始就创建容量为 10 的数组
JDK 8:懒加载,第一次 add 才变成 10
为什么是 1.5 倍?
比 2 倍更节省内存,减少空间浪费
位移运算
oldCapacity >> 1效率极高频繁扩容会影响性能所以能预估大小时,最好指定初始容量:
new ArrayList<>(1000);极简记忆版
初始空,第一次变 10
满了就 1.5 倍扩容
底层数组复制
懒加载、高效、浪费少
HashMap 底层原理
一句话概括:JDK 1.7:数组 + 单链表,头插法,易死循环; JDK 1.8:数组 + 链表 + 红黑树,尾插法,查询更稳定。
一、通用结构(两者共同点)
底层:数组 + 链表(哈希表)
核心:
hash取模定位数组下标,链表解决哈希冲突扩容:容量 ×2 ,负载因子默认 0.75
线程不安全,高并发会出现数据丢失、死循环
二、JDK 1.7 结构
数组 + 单向链表
冲突时:头插法(新节点插到链表头部)
扩容重哈希:重新计算所有 hash,链表会倒置
问题:
链表过长,查询效率退化为 O (n)
多线程扩容会形成环形链表,导致死循环(CPU 100%)
三、JDK 1.8 优化(重点)
结构变为:数组 + 链表 + 红黑树
链表长度 ≥8 且数组长度 ≥64 → 转为红黑树(O(logn))
树节点 ≤6 → 退化为链表
尾插法
新节点插到链表尾部
扩容不会倒置链表,避免了死循环
hash 算法简化
- 高 16 位异或低 16 位,更高效
扩容更高效
不用重新全部计算 hash
用
hash & 旧容量判断是留在原位置还是移到「原位置 + 旧容量」四、1.7 vs 1.8 对比表
表格
对比项 JDK 1.7 JDK 1.8 底层结构 数组 + 单链表 数组 + 链表 + 红黑树 插入方式 头插法 尾插法 哈希冲突 纯链表 链表 / 红黑树自动切换 扩容重 hash 全部重新计算 按 bit 位判断,效率更高 查询效率 链表长则慢 O (n) 树结构稳定 O (logn) 并发问题 可能形成环形链表死循环 不会死循环,但仍线程不安全 五、极简记忆
1.7:数组 + 链表,头插,会死循环,慢
1.8:数组 + 链表 + 红黑树,尾插,更快更安全
阈值:链表≥8 转树,≤6 退链
扩容始终是 2 倍扩容
HashMap 为什么线程不安全
一句话总结:HashMap 没有任何锁保护,多线程同时 put、扩容时,会出现数据覆盖、链表成环、数据丢失,甚至 JDK 1.7 会导致死循环。
下面分版本讲最核心的 3 个问题:
一、JDK 1.7 下最危险:扩容导致环形链表 + 死循环
1.7 用头插法 ,多线程同时扩容时,链表容易变成环。一旦形成环:
get()遍历链表时会无限循环CPU 瞬间 100%,程序卡死
这是 HashMap 最经典、最严重的线程安全问题。
二、JDK 1.8 虽然修复了死循环,但依然不安全
1.8 改成尾插法 ,不会死循环了,但还是线程不安全,主要两个问题:
- 数据覆盖(最常见)
线程 A 和 B 同时计算出相同下标
都判断该位置为空
同时执行插入→ 后执行的会覆盖先插入的数据,导致数据丢失。
- size 计数不准
size++不是原子操作:
读取 size
+1
写回 size
多线程并发时会出现丢失更新,最终 size 比实际元素少。
三、总结:不安全的根本原因
没有加锁(synchronized / CAS 都没有)
多线程并发 put 会互相覆盖
JDK 1.7 扩容会形成环形链表,导致死循环
size 计算非原子,数量不准
四、面试一句话标准答案
HashMap 是非线程安全的,因为没有加锁同步机制。JDK 1.7 中多线程扩容会使用头插法,容易形成环形链表导致死循环 ;JDK 1.8 改用尾插法避免了死循环,但仍会出现数据覆盖、size 不准确 等问题。高并发场景应使用 ConcurrentHashMap。
ConcurrentHashMap 原理
一句话总结:1.7 用分段锁(Segment),细粒度锁,并发度 16; 1.8 用 CAS + synchronized + 红黑树,锁粒度更细,性能更高。
一、JDK 1.7:分段锁 Segment
结构
底层:Segment [] + HashEntry [] + 链表
Segment 继承自 ReentrantLock
默认 16 个 Segment,并发度就是 16
原理
先根据 key 定位到 Segment
对整个 Segment 加锁
同一 Segment 内的操作串行,不同 Segment 并行
优点
- 比 Hashtable 全局锁性能高很多
缺点
锁粒度依然偏大(锁住一段)
链表过长查询效率低
二、JDK 1.8:CAS + synchronized + 红黑树
结构
底层:数组 + 链表 + 红黑树(结构同 HashMap 1.8)
取消 Segment,直接用 Node 数组
加锁策略
无锁:CAS 尝试插入数组对应位置为空时,用 CAS 直接插入,不加锁,效率极高
有锁:synchronized 锁住链表头节点 / 树节点 位置已存在数据时,只锁当前数组槽位的头节点锁粒度极小,只锁一条链表 / 一棵树
核心优化
CAS 无锁操作
synchronized 只锁头节点
链表长了转红黑树
并发性能远超 1.7
三、1.7 vs 1.8 对比表
表格
对比项 JDK 1.7 JDK 1.8 结构 Segment + 数组 + 链表 数组 + 链表 + 红黑树 锁实现 ReentrantLock 分段锁 CAS + synchronized 锁粒度 整个 Segment 数组槽位头节点(更细) 并发度 默认 16 更高,理论等于数组长度 查询效率 链表 O (n) 红黑树 O (logn) 锁复杂度 较高 更简单高效 四、一句话记忆
1.7:分段锁,锁一段,并发 16
1.8:CAS 无锁 + 锁头节点,粒度更细,性能更强
HashSet 底层实现 & 如何保证不重复
- 底层是什么?
一句话:HashSet 底层就是 HashMap,只是只用了 key,value 是一个固定的空对象。
源码一眼看懂:
// HashSet 内部持有一个 HashMap private transient HashMap<E, Object> map; // 所有 put 都用这个固定值当 value private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; }
存元素 → 往 HashMap 的 key 里存
value 统一是一个静态空对象
PRESENT,没用
- 如何保证不重复?
依靠 HashMap 的 key 不重复 机制:
添加元素时流程:
计算元素的 hashCode()
找到数组对应位置
用 equals() 对比链表 / 红黑树上的节点
如果 hashCode 相同 且 equals 为 true → 视为重复,不添加
否则 → 添加成功
所以:HashSet 不重复 = HashMap key 唯一性 = hashCode + equals
- 一句话总结
HashSet 底层 = HashMap
存值只存 key,value 是固定空对象
不重复靠:hashCode 定位 + equals 判断相等
HashSet、LinkedHashSet、TreeSet 对比
一句话总结:HashSet 无序最快;LinkedHashSet 保持插入顺序;TreeSet 有序可排序。
- 底层结构
HashSet :底层
HashMap,数组 + 链表 + 红黑树LinkedHashSet :底层
LinkedHashMap,HashSet + 双向链表TreeSet :底层
TreeMap,红黑树
- 顺序性
HashSet :完全无序,遍历顺序不固定
LinkedHashSet :保持插入顺序(先进先出)
TreeSet :自然有序(按大小排序)或自定义比较器排序
- 能否排序
HashSet:❌ 不能排序
LinkedHashSet:❌ 不能排序,只保插入顺序
TreeSet:✅ 支持排序(自然排序 / Comparator 定制排序)
- 元素要求
HashSet / LinkedHashSet:元素必须重写 hashCode() + equals()
TreeSet:元素实现 Comparable 接口,或传入 Comparator 比较器
- 性能(速度)
HashSet > LinkedHashSet > TreeSet
TreeSet 要维护红黑树排序,最慢
LinkedHashSet 要维护链表,略慢于 HashSet
HashSet 无额外开销,最快
- 是否允许 null
HashSet:允许 1 个 null
LinkedHashSet:允许 1 个 null
TreeSet:不允许 null(排序会报空指针)
- 线程安全
三者都线程不安全 多线程用:
Collections.synchronizedSet()或ConcurrentSkipListSet
- 对比表
表格
特性 HashSet LinkedHashSet TreeSet 底层 HashMap LinkedHashMap TreeMap (红黑树) 顺序 无序 插入顺序 自然有序 / 定制排序 排序 不支持 不支持 支持 性能 最快 中等 最慢 null 允许 1 个 允许 1 个 不允许 元素要求 hashCode+equals hashCode+equals Comparable/Comparator
- 怎么选?
只去重、不在乎顺序 → HashSet(最常用)
去重 + 保持插入顺序 → LinkedHashSet
去重 + 自动排序 → TreeSet
LinkedHashMap 原理与应用
一句话总结:LinkedHashMap = HashMap + 双向链表,可保持插入顺序 / 访问顺序,是实现 LRU 缓存的天然结构。
一、底层原理
继承自 HashMap 结构:数组 + 链表 / 红黑树 + 双向链表
每个节点多两个指针
before
after用来维护一条双向链表,记录顺序。两种顺序模式
插入顺序(默认):按 put 顺序排列
访问顺序 :get /put 都会把节点移到链表尾部通过构造参数
accessOrder = true开启java
运行
new LinkedHashMap<>(16, 0.75f, true);二、核心特点
遍历顺序稳定可预测
底层复用 HashMap 大部分逻辑
性能略低于 HashMap,但远好于 TreeMap
线程不安全
三、实现 LRU 缓存(高频面试)
什么是 LRU?
Least Recently Used 最近最少使用
缓存满了
优先删除最久没被访问的元素
LinkedHashMap 为什么适合?
开启
accessOrder=true后:
每次 get/put ,节点都会被移到链表尾部
链表头部 就是 最近最少使用 的节点
自带方法:
java
运行
protected boolean removeEldestEntry(Map.Entry<K,V> eldest)返回 true 就自动删除头部最老数据。
四、手写 LRU 模板(面试直接写)
java
运行
class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); // 开启访问顺序 this.capacity = capacity; } // 超过容量就删除最老的 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }五、一句话记忆
LinkedHashMap = HashMap + 双向链表
支持插入顺序 / 访问顺序
accessOrder=true实现按访问排序完美用于 LRU 缓存
TreeMap 排序原理 + Comparable vs Comparator
一、TreeMap 排序原理
一句话:TreeMap 底层是红黑树,按照 key 自动排序,排序规则由 Comparable 或 Comparator 决定。
- 底层结构
红黑树(自平衡二叉查找树)
保证查询、插入、删除都是 O(logn)
遍历时是中序遍历,所以输出有序
排序规则
如果构造 TreeMap 时传入了 Comparator → 用比较器排序
否则要求 key 必须实现 Comparable 接口 → 用
compareTo排序两者都没有 → 运行时抛出 ClassCastException
特点
key 不允许为 null
key 必须可比较
线程不安全
按 key 有序遍历
二、Comparable 和 Comparator 区别
- Comparable(内部比较器)
接口:
java.lang.Comparable<T>方法:
public int compareTo(T o)特点:
类自己实现,称为 "自然排序"
一个类只有一种 compareTo
侵入式,修改原类
示例:
class User implements Comparable<User> { int age; @Override public int compareTo(User o) { return this.age - o.age; // 按年龄升序 } }
- Comparator(外部比较器)
接口:
java.util.Comparator<T>方法:
public int compare(T o1, T o2)特点:
单独写比较器,不修改实体类
一个类可以有多个比较器(按姓名、年龄、ID...)
灵活、无侵入
示例:
Comparator<User> comparator = new Comparator<>() { @Override public int compare(User o1, User o2) { return o1.getAge() - o2.getAge(); } };三、核心区别对比表
表格
对比项 Comparable Comparator 包路径 java.lang java.util 方法 compareTo(T o) compare(T o1, T o2) 实现位置 实体类自身 外部类 / Lambda / 匿名类 排序名称 自然排序 定制排序 灵活性 单一排序 多种排序随意切换 侵入性 侵入式(改原类) 无侵入 TreeMap 使用 key 实现即可 构造时传入 优先级 低 高(Comparator 会覆盖 Comparable) 四、记忆口诀
Comparable:自己跟别人比(自己实现)
Comparator:找个裁判比(外部指定)
TreeMap 优先用 Comparator,没有才用 Comparable
五、一句话面试标准答案
TreeMap 底层是红黑树 ,根据 key 进行排序。排序规则优先使用构造时传入的 Comparator ,若没有则要求 key 实现 Comparable 接口 。Comparable 是内部比较器 ,在实体类中实现;Comparator 是外部比较器,更灵活,优先级更高。
start () 和 run () 区别
一句话总结:start () 是启动线程,真正开启新线程;run () 只是普通方法调用,还是在当前线程执行。
start()
作用 :启动一个新线程,让线程进入就绪状态
底层 :调用本地方法
start0(),由 JVM 新建线程,再自动调用run()特点:
真正实现多线程,主线程和子线程交替执行
一个线程只能调用一次 start () ,多次抛
IllegalThreadStateException结果:两条独立线程并行运行
run()
作用 :只是 Thread 里的一个普通方法
特点:
直接调用
run(),不会开启新线程代码还是运行在当前调用线程(比如主线程)
可以多次调用
结果:同步执行,没有多线程效果
- 代码对比一眼懂
java
运行
Thread t = new Thread(() -> { System.out.println(Thread.currentThread().getName()); }); t.start(); // 输出:Thread-0 → 新线程 t.run(); // 输出:main → 主线程里普通调用
- 核心区别表
表格
对比项 start() run() 本质 启动线程的方法 业务逻辑的普通方法 是否新建线程 是 否 执行效果 多线程并行 同步串行执行 调用次数限制 只能 1 次 无限制 底层 JVM 本地方法,自动调用 run () 直接调用,无线程切换
- 极简记忆
start ():开新线程,真正多线程
run ():普通方法,还是单线程
想启动线程必须用 start(),不能直接调 run ()
sleep () 和 wait () 区别
一句话核心:sleep 是线程类方法,抱着锁睡觉;wait 是 Object 方法,释放锁等待。
- 所属类不同
sleep() → Thread 类的静态方法
wait() → Object 类的方法(所有对象都有)
- 是否释放锁(最核心区别)
sleep() :不释放锁抱着锁睡觉,别的线程还是拿不到锁。
wait() :释放锁进入等待时会把锁交出去,让别的线程可以进入同步块。
- 使用位置与前提
sleep() :任何地方都能用,不需要在同步代码块里。
wait() / notify() / notifyAll() :必须在 synchronized 同步代码块中,否则抛 IllegalMonitorStateException。
- 唤醒方式
sleep(long time) :时间到自动唤醒,不需要别人通知。
wait() :必须等别的线程调用 notify() / notifyAll() 才能唤醒;也可以用
wait(time)超时自动醒。
- 线程状态
sleep → TIMED_WAITING
wait → WAITING 或 TIMED_WAITING
- 用法用途
sleep:暂停当前线程一段时间,用于延时、定时。
wait:线程间通信、等待条件满足,配合 notify 使用。
一张表秒懂
表格
对比项 sleep() wait() 所属类 Thread Object 锁 不释放锁 释放锁 同步环境 不需要 必须在 synchronized 内 唤醒 时间到自动醒 需要 notify ()/ 超时 用途 线程休眠延时 线程间通信协作 极简记忆口诀
sleep 抱着锁睡觉,谁也别想进
wait 放开锁等待,等别人叫醒
sleep 是线程的,wait 是对象的
synchronized 底层原理 & 锁升级过程(Java 面试压轴题)
一句话总结:synchronized 是对象锁,底层靠 Mark Word 实现; 锁升级顺序:无锁 → 偏向锁 → 轻量级锁(自旋) → 重量级锁(阻塞),只会升级不会降级。
一、核心基础:对象头(Mark Word)
Java 里每个对象都有对象头,里面存了:
Mark Word :存哈希码、分代年龄、锁状态(锁升级全靠它)
类型指针
synchronized加锁,本质就是修改对象头里的 Mark Word。二、锁升级 4 个阶段(必考流程)
- 无锁状态
没有线程竞争
对象头正常存储哈希码
- 偏向锁(Biased Lock)
场景:一个线程反复加锁,无竞争
锁会偏向第一个获取锁的线程
下次该线程再进来,不需要 CAS,直接通过
成本极低,几乎无开销
触发升级 :出现第二个线程竞争 → 偏向锁撤销 → 升级为轻量级锁
- 轻量级锁(Lightweight Lock)
场景:少量线程竞争,时间很短
采用 CAS 自旋 尝试获取锁
不阻塞线程,不进入内核态
消耗 CPU,但响应快
触发升级:
自旋次数超限
线程等待时间长→ 升级为重量级锁
- 重量级锁(Heavyweight Lock)
场景:高并发、竞争激烈、持有时间长
底层用 OS 互斥量(Monitor)
没拿到锁的线程 进入阻塞队列
CPU 开销低,但线程切换成本高(慢)
三、锁升级完整流程(背这个)
无锁
单线程反复用 → 偏向锁
出现竞争 → 撤销偏向锁 → 轻量级锁(CAS 自旋)
自旋失败 / 竞争激烈 → 重量级锁(阻塞等待)
四、三种锁对比(面试必背)
表格
锁 优点 缺点 适用场景 偏向锁 加解锁无消耗,最快 多线程竞争时撤销开销大 单线程反复同步 轻量级锁 不阻塞,响应快 自旋消耗 CPU 低竞争、短持有 重量级锁 不消耗 CPU,线程阻塞 线程阻塞、上下文切换慢 高并发、长持有 五、底层原理:Monitor(监视器锁)
重量级锁底层依赖
Monitor:
每个对象有一个 Monitor
包含:_owner(持有锁线程)、WaitSet、EntryList
synchronized就是让线程成为 Monitor 的 ownerJVM 指令:
monitorenter 加锁
monitorexit 解锁
六、一句话面试标准答案
synchronized 基于对象头 Mark Word 实现,锁会自动升级: 无锁 → 偏向锁 → 轻量级锁(CAS 自旋) → 重量级锁(Monitor 阻塞)。 偏向锁适合单线程,轻量级锁适合短竞争,重量级锁适合高并发。
volatile 三大作用
一句话背下来:volatile 保证可见性、禁止指令重排序,但不保证原子性。
- 保证可见性
多个线程操作同一个变量时,一个线程改了,其他线程能立刻看到最新值
原理:
写操作:直接刷新到主内存
读操作:直接从主内存读取,不使用工作内存缓存
解决:多线程下变量 "看不见" 导致的数据不一致
- 禁止指令重排序(有序性)
编译器 / CPU 为了优化,可能会打乱指令执行顺序
volatile 会加内存屏障:
屏障前的代码不能跑到屏障后
屏障后的代码不能跑到屏障前
典型应用:DCL 单例必须加 volatile,防止半初始化对象问题
- 不保证原子性(重点坑点)
volatile 只保证单次读 / 写原子,但复合操作不原子:
i++本质是:读 → +1 → 写多线程下会出现丢失更新
解决原子性:
synchronized
Lock
AtomicInteger等原子类总结(面试直接说)
可见性:一个线程改,其他线程立刻可见
有序性:禁止指令重排,保证执行顺序
不保证原子性:复合操作(如 i++)仍线程不安全
小对比
volatile:轻量级,解决可见性 + 有序性,无锁
synchronized :重量级别,保证原子性 + 可见性 + 有序性
线程池 7 大参数
ThreadPoolExecutor 构造方法的 7 个核心参数:
corePoolSize核心线程数(常驻线程,即使空闲也不销毁)
maximumPoolSize最大线程数(核心线程 + 非核心线程总数)
keepAliveTime非核心线程空闲超时时间(超时就被回收)
TimeUnit unit超时时间单位(秒、毫秒等)
BlockingQueue<Runnable> workQueue任务阻塞队列(任务进来先放这里排队)
ThreadFactory threadFactory线程工厂(用来创建新线程,可自定义名称、优先级)
RejectedExecutionHandler handler拒绝策略(队列满 + 线程数达到最大时的处理策略)
一句话串起来
线程池先开 corePoolSize 个常驻线程;任务多了放进 workQueue 排队;队列满了再开新线程直到 maximumPoolSize ;多余线程空闲 keepAliveTime 后销毁;全都满了执行 handler 拒绝策略。
常见拒绝策略(顺带记)
AbortPolicy:直接抛异常(默认)
CallerRunsPolicy:让提交任务的线程自己执行
DiscardPolicy:直接丢弃当前任务
DiscardOldestPolicy:丢弃队列最老任务,再尝试提交
死锁的四个必要条件 + 避免方案(面试必背)
一、死锁的四个必要条件
这四个条件必须同时满足,才会产生死锁,缺一不可。
互斥条件 资源同一时刻只能被一个线程占有,其他线程必须等待。
请求与保持条件 线程已经持有至少一个资源,又去请求新资源 ,且不释放已持有的资源。
不可剥夺条件 线程获得的资源,只能自己用完释放,不能被其他线程强行抢走。
循环等待条件 多个线程形成环形资源等待链:A 等 B,B 等 C,C 等 A。
二、如何避免死锁
破坏任意一个条件即可避免死锁。
破坏 "请求与保持"
- 线程一次性申请所有需要的资源,要么全拿到,要么全不拿。
破坏 "不可剥夺"
- 申请不到新资源时,主动释放已持有的资源,之后再重试。
破坏 "循环等待"(最常用、最实用)
统一资源获取顺序 所有线程都按固定顺序申请锁(比如先锁 A,再锁 B,不许反过来)。
从根源上杜绝环路。
尽量避免 "互斥"
- 使用无锁结构:
volatile、原子类、ConcurrentHashMap、CAS 等。三、一句话面试标准答案
死锁产生必须同时满足:互斥、请求与保持、不可剥夺、循环等待 四个条件。避免死锁最常用的方式是破坏循环等待,统一加锁顺序;也可以一次性申请所有资源,或主动释放已占资源。
JVM 内存结构(运行时数据区)面试标准答案
一句话总结:线程私有:程序计数器、虚拟机栈、本地方法栈; 线程共享:堆、方法区; 直接内存(堆外内存)不算运行时数据区,但常用。
一、线程私有区域(每个线程一份)
- 程序计数器(PC Register)
记录当前线程执行的字节码行号
线程切换后能恢复到正确位置
唯一不会 OOM 的区域
- 虚拟机栈(VM Stack)
存储栈帧:局部变量表、操作数栈、方法出口等
执行一个方法就创建一个栈帧,方法结束出栈
异常:
栈深度超限 →
StackOverflowError无法申请栈空间 →
OutOfMemoryError
- 本地方法栈(Native Method Stack)
为
native方法服务作用同虚拟机栈,只是针对本地方法
二、线程共享区域(所有线程共用)
- 堆(Heap)------ 最大一块
存放几乎所有对象实例、数组
GC 主要管理区域
分代:新生代(Eden + S0 + S1)+ 老年代
异常:
OutOfMemoryError: Java heap space
- 方法区(Method Area)
存储类信息、常量、静态变量、即时编译代码
JDK8 以前:永久代(PermGen)
JDK8 及以后:元空间(Metaspace,使用本地内存)
运行时常量池也在方法区
三、直接内存(Direct Memory)
不属于 JVM 运行时数据区
NIO 使用,堆外内存,不受 GC 直接管理
申请过多也会 OOM
四、一张表秒记
表格
区域 线程 存储内容 常见异常 程序计数器 私有 字节码行号 无 虚拟机栈 私有 栈帧、局部变量 SOE、OOM 本地方法栈 私有 Native 方法栈 SOE、OOM 堆 共享 对象实例 OOM 方法区 共享 类元信息、常量、静态变量 OOM 极简记忆口诀
栈管运行,堆管对象,方法区存类信息; 计数器指路,本地栈管 native; 堆栈共享私有分清楚。
3 大垃圾回收算法(标记清除 / 复制 / 标记整理)
一句话总结:标记清除会碎片、复制算法空一半、标记整理无碎片但慢。
- 标记 - 清除算法(Mark-Sweep)
流程
标记:遍历所有对象,标记存活对象
清除:统一回收未标记的垃圾对象
优点
- 简单,不需要额外大量空间
缺点
产生大量内存碎片
空间不连续,大对象无法分配
效率一般,标记和清除都要遍历
适用
存活对象较多、垃圾较少的区域
老年代早期回收器(CMS 基础)
- 复制算法(Copying)
流程
内存分成大小相等两块:From 和 To
只使用 From 区
垃圾回收时,把存活对象复制到 To 区
清空 From,交换 From/To 角色
优点
简单高效
无内存碎片
适合存活对象少的场景
缺点
- 浪费一半内存
适用
新生代(对象朝生夕死,存活少)
Eden + S0 + S1 就是用这种思路
- 标记 - 整理算法(Mark-Compact)
流程
标记:标记存活对象
整理 :把存活对象向一端移动压缩
清理端外所有垃圾
优点
无内存碎片
能充分利用内存
缺点
- 效率最低(需要移动对象、更新引用)
适用
- 老年代(存活对象多、垃圾少、不能浪费空间)
对比表(面试直接背)
表格
算法 优点 缺点 适用区域 标记 - 清除 简单、不浪费空间 内存碎片、效率一般 老年代(CMS) 复制 无碎片、速度快 浪费一半内存 新生代 标记 - 整理 无碎片、内存利用率高 效率低、要移动对象 老年代 极简记忆口诀
标记清除:简单但碎
复制算法:快但空一半
标记整理:整齐但慢
GC 如何判断对象死亡:可达性分析(面试标准答案)
一句话总结:以 GC Roots 为起点,向下搜索引用链,对象不可达就判定为垃圾,可以回收。
- 什么是可达性分析
从一系列 GC Roots 根对象开始
沿着引用链(强引用、软引用、弱引用等)遍历
能被遍历到 → 可达 → 存活
遍历不到 → 不可达 → 死亡 → 可被 GC 回收
- GC Roots 有哪些(必须背)
可以作为 GC Roots 的对象主要有:
虚拟机栈中引用的对象(局部变量表)
本地方法栈中引用的对象(Native 方法)
方法区中静态属性引用的对象(static 变量)
方法区中常量引用的对象(字符串常量池)
被同步锁持有的对象(synchronized 锁住的对象)
简单记:栈里的、静态的、常量的、锁持有的,都是根。
对象死亡的完整过程(不是一次判死刑)
第一次可达性分析不可达
判断是否有必要执行
finalize()
没覆盖 / 已执行过 → 直接判死
有且未执行 → 放入 F-Queue 等待执行
finalize()中如果对象重新与 GC Roots 建立引用 → 可以自救,活下来否则,真正被判定死亡,等待回收
注意:finalize () 不推荐用,不确定、性能差,Java 9 已废弃。
- 和引用计数法对比(顺带考点)
引用计数法 :对象被引用一次 + 1,取消引用 - 1,为 0 回收→ 简单,但无法解决循环引用
可达性分析:解决循环引用,是 Java 主流使用方式
一句话面试背诵版
JVM 使用可达性分析算法 判断对象是否存活,以 GC Roots 为起点遍历引用链,不可达对象判定为可回收。GC Roots 包括虚拟机栈、本地方法栈、静态变量、常量、锁对象等。对象可在
finalize()中自救一次,但该方法不推荐使用。
CMS vs G1 垃圾收集器(面试必背)
一句话总结:CMS 是追求低延迟的并发回收器,但有碎片;G1 是平衡型,兼顾吞吐量与延迟,是目前主流。
一、CMS (Concurrent Mark Sweep)
全称
Concurrent Mark Sweep(并发标记清除)
核心特点
**口号 :最低停顿时间(Low Latency GC)
年代 :主要用在 新生代(ParNew)+ 老年代
算法 :老年代采用 标记 - 清除 算法(会产生内存碎片)
回收流程(四步走)
初始标记 (Stop The World):标记 GC Roots 能直接关联的对象,极快。
并发标记 :与用户线程同时运行,遍历整个引用链。
重新标记 (STW):修正并发标记期间用户线程变动的引用,比初始标记慢,但比 Full GC 快。
并发清除 :与用户线程同时运行,直接清除垃圾对象。
优缺点
优点 :并发收集、低停顿(用户感觉不到卡顿)。
缺点:
产生大量内存碎片(纯清除算法)。
占用 CPU 资源(并发阶段占用一部分线程)。
无法处理浮动垃圾(并发清除时产生的新垃圾)。
老年代碎片过多导致大对象分配失败,需降级使用 Serial Old 收集器(会产生长时间 STW)。
适用场景
响应速度优先的应用(如电商交易、门户),但 JDK 9 已被标记为废弃,不再推荐使用。
二、G1 (Garbage-First)
全称
Garbage-First Garbage Collector
核心特点
口号 :兼顾吞吐量与延迟,面向服务端应用。
布局 :不再区分物理新生代 / 老年代,而是将堆划分为多个 Region(区域)。
算法 :结合了 复制算法 (Eden/Survivor)和 标记 - 整理算法(老年代)。
核心逻辑 :维护一个 Remembered Set,记录其他 Region 对本 Region 的引用,避免全堆扫描。
回收流程(两步走)
年轻代收集(Young GC):回收 Eden + Survivor 区域。
混合收集(Mixed GC):
不仅回收整个年轻代,还根据 预测停顿时间,回收价值最高(垃圾最多)的部分老年代 Region。
不搞 Full GC,而是做 Full GC 单线程版本(效率较低)。
优缺点
优点:
空间整合 :整体看是标记 - 整理,局部看是复制,无内存碎片。
可预测的停顿:可以指定最大停顿时间(MaxGCPauseMillis)。
平衡:兼顾高吞吐量和低延迟。
缺点:
实现复杂,负载较高。
起步阶段收集效率不如 CMS。
适用场景
大堆、高并发、服务端应用(如微服务、大数据处理)。JDK 9 后成为默认垃圾收集器。
三、核心对比表
表格
特性 CMS G1 全称 Concurrent Mark Sweep Garbage-First 设计目标 极致低延迟(响应快) 平衡(延迟 + 吞吐量) 堆结构 物理分代(Eden/S0/S1/Old) 逻辑分代(Region 集合) 老年代算法 标记 - 清除(有碎片) 标记 - 整理(无碎片) 内存碎片 严重(需 Full GC 整理) 无(混合回收整理) 可预测停顿 无(只能保证短,不可控) 有(可指定最大停顿时间) 底层算法 标记 - 清除 复制 + 标记 - 整理 状态 废弃(JDK9+) 主流(JDK9 + 默认) 四、极简记忆口诀
CMS :并发低延迟,清除有碎片,CPU 也占线,废弃不推荐。
G1 :Region 分块,兼顾快与稳,整理无碎片,主流最常用。
类加载过程 + 双亲委派模型(Java 面试必背)
一、类加载过程(5 步)
加载 → 验证 → 准备 → 解析 → 初始化
- 加载(Loading)
找到
.class文件,读取二进制流在方法区生成
Class对象在堆中生成对应
java.lang.Class实例
- 验证(Verification)
校验文件格式、元数据、字节码、符号引用
保证类符合 JVM 规范,不会危害虚拟机安全
- 准备(Preparation)
为静态变量分配内存
设置默认初始值 (
int=0、boolean=false、reference=null)注意:这里不执行代码,只赋默认值
- 解析(Resolution)
将符号引用转为直接引用
主要针对:类、接口、字段、方法
- 初始化(Initialization)
执行静态代码块
给静态变量真正赋值
只有主动使用时才触发(new、调用静态方法等)
二、双亲委派模型(Parents Delegation Model)
三类类加载器(从高到低)
Bootstrap ClassLoader(启动类加载器)
加载
JAVA_HOME/jre/lib核心类(rt.jar 等)C++ 实现,最顶层
Extension ClassLoader(扩展类加载器)
- 加载
jre/lib/ext下的类Application ClassLoader(应用类加载器)
- 加载 classpath 下我们自己写的类
工作流程
类加载器收到加载请求
先委托给父加载器,一直向上传到启动类加载器
父加载器无法加载,才由子加载器自己加载
一句话:儿子先找爹,爹不行儿子才干。
- 为什么要双亲委派?
沙箱安全 :防止核心类被恶意替换(比如自己写一个
java.lang.String不会被加载)保证类的唯一性:同一个类只会被加载一次,避免重复加载
- 如何打破?
继承
ClassLoader重写
loadClass()方法,不执行双亲委派逻辑典型:Tomcat、JDBC 等都打破了双亲委派
极简面试背诵版
类加载五步:加载 → 验证 → 准备 → 解析 → 初始化
双亲委派:类加载时优先交给父加载器,父加载器加载失败才自己加载
作用:保证核心类安全、类唯一、不被篡改
强引用、软引用、弱引用区别(面试必背)
一句话总结:强引用永不回收,软引用内存不够才回收,弱引用下次 GC 就回收。
- 强引用(Strong Reference)
默认引用 :
Object obj = new Object();特点 :只要可达,GC 永远不会回收即使 OOM 也不回收
场景:99% 日常业务对象
结果:内存不足直接抛 OOM
- 软引用(SoftReference)
写法:
SoftReference<Object> ref = new SoftReference<>(obj);特点 :内存充足 → 不回收 内存不足(即将 OOM)→ 才回收
场景:缓存(图片缓存、网页缓存)
结果:尽量保留,不到万不得已不回收
- 弱引用(WeakReference)
写法:
WeakReference<Object> ref = new WeakReference<>(obj);特点 :只要发生 GC,就会被回收,不管内存够不够
场景 :容器里的辅助对象、避免内存泄漏典型:
ThreadLocal内部使用结果:生命周期很短,一 GC 就没
- 虚引用(PhantomReference,顺带记)
必须配合引用队列使用
等于没引用,任何时候都可能被回收
作用:对象回收时收到通知,做资源释放
几乎不用
对比表(直接背)
表格
引用类型 回收时机 用途 强引用 永不回收(OOM 也不) 普通对象 软引用 内存不足时回收 缓存 弱引用 每次 GC 必回收 防止内存泄漏、ThreadLocal 虚引用 随时回收 资源释放监听 极简口诀
强引用:宁死不回收
软引用:不够才回收
弱引用:GC 就回收
OOM 常见原因(面试高频,直接背)
OOM 本质:内存不够用 + 回收不了,常见就这 6 大类:
- 堆溢出(Java heap space)最常见
原因
集合对象只加不删,无限增长(static Map/List 缓存爆炸)
死循环创建对象
大对象一次性加载(一次性查全表数据到 List)
线程池任务堆积,对象引用无法释放
典型场景:批量查询未分页、缓存未过期
- 栈溢出 StackOverflowError /unable to create new native thread
StackOverflowError
- 递归太深 / 死递归
unable to create new native thread
线程创建太多,栈内存耗尽
线程池无限制、手动无限 new Thread
- 方法区 / 元空间溢出(Metaspace / PermGen space)
原因
大量动态生成类(CGLib 代理、反射、动态代理滥用)
大量 JSP、自定义 ClassLoader 加载过多类
JDK8+ 表现:Metaspace OOM
- 直接内存溢出(Direct buffer memory)
NIO、Netty 等使用堆外内存
申请未释放、分配过大
不受堆大小控制,但受本机内存限制
- GC overhead limit exceeded
GC 回收效率极低
花大量时间 GC,却只回收一点点内存
典型:内存快耗尽,对象几乎都存活
- 内存泄漏导致的 OOM(隐蔽但高发)
ThreadLocal 用完没 remove
静态集合持有长生命周期对象
IO / 连接未关闭(流、数据库连接、HttpClient)
内部类持有外部类引用(匿名内部类 + 长生命周期容器)
缓存没过期、没淘汰策略
一句话总结版
堆 OOM:对象太多、只增不减、大对象、死循环
栈 OOM:递归太深、线程开太多
元空间 OOM:动态类太多、CGLib 滥用
直接内存 OOM:NIO 堆外内存没释放
GC 超限:内存几乎满了,GC 救不回来
内存泄漏:该释放的对象被一直拿着,GC 收不回
IOC、AOP 是什么(Spring 核心,面试极简标准答案)
一、IOC(Inversion of Control)控制反转
一句话:把创建对象、管理对象依赖的控制权,从代码交给 Spring 容器。
通俗理解
以前:
自己
new UserService()自己
setUserDao()控制权在代码手里
现在:
类上加
@Service、@ComponentSpring 自动创建对象、自动注入依赖
控制权交给 Spring 容器
核心作用
解耦:对象之间不用硬编码依赖
统一管理对象生命周期
方便替换实现类
关键词
控制反转、依赖注入(DI)、容器管理 Bean
二、AOP(Aspect Oriented Programming)面向切面编程
一句话:在不修改原有业务代码的前提下,统一增强横向逻辑(日志、事务、权限)。
通俗理解
很多方法都需要:
日志打印
事务开启 / 提交
权限校验
性能监控
如果每个方法都写一遍,代码重复、难维护。
AOP 把这些通用逻辑抽成 "切面",统一织入到目标方法前后 / 异常时。
核心术语(简单记)
切面(Aspect):通用功能模块(日志、事务)
通知(Advice):什么时候执行(前、后、异常、环绕)
切点(Pointcut):对哪些方法生效
连接点(JoinPoint):可以被拦截的方法
典型应用
@Transactional声明式事务统一接口日志
全局权限校验
接口耗时监控
三、一句话总结
IOC:让 Spring 帮你管对象、注入依赖 → 解耦
AOP:不改业务代码,统一加通用功能 → 简化横向逻辑
JDK 动态代理 vs CGLIB 动态代理(面试必背)
一句话总结:JDK 代理面向接口,用反射;CGLIB 继承类,用字节码生成,性能更好。
- 核心区别
JDK 动态代理
基于接口
被代理类必须实现接口
利用
java.lang.reflect.Proxy+InvocationHandler底层是反射调用
CGLIB 动态代理
基于继承
被代理类不需要接口
通过继承目标类,生成子类重写方法
底层是字节码生成(ASM 框架),直接调用方法
- 能否代理没有接口的类
JDK 代理:不能必须有接口,否则无法生成代理类。
CGLIB:能直接继承类就行,有无接口都可以。
- 性能
JDK 代理:早期版本反射较慢,JDK8 以后反射大幅优化,但仍略逊于 CGLIB。
CGLIB :生成字节码,直接方法调用 ,执行速度更快。
- 方法限制
JDK 代理:接口里的方法都能代理,无限制。
CGLIB :不能代理 final 类、final 方法、static 方法(因为要继承重写,final 不能被重写)
- Spring 中使用规则
目标类实现了接口 → 默认用 JDK 代理
目标类没有接口 → 自动用 CGLIB
可以通过配置强制 Spring 全部使用 CGLIB:
java
运行
@EnableAspectJAutoProxy(proxyTargetClass = true)对比表(面试直接背)
表格
对比项 JDK 动态代理 CGLIB 动态代理 实现方式 反射 + 接口 继承 + 字节码生成 是否需要接口 必须有接口 不需要接口 代理原理 生成接口实现类 生成目标类的子类 能否代理 final 可以 不能 性能 一般(反射) 更快(直接调用) Spring 默认 有接口时使用 无接口时使用 极简口诀
JDK 代理:靠接口,用反射,不能代理无接口类
CGLIB:靠继承,字节码,速度快,不能代理 final
Spring Bean 生命周期(面试标准版,一步不差)
一句话总结:实例化 → 属性填充 → 初始化 → 使用 → 销毁
- 实例化(Instantiation)
加载 Bean 定义,通过构造方法创建对象
此时对象还是空壳,属性未赋值
- 属性填充(Populate Properties)
执行依赖注入 :
@Autowired、@Value、XML 注入等给 Bean 的成员变量赋值
- 初始化前置处理(BeanPostProcessor#postProcessBeforeInitialization)
执行后置处理器的前置方法
常见:
@PostConstruct就是在这里执行
初始化(Initialization)
执行
@PostConstruct标注的方法执行
InitializingBean#afterPropertiesSet()执行
init-method或@Bean(initMethod = ...)初始化后置处理(BeanPostProcessor#postProcessAfterInitialization)
执行后置处理器的后置方法
AOP 代理在这里生成
- Bean 正常使用
- 业务调用、方法执行
- 销毁(Destruction)
容器关闭时执行:
@PreDestroy标注的方法
DisposableBean#destroy()
destroy-method或@Bean(destroyMethod = ...)极简流程(背诵版)
实例化(new)
属性注入(DI)
初始化前(前置处理器)
初始化(@PostConstruct → afterPropertiesSet → initMethod)
初始化后(后置处理器,生成 AOP 代理)
使用中
销毁(@PreDestroy → destroy → destroyMethod)
超短口诀(面试秒答)
实例化 → 赋值 → 初始化 → 用 → 销毁 前后各包一层 BeanPostProcessor。
@Autowired 和 @Resource 区别(面试必背)
一句话总结:@Autowired 按类型自动装配;@Resource 默认按名称,名称找不到再按类型。
- 来源不同
@Autowired :Spring 提供的注解
@Resource:**Java 标准(JSR-250)** 注解,通用性更强
- 装配顺序(核心区别)
@Autowired
默认按类型(byType)装配
类型唯一 → 直接注入
同一类型多个 Bean → 报错
配合 @Qualifier("beanName") 指定名称
@Resource
先按名称(byName)装配
名称匹配不到 → 再按类型(byType)
可以直接指定
name或type:java
运行
@Resource(name = "userService")
- 是否支持 required
@Autowired :支持
required = false,允许为 null@Resource :不支持 required 属性
- 装配对象范围
@Autowired:可注入构造器、成员变量、setter、方法参数
@Resource :只能用在字段、setter 方法上
- Spring 依赖注入优先级
@Resource 按名字匹配更精确,不容易冲突
@Autowired 按类型,配合 @Qualifier 也能精确匹配
对比表(直接背)
表格
对比项 @Autowired @Resource 来源 Spring Java JSR-250 装配顺序 先按类型 先按名称,再按类型 指定名称 需要 @Qualifier 直接用 name 属性 required 支持 不支持 通用性 仅限 Spring 通用 极简口诀
@Autowired:Spring 出身,按类型,要名字加 Qualifier
@Resource:Java 标准,先名后型,更省心
Spring 事务传播行为 + 隔离级别(面试必背完整版)
一、事务传播行为(7 种,重点记 4 个)
控制被调用方法的事务 与调用方事务的关系。
- REQUIRED(默认)
有事务就加入,没有就新建
最常用,业务层默认
- REQUIRES_NEW
新建独立事务,挂起外部事务
内部事务提交 / 回滚不影响外部
适用于:日志、通知必须成功,不跟主业务一起回滚
- NESTED
嵌套事务,是外部事务的子事务
外部回滚 → 内部一定回滚
内部回滚 → 只回滚到保存点,不影响外部
仅支持 JDBC,不支持 JPA/Hibernate
- SUPPORTS
- 有事务就支持,没事务就以非事务运行
- MANDATORY
- 强制要求已有事务,否则抛异常
- NOT_SUPPORTED
- 始终非事务运行,挂起当前事务
- NEVER
- 强制非事务,有事务就抛异常
二、事务隔离级别(4 种标准 + 默认)
- DEFAULT(默认)
使用数据库默认隔离级别
MySQL:REPEATABLE_READ
Oracle:READ_COMMITTED
- READ_UNCOMMITTED
读未提交
问题:脏读、不可重复读、幻读
- READ_COMMITTED
读已提交
解决:脏读
仍有:不可重复读、幻读
- REPEATABLE_READ
可重复读
解决:脏读、不可重复读
仍有:幻读(MySQL InnoDB 用间隙锁很大程度规避幻读)
- SERIALIZABLE
串行化
解决所有问题,但性能极低
三、3 大读问题(一句话区分)
脏读 :读到未提交的数据
不可重复读 :同一事务内,两次查询结果不一样(被 update)
幻读 :同一事务内,查询到新增 / 删除的行(insert/delete)
四、极简背诵版
传播行为(重点)
REQUIRED:有就加,没有就建(默认)
REQUIRES_NEW:新开独立事务
NESTED:嵌套子事务
SUPPORTS:有就用,没有就算
隔离级别
读未提交:脏读、不可重复读、幻读都有
读已提交:解决脏读
可重复读:解决脏读 + 不可重复读(MySQL 默认)
串行化:全解决,性能差
SpringMVC 核心执行流程(面试标准 8 步,背这版就够)
一句话总结:用户请求 → 前端控制器分发 → 找映射 → 调处理器 → 执行方法 → 渲染视图 → 响应返回
- 用户发送请求
请求到达 DispatcherServlet(前端控制器,核心总入口)
- DispatcherServlet 调用 HandlerMapping
根据请求 URL 找到对应的 Controller 方法 得到一个 HandlerExecutionChain(处理器 + 拦截器链)
- DispatcherServlet 调用 HandlerAdapter
根据 Handler 找到对应适配器,统一执行 Controller 方法
- 执行拦截器 preHandle
按顺序执行所有 HandlerInterceptor#preHandle()
- 执行 Controller 业务方法
调用真正的接口方法,返回 ModelAndView(或直接返回 JSON)
- 执行拦截器 postHandle
执行 HandlerInterceptor#postHandle()
- 处理视图渲染
有视图:调用 ViewResolver 解析视图,渲染页面
返回 JSON:直接通过消息转换器写出 JSON,不走视图解析
- 执行拦截器 afterCompletion
最后执行 afterCompletion(),并把响应返回给用户
极简背诵版(8 步浓缩)
请求 → DispatcherServlet
找映射:HandlerMapping 找 Controller
找适配器:HandlerAdapter
执行:拦截器 preHandle
执行:Controller 方法
执行:拦截器 postHandle
渲染:ViewResolver 视图解析
最终:拦截器 afterCompletion → 返回响应
超简口诀(面试秒答)
前端控制器接单 → 找映射 → 找适配器 → 过拦截器 → 执行业务 → 渲染视图 → 返回
SpringBoot 自动配置原理(面试标准答案,精简好背)
一句话总结:SpringBoot 通过 @EnableAutoConfiguration 加载 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里的配置类,再按条件判断是否生效,实现自动装配。
- 核心注解:@SpringBootApplication
它是一个组合注解,本质包含 3 个:
@SpringBootConfiguration标记当前类为配置类。
@ComponentScan自动扫描包下的 Bean。
@EnableAutoConfiguration 自动配置的核心开关。
@EnableAutoConfiguration 做了什么
借助
@Import(AutoConfigurationImportSelector.class)读取类路径下:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
里面列出了所有自动配置类(如 DataSourceAutoConfiguration、WebMvcAutoConfiguration...)
- 自动配置类按条件生效
每个配置类上都有条件注解,满足才生效:
@ConditionalOnClass:类路径存在某个类
@ConditionalOnBean:容器中有某个 Bean
@ConditionalOnMissingBean:容器没有这个 Bean 才创建
@ConditionalOnProperty:配置文件有对应属性
@ConditionalOnWebApplication:是 Web 环境例子:你引入
spring-boot-starter-web→ 有 DispatcherServlet → WebMvcAutoConfiguration 生效。
- 配置绑定 @ConfigurationProperties
自动配置类从
application.yml/application.properties读取属性:
通过
@ConfigurationProperties(prefix = "spring.datasource")把配置绑定到 Bean 中,完成自动装配。
完整流程(极简背诵版)
启动类
@SpringBootApplication开启自动配置
@EnableAutoConfiguration读取自动配置类列表加载所有
xxxAutoConfiguration通过
@Conditional条件判断是否生效结合
@ConfigurationProperties读取配置向容器注册 Bean,完成自动配置
一句话超简版
SpringBoot 扫描约定位置的自动配置类,根据依赖和条件自动装配 Bean,无需手动配置。
事务 ACID(面试必背,一句话一个)
ACID 是事务的四大特性:原子性、一致性、隔离性、持久性。
- A --- Atomicity 原子性
事务是最小执行单元,不可再分
要么全部执行成功 ,要么全部回滚失败
不会出现只执行一半的情况
- C --- Consistency 一致性
事务执行前后,数据库的完整性约束不变
数据从一个合法状态 转到另一个合法状态
例如:转账前后总金额不变
- I --- Isolation 隔离性
多个并发事务之间互相隔离、互不干扰
一个事务的修改在提交前,对其他事务不可见
对应数据库的事务隔离级别
- D --- Durability 持久性
事务一旦提交成功 ,对数据的修改就是永久性的
即使宕机、重启,数据也不会丢失
极简口诀
A 原子不可切,C 一致总不变, I 隔离互不扰,D 持久永留存。
事务隔离级别 + 脏读 / 不可重复读 / 幻读(面试必背)
一、先搞懂 3 个问题
脏读 读到了别的事务未提交的数据,对方一回滚,数据就无效了。
不可重复读 同一个事务内,两次查询结果不一样(被别的事务 update 并提交了)。
幻读 同一个事务内,按条件查询,突然多了 / 少了行(被别的事务 insert/delete 并提交了)。
二、4 大隔离级别(从低到高)
- READ UNCOMMITTED(读未提交)
问题:脏读 ✅、不可重复读 ✅、幻读 ✅
性能最高,数据最不安全,基本不用
- READ COMMITTED(读已提交)
解决:脏读 ❌
仍有:不可重复读 ✅、幻读 ✅
Oracle 默认
- REPEATABLE READ(可重复读)
解决:脏读 ❌、不可重复读 ❌
仍有:幻读 ✅
MySQL InnoDB 默认(通过间隙锁很大程度缓解幻读)
- SERIALIZABLE(串行化)
解决:脏读 ❌、不可重复读 ❌、幻读 ❌
事务排队执行,性能极低
三、一张表背完
表格
隔离级别 脏读 不可重复读 幻读 读未提交 有 有 有 读已提交 无 有 有 可重复读 无 无 有 串行化 无 无 无 四、一句话记忆
脏读:读到未提交数据
不可重复读:同事务两次查询结果不同(update)
幻读:同事务查询行数变了(insert/delete)
MySQL 默认可重复读,Oracle 默认读已提交
索引为什么用 B+ 树(面试标准答案,精简好背)
一句话总结:B+ 树层级少、磁盘 IO 少、范围查询快、适合磁盘存储,是数据库索引的最优结构。
- 相比二叉树:层级更少,IO 更少
二叉树:一次只读 1 个节点,数据量大时层级极深
B+ 树:多路平衡查找树,一个节点存大量 key
同样 1000 万数据
二叉树:深度 ≈ 24 → 24 次 IO
B+ 树:深度 ≈ 3~4 → 仅几次 IOIO 越少,查询越快。
- 相比 B 树:只在叶子节点存数据,节点更 "胖"
B 树:每个节点都存 key + 数据
B+ 树:
非叶子节点只存 key,不存完整数据
同样大小磁盘页,能存更多 key
树更矮更胖,IO 更少
- 叶子节点形成有序链表,范围查询极快
B 树:范围查询需要回溯,效率低
B+ 树:
所有叶子节点用双向链表串联
范围查询(
>、<、between、like)只需遍历链表非常适合数据库
order by / group by
- 查询稳定,所有查询都走叶子节点
B+ 树任何数据查询,路径长度都一样
查询效率稳定、可预测
- 适合磁盘预读,充分利用局部性原理
磁盘顺序读写远快于随机 IO
B+ 树节点大小通常等于磁盘页(4KB/16KB)
加载一个节点就预读一整页,充分利用缓存
对比总结(直接背)
哈希索引 :等值查询快,但不支持范围查询
二叉树:层级深、IO 多
B 树:节点存数据,范围查询慢
B+ 树:
多路矮胖 → IO 少
叶子链表 → 范围查询快
节点只存 key → 更省空间
查询稳定 → 适合数据库索引
超简口诀
多路矮胖 IO 少,叶子链表范围好, 只存键值节点胖,磁盘友好索引王。
聚簇索引 vs 非聚簇索引(面试必背,一句话分清)
一句话:聚簇索引:叶子节点存整行数据,索引即数据; 非聚簇索引:叶子节点存主键 / 地址,需回表查数据。
一、聚簇索引(Clustered Index)
特点
索引结构和数据存放在一起 ,叶子节点就是完整的行数据
一个表只能有一个聚簇索引
数据物理存储顺序与索引顺序一致
InnoDB 表现
主键索引就是聚簇索引
没有主键时,用唯一键
都没有,自动生成隐藏 rowid
优点
按主键范围查询、排序非常快
查整行数据不需要回表,一次找到
缺点
插入、更新(尤其随机主键)会页分裂,影响性能
主键不宜过长、不宜随机(如 UUID)
二、非聚簇索引(Secondary Index / 二级索引)
特点
索引和数据分开存放
叶子节点不存整行数据,只存:
InnoDB:主键值
MyISAM:数据行的物理地址
一个表可以建多个非聚簇索引
查询过程
走二级索引找到主键
再通过主键走聚簇索引查完整数据→ 这一步叫 回表
优点
适合多条件查询,灵活建索引
不影响数据物理存储
缺点
可能需要回表,多一次 IO
查询比聚簇索引稍慢
三、一张表对比(直接背)
表格
聚簇索引 非聚簇索引(二级索引) 叶子节点 整行数据 主键 / 物理地址 数量 一个表只能一个 可以多个 回表 不需要 通常需要回表 范围查询 极快 一般 代表 InnoDB 主键索引 InnoDB 普通索引、MyISAM 索引 四、超简口诀
聚簇索引叶存行,主键索引就是它; 二级索引存主键,查到主键再回表。
最左前缀原则 & 索引失效场景(面试高频,直接背)
一、最左前缀原则
联合索引必须从左到右依次使用,跳过前面的列,后面的索引失效。
举例
索引:
idx(a, b, c)
where a=? → 命中索引
where a=? and b=? → 命中
where a=? and b=? and c=? → 命中
where b=? → 失效(跳过 a)
where a=? and c=? → 只命中 a,b 断了,c 失效
where a=? and b like 'x%' and c=? → a、b 命中,c 失效
一句话:断了中间的列,后面全都用不上。
二、索引失效场景(高频 10 条)
使用函数 / 运算
where abs(age) = 18
where age + 1 = 20隐式类型转换
- varchar 字段传数字:
where phone = 13800138000模糊查询以 % 开头
like '%abc'
like '%abc%'
like 'abc%'可以走索引使用!= / <> /is not null
- 会导致索引失效
or 连接条件
- 一边有索引,一边没有 → 整个索引失效
违反最左前缀
- 联合索引跳过前面字段
order by /group by 违反最左前缀
- 索引 (a,b,c),order by c → 无法用索引排序
not in / not exists
- 通常导致全表扫描
数据分布不均,优化器选择不走索引
- 如查询 80% 数据,MySQL 直接全表扫
使用 select *
- 不一定失效,但容易无法使用覆盖索引
三、极简总结版
最左前缀:联合索引必须从左往右用,断一个后面全废
索引失效:函数运算、隐式转换、首百分号、不等值违反最左、or 连接、not in、数据太分散
MVCC 简单通俗理解(好记不绕)
一句话:MVCC = 多版本并发控制,让读不加锁、写不加锁,读写不阻塞,实现高并发。
- MVCC 是干嘛的?
解决两个问题:
读数据时,别人在修改,不阻塞、也不乱
写数据时,别人在读,也不阻塞
实现:读不加锁、写不加锁,并发极高
- 核心思想:多版本
一条数据被更新时,不直接覆盖旧数据
而是生成一个新版本,旧版本保留
不同事务看到的是不同版本的数据
就像:同一行数据有好几个 "快照",每个人看自己那一版。
- 靠什么实现?(极简版)
每行数据隐藏 3 个字段:
trx_id:创建这条版本的事务 ID
roll_pointer:指向旧版本(undo log 里)
deleted_flag:标记是否删除
再配合两个东西:
Read View:事务启动时的 "快照",决定能看见哪些版本
undo log:存放历史版本,用来回滚 & 读旧数据
- 读怎么工作?
事务启动时生成一个 Read View,根据规则判断:
这个版本能不能看见
看不见就顺着
roll_pointer找旧版本直到找到可见版本为止
这就是快照读 (普通 select),完全无锁。
- 写怎么工作?
更新时生成新版本
对记录加行锁(只锁这一行)
旧版本保留在 undo log 里
其他事务读的是旧版本,不被阻塞
- 最终一句话总结
MVCC 就是给数据存多个历史版本,让读操作读旧版本、写操作新版本,读写互不阻塞,实现高并发且安全。
它是 MySQL InnoDB 实现 RC、RR 隔离级别的底层原理。
Redis 常用数据结构(面试高频,极简版)
Redis 常用 5 种基础结构 + 3 种高级结构,一句话记用途:
一、5 大基础数据结构
- String(字符串)
最简单:
key=value场景:缓存、计数器、分布式锁、session
- Hash(哈希)
类似
Map<String, Map<String, Object>>场景:用户信息、商品详情、对象缓存
- List(列表)
有序可重复,双向链表
场景:消息队列、排行榜、时间线、栈 / 队列
- Set(集合)
无序不重复
场景:去重、共同好友、点赞、抽奖
- ZSet(有序集合)
按 score 排序,不重复
场景:排行榜、延时任务、带权重排序
二、高级常用结构
- Bitmap(位图)
用 bit 存状态,极省空间
场景:签到、日活统计、布隆过滤器
- HyperLogLog
做基数统计(不精确但极省内存)
场景:UV、独立访客统计
- GEO(地理坐标)
存储经纬度
场景:附近的人、附近门店
超简口诀
String 存值,Hash 存对象, List 做队列,Set 去重,ZSet 排序。
缓存穿透、击穿、雪崩(最简单易懂版)
一句话区分:
穿透 :查不存在的数据,缓存没有,直接打库
击穿 :一个热点 Key 过期,瞬间大量请求打库
雪崩 :大量 Key 同时过期,或 Redis 宕机,数据库被打垮
- 缓存穿透
现象
查询根本不存在的数据
缓存不命中 → 每次都查数据库
恶意攻击:id=-1、id=999999999 疯狂请求
解决方案
缓存空值 :查不到也存
null,设置短过期布隆过滤器:过滤不存在的 key,直接返回
参数校验、接口限流
缓存击穿
现象
某个热点 Key 过期
同一瞬间大量并发请求进来
缓存不命中 → 全部请求数据库
解决方案
互斥锁(mutex lock):只让一个线程去查库重建缓存
热点 Key 永不过期
加随机过期时间,避免扎堆失效
缓存雪崩
现象
大量缓存同一时间过期 或 Redis 宕机
所有请求全部打到数据库
数据库压力暴增 → 宕机 → 连锁崩溃
解决方案
过期时间加随机值,避免批量同时过期
多级缓存:本地缓存 + Redis
Redis 集群、高可用(主从 + 哨兵)
限流、降级、熔断
缓存预热
极简口诀(面试秒答)
穿透:查不存在数据 → 布隆过滤器、缓存空值
击穿:热点 Key 过期 → 加锁、永不过期
雪崩:大量 Key 同时挂 → 随机过期、集群、限流
缓存与数据库一致性(面试最实用版本)
核心一句话:保证缓存与数据库一致,本质是:先更数据库,再删缓存;并且要避免并发错乱。
- 为什么不能 "更新缓存"?
线程 A 更新数据库
线程 B 又更新数据库
B 先更新缓存
A 后更新缓存→ 缓存变成旧数据,永久不一致
所以:不做更新缓存,只做删除缓存。
- 标准方案:先更数据库,再删缓存
流程:
更新数据库
删除缓存(而不是更新)
下次查询时,重新查询数据库并回填缓存
这是业界最常用、最推荐的方案。
- 极端并发不一致场景(面试必说)
读请求:缓存未命中 → 查数据库得到旧值
写请求:更新数据库 → 删除缓存
读请求:把旧值回填到缓存
→ 缓存是旧值,数据库是新值
概率极低,但存在。
解决方案:
延时双删:更库 → 删缓存 → 延迟几百 ms 再删一次
或使用 消息队列异步删缓存
- 强一致性方案(分布式事务)
要求极高一致性时用:
Canal 订阅 binlog
数据库变更后自动异步更新 / 删除缓存
最终一致性,业务大多足够用
- 一句话总结(面试背诵版)
优先方案:先更新数据库,再删除缓存
并发不一致问题用延时双删 或异步删缓存解决
不推荐更新缓存,避免脏数据
绝大多数业务保证最终一致性即可
说说JVM
首先是 JVM 内存结构主要分成五块:
- 堆:存放对象实例,是 GC 主要区域
- 虚拟机栈:每个线程一个,存局部变量、方法栈帧
- 方法区(元空间):存类信息、常量、静态变量
- 本地方法栈:给 native 方法用
- 程序计数器:记录当前线程执行到哪行指令
然后是垃圾回收 GC
- 判断对象死活用 可达性分析,GC Roots 做根节点
- 回收算法主要有:标记清除、标记复制、标记整理
- 堆分代:新生代 Eden、S0、S1,对象熬过多次 GC 进老年代
- 常见垃圾收集器:CMS、G1 现在用得比较多
类加载机制
- 过程:加载 → 验证 → 准备 → 解析 → 初始化
- 用 双亲委派模型,防止类重复加载、保证安全
- 类加载器:启动类加载器、扩展、应用类加载器
最后是常见问题 比如 OOM 异常 ,可能是堆溢出、栈溢出;还有 GC 频繁、Full GC 太多 这些线上调优问题。
JVM 是 Java 虚拟机,核心作用就是加载并执行字节码,实现 Java 跨平台。我一般从四个维度理解:
第一是内存模型 。JVM 把内存划分为堆、虚拟机栈、本地方法栈、方法区、程序计数器。其中堆是最大的区域,存放对象实例,也是 GC 主要工作的地方;栈是线程私有的,每个方法对应一个栈帧,存局部变量和返回地址。
第二是垃圾回收机制 。JVM 不用手动管理内存,通过可达性分析判断对象是否存活,再用标记复制、标记清除、标记整理算法回收垃圾。堆一般分为新生代和老年代,对象先在 Eden 区分配,熬过多次 Minor GC 会进入老年代,老年代满了触发 Full GC。常用收集器比如 CMS、G1,现在线上基本都用 G1 了。
第三是类加载机制 。流程是加载、验证、准备、解析、初始化。采用双亲委派模型 ,优先让父类加载器加载,好处是安全、防止类重复、保证核心类不被篡改。
第四是常见问题与调优。比如 OOM 一般是堆内存不足、内存泄漏或者创建对象太多没释放;GC 频繁多半是新生代设置不合理、大对象太多。平时排查主要看 GC 日志、堆 dump,定位是内存泄漏还是参数问题。