由两个int值引发的思考
背景
在日常使用Java
语言调用操作系统提供的API
的时候,我们会遇到非常多与以下示例类似的情况:
int epoll_wait(int epfd , struct epoll_event * events ,
int maxevents , int timeout );
比如在使用epoll时,epoll的返回值并不是一个实际的结果,只是代表该调用是否成功,实际的方法返回值会被写入到events
指针所指向的位置
如果我们需要将epoll_wait()
函数封装成一个Java
函数,我们可以选择这样两种做法:
- 原封不动的实现
epoll_wait()
函数,把events
结构体替换成对应的Java
类对象,然后修改其成员变量,调用方再通过读取该成员变量获得返回值 - 对
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