Aviator 重复编译表达式致使 Metaspace 飙升,最终导致 OOM killed

1,482 阅读8分钟

问题

线上频繁收到 Pod 重启告警,通过 grafana 查看容器监控,发现程序在一瞬间加载大量的类,metaspace(no-heap) 空间飙升。

Pod 容器使用的内存超过 memory.limit,Kubernetes 会将该容器标记为 OOMKilled(Out of Memory Killed),导致容器被终止并重启。

在容器重启期间,prometheus 监控端点 /actuator/prometheus 无法正常工作,因此监控图标中缺失了一段。

image.png

image.png

Kubernetes 的内存限制是作用于容器级别的,而不是进程级别。当容器中的进程尝试使用超过其内存限制的内存时,Linux 内核会在容器级别触发 OOM 事件,而不是限制单个进程。

K8S 环境可通过 irate(kube_pod_container_status_restarts_total{container=~"container1|container2|container3"}[2m]) 来监控容器的重启事件。

解决

Metaspace 存储着 Java 类的元数据信息,随着 JVM 加载的类数量增加,Metaspace 使用空间也会逐渐增多。Java 程序不停加载新的类,可以检查以下两点:

  1. 使用动态代理框架生成代理类,比如 JDK Proxy,cglib
  2. 使用脚本语言动态生成 Java 字节码,比如 aviatorscript 表达式引擎:轻量化,高性能,ASM 模式下通过直接将脚本翻译成 JVM 字节码,解释模式可运行于 Android 等非标 Java 平台。

反正总结成一句话:除了现成的、编译好的字节码文件,还有其他地方可以动态生成和载入 Java 字节码。

经检查,发现程序中使用 aviator 表达式引擎,如果未设置 cached 参数,每次加载相同的脚本都会使用新的 aviator classloader 去加载相同的脚本,开启 -XX:+TraceClassLoading 参数,可以看到非常多的 Loaded Script 日志输出,导致 metaspace 使用率瞬间增加。

public class AviatorUtils {

    private static final AviatorEvaluatorInstance AVIATOR_EVALUATOR_INSTANCE = AviatorEvaluator.getInstance();

    // 对于相同的脚本,aviator 会生成相同的字节码,但是会使用不同的 ClassLoader 加载其字节码,
    public static BigDecimal executeExpression(String expression, Map<String, Object> params) {
        return (BigDecimal) AVIATOR_EVALUATOR_INSTANCE.compile(expression).execute(params);
    }
    
    // 对于相同的脚本,aviator 不会重复加载,会复用编译好的 Java 字节码
    public static BigDecimal executeExpression(String expression, Map<String, Object> params) {
        return (BigDecimal) AVIATOR_EVALUATOR_INSTANCE.compile(expression, true).execute(params);
    }

未设置 MaxMetaspaceSize 的话,是一个非常大的数值,无限制加载类会导致 pod 的内存使用超出 memory.limit,导致 oom killed。使用 jinfo -flag MetaspaceSize <PID> 和 jinfo -flag MaxMetaspaceSize <PID> 可以查看 JVM 运行时的 MetaspaceSize(已使用)和 MaxMetaspaceSize(最大值)。如果没有设置 MaxMetaspaceSize 选项,Metaspace 的最大值默认为无限大(unlimited)。

$ jinfo -flag MetaspaceSize 13
-XX:MetaspaceSize=21807104
$ jinfo -flag MaxMetaspaceSize 13
-XX:MaxMetaspaceSize=18446744073709547520

解决办法:二选一

  1. 调用 compile(final String expression, final boolean cached) 方法时,设置cached 参数为 true:AVIATOR_EVALUATOR_INSTANCE.compile(expression, true)。只要脚本不变,就不会重复加载。 image.png
  2. 初始化 AviatorEvaluatorInstance 时,将 cachedExpressionByDefault 属性设置为 true:AVIATOR_EVALUATOR_INSTANCE.setCachedExpressionByDefault(true)。 image.png

排查 JVM 类加载相关参数:

  1. -verbose:class,输出类的加载和卸载信息。
  2. -XX:+TraceClassLoading,输出类加载信息。
  3. -XX:+TraceClassUnloading,输出类卸载信息。

-verbose:class 相当于 -XX:+TraceClassLoading 和 -XX:+TraceClassUnloading 的合体,控制台输出的类加载信息如下:

[Loaded java.lang.Object from C:\Program Files\Eclipse Adoptium\jdk-8.0.382.5-hotspot\jre\lib\rt.jar]
[Loaded java.io.Serializable from C:\Program Files\Eclipse Adoptium\jdk-8.0.382.5-hotspot\jre\lib\rt.jar]
[Loaded java.lang.Comparable from C:\Program Files\Eclipse Adoptium\jdk-8.0.382.5-hotspot\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from C:\Program Files\Eclipse Adoptium\jdk-8.0.382.5-hotspot\jre\lib\rt.jar]
[Loaded java.lang.String from C:\Program Files\Eclipse Adoptium\jdk-8.0.382.5-hotspot\jre\lib\rt.jar]

设置 JVM Metaspace 相关参数:

  1. -XX:MetaspaceSize,设置 Metaspace 的初始大小,默认情况下,Metaspace 的初始大小是根据运行时系统的可用内存动态计算的。
  2. -XX:MaxMetaspaceSize,设置 Metaspace 的最大大小,当 Metaspace 的使用量达到该限制时,JVM 会触发垃圾回收,如果达到最大限制后,仍然无法回收内存,则会导致 Metaspace OutOfMemoryError 异常(JVM 层面,不是容器层面)。

Metaspace 使用堆外内存,若不加限制(默认是 unlimited),Metaspace 可能会无限增长,最终导致系统内存耗尽,触发 OOM killed。

Aviator 加载脚本原理

compile(final String expression) 会调用重载的 compile(final String expression, final boolean cached) 方法,this.cachedExpressionByDefault 默认值为 false,对于相同的表达式,aviator 不会缓存其字节码,会重复加载。

public final class AviatorEvaluatorInstance {

    private boolean cachedExpressionByDefault;

    // Compile a text expression to Expression Object without caching
    public Expression compile(final String expression) {
      return compile(expression, this.cachedExpressionByDefault);
    }

    private void fillDefaultOpts() {
        // cachedExpressionByDefault 默认为 false,
        this.cachedExpressionByDefault = false;
        for (Options opt : Options.values()) {
          this.options.put(opt, opt.getDefaultValueObject());
        }
    }

编译表达式脚本,最终都会调用 compile(final String cacheKey, final String expression, final String sourceFile, final boolean cached)。

private Expression compile(final String cacheKey, final String expression,
    final String sourceFile, final boolean cached) {
  if (expression == null || expression.trim().length() == 0) {
    throw new CompileExpressionErrorException("Blank expression");
  }
  if (cacheKey == null || cacheKey.trim().length() == 0) {
    throw new CompileExpressionErrorException("Blank cacheKey");
  }

  if (cached) {
    FutureTask<Expression> existedTask = null;
    if (this.expressionLRUCache != null) {
      boolean runTask = false;
      synchronized (this.expressionLRUCache) {
        existedTask = this.expressionLRUCache.get(cacheKey);
        if (existedTask == null) {
          existedTask = newCompileTask(expression, sourceFile, cached);
          runTask = true;
          this.expressionLRUCache.put(cacheKey, existedTask);
        }
      }
      if (runTask) {
        existedTask.run();
      }
    } else {
      FutureTask<Expression> task = this.expressionCache.get(cacheKey);
      if (task != null) {
        return getCompiledExpression(expression, task);
      }
      task = newCompileTask(expression, sourceFile, cached);
      existedTask = this.expressionCache.putIfAbsent(cacheKey, task);
      if (existedTask == null) {
        existedTask = task;
        existedTask.run();
      }
    }
    return getCompiledExpression(cacheKey, existedTask);

  } else {
    return innerCompile(expression, sourceFile, cached);
  }

}

cached 参数为 true:第一次编译,会创建编译任务:task = newCompileTask(expression, sourceFile, cached),并执行编译任务:existedTask.run(),然后将编译好的 Java 字节码缓存在 this.expressionCache 中。

newCompileTask(expression, sourceFile, cached) 中也是调用 innerCompile(expression, sourceFile, cached) 方法,这里为什么要创建 FutureTask 对象来执行编译任务呢?答案是:为了多线程之间的同步。

如果多个线程同时对同一个表达式进行编译,为了防止重复编译和重复加载,aviator 选择将编译任务封装成 FutureTask 对象,并放进 this.expressionCache 中,expressionCache 本质上是个 ConcurrentHashMap,多个线程之间,只有一个线程可以把自己创建的 FutureTask 对象放进 expressionCache。由于有 FutureTask 执行多线程任务之间的同步和保护,只会有一个线程执行编译脚本的任务:task.get(),其他线程调用 task.get() 方法时会阻塞,等待编译脚本的任务完成。

image.png

private FutureTask<Expression> newCompileTask(final String expression, final String sourceFile,
    final boolean cached) {
  return new FutureTask<>(new Callable<Expression>() {
    @Override
    public Expression call() throws Exception {
      return innerCompile(expression, sourceFile, cached);
    }

  });
}

private Expression getCompiledExpression(final String cacheKey,
    final FutureTask<Expression> task) {
  try {
    return task.get();
  } catch (Throwable t) {
    invalidateCacheByKey(cacheKey);
    final Throwable cause = t.getCause();
    if (cause instanceof ExpressionSyntaxErrorException
        || cause instanceof CompileExpressionErrorException) {
      throw Reflector.sneakyThrow(cause);
    }
    throw new CompileExpressionErrorException("Compile expression failure, cacheKey=" + cacheKey,
        t);
  }
}

cached 参数为 true:第二次编译,会从 this.expressionCache 获取上次已经执行完成的编译任务:FutureTask<Expression> task = this.expressionCache.get(cacheKey),调用 task.get() 得到上次已经编译好的 Expression,不会重复执行编译任务。

image.png

cached 参数为 false:每次都会执行 innerCompile(expression, sourceFile, cached) 方法,编译并加载 Java 字节码。

image.png

private Expression innerCompile(final String expression, final String sourceFile,
    final boolean cached) {
  ExpressionLexer lexer = new ExpressionLexer(this, expression);
  CodeGenerator codeGenerator = newCodeGenerator(sourceFile, cached);
  ExpressionParser parser = new ExpressionParser(this, lexer, codeGenerator);
  Expression exp = parser.parse();
  if (getOptionValue(Options.TRACE_EVAL).bool) {
    ((BaseExpression) exp).setExpression(expression);
  }
  return exp;
}

innerCompile(final String expression, final String sourceFile, final boolean cached) 方法中只有 CodeGenerator codeGenerator = newCodeGenerator(sourceFile, cached) 这一行代码用到了 cached 参数。

newCodeGenerator(final String sourceFile, final boolean cached) 方法中:

  1. cached 参数为 true:每次都返回 this.aviatorClassLoader。
  2. cached 参数为 false:每次都返回新创建的 AviatorClassLoader 实例。
public final class AviatorEvaluatorInstance {

    private volatile AviatorClassLoader aviatorClassLoader = initAviatorClassLoader()

    public CodeGenerator newCodeGenerator(final String sourceFile, final boolean cached) {
      AviatorClassLoader classLoader = getAviatorClassLoader(cached);
      return newCodeGenerator(classLoader, sourceFile);

    }

    public AviatorClassLoader getAviatorClassLoader(final boolean cached) {
      if (cached) {
        return this.aviatorClassLoader;
      } else {
        return new AviatorClassLoader(this.getClass().getClassLoader());
      }
    }
    
    public CodeGenerator newCodeGenerator(final AviatorClassLoader classLoader,
        final String sourceFile) {
      switch (getOptimizeLevel()) {
        case AviatorEvaluator.COMPILE:
          ASMCodeGenerator asmCodeGenerator =
              new ASMCodeGenerator(this, sourceFile, classLoader, this.traceOutputStream);
          asmCodeGenerator.start();
          return asmCodeGenerator;
        case AviatorEvaluator.EVAL:
          return new OptimizeCodeGenerator(this, sourceFile, classLoader, this.traceOutputStream);
        default:
          throw new IllegalArgumentException("Unknow option " + getOptimizeLevel());
      }
    }

AviatorClassLoader 就是一个普普通通的 ClassLoader。

// Aviator classloader to define class
public class AviatorClassLoader extends ClassLoader {

  public AviatorClassLoader(ClassLoader parent) {
    super(parent);
  }


  public Class<?> defineClass(String name, byte[] b) {
    return defineClass(name, b, 0, b.length);
  }
}

aviator 默认的 OPTIMIZE_LEVEL 为 AviatorEvaluator.EVAL,会创建 OptimizeCodeGenerator 实例生成 Java 字节码。

image.png

AviatorEvaluator.COMPILE(ASMCodeGenerator)和 AviatorEvaluator.EVAL(OptimizeCodeGenerator)的区别:我看了下源码,OptimizeCodeGenerator 是 ASMCodeGenerator 的装饰器,会在 ASMCodeGenerator 的基础上执行一些优化,根据优化之后的结果,调用 ASMCodeGenerator 的相关方法,生成 Java 字节码。ASMCodeGenerator 的相关方法就是直接操作字节码,非常底层。

// Optimized code generator
public class OptimizeCodeGenerator implements CodeGenerator {
  private final ASMCodeGenerator codeGen;
  
  public OptimizeCodeGenerator(final AviatorEvaluatorInstance instance, final String sourceFile,
    final ClassLoader classLoader, final OutputStream traceOutStream) {
      this.instance = instance;
      this.sourceFile = sourceFile;
      this.codeGen = new ASMCodeGenerator(instance, sourceFile, (AviatorClassLoader) classLoader,
          traceOutStream);
}
// Code generator using asm
public class ASMCodeGenerator implements CodeGenerator {

  public ASMCodeGenerator(final AviatorEvaluatorInstance instance, final String sourceFile,
      final AviatorClassLoader classLoader, final OutputStream traceOut) {
    this.classLoader = classLoader;
    this.instance = instance;
    this.compileEnv = new Env();
    this.sourceFile = sourceFile;
    this.compileEnv.setInstance(this.instance);
    // Generate inner class name
    this.className = "Script_" + System.currentTimeMillis() + "_" + CLASS_COUNTER.getAndIncrement();
    // Auto compute frames
    this.classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    // if (trace) {
    // this.traceClassVisitor = new TraceClassVisitor(this.clazzWriter, new PrintWriter(traceOut));
    // this.classWriter = new CheckClassAdapter(this.traceClassVisitor);
    // } else {
    // this.classWriter = new CheckClassAdapter(this.clazzWriter);
    // }
    visitClass();
  }

加载类的重任最终都会落到 ASMCodeGenerator#getResult() 方法上:首先调用 byte[] bytes = this.classWriter.toByteArray() 生成字节码文件,然后调用 ClassDefiner.defineClass(this.className, Expression.class, bytes, this.classLoader) 将字节码文件载入 JVM 中。

image.png

加载类最终都会调用 classLoader.defineClass(className, bytes)。

虽然不同的类加载器可以加载同一字节码,但它们加载的类是不同的类实例。由于每个类加载器都有自己独立的命名空间,同一个类在不同的类加载器中被加载后,它们被认为是不同的类。

因此如果 aviator 加载脚本的字节码时,使用不同的 ClassLoader,每次加载的类都会被认为是不同的类,导致 Metaspace 空间持续飙升。

public class ClassDefiner {

  public static final Class<?> defineClass(final String className, final Class<?> clazz,
      final byte[] bytes, final AviatorClassLoader classLoader)
      throws NoSuchFieldException, IllegalAccessException {
    if (!preferClassLoader && DEFINE_CLASS_HANDLE != null) {
      try {
        Class<?> defineClass = (Class<?>) DEFINE_CLASS_HANDLE.invokeExact(clazz, bytes, EMPTY_OBJS);
        return defineClass;
      } catch (Throwable e) {
        // fallback to class loader mode.
        if (errorTimes++ > 10000) {
          preferClassLoader = true;
        }
        return defineClassByClassLoader(className, bytes, classLoader);
      }
    } else {
      return defineClassByClassLoader(className, bytes, classLoader);
    }
  }

  public static Class<?> defineClassByClassLoader(final String className, final byte[] bytes,
      final AviatorClassLoader classLoader) {
    return classLoader.defineClass(className, bytes);
  }