在 Spring Boot 开发中,事务管理是核心场景之一,而事务的实现依赖于 Spring 的代理机制。这一机制初学时往往让人困惑:为何加了 @Transactional 注解的方法,调用时并非原始对象执行?带着这个问题,我们从 Spring Boot 代理对象与事务的关联入手,逐步回溯 Java SE 基础中的对象、引用、equals 方法、字符串常量池等核心知识点,打通从框架到基础的逻辑链路,帮你彻底理解底层原理与实际开发中的避坑点。

一、Spring Boot 中的对象与代理对象引入及 @Transactional 事务实践

1.1 核心问题:为何事务方法需通过代理对象调用?

在 Spring Boot 的 MVC 三层架构中,我们通常在 Service 层添加 @Transactional 注解实现事务管理。但很多开发者会遇到一个经典问题:同一 Service 类中,非事务方法调用事务方法时,事务失效。这一问题的根源就是 Spring 的代理机制——@Autowired 注入的 Service 实例,并非我们编写的原始类对象,而是 Spring 动态生成的代理对象。

Spring 为了实现 AOP(面向切面编程)的无侵入式增强,会对包含 @Transactional@Cacheable@Async 等注解的 Bean 生成代理对象。代理对象的核心作用是在原始方法执行前后,嵌入增强逻辑(如事务的开启、提交、回滚),而原始对象仅包含核心业务逻辑。这种设计让业务代码与横切逻辑(事务、缓存、日志等)解耦,符合“单一职责原则”。

1.2 Spring Boot 中代理对象的生成场景与触发条件

通过 @Autowired 注入的实例,并非都会被转为代理对象,仅在满足特定条件时,Spring 才会触发动态代理机制。核心场景及条件如下:

1.2.1 基于注解的 AOP 增强场景(最常见)

当 Bean 中存在被 Spring AOP 注解标记的方法时,Spring 会为该 Bean 生成代理对象,核心注解包括:

  • 事务相关@Transactional(类或方法上添加,无论是否设置属性,均会触发代理);

  • 缓存相关@Cacheable@CacheEvict@CachePut 等;

  • 异步相关@Async(异步方法注解,需配合@EnableAsync 开启);

  • 自定义 AOP 注解:通过 @Aspect 定义切面,结合 @Pointcut 切入自定义注解的方法。

注意:若注解添加在类上,该类所有 public 方法都会被代理增强;若仅添加在方法上,仅目标方法会被增强。非 public 方法即使添加注解,也无法被代理(JDK 动态代理不支持 private 方法,CGLIB 虽能继承但无法重写 private 方法)。

1.2.2 基于接口/类的 AOP 配置场景

除了注解方式,通过 XML 配置(如 <aop:config>)或 Java 配置类(通过 @Bean 定义 Aspect 切面)指定切入规则时,符合规则的 Bean 会被生成代理对象。例如,配置切入所有 Service 层的 transfer* 方法,此时所有 Service 中匹配该规则的方法都会被代理。

1.2.3 特殊 Bean 类型的代理场景

部分 Spring 内置 Bean 或自定义 Bean 实现特定接口时,会被强制生成代理对象,例如:

  • 实现 TransactionAwareBeanPostProcessorAopInfrastructureBean 等接口的 Bean;

  • Spring Security 中的 UserDetailsService、Shiro 中的 Realm 等安全相关 Bean,会被框架自动代理以植入权限校验逻辑。

1.2.4 不会生成代理对象的场景

以下情况中,@Autowired 注入的是原始对象,而非代理对象:

  • Bean 中无任何 AOP 注解,且未被自定义切面切入;

  • 通过 new 关键字手动创建的对象(未交给 Spring 容器管理);

  • @Scope("prototype") 标记的 Bean,虽每次获取都是新实例,但仅当满足 AOP 条件时才会生成代理,否则为原始对象;

  • 静态方法、final 方法(无法被代理重写,即使添加注解也不会触发增强)。

1.3 为何 Spring Boot 偏爱依赖注入(DI)方式?

Spring Boot 核心设计思想之一就是“依赖注入”(Dependency Injection,DI),通过@Autowired@Resource、构造器注入等方式管理 Bean 依赖,而非手动通过 new 创建对象。这种方式的优势的体现在多方面,是企业级开发的核心需求:

1.3.1 解耦依赖关系,提升代码可维护性

传统开发中,对象间依赖通过 new 硬编码实现,例如 UserService userService = new UserServiceImpl();。这种方式导致对象间耦合度极高,若UserServiceImpl 构造器变化,所有调用处都需修改。而依赖注入将对象的创建权交给 Spring 容器,开发者只需通过注解声明依赖,容器自动完成对象创建与注入,彻底解除对象间的硬耦合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

// 传统硬编码(高耦合)
public class UserController {
// 直接依赖具体实现,若 UserServiceImpl 变更,此处需修改
private UserService userService = new UserServiceImpl();
}

// 依赖注入(低耦合)
@RestController
public class UserController {
// 仅依赖接口,具体实现由 Spring 容器管理
@Autowired
private UserService userService;
}

1.3.2 支持 AOP 动态代理,实现横切逻辑增强

如前文所述,Spring 的代理机制依赖于容器管理的 Bean。若对象通过 new 手动创建,Spring 无法对其生成代理对象,也就无法植入事务、缓存等横切逻辑。而依赖注入获取的 Bean,本质是容器管理的代理对象(满足条件时),这为 AOP 增强提供了基础,是 Spring 事务、缓存等核心功能的实现前提。

1.3.3 统一管理 Bean 生命周期,优化资源占用

Spring 容器对所有注入的 Bean 进行统一生命周期管理,包括初始化(@PostConstruct)、销毁(@PreDestroy)、作用域控制(单例/多例)等。默认情况下,Bean 为单例(@Scope("singleton")),容器仅创建一个实例并复用,避免了频繁创建对象导致的内存浪费和性能损耗。若手动new 对象,需自行管理生命周期,易出现资源泄漏(如数据库连接未关闭)。

1.3.4 简化测试,提升开发效率

依赖注入便于单元测试时进行 Mock 替换。例如,测试 UserController 时,可通过 Mockito 模拟 UserService 实例,注入到控制器中,无需依赖真实的 Service 实现和数据库环境,大幅降低测试成本。若为硬编码创建对象,Mock 替换需修改源码,测试灵活性极差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

// 单元测试中 Mock 注入
@SpringBootTest
public class UserControllerTest {
@MockBean // 模拟 UserService 实例
private UserService userService;

@Autowired // 注入被测试的控制器,依赖的 Service 已被 Mock
private UserController userController;

@Test
public void testAddUser() {
// 模拟 Service 方法返回结果
when(userService.addUser(any(User.class))).thenReturn(true);
// 执行测试逻辑
Result result = userController.addUser(new User());
Assertions.assertTrue(result.isSuccess());
}
}

1.3.5 支持依赖传递与循环依赖解决

Spring 容器能自动处理 Bean 间的依赖传递(如 A 依赖 B,B 依赖 C,容器自动按顺序创建 C、B、A 并注入),同时通过三级缓存机制解决合法的循环依赖(如 A 依赖 B,B 依赖 A)。若手动管理依赖,需自行处理依赖顺序和循环依赖问题,极易出现逻辑错误。

1.4 代理对象的实现原理与两种代理方式

Spring 生成代理对象有两种主流方式,根据目标类是否实现接口自动选择(Spring Boot 2.x 默认优先使用 CGLIB 代理,Spring Boot 1.x 默认优先使用 JDK 动态代理)。

方式一:CGLIB 代理(基于继承,无接口场景)

CGLIB(Code Generation Library)通过继承目标类,动态生成子类作为代理对象,重写目标类的方法并嵌入增强逻辑。代理对象持有原始对象的引用,执行方法时先触发增强逻辑,再调用原始对象的核心方法。

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

// 原始 Service 类(无接口)
@Service
public class UserService {
// 事务方法:核心业务逻辑 + 事务增强
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 扣减账户余额
deductBalance(fromId, amount);
// 增加目标账户余额
addBalance(toId, amount);
}

// 普通业务方法
public void deductBalance(Long userId, BigDecimal amount) {
// 数据库操作逻辑
System.out.println("扣减用户" + userId + "余额:" + amount);
}

public void addBalance(Long userId, BigDecimal amount) {
// 数据库操作逻辑
System.out.println("增加用户" + userId + "余额:" + amount);
}
}

// Spring 动态生成的 CGLIB 代理类(伪代码)
public class UserService$$EnhancerBySpringCGLIB extends UserService {
// 持有原始对象引用
private UserService target;

// 重写事务方法,嵌入事务增强逻辑
@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
TransactionStatus status = null;
try {
// 增强逻辑:开启事务
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// 调用原始对象的核心方法
target.transferMoney(fromId, toId, amount);
// 增强逻辑:提交事务
transactionManager.commit(status);
} catch (Exception e) {
// 增强逻辑:回滚事务
if (status != null) {
transactionManager.rollback(status);
}
throw e;
}
}

// 重写普通方法,仅转发调用原始对象
@Override
public void deductBalance(Long userId, BigDecimal amount) {
target.deductBalance(userId, amount);
}
}

方式二:JDK 动态代理(基于接口,有接口场景)

若目标类实现了接口,Spring 会使用 JDK 动态代理,生成实现该接口的代理类。代理类持有原始对象引用,实现接口方法时嵌入增强逻辑,本质是“基于接口的委托模式”。

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

// 定义接口
public interface IUserService {
void transferMoney(Long fromId, Long toId, BigDecimal amount);
}

// 原始 Service 类实现接口
@Service
public class UserService implements IUserService {
@Transactional(rollbackFor = Exception.class)
@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
deductBalance(fromId, amount);
addBalance(toId, amount);
}

// 省略其他方法...
}

// Spring 动态生成的 JDK 代理类(伪代码)
public class UserServiceJdkProxy implements IUserService {
private IUserService target;

@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
TransactionStatus status = null;
try {
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
target.transferMoney(fromId, toId, amount);
transactionManager.commit(status);
} catch (Exception e) {
if (status != null) {
transactionManager.rollback(status);
}
throw e;
}
}
}

1.5 事务失效的经典场景与解决方案

最常见的事务失效场景:同一 Service 类中,非事务方法通过 this 调用事务方法。因为 this 代表原始对象,而非代理对象,跳过了增强逻辑,导致事务无法触发。

场景复现(事务失效)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

@Service
public class UserService {
// 非事务方法
public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
// 新增用户
saveUser(user);
// this 调用事务方法:事务失效
this.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
deductBalance(fromId, amount);
addBalance(toId, amount);
}

public void saveUser(User user) {
System.out.println("新增用户:" + user.getName());
}
}

解决方案:获取代理对象调用事务方法

核心思路:放弃 this 调用,通过代理对象调用事务方法,常用两种方式:

方案1:通过 AopContext 获取代理对象(需开启暴露代理)

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

// 启动类开启暴露代理
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

// Service 类中获取代理对象
@Service
public class UserService {
public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
saveUser(user);
// 获取代理对象
UserService proxy = (UserService) AopContext.currentProxy();
// 代理对象调用事务方法:事务生效
proxy.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑...
}
}

方案2:自注入代理对象(更优雅,无需额外配置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

@Service
public class UserService {
// 自注入:Spring 注入代理对象而非原始对象
@Autowired
private UserService userServiceProxy;

public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
saveUser(user);
// 代理对象调用事务方法
userServiceProxy.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑...
}
}

方案3:拆分 Service 类(彻底解决同一类调用问题)

将事务方法拆分到另一个 Service 类中,通过依赖注入调用,避免同一类中 this 调用的问题,同时符合“单一职责原则”。

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

// 拆分后的 TransactionService
@Service
public class TransactionService {
@Autowired
private UserService userService;

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
userService.deductBalance(fromId, amount);
userService.addBalance(toId, amount);
}
}

// 原 UserService
@Service
public class UserService {
@Autowired
private TransactionService transactionService;

public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
saveUser(user);
// 跨类调用事务方法:事务生效
transactionService.transferMoney(fromId, toId, amount);
}

public void deductBalance(Long userId, BigDecimal amount) { /* 业务逻辑 */ }
public void addBalance(Long userId, BigDecimal amount) { /* 业务逻辑 */ }
public void saveUser(User user) { /* 业务逻辑 */ }
}

1.6 核心问题回顾:为何事务方法需通过代理对象调用?

(复盘小节,保留原知识点)在 Spring Boot 的 MVC 三层架构中,我们通常在 Service 层添加 @Transactional 注解实现事务管理。但很多开发者会遇到一个经典问题:同一 Service 类中,非事务方法调用事务方法时,事务失效。这一问题的根源的就是 Spring 的代理机制——@Autowired 注入的 Service 实例,并非我们编写的原始类对象,而是 Spring 动态生成的代理对象。

Spring 为了实现 AOP(面向切面编程)的无侵入式增强,会对包含 @Transactional@Cacheable@Async 等注解的 Bean 生成代理对象。代理对象的核心作用是在原始方法执行前后,嵌入增强逻辑(如事务的开启、提交、回滚),而原始对象仅包含核心业务逻辑。

1.7 代理对象的实现原理与两种代理方式

Spring 生成代理对象有两种主流方式,根据目标类是否实现接口自动选择(Spring Boot 默认优先使用 CGLIB 代理)。

方式一:CGLIB 代理(基于继承,无接口场景)

CGLIB(Code Generation Library)通过继承目标类,动态生成子类作为代理对象,重写目标类的方法并嵌入增强逻辑。代理对象持有原始对象的引用,执行方法时先触发增强逻辑,再调用原始对象的核心方法。

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

// 原始 Service 类(无接口)
@Service
public class UserService {
// 事务方法:核心业务逻辑 + 事务增强
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 扣减账户余额
deductBalance(fromId, amount);
// 增加目标账户余额
addBalance(toId, amount);
}

// 普通业务方法
public void deductBalance(Long userId, BigDecimal amount) {
// 数据库操作逻辑
System.out.println("扣减用户" + userId + "余额:" + amount);
}

public void addBalance(Long userId, BigDecimal amount) {
// 数据库操作逻辑
System.out.println("增加用户" + userId + "余额:" + amount);
}
}

// Spring 动态生成的 CGLIB 代理类(伪代码)
public class UserService$$EnhancerBySpringCGLIB extends UserService {
// 持有原始对象引用
private UserService target;

// 重写事务方法,嵌入事务增强逻辑
@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
TransactionStatus status = null;
try {
// 增强逻辑:开启事务
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// 调用原始对象的核心方法
target.transferMoney(fromId, toId, amount);
// 增强逻辑:提交事务
transactionManager.commit(status);
} catch (Exception e) {
// 增强逻辑:回滚事务
if (status != null) {
transactionManager.rollback(status);
}
throw e;
}
}

// 重写普通方法,仅转发调用原始对象
@Override
public void deductBalance(Long userId, BigDecimal amount) {
target.deductBalance(userId, amount);
}
}

方式二:JDK 动态代理(基于接口,有接口场景)

若目标类实现了接口,Spring 会使用 JDK 动态代理,生成实现该接口的代理类。代理类持有原始对象引用,实现接口方法时嵌入增强逻辑,本质是“基于接口的委托模式”。

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

// 定义接口
public interface IUserService {
void transferMoney(Long fromId, Long toId, BigDecimal amount);
}

// 原始 Service 类实现接口
@Service
public class UserService implements IUserService {
@Transactional(rollbackFor = Exception.class)
@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
deductBalance(fromId, amount);
addBalance(toId, amount);
}

// 省略其他方法...
}

// Spring 动态生成的 JDK 代理类(伪代码)
public class UserServiceJdkProxy implements IUserService {
private IUserService target;

@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
TransactionStatus status = null;
try {
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
target.transferMoney(fromId, toId, amount);
transactionManager.commit(status);
} catch (Exception e) {
if (status != null) {
transactionManager.rollback(status);
}
throw e;
}
}
}

1.8 事务失效的经典场景与解决方案

最常见的事务失效场景:同一 Service 类中,非事务方法通过 this 调用事务方法。因为 this 代表原始对象,而非代理对象,跳过了增强逻辑,导致事务无法触发。

场景复现(事务失效)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

@Service
public class UserService {
// 非事务方法
public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
// 新增用户
saveUser(user);
// this 调用事务方法:事务失效
this.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
deductBalance(fromId, amount);
addBalance(toId, amount);
}

public void saveUser(User user) {
System.out.println("新增用户:" + user.getName());
}
}

解决方案:获取代理对象调用事务方法

核心思路:放弃 this 调用,通过代理对象调用事务方法,常用两种方式:

方案1:通过 AopContext 获取代理对象(需开启暴露代理)

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

// 启动类开启暴露代理
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

// Service 类中获取代理对象
@Service
public class UserService {
public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
saveUser(user);
// 获取代理对象
UserService proxy = (UserService) AopContext.currentProxy();
// 代理对象调用事务方法:事务生效
proxy.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑...
}
}

方案2:自注入代理对象(更优雅,无需额外配置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

@Service
public class UserService {
// 自注入:Spring 注入代理对象而非原始对象
@Autowired
private UserService userServiceProxy;

public void addUserAndTransfer(User user, Long fromId, Long toId, BigDecimal amount) {
saveUser(user);
// 代理对象调用事务方法
userServiceProxy.transferMoney(fromId, toId, amount);
}

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑...
}
}

二、Java SE 基础回顾:对象与引用的核心区别

理解 Spring 代理机制的前提,是掌握 Java 中对象与引用的本质区别。很多开发者混淆了“对象”和“引用”,导致无法理解代理对象的调用逻辑、空指针异常等问题。

2.1 对象与引用的定义与内存模型

Java 是面向对象语言,但我们无法直接操作对象,必须通过“引用”间接操作。核心关系如下:

  • 对象:通过 new 类名() 创建,存储在堆内存中,是真实的内存实体,包含类的属性和方法实现,占用实际内存空间。

  • 引用:是一个变量,存储在栈内存中,本身不包含对象的属性,仅存储堆内存中对象的地址(相当于对象的“门牌号”)。通过引用,我们可以访问和修改对象的属性、调用对象的方法。

代码拆解与内存图解

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

// 定义 Student 类(对象的“设计图纸”)
class Student {
String name; // 属性
int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}

public void study() {
System.out.println(name + "正在学习");
}
}

public class Test {
public static void main(String[] args) {
// 核心代码:创建对象 + 绑定引用
Student student = new Student("张三", 20);
// 通过引用操作对象
student.age = 21;
student.study();
}
}

内存模型(简化):

  • 栈内存:student 引用变量,存储地址(如 0x001),指向堆内存中的 Student 对象。

  • 堆内存:Student 对象,存储属性 name="张三"age=21,以及方法 study() 的引用。

2.2 引用的核心特性与实战场景

特性1:多个引用可指向同一个对象

多个引用变量存储同一个对象的地址,修改其中一个引用操作的对象属性,会影响所有指向该对象的引用。

1
2
3
4
5
6
7
8
9
10
11

public class Test {
public static void main(String[] args) {
Student s1 = new Student("张三", 20);
Student s2 = s1; // s2 与 s1 指向同一个对象

s2.age = 21;
System.out.println(s1.age); // 输出 21(对象属性被修改)
System.out.println(s1 == s2); // true(地址相同)
}
}

特性2:引用可重新指向其他对象

引用变量的值(对象地址)可以修改,重新指向其他对象,原对象若无人引用,会被 GC(垃圾回收器)回收。

1
2
3
4
5
6
7
8

public class Test {
public static void main(String[] args) {
Student s1 = new Student("张三", 20);
s1 = new Student("李四", 22); // s1 重新指向新对象
// 原 "张三" 对象无人引用,等待 GC 回收
}
}

特性3:空引用(null)

引用变量可赋值为 null,表示不指向任何对象。通过空引用调用方法/属性,会抛出 NullPointerException(NPE),这是 Java 开发中最常见的异常之一。

1
2
3
4
5
6
7

public class Test {
public static void main(String[] args) {
Student s = null;
// s.study(); // 运行时抛出 NullPointerException
}
}

2.3 == 与 equals() 方法的区别(面试核心)

判断两个对象是否“相等”,是 Java 基础高频考点,核心在于区分 == 运算符和 equals() 方法的作用。

2.3.1 == 运算符的作用

== 是运算符,无法被重写,作用分两种场景:

  • 基本类型:比较值本身(如 int a=10; int b=10; a==b → true)。

  • 引用类型:比较两个引用存储的堆内存地址是否相同,即判断是否指向同一个对象(与对象内容无关)。

2.3.2 equals() 方法的作用

equals()Object 类的方法,可被重写,核心作用是判断两个对象的“内容是否相等”,但默认实现等价于 ==

1
2
3
4
5

// Object 类中 equals() 的默认实现
public boolean equals(Object obj) {
return (this == obj); // 比较地址
}

2.3.3 重写 equals() 方法(自定义内容比较)

实际开发中,我们通常需要按对象属性判断内容是否相等,此时需重写equals() 方法,同时必须重写 hashCode() 方法(遵循“相等的对象必须有相等的哈希码”规则,否则 HashMap、HashSet 等集合无法正常工作)。

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

import java.util.Objects;

class Student {
String name;
int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}

// 重写 equals():按 name 和 age 比较内容
@Override
public boolean equals(Object o) {
// 1. 地址相同 → 内容必然相同(优化性能)
if (this == o) return true;
// 2. 待比较对象为 null 或类型不同 → 内容不同
if (o == null || getClass() != o.getClass()) return false;
// 3. 强转后比较属性值
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name);
}

// 重写 hashCode():与 equals() 保持一致
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}

public class Test {
public static void main(String[] args) {
Student s1 = new Student("张三", 20);
Student s2 = new Student("张三", 20);
Student s3 = s1;

System.out.println(s1 == s2); // false(地址不同)
System.out.println(s1.equals(s2)); // true(内容相同)
System.out.println(s1 == s3); // true(地址相同)
}
}

关键说明:getClass() 的作用

代码中 getClass() != o.getClass() 等价于 this.getClass() != o.getClass()

  • this.getClass()this 代表当前调用 equals() 的对象(如 s1),获取其真实运行时类型。

  • o.getClass():获取待比较对象(如 s2)的真实运行时类型。

作用是严格判断两个对象是否属于同一个类的实例,避免子类与父类对象误判(比 instanceof 更严格,instanceof 允许子类对象匹配父类类型)。

三、JVM 字符串常量池与字符串操作全解析

字符串是 Java 中最常用的引用类型,其特殊的内存管理机制(字符串常量池)是面试高频考点。与其他语言不同,Java 为字符串设计了常量池,目的是缓存字符串字面量,避免重复创建相同内容的对象,节省内存。

3.1 字符串常量池的核心原理

字符串常量池(String Constant Pool)是 JVM 中专门缓存字符串字面量的区域,位置随 JDK 版本变化:

  • JDK 1.6 及以前:位于方法区(永久代)。

  • JDK 1.7 及以后:移至堆内存(逻辑上独立于普通堆对象区域)。

核心规则:编译期确定的字符串字面量(如"张三")会自动入池;运行期创建的字符串(如 new String()、变量拼接)不会自动入池,需通过 intern() 方法手动入池。

3.2 字符串的三种创建方式与内存差异

方式1:字面量赋值(自动入池,复用对象)

直接用 String s = "xxx" 赋值时,JVM 先检查常量池是否存在该字符串:

  • 若存在,直接让引用指向常量池中的对象。

  • 若不存在,在常量池创建该字符串对象,再让引用指向它。

1
2
3
4
5
6
7
8
9

public class Test {
public static void main(String[] args) {
String s1 = "张三";
String s2 = "张三";

System.out.println(s1 == s2); // true(指向常量池同一对象)
System.out.println(s1.equals(s2)); // true(内容相同)
}

方式2:new String() 创建(堆中新建对象,不复用)

new String("xxx") 创建字符串时,JVM 分两步执行:

  • 步骤1:检查常量池,若不存在 "xxx",先在常量池创建该字符串。

  • 步骤2:在堆内存中新建一个 String 对象,该对象的字符数组引用指向常量池中的字符串。

因此,new String() 必然在堆中创建新对象,地址与常量池对象不同。

1
2
3
4
5
6
7
8
9
10
11

public class Test {
public static void main(String[] args) {
String s1 = "张三";
String s2 = new String("张三");
String s3 = new String("张三");

System.out.println(s1 == s2); // false(常量池 vs 堆)
System.out.println(s2 == s3); // false(堆中两个不同对象)
System.out.println(s1.equals(s2)); // true(内容相同)
}

方式3:字符串拼接(字面量拼接 vs 变量拼接)

字符串拼接的逻辑随拼接元素不同而变化,是面试高频坑点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

public class Test {
public static void main(String[] args) {
// 情况1:纯字面量拼接 → 编译期优化为 "张三",入常量池
String s1 = "张" + "三";
String s2 = "张三";
System.out.println(s1 == s2); // true

// 情况2:变量 + 字面量拼接 → 运行期创建堆对象,不入池
String a = "张";
String s3 = a + "三";
System.out.println(s3 == s2); // false

// 情况3:final 变量 + 字面量拼接 → 编译期优化(final 变量值固定)
final String b = "张";
String s4 = b + "三";
System.out.println(s4 == s2); // true
}

3.3 intern() 方法的作用与使用场景

intern() 方法是 String 类的核心方法,作用是手动将字符串对象加入常量池,返回常量池中的引用(无论是否已存在)。

intern() 核心逻辑

  • 调用 s.intern() 时,JVM 检查常量池是否存在与 s 内容相同的字符串。

  • 若存在,直接返回常量池中的引用。

  • 若不存在,JDK 1.7+ 会将 s 指向的堆对象引用存入常量池,返回该引用;JDK 1.6 会复制 s 的内容到常量池,返回常量池新对象引用。

代码示例

1
2
3
4
5
6
7
8
9
10

public class Test {
public static void main(String[] args) {
String s1 = new String("张三");
String s2 = s1.intern(); // 手动入池,返回常量池引用
String s3 = "张三";

System.out.println(s1 == s2); // false(s1指向堆,s2指向常量池)
System.out.println(s2 == s3); // true(均指向常量池)
}

关键注意点

intern() 方法不会改变原字符串变量的指向,仅返回常量池引用。若要让变量指向常量池,需接收其返回值。

1
2
3
4
5
6
7

public class Test {
public static void main(String[] args) {
String s = new String("张三");
s.intern(); // 调用但不接收返回值,s 仍指向堆对象
System.out.println(s == "张三"); // false
}

四、高频面试题(含详细解析)

面试题1:Spring 中 @Transactional 事务失效的常见场景有哪些?如何避免?(中等难度)

答案解析:

常见失效场景及解决方案如下:

  1. 同一 Service 类中,非事务方法通过 this 调用事务方法:this 代表原始对象,跳过代理,事务增强逻辑无法触发。解决方案:通过 AopContext 获取代理对象,或自注入代理对象调用。

  2. 事务方法未抛出异常,或异常被捕获未抛出:默认情况下,@Transactional 仅对 RuntimeException 和 Error 回滚。解决方案:指定 rollbackFor 属性(如 rollbackFor = Exception.class),确保异常能触发回滚;避免在事务方法内捕获异常不抛出。

  3. 目标类未被 Spring 管理:未加 @Service、@Component 等注解,或通过 new 手动创建对象,Spring 无法生成代理。解决方案:确保类被 Spring 扫描管理,通过 @Autowired 注入而非 new 创建。

  4. 事务方法为 private、static 或 final 修饰:private 方法无法被代理重写;static/final 方法无法被动态代理重写(CGLIB 也无法重写 final 方法)。解决方案:事务方法改为 public 修饰,避免 static/final。

  5. propagation 传播行为设置不当:如设置为 PROPAGATION_NOT_SUPPORTED(不支持事务)、PROPAGATION_NEVER(禁止事务)。解决方案:根据业务场景选择合适的传播行为(默认 PROPAGATION_REQUIRED 即可)。

面试题2:Java 中 == 与 equals() 的区别,为何重写 equals() 必须重写 hashCode()?(基础+进阶)

答案解析:

  1. == 与 equals() 的区别:
  • == 是运算符,对基本类型比较值,对引用类型比较地址;无法被重写。

  • equals() 是 Object 类方法,默认等价于 ==;重写后可比较对象内容(如 String、Integer);可被重写。

  1. 重写 equals() 必须重写 hashCode() 的原因:

遵循 JDK 规范:相等的对象必须有相等的哈希码(hashCode),反之不成立(哈希码相等的对象不一定相等)。

若仅重写 equals() 不重写 hashCode(),会导致两个内容相等的对象哈希码不同。当这两个对象存入 HashMap、HashSet 等基于哈希表的集合时,会被存入不同的桶位,导致集合无法识别它们是同一个对象,出现重复元素、查询失效等问题。

示例:Student s1 和 s2 内容相等(equals() 返回 true),若未重写 hashCode(),s1.hashCode() != s2.hashCode(),存入 HashSet 会被视为两个元素,违反集合去重规则。

面试题3:JDK 1.6 与 JDK 1.7+ 中字符串常量池的位置变化,对 intern() 方法有何影响?(进阶难度)

答案解析:

  1. 常量池位置变化:
  • JDK 1.6:字符串常量池位于方法区(永久代),与堆内存相互独立。

  • JDK 1.7+:字符串常量池移至堆内存,与普通堆对象同区域(逻辑上独立)。

  1. 对 intern() 方法的影响:

当常量池不存在目标字符串时,intern() 方法的行为差异:

  • JDK 1.6:会复制堆对象的内容到方法区的常量池,创建新的字符串对象,返回常量池新对象的引用。此时堆对象与常量池对象是两个独立对象,地址不同。

  • JDK 1.7+:不会复制内容,而是将堆对象的引用存入常量池,返回该堆对象的引用。此时常量池存储的是堆对象地址,堆对象与常量池引用指向同一个对象,节省内存。

代码验证(JDK 1.7+):

1
2
3
4
5
6
7

public class Test {
public static void main(String[] args) {
String s = new String("a") + new String("b"); // 堆对象 "ab",常量池无 "ab"
String s2 = s.intern(); // 常量池存入 s 的引用,返回 s 的引用
System.out.println(s == s2); // true(JDK 1.7+);false(JDK 1.6)
}

面试题4:Spring 中 CGLIB 代理与 JDK 动态代理的区别,如何强制使用 CGLIB 代理?(中等难度)

答案解析:

  1. 核心区别:
对比维度 JDK 动态代理 CGLIB 代理
实现原理 基于接口实现,生成实现目标接口的代理类 基于继承,生成目标类的子类作为代理类
依赖条件 目标类必须实现接口 目标类不能是 final 修饰(无法继承)
方法限制 仅能代理接口中声明的方法 可代理目标类的所有 public 方法(不能代理 private/final 方法)
性能 代理创建速度快,运行速度稍慢 代理创建速度慢(需生成子类字节码),运行速度快
2. 强制使用 CGLIB 代理的方式:

Spring Boot 2.x 默认优先使用 CGLIB 代理,若需明确配置,可通过以下方式:

1
2
3
4
5
6
7
8
9
10

// 方式1:启动类添加注解
@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true)
// proxyTargetClass = true:强制使用 CGLIB 代理;false:默认策略(接口用 JDK,无接口用 CGLIB)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

方式2:配置文件(application.yml)

1
2
3
4
5

spring:
aop:
proxy-target-class: true # 强制使用 CGLIB 代理
auto: true # 自动开启 AOP 代理

面试题5:以下代码的输出结果是什么?请解释原因(综合难度,考察字符串常量池+intern())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public class StringTest {
public static void main(String[] args) {
String str1 = new String("abc");
String str2 = str1.intern();
String str3 = "abc";

System.out.println(str1 == str2);
System.out.println(str2 == str3);
System.out.println(str1 == str3);

String str4 = new String("a") + new String("b");
String str5 = str4.intern();
String str6 = "ab";

System.out.println(str4 == str5);
System.out.println(str5 == str6);
System.out.println(str4 == str6);
}
}

答案解析:(基于 JDK 1.7+)

输出结果:

原因分析:

  1. str1、str2、str3 部分

    • str1 = new String(“abc”):常量池创建 “abc”,堆创建新对象,str1 指向堆对象。

    • str1.intern():常量池已存在 “abc”,返回常量池引用给 str2。

    • str3 = “abc”:指向常量池对象。

    • 因此:str1(堆)== str2(常量池)→ false;str2 == str3(均为常量池)→ true;str1 == str3 → false。

  2. str4、str5、str6 部分

    • str4 = new String(“a”) + new String(“b”):运行期拼接,堆创建 “ab” 对象,常量池无 “ab”。

    • str4.intern():常量池无 “ab”,将堆对象 str4 的引用存入常量池,返回 str4 的引用给 str5。

    • str6 = “ab”:常量池已存储 str4 的引用,直接指向 str4 对应的堆对象。

    • 因此:str4 == str5(同一堆对象)→ true;str5 == str6(均指向堆对象)→ true;str4 == str6 → true。

若在 JDK 1.6 中,str4 == str5 会返回 false,因为 JDK 1.6 中 intern() 会复制堆对象内容到常量池,str5 指向常量池新对象,与 str4(堆对象)地址不同。