JVM 的自愈能力

发布于 2024-07-24 11:44:29 字数 4156 浏览 11 评论 0

在 IT 行业,碰到问题的第一个反应通常是——“你重启过没”——而这样做可能会适得其反,本文要讲述的就是这样的一个场景。

接下来要介绍的这个应用,它不仅不需要重启,而且毫不夸张地说,它能够自我治愈:刚开始运行的时候它可能会碰到些挫折,但会渐入佳境。为了能实际地展示出它的自愈能力,我们尽可能简单地重现了这一场景,这个灵感还得归功于 五年前 heinz Kabutz 发表的一篇老文章 :

package eu.plumbr.test;

public class HealMe {
  private static final int SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.6);

  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 1000; i++) {
      allocateMemory(i);
    }
  }

  private static void allocateMemory(int i) {
    try {
      {
        byte[] bytes = new byte[SIZE];
        System.out.println(bytes.length);
      }

      byte[] moreBytes = new byte[SIZE];
      System.out.println(moreBytes.length);

      System.out.println("I allocated memory successfully " + i);

    } catch (OutOfMemoryError e) {
      System.out.println("I failed to allocate memory " + i);
    }
  }
}

上述代码会循环地分配两块内存。每次分配的内存都是堆中总内存的 60%。由于在同一个方法内会不停地进行这个内存分配,因此你可能会认为这段代码会 不断地抛出 java.lang.OutOfMemoryError: Java heap space 异常,永远无法正常地执行完 allocateMemory 方法。

我们先来对源代码进行下静态分析,看看这种猜测是否恰当:

  1. 乍看一下这段程序的话,这确实是无法成功执行的,因为要分配的内存已经超出了 JVM 的限制。
  2. 但再仔细分析下的话我们会发现第一次分配是在一个块作用域内完成的,也就是说这个块中定义的变量仅对块内可见。这意味着这些内存在这个代码块执行完成后便可以回收掉了。这段代码一开始应该是可以成功执行的,只是当它再去尝试分配 moreBytes 的时候才会挂掉。
  3. 如果再查看下编译后的 class 文件的话,你会看到如下的字节码:
private static void allocateMemory(int);
    Code:
       0: getstatic     #3                  // Field SIZE:I
       3: newarray       byte
       5: astore_1      
       6: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: aload_1       
      10: arraylength   
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
      14: getstatic     #3                  // Field SIZE:I
      17: newarray       byte
      19: astore_1      
      20: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: aload_1       
      24: arraylength   
      25: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
---- cut for brevity ----

从中能够看出,第一个数组是在位置 3~5 处完成分配的,并存储到了序号为 1 的本地变量中。随后在位置 17 处,正要分配另一个数组。不过由于第一个数 组仍被本地变量所引用着,因此第二次分配总会抛出 OOM 的异常而失败。字节码解释器不会允许 GC 去回收第一个数组,因为它仍然存在着一个强引用。

从静态代码分析中可看出,由于底层的两个约束,上述的代码是无法成功执行的,而在第一种情况下则是能够运行的。这三点分析里面哪个才是正确的呢?我 们来实际运行下看看结果吧。结果表明,这些结论都是正确的。首先,应用程序的确无法分配内存。但是,经过一段时间之后(在我的 Mac OS X 上使用 Java 8 大概是出现在第 255 次迭代中),内存分配开始能够成功执行了:

 java -Xmx2g eu.plumbr.test.HealMe
1145359564
I failed to allocate memory 0
1145359564
I failed to allocate memory 1

… cut for brevity ...

I failed to allocate memory 254
1145359564
I failed to allocate memory 255
1145359564
1145359564
I allocated memory successfully 256
1145359564
1145359564
I allocated memory successfully 257
1145359564
1145359564
Self-healing code is a reality! Skynet is near...

为了搞清楚究竟发生了什么,我们得思考一下,在程序运行期间发生了什么变化?显然,Just-In-Time 编译开始介入了。如果你还记得的 话,JIT 编译是 JVM 的一个内建机制,它可以优化热点代码。JIT 会监控运行的代码,如果发现了一个热点,它会将你的字节码转化成本地代码,同时会执行 一些额外的优化,譬如方法内联以及无用代码擦除。

我们打开下面的命令行参数重启下程序,看看是否触发了 JIT 编译。

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation

这会生成一个日志文件,在我这里是一个 hotspot_pid38139.log 文件,38139 是 Java 进程的 PID。在该文件中可以找到这么一行:

<task_queued compile_id='94' method='HealMe allocateMemory (I)V' bytes='83' count='256' iicount='256' level='3' stamp='112.305' comment='tiered' hot_count='256'/>

这说明,在运行了 256 次 allocateMemory() 方法 2 之后,C1 编译器决定将这个方法进行 3 级编译。看下 这里 可 以了解下分层编译的各个级别以及不同的阈值。在前面的 256 次迭代中这段程序都是在解释模式下运行的,这里的字节码解释器就是一个简单堆栈机器,它无法提 前预知某个变量后续是否会被用到,在这里对应的是变量 bytes。但是 JIT 会一次性查看整个方法,因此它能推断出后面不会再用到 bytes 变量,可以对 它进行 GC。

所以才会触发垃圾回收,因此我们的程序才能奇迹般地自愈。我只是希望本文的读者都不要在生产环境碰到调试这类问题的情况。不过如果你想让某人 抓狂的话,倒是可以试试在生产环境中加下类似的代码。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

五里雾

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

内心激荡

文章 0 评论 0

JSmiles

文章 0 评论 0

左秋

文章 0 评论 0

迪街小绵羊

文章 0 评论 0

瞳孔里扚悲伤

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文