[spring-projects/spring-boot]迭代 JarFile.entries() 非常慢。

2024-04-20 288 views
8

迭代org.springframework.boot.loader.jar.JarFile#entries()非常慢。对JarFileEntries#getEntryby 的连续调用EntryIterator#next导致底层RandomAccessFile来回跳转,以便从 jar 文件的中央目录重新读取文件头。如果JarFileEntries#visitFileHeader存储完整的内容FileHeader而不仅仅是它的偏移量和名称的哈希值,这可能会快得多。

一些背景

在使用https://github.com/joinfaces/joinfaces/pull/565时,我注意到扫描重新打包的 Spring Boot 应用程序时,ClassGraph 比扫描未打包形式的同一应用程序要慢 500 毫秒。所以我查看了 ClassGraph 的代码,发现它提取了所有嵌套的 jar 以便扫描它们。为了提高扫描嵌套 jar 的性能,我准备了以下补丁来使用 JarFile 实现,spring-boot-loader以避免提取所有嵌套 jar 的额外成本: https://github.com/larsgrefer/classgraph/compare/ba4c69347eaf915571e9f5142e09f7a481471570 ...cfb317aff4d6949afbddcc1fd0ad78b118ef52ec?expand=1

令人惊讶的是,这种方法甚至有点慢,所以我更深入地研究了代码,并将性能差异追溯到此处完成的迭代:https: //github.com/classgraph/classgraph/blob/b170d2bebb871824f7d53d54aa7a9b6939f25cf0/src/main/java/ io/github/classgraph/utils/JarfileMetadataReader.java#L148

在我的测试中,迭代org.springframework.boot.loader.jar.JarFilea 比迭代 a 慢大约 5 到 10 倍java.util.zip.ZipFile。这种性能影响是如此严重,以至于首先提取嵌套 jar 的速度更快,以便能够使用java.util.zip.ZipFile

结论

在我阅读了https://github.com/spring-projects/spring-boot/commit/e2368b909b46bc5bcec6792fb208ba9bd0fe6aaa的提交消息后,在我看来,内存效率对你来说比迭代性能更重要。所以我的问题是,您是否愿意接受更改当前行为的拉取请求或允许 ClassGraph 更改实现的行为JarFileEntries以及此 PR 的外观。

回答

6

有趣的发现,感谢您分享。

我们对 Spring Boot 的 fat jar 支持所做的任何更改的首要目标是优化启动性能。虽然 e2368b9 中所做的更改减少了内存使用量,但由于 GC 压力减少,最终结果是启动速度更快。这包括通过 Spring Framework 的类路径扫描机制扫描 jar 的内容。

据我所知,我们还没有对ClassGraph涉及的启动性能进行任何分析。乍一看,它似乎采用了与 Spring Framework 不同的扫描方法。您在此处提出的更改可能通常有用,或者仅在执行 ClassGraph 样式扫描时才有用。另一种可能性是,我们可以建议对 ClassGraph 进行一些更改,以提高其性能,而无需对 Boot.js 进行任何更改。

您能否提供一些示例代码来重现您上面描述的行为?我想看一下 Joinfaces 的现实场景以及您所看到的org.springframework.boot.loader.jar.JarFilejava.util.zip.ZipFile.

5

我不确定提议的更改是否会对 GC 压力产生很大影响。无论如何,必要的对象都会被创建并传递给JarFileEntries#visitFileHeader.它们只是没有被存储,因此getEntry需要在迭代时再次创建它们。

除了更改当前的实现之外,还可以添加 的第二个实现JarFileEntries

我稍后会发布我的示例代码,然后将其链接到此处,以便您查看。

3

这是我的示例项目:https://github.com/larsgrefer/zip-performance-test

只需执行单元测试并查看它们的输出(stdout)

在我的机器上,结果如下:

3.738206 ms/iteration for java.util.zip.ZipFile
51.757802 ms/iteration for org.springframework.boot.loader.jar.JarFile
2,796645 ms/iteration for java.util.zip.ZipFile
53,356237 ms/iteration for org.springframework.boot.loader.jar.JarFile
0

Spring 框架JarFile#entries()正是在这里进行迭代。 (Spring Boot在这里JarFile获取)

5

感谢您提供样品。不幸的是,在这种情况下,测试ZipFileJarFile孤立可能会产生误导。一个组件中的内存使用或垃圾创建可能会对整体性能或另一组件的性能产生重大影响。通过ZipFile单独观察JarFile,许多其他影响 GC 相关性能的因素都已被消除。这就是为什么我要求提供现实场景和更集中的案例的示例。您提供了后者,但没有提供前者。如果没有前者,我们可以做出一些改变,JarFile看起来速度更快,但在现实场景中实际上会减慢速度。

2

您更喜欢什么样的现实场景?

  • 我可以根据 ClassGraph 完成的类路径扫描准备一个测试。

    • 对于非嵌套罐子,这将比较ZipFile<> JarFile
    • 对于嵌套罐子,这将比较Unzip + ZipFile<> Jarfile
  • 我可以在 spring-boot 的功能分支中准备提议的更改,以便您可以将当前JarFile实现的性能与提议的性能进行比较。

如果我开始研究这个,我的工作应该基于1.5.x2.0.x还是仅仅2.1.x

9

在现实场景中,我指的是一个完整的应用程序,因为在这种情况下,我们的性能JarFile非常重要。您在处理https://github.com/joinfaces/joinfaces/pull/565或类似的应用程序时使用的应用程序将是理想的选择。

5

我将尝试组装这样一个例子。

0

我在https://github.com/larsgrefer/zip-performance-test中添加了另外两个示例项目

我使用以下命令进行测试:

./gradlew full-example:jarfile:bootRun
./gradlew full-example:zipfile:bootRun

这是我机器上的结果:

  • Zip 文件示例

    StopWatch 'com.sun.faces.config.FacesInitializer': running time (millis) = 2405
    -----------------------------------------
    ms     %     Task name
    -----------------------------------------
    00017  001%  prepare
    02045  085%  classpath scan
    00343  014%  collect results
  • Jar 文件示例

    StopWatch 'com.sun.faces.config.FacesInitializer': running time (millis) = 2841
    -----------------------------------------
    ms     %     Task name
    -----------------------------------------
    00021  001%  prepare
    02447  086%  classpath scan
    00373  013%  collect results

使用基于类图实现的示例org.springframework.boot.loader.jar.JarFile慢了 300 到 500 毫秒

7

我还添加了一个中等大小的示例,它提供了更稳定的结果。

执行单元测试时:half-example:zipfile:half-example:jarfile这是我的结果:

使用 Zip 文件:

Classpath scan took 207,036227 ms
Classpath scan took 191,851481 ms
Classpath scan took 193,802979 ms
Classpath scan took 182,531380 ms

使用 Jar 文件:

Classpath scan took 232,512777 ms
Classpath scan took 245,336233 ms
Classpath scan took 248,245218 ms
Classpath scan took 230,905295 ms
8

感谢您提供新示例。不幸的是,我不确定在看过它们之后我是否真的变得更明智了。 Classgraph 的修补版本很大程度上是一个黑匣子。我花了一些时间试图了解您的补丁的作用,但它们分布在大量提交中。他们似乎还反射性地使用 Boot 的 jar 加载器,并且在通常不会使用它的情况下使用,这两种情况我都不推荐。

Spring Boot 的启动器旨在当应用程序已打包为可执行 jar 文件或 war 文件并使用org.springframework.boot.loader.Launcher子类作为其入口点时使用。这包括应用程序被打包为可执行 jar 文件或 war 文件,然后在 CloudFoundry 上发生爆炸的情况。org.springframework.boot.loader.jar.JarFile而不是的使用java.util.jar.JarFile应该是完全透明的。这种预期的透明度就是为什么前者是后者的子类,并且我们不希望org.springframework.boot.loader.jar.JarFile直接使用。这是您为 Classgraph 提议的与 Spring 框架不同的一个领域。 Spring Framework 不了解 Spring Boot,纯粹处理java.net.JarURLConnectionjava.util.jar.JarFile

性能可能还有改进的空间org.springframework.boot.loader.jar.JarFile。不幸的是,到目前为止提供的示例很难证明花更多时间进行调查是合理的,因为据我所知,它们没有org.springframework.boot.loader.jar.JarFile按照预期的方式使用。 Classgraph 应该可以在不知道 Spring Boot fat jar 是什么并且不依赖于org.springframework.boot.loader.jar.JarFile.例如,这正是 Spring Framework 所做的,我建议 Classgraph 也这样做。

我很高兴在这方面花费更多时间,并探索进行一些可以提高性能的更改,但前提是org.springframework.boot.loader.jar.JarFile按预期使用。正如我上面所说,任何旨在提高性能的更改都需要在使用java -jar.

4

再次阅读讨论,我认为这是最好的前进路线:

5

Spring Framework 和提议的 ClassGraph 版本获取实例​​的方式org.springframework.boot.loader.jar.JarFile确实不同,但实际上它们都迭代org.springframework.boot.loader.jar.JarFile.entries().

我还了解到,我计划在 ClassGraph 中使用的方式org.springframework.boot.loader.jar.JarFile并不完全是它的预期用例,因此这个问题对您来说可能不太重要。

我应该从哪个分支开始实施提议的更改?

0

master至少现在我会用。

2

好的:+1:

我将实施提议的更改

2

我已经开始实施我提出的更改:https://github.com/spring-projects/spring-boot/compare/master...larsgrefer :feature/jarFilePerformance?expand=1

我还更新了示例项目来测试新的JarFile实现。这些是我到目前为止的结果:

  • small-example:
    • 压缩文件:2-4ms
    • 当前 JarFile:40-55ms
    • 新 JarFile:15-18 毫秒
  • half-example:
    • Zip文件:180-210ms
    • 当前 JarFile:230-250ms
    • 新 JarFile:66-77 毫秒

我是否应该打开一个包含我的更改的拉取请求,以便可以更轻松地审查和讨论它们,或者我应该等到我认为它已准备好合并?

您对如何使用新的 JarFile 实现(而不是当前的实现)生成重新打包的 jar 有什么建议吗?

8

@larsgrefer 您可以使用自定义LayoutFactory并返回编写其他类的CustomLoaderLayout 。

9

我肯定会想要构建一个额外的示例,我们可以在“正常”Spring Boot 应用程序中比较当前和新的 JarFile 实现的执行速度和内存消耗。

我还想强调,我的功能分支尚未完成。由于所有条目现在都缓存在 Map 中,因此其他一些东西(例如 int 数组)现在可能是多余的。清理这个应该会进一步提高我的提案的性能。

虽然内存消耗肯定会增加一点,但我很好奇 spring 的组件扫描将如何受此影响。 (如前所述,Spring Framework也会在这里迭代 JarFile 的条目)

为了更快的执行速度,我愿意接受 10-100kb 的更多 RAM 使用量,但我们必须看看实际数字是多少。

2

我还想强调,我的功能分支尚未完成。由于所有条目现在都缓存在 Map 中,因此其他一些东西(例如 int 数组)现在可能是多余的。

数组背后的一个想法是能够非常快速地回答“这个类是否缺失”的问题。 Spring 中有如此多的可选依赖项,因此出现大量调用是很常见的Class.forName("something.not.on.the.classpath")。该数组让我们可以快速回答这个问题,而不会消耗太多内存。

如果我们缓存所有条目,那么这些数组就可以走了,但目前这对我来说并不容易。我想我宁愿探索制作entriesCache一些可以根据需要增长和缩小的东西。这样,如果内存紧张,这些条目就可以消失。

Spring框架有一个ConcurrentReferenceHashMap专为缓存设计的类。它使用弱引用,以便在必要时进行 GC。我们遇到的问题是,如果我们想使用该类,则需要将该类的版本复制到加载器中。

8

如果您希望我们查看此问题,请提供所需的信息。如果在接下来的 7 天内未提供信息,此问题将被关闭。

5

由于缺乏所需的反馈而关闭。如果您希望我们查看此问题,请提供所需的信息,我们将重新打开该问题。

1

在研究这个问题时,我注意到我的想法对内存使用的影响比我想象的要大得多。在当前的实现中,仅为CentralDirectoryFileHeader每个 jar 文件创建一个实例,并随后填充 jar 文件条目的信息。通过我的实现,我必须为每个条目创建一个实例。

与此同时,我们找到了另外两个解决方案来解决我们最初的问题:

  1. @lukehutch 为 classgraph 实现了一个自定义目录解析器,因此 classgraph 现在可以直接扫描嵌套的 jar 文件,而无需先提取它们。
  2. 在 JoinFaces,我们实现了一个 Maven 插件和一个 Gradle 插件,它们在构建时执行类路径扫描。

通过构建时扫描,我们将应用程序启动的这一部分减少到约 200 毫秒,用于从文本文件读取类名列表并加载它们。

6

如果您有时间这样做,我会很好奇看到 Spring 的中央目录解析器与最新版本的 ClassGraph 的基准测试。

对于 ClassGraph,您不会启用类文件扫描,因此目录条目实际上不会膨胀,因此如果您需要将任何内容列入白名单以进行同类比较,请使用.whitelistPaths()而不是.whitelistPackages(),并且不要调用.enableClassInfo()等)。然后您可以通过调用来迭代条目ScanResult#getAllResources()