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

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

坑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

在Unity开发过程中,开发者常常会遇到一些常见问题和错误。以下是一些常见的问题及其解决方案以及经验: ### 1. 数据丢失问题 为了避免在Unity3D中出现数据丢失的情况,建议采取以下措施: - 定期备份项目文件。 - 及时保存更改,尤其是在进行重大修改后。 - 确保系统有足够的内存和磁盘空间。 - 避免使用未经测试或不信任的第三方插件或脚本[^3]。 ### 2. 性能优化问题 性能优化是游戏开发中的一个重要方面,尤其是降低Draw Call数可以显著提升性能。以下是一些优化技巧: - 使用静态批处理(Static Batching)来合并多个静态对象的绘制调用。 - 动态批处理(Dynamic Batching)适用于小网格对象,但需要注意其限制。 - 合并材质球(Material),尽量减少不同材质的数量。 - 使用纹理图集(Texture Atlas)来减少纹理切换次数。 可以通过Unity内置的Profiler工具来监控Draw Call数,并进行相应的优化。 ### 3. Android平台调用Java代码 在Unity中调用Android代码的过程相对简单,主要依赖于`Unity提供的AndroidJavaClass和AndroidJavaObject这两个类。它们允许Unity与Java代码进行交互,类似于通过反射调用Java方法。例如: ```csharp // 调用Android的Toast显示消息 AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); currentActivity.Call("runOnUiThread", new AndroidJavaRunnable(() => { AndroidJavaClass toastClass = new AndroidJavaClass("android.widget.Toast"); AndroidJavaObject context = currentActivity.Call<AndroidJavaObject>("getApplicationContext"); AndroidJavaObject toast = toastClass.CallStatic<AndroidJavaObject>("makeText", context, "Hello from Unity!", 0); toast.Call("show"); })); ``` ### 4. XR领域的问题 随着Unity在XR领域全面转向OpenXR标准,越来越多的开发者选择使用OpenXR来构建跨平台的VR应用。然而,在项目实际部署中可能会遇到打包成的EXE程序无法正常启动SteamVR,或者SteamVR未能识别到该应用的问题。解决这类问题的关键步骤包括: - 确保正确配置了OpenXR和SteamVR插件。 - 检查项目的XR设置是否正确,确保启用了所需的XR设备支持。 - 在构建项目时,确保所有必要的依赖项都被包含在内。 - 运行时检查日志输出,查找可能的错误信息,并根据日志进行调试。 ### 5. 团队协作与资源管理 对于大型项目,如《明日方舟》,团队规模通常较大,因此有效的团队协作和资源管理至关重要。以下是一些建议: - 利用Unity的版本控制功能,如Git,来管理源代码和资产。 - 建立清晰的工作流程,确保每个成员都知道自己的职责。 - 使用Unity的多人协作工具,如Unity Collaborate,来简化团队合作。 - 定期进行代码审查和技术分享,以提高团队的整体技术水平。 ### 6. 插件兼容性问题 当使用第三方插件时,可能会遇到兼容性问题。为了解决这些问题,建议: - 在导入新插件之前,先查阅官方文档和支持论坛,了解其他用户的反馈。 - 测试插件在当前项目中的表现,特别是在不同平台上。 - 如果发现问题,尝试联系插件作者获取支持,或者寻找替代方案。 ### 7. 内存泄漏问题 内存泄漏是Unity开发中常见的问题之一。为了防止内存泄漏,建议: - 使用Unity的Memory Profiler工具来检测内存使用情况。 - 注意不要保留不必要的引用,特别是单例模式中的全局引用。 - 定期释放不再使用的资源,如纹理、音频等。 ### 8. UI布局问题 UI布局问题是另一个常见问题,尤其是在不同分辨率和屏幕尺寸下。解决这个问题的方法包括: - 使用Canvas Scaler组件来适应不同的屏幕分辨率。 - 设计响应式布局,利用锚点和自动布局功能。 - 对关键界面进行多分辨率测试,确保在各种设备上都能正常显示。 ### 9. 物理引擎问题 物理引擎问题可能导致碰撞检测失败或物理效果不符合预期。解决这些问题的方法包括: - 检查碰撞器和刚体组件的设置是否正确。 - 确保物体的质量、摩擦力等参数合理。 - 使用Debug工具来可视化碰撞体,帮助定位问题。 ### 10. 跨平台构建问题 跨平台构建时可能会遇到特定平台上的问题。解决这些问题的方法包括: - 在目标平台上进行充分的测试。 - 使用条件编译指令(如`#if UNITY_ANDROID && !UNITY_EDITOR`)来处理平台相关的代码。 - 确保所有依赖的原生库都已正确配置。 通过以上这些常见问题的解决方案和经验,开发者可以在Unity开发过程中更加顺利地解决问题,提高开发效率和产品质量。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Apple_Web

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

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

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

打赏作者

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

抵扣说明:

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

余额充值