研发人员在遇到线上报警或须要优化系统性能时,经常须要分析程序运行行为和性能瓶颈。Profiling技术是一种在应用运行时收集程序相关信息的动态分析手段,经常使用的JVM Profiler能够从多个方面对程序进行动态分析,如CPU、Memory、Thread、Classes、GC等,其中CPU Profiling的应用最为普遍。CPU Profiling常常被用于分析代码的执行热点,如“哪一个方法占用CPU的执行时间最长”、“每一个方法占用CPU的比例是多少”等等,经过CPU Profiling获得上述相关信息后,研发人员就能够轻松针对热点瓶颈进行分析和性能优化,进而突破性能瓶颈,大幅提高系统的吞吐量。html
本文介绍了JVM平台上CPU Profiler的实现原理,但愿能帮助读者在使用相似工具的同时也能清楚其内部的技术实现。java
社区实现的JVM Profiler不少,好比已经商用且功能强大的JProfiler,也有免费开源的产品,如JVM-Profiler,功能各有所长。咱们平常使用的Intellij IDEA最新版内部也集成了一个简单好用的Profiler,详细的介绍参见官方Blog。linux
在用IDEA打开须要诊断的Java项目后,在“Preferences -> Build, Execution, Deployment -> Java Profiler”界面添加一个“CPU Profiler”,而后回到项目,单击右上角的“Run with Profiler”启动项目并开始CPU Profiling过程。必定时间后(推荐5min),在Profiler界面点击“Stop Profiling and Show Results”,便可看到Profiling的结果,包含火焰图和调用树,以下图所示:git
火焰图是根据调用栈的样本集生成的可视化性能分析图,《如何读懂火焰图?》一文对火焰图进行了不错的讲解,你们能够参考一下。简而言之,看火焰图时咱们须要关注“平顶”,由于那里就是咱们程序的CPU热点。调用树是另外一种可视化分析的手段,与火焰图同样,也是根据同一份样本集而生成,按需选择便可。github
这里要说明一下,由于咱们没有在项目中引入任何依赖,仅仅是“Run with Profiler”,Profiler就能获取咱们程序运行时的信息。这个功能实际上是经过JVM Agent实现的,为了更好地帮助你们系统性的了解它,咱们在这里先对JVM Agent作个简单的介绍。数据库
JVM Agent是一个按必定规则编写的特殊程序库,能够在启动阶段经过命令行参数传递给JVM,做为一个伴生库与目标JVM运行在同一个进程中。在Agent中能够经过固定的接口获取JVM进程内的相关信息。Agent既能够是用C/C++/Rust编写的JVMTI Agent,也能够是用Java编写的Java Agent。macos
执行Java命令,咱们能够看到Agent相关的命令行参数:编程
Plain Text -agentlib:<库名>[=<选项>] 加载本机代理库 <库名>, 例如 -agentlib:jdwp 另请参阅 -agentlib:jdwp=help -agentpath:<路径名>[=<选项>] 按完整路径名加载本机代理库 -javaagent:<jar 路径>[=<选项>] 加载 Java 编程语言代理, 请参阅 java.lang.instrument
JVMTI(JVM Tool Interface)是JVM提供的一套标准的C/C++编程接口,是实现Debugger、Profiler、Monitor、Thread Analyser等工具的统一基础,在主流Java虚拟机中都有实现。api
当咱们要基于JVMTI实现一个Agent时,须要实现以下入口函数:数组
// $JAVA_HOME/include/jvmti.h JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
使用C/C++实现该函数,并将代码编译为动态链接库(Linux上是.so),经过-agentpath参数将库的完整路径传递给Java进程,JVM就会在启动阶段的合适时机执行该函数。在函数内部,咱们能够经过JavaVM指针参数拿到JNI和JVMTI的函数指针表,这样咱们就拥有了与JVM进行各类复杂交互的能力。
更多JVMTI相关的细节能够参考官方文档。
在不少场景下,咱们没有必要必须使用C/C++来开发JVMTI Agent,由于成本高且不易维护。JVM自身基于JVMTI封装了一套Java的Instrument API接口,容许使用Java语言开发Java Agent(只是一个jar包),大大下降了Agent的开发成本。社区开源的产品如Greys、Arthas、JVM-Sandbox、JVM-Profiler等都是纯Java编写的,也是以Java Agent形式来运行。
在Java Agent中,咱们须要在jar包的MANIFEST.MF中将Premain-Class指定为一个入口类,并在该入口类中实现以下方法:
public static void premain(String args, Instrumentation ins) { // implement }
这样打包出来的jar就是一个Java Agent,能够经过-javaagent参数将jar传递给Java进程伴随启动,JVM一样会在启动阶段的合适时机执行该方法。
在该方法内部,参数Instrumentation接口提供了Retransform Classes的能力,咱们利用该接口就能够对宿主进程的Class进行修改,实现方法耗时统计、故障注入、Trace等功能。Instrumentation接口提供的能力较为单一,仅与Class字节码操做相关,但因为咱们如今已经处于宿主进程环境内,就能够利用JMX直接获取宿主进程的内存、线程、锁等信息。不管是Instrument API仍是JMX,它们内部还是统一基于JVMTI来实现。
更多Instrument API相关的细节能够参考官方文档。
在了解完Profiler如何以Agent的形式执行后,咱们能够开始尝试构造一个简单的CPU Profiler。但在此以前,还有必要了解下CPU Profiling技术的两种实现方式及其区别。
使用过JProfiler的同窗应该都知道,JProfiler的CPU Profiling功能提供了两种方式选项: Sampling和Instrumentation,它们也是实现CPU Profiler的两种手段。
Sampling方式顾名思义,基于对StackTrace的“采样”进行实现,核心原理以下:
Instrumentation则是利用Instrument API,对全部必要的Class进行字节码加强,在进入每一个方法前进行埋点,方法执行结束后统计本次方法执行耗时,最终进行汇总。两者都能获得想要的结果,那么它们有什么区别呢?或者说,孰优孰劣?
Instrumentation方式对几乎全部方法添加了额外的AOP逻辑,这会致使对线上服务形成巨额的性能影响,但其优点是:绝对精准的方法调用次数、调用时间统计。
Sampling方式基于无侵入的额外线程对全部线程的调用栈快照进行固定频率抽样,相对前者来讲它的性能开销很低。但因为它基于“采样”的模式,以及JVM固有的只能在安全点(Safe Point)进行采样的“缺陷”,会致使统计结果存在必定的误差。譬如说:某些方法执行时间极短,但执行频率很高,真实占用了大量的CPU Time,但Sampling Profiler的采样周期不能无限调小,这会致使性能开销骤增,因此会致使大量的样本调用栈中并不存在刚才提到的”高频小方法“,进而致使最终结果没法反映真实的CPU热点。更多Sampling相关的问题能够参考《Why (Most) Sampling Java Profilers Are Fucking Terrible》。
具体到“孰优孰劣”的问题层面,这两种实现技术并无很是明显的高下之判,只有在分场景讨论下才有意义。Sampling因为低开销的特性,更适合用在CPU密集型的应用中,以及不可接受大量性能开销的线上服务中。而Instrumentation则更适合用在I/O密集的应用中、对性能开销不敏感以及确实须要精确统计的场景中。社区的Profiler更多的是基于Sampling来实现,本文也是基于Sampling来进行讲解。
一个最简单的Sampling CPU Profiler能够用Java Agent + JMX方式来实现。以Java Agent为入口,进入目标JVM进程后开启一个ScheduledExecutorService,定时利用JMX的threadMXBean.dumpAllThreads()来导出全部线程的StackTrace,最终汇总并导出便可。
Uber的JVM-Profiler实现原理也是如此,关键部分代码以下:
// com/uber/profiling/profilers/StacktraceCollectorProfiler.java /* * StacktraceCollectorProfiler等同于文中所述CpuProfiler,仅命名偏好不一样而已 * jvm-profiler的CpuProfiler指代的是CpuLoad指标的Profiler */ // 实现了Profiler接口,外部由统一的ScheduledExecutorService对全部Profiler定时执行 @Override public void profile() { ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // ... for (ThreadInfo threadInfo : threadInfos) { String threadName = threadInfo.getThreadName(); // ... StackTraceElement[] stackTraceElements = threadInfo.getStackTrace(); // ... for (int i = stackTraceElements.length - 1; i >= 0; i--) { StackTraceElement stackTraceElement = stackTraceElements[i]; // ... } // ... } }
Uber提供的定时器默认Interval是100ms,对于CPU Profiler来讲,这略显粗糙。但因为dumpAllThreads()的执行开销不容小觑,Interval不宜设置的太小,因此该方法的CPU Profiling结果会存在不小的偏差。
JVM-Profiler的优势在于支持多种指标的Profiling(StackTrace、CPUBusy、Memory、I/O、Method),且支持将Profiling结果经过Kafka上报回中心Server进行分析,也即支持集群诊断。
使用Java实现Profiler相对较简单,但也存在一些问题,譬如说Java Agent代码与业务代码共享AppClassLoader,被JVM直接加载的agent.jar若是引入了第三方依赖,可能会对业务Class形成污染。截止发稿时,JVM-Profiler都存在这个问题,它引入了Kafka-Client、http-Client、Jackson等组件,若是与业务代码中的组件版本发生冲突,可能会引起未知错误。Greys/Arthas/JVM-Sandbox的解决方式是分离入口与核心代码,使用定制的ClassLoader加载核心代码,避免影响业务代码。
在更底层的C/C++层面,咱们能够直接对接JVMTI接口,使用原生C API对JVM进行操做,功能更丰富更强大,但开发效率偏低。基于上节一样的原理开发CPU Profiler,使用JVMTI须要进行以下这些步骤:
1.编写Agent_OnLoad(),在入口经过JNI的JavaVM*指针的GetEnv()函数拿到JVMTI的jvmtiEnv指针:
// agent.c JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) { jvmtiEnv *jvmti; (*vm)->GetEnv((void **)&jvmti, JVMTI_VERSION_1_0); // ... return JNI_OK; }
2.开启一个线程定时循环,定时使用jvmtiEnv指针配合调用以下几个JVMTI函数:
// 获取全部线程的jthread jvmtiError GetAllThreads(jvmtiEnv *env, jint *threads_count_ptr, jthread **threads_ptr); // 根据jthread获取该线程信息(name、daemon、priority...) jvmtiError GetThreadInfo(jvmtiEnv *env, jthread thread, jvmtiThreadInfo* info_ptr); // 根据jthread获取该线程调用栈 jvmtiError GetStackTrace(jvmtiEnv *env, jthread thread, jint start_depth, jint max_frame_count, jvmtiFrameInfo *frame_buffer, jint *count_ptr);
主逻辑大体是:首先调用GetAllThreads()获取全部线程的“句柄”jthread,而后遍历根据jthread调用GetThreadInfo()获取线程信息,按线程名过滤掉不须要的线程后,继续遍历根据jthread调用GetStackTrace()获取线程的调用栈。
3.在Buffer中保存每一次的采样结果,最终生成必要的统计数据便可。
按如上步骤便可实现基于JVMTI的CPU Profiler。但须要说明的是,即使是基于原生JVMTI接口使用GetStackTrace()的方式获取调用栈,也存在与JMX相同的问题——只能在安全点(Safe Point)进行采样。
基于Sampling的CPU Profiler经过采集程序在不一样时间点的调用栈样原本近似地推算出热点方法,所以,从理论上来说Sampling CPU Profiler必须遵循如下两个原则:
若是只能在安全点采样,就违背了第二条原则。由于咱们只能采集到位于安全点时刻的调用栈快照,意味着某些代码可能永远没有机会被采样,即便它真实耗费了大量的CPU执行时间,这种现象被称为“SafePoint Bias”。
上文咱们提到,基于JMX与基于JVMTI的Profiler实现都存在SafePoint Bias,但一个值得了解的细节是:单独来讲,JVMTI的GetStackTrace()函数并不须要在Caller的安全点执行,但当调用GetStackTrace()获取其余线程的调用栈时,必须等待,直到目标线程进入安全点;并且,GetStackTrace()仅能经过单独的线程同步定时调用,不能在UNIX信号处理器的Handler中被异步调用。综合来讲,GetStackTrace()存在与JMX同样的SafePoint Bias。更多安全点相关的知识能够参考《Safepoints: Meaning, Side Effects and Overheads》。
那么,如何避免SafePoint Bias?社区提供了一种Hack思路——AsyncGetCallTrace。
如上节所述,假如咱们拥有一个函数能够获取当前线程的调用栈且不受安全点干扰,另外它还支持在UNIX信号处理器中被异步调用,那么咱们只需注册一个UNIX信号处理器,在Handler中调用该函数获取当前线程的调用栈便可。因为UNIX信号会被发送给进程的随机一线程进行处理,所以最终信号会均匀分布在全部线程上,也就均匀获取了全部线程的调用栈样本。
OracleJDK/OpenJDK内部提供了这么一个函数——AsyncGetCallTrace,它的原型以下:
// 栈帧 typedef struct { jint lineno; jmethodID method_id; } AGCT_CallFrame; // 调用栈 typedef struct { JNIEnv *env; jint num_frames; AGCT_CallFrame *frames; } AGCT_CallTrace; // 根据ucontext将调用栈填充进trace指针 void AsyncGetCallTrace(AGCT_CallTrace *trace, jint depth, void *ucontext);
经过原型能够看到,该函数的使用方式很是简洁,直接经过ucontext就能获取到完整的Java调用栈。
顾名思义,AsyncGetCallTrace是“async”的,不受安全点影响,这样的话采样就可能发生在任什么时候间,包括Native代码执行期间、GC期间等,在这时咱们是没法获取Java调用栈的,AGCT_CallTrace的num_frames字段正常状况下标识了获取到的调用栈深度,但在如前所述的异常状况下它就表示为负数,最多见的-2表明此刻正在GC。
因为AsyncGetCallTrace非标准JVMTI函数,所以咱们没法在jvmti.h中找到该函数声明,且因为其目标文件也早已连接进JVM二进制文件中,因此没法经过简单的声明来获取该函数的地址,这须要经过一些Trick方式来解决。简单说,Agent最终是做为动态连接库加载到目标JVM进程的地址空间中,所以能够在Agent_OnLoad内经过glibc提供的dlsym()函数拿到当前地址空间(即目标JVM进程地址空间)名为“AsyncGetCallTrace”的符号地址。这样就拿到了该函数的指针,按照上述原型进行类型转换后,就能够正常调用了。
经过AsyncGetCallTrace实现CPU Profiler的大体流程:
1.编写Agent_OnLoad(),在入口拿到jvmtiEnv和AsyncGetCallTrace指针,获取AsyncGetCallTrace方式以下:
typedef void (*AsyncGetCallTrace)(AGCT_CallTrace *traces, jint depth, void *ucontext); // ... AsyncGetCallTrace agct_ptr = (AsyncGetCallTrace)dlsym(RTLD_DEFAULT, "AsyncGetCallTrace"); if (agct_ptr == NULL) { void *libjvm = dlopen("libjvm.so", RTLD_NOW); if (!libjvm) { // 处理dlerror()... } agct_ptr = (AsyncGetCallTrace)dlsym(libjvm, "AsyncGetCallTrace"); }
2.在OnLoad阶段,咱们还须要作一件事,即注册OnClassLoad和OnClassPrepare这两个Hook,缘由是jmethodID是延迟分配的,使用AGCT获取Traces依赖预先分配好的数据。咱们在OnClassPrepare的CallBack中尝试获取该Class的全部Methods,这样就使JVMTI提早分配了全部方法的jmethodID,以下所示:
void JNICALL OnClassLoad(jvmtiEnv *jvmti, JNIEnv* jni, jthread thread, jclass klass) {} void JNICALL OnClassPrepare(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jclass klass) { jint method_count; jmethodID *methods; jvmti->GetClassMethods(klass, &method_count, &methods); delete [] methods; } // ... jvmtiEventCallbacks callbacks = {0}; callbacks.ClassLoad = OnClassLoad; callbacks.ClassPrepare = OnClassPrepare; jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_LOAD, NULL); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_PREPARE, NULL);
3.利用SIGPROF信号来进行定时采样:
// 这里信号handler传进来的的ucontext即AsyncGetCallTrace须要的ucontext void signal_handler(int signo, siginfo_t *siginfo, void *ucontext) { // 使用AsyncCallTrace进行采样,注意处理num_frames为负的异常状况 } // ... // 注册SIGPROF信号的handler struct sigaction sa; sigemptyset(&sa.sa_mask); sa.sa_sigaction = signal_handler; sa.sa_flags = SA_RESTART | SA_SIGINFO; sigaction(SIGPROF, &sa, NULL); // 定时产生SIGPROF信号 // interval是nanoseconds表示的采样间隔,AsyncGetCallTrace相对于同步采样来讲能够适当高频一些 long sec = interval / 1000000000; long usec = (interval % 1000000000) / 1000; struct itimerval tv = {{sec, usec}, {sec, usec}}; setitimer(ITIMER_PROF, &tv, NULL);
4.在Buffer中保存每一次的采样结果,最终生成必要的统计数据便可。
按如上步骤便可实现基于AsyncGetCallTrace的CPU Profiler,这是社区中目前性能开销最低、相对效率最高的CPU Profiler实现方式,在Linux环境下结合perf_events还能作到同时采样Java栈与Native栈,也就能同时分析Native代码中存在的性能热点。该方式的典型开源实现有Async-Profiler和Honest-Profiler,Async-Profiler实现质量较高,感兴趣的话建议你们阅读参考文章。有趣的是,IntelliJ IDEA内置的Java Profiler,其实就是Async-Profiler的包装。更多关于AsyncGetCallTrace的内容,你们能够参考《The Pros and Cons of AsyncGetCallTrace Profilers》。
如今咱们拥有了采样调用栈的能力,可是调用栈样本集是以二维数组的数据结构形式存在于内存中的,如何将其转换为可视化的火焰图呢?
火焰图一般是一个svg文件,部分优秀项目能够根据文本文件自动生成火焰图文件,仅对文本文件的格式有必定要求。FlameGraph项目的核心只是一个Perl脚本,能够根据咱们提供的调用栈文本生成相应的火焰图svg文件。调用栈的文本格式至关简单,以下所示:
base_func;func1;func2;func3 10 base_func;funca;funcb 15
将咱们采样到的调用栈样本集进行整合后,需输出如上所示的文本格式。每一行表明一“类“调用栈,空格左边是调用栈的方法名排列,以分号分割,左栈底右栈顶,空格右边是该样本出现的次数。
将样本文件交给flamegraph.pl脚本执行,就能输出相应的火焰图了:
$ flamegraph.pl stacktraces.txt > stacktraces.svg
效果以下图所示:
到目前为止,咱们已经了解了CPU Profiler完整的工做原理,然而使用过JProfiler/Arthas的同窗可能会有疑问,不少状况下能够直接对线上运行中的服务进行Profling,并不须要在Java进程的启动参数添加Agent参数,这是经过什么手段作到的?答案是Dynamic Attach。
JDK在1.6之后提供了Attach API,容许向运行中的JVM进程添加Agent,这项手段被普遍使用在各类Profiler和字节码加强工具中,其官方简介以下:
This is a Sun extension that allows a tool to 'attach' to another process running Java code and launch a JVM TI agent or a java.lang.instrument agent in that process.
总的来讲,Dynamic Attach是HotSpot提供的一种特殊能力,它容许一个进程向另外一个运行中的JVM进程发送一些命令并执行,命令并不限于加载Agent,还包括Dump内存、Dump线程等等。
Attach虽然是HotSpot提供的能力,但JDK在Java层面也对其作了封装。
前文已经提到,对于Java Agent来讲,PreMain方法在Agent做为启动参数运行的时候执行,其实咱们还能够额外实现一个AgentMain方法,并在MANIFEST.MF中将Agent-Class指定为该Class:
public static void agentmain(String args, Instrumentation ins) { // implement }
这样打包出来的jar,既能够做为-javaagent参数启动,也能够被Attach到运行中的目标JVM进程。JDK已经封装了简单的API让咱们直接Attach一个Java Agent,下面以Arthas中的代码进行演示:
// com/taobao/arthas/core/Arthas.java import com.sun.tools.attach.VirtualMachine; import com.sun.tools.attach.VirtualMachineDescriptor; // ... private void attachAgent(Configure configure) throws Exception { VirtualMachineDescriptor virtualMachineDescriptor = null; // 拿到全部JVM进程,找出目标进程 for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) { String pid = descriptor.id(); if (pid.equals(Integer.toString(configure.getJavaPid()))) { virtualMachineDescriptor = descriptor; } } VirtualMachine virtualMachine = null; try { // 针对某个JVM进程调用VirtualMachine.attach()方法,拿到VirtualMachine实例 if (null == virtualMachineDescriptor) { virtualMachine = VirtualMachine.attach("" + configure.getJavaPid()); } else { virtualMachine = VirtualMachine.attach(virtualMachineDescriptor); } // ... // 调用VirtualMachine#loadAgent(),将arthasAgentPath指定的jar attach到目标JVM进程中 // 第二个参数为attach参数,即agentmain的首个String参数args virtualMachine.loadAgent(arthasAgentPath, configure.getArthasCore() + ";" + configure.toString()); } finally { if (null != virtualMachine) { // 调用VirtualMachine#detach()释放 virtualMachine.detach(); } } }
sun.tools封装的API足够简单易用,但只能使用Java编写,也只能用在Java Agent上,所以有些时候咱们必须手工对JVM进程直接进行Attach。对于JVMTI,除了Agent_OnLoad()以外,咱们还需实现一个Agent_OnAttach()函数,当将JVMTI Agent Attach到目标进程时,从该函数开始执行:
// $JAVA_HOME/include/jvmti.h JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options, void *reserved);
下面咱们以Async-Profiler中的jattach源码为线索,探究一下如何利用Attach机制给运行中的JVM进程发送命令。jattach是Async-Profiler提供的一个Driver,使用方式比较直观:
Usage: jattach <pid> <cmd> [args ...] Args: <pid> 目标JVM进程的进程ID <cmd> 要执行的命令 <args> 命令参数
使用方式如:
$ jattach 1234 load /absolute/path/to/agent/libagent.so true
执行上述命令,libagent.so就被加载到ID为1234的JVM进程中并开始执行Agent_OnAttach函数了。有一点须要注意,执行Attach的进程euid及egid,与被Attach的目标JVM进程必须相同。接下来开始分析jattach源码。
以下所示的Main函数描述了一次Attach的总体流程:
// async-profiler/src/jattach/jattach.c int main(int argc, char** argv) { // 解析命令行参数 // 检查euid与egid // ... if (!check_socket(nspid) && !start_attach_mechanism(pid, nspid)) { perror("Could not start attach mechanism"); return 1; } int fd = connect_socket(nspid); if (fd == -1) { perror("Could not connect to socket"); return 1; } printf("Connected to remote JVM\n"); if (!write_command(fd, argc - 2, argv + 2)) { perror("Error writing to socket"); close(fd); return 1; } printf("Response code = "); fflush(stdout); int result = read_response(fd); close(fd); return result; }
忽略掉命令行参数解析与检查euid和egid的过程。jattach首先调用了check_socket函数进行了“socket检查?”,check_socket源码以下:
// async-profiler/src/jattach/jattach.c // Check if remote JVM has already opened socket for Dynamic Attach static int check_socket(int pid) { char path[MAX_PATH]; snprintf(path, MAX_PATH, "%s/.java_pid%d", get_temp_directory(), pid); // get_temp_directory()在Linux下固定返回"/tmp" struct stat stats; return stat(path, &stats) == 0 && S_ISSOCK(stats.st_mode); }
咱们知道,UNIX操做系统提供了一种基于文件的Socket接口,称为“UNIX Socket”(一种经常使用的进程间通讯方式)。在该函数中使用S_ISSOCK宏来判断该文件是否被绑定到了UNIX Socket,如此看来,“/tmp/.java_pid<pid>”文件颇有可能就是外部进程与JVM进程间通讯的桥梁。
查阅官方文档,获得以下描述:
The attach listener thread then communicates with the source JVM in an OS dependent manner:
证实了咱们的猜测是正确的。目前为止check_socket函数的做用很容易理解了:判断外部进程与目标JVM进程之间是否已经创建了UNIX Socket链接。
回到Main函数,在使用check_socket肯定链接还没有创建后,紧接着调用start_attach_mechanism函数,函数名很直观地描述了它的做用,源码以下:
// async-profiler/src/jattach/jattach.c // Force remote JVM to start Attach listener. // HotSpot will start Attach listener in response to SIGQUIT if it sees .attach_pid file static int start_attach_mechanism(int pid, int nspid) { char path[MAX_PATH]; snprintf(path, MAX_PATH, "/proc/%d/cwd/.attach_pid%d", nspid, nspid); int fd = creat(path, 0660); if (fd == -1 || (close(fd) == 0 && !check_file_owner(path))) { // Failed to create attach trigger in current directory. Retry in /tmp snprintf(path, MAX_PATH, "%s/.attach_pid%d", get_temp_directory(), nspid); fd = creat(path, 0660); if (fd == -1) { return 0; } close(fd); } // We have to still use the host namespace pid here for the kill() call kill(pid, SIGQUIT); // Start with 20 ms sleep and increment delay each iteration struct timespec ts = {0, 20000000}; int result; do { nanosleep(&ts, NULL); result = check_socket(nspid); } while (!result && (ts.tv_nsec += 20000000) < 300000000); unlink(path); return result; }
start_attach_mechanism函数首先建立了一个名为“/tmp/.attach_pid<pid>”的空文件,而后向目标JVM进程发送了一个SIGQUIT信号,这个信号彷佛触发了JVM的某种机制?紧接着,start_attach_mechanism函数开始陷入了一种等待,每20ms调用一次check_socket函数检查链接是否被创建,若是等了300ms尚未成功就放弃。函数的最后调用Unlink删掉.attach_pid<pid>文件并返回。
如此看来,HotSpot彷佛提供了一种特殊的机制,只要给它发送一个SIGQUIT信号,并预先准备好.attach_pid<pid>文件,HotSpot会主动建立一个地址为“/tmp/.java_pid<pid>”的UNIX Socket,接下来主动Connect这个地址便可创建链接执行命令。
查阅文档,获得以下描述:
Dynamic attach has an attach listener thread in the target JVM. This is a thread that is started when the first attach request occurs. On Linux and Solaris, the client creates a file named .attach_pid(pid) and sends a SIGQUIT to the target JVM process. The existence of this file causes the SIGQUIT handler in HotSpot to start the attach listener thread. On Windows, the client uses the Win32 CreateRemoteThread function to create a new thread in the target process.
这样一来就很明确了,在Linux上咱们只需建立一个“/tmp/.attach_pid<pid>”文件,并向目标JVM进程发送一个SIGQUIT信号,HotSpot就会开始监听“/tmp/.java_pid<pid>”地址上的UNIX Socket,接收并执行相关Attach的命令。至于为何必定要建立.attach_pid<pid>文件才能够触发Attach Listener的建立,经查阅资料,咱们获得了两种说法:一是JVM不止接收从外部Attach进程发送的SIGQUIT信号,必须配合外部进程建立的外部文件才能肯定这是一次Attach请求;二是为了安全。
继续看jattach的源码,果不其然,它调用了connect_socket函数对“/tmp/.java_pid<pid>”进行链接,connect_socket源码以下:
// async-profiler/src/jattach/jattach.c // Connect to UNIX domain socket created by JVM for Dynamic Attach static int connect_socket(int pid) { int fd = socket(PF_UNIX, SOCK_STREAM, 0); if (fd == -1) { return -1; } struct sockaddr_un addr; addr.sun_family = AF_UNIX; snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/.java_pid%d", get_temp_directory(), pid); if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { close(fd); return -1; } return fd; }
一个很普通的Socket建立函数,返回Socket文件描述符。
回到Main函数,主流程紧接着调用write_command函数向该Socket写入了从命令行传进来的参数,而且调用read_response函数接收从目标JVM进程返回的数据。两个很常见的Socket读写函数,源码以下:
// async-profiler/src/jattach/jattach.c // Send command with arguments to socket static int write_command(int fd, int argc, char** argv) { // Protocol version if (write(fd, "1", 2) <= 0) { return 0; } int i; for (i = 0; i < 4; i++) { const char* arg = i < argc ? argv[i] : ""; if (write(fd, arg, strlen(arg) + 1) <= 0) { return 0; } } return 1; } // Mirror response from remote JVM to stdout static int read_response(int fd) { char buf[8192]; ssize_t bytes = read(fd, buf, sizeof(buf) - 1); if (bytes <= 0) { perror("Error reading response"); return 1; } // First line of response is the command result code buf[bytes] = 0; int result = atoi(buf); do { fwrite(buf, 1, bytes, stdout); bytes = read(fd, buf, sizeof(buf)); } while (bytes > 0); return result; }
浏览write_command函数就可知外部进程与目标JVM进程之间发送的数据格式至关简单,基本以下所示:
<PROTOCOL VERSION>\0<COMMAND>\0<ARG1>\0<ARG2>\0<ARG3>\0
以先前咱们使用的Load命令为例,发送给HotSpot时格式以下:
1\0load\0/absolute/path/to/agent/libagent.so\0true\0\0
至此,咱们已经了解了如何手工对JVM进程直接进行Attach。
Load命令仅仅是HotSpot所支持的诸多命令中的一种,用于动态加载基于JVMTI的Agent,完整的命令表以下所示:
static AttachOperationFunctionInfo funcs[] = { { "agentProperties", get_agent_properties }, { "datadump", data_dump }, { "dumpheap", dump_heap }, { "load", JvmtiExport::load_agent_library }, { "properties", get_system_properties }, { "threaddump", thread_dump }, { "inspectheap", heap_inspection }, { "setflag", set_flag }, { "printflag", print_flag }, { "jcmd", jcmd }, { NULL, NULL } };
读者能够尝试下threaddump命令,而后对相同的进程进行jstack,对比观察输出,实际上是彻底相同的,其它命令你们能够自行进行探索。
总的来讲,善用各种Profiler是提高性能优化效率的一把利器,了解Profiler自己的实现原理更能帮助咱们避免对工具的各类误用。CPU Profiler所依赖的Attach、JVMTI、Instrumentation、JMX等皆是JVM平台比较通用的技术,在此基础上,咱们去实现Memory Profiler、Thread Profiler、GC Analyzer等工具也没有想象中那么神秘和复杂了。
业祥,继东,美团基础架构部/服务框架组工程师。
美团点评基础架构团队诚招高级、资深技术专家,Base北京、上海。咱们致力于建设美团点评全公司统一的高并发高性能分布式基础架构平台,涵盖数据库、分布式监控、服务治理、高性能通讯、消息中间件、基础存储、容器化、集群调度等基础架构主要的技术领域。欢迎有兴趣的同窗投送简历至:tech@meituan.com。