一 数组长度为负
历时两年半,项目终于成功上线(👏,现阶段还没有对外发布项目上线的新闻公告,大家再等两天)。所以很久一段时间我都没有写学习笔记了,今天在家办公,恰巧昨天夜里在给应用组做技术支持的时候发现了一个较为严重的bug,所以今天我把它记录下来。
问题发生在一笔外发交易,核心服务接收外围系统的应答报文时,报出了数组长度为负数的异常:
java.lang.NegativeArraySizeException
二 排查过程
显然这是通信层为应答报文分配字节数组的时候,计算数组长度错误导致的。咨询业务组同事得知此类外发交易是通过Socket同步短链接进行通信的,而常规情况下通信接收方会将报文的接收过程拆成两段:
- 第一段接收固定(双方约定好的)长度一小段报文,这很小的一段报文内容会告知接收方后续还有多少字节长度的报文内容需要接收(这一段报文我们常称之为报文头,仅用以描述完整报文的长度信息);
- 第二段循环接收应答报文,只要没有接收到报文头告知的报文长度,就阻塞读,直到接收了完整的报文为止。
那么很有可能是外围系统应答的报文头出现了错误,它描述一个错误的报文长度,导致接收方计算错误,进而出现数组长度负数的情况。所以当机立断,联系对端系统请支持人员提供应答报文的通信日志:
...1A 18 00 00 DE 73...
上面就是应答给接收方的报文头内容,其中DE73表示报文体的长度,哇塞好大……(😏什么大?)注意咯,一般通信报文都会采用16进制的方式来记录,DE73转换为10进制后是56947。
核对了核心的通信日志,发现也是DE73,外围系统应答报文错误的可能排除!
第二种可能是通信缓存区分配小了,为了提高JVM内存使用率,并且减少因频繁通信产生的内存碎片,应用系统一般会在服务启动时就预先申请出一块连续的内存空间,并划分出若干个通信缓存块,如果应答报文长度超过了缓存块大小,会被截断,……等等,这似乎跟负数没啥关系,阿西吧,我太紧张了。不管咋说还是看一眼吧,生产上每个缓存块将近300KB,这足以满足最大长度的收发报文了,DE73也不过55KB左右。
那么最后只有一种可能了,根据报文头计算报文长度这里出了问题,赶紧写一个单元测试函数,就以DE73作为长度来看看函数返回:
int length = Byte2Int(new byte[]{0, 0, -34, 115});
果然不出所料,length是-8589!
三 数据存储
先解释下为什么测试函数中我用的byte数组是{0, 0, -34, 115},这个值转换成int就是56947,用十六进制表示就是DE73。
进制转换问题暂不讨论,那么先说说Java中数据怎么存的,Java里所有的数据都是以2进制补码的方式存储的。int类型占用4个字节,每个字节8个比特,其中符号位占一位。
这也是为什么需要四个元素的字节数组来描述一个int值的原因,结下来我们看看56947用2进制表示应该是多少:
00000000 00000000 11011110 01110011
注意咯,把56947转换为2进制表示就是1101111001110011,将其存储在int中,分4个字节,高位不足的补零,就变成了上面的样子。接下来我们要把它变成Java能接受的存储格式即补码存储,那么就会变成下面的样子:
00000000 00000000 11011110 01110011
00000000 00000000 10100010 01110011
0 0 -34 115
懂了吗?大家需要记住以下几点:
- Java数据存储格式为2进制补码
- 正数反码为原码
- 正数补码为原码
- 负数反码为符号位以外位数取反
- 负数补码为反码+1
四 码制转换
接下来介绍另一个概念——模数,就是“%”模运算,实际上就是余数,比如说3%2=1,商为1,余数为1,余数就是模数。一个非常经典的案例就是手表,比如说我手表放了两天了没上转(机械表,我弟弟送我的,😁),显示时间是9点,实际上现在是11点,那么我有两种办法调整时针,要么向前拨2小时,要么向后拨10小时。
无论向前、向后拨都可以的原因就在于2和10相对于12互为补数,它们加起来一定是12,正好覆盖表盘一圈。
最后介绍下模数运算规则:
A - B = A + (-B) = A + (B补)
可以看出来上面的公式将一个减法运算最后转换为了加法运算,那么一个负数就可以用一个正数的补码来表示,如此可以统一运算过程全部采用加法。这和处理器也是息息相关的,因为处理器中有一个加法器,本质上软件程序的数据运算是由处理器的加法器完成的,那么必须要统一运算方式。
Java采用补码存储和计算数据的第二个原因在于可以让符号位(左数第一位,正数0,负数1)直接参与运算过程,这在电路实现过程中节省了一个电路位(知道为什么软件工程专业必须要学习数学和电工电子了吧)。什么意思呢,举个例子1-2,首先将减法转为加法运算1+(-2):
00000001 >1(1的补码)
11111110 >-2(-2的补码)
11111111(结果的补码)
10000001 >-1(结果的原码)
可以看到每一位包括符号位都参与了加法运算过程。
五 进制转换
那么问题基本上就很明确了,查一下Byte2Int方法:
public static int Byte2Int(byte[] bytes) {
bytes[0] & 0xff << 24 | bytes[1] & 0xff << 16 | bytes[2] << 8 | bytes[3] & 0xff;
}
很明显第三位没有进行0xff的与运算,那么为什么每个字节都要和0xff进行与运算呢?
首先必须要清楚0xff是个啥东西,在16进制中F是15,2进制表示为11111111,也就是-1。
那么当一个int被拆成了4个byte来存储时,如果byte变成了负数,当它还原回int时,高位是要补1的,回到前文中的例子:
00000000 00000000 11011110 01110011
00000000 00000000 10100010 01110011
0 0 -34 115
-34如果不处理符号位的话,那么还原回int值就变成了:
11111111 11111111 11111111 10100010
怎么办?将-34和0xff进行与运算:
11111111 11111111 11111111 10100010
11111111 11111111 11111111 11111111
00000000 00000000 00000000 10100010
看到了吗?这就是和-1进行与运算的神奇之处,也即是说和0xff进行与运算是为了处理负数的场景,保证将byte还原会int时将高位置0。
接下来需要将各byte进行左移运算了,让所有byte归位到int结构中:
byte[0] | byte[1] | byte[2] | byte[3] |
---|---|---|---|
00000000 | 00000000 | 11011110 | 01110011 |
byte[0] & 0xff << 24 | 00000000 00000000 00000000 00000000 |
byte[1] & 0xff << 16 | 00000000 00000000 00000000 00000000 |
byte[2] & 0xff << 8 | 00000000 00000000 11011110 00000000 |
byte[3] & 0xff | 00000000 00000000 00000000 01110011 |
最后将四个byte值通过或运算得到最终的结果:
00000000 00000000 11011110 01110011
综上,调整逻辑如下(一定要注意加括号,保证运算符号优先级,我发现好多人包括写出这次bug的同事的编码习惯真的不好):
public static int Byte2Int(byte[] bytes) {
(bytes[0] & 0xff) << 24 | (bytes[1] & 0xff) << 16 | (bytes[2] & 0xff) << 8 | bytes[3] & 0xff;
}
六 结语
这是一次不大不小的事故,之所以一直没有在测试环境验证出问题,是因为常规情况下的通信报文没有这么大的长度,按照未修改前的逻辑,只要保证byte [2]的值不为负,也即是说报文长度不超过32767的话,也不会出现问题。
但是谁也无法保证通信报文都小于32767,还是要将逻辑处理的严谨些才好。
这里也想跟所有的Java道友们说一句,不停的学习各种开源框架只能是锦上添花,并不能给自己的能力带来实质性的提升,打好基础才是正途,万变不离其宗!
如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。