从JavaAgent报错,到JVM原理,到glibc线程安全,再到pthreadtls,逐步探究JavaAgent的奇葩报错。背景由于阿里云的多个产品都提供了JavaAgent供用户使用,在多个JavaAgent一起使用的场景下,JavaAgent整体耗时增加,每个Agent单独存储,导致内存占用和资源消耗增加。于是我们推出了one-java-agent项目,可以配合各种JavaAgent;同时还支持更高效便捷的字节码注入。其中,每个JavaAgent作为one-java-agent的插件,在premain阶段通过多线程启动加载,从而将启动速度从O(n)降低到O(1),降低了整体成本Java代理作为一个整体。加载时间。问题但是最近在验证新版Agent的过程中,发现one-java-agent的premain阶段出现如下错误:2022-06-1609:51:09[oneagentplugina-java-agentstart]ERRORc.a.o.plugin.PluginManagerImpl-启动插件错误,名称:a-java-agentcom.alibaba.oneagent.plugin.PluginException:启动错误,agentjar::/path/to/one-java-agent/plugins/a-java-agent/a-java-agent-1.7.0-SNAPSHOT.jar位于com.alibaba.oneagent.plugin.TraditionalPlugin.start(TraditionalPlugin.java:113)位于com.alibaba.oneagent.plugin.PluginManagerImpl.startOnePlugin(PluginManagerImpl.java:294)在com.alibaba.oneagent.plugin.PluginManagerImpl.access$200(PluginManagerImpl.java:22)在com.alibaba.oneagent.plugin.PluginManagerImpl$2.run(PluginManagerImpl.java:325)在java.lang.Thread.run(Thread.java:750)引起:java.lang.InternalError:sun.instrument.InstrumentationImpl.appendToClassLoaderSearch0(本机方法)在com.ali的sun.instrument.InstrumentationImpl.appendToSystemClassLoaderSearch(InstrumentationImpl.java:200)为空baba.oneagent.plugin.TraditionalPlugin.start(TraditionalPlugin.java:100)...省略了4个公共框架2022-06-1609:51:09[oneagent插件b-java-agent启动]错误c.a.o.plugin.PluginManagerImpl-start插件错误,名称:b-java-agentcom.alibaba.oneagent.plugin.PluginException:启动错误,agentjar::/path/to/one-java-agent/plugins/b-java-agent/b-java-agent。jar在com.alibaba.oneagent.plugin.TraditionalPlugin.start(TraditionalPlugin.java:113)在com.alibaba.oneagent.plugin.PluginManagerImpl.startOnePlugin(PluginManagerImpl.java:294)在com.alibaba.oneagent.plugin.PluginManagerImpl。access$200(PluginManagerImpl.java:22)在com.alibaba.oneagent.plugin.PluginManagerImpl$2.run(PluginManagerImpl.java:325)在java.lang.Thread.run(Thread.java:855)Causedby:java.lang.IllegalArgumentException:nullatsun.instrument.InstrumentationImpl.appendToClassLoaderSearch0(NativeMethod)atsun.instrument.InstrumentationImpl.appendToSystemClassLoaderSearch(InstrumentationImpl.java:200)atcom.alibaba.oneagent.plugin.TraditionalPlugin.start(TraditionalPlugin.java:100)...省略了4个常用框架熟悉JavaAgent的同学可能会注意到这是调用Instrumentation时的错误。appendToSystemClassLoaderSearch但首先,appendToSystemClassLoaderSearch的路径是存在的;其次,这个错误的真正原因在C++部分,很难排查。但无论如何,还是有必要深究为什么会出现这个错误。首先我们梳理一下具体的调用流程。下面的分析就是基于这个分析(当然,这些图也是解题后推导出来的):`-create_class_path_zip_entry|`-stat`-convertUft8ToPlatformString`-iconv登录确认站点因为这个问题在容器环境下有10%的概率,所以比较容易复现,所以我使用最新版的dragonwell8代码,添加日志,确认网站。首先在JNI的实际入口,即appendToClassLoaderSearch的方法入口添加一条日志:加上上面的日志后,问题就更秃了:没有报错的时候,会输出appendToClassLoaderSearch入口。报错时,appendToClassLoaderSearch入口没有输出。这里没有实现吗?这与错误日志不匹配。难道是stacktrace信息欺骗了我们?难熬了一夜,第二天问了龙井的同学。logger的姿势是这样的:tty->print_cr("internalerror");如果上述方法不起作用,请使用printf("xxx\n");fflush(stdout);这样添加日志后,我们的日志就可以打出来了。这是第一个踩的坑。printf需要加上fflush才能保证输出成功。分析代码后不断添加日志,最后发现原因是create_class_path_zip_entry返回NULL。找不到对应的jar文件?继续查看,发现stat报错,返回Nosuchfileordirectory。但是前面说了,jarFile的路径是存在的,stat线程不安全吗?查看了stat相关文档[1],发现stat系统调用是线程安全的。于是回头看,发现stat的路径不正常:有时路径为空,有时路径为/path/to/b-java-agent/b-java-agent.jarSHOT.jar,可以看到从字符的末尾开始,基本上是两个字符写入同一块内存造成的;而相应字符串的长度也变成了一个不规则的数字。那么问题就很明确了,开始寻找这个字符串的生成。此字符由convertUft8ToPlatformString生成。字符编码转换问题?于是开始调试utf8ToPlatform的逻辑。这个时候为了避免频繁的添加日志、编译、重启容器,我直接在ECS上运行gdb来调试jvm。原来在linux下,utf8ToPlatform是直接memcpy,memcpy的目标地址分配在栈上:这样不太可能有线程安全问题吧?后来仔细查看,发现是跟环境变量有关。ECS上编码相关的环境变量为LANG=en_US.UTF-8;在容器上,centos:7默认是没有这个环境变量的。在这种情况下,jvm读取到达的是ANSI_X3.4-1968。这块可以参考nl_langinfo(CODESET)的文档:https://man7.org/linux/man-pa...这里是第二个坑,环境变量会影响本地代码转换。结合以上现象和代码,发现在容器环境下,还是需要经过iconv从UTF-8转成ANSI_X3.4-1968编码。其实这里也可以推断,如果在容器中手动设置LANG=en_US.UTF-8,就不会再出现这个问题。额外的验证也证实了这一点。言归正传,再补充log,最后确认是iconv的时候,目标字符串乱了。难道iconv是线程不安全的吗?iconv不是线程安全的!查看iconv的文档,发现它并不是完全线程安全的:说明一下,在iconv之前,需要用iconv_open开启一个iconv_t,而这个iconv_t不支持多线程同时使用。至此,问题已经定位的差不多了:因为jvm把iconv_t写成了一个全局变量,所以当多个线程同时调用appendToClassLoaderSearch0时,有可能同时调用iconv,造成racecondition,把string弄乱写作。这里是第三个坑,iconv不是线程安全的。Howtofixfirstfixone-java-agent对于Java代码,修改起来非常容易,只需要加一把锁即可:但是设计有问题,instrument对象一直散落在代码中,现在突然需要加一把锁,几乎所有用到的地方都要改,代码改造成本比较高。所以最终通过代理类解决了:这样其他地方只需要使用InstrumentationWrapper,就不会触发这个问题。Java9+处理但是注意在JDK9中,Instrumentation接口增加了redefineModule/isModifiableModule方法。在新版本的JVM下,上图中的InstrumentationWrapper会报错,因为没有这两个方法。本质上,JDK对Instrumentation接口做了不兼容的修改,这种修改很难通过手动代理兼容。所以只能使用JDKProxy来实现。主要代码如图:就这样,one-java-agent终于彻底修复了这个bug。应该修复jvm吗?然后我们分析jvm端的代码,发现appendToClassLoaderSearch0方法不是线程安全的,因为iconv_t不是线程安全的。这个问题能优雅的解决吗?如果是Java程序,我们可以直接使用ThreadLoal来存储iconv_t来解决这个问题。但是在cpp这边,虽然C++11支持thread_local,但是首先jdk8还没有使用C++11(这个可以参考JEP347[2]);其次,C++11只支持thread_local的set和get,thread_local的初始化和销毁??等,还不支持生命周期管理,比如没有办法在线程结束时自动回收iconv_t资源。然后让我们回退到pthread?因为pthread提供线程特定的数据,所以可以做类似的事情。pthread_key_create创建一个线程本地存储区pthread_setspecific用于将值放入线程本地存储pthread_getspecific用于从线程本地存储中取出值最重要的是,pthread_once满足了pthread_key_t只能初始化一次的要求。另外需要说明的是,pthread_once的第二个参数是线程结束时的回调,我们可以用它来关闭iconv_t,避免资源泄露。综上所述,pthread提供了thread_local的全生命周期管理。所以,最后的代码如下,使用pthread_once初始化线程局部存储,然后给每个线程分配一个iconv_t:接下来编译JDK,创建镜像,分批重启pod验证几次,上面提到的问题文章开头将不再出现。总结一下,整个过程中,我们从Java到JNI/JVMTi,再到glibc,再到pthread,踩了很多坑:printf需要加fflush保证输出成功环境变量会影响本地字符encodingconversioniconvisnotthread-safe使用pthread线程局部存储实现线程局部变量的全生命周期管理从这个案例开始,沿着调用栈和代码,逐步还原和修复问题,希望大家多多学习通过这个案例了解Java/JVM。参考资料one-java-agent修复链接:https://github.com/alibaba/on...dragonwell8修复链接:https://github.com/alibaba/dr...[1]stat相关文档:https://pubs.opengroup.org/on...[2]JEP347:https://openjdk.org/jeps/347原文链接本文为阿里云原创内容,未经允许不得转载。
