Java日常开发的踩坑经验总结

161 阅读13分钟

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)

麻雀:大榜,这种写法,有StreamOptional,感觉代码好复杂的样子。可以使用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],堆栈图是下面这样:

image-20241124195753206.png

如果是 strCompareTo("11", "2"); 结果如何呢?

麻雀:哎哟,这个容易搞错啊,按照上面教的方法(依次比较字符串中每个字符的大小),显然是 字符串"11" 小于 字符串"2"。但按照以前的想法,我会错误地判断 字符串 "11" 大于 字符串"2",因为 11 大于 2 嘛。

大榜:哈哈哈,是的,这一块我之前踩坑过。对于整数来说,11肯定大于2;但对于字符串比较来说,因为是依次比较字符串中每个字符的大小,显然是 字符串"11" 小于 字符串"2"。

6、其他

开发规范约束

JPA自动建表,字符串长度默认为255。有些业务场景,字段长度超过了255,会导致插入数据库异常,需要出数据库设计文档来明确字段存储大小,通过数据库文档,来规范和约束开发人员的行为。

排查踩坑

用单张表去思考问题,然后逐一进行关联,减少复杂度。

排查问题时,可以画出调用链路图,理清思路,辅助排查问题。(比如:生产者-》队列-》消费者)