4年前 (2020-11-07)  Java系列 |   抢沙发  638 
文章评分 0 次,平均分 0.0

Java反射并没有想象的慢

当阅读Java反射时,很难知道反射有多慢。也许我们大多数人都见过将反射与直接方法分派、lambdas等进行比较的基准测试,而反射通常损失惨重。然而,前几天,我遇到了这样一种情况:反思的表现远远超过了其他选择。

在过去的几个月里,我把大部分空闲时间都花在了游戏开发的脚本语言/解释器上。语言是动态类型化的,并编译成自定义字节码格式,由Java编写的解释器运行。解释器的最初设计使用了一个“catch all”基类,该基类依赖于重写的方法来处理与解释器的大多数交互(非常类似于Python处理运算符重载的方式)。这种设计的问题是,它异常冗长,导致了超密集、丑陋的代码,即使对于简单的操作也是如此。例如,下面是整数加法代码:

@Override
public CObject __plus__(CObject other){
	
	if(other instanceof CInt){
		return new CInt(intValue + ((CInt) other).intValue);
	}else if(other instanceof CFloat){
		return new CFloat((float)intValue + ((CFloat) other).floatValue);
	}else{
		throw new UnimplementedOperation(String.format("Undefined operation: cannot perform int + %s", other.getClass().getSimpleName()));
	}
}

这种设计有两个主要问题:

  • 代码的编写和维护都很糟糕。
  • 与Java代码的互操作是相当棘手的—您必须编写自己的包装器来向解释器公开Java对象,每个Java类一个包装器。

为了解决第二个问题,我计划写一个反射式的“自动包装器”,在我有机会这么做之前,我已经厌倦了写上面那些奇怪的混乱的代码,完全重写了解释器的内部结构。新设计使用反射将操作码分派给Java方法,取代了上面的基类重写设计。我担心这会对性能产生影响,当我重写时,我花时间用适当的JMH基准设置项目。我一直在使用一个简单的count to 100万循环来测试/评测运行时的核心基本功能:

var x = 0
while(x < 1000000){
    x = x + 1
}

果不其然,我那闪亮的新反射设计以惨淡的7秒完成了一次百万次基准测试的迭代。我知道它不是最佳的,因为我在查找每个操作码分派的方法对象,但我没想到它会那么糟糕。我立即添加了一个方法缓存来保存和重用分派之间的方法引用(查找只在第一次调用方法时完成一次)。我还通过呼叫禁用了访问检查方法集可访问(正确)。结果非常好——在我的主开发机器上运行基准测试大约需要0.27秒(在一台处理器更新、RAM时钟更快的机器上大约0.18秒)。使用VisualVM进行分析表明,每个操作码大约有1/3的时间用于解码和准备参数,2/3的时间花在Java的反射方法调用上,因此,我决定放手,继续进行项目中其他优先级更高的任务。

然而,前几天,我有了在新设计中重新引入旧样式基类的想法。从基类扩展的对象将通过虚拟方法调用静态地调度,检查其参数类型,并在不涉及反射的情况下执行操作。没有这种“优化”的对象将通过简单的反射调度。我投入了足够多的代码来使用这项技术作为上面的基准测试,并启动了JMH,因为我绕过了反射调用,所以期望性能得到适度的改进。

令我大吃一惊的是,反射版的基准测试运行速度快了20%。我的“优化”比JVM花费更多的时间检查参数类型和调用操作。事后看来,我不应该感到惊讶——无论何时从动态类型的参数到静态类型的方法调用,您都必须支付一些类型检查成本,而且JVM显然有一种比“if instanceof then cast”技术更优化的方法来做到这一点。

我的收获是:

像往常一样,先做一个好的设计,然后再优化那些不可接受的缓慢部分(在适当的基准测试/分析之后)。性能问题通常不是您认为的那样。

反射比静态方法分派慢,但是如果您必须动态绑定方法调用(invokedynamic/MethodHandles不是一个好的/适用的选项),那么它可能比手写的替代方法更快。换句话说,反射可以很快——相当快。

更多关于反射性能和成本的研究可以参考这篇文章:https://javakk.com/768.html

这在GC端是一个惊人的高成本。一旦我完成了我在文中提到的方法缓存优化,我就在VisualVM中运行它,以获得垃圾创建的感觉,我的简单的基准测试循环无情地击败了GC,因为我在每个操作码调度中创建了两个数组——一个是传递给方法的参数数组,一个是在调用前验证方法签名的参数类型数组。缓存和重用这些数组使解释器无垃圾。与创建新数组相比,缓存对性能没有任何影响(不希望这是可测量的),但是现在我有了一个很好的稳定和渐进的分配斜率,因为不可变整数对象是在基准测试的while循环中创建的。

在我的测试中,GC并没有引起任何性能问题,我只是重用了数组,不会使代码复杂化。它可能不会有显著的改进,但不会造成伤害,并且在字节码调度操作期间,它使解释器100%无垃圾。另外,虽然它在低规模上可能没有任何区别,但当部署时,我希望在一个服务器上同时运行大量的脚本(几十万个)。即使这样,垃圾数组可能根本就不是问题。

Java的GC非常棒,我期待着在操作规模上尝试G1。我总是咯咯笑当C++程序员谈论没有垃圾收集器的(假定)性能优势,然后愉快地推出(显著更高的开销)引用计数共享指针作为替代。

目前解释器不是为多线程使用而设计的,不是的。解释器现在实际上只封装了脚本的执行状态、内存和一些内部结构,比如方法缓存和参数数组。脚本语言本身没有线程的原生概念,如果我真的支持并发,我倾向于使用Erlang/Akka actors&消息传递模式(没有脚本可访问的共享内存),而不是原始线程/锁等。

从主机JVM的多线程使用应该使用相同的范例。

在这种情况下,“缓存”可能用词不当-我只是使用一组对象数组,这些数组存储为解释器类的实例成员。每次使用数组时,它都会被覆盖(除了位置0,它总是保存对解释器的引用,从不更改),它们只在一个地方写入,只在另一个地方读取。

我喜欢boolean标志的想法,但是随着核心调度循环现在编写完成,它需要一个主要的重构来支持(由于数组被每个字节码调度覆盖,我看不到这种重构的价值)。使用它们也很简单,因为它们只用于一种类型的操作码。

解释器有三种类型的操作码:

  1. 解释器状态操作码(堆栈推送、弹出等)。它们在字节码分派循环中立即处理,不进行任何调用(因此不接触调用数组)。
  2. “内部”(某种)操作码。这些就是参数数组的用途。像加法、减法等操作码就属于这一类。它们通过从堆栈中弹出一个目标和一个或多个参数来运行。参数被写入参数数组,该数组保存将传递给目标方法的确切参数集。字节码调度循环然后调用另一个内部方法,指定操作类型(addsubtract等)、要对目标对象调用的方法的名称(加法时为plus)和参数数组。该方法负责检索缓存的方法引用、检查参数类型以及对目标上的匹配方法执行最终调用。这使得操作符重载变得微不足道,而且由于总是传递对解释器的引用,因此目标对象很容易与解释器的内存跟踪系统协作。
  3. “外部”操作码-现在只有一个,它会导致对目标对象上任何名称的方法的普通反射调用。这就弥合了Java的方法约定和脚本语言的方法即对象约定之间的差距,因此从脚本语言中调用普通的Java对象很容易。

因为只使用opcode的数组类型2的参数,所以只使用opcode的类型。一旦调用这些数组,任何相关的单元测试都会很快被覆盖。

以下是dispatch循环和Integer运行时类中的片段来说明:

解释程序调度循环:

case ADD:
    rh = this.pop();
    lh = this.pop();
    internalParams[2][1] = rh;
    this.push(doInternal(InternalOp.ADD, lh, 2));
    ip++;
    break;

数组赋值行将加法表达式的右侧设置为2参数方法调用的参数(第一个参数是对解释器的引用,是在前面设置的)。InternalOp+该数组中对象的类型用于查找目标对象上的方法。

doInternal()方法签名:

private Object doInternal(InternalOp op, Object target, int paramCount)

我应该只传递数组而不是paramCount(用于查找正确的数组)-这可能是在我完全区分操作码类型2和3之前的一个产物)。

这个CInteger.java加法:

public CInteger plus(Interpreter vm, CInteger other){
    vm.traceMem(4);
    return new CInteger(value + other.value);
}

这种设计的好处在于,支持浮点加法就像添加一个重写的“plus()”方法一样简单,该方法采用浮点而不是整数,并且解释器根据参数类型自动调用正确的方法。

所以boolean标志的问题是,我不得不跳过一些额外的步骤来避免使用每实例数组。这当然是可行的,但是考虑到这个设计有多简单,我不确定它是否值得——任何bug都应该很快出现。

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/777.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册