1,Java内存区域
- 程序计数器(Program Counter Register)
- 定义:程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- 作用:在 Java 虚拟机的多线程环境下,程序计数器用于记录每个线程正在执行的虚拟机字节码指令的地址。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 特点:它是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。因为程序计数器占用的内存空间非常小,并且它的生命周期和线程相同,随着线程的创建而创建,随着线程的结束而死亡。
- Java 虚拟机栈(Java Virtual Machine Stacks)
- 定义:Java 虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
- 局部变量表:用于存放方法参数和方法内部定义的局部变量。局部变量表所需的容量以变量槽(Variable Slot)为最小单位,在编译期确定。例如,在一个简单的方法
public int add(int a, int b)
中,a
和b
就存放在局部变量表中。 - 操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。例如在执行表达式
int c=a + b
时,a
和b
会从局部变量表中加载到操作数栈,相加后的结果也暂存在操作数栈中。 - 动态连接:在 Java 多态的情况下,比如一个对象调用一个虚方法,在编译期无法确定具体调用的是哪个类的方法,需要在运行时通过动态连接来确定真正要调用的方法。
- 方法出口:当一个方法执行完毕后,需要通过方法出口来返回方法被调用的位置,以便程序能够继续执行调用该方法后的下一条指令。
- 异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
- 本地方法栈(Native Method Stacks)
- 定义:本地方法栈与 Java 虚拟机栈非常相似,它们之间的区别是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。
- Native 方法:在 Java 中,Native 方法是指用非 Java 语言(如 C、C++)编写的方法,这些方法可以通过 Java Native Interface(JNI)来调用。例如,在 Java 中调用操作系统底层的一些功能(如文件系统操作等)可能会用到本地方法。当调用本地方法时,本地方法栈会为这些方法提供相应的栈帧来支持方法的执行。
- 异常情况:与 Java 虚拟机栈类似,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
- Java 堆(Java Heap)
- 定义:Java 堆是 Java 虚拟机所管理的内存中最大的一块,它是被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 作用:此区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。例如,当使用
new
关键字创建一个对象时,如Object obj = new Object();
,这个obj
对象就存放在 Java 堆中。 - 垃圾回收(Garbage Collection,GC):Java 堆是垃圾收集器管理的主要区域,由于 Java 堆中的对象是动态分配和回收的,所以垃圾回收机制对 Java 堆的管理非常重要。垃圾回收器会定期扫描 Java 堆,识别并回收那些不再被引用的对象所占用的内存空间,以避免内存泄漏和内存耗尽的情况。
- 内存划分:Java 堆可以细分为新生代(Young Generation)和老年代(Old Generation)。新生代又可以进一步分为 Eden 空间、From Survivor 空间和 To Survivor 空间。大部分对象在新生代中创建和销毁,存活时间较长的对象会被移到老年代。
- 异常情况:如果在堆中没有足够的内存来分配对象,并且垃圾回收器也无法回收足够的空间,就会抛出 OutOfMemoryError 异常。
- 方法区(Method Area)
- 定义:方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 类信息:包括类的版本、字段、方法、接口等信息。例如,当一个类被加载时,它的类名、父类信息、接口列表、方法签名等都会存储在方法区。
- 常量池:存放编译期生成的各种字面量和符号引用,字面量包括字符串常量(如
"Hello World"
)、基本数据类型的常量(如int
类型的10
)等。符号引用包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。 - 静态变量:被
static
修饰的变量存储在方法区。例如,public static int count = 0;
中的count
变量就存储在方法区。 - 运行时常量池(Runtime Constant Pool):它是方法区的一部分,是在类加载后将类的常量池中的内容放入运行时常量池。运行时常量池相对于 Class 文件常量池的一个重要特征是具备动态性,比如在运行时可以将新的常量放入池中,如
String.intern()
方法就可以将字符串放入运行时常量池。 - 异常情况:当方法区无法满足内存分配需求时,会抛出 OutOfMemoryError 异常。在 Java 8 及以后版本,永久代(PermGen)被元空间(Metaspace)取代,元空间使用的是本地内存,而不是虚拟机内存,这在一定程度上解决了永久代内存溢出的问题。
2,事务的隔离级别
-
未提交读(Read Uncommitted)
- 定义:这是最低的隔离级别。在这个级别下,一个事务可以读取另一个未提交事务的数据。
- 示例代码:
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement;public class TransactionIsolationLevel {public static void main(String[] args) {try {// 加载数据库驱动Class.forName("com.mysql.cj.jdbc.Driver");// 获取数据库连接Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "password");// 设置事务隔离级别为未提交读connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);// 开启事务connection.setAutoCommit(false);// 创建Statement对象Statement statement = connection.createStatement();// 执行SQL语句statement.executeUpdate("INSERT INTO users (name, age) VALUES ('John', 30)");// 另一个事务(模拟)可以在这里读取未提交的数据// 提交事务connection.commit();// 关闭连接connection.close();} catch (ClassNotFoundException | SQLException e) {e.printStackTrace();}} }
- 存在的问题:会出现脏读(Dirty Read)问题。脏读是指一个事务读取了另一个未提交事务修改的数据。例如,事务 A 修改了一条数据但尚未提交,事务 B 读取了这条被修改的数据,之后事务 A 由于某种原因回滚了修改,那么事务 B 读取的数据就是无效的、“脏” 的数据。
-
提交读(Read Committed)
- 定义:一个事务只能读取另一个已经提交事务的数据。
- 示例代码(基于上述代码修改事务隔离级别部分):
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
- 存在的问题:会出现不可重复读(Non - Repeatable Read)问题。不可重复读是指在一个事务内,多次读取同一数据,在这个事务还没有结束时,另一个事务对该数据进行了修改并提交,导致这个事务多次读取到的数据不一致。例如,事务 A 读取了一条数据,事务 B 修改并提交了这条数据,然后事务 A 再次读取同一条数据时,得到的结果与第一次不同。
-
可重复读(Repeatable Read)
- 定义:在一个事务内,多次读取同一数据的结果是一样的,即使这个数据被其他事务修改并提交。
- 示例代码(修改事务隔离级别部分):
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
- 存在的问题:会出现幻读(Phantom Read)问题。幻读是指一个事务在按照某个条件读取数据时,没有对应的数据行,但是在这个事务执行期间,另一个事务插入了符合这个条件的新数据,当第一个事务再次按照相同条件读取数据时,就会发现多了一些之前没有的 “幻影” 数据。例如,事务 A 按照条件 “年龄大于 30 岁” 查询用户列表,此时没有符合条件的数据。事务 B 插入了一个年龄为 35 岁的用户,事务 A 再次查询时,就会发现出现了之前没有的符合条件的数据。
-
串行化(Serializable)
- 定义:这是最高的隔离级别。所有事务依次逐个执行,这样就避免了脏读、不可重复读和幻读的问题。
- 示例代码(修改事务隔离级别部分):
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
- 缺点:这种隔离级别会严重影响系统的性能,因为它完全串行地执行事务,并发性能很低,在高并发场景下一般很少使用。
3,脏读与幻读的区别
脏读(Dirty Read)与幻读(Phantom Read)主要有以下区别:
概念层面
- 脏读:
脏读是指在一个事务处理过程中,读取了另一个未提交事务修改的数据。例如,事务 A 对某条数据做了修改,但尚未提交这个修改操作,此时事务 B 却读取了该数据,而之后事务 A 因为某些原因回滚了修改,那么事务 B 所读取到的就是无效的、“脏” 的数据,这就产生了脏读现象。 - 幻读:
幻读是指在一个事务按照某个特定条件多次读取数据时,第一次读取时没有符合该条件的数据行,但是在这个事务执行期间,另一个事务插入了符合该条件的新数据,当第一个事务再次按照相同条件读取数据时,就会发现多了一些之前没有的 “幻影” 数据。比如事务 A 按照 “年龄大于 30 岁” 的条件查询用户列表,一开始没有符合条件的数据,在事务 A 执行期间,事务 B 插入了几个年龄大于 30 岁的用户,事务 A 再次按照相同条件查询时,就会出现之前不曾有的符合条件的数据,仿佛出现了 “幻影” 一样,这就是幻读。
产生原因层面
- 脏读:
是由于允许一个事务读取另一个未提交事务修改的数据导致的,主要是事务隔离级别设置得比较低(如在未提交读隔离级别下),对数据读取的限制少,所以能读到其他未提交事务正在修改的数据。 - 幻读:
通常出现在可重复读隔离级别下,虽然该隔离级别能保证在一个事务内多次读取同一数据的结果一致,但无法控制其他事务插入新的数据,所以当其他事务插入符合当前事务查询条件的新数据时,就产生了幻读情况。
影响范围层面
- 脏读:
影响的是事务读取到的数据本身的有效性,读到的是还未确定最终状态的数据,可能导致基于这些脏数据所做的后续操作出现错误,比如基于脏数据进行计算、更新等操作,当数据回滚后,这些操作就失去了正确的基础。 - 幻读:
更多地影响的是基于特定条件查询数据的完整性,使得事务内按同一条件多次查询的数据集合不一致,对于一些依赖特定条件查询结果数量或者范围来进行业务逻辑处理的情况,可能会造成业务逻辑错误,比如统计满足某个条件的记录数量等场景。
解决方式层面
- 脏读:
通过提高事务隔离级别来解决,如将事务隔离级别设置为提交读(Read Committed)及以上,就能避免事务读取到未提交事务修改的数据,从而杜绝脏读现象。 - 幻读:
要彻底解决幻读,可以将事务隔离级别设置为串行化(Serializable),让事务串行执行,避免其他事务插入新数据的干扰;另外,在一些数据库中(如 MySQL 的 InnoDB 引擎),使用锁机制(如间隙锁等)也可以在可重复读隔离级别下一定程度上解决幻读问题,使得在该隔离级别下按条件查询的数据范围能保持相对稳
4,说下 MVCC
- MVCC(多版本并发控制,Multiversion Concurrency Control)定义
- MVCC 是一种并发控制的方法,它的基本思想是为每个事务访问的数据行保存多个版本,每个事务看到的是数据的一个特定版本,这样可以在不加锁或者少加锁的情况下,实现对数据库的并发访问。在数据库系统中,如 MySQL 的 InnoDB 存储引擎就广泛使用了 MVCC 来提高数据库的并发性能。
- MVCC 的实现原理
- 版本链(Version Chain)
- 在 InnoDB 中,每一行数据都有多个版本,这些版本通过一个单向链表(版本链)连接起来。当一个事务对某行数据进行修改时,不是直接覆盖原来的数据,而是生成一个新的版本,并将新的版本插入到版本链中。这个新的版本会记录一些关键信息,如事务 ID(Transaction ID)和回滚指针(Roll - Pointer)。
- 例如,假设有一行数据初始值为
value1
,事务 ID 为0
。当事务1
对其进行修改,将值变为value2
,此时会生成一个新的版本,该版本的事务 ID 为1
,回滚指针指向旧版本(事务 ID 为0
)的数据。如果后续还有事务对该行数据进行修改,就会不断地在版本链上添加新的版本。
- 事务 ID(Transaction ID)
- 每个事务在开始时都会被分配一个唯一的事务 ID。这个事务 ID 用于区分不同的事务,并且在 MVCC 中用于判断事务应该看到哪个版本的数据。
- 例如,在一个数据库系统中,事务 ID 是一个递增的数字。当一个事务读取数据时,它会根据自己的事务 ID 以及数据行版本链上的事务 ID 来确定能看到的数据版本。
- Read View(读视图)
- 读视图是 MVCC 实现的关键部分。它是在事务开始读取数据时创建的一个视图,这个视图用于确定事务能看到哪些版本的数据。读视图包含了两个重要的信息:创建该读视图时系统中尚未提交的事务列表(低水位)和已经提交的最大事务 ID(高水位)。
- 当一个事务读取数据行时,会通过比较数据行版本链上的事务 ID 与读视图中的高水位和低水位来判断该版本是否可见。如果数据行版本的事务 ID 小于等于读视图的高水位,并且不在低水位的未提交事务列表中,那么这个版本的数据对当前事务就是可见的。
- 版本链(Version Chain)
- MVCC 的优势
- 提高并发性能
- MVCC 通过允许不同的事务看到数据的不同版本,避免了传统的基于锁的并发控制方式中频繁加锁和解锁的操作。在多事务并发访问数据时,大部分情况下不需要等待其他事务释放锁,各个事务可以同时读取和修改数据,只是每个事务看到的是符合 MVCC 规则的数据版本。例如,在一个高并发的数据库应用场景中,多个用户同时查询和修改同一张表的数据,MVCC 可以使这些操作更加高效地进行,减少了事务等待锁的时间,从而提高了系统的整体吞吐量。
- 减少锁竞争
- 与传统的锁机制相比,MVCC 减少了锁的使用。在传统的锁机制下,当一个事务对某行数据进行读写操作时,可能需要对该行数据加锁,这会导致其他事务在访问同一行数据时需要等待锁的释放。而 MVCC 使得多个事务可以在不同版本的数据上进行操作,不需要对数据行进行长时间的独占锁操作,从而减少了锁竞争。例如,在一个频繁读写操作的数据库环境中,锁竞争可能会导致系统性能下降,MVCC 通过提供数据的多个版本来避免这种情况,使得系统能够更加稳定地运行。
- 提高并发性能
- MVCC 的应用场景
- 读多写少的场景
- 在很多数据库应用中,如内容管理系统(CMS)、博客系统等,读操作(如用户浏览文章、查询评论等)的频率远远高于写操作(如发布新文章、修改评论等)。MVCC 在这种场景下可以发挥很好的优势,因为它能够让多个读操作并发进行,而不会因为少数的写操作而阻塞。例如,在一个高流量的博客网站上,大量用户可以同时浏览文章,而即使有作者在后台修改文章,这些浏览操作也不会受到太大的影响,因为 MVCC 可以保证每个用户看到的是符合规则的数据版本。
- 事务隔离场景
- MVCC 可以有效地支持事务隔离。例如,在可重复读(Repeatable Read)隔离级别下,MVCC 通过为每个事务提供独立的数据视图,使得一个事务在执行期间多次读取同一数据时能够看到相同的版本,从而实现了事务隔离的要求。同时,它也可以在一定程度上避免幻读等问题,因为每个事务看到的数据版本是相对固定的,不受其他事务插入或删除操作的即时影响。
- 读多写少的场景
5,redo log 和 bin log 区别
- 定义和用途
- redo log(重做日志)
- 定义:redo log 是 InnoDB 存储引擎特有的日志,用于在事务提交时,将事务中修改的数据页的物理变化记录下来。它是一种基于物理日志的方式,主要目的是保证事务的持久性。
- 用途:在数据库系统发生故障(如断电、系统崩溃等)后,InnoDB 存储引擎可以使用 redo log 来恢复未写入磁盘的数据,确保已经提交的事务不会因为故障而丢失。例如,一个事务对某个数据页进行了修改,在修改后的数据页还没有完全刷新到磁盘之前,如果发生了故障,系统重启后可以根据 redo log 中的记录来重做这些修改,使数据恢复到事务提交后的状态。
- bin log(二进制日志)
- 定义:bin log 是 MySQL Server 层记录的日志,它以二进制的形式记录了数据库的所有变更操作(如插入、删除、修改语句等),包括对所有存储引擎的操作。
- 用途:主要用于数据恢复、数据复制和审计。在数据恢复方面,它可以用于基于时间点的恢复或者按照特定的事件序列来恢复数据库。在数据复制中,主库(Master)会将 bin log 发送给从库(Slave),从库通过读取 bin log 来实现和主库的数据同步,这是 MySQL 主从复制的基础。在审计方面,通过查看 bin log 可以了解数据库的操作历史,用于安全审计和故障排查。
- redo log(重做日志)
- 记录内容层面
- redo log
- 记录的是数据页的物理修改操作,如对某个索引页的修改,包括修改的数据页编号、修改的偏移量、修改后的具体数据等物理层面的信息。它记录的是 InnoDB 存储引擎内部的操作细节,是一种比较底层的日志。
- bin log
- 记录的是逻辑上的 SQL 语句或者基于行的变更信息。它可以记录完整的 SQL 语句,如
INSERT INTO table_name (column1, column2) VALUES (value1, value2)
,也可以记录基于行的变更,如哪个表的哪一行数据被修改了,修改前后的值分别是什么。这种记录方式更侧重于数据库操作的逻辑表达。
- 记录的是逻辑上的 SQL 语句或者基于行的变更信息。它可以记录完整的 SQL 语句,如
- redo log
- 写入时机和方式
- redo log
- 写入时机:在事务执行过程中,InnoDB 存储引擎会不断地将事务修改的数据页的物理变化记录到 redo log buffer 中。当事务提交时,会将 redo log buffer 中的内容同步到磁盘上的 redo log 文件中。不过为了提高性能,这个同步过程可能是异步的,具体取决于数据库的配置参数(如
innodb_flush_log_at_trx_commit
参数)。 - 写入方式:采用循环写入的方式,redo log 文件大小是固定的,当写满一个文件后,会重新从第一个文件开始写。它有多个 redo log 文件组成一个组,这种循环使用的方式可以有效地利用磁盘空间,同时保证日志记录的连续性。
- 写入时机:在事务执行过程中,InnoDB 存储引擎会不断地将事务修改的数据页的物理变化记录到 redo log buffer 中。当事务提交时,会将 redo log buffer 中的内容同步到磁盘上的 redo log 文件中。不过为了提高性能,这个同步过程可能是异步的,具体取决于数据库的配置参数(如
- bin log
- 写入时机:bin log 是在事务提交后才会写入磁盘。它会将事务执行过程中的所有变更操作按照顺序记录到 bin log 文件中。
- 写入方式:bin log 文件会不断地增长,当文件达到一定大小后,会进行滚动(如生成一个新的 bin log 文件)。它的写入顺序是严格按照事务提交的顺序进行的,保证了日志记录的时序性,便于后续的恢复和复制操作。
- redo log
- 日志格式层面
- redo log
- 它的格式是 InnoDB 存储引擎内部定义的,主要是为了方便快速地进行物理数据恢复。它的格式比较紧凑,并且是基于 InnoDB 的存储结构和操作方式来设计的。
- bin log
- 有多种格式,如 STATEMENT(记录 SQL 语句)、ROW(记录行的变更)和 MIXED(混合了 STATEMENT 和 ROW 两种格式)。用户可以根据具体的需求来选择合适的格式。例如,在需要精确记录数据行变更的场景下,可以使用 ROW 格式;在更关注 SQL 语句执行逻辑的场景下,可以使用 STATEMENT 格式。
- redo log
6,Spring 事务的传播机制
-
REQUIRED(默认)
-
定义:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新事务。
-
示例场景与代码示例
-
假设我们有两个方法
methodA
和methodB
,methodA
调用methodB
,并且methodA
已经开启了一个事务。在methodB
上使用@Transactional(propagation = Propagation.REQUIRED)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.REQUIRED)public void methodB() {// 业务逻辑代码} }
-
当
methodA
调用methodB
时,methodB
会加入methodA
已经开启的事务中,它们在同一个事务里执行。如果methodA
没有开启事务,methodB
会自己创建一个新事务。
-
-
-
SUPPORTS
-
定义:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行。
-
示例场景与代码示例
-
同样有
methodA
和methodB
,methodA
开启了事务,methodB
使用@Transactional(propagation = Propagation.SUPPORTS)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.SUPPORTS)public void methodB() {// 业务逻辑代码} }
-
当
methodA
调用methodB
时,methodB
会加入methodA
的事务。但如果methodA
没有事务,methodB
就会以非事务的方式执行自己的业务逻辑。
-
-
-
MANDATORY
-
定义:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
-
示例场景与代码示例
-
假设有
methodA
和methodB
,methodB
使用@Transactional(propagation = Propagation.MANDATORY)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;public void methodA() {// 没有开启事务serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.MANDATORY)public void methodB() {// 业务逻辑代码} }
-
当
methodA
没有开启事务就调用methodB
时,会抛出IllegalTransactionStateException
异常,因为methodB
要求必须在一个事务中执行。
-
-
-
REQUIRES_NEW
-
定义:创建一个新事务,如果当前存在事务,则暂停当前事务。
-
示例场景与代码示例
-
设有
methodA
和methodB
,methodA
开启了事务,methodB
使用@Transactional(propagation = Propagation.REQUIRES_NEW)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.REQUIRES_NEW)public void methodB() {// 业务逻辑代码} }
-
当
methodA
调用methodB
时,methodB
会创建一个新事务,并且methodA
的事务会被暂停。methodB
的新事务提交或回滚不会影响methodA
之前的事务状态,它们是相互独立的。
-
-
-
NOT_SUPPORTED
-
定义:以非事务方式执行,如果当前存在事务,则暂停当前事务。
-
示例场景与代码示例
-
假设有
methodA
和methodB
,methodA
开启了事务,methodB
使用@Transactional(propagation = Propagation.NOT_SUPPORTED)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.NOT_SUPPORTED)public void methodB() {// 业务逻辑代码} }
-
当
methodA
调用methodB
时,methodA
的事务会被暂停,methodB
以非事务方式执行。在methodB
执行完毕后,methodA
的事务会恢复。
-
-
-
NEVER
-
定义:以非事务方式执行,如果当前存在事务,则抛出异常。
-
示例场景与代码示例
-
设有
methodA
和methodB
,methodB
使用@Transactional(propagation = Propagation.NEVER)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.NEVER)public void methodB() {// 业务逻辑代码} }
-
当
methodA
开启事务并调用methodB
时,会抛出IllegalTransactionStateException
异常,因为methodB
要求必须以非事务方式执行。
-
-
-
NESTED
-
定义:如果当前存在事务,则在嵌套事务中执行;如果当前没有事务,则创建一个新事务。
-
示例场景与代码示例
-
假设有
methodA
和methodB
,methodA
开启了事务,methodB
使用@Transactional(propagation = Propagation.NESTED)
注解。@Service public class ServiceA {@Autowiredprivate ServiceB serviceB;@Transactionalpublic void methodA() {// 业务逻辑代码serviceB.methodB();// 其他业务逻辑代码} } @Service public class ServiceB {@Transactional(propagation = Propagation.NESTED)public void methodB() {// 业务逻辑代码} }
-
当
methodA
调用methodB
时,methodB
会在methodA
的事务中开启一个嵌套事务。如果methodB
的嵌套事务回滚,methodA
的事务可以选择继续提交或者全部回滚,这取决于具体的业务逻辑和配置。
-
-
7,AOP 的原理是什么
- AOP(Aspect - Oriented Programming,面向切面编程)的基本概念
- AOP 是一种编程范式,它允许开发者将横切关注点(如日志记录、性能监控、事务管理等)从业务逻辑中分离出来,以提高代码的模块化程度和可维护性。在 Spring 框架中,AOP 是一个重要的特性,用于实现这些横切关注点。
- AOP 的核心组件
- 切面(Aspect)
- 切面是一个包含了横切关注点的模块化单元。它将跨越多个对象的公共行为封装在一起,例如,一个日志切面可能包含了记录方法调用时间、参数和返回值的代码。切面由切点和通知组成。
- 切点(Pointcut)
- 切点用于定义在哪些连接点(Join Point)上应用切面的通知。连接点是程序执行过程中的一个点,例如方法调用、方法执行、异常抛出等。切点可以通过表达式(如 AspectJ 的切点表达式)来指定,这些表达式能够精确地定位到程序中的特定连接点。例如,
execution(* com.example.service..*.*(..))
这个切点表达式可以匹配com.example.service
包及其子包下的所有类的所有方法。
- 切点用于定义在哪些连接点(Join Point)上应用切面的通知。连接点是程序执行过程中的一个点,例如方法调用、方法执行、异常抛出等。切点可以通过表达式(如 AspectJ 的切点表达式)来指定,这些表达式能够精确地定位到程序中的特定连接点。例如,
- 通知(Advice)
- 通知是切面在特定切点处执行的代码块。Spring AOP 中有五种通知类型:
- 前置通知(Before Advice):在目标方法调用之前执行的通知。可以用于在方法调用前进行权限检查、参数验证等操作。例如,在一个 Web 服务中,前置通知可以检查用户是否有访问某个方法的权限。
- 后置通知(After Advice):在目标方法完成(无论是否抛出异常)之后执行的通知。它可以用于进行资源清理等操作。比如,关闭数据库连接、释放文件句柄等。
- 返回通知(After - returning Advice):在目标方法正常返回后执行的通知。可以用于对返回值进行处理,如记录返回值用于审计目的。
- 异常通知(After - throwing Advice):在目标方法抛出异常后执行的通知。用于处理异常情况,如记录异常日志、进行异常转换等。
- 环绕通知(Around Advice):环绕通知可以在目标方法调用前后都执行代码。它可以完全控制目标方法的执行,包括是否执行目标方法、如何执行目标方法以及在方法执行前后进行额外的操作。这是最强大的一种通知类型,但是使用起来也相对复杂。
- 切面(Aspect)
- AOP 的实现原理(基于 Spring AOP)
- 动态代理(Dynamic Proxy)
- Spring AOP 主要是通过动态代理来实现的。当一个目标对象需要被代理(因为有切面要应用到它上面)时,Spring 会根据目标对象是否实现接口来选择使用 JDK 动态代理或者 CGLIB 动态代理。
- JDK 动态代理:如果目标对象实现了至少一个接口,Spring 会使用 JDK 动态代理。JDK 动态代理是基于接口的代理,它在运行时动态地生成一个实现了目标对象接口的代理类。代理类和目标对象实现相同的接口,并且在代理类的方法中会调用切面的通知和目标对象的方法。例如,对于一个实现了
UserService
接口的目标对象,JDK 动态代理会生成一个新的代理类,这个代理类也实现了UserService
接口,当调用代理类的方法时,会先执行切面的通知,然后再调用目标对象的真实方法。 - CGLIB 动态代理:如果目标对象没有实现接口,Spring 会使用 CGLIB 动态代理。CGLIB 动态代理是基于子类的代理,它在运行时动态地生成目标对象的一个子类作为代理对象。代理对象会重写目标对象的方法,在重写的方法中插入切面的通知代码和对目标对象方法的调用。例如,对于一个没有接口的
UserServiceImpl
类,CGLIB 会生成一个UserServiceImpl
的子类作为代理对象,在代理对象的方法中实现切面的通知和对原方法的调用。
- 织入(Weaving)
- 织入是将切面应用到目标对象,创建代理对象的过程。在 Spring 中,织入可以在编译时、类加载时或者运行时进行。Spring AOP 主要是在运行时进行织入,也就是当目标对象的方法被调用时,通过动态代理来执行切面的通知和目标对象的方法。这种运行时织入的方式使得 AOP 更加灵活,因为它不需要对原始的 Java 代码进行修改,只需要在配置文件或者通过注解来定义切面、切点和通知,就可以在运行时将切面应用到目标对象上。
- 动态代理(Dynamic Proxy)
8,AOP 底层两个动态代理的区别
-
JDK 动态代理
-
实现原理
- JDK 动态代理是基于 Java 的反射机制实现的。它要求被代理的目标对象必须实现至少一个接口。在运行时,JDK 动态代理会根据目标对象实现的接口,动态地生成一个代理类。这个代理类实现了和目标对象相同的接口,并且代理类的每个方法内部都会调用
java.lang.reflect.Proxy
类的invoke
方法。在invoke
方法中,先执行切面(Aspect)的通知(Advice)代码,然后通过反射调用目标对象对应的方法。
- JDK 动态代理是基于 Java 的反射机制实现的。它要求被代理的目标对象必须实现至少一个接口。在运行时,JDK 动态代理会根据目标对象实现的接口,动态地生成一个代理类。这个代理类实现了和目标对象相同的接口,并且代理类的每个方法内部都会调用
-
代码示例
-
假设我们有一个接口
UserService
和一个实现了该接口的类UserServiceImpl
。// 用户服务接口 public interface UserService {void addUser(String name); } // 用户服务实现类 public class UserServiceImpl implements UserService {@Overridepublic void addUser(String name) {System.out.println("添加用户: " + name);} } // 动态代理类的创建 import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class JDKDynamicProxy implements InvocationHandler {private Object target;public JDKDynamicProxy(Object target) {this.target = target;}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {// 前置通知,这里可以添加切面逻辑System.out.println("前置通知:方法即将执行");// 调用目标对象的方法Object result = method.invoke(target, args);// 后置通知,这里可以添加切面逻辑System.out.println("后置通知:方法已执行");return result;} } public class Main {public static void main(String[] args) {UserService userService = new UserServiceImpl();// 创建动态代理对象UserService proxy = (UserService) Proxy.newProxyInstance(userService.getClass().getClassLoader(),userService.getClass().getInterfaces(),new JDKDynamicProxy(userService));proxy.addUser("张三");} }
-
-
性能方面
- JDK 动态代理在创建代理对象时的性能开销相对较小,因为它只是根据接口生成代理类。但是,在每次代理方法调用时,由于需要通过反射来调用目标对象的方法,会有一定的性能损耗。反射操作相对较慢,尤其是在频繁调用代理方法的情况下,这种性能损耗可能会比较明显。
-
适用场景
- 适用于目标对象已经实现了接口的情况。在 Java 开发中,如果遵循面向接口编程的原则,很多业务逻辑会通过接口来定义,这种情况下 JDK 动态代理是一个很好的选择。例如,在企业级的服务层开发中,服务接口和实现类分离,当需要对服务方法进行切面操作(如事务管理、日志记录等)时,JDK 动态代理可以很方便地应用。
-
-
CGLIB 动态代理
-
实现原理
- CGLIB(Code Generation Library)动态代理是基于字节码生成技术实现的。它不要求目标对象实现接口,而是通过继承目标对象的类来生成代理对象。在运行时,CGLIB 会动态地生成目标对象的一个子类作为代理对象。这个代理子类会重写目标对象的方法,在重写的方法中,先执行切面的通知代码,然后再通过
super
关键字调用目标对象的原始方法。CGLIB 使用了字节码操作库(如 ASM)来生成和修改字节码。
- CGLIB(Code Generation Library)动态代理是基于字节码生成技术实现的。它不要求目标对象实现接口,而是通过继承目标对象的类来生成代理对象。在运行时,CGLIB 会动态地生成目标对象的一个子类作为代理对象。这个代理子类会重写目标对象的方法,在重写的方法中,先执行切面的通知代码,然后再通过
-
代码示例
-
假设我们有一个没有实现接口的类
UserService
。// 用户服务类 public class UserService {public void addUser(String name) {System.out.println("添加用户: " + name);} } // CGLIB动态代理类 import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class CGLIBDynamicProxy implements MethodInterceptor {@Overridepublic Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {// 前置通知,这里可以添加切面逻辑System.out.println("前置通知:方法即将执行");// 调用目标对象的方法Object result = proxy.invokeSuper(obj, args);// 后置通知,这里可以添加切面逻辑System.out.println("后置通知:方法已执行");return result;} } public class Main {public static void main(String[] args) {UserService userService = new UserService();// 创建CGLIB动态代理对象Enhancer enhancer = new Enhancer();enhancer.setSuperclass(userService.getClass());enhancer.setCallback(new CGLIBDynamicProxy());UserService proxy = (UserService) enhancer.create();proxy.addUser("李四");} }
-
-
性能方面
- CGLIB 在创建代理对象时,由于需要生成目标对象的子类,并且操作字节码,其性能开销相对 JDK 动态代理在创建阶段会大一些。但是,在代理方法调用时,因为不需要像 JDK 动态代理那样通过反射来调用目标对象的方法,所以在性能上可能会比 JDK 动态代理好一些,尤其是在频繁调用代理方法的情况下。
-
适用场景
- 适用于目标对象没有实现接口的情况。在一些遗留系统或者对性能要求较高且无法通过接口实现代理的场景下,CGLIB 动态代理非常有用。例如,在对一些没有良好接口设计的第三方库或者内部工具类进行切面操作时,CGLIB 可以提供代理支持。
-
9,说说redis分布式锁
-
什么是 Redis 分布式锁
- Redis 分布式锁是一种用于在分布式系统环境下,协调多个进程或线程对共享资源访问的机制。它利用 Redis 的单线程特性和原子操作命令,确保在同一时刻只有一个客户端能够获取到锁,从而保证共享资源的互斥访问。
-
实现原理
- SETNX 命令(SET if Not eXists):这是实现 Redis 分布式锁的核心命令之一。当一个客户端想要获取锁时,它会使用 SETNX 命令尝试在 Redis 中设置一个键值对。例如,
SETNX lock_key unique_value
,这里lock_key
是用于标识锁的键,unique_value
是一个唯一的值,通常可以使用 UUID(通用唯一识别码)来确保每个客户端的标识都是唯一的。如果键不存在(即锁未被其他客户端获取),SETNX 命令会将键值对设置成功,返回 1,表示获取锁成功;如果键已经存在(即锁已被其他客户端获取),则返回 0,表示获取锁失败。 - EXPIRE 命令(设置过期时间):为了避免因为客户端获取锁后异常崩溃或者网络故障等原因导致锁无法释放,需要给锁设置一个过期时间。在获取锁成功后(SETNX 返回 1),可以使用 EXPIRE 命令来设置
lock_key
的过期时间,例如EXPIRE lock_key 10
,这里将锁的过期时间设置为 10 秒。这样,即使客户端没有主动释放锁,在过期时间到达后,锁也会自动释放,其他客户端就可以获取锁来访问共享资源。不过,在 Redis 2.6.12 版本之后,SET 命令支持了可选参数,可以在设置键值对的同时设置过期时间,如SET lock_key unique_value NX PX 10000
(这里 PX 表示设置过期时间的单位是毫秒,10000 毫秒即 10 秒),这样就避免了 SETNX 和 EXPIRE 命令不是原子操作可能导致的问题。 - 释放锁:当客户端完成对共享资源的访问后,需要释放锁。释放锁的过程是先判断当前锁的值是否是自己设置的
unique_value
,如果是,则使用 DEL 命令删除lock_key
来释放锁。可以使用 GET 命令获取锁的值进行判断,不过这个操作不是原子的,在高并发场景下可能会出现问题。在 Redis 中,可以使用 Lua 脚本实现原子的锁释放操作,例如:
if redis.call("GET", KEYS[1]) == ARGV[1] thenreturn redis.call("DEL", KEYS[1]) elsereturn 0 end
这里
KEYS[1]
表示锁的键(lock_key
),ARGV[1]
表示客户端自己设置的unique_value
。通过 EVAL 命令可以执行这个 Lua 脚本,从而实现原子的锁释放。 - SETNX 命令(SET if Not eXists):这是实现 Redis 分布式锁的核心命令之一。当一个客户端想要获取锁时,它会使用 SETNX 命令尝试在 Redis 中设置一个键值对。例如,
-
使用场景
- 防止缓存击穿:在分布式缓存系统中,当大量请求同时访问一个不存在于缓存中的数据(通常是因为缓存过期或者数据尚未被缓存)时,可能会导致这些请求直接访问数据库,从而造成数据库压力过大。通过使用 Redis 分布式锁,可以在第一个请求访问数据库获取数据并更新缓存时,获取锁,其他请求等待锁释放后再访问缓存或者数据库,避免大量请求同时访问数据库的情况。
- 资源互斥访问:在分布式系统中,对于一些共享资源(如文件系统、数据库表中的某行数据等),需要保证同一时刻只有一个客户端能够进行修改操作。例如,在一个分布式的订单处理系统中,多个节点可能会同时处理订单,当需要对某个订单状态进行修改时,通过 Redis 分布式锁可以确保只有一个节点能够获取锁并修改订单状态,避免数据不一致的情况。
-
可能出现的问题及解决方案
- 锁过期导致的问题:如果设置的锁过期时间过短,可能会导致客户端还没有完成对共享资源的操作,锁就已经过期,其他客户端获取锁后可能会对共享资源进行错误的操作。为了解决这个问题,可以在客户端获取锁后,开启一个后台线程或者使用定时器,在锁快要过期时自动刷新锁的过期时间。不过这个操作需要注意避免多个客户端同时刷新过期时间导致锁一直无法释放的情况,可以通过设置一个随机的过期时间范围或者使用分布式锁的续租机制来解决。
- 误删锁的问题:如果一个客户端在释放锁时,没有正确判断锁是否是自己持有的,可能会误删其他客户端持有的锁。使用 Lua 脚本进行原子的锁释放操作可以有效避免这个问题,通过在脚本中比较锁的值和自己持有的
unique_value
来确保只有自己持有的锁才会被释放。 - Redis 单点故障问题:如果 Redis 实例出现故障(如宕机、网络故障等),可能会导致分布式锁无法正常工作。为了解决这个问题,可以使用 Redis 的高可用方案,如 Redis Sentinel(主从复制和自动故障转移)或者 Redis Cluster(分布式集群)来提高 Redis 的可用性,确保分布式锁在 Redis 故障时也能够正常工作。