又写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的数据就不符预测值了。
总结
那么现在我们来总结一下这里的流程:
- 增强的for循环会解释成hasNext()和next()进行迭代遍历;
- next()当中的get()方法其实是进行list元素列表的数据截取,可理解为分页;
- get()方法会进行数组越界判断,当list数据删减的时候,size()方法的返回值不是固定值。
- 每一次迭代都会将游标cursor进行+1,当list数据删减的时候,调用get()进行数组越界判断就会发生IndexOutOfBoundsException异常,继而在next()方法当中抛出NoSuchElementException异常。
解决
解决的思路无非是两点:
- 控制业务调用层面禁止对原数据列表的删除。(但是由于业务场景的复杂性,这点比较难以控制,所以不太推荐)
- 在这种场景下不再使用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}{点个赞},再次感谢大家的支持!
🏆 掘金技术征文-双节特别篇