首页 > 编程语言 >拥抱 invokedynamic,在 Java agent 中驯服类加载器

拥抱 invokedynamic,在 Java agent 中驯服类加载器

时间:2024-04-22 23:12:41浏览次数:18  
标签:Java invokedynamic advice agent 方法 插桩 加载

前言

在开发项目的agent 时,找了很多类隔离加载的解决方案,最终参照开源项目实现,采用了 Elastic APM Java agent 的方案。以下为本方案的核心说明文章。

翻译正文

Byte Buddy 最棒的一点是,它允许您编写 Java agent,而无需手动处理字节代码。agent作者只需用纯 Java 编写要注入的代码,即可对方法进行检测。这使得编写 Java agent变得更加容易,并避免了复杂的入门要求。

在第一次成功的实验之后,agent作者往往会被 JVM 的复杂性所困扰:类加载器(例如OSGi)、类可见性、对内部 API 的依赖性、类路径扫描器和版本冲突等等。

在本文中,我们将探讨一种相对新颖的方法来突破这堵复杂的墙。该架构基于 invokedynamic 字节码指令(一种因利用 Java lambda 表达式而闻名的字节码),允许在编写插桩时使用简单的心智模型。另外,这样还能在运行时更新到较新版本的agent,而无需重新启动插桩的应用程序。一年多以前,Elastic APM Java agent开始迁移到invokedynamic为基础的架构( migration to this invokedynamic-based architecture),并于最近完成了迁移。

传统 advice 分派方法的问题

让我们举一个简单的例子:一个agent希望测量 Java servlets 的响应时间。在所谓的 advice 方法中,我们可以定义应在实际方法之前或之后运行的代码。此外,还可以访问instrumented方法的参数。

@Advice.OnMethodEnter
public static long enter() {
    return System.nanoTime();
}

@Advice.OnMethodExit
public static void exit(
        @Advice.Argument(0) HttpServletRequest request、
        @Advice.Enter long startTime) {
    System.out.printf(
            "向 %s 请求花费了 %d ns%n"、
            request.getRequestURI()、
            System.nanoTime() - startTime);
}

在 Byte Buddy 中,有两种主要方法可将 advice 应用于 instrumented method

内嵌 advice

默认情况下,进入和退出 advice 会被复制到目标方法中,就像类的原作者将agent代码添加到方法中一样。如果用纯 Java 编写 插桩的方法一样,它看起来会如下所示:

protected void service(HttpServletRequest req, HttpServletResponse resp) {
    long startTime = System.nanoTime();
    // 原始方法体
    System.out.printf(
            "Request to %s took %d ns%n"、
            request.getRequestURI()、
            System.nanoTime() - startTime);
}

这样做的好处是, advice 可以访问插桩方法通常可以访问的任何值或类型。在上例中,这就允许访问 javax.servlet.http.HttpServletRequest,尽管agent本身并不包含该接口。当agent代码在目标方法中运行时,它只需获取方法本身已有的类型定义。

缺点是, advice 代码不再在其定义的上下文中执行。因此,你不能在 advice 方法中设置断点,因为它从未被实际调用过。请记住:方法只是用作模板。

但真正的问题在于,将代码从 advice 方法中分离出来或调用任何通常可从 advice 方法中访问的方法已不再可能。由于所有代码现在都从插桩方法中执行,agent可能会在完全不同的类加载器上运行,与插桩方法没有任何联系,因此即使是公共方法插桩代码中也无法调用。我们将在下一节进一步讨论这个问题。

Delegated advice

对于类似但仍然非常不同的方法,可以指示 Byte Buddy 委托使用 advice 方法。这可以通过 advice 注解属性 @Advice.OnMethodEnter(inline = false) 进行控制。默认情况下,Byte Buddy 将通过静态方法调用委托给 advice 方法。这样,插桩方法就会如下所示:

protected void service(HttpServletRequest req, HttpServletResponse resp) {
    long startTime = AdviceClass.enter();
    // 原始方法体
    AdviceClass.exit(req, startTime);
}

与之前类似,需要由agent的开发人员来确保插桩方法中能看到 advice 代码。如果插桩方法与agent的代码不共享类加载器层次结构,那么在检测到上述方法时就会产生 NoClassDefFoundError。即使agent可以访问委托 advice ,agent的类加载器也可能无法使用 HttpServletRequest 等参数类型。这样,只有在agent调用 advice 时,错误才会转移到agent的代码中。

类加载器问题

默认情况下,agent在连接到 JVM 时会被添加到系统类加载器,而 java.lang.instrument.Instrumentation 接口提供了将agent添加到引导类加载器的方法。理论上,将类添加到引导类加载器会使它们在任何地方都可见。但是,有些类加载器(如 OSGi)只允许从系统或引导类加载器加载某些类(如 java.、com.sun.)。常见的解决方案是检测所有类加载器,并显式地将某些包中类的加载直接重定向到引导加载器。

但在系统类加载器和引导类加载器中添加类也有其弊端。额外的类可能会减慢类路径扫描速度,甚至导致应用程序无法启动。请参见 elastic/apm-agent-java#364,以了解示例。此外,无法卸载这种持久类加载器的类,这在设计一个希望在运行时自行删除的agent时是个问题。

从概念上讲,只有两种方法可以克服这些类加载器问题(advice 类想要调用通常随agent一起提供的不同方法,但这些方法可能无法访问)。要么,必须将这些代码注入到插桩类的类加载器中,以便可以直接从那里查找这些方法。或者,必须定义一个新的类加载器,作为前一个类加载器的子类,现在可以通过实现这样一个自定义类加载器来找到任何其他类型。

对于第一种方法,Byte Buddy 自带的实用工具允许将类注入到任何类加载器中(net.bytebuddy.dynamic.loading.ClassInjector)。虽然这看起来是一个简单的解决方案,但却有很大的缺点。更灵活的注入器建立在内部 API(如 sun.misc.Unsafe / jdk.internal.misc.Unsafe)之上。此外,听起来更安全的类注入器策略(如 UsingReflection)也使用了巧妙的变通方法来规避最近 Java 版本中引入的保护措施,这些措施通常不允许使用 Unsafe::putBoolean 访问私有字段。到目前为止,限制访问内部 API 和在反射 API 中强制执行可见性的 Oracle 与发现可以规避这些措施的新漏洞之间,就像一场猫捉老鼠的游戏。同时,使用方法句柄查找的官方网关几乎与agent不兼容,其集成也是一个未决问题(https://bugs.openjdk.java.net/browse/JDK-8200559)。因此,使用当前不安全的 API 构建整个agent架构似乎相当冒险,而 Oracle 正致力于进一步锁定这些 API。

第二种方法是在子类加载器中加载所有 advice 类和辅助类。这种方法无需依赖不安全的 API,因为类加载器是由agent开发人员实现的,而且类加载器可以访问父类加载器定义的所有类型。

在专用类加载器中加载辅助类,而不是将其注入到插桩类的类加载器中的另一个好处是,可以卸载这些类。这样就可以将agent从应用程序中完全分离出来,并附加新版本的agent,而不会留下前一版本的任何痕迹,这也被称为实时更新agent。Byte Buddy 已经允许通过重新转换来恢复所有已应用的插桩类(Byte Buddy already allows reverting all the instrumentations it has applied via re-transformation)。当agent辅助类加载器的其他引用没有泄露时,这将使其所有对象、类甚至整个类加载器都符合垃圾回收的条件。

这种方法的一个复杂问题是,插桩类看不到 Advice 类。上例中的插桩方法 HttpServlet::service 通过静态方法调用 AdviceClass。这会在运行时导致 NoClassDefFoundError,因为 AdviceClassHttpServlet::service 方法的上下文中不可见。这是因为 AdviceClass 是由插桩类(HttpServlet)的子类加载器加载的。虽然 AdviceClass 可以访问插桩类可见的类,如 HttpServletRequest 参数,但反之则不行。

引入基于 invokedynamic 的 advice 分派方法

除了通过静态方法调用来调度 advice 外,还有另一种鲜为人知的方法。通过 net.bytebuddy.asm.Advice.WithCustomMapping::bootstrap,您可以指示 Byte Buddy 将 invokedynamic 字节码指令插入到插桩方法中。该指令是在 Java 7 中添加的,目的是更好地支持 JVM 中的动态语言,如 Groovy 和 JRuby。
简而言之,invokedynamic 调用包括两个阶段:查找 CallSite,然后调用 CallSite 持有的方法句柄。如果再次执行相同的 invokedynamic 指令,将调用最初查找的 CallSite。

下面的示例显示了 invokedynamic 指令在方法字节码中的样子。

// InvokeDynamic #1:exit:(Ljavax/servlet/ServletRequest;long)V</p> <p>invokedynamic #1076, 0

CallSite 的查找发生在所谓的引导方法中。该方法接收用于查找的几个参数,如 advice 类名称、方法名称以及代表参数和返回类型的 advice 方法类型。下面的示例展示了如何在类的字节码中声明引导方法。

BootstrapMethods:
  1: #1060 REF_invokeStatic java/lang/IndyBootstrapDispatcher.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite
    Method arguments:
      #1049 org.example.ServletAdvice
      #1050 1
      #12 javax/servlet/http/HttpServlet
      #1072 service
      #1075 REF_invokeVirtual javax/servlet/http/HttpServlet.service:(Ljavax/servlet/HttpServletRequest;Ljavax/servlet/HttpServletResponse;)V

包含引导方法的类(本例中为 java/lang/IndyBootstrapDispatcher.bootstrap)必须在任何插桩的类中可见。因此,需要将该类添加到引导类加载器中。为确保与过滤类加载器(如 OSGi 加载器)兼容,该类被放入 java.lang 包中。

虽然这种方法并不能完全避免类注入,但只注入一个类确实会减少agent添加的永恒类的数量,并在 JDK 的未来版本不再允许此类注入时减少重构现有agent的需要。

Elastic APM Java agent中,引导方法将创建一个新的类加载器,其父类加载器就是插桩类的类加载器,并从中加载 advice 和任意数量的助手。然后,我们可以从这个新创建的类加载器中加载 advice 类, advice 类名称作为参数提供给引导方法(方法参数:org.example.ServletAdvice)。

使用引导方法的其他参数,我们可以构建一个方法句柄(MethodHandle)和一个调用站点(CallSite),在我们创建的子类加载器中表示 advice 方法。根据我们的需要,目标方法总是相同的。因此,可以返回一个 ConstantCallSite,允许 JIT 内联(inline) advice 方法。

现在,我们只依赖一个类(java.lang.IndyBootstrapDispatcher)来显示插桩的方法,我们可以通过从专用类加载器加载非特定库的类来进一步隔离agent。如上一节所述,将agent的类从常规类加载器层次结构中隐藏起来可避免兼容性问题,例如与类路径扫描器的兼容性问题。它还允许agent发送任何依赖项,如 Byte Buddy 或日志库,而无需将依赖项隐藏(又称重定位)到agent的命名空间。这使得调试agent变得更加容易。由于使用了隔离的类加载器,因此无需担心应用程序的类加载器层次结构中可能存在的冲突类。有关隔离类加载器实现的更多详情,请参阅 Elastic APM Java agent的 ShadedClassLoader 源代码。

由此产生的类加载器层次结构如下所示:

alt 类加载器层次结构

类加载器层次结构

请注意,agent辅助类加载器(用于加载 advice 和特定于库的辅助类)有两个父类: helper类的类加载器(如 servlet 容器为每个网络应用程序创建的类加载器)和agent类加载器。这样, advice 类和辅助类就能访问从helper类的类加载器和agent类加载器中可见的两种类型。虽然内置类加载器不提供多父类,但自己实现多父类还是比较简单的。Byte Buddy 还提供了一个名为 net.bytebuddy.dynamic.loading.MultipleParentClassLoader 的实现。

总之,本节介绍了 invokedynamic 指令如何用于调用从插桩类的定义类加载器的子类加载器加载 advice 方法。这样,agent就可以将自己的类从应用程序中隐藏起来,同时提供一种方法来调用它所使用的应用程序类中的隔离方法。这一点非常有用,因为 advice 和该类加载器加载的所有其他类都可以访问helper库的类,而 advice 代码仍作为常规代码执行。这也避免了将 advice 和辅助类直接注入目标类加载器,而目前只有通过使用内部 API 才能做到这一点,Oracle 正致力于进一步锁定这些 API。

指定返回

虽然使用内联或委托的 advice 都是通过相同的 API 实现的,因此看起来非常相似,但两者还是有区别的。委托 advice 不能轻易地在插桩方法的作用域中写入值。当使用内联 advice 时, advice 方法可以简单地为注释参数赋值,然后 Byte Buddy 在内联过程中将其转换为替换所代表的值。举例来说,下面的内联 advice 将用一个也实现了 Runnable 接口的封装实例来替换插桩方法的第一个参数(这里是一个 Runnable),该封装实例将向agent报告任何未来的调用:

@Advice.OnMethodEnter
public static void enter(
        @Advice.Argument(value = 0, readOnly = false) Runnable callback) {
    callback = new TracingRunnable(callback);
}

由于上述代码是内联的,因此 advice 只是替换了分配给插桩方法第一个参数的值。因此,现在执行插桩方法时,就好像调用者已经传递了 TracingRunnable(callback)一样。

遗憾的是,在使用委托时,这种方法不起作用。使用委托时,新值只会被赋值给 advice 方法的参数,而不会影响插桩方法的赋值,因为插桩方法在执行 advice 方法后仍会携带原来的 runnable。

为了在使用委托 advice 时提供此类赋值,Byte Buddy 最近引入了 Advice.AssignReturned 后处理器。 advice 后处理器是在 advice 方法被派发后调用的处理程序,允许进行独立于所应用 advice 的其他操作。但最重要的是,即使 advice 本身是通过委托调用的,后处理器生成的代码也总是内联到插桩方法中。这样,如果 advice 方法返回了值,就可以在插桩方法的作用域中写入这些值。由于后处理器是常规 advice 实现的扩展,因此首先需要通过调用后处理器来手动注册:

Advice.withCustomBinding()
    .with(new Advice.AssignReturned.Factory());

顾名思义,这个后处理器允许将从 advice 方法返回的值赋值给插桩方法的参数。例如,要实现上述示例,我们可以指示后处理器将返回值赋值给插桩方法的第一个参数,就像之前所做的那样:

@Advice.OnMethodEnter(inline = false)
@Advice.AssignReturned.ToArguments(@ToArgument(0))
public static Runnable enter(@Advice.Argument(0) Runnable callback) {
    return new TracingRunnable(callback);
}

就像在内联示例中一样,插桩方法现在会将 TracingRunnable 作为其第一个参数,因为它已被后处理器替换。除了为参数赋值外,还可以为字段赋值、为方法的返回值赋值、为方法抛出的异常赋值,如果方法是非静态的,甚至还可以为方法的 this 引用赋值。

但在某些情况下,可能需要分配多个值。对于内联 advice ,这可以通过在 advice 方法中直接为每个注释参数赋多个值来直接实现。而对于委托 advice ,通过返回一个数组作为返回类型,并指定返回数组的哪个索引包含哪个值,同样可以轻松实现多个赋值。

为了扩展假设示例,假设插桩方法也需要执行器服务作为第二个参数,我们可以通过将其作为 advice 方法返回数组的第二个参数来强制使用新创建的缓存线程池。在注释 advice 方法的赋值时,每个赋值现在只需指明哪个数组索引代表哪个赋值。

@Advice.OnMethodEnter(inline = false)
@Advice.AssignReturned.ToArguments(
  @ToArgument(value = 0, index = 0, typing = DYNAMIC)、
  @ToArgument(value = 1, index = 1, typing = DYNAMIC))
public static Runnable enter(@Advice.Argument(0) Runnable callback) {
    return new Object[] {
        new TracingRunnable(callback)、
        Executors.newCachedThreadPool()
    };
}

最后,由于Object-typed数组可能包含不可赋值的值,因此注解必须指定使用动态类型。这样,Byte Buddy 就会在赋值前尝试对值进行类型转换。为避免潜在的 ClassCastException(类转换异常)影响插桩应用程序,可以配置后处理器来抑制这些异常。

new Advice.AssignReturned().Factory()
    .withSuppressed(ClassCastException.class)

如果在数组包含不可赋值的情况下没有配置动态类型,就会在类的检测过程中导致异常。除了失去检测功能外,应用程序不会受到影响。

权衡利弊

这种架构的局限之一是无法支持 Java 6 应用程序,因为它依赖于 Java 7 中新增的 invokedynamic 字节码指令。由于 Elastic APM Java agent从未支持过 Java 6,因此在这种情况下这并不是一个问题。许多其他agent甚至不再支持 Java 7,其市场份额仅约为 1-5%,具体取决于哪项研究。

除了要求 Java 7+ 之外,插桩类还必须达到字节码 51 级,这意味着它必须以 Java 7 或更高版本为目标进行编译。这是因为旧版本的类文件无法使用 invokedynamic 指令。有些库,尤其是agent可能希望使用的旧版 JDBC 驱动程序,有时会使用相当旧的类文件版本进行编译。不过有一个相对简单的解决方法。使用 ClassVisitor,我们可以让 ASM 将字节码重写为类文件版本 51(Java 7)。自从 Elastic APM Java agent引入这种方法以来,事实证明这是一种稳定可靠的方法。这确实会带来一些性能损失,但我们只需要在插桩类的类文件版本低于 51 的相对罕见情况下这样做。

另一个需要注意的问题是,早期版本的 Java 7(更新 60 之前,2014 年 5 月发布)和 Java 8(更新 40 之前,2015 年 3 月发布)在 invokedynamicMethodHandl 支持方面存在bugs。因此,如果检测到 Elastic APM Java agent在这些 JVM 版本上运行,它就会禁用自己。

参考资料

原文:https://www.elastic.co/cn/blog/embracing-invokedynamic-to-tame-class-loaders-in-java-agents/
项目:https://github.com/raphw/byte-buddy
项目:https://github.com/elastic/apm-agent-java

标签:Java,invokedynamic,advice,agent,方法,插桩,加载
From: https://www.cnblogs.com/wzgblogs/p/18151745

相关文章

  • JavaBean知识
    “感谢您阅读本篇博客!如果您觉得本文对您有所帮助或启发,请不吝点赞和分享给更多的朋友。您的支持是我持续创作的动力,也欢迎留言交流,让我们一起探讨技术,共同成长!谢谢!......
  • windows设置 java 的环境变量
    1.首先打开“我的电脑”里的环境变量如下的 ,选中 “属性” 2.选中属性之后打开高级系统设置 3.点击环境变量3.在系统变量中点击新建4.如图所示: 新建变量名:JAVA_HOME   变量值为:E:\ProgramFiles\java\jdk-22 (这个值是你自己下载jdk的路径)5.点......
  • 【JavaScript】微信小程序:高效性能优化策略与实践
    ​本文作者:黄启聪,碧桂园服务前端开发高级工程师,专注于运用前沿的Web技术提升工作效率,并致力于打造卓越的交互式用户体验。​01前言目前,凤凰会商城支持全国商城、门店、酒司令、地推、群接龙等多种业务,并且具备多端能力。一套代码可以在凤凰会APP、移动端H5和微信小程序中运行......
  • multi-agent框架camel学习笔记(二)RAG和向量数据库
    本系列想学习如何从零开始搭建一个multi-agent系统并融入到应用中,这篇文章主要写其中的LLM-agent的核心模块RAG和向量数据库,以及Camel系统中是如何使用RAG。1.为什么要用RAG(检索增强生成)先聊下什么是RAG,为什么我们要用RAG:RAG和向量数据库本身不是很新的技术,传统的搜广推里也......
  • Java+Selenium+edge自动化测试环境搭建
    查看edge版本:​​下载edge驱动:MicrosoftEdgeWebDriver|MicrosoftEdge开发人员​​在官网下载依赖包:Downloads|Selenium​​​​安装edge扩展:​​​​解压下载到的jar到一个文件夹,添加jar包:​​写一个自动化测试类:importorg.openqa.selenium.edge.EdgeDriv......
  • day18_我的Java学习笔记 (Logback日志框架、阶段项目--详见视频教程)
    1.日志框架1.1日志技术的概述1.2日志技术体系结构1.3Logback概述需要3个文件:1.4Logback快速入门1.4.1在项目下新建lib文件夹,导入Logback的相关jar包,并全选右键添加到项目依赖库中新建工程:logback-app将3个jar包拷贝到lib目录下全选,右键,选择......
  • oem 135 agent升级
    [oracle@prdb1936172334]$exportORACLE_HOME=/u01/agentapp/agent_13.5.0.0.0[oracle@prdb1936172334]$unzipp33355570_135000_Generic.zip-d$ORACLE_HOME[oracle@prdb1936172334]$$ORACLE_HOME/AgentPatcher/agentpatcherapply-analyzeAgentPatcherAutomati......
  • 6.Java数组
    Java数组数组概述相同类型数据的有序集合通过下标访问他们数组的声明与创建publicclassArrayDemo01{publicstaticvoidmain(String[]args){//变量类型变量名字=变量的值int[]nums;//1.声明一个数组首选//intnums2[]......
  • java解析html的table元素
    java解析html的table元素解析HTMLTable元素的Java实现在网页开发中,HTML的Table元素是用来展示数据的一种常见方式。有时候我们需要从网页中提取表格中的数据,这就需要使用Java对HTMLTable进行解析。本文将介绍如何使用Java实现对HTMLTable元素的解析,以及一些常......
  • docker Java 应用堆内存配置
    引言本文主要是讲解InitialRAMPercentage、MinRAMPercentage,MaxRAMPercentage三个JVM参数之间的区别。参数由Java8update191引入,主要是用于配置运行在物理机或者容器中的Java应用堆内存大小。InitialRAMPercentage-XX:InitialRAMPercentage用于配置堆的初始化......