Java - 如何克服自动生成代码中的最大方法大小限制

7
我有一个不寻常的要求:我的应用程序会自动从一个非常长的脚本(用一种动态类型语言编写)生成Java代码。这个脚本非常长,以至于我达到了JVM的最大方法大小65k的限制。
脚本只包含有关原始类型的简单指令(除数学指令外没有调用其他函数)。它可能看起来像:
...
a = b * c + sin(d)
...
if a>10 then
   e = a * 2
else
   e = a * abs(b)
end
...

...被转化为:

...
double a = b * c + Math.sin(d);
...
double e;
if(a>10){
   e = a * 2;
}else{
   e = a * Math.abs(b);
}
...


我的第一个想法是克服方法大小限制的方法如下:

  • 将所有局部变量转换为字段
  • 将代码每100行(如果需要,在if / else块中更长)拆分成单独的方法。

类似于:

class AutoGenerated {

   double a,b,c,d,e,....;

   void run1(){
      ...
      a = b * c + sin(d);
      ...
      run2();
   }

   void run2(){
      ...
      if(a>10){
          e = a * 2;
      }else{
          e = a * Math.abs(b);
      }
      ...
      run3();
   }

   ...
}

你知道其他更有效的方法吗?请注意,我需要代码尽可能快地运行,因为它将在长循环中执行。我不能编译成C,因为互操作性也是一个问题...

我也会感激指向可能有帮助的库的指针。


如果您关注效率,您应该注意到默认情况下不编译大小超过8 KB的方法。 - Peter Lawrey
我会考虑内联对你有什么帮助。是否有任何重复的代码序列,在内联后将产生相同的代码? - Peter Lawrey
@PeterLawrey,那么超过8KB会发生什么?代码是否被解释,导致效率大大降低?编译器如何决定应该编译什么或不编译什么?关于内联,它会如何工作?我应该在代码中寻找“模式”并创建专用方法来处理它们吗? - Eric Leibenguth
如果方法没有编译,它将被解释执行。通过内联,我建议为您在生成的代码中使用的常见功能编写一些小方法。 - Peter Lawrey
如果我是你,我会认真考虑@OldCurmudgeon的答案。你说它需要尽可能快地运行,但我总是会质疑这样的假设,因为它强烈依赖于那个长循环中发生的其他事情。如果即使在解释时,这段代码只占总体的20%,那么编译它也不能节省更多,而且你可能有更大的“鱼”要煎。 - Mike Dunlavey
4个回答

2
尽管其他人提到了它的缺点,但我们在其中一个项目中使用了类似的方法。像@Marco13建议的那样从单个启动器方法调用多个生成的方法。我们实际上会精确计算生成的字节码大小,并且只有当达到限制时才会启动新的方法。我们将数学公式转换为Java代码,这些代码可用作AstTree,并且我们有一个特殊的访问者,可以计算每个表达式的字节码长度。对于这种简单程序,它在不同版本的Java和不同编译器之间相当稳定。因此,我们不会创建比必要更多的方法。在我们的情况下,直接发出字节码非常困难,但您可以尝试使用ASM或类似库来为您的语言完成此操作(当然,ASM将为您计算字节码长度)。
通常,我们将数据变量存储在单个double[]数组中(我们不需要其他类型),并将其作为参数传递。这样,您就不需要大量的字段(有时我们有数千个变量)。另一方面,对于索引高于127的本地数组访问可能需要更多的字节码字节,与字段访问相比。
另一个问题是常量池大小。我们通常在自动生成的代码中有很多双重常量。如果您声明了许多字段和/或方法,则它们的名称也会占用常量池条目。因此,可能会达到类常量池限制。有时我们会遇到这个问题并生成嵌套类来解决它。
其他人建议调整JVM选项。请谨慎使用这些建议,因为它们不仅会影响此自动生成的类,而且还会影响其他所有类(我假设在您的情况下,其他代码也在同一个JVM中执行)。

1
将局部变量转换为字段可能会对性能产生负面影响,但只要代码没有被JIT优化(有关详细信息,请参见这个问题和相关问题)。但是,我发现,根据所涉及的变量,可能几乎没有其他可行的选项。

编译和方法大小可能会有额外的限制。Peter Lawrey在评论中提到“...默认情况下不编译超过8 KB大小的方法”,我之前不知道这一点,但他通常知道自己在说什么,所以你应该在这里深入挖掘一下。此外,您可能还想查看HotSpot VM options,以了解哪些进一步的限制和设置可能与您相关。我主要认为

-XX:MaxInlineSize=35:要内联的方法的最大字节码大小。

可能是需要记住的内容。

(事实上,调用许多具有MaxInlineSize大小的方法,以至于内联所有这些调用将超过包含方法的65k字节的大小,可能是内联过程的健壮性和边缘案例测试的一个好的测试用例...)


你为这些方法构思了一个“伸缩”调用方案:
void run1(){
   ...
   run2();
}

void run2(){
   ...
   run3();
}

这也可能会带来问题:考虑到你有超过650个这样的方法(在最好的情况下),这至少会导致一个非常深的堆栈,并且实际上可能会导致StackOverflowError - 这取决于一些内存选项。您可能需要通过相应地设置-Xss参数来增加堆栈大小。
实际的问题描述有点模糊,没有关于要生成的代码的进一步信息(例如关于需要多少个本地变量,可能必须转换为实例变量等问题),我建议如下:
  • Create many small methods if possible (considering the MaxInlineSize)
  • Try to reuse these small methods (if such reusability can be detected from the input with reasonable effort)
  • Call these methods sequentially, as in

    void run()
    {
        run0();
        run1();
        ...
        run2000();
    }
    

    to avoid problems with the stack size.


然而,如果您添加更多示例或详细信息,则可能会获得更专注的建议。这甚至可以是一个“完整”的示例 - 不一定涉及数千行代码,而是显示实际出现的模式

谢谢你的回答,肯定有所帮助!提供一个“代表性样本”的代码很难,因为它可能非常多样化。在指令级别上有一些重复模式(各种行看起来相似),但在程序级别上没有那么多(一堆行看起来相似)。 - Eric Leibenguth

0
我会考虑编写一个解释器或者内联编译器。这样做甚至可能会带来一些速度上的提升,因为最终生成的代码库要小得多,更容易缓存。

你是指要编写一个解释器(用Java语言)来解释原始语言吗?内联编译器是如何工作的,而且你应该怎么去编写呢? - Eric Leibenguth
@EricLeibenguth - 一种内联编译器将读取语言并构建一个数据结构,然后可以直接执行 - 类似于状态机。这可能比解释器更快。 - OldCurmudgeon
好的,我明白你的意思,但是这似乎有点复杂,我自己建立起来可能有些困难。也许你知道一个可以帮助我的库? - Eric Leibenguth
@EricLeibenguth - 你可能会发现表达式解析器是一个不错的开始。JEP看起来很有趣。 - OldCurmudgeon

0
  • 将所有本地变量转换为字段

这样做不会有丝毫影响。方法大小==代码大小,与本地变量无关,本地变量只影响调用帧大小。

  • 将代码每100行(或更长,如果需要在if/else块中)拆分成单独的方法。

这是您唯一的选择,除非采用完全不同的实现策略。

代码生成器的问题在于它们生成代码。


1
将局部变量转换为字段是拆分方法的一种实现方式(因为所有变量都作为字段可用,所以我不必弄清楚method1应该传递给method2什么) - Eric Leibenguth

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接