首页 > 技术文章 > 怎样让你的代码更好地被 JVM JIT Inlining

nizuimeiabc1 2021-05-16 15:13 原文

JVM JIT编译器优化技术有近100中,其中最最重要的方式就是内联(inlining)。方法内联可以省掉方法栈帧的创建,方法内联还使让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的

为Inlining减少方法字节码

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

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

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

private final class NioMessageUnsafe extends AbstractNioUnsafe {
	...
 
    @Override
    public void read() {
        assert eventLoop().inEventLoop();
        final SelectionKey key = selectionKey();
        if (!config().isAutoRead()) {
            int interestOps = key.interestOps();
            if ((interestOps & readInterestOp) != 0) {
                // only remove readInterestOp if needed
                key.interestOps(interestOps & ~readInterestOp);
            }
        }
 
        final ChannelConfig config = config();
        final int maxMessagesPerRead = config.getMaxMessagesPerRead();
        final boolean autoRead = config.isAutoRead();
        final ChannelPipeline pipeline = pipeline();
        boolean closed = false;
        Throwable exception = null;
        try {
            for (;;) {
                int localRead = doReadMessages(readBuf);
                if (localRead == 0) {
                    break;
                }
                if (localRead < 0) {
                    closed = true;
                    break;
                }
 
                if (readBuf.size() >= maxMessagesPerRead | !autoRead) {
                    break;
                }
            }
        } catch (Throwable t) {
            exception = t;
        }
 
        int size = readBuf.size();
        for (int i = 0; i < size; i ++) {
            pipeline.fireChannelRead(readBuf.get(i));
        }
        readBuf.clear();
        pipeline.fireChannelReadComplete();
 
        if (exception != null) {
            if (exception instanceof IOException) {
                // ServerChannel should not be closed even on IOException because it can often continue
                // accepting incoming connections. (e.g. too many open files)
                closed = !(AbstractNioMessageChannel.this instanceof ServerChannel);
            }
 
            pipeline.fireExceptionCaught(exception);
        }
 
        if (closed) {
            if (isOpen()) {
                close(voidPromise());
            }
        }
    }
    ...
}

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

private final class NioMessageUnsafe extends AbstractNioUnsafe {
    ...
 
    private void removeReadOp() {
        SelectionKey key = selectionKey();
        int interestOps = key.interestOps();
        if ((interestOps & readInterestOp) != 0) {
            // only remove readInterestOp if needed
            key.interestOps(interestOps & ~readInterestOp);
        }
    }
 
    @Override
    public void read() {
        assert eventLoop().inEventLoop();
        if (!config().isAutoRead()) {
            removeReadOp();
        }
 
        final ChannelConfig config = config();
        ...
    }
    ...
}

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

方法2.移除无用的代码

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

public static String parseTrimedString(byte[] bytes, int begin, int length, Charset charset, boolean decodeEntityReference) throws ParseException {
    if (length == 0) {
        return null;
    }
 
    int last = begin + length;
    for (; begin < last; begin++) { // forward to find a valid char
        if (!ByteUtils.isWhiteSpaceOrNewLine(bytes[begin])) {
            break;
        }
    }
    for (last = last - 1; last > begin; last--) { // backward to find a valid char
        if (!ByteUtils.isWhiteSpaceOrNewLine(bytes[last])) {
            break;
        }
    }
    return parseString(bytes, begin, last - begin + 1, charset, decodeEntityReference);
}
 
public static String parseString(byte[] bytes, int begin, int length, Charset charset, boolean decodeEntityReference) throws ParseException {
    if (length == 0) {
        return null;
    }
        // .....
 
}

在方法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优化提供了更多可能)。

通过抽取重复的代码来减少字节码,这种方式的效果是显而易见的。这里终点讲解一下,相同的幂等的表达式抽取为临时变量前后的字节码大小差异,看下面的代码:

if (ByteUtils.isValidTokenChar(docBytes[cursor])) {// next attributeName
    return ATTRIBUTE_NAME;
} else if (docBytes[cursor] == '>') { // the start tag
    moveCursor(1);
    return processAfterStartTag();
} else if (docBytes[cursor] == '/') {// found end tag
    moveCursor(1);
    return END_TAG_WITHOUT_TEXT;
} else {
    throw ParseException.formatError("should be space or '>' or '/>' or another attribute here", this);
}

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

aload_0 //取this
getfield #3 //取cursor
aload_0 //取this
getfield #4 //取docBytes属性
bload // 取数组下标对应的元素值

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

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

先看代码:

i=i+2;

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

iload_2 // 加载栈帧地址为2的变量的值,此处为变量i
iconst_2 // 加载数值2
iadd // 相加
istore_2 // 把相加的结果赋值给i

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

i+=2;

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

iinc 2,2 // 第一个参数为变量在栈帧的位置,第二个参数为数值2</span>

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

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

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

int x = 1;
        int sum = 0;
	if(x > 0){
		sum = 1;
	}else{
		sum = 0;
	}

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

iconst_1 // 数值1
istore_1 // 把1存到偏移量为1的临时变量中,即变量x中
iconst_0 // 数值0
istore_2 // 把0存到偏移量为2的临时变量中,即变量sum中
iload_1 // 取x的值
ifle 15    // 把x与0比较,如果成功跳转到15行字节码
iconst_1 // 数值1
istore_2 // 把数值1存到偏移量为2的临时变量中,即变量sum中
goto 17 // 跳转到17行字节码
iconst_0 // 数值0
istore_2 // 把数值0存到变量sum

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

int x = 1;
        int sum;
	if(x > 0){
		sum = 1;
	}else{
		sum = 0;
	}

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

iconst_1 // 数值1
istore_1 // 把1存到偏移量为1的临时变量中,即变量x中
iload_1 // 取x的值
ifle 15    // 把x与0比较,如果成功跳转到15行字节码
iconst_1 // 数值1
istore_2 // 把数值1存到偏移量为2的临时变量中,即变量sum中
goto 17 // 跳转到17行字节码
iconst_0 // 数值0

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

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

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

public final static String toString(final byte[] bytes, int begin, int length) {
    StringBuilder sb = new StringBuilder(length);
    int last = begin + length;
    for(int i = begin; i < last; i++){
        sb.append((char)bytes[i]);
    }
    return sb.toString();
}

这个例子中,习惯性的使用临时变量i来作为for循环数组的下标,但是这个临时变量i真的有必要吗?

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

public final static String toString(final byte[] bytes, int begin, int length) {
    int last = begin + length;
    StringBuilder sb = new StringBuilder(length);
    for (; begin < last; begin++) {
        sb.append(bytes[begin]);
    }
    return sb.toString();
}

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

方法7. 减少传参个数

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

private int processAttributeName() throws ParseException {
    moveCursor(1); // the first char has been checked in previous event, so here just skip it
    for (; cursor < docBytesLength; moveCursor(1)) {// read tag bytes
        if (!ByteUtils.isValidTokenChar(docBytes[cursor])) {// this attribute name end
            currentBytesLength = cursor - currentIndex;
            skipUselessChar(); // skip ' ' and '\t' between attribute name and '='
            // read "=\"", '\'' should be ok
            if (docBytes[cursor] == '=') {
                moveCursor(1);
                skipUselessChar(); // skip ' ' and '\t' between '=' and attribute value
                if (docBytes[cursor] == '\"' || docBytes[cursor] == '\'') { // found the quotation at the beginning of attribute value
                    moveCursor(1); // move to the first byte in quotes
                    return ATTRIBUTE_VALUE; // found attribute value
                } else {
                    throw ParseException.formatError("need '\"' or '\'' here", this.getRow(), this.getColumn());
                }
            } else {
                throw ParseException.formatError("need '=' here", this.getRow(), this.getColumn());
            }
        }
    }
    throw ParseException.documentEndUnexpected(this.getRow(), this.getColumn());
}

这代代码中,可以看到有两处抛出异常,每次创建异常都需要传三个参数: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规范,我需要做如下的判断:

public static boolean isValidTokenChar(byte b) {
    return b > 0 && ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
        || b == ':' || b == '-' || b == '_' || b == '.');
}

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

0: iload_0         
 
 1: ifle            68   
 
 4: iload_0         
 
 5: bipush          97   
 
 7: if_icmplt       16   
 
10: iload_0         
 
11: bipush          122  
 
13: if_icmple       64   
 
16: iload_0         
 
17: bipush          65   
 
19: if_icmplt       28   
 
22: iload_0         
 
23: bipush          90   
 
25: if_icmple       64   
 
28: iload_0         
 
29: bipush          48   
 
31: if_icmplt       40   
 
34: iload_0         
 
35: bipush          57   
 
37: if_icmple       64   
 
40: iload_0         
 
41: bipush          58   
 
43: if_icmpeq       64   
 
46: iload_0         
 
47: bipush          45   
 
49: if_icmpeq       64   
 
52: iload_0         
 
53: bipush          95   
 
55: if_icmpeq       64   
 
58: iload_0         
 
59: bipush          46   
 
61: if_icmpne       68   
 
64: iconst_1        
 
65: goto            69   
 
68: iconst_0        
 
69: ireturn

上面的代码中冒号前的数组为当前行字节码的第一个字节为整个方法体字节码中的位置。可以看到这里面的有11个的if判断,而每次判断都需要加载if判断的两个操作数,第一个操作数为变量b,第二个操作数为常量(比如:“:”),由于代码太长,此处以b == ‘.’为例,其字节码对应58~61处,共7个字节,三行字节码。

由于这个方法相比较的字符串都在ASCII码0~128范围内,所以我这里可以把比较转换为数组下标的方式,数组下标为byte的数值,数组的元素值为当前下标是否是符合xml规范的字符,修改后的代码如下:

public final static byte[] byteType = {
            0, // 0
            0, 0, 0, 0, 0, 0, 0, 0, // 1~8
            2, // 9: '\t'
            2, // 10: '\n'
            0, 0, // 11~12
            2, // 13: '\r'
            0, 0, 0, 0, 0, 0, 0, // 14~ 20
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 21~30
            0, // 31
            2, // 32: ' '
            0, 0, 0, 0, 0, 0, 0, 0, // 33~40
            0, 0, 0, 0, // 41~44
            1, // 45: '-'
            1, // 46: '.'
            0, // 47
            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 48~57: '0'~'9'
            1, // 58: ':'
            0, 0, 0, 0, 0, 0, // 59~64
            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 65~90: 'A'~'Z'
            0, 0, 0, 0, // 91~94
            1, // 95: '_'
            0, // 96
            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 97~122: 'a'~'z'
    };
 
    public static boolean isValidTokenChar(byte b) {
        // to check validChars
        return b >= 0 && b <= 122 && (byteType[b] & 1) > 0;
    }

可以看到方法变短来很多,字节码也减少了很多,执行速度也快很多。当然Java中原始类型比较适合食用数组的方式解决这种众多if判断的问题,如果时对象的话,也可以用Map的方式。

方法9. 必要时把JDK中的类重写成简单的版本

一般而言JDK中的API都是久经考验的、非常完备的、通用的、众多高手智慧的结晶,但是也正是因为它的完备性和通用性,就导致其必然的损失了针对性。当优化到了一定程度后,就需要针对数据场景或者业务场景来进行针对性的优化来,举个例子:给一个基本排好序的数组进行排序时,冒泡比快速排序要快得多,因为这种数组最适合冒泡排序(O(1)时间复杂度),而快速排序时是O(nlog(n))。

看一个实际案例,下面的代码在fastxml的性能测试输出的编译和内联的log中看到StringBuilder的append方法太大了,导致无法内联到下面的方法中。

public final static String toString(final byte[] bytes, int begin, int length) {
    int last = begin + length;
    StringBuilder sb = new StringBuilder(length);
    for (; begin < last; begin++) {
        sb.append(bytes[begin]);
    }
    return sb.toString();
}

看看StringBuilder的append方法内容是什么样子:

public StringBuilder append(char c) {
    super.append(c);
    return this;
}
public AbstractStringBuilder append(char c) {
    ensureCapacityInternal(count + 1);
    value[count++] = c;
    return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
    // overflow-conscious code
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}
void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    if (newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    if (newCapacity < 0) {
        if (minimumCapacity < 0) // overflow
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    value = Arrays.copyOf(value, newCapacity);
}

可以看到append方法一层层调用了几层方法,每个方法不算太大,用JITWatch可以看到优化建议:

The call at bytecode 2 to

Class: java.lang.AbstractStringBuilder

Member: public AbstractStringBuilder append(String)

was not inlined for reason: ‘callee is too large’

The callee method is greater than the max inlining size at the C1 compiler level.

Invocations: 1031

Size of callee bytecode: 50

可以看到public AbstractStringBuilder append(String)这个方法稍大了一点,导致没有内联。这会导致多出一次方法栈,而这个方法可能在程序运行中调用频率很高,如果这个方法不能inlining,势必降低了性能。jdk的源码我们改不了,那我们可以选择不使用jdk的类,而是根据自己的实际场景写一个更简单的类,比如下面这个:

public final class FastStringBuilder {
    private char[] chars; // char array holder
    private int last = 0; // last index to append a byte or a char
 
    public FastStringBuilder(int length) {
        this.chars = new char[length];
    }
 
    public void append(byte b) {
        chars[last] = (char) b;
        last++;
    }
 
    public void append(char c) {
        chars[last] = c;
        last++;
    }
 
    public int length() {
        return last;
    }
 
    public String toString() {
        return new String(chars, 0, last);
    }
}

这个代码把安全检查都移除了,那么冗长繁杂的校验和数组扩容都直接移除了,代码变得简洁高效。这么做不会出错么?当然不会,因为在fastxml中,调用方已经保证了其不会出现数组越界,而且保证了初始长度length就是最大长度,所以多一层的安全检查是没有必要的,也没有必要考虑去扩容和复制数组了。在fastxml中,使用这个简化后的代码,使性能提升了10%。

总结

上面这9种方法都是在使用了JITWatch后,根据其提示绞尽脑汁想到的一些办法,通过这些优化,fastxml的性能提升了近1倍,现在fastxml的性能已经是XPP3的1倍左右。优化方法还有很多,针对不同的场景,会有很多不可思议的优化方法,这需要不断的挖掘。欢迎指正。

最后引用大牛Donald Knuth的一句话:

过早的优化是万恶之本

原创文章:怎样让你的代码更好的被JVM JIT Inlining,转载请注明:转载自戎码一生

推荐阅读