Integer、Long等自动拆箱类型为啥会抛NullPointerException

通常的NullPointerException异常其实是很好判断,一定是有某个对象是null导致的。但是java中数字都有自动装箱和拆箱功能,它简化了我们的代码,隐藏了其背后的执行步骤,导致程序抛出异常时我们会有意识的忽略了数字类型也可能会抛NullPointerException。 其实自动装箱和拆箱在字面上很容易使人对其理解的不准确或者不完整,大家都知道装箱就是从基础类型自动转换成对应的对象类型,拆箱就是从对象类型自动转换成基础类型;字面上很好理解,但是背后到底是如何实现的,其实有一种道不清说不明的感觉。自动装箱是不会抛异常的,但是自动拆箱则有可能会,所以本文从三种代码场景,以字节码的角度,还原自动拆箱背后的逻辑。

场景一:赋值时自动拆箱抛NullPointerException

上面的代码在把变量a赋值给b时就会抛出NullPointerException,使用javap Test.class命令查看其对应的字节码如下:

可以看到main方法行号为3的字节码调用了Long.longValue()方法,行号6的字节码才是把变量a赋值给b,显然由于a是null,所以调用longValue()方法自然会抛空指针了。

场景二:方法传参时自动拆箱抛NullPointerException

上面把变量a传参给方法increment时就会抛NullPointerException,还是用javap Test.class查看其字节码如下:

可以看到main方法行号为3的字节码调用了Long.longValue()方法,行号6的字节码才是真正的执行increment,显然由于a是null,所以调用longValue()方法也会抛空指针了。

场景三:用于大小比较时拆箱抛NullPointerException

同样用javap Test.class查看字节码内容如下:

同样的在行号8的字节码通过Long.longValue()方法把变量n1从Long转换成了long,然后行号8为加载变量n2,行号9才是比较两个long(注意不是比较Long)。

上面提到的字节码中的行号其实是字节码指令占用空间的偏移量,为了便于理解,暂以行号来理解

总结

拆箱的过程其实是调用了对应的XXX.xxxValue()方法,比如:Integer.intValue(), Long.longValue(), Short.shortValue(), Byte.byteValue(), Float.floatValue(), Double.doubleValue(),如果这个对象是null,那么就会抛出空指针异常。

怎样让你的代码更好的被JVM JIT Inlining

JVM JIT编译器优化技术有近100中,其中最最重要的方式就是内联(inlining)。方法内联可以省掉方法栈帧的创建,同时增加了CPU指令cache的命中率,方法内联还使让JIT编译器更多更深入的优化变成可能。本人在fastxml(速度比XPP3(基于xmlpull)还快的xml解析器)开源项目中针对方法内联进行了很多学习和实践,这里总结一下,介绍一下怎么让你的代码更好的被JVM JIT Inlining。

Inlining相关的启动参数

上一篇博客《Java JIT性能调优》中介绍了inlining相关的几个参数,这里copy下: jvm可以通过两个启动参数来控制字节码大小为多少的方法可以被内联:

  • -XX:MaxInlineSize:能被内联的方法的最大字节码大小,默认值为35B,这种方法不需要频繁的调用。比如:一般pojo类中的getter和setter方法,它们不是那种调用频率特别高的方法,但是它们的字节码大小非常短,这种方法会在执行后被内联。
  • -XX:FreqInlineSize:调用很频繁的方法能被内联的最大字节码大小,这个大小可以比MaxInlineSize大,默认值为325B(和平台有关,我的机器是64位mac)

可见,要想inlining就要让你的方法的字节码变得尽可能的小,默认情况下,你的方法要么小于35B(针对普通方法),要么小于325B(针对调用频率很高的方法)。

Inlining调优的工具

同样的上一篇博客《Java JIT性能调优》中也介绍了非常牛x的JIT优化工具JITWatch,该工具的详细文档请看这里:https://github.com/AdoptOpenJDK/jitwatch

为Inlining减少方法字节码

通过JITWatch中提示或者在启动命令中添加-XX:+PrintCompilation  -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining来输出提示信息,我们可以很快定位哪些方法需要优化,从而针对性的调优那些执行频率很高但没有inlining的方法,或者那些很小但不足以小到35Byte的方法。减少方法字节码的方法有很多,下面介绍我在fastxml项目中实践过的9种方法。

方法1. 把分支语句中的代码段抽取到方法中

我们的代码里面常常会有分支语句,比如:if-else、switch。但是我仔细想想,是不是所有的分支都有均等的执行机会,是不是很多时候,某些分支能进入的概率很小或者几乎没有。如果某个分支执行的概率很小,而且这个分支中的代码量不算小,那把这个分支中的代码抽取成一个方法就可以大大缩减当前这个方法的字节码大小。下面举一个著名NIO框架Netty中的一个例子,看优化前的代码:

代码中的config().isAutoRead()默认情况是false,也就是说,在没有专门配置的情况下,这个分支不会进去,而且一般人不会把这个配置为true,所以可以把这个if分支中的代码块抽取为方法,这样方法就变小了一些,而代码的功能没有减少。修改后的代码如下:

这样就很容易的把方法的字节码大小减少了一些,这样read()方法就可以inlining了。详见这篇文章:http://normanmaurer.me/blog_in_progress/2013/11/07/Inline-all-the-Things/

方法2.移除无用的代码

现在IDE(idea、eclipse等)功能很强大,往往能在开发阶段就能提示我们哪些代码是冗余无用的代码,请及时的删除那些无用代码,因为它们会占用方法的字节大小。此处我要说的不是这种IDE能提示我们的场景,而是隐含在我们编写的代码中的无用代码,比如下面的例子:

在方法parseTrimedString的开头处对length进行判断,乍一看,似乎没有什么不合理的,如果要处理的数组长度为0就直接返回null。逻辑上没有问题,IDE也不会提示这一段代码是无用的,但是仔细想想,要是length==0时,要是没有这个判断会怎么样呢? 第一个for循环中有begin<last的判断,避免了循环体中的bytes[begin]数组越界;同样的,第二个for循环里数组下标也被last>begin保护起来了,防止越界;再看看parseString()方法中正好有对length的判断,也就是说方法parseTrimString内部不再需要对length等于0的验证了。这一段代码可以移除。 类似这种非0、非null的判断在我们日常的代码中也是很常见的,其实可以通过一定的约定来减少这种判断,比如:按照约定保证每次方法的返回都不为null,若返回类型为String数组,则返回new String[0];若返回类型为Map<String, String>,则返回new HashMap<String, String>()。

方法3. 抽取重复的代码

很多时候,程序员因为懒惰不愿修改别人的代码,怕承担风险,而是有时候偏向于复制代码,这可能会导致一个方法体内部出现重复的表达式或者代码段。这时候应该把相同的幂等的表达式抽取为临时变量,把相同的代码段抽取为成员方法(不仅可以减少当前方法的字节码,还可能增加方法的复用率,为JIT优化提供了更多可能)。 通过抽取重复的代码来减少字节码,这种方式的效果是显而易见的。这里重点讲解一下,相同的幂等的表达式抽取为临时变量前后的字节码大小差异,看下面的代码:

这段代码中重复出现了docBytes[cursor],通常大家可能会认为这种数组取下标的操作没有几行字节码,应该不耗时,只有这种objA.getField1().getSubField2().getSubSubField3()的字节码多。其实不然,先看看仅docBytes[cursor]一句话的字节码有多少:

这里看出docBytes[cursor]对应9个字节的5行字节码,而这个表达式在上面的语句块中出现了3次,所以一共占用了9*3=27个字节。如果抽取成临时变量那么第二次和第三次使用临时变量即可,而使用临时变量仅占用1个字节,这样又可以减少近18字节。当一个方法里面重复出现的变量越多,优化效果就越明显。

方法4. 优化常量加减运算

先看代码:

上面的代码很简单,其中i=i+2这种写法也很常见,那么看看这行代码对应的字节码是什么样的:

这行代码已经很简单了,只占用4个字节,还有优化空间吗?当然有,我们还有一种常见的写法:

再看看这种写法的字节码:

这一行字节码占用3个字节的空间,而且只有一行字节码。这样一个微小的改动就可以节省1个字节,缩减3条字节码的指令条数。

方法5. 移除无用的初始化赋值

现在一些高级IDE也支持无用初始化的提示,比如:idea(eclipse还不支持这个功能)。如果IDE提示,请尽量移除这些无用的初始化,比如下面的例子:

显然对临时变量sum的初始化是没有作用的,上面的java代码产生的字节码有这些:

如果把对sum的初始化移除,java代码如下:

代码变化很小,改后的字节码如下:

通过对比可以看出移除对sum的初始化后, 字节码少了两行(2个字节)。有时候为了减少字节码就是需要一个字节一个字节的扣,没有办法。

方法6. 尽量复用临时变量

临时变量的初始化是会占用字节码的,减少不必要的临时变量无形之中也减少了临时变量的初始化。看下面的例子:

这个例子中,习惯性的使用临时变量i来作为for循环数组的下标,但是这个临时变量i真的有必要吗? 变量begin虽然是方法的参数,但是它也是这个方法的临时变量,而且它是java中的原始类型,改变begin的值不回对调用toString方法的调用方有任何影响,而且变量begin在方法中没有其他作用,很自然可以使用begin来代替i的下标作用。修改后的代码如下:

可以看到代码中少了一句:int i=begin; 这将减少2字节的2行字节码。 当你很需要这个方法被inlining时,每个字节的减少都来之不易。

方法7. 减少传参个数

有时我们写代码时,为了把方法的输入表达得更准确,会给出一个精确的参数列表,其中可能有多个参数来自相同的对象。比如下面的例子:

这代代码中,可以看到有两处抛出异常,每次创建异常都需要传三个参数:message、row、column。而上面的这个例子,很显然row和column都来自当前对象this,我在优化fastxml时,发现这个地方也可以优化一下,毕竟this.getRow()和this.getColumn()这两个表达式分别占用了4个字节,如果把formatError方法的参数减少为message、parser(即this),就可以减少7个字节的字节码,因为传this仅占用1个字节。

方法8. 把多个if判断转变成map的contain或者数组取下标操作

举个实际的案例,fastxml中为了解析xml中的标签名中的字符是否符合xml规范,我需要做如下的判断:

这一长串的条件判断其对应的字节码如下:

上面的代码中冒号前的数组为当前行字节码的第一个字节为整个方法体字节码中的位置。可以看到这里面的有11个的if判断,而每次判断都需要加载if判断的两个操作数,第一个操作数为变量b,第二个操作数为常量(比如:“:”),由于代码太长,此处以b == ‘.’为例,其字节码对应58~61处,共7个字节,三行字节码。 由于这个方法相比较的字符串都在ASCII码0~128范围内,所以我这里可以把比较转换为数组下标的方式,数组下标为byte的数值,数组的元素值为当前下标是否是符合xml规范的字符,修改后的代码如下: