盘点 2021|「避坑宝典」为大家分享一下笔者在 2021 年所遇到“匪夷所思”的 Bug 趣事(上)
2021 尾声想跟大家说的话
虚则实之
引用 https://xie.infoq.cn/article/0f1f2d4a3423ba8cf9685f720中的一句话:
距离 2021 年结束只剩下 10 多天了,又到了年终岁末,回顾、盘点再次成为本月常出现的词语。
虚:本意是说的是背景介绍;实:(O(∩_∩)O~嘘嘘帮官方宣传一下哈,不用谢我哈,)
如果你坚持看到了这里,那么允许笔者冒昧的来一个激动三连问环节。
激动三连问
你们公司年终奖发了没?
你们公司啥时候放假啊?
你们公司年会开了没啊?
这三个答案,我相信可以让你或者其他人激动下,如果没激动的话,那我们再来一个糟心三连问?
糟心三连问
你们过年加班或者需要支持解决 bug 吗?(#^.^#)
目前的需求版本中,你手头的 bug 解决完了吗?
你的需求开发完了吗?嘻嘻嘻。
千万别打我,笔者经过精密的大数据猜测后,知道以上这糟心三连问的范围可以覆盖到整个 IT 编程圈的近 8 成的程序员,好了我们废话不多说,直奔主题。
“BUG”的你咋又来了?
无论这一年我们遇到了什么困难或者是喜事,在此时此刻,个人觉得都应该反思或者回味一下这些事情,对于好事,我们欣慰开心;坏事那我们能做到的就是极力避免它们再次发生,就如同接下来笔者要介绍的整个熟悉而陌生的名称“BUG”,接下来我主要会为大家介绍一下,发生在 2021 年这一年中的笔者在开发过程中所遇到的“bug”和“坑”。
衷心提醒
希望大家不要当做笑话,认真去了解或者研究笔者所梳理出来的坑和 bug,希望可以警示和告诫大
家,无论在代码的书写层面还是实际的开发层面都可以跳出这些问题改圈和坑点!在开发的航线中一路顺风,成为 IT 界的“海贼王”!
直奔主题
空指针的问题系列
「2021-03-X」空指针问题
场景讨论
问题发生在 Boolean 类型的判断场景
问题发生在 Integer 类型参数传递给方法参数或者局部变量时候的自动拆箱场景
问题发生在对象中采用了 List 集合字段以及 Map 字段的时候进行操作,一般我们很少会对 List 或者 HashMap 参数进行初始化在进行赋值,此时出现了获取了一个 null 的引用进行 add 或者 put 操作。
「2021-05-X」空指针问题(1)
场景讨论
问题发生在相关业务场景的 Null 值操作,最后暴露在非传统的 NPE 场景,NumberFormatException:"null",此部分在于'null'的场景:总结下来归咎为:代码中有个小伙伴使用了 String.valueOf 方法区承接参数的 Integer 和 Long 类型的转换操作,(当没有传递该参数的时候或者传递了 null)导致最后数据库转为了'null',这里大家会说为什么要用 String 去转换或者直接用 String 定义不就好了,确实道理是这个道理,但是“木已成舟、米已成炊”,是哪位大神留下的代码啊......
承接上面的问题还引发出了很多连锁问题,比如说:'null'的控制是不容易判断出来的,比如;isEmpty/isBlank 等方法无法处理,所以会将错误的数据更新进入数据库以及计算数据的时候出现了紊乱(例如:Mybatis 的 if test 一般只会做!=null 的判断、加解密的场景下,一般不会对 null 或者''的值进行处理,但是一旦出现了此种场景,就发生了异常!)
「2021-05-X」空指针问题(2)
场景讨论
问题发生在数据库总存储 Null 的场景:包含了 null 和''的两种情况,此时查询的时候 ifnull 的函数无法进行判断,可以考虑采用''的匹配或者 length 函数才可以进行控制!但是没想到啊!我们的数据库同一个字段存储了两种 null 和''都有!此外还“发扬光大”!出现了[],空集合的操作,而且业务场景下数据也为空,哈哈千算万算,没想到还有这一招!大家可要注意啊。
多说一句哈,统计或者窗口函数的时候对 null 的处理一定要多注意,我们承接'null'的时候,出现了唯一索引的问题:MySQLIntegrityConstraintViolationException,因为'null'出现了重复,innodb 引擎是可以允许唯一索引进行多个 null 的场景,但是'null'或者''、[]是不允许的哦!
此外有一个伙伴们,因为不想总是针对于 null 进行判断,所以将 PO 数据持久对象属性的默认值:导致很多数据库中的默认值都失效了,┭┮﹏┭┮。好难过!
区分空字符串和 NULL
数据库存储数据有必要搞清空值,空字符串和 NULL 的概念。
空字符串是单引号引的,它其实是不占空间的。
MySQL 中 null 其实是占空间的,官网文档里有说明。
null 其实并不是空值,而是要占用空间,所以 MySQL 在进行比较的时候,null 会参与字段比较,所以对效率有一部分影响。对于表索引时不会存储 null 值的,所以如果索引的字段可以为 null,索引的效率会下降很多。
空值也不一定为空,对于 timestamp 数据类型,如果往这个数据类型插入的列插入 null 值,则出现的值是当前系统时间,插入空值,则会出现 ‘0000-00-00 00:00:00’。
根据 NULL 的定义,NULL 表示的是未知,因此两个 NULL 比较的结果既不相等,也不不等,结果仍然是未知。根据这个定义,多个 NULL 值的存在应该不违反唯一约束,所以是合理的,在 oracle 也是如此。
集合的问题系列
「2021-03-X」集合问题
场景讨论
集合转换问题:用 Array.asList 转换基础类型数组,此时转换后的 List 集合的元素是有问题的,当接收页面请求的时候,循环以及获取元素的时候程序崩溃了!
int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());
此时 List 集合的长度并不是我们预期的 3,而是 1,因为内部的元素是一个数组,而不是所有的元
素。直接遍历这样的 List 必然会出现 Bug:
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
解决方案
如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组:
int[] arr1 = {1, 2, 3};
List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
List list2 = Arrays.asList(arr1);
不能直接使用 Arrays.asList 来转换基本类型数组。
集合转换问题:Arrays.asList 返回的 List 不支持增删操作
String[] arr = {"1", "2", "3"};
List list = Arrays.asList(arr);
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
原因分析:Arrays.asList 返回的 List 不支持增删操作。Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList。ArrayList 内部类继承自 AbstractList 类,并没有覆写父类的 add 方法,而父类中 add 方法的实现,就是抛出 UnsupportedOperationException。
private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable
集合转换问题:对原始数组的修改会直接影响得到的 list
String[] arr = {"1", "2", "3"};
List<String> list = Arrays.asList(arr);
arr[0]="aaaaa";
asList 生成的那个 Array 内部的 ArrayList 内部直接使用了原始的 array 导致的,这估计也是不让生成的 list add 和 remove 的原因吧,因为这样会影响到原始值。
「2021-03-X」集合问题(2)
场景考虑
List.subList 操作还会导致 OOM?
在日常开发过程中,经常会常常需要取集合中的某一部分子集来进行一下操作,而对于 subList 这个方法会经常的被我们所熟知。
List<Object> lists = new ArrayList<Object>();
lists.add( "1" );
lists.add( "2" );
lists.add( "3" );
lists.add( "4" );
... ...
List<Object> tempList = lists.subList( 2 , lists.size());
在上面的代码终会,执行的 subList 方法的次数越多、或者分离的原始集合越大,越容易出现 OOM,我们其实很容易误解,底层真正会对数组或者 List 集合进行相关的分割,其实不然,本身来讲会建立的方案只是单纯的逻辑分割哦!让我们来看看为什么会出现。
原因分析
假设来 100000 次循环中的产生的一个个 size 为 1000 的 list 始终执行 subList。那么返回的 List 强引用,使他得不到回收造成的。接下来我们来看一看为什么返回的子 list 会强引用原来的 list。我们点进入 ArrayList.subList()的源码。
SubList 类的构造方法:
public SubList(ArrayList<E> root, int fromIndex, int toIndex) {
this.root = root;
this.parent = null;
this.offset = fromIndex;
this.size = toIndex - fromIndex;
this.modCount = root.modCount;
}
subList()返回的并不是一个 ArrayList,他返回的是一个 SubList 类,并且在初始化时传入了 this。
SubList 是 ArrayList 的一个内部类。再看一下他的构造方法会发现他的 root 就是原来的 List,初始化时并没有将截取的元素复制到新的变量中。由此可见 SubList 就是原来 List 的视图,并不是新的 List,双方对集合中元素的修改是会互相影响的。并且因为 SubList 对原来的 List 有强引用,导致这些原始集合不能被垃圾回收,所以导致了 OOM。
SubList 的构造方法中我们会发现 this.modCount = root.modCount,SubList 的 modCount 就是原来集合的 modCount。modCount 是在 ArrayList 中维护的一个字段,表示集合的结构性修改的次数。所以对于原始集合的 add,remove 操作时一定会改变原始集合 modCount 的值,而经过 subList()后得到的 List 的 modCount 是不会改变的。
解决方案
List.subList 操作导致 OOM 的根本原因就是分片后的 List 对饮食集合的强引用。为了避免这种情况的发生,在获取到分片后的 List 后,我们不要直接使用这个集合进行操作,可以使用一个新的变量保存分片后的 list。
// 方法一
List<Integer> arrayList = new ArrayList<>(rawList.subList(0, 2));
// 方法二
List<Integer> arrayList1 = list.stream().skip(1).limit(3).collect(Collectors.toList());
因为 sublist 中保存有原有 list 对象的引用——而且是强引用,这意味着, 只要 sublist 没有被 jvm 回收,那么这个原有 list 对象就不能 gc,这个 list 中保存的所有对象也不能 gc,即使这个 list 和其包含的对象已经没有其他任何引用。
数值计算的问题系列
「2021-03-X」计算问题
场景考虑:
Double 和 Float 的计算操作,加减乘除方式会存在相关的误差哦,初级小伙伴们,一定要注意,如果(1)要求比较高一定要采用 BigDecimal 类型进行计算操作。
String a = "16.11";
Double v = Double.parseDouble(a) * 100;
BigDecimal bigDecimal = new BigDecimal(a);
BigDecimal multiply = bigDecimal.multiply(new BigDecimal(100));
最后的结果是会存在误差的哦,Double 的数据会<1611。
(2)条件判断超预期
System.out.println( 1f == 0.9999999f ); // 打印:false
System.out.println( 1f == 0.99999999f ); // 打印:true 惊喜不?
最后的比较大小会存在歧义,差一位小数,竟然天壤之别
(3)数据转换超预期
float f = 1.1f; double d = (double) f;
System.out.println(f); // 打印:1.1
System.out.println(d); // 打印:1.100000023841858,咋会变成这样
「2021-11-X」计算问题
场景考虑:
你以为 BigDecimal 就没有坑了?它的精度与相等比较的坑(equals 方法可能不相等)
作为一个数字类型,经常有的操作是比较大小,有一种情况是比较是否相等。用 equal 方法还是 compareTo 方法?这里就是一个大坑。
//new 传进去一个 double
BigDecimal newZero = new BigDecimal(0.0);
System.out.println(BigDecimal.ZERO.equals(newZero));
//new 传进去一个字符串
BigDecimal stringNewZero = new BigDecimal("0.0");
System.out.println(BigDecimal.ZERO.equals(stringNewZero));
//valueOf 传进去一个 double
BigDecimal noScaleZero = BigDecimal.valueOf(0.0);
System.out.println(BigDecimal.ZERO.equals(noScaleZero));
//valueOf 传进去一个 double,再手动设置精度为 1
BigDecimal scaleZero = BigDecimal.valueOf(0.0).setScale(1);
System.out.println(BigDecimal.ZERO.equals(scaleZero));
用于比较的值全都是 0,猜一猜上面几个 equals 方法返回的结果是什么?全都是 true?no no no...
看看 equal 方法你就会豁然开朗咯,它还比较 scale 精度哦,哈哈,没有表面的那么简单哦!
那么对于这种本身就需要忽略 scale 的对比怎么办?其实 BigDecimal 类也提供了相关的 compare 方法,而且这个方法的设计也和 comparable 接口的实现也很相似,所以使用起来也挺舒服的。
一个更大的坑是,如果将 BigDecimal 的值作为 HashMap 的 key,因为精度的问题,相同的值就可能出现 hashCode 值不同并且 equals 方法返回 false,导致 put 和 get 就很可能会出现相同的值但是存取了不同的 value。小数类型在计算机中本来就不能精确存储,再把其作为 HashMap 的 key 就相当不靠谱了,以后还是少用。
比较的问题系列
lombok 中注解 @EqualsAndHashCode 的坑
@Data 相当于 @Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode 这 5 个注解的合集
//父类
@Data
public class Parent { private String id;}
//子类
@Data
public class Child extends Parent { private String name;}
所以如果继承父类时候使用 @Data 需要加上 @EqualsAndHashCode(callSuper = true),如下:
@Data
@EqualsAndHashCode(callSuper = true)
public class Child extends Parent {
private String name;
}
并发多线程的问题
「2021-12-X」数据紊乱 ThreadLocal 问题
场景考虑:
在登录认证后,我们系统频繁高并发去处理请求的时候,发现数据出现了紊乱,什么紊乱?就是多个账号之间的数据发生了流窜,道理很简单从数据上来看就是数据对应的 userId 完全对不上了。
分析了以后发现,系统在调用的时候对 ThreadLocal 的使用出现了内存泄漏以及内存数据紊乱的问
题,也就是和 PageHelper 一样的道理,需要清理参数执行,在公司内部的系统中出现了相关的权限认证和会话信息注入到 ThreadLocal 的内容,这个相信大家并不陌生,但是在有一些不需要鉴权的接口的时候,就会存在不会处理 ThreadLocal 中数据的 remove 以及更新的操作,导致出现了数据紊乱的问题。
解决方案
web 请求下的 ThreadLocal 使用要保证:请求进来的时候 set,请求回去的时候 remove。只有这样才能保证请求内的 ThreadLocal 是唯一的。 这个特性在深刻的提醒我们:一次 http 请求和 tomcat 启动处理业务的线程并非一一对应的,而是通过一个线程池进行调度。
「2021-09-X」ConcurrentHashMap 的线程不安全问题
案例分析
ConcurrentHashMap 是个线程安全的哈希表容器,但它仅保证提供的原子性读写操作线程安全。
当我们在通过多线程情况下,如果在对相关的 ConcurrentHashMap 做较为复杂的操作处理功能的时候,就会存在线程不安全的场景:
map.put(1,getResult()); 这种场景就是线程不安全的考虑哦!请大家慎用和谨记!
ConcurrentHashMap 对外提供能力的限制:
使用不代表对其的多个操作之间的状态一致,是没有其他线程在操作它的。如果需要确保需要手动加锁
诸如 size、isEmpty 和 containsValue 等聚合方法,在并发下可能会反映 ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。
诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据。
解决方案
我们可以使用相关的 computeIfAbsent、putIfAbsent 等操作可以保证原子化处理。
可以参考一下这篇文章哦:https://blog.csdn.net/singwhatiwanna/article/details/104586114/
RocketMQ 问题分析系列
场景考虑:
发送 Topic 消息报该错误,com.alibaba.rocketmq.client.exception.MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 208ms, size of queue: 8
sendThreadPoolQueue 取出头元素,转换成对应的任务,判断任务在队列存活时间是否超过了队列设置的最大等待时间,如果超过了则组装处理返回对象 response,response 的 code 为 RemotingSysResponseCode.SYSTEM_BUSY。
[TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: [当前任务在队列存活时间], size of queue: [当前队列的长度]
解决方案
说实在的就是 RocketMQ 处理不过来了:那么有以下几个选择供大家参考:
给 rocketmq 单独部署性能较高的服务器.
sendMessageThreadPoolNums 改成 N(N>1),useReentrantLockWhenPutMessage 改成 true,修改 broker 的默认发送消息任务队列等待时长 waitTimeMillsInSendQueue,可通过增大 osPageCacheBusyTimeOutMills 进一步优化调整,仅供参考,不是万金油,会有副作用的哦,慎用!
结束语
好了就到这里了,已经接近 8000 字了,笔者最后就是提醒大家,最近的 Log4j2 的问题,相信地球人都知道,笔者就不多说了,希望大家以后多多注意这种第三方库的选用哦,以后我还会多多分析一些相关的开发过程中的问题和深坑哦,敬请期待我们的(下篇)。
版权声明: 本文为 InfoQ 作者【浩宇天尚】的原创文章。
原文链接:【http://xie.infoq.cn/article/3145cd5f525fe26ce9d574c8d】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论 (54 条评论)