面试题目
一、Java 基础语法 & 关键字
1. JDK、JRE 和 JVM 的区别
- JVM(Java Virtual Machine):Java 虚拟机,负责执行字节码(
.class
)。提供类加载、字节码校验、内存管理(堆、方法区)、执行引擎、垃圾回收、线程调度等。是抽象的规范,具体由不同厂商实现(HotSpot、OpenJ9 等)。 - JRE(Java Runtime Environment):运行环境,包含 JVM、核心类库(rt.jar 或 modules)、以及运行时需要的其它资源。用于运行 Java 程序。
- JDK(Java Development Kit):开发工具包,包含 JRE + 开发工具(
javac
、jar
、javadoc
、调试工具等)。用于开发 Java 程序。
总结:JDK ⊃ JRE ⊃ JVM(JDK 包含 JRE,JRE 包含 JVM)。
2. Java 的跨平台原理是什么?
- Java 程序先由
javac
编译成与平台无关的 字节码(.class
)。字节码不是机器码,而是供 JVM 执行的指令集。 - 每个平台(Windows/Linux/macOS)有相应的 JVM 实现,JVM 把字节码翻译成该平台的机器指令(通过解释器、JIT 编译器等)。因此同一套
.class
可以在不同平台的 JVM 上运行 —— “一次编写,处处运行”(Write Once, Run Anywhere)。 - 其它保证跨平台的要点:标准库隐藏平台差异(I/O、线程等),需避免使用与平台绑定的本地代码(JNI)。
3. Java 的基本数据类型有哪些?各自的默认值?
8 种基本类型及默认值(成员变量/数组元素;局部变量无默认值必须初始化):
byte
:1 byte,默认值0
short
:2 bytes,默认值0
int
:4 bytes,默认值0
long
:8 bytes,默认值0L
float
:4 bytes,默认值0.0f
double
:8 bytes,默认值0.0d
char
:2 bytes(UTF-16 code unit),默认值'\u0000'
(即 0)boolean
:JVM 语义上用 1 bit 表示,默认值false
另外,引用类型(对象、数组等)的默认值是 null
。
4. byte
、short
、int
、long
各占多少字节?
byte
:1 字节(8 位)short
:2 字节(16 位)int
:4 字节(32 位)long
:8 字节(64 位)
(上述为 Java 语言规范定义,与平台无关。)
5. float
和 double
的区别?
- 精度与存储:
float
:32 位单精度浮点(IEEE 754),约 6~7 位有效数字。double
:64 位双精度浮点(IEEE 754),约 15~16 位有效数字。
- 精度更高的
double
更常用;float
用于节省内存或与特定 API(如图形库)兼容。 - 注意:浮点数有精度误差,不适合做精确货币计算(应使用
BigDecimal
)。 - 默认浮点字面量为
double
(例如1.2
是double
,写成1.2f
才是float
)。
6. char
类型占几个字节?能存汉字吗?
char
占 2 字节(16 位),表示 UTF-16 的一个 code unit。- 能否存汉字:多数常用汉字(位于基本多文种平面 BMP)可以用单个
char
表示(例如'你'
);但有些罕见汉字或 emoji 属于补充平面(Supplementary Planes),它们需要 两个 char(称为 surrogate pair) 来表示。 - 因此,单个
char
能表示一个 UTF-16 code unit,而不是完整的 Unicode code point。处理 Unicode 字符时注意使用int codePoint
/Character
的相关 API。
7. 自动装箱(autoboxing)和拆箱(unboxing)的原理是什么?
概念:
- 装箱:把基本类型自动转换成对应的包装类型(如
int
→Integer
)。 - 拆箱:把包装类型自动转换回基本类型(如
Integer
→int
)。
- 装箱:把基本类型自动转换成对应的包装类型(如
编译器行为:自动装箱/拆箱是在编译期由 Java 编译器插入对应的调用,示例:
1
2Integer a = 10; // 编译器会转换为 Integer.valueOf(10)
int b = a; // 编译器会转换为 a.intValue()valueOf 缓存:包装类(如
Integer.valueOf(int)
)会对小整数(默认 -128 到 127)做缓存以重用对象,减少频繁创建对象。注意事项:
拆箱
null
会抛出NullPointerException
:1
2Integer x = null;
int y = x; // NPE装箱/拆箱会有性能开销(对象创建、装箱/拆箱方法调用),在性能敏感场景尽量使用基本类型或手动优化。
比较时注意:
Integer a = 100; Integer b = 100; a==b
在缓存范围内可能为true
,超出范围则通常为false
。
8. ==
和 equals()
的区别?
- ==:
- 对于基本类型:比较 值(数值是否相等)。
- 对于引用类型:比较 引用地址(是否是同一个对象)。
- equals():
- 是
Object
的方法,默认实现也是比较引用(等价于==
)。 - 许多类(如
String
、Integer
、集合类等)重写了equals()
,用于比较逻辑/内容相等。
- 是
- 使用建议:
- 比较对象内容用
equals()
(需检查null
),比较是否同一实例用==
。 - 与
equals()
配套应重写hashCode()
(见下题)。
- 比较对象内容用
9. hashCode
和 equals
的关系?
- 合同(Contract)(重要):
- 如果两个对象通过
equals()
被判定为相等(a.equals(b)
为true
),那么a.hashCode() == b.hashCode()
必须成立。 - 反之不要求:
hashCode
相等的对象不一定equals()
相等(哈希冲突允许)。
- 如果两个对象通过
- 在哈希集合中的角色:
- 哈希表(
HashMap
/HashSet
)先用hashCode()
找到桶(bucket),若桶中有多个元素,再用equals()
逐个比较确认相等或冲突。 - 如果只重写
equals()
而不重写hashCode()
会破坏集合行为(例如放入HashSet
后无法正确查找)。
- 哈希表(
- 实现要点:
hashCode()
要尽量分散(降低冲突),并在对象不可变字段上基于相同规则计算。- 若对象可变,若用于哈希集合要小心:修改字段会导致
hashCode()
改变,破坏集合内部结构。
10. String
、StringBuffer
、StringBuilder
的区别?
- String:
- 不可变(immutable),每次修改都会产生新的对象(或新内部 char/byte 数组)。
- 线程安全(因为不可变),适合频繁读取、少量修改的场景。
- StringBuffer:
- 可变的字符序列(内部有缓冲区
char[]
/byte[]
),几乎与StringBuilder
接口相同。 - 线程安全,其方法大多使用
synchronized
,因此在多线程下可以被多个线程安全使用。 - 相对较慢(同步开销)。
- 可变的字符序列(内部有缓冲区
- StringBuilder(Java 5+):
- 可变,非线程安全(没有同步),比
StringBuffer
快。 - 推荐在单线程或外部已同步的场景下使用。
- 可变,非线程安全(没有同步),比
- 选择建议:
- 多线程需要可变字符串:
StringBuffer
(或外部同步)。 - 单线程/局部构造字符串:
StringBuilder
。 - 常量字符串或少量拼接:
String
(编译器对+
会优化为StringBuilder
)。
- 多线程需要可变字符串:
11. 为什么 String
是不可变的(immutable)?
原因(几条重要理由):
- 安全性:
String
经常用于关键场景(类加载器、网络地址、文件名、权限检查、数据库连接字符串等)。不可变保证在传递引用时不被恶意或意外修改。 - 线程安全:不可变对象固有线程安全,多个线程可共享同一
String
实例而无需同步。 - 性能(缓存 hashCode):
String
的哈希值可缓存(hash
字段),便于作为 Map 的 key,避免重复计算。 - 字符串常量池:可安全地将字面量放入池中重用,不用复制或担心修改。
- 优化:JVM 可进行共享、常量折叠等优化(更易于实现某些编译期/运行期优化)。
12. String
常量池的实现机制?
- 概念:编译期和运行期维护一个字符串池(String Intern Pool),用于存放字符串字面量(literal)和显式
intern()
后的字符串,以便重用相同内容的String
实例。 - 编译期:源代码中的字符串字面量(如
"abc"
)会被放到常量池(编译后的 class 文件常量池),类加载后这些字面量会放入运行时的字符串池。 - 运行期:
- 以前(Java 6 及更早):字符串常量池在 PermGen(方法区)中。
- Java 7 起:常量池迁移到 Java 堆(运行时常量池也在堆里),避免 PermGen 问题。
- intern():当调用
s.intern()
时,JVM 会检查池中是否已有相同内容的字符串:- 若存在,返回池中的引用;
- 若不存在,将该字符串的引用加入池并返回它。
- 编译时优化:字符串常量的拼接(编译时常量)会在编译期合并,如
"a" + "b"
→"ab"
,直接放入常量池;而运行时拼接(变量参与)则使用StringBuilder
。
13. new String("abc")
创建了几个对象?
- 通常情形:
- 若
"abc"
字面量尚未在常量池中存在,执行new String("abc")
会导致 两个对象 被创建:常量池中的"abc"
(一个String
)和堆中通过new
创建的新的String
实例(内容通常是对常量池中字符数组的复制或共享,具体实现随 Java 版本而异)。 - 如果字面量
"abc"
已经存在于常量池中(例如之前被加载过),那么new String("abc")
只会创建 一个堆对象(new 的那个String
)。
- 若
- 注意:自 Java 7/9 后
String
内部实现改变(压缩字符串、byte[] 存储等),但逻辑上上述结论成立:通常 1 或 2 个对象,取决于常量池中是否已存在该字面量。
14. final
关键字的作用?
- 用于类(final class):类不可被继承(如
String
)。 - 用于方法(final 方法):方法不能被子类重写(override),用于确保行为不可变。
- 用于变量(final 字段/局部变量):
- 对基本类型:赋值后值不可改变(常量)。
- 对引用类型:引用不可改变(不能指向另一个对象),但所指向对象的内部状态仍可变(除非对象本身不可变)。
static final
常用来定义常量(编译期常量)。
- 用于参数(方法形参可以声明为
final
):代表方法内不能修改该参数引用/值。 - 其它用途:在多线程中,
final
字段的写-构造过程被 JMM(Java 内存模型)处理,确保构造完成后其他线程可见(安全发布方面有好处)。
15. static
关键字的作用?
- 用于声明类级别成员(字段、方法、初始化块、嵌套类),不依赖实例。
- static 变量:类变量,所有实例共享一份。内存中仅有一份副本。
- static 方法:类方法,可通过
ClassName.method()
调用;不能直接访问非静态成员(因无 this)。 - static 代码块:类加载时执行一次(用于静态初始化)。
- static 嵌套类:可以声明为静态的内部类(静态嵌套类),没有外部实例引用。
16. static
修饰变量、方法、代码块分别意味着什么?
- static 变量(类变量):
- 随类加载而创建,所有对象共享同一份数据。
- 可通过
ClassName.field
或instance.field
(不推荐)访问。
- static 方法:
- 属于类,调用时无需实例。
- 不能使用
this
、不能直接访问非静态成员。 - 可作为工具方法(如
Math.abs()
)。
- static 代码块:
- 在类加载阶段执行一次,用于复杂静态初始化(比如初始化静态常量、加载本地库等)。
- 执行顺序:静态块按定义顺序执行,类加载时运行(在实例化之前)。
17. static
和 final
能一起用吗?
能。
static final
常用于定义类常量(尤其是基本类型和String
),例如:1
2public static final int MAX = 100;
public static final String NAME = "abc";区别:
- 如果是编译期常量(
static final
基本类型或String
且在编译时可以确定),编译器会将其内联到使用处(被引用的类编译后看到的是常量值),注意跨模块修改可能导致需要重新编译引用方。 - 如果是
static final
引用对象且不是编译期常量,则引用本身不可变,但对象内容可能可变。
- 如果是编译期常量(
18. static
内部类和非静态内部类的区别?
- 静态内部类(static nested class):
- 相当于外部类的一个静态成员。
- 没有对外部类实例的隐式引用(不能直接访问外部类的非静态成员)。
- 可以像普通类那样实例化:
Outer.StaticInner inner = new Outer.StaticInner();
- 非静态内部类(inner class):
- 每个实例隐式持有一个外部类实例引用(
Outer.this
),可以直接访问外部类的所有成员(包括私有成员)。 - 创建方式:
Outer outer = new Outer(); Outer.Inner inner = outer.new Inner();
- 占用外部类对象的内存引用,可能导致内存泄露(如果长期持有内部类实例导致外部类不能被回收)。
- 每个实例隐式持有一个外部类实例引用(
- 选择原则:如果内部类不需要访问外部实例成员,优先使用
static
嵌套类,避免额外引用。
19. Java 中的 this
和 super
的区别?
- this:
- 引用当前对象的引用。
- 用于访问当前类的实例变量、调用当前类的其他构造器(
this(...)
)、或传递当前对象引用。
- super:
- 引用当前对象的父类部分。
- 用于访问父类被覆盖的方法(
super.method()
)、父类字段(若有同名字段)、以及在子类构造器第一行调用父类构造器(super(...)
)。
- 注意:
this()
或super()
必须在构造器的第一行(两者不能同时出现)。super
不能用于访问父类的 private 成员(编译期限制)。
20. Java 中构造方法能否被重写?
- 不能。重写(override)适用于实例方法,构造方法不是继承的成员,子类不能重写父类的构造器。
- 可以重载(overload):在同一类中同名构造器有不同参数列表属于重载。
- 子类构造器可通过
super(...)
显式调用父类构造器来完成父类初始化。
21. 接口和抽象类的区别?
- 接口(interface):
- 早期(Java 7 之前):只包含抽象方法(默认是
public abstract
)和public static final
常量。 - Java 8+:可以包含
default
方法与static
方法(可以有部分实现);Java 9+ 允许private
方法。 - 支持多继承(一个类可以实现多个接口)。
- 没有实例字段(除
static final
常量外)。
- 早期(Java 7 之前):只包含抽象方法(默认是
- 抽象类(abstract class):
- 可以有实例字段(非静态成员变量)、已有实现的方法(具体方法)、构造器。
- 适合表示一类有共同行为并共享状态的类层次。
- 类只能单继承(只能继承一个抽象类)。
- 选择建议:
- 如果需要多继承行为、只定义方法契约且不需要状态,使用接口。
- 如果需要提供共有实现和状态,使用抽象类。
22. 接口中可以有 default
方法和 static
方法吗?
- 可以(Java 8 起)。
default
方法:为实现类提供默认实现,避免破坏已有实现(接口演化)。实现类可以覆盖(override)。static
方法:属于接口自身,可以通过InterfaceName.method()
调用,不能通过实例调用。
- Java 9 及以后还可有
private
方法,供接口内部复用实现逻辑。
23. Java 8 接口新增了什么?
- Java 8 在接口中新增了 default 方法 和 static 方法,允许接口提供带实现的方法,从而便于接口的演化(向后兼容)。这也是 Java 8 引入函数式编程支持的基础之一(允许接口作为函数式接口)。
24. Java 8 有什么新特性,并详细介绍(重点)
一、Java 8 新特性深入解析
好的,我来帮你把 Java 8 的六大核心特性(Lambda、函数式接口、Stream API、方法引用、java.time API、Optional) 进行 完整整合,从概念、示例、底层原理、实现机制、特点和优势做一个统一、连贯的解析。
Java 8 六大核心特性整合解析
1. Lambda 表达式
概念
- 匿名函数,允许把行为作为参数传递。
- 简化匿名内部类写法,实现函数式编程。
示例
1 | List<String> list = Arrays.asList("a", "bb", "ccc"); |
底层原理
- 编译器生成静态/实例方法 +
invokedynamic
指令。 - JVM 调用
LambdaMetafactory
动态生成函数对象,实现 函数式接口。 - 捕获外部变量通过闭包机制存储(必须是
effectively final
)。
特点
- 语法简洁
- 可作为参数传递行为
- 性能高于匿名内部类(减少对象创建)
2. 函数式接口
概念
- 仅有一个抽象方法的接口,用作 Lambda 表达式的目标类型。
- 可用
@FunctionalInterface
标识。
示例
1 | @FunctionalInterface |
底层原理
- Lambda 表达式编译时生成实现函数式接口的函数对象。
- 捕获变量通过闭包对象保存。
特点
- 简化匿名类
- 支持函数式编程
- 可与 Stream / Optional 等结合
3. Stream API
概念
- 对集合进行声明式操作(过滤、映射、归约)。
- 支持 惰性求值 和 并行处理。
示例
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
底层原理
- Stream 是流水线对象,中间操作返回新的 Stream,终端操作触发计算。
- 串行流:顺序迭代器处理
- 并行流:ForkJoinPool 分块处理
特点
- 声明式、链式操作
- 支持并行
- 可与 Lambda / 方法引用结合
4. 方法引用
概念
- Lambda 表达式的简化写法,引用现有方法。
- 类型:
- 静态方法引用:
ClassName::staticMethod
- 实例方法引用:
instance::method
- 构造器引用:
ClassName::new
- 静态方法引用:
示例
1 | List<String> names = Arrays.asList("a", "bb", "ccc"); |
底层原理
- 编译器转成 Lambda 表达式 + invokedynamic。
- JVM 生成实现函数式接口的函数对象,内部持有方法引用。
特点
- 简洁直观
- 可减少 Lambda 代码
- 与 Stream / Optional 配合使用
5. java.time API
概念
- 替代
Date
/Calendar
,不可变、线程安全。 - 核心类:
LocalDate
/LocalTime
/LocalDateTime
(无时区)ZonedDateTime
(带时区)Duration
/Period
(时间段)
示例
1 | LocalDate today = LocalDate.now(); |
底层原理
- 内部字段
final
保存值,不可变。 - 工厂方法创建对象,链式操作返回新对象。
- 使用 enum + 整型/字节优化存储(如 LocalDate 年月日用 int)。
特点
- 不可变,线程安全
- 链式操作,易组合
- 支持各种历法和时区计算
6. Optional
概念
- 容器对象,防止
NullPointerException
。 - 可以包含值或为空(empty)。
示例
1 | Optional<String> opt1 = Optional.of("Hello"); |
底层原理
- 内部
value
字段存储非空值,空值使用单例EMPTY
。 map
/flatMap
/filter
返回新的 Optional,对象不可变。- 与 Lambda / 函数式接口结合实现链式安全操作。
特点
- 避免显式 null 判断
- 支持链式调用
- 可与 Stream / Lambda 配合
Java 8 六大核心特性底层对比表
特性 | 底层实现 | 核心原理 | 优势 |
---|---|---|---|
Lambda | invokedynamic + LambdaMetafactory | 动态生成函数对象 | 简洁、性能高 |
函数式接口 | 单抽象方法接口 | 捕获变量通过闭包存储 | 简化匿名类,函数式编程 |
Stream API | 流水线 + 延迟求值 | 中间操作惰性,终端触发 | 声明式、可并行 |
方法引用 | Lambda 简化语法 + invokedynamic | 内部持有方法引用 | 简洁直观,减少代码 |
java.time | 不可变对象 + 工厂方法 | final字段保存值,链式返回新对象 | 线程安全,可组合 |
Optional | 包装对象 + 单例 EMPTY | 不可变容器,函数式链式调用 | 避免 NPE,安全链式操作 |
1. Lambda 表达式
语法
1 | (parameters) -> expression |
示例:排序
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
图示
1 | 编译器把 Lambda 转成匿名类或 invokedynamic 机制 |
- 捕获外部变量:必须是
effectively final
,底层通过匿名类的构造器保存引用或值。
2. Stream API
特点
- 声明式:关注“做什么”,而非“怎么做”。
- 惰性求值:中间操作不会立即执行,终端操作触发。
- 可并行:使用
.parallelStream()
自动利用多核 CPU。
示例:数据处理
1 | List<String> words = Arrays.asList("Java","Python","C++","Go"); |
图示
1 | List -> Stream -> filter -> map -> sorted -> collect |
3. 方法引用
类型
- 静态方法:
Class::staticMethod
- 实例方法:
instance::method
- 构造器:
Class::new
示例
1 | List<String> names = Arrays.asList("a", "bb", "ccc"); |
4. java.time API
示例
1 | LocalDate date = LocalDate.of(2025, 9, 10); |
图示
1 | LocalDate/Time (不可变) |
- 线程安全:无需同步即可共享对象。
5. Optional
示例
1 | Optional<String> opt = Optional.ofNullable(null); |
图示
1 | Optional.ofNullable(value) |
二、synchronized 底层原理深入解析
1. JVM 锁的类型
锁类型 | 特点 | 性能 |
---|---|---|
偏向锁 (Biased Lock) | 无竞争时偏向第一个线程,轻量 | 高 |
轻量级锁 (Lightweight) | 竞争少时使用 CAS 升级锁 | 中 |
重量级锁 (Monitor) | 高竞争时使用 OS mutex,阻塞线程 | 低 |
2. synchronized 对象头结构
- 每个对象在 JVM 内存中都有 对象头(Mark Word),存储锁信息、哈希码、GC 标记等。
1 | 对象头 Mark Word (32/64bit) |
- 锁标记位:
- 00:无锁
- 01:偏向锁
- 10:轻量级锁
- 11:重量级锁
3. synchronized 执行流程
a) 偏向锁(无竞争)
1 | 线程获取锁 -> 在对象头打上线程ID -> 执行 -> 释放锁 -> 保持偏向状态 |
b) 轻量级锁(CAS竞争)
1 | 线程尝试CAS抢锁 -> 成功:持有锁 -> 执行 -> 释放 |
c) 重量级锁(阻塞)
1 | 线程无法获取锁 -> 阻塞(OS等待队列) -> 被唤醒 -> 获取锁 -> 执行 -> 释放锁 |
图示
1 | +------------------+ |
4. synchronized 方法示例
1 | class Counter { |
- 锁对象:实例方法锁住
this
,静态方法锁住类对象ClassName.class
。 - 效果:同一时间只有一个线程能进入
increment()
,保证count++
操作的原子性。
5. JVM 优化
- 锁消除:编译器或 JIT 可检测无竞争情况,消除同步锁。
- 锁粗化:把多次连续的小范围锁合并,减少加锁次数。
- 偏向锁 / 轻量级锁 / 重量级锁升级:动态适应竞争情况,兼顾性能与正确性。
如果你愿意,我可以帮你把 Java 8 新特性 和 synchronized 底层原理 做一张 图解全流程总结图,把 Lambda、Stream、锁状态、对象头等内容融合成一张直观的图,方便记忆和面试答题。
你希望我做这张图吗?25. 多态的实现机制是什么?
- 概念:同一操作作用于不同对象时表现出不同的行为(主要表现为方法的动态绑定)。
- 实现机制:
- 编译时:根据引用类型检查方法签名(静态类型检查)。
- 运行时:实际调用的方法根据对象的实际类型(运行时类型)决定;即 动态绑定(dynamic dispatch)。
- JVM 通常通过 虚方法表(vtable)/方法查找机制 实现。调用一个非
static
、非private
、非final
的方法时,会在运行时查找实际类的实现并调用。
- 结果:子类可以覆盖父类方法,调用者使用父类引用指向子类对象时,调用的是子类的覆盖方法(运行时决定)。
- 注意:
static
、private
、final
方法不会被动态绑定(是静态绑定)。
26. 方法重载(overload)和方法重写(override)的区别?
方法重载(Overloading):
- 同一类中方法名相同、参数列表不同(参数类型/个数/顺序),返回类型可以不同。
- 编译时决定(重载解析在编译期完成),与继承关系无关。
方法重写(Overriding):
- 子类定义与父类相同方法签名(方法名 + 参数类型相同)的实现,目的是改变/扩展父类行为。
- 运行时动态绑定,必须满足访问权限不能更严格,抛出的受检异常不能超过父类版本等规则。
示例:
1
2
3
4
5
6// overload
void f(int x) {}
void f(String s) {}
// override
class A { void m() {} }
class B extends A { @Override void m() {} }
27. Java 支持多继承吗?如何实现类似效果?
- 类的多继承:Java 不支持类的多继承(不能继承多个类),以避免菱形继承问题(diamond problem)。
- 实现类似效果的方法:
- 接口多实现:一个类可以实现多个接口(Java 8 的
default
方法也带来类似多继承方法实现的可能,但有冲突解决规则)。 - 组合/委托(composition/delegation):在类中持有其它类的实例并把调用委托给它们(优于继承的面向对象设计原则)。
- 接口多实现:一个类可以实现多个接口(Java 8 的
- 接口冲突解决:若多个接口提供相同默认方法,类必须重写该方法并明确调用哪个接口的默认实现(
InterfaceName.super.method()
)。
28. Java 的四种访问修饰符?
public
:对所有类可见(任何包)。protected
:对同包类和子类可见(即同包或子类可以访问)。- 默认(包私有,package-private)(不写修饰符):对同包类可见,包外不可见。
private
:仅在本类内可见,包外/子类不可访问(子类无法直接访问父类 private 成员)。- 注意:对类(顶层类)只能使用
public
或默认(包私有),不能声明为private
/protected
。
29. transient
关键字的作用?
- 用于标记字段在 Java 序列化(
Serializable
)过程中不被序列化。 - 被标记为
transient
的字段在序列化后不会写入序列化流,反序列化时这些字段会以默认值恢复(基本类型 0,引用类型null
)。 - 常用于敏感信息(密码)、不需要持久化的缓存字段、或可重建的状态字段。
- 注意:
static
字段本身也不会被序列化(因为是类级别,不属于实例状态)。
30. volatile
关键字的作用?
- 可见性:保证对
volatile
变量的写入对其它线程立即可见(读取总是从主内存而不是线程缓存读取)。 - 禁止指令重排序(部分):读/写
volatile
存在的内存屏障能保证一定的有序性(写volatile
发生在后续读的可见性上,更多细节参考 JMM)。 - 不保证原子性:对单次读/写操作是原子的(对
long
/double
在 Java 5 后也保证原子),但非原子操作(如i++
)不是原子的。 - 典型用途:
- 状态标志(如
volatile boolean running
)。 - 用于实现双重检查锁定(DCL)单例中的
instance
(在 Java 5+ 环境下有效)。
- 状态标志(如
- 与 synchronized 比较:
volatile
轻量,只保证可见性与部分有序性,不保证互斥;适用于简单状态通信。synchronized
提供互斥和可见性保证(更重),并可配合wait/notify
。
31. synchronized
的作用及底层原理?
作用
- 互斥(排它):保证同一时间只有一个线程可以执行被
synchronized
修饰的代码块或方法(针对同一把锁)。 - 可见性:进入/退出同步块会建立 happens-before 关系,确保锁释放前的写对随后获取该锁的线程可见。
- 用途:保护临界区、确保多个线程对共享可变状态的安全访问。
使用方式
- 方法级别:
synchronized void m() { ... }
:等价于synchronized(this)
(实例方法)或synchronized (ClassName.class)
(静态方法)。
- 代码块级别:
synchronized(this) { ... }
或synchronized(lockObj) { ... }
更灵活,能减小锁的粒度。
底层原理(HotSpot 实现概要)
- JVM 使用对象头(object header)中的 mark word 和关联数据结构维护锁状态。
- 锁优化策略(为提升性能,HotSpot 引入若干优化):
- 偏向锁(Biased Locking):在没有竞争的情况下,锁会偏向于第一次获得它的线程,重复获取无需 CAS,减少开销。
- 轻量级锁(Lightweight Locking):使用 CAS 操作在栈上记录加锁记录,避免进入重量级监视器(monitor)。
- 重量级锁(Monitor/Mutex):当竞争激烈或 CAS 失败时,升级为重量级锁,使用操作系统互斥量(可能涉及线程阻塞/唤醒)。
monitorenter
/monitorexit
是字节码指令(由编译器/字节码生成器生成)。- JIT 编译器可进行锁消除、锁粗化、锁优化等(当能证明无并发访问或已外部同步时)。
wait/notify
/notifyAll
Object.wait()
、notify()
、notifyAll()
必须在持有对象监视器(即在synchronized
块内)时调用,用于线程间协作(条件等待/通知)。wait()
会释放锁并进入等待队列;notify()
唤醒等待队列中的一个线程(被唤醒线程在重新获得锁后继续)。
注意与陷阱
- 锁粒度:避免用过大锁(如
synchronized
在方法头部锁住大量操作),谨慎使用String
或装箱对象作为锁(可能会导致多个实例共用同一锁或锁被外部持有)。 - 死锁:多线程锁顺序不当可能死锁;设计时谨防。
- 性能:在高并发下可考虑使用
java.util.concurrent
包(ReentrantLock
、ConcurrentHashMap
、AtomicXxx
)等更细粒度、高性能的并发工具。
二、面向对象编程(OOP)
- 面向对象的三大特性是什么?
- 封装的作用是什么?
- 多态的优点是什么?
- 重写方法时返回值能否不同?
- 构造函数能否被继承?
- 构造函数能否 private?
- 单例模式的几种实现方式?
- 饿汉式和懒汉式单例的区别?
- 为什么要使用内部类?
- 成员内部类、局部内部类、静态内部类的区别?
- Java 中对象的创建方式有哪些?
- 对象之间的浅拷贝和深拷贝的区别?
- clone() 方法的原理?
- 为什么 Java 不支持多继承?
- Java 是值传递还是引用传递?
- 方法参数传递时是如何处理对象的?
- 封装性体现在哪些方面?
- 为什么需要继承?
- 重写 Object 类的 toString 方法的意义?
- 重写 Object 类的 equals 方法时需要注意什么?
三、异常处理
- Java 的异常体系结构?
- Checked Exception 和 Unchecked Exception 的区别?
- throw 和 throws 的区别?
- try-catch-finally 的执行顺序?
- finally 中的 return 会覆盖 try 中的 return 吗?
- try-with-resources 的作用?
- 自定义异常类如何实现?
- Error 和 Exception 的区别?
- NullPointerException 常见场景有哪些?
- 运行时异常需要捕获吗?为什么?
四、集合框架(Collections)
- Collection 和 Collections 的区别?
- List、Set、Map 的区别?
- ArrayList 和 LinkedList 的区别?
- HashMap 的底层实现?
- HashMap 1.7 和 1.8 的区别?
- HashMap 如何解决哈希冲突?
- HashSet 的底层实现?
- ConcurrentHashMap 的底层实现?
- Hashtable 和 HashMap 的区别?
- TreeMap 和 HashMap 的区别?
- WeakHashMap 的特点?
- CopyOnWriteArrayList 的应用场景?
- LinkedHashMap 的底层原理?
- Map 的 key 是否可以为 null?
- ArrayList 扩容机制?
- Vector 和 ArrayList 的区别?
- PriorityQueue 的底层实现?
- BlockingQueue 的几种实现类?
- HashMap 死循环问题出现在哪个版本?
- fail-fast 和 fail-safe 的区别?
五、并发编程(JUC)
- Java 中的线程生命周期?
- Runnable 和 Callable 的区别?
- ThreadPoolExecutor 的参数有哪些?
- 线程池的拒绝策略有哪些?
- 创建线程的四种方式?
- synchronized 的底层实现原理?
- synchronized 和 ReentrantLock 的区别?
- 公平锁和非公平锁的区别?
- AQS 的原理?
- CountDownLatch 的应用场景?
- CyclicBarrier 的应用场景?
- Semaphore 的应用场景?
- Exchanger 的应用场景?
- volatile 能保证原子性吗?
- CAS 的原理?
- ABA 问题是什么?怎么解决?
- ThreadLocal 的作用及原理?
- ThreadLocal 内存泄漏的原因?
- JUC 中的原子类有哪些?
- 乐观锁和悲观锁的区别?
六、JVM & 内存模型
- JVM 内存结构?
- 堆和栈的区别?
- 堆内存的分代模型?
- 新生代、老年代、永久代的区别?
- 元空间和永久代的区别?
- 对象在堆中的分配过程?
- 对象什么时候进入老年代?
- JVM 的垃圾回收算法?
- 垃圾收集器有哪些?
- CMS 和 G1 的区别?
- Minor GC 和 Full GC 的区别?
- 什么是 Stop The World?
- JVM 常见的调优参数?
- 类加载的双亲委派机制?
- 双亲委派的好处?
- 类加载器的种类?
- OOM 的常见类型?
- 内存泄漏和内存溢出的区别?
- finalize() 方法的作用?
- Java 内存模型(JMM)的核心内容?
七、泛型、注解、反射
- 什么是泛型?
- 泛型的类型擦除机制?
- 泛型通配符
? extends
和? super
的区别? - 注解的作用是什么?
- 元注解有哪些?
- 自定义注解如何实现?
- 反射的作用?
- 反射的性能问题?
- 反射能否访问私有字段?
- 动态代理的两种实现方式?
- JDK 动态代理和 CGLIB 的区别?
- 反射如何创建对象?
- Class.forName 和 ClassLoader.loadClass 的区别?
- 注解和反射结合能实现哪些功能?
- 注解处理器(APT)的作用?
八、Java 8+ 新特性
- Lambda 表达式的作用?
- 函数式接口有哪些?
- Stream 流的常见操作?
- Optional 的作用?
- CompletableFuture 的作用?
- Java 8 接口的默认方法和静态方法?
- forEach 和传统 for 的区别?
- parallelStream 的原理?
- Java 9 模块化系统(Jigsaw)是什么?
- Java 14 的 switch 表达式增强?
- Java 16 的 record 特性?
- Java 17 的 sealed class 特性?
- var 关键字是什么?
- ZGC 的特性?
- Project Loom 的虚拟线程是什么?
九、IO
好 — 我把范围 大幅扩展并细化,把 Socket / TCP / UDP / TLS / WebSocket / 内核调优 / 实战题 / 编码题 都加进来了。现在给你一个160 道题目清单(按主题分组),覆盖面更广、更细致,便于你当面试题库或复习大纲使用。
Java IO & Socket 深度面试题(160题)
一、Java IO 基础与流(1–20)
- Java IO 的总体体系结构是什么?
- InputStream / OutputStream 的常见子类有哪些?
- Reader / Writer 的常见子类有哪些?
- 字节流与字符流的本质区别和使用场景?
- 为什么要有缓冲流(BufferedInputStream / BufferedReader)?
- flush() 与 close() 的区别与细节(含异常处理)?
- try-with-resources 的工作原理(AutoCloseable、suppressed exceptions)?
- 装饰器模式在 Java IO 中如何体现?举例链式包装。
- File 类的能力与限制(能否读写数据、元数据操作)?
- RandomAccessFile 的用途、指针机制与并发注意点?
- FileInputStream 与 FileReader 有什么差别?何时用哪个?
- 常见的文件读写拷贝实现方式(read/write、buffered、NIO transferTo)性能对比。
- 如何安全地读写文本并处理字符编码(InputStreamReader/OutputStreamWriter)?
- 字符编码问题常见坑(UTF-8 与 GBK 混用、BOM、截断)如何避免?
- 文件追加(append)实现方式与原子性问题。
- 如何判断并处理文件是否存在、权限、是否被占用?
- File.mkdirs() 与 mkdir() 的区别与返回值含义。
- 临时文件(File.createTempFile)与删除策略。
- 文件的读写锁与并发访问策略(何时需要外部锁)。
- 流关闭异常处理的最佳实践。
二、Java IO 设计与模式(21–40)
- InputStream/OutputStream 的装饰器链常见组合(Buffered -> GZIP -> Cipher 等)。
- PushbackInputStream、SequenceInputStream 的作用与应用场景。
- PipedInputStream / PipedOutputStream(管道流)的使用与线程注意点。
- FilterInputStream/FilterOutputStream 的设计与扩展。
- IO 与异常处理:IOException 的常见子类与处理策略。
- 流复制的常见实现模板(模板方法式代码)。
- NIO 引入前的 IO 局限性(可扩展性、线程模型)。
- 如何实现一个带超时的读操作(Socket/Channel)?
- 流式处理与内存友好型处理(流式处理大文件)实践。
- Base64 编解码在流中的高效集成方法。
- 加密/解密流(CipherInputStream/CipherOutputStream)如何正确关闭?
- GZIPInputStream/GZIPOutputStream 的压缩流使用注意事项。
- ObjectInputStream/ObjectOutputStream 的工作机制(类元数据、句柄表)。
- 如何实现跨语言的序列化兼容(JSON/Protobuf/Avro)?
- 实现自定义 InputStream 子类时需要注意什么(read 方法语义)?
- 设计用于日志写入的高吞吐 IO 模式(异步批量写)。
- 流复制时如何统计速率与进度(带进度回调)?
- 如何安全处理二进制文件(流边界、magic header)?
- 大对象/大数组写入流时的内存优化策略(分块、流化)。
- 如何实现可重入/可恢复的断点续传文件写入?
三、文件系统、锁与操作(41–60)
- Java 中文件锁 FileLock 的类型(共享/独占)及实现原理。
- FileLock 的局限性(跨 JVM、跨 OS 行为差异)。
- 文件描述符泄露的常见原因与定位方法。
- 文件句柄上限(ulimit)对 Java 服务的影响与排查。
- 硬链接与软链接在 Java 中如何区分与操作?
- MappedByteBuffer(内存映射文件)的优劣与风险(内存回收、文件锁)。
- 大文件分片读取与并发合并策略。
- 如何高效统计大日志文件中某条件的行数(分块 + 并行)?
- 文件系统缓存(PageCache)对读写性能的影响机制。
- fsync / FileDescriptor.sync 在持久化保障上的作用。
- 文件顺序写与随机写的性能差异与优化建议。
- 磁盘类型(SSD vs HDD)对 IO 策略的影响。
- 原子重命名(renameTo/Files.move)的跨平台差异。
- 文件监控(WatchService)的实现限制与替代方案。
- 处理日志切割(rotation)时的文件句柄管理策略。
- 如何实现零停机部署中对文件的平滑迁移?
- 软删除(标记删除)与物理删除的 IO 考量。
- 备份/快照策略对 IO 的影响(冷备 vs 热备)。
- 大文件校验(MD5/SHA)在流式处理中的实现。
- 文件系统一致性问题(写入后立即读取到不同步)如何诊断。
四、序列化与反序列化(61–75)
- Java 原生序列化(Serializable)的机制和对象写入格式。
- serialVersionUID 的作用、自动生成与兼容性策略。
- transient 字段、static 字段在序列化中的处理。
- Externalizable 与 Serializable 的区别与使用场景。
- Java 序列化的安全风险(反序列化漏洞)与防护措施。
- 高性能二进制序列化方案对比:Kryo、Protostuff、Protobuf、Avro。
- JSON 和二进制序列化的权衡(可读性 vs 性能/大小)。
- 如何实现可演化的序列化协议(向前/向后兼容)?
- 对象图序列化时循环引用的处理(句柄机制)。
- 对象序列化性能调优要点(缓冲、对象重用)。
- 在分布式系统中如何管理序列化策略(跨服务版本)?
- 自定义序列化(writeObject/readObject)常见陷阱。
- 序列化时如何处理类加载器问题?
- 大对象序列化时的内存与 GC 风险如何降低?
- 使用序列化作为缓存(Redis/Memcached)时的注意事项。
五、NIO 深入:Buffer / Channel / Selector(76–100)
- NIO 三大核心(Buffer、Channel、Selector)各自职责是什么?
- ByteBuffer 的 position / limit / capacity 三指针语义详解。
- flip(), clear(), rewind(), compact() 各自什么时候用?
- DirectByteBuffer 与 HeapByteBuffer 的底层差异与性能影响。
- ByteBuffer 的切片(slice)与只读视图(asReadOnlyBuffer)。
- Scatter/Gather IO(分散读取/聚集写入)的使用场景与实现。
- FileChannel 的 position、size、truncate 等方法详解。
- MappedByteBuffer(内存映射)在 NIO 中如何创建与释放?
- Channel 与传统 Stream 的区别(阻塞/非阻塞、文件描述符共享)。
- Selector 的工作流程:注册、选择、取消、keys、selectedKeys。
- Selector 的底层实现依赖(select/poll/epoll/kqueue)及其影响。
- 非阻塞 Channel 的 read/write 返回值语义(-1、0、>0)。
- NIO 中的粘包/拆包问题与常见解决策略(定长/分隔/长度前置)。
- 如何在 NIO 中实现超时断开和心跳检测?
- Pipe(管道)在 NIO 中的用途与限制。
- 多 Selector / 多 Reactor 的线程模型设计要点。
- ByteBuffer 内存可见性与多线程访问问题(不是线程安全)。
- 避免 NIO 频繁创建 DirectByteBuffer 的垃圾和内存泄露策略。
- 使用 Selector 时的遍历与删除 selectedKeys 的正确姿势。
- NIO 读写循环的高效实现模板。
- Channel.transferTo/transferFrom 的零拷贝含义与局限。
- NIO 与 FileChannel 实现文件间高效复制的流程。
- 使用 NIO 实现高并发服务器时的常见瓶颈。
- NIO 在 Windows 与 Linux 上的行为差异(可伸缩性影响)。
- 实战题:实现一个使用 NIO 的简单 echo 服务器(思路要点)。
六、Socket / TCP / UDP / WebSocket(101–140)
- Socket 的基本概念:端点、三元组/四元组(IP:port + peer)。
- Java 中 Socket、ServerSocket、DatagramSocket 的主要 API 区别。
- TCP 与 UDP 的核心差异(可靠性、有序性、连接性)。
- TCP 三次握手(SYN、SYN-ACK、ACK)与四次挥手流程详解。
- TIME_WAIT、CLOSE_WAIT、FIN_WAIT1/2 等 TCP 状态含义与产生原因。
- 半开连接(half-open)是什么,如何检测与恢复?
- TCP 的流量控制(窗口)与拥塞控制(慢启动、拥塞避免、快重传、快恢复)基础。
- Nagle 算法(TCP_NODELAY)的原理和在延迟场景下的影响。
- TCP 延迟确认(delayed ACK)对交互型应用的影响。
- Socket 选项 SO_TIMEOUT、SO_KEEPALIVE、SO_REUSEADDR、SO_REUSEPORT、SO_LINGER 含义与使用场景。
- backlog 参数(ServerSocket 构造或 listen)与 accept 队列(syn, accept 队列)区别。
- ephemeral port(短暂端口)与端口耗尽问题及解决办法。
- SYN flood 攻击的原理和防护(SYN cookies、firewall)。
- TCP 快速打开(TCP Fast Open)是什么,有何优劣?(简述)
- MTU、MSS 与 IP 分片对传输的影响与诊断方法。
- UDP 的组播(Multicast)与广播(Broadcast)机制与 Java 支持(MulticastSocket)。
- UDP 丢包、乱序、包大小限制(最佳实践)。
- UDP 穿透 NAT(STUN/ICE)的基本原理。
- 如何在 Java 中实现高性能 UDP 服务器?(NIO + DatagramChannel)
- WebSocket 协议基础(握手、帧格式、ping/pong、close)。
- Java 实现 WebSocket 的常见库(javax.websocket、Netty websocket、Undertow)。
- TLS over TCP(HTTPS)的握手流程要点(证书验证、对称密钥协商)。
- 使用 SSLSocket / SSLServerSocket 和 SSLEngine 的差别及适用场景。
- SSL/TLS 握手的重用(session resumption)与性能优化。
- 中间人攻击(MITM)与证书链、CA、信任根的角色。
- 如何在 Socket 程序中实现心跳、超时与断线重连?
- TCP Keepalive 与应用层心跳的区别和协同使用。
- 如何通过抓包(tcpdump/wireshark)诊断 Socket 连接问题?
- Socket 的非阻塞 accept/read/write 实现注意点(资源/异步安全)。
- 如何避免 TCP 粘包/拆包在 Socket 原生编程中的影响(流式协议设计)?
- 实战:实现一个带长度前缀的 TCP 协议的 Java 服务端/客户端(思路)。
- 如何优雅处理大量短连接的场景?(连接池、HTTP keep-alive)
- Socket 端口复用(SO_REUSEADDR vs SO_REUSEPORT)在负载均衡中的用法。
- 多路复用(select/poll/epoll)与 socket 大连接数的处理。
- 网络字节序(big-endian)与数据编解码注意点。
- 使用 TCP_NODELAY(禁用 Nagle)时的 CPU/网络 trade-off。
- 如何在 Java 中做到零拷贝文件传输(Socket + FileChannel.transferTo)?
- Socket 关闭流程中遇到阻塞(SO_LINGER)的处理办法。
- 如何防止或检测 “socket half-closed” 的资源泄漏?
- 实战题:用 BIO、NIO、Netty 各实现一个简易的聊天服务器,比较性能与代码复杂度(思路要点)。
七、异步 IO、IO 模型与高阶模式(141–150)
- IO 模型分类:同步阻塞、同步非阻塞、IO 多路复用、信号驱动、异步(AIO)。
- Reactor 模式与 Proactor 模式的原理与区别。
- Java AIO(AsynchronousChannel、AsynchronousSocketChannel)API 介绍。
- CompletionHandler 与 Future 风格的异步回调比较。
- 高并发下选择 NIO 还是 AIO 的实际考量(实现复杂度、平台支持)。
- 事件驱动系统的 back-pressure 设计(流控、速率限制)。
- 线程模型设计:acceptors / io workers / business workers 的权衡。
- RCU、无锁队列在高并发 IO 框架中的应用场景。
- 如何在应用层实现请求队列与排队策略来缓解突发流量?
- 实战题:设计一个支持百万连接的服务器架构(核心组件与 IO 策略)。
八、零拷贝、内核与性能调优(151–160)
- 零拷贝(zero-copy)概念和常见实现(sendfile, mmap, splice)。
- FileChannel.transferTo/transferFrom 在 Linux 下如何利用 sendfile 实现零拷贝?
- mmap(内存映射文件)的实现原理与 GC /回收问题。
- DMA(Direct Memory Access)在零拷贝中的作用与限制。
- 内核态 / 用户态拷贝次数与零拷贝带来的减少效果。
- TCP 窗口、拥塞控制调优参数(net.ipv4.tcp_* 系列常见项)。
- 系统级调优:文件描述符上限(ulimit -n)、somaxconn、backlog、epoll_limits 等。
- 高并发网络服务的监控指标(fd 使用、accept latency、context switch、cpu steal)。
- 性能诊断工具与流程:iostat, vmstat, sar, perf, tcpdump, ss, strace。
- 实战题:如何定位并修复一个高并发服务器的 accept 阻塞 / 连接丢失 问题(诊断步骤)。
如果你愿意,我可以 接着把每一道题写成“面试级详细解析”:每题包含 —— 概念简述 + 原理/底层机制 + 常见面试追问点 + 典型代码/伪代码(若适用) + 常见坑与性能考量。
为了不把单条回复弄得太长,我建议按批次输出解析(例如每批 15–20 题)。
下面我 先示范性地把第 1–10 题写成详细解析,风格如下(概念 + 要点 + 代码/命令 + 面试追问点)。如果你满意这个格式,我会继续按相同风格往下写每批题的解析。
示例:第1–10题 详细解析(示范风格)
1. Java IO 的总体体系结构是什么?
概念
Java IO 可以分为两大类:传统阻塞 IO(java.io)和 NIO(java.nio)。传统 IO 提供字节流(InputStream/OutputStream)、字符流(Reader/Writer)、以及各种装饰器(Buffered、Data、Object 等)。NIO 提供 Buffer、Channel、Selector 及更底层的文件映射(MappedByteBuffer)。
原理 / 要点
- 传统 IO 基于流(Stream)抽象,面向流式、同步、阻塞操作。
- NIO 引入 Buffer(内存块)和 Channel(可读写的端点),支持非阻塞和多路复用(Selector),更适合高并发网络 I/O。
- Java 还有 AIO(Asynchronous IO)作为异步回调风格的补充(java.nio.channels.AsynchronousChannel)。
面试追问点
- 什么时候用阻塞 IO,什么时候用 NIO?
- NIO 能否替代传统 IO?(部分场景:文件流仍然方便)
- AIO 与 NIO 的区别是什么?
2. InputStream / OutputStream 的常见子类有哪些?
要点列举(常见)
- FileInputStream / FileOutputStream(文件字节流)
- ByteArrayInputStream / ByteArrayOutputStream(内存字节流)
- BufferedInputStream / BufferedOutputStream(缓冲)
- DataInputStream / DataOutputStream(按基本类型读写)
- ObjectInputStream / ObjectOutputStream(对象序列化)
- PipedInputStream / PipedOutputStream(管道,用于线程间通信)
面试追问点
- 什么时候使用 Buffered 流?(I/O 较小且频繁时)
- DataOutputStream 写的二进制如何跨平台读?(须约定端序)
3. Reader / Writer 的常见子类有哪些?
要点列举(常见)
- FileReader / FileWriter(字符文件)
- InputStreamReader / OutputStreamWriter(字节流 ↔ 字符流 适配器)
- BufferedReader / BufferedWriter(字符缓冲)
- CharArrayReader / CharArrayWriter(基于字符数组)
面试追问点
- 为什么不用 FileReader 直接读取所有文本?(编码问题)
- InputStreamReader 如何指定编码?
4. 字节流与字符流的本质区别和使用场景?
概念
- 字节流(InputStream/OutputStream)以字节为单位处理数据,适用于二进制数据(图片、音频、视频、压缩包)。
- 字符流(Reader/Writer)以字符为单位,做了字符解码/编码,适合处理文本。字符流基于字符集(charset),内部通常由 InputStreamReader/OutputStreamWriter 做转换。
面试追问点
- 处理 UTF-8 文件时用哪种流?(用字节流 + 指定 InputStreamReader 编码,或者 Files.newBufferedReader(Path, Charset))
- 字符流会不会丢失数据?(若编码/解码不一致会)
5. 为什么要有缓冲流(BufferedInputStream / BufferedReader)?
要点
- 减少系统调用次数(read 系统调用代价高),提升 I/O 效率。
- 提供按行读取、标记/重置等高级功能(BufferedReader.readLine(), mark/reset)。
- 对小、频繁的读写操作特别有效。
代码示例
1 | try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) { |
面试追问点
- BufferedInputStream 的默认缓冲区大小是多少?(通常 8KB,但可构造时指定)
- 缓冲流什么时候可能不适合使用?(极低延迟、实时数据场景)
6. flush() 与 close() 的区别与细节(含异常处理)
要点
flush()
:将缓冲区中的数据强制写出到底层流,但流仍然可用。适合在不关闭流的情况下确保数据发送。close()
:先flush()
,再释放资源(关闭底层描述符),关闭后不能再写入。close()
可能抛出异常,要在 finally 或 try-with-resources 中处理。- try-with-resources 会自动调用 close,并会将 close 抛出的异常作为 suppressed exception 记录。
面试追问点
- 为什么要显式 flush?(网络协议的即时发送、日志写入保证等)
- close 抛出异常时如何处理 suppressed exception?(在 Java 里可通过 Throwable.getSuppressed() 查看)
7. try-with-resources 的工作原理(AutoCloseable、suppressed exceptions)
要点
- Java 7 引入,任何实现
AutoCloseable
的对象都可以在 try(…) 中声明,try 结束时会自动调用close()
。 - 若 try 块中抛出异常,close 抛出异常会被作为 suppressed 添加到初始异常上,不会覆盖初始异常。
- 编译器把 try-with-resources 翻译为 finally 块并处理 suppressed。
面试追问点
- AutoCloseable 与 Closeable 的区别?(Closeable 的 close() 声明抛 IOException;AutoCloseable 更通用)
- 多个资源按什么顺序关闭?(与声明顺序相反,即后声明先关闭)
8. 装饰器模式在 Java IO 中如何体现?举例链式包装
要点
- Java IO 将基础流包装成更复杂功能的流:例如
new BufferedReader(new InputStreamReader(new FileInputStream(path)))
,每一层都是装饰器,扩展了读取能力(缓冲、解码)。 - 装饰器允许在不改变原始类的情况下添加行为,且可任意组合。
面试追问点
- 装饰器带来的缺点?(层级过深可读性差、异常处理复杂)
9. File 类的能力与限制(能否读写数据、元数据操作)
要点
java.io.File
主要用于表示路径和进行文件/目录的元数据操作(exists, isDirectory, length, renameTo 等),它 不直接读写内容。- 读写需通过流(FileInputStream/FileOutputStream/FileChannel 等)或 NIO 的 Files 工具类。
File
的 renameTo 在不同平台上语义不一致(原子性不可保证)。
面试追问点
- Java 7 后 Files / Path 提供哪些增强?(更丰富的原子操作、拷贝、移动、文件属性)
10. RandomAccessFile 的用途、指针机制与并发注意点
要点
RandomAccessFile
允许在文件中任意位置读取/写入,通过seek(long pos)
移动文件指针。- 适合索引文件、实现简单的数据库、文件内插入/覆盖等。
- 不是线程安全的;多个线程共享同一个 RandomAccessFile 需外部同步或为每线程打开独立实例(但会增加文件描述符消耗)。
代码示例
1 | try (RandomAccessFile raf = new RandomAccessFile("data.bin", "rw")) { |
面试追问点
- RandomAccessFile 与 FileChannel 的关系?(RandomAccessFile.getChannel() 可获取 Channel;NIO 更现代)
如果这个示例的详细程度和格式符合你的期望,我会接着按相同格式把 第11–30、31–50……分批完整输出,直到把所有 160 题都写成“面试级详解”。我建议每批 15–20 题,这样查阅方便,也便于你随时检索或要求调整深度。
要我继续把接下来的题(11–30)写成详细解析吗?我可以现在开始并把下一批(11–30,共20题)一次性发给你。
当然可以 👍
Java 基础的面试题非常庞杂,我可以帮你继续扩展,补充到更多的维度,甚至超过 200+ 道。之前我给你整理了 150 道,下面我会在原有的基础上,继续补充更多(会覆盖一些面试官经常“刁钻”考的点、源码、陷阱题、实战场景):
Java 基础面试题(继续扩展)
一、语言特性 & 基础语法
- Java 中的
==
和equals()
有什么区别?如何正确重写equals()
? - 为什么重写
equals()
时必须重写hashCode()
?不重写会怎样? Object
类有哪些方法?哪些方法经常需要重写?final
、finally
、finalize()
有什么区别?- Java 中的
instanceof
关键字是如何实现的? - 接口和抽象类的区别?接口能不能有构造函数?
- 接口可以多继承吗?抽象类可以实现接口吗?
default
方法和static
方法在接口中的意义是什么?- 什么是内部类?分为哪几种?(成员内部类、静态内部类、局部内部类、匿名内部类)
- 匿名内部类和 Lambda 表达式的区别?
二、数据类型 & 内存
- Java 中的基本数据类型和包装类型有哪些区别?
- 为什么
Integer
有缓存机制?范围是多少? - 为什么
new Integer(127) == new Integer(127)
为 false,而Integer.valueOf(127) == Integer.valueOf(127)
为 true? NaN
和Infinity
在 Java 中如何表示?- 为什么浮点数计算有精度问题?如何避免?
BigDecimal
为什么能解决浮点数精度问题?- Java 中的字符集默认是什么?在不同平台上会不一样吗?
String
、StringBuilder
、StringBuffer
区别?线程安全性?- 为什么
String
是不可变的?背后实现细节? intern()
方法的作用是什么?
三、集合框架
ArrayList
和Vector
的区别?- 为什么
ArrayList
的扩容是1.5 倍
? LinkedList
是双向链表还是单向链表?HashSet
底层是如何实现的?TreeSet
和TreeMap
的底层实现是什么?PriorityQueue
的底层数据结构是什么?ConcurrentSkipListMap
的底层原理是什么?CopyOnWriteArrayList
的写时复制是怎么实现的?有什么优缺点?EnumMap
和EnumSet
是什么?为什么效率高?WeakHashMap
和HashMap
的区别?
四、异常 & 错误处理
- Java 中的异常分为哪两大类?
Error
和Exception
的区别?- 受检异常和非受检异常区别?
throw
和throws
有什么区别?try-with-resources
的底层原理是什么?- 自定义异常如何设计?继承哪个类?
- 为什么不建议捕获
Throwable
? - 异常链(Exception Chaining)是什么?
- Java 8 之后对异常处理有改进吗?
- 如何在多线程环境下正确传递异常?
五、JVM & 内存管理
- Java 内存模型(JMM)是什么?
- JVM 内存分为哪几个区域?每个区域的作用是什么?
- 为什么
String
常量池要放在堆里而不是方法区? - 什么是垃圾回收(GC)的可达性分析算法?
- 哪些对象可以作为 GC Roots?
- 强引用、软引用、弱引用、虚引用的区别?
- GC 有哪些算法?标记-清除、标记-整理、复制算法区别?
- 常见的垃圾收集器有哪些?G1、ZGC、Shenandoah 有什么特点?
- 什么是 Stop-The-World (STW)?
- JVM 调优常见参数有哪些?
六、并发编程基础
- Java 中的
volatile
关键字的底层实现原理? synchronized
的实现原理?对象头和 Monitor 是什么?- 偏向锁、轻量级锁、重量级锁的区别?
- 什么是 CAS 操作?ABA 问题如何解决?
- 什么是线程安全?举例哪些类是线程安全的?
- ThreadLocal 的底层原理是什么?为什么会有内存泄漏问题?
- 线程池的核心参数有哪些?
- 什么是拒绝策略?
- Executors 提供的几种常见线程池?
Future
和CompletableFuture
区别?
七、编译 & 运行时
- Java 的反射机制是什么?底层原理?
- 为什么反射性能差?JDK 9 之后如何优化?
- 什么是动态代理?JDK 动态代理和 CGLIB 区别?
- 注解的底层原理是什么?运行时注解如何实现?
- ClassLoader 的双亲委派模型是什么?
- 如何打破双亲委派?
- 什么是 SPI(Service Provider Interface)机制?
- Java 是如何实现跨平台的?
- JIT(即时编译器)优化了哪些东西?
- 什么是逃逸分析?
八、常见场景 & 设计
- 单例模式在 Java 中有哪些实现方式?哪种最优?
- 懒汉模式和饿汉模式的区别?
- 枚举单例为什么是最佳实践?
- 为什么要用工厂模式?
- 装饰器模式和代理模式区别?
- Java 中的事件监听模型是怎么实现的?
- 为什么 Java 不支持多继承?
- 如何用 Java 实现回调机制?
- Java 中如何实现观察者模式?
Optional
的设计初衷是什么?
二、Java 并发编程(30题)
- 线程的生命周期?
- 创建线程的方式有哪些?
- Runnable 和 Callable 的区别?
- Future 和 CompletableFuture 的区别?
- ThreadLocal 原理?
- synchronized 的锁优化机制?
- ReentrantLock 的实现原理?
- 公平锁和非公平锁区别?
- 乐观锁和悲观锁区别?
- AQS 原理?
- CountDownLatch 和 CyclicBarrier 区别?
- Semaphore 的实现原理?
- Exchanger 的使用场景?
- ForkJoinPool 的原理?
- CAS 操作的底层实现?
- ABA 问题是什么?如何解决?
- Atomic 原子类的原理?
- BlockingQueue 的实现原理?
- ConcurrentHashMap 的分段锁机制?
- ConcurrentHashMap 扩容机制?
- CopyOnWriteArrayList 的原理?
- 线程池的核心参数?
- 线程池的拒绝策略?
- 线程池的工作流程?
- ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 的区别?
- 为什么不建议使用 Executors 创建线程池?
- JMM 内存可见性问题?
- happens-before 规则?
- 死锁产生的条件?如何避免?
- 常见并发容器有哪些?区别?
三、JVM 与性能优化(30题)
- JVM 内存结构?
- 堆和栈的区别?
- 新生代、老年代、永久代(元空间)的区别?
- 对象的创建过程?
- 对象的内存分配策略?
- GC Roots 有哪些?
- 垃圾回收算法有哪些?
- CMS 垃圾回收器的工作流程?
- G1 垃圾回收器原理?
- ZGC 和 Shenandoah 的特点?
- Minor GC 和 Full GC 的区别?
- OOM 常见类型?如何排查?
- 类加载器的分类?
- 双亲委派模型的破坏场景?
- JVM 常见参数调优?
- 内存泄漏和内存溢出的区别?
- 逃逸分析是什么?
- 方法内联的原理?
- JIT 编译器的优化?
- Safepoint 机制?
- Stop The World 是什么?
- Finalize 方法的问题?
- JVM 如何判断对象可回收?
- TLAB 的作用?
- Direct Memory 为什么会 OOM?
- Metaspace 的作用?
- Full GC 触发条件?
- 如何查看 JVM 内存使用情况?
- Arthas 常用命令?
- JVM 调优常见面试题?
四、Spring & Spring Boot(30题)
- Spring 的 IoC 容器原理?
- Bean 的生命周期?
- BeanFactory 和 ApplicationContext 的区别?
- 单例 Bean 如何保证线程安全?
- Spring AOP 的实现原理?
- JDK 动态代理和 CGLIB 的区别?
- Spring 事务的传播机制?
- Spring 事务的隔离级别?
- @Transactional 的实现原理?
- 循环依赖如何解决?
- Spring 的三级缓存解决了什么问题?
- Spring MVC 的工作流程?
- DispatcherServlet 的作用?
- Spring Boot 的自动装配原理?
- Spring Boot Starter 的机制?
- @ConfigurationProperties 和 @Value 的区别?
- Spring Boot 如何实现热部署?
- Spring Boot 如何处理配置文件?
- @Conditional 注解的作用?
- Spring Boot 常见的优化点?
- Spring 中的事件机制?
- ApplicationListener 的使用?
- Spring 如何加载配置文件?
- Bean 的作用域有哪些?
- Spring 如何实现异步调用?
- Spring 中的 @Lazy 注解?
- Spring 的资源加载机制?
- Spring Boot 的内嵌容器有哪些?
- Spring Boot Actuator 的作用?
- Spring Boot 如何与 Docker/K8s 结合?
五、Spring Cloud(30题)
- Spring Cloud 是什么?
- Spring Cloud 和 Dubbo 的区别?
- Eureka 的工作原理?
- Eureka 的自我保护机制?
- Ribbon 的负载均衡策略?
- Ribbon 和 Nginx 的区别?
- Feign 的工作原理?
- Feign 如何支持熔断?
- Hystrix 的工作原理?
- Hystrix 的线程池隔离和信号量隔离?
- Resilience4j 和 Hystrix 区别?
- Gateway 的核心功能?
- Gateway 和 Zuul 的区别?
- Config Server 的作用?
- Config Server 如何保证配置实时刷新?
- Spring Cloud Bus 的原理?
- Sleuth 的作用?
- Sleuth 与 Zipkin 的关系?
- 分布式链路追踪的原理?
- Spring Cloud Stream 的作用?
- 消息驱动模型和事件驱动模型区别?
- Nacos 和 Eureka 的区别?
- Nacos 配置中心和注册中心的实现原理?
- Sentinel 的工作原理?
- 限流的常见算法(令牌桶、漏桶)?
- 熔断和降级的区别?
- CAP 定理和 Spring Cloud 的关系?
- Spring Cloud 与 Kubernetes 的关系?
- 微服务中的灰度发布?
- Spring Cloud 如何实现服务网格?
六、微服务架构 & 分布式系统(30题)
- 什么是微服务?
- 微服务与 SOA 的区别?
- 微服务的优缺点?
- 微服务的拆分原则?
- 如何保证微服务之间的数据一致性?
- 分布式事务有哪些解决方案?
- TCC、SAGA、XA 的区别?
- 本地消息表和可靠消息最终一致性?
- 分布式锁的实现方式?
- Redis 分布式锁的缺陷?
- Zookeeper 分布式锁的实现?
- 数据库分库分表策略?
- 水平拆分与垂直拆分区别?
- 分布式 ID 生成方案?
- 雪花算法的原理?
- API 网关的作用?
- 服务注册与发现机制?
- 微服务如何保证高可用?
- 微服务中的限流和熔断?
- 微服务如何保证安全性?
- OAuth2 的原理?
- JWT 的作用?
- 单点登录 SSO 的实现?
- 配置中心的作用?
- 微服务中的监控体系?
- Prometheus 的原理?
- ELK 在微服务中的应用?
- 服务调用链路追踪的意义?
- 服务雪崩效应是什么?
- 如何设计一个高并发的微服务系统?
七、数据库 & 缓存(30题)
- MySQL 的存储引擎区别?
- InnoDB 和 MyISAM 的区别?
- MySQL 的事务特性?
- MVCC 的实现原理?
- MySQL 的索引类型?
- 聚簇索引和非聚簇索引?
- B+ 树索引原理?
- 覆盖索引和回表?
- 索引下推优化?
- MySQL 的锁机制?
- 行锁和表锁区别?
- 间隙锁是什么?
- 死锁的排查方法?
- SQL 执行计划如何分析?
- 慢查询优化方法?
- 数据库分库分表的难点?
- 分布式事务如何保证?
- Redis 的数据结构?
- Redis 为什么快?
- Redis 的持久化机制?
- Redis 的过期策略?
- Redis 的内存淘汰机制?
- Redis 的主从复制原理?
- Redis 的哨兵机制?
- Redis 的集群模式?
- Redis 的分布式锁实现?
- Redis 的常见问题?
- 缓存穿透、击穿、雪崩?
- 如何保证缓存和数据库一致性?
- Redis 和本地缓存 Caffeine 的区别?
八、消息队列(30题)
- 为什么要使用消息队列?
- 消息队列的优缺点?
- RabbitMQ 的核心组件?
- RabbitMQ 的消息确认机制?
- Kafka 的核心概念?
- Kafka 的分区机制?
- Kafka 的副本机制?
- Kafka 的 ISR 原理?
- Kafka 的消费组原理?
- Kafka 的 offset 提交方式?
- Kafka 的高吞吐量原理?
- Kafka 的零拷贝机制?
- RocketMQ 的核心特点?
- RocketMQ 的顺序消息?
- RocketMQ 的事务消息?
- 如何保证消息不丢失?
- 如何保证消息不重复消费?
- 如何保证消息有序?
- 消息堆积的解决办法?
- 如何设计延迟队列?
- 死信队列的作用?
- 幂等性的实现方式?
- 消息队列的限流方式?
- 消息队列的监控?
- Kafka 和 RabbitMQ 的区别?
- Kafka 和 RocketMQ 的区别?
- Pulsar 的特点?
- 消息中间件如何保证高可用?
- 消息队列如何做水平扩展?
- 消息队列和事件总线的区别?
九、DevOps & 容器化(30题)
- CI/CD 的流程?
- Jenkins 的作用?
- Docker 的核心原理?
- Docker 镜像和容器的区别?
- Dockerfile 的常见指令?
- 镜像分层机制?
- 容器与虚拟机的区别?
- Kubernetes 的核心组件?
- Pod 的生命周期?
- Deployment 和 StatefulSet 区别?
- K8s 的服务发现机制?
- ConfigMap 和 Secret 的区别?
- Ingress 的作用?
- K8s 的水平扩缩容机制?
- K8s 的滚动更新机制?
- K8s 如何做灰度发布?
- K8s 的调度策略?
- K8s 的网络模型?
- Service Mesh 的作用?
- Istio 的工作原理?
- Helm 的作用?
- K8s 的存储方案?
- Prometheus 的监控体系?
- Grafana 的作用?
- K8s 的安全机制?
- 容器的资源限制?
- Docker Compose 的作用?
- DevOps 的最佳实践?
- GitOps 的理念?
- K8s 如何保证高可用?
十、场景设计题(30题)
- 如何设计一个秒杀系统?
- 如何设计一个短链系统?
- 如何设计一个分布式 ID 生成系统?
- 如何设计一个高可用缓存系统?
- 如何设计一个订单系统?
- 如何设计一个支付系统?
- 如何设计一个消息推送系统?
- 如何设计一个日志收集系统?
- 如何设计一个搜索引擎?
- 如何设计一个推荐系统?
- 如何设计一个分布式锁?
- 如何设计一个统一鉴权系统?
- 如何设计一个限流系统?
- 如何设计一个灰度发布系统?
- 如何设计一个电商购物车?
- 如何设计一个库存系统?
- 如何设计一个分布式文件存储系统?
- 如何设计一个微服务网关?
- 如何设计一个分布式任务调度系统?
- 如何设计一个监控报警系统?
- 如何设计一个高并发聊天室?
- 如何设计一个排行榜系统?
- 如何设计一个评论系统?
- 如何设计一个分布式事务系统?
- 如何设计一个高可用注册中心?
- 如何设计一个数据同步系统?
- 如何设计一个 API 限速系统?
- 如何设计一个高可用的支付网关?
- 如何设计一个跨境电商架构?
- 如何设计一个金融级别的微服务系统?
面试
Java基础
问题: 请详细解释Java的垃圾回收(GC)机制,以及你了解的垃圾回收算法和GC收集器。
答案: Java的垃圾回收是自动内存管理机制,它负责自动回收不再使用的对象所占用的内存。我理解的GC主要为了避免内存泄漏和OutOfMemoryError。常见的垃圾回收算法包括:
标记-清除(Mark-Sweep)算法: 标记出所有需要回收的对象,然后清除。缺点是会产生大量不连续的内存碎片。
复制(Copying)算法: 将内存分为两块,每次只使用其中一块。当这块用完了,就将存活的对象复制到另一块上,然后清空已使用的那块。缺点是内存利用率只有一半。
标记-整理(Mark-Compact)算法: 标记出所有存活的对象,然后将所有存活的对象向一端移动,最后清理掉边界以外的内存。解决了内存碎片问题。
分代收集(Generational Collection)算法: 将堆内存分为新生代和老年代。新生代中的对象生命周期短,使用复制算法。老年代中的对象生命周期长,使用标记-整理算法。
我了解的GC收集器包括:Serial、Parallel、CMS、G1等。G1收集器是一种面向服务器的垃圾收集器,它将堆内存划分为多个大小相等的区域,可以并行和并发地进行垃圾回收,并且可以预测GC暂停时间。
问题: 描述一下ArrayList和LinkedList的底层实现原理和各自的优缺点及适用场景。
答案:
- ArrayList: 底层是基于动态数组实现的。它支持快速随机访问,因为可以通过索引直接访问元素。但当添加或删除元素时,需要移动数组中的其他元素,特别是当数组容量不足时,需要进行扩容操作,效率较低。因此,
ArrayList
适用于随机访问频繁的场景。 - LinkedList: 底层是基于双向链表实现的。每个节点都包含数据、指向前一个节点的引用和指向后一个节点的引用。由于是链式存储,添加和删除元素只需要修改节点的引用,效率很高。但是,随机访问需要从头或尾遍历,效率较低。因此,
LinkedList
适用于插入和删除操作频繁的场景。
- ArrayList: 底层是基于动态数组实现的。它支持快速随机访问,因为可以通过索引直接访问元素。但当添加或删除元素时,需要移动数组中的其他元素,特别是当数组容量不足时,需要进行扩容操作,效率较低。因此,
问题: HashMap的底层数据结构是什么?它是如何工作的?在Java 8中,它有哪些改进?
答案: HashMap的底层数据结构是数组加链表,在Java 8及以后版本是数组加链表加红黑树。
- 工作原理:
HashMap
通过键的hashCode()
方法计算哈希值,然后将键值对存储在对应的数组索引位置。当哈希冲突发生时,HashMap
采用“链地址法”解决,即将冲突的键值对连接成一个链表。 - Java 8的改进: 为了解决链表过长导致的查询效率降低问题,当链表长度超过8,且数组长度超过64时,
HashMap
会将链表转换为红黑树,将O(n)
的查询复杂度优化为O(log n)
。当红黑树的节点数量少于6时,又会退化为链表。
- 工作原理:
问题: 请详细解释ThreadLocal的原理,以及它在多线程环境下如何实现线程隔离?它是否存在内存泄漏问题?
答案:
ThreadLocal
为每个线程提供一个独立的变量副本,从而实现线程隔离。它的原理是,每个Thread
对象内部都有一个ThreadLocalMap
,键是ThreadLocal
对象本身,值是我们要存储的变量。当一个线程调用ThreadLocal.set()
方法时,它会将变量存储在自己的ThreadLocalMap
中,其他线程无法访问,从而实现了线程隔离 。ThreadLocalMap
中的键是ThreadLocal
对象的弱引用,这意味着当ThreadLocal
对象没有其他强引用时,即使ThreadLocalMap
中还存在弱引用,垃圾回收器也会回收该ThreadLocal
对象。但此时ThreadLocalMap
中键为null
的值还存在,这就可能导致内存泄漏。为了避免这个问题,我们应该在使用完ThreadLocal
后,手动调用remove()
方法来清除变量。问题: 什么是Java中的锁?请详细阐述synchronized和ReentrantLock的区别。
答案: Java中的锁是用于控制多线程对共享资源访问的机制,以确保线程安全。
- synchronized: 是Java内置的悲观锁,通过
monitorenter
和monitorexit
指令实现。它是非公平锁,并且是可重入的。它的优点是使用简单,无需手动加锁和解锁,由JVM自动管理。缺点是,它是一个重量级锁,性能相对较低,并且不支持中断、超时等高级功能。 - ReentrantLock: 是
java.util.concurrent
包下的一个类,它提供了与synchronized
类似的功能,但更加灵活。它可以通过构造函数选择是公平锁还是非公平锁,并且支持可中断锁、限时锁等高级功能。使用时需要手动调用lock()
和unlock()
方法,unlock()
必须放在finally
块中以确保锁的释放。在性能方面,ReentrantLock
在竞争激烈时通常优于synchronized
。
- synchronized: 是Java内置的悲观锁,通过
问题: 谈谈你对Java多线程的理解,以及创建多线程的几种方式的优劣。
答案: 多线程允许程序同时执行多个任务,提高程序的并发性和响应速度 。
- 继承Thread类: 优点是简单直接,但缺点是Java不支持多重继承,如果继承了
Thread
类,就不能再继承其他类了。 - 实现Runnable接口: 优点是更灵活,可以避免单继承的限制,并且可以实现资源的共享。这是最常用的方式。
- 实现Callable接口: 优点是可以有返回值,并且可以抛出异常。通常与线程池结合使用,通过
Future
对象获取返回值。
- 继承Thread类: 优点是简单直接,但缺点是Java不支持多重继承,如果继承了
问题: 解释一下Java中的volatile关键字,它的作用是什么?它能保证原子性吗?
答案: volatile关键字用于修饰变量,它能保证变量在多线程之间的可见性,但不能保证原子性。
- 可见性: 当一个线程修改了
volatile
变量的值,新值会立即被刷新到主内存,并且其他线程在读取该变量时,会从主内存中读取最新值。 - 原子性:
volatile
不能保证对变量的操作是原子性的。例如,i++
不是原子操作,它包含了读取、加1、写入三个步骤,在多线程环境下,仍然可能出现问题。 - happens-before原则:
volatile
还具有happens-before
原则,即对一个volatile
变量的写操作,happens-before
后续对该变量的读操作。
- 可见性: 当一个线程修改了
问题: 描述一下Java中的异常处理机制。try-catch-finally、throw、throws分别有什么作用?
答案: Java的异常处理机制通过try-catch-finally、throw、throws等关键字来实现。
- try-catch-finally:
try
块中放置可能抛出异常的代码。如果try
块中的代码抛出异常,catch
块会捕获并处理该异常。finally
块中的代码无论是否发生异常都会被执行,通常用于释放资源。 - throw: 用于在程序中手动抛出一个异常对象。
- throws: 用于在方法签名中声明该方法可能抛出的异常,告诉调用者需要处理这些异常。
- try-catch-finally:
问题: 谈谈你对Java泛型的理解,以及泛型中的通配符?、? extends T、? super T的区别。
答案: Java泛型是在编译时检查类型安全,并在运行时实现类型擦除的一种机制。它允许我们在编写代码时使用类型参数,从而提高代码的复用性和安全性。
?
: 无界通配符,表示可以匹配任何类型。? extends T
: 上界通配符,表示可以匹配T及其子类。它只能用于读取,不能用于写入。? super T
: 下界通配符,表示可以匹配T及其父类。它只能用于写入,不能用于读取。
问题: 解释一下Java中的transient关键字的作用,以及它在对象序列化中的应用。
答案: transient关键字用于修饰类的成员变量,它告诉JVM在对象序列化时,忽略被transient修饰的变量。当对象被反序列化时,transient变量的值将被赋予其类型的默认值(如int为0,String为null)。
Spring框架
问题: 请详细解释Spring IoC(控制反转)的原理和实现方式,以及DI(依赖注入)与IoC的关系。
答案: IoC是一种设计思想,它将对象的创建和依赖关系的控制权从程序本身反转交给Spring容器 3。IoC的实现方式主要是依赖注入(DI)。
- 依赖注入: DI是IoC的具体实现,它指的是Spring容器在运行时,自动将对象所依赖的其他对象注入到该对象中。我们不需要手动创建依赖对象,而是通过
@Autowired
等注解或XML配置,让Spring容器来完成。 - 关系: DI是IoC的一种实现方式,IoC是DI的设计思想。
- 依赖注入: DI是IoC的具体实现,它指的是Spring容器在运行时,自动将对象所依赖的其他对象注入到该对象中。我们不需要手动创建依赖对象,而是通过
问题: 描述一下Spring AOP(面向切面编程)的原理,以及你了解的Joinpoint、Pointcut、Advice、Aspect等核心概念。
答案: AOP是一种编程范式,它允许开发者在不修改原有代码的情况下,通过切面(Aspect)的方式,在程序的特定连接点(Joinpoint)上插入额外的逻辑。
- Joinpoint: 连接点,表示程序执行过程中可以插入切面的点,例如方法的调用、异常的抛出等。
- Pointcut: 切入点,是对
Joinpoint
的正则
表达式匹配,用于确定在哪些Joinpoint
上应用Advice
。 - Advice: 通知,定义了在特定
Pointcut
上要执行的动作,如@Before
、@After
、@Around
等。 - Aspect: 切面,是
Pointcut
和Advice
的组合,包含了横切关注点和如何将其应用到连接点的逻辑。
问题: 你在简历中提到了Spring的三级缓存机制,请详细描述它是如何解决循环依赖问题的,并解释为什么需要三级缓存而不是一、二级缓存就够了。
答案: Spring的三级缓存机制用于解决单例模式下的循环依赖问题。它包括:
- 一级缓存(
singletonObjects
):存放完全初始化好的单例对象。 - 二级缓存(
earlySingletonObjects
):存放提前暴露的单例对象,但未进行属性填充。 - 三级缓存(
singletonFactories
):存放ObjectFactory
,用于生成代理对象。 - 解决过程: 当A依赖B,B依赖A时,Spring会先创建A的实例,并将一个
ObjectFactory
放入三级缓存中。当创建B时,发现B依赖A,会从三级缓存中获取ObjectFactory
并创建A的代理对象,注入给B。B创建完成后,将其放入一级缓存。然后Spring再回到A的创建过程,注入B,最后将A放入一级缓存,从而解决了循环依赖。 - 为什么需要三级缓存: 如果只有二级缓存,只能解决
A->B->A
的循环依赖,但无法解决A->B->A
中A需要被代理的情况。三级缓存中的ObjectFactory
可以在需要时生成代理对象,从而在不影响正常对象创建流程的情况下,解决代理对象带来的循环依赖问题。
- 一级缓存(
问题: 详细描述Spring MVC的工作流程,并解释DispatcherServlet在其中的作用。
答案: Spring MVC的工作流程大致是:
用户发送请求。
DispatcherServlet
接收所有请求,作为前端控制器 5。DispatcherServlet
根据请求找到对应的HandlerMapping
。HandlerMapping
找到对应的Controller
。Controller
调用Service
层处理业务逻辑 6。Service
层返回结果给Controller
。Controller
返回ModelAndView
给DispatcherServlet
7。DispatcherServlet
根据ModelAndView
找到对应的ViewResolver
。ViewResolver
返回View
。View将结果渲染后返回给用户。
DispatcherServlet是整个流程的核心,它负责接收请求、分发请求、协调各个组件的工作,是整个Spring MVC框架的中心。
问题: 请详细解释Spring Boot的自动配置原理,以及你了解的常用注解。
答案: Spring Boot的自动配置是通过
@EnableAutoConfiguration
注解实现的。它会根据项目的classpath
中的依赖,自动配置相应的Bean 8。- 原理:
@EnableAutoConfiguration
注解会扫描META-INF/spring.factories
文件,找到EnableAutoConfiguration
接口的所有实现类,然后根据这些实现类,结合@Conditional
注解,判断是否满足条件,如果满足,则自动配置相应的Bean。 - 常用注解:
@SpringBootApplication
(包含了@Configuration
、@EnableAutoConfiguration
、@ComponentScan
)、@RestController
、@RequestMapping
、@Autowired
等。
- 原理:
问题: 描述一下Spring的声明式事务管理,以及它是如何通过AOP实现的。
答案: 声明式事务管理是通过AOP实现的。开发者只需要使用
@Transactional
注解,Spring就会在方法的执行前后自动开启和提交/回滚事务,无需手动编写事务管理代码。- 实现原理: Spring在启动时,会通过AOP为带有
@Transactional
注解的方法生成代理对象。当调用这些方法时,代理对象会在方法执行前开启事务,如果方法正常执行,则提交事务;如果方法抛出异常,则回滚事务。
- 实现原理: Spring在启动时,会通过AOP为带有
问题: 谈谈你对Spring Cloud的理解,以及你简历中提到的服务注册、负载均衡和熔断降级。
答案: Spring Cloud是一套基于Spring Boot的微服务框架 10。
服务注册: 每个微服务在启动时会向注册中心(如
Eureka
)注册自己的信息,包括服务名称、IP地址、端口号等,以便其他服务能够发现它 11。负载均衡: 当一个服务有多个实例时,
Spring Cloud
会使用负载均衡器(如Ribbon
)将请求分发到不同的实例上,以达到负载均衡的效果。它支持多种负载均衡策略,如轮询、随机等 12。熔断降级: 当某个服务调用失败次数达到阈值时,熔断机制(如
Hystrix
)会阻止对该服务的进一步调用,并返回一个预设的默认值(降级),避免整个系统因一个服务的故障而崩溃 13。
问题: 描述一下Spring IoC容器管理Bean的生命周期。
答案: Spring IoC容器管理Bean的生命周期包括:
- 实例化: 容器根据配置创建Bean实例。
- 属性注入: 容器注入Bean的依赖。
- 初始化: 调用Bean的初始化方法(如
@PostConstruct
注解修饰的方法)。 - 使用: Bean可以被程序使用。
- 销毁: 容器关闭时,调用Bean的销毁方法(如
@PreDestroy
注解修饰的方法)。
问题: 解释一下@Controller、@RestController、@Service、@Repository注解的区别。
答案:
- @Controller: 用于标识
Controller
层,通常与@RequestMapping
结合使用,返回ModelAndView
或视图名称。 - @RestController: 是
@Controller
和@ResponseBody
的组合注解。用于标识Controller
层,并自动将返回值序列化为JSON
或XML
格式,通常用于开发RESTful
风格的API。 - @Service: 用于标识
Service
层,处理业务逻辑。 - @Repository: 用于标识
Dao
层,用于数据库访问。
- @Controller: 用于标识
问题: 在你的项目中,如何处理Spring中的事务传播行为?请举例说明。
答案: 在Spring中,我可以通过@Transactional注解的propagation属性来控制事务的传播行为。
Propagation.REQUIRED
: 如果当前没有事务,就创建一个新事务;如果当前有事务,就加入到这个事务中。这是默认的传播行为。Propagation.REQUIRES_NEW
: 总是创建一个新事务,如果当前存在事务,就将当前事务挂起。Propagation.SUPPORTS: 如果当前有事务,就加入到这个事务中;如果没有,就以非事务方式执行。
例如,在Service层的方法上加上@Transactional(propagation = Propagation.REQUIRES_NEW),可以确保该方法总是在一个新事务中执行。
数据库
问题: 解释一下数据库事务的ACID特性,并描述每种特性在数据库中的作用。
答案: 数据库事务的ACID特性是指:
- 原子性(Atomicity): 事务是一个不可分割的工作单位,要么全部执行成功,要么全部失败。
- 一致性(Consistency): 事务执行前后,数据库从一个一致性状态变为另一个一致性状态。
- 隔离性(Isolation): 多个事务并发执行时,一个事务的执行不应被其他事务干扰。
- 持久性(Durability): 事务提交后,对数据库的修改是永久性的,即使系统崩溃,也不会丢失。
问题: 你熟悉MySQL数据库,能详细解释一下InnoDB和MyISAM存储引擎的区别和各自的适用场景吗?
**答案:**我熟悉
InnoDB
和MyISAM
存储引擎。- InnoDB:
- 事务: 支持事务,符合ACID特性。
- 锁: 支持行级锁,并发性能高。
- 外键: 支持外键约束。
- 适用场景: 适用于对数据完整性和并发性要求较高的OLTP(在线事务处理)应用。
- MyISAM:
- 事务: 不支持事务。
- 锁: 只支持表级锁,并发性能差。
- 外键: 不支持外键约束。
- 适用场景: 适用于只读或以插入操作为主的OLAP(在线分析处理)应用。
- InnoDB:
问题: 描述一下数据库的四种隔离级别,以及它们各自解决了什么问题,又带来了什么问题。
答案: 数据库的四种隔离级别从低到高分别是:
- 读未提交(Read Uncommitted): 允许一个事务读取另一个事务未提交的数据。会带来脏读、不可重复读、幻读问题。
- 读已提交(Read Committed): 只允许一个事务读取另一个事务已提交的数据。解决了脏读问题,但会带来不可重复读、幻读问题。
- 可重复读(Repeatable Read): 保证一个事务在多次读取同一数据时,结果始终一致。解决了脏读、不可重复读问题,但会带来幻读问题。这是MySQL的默认隔离级别。
- 串行化(Serializable): 强制事务串行执行。解决了所有并发问题,但并发性能极低。
问题: 谈谈你对数据库索引的理解,以及B树和B+树的区别。
答案: 索引是一种数据结构,可以帮助数据库快速查找数据,从而提高查询性能。
- B树(B-Tree): 是一种多路平衡查找树,每个节点都包含键和值,并且子节点数量多于2个。它适用于数据库索引,因为可以减少磁盘I/O次数。
- B+树(B+Tree): 是B树的变种,它只在叶子节点存储数据,所有叶子节点构成一个有序链表。非叶子节点只存储键,不存储值。
- 区别: B+树的查询效率更高,因为它只需要遍历到叶子节点就能找到所有数据。同时,B+树的叶子节点构成的有序链表,使得范围查询更加高效。
问题: 在你的项目中,如何实现数据库分库分表?它解决了什么问题?
答案: 在
prism vision
短剧平台项目中,我根据模块不同进行了分库分表 16。实现方式: 我使用了
ShardingSphere
或Mycat
等中间件,根据业务需求,将用户数据、短剧内容数据分别存放在不同的库中,再对每个库中的表进行水平切分。解决问题: 分库分表解决了数据库单库单表的性能瓶颈,提高了数据库的并发处理能力和系统性能,降低了服务器压力 17。它还能避免单点故障,提高系统的可用性。
问题: MyBatis作为持久层框架,它的动态SQL是如何实现的?请举例说明。
答案:
MyBatis
的动态SQL是通过XML映射文件中的标签实现的,如<if>
、<choose>
、<when>
、<otherwise>
、<foreach>
等 18。举例:
1
2
3
4
5
6
7
8
9
10
11<select id="findUserByCondition" resultType="User">
SELECT * FROM user
<where>
<if test="id != null">
AND id = #{id}
</if>
<if test="name != null">
AND name LIKE concat('%', #{name}, '%')
</if>
</where>
</select>
这段代码会根据传入的参数是否为空,动态地生成不同的
SQL
语句,避免了手动拼接SQL
字符串的麻烦。问题: 解释一下Redis的持久化机制,以及你了解的两种持久化方式的优缺点。
答案: Redis的持久化机制是将内存中的数据保存到磁盘上,以防止数据丢失。主要有两种方式:
- RDB(Redis Database):
RDB
是快照持久化,它会在指定的时间间隔内将内存中的数据生成一个快照文件,保存到磁盘上。- 优点: 占用空间小,恢复速度快。
- 缺点: 如果
Redis
宕机,可能会丢失最后一次快照之后的数据。
- AOF(Append Only File):
AOF
是增量持久化,它会将所有写操作命令以追加的方式保存到文件中。- 优点: 数据安全性高,可以通过
aof-rewrite
命令压缩文件。 - 缺点: 文件体积大,恢复速度慢。
- 优点: 数据安全性高,可以通过
- RDB(Redis Database):
问题: 在你的prism vision项目中,你使用Redis作为分布式缓存,能具体谈谈它的作用以及如何提升系统性能?
答案: 在prism vision项目中,我使用Redis作为分布式缓存,主要作用是缓存热门短剧信息、用户数据等。
作用: 当用户请求这些数据时,系统首先从
Redis
中查找,如果命中则直接返回,避免了对数据库的频繁访问,从而大大提升了系统响应速度和并发能力。提升性能:
Redis
是内存数据库,读写速度远超磁盘数据库。通过将热点数据存放在Redis
中,可以有效减轻数据库的压力,提高系统的吞吐量和并发能力。
问题: 描述一下Redis的分布式锁如何实现,以及在实现过程中需要注意哪些问题?
答案: Redis的分布式锁可以通过SETNX(SET if Not eXists)命令来实现。
- 实现步骤:
- 客户端A尝试使用
SETNX lock_key my_unique_id
命令获取锁。 - 如果返回1,表示获取锁成功,并设置锁的过期时间。
- 如果返回0,表示获取锁失败,客户端A需要重试。
- 客户端A尝试使用
- 注意事项:
- 死锁问题: 如果客户端A在获取锁后宕机,锁将永远无法释放。因此,必须为锁设置过期时间。
- SETNX和EXPIRE的非原子性: 如果在执行
SETNX
后,EXPIRE
之前宕机,仍会发生死锁。因此,需要使用Redis
2.6.12版本以上提供的SET key value [EX seconds]
命令,将SETNX
和EXPIRE
合并成一个原子操作。 - 误删锁问题: 如果客户端A获取锁后,因业务处理时间过长,导致锁过期被释放,客户端B获取了锁。此时客户端A处理完成,调用
DEL
命令删除锁,就会误删客户端B的锁。因此,需要在value
中存储一个唯一的ID
,在删除锁时进行校验。
- 实现步骤:
问题: 谈谈你对MySQL慢查询日志的理解,以及如何使用它进行数据库性能优化。
答案: MySQL慢查询日志记录了所有执行时间超过long_query_time阈值的SQL语句。
- 作用: 通过分析慢查询日志,可以找出性能瓶颈所在的
SQL
语句,然后针对性地进行优化。 - 优化步骤:
- 开启慢查询日志: 在
my.cnf
中配置slow_query_log=1
和long_query_time=1
。 - 分析日志: 使用
mysqldumpslow
或pt-query-digest
等工具分析慢查询日志。 - 优化SQL:
- 为
WHERE
、ORDER BY
、GROUP BY
子句中的字段创建索引。 - 避免在
WHERE
子句中使用OR
、!=
、LIKE '%xxx%'
等操作符。 - 避免全表扫描。
- 使用
EXPLAIN
分析SQL
语句的执行计划。 - 优化表结构和数据类型。
- 为
- 开启慢查询日志: 在
项目经验
问题: 在你的prism vision短剧平台项目中,你是如何实现JWT认证和双令牌刷新策略的?
答案: 在
prism vision
项目中,我使用JWT
(JSON Web Token)实现了用户认证。认证流程: 用户登录成功后,我会生成一个
access token
和一个refresh token
。access token
用于访问受保护资源,并设置较短的过期时间(如15分钟)。refresh token
用于刷新access token
,并设置较长的过期时间(如7天)。双令牌刷新: 当
access token
过期后,客户端会使用refresh token
向认证服务器请求新的access token
。服务器验证refresh token
的有效性,如果有效,则生成新的access token
和refresh token
返回给客户端,从而实现了无感刷新和系统安全。
问题: 描述一下prism vision短剧平台中的RBAC权限控制是如何实现的?
答案: 在
prism vision
项目中,我基于RBAC
(Role-Based Access Control)模型实现了权限控制 22。- 实现模型: 我定义了
用户
、角色
、权限
三个实体,并建立了它们之间的关联关系。一个用户可以拥有一个或多个角色,一个角色可以拥有一个或多个权限。 - 权限校验: 在用户访问资源时,我会通过拦截器或
AOP
,检查用户是否拥有对应的角色和权限,从而实现精细的权限控制。
- 实现模型: 我定义了
问题: 你在prism vision项目中使用了雪花算法生成用户ID,能详细解释一下雪花算法的原理吗?
答案: 雪花算法是一种分布式
ID
生成算法,它生成的ID
是一个64位的长整型 23。ID构成:
- 1位:符号位,永远为0。
- 41位:时间戳,精确到毫秒,可以支持69年。
- 10位:工作机器
ID
,可以支持1024台机器。 - 12位:序列号,用于在同一毫秒内生成不同的
ID
,可以支持每毫秒生成4096个ID
。
优点: 生成的
ID
是全局唯一且趋势递增的,可以用于数据库主键,避免了ID
冲突和数据页分裂问题。
问题: 在prism vision项目中,你提到了CDN加速,它具体是如何工作的?
答案: 我使用
CDN
(Content Delivery Network)来加速短剧视频流和媒体资源 25。工作原理:
CDN
服务商在全球部署了大量的加速节点。当用户请求短剧视频等静态资源时,CDN
会根据用户的地理位置,将请求导向离用户最近的加速节点。如果节点上没有该资源,它会从源站拉取,并缓存起来。优点:
CDN
可以提供低延迟、高流畅的观看体验,同时减轻源站服务器的压力 26。
问题: NGINX在你的prism vision项目中扮演了什么角色?
答案: 在
prism vision
项目中,NGINX
作为Web
服务器和反向代理服务器 27。- 反向代理: 它作为统一入口,接收所有外部请求,并根据配置将请求转发到不同的后端服务上。
- 负载均衡: 当后端服务有多个实例时,
NGINX
可以实现负载均衡,将请求分发到不同的实例上,提高系统的可用性和性能。 - 静态资源托管:
NGINX
可以托管静态资源,如图片、CSS
、JavaScript
等,减轻后端服务器的压力。 - SSL卸载:
NGINX
还可以处理SSL/TLS
加密,将加密请求转发给后端,减轻后端服务的加密解密负担。
问题: 在视频管理系统项目中,你使用Session技术存储登录用户信息,这种方式有什么优缺点?
答案: 在
视频管理系统
项目中,我使用Session
技术存储登录用户信息 28。- 优点: 用户信息存储在服务器端,相对安全。
- 缺点:
- 服务器压力:
Session
会占用服务器内存,当并发用户量大时,会给服务器带来很大的压力。 - 分布式问题: 在分布式环境下,
Session
无法共享。需要使用Spring Session
结合Redis
等技术来解决。
- 服务器压力:
问题: Hutool在视频管理系统项目中是如何用于验证码验证的?
答案: 在
视频管理系统
项目中,我使用Hutool
工具包生成验证码,并将验证码的值存储在Session
中 29。验证流程:
- 后端生成验证码图片和验证码的值,将值存储在
Session
中,并将图片返回给前端。 - 前端用户输入验证码,并提交表单。
- 后端从
Session
中取出验证码,与用户输入的值进行比较。 - 如果一致,则验证通过;否则,验证失败,从而防止恶意访问数据库。
- 后端生成验证码图片和验证码的值,将值存储在
问题: 在你的prism vision项目中,你是如何处理日志记录与异常的?
答案: 我实现了统一的异常捕获机制,使用
@ControllerAdvice
或@RestControllerAdvice
注解来处理全局异常,并返回统一的JSON
格式的错误信息。日志记录: 我使用了
Log4j2
或Slf4j
等日志框架,记录系统运行日志,包括请求日志、业务日志、异常日志等,方便排查问题 。日志级别: 我根据不同的日志信息,使用不同的日志级别(如
INFO
、WARN
、ERROR
)进行记录,方便后期分析。
问题: 你在prism vision项目中使用了Git进行版本管理,能谈谈你常用的Git命令和工作流程吗?
答案: 我常用的
Git
命令包括git add
、git commit
、git push
、git pull
、git branch
、git checkout
、git merge
、git rebase
等 33。- 工作流程: 我通常遵循
Git Flow
工作流。- 创建新分支:
git checkout -b feature/xxx
。 - 进行开发。
- 提交代码:
git add .
、git commit -m "xxx"
。 - 推送到远程仓库:
git push origin feature/xxx
。 - 发起
Pull Request
,进行代码审核。 - 合并到主分支,发布版本。
- 创建新分支:
- 工作流程: 我通常遵循
问题: 描述一下你在视频管理系统项目中是如何使用Axios完成增量更新和文件上传的?
答案: 在视频管理系统
项目中,我使用Axios
发送HTTP
请求 34。
增量更新: 对于增量更新,我使用
Axios
的GET
或POST
请求,获取最新的数据并更新页面局部内容,而不是刷新整个页面。文件上传: 对于文件上传,我将文件封装在
FormData
对象中,然后使用Axios
发送POST
请求到后端,后端接收并保存文件 35。
计算机基础与网络
问题: 解释一下TCP和UDP协议的区别,以及各自的适用场景。
答案:
- TCP(Transmission Control Protocol): 面向连接的、可靠的、基于字节流的传输层协议。它通过三次握手建立连接,四次挥手断开连接,保证数据传输的可靠性。
- 适用场景: 对数据完整性要求高、对速度要求不高的场景,如
HTTP
、HTTPS
、FTP
等。
- 适用场景: 对数据完整性要求高、对速度要求不高的场景,如
- UDP(User Datagram Protocol): 无连接的、不可靠的、基于数据报的传输层协议。它不保证数据包的顺序和完整性,但传输速度快。
- 适用场景: 对速度要求高、对数据完整性要求不高的场景,如在线视频、
DNS
、RTP
等。
- 适用场景: 对速度要求高、对数据完整性要求不高的场景,如在线视频、
- TCP(Transmission Control Protocol): 面向连接的、可靠的、基于字节流的传输层协议。它通过三次握手建立连接,四次挥手断开连接,保证数据传输的可靠性。
问题: 描述一下HTTP和HTTPS的区别,以及HTTPS是如何保证通信安全的。
答案:
HTTP
是超文本传输协议,以明文方式传输数据,不安全 36。HTTPS
是在HTTP
的基础上加入了SSL/TLS
协议,对数据进行加密传输,提供了身份验证、数据完整性和保密性,更加安全 。- HTTPS保证安全:
- 加密:
HTTPS
使用对称加密和非对称加密结合的方式,对传输数据进行加密。 - 认证: 服务器会向客户端发送数字证书,客户端验证证书的有效性,确保通信方是可信的。
- 完整性:
SSL/TLS
协议使用MAC
(消息认证码)来验证数据的完整性,确保数据在传输过程中没有被篡改。
- 加密:
- HTTPS保证安全:
问题: 谈谈你对Linux操作系统的理解,以及你掌握的常用命令。
答案: 我熟悉
Linux
操作系统,掌握了常用的命令操作,如文件管理、权限控制等 38。- 常用命令:
ls
(列出文件)、cd
(切换目录)、mkdir
(创建目录)、rm
(删除文件)、cp
(复制文件)、mv
(移动文件)、cat
(查看文件内容)、tail
(查看文件末尾内容)、grep
(查找文件内容)、chmod
(修改文件权限)、chown
(修改文件所有者)等。
- 常用命令:
问题: 解释一下DNS(域名系统)的工作原理。
答案: DNS是一个分布式数据库,它将域名(如www.google.com)转换为IP地址。
- 工作流程:
- 用户在浏览器输入域名。
- 浏览器首先检查本地
hosts
文件和DNS
缓存。 - 如果找不到,向本地
DNS
服务器发起DNS
查询。 - 本地
DNS
服务器向根域名服务器查询。 - 根域名服务器返回
.com
域名服务器的地址。 - 本地
DNS
服务器向.com
域名服务器查询。 .com
域名服务器返回google.com
域名服务器的地址。- 本地
DNS
服务器向google.com
域名服务器查询。 google.com
域名服务器返回www.google.com
的IP
地址。- 本地
DNS
服务器将IP
地址返回给浏览器,并缓存起来。 - 浏览器使用
IP
地址访问Web
服务器。
- 工作流程:
问题: 谈谈你对RESTful API的理解,以及你如何在项目中设计RESTful接口。
答案: RESTful API是一种设计风格,它使用统一的URL来表示资源,并使用HTTP方法(GET、POST、PUT、DELETE)来对资源进行操作。
- 设计原则:
- 资源: 使用名词来表示资源,如
/users
、/products
。 - HTTP方法: 使用
HTTP
方法来表示对资源的操作,如GET
(查询)、POST
(创建)、PUT
(更新)、DELETE
(删除)。 - 状态码: 使用
HTTP
状态码来表示请求结果,如200(成功)、201(创建成功)、404(资源未找到)、500(服务器错误)。
- 资源: 使用名词来表示资源,如
- 项目实践: 在项目中,我使用
@RestController
注解,并结合@RequestMapping
、@GetMapping
、@PostMapping
等注解来设计RESTful
接口。
- 设计原则:
问题: 描述一下JWT(JSON Web Token)的结构和工作原理。
答案: JWT是一个开放标准,它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。JWT由三部分组成:Header、Payload和Signature。
- Header: 包含
Token
的类型和签名算法。 - Payload: 包含了一系列声明(
claim
),用于存储用户信息、过期时间等。 - Signature: 由
Header
和Payload
以及一个密钥进行签名,用于验证Token
的完整性。 - 工作原理: 客户端登录成功后,服务器会生成一个
JWT
并返回给客户端。客户端在后续请求中,将JWT
放在HTTP Header
中发送给服务器。服务器接收到请求后,会验证JWT
的签名,如果有效,则从Payload
中获取用户信息。
- Header: 包含
综合能力与未来发展
问题: 你在简历中提到了Vue,能详细描述一下你对前端技术的掌握程度吗?
答案: 我对前端技术有一定的了解,能够使用
Vue
完成前后端数据的绑定,使用Element Plus
等组件库快速搭建页面。我能够通过Vue
的路由、组件化、状态管理等特性,构建单页应用。虽然我的主要方向是Java
后端开发,但我具备与前端开发人员沟通需求、协作开发的能力 40。问题: 你在自我评价中提到自学能力强,能举个例子吗?
答案: 我对技术有浓厚兴趣,自学能力强 44。例如,在项目中需要使用
Spring Cloud
时,我通过阅读官方文档、博客、视频教程等方式,快速掌握了服务注册、负载均衡、熔断降级等核心概念,并成功地将其应用到项目中 45。我还自学了Redis
、MySQL
的分库分表等技术,并应用到实际项目中 46。问题: 你对未来有什么职业规划?
答案: 我希望能够成为一名优秀的全栈工程师,不仅在后端技术方面深入研究,也希望能够掌握更多的前端技术。我将持续学习,不断提升自己的技术能力,关注行业发展趋势,为公司创造更大的价值。
JVM与内存管理
问题: 描述一下JVM的运行时数据区域,并解释每个区域的作用。
答案: JVM的运行时数据区域主要分为五块:
* 程序计数器(Program Counter Register): 一块较小的内存空间,用于存储当前线程执行的字节码指令的地址。它是唯一一个在JVM中不会出现OutOfMemoryError的区域。
* Java虚拟机栈(Java Virtual Machine Stacks): 每个线程私有的,用于存储栈帧(Stack Frame),每个栈帧包含局部变量表、操作数栈、动态连接、方法出口等信息。StackOverflowError和OutOfMemoryError可能在此区域发生。
* 本地方法栈(Native Method Stacks): 与虚拟机栈类似,但它为Native方法服务。
* Java堆(Java Heap): 线程共享的区域,是JVM管理的最大一块内存,用于存放对象实例。这是垃圾回收的主要区域。OutOfMemoryError最常发生在此区域。
* 方法区(Method Area): 线程共享的区域,用于存储已被JVM加载的类信息、常量、静态变量等。在JDK 8中,方法区被元空间(Metaspace)取代,元空间使用本地内存,不再受JVM堆大小的限制。
问题: 谈谈你对Java内存模型的理解,它解决了什么问题?
答案: Java内存模型(JMM)定义了线程如何以及何时可以看到其他线程写入共享变量的值。它解决了多线程并发访问共享变量时,因缓存不一致导致的可见性问题和有序性问题。JMM规定了以下主要内容:
* 主内存(Main Memory): 所有线程共享的内存区域,用于存储共享变量。
* 工作内存(Working Memory): 每个线程私有的内存区域,用于存储该线程使用的共享变量的副本。
* 同步规则: JMM定义了线程之间如何通过synchronized、volatile等关键字进行通信,确保操作的可见性和有序性。它通过happens-before原则来保证操作的有序性。
问题: 解释一下Java中的类加载机制,以及双亲委派模型。
答案: Java的类加载机制是JVM将class文件加载到内存,并对其进行校验、准备、解析、初始化,最终形成可被虚拟机直接使用的Java.lang.Class对象。
* 加载过程:
加载(Loading): 通过类的全限定名获取定义此类的二进制字节流。
验证(Verification): 确保加载的
class
文件的字节流符合JVM规范。准备(Preparation): 为类的静态变量分配内存,并设置默认初始值。
解析(Resolution): 将常量池中的符号引用替换为直接引用。
初始化(Initialization): 执行类构造器
()方法,为静态变量赋初始值。 * 双亲委派模型: 是一种类加载器的层次结构。当一个类加载器收到加载类的请求时,它会首先把这个请求委派给它的父类加载器。只有当父类加载器无法完成加载时,子加载器才会尝试自己加载。这种模型的好处是,可以避免类的重复加载,并保证Java核心API的类不会被随意替换。
问题: 如何对JVM进行性能调优?你了解哪些JVM参数?
答案: JVM性能调优通常涉及调整堆内存大小、垃圾回收器选择、线程池配置等。
* 常见调优步骤:
监控: 使用
JVisualVM
、JConsole
、arthas
等工具监控JVM的内存、CPU、GC等指标。分析: 根据监控数据,分析是否存在内存泄漏、
GC
频繁、CPU
使用率过高等问题。调整:
堆内存: 使用
-Xms
和-Xmx
设置堆的初始和最大内存。GC收集器: 根据应用类型选择合适的
GC
收集器,如G1
或ZGC
。GC日志: 通过-XX:+PrintGCDetails等参数打印详细的GC日志,方便分析。
* 常用JVM参数:
-Xms<size>
: 设置堆的初始大小。-Xmx<size>
: 设置堆的最大大小。-Xmn<size>
: 设置新生代的大小。-XX:MetaspaceSize
、-XX:MaxMetaspaceSize
: 设置元空间的大小。-XX:+UseG1GC
: 启用G1
垃圾回收器。-XX:+PrintGCDetails
: 打印详细GC
日志。
Java并发与高并发
问题: 解释一下java.util.concurrent包中的核心组件,并举例说明你在项目中的应用。
答案: java.util.concurrent包提供了丰富的并发编程工具类,包括:
* ExecutorService和ThreadPoolExecutor: 线程池,用于管理和复用线程。在prism vision项目中,我使用ThreadPoolExecutor来管理短剧视频转码任务,避免了频繁创建和销毁线程的开销。
* BlockingQueue: 阻塞队列,用于生产者-消费者模式。在消息队列的实现中,我使用ArrayBlockingQueue来存储待处理的消息,当队列满时,生产者线程会被阻塞。
* CountDownLatch: 倒计时门闩,用于控制多个线程等待所有其他线程执行完毕。在项目中,我使用CountDownLatch来等待多个子任务完成后,再进行下一步操作。
* CyclicBarrier: 循环栅栏,用于让一组线程达到一个共同点时再继续执行。
* Semaphore: 信号量,用于控制对共享资源的访问数量。
* Future和FutureTask: 用于获取异步任务的执行结果。
问题: 详细解释一下synchronized和ReentrantLock的底层实现机制,以及它们在性能上的区别。
答案:
* synchronized: 是Java内置的悲观锁,底层通过monitorenter和monitorexit指令实现。在JDK 1.6之后,synchronized引入了锁升级机制,从无锁、偏向锁、轻量级锁到重量级锁,以减少锁竞争时的开销。
* ReentrantLock: 是AQS(AbstractQueuedSynchronizer)的实现类。AQS是一个队列同步器,它通过CAS操作和volatile变量维护一个同步状态,当线程获取锁失败时,会被封装成一个节点,并加入到AQS的等待队列中。
* 性能区别: 在锁竞争不激烈时,synchronized通过偏向锁和轻量级锁的优化,性能可以和ReentrantLock持平,甚至更好。但在锁竞争激烈时,ReentrantLock由于AQS的队列机制,可以更公平地进行锁的获取,并且提供了可中断锁、限时锁等高级功能,性能通常优于synchronized。
问题: 什么是线程死锁?如何避免死锁的发生?
答案: 线程死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法继续执行。
* 死锁的四个必要条件:
互斥条件: 一个资源每次只能被一个线程使用。
请求与保持条件: 一个线程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件: 一个线程已获得的资源,在未使用完之前,不能被强行剥夺。
循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。
* 避免死锁: 只要破坏这四个条件中的一个或多个即可。最常用的方法是破坏循环等待条件,例如通过规定资源的获取顺序。
问题: 谈谈你对高并发的理解,以及你如何在项目中处理高并发问题。
答案: 高并发是指系统在同一时间点能够处理大量请求的能力。在我的项目中,我通过以下方式处理高并发问题:
* 前端: 使用CDN加速静态资源,使用NGINX进行负载均衡。
* 后端:
缓存: 使用
Redis
作为分布式缓存,减少数据库访问压力。异步处理: 使用
MQ
(消息队列)将耗时任务异步化,提高系统响应速度。线程池: 使用线程池管理线程,避免频繁创建和销毁线程的开销。
限流、降级、熔断: 使用Hystrix或Sentinel等组件,对接口进行限流、降级和熔断,保证系统的可用性。
* 数据库: 使用数据库连接池,分库分表,读写分离,优化SQL查询。
问题: volatile和synchronized有什么区别?volatile能代替synchronized吗?
答案:
* 区别:
作用:
volatile
保证可见性和有序性,但不保证原子性。synchronized
既保证可见性、有序性,也保证原子性。锁:
synchronized
是悲观锁,会阻塞线程。volatile
是轻量级的,不会阻塞线程。使用范围: volatile只能修饰变量,synchronized可以修饰方法和代码块。
* 替代性: volatile不能完全代替synchronized。synchronized可以保证对共享资源的原子性操作,而volatile不能。例如,i++不是原子操作,即使使用volatile修饰,在多线程环境下仍然可能出现问题。
问题: 解释一下CAS(Compare-and-Swap)的原理,以及它在并发编程中的应用。
答案: CAS是一种乐观锁的实现机制,它包含三个操作数:内存位置V、旧的预期值A和新的值B。当且仅当V处的值等于A时,才用B更新V的值,否则不进行任何操作。这个过程是原子性的。
* 应用: 在java.util.concurrent包中,很多类的实现都依赖于CAS,例如AtomicInteger。它通过CAS操作,在不使用锁的情况下,实现了对共享变量的原子性更新。
微服务与分布式
问题: 谈谈你对微服务的理解,它与单体应用相比,有什么优缺点?
答案: 微服务是一种架构风格,它将一个大型的应用拆分成多个独立运行的小服务,每个服务都运行在独立的进程中,并使用轻量级通信机制相互协作。
* 优点:
独立部署: 每个服务都可以独立部署,互不影响。
技术栈灵活: 每个服务可以使用不同的技术栈。
高可用: 单个服务故障不会影响整个系统。
扩展性好: 可以根据需要对单个服务进行水平扩展。
* 缺点:
运维复杂: 服务数量增多,运维和监控变得复杂。
分布式问题: 需要处理分布式事务、服务治理等问题。
开发成本高: 需要更多的技术栈来支持微服务架构。
问题: 在你的项目中,如何实现分布式事务?你了解哪些分布式事务解决方案?
答案: 分布式事务是指在分布式系统中,多个独立的事务共同完成一个业务操作。
* 项目实践: 我通常使用Seata框架来解决分布式事务问题。Seata支持AT、TCC、Saga、XA等多种模式。
* 解决方案:
- 2PC(两阶段提交): 在
XA
模式中,有一个事务协调者,负责协调多个事务参与者,保证所有参与者要么都提交,要么都回滚。缺点是性能差,容易出现单点故障。 - TCC(Try-Confirm-Cancel): 将一个业务操作分为三个阶段:
Try
阶段尝试执行,Confirm
阶段确认执行,Cancel
阶段取消执行。优点是性能好,但需要业务代码支持。 - Saga模式: 将一个长事务分解为多个短事务,每个短事务都有一个对应的补偿操作。当某个短事务失败时,会通过补偿操作来撤销之前已执行的短事务。
- MQ(消息队列): 通过消息队列来实现最终一致性。当一个服务执行成功后,发送一条消息,其他服务消费这条消息并执行相应的操作。
- 2PC(两阶段提交): 在
问题: 解释一下API Gateway的作用,以及你在项目中如何使用它。
答案: API Gateway是微服务架构中的一个重要组件,它作为所有微服务请求的统一入口。
* 作用:
路由: 将外部请求路由到对应的微服务。
认证与授权: 统一处理用户的认证和授权。
限流与熔断: 对请求进行限流、熔断,保护后端服务。
日志与监控: 统一记录请求日志,方便监控。
* 项目实践: 在prism vision项目中,我使用Spring Cloud Gateway作为API Gateway,配置了路由规则、过滤器,实现了统一认证、日志记录和限流等功能。
问题: 什么是RPC?你了解哪些RPC框架?
答案: RPC(Remote Procedure Call)是远程过程调用,它允许程序调用位于不同地址空间(通常是网络上的另一台计算机)中的过程,就像调用本地过程一样。
* RPC框架:
- Dubbo: 阿里巴巴开源的
RPC
框架,支持多种协议和负载均衡策略。 - gRPC:
Google
开源的高性能RPC
框架,基于HTTP/2
协议,使用Protocol Buffers
作为序列化协议。 - Thrift:
Facebook
开源的跨语言RPC
框架。
- Dubbo: 阿里巴巴开源的
问题: 你在项目中使用了Nacos,请详细解释一下Nacos的作用。
答案: Nacos是一个开源的服务发现、配置管理和服务治理平台。
* 服务注册与发现: 每个微服务在启动时向Nacos注册自己的信息,Nacos会维护一个服务实例列表。当一个服务需要调用另一个服务时,会向Nacos查询该服务的实例列表,从而实现服务发现。
* 配置管理: Nacos可以作为配置中心,将应用的配置信息统一管理。当配置发生变更时,Nacos会通知所有订阅了该配置的服务,实现配置的热更新。
设计模式与架构
问题: 什么是设计模式?请举例说明你在项目中常用的设计模式。
答案: 设计模式是软件设计中,针对特定问题的通用、可重用的解决方案。
* 常用设计模式:
- 单例模式: 确保一个类只有一个实例,并提供一个全局访问点。例如,我在项目中封装了
Redis
工具类,通过单例模式确保只有一个Redis
连接实例。 - 工厂模式: 定义一个创建对象的接口,让子类决定实例化哪一个类。例如,在支付业务中,根据不同的支付类型(
支付宝
、微信
)创建不同的支付对象。 - 代理模式: 为某个对象提供一个代理,以控制对这个对象的访问。例如,
Spring AOP
就是基于代理模式实现的。 - 策略模式: 定义一系列算法,把它们封装起来,并且使它们可以相互替换。例如,在促销活动中,根据不同的活动类型(满减、打折)使用不同的策略。
- 单例模式: 确保一个类只有一个实例,并提供一个全局访问点。例如,我在项目中封装了
问题: 解释一下MVC和MVVM架构模式的区别。
答案:
* MVC(Model-View-Controller):
Model
: 负责数据和业务逻辑。View
: 负责展示数据。Controller: 负责接收用户输入,调用Model进行处理,并将结果返回给View。
* MVVM(Model-View-ViewModel):
Model
: 负责数据和业务逻辑。View
: 负责展示数据,并通过数据绑定(Data Binding
)与ViewModel
进行通信。ViewModel: 负责将Model的数据转换为View可以展示的数据,并处理View的输入。
* 区别: MVVM通过数据绑定,实现了View和ViewModel的双向同步,使得开发者无需手动操作DOM,大大简化了前端开发。
问题: 谈谈你对消息队列(MQ)的理解,以及你在项目中如何使用它。
答案: 消息队列是一种用于解耦、异步、削峰的中间件。
* 作用:
解耦: 生产者和消费者之间不再直接依赖,提高了系统的灵活性。
异步: 将耗时任务异步化,提高系统响应速度。
削峰: 当系统瞬时流量过大时,MQ可以将请求缓存起来,后端服务可以按照自己的处理能力消费,避免系统崩溃。
* 项目实践: 在prism vision项目中,我使用RabbitMQ来处理短剧视频转码任务。当用户上传视频后,系统将转码任务发送到MQ中,由独立的转码服务进行消费和处理,避免了用户等待转码完成,提高了用户体验。
问题: 你在简历中提到了MyBatis Plus,它与MyBatis相比,有什么优势?
答案: MyBatis Plus是MyBatis的增强工具,它在MyBatis的基础上提供了许多便捷的功能。
* 优势:
- CRUD操作: 提供了
BaseMapper
,封装了常用的CRUD
(增删改查)方法,无需手动编写SQL
。 - 代码生成器: 提供了代码生成器,可以根据数据库表结构自动生成
Entity
、Mapper
、Service
等代码。 - 分页插件: 内置了分页插件,可以方便地实现分页查询。
- 乐观锁: 提供了乐观锁插件,可以方便地实现乐观锁。
- 条件构造器: 提供了
Wrapper
,可以通过API
的方式构建复杂的SQL
查询条件。
- CRUD操作: 提供了
问题: 什么是OAuth2.0?它在你的项目中有什么应用?
答案: OAuth2.0是一个开放授权协议,它允许用户授权第三方应用访问他们在Web服务上的私有资源,而无需提供用户名和密码。
* 应用: 在prism vision项目中,我使用了OAuth2.0作为授权协议,允许用户使用微信、QQ等第三方账号登录。用户授权后,第三方平台会返回一个access token,后端通过这个token获取用户信息。
安全
问题: 解释一下SQL注入和XSS攻击的原理,以及如何防范。
答案:
* SQL注入: 攻击者通过在输入框中注入恶意的SQL语句,来欺骗数据库执行非法的SQL命令。
防范:
使用预编译的
PreparedStatement
,而不是字符串拼接。对用户输入进行严格的SQL关键字过滤和转义。
* XSS(Cross-Site Scripting): 攻击者通过注入恶意脚本,当其他用户访问该页面时,恶意脚本会在用户的浏览器中执行,从而窃取用户信息。
防范:
- 对用户输入进行
HTML
转义,将特殊字符转换为实体字符。 - 设置
HTTP
响应头Content-Security-Policy
。
- 对用户输入进行
问题: 什么是CSRF(Cross-Site Request Forgery)?如何防范?
答案: CSRF是跨站请求伪造,攻击者通过伪造用户请求,以用户的身份执行一些操作。例如,攻击者构造一个URL,当用户点击这个URL时,会以用户的身份发送一个转账请求。
* 防范:
- Token校验: 在请求中加入一个
CSRF Token
,服务器验证Token
的有效性。 - Referer校验: 检查
HTTP
请求头中的Referer
,判断请求是否来自合法的URL
。 - SameSite属性: 设置
Cookie
的SameSite
属性为Strict
或Lax
,可以防止CSRF
攻击。
- Token校验: 在请求中加入一个
问题: 解释一下HTTPS的握手过程。
答案: HTTPS的握手过程主要是SSL/TLS协议的握手过程。
\1. 客户端Hello: 客户端发送Client Hello消息,包含SSL版本、加密套件、随机数等信息。
\2. 服务端Hello: 服务端收到消息后,返回Server Hello消息,包含协商好的SSL版本、加密套件,以及服务器的数字证书和另一个随机数。
\3. 客户端验证证书: 客户端验证服务器的数字证书是否有效。
\4. 客户端发送密钥: 客户端生成一个Pre-master密钥,并使用服务器的公钥加密后发送给服务器。
\5. 服务端解密密钥: 服务器使用自己的私钥解密Pre-master密钥。
\6. 生成会话密钥: 客户端和服务器使用Pre-master密钥和之前的两个随机数,生成一个会话密钥。
\7. 加密通信: 后续的通信都使用会话密钥进行对称加密,保证通信安全。
DevOps与CI/CD
问题: 谈谈你对DevOps的理解,以及你了解哪些DevOps工具。
答案: DevOps是一种文化、方法论和实践,旨在促进开发(Development)和运维(Operations)团队之间的协作和沟通。
* 核心思想: 自动化、持续集成、持续交付、持续部署。
* 常用工具:
- 版本控制:
Git
。 - 持续集成(CI):
Jenkins
、Gitlab CI
、Travis CI
。 - 容器化:
Docker
。 - 容器编排:
Kubernetes
。 - 监控:
Prometheus
、Grafana
。
- 版本控制:
问题: 解释一下CI/CD(持续集成/持续交付)流程。
答案:
* CI(Continuous Integration): 持续集成是指开发者将代码频繁地合并到主干分支,并通过自动化测试来验证代码的正确性。
开发者提交代码到
Git
仓库。Jenkins
等CI
工具监听到代码变更。CI
工具拉取代码,进行编译、构建、单元测试等。如果构建失败,通知开发者。
* CD(Continuous Delivery): 持续交付是指将CI通过的构建产物自动部署到测试环境或预发布环境,并等待人工触发部署到生产环境。
* CD(Continuous Deployment): 持续部署是指将CI通过的构建产物自动部署到生产环境,无需人工干预。
问题: 你了解Docker吗?它在你的项目中有什么应用?
答案: Docker是一个开源的应用容器引擎,它可以将应用及其依赖打包成一个轻量级、可移植的容器,从而实现快速部署和环境一致性。
* 应用: 在prism vision项目中,我将后端服务、MySQL、Redis等都打包成Docker镜像。
- 开发环境: 开发者只需要拉取
Docker
镜像,即可快速搭建开发环境。 - 部署: 通过
Docker Compose
或Kubernetes
,可以一键部署整个应用,实现了环境的隔离和一致性。 - CI/CD: 在
CI/CD
流程中,Jenkins
等工具可以自动构建Docker
镜像,并将其推送到镜像仓库。
- 开发环境: 开发者只需要拉取
前端技术
问题: 你在简历中提到了Vue,请详细解释一下Vue的生命周期钩子函数,以及你常用的几个。
答案: Vue的生命周期钩子函数是在Vue实例或组件从创建到销毁的整个过程中,可以执行的函数。
* 常用钩子函数:
created
: 实例创建完成后调用,但DOM
尚未渲染。可以在此阶段进行数据初始化,发送Ajax
请求等。mounted
: 实例挂载到DOM
后调用,DOM
已渲染。可以在此阶段进行DOM
操作、访问子组件等。updated
: 当数据更新导致DOM
重新渲染时调用。beforeDestroy
/destroyed
: 实例销毁前/后调用,可以在此阶段清除定时器、解绑事件等。
问题: 解释一下Vue中的组件通信方式,并举例说明。
答案:
* 父子组件通信:
props / emit: 父组件通过props向子组件传递数据,子组件通过$emit向父组件触发事件。
* 兄弟组件通信:
Event Bus
: 创建一个空的Vue
实例作为事件总线,通过$on
和$emit
进行通信。Vuex: 使用Vuex进行状态管理,兄弟组件可以共享数据。
* 祖孙组件通信:
provide
/inject
: 在父组件中使用provide
提供数据,在子孙组件中使用inject
注入数据。Vuex
: 使用Vuex
进行状态管理。
问题: 什么是Vuex?它解决了什么问题?
答案: Vuex是Vue的官方状态管理库,它用于集中管理应用中所有组件的状态。
* 作用: 它解决了多组件共享状态时,状态难以管理和维护的问题,特别是在大型应用中。
* 核心概念:
State
: 存储应用的状态。Mutations
: 用于同步修改State
,必须是同步函数。Actions
: 用于异步操作,可以提交Mutations
。Getters
: 类似于Vue
的计算属性,用于从State
中派生出新的状态。Modules
: 用于将Store
分割成模块。
问题: 你了解Webpack吗?它的核心概念是什么?
答案: Webpack是一个模块打包工具,它可以将各种前端资源(JS、CSS、图片等)打包成一个或多个bundle文件。
* 核心概念:
Entry
: 入口,Webpack
从Entry
开始构建依赖图。Output
: 出口,Webpack
打包后的文件输出位置。Loader
: 转换器,用于对模块进行转换,例如将ES6
转为ES5
。Plugin
: 插件,用于在Webpack
的生命周期中执行各种任务,例如压缩文件、生成HTML
文件等。
数据结构与算法
问题: 描述一下你熟悉的数据结构,以及它们在项目中的应用。
答案: 我熟悉数组、链表、栈、队列、哈希表、树等数据结构。
* 哈希表: 在HashMap、Redis等框架中广泛使用,用于实现快速的键值对存取。
* 队列: 在消息队列中应用,用于存储待处理的任务。
* 栈: 在方法调用栈、浏览器的前进后退功能中应用。
* 树: 在MySQL索引(B+树)、文件系统中应用。
问题: 解释一下二分查找算法的原理和适用场景。
答案: 二分查找是一种高效的查找算法,它要求待查找的集合必须是有序的。
* 原理: 它将集合分成两半,与中间元素进行比较。如果目标值等于中间元素,则查找成功。如果目标值小于中间元素,则在左半部分继续查找;如果大于,则在右半部分继续查找。
* 适用场景: 适用于有序数组、有序链表的查找,时间复杂度为O(log n)。
问题: 描述一下你对快速排序算法的理解,并简述其实现步骤。
答案: 快速排序是一种分治算法,其平均时间复杂度为O(n log n)。
* 实现步骤:
- 选择基准元素: 从数组中选择一个元素作为基准(
pivot
)。 - 分区(Partition): 将数组中所有小于基准的元素移到基准的左边,所有大于基准的元素移到基准的右边。
- 递归: 对基准左右两边的子数组递归地进行快速排序,直到子数组只包含一个元素或为空。
- 选择基准元素: 从数组中选择一个元素作为基准(
问题: 什么是红黑树?它有什么特点?在Java中有哪些应用?
答案: 红黑树是一种自平衡的二叉查找树,它通过对节点颜色(红或黑)的约束,确保了树的高度相对平衡,从而保证了查找、插入、删除等操作的平均时间复杂度为O(log n)。
* 特点:
每个节点不是红色就是黑色。
根节点是黑色的。
每个叶子节点(
null
节点)是黑色的。如果一个节点是红色的,则它的子节点必须是黑色的。
从任一节点到其每个叶子节点的所有路径上,包含的黑色节点数相同。
* 应用: 在Java中,HashMap在链表长度超过阈值时会转换为红黑树,TreeMap、ConcurrentHashMap等集合类的底层也使用了红黑树。
综合能力与未来发展
问题: 假设你负责一个高并发短剧平台的后端设计,你会如何考虑系统的扩展性?
答案: 我会从以下几个方面考虑系统的扩展性:
* 水平扩展: 使用微服务架构,将不同功能的短剧服务拆分成独立的服务,并通过负载均衡器(NGINX)将请求分发到多个服务实例上。
* 数据库扩展: 使用分库分表技术,将用户数据、短剧内容数据分别存放在不同的库中。使用读写分离,将读请求分发到从库,写请求分发到主库。
* 缓存扩展: 使用Redis Cluster等分布式缓存,对缓存进行水平扩展,提高缓存的存储能力和并发能力。
* 异步化: 使用消息队列,将视频转码、日志记录等耗时任务异步化,提高系统响应速度。
问题: 在你的项目开发中,你遇到过哪些技术难题?你是如何解决的?
答案: 在prism vision项目中,我遇到过一个技术难题:如何在分布式环境下实现Session共享。
* 问题分析: 在单体应用中,Session默认存储在服务器内存中。但在微服务架构中,Session无法共享,导致用户登录后,无法访问其他服务。
* 解决方案: 我通过引入Spring Session结合Redis来解决。Spring Session将Session数据存储在Redis中,当一个服务需要获取Session时,会从Redis中读取,从而实现了Session的共享。
问题: 你如何学习新技术?请举例说明。
答案: 我通常会通过以下几种方式学习新技术:
\1. 官方文档: 官方文档是学习新技术的最佳途径,它包含了最全面、最准确的信息。
\2. 博客和技术社区: 通过阅读技术博客、GitHub上的开源项目,可以学习到新技术的应用和实践经验。
\3. 视频教程: 通过观看视频教程,可以快速入门,掌握新技术的核心概念。
\4. 动手实践: 边学边练,将新技术应用到实际项目中,遇到问题再查阅资料解决,这是最有效的学习方式。例如,我通过阅读Spring Cloud官方文档和GitHub上的示例项目,快速掌握了Spring Cloud的微服务开发。
问题: 你对JVM和G1垃圾收集器有何更深入的了解?
答案: G1(Garbage-First)是一款面向服务器的垃圾收集器,它的设计目标是:
* 可预测的GC暂停时间: G1将堆内存划分为多个大小相等的Region,每次GC时,它会优先回收垃圾最多的Region,从而在可控的GC暂停时间内,完成垃圾回收。
* 并行与并发: G1在GC过程中,可以并行或并发地执行,减少了STW(Stop-The-World)的时间。
* 分代收集: G1依然采用了分代收集的思想,但不再是物理分代,而是逻辑分代。
问题: 描述一下你对CAP理论的理解,以及它在分布式系统中的应用。
答案: CAP理论是指在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)这三个特性不能同时满足,最多只能满足其中两个。
* Consistency: 一致性,指所有节点在同一时刻看到的数据是一致的。
* Availability: 可用性,指系统在SLA(服务等级协议)内,始终能够对外提供服务。
* Partition tolerance: 分区容错性,指系统在网络分区的情况下,仍然能够对外提供服务。
* 应用:
- CP系统: 强调一致性和分区容错性,例如
Zookeeper
。当网络分区时,为了保证一致性,Zookeeper
会暂停对外服务。 - AP系统: 强调可用性和分区容错性,例如
Eureka
、Redis
。当网络分区时,Eureka
会保证服务可用,但可能会出现数据不一致。
- CP系统: 强调一致性和分区容错性,例如
问题: 谈谈你对Java反射机制的理解,以及它在框架中的应用。
答案: Java反射机制是指在运行时,可以动态地获取类的信息(如构造方法、属性、方法),并可以动态地创建对象、调用方法。
* 应用: 反射机制在Spring、MyBatis等框架中被广泛应用。
- Spring:
Spring
通过反射机制,根据配置动态地创建Bean
实例、注入依赖。 - MyBatis:
MyBatis
通过反射机制,根据Mapper
接口和XML
配置文件,动态生成代理对象,实现数据库操作。
- Spring:
操作系统
问题: 解释一下进程和线程的区别。
答案:
* 进程: 是程序的一次执行过程,是系统进行资源分配和调度的基本单位。每个进程都有独立的内存空间。
* 线程: 是进程的一个执行流,是CPU调度的基本单位。一个进程中可以有多个线程,这些线程共享进程的内存空间。
* 区别: 进程拥有独立的资源,切换开销大。线程共享进程的资源,切换开销小。
问题: 什么是I/O多路复用?它在Java中的应用?
答案: I/O多路复用是一种I/O模型,它通过一个线程监听多个socket,当某个socket有数据可读或可写时,通知应用程序进行处理。
* 应用: 在Java中,NIO(New I/O)框架实现了I/O多路复用。例如,Selector可以监听多个Channel,当某个Channel有事件发生时,Selector会通知应用程序,从而实现一个线程处理多个I/O请求,提高了并发能力。
问题: 描述一下Linux中的Shell脚本,以及你常用的几个命令。
答案: Shell脚本是Linux中的一种脚本语言,它可以方便地进行自动化操作。
* 常用命令:
ps -ef | grep <keyword>
: 查找指定进程。nohup java -jar <jar_name> &
: 后台运行Java
程序。tail -f <file_name>
: 实时查看日志文件。scp <source> <target>
: 远程复制文件。top
/htop
: 查看系统资源使用情况。
问题: 什么是Linux中的inode和block?
答案:
* inode: inode是Linux中的一个数据结构,它包含了文件的元信息,例如文件大小、创建时间、文件所有者、文件权限等。每个文件都有一个唯一的inode号。
* block: block是Linux文件系统存储数据的最小单位。文件的数据存储在block中,inode通过指针指向这些block。
数据库调优
问题: 除了你之前提到的索引优化,你还了解哪些MySQL数据库性能调优的手段?
答案:
* 优化SQL语句:
避免使用
SELECT *
,只查询需要的字段。使用
LIMIT
进行分页查询,而不是一次性查询所有数据。将大表拆分成小表,或者对热点数据进行缓存。
* 优化表结构:
选择合适的数据类型,例如使用
int
而不是varchar
存储数字。为
join
操作的字段创建索引。减少表的字段数量。
* 优化数据库配置:
调整
MySQL
的配置参数,例如innodb_buffer_pool_size
(InnoDB
缓存池大小)。使用
mysqltuner
等工具进行配置优化。
问题: 什么是数据库的悲观锁和乐观锁?它们在MySQL中如何实现?
答案:
* 悲观锁: 假设并发访问时会发生冲突,在访问资源前加锁,以保证数据的完整性。
MySQL实现: SELECT … FOR UPDATE。
* 乐观锁: 假设并发访问时不会发生冲突,在更新数据时,检查数据是否被其他线程修改过。
MySQL实现: 通过版本号(
version
字段)或时间戳字段。在更新时,检查版本号是否与读取时一致。
算法
问题: 描述一下你对动态规划算法的理解,并举一个简单的例子。
答案: 动态规划是一种通过把原问题分解为相对简单的子问题的方式,来求解复杂问题的方法。
* 核心思想:
最优子结构: 问题的最优解包含其子问题的最优解。
重叠子问题: 子问题被多次重复计算。
* 例子: 斐波那契数列,f(n) = f(n-1) + f(n-2)。通过动态规划,我们可以将计算结果缓存起来,避免重复计算。
问题: 解释一下贪心算法的原理,并与动态规划进行比较。
答案: 贪心算法是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望能够导致结果是全局最好或最优的算法。
* 与动态规划的比较:
动态规划
会考虑所有可能的子问题,然后从子问题的最优解中推导出原问题的最优解。贪心算法
在每一步都做出局部最优解,但并不总是能得到全局最优解。
职业发展
问题: 你如何评估和提高自己的代码质量?
答案: 我通常会从以下几个方面评估和提高自己的代码质量:
* 代码规范: 遵循团队的代码规范,使用SonarQube等工具进行代码扫描。
* 可读性: 使用有意义的变量名和方法名,添加必要的注释。
* 测试: 编写单元测试和集成测试,确保代码的正确性。
* Code Review: 积极参与Code Review,从同事的反馈中学习,也帮助同事发现问题。
* 重构: 定期对代码进行重构,优化代码结构,提高代码的健壮性和可维护性。
问题: 你对未来3-5年的职业规划是什么?
答案: 在未来3-5年,我希望能够成为一名资深后端工程师,并在技术广度和深度上都有所提升。
* 技术深度: 在Java、Spring Cloud、MySQL、Redis等技术栈上深入研究,特别是高并发、分布式、性能调优等领域。
* 技术广度: 学习Go、Python等其他语言,了解Kubernetes、Service Mesh等新技术,拓宽自己的技术视野。
* 架构设计: 参与和主导大型项目的架构设计,提升自己的系统设计能力。
* 团队协作: 成为团队中的核心成员,能够带领和指导初级工程师,共同成长。
消息队列(Message Queue,简称MQ)是一种异步通信的中间件,它允许不同的应用程序或服务通过消息进行通信,从而实现系统之间的解耦、异步处理和流量削峰。
核心作用
- 解耦 (Decoupling): * 问题: 在传统的同步调用模式下,如果服务A需要调用服务B,服务A必须知道服务B的存在。一旦服务B发生故障或需要更换,服务A也需要修改。这种紧耦合的关系使得系统难以维护和扩展。
- MQ解决方案: 通过引入消息队列,服务A(生产者)只负责将消息发送到队列中,而服务B(消费者)则从队列中获取消息进行处理。生产者和消费者之间不再直接依赖,它们只依赖于消息队列,从而实现了系统的解耦。即使服务B发生故障或需要更换,只要消息格式不变,服务A仍然可以正常发送消息,系统的高可用性得到了保障。
- 异步 (Asynchronous):
- 问题: 在同步调用中,当一个请求需要处理多个耗时任务时,用户必须等待所有任务执行完毕才能得到响应,这会严重影响用户体验。例如,一个电商平台的下单操作,可能需要更新库存、生成订单、发送邮件等多个步骤。
- MQ解决方案: 我们可以将这些耗时任务放入消息队列中。下单服务在创建订单后,立即向消息队列发送一条消息,然后就返回给用户响应。而其他服务(如库存服务、邮件服务)则可以异步地从队列中获取消息进行处理。这样,用户可以更快地得到响应,系统的并发能力也得到了提升。
- 削峰 (Peak Shaving):
- 问题: 当系统在某一时刻(如秒杀活动)面临突发的高并发流量时,后端服务可能会因为处理能力不足而崩溃。
- MQ解决方案: 我们可以将所有请求都先放入消息队列中。后端服务则可以按照自己的处理能力,匀速地从队列中获取消息进行处理。这样,即使瞬时流量超过了后端服务的处理能力,消息队列也可以将多余的请求缓存起来,保护后端服务不被压垮。
消息队列的核心概念
- 生产者 (Producer): 负责创建并发送消息到消息队列的应用程序或服务。
- 消费者 (Consumer): 负责从消息队列中获取并处理消息的应用程序或服务。
- 消息 (Message): 生产者和消费者之间通信的数据载体。
- 队列 (Queue): 消息的存储单元,用于存放生产者发送的消息。
- 代理 (Broker): 消息队列服务器,负责消息的存储、路由、转发等。
消息队列的保障机制
为了确保消息的可靠传输,消息队列通常会提供以下保障机制:
- 消息持久化: 将消息写入磁盘,即使消息队列服务器宕机,消息也不会丢失。
- 消息确认机制 (ACK): 消费者在成功处理完消息后,会向消息队列发送确认(
ACK
),消息队列收到确认后才会删除该消息。如果消费者处理失败或宕机,消息队列会在超时后将消息重新发送给其他消费者,确保消息至少被成功处理一次。 - 死信队列 (Dead-Letter Queue): 当消息处理失败或超过重试次数后,消息会被发送到死信队列。开发者可以对死信队列中的消息进行分析和处理。
常见的消息队列
- RabbitMQ: 基于
AMQP
协议,功能强大,支持多种模式,如点对点、发布/订阅。 - Kafka: 高吞吐量的分布式流平台,适用于日志收集、大数据处理等场景。
- ActiveMQ: 老牌开源消息队列,支持多种协议。
- RocketMQ: 阿里巴巴开源的分布式消息中间件,适用于大规模
Kafka
场景。 - Redis:
Redis
的List
数据结构可以作为简单的消息队列使用,但功能相对较弱,不适用于复杂的生产环境。
项目应用举例
在prism vision
短剧平台项目中,消息队列可以用于以下场景:
- 视频转码: 用户上传视频后,将视频转码任务发送到消息队列。后端独立的转码服务可以异步地消费这些任务,进行视频压缩、格式转换等操作。
- 日志收集: 将系统产生的日志发送到消息队列,由日志服务统一收集、分析和存储。
- 异步通知: 用户下单成功后,发送一条消息到消息队列。通知服务可以消费这条消息,异步地发送邮件或短信通知用户。
常用消息队列详解与项目实践
消息队列在微服务架构中扮演着至关重要的角色,它能够有效地解决服务间的解耦、异步处理、流量削峰等问题。下面将详细介绍几种常用的消息队列,并结合项目经验,阐述如何在实际项目中实现和使用它们。
RabbitMQ
特点:
- 协议: 基于
AMQP
(Advanced Message Queuing Protocol)协议,它是一个开放标准的协议,支持跨语言、跨平台的通信。 - 路由机制: 拥有灵活的路由机制,包括直连交换机(
direct
)、扇形交换机(fanout
)、主题交换机(topic
)和头交换机(headers
)。 - 可靠性: 提供了多种可靠性保障机制,如消息持久化、发布确认(
publisher confirms
)、消费者确认(consumer acknowledges
)等。 - 上手难度: 相对简单,文档完善,社区活跃。
实现方式:
RabbitMQ
的核心是Broker
、Exchange
、Queue
和Binding
。
- 生产者:
- 与
RabbitMQ Broker
建立连接。 - 创建一个
Channel
。 - 声明一个
Exchange
(交换机),指定类型(如direct
、fanout
、topic
)。 - 将消息发送到
Exchange
,并指定Routing Key
。
- 与
- 消费者:
- 与
RabbitMQ Broker
建立连接。 - 创建一个
Channel
。 - 声明一个
Queue
(队列)。 - 将
Queue
通过Binding
绑定到指定的Exchange
,并指定Binding Key
。 - 开始消费
Queue
中的消息。
- 与
项目中应用:
在prism vision
短剧平台中,RabbitMQ
可以用于以下场景:
- 视频转码: 用户上传短剧视频后,后端服务将转码任务(包括视频ID、分辨率、码率等信息)封装成消息,发送到
RabbitMQ
的Exchange
。转码服务作为消费者,从队列中获取任务并进行异步转码,完成后将转码结果更新到数据库。 - 异步通知: 用户完成支付后,支付服务发送一条支付成功消息到
RabbitMQ
。通知服务作为消费者,接收到消息后,异步地发送短信、邮件或站内信通知用户。
Kafka
特点:
- 高吞吐量:
Kafka
最初是为日志处理设计的,因此具有极高的吞吐量,能够处理每秒百万级别的消息。 - 分布式: 具有良好的分布式特性,通过分区(
Partition
)和副本(Replica
),可以实现水平扩展和高可用。 - 消息持久化: 消息以追加日志(
log
)的方式存储在磁盘上,具有极高的持久性。 - 流处理: 支持实时流处理,可以与
Flink
、Spark
等流处理框架集成。 - 上手难度: 相对
RabbitMQ
复杂,但性能更优。
实现方式:
Kafka
的核心是Broker
、Topic
、Partition
和Consumer Group
。
- 生产者:
- 连接
Kafka Broker
。 - 创建
Producer
实例。 - 将消息发送到指定的
Topic
,Kafka
会根据分区策略将消息写入不同的Partition
。
- 连接
- 消费者:
- 连接
Kafka Broker
。 - 创建
Consumer
实例,并指定Consumer Group
。 - 订阅指定的
Topic
。 Kafka
会确保同一Consumer Group
下的不同消费者消费同一个Partition
的消息,从而实现负载均衡。
- 连接
项目中应用:
在prism vision
短剧平台中,Kafka
可以用于以下场景:
- 日志收集与分析: 将所有微服务的日志(包括访问日志、错误日志、业务日志)都发送到
Kafka
。后端通过Logstash
等工具消费这些日志,进行实时分析和存储,以便后续的监控和问题排查。 - 大数据分析: 收集用户行为数据(如观看历史、点赞、评论),发送到
Kafka
。流处理平台(如Spark Streaming
)可以消费这些数据,进行实时推荐、用户画像等大数据分析。
Redis
特点:
- 简单:
Redis
的List
数据结构可以作为简单的消息队列使用。 - 内存存储: 消息存储在内存中,读写速度极快。
- 非正式:
Redis
并非专业的MQ
,不具备复杂的路由、持久化、确认机制等。
实现方式:
使用Redis
的List
数据结构,通过lpush
(左侧入队)和rpop
(右侧出队)命令,可以实现一个简单的消息队列。
- 生产者:
- 连接
Redis
。 - 使用
lpush key message
命令,将消息推入队列。
- 连接
- 消费者:
- 连接
Redis
。 - 使用
rpop key
命令,从队列中获取消息。
- 连接
项目中应用:
在prism vision
短剧平台中,Redis
可以用于以下场景:
- 短时任务队列: 对于一些对可靠性要求不高,但对速度要求极高的任务,例如后台的异步计算、统计等,可以使用
Redis
作为轻量级的消息队列。 - 秒杀队列: 在秒杀活动中,将所有下单请求都推入
Redis
队列,后端服务按顺序消费,从而实现流量削峰和库存
超卖控制。
总结
在选择消息队列时,需要根据实际业务需求进行权衡:
- 如果需要强大的路由功能、完善的可靠性保障,且消息量适中,RabbitMQ是不错的选择。
- 如果需要处理海量的消息、追求极高的吞吐量和并发,且需要进行实时流处理,Kafka是更佳的选择。
- 如果只是需要一个轻量级的、简单的异步任务队列,且对可靠性要求不高,Redis也可以作为一种快速的解决方案。
背诵
一、网络基础概念
- 网络的定义
网络是多个计算机及通信设备通过通信介质互联的系统,用于信息传输、资源共享、协同工作。- 核心目标:可靠、高效、可扩展的数据传输。
- 设计原则:分层、模块化、接口标准化。
- 数据传输方式
- 单工:单向传输(电视广播)。
- 半双工:双向传输,但不能同时发送(对讲机)。
- 全双工:双向同时传输(电话、以太网交换机端口)。
- 通信方式
- 点对点:两个节点直接通信(PPP)。
- 广播:单点发送,多点接收(Ethernet)。
- 组播:单点发送,特定组接收(IP 多播)。
- 网络分类
- 按地理范围:LAN、MAN、WAN。
- 按拓扑结构:星型、总线型、环型、混合型。
- 按访问控制方式:CSMA/CD(Ethernet)、令牌环(Token Ring)。
二、OSI 七层模型
OSI 模型将网络通信划分为七层,每层负责特定功能,确保模块化与互操作性。
层 | 功能 | 数据单位 | 典型协议/设备 | 面试易问点 |
---|---|---|---|---|
物理层 | 比特流传输,电气/光学/机械接口 | 比特 Bit | 网线、光纤、集线器、网卡物理部分、调制解调器 | 电压、传输速率、双工模式、光纤与铜缆区别 |
数据链路层 | 节点间帧传输,差错控制、流量控制、MAC 地址 | 帧 Frame | 以太网、PPP、交换机、网桥 | CSMA/CD 工作原理、MAC 地址格式、VLAN |
网络层 | 主机到主机传输,逻辑寻址、路由、拥塞控制 | 数据包 Packet | IP、ICMP、IGMP、路由器 | 路由选择算法(RIP/OSPF/BGP)、子网掩码计算、NAT |
传输层 | 端到端通信,端口映射、分段重组、流量控制 | 报文段 Segment (TCP)、数据报 Datagram (UDP) | TCP、UDP | TCP 三次握手、四次挥手、滑动窗口、端口号分类 |
会话层 | 管理应用会话,数据同步 | 会话数据 | NetBIOS、RPC、NFS、SQL | 断点续传、会话保持机制 |
表示层 | 数据表示、编码、加密压缩 | 表示数据 | JPEG、MPEG、ASCII、Unicode、TLS/SSL | 大端/小端、编码转换、加密/解密流程 |
应用层 | 提供用户服务,直接与应用交互 | 应用数据 | HTTP、FTP、SMTP、POP3/IMAP、DNS、Telnet、SNMP | HTTP 请求/响应报文结构、DNS 查询流程、FTP 主动/被动模式 |
1. 物理层深度
- 电气特性:电压幅值、频率、信号调制。
- 传输介质差异:
- 铜缆(双绞线、同轴): 成本低、易干扰。
- 光纤: 高速、长距离、抗电磁干扰。
- 面试常问:光纤单模和多模区别、网卡速率、半双工/全双工。
2. 数据链路层深度
- MAC 地址:唯一标识网卡,48 位(前 24 位厂商号,后 24 位设备号)。
- 流量控制:
- 停止等待(Stop-and-Wait)
- 滑动窗口(Sliding Window)
- 差错控制:CRC 校验、帧校验序列 (FCS)。
- VLAN:逻辑分隔网络,减少广播风暴。
3. 网络层深度
- 逻辑寻址:IPv4/IPv6
- 子网划分:CIDR、VLSM。
- 路由协议:
- RIP:跳数最短,收敛慢。
- OSPF:链路状态,快速收敛。
- BGP:自治系统间,策略路由。
- NAT:私网 IP 转换为公网 IP,节约 IP。
4. 传输层深度
TCP
- 可靠性机制:
- 序列号与确认号。
- 滑动窗口。
- 重传机制(超时/快速重传)。
- 三次握手(建立连接)
- 第一次 (SYN):客户端请求,seq=client_ISN。
- 第二次 (SYN-ACK):服务器确认客户端,seq=server_ISN, ack=client_ISN+1。
- 第三次 (ACK):客户端确认服务器,ack=server_ISN+1。
- 四次挥手(断开连接)
- FIN/ACK 交替关闭双向。
- TIME_WAIT: 2MSL,保证最后 ACK 到达并避免旧报文干扰。
- 流量控制:滑动窗口
- 拥塞控制:慢启动、拥塞避免、快速重传、快速恢复。
UDP
- 特点:无连接、无序、不保证到达。
- 应用:DNS、VoIP、视频直播。
- 优点:低延迟、开销小。
5. 会话层深度
- 会话管理:
- 会话建立、保持、终止。
- 数据同步点(断点续传)。
- 面试点:HTTP 是无状态协议,如何实现会话保持?(Cookie/Session/Token)
6. 表示层深度
- 数据格式化:ASCII、Unicode、EBCDIC。
- 压缩/解压:JPEG、MPEG、ZIP。
- 加密/解密:TLS/SSL,实现安全通信。
- 面试点:大端小端,Base64 编码。
7. 应用层深度
- HTTP:
- 请求方法:GET/POST/PUT/DELETE/OPTIONS。
- 状态码:1xx-5xx。
- DNS:
- 查询类型:A/AAAA/CNAME/MX。
- 递归/迭代查询。
- SMTP/POP3/IMAP:
- 发送与接收流程。
- 面试点:
- FTP 主动/被动模式差异。
- HTTPS 握手流程(证书交换 + 对称密钥)。
三、TCP/IP 协议族(详细版)
层 | 功能 | 协议 | 特点 | 面试易问点 |
---|---|---|---|---|
应用层 | 用户应用服务 | HTTP、DNS、FTP、SMTP | 与用户交互 | HTTP 状态码、DNS 查询过程 |
传输层 | 端到端通信 | TCP、UDP | TCP 可靠、UDP 高效 | 三次握手、四次挥手、流量控制 |
网络层 | 主机到主机 | IP、ICMP、ARP | IP 无连接、路由选择 | 子网划分、NAT、ICMP 报文 |
网络接口层 | 物理链路传输 | Ethernet、PPP | 比特传输 | MAC 地址、冲突检测 |
TCP 三次握手总结
- 客户端发送 SYN,告知初始序列号。
- 服务器返回 SYN-ACK,确认收到 SYN 并告知自身序列号。
- 客户端 ACK 确认,双方 ESTABLISHED。
TCP 四次挥手总结
- 主动方 FIN,关闭发送。
- 被动方 ACK,确认。
- 被动方 FIN,关闭发送。
- 主动方 ACK,进入 TIME_WAIT,等待 2MSL。
好的,我们可以把“面试重点提示”部分拆解得更深入,每一点都加上原理、实现机制、典型面试问法及回答要点,让你在面试中不仅会答,还能讲出背后的原因。
一、TCP 与 UDP 区别(深入版)
特性 | TCP | UDP |
---|---|---|
连接方式 | 面向连接,需要三次握手建立连接 | 无连接,直接发送数据报 |
可靠性 | 可靠传输,确保数据完整、有序、不丢失 | 不可靠传输,不能保证顺序和到达 |
数据单位 | 报文段 (Segment) | 数据报 (Datagram) |
速度 | 较慢,需要建立连接、确认和重传机制 | 快,无握手和重传,开销小 |
传输顺序 | 保证顺序,序列号 + ACK 确认 | 不保证顺序,独立发送 |
拥塞控制 | 有,TCP 拥塞控制算法(慢启动、拥塞避免、快速重传、快速恢复) | 无 |
应用场景 | 文件传输 (FTP)、网页访问 (HTTP/HTTPS)、邮件 (SMTP/POP3) | 实时视频/语音 (VoIP、直播)、DNS 查询、DHCP |
面试拓展点 | TCP 适合可靠性要求高的场景 | UDP 适合低延迟、可容忍丢包的场景 |
面试问法示例:
- “TCP 为什么可靠,UDP 为什么不可靠?”
回答要点:TCP 使用序列号、ACK 确认、重传机制、流量控制和拥塞控制来保证可靠性;UDP 没有这些机制,直接发送报文,速度快,但不保证到达和顺序。
二、三次握手的意义(深入版)
三次握手步骤复习:
- 客户端 → 服务器:SYN 报文,请求建立连接,发送 client_ISN。
- 服务器 → 客户端:SYN-ACK 报文,确认客户端 SYN,并发送 server_ISN。
- 客户端 → 服务器:ACK 报文,确认 server_ISN,连接建立。
为什么需要三次握手?
- 防止旧连接报文误建
- 如果只有两次握手,旧的 SYN 报文可能延迟到达,服务器误认为是新连接。
- 三次握手确保客户端主动确认,服务器不会因为旧报文进入 ESTABLISHED。
- 双向确认收发能力
- 第一次握手:客户端告诉服务器它能发。
- 第二次握手:服务器告诉客户端它能发,也确认客户端能发。
- 第三次握手:客户端确认服务器能收。
- 面试常问拓展:为什么第一次握手服务器不直接进入 ESTABLISHED?
答案:服务器不能确定客户端已经准备好接收数据,需第三次握手确认。
三、TIME_WAIT 的意义(深入版)
- TCP 四次挥手后客户端进入 TIME_WAIT
- 作用:
- 确保最后 ACK 能到达服务器
- 如果 ACK 丢失,服务器会重发 FIN,客户端在 TIME_WAIT 状态可以重新响应。
- 防止旧报文干扰新连接
- 2MSL(Maximum Segment Lifetime)内保证网络中旧报文被清除。
- 确保最后 ACK 能到达服务器
- 典型面试问法:
- “为什么客户端主动关闭连接进入 TIME_WAIT,而服务器不进入?”
答案:主动关闭方需要确保 ACK 能到达被动关闭方并防止旧报文干扰新连接;被动关闭方在收到 ACK 后即可关闭。 - “TIME_WAIT 为什么要等待 2MSL?”
答案:MSL 是报文在网络中的最大生存时间,等待 2MSL 可以确保所有可能滞留的报文消失。
- “为什么客户端主动关闭连接进入 TIME_WAIT,而服务器不进入?”
四、OSI 七层 vs TCP/IP 模型(深入版)
模型 | 层数 | 功能 | 面试关注点 |
---|---|---|---|
OSI | 7 层:应用、表示、会话、传输、网络、数据链路、物理 | 理论模型,描述网络通信的完整功能 | 面试常考“每层功能、数据单位、协议、典型设备” |
TCP/IP | 4 层:应用、传输、网络、网络接口 | 实际互联网使用的模型,简化 OSI,结合实际协议 | 面试常考“TCP/IP 与 OSI 的对应关系、核心协议、应用场景” |
OSI 与 TCP/IP 对应关系:
- OSI 应用/表示/会话层 → TCP/IP 应用层
- OSI 传输层 → TCP/IP 传输层
- OSI 网络层 → TCP/IP 网络层
- OSI 数据链路层 + 物理层 → TCP/IP 网络接口层
面试拓展点:
- TCP/IP 为什么只有 4 层?
- 因为实际应用中,表示层与会话层功能通常由应用层实现,硬件设备的物理和链路层可合并。
- OSI 模型为什么重要?
- 理论指导,帮助理解网络通信分层设计、模块化、互操作性。
- 面试常问:OSI 七层模型每层面试问法示例
- 物理层:双绞线和光纤区别?全双工和半双工区别?
- 数据链路层:MAC 地址格式?交换机与网桥区别?VLAN 原理?
- 网络层:IP 地址分类?子网掩码?RIP vs OSPF?
- 传输层:TCP/UDP 区别?三次握手流程?滑动窗口原理?
- 会话层:HTTP 无状态如何实现会话保持?
- 表示层:编码转换?大端小端?TLS 加密流程?
- 应用层:HTTP 状态码?FTP 主动/被动模式?DNS 查询过程?
1. 物理层
Q1:双绞线和光纤区别?
A1:
特性 | 双绞线 | 光纤 |
---|---|---|
传输介质 | 铜线 | 光纤(玻璃或塑料) |
传输方式 | 电信号 | 光信号 |
抗干扰性 | 容易受电磁干扰 | 抗电磁干扰强 |
传输距离 | 短(100米左右) | 长(可达数公里) |
传输速率 | 10Mbps~10Gbps | 1Gbps~400Gbps |
成本 | 低 | 高 |
面试要点:光纤适合高速、长距离、数据中心或核心网,双绞线适合局域网。 |
Q2:全双工和半双工区别?
A2:
- 半双工:数据双向传输,但同一时间只能一个方向(如对讲机)。
- 全双工:数据双向传输,可同时收发(如现代交换机端口)。
面试要点:半双工容易冲突,需要 CSMA/CD,降低效率;全双工无需冲突检测,速率翻倍。
2. 数据链路层
Q1:MAC 地址格式?
A1:
- 48 位二进制,通常写作 6 组十六进制:
00:1A:2B:3C:4D:5E
- 前 24 位:厂商标识(OUI)
- 后 24 位:网卡序列号
- 唯一标识网卡设备
面试要点:MAC 地址用于局域网内通信,交换机通过 MAC 表转发帧。
Q2:交换机与网桥区别?
特性 | 网桥 (Bridge) | 交换机 (Switch) |
---|---|---|
端口数 | 少 | 多 |
功能 | 转发帧、过滤广播 | 转发帧、学习 MAC、分割冲突域 |
性能 | 较低 | 高 |
面试要点:交换机可以理解为多端口网桥,隔离冲突域,效率更高。 |
Q3:VLAN 原理?
A3:
- 虚拟局域网(Virtual LAN)在同一物理交换机上划分不同逻辑网段。
- 通过 802.1Q 标签 给帧打标签,实现逻辑隔离。
- 优点:隔离广播域,提高安全性和管理性。
面试要点:同一交换机内不同 VLAN 之间通信需要路由器或三层交换机。
3. 网络层
Q1:IP 地址分类?
类别 | 起始位 | 范围 | 默认子网掩码 | 用途 |
---|---|---|---|---|
A | 0 | 1.0.0.0–126.255.255.255 | 255.0.0.0 | 大型网络 |
B | 10 | 128.0.0.0–191.255.255.255 | 255.255.0.0 | 中型网络 |
C | 110 | 192.0.0.0–223.255.255.255 | 255.255.255.0 | 小型网络 |
D | 1110 | 224.0.0.0–239.255.255.255 | N/A | 多播 |
E | 1111 | 240.0.0.0–255.255.255.255 | N/A | 实验/保留 |
Q2:子网掩码作用?
- 用于区分网络号和主机号。
- 通过按位与运算确定同一网段内主机。
- 面试问法:给 IP/子网掩码计算可用主机数或广播地址。
Q3:RIP vs OSPF
特性 | RIP | OSPF |
---|---|---|
类型 | 距离矢量 | 链路状态 |
收敛速度 | 慢 | 快 |
度量 | 跳数 | 带宽/延迟 |
缺点 | 路径可能环路 | 配置复杂 |
面试要点:RIP 简单、适合小网络;OSPF 复杂、适合大型网络。 |
4. 传输层
Q1:TCP/UDP 区别
详见上条回答。
Q2:三次握手流程
详见上条回答,可画图辅助说明。
Q3:滑动窗口原理
- TCP 使用滑动窗口进行流量控制。
- 发送方可发送窗口大小内的数据而不必等待 ACK。
- 接收方通过窗口大小告知可接收缓冲区容量。
- 好处:提高链路利用率,避免频繁等待确认。
- 面试问法:滑动窗口与停止等待区别?答案:滑动窗口一次可发送多段,停止等待一次只能发送一段。
5. 会话层
Q1:HTTP 无状态如何实现会话保持?
- HTTP 本身无状态,每次请求独立。
- 实现方式:
- Cookie:客户端保存会话 ID,随请求发送。
- Session:服务端保存用户状态,客户端发送标识。
- Token/JWT:无状态方式,将用户信息加密放在客户端。
- 面试要点:讲清楚“状态存放位置”和“优缺点”。
6. 表示层
Q1:编码转换
- ASCII、UTF-8、Unicode 等,用于不同系统间字符表示兼容。
- 面试问法:中文 UTF-8 编码占多少字节?
答案:汉字 3 字节,英文 1 字节。
Q2:大端小端
- 大端(Big Endian):高位字节存放在低地址。
- 小端(Little Endian):低位字节存放在低地址。
- 面试问法:网络传输使用哪种?
答案:大端(网络字节序)。
Q3:TLS 加密流程
- 客户端发送 ClientHello(支持加密算法)。
- 服务器返回 ServerHello,发送证书。
- 客户端验证证书,生成会话密钥。
- 双方用会话密钥加密通信。
- 面试要点:讲清楚“证书验证”和“对称密钥加密数据”。
7. 应用层
Q1:HTTP 状态码
类型 | 示例 | 含义 |
---|---|---|
1xx | 100 Continue | 信息性响应 |
2xx | 200 OK | 成功 |
3xx | 301 Moved Permanently | 重定向 |
4xx | 404 Not Found | 客户端错误 |
5xx | 500 Internal Server Error | 服务器错误 |
Q2:FTP 主动/被动模式
- 主动模式:客户端建立控制连接,服务器主动建立数据连接。
- 被动模式:客户端建立控制连接和数据连接,服务器被动监听。
- 面试要点:被动模式用于客户端在防火墙后的情况。
Q3:DNS 查询过程
- 客户端向本地 DNS 发起递归查询。
- 本地 DNS 如果缓存没有,向根 DNS 查询 → 顶级域 → 权威 DNS。
- 权威 DNS 返回解析结果到本地 DNS,本地返回给客户端。
- 面试要点:递归查询 vs 迭代查询区别。
一、前后端交互的基本流程
- 前端发起请求
- 用户在浏览器或客户端操作,比如点击按钮、提交表单。
- 前端通过 HTTP/HTTPS 或 WebSocket 向后端发送请求。
- 请求包含:URL、请求方法(GET/POST/PUT/DELETE 等)、请求头(Header)、请求体(Body,POST/PUT 时)和参数。
- 后端接收请求
- 后端服务器(如 Java/Spring、Node.js、Python Flask/Django)解析请求。
- 后端根据 URL 路径、HTTP 方法匹配路由到对应的处理函数(Controller/Handler)。
- 后端处理请求
- 后端进行业务逻辑处理:
- 数据库查询/更新(CRUD)
- 调用其他服务或 API
- 数据验证、权限校验
- 处理完成后,生成响应数据(通常为 JSON、XML、HTML 或二进制文件)。
- 后端进行业务逻辑处理:
- 后端返回响应
- 响应包含:状态码(HTTP Status Code)、响应头、响应体(Body)。
- 状态码示例:
- 200 OK:请求成功
- 201 Created:资源创建成功
- 400 Bad Request:请求错误
- 401 Unauthorized:未授权
- 500 Internal Server Error:服务器错误
- 前端接收响应
- 前端根据状态码判断请求结果。
- 前端解析响应体并更新界面(UI),例如渲染数据表格、显示提示信息。
- 如果是异步请求(AJAX/Fetch/axios),前端可以在回调或 Promise 中处理响应。
二、前后端交互方式
1. HTTP 请求-响应模式
- 同步请求(传统表单提交):浏览器提交表单,等待响应页面刷新。
- 异步请求(AJAX/Fetch/axios):页面不刷新,通过 JavaScript 异步获取数据。
2. WebSocket 实时通信
- 前端与后端建立长连接。
- 可以实时双向通信,不需要每次都发 HTTP 请求。
- 适用场景:聊天应用、在线游戏、实时监控。
3. Server-Sent Events(SSE)
- 后端主动向前端推送事件流。
- 单向通信(后端 → 前端)。
- 适用于消息通知、实时数据更新。
4. GraphQL
- 前端发送一个查询请求,后端根据查询返回精确的数据。
- 相比 REST,可以减少冗余数据传输。
三、前后端响应示例
前端(JavaScript fetch):
1 | fetch("https://api.example.com/users", { |
后端(Node.js/Express):
1 | app.get("/users", (req, res) => { |
- 前端发起 GET 请求 → 后端返回 JSON → 前端解析 JSON 更新页面。
四、状态码与响应流程
状态码 | 含义 | 前端处理方式 |
---|---|---|
200 OK | 请求成功 | 解析数据更新 UI |
201 Created | 资源创建成功 | 可以跳转或提示成功 |
400 Bad Request | 请求参数错误 | 提示用户检查输入 |
401 Unauthorized | 未授权 | 跳转登录页或请求授权 |
404 Not Found | 资源不存在 | 显示 404 页面 |
500 Internal Server Error | 后端错误 | 显示错误提示或重试 |
Linux
好的,我来给你整理一份 Linux 常见知识点清单,涵盖基础命令、系统管理、权限、网络、进程、文件系统等方面,适合面试或日常运维参考。我会按模块整理,并给出重点说明。
一、基础命令
1. 文件和目录操作
命令 | 功能 |
---|---|
ls |
列出目录内容 |
cd |
切换目录 |
pwd |
查看当前路径 |
mkdir |
创建目录 |
rm |
删除文件/目录(-r 递归) |
cp |
复制文件/目录 |
mv |
移动或重命名 |
find |
查找文件 |
locate |
快速查找文件(需更新索引) |
2. 文件查看与编辑
命令 | 功能 |
---|---|
cat |
查看文件内容 |
less / more |
分页浏览 |
head / tail |
查看开头/结尾行 |
grep |
文件内容搜索 |
wc |
统计行数、字数、字符数 |
vim / nano |
文本编辑 |
二、用户与权限管理
1. 用户管理
命令 | 功能 |
---|---|
useradd |
添加用户 |
usermod |
修改用户 |
passwd |
修改用户密码 |
userdel |
删除用户 |
id |
查看用户信息 |
2. 权限管理
命令 | 功能 |
---|---|
chmod |
修改文件权限(数字/符号模式) |
chown |
修改文件所有者 |
chgrp |
修改文件所属组 |
umask |
设置默认权限掩码 |
sudo |
提权执行命令 |
三、进程管理
命令 | 功能 |
---|---|
ps |
查看进程 |
top / htop |
动态查看进程和资源 |
kill / killall |
结束进程 |
nice / renice |
设置进程优先级 |
jobs / fg / bg |
作业控制(后台/前台) |
四、网络管理
命令 | 功能 |
---|---|
ifconfig / ip addr |
查看网卡IP信息 |
ping |
测试网络连通性 |
traceroute / tracepath |
路由跟踪 |
netstat / ss |
查看端口/连接信息 |
curl / wget |
下载文件或访问URL |
scp / rsync |
远程复制文件 |
iptables / firewalld |
防火墙管理 |
五、包管理
- Debian/Ubuntu 系列:
apt update
/apt upgrade
apt install <package>
/apt remove <package>
- RedHat/CentOS 系列:
yum install <package>
/yum remove <package>
dnf
(新版本替代 yum)
六、磁盘与文件系统管理
命令 | 功能 |
---|---|
df -h |
查看磁盘空间 |
du -h |
查看目录/文件大小 |
mount / umount |
挂载/卸载分区 |
lsblk |
查看磁盘分区 |
fdisk / parted |
分区管理 |
mkfs |
格式化文件系统 |
fsck |
检查文件系统 |
七、日志管理
文件/命令 | 功能 |
---|---|
/var/log/messages |
系统日志 |
/var/log/syslog |
系统事件 |
/var/log/auth.log |
用户登录与认证 |
journalctl |
查看 systemd 日志 |
dmesg |
查看内核启动和硬件日志 |
八、系统管理
命令 | 功能 |
---|---|
uptime |
系统运行时间 |
uname -a |
系统信息 |
free -h |
内存使用 |
vmstat |
系统资源统计 |
df -h |
磁盘使用情况 |
systemctl |
管理服务(启动/停止/重启) |
crontab |
定时任务管理 |
九、压缩与解压
命令 | 功能 |
---|---|
tar -cvf / tar -xvf |
打包/解包 |
gzip / gunzip |
压缩/解压 |
zip / unzip |
压缩/解压 |
十、Shell 脚本基础(详细版)
1. 变量
- 定义变量:
1 | NAME="Charlotte" |
- 访问变量:
1 | echo $NAME |
- 只读变量:
1 | readonly NAME |
- 环境变量:
1 | export PATH=$PATH:/new/path |
2. 条件判断
- if 语句:
1 | if [ $AGE -ge 18 ]; then |
- 常用条件表达式:
表达式 含义 -eq
等于 -ne
不等于 -gt
大于 -lt
小于 -ge
大于等于 -le
小于等于 -f filename
文件存在且为普通文件 -d dirname
目录存在 -x filename
可执行文件
3. 循环语句
- for 循环:
1 | for i in 1 2 3 4 5; do |
- while 循环:
1 | count=1 |
- until 循环:
1 | count=1 |
4. 函数
1 | greet() { |
$1
表示函数传入的第一个参数$@
表示所有参数return
返回状态码(0表示成功,非0表示失败)
5. 输入输出
- 标准输入/输出/错误:
1 | echo "Hello" # stdout |
- 管道与重定向:
1 | cat file.txt | grep "hello" > result.txt |
6. 脚本执行权限
1 | chmod +x script.sh |
7. 实用示例
1 |
|
十一、Linux 面试常问知识点(全面版)
1. 文件权限与权限管理
核心知识点:
- 权限类型:读(r)、写(w)、执行(x)
- 三类用户:
- 拥有者(u)
- 用户组(g)
- 其他用户(o)
- 权限表示:
- 符号表示法:
rwxr-xr-x
- 数字表示法:
chmod 755 file
→ 7=rw, x; 5=r-x; 5=r-x
- 符号表示法:
面试常问问题:
命令 chmod、chown、chgrp 有什么作用?
chmod
:修改权限chown
:修改文件所有者chgrp
:修改文件所属组
硬链接和软链接区别?
项目 硬链接 软链接 inode 相同 不同 跨分区 不行 可以 删除原文件 内容仍存在 链接失效 命令 ln source target
ln -s source target
文件描述符和重定向
0
stdin,1
stdout,2
stderr- 示例:
command > out.log 2>&1
→ stdout 和 stderr 都写入 out.log
2. 进程与作业管理
核心知识点:
- 查看进程:
ps aux
、top
、htop
- 结束进程:
kill PID
、kill -9 PID
、killall process_name
- 后台/前台任务:
&
:后台执行jobs
:查看后台任务fg %1
:切换到前台
- 优先级:
nice
、renice
设置 CPU 优先级
面试常问问题:
如何查看端口被哪个进程占用?
1
2
3lsof -i :8080
netstat -tulpn | grep 8080
ss -tulpn | grep 8080如何终止占用端口的进程?
1
kill -9 PID
3. 文件系统与磁盘管理
核心知识点:
df -h
查看磁盘空间du -h
查看目录大小lsblk
查看分区信息mount / umount
挂载卸载fdisk / parted
分区mkfs
格式化文件系统fsck
检查文件系统
面试常问问题:
硬链接和软链接在文件系统中有什么区别?
如何查看磁盘空间和使用情况?
1
2df -h
du -sh /path/to/dir
4. 网络管理
核心知识点:
- 查看 IP 地址:
ifconfig
/ip addr
- 测试连通性:
ping
- 路由追踪:
traceroute
/tracepath
- 查看端口和连接:
netstat
/ss
- 下载文件或请求 URL:
wget
/curl
- 远程文件传输:
scp
/rsync
面试常问问题:
如何查看网卡信息?
1
2ip addr show
ifconfig -a如何调试网络连通性?
1
2ping 8.8.8.8
traceroute www.baidu.com如何查看 8080 端口占用?
1
ss -tulpn | grep 8080
5. 用户与组管理
核心知识点:
- 添加用户:
useradd
- 删除用户:
userdel
- 修改用户:
usermod -aG group user
- 修改密码:
passwd user
- 查看组信息:
groups user
- 提权命令:
sudo command
面试常问问题:
如何给用户加入某个组?
1
usermod -aG dev user
如何限制用户权限?
- 使用
chmod
设置文件权限 - 使用
sudoers
配置命令权限
- 使用
6. Shell 脚本基础
核心知识点:
- 变量、条件判断、循环、函数
- 管道、重定向、标准输入输出
- 常用脚本示例:
1 |
|
面试常问问题:
- 如何判断文件是否存在?
1 | if [ -f "/path/to/file" ]; then |
- 如何统计目录下文件数量?
1 | count=$(ls -l | grep "^-" | wc -l) |
7. 定时任务(Crontab)
核心知识点:
- 查看:
crontab -l
- 编辑:
crontab -e
- 时间格式:
1 | * * * * * command |
- 示例:每天凌晨 2 点备份
1 | 0 2 * * * /home/user/backup.sh |
8. 系统日志
核心知识点:
- 常用日志路径:
文件 内容 /var/log/messages
系统事件 /var/log/syslog
系统服务日志 /var/log/auth.log
登录/认证日志 /var/log/kern.log
内核日志 journalctl
systemd 日志 - 查看日志:
1 | tail -f /var/log/syslog |
9. 系统管理
核心知识点:
- 系统信息:
uname -a
- 内存查看:
free -h
- CPU/资源统计:
top
/vmstat
- 服务管理:
systemctl start/stop/restart/status service
- 系统启动流程:BIOS → Bootloader → Kernel → init/systemd → 多用户模式
面试常问问题:
- 如何查看系统运行时间?
1 | shelluptime |
- 如何管理系统服务?
1 | systemctl restart nginx |
10. 综合示例(面试题)
- 如何查找占用 CPU 前 5 的进程?
1 | ps aux --sort=-%cpu | head -n 6 |
2.如何查找 1 小时内修改过的文件?
1 | find /path/to/dir -type f -mmin -60 |
3.如何将错误日志和输出日志写入同一个文件?
1 | command > all.log 2>&1 |
4.如何给用户添加 sudo 权限?
1 | usermod -aG sudo username |
MySQL 数据库
SQL语句
1. DML (Data Manipulation Language) — 数据操作
用于操作数据库中的数据。
1.1 SELECT 查询
1 | -- 查询指定列,并对结果排序、分页 |
聚合函数
1 | SELECT COUNT(*), SUM(column), AVG(column), MAX(column), MIN(column) |
分组查询
1 | SELECT column, COUNT(*) |
联接查询 (JOIN)
1 | -- 内连接,只返回两个表中匹配的行 |
1.2 INSERT 插入
1 | -- 指定列插入 |
1.3 UPDATE 更新
1 | UPDATE table_name |
1.4 DELETE 删除
1 | DELETE FROM table_name |
2. DDL (Data Definition Language) — 数据定义
用于创建、修改和删除数据库对象。
1 | -- 创建数据库 |
3. DCL (Data Control Language) — 数据权限控制
1 | -- 授权 |
4. TCL (Transaction Control Language) — 事务控制
1 | -- 开始事务 |
5. EXPLAIN 查询分析 — SQL 优化核心工具
用于分析 SELECT(或 DML)语句的执行计划,判断是否使用索引,以及优化查询性能。
1 | EXPLAIN SELECT * FROM table_name WHERE condition; |
5.1 EXPLAIN 核心字段解析
字段 | 说明 |
---|---|
id | 查询序列号,越大优先级越高;相同 id 按顺序执行。 |
select_type | 查询类型:SIMPLE, PRIMARY, SUBQUERY, DEPENDENT SUBQUERY, UNION, DERIVED 等。 |
table | 正在访问的表名。 |
partitions | 匹配的分区信息(如果有分区表)。 |
type | 连接类型/访问方式,性能从好到差:system → const → eq_ref → ref → range → index → ALL。 |
possible_keys | 查询可能使用的索引。 |
key | 实际使用的索引,NULL 表示未使用索引。 |
key_len | 使用的索引长度(字节),越短越好。 |
ref | 表示索引列与哪个列或常量进行比较。 |
rows | MySQL 估计要扫描的行数,越少越好。 |
filtered | WHERE 条件过滤行的百分比,越高越好。 |
Extra | 额外信息,重要优化指标:- Using filesort :需要额外排序。- Using temporary :需要临时表处理。- Using index :使用覆盖索引,查询效率高。- Using where :使用 WHERE 条件过滤。- Using index condition :索引条件下推(ICP)。- Using join buffer :连接查询未用索引,使用缓存优化。 |
5.2 type 类型说明(访问方式)
1 | system : 表中只有一行记录,性能极高 |
5.3 优化建议
- 尽量使用索引,避免
ALL
类型。 - ORDER BY / GROUP BY 尽量使用索引列,避免
Using filesort
或Using temporary
。 - 覆盖索引(
Using index
)能显著提高性能。 - 对复杂查询,使用 EXPLAIN 分析并调整索引或 SQL 结构。
- 对 JOIN 查询,保证连接字段有索引。
事务 (Transaction)
事务是一组操作的集合,这些操作要么全部成功,要么全部失败。它具有四大特性 (ACID)。
- ACID 特性:
- 原子性 (Atomicity): 事务是最小的执行单位,不可再分割。事务中的所有操作要么全部成功提交,要么全部失败回滚到事务开始前的状态。
- 一致性 (Consistency): 事务执行前后,数据库的完整性约束 (如主键唯一性、外键约束、检查约束、自定义业务规则 ) 没有被破坏,数据从一个一致性状态转换到另一个一致性状态。
- 隔离性 (Isolation): 并发执行的事务之间是隔离的,一个事务的执行不应影响其他事务的执行,反之亦然。事务的中间状态对其他事务是不可见的。
- 持久性 (Durability): 一旦事务提交,它对数据库中数据的改变就是永久性的,即使系统崩溃也不会丢失。
- 并发事务带来的问题:
- 脏读 (Dirty Read): 一个事务读取了另一个未提交事务的数据。如果这个未提交事务最终回滚,那么之前读取的数据就是“脏数据”。
- 不可重复读 (Non-Repeatable Read):一个事务在两次相同的查询中,读取到了不同的数据。通常是因为另一个已提交事务对这些数据进行了 UPDATE 或 DELETE 操作。
- 幻读 (Phantom Read):一个事务在两次相同的查询中,读取到了不同数量的行。通常是因为另一个已提交事务对数据进行了 INSERT 操作,导致第二次查询出现了之前没有的行。
- 丢失更新 (Lost Update):当两个事务都读取同一数据并进行修改时,其中一个事务的修改覆盖了另一个事务的修改,导致数据丢失。
- 事务隔离级别 (从低到高):
- READ UNCOMMITTED (读未提交):
- 最低的隔离级别。
- 允许脏读、不可重复读和幻读。
- 性能最高,但数据一致性最差。
- READ COMMITTED (读已提交):
- 解决了脏读问题。一个事务只能看到其他事务已经提交的数据。
- 仍然存在不可重复读和幻读问题。
- 多数数据库 (如 Oracle, SQL Server) 的默认隔离级别。
- REPEATABLE READ (可重复读):
- MySQL (InnoDB 存储引擎) 的默认隔离级别。
- 解决了脏读和不可重复读问题。在同一个事务中,多次读取同一数据会得到相同的结果 (通过 MVCC - 多版本并发控制)。
- 仍然可能存在幻读问题 (但在 InnoDB 默认隔离级别下,通过间隙锁解决了大部分幻读问题)。
- SERIALIZABLE (串行化):
- 最高的隔离级别。
- 完全解决了脏读、不可重复读和幻读问题。
- 强制事务串行执行,避免所有并发问题。
- 性能最低,因为它牺牲了并发性。
- READ UNCOMMITTED (读未提交):
索引 (Index)
索引是帮助 MySQL 高效获取数据的数据结构。它类似于书籍的目录,可以快速定位所需数据,而无需扫描整个表。
1. 索引的优缺点
优点
- 加快数据检索速度:通过索引可以直接定位到数据,避免全表扫描。
- 减少 I/O 次数:索引结构比数据表小,减少磁盘访问。
- 优化分组和排序:在
GROUP BY
和ORDER BY
中,可减少 CPU 消耗。
缺点
- 维护成本:数据增加、更新或删除时,索引也需要更新。
- 占用存储空间:索引本身也需要占用磁盘。
- 写入性能影响:虽然查询快,但对
INSERT/UPDATE/DELETE
有一定性能开销。
2. 索引底层实现 — B+ 树
B+ 树特点
- 数据存储:所有数据都存储在叶子节点,非叶子节点仅存储键值用于导航。
- 叶子节点链表:方便范围查询和顺序遍历。
- 树层高低:非叶子节点不存储数据,一个节点可存储更多索引键,降低树高度,减少磁盘 I/O。
- 适合磁盘存储:节点大小匹配磁盘块大小,最大限度减少磁盘寻道次数。
3. 索引分类
3.1 主键索引 (Primary Key Index)
- 特殊的唯一索引,一个表只能有一个主键。
- 不允许
NULL
值。 - 通常是 聚集索引 (InnoDB 存储引擎)。
3.2 唯一索引 (Unique Index)
- 列值必须唯一,但允许多个
NULL
值。 - SQL 示例:
1 | CREATE UNIQUE INDEX idx_name ON table_name (column_name); |
3.3 普通索引 (Normal Index / Non-Unique Index)
- 最基本索引,没有唯一性限制。
- SQL 示例:
1 | CREATE INDEX idx_name ON table_name (column_name); |
3.4 全文索引 (Fulltext Index)
- 用于大文本字段的模糊搜索。
- 仅支持 MyISAM 和 InnoDB。
- SQL 示例:
1 | CREATE FULLTEXT INDEX idx_name ON table_name (column_name); |
3.5 复合索引 (Composite / Multi-Column Index)
- 在多列上创建的索引。
- 最左前缀原则:
- 查询条件使用复合索引的第一列,则整个索引可用。
- 仅使用非首列,则索引可能无法完全使用。
- SQL 示例:
1 | CREATE INDEX idx_name ON table_name (column1, column2, column3); |
4. 聚集索引与非聚集索引
4.1 聚集索引 (Clustered Index)
- 叶子节点存储完整数据行。
- 一个表只能有一个聚集索引(通常是主键)。
- 数据物理顺序与索引逻辑顺序一致。
- 查询效率高,无需回表。
- 写入影响:插入、更新、删除需要维护物理顺序。
- InnoDB 实现:
- 主键索引即聚集索引。
- 若未定义主键,选择唯一非空索引作为聚集索引;若无,则创建隐藏 6 字节
row_id
作为聚集索引。
4.2 非聚集索引 (Non-Clustered Index)
- 叶子节点存储主键或指向数据行的指针。
- 表可有多个非聚集索引。
- 数据物理顺序与索引逻辑顺序无关。
- 查询需回表:若查询列不在索引中,需要通过索引找到主键,再通过聚集索引获取完整数据。
- InnoDB 实现:所有非主键索引都是非聚集索引。
5. 索引优化策略
5.1 创建索引的列选择
- 常用列:
WHERE
、JOIN
、ORDER BY
中频繁使用的列。 - 高区分度列:唯一值多的列更适合建立索引。
- 避免:
- 小表或频繁更新表上的索引。
- 重复值多的列上建索引。
5.2 遵循最左前缀原则
- 对复合索引
(a, b, c)
:
1 | WHERE a = 1 -> 索引可用 |
5.3 避免索引失效
- 函数或表达式:如
WHERE YEAR(date_column) = 2023
- 隐式类型转换:索引列类型不同于查询值类型
- OR 条件:除非两边都有索引,且优化器决定合并索引
- 左模糊匹配:
LIKE '%keyword'
- 不等/非 IN:
!=
或NOT IN
5.4 覆盖索引 (Covering Index)
- 查询所需列都在索引中,无需回表:
1 | SELECT column_in_index |
5.5 防止回表
- 尽量让 SELECT 列被索引覆盖,或只查询主键。
5.6 定期维护索引
- 重建索引或优化碎片化索引,保持性能稳定。
一、B+树的结构特点
- 多路平衡查找树:MySQL(InnoDB)使用的是 B+树索引,不同于二叉树,一个节点能存储大量 key。
- 非叶子节点:只存储 索引键值 + 子节点指针,不存放真实数据。
- 叶子节点:存放 完整的数据行(聚簇索引) 或 主键值(辅助索引),并且通过 双向链表 相连,便于范围查询。
- 有序性:索引键在节点内是有序存储的,适合二分查找。
二、定位过程(精确查找某条记录)
假设有一张表:
1 | CREATE TABLE user ( |
索引类型:
- 聚簇索引(Clustered Index):叶子节点存储整行记录。
- 辅助索引(Secondary Index):叶子节点存储主键值,再回表查询整行。
1. 精确查找(主键查找)
例如:
1 | SELECT * FROM user WHERE id = 25; |
定位步骤:
- 从 根节点 开始,根据
id
值进行二分查找,找到对应的子节点指针。 - 进入 中间节点,重复二分查找,继续向下。
- 进入 叶子节点,找到键值 = 25 的位置。
- 因为是 聚簇索引,叶子节点存放了完整行记录,直接返回。
2. 辅助索引查找(非主键查找)
例如:
1 | SELECT * FROM user WHERE name = 'Tom'; |
定位步骤:
- 进入 辅助索引 B+树,从根 → 中间节点 → 叶子节点,找到
name = 'Tom'
对应的记录。 - 叶子节点存放的不是整行数据,而是 主键值(id)。
- 根据主键值,再回到 聚簇索引 B+树 中,走一次查找过程,定位到整行数据。
这就是所谓的 回表。
三、为什么能“精确定位”
- 有序存储 → 节点内可以二分查找,快速缩小范围。
- 树高较低 → InnoDB 一般 3
4 层就能存下百万级数据,每次查找只需磁盘 I/O 34 次。 - 聚簇索引 → 直接在叶子节点找到完整行,避免额外跳转。
- 辅助索引 + 主键索引 → 通过两次 B+树查找,依然能精确定位。
四、类比理解
B+树就像一个“图书馆目录”:
- 根节点 = 图书馆的“目录索引页”
- 中间节点 = 分区目录
- 叶子节点 = 书架(放书或者书的编号)
- 聚簇索引 = 直接找到书本
- 辅助索引 = 先找到书的编号,再去另一个书架找书
👉 总结一句:
MySQL 通过 B+树的有序性和分层查找机制,从根节点到叶子节点逐级定位,最终在叶子节点找到目标记录(主键索引直接拿数据,辅助索引还需回表)。
要不要我帮你画一张 B+树精确查找的图解流程,形象化展示“查找某条记录”的路径?
MySQL 存储引擎
MySQL 支持多种存储引擎,每种存储引擎在事务支持、锁机制、索引类型、适用场景等方面有所不同。
存储引擎 | 特性 | 锁机制 | 事务支持 | 索引类型 | 崩溃恢复 | 适用场景 |
---|---|---|---|---|---|---|
InnoDB (默认) | - 支持事务(ACID)- 支持行级锁- 支持外键约束- 支持崩溃恢复- 默认聚集索引- MVCC(多版本并发控制) | 行级锁 | 支持 | 聚集索引(叶子节点存储数据行) | 支持 redo log / undo log | 事务性应用、高并发读写、数据完整性要求高的场景(如电商、金融) |
MyISAM | - 不支持事务- 不支持外键- 非聚集索引- SELECT COUNT(*) 快 | 表级锁 | 不支持 | 非聚集索引(叶子节点存储数据地址) | 不支持 | 只读或读多写少、对事务性要求不高,需要频繁 COUNT(*) 的应用(如日志、数据仓库) |
Memory (HEAP) | - 数据存储在内存中,速度快- 重启数据丢失 | 表级锁 | 不支持 | 哈希索引(默认,可用 BTREE) | 不支持 | 临时表、缓存、快速计算 |
Archive | - 高度压缩,节省空间- 支持高速插入和查询- 不支持更新和删除 | 表级锁 | 不支持 | 非聚集索引 | 不支持 | 存储历史数据、不常访问的数据仓库 |
CSV | - 数据以 CSV 文件格式存储- 易于与其他应用交换 | 表级锁 | 不支持 | 无索引或简单索引 | 不支持 | 数据导入导出、交换数据文件 |
额外说明
- InnoDB 逐渐成为默认引擎:MySQL 5.5 之后,InnoDB 取代 MyISAM,成为事务型应用首选。
- 索引和锁机制区别:
- 行级锁:并发性能高,锁粒度小。
- 表级锁:并发性能低,适合读多写少场景。
- 崩溃恢复:
- InnoDB 通过 redo log 和 undo log 保证数据持久性和事务回滚。
- MyISAM 和 Memory 不支持事务或崩溃恢复。
- 存储优化选择:
- 高并发、事务性应用 → InnoDB
- 只读或日志分析 → MyISAM
- 临时计算/缓存 → Memory
- 历史归档数据 → Archive
- 数据交换 → CSV
MVCC(多版本并发控制):
好的,我们来深入探讨一下 MVCC (Multi-Version Concurrency Control),并分析一些常见的面试题。
什么是 MVCC?
MVCC,即多版本并发控制,是一种在数据库中用于解决并发访问问题的方法。它不是通过加锁的方式来控制并发,而是通过为每个事务生成一个数据快照,让读操作在快照上进行。
你可以把它想象成一个“时光机”。当一个事务开始时,数据库会为它“拍一张照片”,也就是生成一个数据快照。这个事务的所有读操作都只会看到这个快照里的数据,而不会受到其他并发事务修改的影响。这样,读操作就不需要等待写锁释放,从而实现了读写分离,大大提高了并发性能。
简而言之,MVCC 的核心思想是:
- 读不加锁:读取数据时,直接从数据的历史版本中读取,不需要等待其他事务的写锁。
- 写不阻塞读:写操作修改数据时,会创建一个新的版本,而旧版本依然保留,供其他读事务使用。
MVCC 的实现原理
MVCC 的实现通常依赖于以下几个核心要素:
隐藏列(Hidden Columns):
每个表都会有几个隐藏的列,用于记录版本信息:
- DB_TRX_ID:事务 ID,记录最近一次修改数据的事务 ID。
- DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本。
- DB_ROW_ID:行 ID,是插入新行时分配的隐藏 ID,当主键是字符串时,可能用于辅助索引。
Undo Log (回滚日志):
Undo Log 记录了数据在被修改之前的值。每次修改数据时,都会将修改前的数据版本记录在 Undo Log 中,并通过回滚指针 DB_ROLL_PTR 将新版本与旧版本连接起来,形成一个版本链。这样,通过版本链,我们就可以追溯到这条数据的历史版本。
Read View (读视图):
Read View 是 MVCC 的核心,它是一个在事务启动时生成的、用来判断某个数据版本对当前事务是否可见的数据快照。它主要包含以下几个关键信息:
m_ids
:在生成Read View
时,当前系统中所有活跃事务的 ID 列表。min_trx_id
:在生成Read View
时,m_ids
中最小的事务 ID。max_trx_id
:在生成Read View
时,系统将要分配给下一个事务的 ID。creator_trx_id
:创建Read View
的事务 ID。
当一个事务想要读取一条数据时,会根据
Read View
的规则来判断这条数据的**DB_TRX_ID
**是否可见。- 如果
DB_TRX_ID
小于min_trx_id
,说明这个修改操作在当前事务启动前就已经提交了,数据可见。 - 如果
DB_TRX_ID
大于等于max_trx_id
,说明这个修改操作是在当前事务启动后才发生的,数据不可见。 - 如果
DB_TRX_ID
在min_trx_id
和max_trx_id
之间,那么需要判断DB_TRX_ID
是否在m_ids
列表中。如果在,说明这个修改操作是和当前事务同时启动的,数据不可见;如果不在,说明这个修改操作在当前事务启动前就已经提交了,数据可见。
如果当前版本不可见,事务就会通过回滚指针
DB_ROLL_PTR
沿着版本链找到上一版本,直到找到一个可见的版本。
面试题分析
1. 什么是 MVCC?它解决了什么问题?
回答要点:
- 概念:多版本并发控制,通过维护数据历史版本实现并发。
- 解决问题:在数据库隔离级别为**读已提交(RC)和可重复读(RR)**时,实现了读写不冲突。它解决了 读写锁冲突 和 脏读 问题,但无法完全解决幻读。
- 核心思想:读操作读取数据快照,写操作创建新版本。
2. MVCC 是如何实现可重复读(Repeatable Read)的?
回答要点:
- 核心:
Read View
的创建时机。 - 可重复读:事务在第一次读操作时创建
Read View
,并且在整个事务的生命周期内都使用这个 Read View。这意味着无论事务中执行多少次读,看到的都是同一个数据快照,所以能保证多次读取结果一致。 - 读已提交:相比之下,读已提交的隔离级别是每次执行读操作时都重新生成一个 Read View。因此,如果其他事务在两次读操作之间提交了修改,第二次读就能看到新数据,导致不可重复读。
3. MVCC 能解决幻读吗?
回答要点:
- 部分解决,但不能完全解决。
- 幻读(Phantom Read):当一个事务在两次查询之间,另一个事务插入了新的数据,导致第一次查询不存在的数据,第二次查询却出现了。
- MVCC 的作用:MVCC 可以防止更新幻读(即一个事务在两次查询之间,另一个事务更新了数据),因为它总是读取事务启动时的快照。
- 无法解决:MVCC 无法完全解决插入幻读。例如,事务 A 两次查询
WHERE id > 10
,但在两次查询之间,事务 B 插入了一条id=11
的记录并提交。虽然事务 A 的Read View
看不到这条新记录,但如果事务 A 执行UPDATE ... WHERE id > 10
时,它会发现这条新记录并对其加锁,从而更新成功。这会打破可重复读的承诺。 - InnoDB 的解决方案:InnoDB 数据库在
可重复读
隔离级别下,除了 MVCC,还会结合**间隙锁(Gap Lock)**来彻底解决幻读问题。
4. Undo Log 和 Redo Log 有什么区别?
回答要点:
- Undo Log (回滚日志):
- 作用:用于回滚事务和实现 MVCC。
- 记录内容:记录的是数据修改前的版本。
- 生命周期:在事务提交后,如果数据有其他事务在使用(用于 MVCC),
Undo Log
依然保留;如果没有,Undo Log
会被清除。
- Redo Log (重做日志):
- 作用:用于保证事务的持久性。
- 记录内容:记录的是数据修改后的日志,比如“某某页的某某偏移量改成了某某值”。
- 生命周期:在数据同步到磁盘后,
Redo Log
就会被清除。 - 作用点:
Redo Log
作用于崩溃恢复。当数据库发生宕机时,可以根据Redo Log
将已提交但尚未写入磁盘的数据重新写入,以保证数据不丢失。
MySQL日志
MySQL的日志系统是其数据库管理系统(DBMS)中至关重要的组成部分,扮演着监控、审计、故障恢复和数据复制等多种关键角色。用户提到的错误日志、查询日志、慢查询日志、事务日志和二进制日志构成了MySQL日志体系的核心。下面将对这些主要日志进行详细的梳理和解析。
1. 错误日志(Error Log)
错误日志是MySQL中最基础的日志之一,它记录了mysqld服务器启动、运行和关闭过程中遇到的所有严重错误和警告。
- 主要内容:
- 服务器启动和关闭的详细信息。
- 运行过程中发生的错误,例如表损坏、无法访问特定文件等。
- 事件调度器运行出错时的信息。
- 在主从复制架构中,从服务器上启动和关闭复制线程,连接主服务器时发生的错误等。
- 作用:错误日志是诊断和解决MySQL服务器问题的首要工具。当数据库无法启动或运行异常时,应首先检查此日志。
- 配置:默认情况下,错误日志是开启的。其文件名通常为
.err,位于数据目录(datadir)下。可以通过在my.cnf或my.ini配置文件中设置log_error变量来指定其路径。
2. 查询日志(Query Log)/通用查询日志(General Query Log)
通用查询日志记录了MySQL服务器接收到的每一个客户端连接和执行的每一条SQL语句。
- 主要内容:
- 客户端的连接信息,包括连接时间、用户名和主机。
- 客户端发送给服务器的所有SQL语句,无论其是否正确执行。
- 作用:该日志对于数据库的审计和问题排查非常有用,可以精确复现用户的操作序列。然而,由于它会记录所有操作,对系统性能会产生显著影响,并会迅速占用大量磁盘空间。因此,不建议在生产环境中长期开启。
- 配置:默认关闭。可以通过设置general_log为ON来启用,并使用general_log_file指定日志文件路径。
3. 慢查询日志(Slow Query Log)
慢查询日志用于记录执行时间超过指定阈值的SQL查询语句,是数据库性能优化的关键工具。
- 主要内容:
- 执行时间超过long_query_time阈值的SQL语句。
- 查询执行时的相关信息,如执行时间、锁定时间、扫描的行数、返回的行数以及执行该查询的用户和主机。
- 作用:通过分析慢查询日志,开发者和数据库管理员(DBA)可以定位到效率低下的SQL语句,并针对性地进行优化,例如添加索引、改写查询等。
- 配置:默认关闭。需在配置文件中设置slow_query_log为ON开启。long_query_time参数用于设定慢查询的时间阈值(单位:秒),slow_query_log_file用于指定日志文件位置。log_queries_not_using_indexes参数还可以记录未使用索引的查询。
4. 事务日志(Transaction Log)
用户提到的”事务日志”在InnoDB存储引擎中,主要由两种日志构成:重做日志(Redo Log)和回滚日志(Undo Log)。它们共同保证了事务的ACID特性(原子性、一致性、隔离性、持久性)。
- 重做日志(Redo Log):
- 作用:保证事务的持久性。它记录了数据被修改后的物理变化。当事务提交后,即使数据尚未完全写入数据文件,只要Redo Log已经持久化,在数据库发生崩溃时,也可以通过重放Redo Log来恢复已提交的事务,确保数据不丢失。这种技术被称为预写日志(Write-Ahead Logging, WAL)。
- 特点:Redo Log是以循环写的方式记录在连续的物理文件中,大小固定。
- 回滚日志(Undo Log):
- 作用:保证事务的原子性和实现多版本并发控制(MVCC)。Undo Log记录的是数据被修改前的状态。当事务需要回滚时,可以通过Undo Log将数据恢复到修改之前的版本。同时,在读已提交(Read Committed)和可重复读(Repeatable Read)隔离级别下,当一个事务需要读取被另一个未提交事务修改的行时,会通过Undo Log读取该行之前的版本,从而实现非锁定读。
- 特点:Undo Log逻辑上记录了每个修改操作的逆操作。
5. 二进制日志(Binary Log/Binlog)
二进制日志是MySQL中功能最强大、用途最广泛的日志之一。它以二进制格式记录了所有修改数据库数据的操作(DML)以及数据定义语言(DDL)的操作,但不包括SELECT和SHOW等不修改数据的查询。
- 主要内容:记录了导致数据发生更改的所有事件。根据格式不同,可以记录为SQL语句(STATEMENT格式)、行的变更(ROW格式)或两者的混合(MIXED格式)。
- 主要作用:
- 数据恢复(Point-in-Time Recovery):通过备份的数据文件和之后的二进制日志,可以将数据库恢复到过去的任意一个时间点。
- 主从复制(Replication):在主从架构中,主服务器将二进制日志传送给从服务器,从服务器重放这些日志中的事件,从而实现与主服务器的数据同步。
- 配置:默认情况下可能关闭,需要通过配置文件中的log_bin选项来启用。启用后,会生成一个索引文件(默认为
-bin.index)和一系列的二进制日志文件。
扩展:中继日志(Relay Log)
在主从复制环境中,还有一个重要的日志类型——中继日志。
- 作用:从服务器的I/O线程从主服务器获取二进制日志,并将其写入本地的中继日志中。然后,从服务器的SQL线程读取中继日志中的事件,并在从服务器上执行,以实现数据同步。
- 特点:中继日志的格式与二进制日志完全相同。它的存在使得从服务器的I/O和SQL执行可以解耦,即使在网络不稳定的情况下,只要I/O线程将日志拉到本地,SQL线程就可以持续执行。
总结
日志类型 | 主要作用 | 生产环境建议 |
---|---|---|
错误日志 | 记录服务器启停和运行错误 | 始终开启 |
查询日志 | 记录所有连接和SQL语句,用于审计 | 默认关闭,仅在调试时短期开启 |
慢查询日志 | 记录执行缓慢的SQL,用于性能优化 | 建议开启 |
事务日志 | ||
- Redo Log | 保证事务持久性,用于崩溃恢复 | InnoDB引擎核心组件,始终开启 |
- Undo Log | 保证事务原子性,支持MVCC | InnoDB引擎核心组件,始终开启 |
二进制日志 | 数据恢复、主从复制 | 强烈建议开启,尤其是需要数据恢复和复制的场景 |
中继日志 | 主从复制中,从库用于暂存主库的二进制日志 | 在从服务器上自动创建和管理 |
MySQL中的锁
第一部分:数据库锁系统
1. 锁的分类体系
1.1 按锁粒度的层次分类
表级锁(Table-Level Lock) 表级锁是最粗粒度的锁机制,一次锁定整个表的所有数据。MyISAM存储引擎主要使用表级锁,其内部维护一个全局的表锁列表。当线程需要访问表时,首先检查表锁状态,如果表已被其他线程以不兼容模式锁定,则当前线程进入等待队列。表级锁的优势在于锁管理开销极小,只需要维护少量的锁对象;缺点是并发度极低,即使访问不同行的操作也会相互阻塞。
页级锁(Page-Level Lock) 页级锁锁定数据页,是表级锁和行级锁的折中方案。BDB存储引擎使用页级锁,每个数据页通常包含多条记录。页级锁的实现需要在页头维护锁信息,包括锁模式、持有者信息等。这种锁粒度在空间局部性较好的应用中表现优秀,因为相关的数据通常存储在相邻的页面中。
行级锁(Row-Level Lock) 行级锁是最细粒度的锁机制,InnoDB存储引擎的核心特性。行锁的实现依赖于索引结构,实际上锁定的是索引记录而不是数据行本身。当查询没有使用索引时,InnoDB会扫描整个表并对所有记录加锁,退化为类似表锁的行为。行级锁提供最高的并发度,但也带来最大的管理开销。
1.2 按锁模式的功能分类
共享锁(Shared Lock, S锁) 共享锁允许多个事务同时读取同一资源,但阻止任何事务修改该资源。在InnoDB中,共享锁通过在锁对象的type_mode字段中设置LOCK_S标志位来标识。多个共享锁可以并存,这是通过锁兼容性矩阵来判断的。共享锁的获取相对简单,只需要检查是否存在冲突的排他锁。
排他锁(Exclusive Lock, X锁) 排他锁提供独占访问,同一时间只能有一个事务持有资源的排他锁。排他锁与任何其他锁都不兼容,包括共享锁和其他排他锁。在InnoDB实现中,排他锁的获取需要等待所有现有的锁释放,这通过等待队列机制来实现。
意向锁(Intention Lock) 意向锁是一种表级锁,用于表明事务在表的某些行上持有或即将请求某种类型的锁。意向共享锁(IS)表示事务意图在某些行上获取共享锁,意向排他锁(IX)表示事务意图在某些行上获取排他锁。意向锁的引入大大简化了表级操作的锁冲突检测,避免了遍历所有行锁的开销。
1.3 按锁算法的实现分类
记录锁(Record Lock) 记录锁锁定索引中的一条具体记录,是最基本的行级锁形式。在InnoDB的实现中,记录锁通过在B+树的叶子节点记录上设置锁标记来实现。锁对象中的heap_no字段精确标识被锁定的记录在页面中的位置。记录锁只能防止其他事务修改或删除该记录,但不能防止在该记录前后插入新记录。
间隙锁(Gap Lock) 间隙锁锁定索引记录之间的间隙,防止其他事务在该间隙中插入新记录。间隙锁的范围是开区间,不包含边界记录本身。InnoDB通过比较索引键值来确定间隙的边界,对于复合索引,间隙的比较需要考虑所有键值列的组合。间隙锁之间不冲突,多个事务可以同时持有相同间隙的间隙锁。
临键锁(Next-Key Lock) 临键锁是记录锁和间隙锁的组合,锁定一个记录以及该记录前面的间隙。这是InnoDB在可重复读隔离级别下的默认锁算法。临键锁有效解决了幻读问题,因为它不仅锁定已存在的记录,还锁定了可能插入新记录的位置。临键锁的范围是左开右闭区间。
2. InnoDB锁系统的深层实现
2.1 锁对象的数据结构设计
1 | struct lock_t { |
锁哈希表的设计: InnoDB使用一个全局的锁哈希表来管理所有的锁对象,哈希函数基于space_id和page_no计算。每个哈希桶包含一个锁对象链表,相同页面的所有锁对象都链接在同一个桶中。这种设计支持快速的锁查找和冲突检测,时间复杂度接近O(1)。
锁位图的精巧设计: 对于页面级别的锁管理,InnoDB使用位图来标识页面中哪些记录被锁定。每个记录对应位图中的一位,设置为1表示该记录被锁定。这种压缩表示法大大节省了内存空间,特别是在锁定大量记录时。位图操作使用高效的位运算指令,支持快速的锁状态查询和更新。
2.2 死锁检测的高级算法
增量式死锁检测: InnoDB不是每次都重新构建整个等待图,而是采用增量式检测。当新的等待关系建立时,算法只检查从新等待者开始的路径是否形成环。这种优化大大减少了检测的计算开销,特别是在高并发场景下效果显著。
死锁检测的优先级调度: 死锁检测器运行在独立的后台线程中,具有较高的调度优先级。检测频率根据系统负载动态调整:在高并发时增加检测频率,在低负载时降低频率以节省CPU资源。检测器还会维护统计信息,包括死锁发生频率、平均解决时间等,用于系统调优。
复杂死锁场景的处理: 实际生产环境中可能出现涉及多个资源类型的复杂死锁,比如同时涉及行锁、表锁、和外键约束锁的死锁。InnoDB的死锁检测器需要构建一个多层次的等待图,包含不同类型的资源节点和等待边。解决这类死锁时,需要考虑不同锁类型的回滚代价。
2.3 MVCC与锁的深度集成
ReadView的内部结构:
1 | struct ReadView { |
版本可见性的判断算法: 当事务读取记录时,需要判断记录的某个版本是否对当前事务可见。判断逻辑基于记录的DB_TRX_ID字段和ReadView的信息:如果DB_TRX_ID小于up_limit_id,则该版本对所有事务可见;如果DB_TRX_ID大于等于low_limit_id,则该版本对当前事务不可见;如果DB_TRX_ID在两者之间,则需要检查是否在活跃事务列表中。
undo log的链式结构: 每个事务的undo log形成一个链式结构,记录了该事务的所有修改操作。undo log不仅用于事务回滚,还用于MVCC的版本构建。当需要构建某个历史版本时,InnoDB会从当前版本开始,沿着undo log链向前回溯,逐步应用逆向操作直到达到目标版本。
3. 数据库锁的性能优化策略
3.1 锁等待的自适应策略
等待时间的动态调整: InnoDB实现了自适应的锁等待策略,根据系统负载和锁竞争情况动态调整等待超时时间。在低负载时延长等待时间以减少不必要的回滚,在高负载时缩短等待时间以快速释放资源。这种策略通过维护系统级的统计信息来实现,包括平均锁持有时间、锁竞争频率等。
等待队列的优先级管理: 对于同一资源的多个等待者,InnoDB可以根据事务的重要性、等待时间、资源消耗等因素进行优先级排序。高优先级的事务可以优先获得锁,这对于关键业务操作特别重要。优先级的计算考虑多个维度,包括事务的年龄、修改的数据量、是否为只读事务等。
3.2 索引设计对锁性能的影响
覆盖索引与锁范围优化: 当查询能够通过覆盖索引获取所有需要的数据时,InnoDB只需要在二级索引上加锁,而不需要回表访问聚簇索引。这大大减少了锁的数量和范围,提高了并发性能。覆盖索引的设计需要仔细分析查询模式,将经常一起访问的列组合成复合索引。
索引前缀长度对锁粒度的影响: 对于字符串类型的索引,前缀长度的选择直接影响锁的粒度。较短的前缀可能导致多个不同的值映射到相同的索引前缀,从而扩大锁的范围;较长的前缀提供更精确的锁定,但增加了索引的存储开销。最优前缀长度需要在锁粒度和存储效率之间平衡。
3.3 事务设计对锁性能的优化
事务边界的精细控制: 事务边界的设计直接影响锁的持有时间。长事务会长时间持有锁,阻塞其他事务的执行。优化策略包括:将大事务拆分为多个小事务,减少每个事务的锁持有时间;将只读操作和写操作分离,只读操作使用快照读避免加锁;在事务中优先执行可能失败的操作,减少无效锁持有。
批量操作的锁优化: 对于批量插入、更新、删除操作,可以采用特殊的锁策略。比如批量插入时可以使用INSERT … ON DUPLICATE KEY UPDATE语法,减少锁冲突;批量更新时可以按照索引顺序进行,避免死锁;批量删除时可以分批进行,避免长时间持有大量锁。
行锁(Row Lock)
InnoDB的行锁实现
InnoDB是MySQL中支持行锁的主要存储引擎:
实现原理:
- 当事务需要锁定某行时,InnoDB会在该行对应的索引记录上加锁
- 如果没有索引,会锁定整个表(实际是锁定所有行)
- 锁信息存储在内存中的哈希表结构中
表锁(Table Lock)
MyISAM的表锁实现
MyISAM只支持表级锁
- 维护一个全局的表锁队列
- 写锁具有更高优先级
- 锁信息存储在服务器层面
Java 核心技术
数据类型
八个基本数据类型(Primitive Data Types)
1. 整型
整型用于存储不带小数的整数。Java 提供了四种整型,它们的区别在于所占内存空间的大小,从而影响可存储的数值范围。
- byte
- 大小:1 字节(8 位)
- 范围:−128 到 127
- 用途:主要用于节省内存,尤其是在处理大量数据时,例如在文件或网络传输中。
- short
- 大小:2 字节(16 位)
- 范围:−32768 到 32767
- 用途:同样用于节省内存,比
byte
的范围更大。
- int
- 大小:4 字节(32 位)
- 范围:约 −2.1×109 到 2.1×109
- 用途:这是最常用的整型,通常用来表示普通的整数。
- long
- 大小:8 字节(64 位)
- 范围:约 −9.2×1018 到 9.2×1018
- 用途:当
int
类型的范围不足以存储一个大数时使用,例如处理时间戳、文件大小等。在数值后面需要加上L
或l
来表示,例如long myLong = 100L;
。
2. 浮点型
浮点型用于存储带有小数点的数值。
- float
- 大小:4 字节(32 位)
- 用途:单精度浮点数,有效位数为 6-7 位。在数值后面需要加上
F
或f
来表示,例如float myFloat = 3.14f;
。
- double
- 大小:8 字节(64 位)
- 用途:双精度浮点数,有效位数为 15-16 位。这是最常用的浮点型,Java 默认的浮点数类型就是
double
。
注意:浮点数存在精度问题,不适合用于精确的金融计算。如果需要精确计算,应使用
java.math.BigDecimal
类。
3. 字符型
- char
- 大小:2 字节(16 位)
- 用途:存储单个字符。在 Java 中,
char
采用 Unicode 编码,可以表示包括中文在内的所有字符。它可以用单引号'
括起来,例如char myChar = 'A';
。
4. 布尔型
- boolean
- 大小:在内存中通常被视为 1 位,但在数组中会占用 1 字节。
- 用途:用于存储
true
或false
,表示逻辑值。
自动拆装箱的本质
自动拆装箱是 Java 编译器在编译时期,根据代码的语法,自动将基本数据类型和其对应的包装类型之间进行转换的一种机制。这在 Java 5 之后被引入,大大简化了代码,让开发者可以像操作基本类型一样操作包装类型。
- 装箱(Boxing):将基本数据类型(如
int
)转换为对应的包装类型(Integer
)。 - 拆箱(Unboxing):将包装类型(
Integer
)转换为对应的基本数据类型(int
)。
自动拆装箱的底层原理
这并非 JVM 的特性,而是编译器的“障眼法”。当你在 Java 代码中使用自动拆装箱时,编译器会在你看不见的地方,自动插入对应的 valueOf()
和 xxxValue()
方法。
1. 自动装箱的原理
当你写下 Integer a = 100;
这样的代码时,编译器会将其自动转换为:
1 | Integer a = Integer.valueOf(100); |
这就是自动装箱的本质。它调用了包装类的静态方法 valueOf()。
Integer.valueOf() 的特殊之处:
为了节省内存并提高性能,Integer
类内部维护了一个 缓存。这个缓存的范围通常是 -128 到 127。
当 valueOf()
方法传入的值在这个范围内时,它会直接从缓存中返回对应的 Integer
对象,而不是创建一个新的对象。
所以,当你执行以下代码时:
1 | Integer a = 100; |
结果是 true
,因为 a
和 b
都指向了缓存中的同一个对象。
但如果值超出这个范围:
1 | Integer c = 200; |
结果就是 false
,因为每次都会创建新的 Integer
对象。这个缓存优化机制也存在于 Byte
, Short
, Long
和 Character
类型中。
2. 自动拆箱的原理
当你写下 int i = a;
这样的代码(其中 a
是一个 Integer
对象)时,编译器会将其自动转换为:
1 | int i = a.intValue(); |
这就是自动拆箱的本质。它调用了包装类的实例方法 xxxValue()(如 intValue()
, doubleValue()
等)。
注意事项与潜在问题
虽然自动拆装箱让代码更简洁,但如果不了解其原理,可能会引发一些问题:
NullPointerException:如果一个包装类型为
null
,在进行自动拆箱时,会调用null
对象的intValue()
方法,从而抛出NullPointerException
。1
2Integer i = null;
int j = i; // 这里会抛出 NullPointerException性能问题:在循环中频繁地进行自动装箱和拆箱,会不断地创建和销毁对象,这会增加垃圾回收(GC)的负担,影响程序性能。
1
2
3
4
5// 糟糕的写法
Integer sum = 0;
for (int k = 0; k < 10000; k++) {
sum += k; // 每次循环都会进行一次拆箱和一次装箱
}应该使用基本数据类型来避免这种开销:
1
2
3
4
5// 更好的写法
int sum = 0;
for (int k = 0; k < 10000; k++) {
sum += k;
}
好的,我们来详细解析一下静态变量。
什么是静态变量?
在 Java 中,静态变量(Static Variable),也称为类变量(Class Variable),是使用 static 关键字 修饰的变量。它属于 类,而不是类的任何一个 实例对象。
这意味着,不管你创建了多少个类的对象,静态变量在内存中都只有一份,并且这唯一的一份数据被该类的所有对象共享。
代码示例:
1 | public class User { |
在这个例子中:
name
是一个实例变量。每个User
对象都有自己独立的name
属性,user1
的name
和user2
的name
互不影响。company
是一个静态变量。无论你创建多少个User
对象,它们共享同一个company
变量。如果你修改了company
的值,所有User
对象的company
属性都会跟着改变。
静态变量的内存分配与生命周期
- 内存分配:静态变量在类加载时,被分配到 方法区(Method Area) 的 静态区 中。这与实例变量不同,实例变量是在创建对象时,随着对象一起分配在 堆(Heap) 中。
- 生命周期:静态变量的生命周期与类的生命周期相同。当类被加载时,静态变量被创建;当类被卸载时,静态变量才会被销毁。
因为它的生命周期贯穿整个程序运行过程,所以也常常被称为“全局变量”。
如何访问静态变量?
访问静态变量有两种方式,但推荐使用第一种,因为它更符合静态变量“属于类”的特性。
通过类名访问(推荐)
这是最常见、最规范的访问方式。
1
System.out.println(User.company); // 输出: Google
通过对象实例访问(不推荐)
虽然可以通过对象实例来访问静态变量,但编译器会发出警告,因为它可能会造成混淆,让人误以为静态变量是实例特有的。
1
2User user1 = new User("Alice");
System.out.println(user1.company); // 输出: Google
静态变量的应用场景
静态变量因其“全局唯一”的特性,非常适合以下场景:
计数器:需要统计某个类被创建了多少次,可以使用一个静态变量作为计数器。每次创建对象时,就在构造函数中对该静态变量进行自增操作。
1
2
3
4
5
6public class Counter {
public static int count = 0;
public Counter() {
count++;
}
}共享配置:当某些数据是所有对象共有的,比如公司名称、项目名称、数据库连接池等,可以将它们定义为静态变量,方便所有对象共享和访问。
常量:定义程序中不会改变的常量,通常结合
final
关键字一起使用。1
public static final String SERVER_URL = "http://api.example.com";
这样定义的常量可以在整个程序中被安全地访问。
静态变量的缺点与注意事项
- 线程安全问题:由于静态变量是所有线程共享的,当多个线程同时修改它时,可能会发生数据不一致的问题。因此,在使用静态变量时,需要特别注意线程安全,可以考虑使用同步锁或原子类(如
AtomicInteger
)来保证并发安全。 - 过度使用问题:滥用静态变量可能会导致程序耦合度过高,不利于单元测试和代码维护。因为静态变量无法通过依赖注入进行替换,测试时难以模拟或隔离其状态。
new
关键字在 Java 中是用来创建对象实例的。当你在代码中使用 new
关键字时,它会执行以下三个关键步骤:
1. 分配内存
new
关键字会计算新创建对象所需的内存大小,并在 堆(Heap) 上为这个对象分配一块内存空间。这块内存包含了所有实例变量,以及指向该类信息的指针(例如,该类的方法区中的类元信息)。
2. 初始化实例变量
内存分配完成后,Java 会对实例变量进行初始化。
- 如果在定义时给变量赋了初始值,那么它们会按照代码中的值进行初始化。
- 如果没有明确赋值,那么它们会被赋予默认值:
int
为 0,boolean
为false
,引用类型为null
等。
3. 调用构造方法
初始化实例变量之后,new
关键字会调用类的构造方法。构造方法的主要作用是完成对象的最终初始化工作,例如为实例变量赋上更具体的值。
new
关键字与构造方法的关系
很多人会把 new
关键字和构造方法混为一谈,但它们是两个不同的概念。
- new 关键字 是一个操作符,负责创建对象并分配内存。
- 构造方法 是一个特殊方法,负责初始化对象。
你可以把它们想象成这样:
new
就像是工厂的流水线,负责制造一个空壳产品(对象)。- 构造方法就像是流水线上的工人,负责给这个产品填充内容(初始化)。
new
关键字的作用是实例化,而构造方法的作用是初始化。只有通过 new
关键字,你才能调用构造方法来创建一个新的对象。
new
关键字的局限性
虽然 new
是最常见的对象创建方式,但它也有一些局限性:
- 强耦合:直接使用
new
会在代码中创建对具体类的依赖,不利于代码解耦和单元测试。 - 无法灵活创建:你无法通过
new
关键字来动态地决定创建哪个类的实例,也无法在创建对象时进行一些额外的逻辑处理。
因此,在很多设计模式和框架中,会使用其他方式来代替 new
,比如:
- 反射:通过
Class.forName().newInstance()
来创建对象。 - 工厂模式:通过工厂方法或抽象工厂来创建对象。
- 依赖注入(DI)框架:如 Spring 框架,通过配置文件或注解自动创建和管理对象。
Object
/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class<?> getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * native 方法,用于创建并返回当前对象的一份拷贝。 */ protected native Object clone() throws CloneNotSupportedException /** * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。 */ public String toString() /** * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 */ public final native void notify() /** * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 */ public final native void notifyAll() /** * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。 */ public final native void wait(long timeout) throws InterruptedException /** * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。 */ public final void wait(long timeout, int nanos) throws InterruptedException /** * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 */ public final void wait() throws InterruptedException /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
------
## `Object` 类的所有方法
#### 1. `public final native Class<?> getClass()`
返回当前正在运行的对象的 **Class** 对象。这个方法是 **final** 的,这意味着你不能重写它。它通常用于反射(Reflection),让你在运行时获取对象的类型信息。
#### 2. `public native int hashCode()`
返回对象的 **哈希码** 值。这个哈希值通常用于基于哈希的集合,如 `HashMap`、`HashSet` 和 `HashTable`。**hashCode() 和 equals() 必须保持一致**:如果两个对象 `equals()` 返回 `true`,那么它们的 `hashCode()` 也必须相等。
#### 3. `public boolean equals(Object obj)`
比较两个对象是否相等。**Object** 类的默认实现是比较两个对象的**内存地址**。通常情况下,为了比较对象的内容,你需要根据业务逻辑**重写**这个方法。
#### 4. `protected native Object clone() throws CloneNotSupportedException`
创建并返回当前对象的**浅拷贝**。要使用 `clone()` 方法,你的类必须实现**`Cloneable`**接口,否则会抛出 `CloneNotSupportedException` 异常。
#### 5. `public String toString()`
返回对象的字符串表示。默认返回 `“类名@对象的哈希码的无符号十六进制表示”`。为了提供有意义的字符串描述,通常需要**重写**这个方法。
#### 6. `public final native void notify()`
唤醒在此对象监视器(monitor)上等待的**一个**线程。这个方法必须在 `synchronized` 代码块中调用,否则会抛出 `IllegalMonitorStateException`。
#### 7. `public final native void notifyAll()`
唤醒在此对象监视器上等待的**所有**线程。与 `notify()` 一样,它也必须在 `synchronized` 代码块中调用。
#### 8. `public final native void wait(long timeout) throws InterruptedException`
让当前线程进入等待状态,并释放它所持有的锁,直到其他线程调用 `notify()` 或 `notifyAll()`,或者等待时间超时。
#### 9. `public final void wait() throws InterruptedException`
与 `wait(long timeout)` 类似,但它会**无限期**地等待,直到被唤醒。
#### 10. `public final void wait(long timeout, int nanos) throws InterruptedException`
更精确的 `wait()` 方法,允许以毫秒和纳秒为单位指定等待时间。
#### 11. `protected void finalize() throws Throwable`
当垃圾回收器确定没有对该对象的引用时,垃圾回收器会在该对象上调用此方法。这个方法用于在对象被销毁前执行一些清理工作。**不推荐**使用 `finalize()`,因为它不保证何时执行,甚至可能不执行,并且会影响垃圾回收性能。现代 Java 中应使用 `try-with-resources` 或其他资源管理方式。
## **String 类型**
### 1. 字节和字符的区别?
字节是存储容量的基本单位;字符是数字、字母、汉字以及其他语言的各种符号;1 字节 = 8 个二进制单位,一个字符由一个字节或多个字节的二进制单位组成。
------
### 2. String 为什么要设计为不可变类?
在 Java 中将 String 设计成不可变的是综合考虑到各种因素的结果。主要的原因主要有以下三点:
- **字符串常量池的需要**:字符串常量池是 Java 堆内存中一个特殊的存储区域, 当创建一个 String 对象时,假如此字符串值已经存在于常量池中,则不会创建一个新的对象,而是引用已经存在的对象;
- **允许 String 对象缓存 HashCode**:Java 中 String 对象的哈希码被频繁地使用, 比如在 HashMap 等容器中。字符串不变性保证了 hash 码的唯一性,因此可以放心地进行缓存。这也是一种性能优化手段,意味着不必每次都去计算新的哈希码;
- **String 被许多的 Java 类(库)用来当做参数**,例如:网络连接地址 URL、文件路径 path、还有反射机制所需要的 String 参数等, 假若 String 不是固定不变的,将会引起各种安全隐患。
------
### 3. String、StringBuilder、StringBuffer 的区别?
string类不能被继承,因为被final修饰
- **String**:用于字符串操作,属于不可变类;【补充:String 不是基本数据类型,是引用类型,底层用 char 数组实现的】
- **StringBuilder**:与 StringBuffer 类似,都是字符串缓冲区,但线程不安全;
- **StringBuffer**:也用于字符串操作,不同之处是 StringBuffer 属于可变类,对方法加了同步锁,线程安全
StringBuffer的补充说明:StringBuffer 中并不是所有方法都使用了 Synchronized 修饰来实现同步:
```java
@Override public StringBuffer insert(int dstOffset, CharSequence s) { // Note, synchronization achieved via invocations of other StringBuffer methods // after narrowing of s to specific type // Ditto for toStringCache clearing super.insert(dstOffset, s); return this; }
执行效率:StringBuilder > StringBuffer > String
4. String 字符串修改实现的原理?
当用 String 类型来对字符串进行修改时,其实现方法是首先创建一个 StringBuffer,其次调用 StringBuffer 的 append()
方法,最后调用 StringBuffer 的 toString()
方法把结果返回。
5. String str = “i” 与 String str = new String(“i”) 一样吗?
不一样,因为内存的分配方式不一样。String str = "i"
的方式,Java 虚拟机会将其分配到常量池中;而 String str = new String("i")
则会被分到堆内存中。
1 | public class StringTest { |
在执行 String str1 = "abc"
的时候,JVM 会首先检查字符串常量池中是否已经存在该字符串对象,如果已经存在,那么就不会再创建了,直接返回该字符串在字符串常量池中的内存地址;如果该字符串还不存在字符串常量池中,那么就会在字符串常量池中创建该字符串对象,然后再返回。所以在执行 String str2 = "abc"
的时候,因为字符串常量池中已经存在“abc”字符串对象了,就不会在字符串常量池中再次创建了,所以栈内存中 str1 和 str2 的内存地址都是指向 “abc” 在字符串常量池中的位置,所以 str1 = str2
的运行结果为 true。
而在执行 String str3 = new String("abc")
的时候,JVM 会首先检查字符串常量池中是否已经存在“abc”字符串,如果已经存在,则不会在字符串常量池中再创建了;如果不存在,则就会在字符串常量池中创建 “abc” 字符串对象,然后再到堆内存中再创建一份字符串对象,把字符串常量池中的 “abc” 字符串内容拷贝到内存中的字符串对象中,然后返回堆内存中该字符串的内存地址,即栈内存中存储的地址是堆内存中对象的内存地址。String str4 = new String("abc")
是在堆内存中又创建了一个对象,所以 str3 == str4
运行的结果是 false。str1、str2、str3、str4 在内存中的存储状况如下图所示:
6. String 类的常用方法都有那些?
indexOf()
:返回指定字符的索引。charAt()
:返回指定索引处的字符。replace()
:字符串替换。trim()
:去除字符串两端空白。split()
:分割字符串,返回一个分割后的字符串数组。getBytes()
:返回字符串的 byte 类型数组。length()
:返回字符串长度。toLowerCase()
:将字符串转成小写字母。toUpperCase()
:将字符串转成大写字符。substring()
:截取字符串。equals()
:字符串比较。
7. final 修饰 StringBuffer 后还可以 append 吗?
可以。final
修饰的是一个引用变量,那么这个引用始终只能指向这个对象,但是这个对象内部的属性是可以变化的。
官方文档解释:once a final variable has been assigned, it always contains the same value. If a final variable holds a reference to an object, then the state of the object may be changed by operations on the object, but the variable will always refer to the same object.
String
是一个非常特殊的类型,它不是基本数据类型,而是一个引用数据类型(Reference Data Type)。
String 的核心特性
- 不可变性(Immutability)
String
对象一旦被创建,它的值就不能被改变。- 当你对
String
进行修改操作时,比如拼接字符串,实际上是创建了一个新的 String 对象,而原始对象没有改变。 - 这种不可变性使得
String
对象是线程安全的,可以在多线程环境中被安全地共享。
- String 的创建
- 字面量方式:
String s = "hello";
- 这种方式创建的字符串会被存储在**字符串常量池(String Pool)**中。
- 当创建第二个值为
"hello"
的字符串时,JVM 会直接从常量池中返回已存在的对象引用,而不会创建新的对象。这是一种重要的内存优化。
- new 关键字方式:
String s = new String("hello");
- 这种方式会创建一个新的
String
对象在堆(Heap)中。即使常量池中已经存在"hello"
,也会在堆中再创建一个新的对象。
- 这种方式会创建一个新的
- 字面量方式:
String 的常用方法
- 拼接:
- 使用
+
运算符:String s3 = s1 + s2;
- 使用
concat()
方法:s1.concat(s2);
- 推荐使用
StringBuilder
或StringBuffer
进行大量字符串拼接,因为它们是可变的,效率更高。
- 使用
- 比较:
equals()
:比较字符串的内容,返回boolean
类型。equalsIgnoreCase()
:忽略大小写比较字符串内容。==
:比较的是对象的引用地址,只有当两个引用指向同一个对象时才为true
。
- 查找:
indexOf(char)
:查找字符第一次出现的索引。contains(String)
:判断是否包含某个子字符串。
- 修改:
replace(oldChar, newChar)
:替换字符串中的字符。substring(beginIndex, endIndex)
:截取子字符串。
为什么不用 char 数组代替 String?
虽然 String
内部也是用 char
数组实现的,但 String
的不可变性提供了很多优势:
- 安全性:作为方法参数时,可以防止方法内部修改原始字符串。
- 线程安全:可以在多线程环境下共享。
- 哈希码缓存:
String
的哈希码会被缓存,在作为HashMap
的键时,性能更好。
基本类型与 String 的转换
- 基本类型转 String:
String.valueOf(int i)
Integer.toString(int i)
"" + i
- String 转基本类型:
Integer.parseInt("123")
Double.parseDouble("3.14")
Float.parseFloat("1.23")
- 等等。
注意:
String
转基本类型时,如果字符串格式不正确,会抛出NumberFormatException
。
&
和 &&
在 Java 中都是逻辑与运算符,但它们之间有一个至关重要的区别:短路特性。
&
(逻辑与 / 按位与)
&
运算符有两种不同的用途,取决于它的操作数类型。
1. 逻辑与(当操作数为 boolean 类型时)
当 &
的两边是布尔表达式时,它执行非短路逻辑与。这意味着无论第一个表达式的结果是 true
还是 false
,它都会继续计算第二个表达式。
1 | int a = 10; |
在上面的例子中,++a
总是会被执行,a
的值最终会增加。
2. 按位与(当操作数为整数类型时)
当 &
的两边是整数类型(如 int
, long
等)时,它执行按位与运算。它将两个数的二进制位进行比较,如果对应的位都是 1
,则结果位是 1
,否则是 0
。
1 | int x = 5; // 二进制 0101 |
&&
(短路逻辑与)
&&
运算符只用于布尔类型的操作,它执行短路逻辑与。
**短路(Short-circuiting)**是 &&
最重要的特性。如果第一个表达式的结果是 false
,那么整个表达式的结果必定是 false
,因此 &&
会立即停止计算,不再执行第二个表达式。
1 | int a = 10; |
在这个例子中,++a
永远不会被执行,因为它位于短路操作的右侧。
总结
特性 | & |
&& |
---|---|---|
功能 | 逻辑与、按位与 | 仅为逻辑与 |
短路 | 不短路 | 短路 |
计算 | 始终计算第二个表达式 | 当第一个表达式为 false 时,不计算第二个表达式 |
适用类型 | boolean 和整数类型 |
仅 boolean |
性能 | 可能比 && 慢(如果第一个表达式为 false ) |
通常比 & 快(当第一个表达式为 false 时) |
在日常开发中,优先使用 &&。因为它能提高代码效率,并在避免 NullPointerException
等问题时非常有用,比如:
1 | // 使用 && 避免空指针异常 |
而 &
通常只在需要两个表达式都必须执行的特定场景下使用,或者用于按位运算。
异常
Exception 和 Error 有什么区别?
在 Java 中,所有的异常都有一个共同的祖先 java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类:
- Exception :程序本身可以处理的异常,可以通过
catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。 - Error:
Error
属于程序无法处理的错误 ,我们没办法通过catch
来进行捕获不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
⭐️Checked Exception 和 Unchecked Exception 有什么区别?
Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch
或者throws
关键字处理的话,就没办法通过编译。
除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException
、SQLException
…。
Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException
及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误比如权限不够)UnsupportedOperationException
(不支持的操作错误比如重复创建同一用户)- ……
Throwable 类常用方法有哪些?
String getMessage()
: 返回异常发生时的详细信息String toString()
: 返回异常发生时的简要描述String getLocalizedMessage()
: 返回异常对象的本地化信息。使用Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同void printStackTrace()
: 在控制台上打印Throwable
对象封装的异常信息
try-catch-finally 如何使用?
try
块:用于捕获异常。其后可接零个或多个 catch
块,如果没有 catch
块,则必须跟一个 finally
块。
catch
块:用于处理 try 捕获到的异常。
finally
块:无论是否捕获或处理异常,finally
块里的语句都会被执行。当在 try
块或 catch
块中遇到 return
语句时,finally
语句块将在方法返回之前被执行。
5. Error 和 Exception 的区别?
Error 类和 Exception 类的父类都是 Throwable 类。主要区别如下:
- Error 类:一般是指与虚拟机相关的问题,如:系统崩溃、虚拟机错误、内存空间不足、方法调用栈溢出等。这类错误将会导致应用程序中断,仅靠程序本身无法恢复和预防;
- Exception 类:分为运行时异常和受检查的异常。
6. 运行时异常与受检异常有何异同?
- 运行时异常:如:空指针异常、指定的类找不到、数组越界、方法传递参数错误、数据类型转换错误。可以编译通过,但是一运行就停止了,程序不会自己处理;
- 受检查异常:要么用
try ... catch...
捕获,要么用throws
声明抛出,交给父类处理。
7. throw 和 throws 的区别?
- throw:在方法体内部,表示抛出异常,由方法体内部的语句处理;
throw
是具体向外抛出异常的动作,所以它抛出一个异常实例; - throws:在方法声明后面,表示如果抛出异常,由该方法的调用者来处理;表示出现异常的可能性,并不一定会发生这种异常。
8. 常见的异常类有哪些?
- NullPointerException:当应用程序试图访问空对象时,则抛出该异常。
- SQLException:提供关于数据库访问错误或其他错误信息的异常。
- IndexOutOfBoundsException:指示某排序索引(例如对数组、字符串或向量的排序)超出范围时抛出。
- FileNotFoundException:当试图打开指定路径名表示的文件失败时,抛出此异常。
- IOException:当发生某种 I/O 异常时,抛出此异常。此类是失败或中断的 I/O 操作生成的异常的通用类。
- ClassCastException:当试图将对象强制转换为不是实例的子类时,抛出该异常。
- IllegalArgumentException:抛出的异常表明向方法传递了一个不合法或不正确的参数。
9. 主线程可以捕获到子线程的异常吗?
线程设计的理念:“线程的问题应该线程它自己本身来解决,而不要委托到外部”。
正常情况下,如果不做特殊的处理,在主线程中是不能够捕获到子线程中的异常的。如果想要在主线程中捕获子线程的异常,我们可以用如下的方式进行处理,使用 Thread
的静态方法 Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandle());
通常情况下,主线程不能直接捕获到子线程中抛出的异常。这是因为线程是独立的执行流,每个线程都有自己的调用栈。当子线程中发生异常时,如果该异常没有被子线程内部的 try-catch
块捕获,它会沿着子线程的调用栈向上冒泡,最终导致子线程终止。这个异常不会传递到主线程的调用栈中,因此主线程无法感知或捕获它。
这个设计理念被称为“线程的问题应该由线程自己本身来解决,而不是委托到外部”。
捕获子线程异常的方法
虽然主线程无法直接捕获子线程异常,但 Java 提供了两种机制来处理这种情况,允许你在主线程或其他线程中对子线程的异常进行处理:
1. 使用 try-catch
在子线程内部处理
这是最直接也最推荐的方式。在子线程的 run()
方法中,用 try-catch
块将可能抛出异常的代码包围起来。这样,异常就会在子线程内部被捕获并处理,你可以选择记录日志、发送通知,或者执行其他恢复操作。
1 | public class MyRunnable implements Runnable { |
2. 设置未捕获异常处理器 (UncaughtExceptionHandler
)
当你无法在子线程内部处理所有异常时,可以使用 UncaughtExceptionHandler
。这是一个在线程因未捕获异常而突然终止时被调用的回调接口。你可以为每个线程或所有线程设置一个默认的处理器。
A. 为单个线程设置处理器
你可以为特定的 Thread
实例设置一个 UncaughtExceptionHandler
。当该线程发生未捕获异常时,JVM 会调用你提供的处理器的 uncaughtException()
方法。
1 | Thread thread = new Thread(() -> { |
B. 为所有线程设置默认处理器
如果你想为所有线程(包括线程池中的线程)设置一个通用的异常处理逻辑,可以使用 Thread.setDefaultUncaughtExceptionHandler()
。这个处理器会在没有任何特定处理器的线程上被调用。
1 | // 在主线程中设置全局默认处理器 |
总结
- 主线程不能直接捕获子线程的异常,因为它们是独立的执行流。
- 要处理子线程的异常,最简单的方法是在子线程内部使用 try-catch 块。
- 如果需要在主线程中感知和处理子线程的异常,可以使用
Thread
提供的**UncaughtExceptionHandler
**机制。你可以为特定线程或所有线程设置一个处理器,当线程因未捕获异常而终止时,这个处理器会被调用。
创建对象:
好的,Java 中创建对象的方式有多种,除了最常见的 new
关键字,还有其他几种重要的技术。下面我们来详细解析这四种创建对象的方法。
1. 使用 new
关键字
这是最常见、最直接的创建对象方式。当你使用 new
关键字时,它会调用类的构造器来初始化对象。
原理:
- 分配内存: JVM 在堆(Heap)上为新对象分配所需的内存空间。
- 默认初始化: 成员变量会被赋予默认值(例如,
int
为0
,boolean
为false
,引用类型为null
)。 - 调用构造器: JVM 调用指定的构造器来执行初始化代码。构造器中的代码会覆盖之前的默认值。
- 返回引用: 对象创建完成后,
new
表达式会返回该对象在内存中的引用。
优点:
- 简单直观,代码可读性好。
- 可以调用任何可访问的构造器(包括有参和无参)。
缺点:
- 每次调用
new
都会创建一个新对象,开销较大。
示例:
1 | // 使用 new 关键字创建对象 |
2. 使用 Class
类的 newInstance()
方法(反射)
Class.forName().newInstance()
是通过反射来创建对象的方式。
原理:
- 加载类:
Class.forName()
方法根据类的全限定名(如"com.example.MyObject"
)加载类,并返回对应的Class
对象。 - 创建实例:
newInstance()
方法会调用该类的无参构造器来创建对象。
注意:
newInstance()
要求类必须有一个无参的构造器,且该构造器必须是可见的(public
)。- Java 9 之后,
Class.newInstance()
被标记为过时(deprecated)。 - 在现代 Java 中,更推荐使用
Constructor
类的newInstance()
方法,因为它能够调用有参构造器,并且可以处理构造器抛出的异常。
优点:
- 可以在运行时动态地创建对象,非常灵活。
- 适用于工厂模式、依赖注入(如 Spring)等场景。
缺点:
- 性能比
new
关键字差,因为涉及反射机制。 - 如果类没有无参构造器或构造器不可访问,会抛出异常。
- 没有编译时检查,错误只能在运行时发现。
示例:
1 | // 使用 Class.forName().newInstance() 创建对象 |
3. 使用 clone()
方法
clone()
方法通过复制现有对象来创建新对象,而不是重新构造。它属于 Object
类的一个 protected
方法。
原理:
- 实现接口: 要使用
clone()
方法,你的类必须实现Cloneable
接口。这个接口是一个标记接口,本身没有任何方法,但它告诉 JVM 这个类可以被克隆。 - 重写方法: 你需要重写
Object
类的clone()
方法,并将其访问修饰符改为public
。 - 执行克隆:
clone()
方法会创建一个新对象,并将现有对象的字段值逐一复制到新对象中。这是一种浅拷贝(shallow copy)。如果你的对象包含引用类型的成员,那么新对象和旧对象将共享同一个引用。
优点:
- 克隆一个对象比使用
new
关键字创建对象更快,因为它跳过了构造器的初始化过程。 - 在需要大量创建相同或相似对象时,是一种有效的性能优化手段。
缺点:
clone()
方法默认是浅拷贝,如果需要深拷贝(deep copy),需要手动实现。Cloneable
接口和clone()
方法的设计有些“反直觉”,容易出现问题。
示例:
1 | // 类需要实现 Cloneable 接口并重写 clone() 方法 |
4. 反序列化(Deserialization)
反序列化是指将字节流恢复成 Java 对象的过程。
原理:
- 序列化: 首先,一个对象必须是**可序列化(
Serializable
)**的,即它实现了Serializable
接口。 - 写入流: 序列化就是将对象的状态(成员变量的值)写入一个字节流中(例如,写入文件或网络)。
- 反序列化: 反序列化则是从字节流中读取数据,并重建(re-create)原始对象。这个过程不会调用构造器。
优点:
- 可以将对象持久化到磁盘或在网络中传输,这对于分布式系统和缓存非常重要。
- 可以跨进程甚至跨机器传输对象。
缺点:
- 只有实现了
Serializable
接口的类才能进行序列化和反序列化。 - 如果对象的版本不兼容,反序列化可能会失败。
- 存在安全风险,因为它能够绕过构造器,如果设计不当可能导致不一致的状态。
示例:
1 | // 对象必须实现 Serializable 接口 |
总结
这四种创建对象的方式各有优劣和适用场景:
- new:最常用,用于常规的对象创建。
- 反射:用于运行时动态创建对象,常用于框架开发。
- clone():用于快速复制现有对象,以提高性能。
- 反序列化:用于将对象持久化或在网络传输中重建。
理解这些创建对象的方式,可以让你在不同的编程场景中做出更明智的选择。
深拷贝和浅拷贝是 Java 对象复制时需要理解的两个重要概念。它们主要通过 clone()
方法或序列化来实现。
浅拷贝(Shallow Copy)
浅拷贝只复制对象本身,以及其基本数据类型的字段。对于引用类型的字段,它只复制引用地址,新旧对象会共享同一块内存中的引用数据。
实现方式:clone()
方法
Java 中的浅拷贝通常通过实现 Cloneable
接口并重写 Object
类的 clone()
方法来实现。
步骤:
- 让你的类实现
java.lang.Cloneable
接口。这是一个标记接口,告诉 JVM 这个类的实例可以被克隆。 - 重写
Object
类的clone()
方法,并将其可见性改为public
。 - 在重写的方法中调用
super.clone()
。
示例:
1 | class Address { |
输出:
1 | --- 浅拷贝前 --- |
可以看到,修改克隆对象的地址,原始对象的地址也随之改变,这正是浅拷贝的特点。
深拷贝(Deep Copy)
深拷贝会创建一个完全独立的新对象,不仅复制基本数据类型,还会递归地复制所有引用类型字段指向的对象。新旧对象之间互不影响。
实现方式一:手动实现递归复制
在重写的 clone()
方法中,对引用类型的字段进行单独的克隆。
示例:
修改上面的 Student 类,实现深拷贝:
1 | class Address implements Cloneable { |
输出:
1 | --- 深拷贝前 --- |
这次,修改克隆对象的地址,原始对象没有受到影响。
实现方式二:序列化与反序列化
这是一种更简单、通用的深拷贝方法,特别适用于对象结构复杂的情况。
步骤:
- 让所有相关类(包括引用类型)都实现
java.io.Serializable
接口。 - 将对象写入一个字节流中(序列化)。
- 再从字节流中读出新的对象(反序列化)。
示例:
1 | import java.io.*; |
这个方法代码虽然多,但对于复杂的对象图来说,它比手动递归实现要简单得多,因为它会自动处理所有嵌套对象的拷贝。
接口和抽象类:
在软件开发中,抽象类(abstract class)和接口(interface)都是用于实现多态的重要概念,它们允许你定义一个通用的契约,然后让具体的类去实现这个契约。虽然它们的目的相似,但在用途、设计和实现上存在一些关键区别。
抽象类
抽象类是一个不能被实例化的类,它通常包含抽象方法(没有具体实现的方法)和具体方法(有具体实现的方法)。
主要特点:
- 语法: 使用
abstract
关键字来声明类和抽象方法。 - 方法: 可以同时包含抽象方法和具体方法。抽象方法必须在子类中被实现,具体方法则可以选择性地被重写。
- 成员变量: 可以包含成员变量,包括
private
、protected
和public
等修饰符。 - 继承: 一个子类只能继承一个抽象类。如果子类不是抽象类,它必须实现所有父抽象类中的抽象方法。
- 构造器: 抽象类可以有构造器,但不能直接用
new
关键字来创建实例。它的构造器主要用于在子类中通过super
关键字调用,以初始化父类的成员。 - 目的: 通常用于定义一类事物共同的属性和行为。它代表一种“is-a”(是一种)的关系,比如“猫是一种动物”,
Cat
类继承Animal
抽象类。
使用场景:
当你需要定义一个模板,其中一些方法是通用的(已实现),而另一些方法是需要子类根据自身特点去实现的,抽象类是理想选择。例如,一个 Shape 抽象类可以定义一个通用的 draw() 抽象方法,但每个具体的形状(Circle,Rectangle)都有自己独特的绘制方式。同时,它还可以有一个具体的 getColor() 方法。
1 | // Java 示例 |
接口
接口是一个完全抽象的“类”,它定义了一组契约或规范,其中只包含抽象方法和常量。自 Java 8 之后,接口也可以包含默认方法(default method)和静态方法。
主要特点:
- 语法: 使用
interface
关键字声明。 - 方法: 在 Java 8 之前,接口中的方法默认都是
public abstract
的,可以省略这两个关键字。自 Java 8 之后,可以有default
和static
方法。自 Java 9 之后,还可以有private
方法。 - 成员变量: 接口中的成员变量默认是
public static final
的,也即常量。 - 实现: 一个类可以实现(implements)多个接口,从而克服了单继承的限制。
- 目的: 主要用于定义一个能力或行为。它代表一种“can-do”(能做)的关系,比如一个
Car
类可以实现Driveable
接口,一个Bird
类可以实现Flyable
接口。它是一种对行为的抽象。
使用场景:
当你需要定义一种能力,而这种能力可以被不同类别的对象共享时,接口非常有用。例如,Flyable 接口可以被 Airplane、Bird 或 Superman 等完全不相关的类实现,因为它们都具有“飞行”的能力。
Java
1 | // Java 示例 |
核心区别总结
特性 | 抽象类(Abstract Class) | 接口(Interface) |
---|---|---|
继承/实现 | extends ,单继承 |
implements ,多实现 |
方法 | 可包含抽象方法和具体方法 | 仅包含抽象方法(Java 8+ 可有 default 、static 方法) |
成员变量 | 各种类型变量,包括常量和非常量 | 只能是 public static final 常量 |
构造器 | 可以有 | 不能有 |
可访问性 | 成员可以是 private , protected 或 public |
方法默认是 public ,变量默认是 public static final |
目的 | 定义一类事物的共同特征和行为,代表“is-a”关系 | 定义一种能力或行为,代表“can-do”关系 |
10、抽象类和接口有什么区别?
(1)抽象类中可以定义构造函数,接口不能定义构造函数;
(2)抽象类中可以有抽象方法和具体⽅方法,而接口中只能有抽象方法(public abstract);
(3)抽象类中的成员权限可以是 public、默认、protected(抽象类中抽象方法就是为了重写,所以不能被 private 修饰),而接口中的成员只可以是 public(方法默认:public abstrat、成员变量默认:public static final);
(4)抽象类中可以包含静态方法,而接口中不不可以包含静态方法;
JDK 8 中的改变:
1、在 JDK1.8中,允许在接⼝口中包含带有具体实现的⽅方法,使用 default 修饰,这类⽅方法就是默认方法。 2、抽象类中可以包含静态方法,在 JDK1.8 之前接口中不不能包含静态方法,JDK1.8 以后可以包含。之前不能包含是因为,接口不可以实现方法,只可以定义方法,所以不能使用静态方法(因为静态方法必须实现)。现在可以包 含了,只能直接用接口调用静态⽅方法。JDK1.8 仍然不可以包含静态代码块。
面试官可能会问的深入问题
- 为什么 Java 要同时保留抽象类和接口?
- 抽象类侧重于代码复用,通过具体方法减少子类的重复代码,并强制子类实现某些特定行为。
- 接口侧重于功能扩展和解耦,允许不相关的类拥有相同的行为,实现更灵活的设计,同时避免多继承带来的复杂性。
- 什么时候用抽象类,什么时候用接口?
- 如果你想在类层次结构中共享代码,并且这种关系是一种**“is-a”关系**,使用抽象类。
- 如果你想为不相关的类定义通用的行为,并且希望一个类能拥有多种能力,使用接口。
- Java 8 的默认方法对接口和抽象类的区别有什么影响?
- 默认方法的引入模糊了两者的一些界限,接口现在也可以提供方法的默认实现,使得接口的能力更强。
- 但是,本质区别仍然存在:抽象类可以有状态(非 final 成员变量),而接口不能。 抽象类可以共享状态和具体实现,接口则更关注行为的契约。
简单来说,抽象类是模板,提供了骨架和部分实现;接口是契约,只规定了行为。理解了这一点,就能很好地应对这类面试题。
好的,我帮你系统整理 代理模式(Proxy Pattern) 的概念、分类和特点,并附带易懂示例。
代理模式(Proxy Pattern)
代理模式是一种 结构型设计模式,其核心思想是:为其他对象提供一种代理以控制对这个对象的访问。
代理对象(Proxy)充当真实对象(Real Subject)的替身,客户端通过代理访问真实对象,而代理可以在访问前后进行增强操作(如权限校验、延迟加载、日志记录等)。
1. 代理模式的组成
- Subject(抽象主题)
- 定义真实对象和代理对象的公共接口,客户端通过这个接口访问对象。
- RealSubject(真实主题)
- 真正的业务逻辑对象,实现 Subject 接口。
- Proxy(代理类)
- 通过实现 Subject 接口,内部持有 RealSubject 的引用。
- 可在调用真实对象方法前后添加额外功能。
1 | // 示例 |
2. 代理模式的几种类型及特点
类型 | 特点 | 适用场景 |
---|---|---|
静态代理(Static Proxy) | - 代理类在编译时就确定- 代理类与真实对象实现相同接口- 优点:简单易懂,编译时检查安全- 缺点:每一个真实对象都需要对应代理类,代理类数量多,扩展性差 | 权限控制、日志记录等固定功能的增强 |
动态代理(Dynamic Proxy) | - 运行时创建代理类- Java 提供 JDK 动态代理(接口)和 CGLIB 动态代理(继承子类)- 优点:减少代理类数量,增强可扩展性- 缺点:运行时生成字节码,性能稍低 | AOP、事务管理、日志记录、RPC 调用 |
强制代理(Force Proxy) | - 真实对象只能通过代理访问- 客户端无法直接访问真实对象 | 需要严格控制访问权限的场景 |
远程代理(Remote Proxy) | - 为远程对象提供本地代理,隐藏网络通信细节 | RPC 调用、分布式系统 |
虚拟代理(Virtual Proxy) | - 延迟创建或加载真实对象- 代理对象在需要时才创建真实对象 | 大对象延迟加载、懒加载场景 |
保护代理(Protection Proxy) | - 控制真实对象的访问权限- 根据不同用户权限返回不同结果 | 权限控制、安全管理 |
缓存代理(Cache Proxy) | - 对结果进行缓存,减少真实对象重复计算 | 频繁查询、计算量大的场景 |
3. 代理模式的优缺点
优点:
- 客户端与真实对象解耦,通过代理可以灵活增强功能。
- 可以控制访问、延迟加载、记录日志等。
- 增强对象功能而不修改原始类。
缺点:
- 增加系统复杂性,代理类可能增加额外层次。
- 对性能有一定影响(尤其是动态代理或远程代理)。
如何实现动态代理?
动态代理的核心思想是在运行时创建一个代理对象,用于在不修改原始类代码的情况下,为对象添加额外的功能(如日志记录、性能监控、事务管理等)。Java 提供了两种主要的动态代理实现方式:JDK 动态代理和 CGLIB 动态代理。
这里我们重点解析 JDK 动态代理的实现,它依赖于接口。
核心组件
JDK 动态代理的实现主要依赖于以下三个核心组件:
- 接口 (Interface):代理对象和被代理对象必须实现同一个或一组接口。这是 JDK 动态代理的基础,它强制代理对象和被代理对象具有相同的公共行为。
- InvocationHandler:这是动态代理的核心处理逻辑。你需要在其中实现
invoke
方法,这个方法包含了代理对象被调用时要执行的所有逻辑。 - Proxy:这是一个工具类,用于动态生成代理对象。你不能直接创建代理对象,而是通过
Proxy
类的静态方法来完成。
实现步骤
动态代理的整个过程可以分为以下几步:
- 定义接口:首先,你需要定义一个接口,声明被代理对象和代理对象共同拥有的方法。
- 创建被代理对象(目标对象):创建一个类,实现上一步定义的接口,这就是我们真正要代理的对象。
- 编写 InvocationHandler:创建一个处理类,它实现
InvocationHandler
接口,并重写invoke
方法。在这个方法中,你将:- 接收被代理对象的实例。
- 在调用原始方法之前执行一些预处理逻辑。
- 通过
method.invoke(目标对象, args)
调用被代理对象的原始方法。 - 在调用原始方法之后执行一些后处理逻辑。
- 使用 Proxy 类生成代理对象:调用
Proxy
类的静态方法newProxyInstance()
来创建代理对象。这个方法需要三个参数:- ClassLoader loader:用于加载代理类的类加载器。通常使用被代理对象的类加载器。
- Class<?>[] interfaces:一个接口数组,代理对象需要实现的接口。
- InvocationHandler handler:实现了
InvocationHandler
的对象,它包含了代理的逻辑。
invoke()
方法的参数解析
InvocationHandler
接口的 invoke()
方法签名如下:
1 | Object invoke(Object proxy, Method method, Object[] args) throws Throwable |
- proxy:这个参数是代理对象本身的引用。在
invoke
方法内部,你应该避免直接调用proxy
的方法,否则可能导致无限递归调用。 - method:代表正在被调用的方法,它是
java.lang.reflect.Method
类型的对象。你可以通过它获取方法名、参数类型、返回值类型等信息。 - args:一个
Object
数组,包含了调用原始方法时传递的所有参数。
通过以上步骤,JVM 会在运行时动态生成一个实现了指定接口、并继承自 Proxy
类的字节码,将其加载到内存中,并最终返回一个代理对象实例。所有对代理对象方法的调用都会被自动转发到 InvocationHandler
的 invoke
方法中,从而实现了对原始方法的增强和控制。
Proxy 类的作用是动态创建一个代理对象的类。它提供了许多的方法,但是我们用得最多的就是 newProxyInstance
这个方法:
public static Object newProxyInstance(ClassLoader loader, Class[] interfaces, InvocationHandler handler) throws IllegalArgumentException
loader
:一个 ClassLoader 对象,定义了由哪个 ClassLoader 对象来对生成的代理对象进行加载;interfaces
:一个 Interface 对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了。handler
:一个 InvocationHandler 对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个 InvocationHandler 对象上。
通过 Proxy.newProxyInstance
创建的代理对象是在 Jvm 运行时动态生成的一个对象,它并不是我们的 InvocationHandler 类型,也不是我们定义的那组接口的类型,而是在运行时动态生成的一个对象。
成员类:
我已经为您将之前关于成员类分类的详细解析和表格总结进行了合并,以提供一个更全面、更易于理解的回答。
在 Java 中,一个类可以定义在另一个类的内部,这种类被称为成员类(Member Class),也被称为嵌套类(Nested Class)。根据其定义方式和特性,成员类主要分为以下四种类型:
1. 静态成员类(Static Member Class)
静态成员类是使用 static
关键字修饰的内部类。它与外部类的实例没有关联,可以像访问静态变量一样,直接通过外部类名来创建它的实例。
- 特点:
- 无法直接访问外部类的非静态成员,但可以访问外部类的静态成员。
- 适用场景:
- 当一个类与外部类逻辑上紧密相关,但又不需要访问外部类的实例成员时,使用静态成员类。它常用于作为外部类的辅助工具类,比如在一个
LinkedList
类中定义一个Node
静态成员类来表示链表节点。
- 当一个类与外部类逻辑上紧密相关,但又不需要访问外部类的实例成员时,使用静态成员类。它常用于作为外部类的辅助工具类,比如在一个
2. 内部类(Inner Class)
内部类是没有被 static
关键字修饰的嵌套类。它与外部类的实例紧密相连,必须依附于一个外部类的实例才能被创建。
- 特点:
- 可以直接访问外部类的所有成员,包括私有成员。这是内部类最强大的特性。
- 适用场景:
- 当一个类的实例需要直接访问外部类的私有状态时,使用内部类。它常用于实现某些特定功能的回调接口。
3. 局部内部类(Local Inner Class)
局部内部类是定义在方法或代码块内部的类。它只能在定义它的方法或代码块内部被访问和实例化。
- 特点:
- 无法使用访问修饰符(如
public
)。 - 可以访问外部类的所有成员,但只能访问其所在方法中的 final 或事实上的 final 局部变量。
- 无法使用访问修饰符(如
- 适用场景:
- 当某个方法中需要一个只用一次的复杂对象或工具类时,可以使用局部内部类,以避免在类级别创建不必要的成员。
4. 匿名内部类(Anonymous Inner Class)
匿名内部类是一种没有名字的局部内部类,通常用于创建一个只使用一次的类的实例。
- 特点:
- 没有名字,因此无法被复用。
- 必须在创建实例的同时定义类的实现。
- 无法定义构造器。
- 可以实现一个接口或继承一个类。
- 适用场景:
- 最常用于实现事件监听器或创建线程,尤其是在需要简洁地实现某个接口的单个方法时。
匿名内部类中的局部变量为什么只能用 final
?
这个限制的根本原因在于生命周期(Lifecycle)。
- 局部变量的生命周期:局部变量(如方法中的参数或变量)是在方法被调用时创建的,存储在**栈(Stack)**上。当方法执行完毕,这些变量就会被销毁。
- 匿名内部类的生命周期:匿名内部类的实例通常会作为一个对象被传递或保存,它的生命周期可能比创建它的方法更长。例如,你可能会将一个实现了事件监听器的匿名内部类实例作为参数传递给一个 UI 组件,当方法返回后,这个监听器对象可能仍然存在并等待事件触发。
问题出现:
如果匿名内部类可以访问和修改一个非 final
的局部变量,那么当创建它的方法执行完毕、局部变量被销毁后,匿名内部类的实例仍然存在。当它试图访问这个已经不存在的变量时,就会引发一个**悬空引用(Dangling Reference)**的错误。
为了解决这个问题,Java 编译器采取了以下策略:
- 将局部变量复制一份:当匿名内部类访问一个局部变量时,编译器会为它在堆(Heap)上创建一个副本。这个副本会和匿名内部类实例一起存储,从而确保匿名内部类在任何时候都能访问到这个值。
- 强制 final 保证一致性:如果允许修改原始的局部变量,而匿名内部类使用的是它的副本,那么就会导致原始变量和副本之间的数据不一致。为了避免这种混乱,Java 强制要求被匿名内部类访问的局部变量必须是
final
的,以确保其值不可变。final
关键字保证了原始变量和副本的值始终相同。
Java 8 之后的改变:“事实上的 final
”(Effectively Final)
在 Java 8 之后,为了简化代码,这个规则有了一些放松。你不再需要显式地使用 final
关键字。只要一个局部变量在定义后没有被重新赋值,它就会被编译器自动视为“事实上的 final”。
这使得代码看起来更简洁,但本质上的限制并没有改变:匿名内部类仍然只能访问那些值在方法执行期间不会改变的局部变量。
示例:
1 | public void doSomething() { |
在这个例子中,name
变量没有被 final
修饰,但在它被匿名内部类访问后没有被修改,所以编译器会将其视为事实上的 final
,代码可以正常编译和运行。但如果你尝试在 doSomething
方法的 new Thread
之后修改 name
,编译器就会报错。
总结表格
类型 | 关键字 | 定义位置 | 访问外部类成员 | 创建方式 |
---|---|---|---|---|
静态成员类 | static |
在外部类内部 | 只能访问外部类的 静态成员 | new Outer.StaticInner(); |
内部类 | 无 | 在外部类内部 | 可访问外部类的所有成员 | new Outer().new Inner(); |
局部内部类 | 无 | 在方法或代码块内部 | 可访问外部成员和 final 局部变量 | 只能在定义它的方法内部创建 |
匿名内部类 | 无 | 在方法或代码块内部 | 同局部内部类 | new Interface/Class() { ... }; |
匿名内部类、函数式接口和 Lambda 表达式是 Java 语言在不断演进过程中,为了简化代码和支持函数式编程而引入的重要特性。它们经常在面试中被一起考察,因为它们之间存在演变和替代的关系。
1. 匿名内部类 (Anonymous Inner Class)
匿名内部类是一种没有名字的局部内部类,通常用于创建一个只使用一次的类的实例。
- 核心特点:
- 没有名字: 无法被复用。
- 创建时定义: 必须在创建实例的同时定义类的实现。
- 只能实现一个: 它可以实现一个接口或继承一个类,但不能两者都做。
- 用途:
- 主要用于简化代码,特别是当一个类只被使用一次,且其实现非常简单时。
- 常见的应用场景是事件监听器(
ActionListener
)、线程(Runnable
)等。
示例:
创建一个线程,实现 Runnable 接口:
1 | Thread t = new Thread(new Runnable() { |
2. 函数式接口 (Functional Interface)
函数式接口是指只包含一个抽象方法的接口。它可以包含其他默认方法(default
)、静态方法(static
)和来自 Object
类的方法(如 equals
)。
- 核心特点:
- 一个抽象方法: 这是函数式接口的唯一强制要求。
- @FunctionalInterface 注解: 这是一个可选的注解,但强烈推荐使用。它会帮助编译器检查该接口是否满足函数式接口的定义,如果违反了规则,就会报错。
用途:
- 它为 Lambda 表达式提供了一个目标类型。Lambda 表达式的本质就是函数式接口的一个匿名实现。
java.util.function
包中定义了大量的函数式接口,如Predicate
、Consumer
、Function
和Supplier
,它们是 Java 8 Stream API 的基础。
示例:
一个自定义的函数式接口
1 |
|
3. Lambda 表达式 (Lambda Expression)
Lambda 表达式是 Java 8 引入的一个新特性,它提供了一种简洁的方式来表示函数式接口的实现。Lambda 表达式可以被看作是匿名内部类的简化版本,用于实现只有一个抽象方法的接口。
- 语法:
(参数列表) -> { 方法体 }
- 核心特点:
- 简洁: 极大地减少了代码量,省去了匿名内部类的许多样板代码。
- 函数式编程: 使得 Java 能够更方便地支持函数式编程范式,如将函数作为参数传递。
- 类型推断: 编译器可以根据上下文自动推断参数类型。
示例:
使用 Lambda 表达式实现上面的 Runnable 接口:
1 | Thread t = new Thread(() -> System.out.println("Hello from a Lambda expression!")); |
常见的面试问题
1. 匿名内部类、Lambda 表达式和函数式接口之间的关系是什么?
- 演变关系: 匿名内部类是 Java 早期用于实现接口或抽象类的单一方法的笨重方式。函数式接口的出现,为 Lambda 表达式提供了一个目标类型。Lambda 表达式就是对只有一个抽象方法的匿名内部类的一种语法糖,它极大地简化了代码。
- 本质: Lambda 表达式是函数式接口的一个匿名实现。
2. 为什么 Lambda 表达式只能实现函数式接口?
Lambda 表达式的语法((参数) -> {方法体}
)本身就只适用于实现一个方法。如果一个接口有两个抽象方法,编译器无法确定 Lambda 表达式的 ()
和 {}
对应的是哪个方法,因此会报错。
3. Lambda 表达式和匿名内部类在使用 this
关键字时有什么区别?
- 匿名内部类: 匿名内部类有自己的作用域。它内部的
this
关键字指向的是匿名内部类自身的实例。 - Lambda 表达式: Lambda 表达式没有自己的作用域。它内部的
this
关键字指向的是其定义所在类的实例。
示例:
1 | public class OuterClass { |
4. Lambda 表达式捕获的局部变量为什么必须是 final
的?
和匿名内部类一样,Lambda 表达式会捕获其外部作用域的局部变量。这些变量在 Lambda 表达式的生命周期中是不可变的,因为它们被复制到了堆中。在 Java 8 之前,你需要显式地用 final
修饰;自 Java 8 之后,只要变量没有被重新赋值,它就是**“事实上的 final
”**,可以省略 final
关键字。
Lambda表达式:
Lambda 表达式是 Java 8 引入的语法糖,它让你可以用更简洁的方式实现函数式接口。它的底层实现机制比你想象的要复杂,但核心思想是利用了新的字节码指令和方法。
Lambda 表达式的底层实现
Lambda 表达式的底层实现依赖于两个关键技术:
- invokedynamic 指令
- 方法句柄(Method Handle)
在 Java 7 之前,JVM 中只有四种调用方法的指令:invokevirtual
、invokeinterface
、invokespecial
和 invokestatic
。这些指令都是静态绑定的,意味着在编译时就确定了调用的目标方法。
然而,Lambda 表达式的调用是动态的。编译器在处理 Lambda 表达式时,并不知道它会具体调用哪个方法。因此,Java 7 引入了 invokedynamic 指令,它允许在运行时动态地解析和绑定方法调用。
编译时:生成 invokedynamic
指令
当你编写一个 Lambda 表达式时,例如:
1 | Runnable r = () -> System.out.println("Hello"); |
Java 编译器在编译时并不会直接生成一个匿名内部类。相反,它会做以下事情:
- 将 Lambda 表达式体转换为一个私有方法:编译器会把
System.out.println("Hello")
这段代码提取出来,生成一个新的私有方法,通常是private static
或private
类型,并带有特殊的命名规则(比如lambda$main$0
)。这个新方法包含了 Lambda 表达式的实际逻辑。 - 生成 invokedynamic 指令:在 Lambda 表达式出现的地方,编译器会生成一条
invokedynamic
指令。这条指令不会直接调用上一步生成的私有方法。相反,它指向一个引导方法(Bootstrap Method)。
invokedynamic
指令的结构大致如下:
invokedynamic #0:run:()Ljava/lang/Runnable;
#0
是一个常量池索引,指向引导方法(通常是java.lang.invoke.LambdaMetafactory.metafactory
)。run
是函数式接口中抽象方法的名称。()Ljava/lang/Runnable;
是函数式接口的签名。
运行时:动态绑定
当 JVM 执行到 invokedynamic
指令时,它会执行以下步骤:
- 调用引导方法:JVM 会调用
LambdaMetafactory.metafactory
引导方法。这个方法是 Lambda 表达式的**“工厂”,它负责生成一个方法句柄(Method Handle)**。 - 生成方法句柄:方法句柄是一个轻量级的、类似于
java.lang.reflect.Method
的引用,但它更高效,可以直接调用。它指向编译时生成的那个私有方法(即 Lambda 表达式的实际逻辑)。 - 生成调用点对象:
LambdaMetafactory.metafactory
返回一个**CallSite
**对象,这个对象持有一个指向方法句柄的引用。这个CallSite
会被缓存起来。 - 执行方法调用:
invokedynamic
指令会直接跳转到CallSite
对象中的方法句柄,然后执行实际的 Lambda 表达式代码。
Lambda 表达式与匿名内部类的区别
理解底层实现后,两者在性能和设计上有一些关键区别:
特性 | Lambda 表达式 | 匿名内部类 |
---|---|---|
生成方式 | 动态生成私有方法和 invokedynamic 指令 |
编译时生成独立的 .class 文件 |
性能 | 通常更快。invokedynamic 优化了性能,避免了每次都创建新对象和执行 invokespecial 指令。 |
每次使用都会创建一个新的对象实例,并生成一个额外的类文件。 |
作用域 | 没有自己的作用域,this 关键字指向外部类的实例。 |
有自己的作用域,this 关键字指向匿名内部类实例本身。 |
内存开销 | 更小。由于采用了方法句柄,避免了为每个 Lambda 表达式创建独立的匿名类对象。 | 会产生额外的 .class 文件和对象实例,内存开销更大。 |
总结来说,Lambda 表达式的底层实现是一种延迟创建和动态绑定的机制。编译器在编译时只留下一个调用指令,将真正的实现工作推迟到运行时。这种设计使得 Lambda 表达式在保持代码简洁的同时,也带来了更高的性能。
switch语句支持的数据类型:
switch
语句是 Java 中一个非常实用的控制流语句,它能够根据变量的值来选择不同的执行路径。从 Java 7 开始,switch
语句支持的数据类型有了显著的扩展。
switch
语句作用的数据类型包括:
1. 基本数据类型
- byte
- short
- char
- int
注意:
switch
语句不支持long
、float
、double
或boolean
类型。- 如果你的变量是
long
类型,你需要将其强制转换为int
类型才能在switch
中使用,但这可能导致数据丢失,通常不推荐。
2. 包装类
- Byte
- Short
- Character
- Integer
这是因为 Java 编译器在编译时会进行自动拆箱(autounboxing),将包装类对象自动转换为对应的基本数据类型。
3. 枚举类型 (enum)
- 从 Java 5 开始,
switch
语句开始支持枚举类型。 - 使用枚举可以使代码更具可读性和安全性,因为编译器会检查所有
case
标签是否对应了枚举中的有效常量。
示例:
1 | enum Day { |
4. String 类型
- 从 Java 7 开始,
switch
语句开始支持String
类型。 - 这个特性极大地简化了基于字符串的条件判断代码,避免了大量的
if-else if-else
链。 - 在底层,编译器通过
String
对象的hashCode()
和equals()
方法来执行switch
逻辑。
示例:
1 | String fruit = "apple"; |
总结
支持类型 | 版本 | 备注 |
---|---|---|
byte , short , char , int |
所有版本 | switch 的基础类型。 |
Byte , Short , Character , Integer |
所有版本 | 自动拆箱机制使其可用。 |
enum |
Java 5+ | 增强代码的可读性和安全性。 |
String |
Java 7+ | 极大地简化了字符串判断逻辑。 |
记住,无论使用哪种类型,case
标签后面的值都必须是编译时常量。
锁
1. 内置锁:synchronized
关键字
synchronized
是Java语言层面的同步机制,使用起来相对简单。它可以修饰方法或代码块,由JVM负责加锁和释放锁,开发者不需要手动管理。
- 同步方法: 当你用
synchronized
修饰一个非静态方法时,锁对象是这个方法的实例对象(this
)。当一个线程进入这个同步方法时,它会获得该实例的锁,其他线程就无法进入这个实例的任何同步方法,直到它释放锁。 - 同步静态方法: 当
synchronized
修饰一个静态方法时,锁对象是该类的Class
对象。这意味着所有线程在访问这个类的任何同步静态方法时都会争抢同一把锁,无论有多少个实例。 - 同步代码块:
synchronized (object)
。这是最灵活的使用方式,你可以指定任何对象作为锁。通过缩小同步范围,可以减少锁的持有时间,从而提高程序的并发性能。
synchronized的特点:
- 可重入性(Reentrant): 一个线程可以重复获取它已经持有的锁,避免死锁。
- 非公平性(Unfair): 默认情况下,线程获取锁的顺序是不确定的,先来的不一定先得到。
- 锁的升级: 为了优化性能,
synchronized
锁的实现经历了从偏向锁、轻量级锁到重量级锁的升级过程。这使得在竞争不激烈的情况下,加锁的开销非常小。 - 内存可见性(Visibility):
synchronized
可以保证当一个线程释放锁时,它对共享变量所做的修改会立即刷新到主内存,而当另一个线程获取锁时,它会从主内存中读取最新的变量值。这是由Java内存模型(JMM)的happens-before
规则保证的。
底层实现机制
synchronized本质上是基于JVM的**内置锁(Intrinsic Lock)**实现的。每个Java对象都天生具备一个监视器锁(Monitor),这是JVM级别的原生支持。
监视器的工作原理:
- 当线程进入synchronized代码块时,会执行monitorenter指令获取对象的监视器
- 当线程退出时,执行monitorexit指令释放监视器
- 如果获取失败,线程会被阻塞在操作系统级别
锁升级的演进过程
synchronized的性能优化经历了一个复杂的演进过程,从最初的重量级锁逐步优化:
偏向锁阶段:
当只有一个线程反复进入同步块时,JVM会将锁偏向于这个线程。此时的开销极低,几乎等同于无锁操作。对象头中会记录偏向的线程ID,后续该线程进入时直接通过简单的比较就能获取锁。
轻量级锁阶段:
当有其他线程开始竞争时,偏向锁会升级为轻量级锁。JVM在当前线程的栈帧中创建锁记录(Lock Record),通过CAS操作尝试将对象头的Mark Word替换为指向锁记录的指针。这个过程避免了操作系统层面的阻塞。
重量级锁阶段:
当CAS操作失败次数过多或者有多个线程同时竞争时,锁会膨胀为重量级锁。此时会创建Monitor对象,利用操作系统的互斥量(Mutex)来实现同步,涉及用户态和内核态的切换。
好的,我帮你把 JVM 锁升级过程 优化排版,整理成条理清晰、易于理解的版本,并加上一些注释说明,让整个流程更直观。
JVM 锁的升级过程
JVM 的对象锁在多线程访问时会经历 偏向锁 → 轻量级锁 → 重量级锁 的升级过程。下面详细说明每一步。
1. 普通对象(无锁状态)
- 对象未被任何线程持有锁时:
- Mark Word 中记录 对象的 HashCode。
- 锁标志位:
01
(表示无锁)。 - 是否偏向锁位:
0
。
此时对象只是普通对象,没有同步锁。
2. 偏向锁初始化(第一次加锁)
- 线程 A 获取锁:
- 锁标志位仍为
01
(无锁标志)。 - 偏向锁标志位改为
1
,表示对象进入 偏向锁状态。 - Mark Word 的前 23 bit 保存 线程 A 的线程 ID。
- 锁标志位仍为
偏向锁用于优化单线程访问锁的性能,减少同步开销。
3. 偏向锁重入(线程 A 再次获取锁)
- 线程 A 再次尝试获取锁:
- JVM 检查对象标志位:
- 锁标志位
01
- 偏向锁位
1
- Mark Word 中线程 ID = A 的 ID
- 锁标志位
- 由于线程 A 已持有偏向锁,直接进入同步代码,无需 CAS 操作。
- JVM 检查对象标志位:
偏向锁允许线程重复获取锁,性能开销极低。
4. 偏向锁竞争(线程 B 尝试获取锁)
- 线程 B 尝试获取锁:
- JVM 检查对象偏向锁:
- 锁标志位
01
- 偏向锁位
1
- Mark Word 中线程 ID ≠ B
- 锁标志位
- 线程 B 尝试通过 CAS 抢锁:
- 成功:偏向锁切换到线程 B,Mark Word 更新为 B 的线程 ID
- 失败:表示竞争存在,进入下一步升级轻量级锁
- JVM 检查对象偏向锁:
偏向锁竞争失败时,说明锁开始有多线程访问,需要升级锁状态。
5. 轻量级锁(Lightweight Lock)
- JVM 为当前线程在 栈帧中分配一块空间,称为 锁记录区(Lock Record):
- 保存指向对象 Mark Word 的指针
- 对象 Mark Word 保存指向锁记录区的指针
- CAS 操作同时更新两处指针:
- 成功:线程获得轻量级锁,执行同步代码,锁标志位改为
00
(轻量级锁状态) - 失败:竞争激烈,进入自旋阶段
- 成功:线程获得轻量级锁,执行同步代码,锁标志位改为
轻量级锁适合 多线程少量竞争 场景,性能较好。
6. 自旋锁(Spin Lock)
- 线程尝试获取轻量级锁失败:
- JVM 自旋重试(循环尝试抢锁)
- 自旋次数由 JVM 决定(JDK 1.7 默认启用)
- 成功:执行同步代码
- 失败:锁升级为重量级锁
自旋锁适合锁竞争短暂的情况,避免线程阻塞带来的开销。
7. 重量级锁(Heavyweight Lock)
- 自旋失败后,锁升级为 重量级锁:
- 锁标志位改为
10
- 线程未抢到锁的会被阻塞(挂起)
- 系统线程调度负责唤醒等待线程
- 锁标志位改为
重量级锁性能开销较大,使用操作系统互斥机制。
只有当轻量级锁和自旋锁都无法满足需求时才会升级。
🔑 总结锁升级过程
1 | 无锁状态(01 + 偏向位0) |
JVM 通过 锁膨胀机制(Lock Inflation)逐步升级锁,尽量在多线程争用情况下保证性能。
自动化的特点
synchronized的最大特点是完全自动化。程序员无需关心锁的获取和释放时机,JVM会自动处理所有细节。即使在异常情况下,JVM也能保证锁的正确释放,这大大降低了死锁和资源泄露的风险。
2. 显式锁:java.util.concurrent.locks.Lock
从JDK 1.5开始,java.util.concurrent.locks
包提供了更灵活、更强大的锁机制,其核心是Lock
接口。最常用的实现类是**ReentrantLock
**。
使用Lock
需要手动进行加锁和解锁操作,通常是在try...finally
块中进行,以确保锁在任何情况下都能被释放,防止死锁。
1 | Lock lock = new ReentrantLock(); |
Lock相比synchronized的优势:
- 中断锁(Interruptibly):
Lock
提供了lockInterruptibly()
方法,允许在等待锁的过程中响应中断。而synchronized
的线程如果陷入等待锁的状态,是无法被中断的。 - 尝试获取锁(tryLock):
Lock
提供了tryLock()
和tryLock(long timeout, TimeUnit unit)
方法,可以尝试获取锁,如果失败则立即返回或在指定时间内放弃,避免无限等待。 - 公平锁与非公平锁:
ReentrantLock
可以创建公平锁(Fair Lock)。公平锁会按照线程请求锁的顺序来分配锁,虽然这可能会带来一些性能开销。而synchronized
只能是非公平锁。 - 绑定多个条件(Condition):
Lock
配合Condition
接口,可以实现更灵活的线程等待和唤醒机制,类似Object
的wait()
和notify()
,但功能更强大,一个锁可以有多个等待队列。 - 读写锁(ReadWriteLock):
ReadWriteLock
是Lock
的另一个重要实现,它维护了一对锁:一个用于读操作,一个用于写操作。在读多写少的场景下,多个线程可以同时获取读锁,大大提高了并发性能,只有写操作才需要获取独占的写锁。ReentrantReadWriteLock
是其具体实现。
Lock接口的设计理念
显式锁机制
Lock接口代表了Java并发包中显式锁的设计思想。与synchronized的隐式锁不同,Lock要求程序员明确控制锁的生命周期,这带来了更大的灵活性,同时也增加了使用的复杂性。
AQS框架的核心思想
Lock接口的实现基于AbstractQueuedSynchronizer(AQS)框架,这是Doug Lea设计的一个并发框架的杰作。
同步状态的抽象:
AQS使用一个int值来表示同步状态,不同的锁实现可以赋予这个状态不同的含义。比如ReentrantLock用它表示重入次数,Semaphore用它表示许可证数量。
队列化的等待机制:
当线程无法获取锁时,AQS会将其包装成节点加入到一个FIFO队列中。这个队列使用双向链表实现,每个节点都包含了线程引用和等待状态信息。
自旋与阻塞的平衡:
AQS巧妙地结合了自旋和阻塞两种等待策略。线程在入队后会先进行有限次数的自旋尝试,只有在确定无法获取锁时才会调用LockSupport.park()进入阻塞状态。
3. volatile
关键字
volatile
关键字并不是一个锁,它是一种轻量级的同步机制,主要用于保证共享变量的可见性和有序性。
- 可见性(Visibility): 当一个变量被
volatile
修饰后,一个线程对它的修改会立即被其他线程可见。这是通过在写操作后添加内存屏障,强制将修改后的值刷新到主内存,并在读操作前添加内存屏障,强制从主内存中读取最新值来实现的。 - 有序性(Ordering):
volatile
可以禁止指令重排序,确保代码的执行顺序不会被打乱。 - 无法保证原子性(Atomicity):
volatile
无法保证复合操作(如i++
)的原子性,因为i++
实际上是读、加、写三个操作的组合,这三个操作并非一次完成。如果要保证原子性,需要使用synchronized
、Lock
或java.util.concurrent.atomic
包下的原子类。
总结一下:
特性 | synchronized |
Lock (如ReentrantLock ) |
volatile |
---|---|---|---|
功能 | 独占锁,保证原子性、可见性、有序性 | 独占锁,功能更强大,保证原子性、可见性、有序性 | 轻量级同步,只保证可见性和有序性 |
使用方式 | 关键字,自动加锁和解锁 | 接口,需要手动加锁和解锁,必须在finally 块中释放 |
关键字,修饰变量 |
灵活性 | 较差,功能固定 | 强,提供了更多高级功能,如可中断、超时、公平锁等 | 较差,只针对变量 |
性能 | JVM优化后性能较高,开销相对较小 | 高性能,在竞争激烈时通常优于synchronized |
非常高,几乎没有开销 |
适用场景 | 简单的同步需求,大部分情况都适用 | 高级同步需求,需要灵活控制锁的获取和释放 | 变量的写操作不依赖于当前值,需要保证变量的可见性。 |
4. 乐观锁 (Optimistic Locking)
与之前讨论的悲观锁(synchronized
和Lock
)不同,乐观锁并非一个具体的Java关键字或类,而是一种并发控制的思想和策略。悲观锁认为“总会有其他线程来修改数据”,所以在访问共享资源前,先对资源加锁,确保独占访问。而乐观锁则认为“数据冲突发生的概率很小”,所以它不加锁,而是假设所有线程都能正常执行,只有在数据更新提交时,才去检查在此期间数据是否被其他线程修改过。
如果检查到数据没有被修改,则更新成功。如果发现数据已经被修改,则更新失败。处理失败的方式通常有两种:
- 重试: 循环尝试,直到更新成功为止。
- 放弃: 抛出异常或直接返回失败,由调用方处理。
乐观锁的实现方式:
乐观锁的核心在于如何“检查数据是否被修改”。在Java中,常见的实现方式有两种:
版本号(Version Number):
在数据表中增加一个version字段。每次读取数据时,也把version字段读出来。当要进行数据更新时,带上之前读取的version值,在更新语句中加入WHERE version = <当前版本号>的条件。如果更新成功,同时把version值加1。
SQL示例:
1
2UPDATE products SET stock = 100, version = version + 1
WHERE id = 123 AND version = <之前读取的版本号>;原理: 如果在更新时,其他线程已经修改了这条数据,那么
version
值已经改变,上述UPDATE
语句的WHERE
条件将不成立,导致更新失败,影响行数为0。此时,你可以选择重试或放弃。
CAS (Compare-And-Swap) 算法:
这是乐观锁在硬件层面的支持,也是Java中实现乐观锁的核心机制。CAS是一种原子操作,它包含三个操作数:
- V (Value): 内存地址中存放的旧值。
- A (Expected): 预期的旧值。
- B (New): 想要写入的新值。
CAS的操作逻辑是:
如果内存地址V中的值等于预期值A,那么就将V的值更新为新值B。否则,什么都不做。整个操作是原子性的,由CPU指令直接完成。
Java中
java.util.concurrent.atomic
包下的所有原子类,如AtomicInteger
、AtomicLong
等,都是基于CAS实现的。AtomicInteger示例:
1
2
3AtomicInteger count = new AtomicInteger();
// 假设多个线程同时执行以下操作
count.incrementAndGet(); // 内部就是CAS操作incrementAndGet()
方法的内部实现类似于一个自旋重试的循环:- 获取当前值
current
。 - 计算新值
next = current + 1
。 - 使用CAS尝试将
current
更新为next
。 - 如果更新失败(说明
current
已经被其他线程修改),则重新回到第一步,再次获取最新值并尝试更新。
悲观锁与乐观锁的比较:
特性 | 悲观锁 (synchronized , Lock ) |
乐观锁 (CAS, 版本号) |
---|---|---|
加锁方式 | 独占资源时先加锁,阻止其他线程访问 | 不加锁,只在提交时进行冲突检测 |
冲突处理 | 线程排队等待锁,串行执行 | 线程失败后重试或放弃,并发执行 |
适用场景 | 写操作多、竞争激烈的场景。数据冲突概率高。 | 读操作多、写操作少的场景。数据冲突概率低。 |
性能 | 在高竞争环境下,线程切换和上下文开销大,性能下降。 | 在低竞争环境下,无锁开销,性能极高。在高竞争环境下,大量重试可能导致CPU开销增加。 |
总结:
在Java中,悲观锁和乐观锁是两种截然不同的并发控制策略。悲观锁(synchronized
, Lock
)适合写多读少的场景,能够保证数据的一致性,但会牺牲一定的性能。而乐观锁(CAS、版本号)则适合读多写少的场景,通过无锁的并发操作提高了性能,但在高竞争下可能导致频繁重试,反而降低效率。理解这两种锁的思想,可以帮助你根据具体的业务场景选择最合适的并发控制方案。
好的,我们来继续完善Java中关于锁的介绍,增加死锁及其解决方案的内容。
5. 死锁 (Deadlock)
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法继续执行。
死锁的产生是一个非常经典的多线程问题,通常发生在线程需要同时持有多个锁的场景中。一个简单的死锁场景是:线程A持有锁1,想获取锁2;而线程B持有锁2,想获取锁1。此时两个线程都无法继续执行,从而进入死锁状态。
1 | // 线程A |
死锁的四个必要条件
死锁的发生需要同时满足以下四个条件,缺一不可:
- 互斥条件(Mutual Exclusion): 至少有一个资源是独占的,即一次只能被一个线程使用。这是锁本身的基本特性。
- 请求与保持条件(Hold and Wait): 一个线程因请求资源而阻塞时,它对自己已获得的资源保持不放。
- 不剥夺条件(No Preemption): 线程已获得的资源在未使用完之前,不能被强行剥夺,只能由该线程自己释放。
- 循环等待条件(Circular Wait): 存在一个线程等待链,其中每个线程都持有下一个线程所需的资源。例如:线程A等待线程B,线程B等待线程C,线程C又等待线程A。
死锁的解决方案
解决死锁的根本思想是破坏上述四个必要条件之一或多个。通常,我们无法破坏互斥条件(因为资源就是独占的),因此主要从其他三个条件入手。
- 破坏“请求与保持”条件:
- 一次性获取所有锁: 线程在开始执行时,就一次性获取所有需要的锁。如果获取不成功,则释放所有已获得的锁,然后等待一段时间后再次尝试。
- 优点: 简单有效。
- 缺点: 可能会降低并发性,因为线程在很早就持有了锁,即使这些锁在后面才被使用。
- 破坏“不剥夺”条件:
- 使用可中断的锁: 使用
Lock
接口提供的tryLock()
方法。当一个线程尝试获取锁失败时,它可以选择放弃并释放已持有的锁,而不是一直等待。Lock.tryLock(long time, TimeUnit unit)
方法可以在指定时间内尝试获取锁,超时后会放弃。 - 优点: 提高了灵活性,线程可以响应中断或超时,避免无限等待。
- 缺点: 实现起来相对复杂,需要开发者手动处理获取锁失败的情况。
- 使用可中断的锁: 使用
- 破坏“循环等待”条件:
- 按顺序获取锁: 对所有的锁进行排序,并强制所有线程都按照相同的顺序获取锁。
- 示例: 如果线程A和线程B都需要
lock1
和lock2
,那么它们都必须先获取lock1
,再获取lock2
。这样就杜绝了线程A持有lock1
等待lock2
,同时线程B持有lock2
等待lock1
的循环。 - 优点: 这是最常用、最有效的死锁解决方案,实现起来也相对简单。
- 缺点: 有时很难对所有锁进行全局排序,特别是在代码模块化程度较高、依赖关系复杂的情况下。
总结:
在实际开发中,预防死锁的最佳实践通常是破坏循环等待条件,即统一锁的获取顺序。这是最简单且最有效的方案。如果业务场景需要更灵活的控制,可以考虑使用Lock
接口,利用tryLock()
方法来破坏“不剥夺”条件,实现更复杂的死锁处理逻辑。
好的,我们来详细介绍银行家算法 (Banker’s Algorithm)。
银行家算法概述
银行家算法是一种著名的死锁避免算法,由荷兰计算机科学家Dijkstra在1965年提出。它的核心思想是:在每次分配资源之前,先进行一次安全性检查。如果分配后系统仍然处于安全状态,则分配资源;否则,不予分配,线程需要等待。
- 优点:比死锁预防更灵活,能提高资源利用率。
- 缺点:算法复杂,需要预知进程的最大资源需求,并且系统开销大。
之所以叫“银行家算法”,是因为它的工作原理类似于银行管理贷款。银行家在发放贷款时,会先确保这笔贷款发放后,自己还有足够的资金来满足所有客户可能提出的最大取款需求,从而避免因无法支付而破产的风险。
简单来说,银行家算法通过以下两个步骤来避免死锁:
- 安全状态的定义: 系统能够找到一个安全序列,使得所有线程都能按照这个序列执行完毕。
- 资源分配策略: 当一个线程请求资源时,算法会先假设分配成功,然后检查系统是否仍处于安全状态。如果安全,就真的分配;如果不安全,就拒绝分配。
银行家算法中的几个重要数据结构
为了实现算法,需要维护以下几个关键数据结构,假设系统中有n
个线程和m
种资源:
- Available (可用资源矩阵): 一个长度为
m
的向量。Available[j]
表示第j
种资源目前可用的数量。 - Max (最大需求矩阵): 一个
n * m
的矩阵。Max[i, j]
表示线程i
最多需要第j
种资源多少个。 - Allocation (已分配资源矩阵): 一个
n * m
的矩阵。Allocation[i, j]
表示线程i
目前已拥有第j
种资源多少个。 - Need (需求矩阵): 一个
n * m
的矩阵。Need[i, j]
表示线程i
还需要第j
种资源多少个才能完成任务。Need[i, j] = Max[i, j] - Allocation[i, j]
银行家算法的核心:安全状态的判断
判断系统是否处于安全状态是银行家算法的核心。一个系统处于安全状态,当且仅当存在一个安全序列<P1, P2, ..., Pn>
。这个序列满足:对于序列中每一个线程Pi
,它所需要的资源都能由系统中当前可用的资源,以及前面所有已完成的线程释放的资源来满足。
安全性检查算法的步骤:
- 初始化:
- 创建一个
Work
向量,初始化为Available
(即当前可用资源)。 - 创建一个
Finish
向量,初始化为false
,表示所有线程都未完成。
- 创建一个
- 寻找安全线程:
- 从所有线程中找到一个线程
i
,满足以下两个条件:Finish[i]
为false
。Need[i]
向量中的每一个值都小于或等于Work
向量中对应的值。- 换句话说,线程
i
所需要的资源小于或等于当前可用的资源。
- 从所有线程中找到一个线程
- 释放资源:
- 如果找到了这样的线程
i
,则认为它可以顺利执行完毕。 - 模拟该线程执行完毕并释放资源,更新
Work
向量:Work = Work + Allocation[i]
。 - 将
Finish[i]
设置为true
。
- 如果找到了这样的线程
- 循环检查:
- 重复步骤2和步骤3,直到找不到满足条件的线程。
- 判断结果:
- 如果最终所有线程的
Finish
都为true
,则说明找到了一个安全序列,系统处于安全状态。 - 如果还有线程的
Finish
为false
,则说明系统处于不安全状态,可能存在死锁。
- 如果最终所有线程的
银行家算法的流程:资源分配
当一个线程P
请求资源时,银行家算法会执行以下步骤:
- 请求检查: 检查线程
P
请求的资源数量是否小于或等于其Need
向量中的需求量。如果不是,说明线程P
的请求不合理,拒绝分配。 - 可用性检查: 检查线程
P
请求的资源数量是否小于或等于当前Available
中的资源数量。如果不是,说明资源不足,线程P
需要等待。 - 预分配并进行安全性检查:
- 假设资源可以分配,临时进行以下操作:
Available = Available - Request
Allocation[P] = Allocation[P] + Request
Need[P] = Need[P] - Request
- 调用上面的安全性检查算法,判断系统是否处于安全状态。
- 假设资源可以分配,临时进行以下操作:
- 正式分配或拒绝:
- 如果安全性检查的结果是安全,则正式分配资源,并保留步骤3中的修改。
- 如果安全性检查的结果是不安全,则回滚步骤3中的所有临时修改,拒绝分配资源,线程
P
需要等待。
银行家算法的优缺点
- 优点:
- 可以有效地避免死锁的发生,保证系统的安全性。
- 通过提前检查,可以最大化地利用资源,提高系统的并发性。
- 缺点:
- 计算开销大: 每次分配资源都需要运行安全性检查算法,增加了系统的开销。
- 过于保守: 安全状态不等于无死锁,不安全状态也不等于一定发生死锁。算法为了确保安全,可能会拒绝一些本可以成功分配的请求,从而降低了系统的吞吐量。
- 条件苛刻: 算法要求线程在开始前就声明其所需的最大资源量,这在实际应用中很难做到。
- 资源数量固定: 算法假设系统中资源的数量是固定的,不能动态增减。
因此,银行家算法虽然在理论上非常完美,但在实际操作系统中很少被直接完整地实现。然而,它的核心思想——通过安全性检查来避免死锁——仍然是许多并发控制策略的重要理论基础。
实际应用中的死锁处理
在实际的并发编程和数据库系统中,最常见的死锁处理方式是:
死锁预防(通过编程规范):
- 加锁顺序一致:规定所有线程在获取多个锁时,必须按照相同的顺序。这是最有效的预防死锁的编程实践。
- 使用超时锁:尝试获取锁时设置一个超时时间,如果超时未获取到锁,则放弃本次操作并释放已持有的锁,然后重试。这破坏了”请求与保持”条件。例如Java的ReentrantLocktryLock(timeout)。
- 避免嵌套锁:尽量减少持有多个锁的情况。
死锁检测与恢复(数据库系统):
- 大多数关系型数据库(如MySQL InnoDB)都实现了死锁检测机制。当检测到死锁时,数据库会自动选择一个成本较低的事务(“牺牲品”)进行回滚,从而解除死锁。客户端应用会收到相应的错误码(例如MySQL中的Deadlock found when trying to get lock; try restarting transaction)。应用程序通常需要捕获这个错误并重试事务。理解死锁的四个必要条件是关键,因为解决死锁的根本方法就是破坏其中一个或多个条件。
锁扩展:
18. 谈谈 synchronized 和 ReentrantLock 的区别?
synchronized
是和for
、while
一样的关键字,ReentrantLock
是类,这是二者的本质区别。既然ReentrantLock
是类,那么它就提供了比synchronized
更多更灵活的特性:等待可中断、可实现公平锁、可实现选择性通知(锁可以绑定多个条件)、性能已不是选择标准。synchronized
依赖于 JVM 而ReenTrantLock
依赖于 API。synchronized
是依赖于 JVM 实现的,JDK1.6 为synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock
是 JDK 层面实现的(也就是 API 层面,需要lock()
和unlock
方法配合try/finally
语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
19. synchronized 和 volatile 的区别是什么?
volatile
本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
仅能使用在变量级别;synchronized
则可以使用在变量、方法、和类级别的。volatile
仅能实现变量的修改可见性,不能保证原子性;而synchronized
则可以保证变量的修改可见性和原子性。volatile
不会造成线程的阻塞;synchronized
可能会造成线程的阻塞。volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化。
20. 谈一下你对 volatile 关键字的理解?
volatile
关键字是用来保证有序性和可见性的。这跟 Java 内存模型有关。我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流水线阻塞,提高 CPU 的执行效率。这就需要有一定的顺序和规则来保证,不然程序员自己写的代码都不知道对不对了,所以有 happens-before
规则,其中有条就是 volatile
变量规则:对一个变量的写操作先行发生于后面面对这个变量的读操作、有序性实现的是通过插入内存屏障来保证的。
被 volatile
修饰的共享变量,就具有了以下两点特性:
- 保证了不同线程对该变量操作的内存可见性;
- 禁止指令重排序。
备注:这个题如果扩展了答,可以从 Java 的内存模型入手,下一篇 Java 虚拟机高频面试题中会讲到,这里不做过多赘述。
21. 说下对 ReentrantReadWriteLock 的理解?
ReentrantReadWriteLock
允许多个读线程同时访问,但是不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁:一个是用于读操作的 ReadLock
,一个是用于写操作的 WriteLock
。读写锁 ReentrantReadWriteLock
可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。
ReentrantReadWriteLock
基于 AQS
实现,它的自定义同步器(继承 AQS
)需要在同步状态 state
上维护多个读线程和一个写线程,该状态的设计成为实现读写锁的关键。ReentrantReadWriteLock
很好的利用了高低位。来实现一个整型控制两种状态的功能,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
ReentrantReadWriteLock 的特点:
- 写锁可以降级为读锁,但是读锁不能升级为写锁;
- 不管是
ReadLock
还是WriteLock
都支持Interrupt
,语义与ReentrantLock
一致; WriteLock
支持Condition
并且与ReentrantLock
语义一致,而ReadLock
则不能使用Condition
,否则抛出UnsupportedOperationException
异常;- 默认构造方法为非公平模式 ,开发者也可以通过指定
fair
为true
设置为公平模式 。
升级/降级
- 读锁里面加写锁,会导致死锁;
- 写锁里面是可以加读锁的,这就是锁的降级。
多线程与线程池
在 Java 中,多线程是实现并发编程的关键技术,它允许程序同时执行多个任务。线程池则是管理和复用线程的重要机制,能有效提升系统性能和资源利用率。
⭐️什么是线程和进程?
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
何为线程?
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java THREAD 类详解
在 Java 中,Thread 类是多线程编程的核心。它代表了一个线程,并提供了丰富的 API 来创建、控制和管理线程的生命周期。理解 Thread
类及其方法对于编写高效、并发的 Java 应用程序至关重要。
1. THREAD 类的基本概念
Thread
类位于 java.lang
包中。每个 Thread
类的实例都代表一个独立的线程,这个线程能够并行执行代码。有两种主要方式可以创建一个线程:
- 继承 Thread 类:创建一个新的类并继承
Thread
,然后重写其 run() 方法来定义线程的执行逻辑。 - 实现 Runnable 接口:创建一个实现了
Runnable
接口的类,并将其实例作为参数传递给Thread
构造器。这种方式更灵活,因为它允许你的类继承其他类。
2. THREAD 类的主要方法详解
下面详细介绍 Thread
类中的常用和重要方法,并按其功能进行分类。
线程生命周期控制
这些方法用于启动、中断和协调线程的执行。
- start(): 启动线程。此方法会创建一个新的线程,并使它进入可运行 (Runnable) 状态。一旦操作系统调度这个线程,它的 run() 方法就会被调用。
- run(): 线程的执行体。当你调用
start()
方法时,JVM 会自动调用这个方法。如果你直接调用run()
,它只会被当作一个普通方法在当前线程中执行,而不会创建新线程。 - interrupt(): 向线程发送一个中断信号。此方法并不会立即停止线程。它只是设置一个中断标志。线程需要自己检查这个标志并决定如何响应。对于在
sleep()
,join()
, 或wait()
等方法中阻塞的线程,这个方法会抛出InterruptedException
。 - join(): 等待线程终止。调用此方法的线程(通常是主线程)会阻塞,直到被调用
join()
的线程执行结束。- join(): 永远等待,直到线程终止。
- join(long millis): 最多等待指定的毫秒数。
线程属性和状态查询
这些方法用于获取线程的当前状态或设置其属性。
- getName(): 返回线程的名称。线程可以有一个自定义的名称,如果没有指定,则会有一个默认的名称,如 “Thread-0”。
- setName(String name): 设置线程的名称。
- getState(): 返回线程的当前状态。返回值是一个枚举类型
Thread.State
,包括NEW
,RUNNABLE
,BLOCKED
,WAITING
,TIMED_WAITING
,TERMINATED
。 - isAlive(): 判断线程是否存活。如果线程已经启动但尚未终止,则返回
true
。 - isDaemon(): 判断线程是否为守护线程。守护线程在所有非守护线程终止时会自动终止。例如,JVM 的垃圾回收线程就是一个守护线程。
- setDaemon(boolean on): 将线程设置为守护线程或非守护线程。这个方法必须在线程启动前调用。
- getPriority(): 返回线程的优先级。优先级是一个整数,范围从
MIN_PRIORITY
(1) 到MAX_PRIORITY
(10)。 - setPriority(int newPriority): 设置线程的优先级。
静态方法 (影响当前线程)
这些方法直接作用于正在执行的线程。
- Thread.currentThread(): 返回对当前正在执行的线程对象的引用。这是获取当前线程信息最常用的方法。
- Thread.sleep(long millis): 使当前线程暂停执行指定的毫秒数。它会释放 CPU 资源,但不会释放锁。
- Thread.yield(): 提示调度器,当前线程愿意放弃当前的 CPU 使用权,让其他线程有机会运行。这是一个提示,不能保证一定生效。
- Thread.interrupted(): 测试当前线程是否被中断。此方法会清除中断标志。如果线程在调用时中断标志为
true
,则返回true
,然后将中断标志重置为false
。
3. THREAD 类方法使用示例
1 | import java.util.concurrent.TimeUnit; |
好的,这是一个关于 Java 线程控制的非常重要的知识点。我将详细解释守护线程(Daemon Thread),以及 join()
和 yield()
这两个线程控制方法。
一、守护线程 (Daemon Thread)
1. 概念
- 守护线程:是一种特殊的线程,它为其他线程(非守护线程)提供服务。
- 特点:如果 JVM 中只剩下守护线程,那么 JVM 会自动退出。换句话说,守护线程的生命周期不依赖于自己,而是依赖于其他非守护线程。
2. 与普通线程的区别
- 普通线程(User Thread):又称用户线程。如果 JVM 中还有一个用户线程在运行,JVM 就不会退出。
- 守护线程:当所有用户线程都执行完毕后,JVM 会忽略仍在运行的守护线程,并直接终止。
3. 常见用途
- 守护线程常用于执行一些后台任务,例如:
- 垃圾回收器 (Garbage Collector):JVM 的垃圾回收线程就是典型的守护线程。它负责回收内存,但不会阻止 JVM 的退出。
- JIT (Just-In-Time) 编译器:同样是后台服务线程。
- 日志记录:一些日志框架的异步日志线程可以设置为守护线程。
4. 如何创建和判断
- 创建:在线程启动前,调用
setDaemon(true)
方法。- 注意:必须在
start()
方法调用之前设置,否则会抛出IllegalThreadStateException
。
- 注意:必须在
- 判断:
isDaemon()
方法可以判断一个线程是否为守护线程。
代码示例:
1 | public class DaemonThreadExample { |
二、join()
方法
1. 概念
join()
方法的作用是等待调用该方法的线程执行完毕。- 比喻:主线程 A 调用了线程 B 的
B.join()
方法,那么主线程 A 会进入等待状态,直到线程 B 执行结束,主线程 A 才会继续执行。
2. 主要用途
- 确保线程间的执行顺序。当一个线程的执行依赖于另一个线程的执行结果时,
join()
方法非常有用。
3. 代码示例
1 | public class JoinExample { |
三、yield()
方法
1. 概念
yield()
方法的作用是让出当前 CPU 的执行权。- 比喻:一个线程调用了
yield()
,它告诉调度器:“我愿意暂时放弃 CPU,你可以让其他同等优先级的线程先执行。”
2. 主要用途
- 提示:
yield()
只是一个提示,调度器不一定会采纳。它可能让出 CPU 给其他线程,也可能立即重新获得 CPU 的执行权。 - 与其他方法的区别:
sleep()
:让线程进入休眠状态,强制让出 CPU。wait()
:让线程进入等待状态,必须通过notify()
或notifyAll()
唤醒。
- yield() 不会释放锁。
3. 代码示例
1 | public class YieldExample { |
观察结果:在输出到 “放弃CPU执行权” 后,你可能会看到另一个线程的输出先出现。但这并不绝对,因为 yield()
只是一个建议。
在Java多线程编程中,yield()
、join()
、setDaemon()
这些方法都有严格的设置时机,如果使用不当,可能会导致异常或行为不符合预期。
让我们来详细解释一下每个方法的设置时机。
1. setDaemon(boolean on)
方法
作用:将一个线程设置为守护线程或用户线程。
设置时机:必须在线程启动前调用,即在
thread.start()
方法之前。为什么?:
- 线程一旦启动,其类型(用户线程或守护线程)就已经确定,并且不能在运行时改变。
- 如果在
start()
方法之后调用setDaemon(true)
,会抛出IllegalThreadStateException
运行时异常。
代码示例:
1
2
3
4
5
6
7
8
9
10
11Thread daemonThread = new Thread(() -> {
// ... 守护线程的逻辑
});
// 错误示例:先启动再设置
// daemonThread.start();
// daemonThread.setDaemon(true); // 这里会抛出 IllegalThreadStateException
// 正确示例:先设置再启动
daemonThread.setDaemon(true);
daemonThread.start();
2. join()
方法
作用:让当前线程等待另一个线程执行完毕。
设置时机:必须在被等待的线程启动后调用。
为什么?:
join()
的目的是让一个线程等待另一个线程。如果被等待的线程还没有启动,那么等待它结束是没有意义的。- 尽管在启动前调用不会抛出异常,但它也不会有任何效果,因为目标线程根本没有开始运行。
代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
Thread workerThread = new Thread(() -> {
// ... 工作线程的逻辑
});
// 错误示例:在工作线程启动前调用 join()
// workerThread.join(); // 无效,因为 workerThread 还没有运行
// workerThread.start();
// 正确示例:在工作线程启动后调用 join()
workerThread.start();
workerThread.join(); // 主线程将等待 workerThread 运行完毕
3. yield()
方法
作用:向线程调度器发出提示,当前线程愿意放弃 CPU 执行权。
设置时机:可以在线程生命周期的任何阶段调用,但通常在线程的
run()
方法中调用。为什么?:
yield()
是一个动态的行为,它发生在线程正在执行其任务的过程中。- 它的目的是在任务执行过程中,在某个点上主动让出 CPU,让其他线程有机会运行。
代码示例:
1
2
3
4
5
6
7
8
9
10
11Thread myThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println("我正在运行...");
if (i == 5) {
// 在任务执行到一半时,让出 CPU
Thread.yield();
}
}
});
myThread.start();
总结
方法 | 作用 | 设置时机 | 备注 |
---|---|---|---|
setDaemon() | 设定线程类型(守护/用户) | thread.start() 之前 | 启动后设置会抛出异常 |
join() | 等待线程执行结束 | thread.start() 之后 | 在启动前调用是无效的 |
yield() | 提示线程调度器让出 CPU | 线程运行期间的任何时刻 | 只是一个建议,不保证生效 |
1. 深度解析
1. Thread.sleep(long millis)
功能: sleep()
方法是 Thread
类的静态方法,作用是让当前正在执行的线程暂停指定的时间。
核心机制:
sleep()
方法会使线程进入阻塞状态(Timed Waiting)。- 它不会释放任何持有的锁(monitor)。
- 当指定的时间过后,线程会回到可运行状态(Runnable),等待 CPU 调度。
- 如果线程在休眠期间被其他线程调用
interrupt()
方法,sleep()
会立即抛出InterruptedException
。 sleep()
的实现依赖于操作系统的调度器,实际暂停的时间可能比指定的时间长。
源码解析 (伪代码):
1 | public static void sleep(long millis) throws InterruptedException { |
VM.nativeSleep 是一个本地方法,它将线程挂起的任务交给操作系统内核来完成。
2. Thread.yield()
功能: yield()
方法是 Thread
类的静态方法,作用是让当前正在执行的线程放弃其 CPU 时间片,回到可运行状态(Runnable)。
核心机制:
yield()
的目的是给其他线程一个执行的机会,但它不保证其他线程会立即运行。- 它不会使线程进入阻塞或等待状态,线程只是从“正在运行”的状态切换到“可运行”队列的末尾。
- 调度器可能会选择立即重新调度刚才调用
yield()
的线程。 - 它不会释放任何锁。
yield()
主要用于调试或优化,通常不应用于核心业务逻辑。因为它依赖于调度器的实现,其行为不可预测。
源码解析 (伪代码):
1 | public static void yield() { |
nativeYield 同样是一个本地方法,它向操作系统调度器发出一个提示,表示当前线程愿意让出 CPU。
3. obj.wait()
功能: wait()
方法是 Object
类的方法,作用是让当前线程进入等待状态(Waiting),并释放它所持有的对象的锁。
核心机制:
wait()
必须在**同步块(synchronized block)**内部调用,否则会抛出IllegalMonitorStateException
。- 当线程调用
wait()
后,它会释放该同步块所关联的对象的锁,进入等待队列。 - 线程需要等待其他线程调用同一对象的
notify()
或notifyAll()
方法才能被唤醒。 - 被唤醒后,线程需要重新竞争对象的锁,才能继续执行。
源码解析 (伪代码):
1 | public final void wait() throws InterruptedException { |
wait()
是线程间通信和协作的关键,通常与 notify()
或 notifyAll()
配合使用,实现生产者-消费者等设计模式。
4. Thread.join()
功能: join()
方法是 Thread
类的方法,作用是让当前线程(例如主线程)等待调用 join()
的线程(例如子线程)执行完毕。
核心机制:
join()
的本质是调用wait()
方法。它在内部使用一个隐式的锁来同步。- 当一个线程调用
t.join()
时,当前线程(调用者)会阻塞,直到线程t
终止。 - 如果线程
t
终止(无论是正常结束还是异常终止),JVM 会调用t.notifyAll()
来唤醒所有在t.join()
上等待的线程。
源码解析 (简化版):
1 | public final synchronized void join(long millis) throws InterruptedException { |
join()
的实现巧妙地利用了 wait()
和 notifyAll()
的机制。当一个线程的 run()
方法执行完毕时,JVM 会自动调用其 notifyAll()
方法,从而唤醒所有等待它的线程。
2. 区别与使用场景总结
特性/方法 | Thread.sleep() |
Thread.yield() |
obj.wait() |
Thread.join() |
---|---|---|---|---|
类/接口 | java.lang.Thread (静态) |
java.lang.Thread (静态) |
java.lang.Object |
java.lang.Thread |
是否释放锁 | 不释放 | 不释放 | 释放 | 不释放 (但其内部实现会释放) |
状态转换 | RUNNING -> TIMED_WAITING |
RUNNING -> RUNNABLE |
RUNNING -> WAITING |
RUNNING -> WAITING /TIMED_WAITING |
唤醒方式 | 时间到了自动唤醒 或 interrupt() |
依赖于操作系统调度器,立即或稍后被重新调度 | 必须由其他线程调用 notify() /notifyAll() |
目标线程执行结束时自动唤醒 或 interrupt() |
使用场景 | 模拟耗时操作,或让出 CPU 资源(但不释放锁) | 提示调度器给其他线程机会,通常用于调试或低优先级的任务 | 线程间的协作和通信,实现生产者-消费者模式 | 等待一个线程执行结束,实现线程间的顺序执行 |
是否需要同步块 | 否 | 否 | 是 (必须在 synchronized 块内) |
否 (但其内部实现依赖于同步机制) |
3. 使用场景示例
sleep(): 你需要一个定时任务,比如每隔一秒钟打印一次日志。
1
2
3
4for (int i = 0; i < 5; i++) {
Thread.sleep(1000); // 暂停一秒
System.out.println("等待了 " + (i + 1) + " 秒");
}yield(): 你有一个 CPU 密集型任务,希望在每次循环中给其他线程一个运行的机会,以避免饥饿。
1
2
3
4
5
6for (int i = 0; i < 1000; i++) {
// 执行一些计算
if (i % 100 == 0) {
Thread.yield(); // 让出 CPU
}
}wait(): 你有一个共享队列,当队列为空时,消费者线程需要等待,当生产者放入新元素时,消费者被唤醒。
1
2
3
4
5
6synchronized (sharedQueue) {
while (sharedQueue.isEmpty()) {
sharedQueue.wait(); // 队列为空,等待并释放锁
}
// 消费元素...
}join(): 你需要确保子线程的数据处理完成后,主线程才能继续使用这个数据。
1
2
3
4Thread dataProcessor = new Thread(() -> { /* 处理数据 */ });
dataProcessor.start();
dataProcessor.join(); // 主线程等待数据处理完成
System.out.println("数据处理完成,主线程可以继续了。");
理解这些方法的细微差别是编写健壮、高效并发代码的基础。特别是 wait()
和 sleep()
,它们最大的区别在于是否释放锁,这直接影响着线程间的同步和协作方式。
线程(Thread)的概念与生命周期
线程是操作系统调度的最小单位,是进程中的一个执行路径。一个进程可以包含多个线程,这些线程共享进程的内存空间。
线程的生命周期通常包含以下六种状态(定义在 java.lang.Thread.State 枚举中):
NEW(新建):线程被创建但尚未启动。当使用 new Thread() 创建一个线程实例后,它就处于此状态。
1
2Thread myThread = new Thread(() -> System.out.println("Hello from a new thread!"));
// 此时 myThread 处于 NEW 状态,尚未执行 start()RUNNABLE(可运行):线程已调用 start() 方法,正在 JVM 中运行(可能正在执行,也可能在等待 CPU 调度)。一个 RUNNABLE 状态的线程可能正在运行,也可能并没有运行,它仅仅是具备了运行的资格。
1
2myThread.start();
// 此时 myThread 进入 RUNNABLE 状态,等待 CPU 调度执行 run() 方法BLOCKED (阻塞):线程正在等待获取一个监视器锁(例如,进入 synchronized 块或方法)。当一个线程试图访问被其他线程锁定的资源时,它会进入此状态。
1
2
3
4
5
6
7
8
9Object lock = new Object();
// 线程已经持有 lock 对象的锁
synchronized (lock) {
// ... 线程 A 正在执行
}
// 线程 B 尝试获取 lock 对象的锁,但被线程 A 占用,会进入 BLOCKED 状态
synchronized (lock) {
// ...
}WAITING (等待):线程无限制地等待另一个线程执行特定操作。例如,调用 Object.wait(), Thread.join() (无参数) 或 LockSupport.park().这些方法会使线程放弃 CPU 使用权,并进入无限制等待,直到被其他线程 notify(), notifyAll() 或 unpark() 唤醒。
1
2
3
4
5
6
7
8
9Object sharedObject = new Object();
// 线程 A:
synchronized (sharedObject) {
sharedObject.wait(); // 线程 A 进入 WAITING 状态,并释放 sharedObject 的锁
}
// 线程 B:
Thread threadA = new Thread(() -> { /* ... */ });
threadA.start();
threadA.join(); // 线程 B 等待 threadA 执行完毕,进入 WAITING 状态TIMED_WAITING (有时限等待):线程在指定的时间内等待另一个线程执行特定操作,或者休眠。例如,调用 Thread.sleep(long millis)、Object.wait(long timeout)、Thread.join(long millis)、LockSupport.parkNanos() 或 LockSupport.parkUntil()。一旦超时间到达,线程会自动从等待状态唤醒,并尝试重新进入 RUNNABLE 状态。
1
2Thread.sleep(1000);
// 线程进入 TIMED_WAITING 状态 1 秒TERMINATED (终止): 线程已执行完毕其 run() 方法, 或者因未捕获的异常退出。线程一旦进入此状态, 就不能再被重新启动。
1
2// 线程的 run() 方法执行完毕
// 线程在执行过程中抛出未捕获的异常
什么是线程上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了
sleep()
,wait()
等。 - 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
Thread#sleep() 方法和 Object#wait() 方法对比
共同点:两者都可以暂停线程的执行。
区别:
- sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。为什么这样设计呢?下一个问题就会聊到。
为什么 wait() 方法不定义在 Thread 中?
wait()
是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object
)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object
)而非当前的线程(Thread
)。
类似的问题:为什么 sleep() 方法定义在 Thread 中?
因为 sleep()
是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
可以直接调用 Thread 类的 run 方法吗?
这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
new 一个 Thread
,线程进入了新建状态。调用 start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。 但是,直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。
创建线程的方式
继承 Thread 类:
- 通过创建 Thread 类的子类, 并重写其 run() 方法, 在该方法中定义线程执行的任务。
- 创建 Thread 子类的实例, 并调用其 start() 方法来启动线程。调用 start() 方法会使线程进入 RUNNABLE 状态, 并由 JVM 调度执行 run() 方法; 直接调用 run() 方法则只是在当前线程中执行普通方法, 不会启动新线程。
- 优点: 实现简单直观, 代码结构清晰。
- 缺点: Java 是单继承的, 如果你的类已经继承了其他类, 就不能再继承 Thread 类。这限制了类的灵活性。此外, 任务 (run() 方法中的逻辑 ) 与线程本身 (Thread 对象) 紧密耦合, 不利于任务的复用。
1 | // MyThread.java |
实现 Runnable 接口:
- 定义一个类实现 Runnable 接口,并实现其抽象方法 public void run()。run() 方法中包含线程执行的具体任务。
- 创建 Runnable 实现类的实例,然后将其作为参数传入 Thread 类的构造器 (new Thread(Runnable target)),再调用 Thread 实例的 start() 方法。
- 优点:
- 推荐方式:避免了 Java 单继承的限制,你的类可以同时继承其他类来实现 Runnable 接口。
- 任务与线程解耦:Runnable 对象只负责定义任务,而 Thread 对象负责执行任务。这意味着同一个 Runnable 对象可以被多个 Thread 实例共享执行,从而更好地实现资源的共享和任务的复用。
1 | // MyRunnable.java |
实现Callable接口
Callable
是 Java 并发编程中一个非常重要的接口,它与 Runnable
类似,都用于定义一个可在线程中执行的任务。但 Callable
提供了更强大的功能,主要体现在两个方面:
- 可以返回结果:
Callable
的call()
方法可以返回一个泛型类型的结果。 - 可以抛出异常:
Callable
的call()
方法可以声明抛出任何Exception
。
这与 Runnable
接口形成了鲜明对比,Runnable
的 run()
方法没有返回值,也不能抛出受检异常(checked exception)。
Callable
接口是一个泛型接口,定义如下:
1 |
|
Callable
接口本身并不能直接作为 Thread
的构造参数。它需要配合 ExecutorService
线程池和 Future
接口一起使用。
典型使用流程:
创建 Callable 任务: 实现
Callable
接口,并在call()
方法中编写具体的业务逻辑,返回一个结果。1
2
3
4
5
6
7
8
9import java.util.concurrent.Callable;
public class MyCallableTask implements Callable<String> {
public String call() throws Exception {
Thread.sleep(2000); // 模拟耗时操作
return "任务执行完毕,返回结果";
}
}创建 ExecutorService 线程池: 使用
Executors
工厂类创建线程池。1
2
3
4import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(2);提交 Callable 任务: 使用
ExecutorService
的submit()
方法提交任务。submit()
方法会返回一个Future
对象。1
2
3
4import java.util.concurrent.Future;
Callable<String> task = new MyCallableTask();
Future<String> future = executor.submit(task);获取任务结果: 通过
Future
对象的get()
方法来获取Callable
任务的执行结果。future.get()
是一个阻塞方法,它会一直等待,直到任务执行完毕并返回结果。- 如果任务执行过程中抛出了异常,
get()
方法也会将这个异常包装在ExecutionException
中重新抛出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14import java.util.concurrent.ExecutionException;
try {
String result = future.get(); // 阻塞等待结果
System.out.println(result);
} catch (InterruptedException e) {
// 线程被中断
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
// 任务执行过程中抛出的异常
e.printStackTrace();
} finally {
executor.shutdown();
}
特性 | Callable |
Runnable |
---|---|---|
返回值 | call() 方法有返回值(泛型 V ) |
run() 方法没有返回值(void ) |
异常处理 | call() 方法可以抛出受检异常 |
run() 方法不能直接抛出受检异常 |
执行方式 | 必须配合 ExecutorService.submit() 执行 |
可以直接作为 Thread 构造函数的参数,也可以通过 ExecutorService.execute() 或 submit() 执行 |
功能 | 适用于需要返回计算结果或可能抛出异常的异步任务 | 适用于简单的异步任务,不需要返回结果 |
Future
接口是 Callable
的重要伴侣,它代表一个异步计算的结果。它提供了检查任务是否完成、等待任务完成以及获取任务结果的方法。
Future
接口的主要方法:
V get()
: 阻塞式地等待任务完成,并返回结果。V get(long timeout, TimeUnit unit)
: 在指定时间内等待任务完成,超时则抛出TimeoutException
。boolean isDone()
: 检查任务是否已经完成。boolean cancel(boolean mayInterruptIfRunning)
: 尝试取消任务。总结
Callable
是 Java 并发编程中一个更高级的任务抽象,它解决了 Runnable
接口无法返回结果和处理受检异常的痛点。通过与 ExecutorService
和 Future
接口的组合使用,Callable
使得异步编程变得更加简单和灵活,非常适合那些需要耗时计算并返回结果的场景,比如网络请求、数据处理等。
线程池 (Thread Pool)
线程池是一种基于池化思想的线程管理机制,用于管理和复用线程,而不是在每次需要执行任务时都创建新线程。
为什么使用线程池?
- 降低资源消耗:通过重复利用已创建的线程,降低线程创建和销毁的开销。
- 提高响应速度:当任务到达时,任务可以直接执行,无需等待线程创建。
- 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配、调优和监控。
- 提供更多功能:如定时执行、周期执行、单线程化等。
线程池的核心参数 (ThreadPoolExecutor 构造方法)
1
2
3
4
5
6
7public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)corePoolSize: 核心线程数。线程池中始终保持的线程数量,即使它们处于空闲状态,除非设置了 allowCoreThreadTimeOut(true)。
maximumPoolSize: 最大线程数。线程池中允许存在的最大线程数量。当工作队列已满且核心线程都在忙碌时,线程池会创建新的非核心线程,直到达到这个数量。
keepAliveTime: 当线程池中的线程数量超过 corePoolSize 时,这些空闲的非核心线程在终止之前等待新任务的最长时间。
unit: keepAliveTime 参数的时间单位。
workQueue: 任务队列 (阻塞队列)。用于存放等待执行的任务。
- ArrayBlockingQueue: 基于数组的有界阻塞队列,需要指定容量。
- LinkedBlockingQueue: 基于链表的阻塞队列,容量可以指定,也可以是无界的(默认)。如果使用无界队列,maximumPoolSize 参数将失效。
- SynchronousQueue: 一个不存储元素的阻塞队列。每个插入操作必须等待一个对应的移除操作,反之亦然。
- PriorityBlockingQueue: 支持优先级的无界阻塞队列,按照自然顺序或自定义比较器排序。
threadFactory: 线程工厂。用于创建新线程,可以自定义线程的命名、优先级等。
handler: 拒绝策略 (当线程池和工作队列都已满时,新的任务到来时的处理方式)。
ThreadPoolExecutor.AbortPolicy (默认): 直接抛出 RejectedExecutionException 异常。
ThreadPoolExecutor.CallerRunsPolicy: 由调用线程 (提交任务的线程) 执行任务。
ThreadPoolExecutor.DiscardOldestPolicy: 丢弃队列中最老的任务,然后尝试提交当前任务。
ThreadPoolExecutor.DiscardPolicy: 直接丢弃当前新提交的任务。
因为
CallerRunsPolicy
这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。我们从问题的本质入手,调用者采用
CallerRunsPolicy
是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue
中。这样的话,在内存允许的情况下,我们可以增加阻塞队列BlockingQueue
的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。为了充分利用 CPU,我们还可以调整线程池的
maximumPoolSize
(最大线程数)参数,这样可以提高任务处理速度,避免累计在BlockingQueue
的任务过多导致内存用完。
线程池的执行流程
- 当一个任务提交到线程池时,如果当前运行的线程数小于 corePoolSize,即使有空闲线程,也会创建并启动一个新线程来执行任务。
- 如果当前运行的线程数大于或等于 corePoolSize,但任务队列 workQueue 未满,任务会被添加到 workQueue 中等待执行。
- 如果 workQueue 已满,但当前运行的线程数小于 maximumPoolSize,线程池会创建新的非核心线程来执行任务。
- 如果当前运行的线程数等于 maximumPoolSize 且 workQueue 已满,线程池会根据拒绝策略来处理新提交的任务。
Java 内置的四种常用线程池 (通过 Executors 工厂类创建)
- FixedThreadPool (固定大小线程池)
- Executors.newFixedThreadPool(int nThreads)
- corePoolSize = maximumPoolSize = nThreads
- 使用无界 LinkedBlockingQueue。
- 特点:可控制并发的线程数,超出的任务会在队列中等待。
- 问题:当任务提交速度远大于处理速度时,队列会不断增长,可能导致 OOM。
- SingleThreadExecutor (单线程线程池)
- Executors.newSingleThreadExecutor()
- corePoolSize = maximumPoolSize = 1
- 使用无界 LinkedBlockingQueue。
- 特点:保证所有任务都在一个线程中按顺序执行。
- 问题:同 FixedThreadPool,队列无限增长可能导致 OOM。
- CachedThreadPool(可缓存线程池)
- Executors.newCachedThreadPool()
- corePoolSize = 0, maximumPoolSize = Integer.MAX_VALUE
- 使用 SynchronousQueue。
- keepAliveTime = 60s
- 特点:当任务到来时,有空闲线程则复用,无空闲线程则创建新线程。适用于大量短时任务。
- 问题:maximumPoolSize 过大,当任务并发量极高时,可能创建大量线程,导致系统资源耗尽 (OOM)。
- ScheduledThreadPool(定时任务线程池)
- Executors.newScheduledThreadPool(int corePoolSize)
- 特点:支持定时及周期性任务执行。
- 内部使用 DelayedWorkQueue,一个无界队列,可以按时间进行排序。
- FixedThreadPool (固定大小线程池)
阿里巴巴开发手册建议:不推荐使用 Executors 创建线程池,而是手动通过 ThreadPoolExecutor 的构造方法创建,以明确线程池的运行规则,避免资源耗尽的风险。
好的,这是一个非常核心且重要的 Java 并发编程话题。我将从线程池的创建、生命周期和启动流程三个方面进行详细且深入的解析。
一、线程池的创建 (ThreadPoolExecutor)
Java 中,线程池的核心是 java.util.concurrent.ThreadPoolExecutor
类。创建线程池最推荐的方式是直接使用它的构造函数,而不是使用 Executors
工厂类,因为这能让你更清楚地了解线程池的运行参数。
ThreadPoolExecutor
的构造函数有多个参数,理解它们是掌握线程池的关键:
Java
1 | public ThreadPoolExecutor( |
各参数详解:
- corePoolSize (核心线程数):
- 作用:线程池中常驻的线程数量。即使这些线程处于空闲状态,它们也不会被销毁。
- 用途:当有新任务提交时,如果核心线程数未达到
corePoolSize
,线程池会创建新线程来处理任务,直到核心线程数达到上限。
- maximumPoolSize (最大线程数):
- 作用:线程池中允许存在的最大线程数量。
- 用途:当任务队列已满,且核心线程数已满时,线程池会创建新的线程(非核心线程)来处理任务,直到总线程数达到
maximumPoolSize
。
- keepAliveTime (线程空闲存活时间):
- 作用:非核心线程的空闲存活时间。
- 用途:当线程池中的线程数量超过
corePoolSize
时,如果一个非核心线程空闲时间超过keepAliveTime
,它就会被回收。
- unit (时间单位):
- 作用:
keepAliveTime
的时间单位,如秒、分钟、毫秒。
- 作用:
- workQueue (任务队列):
- 作用:用于存放等待执行的任务。当核心线程都在忙碌时,新提交的任务会进入这个队列。
- 常见类型:
ArrayBlockingQueue
:基于数组,有界的阻塞队列。LinkedBlockingQueue
:基于链表,通常是无界的,也可以指定容量。SynchronousQueue
:不存储任务,每个任务提交都会立即被一个线程执行。
- ThreadFactory (线程工厂):
- 作用:用于创建线程。可以自定义工厂来为线程命名、设置守护状态等。
- RejectedExecutionHandler (拒绝策略):
- 作用:当任务队列已满,且线程数达到
maximumPoolSize
时,线程池会根据拒绝策略来处理新提交的任务。 - 常见策略:
AbortPolicy
(默认):直接抛出RejectedExecutionException
异常。CallerRunsPolicy
:由提交任务的线程(caller)自己来执行任务。DiscardOldestPolicy
:丢弃任务队列中最老的任务,然后尝试将当前任务加入队列。DiscardPolicy
:直接丢弃当前任务,不抛出异常。
- 作用:当任务队列已满,且线程数达到
二、线程池的生命周期
线程池有五种状态,这些状态通过内部的 ctl
变量控制,它包含线程池的运行状态和线程数。
- RUNNING (运行中):
- 状态:接受新任务,并处理阻塞队列中的任务。
- 转换:创建线程池时处于此状态。
- SHUTDOWN (关闭):
- 状态:不接受新任务,但会处理阻塞队列中的已有任务。
- 转换:调用
shutdown()
方法后进入此状态。
- STOP (停止):
- 状态:不接受新任务,也不处理阻塞队列中的已有任务,会中断所有正在执行的任务。
- 转换:调用
shutdownNow()
方法后进入此状态。
- TIDYING (整理):
- 状态:所有任务都已终止,线程数为 0。
- 转换:当线程池处于
SHUTDOWN
或STOP
状态,且所有任务都已完成、线程都已销毁时,进入此状态。
- TERMINATED (终止):
- 状态:彻底终止,可以安全地进行回收。
- 转换:
TIDYING
状态下,terminated()
方法执行完毕后进入此状态。
三、线程池的启动流程 (任务提交过程)
当调用 executor.execute(Runnable task)
方法提交一个新任务时,线程池会按照以下严格的流程来决定如何处理任务:
- 核心线程检查:
- 如果当前线程池中的线程数量小于
corePoolSize
,则创建并启动一个新线程来执行任务。即使有空闲的线程,也会创建新线程直到达到核心线程数。
- 如果当前线程池中的线程数量小于
- 任务队列检查:
- 如果当前线程池中的线程数量等于或大于
corePoolSize
,则将任务放入任务队列workQueue
中等待。
- 如果当前线程池中的线程数量等于或大于
- 最大线程数检查:
- 如果任务队列已满,线程池会再次检查当前线程数。
- 如果当前线程数量小于
maximumPoolSize
,则创建并启动一个新线程(非核心线程)来执行任务。
- 拒绝策略:
- 如果任务队列已满,且当前线程数已达到
maximumPoolSize
,则根据线程池的拒绝策略来处理这个新任务。
- 如果任务队列已满,且当前线程数已达到
总结:线程池的运行是一个复杂的、多阶段的过程。它优先使用核心线程,然后将任务放入队列,最后才考虑创建非核心线程。这个流程设计旨在最大限度地利用现有线程,减少线程创建和销毁的开销,从而提高性能和资源利用率。
ThreadLocal:
好的,我们来详细聊聊 ThreadLocal
。
ThreadLocal
是什么?
ThreadLocal
(线程本地变量)并不是用来解决线程间共享数据问题的,它的核心作用是为每个使用该变量的线程都提供一个独立的、隔离的副本。
你可以把 ThreadLocal
想象成一个“线程专属的储物柜”。每个线程都可以往这个储物柜里存东西(通过 set()
方法),取东西(通过 get()
方法),但它只能看到自己储物柜里的东西,无法访问其他线程的。
ThreadLocal
内部其实是通过一个 ThreadLocalMap
来实现的。这个 Map 的键是 ThreadLocal
对象本身,值就是你存入的那个变量。每个线程都有一个属于自己的 ThreadLocalMap
。
为什么需要 ThreadLocal
?
我们通常在开发中会遇到两种数据共享问题:
- 多个线程共享一个变量:这种情况下,需要通过
synchronized
、volatile
或Lock
来保证线程安全。 - 每个线程需要一个独立的变量:这是
ThreadLocal
的主要应用场景。
如果不用 ThreadLocal
,我们可能需要自己手动维护一个 Map<Thread, Object>
,每次存取数据时都以当前线程作为键。这样不仅麻烦,还容易出错。ThreadLocal
帮我们封装了这些细节,让使用变得非常简单。
ThreadLocal
的常见应用场景
ThreadLocal
最常见的应用场景是在 Web 开发中,用于存储与当前请求相关的上下文信息。
例如,一个 HTTP 请求从进入服务器到返回响应,可能由多个方法或组件来处理,但它们都属于同一个线程。如果需要传递一些请求相关的状态(比如用户身份、事务 ID、数据库连接),我们有很多种做法:
- 参数传递:将这些信息作为参数层层传递。这会导致方法签名变得臃肿,并且增加了代码的耦合性。
- 静态变量:如果用静态变量,多个请求同时到达时会互相覆盖,导致线程不安全。
- ThreadLocal:这是最优雅的解决方案。你可以把这些信息存入
ThreadLocal
,然后在任何需要的地方直接通过get()
方法获取,无需在方法间显式传递。
典型的例子:
- Spring 的事务管理:Spring 框架在处理事务时,会使用
ThreadLocal
来保存每个线程的数据库连接,确保在同一个事务中的所有操作都使用同一个连接。 - 上下文信息:例如,在请求处理链中,将用户登录信息、语言偏好等数据存入
ThreadLocal
,下游的业务逻辑可以随时获取。
ThreadLocal
可能带来的问题
内存泄漏
ThreadLocal
可能会导致内存泄漏。这是一个非常重要的问题。
ThreadLocalMap
使用的是弱引用(Weak Reference) 作为键。这意味着,当 ThreadLocal
对象没有其他强引用时,即使它还在 ThreadLocalMap
中,垃圾回收器也会回收它。
但是,ThreadLocalMap
的值(也就是你存入的对象)是强引用。如果线程一直存活,但你不再使用 ThreadLocal
对象,ThreadLocalMap
中的键就会变成 null
,但值还在。这样,值对象就无法被回收,导致内存泄漏。
如何避免?
解决这个问题的关键在于:在 ThreadLocal 使用完毕后,务必调用 remove() 方法。
在 Web 应用中,请求处理结束后,线程会被放回线程池。如果 ThreadLocal
没有被清除,那么下一次其他请求再拿到这个线程时,它会读取到上一个请求残留的数据,导致业务逻辑出错。因此,正确使用模式通常是:
1 | ThreadLocal<String> threadLocal = new ThreadLocal<>(); |
ThreadLocalMap
使用弱引用作为键,但使用强引用作为值,这样做主要是为了优化内存管理并处理由 ThreadLocal
引起的潜在内存泄漏问题。这种设计虽然能解决一些问题,但也引入了新的挑战。
为什么键是弱引用?
ThreadLocalMap
的键是 ThreadLocal
对象的弱引用。当一个 ThreadLocal
对象没有其他地方(比如一个变量)引用它时,它就会被垃圾回收器回收。
- 避免键的内存泄漏: 如果键是强引用,即使
ThreadLocal
对象在程序中不再被使用,只要线程还活着,这个ThreadLocalMap
实例就会一直持有对它的强引用,导致ThreadLocal
对象本身无法被回收,从而引起内存泄漏。通过使用弱引用,当ThreadLocal
变量超出作用域后,即使线程依然存活,ThreadLocalMap
中的键也会被自动回收。
为什么值是强引用?
与键不同,ThreadLocalMap
的值(也就是你通过 set
方法存入的对象)是强引用。
- 确保值的可用性: 如果值也是弱引用,那么当
ThreadLocal
变量超出作用域后,值也可能随时被垃圾回收器回收。这样一来,当你再次通过get
方法访问时,就会得到null
,这与ThreadLocal
的使用初衷相悖。ThreadLocal
的目的是为每个线程提供独立的、可以持续访问的变量副本。
这种设计带来的内存泄漏问题
虽然键的弱引用设计解决了 ThreadLocal
对象的内存泄漏,但如果开发者没有正确地处理,仍然会导致值的内存泄漏。
考虑以下场景:
- 你在一个线程中创建了一个
ThreadLocal
对象,并设置了一个值。 ThreadLocalMap
中会有一个键值对,键是ThreadLocal
对象的弱引用,值是你设置的对象。ThreadLocal
对象在外部不再被强引用,被垃圾回收器回收。- 此时
ThreadLocalMap
中的键变为null
,但值依然存在,因为它是一个强引用。
如果线程一直存活(比如在线程池中),而你没有手动调用 ThreadLocal.remove()
,那么这个值为 null
的键值对就会一直占用内存,导致内存泄漏。
解决方案
为了解决这个问题,Java 社区推荐的最佳实践是:无论何时使用完 ThreadLocal 变量,都应该显式地调用 ThreadLocal.remove() 方法。
ThreadLocal.remove()
会从 ThreadLocalMap
中移除对应的键值对,从而释放内存,避免内存泄漏。这个操作通常应该放在 finally
块中,以确保即使发生异常,也能执行清理工作。
例如:
1 | ThreadLocal<MyObject> threadLocal = new ThreadLocal<>(); |
总而言之,ThreadLocalMap
的设计是一种平衡。它利用弱引用来自动处理 ThreadLocal
对象的回收,从而减轻了开发者的负担。但与此同时,它把值的清理责任留给了开发者,要求我们通过 remove()
方法来显式地释放内存,从而避免因长期存活的线程而导致的值的内存泄漏。
继承问题
ThreadLocal
的值不会自动传递给子线程。如果你需要父线程创建子线程时,让子线程也能访问父线程的 ThreadLocal
值,你需要使用 InheritableThreadLocal
。不过,InheritableThreadLocal
同样需要注意内存泄漏问题,并且在线程池环境下使用时可能会有意外行为,需要格外小心。
Collection (集合框架):
- 核心接口: Collection (父接口), List, Set, Map。
- Iterable 接口: Collection 接口继承了 Iterable 接口,使得所有集合都可以通过增强for 循环(foreach)进行遍历。
A. List 接口及其实现类
List 是一种有序集合,可以包含重复元素。
1. ArrayList
底层实现:基于动态数组(Object[] elementData)实现。
特点:
- 有序:元素有插入顺序,可以通过索引访问(get(index))。
- 可重复:允许存储重复元素。
- 随机访问效率高:通过索引访问元素(get(index))速度非常快,时间复杂度为O(1)。这是因为数组在内存中是连续存储的,可以通过基地址和偏移量直接计算出元素的内存地址。
- 插入和删除效率低:
- 在数组末尾添加或删除元素效率较高(平均O(1))。
- 在数组中间插入或删除元素时,需要使用System.arraycopy()移动被影响位置之后的所有元素,时间复杂度为 O(n)。
- 线程不安全:在多线程环境下,如果一个线程正在修改 ArrayList,而另一个线程正在读取或修改它,可能会导致数据不一致或 ConcurrentModificationException(在使用迭代器时)。
扩容机制:
- 初始容量:默认情况下,当你创建一个无参的ArrayList时,它的底层数组是空的(DEFAULTCAPACITY_EMPTY_ELEMENTDATA, 即 new Object[0])。首次添加元素时,内部数组会被初始化为默认容量 DEFAULT_CAPACITY (JDK8为10)。如果你在创建时指定了容量(new ArrayList<>(capacity)),则初始容量就是你指定的。
- 扩容时机:当ArrayList 的当前元素个数(size)等于底层数组的容量(elementData.length)时,就会触发扩容。
- 扩容方式:扩容逻辑位于grow()方法中。
- 计算新的容量:newCapacity = oldCapacity + (oldCapacity >> 1),即新容量是旧容量的1.5倍。
- 如果计算出的新容量仍然小于需要的最小容量(minCapacity,即当前元素个数 size + 1),则直接将 minCapacity 作为新容量。
- 如果新容量超出了MAX_ARRAY_SIZE(通常是 Integer.MAX_VALUE-8),则会尝试使用 Integer.MAX_VALUE,如果仍不足则抛出 OutOfMemoryError。
- 创建一个新数组,并将旧数组中的元素复制到新数组中(Arrays.copyOf()内部调用 System.arraycopy())。
为什么这么扩容(1.5倍):
- 平衡空间与时间:
- 相比于每次只增加1个元素,1.5倍的扩容策略减少了扩容的次数,从而减少了频繁进行数组复制带来的性能开销(数组复制是O(n)操作)。
- 相比于2倍扩容,1.5倍的策略在空间利用率上更优,避免了过度分配和浪费过多内存。
- 这是一个在时间和空间之间权衡的选择,旨在提供一个相对高效且内存友好的动态数组实现。
- 平衡空间与时间:
可能出现的问题:
- ConcurrentModificationException: 在多线程环境中,如果一个线程正在遍历 ArrayList(通过迭代器或增强 for循环),而另一个线程同时对其进行结构性修改(添加、删除元素等),就会抛出此异常。这是因为ArrayList 的迭代器是快速失败(fail-fast)的,它会检查 modCount(修改次数)是否与迭代器创建时一致。不一致则抛出异常。
- 内存开销:如果预估容量不准确,频繁扩容会导致多次数组复制,增加 CPU和内存开销。
- 内存浪费:如果初始容量设置过大,而实际使用的元素很少,会导致内存浪费。
常用方法:
- add(E e): 在列表末尾添加元素。
- add(int index, E e): 在指定位置插入元素。
- remove(int index) / remove(Object o): 删除指定位置或指定元素的第一个匹配项。
- get(int index):获取指定位置的元素。
- set(int index, E e): 替换指定位置的元素。
- size(): 返回列表中元素的个数。
- indexOf(Object o) / lastIndexOf(Object o):返回元素第一次/最后一次出现的索引。
- contains(Object o): 判断是否包含某个元素。
- clear(): 清空列表。
遍历方式:
传统 for 循环:
1
2
3for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}增强 for 循环(foreach):
1
2
3for (E element: list) { //内部使用迭代器
System.out.println(element);
}迭代器(Iterator):
1
2
3
4
5
6
7
8Iterator<E> it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
// 如果需要删除元素,必须使用 it.remove(),否则会抛出
// ConcurrentModificationException
// it.remove();
}Java 8 Stream API:
1
2
3
4list.forEach(System.out::println);
// 或
list.stream().forEach(System.out::println);
RandomAccess
是 Java 集合框架中的一个标记接口,它本身不包含任何方法。它的主要作用是告诉 Java 虚拟机(JVM)和开发者,实现了这个接口的 List
集合支持快速随机访问,即通过索引访问元素时,性能是恒定的,通常为 O(1)。
ArrayList
实现 RandomAccess
的作用
ArrayList
的底层是数组。通过索引访问数组元素(如 list.get(i)
)是一个非常高效的操作,因为它直接通过内存地址偏移量来查找,耗时是固定的。
RandomAccess
接口的作用就是为这种特性提供一个标记。在某些算法或方法中(比如 Collections.binarySearch()
),程序会先检查一个 List
是否实现了 RandomAccess
。
- 如果实现了,就说明它是
ArrayList
这类基于数组的列表,此时会选择基于索引的 for 循环进行遍历,因为这种方式最快。 - 如果没实现,就说明它是
LinkedList
这类基于链表的列表,此时会选择迭代器进行遍历,以避免低效的随机访问。
因此,RandomAccess
就像一个性能提示标签,帮助 JVM 和算法选择最合适的遍历或查找策略,从而优化性能。
为什么 LinkedList
没有实现 RandomAccess
?
LinkedList
的底层是双向链表。要通过索引访问链表中的一个元素(如 list.get(i)
),需要从头节点或尾节点开始,逐个遍历到目标位置。这个操作的耗时与索引值 i
成正比,时间复杂度为 O(n)。
因为 LinkedList
不支持快速随机访问,所以它没有实现 RandomAccess
接口。如果用 for 循环来遍历 LinkedList
,每次 get(i)
调用都会导致一次从头到尾的遍历,效率极低。相反,使用迭代器则能确保每次只移动到下一个元素,效率更高。
类型 | 底层数据结构 | 随机访问性能 (get(i) ) |
遍历方式 | 是否实现 RandomAccess |
---|---|---|---|---|
ArrayList | 数组 | O(1) | for 循环 | 是 |
LinkedList | 双向链表 | O(n) | 迭代器 | 否 |
总而言之,RandomAccess
接口是一个重要的设计模式,它利用多态性来指导程序为不同的 List
实现选择最优的算法,从而在不修改代码逻辑的前提下提升了性能。
2. LinkedList
底层实现:基于双向链表(Doubly Linked List)实现。每个节点都包含数据,以及指向前一个节点和后一个节点的引用。
特点:
- 有序:元素有插入顺序。
- 可重复:允许存储重复元素。
- 插入和删除效率高:在链表的任何位置插入或删除元素,只需修改前后节点的引用,时间复杂度为O(1)。
- 随机访问效率低:get(index)操作需要从头节点或尾节点开始遍历链表直到目标索引,时间复杂度为O(n)。
- 内存开销大:每个节点除了存储数据本身,还需要额外的内存空间存储两个指针(prev 和 next),因此相比 ArrayList,在存储相同数量元素时, LinkedList 占用更多内存。
- 线程不安全:与ArrayList 类似,在多线程环境下不安全,可能抛出 ConcurrentModificationException。
扩容机制:
- LinkedList 基于链表实现,没有固定容量的概念,也无需进行扩容。每次添加元素就是创建一个新节点并连接到链表中。因此不存在 ArrayList 那样的数组复制开销。
可能出现的问题:
- ConcurrentModification Exception:同样在多线程环境下使用迭代器进行修改时可能发生。
- 内存碎片/开销:频繁的节点创建和销毁,以及每个节点额外的指针开销,可能导致一定的内存碎片和更高的内存占用。
常用方法:
- add(E e) / addFirst(E e) / addLast(E e): 添加元素。
- remove() / removeFirst() / removeLast():删除元素。
- get(int index) / getFirst() / getLast(): 获取元素(get(int index)效率低)。
- peek() / peekFirst() / peekLast(): 获取但不移除头部/尾部元素。
- offer(E e) / offerFirst(E e) / offerLast (E e): 添加元素到队列/双端队列(通常不抛异常)。
- poll() / pollFirst() / pollLast(): 获取并移除头部/尾部元素(为空返回null)。
- push(E e) / pop():实现栈的入栈和出栈操作。
- size(), isEmpty(), contains(Object o), clear().
遍历方式:
传统 for 循环:
for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); }
(不推荐,效率低)增强 for 循环(foreach):
for (E element: list) { System.out.println(element); }
(推荐)迭代器(Iterator):
Java
1
2
3
4
5Iterator<E> it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}Java 8 Stream API:
list.forEach(System.out::println);
3. Vector
- 底层实现:基于动态数组实现,与ArrayList 类似。
- 特点:
- 线程安全:所有公共方法都使用了synchronized 关键字进行同步,因此是线程安全的。
- 效率低:因为所有操作都被同步,在单线程或并发读多写少的场景下,性能比 ArrayList 差。
- 扩容机制: Vector 的扩容策略与 ArrayList 类似,但默认是翻倍扩容(即新容量是旧容量的2倍)。可以通过构造函数指定扩容增量。
- 可能出现的问题:
- 性能瓶颈:全局锁导致并发性能差。
- 使用场景:已经被 java.util.concurrent 包中的并发集合(如 CopyOnWriteArrayList)取代,基本不再推荐使用。
4. Stack
- 底层实现:继承自Vector,因此也是基于数组实现,并具有Vector 的线程安全性。
- 特点:实现了后进先出(LIFO)的栈结构。
- 常用方法:
- push(E item):元素入栈。
- pop():元素出栈。
- peek(): 查看栈顶元素但不移除。
- empty(): 判断栈是否为空。
- search(Object o): 查找元素并返回离栈顶的距离。
- 使用场景:不推荐使用,因为Stack 继承了Vector,而 Vector 本身有很多不适合栈操作的方法。通常使用 Deque 接口的实现类(如ArrayDeque 或 LinkedList)来代替栈,它们更灵活高效。
小结 List:
- ArrayList: 随机访问多,插入删除少(尤其末尾操作)的场景。
- LinkedList: 插入删除多,随机访问少的场景;或需要作为队列/栈使用的场景。
- Vector / Stack:不推荐在现代Java开发中使用,除非有特殊历史兼容需求。
B. Set 接口及其实现类
Set 是一种无序集合,不允许重复元素。
1. HashSet
底层实现:基于HashMap 实现。HashSet 内部使用一个 HashMap 实例来存储元素,HashSet 的元素作为HashMap的键(Key),而HashMap 的值(Value)则是一个固定的、无关紧要的 PRESENT 静态 Object 对象。
特点:
- 无序:不保证元素的存储顺序和迭代顺序。
- 不可重复:元素唯一。通过元素的hashCode() 和 equals() 方法来判断元素的唯一性。当添加元素时,首先计算元素的hashCode(),然后根据哈希值找到对应的“桶”,再在该桶中遍历,如果存在 equals()为true 的元素,则不添加。
- 允许 null元素:允许且只能存储一个 null 元素。
- 查询、添加、删除的平均时间复杂度为(1) (在不发生哈希冲突或冲突较少的情况下)。最坏情况下(所有元素哈希冲突到同一个桶),会退化为 O(n)。
- 线程不安全:与HashMap 类似,非同步。
扩容机制:
- 由于底层是HashMap,其扩容机制与 HashMap 完全相同。
- 初始容量:默认初始容量为16。
- 负载因子:默认负载因子为0.75。
- 扩容时机:当HashSet 中存储的元素数量达到容量*负载因子时,就会进行扩容,新容量是旧容量的2倍。
- 扩容过程:创建一个新的两倍大小的底层数组,然后遍历旧数组中的所有元素,重新计算它们的哈希值,并将它们放入新数组的正确位置。
可能出现的问题:
- 性能下降: 如果自定义类作为元素存储在 HashSet 中,但没有正确重写 hashCode() 和 equals() 方法,可能会导致元素重复,或者哈希冲突严重,从而导致性能急剧下降。
- ConcurrentModicationException: 同步性问题,在多线程环境下使用迭代器修改集合时会抛出。
常用方法:
- add(E e): 添加元素。
- remove(Object o): 删除元素。
- contains(Object o): 判断是否包含元素。
- size(): 返回集合中元素的个数。
- isEmpty(), clear().
遍历方式:
增强 for 循环 (foreach):
for (E element : set) { System.out.println(element); }
迭代器 (Iterator):
Java
1
2
3
4
5Iterator<E> it = set.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}Java 8 Stream API:
set.forEach(System.out::println);
2. LinkedHashSet
- 底层实现: 继承自 HashSet,内部使用 LinkedHashMap 实现。
- 特点:
- 除了具备 HashSet 的所有特性外,最大的特点是保持元素的插入顺序(或者访问顺序,如果配置为 LRU 缓存)。这意味着遍历 LinkedHashSet时,元素的顺序与它们被添加到集合中的顺序一致。
- 维护了一个双向链表,用于维护元素的插入顺序。
- 扩容机制: 与 HashSet 和 HashMap 相同。
- 使用场景: 需要去重,同时又需要保持元素插入顺序的场景。
3. TreeSet
- 底层实现: 基于 TreeMap 实现。TreeSet 内部使用一个 TreeMap 实例来存储元素,TreeSet 的元素作为 TreeMap 的键,而值则是一个固定的 Object。
- 特点:
- 有序: 元素会根据其自然排序(元素必须实现 Comparable 接口)或者在创建 TreeSet 时提供的 Comparator 进行排序。
- 不可重复: 元素唯一,唯一性通过比较结果判断(compareTo() 或 compare() 方法返回 0)。
- 不允许 null 元素: 不允许存储 null 元素(因为 null 无法进行比较)。
- 查询、添加、删除的时间复杂度为 O(log n),因为底层是红黑树。
- 线程不安全: 非同步。
- 扩容机制:
- 由于底层是红黑树,没有传统意义上的扩容机制。每次添加元素就是增加一个节点,并根据红黑树的平衡规则进行调整(旋转和变色)来保持树的平衡。
- 可能出现的问题:
- 性能: 相比 HashSet,性能略低,因为涉及比较和树的平衡操作。
- 元素必须可比较: 如果存储的元素没有实现 Comparable 接口,或者创建 TreeSet 时没有提供 Comparator,则会抛出 ClassCastException。
- ConcurrentModicationException: 同步性问题。
- 常用方法:
- 与 HashSet 类似,但额外提供了与排序相关的方法,如 rst(), last(), headSet(), tailSet(), subSet() 等。
小结 Set:
- HashSet: 最常用,需要快速查找、去重,不关心元素顺序的场景。
- LinkedHashSet: 需要去重,同时又需要保持元素插入顺序的场景。
- TreeSet: 需要去重,并且希望元素自动按照自然顺序或自定义顺序排序的场景。
C. Map 接口及其实现类
Map 存储键值对,键是唯一的,值可以重复。
1. HashMap
底层实现: 基于哈希表实现,JDK 8 及以后是数组 + 链表 + 红黑树。
- 数组: Node
- 链表: 用于解决哈希冲突,将哈希到同一个索引位置的键值对以链表形式连接。
- 红黑树: 当链表长度达到一定阈值(JDK 8 默认为 8)时,为了提高查找效率,该链表会转换为红黑树。当红黑树节点数少于一定阈值(JDK 8 默认为 6)时,会退化为链表。
特点:
- 无序: 不保证键值对的存储和迭代顺序。
- 键唯一,值可重复: 键通过 hashCode() 和 equals() 方法确定唯一性。
- 允许 null 键和 null 值: 只能有一个 null 键(存储在索引 0 的位置),可以有多个 null 值。
- 查询、添加、删除的平均时间复杂度为 O(1),最坏情况下为 O(n)(链表)或 O(logn)(红黑树,JDK 8 及以后)。
扩容机制:
初始容量 (initialCapacity): 默认值为 16。最好在创建 HashMap 时预估并指定一个合适的初始容量,以减少扩容次数。
负载因子 (loadFactor): 默认值为 0.75。表示哈希表在进行扩容前的填充比例。
扩容时机: 当 HashMap 中存储的元素数量 (size) 达到 容量 * 负载因子 (即 threshold) 时,就会触发扩容。
扩容方式: resize() 方法。
- 创建一个新的两倍大小的底层数组。
- 遍历旧数组中的所有键值对。
- 重新计算每个键的哈希值,并根据新的容量大小,将其放入新数组的正确位置。这个过程被称为再哈希 (rehash)。
- JDK 8 优化: 在链表转换时,避免了每个节点单独重新计算哈希值,而是根据原索引和新容量的关系,直接判断节点在新数组中的位置,提高了效率。
为什么这么扩容 (2 倍):
- 位运算优化: 容量始终保持 2 的幂次方,可以利用位运算 (h & (length - 1)) 来替代取模运算 h % length,提高哈希值到索引的映射效率。
- 减少哈希冲突: 扩容为 2 倍可以有效分散哈希冲突,使得更多的键能够映射到不同的桶,从而降低链表/红黑树的长度,保持 O(1) 的平均性能。
为什么负载因子是 0.75:
- 这是一个在“空间利用率”和“查询效率”之间的权衡。
- 如果负载因子过小,会频繁扩容,浪费空间。
- 如果负载因子过大,哈希冲突会增加,链表/红黑树变长,导致查询效率下降。
- 0.75 这个值是经过实践验证,在大多数情况下能够提供较好性能的平衡点。
可能出现的问题:
- 性能下降: 如果自定义类作为键存储在 HashMap 中,但没有正确重写 hashCode() 和 equals() 方法,会导致元素重复,或者哈希冲突严重,从而导致性能急剧下降。
- 多线程问题: 在多线程环境下,对 HashMap 进行修改操作可能导致数据丢失、死循环(JDK 7 及以前),或 ConcurrentModicationException。这是其最大的问题。
- 内存开销: 频繁扩容会带来数组复制的开销。
常用方法:
- put(K key, V value): 关联键值对。
- get(Object key):
遍历方式:
遍历键集 (keySet()):
Java
1
2
3
4
5for (K key : map.keySet()) {
V value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}遍历键值对集 (entrySet()) - 推荐,效率最高:
Java
1
2
3
4for (Map.Entry<K, V> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}遍历值集 (values()):
Java
1
2
3
4for (V value : map.values()) {
System.out.println("Value: " + value);
}迭代器 (Iterator):
Java
1
2
3
4
5
6Iterator<Map.Entry<K, V>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<K, V> entry = it.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}Java 8 Stream API:
1
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
2. LinkedHashMap
- 底层实现: 继承自 HashMap,额外维护了一个双向链表,用于维护插入顺序或访问顺序。
- 特点:
- 除了具备 HashMap 的所有特性外,最大的特点是保持键值对的插入顺序(或访问顺序)。这意味着遍历 LinkedHashMap 时,元素的顺序与它们被添加到 Map 中的顺序一致。
- 可以用于实现 LRU (Least Recently Used) 缓存策略,通过覆盖 removeEldestEntry 方法并设置访问顺序为 true。
- 扩容机制: 与 HashMap 相同。
- 使用场景: 需要快速查找,同时又需要保持插入顺序的场景。
3. TreeMap
- 底层实现: 基于红黑树(Red-Black Tree)实现。
- 特点:
- 有序: 键会根据其自然排序(键的类型必须实现 Comparable 接口)或者在创建 TreeMap 时提供的 Comparator 进行排序。
- 键唯一,值可重复: 唯一性判断依赖于键的比较结果(compareTo() 或 compare() 方法返回 0)。
- 不允许 null 键: 不允许存储 null 键(因为 null 无法进行比较),但允许 null 值。
- 查询、添加、删除的时间复杂度为 O(log n),因为底层是红黑树。
- 线程不安全: 非同步。
- 扩容机制:
- 由于底层是红黑树,没有传统意义上的扩容机制。每次添加键值对就是增加一个节点,并根据红黑树的平衡规则进行调整(旋转和变色)来保持树的平衡。
- 可能出现的问题:
- 性能: 相比 HashMap,性能略低,因为涉及比较和树的平衡操作。
- 键必须可比较: 如果键没有实现 Comparable 接口,或者创建 TreeMap 时没有提供 Comparator,则会抛出 ClassCastException。
- ConcurrentModicationException: 同步性问题。
- 常用方法:
- 与 HashMap 类似,但额外提供了与排序相关的方法,如 rstKey(), lastKey(), ceilingEntry(), oorEntry() 等。
4. Hashtable
- 底层实现: 基于哈希表实现,与 HashMap 类似,但所有方法都使用了 synchronized 关键字。
- 特点:
- 线程安全: 所有公共方法都进行了同步处理。
- 效率低: 全局锁导致并发性能差。
- 不允许 null 键和 null 值。
- 初始容量和扩容机制: 默认初始容量 11,负载因子 0.75。扩容时新容量是旧容量的 2 倍 + 1。
- 使用场景: 已被 ConcurrentHashMap 取代,基本不再推荐使用。
5. ConcurrentHashMap (JUC 包中的并发集合)
底层实现:
- JDK 7 及以前: 采用分段锁 (Segment) 的方式,将 HashMap 内部数据分成多个段(Segment),每个段是一个独立的 ReentrantLock。锁住某个段时,不影响其他段的操作。
- JDK 8 及以后: 放弃了分段锁,改为使用 CAS (Compare-And-Swap) 操作和 synchronized 关键字(只在链表/红黑树头节点发生竞争时才使用,锁住的范围更小)来保证线程安全。
特点:
- 线程安全: 高并发环境下性能优异。
- 不允许 null 键和 null 值。
- 读操作基本无锁。
扩容机制: 与 HashMap 类似,JDK 8 中,每个 Node 数组的扩容是独立的,通过 transfer 方法实现。
使用场景: 高并发场景下替代 HashMap 和 Hashtable 的首选。
java.util.concurrent
(JUC) 包是 Java 并发编程的高级工具包,它提供了比传统synchronized
关键字和wait/notify
机制更强大、更灵活的并发控制手段。
好的 ✅ 我来帮你把前面两个回答 整合成一个完整、系统、逐层递进的 ConcurrentHashMap 解析文档,内容覆盖 实现原理 + 扩容机制 + 高频面试题 + 答题模板,你可以直接拿去当复习资料或面试答题脚本。
🧩 ConcurrentHashMap 超详细解析(JDK8为主,含对比)
一、背景与设计动机
在并发场景下:
HashMap
线程不安全,可能出现死循环、数据丢失。Hashtable
与Collections.synchronizedMap
使用 全表锁,吞吐量极差。
ConcurrentHashMap(CHM):
- 线程安全:保证多线程同时访问时数据一致性。
- 高性能:通过 分桶锁 + CAS + volatile 提升并发度。
- 弱一致性迭代器:遍历过程中允许修改,不会抛
ConcurrentModificationException
。
二、实现演变(JDK7 vs JDK8)
JDK7
- 数据结构:
Segment[]
+HashEntry[]
。 - Segment:类似小型 HashMap,每个 Segment 有独立的 ReentrantLock。
- 并发度:由 Segment 数量决定(默认 16)。
缺点:
- 锁粒度仍然较大;
- 扩容需要锁定整个 Segment。
JDK8
- 数据结构:
Node[]
+ 链表/红黑树。 - 锁机制:CAS + synchronized(桶锁),比 Segment 更细。
- 改进点:
- 无 Segment,更细粒度控制。
- 链表冲突过长时转化为 红黑树(树化)。
- 扩容为 渐进式 + 多线程协助。
- 使用 ForwardingNode 路由迁移。
三、核心数据结构
Node 节点
1 | static class Node<K,V> implements Map.Entry<K,V> { |
val
和next
是volatile
,保证可见性。
TreeNode(红黑树节点)
1 | static final class TreeNode<K,V> extends Node<K,V> { |
- 当链表长度 > 8 且容量 ≥ 64 时转化为红黑树。
ForwardingNode
- 扩容时的占位符,用于标记桶已迁移,引导线程访问新表。
四、核心操作流程
1. get() —— 无锁读
- 直接计算 hash 定位桶。
- 如果是链表 → 遍历;
- 如果是红黑树 → 按树查找;
- 如果是 ForwardingNode → 去新表查。
特点:无锁,只依赖 volatile 保证可见性。
2. put() —— 写时加锁
- 计算索引位置;
- 桶为空 → CAS 插入;
- 桶非空:
- ForwardingNode → 协助扩容;
- 链表/树 → synchronized 锁住桶头,再插入;
- 插入后:
- 如果链表长度 > 8 且容量 ≥ 64 → 树化;
- 如果总数超过阈值 → 触发扩容。
3. 扩容机制(Transfer)
触发条件
- size 超过
threshold = capacity * loadFactor
(默认 0.75)。
流程
- 新建数组:容量翻倍。
- 迁移桶:
- 遍历旧桶,将节点分为 低位链(loHead) 和 高位链(hiHead)。
hash & oldCap == 0
→ 留在原位置;- 否则 → 放到
i+oldCap
。
- 标记迁移完成:
- 旧桶置为 ForwardingNode。
- 之后读/写遇到它时,会路由到新表。
- 多线程协助:
- 其他线程写入时若发现正在扩容 → 调用
helpTransfer()
→ 一起分桶迁移。
- 其他线程写入时若发现正在扩容 → 调用
- 渐进式扩容:
- 一次只迁移一个桶,避免阻塞。
五、并发控制机制
- CAS:初始化 table、插入第一个节点时。
- volatile:保证 val 和 next 的可见性。
- synchronized:写操作时锁定单个桶。
- ForwardingNode:保证扩容过程数据一致性。
六、性能特性
- 读操作几乎无锁 → 高性能。
- 写操作局部锁定 → 粒度小,冲突概率低。
- 扩容支持并行迁移 → 避免长时间卡顿。
- 树化避免链表退化 O(n)。
七、常见高频面试题
基础题
- ConcurrentHashMap 和 HashMap 的区别?
- HashMap:线程不安全;
- ConcurrentHashMap:线程安全,CAS+桶锁;
- 数据结构都支持链表+红黑树。
- JDK7 和 JDK8 的区别?
- JDK7:Segment 分段锁;
- JDK8:CAS + synchronized,锁粒度更细。
- 为什么 synchronized 而不是 ReentrantLock?
- JDK8 之后对 synchronized 优化(偏向锁、轻量级锁);
- 实现简洁,不需手动释放锁。
进阶题
- 为什么 get 无需加锁?
- Node 的 val、next 是 volatile,可见性足够;
- get 只读,不会修改结构。
- 为什么需要红黑树?
- 避免哈希冲突严重时链表退化为 O(n)。
- 树化后查询 O(log n)。
- 红黑树什么时候转化/退化?
- 链表长度 > 8 且容量 ≥ 64 → 树化;
- 树节点数 < 6 → 退化为链表。
扩容相关题
- ConcurrentHashMap 扩容过程?为什么说是渐进式?
- 桶级迁移,每次只迁移一个桶;
- 旧桶置为 ForwardingNode;
- 多线程可协助迁移;
- 不会一次性迁移整个表 → 避免卡顿。
- 扩容时,get 如何保证正确性?
- 遇到 ForwardingNode → 跳转新表继续查;
- 保证读到的是最新数据。
- 为什么不允许 key 或 value 为 null?
- 无法区分「null 表示没有值」还是「值本身就是 null」,容易歧义。
- 为什么迭代器是弱一致性的?
- 遍历时允许并发修改;
- 不抛异常,但可能漏数据或读到旧值。
- size() 如何实现?为什么不精确?
- 分段计数,多次尝试累加;
- 并发下不保证强一致性,只保证大致准确。
高阶题
- 为什么扩容时使用 ForwardingNode?
- 防止重复迁移;
- 读写遇到它能路由到新表。
- ConcurrentHashMap 会死锁吗?
- 不会。每次只锁单个桶,不存在跨桶死锁。
- ConcurrentHashMap 与 CopyOnWriteMap 区别?
- CHM:适合读多写多,分桶锁+CAS;
- COW:适合读多写少,写时复制,读完全无锁。
八、答题模板(面试框架)
当被问到「ConcurrentHashMap」时,可以这样答:
- 整体实现:
- JDK7:Segment 分段锁;
- JDK8:Node 数组 + CAS + synchronized(桶锁)。
- 核心思想:
- 读操作无锁;
- 写操作锁单个桶;
- 扩容渐进式+多线程协助;
- 冲突过长时树化。
- 扩容机制:
- 懒扩容,阈值触发;
- ForwardingNode 标记已迁移桶;
- 多线程 helpTransfer;
- 渐进式迁移,避免阻塞。
- 常见问题:
- 不允许 null key/value;
- 弱一致性迭代器;
- size() 不精确。
📌 一句话总结口诀:
👉 JDK7 Segment 分段锁,JDK8 CAS+桶锁;读无锁,写锁桶,扩容多线程协助;链表转树,弱一致迭代。
🧩 一、并发工具类(协作类)
这些工具类都在 java.util.concurrent
包里,用来解决线程间的 协调/同步/通信问题。
1. CountDownLatch(倒计时器)
📝 定义
- 一个或多个线程等待 其他线程完成操作 后再继续。
- 内部维护一个
state
(计数),初始值设定好,每调用一次countDown()
,state--
。 - 当
state=0
时,await()
的线程全部唤醒。
⚙️ 底层实现
- 基于 AQS(AbstractQueuedSynchronizer) 的共享锁实现。
await()
会阻塞线程,直到state==0
;countDown()
是非阻塞的,只是原子性地减 1。
📌 使用场景
- 主线程等待多个子线程完成(一等多)。
- 例如:并行加载多个配置文件,等都加载完再合并处理。
🎯 面试考点
- 能否重用? → 不能,计数归零后不能重置。
- 和
CyclicBarrier
的区别?→CountDownLatch
是「一等多」,CyclicBarrier
是「多等多」。
2. CyclicBarrier(循环屏障)
📝 定义
- 一组线程互相等待,直到所有线程都到达屏障点,再统一出发。
- 支持 循环使用(计数归零后可重置)。
⚙️ 底层实现
- 基于 ReentrantLock + Condition。
- 每个线程调用
await()
→ 计数 -1 → 当计数 = 0 → 唤醒所有线程。 - 还可以指定一个 barrierAction(所有线程到达后执行的回调)。
📌 使用场景
- 多线程分阶段计算,再统一合并结果。
- 例如:并行计算矩阵的每一行,所有线程到齐后汇总。
🎯 面试考点
- 与
CountDownLatch
的区别?→ 可循环,且是「多等多」。 BrokenBarrierException
什么时候抛?→ 线程中断或超时导致 barrier 破坏。
3. Semaphore(信号量)
📝 定义
- 控制同时访问某资源的线程数,像 限流器/许可证。
⚙️ 底层实现
- 基于 AQS 的共享锁实现。
- 内部维护一个
permits
(许可证数)。 acquire()
获取许可证(permits–,不足则阻塞);release()
归还许可证(permits++)。
📌 使用场景
- 控制并发数:数据库连接池、接口限流。
- 例子:停车场有 3 个车位 → 最多只能停 3 辆车。
🎯 面试考点
- 公平模式 vs 非公平模式?
- 公平:FIFO 获取许可证。
- 非公平:可能插队,吞吐量更高。
- 和
Lock
的区别?→Semaphore
控制的是 并发数量,而Lock
是 互斥(只能 1 个线程)。
4. Exchanger(交换器)
📝 定义
- 让 两个线程 在同步点交换数据。
⚙️ 底层实现
- 基于 自旋 + CAS。
- 一个线程调用
exchange()
会等待,直到另一个线程也调用exchange()
,然后两者交换数据。
📌 使用场景
- 双线程协作:生产者和消费者之间成对交换数据块。
- 比如:一个线程生成数据,另一个线程写入磁盘。
🎯 面试考点
- 如果只有一个线程调用
exchange()
会怎样?→ 会阻塞,直到另一方到来。 - 与队列(BlockingQueue)的区别?→
Exchanger
是 一对一交换,而队列可以一对多。
🧩 二、原子类(Atomic 系列)
这些类在 java.util.concurrent.atomic
包下,提供了 无锁化的线程安全操作。
1. 基础原子类
AtomicInteger / AtomicLong / AtomicBoolean
- 作用:对单个
int
/long
/boolean
变量的原子操作。 - 典型方法:
getAndIncrement()
→ 自增(原子性 i++)。compareAndSet(expect, update)
→ CAS 更新。
⚙️ 底层实现
- 基于 CAS(Compare-And-Swap) + volatile。
- 内部调用 Unsafe 类 的
compareAndSwapInt
等方法。
📌 使用场景
- 高并发计数器:网站访问量统计、ID 生成器。
🎯 面试考点
- CAS 的缺点?
- ABA 问题(可用
AtomicStampedReference
解决)。 - 自旋开销大。
- 只能保证单变量原子性,不能保证复合操作。
- ABA 问题(可用
2. 数组原子类
AtomicIntegerArray / AtomicLongArray / AtomicReferenceArray
- 保证数组中的每个元素的原子操作。
- 例如:统计分片数据时,避免锁整个数组。
3. 引用原子类
AtomicReference
- 保证对象引用的原子更新。
AtomicStampedReference
- 解决 ABA 问题,给引用加一个版本号(stamp)。
AtomicMarkableReference
- 给引用加一个 boolean 标记。
4. 累加器类
LongAdder / DoubleAdder
- 对
AtomicLong
的优化:高并发下热点分离。 - 内部维护一个 base 值 + 分片 cell 数组。
- 多线程写时分散到不同的 cell,最后求和。
📌 使用场景
- 高并发计数,如 QPS 统计、热点计数。
🎯 面试考点
- 为什么
LongAdder
比AtomicLong
性能好?- AtomicLong 所有线程 CAS 同一个值,形成热点;
- LongAdder 分散冲突到多个 cell,降低竞争。
🧠 三、工具类 & 原子类对比总结
工具类 / 原子类 | 功能 | 实现原理 | 典型场景 |
---|---|---|---|
CountDownLatch | 一等多 | AQS 共享锁 | 主线程等子线程完成 |
CyclicBarrier | 多等多,可循环 | ReentrantLock + Condition | 分阶段同步,多线程汇总计算 |
Semaphore | 控制并发数(许可证) | AQS 共享锁 | 限流、连接池、资源池 |
Exchanger | 两线程数据交换 | CAS + 自旋 | 双线程协作,数据块交换 |
AtomicInteger/Long | 单变量原子操作 | CAS + volatile | 高并发计数器,ID 生成 |
AtomicReference | 原子更新引用 | CAS | 无锁更新对象引用 |
AtomicStampedReference | 带版本号引用,防止 ABA | CAS | 解决 ABA 问题 |
LongAdder/DoubleAdder | 高并发累加优化 | 分片 + CAS | 高并发计数,性能优于 AtomicLong |
🎯 四、面试答题模板(可套用)
如果面试官问 “你了解 JUC 的并发工具类吗?” 可以这样答:
- 总体分类
- JUC 里提供了 协作类工具(CountDownLatch、CyclicBarrier、Semaphore、Exchanger) 和 原子类(AtomicInteger、LongAdder 等)。
- 逐个解释
CountDownLatch
:一等多,主线程等子线程。CyclicBarrier
:多等多,可循环。Semaphore
:控制并发数,限流。Exchanger
:两线程交换数据。AtomicInteger/Long
:单变量原子操作,基于 CAS。LongAdder
:高并发下比 AtomicLong 更高效。
- 原理补充
- 工具类大多基于 AQS 实现;
- 原子类基于 CAS + volatile;
- LongAdder 用 分段累加 优化热点。
- 总结
👉 工具类解决线程协作,原子类解决无锁更新,都是 JUC 的核心。
小结 Map:
- HashMap: 最常用,需要快速查找,不关心键值对顺序,且在单线程或由外部同步机制保证线程安全的场景。
- LinkedHashMap: 需要快速查找,同时需要保持插入顺序或访问顺序的场景(如实现 LRU 缓存)。
- TreeMap: 需要根据键的自然顺序或自定义顺序排序的场景。
- ConcurrentHashMap: 高并发场景下对 Map 进行读写操作的首选。
- Hashtable: 已被淘汰,不推荐使用。
集合部分总结:
遍历 Java 集合有多种方式,每种方式都有其优缺点。选择哪种方式取决于你正在使用的集合类型和具体需求。以下是对几种常见遍历方式的优劣分析和推荐。
1. 迭代器 (Iterator)
这是最通用的遍历方式,适用于所有实现了 Iterable
接口的集合。
- 优点:
- 通用性强:它适用于所有集合,包括
List
、Set
和Queue
。 - 安全:在使用
Iterator
遍历时,可以安全地调用iterator.remove()
方法移除元素,不会引发ConcurrentModificationException
。 - 性能优越:对于
LinkedList
这类链式存储的集合,迭代器的遍历效率最高。
- 通用性强:它适用于所有集合,包括
- 缺点:
- 语法相对繁琐,需要手动调用
hasNext()
和next()
。
- 语法相对繁琐,需要手动调用
- 推荐:
- 当需要在遍历过程中删除元素时,必须使用迭代器。
- 作为通用且安全的遍历首选。
2. 增强 for 循环 (For-Each Loop)
增强 for 循环是基于迭代器的一种语法糖,它使遍历代码更简洁易读。
- 优点:
- 代码简洁:语法简单直观,可读性强。
- 不易出错:无需手动管理索引或迭代器。
- 缺点:
- 不支持删除:在遍历过程中直接调用集合的
remove()
方法会抛出ConcurrentModificationException
。 - 性能:对于
LinkedList
来说,它的底层仍然是迭代器,性能很好。但对于ArrayList
,它的性能不如传统的for
循环,因为每次迭代都需要调用next()
方法。
- 不支持删除:在遍历过程中直接调用集合的
- 推荐:
- 当你只需要读取集合中的元素,且代码简洁性比性能更重要时。
3. 传统 for 循环 (Indexed For Loop)
这种方式只适用于支持通过索引访问的集合,如 ArrayList
和 Vector
。
- 优点:
- 性能最高:对于
ArrayList
,它通过索引直接访问元素,效率极高,性能优于迭代器。 - 灵活:可以按需向前或向后遍历,也可以跳过元素。
- 性能最高:对于
- 缺点:
- 不通用:不适用于
Set
或Map
等不按索引存储的集合。 - 效率低下:对于
LinkedList
,每次get(i)
调用都会从头开始遍历,导致性能极差,时间复杂度为 O(n²)。
- 不通用:不适用于
- 推荐:
- 当遍历 ArrayList 并需要高性能时。
4. Lambda 表达式与 Stream API
Java 8 引入的 Stream API 提供了非常强大的函数式编程能力,可以用于遍历和处理集合。
- 优点:
- 简洁优雅:代码非常简洁,表达力强。
- 功能强大:支持过滤、映射、排序等各种操作,能以声明式的方式处理数据。
- 支持并行:可以通过
parallelStream()
开启并行处理,利用多核优势。
- 缺点:
- 性能开销:通常比传统的
for
循环略高,特别是在处理小规模数据时。
- 性能开销:通常比传统的
- 推荐:
- 当你需要对集合进行复杂的操作(如过滤、转换、聚合)时。
- 需要利用并行处理来提高性能时。
总结与最佳实践
方式 | 适用集合 | 优劣 | 推荐场景 |
---|---|---|---|
迭代器 | 所有集合 | 通用、安全(可删除),对 LinkedList 性能好。 |
需要在遍历中删除元素,或作为通用选择。 |
增强 for | 所有集合 | 语法简洁,可读性高。不能在遍历中删除。 | 仅需读取元素,且追求代码简洁。 |
传统 for | ArrayList 等 |
ArrayList 性能最高,灵活。对 LinkedList 性能极差。 |
ArrayList 的高性能遍历,或需要控制索引。 |
Stream API | 所有集合 | 功能强大,代码优雅,支持并行。 | 复杂数据处理、并行计算。 |
简单概括:
- 读取
ArrayList
:首选传统 for 循环。 - 读取
LinkedList
、Set
:首选增强 for 循环。 - 删除元素:必须使用迭代器。
- 复杂操作:使用 Stream API。
通常来说,在 Java 中反向遍历并删除元素时,使用传统的 for 循环并从列表的末尾开始向前遍历是最安全和高效的方法。
为什么推荐反向遍历?
当你使用正向遍历(从索引 0 开始)并删除元素时,会遇到一个常见问题:
- 当你删除一个元素后,它后面的所有元素的索引都会向前移动一位。
- 这会导致在下一次循环迭代时,你可能会跳过紧跟在被删除元素后面的那个元素,因为它现在占据了被删除元素原来的位置,而你的循环索引已经增到了下一个位置。
反向遍历完美地解决了这个问题。当你从列表末尾开始向前遍历时,删除一个元素不会影响你尚未遍历到的任何元素的索引,因为你正在处理的元素及其索引都位于列表的“后半部分”。
实现方式
1. 传统的 for
循环(推荐)
这是反向遍历并删除最直接、最安全、最高效的方式,尤其适用于 ArrayList
。
Java
1 | import java.util.ArrayList; |
2. 迭代器(Iterator)
虽然迭代器通常用于正向遍历,但你也可以使用它来从后向前遍历并删除。不过,这种方式的实现相对复杂,并且对于 LinkedList
之外的集合性能并不好。对于 ArrayList
,它仍然需要从末尾开始遍历,性能不如传统 for
循环。
注意:ListIterator
提供了双向遍历功能,但其 previous()
方法在 Iterator
接口中并不存在。因此,在通用性上,传统 for
循环依然是首选。
Java
1 | // 这是一个使用 ListIterator 的例子,只适用于 List |
总结
- 推荐:对于
ArrayList
或任何基于数组的列表,使用传统的 for 循环反向遍历是最简单、最安全、最高效的删除方式。 - 避免:避免在正向遍历时直接使用
remove()
方法,因为它可能导致跳过元素或引发ConcurrentModificationException
。 - 通用性:如果你需要编写一个通用的删除方法,考虑到所有
List
实现,那么ListIterator
是一个选择,但它的双向遍历特性仅限于List
接口。
多线程下的hashmap死循环问题:
当多个线程同时对一个 HashMap 进行操作时,尤其是在涉及扩容(resizing)的情况下,可能会导致死循环。这个问题主要出现在 Java 7 及以前的版本,由于其底层数据结构和扩容机制的设计缺陷。
HashMap 的死循环问题
在 Java 7 中,HashMap
采用数组 + 链表的方式存储数据。当链表上的元素数量过多时,为了提高性能,HashMap
会进行扩容。扩容的步骤大致如下:
- 创建一个新的、更大的数组。
- 遍历旧数组中的所有元素。
- 将每个元素重新计算哈希值,并放入新数组的正确位置。
问题就出在第三步:将链表上的元素从旧数组转移到新数组时,如果多个线程同时进行此操作,就可能导致链表中的节点顺序颠倒,形成闭环。
扩容时的死循环过程
假设有两个线程 Thread1 和 Thread2 同时对一个 HashMap
进行扩容。
- 初始状态:
HashMap
扩容前,一个桶位上有一个链表,比如NodeA
->NodeB
。 - Thread1 执行到一半:
Thread1
遍历到NodeA
,准备将其移动到新数组。此时Thread1
被挂起。NodeA
的next
引用仍然指向NodeB
。 - Thread2 开始执行:
Thread2
完整地完成了扩容过程。它将链表NodeA
->NodeB
转移到了新数组。但由于是单线程操作,转移后链表顺序保持不变,仍是NodeA
->NodeB
。 - Thread1 恢复执行:
Thread1
恢复执行,但它不知道Thread2
已经完成了扩容。它仍然使用旧的next
引用信息。Thread1
取出NodeA
,将其放入新数组。Thread1
接着处理NodeB
(因为在Thread1
的视角,NodeA
的next
还是NodeB
)。它将NodeB
放入新数组。此时,由于哈希冲突,NodeB 被放在了 NodeA 的前面,导致链表变成了 NodeB -> NodeA。NodeA
的next
引用(在Thread1
的视角)仍然指向NodeB
。现在,NodeB
的next
指向NodeA
,而NodeA
的next
指向NodeB
,形成了一个闭环。
当后续线程在这个闭环链表上进行查找时,就会陷入无限循环,导致 CPU 100% 占用。
如何解决这个问题?
Java 提供了多种线程安全的替代方案来解决 HashMap
的多线程问题:
- Collections.synchronizedMap():
- 这个方法可以创建一个线程安全的
Map
包装器。 - 它通过在每个方法调用上加锁来实现线程安全。
- 优点:简单易用。
- 缺点:每次访问都需要加锁,并发性能较差。
- 这个方法可以创建一个线程安全的
- Hashtable:
- 一个早期的线程安全
Map
实现,所有公共方法都用synchronized
修饰。 - 优点:线程安全。
- 缺点:性能差,几乎所有操作都锁住整个表,已被
ConcurrentHashMap
取代。
- 一个早期的线程安全
- ConcurrentHashMap:
- 这是 Java 推荐的线程安全 Map 实现。
- 在 Java 7 中,它使用了分段锁(Segment Lock),将数据分成多个段,每个段独立加锁。
- 在 Java 8 中,它进一步优化,采用**CAS(Compare-And-Swap)**和 synchronized 关键字,锁的粒度更细,只锁住当前操作的桶,从而大大提高了并发性能。
因此,在多线程环境下,永远不要使用 HashMap。你应该根据需求,选择 ConcurrentHashMap
或 Collections.synchronizedMap()
来保证线程安全。
集合扩展:
12. HashMap 的 get 方法能否判断某个元素是否在 map 中?
HashMap
的 get
函数的返回值不能判断一个 key
是否包含在 map
中,因为 get
返回 null
有可能是不包含该 key
,也有可能该 key
对应的 value
为 null
。因为 HashMap
中允许 key
为 null
,也允许 value
为 null
。
13. HashMap 与 HashTable 的区别是什么?
HashTable
基于Dictionary
类,而HashMap
是基于AbstractMap
。Dictionary
是任何可将键映射到相应值的类的抽象父类,而AbstractMap
是基于Map
接口的实现,它以最大限度地减少实现此接口所需的工作。HashMap
的key
和value
都允许为null
,而Hashtable
的key
和value
都不允许为null
。HashMap
遇到key
为null
的时候,调用putForNullKey
方法进行处理,而对value
没有处理;Hashtable
遇到null
,直接返回NullPointerException
。Hashtable
是线程安全的,而HashMap
不是线程安全的,但是我们也可以通过Collections.synchronizedMap(hashMap)
,使其实现同步。
HashTable 的补充:
HashTable 和 HashMap 的实现原理几乎一样,差别无非是:
HashTable
不允许key
和value
为null
;HashTable
是线程安全的。但是HashTable
线程安全的策略实现代价却太大了,简单粗暴,get/put
所有相关操作都是synchronized
的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
14. HashMap 与 ConcurrentHashMap 的区别是什么?
HashMap
不是线程安全的,而 ConcurrentHashMap
是线程安全的。
ConcurrentHashMap
采用锁分段技术,将整个Hash桶进行了分段segment,也就是将这个大的数组分成了几个小的片段 segment
,而且每个小的片段 segment
上面都有锁存在,那么在插入元素的时候就需要先找到应该插入到哪一个片段 segment
,然后再在这个片段上面进行插入,而且这里还需要获取 segment
锁,这样做明显减小了锁的粒度。
15. HashTable 和 ConcurrentHashMap 的区别?
HashTable
和 ConcurrentHashMap
相比,效率低。 Hashtable
之所以效率低主要是使用了 synchronized
关键字对 put
等操作进行加锁,而 synchronized
关键字加锁是对整张 Hash 表的,即每次锁住整张表让线程独占,致使效率低下,而 ConcurrentHashMap
在对象中保存了一个 Segment
数组,即将整个 Hash 表划分为多个分段;而每个Segment
元素,即每个分段则类似于一个Hashtable
;这样,在执行 put
操作时首先根据 hash 算法定位到元素属于哪个 Segment
,然后对该 Segment
加锁即可,因此,ConcurrentHashMap
在多线程并发编程中可是实现多线程 put
操作。
16. ConcurrentHashMap 的实现原理是什么?
数据结构
- JDK 7:中
ConcurrentHashMap
采用了数组 + Segment + 分段锁的方式实现。 - JDK 8:中
ConcurrentHashMap
参考了 JDK 8HashMap
的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用CAS
操作。
ConcurrentHashMap 采用了非常精妙的”分段锁”策略,ConcurrentHashMap 的主干是个 Segment 数组。
final Segment[] segments;
Segment 继承了 ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在 ConcurrentHashMap,一个 Segment 就是一个子哈希表,Segment 里维护了一个 HashEntry 数组,并发环境下,对于不同 Segment 的数据进行操作是不用考虑锁竞争的。就按默认的 ConcurrentLevel 为 16 来讲,理论上就允许 16 个线程并发执行。所以,对于同一个 Segment 的操作才需考虑线程同步,不同的 Segment 则无需考虑。Segment 类似于 HashMap,一个 Segment 维护着一个HashEntry 数组:
transient volatile HashEntry[] table;
HashEntry 是目前我们提到的最小的逻辑处理单元了。一个 ConcurrentHashMap 维护一个 Segment 数组,一个 Segment 维护一个 HashEntry 数组。因此,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。
17. HashSet 的实现原理?
HashSet
的实现是依赖于 HashMap
的,HashSet
的值都是存储在 HashMap
中的。在 HashSet
的构造法中会初始化一个 HashMap
对象,HashSet
不允许值重复。因此,HashSet
的值是作为 HashMap
的 key
存储在 HashMap
中的,当存储的值已经存在时返回 false
。
18. HashSet 怎么保证元素不重复的?
public boolean add(E e) { return map.put(e, PRESENT)==null; }
元素值作为的是 map
的 key
,map
的 value
则是 PRESENT
变量,这个变量只作为放入 map
时的一个占位符而存在,所以没什么实际用处。其实,这时候答案已经出来了:
HashMap的
key是不能重复的,而这里
HashSet的元素又是作为了
map的
key`,当然也不能重复了。
19. LinkedHashMap 的实现原理?
LinkedHashMap
也是基于 HashMap
实现的,不同的是它定义了一个 Entry
header
,这个 header
不是放在 Table
里,它是额外独立出来的。LinkedHashMap
通过继承 hashMap
中的 Entry
,并添加两个属性 Entry before, after
和 header
结合起来组成一个双向链表,来实现按插入顺序或访问顺序排序。
LinkedHashMap
定义了排序模式 accessOrder
,该属性为 boolean
型变量,对于访问顺序,为 true
;对于插入顺序,则为 false
。一般情况下,不必指定排序模式,其迭代顺序即为默认为插入顺序。
20. Iterator 怎么使用?有什么特点?
迭代器是一种设计模式,它是一个对象,它可以遍历并选择序列中的对象,而开发人员不需要了解该序列的底层结构。迭代器通常被称为“轻量级”对象,因为创建它的代价小。Java 中的 Iterator
功能比较简单,并且只能单向移动:
- 使用方法
iterator()
要求容器返回一个Iterator
。第一次调用Iterator
的next()
方法时,它返回序列的第一个元素。注意:iterator()
方法是java.lang.Iterable
接口,被Collection
继承。 - 使用
next()
获得序列中的下一个元素。 - 使用
hasNext()
检查序列中是否还有元素。 - 使用
remove()
将迭代器新返回的元素删除。
21. Iterator 和 ListIterator 有什么区别?
Iterator
可用来遍历 Set
和 List
集合,但是 ListIterator
只能用来遍历 List
。Iterator
对集合只能是前向遍历,ListIterator
既可以前向也可以后向。ListIterator
实现了 Iterator
接口,并包含其他的功能,比如:增加元素,替换元素,获取前一个和后一个元素的索引等等。
22. Iterator 和 Enumeration 接口的区别?
与 Enumeration
相比,Iterator
更加安全,因为当一个集合正在被遍历的时候,它会阻止其它线程去修改集合。否则会抛出 ConcurrentModificationException
异常。这其实就是 fail-fast
机制。具体区别有三点:
Iterator
的方法名比Enumeration
更科学;Iterator
有fail-fast
机制,比Enumeration
更安全;Iterator
能够删除元素,Enumeration
并不能删除元素。
23. fail-fast 与 fail-safe 有什么区别?
Iterator
的 fail-fast
属性与当前的集合共同起作用,因此它不会受到集合中任何改动的影响。java.util
包中的所有集合类都被设计为 fail-fast
的,而 java.util.concurrent
中的集合类都为 fail-safe
的。当检测到正在遍历的集合的结构被改变时,fail-fast
迭代器抛出 ConcurrentModificationException
,而 fail-safe
迭代器从不抛出 ConcurrentModificationException
。
24. Collection 和 Collections 有什么区别?
Collection
:是最基本的集合接口,一个Collection
代表一组Object
,即Collection
的元素。它的直接继承接口有List
,Set
和Queue
。Collections
:不属于 Java 的集合框架的,它是集合类的一个工具类/帮助类。此类不能被实例化, 服务于 Java 的Collection
框架。它包含有关集合操作的静态多态方法,实现对各种集合的搜索、排序、线程安全等操作。
Java IO 流
Java IO (Input/Output) 流是用于处理计算机与外部设备之间数据传输的抽象概念。它将数据抽象为流 (Stream),通过流可以实现数据的输入和输出。
IO 流的分类
Java IO 流根据不同的标准有多种分类方式:
- 按数据类型分:
- 字节流:处理字节数据,所有文件类型(文本、图片、音视频等)都可以用字节流处理。
- 抽象基类:InputStream(输入流)、OutputStream(输出流)。
- 常用实现:FileInputStream/FileOutputStream(文件操作),BufferedInputStream/BufferedOutputStream(带缓冲),ObjectInputStream/ObjectOutputStream(对象序列化),ByteArrayInputStream/ByteArrayOutputStream(内存操作),DataInputStream/DataOutputStream(基本数据类型操作)。
- 字符流:处理字符数据,专门用于处理文本文件。
- 抽象基类:Reader(输入流)、Writer(输出流)。
- 常用实现:FileReader/FileWriter(文件操作),BufferedReader/BufferedWriter(带缓冲),InputStreamReader/OutputStreamWriter(字节流与字符流的转换)。
- 字节流:处理字节数据,所有文件类型(文本、图片、音视频等)都可以用字节流处理。
- 按流向分:
- 输入流:从数据源读取数据到程序中。
- 输出流:从程序中写入数据到目的地。
- 按功能分:
- 节点流(或源头流):直接与数据源(如文件、内存、网络连接)连接的流。例如FileInputStream、FileReader。
- 处理流(或包装流):对已存在的节点流进行包装,增加新的功能或提升性能。例如BufferedInputStream、BufferedReader。
常用 IO 操作
文件读写(字节流)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 写入文件
try (FileOutputStream fos = new FileOutputStream("output.txt")) {
fos.write("Hello, World!".getBytes());
} catch (IOException e) {
e.printStackTrace();
}
// 读取文件
try (FileInputStream fis = new FileInputStream("output.txt")) {
int data;
while ((data = fis.read()) != -1) {
System.out.print((char) data);
}
} catch (IOException e) {
e.printStackTrace();
}文件读写(字符流)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 写入文件
try (FileWriter fw = new FileWriter("output_char.txt")) {
fw.write("你好,世界!");
} catch (IOException e) {
e.printStackTrace();
}
// 读取文件
try (FileReader fr = new FileReader("output_char.txt")) {
int data;
while ((data = fr.read()) != -1) {
System.out.print((char) data);
}
} catch (IOException e) {
e.printStackTrace();
}缓冲流
1
2
3
4
5
6
7
8
9
10
11// 使用缓冲字节流复制文件
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("source.txt"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("destination.txt"))) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
e.printStackTrace();
}对象序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34// 定义一个可序列化的类
class User implements Serializable {
private static final long serialVersionUID = 1L; // 序列化版本UID
String name;
int age;
transient String password; // transient 关键字修饰的字段不参与序列化
public User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
public String toString() {
return "User(name=" + name + ", age=" + age + ", password=" + password + ")";
}
}
// 序列化
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
User user = new User("Alice", 30, "123456");
oos.writeObject(user);
} catch (IOException e) {
e.printStackTrace();
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.ser"))) {
User deserializedUser = (User) ois.readObject();
System.out.println(deserializedUser); // password 字段将为 null
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
NIO (New Input/Output)
Java NIO (New I/O) 是在 JDK 1.4 中引入的一套新的 I/O API,它提供了非阻塞 I/O 的能力,并引入了“通道 (Channel)”和“缓冲区 (Buffer)”的概念,与传统的基于流 (Stream) 的 I/O 相比,NIO 更加高效。
核心组件:
- Channel (通道): 类似于传统 IO 中的流,但可以双向读写。数据总是通过通道读入缓冲区或从缓冲区写入通道。
- 常用实现: FileChannel (文件), SocketChannel (TCP 客户端), ServerSocketChannel (TCP 服务器), DatagramChannel (UDP)。
- Buffer (缓冲区): 用于存储数据 (字节数组),与通道进行交互。所有数据读写都是通过缓冲区完成的。缓冲区有多种类型,如 ByteBuffer、CharBuffer、IntBuffer 等。
- 核心属性:
- capacity: 缓冲区可容纳的最大数据量。一旦创建,容量不可变。
- limit: 缓冲区中可读或可写的上限。
- position: 下一个读或写的位置。
- mark: 标记当前 position,可以通过 reset() 恢复到 mark 的位置。
- 主要方法:
- put(): 向缓冲区写入数据。
- get(): 从缓冲区读取数据。
- flip(): 将缓冲区从写模式切换到读模式。limit 会设置为当前的 position, position 会重置为 0。
- clear(): 清空缓冲区,为新的写入做准备。position 设为 0, limit 设为 capacity。
- compact(): 压缩缓冲区,将未读的数据移到缓冲区开头,position 设为未读数据数量,limit 设为 capacity。
- rewind(): 将 position 设为 0,可以重复读取缓冲区中的数据。
- 核心属性:
- Selector (选择器): 用于监听多个通道上的事件 (如连接就绪、读就绪、写就绪等)。一个单线程可以管理多个通道,从而实现非阻塞 I/O。
NIO 与传统 IO 的区别:
- I/O 模式:传统 IO 是阻塞式 I/O, NIO 是非阻塞式 I/O。
- 流与缓冲区:传统 IO 基于流(单向), NIO 基于通道和缓冲区(双向)。
- 同步与异步:传统 IO 是同步阻塞的, NIO 是同步非阻塞的(在多路复用模型下)。
NIO 文件复制示例
1 | try (FileInputStream fis = new FileInputStream("source.txt"); |
AIO (Asynchronous Input/Output)
Java AIO (Asynchronous I/O) 是在 JDK 7 中引入的,也称为 NIO 2.0。它提供了真正的异步非阻塞 I/O,通过回调机制来处理 I/O 操作的结果。与 NIO 的同步非阻塞不同,AIO 在 I/O 操作完成后会主动通知应用程序。
核心概念:
- AsynchronousFileChannel:异步文件通道。
- AsynchronousSocketChannel:异步 Socket 通道。
- AsynchronousServerSocketChannel:异步 Server Socket 通道。
- CompletionHandler:回调处理器接口,定义了 completed() (操作成功) 和 failed() (操作失败) 方法。
- Future:也可以通过返回 Future 对象来获取异步操作的结果。
**工作原理:**当发起一个 I/O 操作时,不再需要等待操作完成,而是立即返回。I/O 操作由操作系统在后台完成,完成后通过回调函数通知应用程序。
AIO 优势:
- 真正的异步非阻塞:应用程序不需要等待 I/O 操作,可以将 CPU 资源用于其他任务。
- 提高并发性:特别适合高并发、长连接的网络应用。
AIO 劣势:
- 复杂性:编程模型相对于 NIO 更复杂,需要处理回调逻辑。
- 适用场景:对于连接数较多且连接时间长的应用,如聊天服务器,AIO 表现优异。对于短连接、高并发的场景,NIO(基于 Selector)可能表现更好。
AIO 读文件示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24Path file = Paths.get("async_read.txt");
ByteBuffer buffer = ByteBuffer.allocate(1024);
try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(file, StandardOpenOption.READ)) {
fileChannel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
public void completed(Integer result, ByteBuffer attachment) {
System.out.println("Read " + result + " bytes.");
attachment.flip();
byte[] data = new byte[attachment.remaining()];
attachment.get(data);
System.out.println("Content: " + new String(data));
}
public void failed(Throwable exc, ByteBuffer attachment) {
System.err.println("Read failed: " + exc.getMessage());
}
});
// 为了让主线程不立即退出,等待异步操作完成
Thread.sleep(1000);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
1. Java IO 流的分类?请说出几个你熟悉的实现类。
Java IO 流根据不同的标准可以进行分类:
- 按数据流向:
- 输入流(InputStream / Reader):从外部源(如文件、网络)读取数据到程序中。
- 输出流(OutputStream / Writer):将数据从程序写入到外部目标。
- 按数据类型:
- 字节流(InputStream / OutputStream):以 8 位字节为单位处理数据,适用于所有类型的文件,包括图片、视频、音频等二进制文件。
- 实现类:
FileInputStream
,FileOutputStream
,BufferedInputStream
,BufferedOutputStream
。
- 实现类:
- 字符流(Reader / Writer):以 16 位字符为单位处理数据,适用于文本文件,能处理各种字符编码。
- 实现类:
FileReader
,FileWriter
,BufferedReader
,BufferedWriter
。
- 实现类:
- 字节流(InputStream / OutputStream):以 8 位字节为单位处理数据,适用于所有类型的文件,包括图片、视频、音频等二进制文件。
2. 字节流和字符流有什么区别?
字节流和字符流的主要区别在于处理数据的单位和适用场景:
- 数据单位:字节流以 8 位字节为单位进行读写,而字符流以 16 位字符为单位。
- 编码转换:字符流是字节流的封装,它在内部处理了字节和字符之间的编码转换。当处理文本文件时,字符流可以根据指定的字符集(如 UTF-8)自动进行编码和解码,避免了乱码问题。字节流不进行编码转换,直接处理原始字节。
- 最小存储单元:尽管字符流以字符为单位,但文件或网络传输的最小存储单元依然是字节。字符流在底层仍依赖于字节流来完成数据的传输。
- 适用场景:
- 字节流:适用于所有文件类型,尤其是处理非文本文件(如图片、视频、音频等),因为这些文件不需要进行字符编码转换。
- 字符流:专门用于处理文本文件,可以避免手动处理字符编码的复杂性。
3. BIO、NIO 和 AIO 有什么区别?
这是 Java IO 发展历程中的三种不同模型,主要区别在于处理 IO 操作的方式(阻塞/非阻塞、同步/异步)和并发能力。
- BIO (Blocking I/O)
- 特点:同步阻塞。当一个线程发起 IO 操作(如
read
或accept
),它会一直阻塞,直到数据读写完成或连接建立。 - 模型:采用“一对一”的连接模型,即一个客户端连接对应一个处理线程。
- 优点:编程模型简单直观,适用于连接数较少、并发量不高的场景。
- 缺点:在高并发场景下,每个连接都需要一个线程,会消耗大量系统资源,导致性能瓶颈。
- 特点:同步阻塞。当一个线程发起 IO 操作(如
- NIO (Non-blocking I/O)
- 特点:同步非阻塞。一个线程可以同时处理多个连接。当线程发起 IO 操作时,如果数据没有准备好,它不会阻塞,而是立即返回,去做其他事情,稍后再次检查。
- 模型:采用多路复用模型。通过一个
Selector
(选择器),一个或少数几个线程可以监听多个Channel
(通道)上的 IO 事件(如连接就绪、数据可读),从而高效地处理大量并发连接。 - 优点:解决了 BIO 的高并发问题,大大提高了系统的吞吐量,适用于高并发的网络应用。
- 缺点:编程模型相对复杂。尽管 IO 操作是非阻塞的,但数据读写本身仍然是同步的,即线程在进行读写时仍需等待。
- AIO (Asynchronous I/O)
- 特点:异步非阻塞。应用程序发起 IO 操作后,会立即返回,而不会等待数据读写。操作系统会在后台完成 IO 操作,并在完成后通过回调或事件通知应用程序。
- 模型:基于事件和回调机制。
- 优点:进一步提高了并发性能,彻底释放了线程资源,适用于连接数巨大且连接时间长的应用。
- 缺点:目前在实际应用中,特别是在网络编程方面,使用不如 NIO 广泛,因为其复杂性和对操作系统的依赖性较高。
Java 反射 (Reflection)
Java 反射机制是指在程序运行时,能够动态地获取类的信息(包括类的属性、方法、构造器等),并能够动态地操作类或对象(如创建对象、调用方法、修改属性)。
核心类与接口:
- Class 类:代表类的字节码文件,是反射的入口。
- Constructor 类:代表类的构造器。
- Method 类:代表类的方法。
- Field 类:代表类的成员变量(属性)。
- AccessibleObject:Field, Method, Constructor 的共同父类,提供了 setAccessible(true) 方法,用于抑制 Java 语言访问检查,从而访问私有成员。
获取 Class 对象的三种方式:
Class.forName(“全限定类名”):最常用,动态加载类。
1
Class<?> clazz = Class.forName("java.lang.String");
类名.class:已知具体类名时使用,编译时加载。
1
Class<?> clazz = String.class;
对象.getClass():通过对象实例获取,运行时获取。
1
2String s = "hello";
Class<?> clazz = s.getClass();
反射的应用:
动态创建对象:
1
2
3
4
5Class<?> personClass = Class.forName("com.example.Person");
Object person = personClass.newInstance(); // 调用无参构造器
// 或者调用指定构造器
Constructor<?> constructor = personClass.getConstructor(String.class, int.class);
Object person2 = constructor.newInstance("Alice", 25);动态调用方法:
1
2
3
4
5
6Class<?> personClass = Class.forName("com.example.Person");
Object person = personClass.newInstance();
Method setNameMethod = personClass.getMethod("setName", String.class);
setNameMethod.invoke(person, "Bob"); // 调用 setName 方法
Method getNameMethod = personClass.getMethod("getName");
String name = (String) getNameMethod.invoke(person); // 调用 getName 方法动态操作属性:
1
2
3
4
5
6Class<?> personClass = Class.forName("com.example.Person");
Object person = personClass.newInstance();
Field nameField = personClass.getDeclaredField("name"); // 获取私有属性
nameField.setAccessible(true); // 允许访问私有属性
nameField.set(person, "Charlie"); // 设置属性值
String name = (String) nameField.get(person); // 获取属性值
反射的优缺点:
- 优点:
- 灵活性和动态性:在运行时动态获取类信息和操作对象,大大增强了程序的灵活性,是许多框架(如Spring、ORM框架)和工具(如JSON解析库)的基础。
- 解耦:允许代码在编译时不知道具体的类,只在运行时加载和使用,实现高度解耦。
- 缺点:
- 性能开销:反射操作比直接调用有更高的性能开销,因为涉及到动态解析和查找。
- 安全性问题:setAccessible(true)可以绕过Java的访问控制,可能破坏封装性。
- 可维护性差:反射代码通常比直接调用更复杂,更难调试和维护。
- 编译时检查缺失:反射操作在编译时无法检查类型错误,只能在运行时发现。
- 优点:
JVM内存结构与垃圾收集器
JVM内存结构
JVM 简单介绍
Java 虚拟机(JVM)是 Java 语言跨平台能力的核心。它是一个抽象的计算机,负责将 Java 字节码 (.class
文件) 转换成特定平台的机器指令。下面通过表格的形式,简单介绍 JVM 的主要组成部分、功能和工作流程。
模块名称 | 核心功能 | 主要组成部分 | 简要说明 |
---|---|---|---|
类加载子系统 | 加载、链接、初始化 .class 文件 |
类加载器 (ClassLoader) | 负责从文件系统、网络等来源加载 .class 文件到内存,并进行验证、准备和解析。 |
运行时数据区 | 存放程序运行时的各种数据 | 方法区 堆 虚拟机栈 本地方法栈 程序计数器 |
线程共享: - 方法区: 存储已加载的类信息、常量、静态变量等。 - 堆: 存储所有对象实例和数组。 线程私有: - 虚拟机栈: 存储方法调用的局部变量、操作数栈等。 - 本地方法栈: 存储 Native 方法的调用信息。 - 程序计数器: 记录当前线程正在执行的字节码指令地址。 |
执行引擎 | 执行字节码指令 | 解释器 即时编译器 (JIT) 垃圾收集器 (GC) |
负责将字节码翻译成机器指令并执行。 - 解释器: 逐行解释执行字节码。 - JIT: 将热点代码编译成高效的本地机器码。 - GC: 自动回收堆中不再使用的对象内存。 |
本地方法接口 | 调用 Native 方法 | JNI (Java Native Interface) | 允许 Java 代码与其他语言(如 C/C++)编写的代码进行交互。 |
本地方法库 | 支持 Native 方法 | 一系列本地方法库 | 提供了执行引擎调用本地方法所需的库文件。 |
JVM(Java Virtual Machine)在执行Java程序时,会将内存划分为几个不同的区域,这些区域有各自的用途和生命周期。
程序计数器 (Program Counter Register)
- 功能:一块较小的内存空间,用于存储当前线程所执行的字节码的行号指示器。
- 特点:
- 每个线程私有,生命周期与线程一致。
- JVM规范中唯一没有规定任何OutOfMemoryError情况的区域。
- 在多线程切换时,程序计数器记录了当前线程的执行位置,使得线程切换回来后能够知道从哪里继续执行。
Java虚拟机栈 (Java Virtual Machine Stacks)
- 功能:每个线程私有的内存区域,用于存储栈帧(Stack Frame)。每个方法被执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 特点:
- 生命周期与线程一致。
- 局部变量表:存储方法参数和方法内部定义的局部变量。
- 操作数栈:用于存放方法执行时的操作数和中间结果。
- 动态链接:指向运行时常量池中该栈帧所属方法的引用。
- 方法出口:记录方法执行完后回到哪里。
- 可能抛出StackOverflowError(栈深度超过虚拟机允许的深度)或OutOfMemoryError(栈扩展时无法申请到足够的内存)。
本地方法栈 (Native Method Stacks)
- 功能:与虚拟机栈类似,但是为JVM执行Native方法(即用C/C++等语言实现的方法)服务。
- 特点:
- 每个线程私有。
- 可能抛出 StackOverflowError 或 OutOfMemoryError。
Java 堆 (Java Heap)
功能:JVM 管理的最大一块内存区域,被所有线程共享,用于存放对象实例和数组。
特点:
是垃圾收集器管理的主要区域 (GC 堆)。
是 Java 应用程序对象存放的“老家”。
根据垃圾回收的特性,可以分为新生代 (Young Generation) 和老年代 (Old Generation)。
新生代:通常分为 Eden 空间和两个 Survivor 空间 (From 和 To)。新创建的对象优先在 Eden 区分配,经过 Minor GC 后存活的对象进入 Survivor 区,多次 GC 后仍存活的对象进入老年代。
老年代:存放生命周期较长的对象。
在 JVM 内存模型中,新生代被划分为三个区域,默认的比例通常是 8:1:1。
- Eden 区:占比 80%。这是新创建对象的主要分配区域。
- Survivor S0 区:占比 10%。
- Survivor S1 区:占比 10%。
为什么要这样划分?
这种划分是为了配合 Minor GC 的垃圾回收流程,从而提高垃圾回收的效率。
新生代的垃圾回收流程
- 对象创建:新创建的对象首先在 Eden 区 分配。
- Minor GC:当 Eden 区 满了之后,会触发一次 Minor GC(也叫 Young GC)。
- 存活对象转移:
- 在 Eden 区和其中一个 Survivor 区(比如 S0)中,所有存活的对象会被复制到另一个空的 Survivor 区(比如 S1)。
- 同时,对象的年龄(age)会加一。
- 清空 Eden 和 S0:垃圾回收后,Eden 区和 S0 区都会被清空。
- 角色互换:下一次 Minor GC 时,Eden 区和 S1 区中存活的对象会被复制到 S0 区。S0 和 S1 两个 Survivor 区会不断地进行角色互换。
- 晋升老年代:
- 当对象的年龄达到一个设定的阈值(默认为 15),或者 Survivor 区中同一年龄段的对象大小超过了一定比例,这些对象就会被移动到老年代。
- 这种设计也被称为复制算法(Copying Algorithm),它在新生代存活对象较少的情况下,效率非常高。
为什么是 8:1:1?
这个比例是一个经验值,基于大多数 Java 应用的特点:
- 大多数对象都是朝生夕灭的。因此,将 Eden 区设置得更大,可以容纳更多的新对象,减少 Minor GC 的频率。
- 两个 Survivor 区只需要用来暂存存活的对象,因此不需要太大。10% 的空间通常足以容纳一次 Minor GC 后存活的对象。
当然,这个比例不是固定的。在某些特殊情况下,如果新生代存活对象较多,导致 Survivor 区无法容纳所有存活对象,JVM 会发生空间分配担保,将这些对象直接晋升到老年代。
你可以通过 JVM 参数来调整这个比例,例如:
java -Xmn100m -XX:SurvivorRatio=8
这个命令设置新生代总大小为 100MB,Eden 区和 Survivor 区的比例为 8:1:1。
可能抛出 OutOfMemoryError。
方法区 (Method Area)
- 功能:被所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 特点:
- 在 JDK 1.7 及之前,方法区被称为“永久代 (Permanent Generation)”,它属于堆内存的一部分。
- 在 JDK 1.8 及之后,永久代被移除,方法区的实现改为“元空间 (Metaspace)”,并且元空间不使用 JVM 内存,而是直接使用本地内存 (Native Memory)。
- 可能抛出 OutOfMemoryError。
运行时常量池 (Runtime Constant Pool)
- 功能:方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
- 特点:
- 动态性:Java 语言并不要求常量池在编译期就全部确定,运行时也可以将新的常量放入池中 (如 String.intern())。
- 可能抛出 OutOfMemoryError。
1. 程序计数器
程序计数器(Program Counter Register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。程序的分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的命令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,我们程这块内存区域为“线程私有”的内存。
此区域是唯一一个虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
2. Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks):描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个帧栈(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。它的线程也是私有的,生命周期与线程相同。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)。
Java 虚拟机栈的局部变量表的空间单位是槽(Slot),其中 64 位长度的 double 和 long 类型会占用两个 Slot。局部变量表所需内存空间在编译期完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java虚拟机栈有两种异常状况:如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出 StackOverflowError
异常;如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError
异常。
3. 本地方法栈
本地方法栈(Native Method Stack):与虚拟机栈所发的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
Java 虚拟机规范没有对本地方法栈中方法使用的语言、使用的方式和数据结构做出强制规定,因此具体的虚拟机可以自由地实现它。比如:Sun HotSpot 虚拟机直接把Java虚拟机栈和本地方法栈合二为一。
与Java虚拟机栈一样,本地方法栈也会抛出StackOverflowError
和 OutOfMemoryError
异常。
4. Java 堆
Java堆(Java Heap):是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是:存放对象实例,几乎所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC”堆(Garbage Collected Heap)。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代。从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
Java 虚拟机规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,可以是固定大小的,也可以是可扩展的。如果在堆中没有完成实例分配。并且堆也无法扩展时,将会抛出 OutOfMemoryError
异常。
5. 方法区
方法区(Method Area):与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),其目的应该就是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据Java虚拟机规范规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError
异常。
运行时常量池:运行时常量池(Runtime Constant Pool):是方法区的一部分。Class
文件中除了有类的版本、字段、方法、接口等描述信息外,还有一些信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行。
直接内存:直接内存(Direct Memory):并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也频繁地使用,而且也可能导致 OutOfMemoryError
异常。
本地直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。如果各个内存区域总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError
异常。
3. 堆和栈的区别是什么?
堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的。二者之间最大的区别就是存储的内容不同:堆中主要存放对象实例。栈(局部变量表)中主要存放各种基本数据类型、对象的引用。
从作用来说,栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。在 Java 中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。
4. 堆中存什么?栈中存什么?
堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个 4btye 的引用(堆栈分离的好处)。
为什么不把基本类型放堆中呢?
因为基本数据类型占用的空间一般是1~8个字节,需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况,长度固定,因此栈中存储就够了。如果把它存在堆中是没有什么意义的。基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,它们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java 中参数传递时的问题。
5. 为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
- 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
- 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
- 栈因为运行时的需要,比如:保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
6. Java 中的参数传递时传值呢?还是传引用?
要说明这个问题,先要明确两点:
- 不要试图与 C 进行类比,Java 中没有指针的概念。
- 程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。
Java 在方法调用传递参数时,因为没有指针,所以它都是进行传值调用。但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的,都是传值。所以,如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。
对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。
7. Java 对象的大小是怎么计算的?
基本数据类型的大小是固定的。对于非基本类型的 Java 对象,其大小就值得商榷。在 Java 中,一个空 Object
对象的大小是 8 byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句:
Object ob = new Object(); 这样在程序中完成了一个 Java 对象的生命,但是它所占的空间为:
4 byte + 8 byte。
4 byte是上面部分所说的 Java 栈中保存引用的所需要的空间。而那
8 byte则是 Java 堆中对象的信息。因为所有的 Java 非基本类型的对象都需要默认继承
Object对象,因此不论什么样的 Java 对象,其大小都必须是大于
8 byte。有了
Object` 对象的大小,我们就可以计算其他对象的大小了。
Java
1 | Class MaNong { |
MaNong
的大小为:空对象大小(8 byte) + int
大小(4 byte) + Boolean
大小(1 byte) + 空 Object
引用的大小(4 byte)
= 17byte。但是因为 Java 在对对象内存分配时都是以 8 的整数倍来分,因此大于 17 byte 的最接近 8 的整数倍的是 24,因此此对象的大小为 24 byte。
这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把它们作为对象来看待。包装类型的大小至少是12 byte(声明一个空 Object
至少需要的空间),而且 12 byte 没有包含任何有效信息,同时,因为 Java 对象大小是 8 的整数倍,因此一个基本类型包装类的大小至少是 16 byte。这个内存占用是很恐怖的,它是使用基本类型的 N 倍(N > 2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在 JDK5 以后,因为加入了自动类型装换,因此,Java 虚拟机会在存储方面进行相应的优化。
8. 对象的访问定位的两种方式?
Java 程序通过栈上的引用数据来操作堆上的具体对象。目前主流的对象访问方式有:句柄 和 直接指针。
使用句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。
各自的优点
使用句柄来访问的最大好处是引用中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而引用本身不需要修改;
使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
JVM 调优参数
- 堆内存大小设置:
- -Xms
:设置 JVM 堆的初始内存大小。 - -Xmx
:设置 JVM 堆的最大内存大小。 - 最佳实践:通常建议 -Xms 和 -Xmx 设置为相同值,以避免 JVM 在运行时动态调整堆大小带来的额外开销和 GC 停顿。例如 -Xms4g -Xmx4g。
- -Xms
- 新生代大小设置:
- -Xmn
:设置新生代内存大小。 - -XX:NewRatio=
:设置老年代与新生代的比例,例如 -XX:NewRatio=2 表示老年代:新生代 = 2:1。 - 考量:
- 新生代过小:频繁 Minor GC, 导致对象过早进入老年代。
- 新生代过大:Minor GC 间隔长, 但每次 GC 耗时可能长。
- -Xmn
- 元空间大小设置 (JDK 1.8+):
- -XX:MetaspaceSize=
:设置元空间的初始大小。 - -XX:MaxMetaspaceSize=
:设置元空间的最大大小。 - 考量:如果应用加载大量类或使用动态代码生成, 可能需要调大。
- -XX:MetaspaceSize=
- 选择垃圾收集器:
- -XX:+UseG1GC:启用 G1 垃圾收集器。这是 JDK 9+ 的默认收集器。
- -XX:MaxGCPauseMillis=
:设置 G1 收集器可接受的最大停顿时间(G1 会尽量接近这个目标, 但不保证完全达到)。例如 -XX:MaxGCPauseMillis=200。
10. 垃圾回收是从哪里开始的呢?
垃圾回收从查找哪些对象是正在被当前系统使用的开始。上面分析的堆和栈的区别,其中栈是真正进行程序执行的地方,所以要获取哪些对象正在被使用,则需要从 Java 栈开始。同时,一个栈是与一个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。
同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以 null
引用或者基本类型结束,这样就形成了一棵以 Java 栈中引用所对应的对象为根节点的一棵对象树。如果栈中有多个引用,则最终会形成多棵对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。
11. 被标记为垃圾的对象一定会被回收吗?
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
- 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
- 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行
finalize()
方法。在finalize()
方法中没有重新与引用链建立关联关系的,将被进行第二次标记。第二次标记成功的对象将真的会被回收,如果对象在finalize()
方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。
12. 谈谈对 Java 中引用的了解?
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用 4 种,这四种引用强度依次逐渐减弱。
强引用
在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用
用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内
弱引用
也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用
也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。
13. 谈谈对内存泄漏的理解?
内存泄露的基本概念
在 Java 中,内存泄漏就是存在一些不会再被使用却没有被回收的对象,这些对象有下面两个特点:
这些对象是可达的,即在有向图中,存在通路可以与其相连;
这些对象是无用的,即程序以后不会再使用这些对象。
如果对象满足这两个条件,这些对象就可以判定为 Java 中的内存泄漏,这些对象不会被 GC 所回收,然而它却占用内存。
14. 内存泄露的根本原因是什么?
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。
15. 举几个可能发生内存泄漏的情况?
- 静态集合类引起的内存泄漏;
- 当集合里面的对象属性被修改后,再调用
remove()
方法时不起作用; - 监听器:释放对象的时候没有删除监听器;
- 各种连接:比如数据库连接(
dataSourse.getConnection()
),网络连接(socket
) 和 IO 连接,除非其显式的调用了其close()
方法将其连接关闭,否则是不会自动被 GC 回收的; - 内部类:内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放;
- 单例模式:单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏。
16. 尽量避免内存泄漏的方法?
- 尽量不要使用
static
成员变量,减少生命周期; - 及时关闭资源;
- 不用的对象,可以手动设置为
null
。
垃圾收集器 (Garbage Collector)
垃圾收集器是 JVM 的一个重要组成部分, 负责自动管理 Java 堆内存中的对象的生命周期, 回收不再使用的对象所占用的内存。
垃圾判断算法:
- 引用计数算法:当一个对象被引用一次, 计数器加1;引用失效, 计数器减1。当计数器为0时, 对象被判定为可回收。
- 缺点:难以解决对象之间的循环引用问题。Java 虚拟机不采用此算法。
- 可达性分析算法 (Root Tracing):通过一系列称为 “GC Roots” 的对象作为起始点, 从这些节点向下搜索, 搜索所走过的路径称为引用链 (Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时, 则证明此对象是不可用的。
- 可作为 GC Roots 的对象:
- 虚拟机栈 (栈帧中的局部变量表) 中引用的对象。
- 本地方法栈 (Native 方法) 中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 被同步锁持有的对象。
- JVM 内部的引用 (如基本数据类型对应的 Class 对象)。
- 可作为 GC Roots 的对象:
常见垃圾收集器:
Serial 收集器:
- 特点:单线程, 工作时需要停止所有用户线程 (“Stop The World”, STW)。简单高效, 适用于单核 CPU 或内存较小的客户端应用。
- 新生代使用:复制算法。
- 老年代使用:标记-整理算法。
ParNew 收集器:
- 特点:Serial 收集器的多线程版本, 用于新生代。并行收集时也需要 STW。
- 新生代使用:复制算法。
- 常与 CMS 收集器配合使用。
Parallel Scavenge 收集器:
- 特点:关注吞吐量(Throughput = 用户代码执行时间 / (用户代码执行时间 + GC 时间)),可以设置最大吞吐量或最大 GC 停顿时间。
- 新生代使用:复制算法。
- 老年代使用:与 Parallel Old 配合使用,使用标记-整理算法。
CMS (Concurrent Mark Sweep) 收集器:
- 特点:以获取最短回收停顿时间为目标,并发收集(与用户线程一起执行)。适用于对响应时间要求高的应用(如 Web 服务器)。
- 工作步骤:
- 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联到的对象,速度快。
- 并发标记 (Concurrent Mark):与用户线程并发执行,进行 GC Roots Tracing 过程,耗时最长。
- 重新标记 (Remark):STW,修正并发标记期间因用户程序继续运行而导致标记产生变动的对象,比初始标记耗时长,但远比并发标记短。
- 并发清除 (Concurrent Sweep):与用户线程并发执行,清除已标记为垃圾的对象。
- 缺点:
- 对 CPU 资源敏感:并发阶段会占用一部分 CPU。
- 无法处理浮动垃圾:并发清除阶段产生的垃圾(新生成的对象)只能下次 GC 再处理。
- 可能产生大量空间碎片:采用“标记-清除”算法,不进行整理,可能导致大对象无法分配空间而提前触发 Full GC。
G1 (Garbage-First) 收集器:
- 特点:JDK 9+ 的默认垃圾收集器。面向服务端应用,分区(将 Java 堆划分为多个独立区域 Region),可预测的停顿时间模型。
- 工作原理:
- 将堆内存划分为多个大小相等的 Region。
- G1 跟踪每个 Region 的垃圾回收价值 (Garbage-First),优先回收垃圾最多的 Region。
- 年轻化和老年代不再是物理隔离,而是逻辑上的概念,Region 可以动态地成为 Eden、Survivor 或 Old 区域。
- 并发与并行兼容:并发标记,但回收阶段并行。
- 基本无碎片:采用复制和标记-整理算法结合。
- 工作步骤:
- 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联的对象。
- 并发标记 (Concurrent Mark):与用户线程并发,遍历对象图。
- 最终标记 (Final Mark):STW,处理并发标记阶段结束后仍然存活的对象。
- 筛选回收 (Evacuation):STW,对各个 Region 的回收价值进行排序,根据预期停顿时间来回收 Region,采用复制算法将存活对象复制到新的 Region。
- 优势:在保持高吞吐量的同时,降低了 GC 停顿时间,适合大内存、多核处理器场景。
zGC (Z Garbage Collector) 和 Shenandoah 收集器:
- 特点:低延迟、并发 GC 收集器,旨在实现毫秒级的 GC 停顿。
- ZGC: JDK 11 引入,支持 TB 级别的堆内存,停顿时间与堆大小无关。
- Shenandoah: JDK 12 引入,与 G1 类似,但能进一步降低停顿时间。
- 应用场景:对延迟要求极高的应用
类加载机制 (Class Loading Mechanism)
作用: 将 .class 文件中的字节码加载到 JVM 内存中,并转换为运行时数据结构。
生命周期: 加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载。
主要阶段:
- 加载 (Loading):
- 通过类的全限定名获取该类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的 java.lang.Class 对象。
- 验证 (Verification): 确保 Class 文件的字节流符合 JVM 规范,没有安全问题。
- 准备 (Preparation): 为类的静态变量(static fields)分配内存并初始化为默认值(如 int 变量为 0,引用类型为 null)。
- 解析 (Resolution): 将常量池中的符号引用替换为直接引用。
- 初始化 (Initialization): 执行类的构造器
<clinit>()
方法,真正开始执行类中定义的 Java 程序代码(为静态变量赋予初始值,执行静态代码块)。
- 加载 (Loading):
类加载是 Java 虚拟机(JVM)将
Class
文件中的二进制数据读取到内存中,并将其转换为可用的java.lang.Class
对象的过程。这个过程是所有 Java 程序运行的基础。类加载的整个生命周期包括以下 7 个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
其中,前五个阶段是类加载的核心过程,后面两个阶段是类生命周期的延伸。下面我们详细解析前五个核心阶段。
1. 加载(Loading)
这是类加载过程的第一个阶段,主要任务是查找并导入
Class
文件中的二进制数据。这个阶段主要完成三件事:- 通过类的全限定名获取定义此类的二进制字节流:JVM 可以从文件系统、网络、压缩包(JAR/WAR/EAR)甚至数据库中获取字节流。
- 将字节流中的静态存储结构转换为方法区的运行时数据结构:JVM 会将二进制数据按照虚拟机规范的要求,转化为方法区中可用的数据结构。
- 在内存中生成一个代表该类的 java.lang.Class 对象:这个对象是访问方法区中该类各种数据(如方法、字段)的入口,也是反射机制的基石。
2. 验证(Verification)
验证阶段的目的是确保
Class
文件的字节流符合 JVM 规范,并且不会危害到虚拟机的安全。这个阶段非常重要,因为Class
文件不一定是由 Java 源码编译而来,它可以是任何来源。验证通常包括以下几个检查:- 文件格式验证:检查字节流是否符合
Class
文件格式的规范,例如魔数(0xCAFEBABE
)、版本号等。 - 元数据验证:对类的元数据进行语义分析,确保其符合 Java 语言规范,例如是否继承了不允许继承的
final
类、字段和方法签名是否合理等。 - 字节码验证:这是验证阶段最复杂的一步,通过数据流和控制流分析,确保程序语义是合法的、符合逻辑的。例如,保证类型转换是有效的、方法调用的参数是正确的。
- 符号引用验证:在解析阶段发生,确保解析出来的类、字段、方法是真实存在的,并且具有足够的访问权限。
3. 准备(Preparation)
准备阶段是为类的静态变量分配内存并设置初始值的阶段。
- 只为静态变量分配内存:在这个阶段,JVM 只为
static
变量(类变量)分配内存,而不包括实例变量。实例变量会在对象实例化时随着对象一起分配在堆中。 - 设置初始值:这里的初始值通常是该数据类型的零值(例如
int
的初始值为0
,boolean
为false
,String
为null
)。
注意:如果静态变量被
final
修饰,并且是基本数据类型或字符串字面量,那么在准备阶段就会直接赋值为它在代码中定义的值。例如:
1
2
3
4
5// 准备阶段:value 的初始值为 0
public static int value = 123;
// 准备阶段:finalValue 的初始值直接为 123
public static final int finalValue = 123;
4. 解析(Resolution)
解析阶段是将常量池中的符号引用转换为直接引用的过程。
- 符号引用:一组符号来描述所引用的目标,可以是任何形式的字面量,只要能定位到目标即可。它不依赖于内存布局。
- 直接引用:直接指向目标的指针、相对偏移量或是一个可以间接定位到目标的句柄。它与内存布局相关。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行。这个阶段是动态的,通常会在程序运行时,当符号引用被首次使用时才进行。
5. 初始化(Initialization)
初始化是类加载过程的最后一步,在这个阶段,真正执行类中定义的 Java 代码。
- 执行
() 方法 :初始化阶段会执行类构造器<clinit>()
方法,这个方法由编译器自动生成,它用于收集类中所有静态变量的赋值动作和静态代码块(static {}
)中的语句。 - 线程安全:JVM 会确保
<clinit>()
方法在多线程环境下被正确地加锁和同步,保证一个类的<clinit>()
方法只会被执行一次。 - 触发时机:只有在以下几种情况中,一个类才会被“主动”初始化:
- 创建类的实例(
new
)。 - 访问类的静态变量(除了在编译时被
final
化的常量)。 - 调用类的静态方法。
- 使用
java.lang.reflect
包的方法进行反射调用。 - 初始化一个子类时,其父类尚未初始化。
- 虚拟机启动时,被指定为启动类的类(如包含
main()
方法的类)。
- 创建类的实例(
理解这五个阶段对于深入掌握 JVM 的工作原理至关重要。
类加载器 (Class Loaders):
类加载器的分类:
(1)Bootstrap class loader (使用C++编写的)
简称:启动类加载器
加载路径:JAVA_HOME/jre/lib
显示形式:null
(2)Platform class loader(由Java编写的)
简称:扩展类加载器
加载路径:JAVA_HOME/jre/lib/ext
显示形式:ExtClassLoader
(3)System class loader(由Java编写的)
简称:应用程序类加载器
加载路径:类路径(src目录)
显示形式:AppClassLoader //sun.misc.Launcher$AppClassLoader@18b4aac2
(4)自定义类加载器(由Java编写的)
简称:自定义类加载器
加载路径:自定义
1. 启动类加载器 (Bootstrap ClassLoader)
- 作用:它负责加载 Java 核心库,比如
rt.jar
(包含java.lang.*
,java.util.*
等核心类)。 - 实现:它不是用 Java 写的,而是由 C++ 实现的,是 JVM 自身的一部分。因此,你无法在 Java 代码中直接获取到它的对象,调用
getClassLoader()
得到的会是null
。 - 父加载器:它没有父加载器。它是类加载器层次结构的顶端。
2. 扩展类加载器 (Extension ClassLoader)
- 作用:它负责加载 JVM 扩展目录中的所有 jar 包,通常是
JRE/lib/ext
目录下的库。 - 实现:它是由 Java 语言实现的。
- 父加载器:它的父加载器是启动类加载器。
3. 应用程序类加载器 (Application ClassLoader)
- 作用:它负责加载我们自己编写的 Java 程序中的类,也就是你项目中
classpath
路径下的所有类。 - 实现:它也是由 Java 语言实现的。
- 父加载器:它的父加载器是扩展类加载器。
自定义类加载器
除了上面三个,你也可以根据自己的需求创建自定义类加载器。
- 作用:当你需要加载一些特定来源的类时(比如从网络下载的类、对字节码进行加密或解密),自定义类加载器就很有用。例如,Web 服务器(如 Tomcat)就是通过自定义类加载器来隔离不同 Web 应用的类。
- 如何实现:通常,你需要继承
java.lang.ClassLoader
类,并重写findClass()
方法。在findClass()
方法中,你需要自己定义如何获取类的字节码(比如从文件系统、网络或数据库),然后调用defineClass()
方法将字节码转换为Class
对象。
- 作用:它负责加载 Java 核心库,比如
33. 有哪些类加载器?分别有什么作用?
- 启动类加载器 (Bootstrap ClassLoader):这个类加载器是由 C++ 语言实现的,是虚拟机自身的一部分。负责将存在
\lib
目录中的,或者被-Xbootclasspath
参数所指定的路径中的类库加载到虚拟机内存中。启动内加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用null
即可; - 其他类加载器:由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
。如扩展类加载器和应用程序类加载器:- 扩展类加载器 (Extension ClassLoader):这个类加载器由
sun.misc.Launcher$ExtClassLoader
实现,它负责加载\lib\ext
目录中的,或者被java.ext.dirs
系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 - 应用程序类加载器 (Application ClassLoader):这个类加载器由
sun.misc.Launcher$AppClassLoder
实现。由于个类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,所以一般也称之为系统类加载器。它负责加载用户路径(ClassPath)所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
- 扩展类加载器 (Extension ClassLoader):这个类加载器由
双亲委派模型 (Parents Delegation Model):
- 原理: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
- 优点: 避免类的重复加载,保证 Java 核心库的类型安全。
面试题:
为什么需要四个类加载器,而不是一个?
这是一个很好的问题。为什么 Java 要设计多个类加载器,而不是用一个大一统的加载器呢?这背后主要有三个核心原因:隔离性、安全性和可扩展性。
1. 隔离性(Isolation)
多类加载器的最大优势就是实现了类隔离。
想象一下,你有一个 Web 服务器,上面部署了两个不同的 Web 应用(比如一个博客系统和一个论坛)。这两个应用可能依赖同一个第三方库的不同版本(例如,博客用的是 Spring 框架的 5.x 版本,而论坛用的是 4.x 版本)。
如果只有一个类加载器,它会把所有 classpath
上的类都加载到同一个内存空间。这样一来,两个不同版本的 Spring 框架就会产生冲突,JVM 根本无法区分它们,程序就会报错。
而有了自定义类加载器,服务器就可以为每个 Web 应用创建一个独立的类加载器。每个加载器负责加载自己应用目录下的类,这样两个应用即使使用了同一个库的不同版本,也能在内存中和谐共存,互不干扰。
2. 安全性(Security)
Java 的核心库(如 java.lang
, java.util
等)对 JVM 的稳定和安全至关重要。
双亲委派模型正是为了保护这些核心库不被恶意代码或普通用户代码所篡改。
例如,如果你尝试自己写一个 java.lang.String
类并把它放在应用的 classpath
下,双亲委派机制会确保这个类不会被加载。因为当加载请求到达应用程序类加载器时,它会先委派给它的父加载器——扩展类加载器,然后再委派给最顶层的启动类加载器。启动类加载器会优先加载 JDK 自带的 java.lang.String
,从而你的恶意代码就永远没有机会被执行。
3. 可扩展性(Extensibility)
Java 是一种非常灵活的语言,需要适应各种复杂的运行时环境。
如果只有一个类加载器,所有的类都必须来自文件系统。但在现实世界中,类可能来自各种地方:
- 从网络上下载的字节码。
- 通过数据库存储和加载。
- 对字节码进行加密和解密。
自定义类加载器让 Java 能够动态地加载和处理非标准来源的类。例如,热部署技术(在不重启应用的情况下更新代码)就是通过自定义类加载器实现的。它会创建一个新的加载器来加载新版本的类,而旧的加载器和它的类则可以被垃圾回收。
总结
所以,一个大一统的类加载器虽然简单,但它无法解决复杂的类版本冲突、核心库安全和动态加载等问题。通过分层设计和双亲委派模型,Java 的类加载器体系变得既安全又灵活,能够满足从简单的桌面应用到复杂的企业级服务器等各种场景的需求。
常见的 OOM (OutOfMemoryError) 和 StackOverflowError
- Java heap space: 堆内存不足,最常见。通常通过调整 JVM 启动参数
-Xms
(初始堆大小) 和-Xmx
(最大堆大小) 来解决。 - StackOverflowError: 虚拟机栈溢出。通常是递归调用过深导致栈帧不断入栈。
- PermGen space / Metaspace: 方法区溢出。通常是加载的类过多,或者存在大量字符串常量。通过
-XX:MaxPermSize
(JDK 1.7) 或-XX:MaxMetaspaceSize
(JDK 1.8) 调整。 - Direct buffer memory: 直接内存溢出。通常是使用了 NIO 或 Netty 等直接操作堆外内存的库。
- unable to create new native thread: 无法创建新线程。通常是系统线程数达到上限或内存不足以分配新线程的栈空间。
OOM常见场景:
1. java.lang.OutOfMemoryError: Java heap space
这是最常见、也最广为人知的内存溢出错误。它表示 Java 堆(Heap) 中没有足够的空间来分配新的对象。
常见场景:
- 内存泄漏(Memory Leak):这是最主要的原因。你的程序中创建了对象,但本应被垃圾回收器(GC)回收的对象却因为某些原因(比如被一个长生命周期的对象引用着)而无法被回收。例如:
- 一个静态的
Map
或List
集合,不断地往里面添加对象,但从不删除。 - 监听器或回调函数没有正确移除,导致被监听的对象无法被回收。
- 数据库连接或文件流没有正确关闭,长时间占用资源。
- 一个静态的
- 内存使用不当:一次性加载大量数据到内存中。例如,从数据库查询数百万条记录,并把它们全部加载到一个
List
中;或者处理一个超大的图片或文件,导致瞬间占用大量内存。 - 配置问题:JVM 的堆内存设置得太小,无法满足程序的正常运行需求。这在部署应用时很常见,可以通过调整
-Xmx
参数来解决。
2. java.lang.StackOverflowError
这个错误表示 虚拟机栈(Stack) 溢出。每个线程都有一个独立的栈,用于存储方法调用的栈帧。当栈的深度超过了 JVM 允许的最大深度时,就会抛出此错误。
常见场景:
无限递归(Infinite Recursion):这是最典型的场景。一个方法不断地调用自身,且没有正确的退出条件。例如:
1
2
3
4public void recursiveMethod() {
// 没有退出条件
recursiveMethod();
}另一个例子是两个方法互相调用,形成循环:A 调用 B,B 又调用 A。
递归调用层级过深:即使递归有正确的退出条件,如果数据量过大,导致递归调用层级非常深,也可能导致栈溢出。例如,处理一个深度非常大的树形结构。
3. java.lang.OutOfMemoryError: PermGen space 或 Metaspace
这个错误发生在 方法区 溢出。方法区用于存储类的元数据信息,如类的结构、字段、方法、常量池等。
- JDK 1.7 及之前:方法区在堆中,被称为永久代(PermGen)。溢出错误为
PermGen space
。 - JDK 1.8 及之后:永久代被移除,方法区改为使用元空间(Metaspace),并且默认使用本地内存。溢出错误为
Metaspace
。
常见场景:
- 动态生成大量类:在运行时生成大量新的类。这在一些使用字节码增强技术的框架(如 CGLib)或动态代理的场景中很常见。
- 热部署:在像 Tomcat 这样的 Web 服务器中进行频繁的热部署操作,如果没有正确清理旧的类加载器,会导致旧的类元数据无法被回收,从而逐渐耗尽方法区内存。
- 常量池溢出:在 JDK 1.7 之前,字符串常量池也在永久代中。如果程序创建了大量不同的字符串(例如在循环中不断生成新的字符串),也可能导致永久代溢出。
4. java.lang.OutOfMemoryError: Direct buffer memory
这个错误与 直接内存(Direct Memory) 相关,它不是 Java 堆的一部分,而是通过 ByteBuffer.allocateDirect()
在堆外分配的内存。
常见场景:
- NIO 和网络编程:在使用 Java NIO、Netty、或者其他依赖堆外内存的库时,如果频繁地分配直接内存但没有及时释放,就可能导致此错误。
- 内存泄漏:直接内存的回收不像堆内存那样由 GC 自动管理。如果程序中没有调用
ByteBuffer
的cleaner()
方法,或者在没有关闭资源的情况下直接内存泄漏,就会耗尽系统的直接内存。
5. java.lang.OutOfMemoryError: unable to create new native thread
这个错误通常不是因为 Java 堆内存不足,而是因为系统资源耗尽。
常见场景:
- 线程创建过多:程序中创建了大量的线程,导致系统无法为新的线程分配内存空间。每个线程除了 Java 堆中的栈空间外,还需要分配一些本地内存。
- 系统限制:操作系统对单个进程创建的线程数有限制。如果达到了这个上限,JVM 就会抛出此错误。在 Linux 系统中,你可以通过
ulimit -u
命令查看这个限制。 - 内存不足:系统内存(包括堆外内存)已经所剩无几,JVM 无法为新的线程栈分配足够的内存。
面试题:
1. 简述 Java 堆内存溢出(Java heap space)的原因及解决方案。
原因: 最主要的原因是内存泄漏,即 GC 无法回收本应被回收的对象。其他常见原因包括:
- 内存使用不当:一次性加载大量数据到内存中,如从数据库查询海量数据。
- 配置不当:JVM 启动参数
-Xmx
设置过小,无法满足应用需求。
解决方案:
- 分析和定位问题:使用
jmap
、jstat
、jstack
等 JDK 自带工具或者Eclipse MAT
等可视化工具分析内存快照(heap dump
),找到占用内存最多的对象,并分析是否存在内存泄漏。 - 代码层面优化:
- 检查并解决内存泄漏问题,比如移除不再需要的静态集合引用。
- 优化大批量数据处理逻辑,采用分批、分页处理或流式处理,避免一次性加载所有数据。
- 及时释放不再使用的资源,如关闭数据库连接、文件流等。
- JVM 参数调整:如果确认没有内存泄漏,只是应用确实需要更多内存,可以适当增大
-Xmx
和-Xms
参数。
2. 什么是 StackOverflowError?它和 OOM 有什么区别?
StackOverflowError 表示虚拟机栈溢出,通常是由于无限递归或递归调用层级过深导致栈帧不断入栈而耗尽了栈空间。
与 OOM 的区别:
- 发生位置不同:
StackOverflowError
发生在虚拟机栈,而OutOfMemoryError
发生在堆或方法区等其他内存区域。 - 原因不同:
StackOverflowError
通常是由于代码逻辑问题(如无限递归)导致,与程序处理的数据量大小关系不大(除非递归深度过深)。而OOM
往往与程序处理的数据量、JVM 内存配置或内存泄漏有关。 - 解决方式不同:解决
StackOverflowError
主要需要修改代码,比如增加递归的退出条件或采用非递归(迭代)方式重构。而解决OOM
则需要定位内存泄漏、优化代码或调整 JVM 内存参数。
3. JDK 8 中 PermGen space 溢出错误为什么变成了 Metaspace?
- JDK 1.7 及之前:方法区被实现为永久代(PermGen),它属于堆内存的一部分。因此,它的空间大小受限于 JVM 堆内存的设置,容易发生
PermGen space
溢出。 - JDK 1.8 及之后:永久代被移除,方法区改为元空间(Metaspace)。元空间不在 JVM 堆中,而是直接使用本地内存(Native Memory)。
变化的好处:
- 减少 OOM 可能性:元空间使用本地内存,其大小只受限于系统可用内存,默认情况下比永久代大得多,从而降低了因方法区过小而引发 OOM 的风险。
- 避免 GC 负担:永久代需要由 GC 进行垃圾回收,而元空间则通过
class loader
引用来管理,当对应的class loader
被回收时,其加载的类元数据也会被回收。 - 更灵活:元空间的大小可以通过参数
-XX:MaxMetaspaceSize
来设置上限,如果该参数不设置,元空间会根据需要自动扩容。
4. 除了 Java 堆,还有哪些内存区域可能发生 OOM?如何解决?
- 直接内存(Direct buffer memory):
- 原因:通常是使用 NIO 或 Netty 等框架时,频繁分配堆外内存但没有及时释放。
- 解决:检查代码中堆外内存的使用,确保在不再需要时通过
Buffer.cleaner()
或资源关闭操作来显式释放内存。
- 无法创建新线程(unable to create new native thread):
- 原因:
- 程序创建了过多线程,耗尽了操作系统对单个进程的线程数限制。
- 系统内存不足,无法为新线程分配栈空间。
- 解决:
- 代码优化:检查业务逻辑,看是否可以减少线程创建数量,或者使用线程池来复用线程。
- 系统配置:
- 在 Linux 系统中,使用
ulimit -u
增加线程数限制。 - 检查系统内存,确保有足够的内存空间。
- 在 Linux 系统中,使用
- JVM 参数调整:如果线程确实需要,可以适当减小线程栈的大小(
-Xss
)来允许创建更多线程,但需要注意防止 StackOverflowError。
- 原因:
Java框架核心知识详解
一、Spring框架深入解析
1.1 Spring核心概念与原理
Spring框架是一个分层的企业级应用开发框架,其核心是控制反转(IoC)和面向切面编程(AOP)。
IoC容器原理深入分析
IoC(控制反转)的本质: 传统开发中,对象的创建和依赖关系由程序代码直接控制,而IoC将这个控制权交给了外部容器。Spring通过依赖注入(DI)来实现IoC。
IoC容器的实现机制:
- BeanFactory: 基础容器,提供基本的IoC功能
- ApplicationContext: 高级容器,继承BeanFactory,提供更多企业级功能
好的,我们把 Spring IoC容器的实现机制 展开讲清楚,从 BeanFactory 到 ApplicationContext 的层次演进,以及它们在底层是如何运作的。
IoC 容器实现机制
1. BeanFactory —— 基础 IoC 容器
BeanFactory
是 Spring IoC 的最底层容器接口,定义了 获取 Bean 的基本规则。
核心特性
- 懒加载(Lazy loading)
- 只有在调用
getBean()
时,才会去实例化 Bean。 - 避免启动时消耗过多资源。
- 只有在调用
- 最小依赖
- 只提供 IoC 的基础功能:
- Bean 的定义(BeanDefinition)管理
- Bean 的生命周期(创建、依赖注入、销毁)
- Bean 的作用域(singleton / prototype)
- 只提供 IoC 的基础功能:
- 轻量级
- 更适合资源受限环境,比如 IoT、移动设备,或 Spring 的底层框架内部。
核心实现机制
- 读取 BeanDefinition
- 配置文件(XML)、注解(@Component)、Java Config( @Bean ) → 转换成 BeanDefinition 对象。
- BeanDefinition 是 Bean 的抽象描述,包括:
- Bean 的 class 类型
- 是否单例 / 原型
- 是否懒加载
- 构造参数 / 依赖属性
- 存储 BeanDefinition
- 存入 BeanDefinitionRegistry(通常是 Map 结构)。
- 按需实例化 Bean
- 调用
getBean(name)
→ 根据 BeanDefinition 反射创建实例。 - 进行依赖注入(构造器注入 / setter 注入)。
- 调用
- Bean 生命周期管理
- 初始化前回调(BeanPostProcessor →
postProcessBeforeInitialization
) - 调用初始化方法(
InitializingBean
或@PostConstruct
) - 初始化后回调(BeanPostProcessor →
postProcessAfterInitialization
) - 销毁前回调(
DisposableBean
或@PreDestroy
)
- 初始化前回调(BeanPostProcessor →
2. ApplicationContext —— 高级 IoC 容器
ApplicationContext
是 BeanFactory
的子接口,扩展了很多 企业级特性,是我们在项目中最常用的容器。
核心特性(对比 BeanFactory)
- 预实例化(Eager loading)
- 默认在容器启动时,就会实例化所有单例 Bean。
- 启动时性能开销更大,但运行时性能更好。
- 国际化(I18N)支持
- 通过
MessageSource
接口,支持不同语言的消息解析。
- 通过
- 事件机制(ApplicationEvent)
- 内置事件发布 / 监听机制(观察者模式)。
- 开发者可以自定义事件,实现应用内解耦。
- 资源加载
- 统一的
Resource
抽象,可以加载文件、URL、classpath 下的资源。
- 统一的
- 与 AOP、事务管理集成
- 自动识别 BeanPostProcessor、BeanFactoryPostProcessor,用于扩展 Bean 生命周期逻辑(例如事务代理、AOP 动态代理)。
- Profile 环境支持
- 支持多环境配置(开发、测试、生产),通过
@Profile
注解切换。
- 支持多环境配置(开发、测试、生产),通过
3. BeanFactory vs ApplicationContext 总结
特性 | BeanFactory | ApplicationContext |
---|---|---|
Bean 加载时机 | 懒加载(按需创建) | 预实例化(启动时创建单例 Bean) |
国际化支持 | ❌ | ✅ |
事件机制 | ❌ | ✅ |
AOP / 事务 | 手动注册 BeanPostProcessor | 自动识别并应用 |
资源加载 | 基础 API | 更强大的 Resource 抽象 |
适用场景 | 轻量应用、底层容器 | 企业级应用,大部分 Spring 项目 |
4. 常见 ApplicationContext 实现类
- ClassPathXmlApplicationContext
- 从 classpath 下的 XML 加载配置。
- FileSystemXmlApplicationContext
- 从文件系统路径下加载 XML 配置。
- AnnotationConfigApplicationContext
- 基于注解和 Java Config 的容器。
- WebApplicationContext
- 专门为 Web 应用准备的 IoC 容器,整合 ServletContext。
👉 可以这样理解:
BeanFactory
就是 IoC 的“发动机”,提供最基本的动力。ApplicationContext
就是加了“变速箱、车载娱乐系统、空调、智能导航”的完整汽车,更适合实际企业应用。
Bean的生命周期详解:
1 | 实例化 → 属性赋值 → 初始化前处理 → 初始化 → 初始化后处理 → 使用 → 销毁前处理 → 销毁 |
详细生命周期步骤:
- Bean元数据解析(XML、注解、Java配置)
- 调用Bean构造函数实例化
- 依赖注入(setter方法、构造函数、字段注入)
- 如果实现了BeanNameAware,调用setBeanName()
- 如果实现了BeanFactoryAware,调用setBeanFactory()
- 如果实现了ApplicationContextAware,调用setApplicationContext()
- 如果有BeanPostProcessor,调用postProcessBeforeInitialization()
- 如果实现了InitializingBean,调用afterPropertiesSet()
- 如果配置了init-method,调用自定义初始化方法
- 如果有BeanPostProcessor,调用postProcessAfterInitialization()
- Bean可以被使用
- 容器关闭时,如果实现了DisposableBean,调用destroy()
- 如果配置了destroy-method,调用自定义销毁方法
DI的三种注入方式对比
- 构造器注入(Constructor Injection)
- 优点:强制依赖、依赖清晰、不可变性
- 缺点:当依赖项过多时构造函数臃肿
- 最佳实践:官方推荐,特别是当依赖是必需的、不可选的时候
- Setter注入(Setter Injection)
- 优点:可选依赖,灵活性高
- 缺点:对象可能处于不完整状态,无法使用final修饰
- 最佳实践:用于注入可选的依赖项
- 字段注入(Field Injection)
- 优点:代码简洁
- 缺点:不推荐使用,隐藏依赖、测试困难、无法使用final修饰
Bean 的生命周期
在传统的 Java 应用中,Bean 的生命周期很简单:使用 new
关键字进行实例化,然后 Bean 就能被使用,不再使用时,由 Java 自动进行垃圾回收。
相比之下,Spring 管理 Bean 的生命周期就复杂多了,正确理解 Bean 的生命周期非常重要,因为 Spring 对 Bean 的管理可扩展性非常强。以下是一个 Bean 的构造过程:
- Spring 启动:查找并加载需要被 Spring 管理的 Bean,进行 Bean 的实例化。
- 属性注入:对 Bean 的引用和值注入到 Bean 的属性中。
- BeanNameAware 接口:如果 Bean 实现了
BeanNameAware
接口,Spring 将 Bean 的 Id 传递给setBeanName()
方法。 - BeanFactoryAware 接口:如果 Bean 实现了
BeanFactoryAware
接口,Spring 将调用setBeanFactory()
方法,将BeanFactory
容器实例传入。 - ApplicationContextAware 接口:如果 Bean 实现了
ApplicationContextAware
接口,Spring 将调用 Bean 的setApplicationContext()
方法,将 Bean 所在的应用上下文引用传入。 - BeanPostProcessor (前):如果 Bean 实现了
BeanPostProcessor
接口,Spring 将调用它们的postProcessBeforeInitialization()
方法。 - InitializingBean 接口与 init-method:
- 如果 Bean 实现了
InitializingBean
接口,Spring 将调用它们的afterPropertiesSet()
方法。 - 如果 Bean 使用
init-method
声明了初始化方法,该方法也会被调用。
- 如果 Bean 实现了
- BeanPostProcessor (后):如果 Bean 实现了
BeanPostProcessor
接口,Spring 将调用它们的postProcessAfterInitialization()
方法。 - Bean 准备就绪:此时,Bean 已经准备就绪,可以被应用程序使用。它们将一直驻留在应用上下文中,直到应用上下文被销毁。
- 销毁:
- 如果 Bean 实现了
DisposableBean
接口,Spring 将调用它的destory()
接口方法。 - 如果 Bean 使用
destory-method
声明了销毁方法,该方法也会被调用。
- 如果 Bean 实现了
Bean 的作用域
- singleton:唯一的 Bean 实例,Spring 中的 Bean 默认都是单例的。
- prototype:每次请求都会创建一个新的 Bean 实例。
- request:每一次 HTTP 请求都会产生一个新的 Bean,该 Bean 仅在当前 HTTP request 内有效。
- session:每一次 HTTP 请求都会产生一个新的 Bean,该 Bean 仅在当前 HTTP session 内有效。
- global-session:全局 session 作用域,仅仅在基于 portlet 的 web 应用中才有意义,Spring 5 已经没有了。Portlet 是能够生成语义代码(例如:HTML)片段的小型 Java Web 插件。它们基于 portlet 容器,可以像 servlet 一样处理 HTTP 请求。但是,与 servlet 不同,每个 portlet 都有不同的会话。
Spring 中的单例 Bean 的线程安全问题
大多数时候我们并没有在系统中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为:当多个线程操作同一个对象的时候,对这个对象的非静态成员变量的写操作会存在线程安全问题。常见的有两种解决办法:
- 在 Bean 对象中尽量避免定义可变的成员变量(不太现实)。
- 在类中定义一个
ThreadLocal
成员变量,将需要的可变成员变量保存在ThreadLocal
中(推荐的一种方式)。
对 Spring 中的事务的理解
事务是逻辑上的一组操作,要么都执行,要么都不执行。
事务特性
- 原子性:事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用。
- 一致性:执行事务前后,数据保持一致。
- 隔离性:并发访问数据库时,一个用户的事物不被其他事物所干扰,各并发事务之间数据库是独立的。
- 持久性:一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
Spring 事务管理接口
PlatformTransactionManager
:(平台)事务管理器。TransactionDefinition
:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。TransactionStatus
:事务运行状态。
所谓事务管理,其实就是“按照给定的事务规则来执行提交或者回滚操作”。
Spring 中的事务隔离级别
TransactionDefinition
接口中定义了五个表示隔离级别的常量:
- TransactionDefinition.ISOLATION_DEFAULT:使用后端数据库默认的隔离级别。MySQL 默认采用
REPEATABLE_READ
隔离级别,Oracle 默认采用READ_COMMITTED
隔离级别。 - TransactionDefinition.ISOLATION_READ_UNCOMMITTED:最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
- TransactionDefinition.ISOLATION_READ_COMMITTED:允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
- TransactionDefinition.ISOLATION_REPEATABLE_READ:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
- TransactionDefinition.ISOLATION_SERIALIZABLE:最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。
Spring 中的事物传播行为
事务传播行为是为了解决业务层方法之间互相调用的事务问题。当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。在 TransactionDefinition
定义中包括了如下几个表示传播行为的常量:
支持当前事务的情况:
- TransactionDefinition.PROPAGATION_REQUIRED:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
- TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
- TransactionDefinition.PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
不支持当前事务的情况:
- TransactionDefinition.PROPAGATION_REQUIRES_NEW:创建一个新的事务,如果当前存在事务,则把当前事务挂起。
- TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
- TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。
其他情况:
- TransactionDefinition.PROPAGATION_NESTED:如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于
TransactionDefinition.PROPAGATION_REQUIRED
。
Spring 常用注入方式
- 构造器依赖注入:构造器依赖注入通过容器触发一个类的构造器来实现的,该类有一系列参数,每个参数代表一个对其他类的依赖。
- Setter 方法注入:Setter 方法注入是容器通过调用无参构造器或无参 static 工厂方法实例化 bean 之后,调用该 bean 的 Setter 方法,即实现了基于 Setter 的依赖注入。
- 基于注解的注入:最好的解决方案是使用构造器参数实现强制依赖,Setter 方法实现可选依赖。
Spring 框架中用到了哪些设计模式?
- 工厂设计模式:Spring 使用工厂模式通过
BeanFactory
、ApplicationContext
创建 bean 对象。 - 代理设计模式:Spring AOP 功能的实现。
- 单例设计模式:Spring 中的 Bean 默认都是单例的。
- 模板方法模式:Spring 中
jdbcTemplate
、hibernateTemplate
等以Template
结尾的对数据库操作的类,它们就使用到了模板模式。 - 包装器设计模式:我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们根据客户的需求能够动态切换不同的数据源。
- 观察者模式:Spring 事件驱动模型就是观察者模式很经典的一个应用。
- 适配器模式:Spring AOP 的增强或通知(Advice)使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配 Controller。
ApplicationContext 通常的实现有哪些?
- FileSystemXmlApplicationContext:此容器从一个 XML 文件中加载 beans 的定义,XML Bean 配置文件`的全路径名必须提供给它的构造函数。
- ClassPathXmlApplicationContext:此容器也从一个 XML 文件中加载 beans 的定义,这里,你需要正确设置 classpath 因为这个容器将在 classpath 里找 bean 配置。
- WebXmlApplicationContext:此容器加载一个 XML 文件,此文件定义了一个 Web 应用的所有 bean。
AOP原理深入分析
AOP实现机制: Spring AOP基于代理模式实现,支持两种代理方式:
- JDK动态代理: 针对实现了接口的类
- CGLIB代理: 针对没有实现接口的类
我们来系统性地讲解一下 Spring AOP 的实现机制,包括:
- JDK 动态代理
- CGLIB 动态代理
- 静态代理(对比说明)
🌟 一、Spring AOP 实现机制概述
Spring AOP(面向切面编程)是基于 代理模式 实现的,它通过在目标方法执行前后织入逻辑(增强),实现横切关注点(如日志、安全、事务等)的分离。
Spring AOP 仅支持方法级别的代理(即对方法进行增强),不支持字段、构造器等底层字节码增强(这要用 AspectJ)。
🧩 二、Spring AOP 的两种动态代理方式
1️⃣ JDK 动态代理(基于接口)
✅ 原理:
- 基于 Java 的
java.lang.reflect.Proxy
类实现。 - 必须要有接口,Spring 会为接口生成代理类。
- 代理类在运行时实现接口,并将调用委托给 InvocationHandler。
📦 示例:
1 | public interface UserService { |
Spring AOP 为 UserService
创建代理:
1 | UserService proxy = (UserService) Proxy.newProxyInstance( |
🎯 特点:
特性 | 描述 |
---|---|
是否依赖接口 | ✅ 是 |
性能 | 较高(比 CGLIB 略快) |
生成的代理类 | 实现接口 |
2️⃣ CGLIB 动态代理(基于子类)
✅ 原理:
- 使用 CGLIB 库在运行时 生成目标类的子类,并重写其方法来实现增强。
- 适用于 没有接口 的类,或者想对类本身增强而非接口。
🧪 示例:
1 | public class UserService { |
Spring 通过 CGLIB 生成子类:
1 | Enhancer enhancer = new Enhancer(); |
🎯 特点:
特性 | 描述 |
---|---|
是否依赖接口 | ❌ 否 |
是否可代理 final 类/方法 | ❌ 否(final 无法被继承/重写) |
生成的代理类 | 子类 |
🏗 三、静态代理(对比动态代理)
✅ 原理:
- 自己写一个代理类,实现相同接口,将方法调用委托给目标对象,并在调用前后手动添加增强逻辑。
🧪 示例:
1 | public interface UserService { |
🎯 特点:
特性 | 描述 |
---|---|
是否自动生成 | ❌ 需手动编码 |
灵活性 | 差(每个类都要写一个代理类) |
是否支持 AOP | ❌ 无法动态织入多个切面 |
🔚 四、三者对比总结
特性 | 静态代理 | JDK 动态代理 | CGLIB 动态代理 |
---|---|---|---|
是否依赖接口 | ✅ 是 | ✅ 是 | ❌ 否 |
是否自动生成代理类 | ❌ 手动编写 | ✅ 运行时生成 | ✅ 运行时生成 |
是否可代理类 | ❌ 否(接口为主) | ❌ 否(接口为主) | ✅ 是(类的子类) |
性能 | 一般 | 较高 | 略慢(但可接受) |
是否支持 final 方法 | ✅ | ✅ | ❌ 否 |
✅ Spring 如何选择代理方式?
- 默认使用 JDK 动态代理(如果目标类实现了接口)。
- 如果目标类没有接口,Spring 自动切换为 CGLIB。
- 可以强制使用 CGLIB:
1 |
AOP核心概念:
- 切面(Aspect): 横切关注点的模块化
- 连接点(Joinpoint): 程序执行中的特定点
- 切点(Pointcut): 连接点的集合
- 通知(Advice): 切面在特定连接点执行的代码
- 目标对象(Target): 被代理的对象
- 代理对象(Proxy): AOP框架创建的对象
AOP通知类型:
1. 前置通知(Before advice):在某个连接点(JoinPoint)之前执行的通知,但它不能阻止连接点之前的执行。在 ApplicationContext
中,可以使用 <aop:before>
元素在 <aop:aspect>
内部进行声明。
2. 后置通知(After advice):当某个连接点退出时执行的通知,无论是正常返回还是异常退出。在 ApplicationContext
中,可以使用 <aop:after>
元素在 <aop:aspect>
内部进行声明。
3. 返回后通知(After return advice):在某个连接点正常完成后执行的通知,不包括抛出异常的情况。在 ApplicationContext
中,可以使用 <aop:after-returning>
元素在 <aop:aspect>
内部进行声明。
4. 环绕通知(Around advice):包围一个连接点的通知,类似于 Web 中 Servlet 规范中的 Filter
的 doFilter
方法。可以在方法的调用前后完成自定义的行为,也可以选择不执行。在 ApplicationContext
中,可以使用 <aop:around>
元素在 <aop:aspect>
内部进行声明。
5. 抛出异常后通知(After throwing advice):在方法抛出异常退出时执行的通知。在 ApplicationContext
中,可以使用 <aop:after-throwing>
元素在 <aop:aspect>
内部进行声明。
JDK动态代理 vs. CGLIB动态代理
- JDK动态代理:
- 基于接口实现
- 使用Proxy.newProxyInstance()创建代理
- 只能代理实现了接口的类
- CGLIB动态代理:
- 基于继承实现
- 使用Enhancer类创建代理
- 可以代理普通类
- 不能代理final类和方法
1.2 Spring核心注解详解
基础配置注解
1 | // 表示这是一个配置类 |
Bean定义注解
1 | // 通用组件 |
依赖注入注解
1 | // 自动装配,可用于构造函数、方法、字段 |
生命周期注解
1 | // 初始化方法 |
AOP相关注解
1 | // 声明切面 |
1.3 Spring常见使用场景
场景1:服务层事务管理
1 |
|
场景2:缓存管理
1 |
|
1.4 Spring常见面试问题
Q1: Spring IoC容器的初始化过程是怎样的?
A: Spring IoC容器初始化分为三个阶段:
- Resource定位: 定位配置文件
- BeanDefinition载入: 将配置信息转换为Spring内部数据结构
- BeanDefinition注册: 将BeanDefinition注册到IoC容器的HashMap中
具体流程:
- 创建ApplicationContext
- 加载配置元数据(XML、注解、Java配置)
- 解析配置,创建BeanDefinition
- 注册BeanDefinition到BeanDefinitionRegistry
- 实例化非懒加载的单例Bean
Q2: Spring中的循环依赖是如何解决的?
A: Spring通过三级缓存解决循环依赖:
- singletonObjects: 一级缓存,存放完整的Bean实例
- earlySingletonObjects: 二级缓存,存放早期的Bean实例
- singletonFactories: 三级缓存,存放Bean工厂
解决过程:
- A依赖B,B依赖A
- 创建A时,将A的工厂放入三级缓存
- A需要注入B,开始创建B
- B需要注入A,从缓存中获取A的早期实例
- B创建完成,A继续创建完成
Q3: Spring AOP的实现原理?
A: Spring AOP基于代理模式实现:
- JDK动态代理: 目标类实现接口时使用,基于反射机制
- CGLIB代理: 目标类没有接口时使用,基于字节码技术
代理创建过程:
- Spring在Bean初始化后,检查是否需要AOP
- 如果需要,创建代理对象替换原始Bean
- 代理对象拦截方法调用,执行切面逻辑
二、Spring Boot深入解析
2.1 Spring Boot核心原理
自动配置原理: Spring Boot通过@EnableAutoConfiguration注解启用自动配置机制。
核心类分析:
- SpringBootApplication: 组合注解,包含@Configuration、@EnableAutoConfiguration、@ComponentScan
- AutoConfigurationImportSelector: 负责导入自动配置类
- spring.factories: META-INF/spring.factories文件定义自动配置类
自动配置流程:
- SpringBoot启动时扫描所有jar包下的META-INF/spring.factories文件
- 加载文件中定义的自动配置类
- 根据条件注解(@ConditionalOnClass等)判断是否生效
- 生效的配置类会创建相应的Bean
2.2 Spring Boot启动流程详解
SpringApplication.run()方法执行流程:
- 准备阶段:
- 创建SpringApplication实例
- 确定应用类型(SERVLET、REACTIVE、NONE)
- 加载ApplicationContextInitializer
- 加载ApplicationListener
- 启动阶段:
- 启动计时器
- 配置Headless模式
- 获取并启动监听器
- 准备环境(Environment)
- 打印Banner
- 容器创建阶段:
- 创建ApplicationContext
- 准备ApplicationContext
- 刷新ApplicationContext
- 刷新后处理
- 完成阶段:
- 停止计时器
- 发布启动完成事件
- 调用Runners
2.3 Spring Boot核心注解
启动类注解
1 |
|
条件注解
1 | // 类路径下存在指定类 |
配置属性注解
1 |
|
2.4 Spring Boot实际应用场景
场景1:微服务架构
1 |
|
场景2:数据访问层整合
1 |
|
2.5 Spring Boot面试问题
Q1: Spring Boot的启动原理是什么?
A: Spring Boot启动原理核心在于自动配置:
- @SpringBootApplication组合了三个注解,其中@EnableAutoConfiguration是关键
- AutoConfigurationImportSelector会扫描所有引入的jar包,查找其META-INF/spring.factories文件中org.springframework.boot.autoconfigure.EnableAutoConfiguration键所对应的配置类全限定名列表
- 根据条件注解判断哪些自动配置类生效
- 生效的配置类会向容器中注册相应的Bean
Q2: Spring Boot如何实现自动配置?
A: 自动配置通过以下机制实现:
- 条件注解: @ConditionalOnClass、@ConditionalOnBean等判断配置是否生效
- 配置文件: spring.factories定义自动配置类列表
- 配置属性: @ConfigurationProperties绑定配置文件中的属性
- 默认配置: 提供合理的默认值,用户可覆盖
Q3: Spring Boot Starter的工作原理?
A: Starter是Spring Boot自动配置的载体:
- 依赖管理: 通过Maven/Gradle引入相关依赖
- 自动配置: 包含AutoConfiguration类
- 属性绑定: 提供ConfigurationProperties类
- 条件装配: 使用条件注解控制Bean的创建
三、Spring MVC深入解析
3.1 Spring MVC核心组件
DispatcherServlet处理流程:
- 接收请求: DispatcherServlet接收HTTP请求
- 查找Handler: HandlerMapping查找处理请求的Handler
- 获取HandlerAdapter: 获取能够执行Handler的HandlerAdapter
- 执行Handler: HandlerAdapter执行Handler(Controller方法)
- 处理结果: 返回ModelAndView
- 视图解析: ViewResolver解析视图名称
- 渲染视图: View渲染模型数据
- 返回响应: 响应结果返回给客户端
核心组件详解:
- DispatcherServlet: 前端控制器,统一处理请求
- HandlerMapping: 处理器映射器,URL与Handler的映射
- HandlerAdapter: 处理器适配器,执行Handler
- Handler: 处理器,即Controller
- ViewResolver: 视图解析器
- View: 视图
3.2 Spring MVC核心注解
控制器注解
1 | // 标记控制器类 |
参数绑定注解
1 | // 请求参数 |
响应处理注解
1 | // 响应体 |
3.3 Spring MVC实际应用场景
场景1:RESTful API开发
1 |
|
场景2:全局异常处理
1 |
|
场景3:文件上传处理
1 |
|
3.4 Spring MVC面试问题
Q1: Spring MVC的执行流程是怎样的?
A: Spring MVC的执行流程如下:
- 用户发送请求到DispatcherServlet
- DispatcherServlet调用HandlerMapping查找Handler
- HandlerMapping返回HandlerExecutionChain(包含Handler和拦截器)
- DispatcherServlet调用HandlerAdapter执行Handler
- Handler执行完成后返回ModelAndView
- DispatcherServlet调用ViewResolver解析视图名称
- ViewResolver返回View对象
- DispatcherServlet调用View的render方法渲染视图
- 响应结果返回给用户
Q2: Spring MVC中的拦截器是如何工作的?
A: 拦截器基于AOP思想,在Handler执行前后进行处理:
- HandlerInterceptor接口提供三个方法:
- preHandle(): 前置处理,返回false则中断请求
- postHandle(): 后置处理,Handler执行后调用
- afterCompletion(): 完成处理,视图渲染后调用
- 执行顺序:
- 多个拦截器按配置顺序执行preHandle()
- 按相反顺序执行postHandle()和afterCompletion()
Q3: @RequestBody和@ResponseBody的工作原理?
A: 这两个注解基于HttpMessageConverter工作:
- @RequestBody:
- 使用HttpMessageConverter将HTTP请求体转换为Java对象
- 常用的转换器:MappingJackson2HttpMessageConverter处理JSON
- @ResponseBody:
- 使用HttpMessageConverter将Java对象转换为HTTP响应体
- 根据Accept头选择合适的转换器
四、MyBatis深入解析
4.1 MyBatis核心原理
MyBatis架构分析: MyBatis采用分层架构设计:
- API接口层: 提供给外部使用的接口API
- 数据处理层: 参数映射、SQL解析、结果映射
- 基础支撑层: 连接管理、事务管理、配置加载、缓存处理
MyBatis核心组件:
- SqlSessionFactory: 会话工厂,负责创建SqlSession
- SqlSession: 会话,执行SQL的核心接口
- Executor: 执行器,实际执行SQL
- StatementHandler: 语句处理器,处理SQL语句
- ParameterHandler: 参数处理器,处理SQL参数
- ResultSetHandler: 结果集处理器,处理查询结果
- MappedStatement: 映射语句,封装SQL配置信息
4.2 MyBatis执行流程详解
SQL执行流程:
- 解析配置: 解析mybatis-config.xml和Mapper XML文件
- 创建会话: 通过SqlSessionFactory创建SqlSession
- 获取Mapper: 通过动态代理创建Mapper接口实例
- 执行SQL: 调用Mapper方法,转换为MappedStatement执行
- 参数处理: ParameterHandler处理输入参数
- 执行查询: StatementHandler执行SQL语句
- 结果映射: ResultSetHandler处理结果集
- 返回结果: 将结果返回给调用方
动态代理原理: MyBatis使用JDK动态代理为Mapper接口创建代理对象:
1 | // MapperProxy实现InvocationHandler |
4.3 MyBatis核心注解
基础映射注解
1 |
|
高级映射注解
1 | // 结果映射 |
动态SQL注解
1 |
|
4.4 MyBatis缓存机制
一级缓存(默认开启):
- 作用域:SqlSession级别
- 生命周期:与SqlSession相同
- 存储:HashMap结构,key为CacheKey
二级缓存(需要配置):
- 作用域:Mapper级别
- 生命周期:与应用程序相同
- 配置:@CacheNamespace注解或
标签
1 |
|
4.5 MyBatis实际应用场景
场景1:复杂查询场景
1 |
|
场景2:批量操作场景
1 |
|
场景3:动态SQL场景
1 |
|
4.6 MyBatis性能优化
优化策略1:合理使用缓存
1 | // 开启二级缓存,设置合理的缓存策略 |
优化策略2:批量操作
1 | // 使用批量插入替代循环插入 |
优化策略3:延迟加载
1 |
|
4.7 MyBatis常见问题与解决方案
问题1:N+1查询问题
1 | // 问题:会产生N+1次查询 |
问题2:大数据量查询内存溢出
1 | // 问题:一次性加载大量数据导致内存溢出 |
问题3:SQL注入防护
1 | // 危险:直接拼接SQL,容易SQL注入 |
4.8 MyBatis面试高频问题
Q1: MyBatis的执行流程是怎样的?
A: MyBatis的执行流程包括以下步骤:
- 配置解析: 解析mybatis-config.xml配置文件和Mapper XML文件,创建Configuration对象
- SqlSessionFactory创建: 根据Configuration创建SqlSessionFactory
- SqlSession创建: 通过SqlSessionFactory.openSession()创建SqlSession
- Mapper获取: 通过SqlSession.getMapper()获取Mapper接口的代理对象
- 方法调用: 调用Mapper接口方法,通过动态代理转换为SQL执行
- SQL执行: 通过Executor执行SQL,包括参数处理、语句执行、结果映射
- 结果返回: 将执行结果返回给调用方
Q2: MyBatis的一级缓存和二级缓存有什么区别?
A: 两级缓存的主要区别:
一级缓存(默认开启):
- 作用域:SqlSession级别
- 生命周期:与SqlSession相同,SqlSession关闭时缓存清空
- 存储结构:HashMap,key为CacheKey(由SQL、参数、分页等组成)
- 失效条件:执行update、insert、delete操作或手动清空
二级缓存(需要配置):
- 作用域:Mapper级别,多个SqlSession可以共享
- 生命周期:与应用程序相同
- 存储结构:可配置(HashMap、LRU、FIFO等)
- 配置方式:@CacheNamespace注解或
标签 - 注意事项:需要序列化,可能存在脏读问题
Q3: MyBatis如何防止SQL注入?
A: MyBatis通过以下方式防止SQL注入:
- 参数绑定: 使用#{}而不是${}
- #{}:预编译处理,参数作为占位符传递
- ${}:字符串替换,直接拼接到SQL中(危险)
- 类型检查: MyBatis会对参数类型进行检查
- 白名单验证: 对于动态排序等场景,使用白名单验证
1 | // 安全的写法 |
Q4: MyBatis中#{}和${}的区别?
A: 两者的主要区别:
#{}(推荐使用):
- 预编译处理,生成PreparedStatement
- 参数会被处理为占位符?
- 可以防止SQL注入
- 会进行类型转换
- 适用于参数值传递
${}(谨慎使用):
- 字符串替换,直接拼接到SQL中
- 不会进行预编译
- 存在SQL注入风险
- 不会进行类型转换
- 适用于动态表名、列名等场景
Q5: MyBatis的动态SQL是如何实现的?
A: MyBatis的动态SQL通过以下标签实现:
- if标签: 条件判断
- choose/when/otherwise: 类似switch-case
- where标签: 智能处理WHERE条件
- set标签: 智能处理SET语句
- foreach标签: 循环处理
- trim标签: 去除多余的字符
实现原理:
- 使用OGNL表达式进行条件判断
- 在SQL解析阶段根据参数值动态生成SQL
- 通过SqlNode树结构表示动态SQL
- 在执行时遍历SqlNode树生成最终SQL
五、框架整合与最佳实践
5.1 Spring Boot + MyBatis整合
完整配置示例
1 | // 主启动类 |
事务管理配置
1 |
|
5.2 统一异常处理
1 |
|
5.3 接口文档与验证
1 | // 统一响应格式 |
5.4 性能监控与优化
1 | // 性能监控切面 |
5.5 高频面试综合题
Q1: 在微服务架构中,如何设计一个高并发的订单系统?
A: 设计高并发订单系统需要考虑以下几个方面:
1. 架构设计:
- 使用分布式架构,订单服务、库存服务、支付服务分离
- 引入消息队列处理异步任务
- 使用Redis做缓存和分布式锁
- 数据库读写分离,分库分表
2. 并发控制:
1 |
|
3. 性能优化:
- 使用批量操作减少数据库交互
- 合理使用缓存策略
- 异步处理非核心业务逻辑
- 数据库连接池优化
Q2: 如何处理分布式事务?
A: 分布式事务处理方案:
1. 2PC/3PC协议:
- 强一致性,但性能较差
- 适用于对一致性要求极高的场景
2. TCC模式:
1 |
|
3. 消息队列最终一致性:
1 |
|
Q3: Spring Boot应用如何优化启动速度?
A: Spring Boot启动优化策略:
1. 依赖优化:
- 移除不必要的依赖
- 使用spring-boot-starter-web替代完整的spring-web
- 避免引入大量自动配置类
2. 配置优化:
1 | spring: |
3. JVM优化:
1 | java -XX:+UnlockExperimentalVMOptions |
4. 代码优化:
- 使用@Lazy注解延迟Bean初始化
- 避免在@PostConstruct中执行耗时操作
- 合理使用@ConditionalOn*注解
好的,这是一份详细的解释,我们来逐个分析这些 Java 虚拟机(JVM)参数:
1 | java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:+UseTransparentHugePages -XX:MaxMetaspaceSize=256m -Xms512m -Xmx1024m -jar application.jar |
java
: 这是启动 Java 程序的命令。
内存管理和垃圾回收
这部分参数主要用于优化 JVM 的内存使用和垃圾回收机制,以获得更好的性能。
-XX:+UnlockExperimentalVMOptions
: 这个参数是用来解锁实验性(experimental)的 JVM 选项。ZGC 曾经是实验性功能,所以需要这个参数才能使用。-XX:+UseZGC
: 这条命令是启用 Z 垃圾回收器(Z Garbage Collector)。ZGC 是一种为超大堆内存(从几GB到几十TB)设计的、低延迟的垃圾回收器。它的主要目标是在垃圾回收时,尽量减少应用程序的停顿时间,这对于需要极低延迟的应用程序非常重要。-XX:+UseTransparentHugePages
: 这个参数开启了透明大页(Transparent Huge Pages)的支持。在操作系统层面,通常内存是以 4KB 的小页来管理的。而大页(通常是 2MB 或 1GB)可以减少 CPU 在内存管理上的开销,从而提升性能,尤其对于内存占用较大的应用效果更明显。-XX:MaxMetaspaceSize=256m
: 这个参数设置了元空间(Metaspace)的最大大小为 256MB。元空间是 JVM 存储类的元数据(如类的名称、方法、字段等信息)的地方。在 Java 8 之后,它取代了之前的“永久代”(PermGen)。-Xms512m
: 这条命令设定了 JVM 堆的初始内存为 512MB。堆是用来存放 Java 对象的地方。-Xms
决定了 JVM 启动时会申请的最小内存。-Xmx1024m
: 这条命令设定了 JVM 堆的最大内存为 1024MB(即 1GB)。这是 JVM 运行时可以使用的最大内存。当堆内存用尽时,JVM 会触发垃圾回收,如果依然不足,就会抛出OutOfMemoryError
。
好的,我们来更详细地聊聊 JVM 堆内存的结构,特别是分代设计和具体的划分比例。
堆内存的传统分代结构
在绝大多数的垃圾回收器中(例如 CMS、G1 之前的串行和并行 GC),堆内存被分为以下几个代(Generation):
1. 年轻代(Young Generation)
年轻代是新对象的诞生地。 绝大多数对象,比如局部变量创建的临时对象,都会在年轻代被创建。年轻代被设计成较小且垃圾回收频率高的区域。
- Eden 区:这是年轻代的主要部分,新创建的对象首先被分配到这里。
- Survivor 区(幸存者区):有两个大小相等的 Survivor 区,通常命名为 S0 和 S1。它们的作用是保存每次垃圾回收后,还存活的对象。
划分比例
在默认情况下,年轻代内部的划分比例通常是:
- Eden 区 : S0 区 : S1 区 = 8 : 1 : 1
这个比例可以通过 JVM 参数进行调整,比如使用 -XX:SurvivorRatio=8
。这意味着 Eden 区的大小是单个 Survivor 区的 8 倍。
2. 老年代(Old Generation)
老年代用于存放生命周期较长的对象。 当一个对象在年轻代经过多次垃圾回收(通常是 15 次,这个次数也可以通过 -XX:MaxTenuringThreshold
参数调整)后仍然存活,或者年轻代放不下的大对象,就会被“晋升”到老年代。
老年代的垃圾回收频率远低于年轻代,但每次回收的开销更大。
划分比例
堆内存中,年轻代和老年代的默认划分比例通常是:
- 年轻代 : 老年代 = 1 : 2
例如,如果你设置 -Xms1200m -Xmx1200m
,那么年轻代大约是 400MB,老年代大约是 800MB。这个比例可以通过 -XX:NewRatio
参数来调整。例如,-XX:NewRatio=2
表示老年代与年轻代的大小比值为 2:1。
垃圾回收过程(以 Minor GC 为例)
理解了这些区域,我们再来看看垃圾回收是怎么进行的:
- 当 Eden 区满了,会触发一次 Minor GC。
- Minor GC 会检查 Eden 区和其中一个 Survivor 区(比如 S0)。
- 它会将所有还存活的对象复制到另一个空的 Survivor 区(比如 S1)。
- 同时,那些不再被引用的对象则会被清理。
- 所有被复制到 S1 区的对象,它们的年龄(
age
)会加一。 - 下一次 Minor GC 发生时,同样会扫描 Eden 区和 S1 区,将存活对象复制到 S0 区,清空 Eden 和 S1 区,并增加对象的年龄。
- 当对象的年龄达到某个阈值时,它就会被晋升到老年代。
现代垃圾回收器(如 G1 和 ZGC)
值得注意的是,像 G1 垃圾回收器已经打破了这种严格的分代比例。它将堆划分为一个个大小相等的区域(Region),每个区域都可以是 Eden、Survivor 或者老年代。G1 能够更智能地选择要回收的区域,从而在保证低停顿的同时,提高吞吐量。
而 ZGC 则更进一步,如我之前所说,它完全没有年轻代、老年代的概念,而是通过着色指针和读屏障技术,在不中断应用程序的情况下并发地进行垃圾回收,实现了更低的停顿。
因此,当你使用 ZGC 时,传统的分代比例就不适用了。不过,了解传统的分代结构对于理解 Java 内存管理的基础依然非常重要。
以下是针对Spring Cloud Alibaba及其他分布式技术的深度补充,包含实现原理和核心组件的详细说明:
六、Spring Cloud与分布式技术详解
6.1 Spring Cloud Alibaba核心组件
Nacos深度解析
服务发现原理:
注册流程:
- 服务启动时向Nacos Server发送注册请求(HTTP/GRPC)
- 注册信息包含元数据、健康检查方式(TCP/HTTP/MySQL)
- 客户端本地缓存服务列表(故障转移)
健康检查机制:
1
2
3
4
5
6
7
8// Nacos健康检查配置示例
spring:
cloud:
nacos:
discovery:
health-check-enabled: true
health-check-interval: 10s
health-check-timeout: 5s
配置中心实现:
- 长轮询机制(Push+Pull混合模式):
- 客户端发起长轮询请求(默认30秒超时)
- 服务端配置变更时立即响应
- 客户端收到变更后拉取最新配置
1 | // 动态配置刷新示例 |
Sentinel核心原理
流量控制规则:
- 滑动窗口算法:
- 统计周期(1秒)分为多个格子(如20个50ms)
- 实时淘汰过期格子数据
- QPS计算基于当前窗口总请求数
熔断降级策略:
策略类型 | 计算公式 | 适用场景 |
---|---|---|
慢调用比例 | 响应时间 > RT阈值 && 比例 > 阈值 | 接口性能波动 |
异常比例 | 异常数 / 请求数 > 阈值 | 依赖服务不稳定 |
异常数 | 异常数 > 阈值(时间窗口内) | 关键业务熔断 |
1 | // Sentinel规则持久化到Nacos示例 |
RocketMQ集成
消息轨迹追踪:
1 | // 生产者配置 |
6.2 分布式事务增强(Seata原理)
AT模式工作流程
- 一阶段:
- 解析SQL生成前后镜像
- 注册分支事务到TC(Transaction Coordinator)
- 本地事务提交前记录undo_log
- 二阶段:
- 成功:异步删除undo_log
- 失败:根据undo_log补偿(反向SQL)
undo_log表示例:
1 | CREATE TABLE `undo_log` ( |
TCC模式最佳实践
1 | // 账户服务TCC接口 |
6.3 分布式缓存深度优化
Redis多级缓存架构
1 | // 本地缓存+Caffeine+Redis实现 |
6.4 消息队列高级特性
RocketMQ事务消息
1 | // 事务消息生产者 |
Kafka精确一次语义
1 | // 生产者配置 |
七、云原生支持(新增)
7.1 Kubernetes集成方案
服务发现适配
1 | # Nacos与K8s Service集成 |
配置管理方案
1 | // 多配置源加载(K8s ConfigMap + Nacos) |
7.2 Service Mesh整合
Istio流量治理
1 | # 金丝雀发布策略 |
八、性能优化深度实践
8.1 全链路压测方案
影子库表配置
1 | # ShardingSphere影子库配置 |
流量染色标记
1 | // 通过ThreadLocal传递压测标记 |
分布式
1. Spring Cloud 体系
核心思想
Spring Cloud 并非一个全新的框架,而是一套用于构建微服务架构的规范和解决方案的集合。它巧妙地利用了 Spring Boot 的自动配置和快速开发特性,将业界经过广泛验证的优秀微服务组件(如 Netflix OSS、Alibaba Nacos、HashiCorp Consul 等)进行封装和集成,为开发者提供了一站式的分布式系统开发工具箱,涵盖了服务治理、配置管理、熔断降级、智能路由、服务调用等方方面面。
Netflix OSS 常用组件(部分进入维护模式)
- 服务注册与发现 (Service Discovery): Eureka
- 作用: 提供一个服务注册中心。每个微服务启动时,将自己的网络地址等信息“注册”到 Eureka Server。其他服务(消费者)则从 Eureka Server “发现”并拉取所需服务提供者的地址列表,从而实现服务间的动态寻址和调用。
- 使用: 服务端添加
spring-cloud-starter-netflix-eureka-server
依赖,并使用@EnableEurekaServer
注解。客户端添加spring-cloud-starter-netflix-eureka-client
依赖,并配置 Eureka Server 地址。
- 服务调用与负载均衡 (RPC & Load Balancing): OpenFeign + Ribbon
- 作用: Feign 让远程服务调用变得像调用本地方法一样简单。开发者只需定义一个接口,并使用
@FeignClient
注解,即可完成对远程服务的调用。Ribbon(现已被 Spring Cloud LoadBalancer 替代)则提供了客户端负载均衡能力,当从 Eureka 获取到多个服务实例地址时,Ribbon 会根据配置的策略(如轮询、随机)选择一个实例进行调用。 - 使用: 添加
spring-cloud-starter-openfeign
依赖,在启动类上加@EnableFeignClients
,创建接口并使用@FeignClient("service-name")
注解。
- 作用: Feign 让远程服务调用变得像调用本地方法一样简单。开发者只需定义一个接口,并使用
- 熔断与降级 (Circuit Breaker): Hystrix
- 作用: 当某个下游服务出现故障或响应缓慢时,为了防止故障在系统中蔓延(即“服务雪崩”),熔断器会快速失败,暂时切断对该服务的调用。同时,可以执行一个预定义的降级逻辑(Fallback),例如返回一个缓存的、默认的或友好的提示信息。
- 状态: Hystrix 已进入维护模式,官方推荐使用 Resilience4j 或其他替代方案。
- API 网关 (API Gateway): Zuul
- 作用: 作为系统的统一入口,API 网关负责请求路由、协议转换、权限校验、流量控制、日志监控等。所有外部请求都先经过网关,再由网关分发到后端的各个微服务。
- 状态: Zuul 1.x 已进入维护模式,官方推荐使用 Spring Cloud Gateway。
Spring Cloud Alibaba 详解
Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案,是 Spring Cloud 体系的重要实现。它集成了阿里巴巴开源的优秀组件,为开发者提供了更符合国内技术生态的选择。
- 服务注册与发现 & 分布式配置中心: Nacos
- 作用: Nacos (Naming and Configuration Service) 是一个功能丰富的平台,完美整合了服务注册发现和配置管理两大核心功能。
- 服务发现: 与 Eureka 类似,提供服务注册、发现和健康检查。但 Nacos 支持基于 DNS 和 RPC 的服务发现,并提供更实时的健康检查机制。
- 配置管理: 可以作为分布式配置中心,对所有微服务的配置进行集中化管理。支持配置的热更新,即修改配置后无需重启服务即可生效。还支持配置的版本管理、灰度发布等高级功能。
- 使用:
- 引入
spring-cloud-starter-alibaba-nacos-discovery
和spring-cloud-starter-alibaba-nacos-config
依赖。 - 在
bootstrap.properties
(或.yml
) 文件中配置 Nacos 服务器地址和应用名。 - 使用
@Value
或@ConfigurationProperties
注解即可动态获取和刷新配置。
- 引入
- 作用: Nacos (Naming and Configuration Service) 是一个功能丰富的平台,完美整合了服务注册发现和配置管理两大核心功能。
- 熔断、降级与流量控制: Sentinel
- 作用: Sentinel 是面向分布式服务架构的“流量的守护者”,以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。相较于 Hystrix,Sentinel 功能更强大,提供了可视化的监控和配置平台,并且支持热点参数限流等精细化控制。
- 核心概念:
- 资源 (Resource): Sentinel 中一切皆资源,可以是一个方法、一段代码或一个服务 URL。
- 规则 (Rule): 定义如何保护资源,包括流控规则、降级规则、系统保护规则等。
- 使用:
- 引入
spring-cloud-starter-alibaba-sentinel
依赖。 - 配置 Sentinel 控制台地址。
- 通过
@SentinelResource
注解来定义资源,并指定 Fallback (降级逻辑) 和 BlockHandler (流控/熔断逻辑)。
- 引入
- 分布式事务解决方案: Seata
- 作用: Seata 是一个开源的分布式事务解决方案,提供了高性能且易于使用的分布式事务服务。它支持多种事务模式,包括 AT(自动补偿)、TCC、Saga 和 XA 模式,旨在解决微服务架构下的数据一致性问题。
- 使用: 引入
spring-cloud-starter-alibaba-seata
依赖,配置 Seata Server 地址,并使用@GlobalTransactional
注解开启全局事务。
2. 分布式ID:雪花算法(Snowflake)
原理
Snowflake 是 Twitter 开源的一种分布式 ID 生成算法,它能生成一个 64 位的 long
型数字作为全局唯一 ID。这个 64 位的 ID 由四部分构成:
- 1位符号位: 最高位,固定为0,表示正数,无实际意义。
- 41位时间戳 (Timestamp): 精确到毫秒级,是
(当前时间戳 - 起始时间戳)
的差值。41位可以表示 (241−1) 毫秒,大约可以使用 69 年。 - 10位工作机器ID (Worker ID): 这 10 位可以被灵活划分,例如前 5 位代表数据中心 ID (Datacenter ID),后 5 位代表机器 ID (Machine ID)。这样总共可以支持 210=1024 台机器。
- 12位序列号 (Sequence): 表示在同一毫秒内,同一台机器上生成的 ID 序列号。12位意味着每台机器每毫秒可以生成 212=4096 个不同的 ID。
优点
- 全局唯一: 通过时间戳、机器 ID 和序列号的组合,可以保证在分布式环境下的 ID 唯一性。
- 趋势递增: 由于时间戳在高位,所以生成的 ID 整体上是按时间趋势递增的,这对于数据库索引(特别是 B+树)非常友好,可以减少页分裂,提高插入性能。
- 高性能: ID 在本地生成,不依赖任何外部服务(如数据库或 Redis),生成效率极高。
- 高可用: 算法本身不依赖网络,部署简单,具有很高的可用性。
面试题:“雪花算法有时钟回拨问题,如何解决?”
回答要点:
时钟回拨是指服务器时间被同步到一个过去的时间点。如果算法不做处理,可能会生成重复的 ID。解决方案通常是:在生成 ID 时,记录上一次生成 ID 时所使用的时间戳。当发现当前时间戳小于上次记录的时间戳时,就意味着发生了时钟回拨。
- 方案一(拒绝服务): 直接抛出异常,拒绝生成 ID,等待时钟恢复正常。这种方案简单,但会暂时影响可用性,适合对 ID 连续性要求不高的场景。
- 方案二(等待追赶): 如果回拨幅度很小(比如几毫秒),程序可以
while(currentTime < lastTimestamp)
这样自旋等待,直到当前时间追赶上上次的时间戳。这会造成短暂的线程阻塞。 - 方案三(使用备用位): 一些改进版的雪花算法会预留几位作为扩展位,当发生时钟回拨时,在这几位上做自增,从而在短时间回拨内仍能生成不同的 ID。(这种方案实现较为复杂)
- 业界实践(美团 Leaf): 在发生时钟回拨时,切换到另一种备用 ID 生成策略(如号段模式),或者直接报错。
3. RBAC(基于角色的访问控制)
原理
RBAC (Role-Based Access Control) 是一种主流且灵活的权限管理模型。它的核心思想是在 用户 (User) 和 权限 (Permission) 之间引入一个中间层——角色 (Role)。权限不再直接授予用户,而是授予角色;然后将角色分配给用户。这样,用户与权限实现了解耦,当需要修改大量用户的权限时,只需修改他们共同拥有的角色的权限即可,极大地简化了权限管理和维护。
核心三要素
- 用户 (User): 系统操作的主体。
- 角色 (Role): 权限的集合,代表了一组特定的职责或身份,如“管理员”、“文章编辑”、“普通会员”。
- 权限 (Permission): 对系统中特定资源进行特定操作的许可。通常用一个字符串表示,如
user:create
、order:delete
、article:publish
。
面试题:“请设计一个 RBAC 权限管理系统的数据库表结构。”
回答要点:
一个基础的 RBAC 模型至少需要五张表:
- 用户表 (t_user): 存储用户信息。
user_id
(主键),username
,password
, …
- 角色表 (t_role): 存储角色信息。
role_id
(主键),role_name
(角色名, 如”管理员”),role_key
(角色标识, 如”admin”), …
- 权限表 (t_permission): 存储具体的权限点信息。
permission_id
(主键),permission_name
(权限名称, 如“新增用户”),permission_code
(权限标识, 如user:add
),parent_id
(用于菜单层级), …
- 用户-角色关联表 (t_user_role): 存储用户和角色的多对多关系。
user_id
(外键),role_id
(外键)。(主键是user_id
和role_id
的联合主键)
- 角色-权限关联表 (t_role_permission): 存储角色和权限的多对多关系。
role_id
(外键),permission_id
(外键)。(主键是role_id
和permission_id
的联合主键)
4. Redis 进阶详解
核心特点
- 基于内存: Redis 是一个内存数据结构存储系统,所有数据都存放在内存中,因此读写速度极快。
- 单线程模型: Redis 的核心网络模型处理客户端请求是单线程的。这避免了多线程环境下的上下文切换和锁竞争带来的开销。(注意:Redis 6.0 之后引入了多线程来处理 I/O,但执行命令的核心仍然是单线程)。
- IO多路复用: 单线程能实现高性能的关键在于它使用了 IO 多路复用技术(如 Linux 下的 epoll)。该技术允许单个线程同时监听多个网络连接上的 IO 事件,当某个连接准备好读或写时,才去处理它,极大地提高了并发处理能力。
- 丰富的数据类型: 支持 String, Hash, List, Set, Sorted Set, Bitmap, HyperLogLog, GEO 等多种数据结构。
缓存三大问题与解决方案
- 缓存穿透:
- 问题: 客户端查询一个数据库和缓存中都不存在的数据。这导致每次请求都会绕过缓存,直接打到数据库上,当有大量此类请求时,会给数据库带来巨大压力。
- 解决:
- 缓存空对象: 当从数据库查询不到数据时,也在缓存中存入一个特殊的空值(如
null
或特定字符串),并设置一个较短的过期时间。 - 布隆过滤器 (Bloom Filter): 在访问缓存之前,先通过布隆过滤器判断 key 是否可能存在。布隆过滤器可以高效地判断一个元素一定不存在,从而在第一层就拦截掉大量无效请求。
- 缓存空对象: 当从数据库查询不到数据时,也在缓存中存入一个特殊的空值(如
- 缓存击穿:
- 问题: 某个热点 Key 在某一时刻突然失效,而此时恰好有大量的并发请求访问这个 Key,这些请求会同时穿透缓存,直接打到数据库上,可能导致数据库瞬间崩溃。
- 解决:
- 设置热点 Key 永不过期: 对于一些访问极其频繁且数据相对固定的热点数据,可以考虑不设置过期时间,通过后台任务定时更新。
- 使用分布式锁: 在查询数据库之前,先获取一个该 Key 对应的分布式锁。只有第一个获取到锁的线程才能去查询数据库并回写缓存,其他线程则等待或直接返回。
- 缓存雪崩:
- 问题: 大量的缓存 Key 在同一时间集中失效(例如,在应用启动时缓存了大量数据,并设置了相同的过期时间),导致所有请求都瞬间涌向数据库,造成数据库压力剧增甚至宕机。
- 解决:
- 过期时间加随机值: 在设置缓存的过期时间时,在一个基础时间上增加一个随机数,使得 Key 的失效时间点尽可能分散。
- 多级缓存: 建立多级缓存体系,如 Nginx 缓存 + Redis 缓存 + JVM 本地缓存(Caffeine/Guava Cache)。
- 熔断降级: 使用 Hystrix 或 Sentinel 等组件,当检测到数据库压力过大时,进行熔断或降级处理,暂时不访问数据库,返回一个默认值或提示信息。
5. 消息队列(MQ)
核心作用
- 异步 (Asynchronous): 将耗时的操作(如发送邮件、生成报表)作为消息放入 MQ,主流程可以立即返回,无需等待这些操作完成,从而提高系统的响应速度和吞吐量。
- 解耦 (Decoupling): 生产者和消费者之间通过 MQ 进行通信,无需直接相互依赖。任何一方的修改、宕机或升级都不会影响到另一方,增强了系统的灵活性和可维护性。
- 削峰 (Peak Shaving): 在秒杀、大促等高并发场景下,将瞬时涌入的大量请求暂存在 MQ 中,下游的消费者系统可以按照自己的处理能力,平稳地从 MQ 中拉取并处理请求,避免了流量洪峰直接冲垮下游服务。
面试题:“请列举你使用消息队列时可能遇到的问题,并给出解决方案。”
回答要点:
- 消息丢失 (Message Loss):
- 问题: 消息从生产者发出后,由于网络或 MQ 服务故障,未能成功到达消费者。
- 解决:
- 生产者端: 开启生产者的
confirm
或ack
机制,确保消息被 MQ 成功接收。如果发送失败,可以进行重试。 - MQ 服务端: 对消息进行持久化,防止 MQ 宕机导致消息丢失(如 RabbitMQ 的持久化队列和消息,Kafka 的磁盘存储)。
- 消费者端: 消费者在处理完业务逻辑后,再向 MQ 发送确认应答(
ack
)。如果处理过程中消费者宕机,MQ 没有收到ack
,会将该消息重新投递给其他消费者。
- 生产者端: 开启生产者的
- 重复消费 (Duplicate Consumption):
- 问题: 由于网络抖动、消费者
ack
超时等原因,MQ 可能会重复投递同一条消息。 - 解决: 核心是保证消费逻辑的幂等性 (Idempotence)。即多次执行同一个操作,结果都是相同的。实现方式有:
- 在数据库中为业务操作创建一个唯一键,每次操作前先检查该唯一键是否存在。
- 使用一个全局唯一的业务 ID(如订单号),在处理消息前,先查询这个 ID 是否已经被处理过(例如,存入 Redis Set 或数据库)。
- 问题: 由于网络抖动、消费者
- 消息堆积 (Message Backlog):
- 问题: 生产者的生产速度远大于消费者的消费速度,导致大量消息在 MQ 中积压,占用资源并可能导致消息超时丢失。
- 解决:
- 水平扩展消费者: 增加消费者实例的数量,并行处理消息。这是最常用的方法。
- 优化消费逻辑: 检查消费者代码,看是否有可以优化的慢操作(如 I/O 密集型操作)。
- 消息分片/分区: 对 Topic 进行分区(Partitioning),让不同的消费者组处理不同的分区,提高并发度。
- 增加预警监控: 对消息堆积数量设置阈值,达到阈值时及时告警,人工介入。
6. 分布式锁详解
作用
在分布式系统环境下,多个进程或服务器上的多个线程需要访问同一个共享资源时,为了保证数据的一致性和操作的原子性,需要一种机制来确保在同一时刻,只有一个客户端能够持有锁并访问该资源。
实现方案对比
实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
基于数据库 | 实现简单,直接利用数据库(如唯一索引、悲观锁 for update )。 |
性能开销大,有锁库锁表的风险,不可重入,不是阻塞锁,数据库单点故障问题。 | 并发度不高的简单场景。 |
基于 ZooKeeper | 可靠性高,天然支持阻塞锁和可重入,解决死锁问题(临时节点),无锁过期问题,支持公平锁。 | 性能不如 Redis,实现复杂,依赖 ZK 集群。 | 对可靠性要求极高,且能容忍一定性能损耗的场景,如分布式协调。 |
基于 Redis | 性能极高,实现相对简单,有成熟的框架 (Redisson) 可用。 | 可靠性相对 ZK 稍差,需要处理锁过期和业务未执行完的问题,非公平锁。 | 互联网高并发、对性能要求高的绝大多数场景。 |
基于 Redis 的实现进阶
加锁的正确姿势:
使用 SET key value NX EX time 命令。
SET key value
: 设置键值。value
通常是一个唯一的随机字符串(如 UUID),用于标识锁的持有者。NX
: (if Not eXists),确保只有在 key 不存在时才能设置成功,实现了“加锁”的原子性。EX time: 设置一个自动过期时间(秒),防止因客户端宕机而导致死锁。
这三个参数必须在一个命令中执行,保证原子性。
解锁的正确姿势:Lua 脚本
为什么需要 Lua: 解锁操作包含“判断”和“删除”两步:1.
GET
锁的value
,判断是否与自己加锁时设置的随机字符串相等。2. 如果相等,则DEL
锁。如果不用 Lua 脚本,在执行完第一步后,若该线程阻塞,此时锁恰好过期,另一个线程获取了锁,那么当原线程恢复执行第二步时,就会误删掉新线程的锁。Lua 脚本示例:
1
2
3
4
5
6-- 脚本接收两个参数:KEYS[1] 是锁的 key,ARGV[1] 是加锁时设置的唯一值
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
Lua 脚本可以确保多个命令在 Redis 服务端被原子性地执行,杜绝了上述问题。
Redis 乐观锁:WATCH 命令
作用:
WATCH
命令可以监视一个或多个 key,如果在事务EXEC
执行之前,任何一个被监视的 key 被其他命令修改了,那么整个事务将被取消,EXEC
返回nil
。原理: 这是一种检查后设置 (Check-And-Set, CAS) 的实现。它不是真正的加锁,而是在更新数据时检查数据是否被修改过。
使用场景: 适用于读多写少的并发场景,可以减少锁的开销。例如,更新商品库存。
WATCH stock_key
// 监视库存current_stock = GET stock_key
// 获取当前库存(在客户端代码中判断
current_stock
是否足够)MULTI
// 开启事务SET stock_key new_stock
// 准备更新库存EXEC // 执行事务
如果从 WATCH 到 EXEC 之间 stock_key 被其他客户端修改,EXEC 将失败,此时客户端需要重试整个操作。
面试题:“Redis 分布式锁锁过期了但业务没执行完怎么办?”
回答要点:
这是分布式锁的一个经典问题,被称为锁的超时续期问题。
- 问题根源: 我们给锁设置了一个过期时间,比如 30 秒,但业务执行了 35 秒。在第 30 秒时锁会自动释放,其他线程就能获取到锁,导致并发问题。
- 解决方案:“看门狗”(Watchdog)机制。
- 原理: 比如 Java 中的 Redisson 框架就内置了看门狗。当一个线程获取锁成功后,Redisson 会启动一个后台线程(看门狗),定期(例如每 10 秒)检查该线程是否还持有锁。如果持有,并且业务仍在执行,看门狗就会自动为这个锁延长过期时间(续期),比如再续 30 秒。这个过程会一直持续,直到业务执行完毕,线程主动释放锁,看门狗才会停止。
- 总结: 看门狗机制通过后台线程自动续期,确保了在业务执行完成之前,锁不会因为超时而提前释放,从而保证了锁的可靠性。
7. 分布式事务详解
作用
在微服务架构中,一个业务操作可能需要调用多个独立的服务来共同完成(例如,电商下单操作需要调用订单服务、库存服务、积分服务)。分布式事务旨在保证这些跨服务的数据库操作能够遵循 ACID 原则,要么全部成功,要么全部回滚,以确保数据的最终一致性。
解决方案深入分析
- XA (2PC/3PC): 两阶段/三阶段提交协议。
- 角色: 事务管理器 (Transaction Manager, TM) 和 资源管理器 (Resource Manager, RM)。
- 流程 (2PC):
- 准备阶段 (Prepare): TM 通知所有 RM 准备提交,RM 执行本地事务并锁定资源,但不提交,然后向 TM 报告准备就绪。
- 提交/回滚阶段 (Commit/Rollback): 如果所有 RM 都准备就绪,TM 通知所有 RM 提交;否则,通知所有 RM 回滚。
- 评价: 是一种强一致性的方案,但协议复杂,性能差,同步阻塞模型会长时间锁定资源,且协调器存在单点故障风险,在互联网高并发场景下很少使用。
- TCC (Try-Confirm-Cancel): 补偿型事务。
- 核心: 是一种业务层面的柔性事务方案,对代码侵入性强。
- 流程:
- Try: 对业务资源进行检查和预留。例如,扣减库存操作,Try 阶段是冻结库存。
- Confirm: 如果所有服务的 Try 阶段都成功,则执行所有服务的 Confirm 操作,真正完成业务。例如,将冻结的库存真实扣减。
- Cancel: 如果任何一个服务的 Try 阶段失败,则执行所有已成功服务的 Cancel 操作,释放预留的资源。例如,解冻之前被冻结的库存。
- 评价: 性能较好,数据一致性高于可靠消息方案。但开发成本极高,需要为每个操作都编写 Try, Confirm, Cancel 三个幂等的方法。
- Saga 模式: 长事务解决方案。
- 核心: 将一个大的分布式事务拆分成多个本地事务,每个本地事务都有一个对应的补偿操作。
- 流程:
- 正向执行: Saga 协调器按顺序调用 T1, T2, T3…Tn。
- 反向补偿: 如果 Ti 失败,Saga 会按相反顺序调用前面已成功事务的补偿操作 C(i-1)…C2, C1,进行回滚。
- 与 TCC 对比:
- TCC 有资源预留阶段,锁资源时间长;Saga 没有预留,直接提交本地事务,锁资源时间短。
- TCC 的补偿是逆向操作 (Cancel);Saga 的补偿是反向操作。
- 评价: 适合于业务流程长、需要保证最终一致性的场景。同样对代码有侵入性,需要设计好每个子事务的补偿逻辑。
- 基于可靠消息的最终一致性 (常用):
- 核心: 这是微服务架构中最常用的柔性事务方案。
- 原理: 服务 A 在执行完本地事务后,发送一条消息到 MQ。服务 B 订阅该消息,消费消息并执行自己的本地事务。
- 关键问题: 如何保证本地事务执行和消息发送的原子性?
- 事务消息 (RocketMQ 支持): 生产者先发送一条“半消息”到 MQ,MQ 收到后不投递。然后生产者执行本地事务。如果事务成功,则向 MQ 发送确认,MQ 投递该消息;如果事务失败,则通知 MQ 删除该半消息。
- 本地消息表: 业务操作和“待发送消息”记录在同一本地事务中。一个后台任务定时扫描这张表,将消息发送到 MQ,发送成功后更新表状态。
- 评价: 实现了服务间的解耦,性能高,吞吐量大。但它不保证数据的强一致性,只保证最终一致性,存在一个短暂的数据不一致状态窗口。需要处理好消息的可靠投递和幂等消费问题。
Lua 脚本详解 (在 Redis 中的应用)
1. Lua 是什么?
Lua 是一种轻量级、可扩展的脚本语言,被设计用于嵌入到其他应用程序中,从而为应用程序提供灵活的扩展和定制功能。它以其简洁的语法、高效的执行性能和极小的内存占用而闻名。
在 Redis 的上下文中,Lua 脚本提供了一种在 Redis 服务器端执行复杂逻辑的强大能力。
2. 为什么 Redis 要支持 Lua 脚本?
- 原子性 (Atomicity): 这是在 Redis 中使用 Lua 最核心的原因。Redis 会将整个 Lua 脚本作为一个单独的命令来执行,在脚本执行期间,不会有其他客户端的命令被插入执行。这完美地解决了需要组合多个 Redis 命令才能完成一个业务逻辑时,可能出现的竞态条件问题。例如前面提到的“判断锁并删除锁”的操作,如果分两步执行,就不是原子的,而封装在 Lua 脚本中就是原子的。
- 减少网络开销: 对于需要多次与 Redis 交互的复杂操作,可以将所有逻辑封装在一个 Lua 脚本中,一次性发送给 Redis 服务器。客户端只需发送一次请求,而不是多次,这显著减少了客户端与服务器之间的网络往返时间(RTT),提升了性能。
- 代码复用: 编写好的 Lua 脚本可以被缓存(通过
SCRIPT LOAD
命令生成一个 SHA1 校验和),之后客户端可以通过这个简短的 SHA1 校验和(使用EVALSHA
命令)来调用脚本,避免了每次都发送完整的脚本内容。
3. 如何在 Redis 中使用 Lua 脚本?
通过 EVAL 或 EVALSHA 命令来执行。
EVAL script numkeys key [key …] arg [arg …]
script
: 要执行的 Lua 脚本字符串。numkeys
: 后面跟的key
参数的数量。这有助于 Redis 正确地将参数区分为键名(KEYS
)和普通参数(ARGV
),这对于 Redis Cluster 模式下的路由至关重要。key [key ...]
:脚本中要操作的 Redis 键,在 Lua 脚本中可以通过全局变量KEYS
table(例如KEYS[1]
)来访问。arg [arg ...]
:传递给脚本的额外参数,在 Lua 脚本中可以通过全局变量ARGV
table(例如ARGV[1]
)来访问。
示例:实现一个安全的库存扣减
1 | -- 脚本逻辑:检查库存是否充足,如果充足则扣减并返回1,否则返回0 |
这个脚本保证了“读取库存”和“扣减库存”两个操作的原子性,避免了在高并发下超卖的问题。
Token 认证机制详解
1. Token 是什么?
Token(令牌)是在服务端生成的一串加密字符串,作为客户端进行请求的一个“凭证”。当用户第一次登录成功后,服务端会生成一个 Token 并返回给客户端。之后,客户端在每次请求需要身份认证的接口时,都需要在请求头(通常是 Authorization
字段)中携带这个 Token。服务端接收到请求后,会验证 Token 的有效性,如果验证通过,则处理该请求;否则,拒绝该请求。
一个典型的 Token 是 JWT (JSON Web Token),它由三部分组成,用 .
分隔:
- Header (头部): 包含了令牌的类型(
typ
,即 JWT)和所使用的签名算法(alg
,如 HMAC SHA256 或 RSA)。 - Payload (负载): 包含了“声明 (claims)”,是存放实际需要传递的数据的地方。例如用户ID(
sub
)、签发时间(iat
)、过期时间(exp
)以及其他自定义的用户信息。注意:Payload 部分是 Base64 编码的,并非加密,因此不应存放敏感信息。 - Signature (签名): 对前两部分(Header 和 Payload)使用指定的算法和存储在服务端的密钥(secret)进行签名。这个签名的作用是防止数据被篡改。服务端收到 Token 后,会用同样的算法和密钥重新计算签名,并与 Token 中的签名进行比对,若一致,则说明 Token 未被篡改且是可信的。
2. Token 认证原理(工作流程)
- 登录: 用户使用用户名和密码发起登录请求。
- 验证: 服务端验证用户的凭据是否正确。
- 签发 Token: 验证成功后,服务端根据用户ID、角色等信息,结合密钥(secret),生成一个 Token。
- 返回 Token: 服务端将生成的 Token 返回给客户端。
- 存储 Token: 客户端(浏览器、App)将 Token 存储起来,通常放在
localStorage
、sessionStorage
或HttpOnly
的 Cookie 中。 - 携带 Token 请求: 客户端在后续每次请求受保护的 API 时,都会在 HTTP 请求头的
Authorization
字段中附上 Token,格式通常为Bearer <token>
。 - 验证 Token: 服务端收到请求后,从请求头中解析出 Token,然后:
- 验证签名是否正确,确保 Token 未被篡改。
- 检查 Token 是否在有效期内(
exp
声明)。 - 如果验证通过,则从 Payload 中获取用户信息,执行业务逻辑并返回结果。
- 如果验证失败,则返回
401 Unauthorized
错误。
3. 为什么使用 Token?(与 Session 的区别)
在 Web 开发早期,Session-Cookie
机制是主流。服务端在用户登录后创建一个 Session 对象存储在内存或 Redis 中,并生成一个 Session ID,通过 Cookie 返回给浏览器。浏览器后续请求会自动带上这个 Session ID,服务端根据 ID 找到对应的 Session 信息来识别用户。
Token 机制相比 Session 机制,核心优势在于“无状态性 (Statelessness)”,这带来了以下好处:
特性对比 | Session 机制 | Token 机制 | 优势说明 |
---|---|---|---|
状态存储 | 有状态 (Stateful)。Session 信息需存储在服务端。 | 无状态 (Stateless)。用户信息包含在 Token 的 Payload 中,服务端无需存储。 | 减轻服务端压力。服务端不需要为每个在线用户维护一个 Session 对象。 |
可扩展性 | 差。在分布式或集群环境下,需要解决 Session 共享问题(如 Session Sticky、Session Replication、集中存储)。 | 好。由于服务端不存储状态,任何一台服务器只要有相同的密钥,就能验证 Token,天然适合分布式和微服务架构。 | 轻松实现水平扩展。增加服务器节点无需额外配置 Session 共享。 |
跨域支持 | 有限。基于 Cookie 的 Session 机制在跨域(CORS)场景下处理起来比较麻烦。 | 优秀。Token 可以通过 HTTP 请求头发送,不受同源策略限制,非常适合前后端分离和跨域 API 调用。 | 适应现代架构。完美支持 SPA(单页应用)、移动 App 等多种客户端。 |
安全性 | 依赖 Cookie 机制,可能遭受 CSRF 攻击。 | 如果 Token 存储在 localStorage ,可能遭受 XSS 攻击。需要综合考虑存储方式。 |
两者各有安全风险点,需配合其他安全策略。Token 机制不依赖 Cookie,更灵活。 |
适用性 | 适合传统的、一体化的 Web 应用。 | 适合现代的、分布式的、跨终端的(Web, Mobile, IoT)应用架构。 | Token 更具通用性和前瞻性。 |
4. 双令牌策略 (Access Token + Refresh Token)
- Q: 为什么不用单个 Token?
- 如果 Token 有效期很长(如一个月): 安全风险高。一旦 Token 在此期间被窃取,攻击者可以长时间冒充用户身份进行操作。
- 如果 Token 有效期很短(如 15 分钟): 用户体验差。用户需要频繁地重新登录,这是无法接受的。
- A: 双令牌策略应运而生,完美平衡了安全性和用户体验。
- Access Token (访问令牌): 它的有效期非常短(如 15 分钟到 1 小时)。它被用于访问受保护的资源,由于其生命周期短,即使被窃取,攻击者能造成的危害也有限。
- Refresh Token (刷新令牌): 它的有效期很长(如 7 天或 30 天)。它的唯一作用是用来获取新的 Access Token。Refresh Token 本身不包含任何权限信息,不能用于直接访问 API。
- 双令牌工作流程(静默刷新)
- 首次登录: 用户登录成功,服务端返回一个短期的
Access Token
和一个长期的Refresh Token
。客户端将两者都存储起来。 - 正常访问: 客户端使用
Access Token
访问 API。服务端验证Access Token
通过,返回数据。 - Access Token 过期: 客户端再次使用过期的
Access Token
访问 API,服务端返回401 Unauthorized
错误,并可能带上一个特定错误码,告知客户端是“令牌过期”而非“无效令牌”。 - 静默刷新: 客户端的请求拦截器捕获到这个
401
错误后,不会立即跳转到登录页。而是自动发起一个特殊的请求,携带那个长期的Refresh Token
去访问一个专门的刷新接口(如/api/token/refresh
)。 - 签发新令牌: 服务端验证
Refresh Token
的有效性(通常会将其存储在 Redis 或数据库中进行比对,以实现强制下线功能)。如果验证通过,就生成一个新的 Access Token(有时也会生成一个新的Refresh Token
,这被称为刷新令牌滚动策略)并返回给客户端。 - 重试请求: 客户端收到新的
Access Token
后,用它替换掉本地旧的Access Token
,然后自动重新发送刚才因令牌过期而失败的那个请求。 - 无感体验: 整个过程对用户是透明的,用户感觉不到令牌已经过期并被刷新,实现了“静默刷新”,体验非常流畅。
- Refresh Token 过期: 如果
Refresh Token
也过期了,那么刷新接口会返回错误,此时客户端才会真正清除用户凭证并引导用户重新登录。
- 首次登录: 用户登录成功,服务端返回一个短期的
5. Token 相关场景与面试题
- 面试题 1:“Token 应该存储在哪里?localStorage、sessionStorage 还是 Cookie?”
回答要点:
- localStorage/sessionStorage:
- 优点: 方便 JavaScript 直接读写,容量较大(5MB)。
- 缺点: 容易受到 XSS (跨站脚本攻击)。如果网站存在 XSS 漏洞,攻击者可以执行 JS 代码直接窃取存储在其中的 Token。
- Cookie (HttpOnly):
- 优点: 设置为
HttpOnly
后,JavaScript 将无法读写该 Cookie,可以有效防御 XSS 攻击。浏览器会自动在同域请求中携带它。 - 缺点: 容易受到 CSRF (跨站请求伪造) 攻击。攻击者可以诱导用户点击恶意链接,浏览器会自动带上用户的 Cookie 去请求你的网站,执行非用户本意的操作。需要配合 Anti-CSRF Token 等机制来防御。容量较小(4KB)。
- 优点: 设置为
- 最佳实践/结论: 没有绝对完美的选择,需要权衡。
- 高安全性方案: 将
Refresh Token
存储在HttpOnly
的 Cookie 中(防止 XSS),将Access Token
存储在内存中(变量里,页面刷新丢失)或sessionStorage
中。同时,后端接口必须实施 CSRF 防御策略。 - 主流实践方案 (前后端分离): 将 Token 存储在
localStorage
中,并在Authorization
请求头中携带。同时,前端必须严格做好输入过滤和内容转义,尽最大努力防止 XSS 漏洞的出现。
- 高安全性方案: 将
好的,我们来详细探讨双令牌(Dual Token)的存储方案。这种方案是现代 Web 安全认证的常见模式,主要用于解决传统 JWT(JSON Web Token)方案中的一些安全问题,尤其是防止 XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)攻击。
双令牌方案的核心思想是:将用户认证所需的权限信息分为两个独立的令牌,并采用不同的存储和传输方式,以达到更高的安全性。这两个令牌通常是:
- 访问令牌(Access Token)
- 刷新令牌(Refresh Token)
1. 访问令牌 (Access Token)
访问令牌是用于访问受保护资源的凭证。它通常是一个有效期很短的 JWT。
- 作用:携带用户的身份信息和权限,用于后端 API 接口的认证。后端在收到请求时,会验证这个令牌的有效性(签名、有效期)。
- 有效期:非常短,通常只有几分钟到几小时。这极大地降低了令牌被盗后造成的损失。
- 存储方案:为了防止 XSS 攻击,访问令牌通常不应该存储在浏览器本地的
localStorage
或sessionStorage
中,因为这些存储空间可以被恶意脚本访问。- 推荐方案:存储在内存中。当用户关闭浏览器标签页时,令牌就会被清除。这意味着用户每次打开新标签页都需要重新获取令牌,但这是为了安全牺牲一些便利性。
- 次优方案:存储在HTTP-only Cookie 中。这种 Cookie 只能由服务器设置,无法通过 JavaScript 脚本读取,有效防止了 XSS 攻击。但是,它仍然可能受到 CSRF 攻击的风险。
2. 刷新令牌 (Refresh Token)
刷新令牌是用于获取新的访问令牌的凭证。它通常是一个有效期很长的令牌,可以用于在访问令牌过期后,无感地重新获取一个新的。
- 作用:当访问令牌过期时,客户端携带刷新令牌向认证服务器请求一个新的访问令牌。
- 有效期:很长,通常是几天、几周甚至几个月。
- 存储方案:由于刷新令牌是获取访问令牌的关键,它的存储必须绝对安全。
- 推荐方案:存储在HTTP-only Cookie 中。HTTP-only 属性可以防止恶意脚本(XSS)读取该令牌。同时,由于刷新令牌只发送给认证服务器,并且不用于访问其他 API,可以采取一些额外的安全措施,如 CSRF 令牌或 SameSite Cookie 属性,来防止 CSRF 攻击。
- 注意:刷新令牌绝对不应该存储在
localStorage
中。
双令牌工作流程(结合存储方案)
- 首次登录:
- 用户通过用户名/密码登录。
- 认证服务器验证通过后,生成一个访问令牌(Access Token)和一个刷新令牌(Refresh Token)。
- 服务器将刷新令牌设置到 HTTP-only Cookie 中,发送给浏览器。
- 服务器将访问令牌返回给前端,前端将其存储在内存中。
- 访问受保护资源:
- 前端发起 API 请求时,从内存中取出访问令牌,并将其放在请求头(如
Authorization: Bearer <AccessToken>
)中发送给后端。 - 后端 API 服务器验证访问令牌的有效性,如果有效则返回数据。
- 前端发起 API 请求时,从内存中取出访问令牌,并将其放在请求头(如
- 访问令牌过期:
- 一段时间后,访问令牌过期。前端再次发起 API 请求时,后端会返回一个 401 Unauthorized 错误。
- 前端捕获到这个错误后,会发起一个刷新请求(通常是向一个专门的
/refresh
接口),这个请求会自动带上存储在 HTTP-only Cookie 中的刷新令牌。
- 刷新令牌工作:
- 认证服务器收到刷新请求,验证刷新令牌的有效性。
- 如果刷新令牌有效,服务器会生成一个新的访问令牌和(可选)一个新的刷新令牌。
- 服务器将新的访问令牌返回给前端,前端更新内存中的令牌。
- 服务器将新的刷新令牌设置到 HTTP-only Cookie 中。
- 前端使用新的访问令牌重新发起之前的 API 请求。
为什么这个方案更安全?
- 防止 XSS 攻击:最敏感的刷新令牌存储在 HTTP-only Cookie 中,脚本无法读取。而访问令牌虽然存储在内存中,但其有效期短,即使被盗,也很快会失效。
- 防止 CSRF 攻击:刷新令牌通常只用于一个特定的
/refresh
接口,并且可以与 CSRF 令牌(或 SameSite Cookie)结合使用,进一步降低风险。同时,由于其他 API 接口不依赖于 Cookie 中的刷新令牌,而是依赖于请求头中的访问令牌,因此无法被 CSRF 攻击者利用。 - 提升用户体验:尽管访问令牌有效期很短,但用户无需频繁登录,因为刷新令牌可以在后台无感地获取新令牌。
总之,双令牌方案通过将令牌职责分离和采用不同的存储机制,在保证用户体验的同时,显著提升了系统的安全性,是现代 Web 应用中一种非常成熟和推荐的认证模式。
- localStorage/sessionStorage:
- 面试题 2:“用户点击“退出登录”时,Token 如何失效?”
- 回答要点:
核心观点:无状态 JWT 的特性决定了“退出登录”的本质
首先,要明确指出 JWT 的核心特点是无状态。这意味着服务器无法主动让一个已经签发出去的令牌失效,这与传统的 Session 机制(服务器可以主动销毁会话)有本质区别。因此,“退出登录”的实现方式也截然不同。
1. 客户端实现(常规退出登录)
- 实现方式:这是最简单、最常见的方案,适用于用户主动退出登录的情况。
- 具体操作:
- 前端代码在用户点击“退出登录”时,执行一个简单的操作:从本地存储(如 localStorage、sessionStorage、内存或 Cookie)中删除 Access Token。
- 一旦令牌被删除,前端后续的请求将无法携带有效的
Authorization
头,服务器会返回 401 错误,从而实现了“退出登录”的效果。
- 局限性:这种方法只是一个客户端行为。如果令牌被复制或被攻击者截获,它在过期之前仍然是有效的。
2. 服务端实现(强制下线或即时失效)
- 引入背景:当需要实现更严格的安全需求时,如“管理员踢人下线”、“用户修改密码后所有设备下线”或“令牌泄露后立即失效”,单纯的客户端清除是不够的。此时,我们必须打破无状态的特性,引入服务端的状态管理。
- 方案一:黑名单机制 (Blacklist)
- 工作原理:
- 服务端维护一个高效、可快速查找的黑名单列表(通常使用 Redis 的 Set 数据结构,因为它具有 O(1) 的查找效率)。
- 在用户退出登录或需要强制下线时,将该 JWT 的唯一标识
jti
(或整个令牌本身)添加到这个黑名单中。 - 为黑名单中的条目设置一个过期时间,该过期时间应与令牌本身的剩余有效期一致。
- 在每次验证 JWT 的过程中,除了校验签名和过期时间外,额外增加一步:检查该令牌的 jti 是否存在于黑名单中。如果存在,则认证失败。
- 工作原理:
- 方案二:基于 Refresh Token 的管理
- 工作原理:这种方案是基于双令牌模式设计的,更优雅地解决了问题。
- 服务器在签发
Access Token
和Refresh Token
时,会将Refresh Token
存入数据库或 Redis 中。 - 当用户点击“退出登录”或需要强制下线时,服务器删除或标记失效存储在服务器端的
Refresh Token
。 - 当用户的
Access Token
过期后,它将无法通过Refresh Token
换取新的Access Token
,因为服务器端的Refresh Token
已经失效。这样,用户就无法继续保持登录状态。
- 服务器在签发
- 工作原理:这种方案是基于双令牌模式设计的,更优雅地解决了问题。
总结与对比
方案 优点 缺点 适用场景 客户端清除 实现简单,开销小。 安全性最低,无法强制下线。 用户主动退出登录。 黑名单机制 能够实现即时下线,安全可控。 增加了服务端开销(额外的 Redis 存储和查询)。 强制下线、修改密码、令牌泄露。 Refresh Token 方案 既实现了即时下线,又无需频繁查询黑名单,架构更优雅。 必须采用双令牌模式,实现相对复杂。 现代前后端分离应用,对安全性要求较高。
- 回答要点:
- 面试题 3:“请你设计一个支持 Web 端和 App 端统一登录的认证系统。”
- 回答要点:
- 这正是 Token 认证机制的典型应用场景。我会采用基于 OAuth 2.0/OIDC 或自定义的**双令牌(Access/Refresh Token)**方案。
- 统一认证中心 (UAC): 建立一个独立的认证服务,负责处理所有客户端(Web, iOS, Android)的登录、注册、Token 签发和刷新。
- API 网关: 所有业务请求都通过 API 网关。网关的核心职责之一就是统一鉴权。它会拦截所有请求,解析
Authorization
头中的Access Token
,调用认证中心或自行验证 Token 的有效性。验证通过后,可以将解析出的用户信息(如用户ID)附加到请求头中,再转发给后端的业务微服务。 - 业务微服务: 业务微服务本身不再关心 Token 的验证细节,它们信任来自网关的请求,直接从请求头中获取用户信息进行业务处理,实现了业务与认证的解耦。
- 流程:
- Web/App 客户端引导用户到认证中心进行登录。
- 登录成功后,认证中心返回
Access Token
和Refresh Token
。 - 客户端保存令牌,后续访问业务 API 时,在请求头携带
Access Token
。 - API 网关拦截请求,验证
Access Token
。 Access Token
过期后,客户端使用Refresh Token
向认证中心申请新令牌。
- 这个架构具有高内聚、低耦合、可扩展性强、安全性高的优点。
- 回答要点:
Redis 详细技术解析
Redis 核心架构与原理
内存模型与数据结构
Redis采用基于内存的存储架构,所有数据都保存在RAM中,这是其高性能的根本原因。Redis使用了多种底层数据结构来实现上层的抽象数据类型:
SDS(Simple Dynamic String) Redis没有直接使用C语言的字符串,而是构建了SDS。SDS在字符串头部记录了长度信息,避免了strlen的O(n)复杂度,同时预分配空间减少内存重分配次数。
跳跃表(Skip List) 有序集合的底层实现之一,是一种随机化的数据结构,通过多层链表实现O(log N)的查找复杂度。相比红黑树,跳跃表实现更简单,且支持范围查询。
压缩列表(Ziplist) 当哈希、列表、有序集合元素较少时使用的紧凑存储结构,所有元素存储在一块连续内存中,节省内存但插入删除效率较低。
字典(Dict) Redis的核心数据结构,使用开放寻址法解决哈希冲突,支持渐进式rehash。当负载因子过高时,会创建新的哈希表并逐步迁移数据。
单线程模型与事件循环
Redis 6.0之前采用单线程模型处理客户端请求,通过I/O多路复用(epoll/kqueue)实现高并发。单线程避免了线程切换开销和并发控制问题,但也限制了CPU利用率。
事件循环机制 Redis使用Reactor模式的事件循环,分为文件事件和时间事件:
- 文件事件:处理客户端连接、读写请求
- 时间事件:处理定时任务,如过期键删除、持久化等
Redis 6.0引入了多线程I/O,但命令执行仍是单线程,多线程只用于网络I/O操作的读写,这样既提高了网络处理能力,又保持了数据操作的原子性。
数据类型详解与应用场景
Redis 的数据结构详解
这是 Redis 最有特色的地方,理解这些数据结构是掌握 Redis 的关键。
1. String (字符串)
- 最基本的数据结构,可以存储任何类型的数据,比如字符串、数字或二进制数据。一个键最多可以存储 512MB 的值。
- 常用命令:
SET key value
: 设置键值对。GET key
: 获取键对应的值。INCR key
: 将键对应的值加 1,常用于计数器。MSET key1 value1 key2 value2
: 同时设置多个键值对。MGET key1 key2
: 同时获取多个键的值。
- 应用场景: 缓存、计数器、分布式锁等。
2. Hash (哈希)
- 类似于一个键值对的集合。一个哈希键可以存储多个字段(field)和对应的值(value),非常适合存储对象。
- 常用命令:
HSET key field value
: 设置哈希键中一个字段的值。HGET key field
: 获取哈希键中一个字段的值。HGETALL key
: 获取哈希键中所有的字段和值。HMSET key field1 value1 field2 value2
: 同时设置多个字段。HDEL key field1 field2
: 删除一个或多个字段。
- 应用场景: 存储用户信息(如
user:100
作为键,name
、age
、city
作为字段),商品信息等。
3. List (列表)
- 一个有序的、可以重复的字符串集合。可以从列表的头部或尾部添加或删除元素。
- 常用命令:
LPUSH key value1 value2
: 从列表的头部插入一个或多个值。RPUSH key value1 value2
: 从列表的尾部插入一个或多个值。LPOP key
: 从列表的头部移除并返回一个值。RPOP key
: 从列表的尾部移除并返回一个值。LRANGE key start stop
: 获取列表中指定范围的元素。
- 应用场景: 消息队列(队列、栈)、最新文章列表、历史记录等。
4. Set (集合)
- 一个无序的、不重复的字符串集合。集合提供了交集、并集和差集等操作。
- 常用命令:
SADD key member1 member2
: 向集合中添加一个或多个成员。SMEMBERS key
: 获取集合中的所有成员。SISMEMBER key member
: 判断一个成员是否在集合中。SINTER key1 key2
: 求两个集合的交集。SUNION key1 key2
: 求两个集合的并集。
- 应用场景: 社交网络中的共同好友、标签系统、去重等。
5. Sorted Set (有序集合)
- 和集合类似,但每个成员都会关联一个分数(score)。通过这个分数,可以对集合中的成员进行排序。分数可以重复,但成员必须唯一。
- 常用命令:
ZADD key score1 member1 score2 member2
: 向有序集合中添加一个或多个带分数的成员。ZRANGE key start stop
: 按分数从小到大排序,获取指定范围的成员。ZREVRANGE key start stop
: 按分数从大到小排序,获取指定范围的成员。ZSCORE key member
: 获取一个成员的分数。ZINCRBY key increment member
: 给指定成员的分数增加指定值。
- 应用场景: 排行榜(游戏分数、热点新闻)、带权重的任务队列等。
Redis 的持久化机制
Redis 提供了两种持久化方式,可以将数据从内存保存到硬盘,确保数据不会因为服务重启而丢失。
1. RDB (Redis Database)
- 快照方式。它会在指定的时间间隔内,将内存中的数据快照写入到磁盘上一个二进制文件中(
dump.rdb
)。 - 优点: RDB 文件是一个非常紧凑的二进制文件,非常适合备份和灾难恢复。
- 缺点: 每次保存都会丢失从上次快照到现在之间的数据。
2. AOF (Append Only File)
- 日志方式。它会记录每一次对 Redis 数据库的写操作命令,并以追加(append)的方式写入到文件中。当 Redis 重启时,会重新执行 AOF 文件中的命令来恢复数据。
- 优点: 数据丢失风险低,可以配置为每秒同步一次,或者每执行一个命令就同步一次。
- 缺点: AOF 文件通常比 RDB 文件大,且恢复速度可能较慢。
在实际应用中,通常会同时使用 RDB 和 AOF,以获得更高的可靠性。
String类型
String是Redis最基础的数据类型,底层可以是SDS、整数或浮点数。
常用命令:SET、GET、INCR、DECR、APPEND、GETRANGE 应用场景:
- 缓存:存储用户会话、配置信息
- 计数器:网站访问量、点赞数(INCR原子性保证)
- 分布式锁:SET key value NX EX seconds
- 限流:结合EXPIRE实现滑动窗口限流
Hash类型
Hash类型适合存储对象,避免了将对象序列化为JSON字符串的开销。
底层实现:元素较少时使用ziplist,元素较多时使用hashtable 应用场景:
- 存储用户信息:HSET user:1001 name “张三” age 25
- 购物车:HSET cart:1001 product:123 2
- 配置管理:分类存储不同模块的配置
List类型
List是双向链表实现,支持在两端进行O(1)的插入和删除操作。
常用命令:LPUSH、RPUSH、LPOP、RPOP、LRANGE、BLPOP 应用场景:
- 消息队列:生产者LPUSH,消费者BRPOP实现阻塞队列
- 最新列表:朋友圈动态、商品评论
- 栈和队列:LPUSH+LPOP实现栈,LPUSH+RPOP实现队列
Set类型
Set是无序集合,元素唯一,底层使用hashtable或intset实现。
集合运算:SINTER(交集)、SUNION(并集)、SDIFF(差集) 应用场景:
- 去重:统计网站独立访客
- 社交关系:共同好友、推荐用户
- 标签系统:用户标签、文章分类
- 抽奖系统:SRANDMEMBER随机抽取
Sorted Set类型
有序集合,每个元素关联一个分数,按分数排序。底层使用跳跃表和哈希表。
应用场景:
- 排行榜:游戏积分、热搜榜
- 延时队列:分数为执行时间戳
- 范围查询:按时间、按分数范围获取数据
持久化机制深度解析
RDB持久化
RDB通过fork子进程,将内存数据快照写入磁盘。
优点:
- 文件紧凑,适合备份和灾难恢复
- 恢复速度快
- 对Redis性能影响小(子进程操作)
缺点:
- 数据丢失风险:两次快照间的数据可能丢失
- fork过程会阻塞主进程
- 大数据集fork耗时较长
触发条件:
- 手动执行SAVE或BGSAVE命令
- 配置自动触发:save 900 1(900秒内至少1个键改变)
- 主从复制时自动生成RDB
AOF持久化
AOF记录每个写命令,通过重放命令恢复数据。
写入时机:
- always:每个写命令立即同步,安全但性能低
- everysec:每秒同步一次,平衡安全性和性能
- no:由操作系统决定同步时机,性能高但安全性低
AOF重写: AOF文件会越来越大,Redis提供重写机制优化:
- 遍历内存数据,用最少命令重建AOF文件
- 重写期间的新命令写入AOF重写缓冲区
- 原子性替换旧AOF文件
混合持久化: Redis 4.0引入RDB+AOF混合模式,重写时以RDB格式写入基础数据,增量命令以AOF格式追加,兼顾了恢复速度和数据安全。
缓存问题与解决方案
缓存穿透
查询不存在的数据,缓存无法生效,请求直达数据库。
解决方案:
- 空值缓存:查询结果为空时也缓存,设置较短过期时间
1 | if (data == null) { |
- 布隆过滤器:预先将所有可能存在的数据哈希到位数组
1 | BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01); |
- 参数校验:在API层面进行参数合法性校验
缓存击穿
热点数据过期瞬间,大量并发请求击穿缓存。
解决方案:
- 互斥锁:只允许一个线程查询数据库并重建缓存
1 | public String getData(String key) { |
- 热点数据永不过期:逻辑上设置过期时间,物理上不过期,异步更新
- 预热机制:系统启动时预先加载热点数据
- 二级缓存:L1缓存过期后,先返回L2缓存数据,异步更新L1
缓存雪崩
大量缓存同时过期或Redis宕机,请求涌向数据库。
解决方案:
- 过期时间随机化:避免同时过期
1 | int randomExpire = baseExpire + new Random().nextInt(300); // 基础时间+随机时间 |
- 多级缓存架构:
- L1:本地缓存(如Caffeine)
- L2:Redis分布式缓存
- L3:数据库
- 限流降级:使用Sentinel、Hystrix等组件
- Redis高可用:主从复制、哨兵模式、集群部署
缓存预热
系统启动时预先加载热点数据到缓存。
实现方式:
- 定时任务预热:凌晨低峰期执行
- 手动预热:管理后台触发预热任务
- 实时预热:监控系统发现热点数据自动预热
数据一致性保证
Cache Aside模式(旁路缓存)
应用程序负责维护缓存和数据库的一致性。
读操作:
- 先读缓存,命中则返回
- 缓存不命中,查询数据库
- 将数据写入缓存并返回
写操作:
- 先更新数据库
- 删除缓存(让下次读取时重新加载)
为什么是删除而不是更新缓存?
- 更新缓存可能存在并发问题
- 复杂查询的缓存更新成本高
- 删除缓存更简单可靠
延时双删策略
解决读写并发导致的数据不一致问题。
实现步骤:
1 | public void updateData(String key, Object data) { |
延时时间设置:通常为主从同步时间 + 读数据库时间 + 几十毫秒
基于消息队列的最终一致性
使用消息队列异步处理缓存更新,保证最终一致性。
实现流程:
- 更新数据库,发送消息到队列
- 消息消费者删除相关缓存
- 消费失败时重试,保证最终一致性
1 | // 发送缓存删除消息 |
分布式事务方案
对于强一致性要求高的场景,可以使用分布式事务。
2PC(两阶段提交):
- 准备阶段:协调者询问参与者是否准备好
- 提交阶段:所有参与者都准备好则提交,否则回滚
TCC(Try-Confirm-Cancel):
- Try:尝试执行,预留资源
- Confirm:确认提交
- Cancel:取消执行,释放资源
分布式锁实现
基于SET命令的分布式锁
1 | public class RedisDistributedLock { |
使用方式:
1 | String lockKey = "lock:user:1001"; |
Redlock算法
为了解决单点故障问题,Redis官方提出了Redlock算法。
算法步骤:
- 获取当前时间戳
- 依次向N个Redis实例申请锁
- 如果在大多数实例(N/2+1)上获取锁成功,且总耗时小于锁超时时间,则认为获取锁成功
- 锁的有效时间 = 初始有效时间 - 获取锁消耗的时间
- 释放锁时,向所有Redis实例发送释放命令
1 | public class Redlock { |
锁的问题与优化
锁超时问题: 业务执行时间超过锁超时时间,锁自动释放,可能导致并发问题。
解决方案:
- 看门狗机制:定时续期锁的过期时间
1 | public class WatchDog { |
- 合理评估业务执行时间:设置足够的锁超时时间
锁竞争激烈问题: 大量线程竞争同一把锁,导致性能下降。
解决方案:
- 分段锁:将资源分段,减少锁竞争
- 队列锁:使用List实现公平锁
- 自旋锁优化:适当的退避算法
悲观锁与乐观锁
悲观锁
假设会发生并发冲突,在操作数据前先获取锁。
Redis实现:
1 | // 使用Redis分布式锁实现悲观锁 |
乐观锁
假设不会发生冲突,在更新时检查数据是否被修改。
基于版本号的乐观锁:
1 | public boolean updateWithOptimisticLock(String userId, int amount, int expectedVersion) { |
基于CAS的乐观锁:
1 | public void updateWithCAS(String key, Function<String, String> updater) { |
悲观锁 vs 乐观锁选择
悲观锁适用场景:
- 写操作频繁,冲突概率高
- 业务逻辑复杂,重试成本高
- 对数据一致性要求严格
乐观锁适用场景:
- 读多写少,冲突概率低
- 业务逻辑简单,重试成本低
- 对性能要求高
高可用架构
主从复制
Master负责写操作,Slave负责读操作,通过复制实现数据同步。
复制原理:
- Slave向Master发送PSYNC命令
- Master执行BGSAVE生成RDB文件
- Master将RDB文件发送给Slave
- Slave载入RDB文件
- Master将缓冲区的写命令发送给Slave
- 后续写命令实时同步
部分重同步: 网络断连后,Slave只需要同步断连期间的命令,而不是完整重同步。
哨兵模式(Sentinel)
哨兵负责监控Master状态,在Master故障时自动进行故障转移。
哨兵职责:
- 监控:定期ping Master和Slave
- 通知:故障时通知管理员和客户端
- 故障转移:自动将Slave提升为新Master
- 配置管理:为客户端提供服务发现
故障转移流程:
- 哨兵发现Master下线(主观下线)
- 多个哨兵确认Master下线(客观下线)
- 选举领导哨兵执行故障转移
- 选择合适的Slave作为新Master
- 修改其他Slave的配置指向新Master
- 通知客户端Master地址变更
集群模式(Cluster)
Redis Cluster通过分片实现横向扩展和高可用。
分片算法: 使用CRC16算法计算key的哈希值,然后对16384取模得到槽位号。
节点通信: 使用Gossip协议在节点间交换状态信息,包括节点上线/下线、槽位分配等。
故障转移: 当Master节点故障时,其Slave自动提升为新Master,过程对客户端透明。
数据迁移: 集群扩容时,需要将部分槽位从现有节点迁移到新节点。
Redis 哈希槽(Hash Slot)详解
在 Redis Cluster 中,数据不是存储在单个节点上,而是被分散存储在多个节点上。为了实现这种分散存储,Redis 引入了 哈希槽(hash slot) 的概念。
1. 什么是哈希槽?
Redis Cluster 一共有 16384 个哈希槽(slot),编号从 0 到 16383。集群中的每个键(key)都会通过一个固定的算法映射到这 16384 个哈希槽中的一个,然后这些哈希槽会被分配给集群中的不同节点。
2. 键如何映射到哈希槽?
Redis 使用一个简单的算法来决定一个键属于哪个哈希槽:
$$
slot=CRC16(key)(mod16384)
$$
- CRC16(key):对键名执行 CRC16 校验和计算。
- mod 16384:将 CRC16 校验和的结果对 16384 取模,得到的结果就是这个键所属的哈希槽编号。
例如,如果你有一个键 mykey
,Redis 会计算 CRC16('mykey') % 16384
,得到一个 0 到 16383 之间的数字。
3. 哈希槽如何分配给节点?
集群中的每个节点负责管理一部分哈希槽。例如,一个三节点的集群可以这样分配哈希槽:
- 节点 A:负责哈希槽 0 到 5460。
- 节点 B:负责哈希槽 5461 到 10922。
- 节点 C:负责哈希槽 10923 到 16383。
当一个客户端需要操作一个键时,它会首先计算这个键所属的哈希槽,然后根据哈希槽与节点的映射关系,找到负责该哈希槽的节点,并向其发送请求。
如果客户端请求的键不在当前连接的节点上,这个节点会返回一个 MOVED
重定向错误,告诉客户端正确的节点地址,客户端会根据这个地址重新发起请求。
4. 哈希槽的优势
- 高可用性:当一个节点失效时,它所负责的哈希槽可以被其他节点接管,从而保证数据的可用性。
- 弹性伸缩:
- 添加节点:当需要扩展集群时,可以添加新节点,并从现有节点上迁移一部分哈希槽到新节点上,这个过程是增量的,不需要停机。
- 移除节点:当需要缩减集群时,可以将某个节点上的所有哈希槽迁移到其他节点,然后移除该节点。
性能监控与优化
慢查询日志
Redis提供慢查询日志功能,记录执行时间超过阈值的命令。
配置参数:
1 | slowlog-log-slower-than 10000 # 超过10毫秒记录 |
查看慢查询:
1 | SLOWLOG GET 10 # 获取最近10条慢查询 |
内存分析
使用MEMORY命令分析内存使用情况。
1 | MEMORY USAGE key # 查看key占用内存 |
性能优化建议
避免大key:
- 单个key的value不要超过10KB
- 集合类型元素数量控制在合理范围
- 使用SCAN代替KEYS命令
合理使用数据结构:
- 小对象使用Hash而不是多个String
- 合理设置ziplist等压缩结构的阈值
- 使用位图(bitmap)存储布尔类型大数据集
网络优化:
- 使用Pipeline批量操作
- 合理设置客户端连接池
- 启用TCP_NODELAY选项
持久化优化:
- 根据业务需求选择RDB或AOF
- 合理配置自动保存条件
- 在从节点上进行持久化操作
这些详细的技术点涵盖了Redis的核心概念、常见问题解决方案和实际应用场景,是Redis技术面试的重要考查内容。掌握这些知识点并能结合实际项目经验进行说明,将大大提高面试通过率。
内存管理与淘汰机制
内存淘汰策略详解
当Redis内存使用达到maxmemory限制时,会根据配置的策略淘汰数据。
8种淘汰策略:
1 | # 针对所有key |
LRU vs LFU 实现细节: Redis的LRU并非严格的LRU,而是近似LRU算法:
- 每个key都有24位的时钟字段记录访问时间
- 淘汰时随机采样5个key(可配置),选择时钟值最小的
LFU算法维护访问频率:
- 高16位存储上次访问时间
- 低8位存储访问频率计数器
- 计数器采用概率性递增,避免频率无限增长
内存碎片问题
产生原因:
- 频繁的数据更新导致内存分配/释放
- Redis使用jemalloc内存分配器,存在内存对齐
- 删除大key后留下内存空洞
检测方法:
1 | INFO memory |
解决方案:
- 内存整理(Redis 4.0+):
1 | CONFIG SET activedefrag yes # 开启自动整理 |
- 重启Redis:最彻底但影响服务可用性
- 优化数据结构:减少小对象,使用Hash存储相关数据
过期策略与删除机制
三种过期删除策略
定时删除:设置过期时间时创建定时器,到期立即删除
- 优点:及时释放内存
- 缺点:消耗CPU资源创建和管理定时器
惰性删除:访问key时检查是否过期,过期则删除
- 优点:CPU友好,只在必要时删除
- 缺点:内存不友好,过期key可能长期占用内存
定期删除:定期随机检查部分key,删除过期的
- Redis的实际策略,平衡CPU和内存使用
Redis过期删除实现
1 | // 简化的过期删除逻辑 |
数据结构底层实现深度解析
压缩列表(ZipList)演进
Redis 7.0用ListPack替代了ZipList,解决了级联更新问题。
ZipList问题:
1 | // ZipList结构导致的级联更新 |
ListPack优势:
- 每个元素独立编码,避免级联更新
- 支持从尾部遍历,提高某些操作效率
字典扩容与rehash
Redis字典使用增量式rehash避免阻塞:
1 | // 渐进式rehash实现 |
网络模型与性能优化
Redis 6.0 多线程I/O
多线程只用于网络I/O,命令执行仍是单线程:
1 | // 多线程I/O处理流程 |
客户端连接管理
连接池配置优化:
1 | // Jedis连接池配置 |
Pipeline优化:
1 | // Pipeline批量操作 |
高级数据类型与应用
HyperLogLog
用于基数统计,占用内存固定(12KB),误差率0.81%。
实现原理:
- 基于概率算法,通过观察随机数的最大前导零个数估算基数
- 使用调和平均数减少误差
- 适用于UV统计等场景
1 | # 网站UV统计 |
布隆过滤器(Redis Module)
1 | # Redis布隆过滤器模块 |
Geo地理位置
基于Sorted Set实现,使用GeoHash算法。
1 | # 添加地理位置 |
安全性问题与防护
常见安全漏洞
命令注入:
1 | // 错误做法:直接拼接用户输入 |
未授权访问:
1 | # redis.conf安全配置 |
危险命令禁用:
1 | # 重命名危险命令 |
ACL访问控制(Redis 6.0+)
1 | # 创建用户 |
监控与运维
关键监控指标
性能指标:
1 | # 通过INFO命令获取 |
内存指标:
1 | INFO memory |
持久化指标:
1 | INFO persistence |
故障排查常用命令
1 | # 客户端连接信息 |
特殊应用场景
分布式限流
固定窗口限流:
1 | public class RedisRateLimiter { |
滑动窗口限流:
1 | public boolean slidingWindowLimit(String key, int limit, int window) { |
分布式Session
1 |
|
消息队列实现
基于List的简单队列:
1 | public class RedisQueue { |
基于Stream的消息队列(Redis 5.0+):
1 | # 生产消息 |
延时队列
1 | public class RedisDelayQueue { |
性能调优技巧
内存优化
使用Hash存储小对象:
1 | // 不推荐:为每个用户字段单独设置key |
合理设置过期时间:
1 | // 避免同时过期导致雪崩 |
网络优化
批量操作优化:
1 | // 使用Pipeline |
CPU优化
Lua脚本减少网络往返:
1 | -- 原子性增加库存 |
Hello World
Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.
Quick Start
Create a new post
1 | $ hexo new "My New Post" |
More info: Writing
Run server
1 | $ hexo server |
More info: Server
Generate static files
1 | $ hexo generate |
More info: Generating
Deploy to remote sites
1 | $ hexo deploy |
More info: Deployment