可能我这辈子也不会用Guava的Partition了|🏆 掘金技术征文-双节特别篇

4,047 阅读3分钟

又写BUG了

上回书说道,我写的《这可能是你从未见过如此优雅的写法》里面的方法是为了将非业务和业务逻辑进行解耦出来,于是周围小伙伴觉得不错也都开始使用起来,这颇让人感到很有成就感。于是大家都开始纷纷使用起来。既然话已经说到了这里,想必各位也已经知道接下来肯定是搞出了bug。

故事的开始

有一天,突然有个小伙伴说,有一个查询商品的dubbo接口报了NoSuchElementException的错误,然后在kibana的堆栈日志这边看到的是我之前提供的那个公共方法的一行,这就引起了我的注意 那么我们来看看这是在哪一行

常见的NoSuchElementException原因

NoSuchElementException这个错误是不是十分眼熟?没错,各位有心的话,在网上会搜到很多这种错误,无非就是在循环迭代中多次使用了next()的原因,例如:

List<String> list = new ArrayList<>();
list.add("wo");
list.add("ni");
list.add("ta");
System.out.println(list);
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
     iterator.next();
     System.out.println(iterator.next());
}

我们可以深入一下看看这个ArrayList里面的第862行(本人使用的JDK版本为1.8.181,不同版本之间的行数可能不一样) 可以看到这里在执行next()方法的时候,会进行游标数的检查。可以发现,在上面的例子中,由于每次for循环当中多执行了一次next(),使得每次循环的游标移动了两次,所以就会造成这个问题。具体的各位可以自己DEBUG看看。

回归主线

那么,这次的原因是不是也是这个地方发生的呢,由于公司政策要求,我这边就不方便贴出相关的业务代码(这可是要被开除的),但是这边也没有使用迭代器的循环写法,而是用了简单的增强for循环,也就不会出现像上面的多次调用next()的情况。
于是只好本地进行模拟:

public static void main(String[] args) {
    List<String> all = Lists.newArrayList("ki1");
    Map<String, Object> map = new CommonDoPartition<>().partitionToQuery4Map(500, all, outerIds -> returnMap(outerIds));
    System.out.println(JSON.toJSONString(map));
}

public static Map<String, Object> returnMap(List<String> all) {
    Map<String, Object> map = Maps.newHashMap();
    map.put("ki1", "ki1");
    return map;
}

运行之后发现,并没有报错。 这可真的是吓坏本宝宝了。 在那个出错的代码当中,也只是在lambda表达式中调用一个业务方法。其他的部分完全一致,那么根据控制变量法(当然,运行的机器、容器、环境等这些暂时先不考虑),是不是出问题的地方在于这个业务代码呢,于是拉着小伙伴看了下这里面的业务逻辑。果真,发现了一些端倪。在这里业务代码中,有对这个入参的all进行过删减,于是我也进行一波小小的测试:

public static void main(String[] args) {
    List<String> all = Lists.newArrayList("ki1");
    Map<String, Object> map = new CommonDoPartition<>().partitionToQuery4Map(500, all, outerIds -> returnMap(outerIds));
    System.out.println(JSON.toJSONString(map));
}

public static Map<String, Object> returnMap(List<String> all) {
    Map<String, Object> map = Maps.newHashMap();
    all.remove("ki1");
    map.put("ki1", "ki1");
    return map;
}

果然报了这个错误,这个和kibana上面的错误是一样的。 那么接下来我们来深入一下代码,为什么会发生这样的错误。

增强的for循环

增强的for循环在我们的日常书写中是十分常见的写法,当我们不关心list当中的序列而只是依次迭代遍历对象的时候,就会使用这种写法。当然这只是JVM层面将其包装成固定的写法罢了,在具体的解释上,依旧还是依据迭代器的流程。
例如这样一段简单的代码:

List<Integer> list = Lists.newArrayList(1,2,3);
for (Integer i : list) {
    System.out.println(i);
}

通过javap的解释之后,截取部分图片 可以看到,这里还是使用迭代器的写法去进行遍历,通过next()方法获取到对象。

继续回归主线

但是为什么要讲述上面这些东西呢? 我们来看下报错这边的信息,可以看到最后一行的堆栈是停留在

Exception in thread "main" java.util.NoSuchElementException
	at java.util.AbstractList$Itr.next(AbstractList.java:364)
	at com.example.demo.consume.CommonDoPartition.partitionToQuery4Map(CommonDoPartition.java:67)
	at com.example.demo.consume.CommonDoPartition.main(CommonDoPartition.java:78)

看看AbstractList的364行 可以看到,这里是因为报了IndexOutOfBoundsException的异常,然后抛出了NoSuchElementException异常。不同于常见的NoSuchElementException异常错误(示例1的代码),这里是使用了内部类当中的next()方法。
那么既然会报错这个错误,问题就在于这里面的get方法。这里的get方法是一个抽象类

    /**
     * {@inheritDoc}
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    abstract public E get(int index);

那么我们可以使用IDEA的Diagrams功能可以看一下继承关系 看一下get的实现

    @Override public List<T> get(int index) {
      checkElementIndex(index, size());// 进行安全检查
      int start = index * size;
      int end = Math.min(start + size, list.size());
      return list.subList(start, end);// 通过subList进行部分数据的截取
    }
    
    // 获取size大小,这里其实是向上取整
    @Override public int size() {
      return IntMath.divide(list.size(), size, RoundingMode.CEILING);
    }

那么造成IndexOutOfBoundsException异常也是由于checkElementIndex这个方法抛出来的

  public static int checkElementIndex(
      int index, int size, @Nullable String desc) {
    // Carefully optimized for execution by hotspot (explanatory comment above)
    if (index < 0 || index >= size) {
      throw new IndexOutOfBoundsException(badElementIndex(index, size, desc));
    }
    return index;
  }

看到这里是不是就突然明白了!Guava当中Partition的做法是通过一整个list的数据截取,其实也是一种分页的处理方式。那么倘若在后续的业务代码中对这一整个数据进行了删减,那么这里的size()的取值就不准了,造成整个get的数据就不符预测值了。

总结

那么现在我们来总结一下这里的流程:

  1. 增强的for循环会解释成hasNext()和next()进行迭代遍历;
  2. next()当中的get()方法其实是进行list元素列表的数据截取,可理解为分页;
  3. get()方法会进行数组越界判断,当list数据删减的时候,size()方法的返回值不是固定值。
  4. 每一次迭代都会将游标cursor进行+1,当list数据删减的时候,调用get()进行数组越界判断就会发生IndexOutOfBoundsException异常,继而在next()方法当中抛出NoSuchElementException异常。

解决

解决的思路无非是两点:

  1. 控制业务调用层面禁止对原数据列表的删除。(但是由于业务场景的复杂性,这点比较难以控制,所以不太推荐)
  2. 在这种场景下不再使用Guava的Partition,自己改写一份数据分隔的工具类。

可以发现,Guava的数据自始至终只有一份,也没有对原数据进行保护,就会造成下游业务对源数据进行破坏。那么也给了我们一个思路,那就是需要为原数据留下一个备份。所以我自己又写了一个工具类

import java.util.*;

public class SubListIterator<T> implements Iterator {

    /**
     * 原数据列表
     */
    private List<T> dataList;

    /**
     * 分隔单位
     */
    private int subSize;

    private volatile int nextIndex = 0;

    private int listSize;

    private List<List<T>> subLists = new ArrayList<>();

    public SubListIterator(List<T> dataList, int subSize){
        if(dataList != null){
            listSize = dataList.size();
        }

        this.dataList = dataList;
        this.subSize = subSize;

        initSubLists();
    }

    private void initSubLists(){
        if(listSize <= 0){
            return;
        }

        int index = 1;

        Iterator<T> iterator = dataList.iterator();

        List<T> subDataList = new ArrayList();
        while(iterator.hasNext()){
            T next = iterator.next();

            subDataList.add(next);
            if (index % subSize == 0 || listSize == index) {
                subLists.add(subDataList);
                subDataList = new ArrayList();
            }
            index++;
        }
    }


    @Override
    public boolean hasNext() {
        return subLists.size() > nextIndex;
    }

    @Override
    public Object next() {
        if(hasNext()) {
            return subLists.get(nextIndex++);
        }

        return null;
    }

    @Override
    public void remove() {
        new UnsupportedOperationException("不支持该操作");
    }

}

这里也附上我的Github地址:github.com/showyool/ju…

最后

感谢各位能够看到这里,以上就是我处理这个bug的全部过程。今后还会继续分享我所发现的bug以及知识点,如果我的文章对你有所帮助,还希望各位大佬\color{red}{点个关注}$$\color{red}{点个赞},再次感谢大家的支持!
🏆 掘金技术征文-双节特别篇