likes
comments
collection
share

由两个int值引发的思考

作者站长头像
站长
· 阅读数 52

背景

在日常使用Java语言调用操作系统提供的API的时候,我们会遇到非常多与以下示例类似的情况:

int epoll_wait(int epfd , struct epoll_event * events ,
                      int maxevents , int timeout ); 

比如在使用epoll时,epoll的返回值并不是一个实际的结果,只是代表该调用是否成功,实际的方法返回值会被写入到events指针所指向的位置

如果我们需要将epoll_wait()函数封装成一个Java函数,我们可以选择这样两种做法:

  1. 原封不动的实现epoll_wait()函数,把events结构体替换成对应的Java类对象,然后修改其成员变量,调用方再通过读取该成员变量获得返回值
  2. epoll_wait()进行一层封装,将events参数中所需的变量提取出来,作为返回值交付给调用方

通常来讲,我们会更倾向于使用第二种方式,也就是手动的封装一层所需的返回值,因为这样代码看起来会更直观,即使没有文档也可以从方法签名和变量命名中很清晰的看出来我们这个函数是要干什么,而不会像epoll_wait()一样,要去man一下才知道各个参数是什么含义,应该怎么用。

鉴于epoll在网络编程中需要返回的参数只有两个,一个代表当前触发事件的socket,一个代表当前触发的事件类型eventType,我们可以很自然的构建出一个以下的类作为返回值:


public record IntPair(int socket, int eventType) {

}

疑点

返回IntPair是一个非常高频的操作,每一次进行网络数据读写时都会触发,它需要我们每次都构建一个全新的Java类对象,核心原因是因为Java中目前还不允许多返回值的语法存在,值类型也还没有完全开发完,那么我们有没有办法对这个方法进行一下简单的优化呢?

很明显,两个32位的int值,可以被直接拼接成一个64位的long类型,那么我们只需要手动的拆分,不就可以用值类型实现这种方式了吗?

基于此思想,我们可以实现一个如下的demo


long l = ((long) event << 32) | (eventType & 0xFFFFFFFFL);

int socket = (int) (l >> 32);
int eventType = (int) l;

通过这种手动的拆分,我们完成了对于将需要返回两个int值的场景,不使用对象分配,用类似于值类型的布局,完成传参的形式。

验证

我们可以写一个简单的JMH测试来验证实验的结果,这里推荐大家在只要是与性能相关的测试场景下,统一使用JMH框架进行验证:


public class IntPairTest extends JmhTest {
    @Param({"10", "100", "1000"})
    private int size;

    @Benchmark
    public void testIntPair(Blackhole bh) {
        for(int i = 0; i < size; i++) {
            IntPair intPair = new IntPair(i, i);
            bh.consume(intPair);
            bh.consume(intPair.socket());
            bh.consume(intPair.eventType());
        }
    }

    @Benchmark
    public void testLong(Blackhole bh) {
        for(int i = 0; i < size; i++) {
            long l = ((long) i << 32) | (i & 0xFFFFFFFFL);
            bh.consume(l);
            bh.consume((int) (l >> 32));
            bh.consume((int) l);
        }
    }

    public static void main(String[] args) throws RunnerException {
        runTest(IntPairTest.class);
    }
}

注意,在方法体中,我们一定要使用Blackhole.consume()来吃掉对应的变量,防止JVM认为我们没有用到该变量,然后将其直接的优化掉,这样得出的测试结果是不准确的。

在一个项目中会编写较多JMH测试时,我们可以统一的构建一个抽象类基类,在其中预置一些测试参数,JMH的注解均为可继承的,通过这种方式,我们可以让JMH测试用例的编写体验和JUnit近似:


@BenchmarkMode(value = Mode.AverageTime)
@Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public abstract class JmhTest {
    public static void runTest(Class<?> launchClass) throws RunnerException {
        Options options = new OptionsBuilder().include(launchClass.getSimpleName()).build();
        new Runner(options).run();
    }
}

测试类只需要继承JmhTest,然后在main()方法中调用runTest()方法即可完成测试用例。

让我们来看看测试得到的结果:


IntPairTest.testIntPair              10  avgt   50    12.034 ± 0.032  ns/op
IntPairTest.testIntPair             100  avgt   50   121.573 ± 0.389  ns/op
IntPairTest.testIntPair            1000  avgt   50  1208.208 ± 5.075  ns/op
IntPairTest.testLong                 10  avgt   50     2.821 ± 0.004  ns/op
IntPairTest.testLong                100  avgt   50    34.471 ± 0.050  ns/op
IntPairTest.testLong               1000  avgt   50   309.219 ± 0.684  ns/op

可以看到,相比使用IntPair而言,直接使用long可以获得接近4倍的提升。

不要觉得你比编译器更聪明

当然,这肯定不是结束,实际上如果我们能这么简单的用long来代替IntPair完成任务,那么Java的编译器应该也可以,并且应该能比我们做的更好,让我们再添加一组测试,更贴合实际函数调用的过程:


@Benchmark
public void testFunctionIntPair(Blackhole bh) {
    for(int i = 0; i < size; i++) {
        IntPair intPair = createIntPair(i);
        bh.consume(intPair.socket());
        bh.consume(intPair.eventType());
    }
}


private IntPair createIntPair(int i) {
    // 模拟实际函数调用过程
    return new IntPair(i, i);
}

得到的结果就完全不一样了:


IntPairTest.testFunctionIntPair      10  avgt   50     2.513 ± 0.011  ns/op
IntPairTest.testFunctionIntPair     100  avgt   50    16.912 ± 0.026  ns/op
IntPairTest.testFunctionIntPair    1000  avgt   50   166.353 ± 0.213  ns/op

可以看到,模拟实际函数调用,通过返回值获取参数的性能,比起使用long又提升了不少,那么这是怎么做到的呢?实际上,IntPair这个对象在这种函数传参的形式中,可能根本就不会被创建,而是被编译器的逃逸分析直接给优化掉,也就是用IntPair作为返回值,和直接返回两个int,效率上应该是完全一致的,比起用long来转换而言反而更快。

我们可以再写一个简单的测试来验证这个猜想:


@Benchmark
public void testNoConsumeIntPair(Blackhole bh) {
    for(int i = 0; i < size; i++) {
        bh.consume(i);
        bh.consume(i);
    }
}

得到的结果是:


IntPairTest.testNoConsumeIntPair      10  avgt   50     2.582 ± 0.067  ns/op
IntPairTest.testNoConsumeIntPair     100  avgt   50    17.119 ± 0.062  ns/op
IntPairTest.testNoConsumeIntPair    1000  avgt   50   167.541 ± 0.784  ns/op

直接通过Blackhole.consume()吃掉int值和创建IntPair然后提取成员变量的性能是一模一样的,我们可以直接认为,使用IntPair就是使用了多返回值。

结论

这个简单的实验充分的说明了一个问题:不要去过度优化代码逻辑,更不要觉得自己比编译器更聪明,如果你能很简单的想到一个优化的方案,那么编译器也很容易为你实现,并且比你自己实现的更好。在开发的过程中,直接按照直觉进行编码即可,遇到不确定的地方,多运用JMH测试来验证自己的想法。

转载自:https://juejin.cn/post/7322229620391608372
评论
请登录