那些年我在Java中踩过的坑01-02

本文介绍了Java开发中常见的几个陷阱,包括@Transactional注解事务未回滚、AOP未生效、日期格式化错误、Random产生随机数不随机以及ThreadLocal取值异常。针对这些问题,提供了详细的解决方法,如明确事务回滚策略、正确使用AOP代理、理解SimpleDateFormat的年份格式、避免Random对象复用以及理解InheritableThreadLocal的工作原理。这些经验教训有助于开发者在日常工作中避免类似问题,提高代码质量。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

坑01

在日常开发过程中,作为一名开发者,非常容易因为自己对某个知识点的片面了解而导致误用、乱用,陷入陷阱。在我工作的这些年里,就遇到过很多陷阱。比如说在使用事务的时候明明添加了 @Transcational 注解,事务却没有回滚、明明添加了 AOP 扫描的注解,切面代码却没有执行、使用 SimpleDateFormat 转换出来的字符串日期不是我所想要的等等。

那我们要怎么解决这些问题的呢?关键就在于了解知识点的原理。我们只有对知识点有了充分了解,才能在开发的过程中正确使用它。所以接下来,我会从知识点的原理出发介绍这些年我遇到的一些问题和解决方法,希望能给你带来一些参考价值。

为什么 @Transactional 注解的事务没有回滚?

我们先来看第一个问题,为什么 @Transactional 注解的事务没有回滚?

在业务中总是存在一些需要事务的需求,你会使用 @Transactional 注解。有时候你明明添加了这个注解,但它就是没有生效。

比如说,存在这样一个场景,你去超市购物,超市的收银系统会执行下单和减库存两个操作,而且这两个操作应该在一个事务中执行。

我们来看一段伪代码:

public class Store {
/**
* 下单
**/
@Transactional
public void placeOrder(String productId, Integer num) {
// 添加订单记录
addOrder(productId, num);
// 扣减库存
reduceInventory(productId, num);
}

/**
* 扣减库存
**/
public void reduceInventory(String productId, Integer num) {
Product product = getProduct(productId);
if (product.getNum() < num) {
throw new Exception(" 库存不足 ");
}
reduceProductNum(productId, num);
}
......
}

我定义超市类 Store,在 Store 内部定义下单方法 placeOrder,以及减库存方法 reduceInventory。在 placeOrder 的方法体上使用 @Transactional 注解,标识这个方法里所有操作在一个事务里面执行。在 placeOrder 方法内部调用 addOrder 和 reduceInventory 两个方法。在 reduceInventory 方法里面,会判断当前商品库存是不是足够的,如果足够就减库存,如果不够,就抛出一个 Check Exception。

但是,如果库存不够,reduceInventory 抛出异常,已经添加的订单并没有回滚,这是什么原因呢?

这里的原因是:@Transactional 注解默认情况下只有遇到 Uncheck Exception 才会回滚,对于 Check Exception 不会进行任何操作。所以,如果不知道这一点的话,你就会深陷 Bug,自己还不知道。

现在你知道了不回滚的原因,那么怎么解决也就变的很简单了。你可以直接抛出 Uncheck Exception,这是代码示例:

...
if (product.getNum() < num) {
throw new RuntimeException(" 库存不足 ")
}
...

但如果你必须抛出 Check Exception,然后也期望它进行回滚,也是可以的。只要把 @Transcational 注解的 rollbackFor 属性设置成 Exception.class 就可以了,比如这段代码:
...
@Transactional(rollbackFor=Exception.class)
public void placeOrder(String productId, Integer num){
...
}
...

为什么我写的 AOP 没有生效?

第一个问题解决了,下面接着来看第二个问题,为什么我写的 AOP 没有生效?

用过 Spring 或者了解它的人都知道,Spring 提供的 AOP 功能非常强大,它可以帮助我们在不改变方法内容的情况下增强方法能力。但是有时候会发现添加的 AOP 并没有如期执行。

让我们来看一个例子,我经常使用 AOP 技术实现,在方法执行前、后打印日志。

假定我有一个自定义注解 @MethodLog,它通过 AOP 技术实现在目标方法执行前、后打印日志。

@Component
public class User {
/**
* 获取用户信息
*/
@MethodLog
public User getInfo(String userId) {
...
Address address = getAddress(userId);
}
/**
* 获取用户住址
*/
@MethodLog
public Address getAddress(String userId) {
...

}
----------------------------------------------------------------------
输出打印:
进入方法 getInfo
getInfo 方法执行完成耗时 23ms

你可以看到这段伪代码,我在类 User 的 getInfo 和 getAddress 方法体上分别添加了注解 @MethodLog。在 getInfo 方法中调用 getAddress 方法。当我使用 User 类的 getInfo 方法时,我期望分别在 getInfo、getAddress 方法的执行前后打印日志。但是结果却是仅仅打印了 getInfo 方法的日志,为什么?

因为 Spring AOP 技术实现的原理是通过代理模式。它会给目标类 User 生成一个代理类。在 User 代理类里面也会定义 getInfo 和 getAddress 方法,而且会分别在对应的方法里添加打印日志的代码。通过 Spring Bean 对象调用的 getInfo 方法是代理类的,会打印日志。在我定义的 User 类的 getInfo 方法中调用 getAddress 方法,并不会调用代理类的 getAddress 方法,所以不会打印日志。

那么,回到我们最开始那个问题,怎么解决 AOP 失效呢?从我们刚才的分析可以知道,通过代理对象调用对应的方法,可以执行 AOP。所以只要在 User 类的 getInfo 方法里通过代理对象调用 getAddress 方法就行了。

@Component
public class User {
...
@MethodLog
public void getInfo(String userId) {
...
((User)AopContext.currentProxy()).getAddress(userId);
}
...
}

为什么有时候 2019.12.29 会被格式化成“2020-12-29”?

接下来就聊到第三个问题,为什么有时候 2019.12.29 会被格式化成“2020-12-29”?

在日常开发中,相信你或者身边的同事也遇到过这种情况:日期格式转换。我经常会把日期转换成各种各样的字符串形式,但在转换的过程中,有时候得到的结果却不是我期望的。比如说我想把日期 2019.12.29 通过 SimpleDateFormat 对象转换成字符串“2019-12-29”,就像这段代码里显示的这样:

public static void main(String[] args) {
SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd");
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, 2019);
// 月份从 0 开始
calendar.set(Calendar.MONTH, 11);
calendar.set(Calendar.DAY_OF_MONTH, 29);
System.out.println("Date: 2019.12.29 ==> String: " + sdf.format(calendar.getTime()));
}
----------------------------------------------------------------------
输出打印:
Date: 2019.12.29 ==> String: 2020-12-29

我会发现转换出来的结果不是期望的字符串“2019-12-29”,它变成了字符串“2020-12-29”,莫名其妙的多了一年。这个结果是不是有点匪夷所思?

这是因为 SimpleDateFormat 在表示年份的时候存在两种格式,一种是大写的 YYYY,另一种是小写的 yyyy,这两种形式表达的含义是不一样的。大写的 YYYY 表示的是“周年”,也就是这一周所在的年份,小写的 yyyy 表示的才是这一天所在的年份,什么意思呢?因为包含 2019.12.29 这一天在内的这一周,是跨越了 2019 年和 2020 年的,所以在判断这一周是属于 2019 年还是 2020 年的时候,Java 会自动把这一周定义到年份大的那一年里,也就是 2020 年,所以最后会出现“2020-12-29”这个字符串。

那了解到这个原理,我们以后再把日期格式转换成字符串的时候,就可以用小写的 yyyy 来表示年份,这样就不会莫名其妙多出一年了。

为什么用 Random 产生的随机数不随机?

紧接着来说第四个问题,为什么 Random 不随机?

不知道你有没有这个疑惑,因为我会经常用 Random 产生随机数,但我发现,有时候 Random 产生的数并不随机。假如我想要一个产生 0 到 25 随机数的功能。我会定义 randomNumber 方法,在这个方法里定义一个 Random 对象,然后通过这个对象产生随机值。这是代码:

public static Integer randomNumber() {
Random random = new Random(1000);
return random.nextInt(26);
}
...

我循环 10 次输出,会发现打印的结果是一样的,是不是很纳闷?

之所以出现这种现象,是因为在 randomNumber 方法里每次都创造了一个新的 Random 对象,还设置了一样的 Seed 值。Random 的实现原理是依靠 Seed 值产生一个伪随机序列,每次调用 nextInt() 方法会按顺序从这个伪随机序列里获取值,就相当于 randomNumber 方法每次都从同一个随机序列取第一个值。

想要改变这种情况也很简单。只需要把 Random 对象设置成单例,并不指定 Seed 值。你可以参考这段代码:

static Random random = new Random();
public static Integer randomIndex() {
return random.nextInt(26);
}
...

以上就是我在 Java 中踩过的其中四个坑,比较常见,或许你也都遇到过这些问题,在这里我简单总结一下我的解决方法,供你参考:
第一,为什么 @Transactional 注解的事务没有回滚?因为抛出的 Check Exception 不能被 @Transactional 识别,应该抛出 Uncheck Exception。
第二,为什么我写的 AOP 没有生效?因为只有使用代理对象调用被增强的方法才能使 AOP 生效。
第三,为什么有时候“2019.12.29”会被格式化成“2020-12-29”?因为使用了错误的年份格式 YYYY,应该使用 yyyy。
第四,为什么 Random 不随机?因为只有使用单例的 Random 对象,才能保证随机数随机。

坑02

在开发的过程中,你肯定期望编写出没有 Bug 的代码。要想做到这一点,首先你需要知道产生 Bug 的陷阱在哪里?那怎么知道陷阱在哪里?这就要求我们在学习知识的时候,了解知识点的原理。只有对知识点有了充分了解,才能在开发的过程中正确的使用它。我为什么会这么肯定呢?其实说多了都是泪,我在工作的这些年里,就遇到过很多陷阱。所以接下来,我会从知识点的原理出发介绍这些年我遇到的一些问题和解决方法。

为什么 Integer a = false ? 3 + 4 : b,会抛出 NPE?

先来看第一个问题,为什么 Integer a = false ? 3 + 4 : b,会抛出 NPE?

三元表达式能帮我们简化 if…else… 类型的代码。但是如果三元表达式使用不当,就会出现 NPE。

我们假定存在一个方法 compareAndSum,有三个参数 number1、number2、defaultNumber。如果 number1 大于 number2,那么把 number1 和 number2 进行求和。否则就返回 defaultNumber。我们可以用三元表达式表示这个过程:

public Integer compareAndSum(Integer number1, Integer number2, Integer defaultNumber) {
return number1 > number2 ? number1 + number2 : defaultNumber;
}

你看,如果传入的 number1 小于 number2,defaultNumber 为 null 的时候,就会抛出 NPE。这是因为 number1 和 number2 相加,会被转换成基础类型。在三元表达式中,会把所有的变量转成同一种类型。所以对 defaultNumber 进行转换的时候,就会抛出 NPE。

在使用三元表达式的时候,我们要确认会不会存在参数为 null 的情况。如果存在,就需要使用 if…else… 代替。

为什么 (Integer)100 = = (Integer)100 是 true,但 (Integer)200 = = (Integer)200 却是 false?

下面紧接着来说第二个常见问题,为什么 (Integer)100 == (Integer)100 是 true,但 (Integer)200 = = (Integer)200 却是 false

在 Java 语言里,使用整型是非常常见的。但是呢,在整型比较的过程中也会出现一些匪夷所思的事情。比如说,当我们把两个基础类型的 100 强转成包装类型,然后进行 == 比较,结果是 true。但是当我们把这里的 100 换成 200,结果就变成了 false:

public static void main(String[] args) {
//true
System.out.println((Integer)100 == (Integer)100);
//false
System.out.println((Integer)200 == (Integer)200);
}

这样是不是很奇怪?这是因为 Java 语言里,基础类型 int 转换成包装类 Integer 的过程中,会调用 Integer 类的 valueOf 方法。valueOf 方法中,如果转换的数字在 -128 和 127 之间,就会取 IntegerCache 对象中缓存的 Integer 对象。否则会 new 一个新的 Integer 对象:

...
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
...

所以,-128 到 127 的基础类型向包装类型转换的时候,取到的都是同一个对象。这就解释了 (Integer)100==(Integer)100 会打印出 true,而不在区间内的 200,在转换的时候会变成不同的对象,也就是打印出 false。那我们为了避免 Integer 比较带来的差异,要在比较包装类 Integer 的时候用 equals 方法,杜绝使用 ==。

ThreadLocal 对象为什么取到了预期外的值?

我在这节课要讲的第三个问题,是 ThreadLocal 对象为什么取到了预期外的值。

我们知道在 Java Web 中,每一次用户请求都会从容器的线程池,取一个新的线程来负责执行请求。在这个线程里,我们经常需要使用 ThreadLocal 存储一个上下文信息,在线程的整个生命周期里都可以取用这个上下文信息。但是在使用 ThreadLocal 对象的时候,会遇到明明没有设置值但是却从中取到了数据的现象。

比如说,我定义了一个 RequestContext 类,其中有一个 ThreadLocal 对象。如果请求用户已经登录,就把 UserId 设置到 RequestContext 类的 ThreadLocal 对象里。在后面的执行过程中,就可以从上下文中取用 UserId。

假设这么一个场景,如果登录用户 A 发起了一个请求,后端程序从容器线程池取出线程 1 来执行这个请求,这时会把用户 A 的 UserId 存入到线程 1 的上下文中。线程 1 执行完这次请求之后会被回收到线程池中。过了一段时间另一个没登录的用户 B 来请求,后端程序如果从容器线程池中取出了上次执行过的线程 1 来执行新的程序,那么,从 RequestContext 中就可以取出用户 A 的 UserId,但这并不是我所期望的啊。

img

为什么会出现这种现象呢?原因是我使用的线程是从线程池中取出来的,两次请求完全有可能取到同一个线程。那么在使用 ThreadLocal 对象存储上下文,如果在使用完后不把它清除,那么这个线程的 ThreadLocal 对象里还是会存在上下文信息,所以导致取到预期之外的值。

那么,该怎么解决呢?我们只需要在每次使用完线程后,牢记把线程的 ThreadLocal 对象清除掉就可以了。这是清除 ThreadLocal 数据的代码:

...
public class RequestInterceptor implements HandlerInterceptor {
...
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
RequestContext.CONTEXT.remove();
}
}
...

InheritableThreadLocal 是什么?它有哪些坑?

下面我们来看第四个问题,也是最后一个问题,InheritableThreadLocal 是个什么东西 + 它有哪些坑。

ThreadLocal 可以帮助我们在多线程之间实现数据隔离,来保证线程安全。但是如果你需要把父线程的数据传递给子线程,ThreadLocal 就不行。这个时候可以使用 Thread 中的另一个属性 InheritableThreadLocal。父线程存储在对象 InheritableThreadLocal 中的数据,可以传递到子线程的 InheritableThreadLocal 中。

img

比如说,你要计算用户的账单。你期望在父线程中获取到用户的消费记录,然后在子线程中进行消费记录的汇总。这个时候可以使用 InheritableThreadLocal,这是父子线程传递 InheritableThreadLocal 的代码:

static InheritableThreadLocal<ConsumptionRecord> context = new InheritableThreadLocal<>();
public static void main(String[] args) {
ConsumptionRecord consumptionRecord = new ConsumptionRecord();
...
context.set(consumptionRecord);
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
ConsumptionRecord consumptionRecord = context.get();
...
}
});
thread.start();
}

你可以定义一个全局的 InheritableThreadLocal 对象,在父线程中通过这个对象的 set 方法,存储消费记录。然后在子线程里通过 get 方法获取到消费记录,并进行计算。

虽然 InheritableThreadLocal 能够帮助你在父子线程之间传递数据。但是也有一定的问题。如果你通过线程池来获取子线程,这个时候 InheritableThreadLocal 可能会失效。这是因为 InheritableThreadLocal 对象是在创建线程的 init 方法内,来进行父线程向子线程传递的。线程池里面的线程是已经创建好的,因此在父线程中从线程池获取子线程,并不会执行 init 方法,InheritableThreadLocal 对象也就不会传递。

那怎么避免这种情况呢?有两种方法,方法一是不使用线程池,每次都创建新线程。方法二是使用 TransmittableThreadLocal 代替 InheritableThreadLocal。TransmittableThreadLocal 不是 Java 官方提供的,是阿里技术团队为了解决 InheritableThreadLocal 在线程池中的缺陷而特意扩展的。

好了,今天的四个问题以及解决方法到这里就分享完了。我简单总结一下各个问题对应的解决方法:

第一,为什么 Integer a = false ? 3 * 4 :b,会抛出 NPE?因为 b 为 null 的时候不能被转换为基础类型,需要使用 if…else… 代替三元表达式。

第二,为什么 (Integer)100 = = (Integer)100 是 true,但 (Integer)200 = =(Integer)200 却是 false?因为基础类型 int 转换称包装类 Integer 存在缓存,需要使用 equals 代替 ==。

第三,ThreadLocal 对象为什么取到了预期外的值?因为 ThreadLocal 数据使用完需要及时清除。

第四,InheritableThreadLocal 是什么?它有哪些坑?InheritableThreadLocal 能实现父子线程传递数据,避免线程池中使用 InheritableThreadLocal。

我们要知道,Java 中存在的陷阱是多不胜数的。我们不可能把所有的陷阱都掌握,只能说尽可能地少踩坑。怎么少踩坑?我们可以从三方面解决:

第一,我们可以多听分享,多了解其他人遇见的问题,从而知道更多陷阱。

第二,我们需要扎实我们的 Java 基础。遇见新的知识点,从根本出发将知识点剖析透彻。

第三,我们需要多写单元测试,尽早的将问题暴露出来,从而提高代码质量。

参考文章:
整理于极客时间每日一课:
https://time.geekbang.org/dailylesson/detail/100056850?utm_source=u_nav_web&utm_medium=u_nav_web&utm_term=u_nav_web

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Apple_Web

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值