Java日常开发的踩坑经验总结
1、日常闹磕
大榜:麻雀啊,我最近做开发,踩了不少坑,把自己都坑哭了。
麻雀:大榜,快给我说说,一起交流下踩坑经验,免得我哪天也掉坑里面了。
大榜:最近负责一个模块的开发实现,本地测试了下,没问题。交付给测试同事去测试,在测试环境里面,这个模块经常出问题。
麻雀:是不是开发时,没做边界测试?
大榜:是滴了,也是自己的代码健壮性不够,没对异常数据做处理。
麻雀:咱们一起说说吧?
大榜:踩坑点主要有集合List、Lambda表达式、ThreadLocal、字符串比较、其他方面。首先说说集合List的踩坑。看看下面的代码,Arrays.asList(1, 2, 3),对返回的集合做add操作,会有什么问题呢?
2、集合List踩坑
/**
* Arrays.asList(1, 2, 3)
* 返回的是匿名内部类 java.util.Arrays.ArrayList
* 只支持查询、修改元素值;
* 不支持增加、删除元素操作
*/
private static void arrays_asList() {
List<Integer> integerList = Arrays.asList(1, 2, 3);
logger.info("修改前,integerList = {}", integerList);
// integerList.add(4); // UnsupportedOperationException
// Arrays.asList 返回的列表元素,其元素值,是可以修改的:The list returned by this method is modifiable.
integerList.set(0,88); // 不会抛出异常
logger.info("修改后,integerList = {}", integerList);
}
麻雀: Arrays.asList()我之前踩过坑,面试官当时问了我这块。 Arrays.asList()返回的是java.util.Arrays.ArrayList这个内部类,很显然它不是java.util.ArrayList,对它做add操作,会抛出异常:UnsupportedOperationException。
大榜:是的。那List.of(1, 2, 3);、Collections.emptyList() 返回的集合,支持做add操作吗?
麻雀:那应该也不支持add操作,我记得List.of(1, 2, 3) 返回的是不可变集合(an unmodifiable list)。
大榜:小伙子基础很好啊(大赞)。实际上List.of(1, 2, 3)、Collections.emptyList() 返回的都是不可变集合,如果对集合中的元素做增、删、改操作,结果啪的一下 UnsupportedOperationException 会拍在你那笑容渐渐消失的脸上。那Arrays.asList(1, 2, 3);和 List.of(1, 2, 3)有什么区别吗?
麻雀:我只知道Arrays.asList是JDK 5左右的方法,返回的是内部类;List.of是JDK 9的方法,返回的是不可变集合。它两之间的区别不清楚额?
大榜:麻雀,其实你已经说出了它两的区别了。我们看上面的代码,可以看到Arrays.asList返回的集合虽然不能做add操作,但是可以做修改元素值的操作,下面的代码,是不会报错的。
// Arrays.asList 返回的列表元素,其元素值,是可以修改的:The list returned by this method is modifiable.
integerList.set(0,88); // 不会抛出异常
对于List.of,由于返回的是不可变集合,也就是说只支持查询,不支持增、删除和修改操作,如果List.of返回的集合,做修改元素操作,UnsupportedOperationException 也会拍在你那笑容渐渐消失的脸上。
麻雀:JDK为什么要这么设计呢?
大榜:List.of这么设计,是因为返回不可变集合,保证安全性。至于 Arrays.asList 因为是JDK 5左右版本,就不得而知了。
如果我们想要对Arrays.asList、List.of返回的集合,进行增删操作,我们可以包装集合,然后再使用。
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
麻雀:是啊,有个项目,调用同事的方法,返回了一个List;我没有包装,拿着这个List,支持做add操作,结果抛了UnsupportedOperationException 异常。
同事写的代码示例:
private List<Integer> getUnmodifiableList() {
List<Integer> integerList = List.of(1, 2, 3);
return integerList;
}
我这边调用同事写的getList方法:
List<Integer> integers = getUnmodifiableList();
integers.add(33); // UnsupportedOperationException
大榜:是啊, UnsupportedOperationException 会拍在你那笑容渐渐消失的脸上。所以,如果你需要对同事返回的集合进行操作,可以包装一下,下面的代码,就不会报错了。
// 调用同事的方法
List<Integer> integers = getUnmodifiableList();
// 包装同事返回的集合
List<Integer> list = new ArrayList<>(integers);
// 对包装后的集合,做add操作,就不会报错了
list.add(33);
麻雀:总结一下,咱两刚刚讨论的,Arrays.asList()返回的内部类,不支持增删操作,支持查询、修改;List.of、Collection.emptyList() 返回的是不可变集合,只支持查询,增删、改都不支持;如果需要对返回的不可变集合,进行操作,可以使用 new ArrayList(List.of(1,2,3))包装一层,这样拿着包装后的集合,做add操作,就不会报错了。
3、Lambda表达式踩坑
大榜:总结得很到位啊。咱们再看看Lambda表达式的踩坑,先看看排序的踩坑。这是代码:
public static final Student LISI_Student = new Student("1002", "lisi", 26);
public static final Student WANGWU_Student = new Student("1003", "wangwu", null);
public static final Student ZHANGSAN_Student = new Student(null, "zhangsan", 25);
// 创建学生对象的集合,对集合进行排序。 (List.of: Returns an unmodifiable list)
List<Student> students = List.of( LISI_Student, WANGWU_Student, ZHANGSAN_Student);
List<Student> studentList = new ArrayList<>(students);
System.out.println("排序前:studentList::: " + studentList);
// 未对编号为空的对象做过滤,直接按照编号排序:如果排序的字段有null,会报错 NullPointerException
List<Student> errorSortList = studentList.stream()
.sorted(Comparator.comparing(Student::getCode))
.collect(Collectors.toList());
System.out.println("errorSortList::: " + errorSortList);
麻雀:分析了下,Comparator.comparing(Student::getCode) 是对code进行从小到大排序,看着应该没有问题啊?
大榜:如果code为null,排序会报空指针异常。
麻雀:咦,好像是有这个bug存在。那如何解决呢?
大榜:思想其实很简单。无法就是过滤到code为空的元素,想想代码该如何实现?
麻雀:我想起来了,可以用filter方法,来过滤code为null的元素。代码像下面这样:
// 先过滤得到编号不为空的元素,按编号排序
List<Student> sortList = studentList.stream()
.filter(item -> Objects.nonNull(item.getCode()))
.sorted(Comparator.comparing(Student::getCode))
.collect(Collectors.toList());
大榜:是的,确认不会报错。但引申出来一个问题,就是code为null的元素被你过滤掉了,如果将code为null的元素过滤,并添加到排序好的集合中呢?
麻雀:可以这样,使用filter方法,找到code为null的元素,存储起来,姑且叫做nullCodeList;然后将nullCodeList添加到sortList集合中,这样就可以了。代码是这样:
// 先过滤得到编号不为空的元素,按编号排序
List<Student> sortList = studentList.stream()
.filter(item -> Objects.nonNull(item.getCode()))
.sorted(Comparator.comparing(Student::getCode))
.collect(Collectors.toList());
logger.info("大小 = {}; sortList = {}", sortList.size(), sortList);
// 过滤得到编号为空的元素
List<Student> nullCodeList = studentList.stream()
.filter(item -> Objects.isNull(item.getCode()))
.sorted(Comparator.comparing(Student::getCode))
.collect(Collectors.toList());
logger.info("大小 = {}; nullCodeList = {}", nullCodeList.size(), nullCodeList);
List<Student> allStudentList = new ArrayList<>();
allStudentList.addAll(sortList);
allStudentList.addAll(nullCodeList);
logger.info("总大小 = {}; allStudentList = {}", allStudentList.size(), allStudentList);
大榜:好样的!其实我们刚刚讨论的将排序字段为null的元素,放在末尾,只需要一行代码就可以实现:
List<Student> totalList_code = studentList.stream()
// 按照编号升序,并将编号为null的元素放在列表的末尾
.sorted(Comparator.comparing(Student::getCode, Comparator.nullsLast(String::compareTo)))
// 输出到另一个集合
.toList();
麻雀:好厉害的样子,学到了。通过Comparator.nullsLast(String::compareTo)比较器,将编号为null的元素放在最末尾。
大榜:Lambda表达式的踩坑,还有一个踩坑点:List转成Map。看看下面的代码,会发生什么?
public static final Student LISI_Student = new Student("1002", "lisi", 26);
public static final Student WANGWU_Student = new Student("1003", "wangwu", null);
public static final Student ZHANGSAN_Student = new Student("1003", "zhangsan", 25);
/**
* collectors_toMap
* 当Key 值重复时,IllegalStateException 报错就拍你脸上 (Duplicate key 1003 (attempted merging values wangwu and zhangsan))
* @param studentList
*/
private static void collectors_toMap(List<Student> studentList) {
Map<String, String> codeNameMap = studentList.stream()
.collect(Collectors.toMap(Student::getCode, Student::getName));
logger.info("collectors_toMap = {}", codeNameMap);
}
麻雀:List转成Map,使用Collectors.toMap方法,就可以转成Map了。应该没有问题吧,这能有什么坑?
大榜:一般不会有问题。但如果传递的List中,存在相同的编号,就像上面的WANGWU_Student、ZHANGSAN_Student这两个元素,它们的编号都是"1003",上面的代码,执行时就会报错,结果啪的一下 IllegalStateException
报错就拍你脸上,你定睛一看怎么提示 Key
值重复。
麻雀:哦哦,重复的key,Collectors.toMap方法会报错,那确实是个坑。
大榜:思路也很简单。遇到相同的key,覆盖掉之前的key就可以了。想想代码该如何实现?
麻雀:我知道了,Collectors.toMap方法中增加覆盖掉之前的key的参数,代码像下面这样:
Map<String, String> codeNameMap = studentList.stream()
.collect(Collectors.toMap(Student::getCode, Student::getName, (oldData, newData) -> newData) );
大榜:是的,上面的写法可以解决key重复的问题。如果数据是下面这样,也就是code重复、name为null的场景,会发生什么问题呢?
public static final Student LISI_Student = new Student("1002", "lisi", 26);
public static final Student WANGWU_Student = new Student("1003", "wangwu", 27);
public static final Student ZHANGSAN_Student = new Student("1003", null, 25);
麻雀:看着应该没有问题啊,还能有什么坑等着我?
大榜:执行时会报错,结果啪的一下 NullPointerException 又拍在你那笑容渐渐消失的脸上。
麻雀:我去,Collectors.toMap方法,不允许value为null啊。那怎么办呢?
大榜:有两种思路。一种是过滤掉name为null的元素;另一种思路是找到name为null的元素,将name为null修改为""。
麻雀:第二种思路,代码没有思路。对于第一种思路,使用filter方法,代码应该是这样的吧:
// 方法1:过滤得到 名称不为null的元素
Map<String, String> codeNameMap = studentList.stream().filter(item -> Objects.nonNull(item.getName()))
.collect(Collectors.toMap(Student::getCode, Student::getName, (oldData, newData) -> newData) );
大榜:对于第二种思路,笨方法可以是遍历集合,找到name为null的元素,将name为null修改为""。Lambda表达式的写法如下:
// 方法2:找到名称为null的元素,将null修改为"",解决value为null时,导致List转成Map的空指针异常问题
Map<String, String> codeNameMap = studentList.stream()
.collect(Collectors.toMap(
Student::getCode,
item -> Optional.ofNullable(item.getName()).orElse(""),
(oldData, newData) -> newData)
麻雀:大榜,这种写法,有Stream与
Optional,感觉代码好复杂的样子。可以使用for循环遍历List,将List转成Map吗?
大榜:当然可以,而且很简单、好维护。代码是下面这样:
Map<String, String> forListToMap = new HashMap<>();
studentList.forEach(item -> forListToMap.put(item.getCode(), item.getName()));
麻雀:是啊,还是for循环香,而且代码清晰易懂,果然是大道至简啊。这个Collectors.toMap()果然是个大坑啊,mmp
大榜:所以说,遇到List转Map,答应我别再用Collectors.toMap(),因为需要额外处理重复key、value为null的数据。
4、ThreadLocal存储当前请求用户信息的踩坑
大榜:咱们接着看看下一个踩坑:ThreadLocal存储当前请求用户信息。这是代码:
private static ExecutorService executorService = Executors.newFixedThreadPool(1);
@GetMapping("/map")
public Map<String, Object> getMap() {
System.out.println("test--------");
Map<String, Object> result = new HashMap<>();
result.put("flag", 0);
User user = UserThreadLocal.get();
//根据userId查询
logger.info("getMap线程号 = {}; 获取用户信息 = {}", Thread.currentThread().getName(), user);
executorService.execute(new Runnable() {
@Override
public void run() {
logger.info("run线程号 = {}", Thread.currentThread().getName());
User runUser = UserThreadLocal.get();
logger.info("run线程,无法通过UserThreadLocal来获取用户信息 = {}", runUser);
logger.info("run线程,通过入参来传递用户信息 = {}", user);
}
});
return result;
}
上面的代码,我们假设当前登录用户的信息,会在每次请求时存储到UserThreadLocal中。
此时,我们通过Postman触发一次http请求,登录拦截器会获取用户信息,存储在UserThreadLocal中,请求会执行到User user = UserThreadLocal.get(); 显然是可以获取到用户信息的。我们在getMap()方法中,开启了一个新线程B,那么这个新线程B,可以通过UserThreadLocal.get(); 获取到用户信息吗?
executorService.execute(new Runnable() {
@Override
public void run() {
logger.info("run线程号 = {}", Thread.currentThread().getName());
User runUser = UserThreadLocal.get();
logger.info("run线程,无法通过UserThreadLocal来获取用户信息 = {}", runUser);
}
});
麻雀:UserThreadLocal获取用户信息,既然存储了用户信息,线程B应该可以拿到用户信息吧,这能有什么坑,没搞明白?
大榜:线程B拿不到用户信息。为什么呢?因为获取用户信息,一般都是通过拦截器(线程A),拦截用户请求,获取请求头中的token信息,解析得到用户信息,然后存放在ThreadLocal中。这样本次请求到MVC层(线程A),开发人员可以通过ThreadLocal.get直接获取,但是这有个前提,就是必须是当前请求所在的线程。如果我们在本地请求中,开启新线程B,在新线程中通过ThreadLocal来获取用户信息,是获取不到的,因为ThreadLocal中存储的是拦截请求线程的用户信息,并没有存储新线程B的用户信息。
麻雀:那如果,业务上新线程B中必须使用用户信息,该怎么办呢?
大榜:刚刚讲了,通过ThreadLocal显然是不行了。我们可以这样实现:在线程A中通过ThreadLocal.get获取用户信息,然后将用户信息传递给新线程B。这种方式,是不是实现了线程B中获取用户信息了。
麻雀:总结一下,就是说,ThreadLocal存储的是线程变量,可以理解为ThreadLocal的键是线程号,值是用户信息。因为刚刚是拦截器(线程A)将用户信息存储到ThreadLocal中,线程B通过ThreadLocal.get方法显然是获取不到的。如果线程B想要使用用户信息,那只能拜托线程A去传递用户信息过来了。
5、字符串比较
大榜:理解得很到位!咱们看看下一个踩坑:字符串比较。这是代码:
public static void main(String[] args) {
// strCompareTo("A", "a");
strCompareTo("11", "a");
// strCompareTo("1", "2");
// strCompareTo("11", "2");
}
private static void strCompareTo(String str1, String str2) {
int compareTo = str1.compareTo(str2);
if (compareTo < 0) {
logger.info("str1 = {} 小于 str2 = {}", str1, str2);
} else if (compareTo > 0) {
logger.info("str1 = {} 大于 str2 = {}", str1, str2);
} else {
logger.info("str1 = {} 等于 str2 = {}", str1, str2);
}
}
两个字符串的比较,strCompareTo("A", "B"); 显然第一个字符串小于第二个字符串。如果是strCompareTo("A", "a"); 结果是什么样的呢?
麻雀:我记得字符A的ASCII值为65,字符a的ASCII值为97,所以"A"是小于"a"的。
大榜:你说得很对。两个字符串比较大小,本质上是依次比较字符串中每个字符的大小。strCompareTo("11", "a");的结果是多少呢?
麻雀:字符串"11"是由两个字符1组成,字符1的ASCII值是49,所以字符串"11"组成的ASCII值的字节数组为[49,49]。"a"的是由一个字符组成,其组成的字节数组为[97]。先比较第一个字符,显然49小于97,所以 字符串"11" 是小于字符串"a"。
大榜:是对的。那我们来验证下,程序中字符串"11" 的字节数组:[49,49]。 字符串"a"的字节数组[97],堆栈图是下面这样:
如果是 strCompareTo("11", "2"); 结果如何呢?
麻雀:哎哟,这个容易搞错啊,按照上面教的方法(依次比较字符串中每个字符的大小),显然是 字符串"11" 小于 字符串"2"。但按照以前的想法,我会错误地判断 字符串 "11" 大于 字符串"2",因为 11 大于 2 嘛。
大榜:哈哈哈,是的,这一块我之前踩坑过。对于整数来说,11肯定大于2;但对于字符串比较来说,因为是依次比较字符串中每个字符的大小,显然是 字符串"11" 小于 字符串"2"。
6、其他
开发规范约束
JPA自动建表,字符串长度默认为255。有些业务场景,字段长度超过了255,会导致插入数据库异常,需要出数据库设计文档来明确字段存储大小,通过数据库文档,来规范和约束开发人员的行为。
排查踩坑
用单张表去思考问题,然后逐一进行关联,减少复杂度。
排查问题时,可以画出调用链路图,理清思路,辅助排查问题。(比如:生产者-》队列-》消费者)