4-java
4.java
2025.11.20 DAY23
4.1 String、StringBuffer、StringBuilder的区别
在 Java 中,String、StringBuilder 和 StringBuffer 都是用于处理字符序列的类。它们最核心的区别在于可变性、线程安全和性能。
1. 核心区别:可变性
这是 String 与另外两者最根本的区别。
- String:不可变的 (Immutable)
- String 对象一旦被创建,其内部的字符数组(被 final 修饰)就无法被改变。
- 当你尝试“修改”(例如拼接 +)一个 String 时,JVM 并不会在原有对象上操作,而是会创建一个全新的 String 对象来存放新内容。
- 后果:如果进行大量、频繁的字符串修改,会产生大量临时的 String 对象,这不仅效率低下,还会频繁触发垃圾回收 (GC),导致系统内存抖动。
- StringBuilder / StringBuffer:可变的 (Mutable)
- 这两兄弟都继承自 AbstractStringBuilder,它们内部的字符数组没有被 final 修饰,并且提供了修改字符串内容的方法。
- 当你对它们进行修改(如 append、insert、delete)时,是直接在原有的对象上操作,改变其内部的字符数组。
- 后果:在频繁修改字符串的场景下,它们不会创建新对象(除非内部数组需要扩容),因此性能极高。
2. 关键区别:线程安全
这是 StringBuilder 和 StringBuffer 之间的核心区别。
- String
- 由于其“不可变”的特性,String 对象的内容无法被修改,所以它天然就是线程安全的。
- StringBuffer
- 线程安全。
- StringBuffer 中几乎所有的公开方法(如 append, insert 等)都使用了 synchronized 关键字进行同步。
- 代价:加锁和解锁会带来额外的性能开销。
- StringBuilder
- 线程不安全。
- StringBuilder 的方法没有 synchronized 修饰,它把保证线程安全的责任交给了调用者。
- 优势:由于没有加锁开销,在单线程环境中,它的性能是三者中最好的。
3. 性能对比
基于以上特性,性能对比结论非常清晰:
- 执行效率(单线程): StringBuilder > StringBuffer > String
- StringBuilder 速度最快,因为它既可变,又无锁。
- StringBuffer 其次,因为它可变,但有锁开销。
- String 最慢(特指频繁修改时),因为它不可变,每次修改都涉及创建新对象和 GC。
总结:使用场景(如何选择)
- String:
- 首选。在绝大多数场景下,尤其是字符串内容基本不变时。
- 例如:声明常量、少量变量运算、方法参数或返回值。
- StringBuilder: ( 重点推荐 )
- 单线程环境下,需要频繁进行字符串拼接、修改的场景。
- 这是最常用的场景,例如:循环拼接字符串、SQL 语句拼装、JSON 封装等。
- StringBuffer:
- 多线程环境下,多个线程需要共享并修改同一个字符串变量时。
- 例如:全局共享的字符串缓冲区(但这种场景现在较少,通常有更好的并发工具)。
4.2 接口和抽象类的区别
在 Java 中,接口和抽象类都是用于实现抽象的机制,但它们在定义、用法和设计目的上有着根本的不同。
1. 定义与组成:一个是“规范”,一个是“模板”
- 接口 (Interface):
- 定义:接口是一种纯粹的抽象类型,它更像一份“行为规范”或“能力合同”。它只定义“应该做什么”,但不关心“具体怎么做”。
- 组成:
- 常量:只能包含公共、静态、最终的常量 (public static final)。
- 抽象方法:默认是公共、抽象的 (public abstract)。
- 绝对不能有:构造器和成员变量(实例变量)。
- (注:Java 8 后增加了
default默认方法和static静态方法,但这主要是为了兼容性升级,其核心设计目的仍是定义规范。)
- 抽象类 (Abstract Class):
- 定义:抽象类首先是一个“类”,但它是一个“未完成的模板”,不能被直接实例化。
- 组成:
- 成员变量与常量:和普通类一样,什么都可以有(成员变量、静态变量、常量等)。
- 方法:可以同时包含“抽象方法”和“具体方法”。
- 构造器:可以有构造器,但这并不是给它自己用的,而是用于初始化子类实例。
- 访问修饰符:方法和变量的访问修饰符很灵活(public, protected, private 均可)。
2. 继承与实现:一个是“多实现”,一个是“单继承”
- 接口 (Interface):
- 关系:一个类可以“实现” (implements) 多个接口。
- 限制:实现接口的类,必须重写接口中所有的抽象方法(除非这个类自己也是抽象类)。
- 优势:这让 Java 实现了“多重继承”的好处(一个类可以同时拥有多种能力)。
- 抽象类 (Abstract Class):
- 关系:一个类只能“继承” (extends) 一个父类(无论是抽象类还是普通类)。
- 限制:Java 不支持多重类继承。如果一个类已经继承了一个抽象类,它就不能再继承任何其他类了。
- 要求:继承抽象类的子类,必须实现父类中所有的抽象方法(除非子类自己也是抽象类)。
3. 设计目的:一个是“能力”,一个是“复用”
- 接口 (Interface):
- 设计目的:定义规范,强调“行为”或“能力” (has-a 关系)。
- 使用场景:当你需要定义一组标准,让完全不同的类系都能遵守这个标准时(例如:
Runnable,Comparable)。
- 抽象类 (Abstract Class):
- 设计目的:代码复用,强调“归属”或“基础” (is-a 关系)。
- 使用场景:当你希望在多个紧密相关的子类中共享通用代码(具体方法或成员变量)时,使用抽象类作为它们的“模板父类”。
| 抽象类 (abstract class) | 接口 (interface) | |
|---|---|---|
| 定义 | 包含抽象方法的类 | 主要是抽象方法和静态常量的类 |
| 组成 | 构造器 抽象方法 普通成员方法、成员变量 静态方法、静态变量 常量 | 静态常量 抽象方法 default 方法、静态方法 (Java 8) 私有方法 (Java 9) |
| 使用 | 子类继承抽象类 (extends) | 子类实现接口 (implements) |
| 关系 | 子类只能继承一个抽象类 抽象类可以实现多个接口 | 子类可以实现多个接口 接口不能继承类,但可以继承多个接口 |
| 选择 | 如果需要继承父类的成员变量,或者需要控制子类的实例化,则选抽象类 | 优先选择接口,避免单继承的局限 |
4.3 Java常见的异常类有哪些
理解 Java 异常:Throwable、RuntimeException** 和 Checked Exception**
在 Java 中,所有错误 (Error) 和异常 (Exception) 的“老祖宗”都是 Throwable 类。任何可以被抛出的“问题”都是它的实例。
Throwable 下面有两个主要分支:Error (错误) 和 Exception (异常)。
- Error (错误):这是灾难性的问题,通常由 JVM 虚拟机产生,比如内存溢出 (OutOfMemoryError)。程序无法处理这种情况,我们基本不用关心它。
- Exception (异常):这是我们程序员需要关心和处理的。
Exception 本身又被分为两大类,这个区别非常关键:
1. 运行时异常 (RuntimeException) - “程序自身的 Bug”
顾名思义,这是在程序运行时 (Runtime) 才可能抛出的异常。
- 编译器态度:编译器不强制检查。它相信程序员已经写对了逻辑,不会强制你(使用 try-catch 或 throws)去处理它们。
- 核心原因:这类异常通常是由程序自身的逻辑错误(即 Bug)引起的。它们是程序员本可以提前避免的。
- 典型例子:
- NullPointerException (空指针异常):对一个 null 对象调用方法。
- ArrayIndexOutOfBoundsException (数组越界):访问了不存在的索引。
- ClassCastException (类型转换错误):强制转换了一个不兼容的对象。
- ArithmeticException (算术异常):比如整数除以零。
- 处理方式:当你遇到 RuntimeException 时,首选方案不是捕获它,而是修复你的代码逻辑。
2. 编译时异常 (Checked Exception) - “可预见的外部意外”
这就是 Exception 类中排除了 *RuntimeException* 之后的所有其他异常(也就是你原文中的“其他异常”)。
- 编译器态度:编译器会强制检查。你必须处理它们(通过 try-catch 捕获或 throws 声明抛出),否则编译不通过。
- 核心原因:这类异常不是程序逻辑的错,而是由可预见的外部环境因素(如 I/O、网络、文件系统)引起的。程序本身没有问题,但必须为这些“意外”做好准备。
- 典型例子:
- FileNotFoundException (文件未找到):尝试打开一个不存在的文件。
- IOException (输入/输出异常):读写文件或网络时出错。
- SQLException (SQL 异常):操作数据库时出错。
| 特性 | 运行时异常 (RuntimeException) | 编译时异常 (Checked Exception) |
|---|---|---|
| 核心原因 | 程序逻辑 Bug (内部问题) | 外部环境意外 (外部问题) |
| 编译器态度 | 不强制检查 (Unchecked) | 强制检查 (Checked) |
| 处理方式 | 优先修复代码 | 必须 try-catch 或 throws |
| 常见例子 | NullPointerException | IOException, FileNotFoundException |
2025.11.21 DAY24
4.4 说一说Java面向对象三大特性
Java 面向对象编程 (OOP) 建立在三大核心特性之上:封装、继承和多态。
1. 封装 (Encapsulation)
- 核心思想:“隐藏细节,提供接口”。
- 具体做法:封装就是将对象内部的数据(属性)和操作这些数据的(方法)打包结合在一起。
- 实现方式:
- 使用
private等关键字将内部实现细节隐藏起来,防止外界随意访问和修改。 - 只通过
public等关键字暴露出有限的、可控的“接口”(方法),供外界安全使用。
- 使用
2. 继承 (Inheritance)
- 核心思想:“代码复用,功能扩展”。
- 具体做法:继承允许一个类(子类)自动获取另一个类(父类)的属性和方法。
- 带来的好处:
- 提高代码可重用性:子类可以直接使用父类的代码,无需重写。
- 提高代码可扩展性:子类不仅可以保留父类的功能,还可以添加自己的新方法,或者“重写”(Override)父类的方法来改进功能。
- Java 的限制:Java 只支持单继承,即一个子类只能直接继承一个父类。
3. 多态 (Polymorphism)
- 核心思想:“同一消息,不同响应”。
- 具体做法:多态是指允许不同类的对象对同一个消息(方法调用)*做出响应,但*具体的行为会根据对象的实际类型而有所不同。
- 简单来说:就是“同一个指令,不同对象来执行,会产生不同的结果”。
- 实现方式:这通常是通过方法重载(Overload,编译时多态)和方法重写(Override,运行时多态)来实现的。
4.5 说一说你对Java多态的理解
多态 (Polymorphism),字面意思是“多种形态”。在 Java 中,它指的是允许不同类的对象对同一个消息(方法调用)做出不同的响应。
多态主要分为两种形式:
- 编译时多态(静态多态)
- 运行时多态(动态多态)
1. 编译时多态:方法的“重载”
- 定义:指在编译阶段,编译器就已经能够确定应该调用哪个具体的方法。
- 实现方式:这主要通过方法重载 (Overloading) 来实现。
- 工作原理:编译器在编译时,会根据调用方法时提供的参数数量、类型或顺序来“静态地”选择最合适的那一个方法版本。
2. 运行时多态:方法的“重写”
这是面向对象编程 (OOP) 中最核心的多态形式。
- 定义:指在程序运行时,才根据对象的实际类型来动态确定要调用的方法。
- 实现方式:这必须通过方法重写 (Overriding) 来实现。
- 工作原理:
- 当把一个子类对象直接赋给一个父类引用变量时(例如:Parent p = new Child())。
- 在运行时调用该引用变量(p)的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征。
- 核心特征:
- 运行时多态依赖的是对象的“实际类型”(*new Child()*),而不是“引用类型”(Parent p)。
- 这就实现了你所说的:“相同类型的变量,调用同一个方法时,呈现出多种不同的行为特征”。
4.6 Java重写和重载的区别
Java中的重载 (Overload) 和重写 (Override) 都是实现多态的方式,但它们在概念、用法和实现上完全不同。
1. 方法的重载 (Overload):编译时多态
- 定义:指的是在同一个类中,可以有多个方法名称相同,但参数列表不同的 方法。
- 参数列表不同:这是核心,指的是参数的类型、个数、或顺序必须有所不同。
- 其他:重载方法可以有不同的返回类型和不同的访问修饰符。
- 本质:
- 重载是一种编译时多态(或静态多态)。
- 编译器在编译阶段,就会根据你调用时传入的参数,静态地决定(静态绑定)到底要执行哪个具体的方法版本。
2. 方法的重写 (Override):运行时多态
- 定义:指的是在子类中,重新定义一个与父类中某个方法完全相同的方法。
- “三同”:方法名、参数列表、返回类型都必须与父类相同。(注:Java 5后,返回类型可以是父类返回类型的子类)。
- “一高”:重写的方法的访问级别不能低于被重写的父类方法(例如:
public>protected)。 - 本质:
- 重写是一种运行时多态(或动态多态)。
- 虚拟机(JVM)在程序运行时,才会根据对象的实际类型(而不是引用类型)来动态地确定要调用哪个方法。
| 对比 | 重载 (Overload) | 重写 (Override) |
|---|---|---|
| 比喻 | 一个人,多个同名技能 | 不同的人,同一个技能,不同做法 |
| 多态类型 | 编译时多态 (静态) | 运行时多态 (动态) |
| 发生地点 | 同一个类中 | 父类与子类之间 |
| 方法签名 | 名字相同,参数必须不同 | 名字、参数、返回类型必须全相同 |
| 谁来决定 | 编译器 (在写代码时) | JVM (在跑程序时) |
2025.11.22 DAY25
4.7 final关键字有什么作用
1. final修饰类
- 作用:当一个类被 final 修饰时,它就成为了一个“最终类”。
- 效果:这个类不可被继承。
- 例子:Java 中的 String 类就是 final 类。
2. final 修饰方法
- 作用:当一个方法被 final 修饰时,它就成为了一个“最终方法”。
- 效果:这个方法可以被子类继承和使用,但绝对禁止子类对其进行“重写” (Override)。
- 目的:明确禁止该方法在子类中被覆盖。
3. final修饰变量
这是最需要区分的情况,final 修饰的变量必须在声明时或构造器中初始化,一旦赋值后就不能再改变。
- A. 修饰“基本数据类型”的变量
- 效果:其“数值”一旦在初始化之后便不能再更改。
- 称呼:这种变量通常被称为“常量”。
- (例如:final int a = 10; 之后,a 永远是 10。)
- B. 修饰“引用类型”的变量
- 效果:“引用”(即内存地址)本身不可变。
- 具体来说:在对其初始化之后,便不能再让它“指向”另一个新的对象。
- 重点:虽然这个变量的“指向”被固定了,但是它所“指向的那个对象的内容”是可变的。
- (例如:final List myList = new ArrayList(); 之后,你不能
myList = new LinkedList();,但是你可以myList.add("新元素");。)
4.8 == 和equals的区别
在Java中,== 和 equals() 方法都用来比较对象,但它们在语义和使用上有明显的差别:
1. == 运算符
== 运算符的行为取决于它比较的数据类型:
- 对于原始数据类型 (primitive types):
- == 比较的是值是否相等。 (例如:int a = 10; int b = 10; 此时 a == b 为 true)。
- 对于引用类型 (reference types):
- == 比较的是两个引用是否指向内存中的同一位置,即它们是否是同一个对象实例。
2. equals() 方法
equals() 是定义在 Object 类中的一个方法,因此所有类都继承了它:
- 默认行为 (在 Object 类中):
- 默认情况下,equals() 方法的行为与 == 完全相同,比较的是对象的引用。
- 重写后的行为 (在子类中):
- 在很多子类中(比如 String、 Integer 等),equals() 方法通常被重写 (Override)。
- 重写后,它的目的就变成了比较对象的内容是否相等。
2025.11.24 DAY26
4.9 Java的集合类有哪些,那些是线程安全的,那些是线程不安全的?
Java 集合类概览:分类与线程安全
在 Java 中,所有的集合类都源自两大顶层接口:Collection 和 Map。
- Collection 接口:主要用于存放单一的元素。它又派生出三个核心子接口:Set、List 和 Queue。
- Map 接口:主要用于存放键值对 (Key-Value)。
因此,所有 Java 集合类,基本都是 Set、List、Queue、Map 这四个接口的实现类。
一、四大核心接口
- List 接口
- 特点:有序集合,允许重复元素。
- 常见实现类:ArrayList、LinkedList 等。
- Set 接口
- 特点:不允许重复元素的集合。
- 常见实现类:HashSet、LinkedHashSet、TreeSet 等。
- Queue 接口
- 特点:用于表示队列的数据结构(如先进先出)。
- 常见实现类:LinkedList、PriorityQueue 等。
- Map 接口
- 特点:表示键值对的集合,键 (Key) 唯一。
- 常见实现类:HashMap、LinkedHashMap、TreeMap 等。
二、线程安全性
这是集合类的一个非常重要的划分标准。
1. 线程不安全的集合类
绝大多数我们日常使用的集合类都是非线程安全的。
- 包括:ArrayList、LinkedList、HashSet、HashMap,以及有序的 TreeMap 和 TreeSet 等。
- 特点:在单线程环境中,它们性能极高。
- 警告:在多线程环境中,如果没有外部适当的同步措施,对这些集合的并发操作可能导致数据错乱或不确定的结果。
2. 线程安全的集合类
- Vector
- 类似于 ArrayList,但它的所有方法都是同步 (synchronized) 的,因此是线程安全的。
- 注意:它相对较重,性能较差,现在通常不推荐使用。
- HashTable
- 类似于 HashMap,但它是线程安全的,通过同步整个对象实现。
- 注意:它的使用也不太被推荐,性能开销大。
- ConcurrentHashMap(推荐)
- 提供了远好于 HashTable 的并发性能,它通过锁分离技术(分段锁或 CAS)实现高效的线程安全。
- Collections.synchronizedList / .synchronizedSet / .synchronizedMap
- 作用:这些是工具类 Collections 提供的包装方法。
- 功能:可以将一个非线程安全的集合(如 ArrayList)包装成一个线程安全的集合。
4.10 ArrayList 和 Array 有什么区别?ArrayList 和 LinkedList 的区别是什么?
第一组:Array(数组) vs ArrayList (动态数组)
这是“固定”与“灵活”的区别。
- 核心区别:大小
- Array:是固定长度的数组。一旦创建,大小不可改变。
- ArrayList:是动态数组的实现。大小可以自动增长。
- 功能与操作
- Array:功能基础,没有额外的方法。
- ArrayList:提供了更多功能,比如自动扩容、增加 (add) 和 删除 (remove) 元素等。
- 存储类型
- Array:既可以存储基本类型数据(如 int),也可以存储对象。
- ArrayList:只能存储对象。对于基本类型数据,必须使用其对应的包装类(如 Integer、Double 等)。
- 性能(随机访问)
- Array:由于其连续的内存存储,在随机访问(通过索引
[i]获取)时,性能通常优于 ArrayList。
- Array:由于其连续的内存存储,在随机访问(通过索引
第二组:ArrayList vs LinkedList
这是“数组”与“链表”的区别,它们的核心差异在于底层数据结构。
- 底层结构
- ArrayList:基于动态数组实现。
- LinkedList:基于双向链表实现。
- 性能:随机访问 (Get)
- ArrayList:性能极好 (O(1))。因为它基于数组,可以通过索引瞬间定位。
- LinkedList:效率很低 (O(n))。因为它需要从头开始或从尾开始,顺着链接一个一个遍历来查找元素。
- 性能:删除/添加元素 (Add/Remove)
- ArrayList:
- 在末尾添加:通常很快 (O(1))。
- 在中间或开头插入/删除:非常慢 (O(n))。因为需要移动后续所有元素来填补或腾出空间。
- LinkedList:
- 在任意位置添加/删除:性能极佳 (O(1))。(前提是已经定位到那个节点)
- 它只需要改变前后节点的引用(指针),不需要移动其他元素。
- ArrayList:
- 扩容开销
- ArrayList:当容量不足时,会触发扩容。这个过程涉及创建新数组和复制旧数组的内容,有一定的性能开S销。
- LinkedList:没有扩容概念,它只是在需要时创建新节点。
4.11 ArrayList 扩容机制
ArrayList 的扩容机制
ArrayList 扩容的本质是:在容量不足时,计算出一个新的容量,然后实例化一个全新的、更大的数组,最后将旧数组中的所有内容复制到这个新数组中去。
(关键:不是在原数组上修改,而是创建新数组,并将引用指向这个新数组)。
1. 初始容量
- 当你使用
new ArrayList()创建一个对象时,它内部的数组是空的。 - 当第一次向 ArrayList 中添加元素时,它才会真正分配一个初始容量,这个容量通常为 10。
2. 扩容的触发
- 当 ArrayList 中的元素数量达到(或即将超过)当前容量时,ArrayList 会自动触发其扩容机制。
3. 扩容的过程(grow() 方法)
扩容的计算和执行主要在 grow() 方法内完成:
- 计算新容量:grow 方法会先尝试将数组容量扩大为原数组的 1.5 倍。
- (“1.5倍” 的实现方式是:新容量 = 旧容量 + (旧容量 >> 1)`,即旧容量加上旧容量右移一位)。
- 执行复制:
- 如果这个“1.5倍”的新容量满足本次添加的需求(例如,你只是 add 一个元素),那么 ArrayList 就会调用 Arrays.copyOf 方法。
- Arrays.copyOf 是真正实现扩容的步骤。它会创建一个新数组(大小为“1.5倍”的新容量),并将所有元素从旧数组复制到新数组中。
- 特殊情况:
- 如果 “1.5倍” 的新容量仍然不满足需求(例如,你使用 addAll 一 次性添加了大量元素),那么新数组的容量大小将直接采用当前所需的最小容量(minCapacity)。
2025.11.25 DAY27
4.12 HashMap 的底层实现是什么?
HashMap的底层实现
一、底层实现:数组 + 链表 + 红黑树
- JDK 1.8 之前:HashMap 底层由数组和链表组成。当发生哈希冲突时,多个元素会以链表的形式存储在同一个数组位置(即“拉链法”)。
- JDK 1.8 及之后:引入了红黑树。当链表长度超过一定阈值(TREEIFY_THRESHOLD,默认为 8)时,链表会自动转换成红黑树,以提高搜索效率。
二、为什么是 8 (树化) 和 6 (链化)?
- 为什么是 8?
- 这是基于泊松分布的概率计算。
- 在负载因子默认为 0.75 时,单个哈希槽(数组位置)内元素个数达到 8 的概率小于百万分之一,是极小概率事件。
- 因此,将 7 作为一个分水岭,等于 7 时不转换,大于等于 8 时才转换成红黑树。
- 为什么是 6?
- 当红黑树中的节点数小于等于 6 时,会从红黑树转化回链表。
- 这里设置一个 6 而不是 7,是为了防止“抖动”。如果 8 变树、7 变链表,那么在 7 和 8 之间反复增删元素会导致频繁的转换,开销很大。6 和 8 之间设置了一个缓冲区间。
三、为什么引入红黑树(而不是 AVL 树)?
选择红黑树是因为它在性能和平衡之间做到了很好的折中:
- 不追求绝对平衡:相较于 AVL 树(追求绝对自平衡),红黑树允许有一定的局部不平衡。这使得它在插入或删除节点时,需要调整(旋转)的次数更少,减少了很多性能开销。
- 查找效率高:红黑树是一种自平衡的二叉搜索树,其插入、删除和查找操作的时间复杂度都能稳定在 O(log n)。
四、HashMap 读和写的时间复杂度
- 读 (Get) 操作
- 最佳情况:没有哈希冲突,时间复杂度为 O(1)。
- 最坏情况:发生严重哈希冲突。
- (JDK 1.7) 链表:O(n)
- (JDK 1.8) 红黑树:O(log n)
- 写 (Put) 操作
- 理想情况:哈希函数分布均匀,没有冲突,时间复杂度也是 O(1)。
- 处理哈希冲突:
- 如果发生冲突,需要在链表尾部添加新元素(O(n))或在红黑树中插入新元素(O(log n))。
- (注:如果写操作导致链表转为红黑树,该次操作的复杂度会更高,但均摊下来还是 O(log n))。
4.13 解决Hash冲突的方法有哪些?HashMap 是如何解决 hash 冲突的
解决哈希冲突的主要方法
解决哈希冲突(Hash Collision)的方法主要有以下两种:
1. 链地址法 (Chaining)
- 做法:在哈希表(数组)的每个位置都维护一个数据结构,比如链表。
- 如何解决:当发生冲突时(即多个元素被计算到同一个数组位置),新的元素会被添加到这个位置链表的尾部。
2. 开放寻址法 (Open Addressing)
- 做法:不使用额外的数据结构(如链表)。
- 如何解决:当发生冲突时,根据某种探测算法(如线性探测),在哈希表中寻找下一个空闲的位置来存储这个冲突的元素。
HashMap 是如何解决 hash 冲突的?
- Java 中的 HashMap(及 HashSet)主要使用“链地址法”来解决 hash 冲突。
- (注:在 JDK 1.8 之后,HashMap 做了优化,当同一个位置的链表过长时,会将其转换为红黑树以提高效率)。
4.14 HashMap 的 put 方法流程
1. 检查并初始化数组 (resize)
- 首先,判断 HashMap 的内部数组(table)是否为 null 或长度为 0。
- 如果是,则调用 resize() 方法进行初始化(例如,创建默认容量为 16 的数组)。
2. 计算索引并尝试直接插入
- 根据键值 key 计算 hash 值,并得到它在数组中应存入的索引 i。
- 检查 table[i]:
- 如果 table[i] == null(该位置为空):直接新建节点并添加。然后转向步骤 6。
- 如果 table[i] != null(该位置已有元素,发生冲突):转向步骤 3。
3. 检查首个元素(是否覆盖)
- 判断 table[i] 处的首个元素(可能是链表头,也可能是树根)的 key 是否与当前要插入的 key 相同(通过 hashCode 和 equals 判断)。
- 如果相同:说明 key 已存在,直接覆盖其 value 值。流程结束。
- 如果不相同:转向步骤 4。
4. 判断冲突类型(树或链表)
- 检查 table[i] 是否为 TreeNode,即判断该位置是否已经“树化”(是红黑树)。
- 如果是红黑树:则调用红黑树的插入方法(putTreeVal)来添加键值对。流程结束。
- 如果不是红黑树(即是链表):转向步骤 5。
5. 遍历链表并插入(或树化)
- 开始遍历 table[i] 处的链表。
- A.检查是否树化:插入新节点后,判断该链表的长度是否大于 8(TREEIFY_THRESHOLD)。如果大于 8,则将此链表转换为红黑树(treeifyBin**)。
- B. 遍历中检查:在遍历过程中,如果发现 key 已经存在(通过 equals 判断),则直接覆盖 value。流程结束。
- C. 插入到末尾:如果遍历到链表末尾仍未发现相同的 key,则在链表尾部插入新的节点。
6. 检查是否需要扩容
- 在成功插入一个新节点(注意:覆盖 value 不算)后,HashMap 的总键值对数量 size 会加 1。
- 此时判断 size 是否超过了最大容量阈值 (threshold)。如果超过,则再次执行 resize() 方法进行扩容(通常是容量翻倍)。
4.15 HashMap 的扩容机制
一、JDK 1.7 扩容机制
- 生成新数组:创建一个容量翻倍的新数组。
- 遍历老数组:遍历老数组中的每一个位置。
- 遍历链表:遍历该位置上链表的每一个元素。
- 计算新下标:获取每个元素的 key,并基于新数组的长度,重新计算出每个元素在新数组中的下标位置。
- 转移元素:将该元素添加到新数组的对应位置中去。
- 替换引用:所有元素都转移完毕后,将新数组赋值给 HashMap 对象的 table 属性。
二、JDK 1.8 版本扩容
- 生成新数组:创建一个容量翻倍的新数组。
- 遍历老数组:遍历老数组中的每一个位置,判断是链表还是红黑树。
- 处理链表:
- 如果是链表,则遍历该链表,将链表中的每个元素重新计算下标,并添加到新数组中去。
- 处理红黑树:
- 如果是红黑树,则先遍历红黑树,计算出红黑树中每个元素对应在新数组中的下标位置。
- 统计每个(新)下标位置的元素个数。
- 检查降级:如果该位置下的元素个数没有超过 8(即 UNTREEIFY_THRESHOLD,默认为 6),那么则生成一个新链表,并将链表的头节点添加到新数组的对应位置。
- 保持树形:如果该位置下的元素个数超过了 8(注:JDK 8 中实际是判断是否需要拆分,但按此逻辑),则生成一个新的红黑树,并将根节点添加到新数组的对应位置。
- 替换引用:所有元素都转移完毕后,将新数组赋值给 HashMap 对象的 table 属性。
2025.11.26 DAY28
4.16 HashMap 为什么是线程不安全的? 如何实现线程安全
一、为什么 HashMap 是线程不安全的?
HashMap 并非为多线程并发使用而设计。它的主要原因是操作“非原子性”。
这意味着,在多个线程同时对 HashMap 进行读写操作时,没有内置的保护机制来协调它们,这可能会导致数据混乱或抛出异常:
- 并发修改(写后读):
- 当一个线程正在进行写操作(例如 put 插入、remove 删除)时,另一个线程突然进行读操作。
- 这可能导致读取到不一致的中间数据,甚至在迭代时抛出 ConcurrentModificationException 异常。
- 非原子性操作(写后写):
- HashMap 的一些操作(如 put)在内部包含多个步骤(例如:计算哈希、检查冲突、放入元素)。
- 如果两个线程同时执行 put 操作,它们的步骤可能会交错执行,导致竞态条件(Race Condition),最终可能只有一个 put 成功,或者造成数据丢失。
二、如何实现线程安全?
为了在多线程环境中使用键值对,有以下几种实现线程安全的方式:
- 使用 Collections.synchronizedMap() 方法
- 这是一个包装器方法。你可以通过 Collections.synchronizedMap(new HashMap(...)) 来创建一个“线程安全”的 Map。
- 它的原理是对所有对 Map 的操作都加上了同步锁(synchronized),性能开销较大。
- 使用 ConcurrentHashMap(推荐)
- ConcurrentHashMap 是专门设计用于多线程环境的哈希表实现(位于 java.util.concurrent 包)。
- 它使用了更高效的并发技术(如分段锁或 CAS),允许多个线程同时进行读操作,并发性能远高于 synchronizedMap。
- 使用显式的锁机制
- 你也可以在自定义的代码中,使用显式的锁(例如 ReentrantLock)来“包裹”住所有对非安全 HashMap 的操作,以此来手动保证线程安全。
4.17 concurrentHashMap 如何保证线程安全
ConcurrentHashMap 在 JDK 1.7 和 1.8 中保证线程安全的方式是不同的,1.8 版本在 1.7 的基础上做了重大优化。
一、JDK 1.7:分段锁 (Segment + ReentrantLock)
- 底层结构:
- ConcurrentHashMap 在 JDK 1.7 中使用数组 + 链表的结构。
- 它的数组分为两类:一个大的数组叫 Segment(段),每个 Segment 内部又是一个小的 HashEntry 数组(即链表数组)。
- 安全机制:
- 它的线程安全是建立在 Segment 上的,每个 Segment 都配有一个 ReentrantLock(重入锁)。
- 当一个线程需要操作某个数据时,它只需要锁定该数据所在的那个 Segment,而不会锁定整个 Map。这就是所谓的“分段锁”技术。
二、JDK 1.8:CAS + Synchronized
- 底层结构:
- ConcurrentHashMap 在 JDK 1.8 中抛弃了 Segment,结构演变为数组 + 链表 + 红黑树(与 HashMap 1.8 类似)。
- 安全机制:
- 它通过 CAS(比较并交换)和 synchronized 来保证线程安全。
- CAS:主要用在向数组空位置(null)插入第一个节点时,利用乐观锁的思想,无需加锁。
- Synchronized:当发生哈希冲突,需要修改链表或红黑树的头节点时,它会只锁住那个“头节点”。
- 核心优势:
- 这种方式极大地缩小了锁的粒度(从 1.7 中锁住一个“段” Segment,缩小到 1.8 中只锁住一个“节点” Node)。
- 锁的粒度更小,意味着线程之间竞争锁的概率更低,因此并发性能和查询性能都得到了显著提高。
4.18 HashMap和ConcurrentHashMap的区别
一、线程安全性
- HashMap:不是线程安全的。在多线程环境中,如果同时进行读写操作,可能会导致数据不一致或抛出异常。
- ConcurrentHashMap:是线程安全的。它使用了分段锁(Segment Locking)*的机制,将整个数据结构分成多个*段(Segment),每个段都有自己的锁。这样,不同的线程可以同时访问不同的段,以提高并发性能。
二、同步机制
- HashMap:在实现上没有明确的同步机制。需要在外部进行同步,例如通过使用 Collections.synchronizedMap() 方法。
- ConcurrentHashMap:内部使用了一种更细粒度的锁机制,因此在多线程环境中具有更好的性能。
三、迭代时是否需要加锁
- HashMap:如果在迭代过程中有其他线程对其进行修改,可能抛出 ConcurrentModificationException 异常。
- ConcurrentHashMap:允许在迭代时进行并发的插入和删除操作,而不会抛出异常。但是,它并不保证迭代器的顺序。
四、初始化容量和负载因子
- HashMap:可以通过构造方法设置初始容量和负载因子。
- ConcurrentHashMap:在 Java 8 及之后版本中引入了 ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) 构造方法,允许设置初始容量、负载因子和并发级别。
五、性能
- 低并发情况:HashMap 的性能可能会稍好,因为 ConcurrentHashMap 需要维护额外的并发控制。
- 高并发情况:ConcurrentHashMap 的性能通常更好,因为它能够更有效地支持并发访问。
总结:如何选择
- 总的来说,如果需要在多线程环境中使用哈希表,并且需要高性能的并发访问,通常会选择使用 ConcurrentHashMap。
- 如果在单线程环境中使用,或者能够手动进行外部同步管理,那么 HashMap 可能是更简单的选择。
2025.11.27 DAY29
4.19 HashMap 与 HashSet 的核心区别
HashMap 和 HashSet 都是 Java 集合框架中常用的类,它们最核心的区别在于用途和内部实现。
1. 核心用途:存“键值对”还是“唯一元素”?
- HashMap (哈希图)
- 用途:适用于需要存储“键值对” (Key-Value) 的情况。
- 特点:每个 键 (Key) 必须唯一,每个键关联一个 值 (Value)。它的核心是“映射”,即通过
Key快速查找Value。
- HashSet (哈希集)
- 用途:适用于只关心“元素唯一性”的情况。
- 特点:它是一个不允许重复元素的集合。它的核心是“唯一”,即快速判断某个元素“在不在”集合里。
2. 内部实现:“秘密”关系
这是两者最关键的区别:
- HashMap:它使用键值对的方式存储数据,底层是通过一个哈希表(数组 + 链表/红黑树)来实现的。
- HashSet:HashSet 实际上是“基于” HashMap 来实现的。
- 它只使用了 HashMap 的“键” (Key) 部分来存储 HashSet 的元素。
- 而 HashMap 的“值” (Value) 部分则被设置为了一个全局统一的、固定的常量(一个内部的
PRESENT对象)。 HashSet正是巧妙地利用了HashMap中“键” (Key) 必须唯一的特性,来实现自己“元素” (Element) 必须唯一的功能。
3. 详细对比
| 特性 | HashMap | HashSet |
|---|---|---|
| 存储内容 | 键值对 (Key-Value) | 单一的、唯一的元素 (Element) |
| Null 值 | 允许一个 null 键 (Key) 允许多个 null 值 (Value) | 只允许存储一个 null 元素 |
| 迭代方式 | 遍历的是键值对 (Entry) | 遍历的是元素 (Element) |
| 关联关系 | 键 (Key) 与 值 (Value) 之间 是一一对应的关系 | 元素没有关联的值, 只有元素本身 |
- 性能影响
- HashMap 和 HashSet 的性能都受到其元素的哈希分布(
hashCode())和哈希冲突的影响。 - 理论上,由于
HashSet只需要存储和管理Key(内部HashMap的Key),因此它的性能开销通常会比HashMap稍好一些。
4.20 HashMap 和 HashTable 的区别
1. 同步性 (线程安全)
这是两者最核心的区别。
- Hashtable:是同步的,即它的方法是线程安全的。
- 这是通过在每个方法上添加 synchronized 关键字来实现的(即“全局锁”)。
- 缺点:这种方式可能导致性能严重下降,因为所有线程都在竞争同一把锁。
- HashMap:不是同步的,因此它不保证在多线程环境中的线程安全性。
- 如果需要同步,可以使用 Collections.synchronizedMap() 方法来“包装”一个同步的 HashMap。
2. 性能
- Hashtable:由于是全局同步的,它在多线程环境中的并发性能可能较差。
- HashMap:在单线程环境中通常比 Hashtable 更快,因为它没有同步开销。
3. 对 Null值的支持
- Hashtable:非常严格。它不允许键(Key)或值(Value)为 null。(否则会抛出异常)
- HashMap:更加灵活。它允许键和值都为 null。(即允许一个 null 键和多个 null 值)。
4. 继承关系 (“出身”)
- Hashtable:是 Dictionary 类的子类(一个较早的类,诞生于 JDK 1.0)。
- HashMap:是 AbstractMap 类的子类,实现了 Map 接口(属于 Java 集合框架的成员,诞生于 JDK 1.2)。
5. 迭代器
- Hashtable:它的迭代器是通过 Enumerator 实现的(较早的接口)。
- HashMap:它的迭代器是通过 Iterator 实现的(Java 集合框架的标准接口)。
6. 初始容量和加载因子
- Hashtable:有其默认的初始容量(11)和加载因子(0.75)。
- HashMap:允许通过构造方法灵活设置初始容量(默认 16)和加载因子(0.75),以便更好地调整性能。
2025.11.28 DAY30
4.21 Java 创建线程有哪几种方式?
一、继承 Thread 类
- 做法:通过创建一个继承了 Thread 类的子类,并重写其 run() 方法来定义线程需要执行的任务。
- 启动:直接创建该子类的实例,并调用其 start() 方法。
二、实现 Runnable接口
- 做法:创建一个实现了 Runnable 接口的类,并实现其 run() 方法来定义任务。
- 启动:创建一个该任务类的实例,并将其作为参数传递给一个新建的 Thread 对象,然后调用 Thread 对象的 start() 方法。
- (这是更推荐的方式,因为它将“任务”和“线程”解耦了。)
三、使用 Callable 和 Future 接口
- 做法:创建一个实现了 Callable 接口的类,并实现其 call() 方法。
- 特点:与 Runnable 不同,call() 方法可以返回一个计算结果,并且可以抛出异常。
- 使用:Callable 通常不能直接交给 Thread,而是需要提交给“线程池”(见方式四)来执行。
四、使用线程池 (ExecutorService)
- 做法:通过 Executors 工具类来创建线程池(ExecutorService)。
- 特点:线程池会统一管理线程的创建、复用和销G毁,性能最好,是最推荐的并发处理方式。
- 使用:
- 你可以将 Runnable(方式二)或 Callable(方式三)的任务提交给线程池去执行。
- 当提交 Callable 任务时,线程池会返回一个 Future 对象。你可以通过这个 Future 对象,在未来的某个时刻获取 Callable 任务的计算结果。
4.22 线程start和run的区别
在 Java 多线程中,start() 和 run() 方法有着本质的不同,混淆它们将无法实现多线程。
1. run() 方法(任务体)
- run() 方法是线程的执行体,它包含了线程真正要执行的代码(即“任务”)。
- 但是,如果你直接调用 run() 方法,它不会创建新的线程。
- 相反,它会像一个普通方法一样,在“当前线程”(例如 *main* 线程)的上下文中同步执行。
2. start() 方法(启动器)
- start() 方法才是用于启动一个新线程的正确方式。
- 当你调用 start() 方法时,它会做两件关键的事:
- 创建一个全新的线程,并为其分配系统资源,使其进入“就绪状态”。
- 当这个新线程被 CPU 调度器选中时,它才会去自动执行那个 run() 方法中的代码。
2025.11.29 DAY30
4.23 你知道Java中有哪些锁吗
一、公平锁 / 非公平锁
- 公平锁:指多个线程按照申请锁的顺序来获取锁,遵循“先来后到”。
- 非公平锁:指多个线程获取锁的顺序并非按照申请顺序。后申请的线程有可能比先申请的线程优先获取锁,这可能导致“饥饿”现象(某个线程一直抢不到锁)。
- Java 实现:
- Java ReentrantLock:默认是非公平锁(但可设置为公平锁)。
- Synchronized:是一种非公平锁。
二、可重入锁(递归锁)
- 定义:指在同一个线程已经获取了外层方法的锁之后,在进入该线程的内层方法时会自动获取锁,不会自己把自己锁死。
- Java 实现:
- Java ReentrantLock:是可重入锁。
- Synchronized:也是一个可重入锁。
三、独享锁 / 共享锁
- 独享锁:指该锁一次只能被一个线程所持有。
- 共享锁:指该锁可被多个线程同时所持有。
- Java 实现:
- Java ReentrantLock:是独享锁。
- ReadWriteLock:其读锁 (ReadLock) 是共享锁,其写锁 (WriteLock) 是独享锁。
- Synchronized:是独享锁。
四、互斥锁 / 读写锁
- 这是“独享锁/共享锁”概念的具体实现:
- 互斥锁:是独享锁的一种广义实现。在 Java 中,ReentrantLock 就是一种互斥锁。
- 读写锁:是“读共享、写独享”的实现。在 Java 中,具体实现就是 ReadWriteLock。
五、乐观锁 / 悲观锁
- 这两种不是指具体的锁,而是指看待并发同步的角度(策略)。
- 悲观锁:
- 态度:悲观地认为,并发操作一定会发生修改和冲突。
- 做法:因此,在操作数据时总是先加锁,防止问题发生。
- 适用:适合写操作非常多的场景。
- 乐观锁:
- 态度:乐观地认为,并发操作不会发生修改。
- 做法:不加锁。而是在更新数据的时候,检查一下在此期间数据是否被别人修改过(例如通过版本号或 CAS)。如果被修改了,则不断重试。
- 适用:适合读操作非常多的场景,不加锁会带来大量的性能提升。
六、分段锁
- 分段锁其实是一种锁的设计,并不是具体的一种锁。
- 它将一个大的数据结构(如 ConcurrentHashMap)分成多个“段” (Segment),每个段有自己独立的锁。
- 优点:线程访问不同“段”的数据时不会相互竞争,从而实现高效的并发操作。
七、偏向锁 / 轻量级锁 / 重量级锁
- 这三种不是具体的锁,而是 Synchronized 锁的三种状态,是 Java 5 引入的“锁升级”机制。状态信息记录在对象头中。
- 1. 偏向锁:
- 场景:一段同步代码一直被同一个线程所访问。
- 状态:锁“偏向”该线程,该线程自动获取锁,降低获取锁的代价。
- 2. 轻量级锁:
- 升级:当锁是偏向锁时,被另一个线程所访问,偏向锁就会升级为轻量级锁。
- 行为:其他线程会通过“自旋”(见下文)的形式尝试获取锁,不会立即阻塞,以提高性能。
- 3. 重量级锁:
- 升级:当锁为轻量级锁时,自旋(循环)一定次数后,线程仍未获取到锁。
- 行为:锁会膨胀为重量级锁,使其他申请的线程进入阻塞状态,性能降低。
八、自旋锁
- 自旋锁:指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去不断尝试获取锁。
- 好处:减少线程上下文切换(休眠和唤醒)的消耗。
- 坏处:如果锁被长时间占用,循环会持续消耗 CPU。
4.24 说一说你对 synchronized 的理解
一、核心作用:实现线程安全
synchronized 是 Java 中的一个关键字,用于实现同步和线程安全。
当一个方法或代码块被 synchronized 修饰时,它将成为一个“临界区”。同一时刻只能由一个线程访问。其他线程必须等待当前线程退出临界区才能进入。这确保了多个线程在访问共享资源时不会产生冲突。
二、两种使用方式:方法锁与代码块锁
synchronized 可以应用于方法或代码块:
- 应用于方法:整个方法都会被锁定。
- 应用于代码块:只有该代码块被锁定。
这样做的好处是,你可以选择性地锁定对象的一部分(即缩小锁的粒度),而不是锁定整个方法,从而提高效率。
三、性能优化 (JVM 的“智能”进化)
synchronized 的实现机理依赖于 JVM,因此其性能会随着 Java 版本的不断升级而提高。
尤其到了 Java 1.6,synchronized 进行了大量的优化,例如:适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后的 Java 1.7 与 1.8 中,也均对该关键字的实现机理做了持续优化。
四、一个重要缺点:不可中断
需要特别说明的是,当线程通过 synchronized 等待锁(即发生阻塞)时,它是不能被 Thread.interrupt() 中断的。
因此程序设计时必须检查确保合理,否则如果锁的获取顺序不当,可能会造成线程死锁的尴尬境地。
五、结论与推荐
尽管 Java 提供了多种锁机制(并且有些性能可能更高),但仍然强烈推荐在多线程应用程序中优先使用 synchronized。
原因是:它实现方便,后续所有复杂的优化工作(如锁升级)都由 JVM 自动来完成,可靠性极高。
只有在确定锁机制是当前多线程程序的性能瓶颈时,才需要考虑使用其他更复杂的机制,如 ReentrantLock 等。
2025.12.01 DAY32
4.25 synchronized和lock的区别是什么
synchronized 和 Lock 都是 Java 中用于实现线程同步的手段,但它们在来源、使用方式和功能特性上有显著不同。
1. 来源与用法 (关键字 vs 接口)
- synchronized:是 Java 的关键字,它是 JVM 的内置锁实现。
- 使用简单直接,可以用于修饰方法或代码块,JVM 会自动负责锁的获取和释放。
- Lock:是一个 Java 接口(位于 java.util.concurrent.locks 包),是 JDK 层面提供的显式锁机制。
- 需要手动操作,通过其实现类(如 ReentrantLock)创建锁对象,然后必须主动调用锁的获取 (lock()) 和释放 (unlock()) 方法。
2. 功能特性与灵活性
这是两者最核心的区别,Lock 提供了 synchronized 所不具备的许多高级功能:
synchronized (功能相对单一)
- 灵活性较低:只能用于方法或代码块。
- 不可中断:线程一旦开始等待锁,不能被中断(Thread.interrupt() 无效)。
- 没有超时机制:一旦获取不到锁就会一直死等。
- 非公平锁:它是一种非公平锁,线程调度由 JVM 控制。
Lock (功能丰富灵活)
- 可中断等待:提供了可中断的锁获取方式,线程在等待时可以被中断。
- 超时获取锁:提供了超时获取的能力(例如 tryLock(time)),可在指定时间内尝试获取锁,超时则放弃。
- 尝试获取锁:可以尝试获取锁(tryLock()),如果锁被占用,立即返回 false 而不是等待。
- 公平性选择:可以设置为公平锁(new ReentrantLock(true)),确保线程按照请求锁的顺序来获取锁。
3. 等待与通知机制
- synchronized:与 wait() 和 notify()/notifyAll() 方法配合使用,实现线程的等待和通知。
- Lock:可以与 Condition 接口结合,实现更细粒度、更精确的线程等待和通知机制(例如可以创建多个Condition,实现分组唤醒)。
4. 总结与使用场景
- synchronized:使用简单,由 JVM 保证锁的释放,不易出错。适合锁的粒度较小、竞争不激烈、实现简单的场景。
- Lock:功能更强大,提供了更多的灵活性和控制能力。适用于需要更复杂同步控制的场景(如需要中断、超时或公平锁)。
4.26 synchronized和ReentrantLock的区别是什么
synchronized 和 ReentrantLock 都是 Java 中用于实现线程同步的可重入锁,但它们在来源、使用方式和功能特性上有显著不同。
1. 来源与用法 (关键字 vs 类)
- synchronized:是 Java 的关键字,它是 JVM 的内置锁实现。
- 使用简单直接,可以用于修饰方法或代码块。
- JVM 会自动负责锁的获取和释放,不易出错。
- ReentrantLock:是 java.util.concurrent.locks 包中的一个类(Lock 接口的实现),是 JDK 层面提供的显式锁机制。
- 需要手动操作,必须显式创建锁对象。
- 必须在 finally 块中手动调用 unlock() 来释放锁,否则可能导致死锁。
2. 功能特性与灵活性 (核心区别)
这是两者最核心的区别,ReentrantLock 提供了 synchronized 所不具备的许多高级功能:
synchronized (功能相对单一)
- 灵活性较低:只能用于方法或代码块。
- 不可中断:线程一旦开始等待锁(阻塞),不能被中断(Thread.interrupt() 无效)。
- 没有超时机制:一旦获取不到锁就会一直死等。
- 非公平锁:它是一种非公平锁,线程调度由 JVM 控制。
ReentrantLock (功能丰富灵活)
- 可中断等待:支持在等待锁的过程中响应中断。
- 超时获取锁:提供了尝试获取锁的超时机制,可以通过 tryLock() 方法设置超时时间。
- 公平性选择:可以设置为公平锁(默认非公平),确保线程按照请求锁的顺序来获取锁。
- 状态检查:提供了 isLocked()、isFair() 等方法,可以检查锁的当前状态。
3. 条件变量 (等待/通知机制)
- synchronized:通过 wait()、notify()、notifyAll() 与对象的监视器方法配合使用。
- 缺点:只有一个条件队列,唤醒时不能精确唤醒特定线程。
- ReentrantLock:通过 Condition 接口(
lock.newCondition())实现更灵活的条件变量控制。- 优点:可以绑定多个 Condition 对象,每个对象可以有不同的等待和唤醒逻辑,实现精确唤醒。
4. 总结与使用场景
- synchronized:使用简单,不易出错,且 JVM 已经对其做了大量优化(如锁升级)。适合绝大多数简单的同步需求。
- ReentrantLock:功能更强大,提供了更丰富的控制能力和灵活性。适用于需要复杂同步控制的场景(例如需要中断、超时、公平锁或多条件变量)。
2025.12.02 DAY33
4.27 volatile 关键字的作用有那些?
volatile 通常被比喻成“轻量级的 synchronized”,它是 Java 并发编程中一个非常重要的关键字。
和 synchronized 不同,volatile 是一个变量修饰符,它只能用来修饰变量,无法修饰方法及代码块。
volatile 关键字在 Java 中主要有两大核心作用:
1. 保证“内存可见性”
- 定义:volatile 确保当一个线程修改了某个变量时,其他线程能够立即看到这个改变。
- 工作原理:
- 当对非 volatile 变量进行读写时,每个线程会先从主内存拷贝变量到自己的 CPU 缓存中。这在多 CPU 环境下,可能导致每个线程的“本地副本”数据不一致。
- volatile 变量不会被缓存在对其他处理器不可见的地方(如寄存器或 CPU 缓存)。它保证了每次读写变量都直接从主内存中操作,跳过了 CPU 缓存这一步。
- 效果:当一个线程修改了 volatile 变量的值,新值对于其他线程是立即得知的。
2. 禁止“指令重排”
- 定义:volatile 变量的读写操作在 JVM 执行时不会发生指令重排,确保了代码的执行顺序。
- 工作原理:
- “指令重排序”是 JVM 为了优化指令、提高程序运行效率的一种技术。
- volatile 变量会禁止这种重排序。它的实现方式是,在读写操作指令前后插入“内存屏障”。
- 效果:指令重排序时,不能把内存屏障后面的指令重排序到前面,从而保证了程序的有序性。
volatile 的重要局限性:不保证原子性
虽然 volatile 可以确保可见性和有序性,但它不保证“复合操作”的原子性。
例如,i++ 这种“读-改-写”的操作,volatile 无法保证其在多线程下的安全。在这种场景下,仍需使用 synchronized 或 Atomic 类。
4.28 volatile 与synchronized 的对比
volatile 和 synchronized 都是 Java 中用于多线程同步的工具,但它们在机制、原子性、互斥性、性能和使用场景上有着显著区别。
1. 机制与用途 (广播 vs 锁门)
- synchronized:用于提供线程间的同步机制。
- 当一个线程进入 synchronized 修饰的代码块或方法时,它会获取一个监视器锁。
- 这保证了同一时间只有一个线程可以执行这段代码。
- 其主要用途是确保数据的一致性和线程安全性。
- volatile:用于修饰变量。
- volatile 的主要作用是确保变量的“可见性”——即当一个线程修改了 *volatile* 变量的值,其他线程能够立即看到这个修改。
- 此外,它还可以防止指令重排序。
- 总结:volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性(包括可见性、原子性和互斥性)。
2. 原子性 (不可分割)
- synchronized:可以保证被其修饰的代码块的原子性。
- 这意味着这段代码在执行过程中不会被其他线程打断。
- volatile:不保证复合操作的原子性。
- 它只能保证单个读操作或写操作的原子性。
- 对于像
i++这种“读-改-写”的复合操作,volatile 无法保证原子性。
3. 互斥性 (只进一个)
- synchronized:提供了互斥性。
- 即同一时间只有一个线程可以执行被其修饰的代码块或方法。
- volatile:不提供互斥性。
- 它不能阻止多个线程同时进入某个方法或代码块,它只确保变量的可见性。
4. 性能 (轻量 vs 重量)
- volatile:通常比 synchronized 更轻量级,因为它不涉及锁的获取和释放(线程不需要阻塞)。
- 因此,在仅需保证可见性的情况下,volatile 的性能通常更好。
- synchronized:由于涉及锁的竞争、阻塞和唤醒(尤其是在锁升级为重量级锁后),开销相对较大。
5. 使用场景
- volatile:适用于简单的内存可见性要求的场景(例如,使用一个 boolean 标志来停止另一个线程)。
- synchronized:适用于需要同时保证原子性、可见性和互斥性的复杂同步场景。
4.29 JDK8有哪些新特性
一、Lambda表达式
- 它允许我们以更简洁的语法来编写匿名函数(即没有方法名的函数)。
二、Stream API(流式 API)
- Stream API 提供了一种“声明式”的方式来高效地操作集合。
- 它就像一条流水线,支持各种丰富的操作,如过滤 (filter)、映射 (map)、排序 (sorted)、归约 (reduce) 等。
三、函数式接口
- 这是 Lambda 表达式的“载体”。
- Java 8 提供了一系列内置的函数式接口(如 Consumer、Predicate、Function 等),用于配合 Lambda 和 Stream API 使用。
四、新的日期和时间 API
- Java 8 引入了 java.time 包,提供了一套全新的日期和时间 API。
- 它解决了旧的 java.util.Date 和 java.util.Calendar 的诸多问题(如线程安全、API 混乱等),提供了更清晰、更易用的日期和时间处理方式。
五、方法引用
- 它允许我们通过方法的名称来直接引用一个方法,而不是执行它。
- 它被视为 Lambda 表达式的一种“语法糖”(更简洁的写法),例如 System.out::println。
2025.12.03 DAY34
4.30 为什么要有线程池?
1. 资源管理(控制数量)
- 在多线程应用中,每个线程都需要占用内存和 CPU 资源。
- 如果不加限制地创建线程,当并发量过高时,会导致系统资源耗尽,甚至可能引发系统崩溃。
- 线程池通过限制并控制线程的数量,帮助避免了这个问题。
2. 提高性能(减少开销)
- 线程的创建和销毁本身是有开销的。
- 线程池通过重用已存在的线程,显著减少了因频繁创建和销毁线程而带来的性能开销。
3. 任务排队(缓冲与分配)
- 线程池通过任务队列(Task Queue)和工作线程的配合,可以合理地分配任务。
- 这确保了任务按照一定的策略(如先进先出)执行,避免了过度的线程竞争和冲突。
4. 统一管理(监控与调度)
- 线程池提供了统一的线程管理方式。
- 这使得开发者可以对线程进行集中的监控、调度和管理,而不是分散地处理单个线程。
总结
采用多线程编程时:
- 如果线程过多,会造成系统资源的大量占用,降低系统效率。
- 如果线程存活时间很短但又不得不频繁创建,会因创建和销毁的开销造成资源的浪费。
线程池的作用就是为了解决这些问题:
- 它预先创造并管理一部分核心线程。
- 当系统需要处理任务时,直接将任务添加到线程池的任务队列中。
- 由线程池决定由哪个空闲且存活的线程来处理。
- 线程池还能弹性地管理线程数量:当线程不够时适当创建新线程,当线程冗余时销毁多余线程。
最终,线程池极大地提高了线程的利用率,并降低了系统资源的消耗。
4.31 说一说线程池有哪些常用参数
- corePoolSize (核心线程数)
- 定义:线程池中长期存活的核心线程数量。
- 作用:即使这些线程处于空闲状态,它们也不会被回收(除非设置了 allowCoreThreadTimeOut)。
- maximumPoolSize (最大线程数)
- 定义:线程池允许创建的最大线程数量。
- 作用:当任务队列(workQueue)已满时,线程池会继续创建新线程,直到达到这个最大数量上限。
- keepAliveTime (空闲线程存活时间)
- 定义:非核心线程(即超过 corePoolSize 的那部分线程)在空闲时能存活的最长时间。
- 作用:当线程数大于核心线程数时,如果一个线程的空闲时间超过 keepAliveTime,它将被终止。
- TimeUnit (时间单位)
- 定义:用于指定 keepAliveTime 的时间单位。
- 作用:例如 TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等。
- workQueue (线程池任务队列)
- 定义:一个阻塞队列,用于存放所有等待执行的任务。
- 作用:当核心线程都在忙碌时,新提交的任务会进入这个队列中排队。
- ThreadFactory (创建线程的工厂)
- 定义:用于创建新线程的工厂。
- 作用:通过它,我们可以自定义线程的创建过程,例如设置线程命名规则、优先级或指定为守护线程等。
- RejectedExecutionHandler (拒绝策略)
- 定义:当线程池和任务队列均已饱和(即达到 maximumPoolSize 且 workQueue 已满)时,处理新任务的策略。
- 作用:这是线程池的一种过载保护机制,例如可以选择抛出异常、丢弃任务或由调用者线程自己执行。
4.32 BIO、NIO、AIO 的区别
BIO、NIO 和 AIO 是 Java 中三种不同的 I/O(输入/输出)模型,它们在处理 I/O 操作时有显著的特点和效率差异。
- BIO (Blocking I/O - 阻塞式 I/O)
- 特点:BIO 是一种阻塞式的 I/O 模型。
- 行为:当一个线程执行 I/O 操作时(如读取数据),如果数据还没准备好,这个线程会被阻塞(卡住不动),直到数据到达为止。
- 适用:适合连接数较少且固定的场景,但由于一个连接需要一个线程,扩展性较差。
- NIO (Non-blocking I/O - 非阻塞式 I/O)
- 特点:NIO 是一种非阻塞的 I/O 模型。
- 行为:NIO 使用通道 (Channel) 和缓冲区 (Buffer) 来处理数据,提高了 I/O 效率。线程发起 I/O 请求后不会被阻塞,可以继续做其他事,并通过“轮询”(Selector)的方式检查多个 I/O 操作是否准备就绪。
- 适用:支持面向缓冲区的读写操作,适合高并发、需要处理大量连接的应用(如 Netty)。
- AIO (Asynchronous I/O - 异步 I/O)
- 特点:AIO 是一种异步的 I/O 模型(从 Java 7 开始引入)。
- 行为:在 AIO 中,I/O 操作被发起后,线程可以立即继续执行其他任务。它不需要像 NIO 那样轮询,而是当 I/O 操作真正完成时,操作系统会主动“通知”线程(通过回调函数或 Future)。
- 适用:适合需要高性能、高并发 I/O 操作,且希望完全避免 I/O 操作阻塞线程的场景。
总结:使用场景
- BIO:适合低并发、连接数较少的应用。
- NIO:适合高并发、需要处理大量连接的应用。
- AIO:适合需要极高性能、异步处理 I/O 操作的场景。
2025.12.04 DAY35
4.33 Java 内存区域有哪些部分
根据 Java 虚拟机规范,Java 的内存区域主要分为以下几个部分,我们可以将其归类为“线程私有”和“线程共享”两大类:
一、 线程私有区域
(以下区域随线程的启动而创建,随线程的结束而销毁)
1. 程序计数器 (Program Counter)
- 它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 在多线程环境下,每个线程都有自己独立的程序计数器。
- 当线程执行 Java 方法时,它记录的是正在执行的虚拟机字节码指令的地址。
2. Java 虚拟机栈 (JVM Stack)
- 每个 Java 线程都有一个私有的 Java 虚拟机栈,与线程同时创建。
- 每个方法在执行时都会创建一个“栈帧” (Stack Frame)。
- 栈帧用于存储局部变量、操作数栈、动态链接、方法出口等信息。
- 栈帧在方法调用时入栈,方法返回时出栈。
3. 本地方法栈 (Native Method Stack)
- 本地方法栈与 Java 虚拟机栈非常类似。
- 区别在于:它为本地方法 (Native Method) 服务。本地方法是用其他编程语言(如 C/C++)编写的,通过 JNI 与 Java 代码交互。
二、 线程共享区域
(以下区域被所有线程共享,在虚拟机启动时创建)
1. Java 堆 (Java Heap)
- Java 堆是 Java 虚拟机中最大的一块内存区域,被所有线程共享。
- 它的唯一目的就是存储对象实例。几乎所有的对象实例和数组都在堆上分配内存。
- 堆可以被细分为新生代(Eden 空间、Survivor 空间 From/To)和老年代等区域,以便进行垃圾回收。
2. 方法区 (Method Area)
- 方法区也是所有线程共享的。
- 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。
- 在 HotSpot 虚拟机中,方法区也被称为“永久代”,但在较新的 JVM 版本中(如 JDK 8+),永久代已被“元空间” (Metaspace) 所取代。
3. 运行时常量池 (Runtime Constant Pool)
- 它是方法区的一部分。
- 用于存储编译期生成的各种字面量和符号引用。
4. 字符串常量池 (String Constant Pool)
- 这是 JVM 为了提升性能和减少内存消耗而针对字符串专门开辟的一块区域。
- 其主要目的是为了避免字符串的重复创建。
三、 特殊区域
直接内存 (Direct Memory)
- 它不属于 Java 虚拟机运行时数据区的一部分。
- 但是,Java 可以通过 NIO (New I/O) 操作直接内存,提高 I/O 性能,避免在 Java 堆和 Native 堆之间来回复制数据。
4.34 介绍一下什么是强引用、软引用、弱引用、虚引用
这四种引用类型决定了对象的生命周期,以及垃圾收集器 (GC) 如何对待这些对象。
1. 强引用 (Strong Reference)
- 定义:这是最常见的引用类型,例如
Object obj = new Object();。 - 特点:如果一个对象具有强引用,那么垃圾收集器绝不会回收它。
- 后果:当内存不足时,JVM 宁愿抛出 OutOfMemoryError 错误,也不会回收被强引用关联的对象。
2. 软引用 (Soft Reference)
- 定义:用于描述一些还有用但并非必需的对象。
- 特点:如果一个对象只有软引用指向它,那么在系统内存不足时,垃圾回收器会尝试回收这些对象。
- 用途:软引用通常用于实现内存敏感的缓存。可以在内存不足时自动释放缓存中的对象,防止内存溢出。
3. 弱引用 (Weak Reference)
- 定义:弱引用比软引用的生命周期更短暂。
- 特点:如果一个对象只有弱引用指向它,那么在下一次垃圾回收时,不论系统内存是否充足,这些对象都会被回收。
- 用途:常用于防止内存泄漏(例如 WeakHashMap),即不希望缓存的对象影响垃圾回收器对它的回收。
4. 虚引用 (Phantom Reference / 幽灵引用)
- 定义:这是 Java 中最弱的引用类型,无法通过它获取对象实例。
- 特点:一个对象是否有虚引用,完全不影响其生命周期。
- 用途:虚引用唯一的目的是为了跟踪对象被垃圾回收的时机。在对象被回收之前,虚引用会被放入一个引用队列 (ReferenceQueue) 中,供程序员进行后续的清理或记录(例如管理堆外内存)。
4.35 有哪些垃圾回收算法
一、标记-清除算法 (Mark-Sweep)
“标记-清除”算法将垃圾回收分为两个阶段:
- 标记阶段:首先通过根节点 (GC Roots),标记所有从根节点开始可达的对象。未被标记的对象就是未被引用的垃圾对象。
- 清除阶段:清除所有未被标记的对象。
- 适用场合:
- 在存活对象较多的情况下(相比于复制算法)比较高效。
- 适用于老年代(即旧生代)。
二、复制算法 (Copying)
“复制算法”会从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块全新的、未使用的内存上去。之后,将原来的那一块内存全部回收掉。
- 使用:现在的商业虚拟机(如 HotSpot)都采用这种收集算法来回收新生代。
- 适用场合:
- 在存活对象较少(即垃圾对象多)的情况下比较高效。
- 适用于年轻代(新生代)。这是因为新生代中基本上 98% 的对象是“朝生夕死”的,存活下来的会很少。
- 缺点:
- 需要一块额外的空内存空间(导致空间浪费)。
- 需要复制和移动存活的对象。
复制算法的高效性是建立在存活对象少的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本将会很高。
三、标记-整理 (Mark-Compact / 标记-压缩)
“标记-压缩”算法是一种老年代的回收算法,它在“标记-清除”算法的基础上做了优化。
- 首先,它也需要从根节点开始对所有可达对象做一次标记。
- 但之后,它不是简单地清理未标记的对象,而是将所有的存活对象“压缩”到内存的一端。
- 最后,清理边界之外的所有空间。
- 优点:这种方法既避免了(标记-清除算法的)内存碎片的产生,又不需要(复制算法那样的)两块相同的内存空间,因此,其性价比比较高。
四、分代收集算法 (Generational Collection)
“分代收集算法”是目前虚拟机实际使用的回收算法。它不是一种具体的算法,而是一种策略。
- 核心思想:将内存划分为不同的“年代”(一般划分为老年代和新生代),在不同年代使用不同的、最合适的算法,从而提高整体效率。
- 具体策略:
- 新生代:对象存活率低,因此使用“复制算法”。
- 老年代:对象存活率高,没有额外空间对它进行分配担保,所以只能使用“标记-清除”或者“标记-整理”算法。
4.36 有哪些垃圾回收器
一、 新生代垃圾收集器
(主要特点:新生代对象“朝生夕死”,存活率低,大多采用“复制算法”)
1. Serial 收集器
- 算法:复制算法。
- 特点:这是一个新生代的单线程收集器。
- 优点:简单高效,是最基本、发展历史最悠久的收集器。
- 缺点:它在进行垃圾收集时,必须暂停其他所有的工作线程(Stop-The-World),直到它收集完成。
2. ParNew 收集器
- 算法:复制算法。
- 特点:这是一个新生代的并行(多线程)收集器。
- 总结:它其实就是 Serial 收集器的多线程版本。
3. Parallel Scavenge 收集器
- 算法:复制算法。
- 特点:这是一个新生代的并行收集器。
- 核心目标:它的目标是追求高吞吐量(即高效利用 CPU),而不是最短停顿时间。
二、 老年代垃圾收集器
(主要特点:老年代对象存活率高,空间大,不适合复制算法)
- Serial Old 收集器
- 算法:标记-整理算法。
- 特点:这是 Serial 收集器的老年代版本。
- 它同样是一个单线程(串行)收集器,主要意义在于给 Client 模式下的虚拟机使用。
- Parallel Old 收集器
- 算法:多线程和“标记-整理”算法。
- 特点:这是 Parallel Scavenge 收集器的老年代版本。
- 这个收集器在 JDK 1.6 中才开始提供(配合 Parallel Scavenge 实现高吞吐量)。
- CMS 收集器 (Concurrent Mark Sweep)
- 核心目标:一种以获取最短回收停顿时间为目标的收集器。
- 适用场景:非常符合重视服务器响应速度的应用(如互联网站或 B/S 系统),希望系统停顿时间最短。
- 算法:CMS 收集器是基于“标记-清除”算法实现的。
- 运作过程:它的运作过程相对复杂,整个过程分为 4 个步骤:
- 初始标记
- 并发标记(与用户线程同时运行)
- 重新标记
- 并发清除(与用户线程同时运行)
三、 整堆垃圾收集器 (G1)
G1 收集器
- 定位:JDK 1.7 后推出的全新回收器,用于取代 CMS 收集器。
- 算法:从整体来看是“标记-整理”算法,但局部(Region 之间)是“复制”算法。
- G1 收集器的优势:
- 分代 GC:它是一个分代收集器,同时兼顾年轻代和老年代。
- 分区算法:使用分区 (Region) 算法,不要求 Eden、年轻代或老年代的空间都连续。
- 并行性:回收期间,可由多个线程同时工作,有效利用多核 CPU 资源。
- 空间整理:回收过程中,会进行适当的对象移动,减少空间碎片(这是它优于 CMS 的地方)。
- 可预测性:G1 可选取部分区域进行回收,可以缩小回收范围,减少全局停顿,并建立可预测的停顿时间模型。
- G1 收集器的阶段:
- 初始标记:标记从 GC Root 开始直接可达的对象。
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象。
- 最终标记:标记那些在并发标记阶段发生变化的对象。
- 筛选回收:首先对各个 Region(区域)的回收价值和成本进行排序,根据用户所期待的 GC 停顿时间指定回收计划,回收一部分 Region。
2025.12.05 DAY36(111)
4.37 类加载机制介绍一下
类加载机制是 Java 虚拟机(JVM)运行 Java 程序时,负责将类的字节码文件 (.class) 加载到内存中,并转换为可以使用的类的过程。它主要包括以下几个步骤:
1. 加载 (Loading)
- 在此阶段,类加载器 (ClassLoader) 负责查找类的字节码文件(可以来自文件系统、网络等位置)。
- 找到后,将其加载到内存中。
- 重点:此阶段不会执行类中的静态初始化代码。
2. 连接 (Linking)
连接阶段负责将加载的类“组装”到 JVM 中,它包括三个子阶段:
- a. 验证 (Verification):
- 确保加载的类文件格式正确(例如,是否以
0xCAFEBABE开头)。 - 确保它不包含不安全的构造(例如,类型转换是否安全),以保证 JVM 自身的安全。
- 确保加载的类文件格式正确(例如,是否以
- b. 准备 (Preparation):
- 在内存中为类的静态变量 (static variables) 分配内存空间。
- 重点:并设置默认初始值。例如,数值类型(int, long)被设置为 0,布尔类型为 false,引用类型为 null。(注意:此时还不是代码中指定的初始值)。
- c. 解析 (Resolution):
- 将类、接口、字段和方法中的符号引用(一种“代号”)解析为直接引用(即真实的内存地址)。
- 这一步骤确保了程序在运行时能真正找到它所引用的其他类或方法。
3. 初始化 (Initialization)
- 这是类加载的最后一步,在此阶段,JVM 才真正开始执行类中定义的静态初始化代码。
- 具体工作:
- 执行静态代码块(
static { ... })。 - 为静态变量赋予程序中指定的初始值(例如,
static int a = 100;在“准备”阶段a是 0,在此阶段才被赋值为 100)。
- 执行静态代码块(
- 触发时机:静态初始化只在类的首次使用时进行(例如,创建实例、访问静态字段或调用静态方法)。
4.38 介绍一下双亲委派机制
双亲委派机制是 Java 类加载器(ClassLoader)中的一种设计模式,它用于确定类的加载方式和顺序。
核心工作流程
该机制的核心思想是:
- 如果一个类加载器收到了类加载的请求,它不会首先尝试自己去加载这个类。
- 相反,它会默认先将该请求“委托”给其父类加载器去处理。
- 这个委托过程会层层向上,直到请求到达顶层的启动类加载器 (Bootstrap ClassLoader)。
- 只有当父级加载器无法加载该类时(即在它的搜索路径中找不到),子加载器才会尝试自行加载。
为什么需要双亲委派?(主要优势)
这种设计确保了 Java 核心库的安全性和一致性。
- 1. 提高安全性 (防止核心类被篡改):
- 所有的类加载请求最终都会传递到顶层的启动类加载器。
- 这确保了像 java.lang.Object 或 java.lang.String 这样的核心库类,永远是由“最顶层”的加载器加载的。
- 这可以防止恶意代码通过“伪造”一个同名的核心类来篡改系统核心功能。
- 2. 避免类的重复加载:
- 由于类加载器在加载前会先“向上”询问父加载器是否已经加载过。
- 如果父加载器已经加载了,子加载器就不需要再次加载。
- 这保证了在程序中,同一个类(例如
java.lang.String)永远是同一个 Class 对象。
4.39 说一说你对Spring AOP的了解
一、 AOP:OOP 的补充与完善
面向切面编程 (AOP) 可以说是面向对象编程 (OOP) 的一种补充和完善。
- OOP 的局限性:OOP 引入了封装、继承、多态,非常适合建立一种“纵向”的对象层次结构。但它并不适合定义“横向”的关系,例如日志、事务或安全功能,这些功能需要横跨多个业务模块。
- AOP 的解决方案:AOP 恰恰相反,它利用一种称为“横切”的技术,*剖解*开封装的对象内部,将那些影响了多个类的公共行为封装到一个可重用模块中,这个模块就被命名为“切面” (Aspect)。
二、 什么是“切面” (Aspect)?
所谓“切面”,简单说就是:
- 将那些与核心业务无关,却为多个业务模块所共同调用的逻辑(如日志、事务管理)封装起来。
- AOP 的价值在于:便于减少系统的重复代码,降低模块之间的耦合度,并有利于未来的可操作性和可维护性。
三、 Spring AOP 的实现原理
1. 依赖 IOC 容器
在 Spring 中,AOP 代理由 Spring 的 IOC 容器负责生成和管理,其依赖关系也由 IOC 容器负责。因此,AOP 代理可以直接使用容器中的其它 bean 实例作为目标,这种关系可由 IOC 容器的依赖注入提供。
2. Spring 的代理规则
Spring 创建代理的规则为:
- 默认使用 JDK 动态代理:这样就可以为任何接口 (Interface) 实例创建代理。
- 切换为 CGLIB 代理:当需要代理类 (Class),而不是代理接口的时候,Spring 会自动切换为使用 CGLIB 代理。当然,你也可以强制Spring 使用 CGLIB。
四、 AOP 编程的三大核心
AOP 编程其实很简单,程序员只需要参与以下三个部分:
- 定义普通业务组件(即你的核心业务类)。
- 定义切入点 (Pointcut):一个切入点定义了“在哪里”执行,它可能横切多个业务组件。
- 定义增强处理 (Advice):增强处理定义了“做什么”,它是在 AOP 框架为普通业务组件织入的处理动作。
总结
进行 AOP 编程的关键就是定义切入点(在哪里)*和*定义增强处理(做什么)。
一旦定义了合适的切入点和增强处理,AOP 框架将自动生成 AOP 代理。最终,代理对象的方法执行效果等于:
代理对象的方法 = 增强处理 + 被代理对象的方法
4.40 说一说你对 Spring中IOC的理解
一、 什么是 Spring IOC?(核心思想)
Spring 的 IOC (Inversion of Control),即“控制反转”,是 Spring 框架的核心思想。
它的核心是:将对象的创建和依赖关系的管理从我们自己的代码中转移(反转)给 Spring 容器来控制。
我们不再通过 new 关键字手动创建对象,而是由容器来负责。这样做的最大好处是,各个组件之间能够保持松散的耦合。
二、 容器与依赖注入 (DI)
-
容器是什么? 简单来说,这个“容器”在内部就像一个巨大的 Map,Map 中存放的就是 Spring 所管理的各种对象 (Bean)。
-
如何实现 IOC? IOC 是通过 DI (Dependency Injection) 即“依赖注入”来实现的。
Spring 容器可以在运行时,动态地将一个对象(依赖)“注入”*到*需要它的另一个对象中,而不是让对象自己去寻找或创建它的依赖。
-
为什么这么做? 这样可以极大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。
三、 举例说明
举一个实际项目中的例子:一个 Service 类可能依赖了几十个(甚至上百个)底层的类。
- 没有 IOC:如果我们需要手动实例化这个 Service,我们必须搞清楚它所有底层类的构造函数和创建顺序。
- 有了 IOC:我们只需要配置好,然后在需要的地方直接引用这个 Service 即可,Spring 容器会自动帮我们组装好一切。
四、 如何配置 (XML vs 注解)
我们通过配置来告诉 Spring 容器如何创建对象、如何管理对象的生命周期。
- 在早期的 Spring 时代,我们一般通过 XML 文件来配置。
- 后来,开发人员觉得 XML 文件过于繁琐,于是 SpringBoot 时代的注解配置(如
@Component,@Service,@Autowired)开始慢慢流行起来,成为了主流。
总结
总结来说,Spring 的 IOC 容器是一个中央化的、负责管理应用中所有对象及其生命周期的强大工具。
2025.12.06 DAY37
4.41 Bean的作用域
Spring Bean 的作用域 (Scope)
在 Spring 中,那些构成应用程序主体、并由 Spring IOC 容器所管理的对象,被称之为 Bean。
而 Bean 的作用域 (Scope) 则定义了这些 Bean 实例在应用程序中的生命周期和可见范围。
主要有以下几种:
核心作用域
- Singleton (单例)
- 这是 Spring 的默认作用域。
- 当一个 Bean 的作用域为 Singleton 时,Spring IoC 容器中只会存在一个共享的 Bean 实例。
- 所有对该 Bean 的请求,只要 ID 与 Bean 定义相匹配,容器只会返回同一个实例。
- Prototype (原型)
- 当 Bean 的作用域为 Prototype 时,表示一个 Bean 定义对应多个对象实例。
- 这会导致在每次对该 Bean 发出请求时(例如,通过
getBean()或注入),Spring 容器都会创建一个全新的 Bean 实例。
Web 应用程序作用域
(以下作用域仅在 Web 环境中生效)
- Request (请求)
- 一个 HTTP 请求对应一个 Bean 实例。
- 这意味着每个请求都有自己的 Bean 实例,且该 Bean 仅在当前请求期间有效。
- Session (会话)
- *一个 HTTP 会话**对应一个 Bean 实例。
- Bean 的生命周期与用户的会话周期相同。
- Application (应用程序)
- 对于定义在 ServletContext 中的 Bean,整个 Web 应用程序共享同一个 Bean 实例。
- WebSocket
- 在一个完整的 WebSocket 生命周期内,每个 WebSocket 会话拥有一个独立的 Bean 实例。
4.42 Bean的生命周期
Spring Bean 的生命周期,就是 Spring 容器从创建 Bean 到销毁 Bean 的整个过程。这个过程包含了多个关键的“扩展点”,允许我们在不同阶段自定义 Bean 的行为。
主要步骤如下:
1. 实例化 Bean
- Spring 容器通过构造器或工厂方法创建 Bean 的实例。
2. 设置属性(依赖注入)
- 容器会注入 Bean 的所有属性(DI)。这些属性可能是其他 Bean 的引用,也可能是简单的配置值。
3. 检查 Aware 接口并设置相关依赖
- 如果 Bean 实现了 BeanNameAware、BeanFactoryAware 或 ApplicationContextAware 等接口,容器会调用相应的
set方法,将容器自身的相关依赖(如 Bean 的名字、Bean 工厂、应用上下文)设置给 Bean。
4. BeanPostProcessor - 初始化前
- 在 Bean 的属性设置完毕、初始化方法(init-method)调用之前,Spring 会调用所有注册的
BeanPostProcessor的 postProcessBeforeInitialization 方法。
5. 初始化 Bean
- (A) InitializingBean 接口:如果 Bean 实现了 InitializingBean 接口,容器会调用其
afterPropertiesSet方法。 - (B) 自定义 init-method:如果 Bean 定义了 init-method(例如在 XML 或
@Bean注解中指定),容器也会调用这个方法。
6. BeanPostProcessor - 初始化后
- 容器会再次调用所有注册的
BeanPostProcessor的 postProcessAfterInitialization 方法。 - 重点:这次调用是在 Bean 所有初始化(
afterPropertiesSet或init-method)完成之后。 - (AOP 的代理对象通常是在这一步生成的)
7. 使用 Bean
- 此时,Bean 已经完全准备就绪,可以被应用程序正式使用了。
8. 销毁 Bean
- 当 Spring 容器关闭时(例如调用
context.close()),会触发 Bean 的销毁流程。 - A) DisposableBean 接口:如果 Bean 实现了 DisposableBean 接口,容器会调用其
destroy方法。 - B) 自定义 destroy-method:如果 Bean 定义了 destroy-method,容器也会调用这个方法。
最后,Bean 被 Spring 容器销毁,结束了它的生命周期。
4.43 Spring循环依赖是怎么解决的
一、 什么是 Spring 循环依赖?
“循环依赖”指的是,两个或两个以上的 Bean 互相持有对方,最终形成一个闭环。
- 例如:Bean A 依赖于 Bean B,而 Bean B 又依赖于 Bean A。
- 后果:如果不进行处理,会导致 Spring 容器无法完成 Bean 的初始化,从而在启动时抛出循环依赖异常。
二、 Spring 如何解决循环依赖?
Spring 并非能解决所有的循环依赖。它对不同的注入方式,处理策略是不同的:
1. 构造器循环依赖:无法解决
- Spring 不支持(也无法解决)构造器注入的循环依赖。
- 原因:这会导致无限递归创建 Bean 实例。
- (A 的构造器需要 B,B 的构造器需要 A... 这就成了一个无解的死循环,Spring 无法创建出任何一个 Bean 的实例)。
2. 字段或 Setter 注入:可以解决 (核心)
- 对于字段注入(如
@Autowired)或 Setter 方法注入,Spring 可以解决循环依赖。 - 核心机制:“三级缓存” (Three-Level Caching)。
三、 三级缓存解决循环依赖的流程
Spring 使用三个 Map 来管理 Bean 的创建过程:
- 一级缓存 (singletonObjects):存放完全初始化好的 Bean(最终的 Bean)。
- 二级缓存 (earlySingletonObjects):存放“早期引用”的 Bean(属性尚未填充)。
- 三级缓存 (singletonFactories):存放“工厂”,用于创建“早期引用”。
解决步骤(以 A、B 循环依赖为例):
- 创建 A:Spring 容器实例化 Bean A(通过构造器创建了对象,但属性尚未填充)。
- 暴露 A 的工厂:Spring 并不会立即初始化 A,而是注册一个 singletonFactory(单例工厂)到三级缓存中。这个工厂知道如何获取 A 的“早期引用”。
- 填充 A (A -> B):Spring 尝试为 A 填充属性,发现它依赖 Bean B。
- 创建 B (B -> A):Spring 转而去创建 B。在实例化 B 后,也将 B 的工厂放入三级缓存。
- 填充 B (B -> A):Spring 尝试为 B 填充属性,发现 B 又依赖 Bean A。
- 获取 A (B -> A):此时,B 需要 A。Spring 开始从缓存中获取 A:
- 查一级缓存:找不到(A 还没完全初始化)。
- 查二级缓存:找不到(A 还没被早期引用)。
- 查三级缓存:找到了 A 的工厂!
- 早期引用 A:Spring 调用三级缓存中 A 的工厂,生成 A 的“早期引用”(此时 A 只是一个“半成品”实例,如果需要 AOP 代理,则在此刻生成代理对象)。
- A 放入二级缓存:A 的“早期引用”被放入二级缓存,并从三级缓存中移除 A 的工厂。
- B 完成:B 获取到了 A 的“早期引用”并注入。B 完成了自己的初始化,B 被放入一级缓存(成为完整的 Bean)。
- A 完成:A 获取到了 B 的完整实例并注入。A 也完成了自己的初始化。
- A 移入一级缓存:Spring 把 A 放入一级缓存,并清理掉 A 在二级缓存中的临时引用。
四、 其他解决方案:@Lazy 注解
- 使用 @Lazy 注解也可以打破循环依赖。
- 原理:通过 @Lazy 注解,Spring 会延迟 Bean 的加载(即注入一个代理对象),直到它被实际使用时才真正创建实例,从而避免了启动时的循环依赖。
4.44 Spring 中用到了那些设计模式
1. 工厂设计模式 (Factory Pattern)
- 应用:Spring 最核心的功能就是工厂模式的应用。
- 体现:BeanFactory 和 ApplicationContext 就是 Spring 的核心工厂,它们负责创建、配置和管理所有的 Bean 对象。
2. 代理设计模式 (Proxy Pattern)
- 应用:这是 Spring AOP 功能的核心实现。
- 体现:Spring AOP 不会修改原始代码,而是通过创建代理对象(JDK 动态代理或 CGLIB)来包装原始 Bean,从而在不侵入代码的情况下织入(切入)增强(Advice)功能,如事务和日志。
3. 单例设计模式 (Singleton Pattern)
- 应用:Spring IoC 容器默认的 Bean 作用域。
- 体现:Spring 容器中管理的 Bean 默认都是单例的。这保证了在整个应用程序中,一个 Bean 只有一个实例,实现了高效的复用。
4. 模板方法模式 (Template Method Pattern)
- 应用:Spring 中大量以
Template结尾的类。 - 体现:例如 JdbcTemplate、HibernateTemplate 等用于数据库操作的类。它们固化了核心的操作流程(如获取连接、执行、关闭连接),同时开放了可变的部分(如如何处理结果集)让开发者去实现。
5. 包装器设计模式 (Decorator Pattern)
- 应用:在 Spring 中常用于动态功能增强。
- 体现:例如,当项目需要连接多个数据库时,可以通过包装器模式,根据客户需求动态地切换不同的数据源,而调用方无需关心底层的切换逻辑。
6. 观察者模式 (Observer Pattern)
- 应用:Spring 的事件驱动模型。
- 体现:Spring 的 ApplicationEvent(事件)和 ApplicationListener(监听器)是观察者模式的经典应用。一个事件被发布后,所有“订阅”了该事件的监听器都会收到通知并执行相应操作。
7. 适配器模式 (Adapter Pattern)
- 应用:Spring AOP 和 Spring MVC 中都有广泛使用。
- 体现 1 (AOP):Spring AOP 的增强或通知 (Advice) 使用了适配器模式,用以统一不同类型的通知接口。
- 体现 2 (MVC):Spring MVC 中的 HandlerAdapter 是最典型的适配器。它使得调度器 (DispatcherServlet) 能够适配并调用各种不同类型的 Controller(例如,注解式、实现接口式等)。
2025.12.08 DAY39
4.45描述一下SpringMVC的执行流程
1. 请求入口
- 用户发送请求,前端控制器 (DispatcherServlet) 率先接收到所有请求。
2. 查找处理器 (Controller)
- DispatcherServlet 收到请求后,调用 HandlerMapping (处理器映射器)。
- HandlerMapping 根据请求的 URL(通过 XML 配置或注解进行查找),找到能够处理该请求的具体处理器 (Controller)。
- HandlerMapping 会生成处理器对象 (Handler) 及处理器拦截器(如果配置了的话),一并返回给 DispatcherServlet。
3. 执行处理器
- DispatcherServlet 调用 HandlerAdapter (处理器适配器)。
- HandlerAdapter 负责适配并调用具体的 Controller(后端控制器)去执行业务逻辑。
4. 获取执行结果
- Controller 执行业务逻辑,完成后返回一个 ModelAndView 对象(包含模型数据和视图名称)。
- HandlerAdapter 将 Controller 的执行结果 ModelAndView 返回给 DispatcherServlet。
5. 视图解析
- Dispatcherervlet 将 ModelAndView 传递给 ViewResolver (视图解析器)。
- ViewResolver 解析 ModelAndView 中的视图名,返回一个具体的 View 对象(例如,指向一个 JSP 页面或 Thymeleaf 模板)。
6. 渲染与响应
- DispatcherServlet 根据 ViewResolver 返回的 View 对象,进行视图渲染(即将模型数据 (Model) 填充到视图 (View) 中)。
- DispatcherServlet 最终将渲染好的视图响应给用户。
4.46 SpringBoot的常用注解
一、 启动与自动配置
- @SpringBootApplication
- 用于标识 Spring Boot 的主应用程序类,通常位于项目的顶级包中。
- 这是一个复合注解,它包含了
@Configuration、@EnableAutoConfiguration和@ComponentScan三大功能。
- @EnableAutoConfiguration
- 用于启用 Spring Boot 的自动配置机制。
- 它会根据项目的依赖和配置,自动配置 Spring 应用程序。
二、 组件声明 (Stereotypes)
(这些注解用于声明 Bean,让 Spring IoC 容器来管理)
- @Component
- 通用的组件注解,用于标识任何希望被 Spring 托管的 Bean。
- @Service
- 用于标识一个类作为服务层 (Service Layer) 的 Bean。
- @Repository
- 用于标识一个类作为数据访问层 (DAO Layer) 的 Bean,通常用于与数据库交互。
- @Configuration
- 用于定义配置类。这类中通常会包含一个或多个 @Bean 注解,用于手动定义 Bean。
三、 Web 与请求处理
- @Controller
- 用于标识一个类作为 Spring MVC 的 Controller(控制器),通常用于返回页面。
- @RestController
- 类似于
@Controller,但它是专门用于 RESTful Web 服务的。 - 它是一个复合注解,包含了
@Controller和@ResponseBody(意味着类中所有方法默认返回 JSON 数据)。
- 类似于
- @RequestMapping
- 用于将 HTTP 请求(URL、HTTP 方法等)映射到 Controller 的处理方法上。
- 可以用在类级别(作为父路径)和方法级别(作为子路径)。
四、 依赖注入
- @Autowired
- 用于自动注入 Spring 容器中的 Bean(即“依赖注入”)。
- 可以用在构造方法、字段、Setter 方法上。
- @Qualifier
- 与 @Autowired 配合使用。
- 当存在多个相同类型的 Bean 时,用于指定需要注入的具体 Bean 名称。
五、 配置与环境
- @Value
- 用于从属性文件(如
application.properties)中读取单个值,并将该值注入到成员变量中。
- 用于从属性文件(如
- @ConfigurationProperties
- 用于将配置文件中的一组属性(通常有相同前缀)批量映射到一个 Java Bean 的字段上。
- @Profile
- 用于定义 Bean 或配置类在特定环境 (Profile)(如 dev, test, prod)下才生效。
六、 功能增强
- @Asyn
- 用于将方法标记为异步执行(即在单独的线程池中执行),常用于耗时任务。

浙公网安备 33010602011771号