一次i++引发的bug

5,083 阅读7分钟

大家好,作为日常写bug修bug的我,今天给大家带来前几天刚刚修复的一个事故。不得不承认,有我的地方总是会有这么多bug。

起因

故事的开始发生在前几天,有一个不是很常用的导出功能,被用户反馈出,不管条件是怎么样,导出的数据只有一条,但是实际上根据条件查询是有很多数据,而且页面中也查询出很多数据。(这个问题已经被修复了,所以当时的Kibana日志也找不到了)于是放下手上的工作,投入其中来看这个问题。

分析

从问题的描述中来分析,那么只可能出现在以下情况:

  1. 根据搜索条件查询出来的记录只有一条。
  2. 对查询出来的数据进行相关业务处理,导致最后的结果只有一条。
  3. 文件导出组件的逻辑处理之后,导致结果只有一条。

题外话
写到了这里,突然想到了一个经典面试题,MQ消息丢失的场景原因分析。哈哈哈,其实大致上也是这么几个角度分析。(有机会来写MQ的文章)
题外话

于是就一个个来分析:

  1. 找到相关业务的SQL以及对应的参数,查询可得,数据不止1条,所以第一种情况可以排除。
  2. 中间业务当中有涉及到相关权限、数据敏感等,将这些都放开之后,还是只有1条数据。
  3. 文件导出组件在接收到数据的时候,打印出的日志也显示只有一条,那么可以说明肯定中间相关业务的逻辑发生了问题。

由于这段代码都是写在一整个方法里面,导致Arthas排查起来就比较困难,只好一步步设置日志进行排查。(所以,如果是一大段逻辑,建议是拆分成duoge 子方法,一来在写的时候思路明确,有一种模块化的概念,至于方法复用什么的我就不多提了,基本操作;二是一但发生问题,排查起来也会方便点,经验之谈)。
最终定位到一个for循环里面。

代码

话不多说,我们直接来看代码。众所周知,我向来是一个很保护公司代码的人,所以,我在这里又不得不给大家模拟一下了。从问题的情况来看,是导出的对象记录是空

import com.google.common.collect.Lists;
import java.util.List;

public class Test {

    public static void main(String[] args) {

        // 获取Customer数据,这里就简单模拟
        List<Customer> customerList = Lists.newArrayList(new Customer("Java"), new Customer("Showyool"), new Customer("Soga"));
        int index = 0;
        String[][] exportData = new String[customerList.size()][2];
        for (Customer customer : customerList) {
            exportData[index][0] = String.valueOf(index);
            exportData[index][1] = customer.getName();
            index = index++;
        }
        System.out.println(JSON.toJSONString(exportData));
    }
}

class Customer {

    public Customer(String name) {
        this.name = name;
    }

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

这段代码看起来好像也没什么的,就是将Customer集合转换成一个字符串二维数组。但是输出结果显示: 这就符合我们说的,查询出来有多条,但是输出只有1条。
仔细观察一下,我们可以发现,输出的数据显示都是最后一条,也就是说,Customer这个集合每次遍历的时候,都是后者将前者进行覆盖,也就是说,这个index的下标一直没有变化过,一直是0。

建模

这样看来,我们的这个自增确实有点问题,那么我们再简单来写一个模型

public class Test2 {

    public static void main(String[] args) {
        int index = 3;
        index = index++;
        System.out.println(index);
    }
    
}

我们将上面的业务逻辑简化成这样一个模型,那么这个结果毫无意外的是3。

解释

那么我们执行一下javap,看看JVM字节码是如何解释:

javap -c Test2

Compiled from "Test2.java"
public class com.showyool.blog_4.Test2 {
  public com.showyool.blog_4.Test2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_3
       1: istore_1
       2: iload_1
       3: iinc          1, 1
       6: istore_1
       7: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      10: iload_1
      11: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      14: return
}

这里我简单讲一下这里的JVM字节码指令(后面有机会再详细写写文章)
首先我们需要先知道这里存在两个概念,操作数栈和局部变量表,这两者是存在虚拟机栈当中栈帧(stack frame)当中的一些数据结构,如图:

我们可以简单的理解为,操作数栈的作用是存放数据并且在栈中进行计算数据,而局部变量表则是存放变量的一些信息。
然后我们来看看上面的指令:
0: iconst_3 (先将常量3压入栈)
1: istore_1 (出栈操作,将值赋给第一个参数,也就是将3赋值给index)
2: iload_1 (将第一个参数的值压入栈,也就是将3入栈,此时栈顶的值为3)
3: iinc 1, 1 (将第一个参数的值进行自增操作,那么此时index的值是4)
6: istore_1 (出栈操作,将值赋给第一个参数,也就是将3赋值给index)
也就是说index这个参数的值是经历了index->3->4->3,所以这样一轮操作之后,index又回到了一开始赋值的值。

延伸一下

这样一来,我们发现,问题其实出在最后一步,在进行运算之后,又将原先栈中记录的值重新赋给变量,覆盖掉了 如果我们这样写:

public class Test2 {

    public static void main(String[] args) {
        int index = 3;
        index++;
        System.out.println(index);
    }

}

Compiled from "Test2.java"
public class com.showyool.blog_4.Test2 {
  public com.showyool.blog_4.Test2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_3
       1: istore_1
       2: iinc          1, 1
       5: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: iload_1
       9: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      12: return
}

可以发现,这里就没有最后一步的istore_1,那么在iinc之后,index的值就变成我们预想的4。
还有一种情况,我们来看看:

public class Test2 {

    public static void main(String[] args) {
        int index = 3;
        index = index + 2;
        System.out.println(index);
    }

}

Compiled from "Test2.java"
public class com.showyool.blog_4.Test2 {
  public com.showyool.blog_4.Test2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_3
       1: istore_1
       2: iload_1
       3: iconst_2
       4: iadd
       5: istore_1
       6: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: iload_1
      10: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      13: return
}

0: iconst_3 (先将常量3压入栈)
1: istore_1 (出栈操作,将值赋给第一个参数,也就是将3赋值给index)
2: iload_1 (将第一个参数的值压入栈,也就是将3入栈,此时栈顶的值为3)
3: iconst_2 (将常量2压入栈, 此时栈顶的值为2,2在3之上)
4: iadd (将栈顶的两个数进行相加,并将结果压入栈。2+3=5,此时栈顶的值为5)
5: istore_1 (出栈操作,将值赋给第一个参数,也就是将5赋值给index)

看到这里各位观众老爷肯定会有这么一个疑惑,为什么这里的iadd加法操作之后,会影响栈里面的数据,而先前说的iinc不是在栈里面操作?好的吧,我们可以看看JVM虚拟机规范当中,它是这么描述的:

指令iinc对给定的局部变量做自增操作,这条指令是少数几个执行过程中完全不修改操作数栈的指令。它接收两个操作数: 第1个局部变量表的位置,第2个位累加数。比如常见的i++,就会产生这条指令

看到这里,我们知道,对于一般的加法操作之后复制没啥问题,但是使用i++之后,那么此时栈顶的数还是之前的旧值,如果此刻进行赋值就会回到原来的旧值,因为它并没有修改栈里面的数据。所以先前那个bug,只需要进行自增不赋值就可以修复了。

最后

感谢各位能够看到这里,以上就是我处理这个bug的全部过程。虽然这只是一个小bug,但是这一个小小的bug还是值得学习和思考的。今后还会继续分享我所发现的bug以及知识点,如果我的文章对你有所帮助,还希望各位大佬点个关注\color{red}{点个关注} 点个赞\color{red}{点个赞},再次感谢大家的支持!
这里也附上我的Github地址:github.com/showyool/ju…