性能文章>MProfiler通过Hook进行性能诊断>

MProfiler通过Hook进行性能诊断原创

https://a.perfma.net/img/2382850
1年前
312855

本文含有大量代码,如果阅读不方便,可去MProfiler官网阅读。地址:http://mprofiler.com/pages/guide05/

Hook翻译成中文就是勾取的意思,是一种截取信息,更改程序执行流向,添加新功能的技术。MProfiler也Hook了一些方法,这些方法包括:

(1)Java普通方法

(2)Java的native方法

(3)JNI函数

(4)库函数

下面详细介绍一下如何Hooking这些方法,以及MProfiler如何通过Hooking技术来做性能诊断和优化。

1、Java普通方法

Java方法如果想要运行,首先需要编译为字节码,我们可以在编译生成字节码的过程中对Java普通方法做Hook,或者说增强。在编译为字节码后,还可以借助像ASM、javasist等框架在编译好的字节码上进行修改, 在修改好这些字节码后,接下来的任务就是将其加载到虚拟机中。不过后序考虑到各种业务需求,这种按部就班的顺序需要被打破,例如:

(1)为了实现代码增强部分与原业务代码解耦(如做一款监控产品,尽量避免和目标客户的代码实现耦合,这样就可不必关心用户的业务代码),提出了动态实现类增强的需求

(2)为了不重启虚拟机加载增强后的代码而提出了运行时对已经加载的类字节码进行增强,因为有些问题可能是偶发性的重现,如果重启就会破坏现场

为了满足如上需求,Java提供了JavaAgent+Instrumentation,在动态将编写好的JavaAgent附加到客户的目标进程进行后,通过Instrumentation类中提供的API实现类修改加载等需求。实现方式比较简单,也比较常见,这里就不再举例子了。

MProfiler可以使用字节码增强服务对目标方法进行增强,相关信息请参考:http://mprofiler.com/pages/guide04/

2、Java的native方法

由于Java的native方法没有字节码,所以不能直接进行增强。但是同样是Instrumentation这个类提供了isNativeMethodPrefixSupported()和setNativeMethodPrefix()两个API来支持对Java的native方法增强。举个例子就会明白了。如下:

public class DynamicAgent {
    public final static String NATIVE_PREFIX = "MPROFILER_";

    public static void premain(String agentArgs, Instrumentation inst) {
        main(agentArgs, inst, true);
    }

    public static void agentmain(String agentArgs, Instrumentation inst) {
        main(agentArgs, inst, false);
    }

    private static void main(String agentArgs, Instrumentation inst, boolean isOnLoad) {
        if (!inst.isNativeMethodPrefixSupported()) {
             return;
        }

        MProfilerClassFileTransformer transformer = new MProfilerClassFileTransformer();
        inst.addTransformer(transformer, true);
        inst.setNativeMethodPrefix(transformer, NATIVE_PREFIX);

        String className = "sun.misc.Unsafe";
        try {
            Class<?> clazz = Class.forName(className);
            if (inst.isModifiableClass(clazz)) {
                inst.retransformClasses(clazz);
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        } finally {
            inst.removeTransformer(transformer);
        }
    }
}

在这里要提醒一下,大部分扩展类和业务类加载都会在main() 方法执行之后进行,所以在main()方法之前执行的premain()方法就能拦截这些类的加载活动,但是没办法拦截一些核心的系统类,因为很多系统类必须提前加载完成,由于我们要增强sun.misc.Unsafe类中的方法,而这个类是系统类,所以我们还是要调用retransformClasses()方法。

ASMClassFileTransformer类的代码如下:

public class MProfilerClassFileTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            if ("sun/misc/Unsafe".equals(className)) {
                final ClassReader cr = new ClassReader(classfileBuffer);
                final ClassWriter cw = new ClassWriter(cr, COMPUTE_FRAMES | COMPUTE_MAXS);
                cr.accept(new MProfilerClassVisitor(ASM9, cw, cr.getClassName()), EXPAND_FRAMES);
                return cw.toByteArray();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }

}  

MProfilerClassVisitor类的实现如下:

public class MProfilerClassVisitor extends ClassVisitor {
    private InstrMethod method = null;
    private final String targetClassInternalName;

    public MProfilerClassVisitor(final int api, final ClassVisitor cv, String targetClassInternalName) {
        super(api, cv);
        this.targetClassInternalName = targetClassInternalName;
    }

    @Override
    public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) {
        if ("allocateMemory".equals(name)) {
            int newAccess = access & ~Opcodes.ACC_NATIVE;
            method = new InstrMethod(access, NATIVE_PREFIX + name, desc);
            final MethodVisitor mv = super.visitMethod(newAccess, name, desc, signature, exceptions);
            return new AdviceAdapter(api, new JSRInlinerAdapter(mv, newAccess, name, desc, signature, exceptions), newAccess, name, desc) {
                @Override
                public void visitEnd() {
                    // 在native方法调用前打印
                    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                    mv.visitLdcInsn("enter method:" + name);
                    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

                    loadThis();
                    loadArgs();
                    mv.visitMethodInsn(Opcodes.INVOKESPECIAL, targetClassInternalName, method.getName(), method.getDescriptor(), false);

                    returnValue();
                    super.visitEnd();
                }
            };
        }
        return super.visitMethod(access, name, desc, signature, exceptions);
    }

    @Override
    public void visitEnd() {
        if (method != null) {
            int newAccess = (Opcodes.ACC_PRIVATE | Opcodes.ACC_NATIVE | Opcodes.ACC_FINAL);
            MethodVisitor mv = cv.visitMethod(newAccess, method.getName(), method.getDescriptor(), null, null);
            mv.visitEnd();
        }
        super.visitEnd();
    }

}

如上程序在每次调用allocateMemory()方法时,都会打印出如下内容:

enter method:allocateMemory

通常在直接分配堆外内存时,调用的是ByteBuffer.allocateDirect()方法,在ByteBuffer中可以对直接申请的堆外内存做限制,同时也记录了堆外内存申请的大小和使用情况,但总是会在某些情况下,不调用这个方法,而是直接调用Unsafe类的allocateMemory()方法分配堆外内存,这样不但能绕过堆外内存配置的MaxDirectMemorySize限制的同时,也无法让我们准确追踪到堆外内存精确的大小。如果我们可以对allocateMemory()方法进行增强,这可以在虚拟机启动时记录由Unsafe类的allocateMemory()方法申请的堆外内存的精确大小,避免漏网之鱼。

3、JNI函数

JNI(Java Native Interface)通过使用Java本地接口,实现Java和C/C++的代码的交互,交互是JNI的精髓,意味着Java和C/C++之间可以很方便地进行相互访问变量,调用对方的函数,如Java可以调用C/C++的函数,C/C++也能调用java的函数。

C/C++操作Java要依赖于JNI能提供哪些函数,这里我们着重介绍JNI中的NewGlobalRef()和DeleteGlobalRef()函数。

由于Java对象要GC,这就意味着如果这个Java对象被C/C++使用着,那就是一个活的对象,不允许被回收。但是C/C++是用户编写的程序,如果允许这些代码直接操作Java堆中的对象,那么GC根本无法找到根引用,这时候怎么办呢?JNI就提供了一些类似NewGlobalRef()和DeleteGlobalRef()函数出来,C/C++如果要全局使用某个Java对象,必须调用NewGlobalRef()函数存储起来,当不用时,必须要调用DeleteGlobalRef()函数删除,否则就会造成内存泄漏。那NewGlobalRef()为什么就会让GC找到根了呢?因为通过间接的方式将相关的引用集中存储在了固定的地方,GC在标记时只需要扫描这些固定的地方即可。

假设NewGlobalRef()出现了内存泄漏,我们要怎么排查呢?或者说我们想看一下NewGlobalRef中到底引用的是哪些类型的对象,该怎么办呢?MProfiler的解决方法是Hook。JNI函数都会保存到一个叫JNINativeInterface_的表中,我们可以在这个表中找到原函数的地址,然后替换为我们自己函数的地址即可,如下:

typedef jobject (*JNI_NewGlobalRef)(JNIEnv *, jobject);

typedef void  (*JNI_DeleteGlobalRef)(JNIEnv *, jobject);

JNI_NewGlobalRef originNGR = NULL;
JNI_DeleteGlobalRef originDGR = NULL;

std::atomic<bool> isPatched(false);
jvmtiEnv *_jvmti = NULL;
// Hook函数
jobject NewGlobalRefOverride(JNIEnv *env, jobject obj) {
    jobject g = ((JNI_NewGlobalRef) originNGR)(env, obj); 
    if (g == NULL) {
        return NULL;
    }
    return g;
}
// Hook函数
void DeleteGlobalRefOverride(JNIEnv *env, jobject object) {
    ((JNI_DeleteGlobalRef) originDGR)(env, object);
}

void Java_com_mprofiler_newglobalref_JNIOverride_test(JNIEnv *env, jclass clazz) {
    jobject ref1 = env->NewGlobalRef(clazz);
    env->DeleteGlobalRef(ref1);
}

void Java_com_mprofiler_newglobalref_JNIOverride_init(JNIEnv *env, jclass clazz) {
    if (isPatched) {
        return;
    }
    isPatched = true;

    JavaVM *_vm = NULL;
    env->GetJavaVM(&_vm);

    jint ret = _vm->GetEnv((void **) &_jvmti, JVMTI_VERSION_1_0);
    if (ret != JNI_OK) {
        std::cout << "Unable to access JVMTI" << endl;
        return;
    }

    jniNativeInterface *jni_functions;
    if (_jvmti->GetJNIFunctionTable(&jni_functions) == 0) {
        originNGR = jni_functions->NewGlobalRef;
        originDGR = jni_functions->DeleteGlobalRef;

        jni_functions->NewGlobalRef = NewGlobalRefOverride;
        jni_functions->DeleteGlobalRef = DeleteGlobalRefOverride;
        _jvmti->SetJNIFunctionTable(jni_functions);
    }
}

编写Java类的native方法,如下:

public class JNIOverrideTest {
    static {
        System.load(动态链接库绝对路径);
    }

    public static native void test();

    public static native void init();
}

这样就可以编写测试程序,如下:

public static void main(String[] args) throws InterruptedException {
        JNIOverrideTest.init();

        new Thread(() -> {
            JNIOverrideTest.test();
        }).start();

        new Thread(() -> {
            JNIOverrideTest.test();
        }).start();


        TimeUnit.HOURS.sleep(1);
    }

我们可以简单在新的Hook方法中打印一下日志,可以看到,会调用到Hook方法。这样我们就可以在Hook方法中统计被C/C++所引用的Java对象了。 

其实前面介绍的增强native的Java方法时,也可以使用如上类似的方式,因为Unsafe类的allocateMemory()同样在C++这个层面可以借助RegisterNatives()函数找到原allocateMemory()方法对应的C/C++函数的实现地址以及重新注册新的方法,不过实现起来会比上面的例子更复杂一些。

4、库函数

LD_PRELOAD这个变量允许你定义在程序运行时优先加载的动态链接库,从而在程序运行时的动态链接。指定的库中的函数将会替换掉 glibc 中的相关函数,例如 malloc() 和free()。可以将内存管理库替换为 jemalloc 或者 tcmalloc 。下面举个例子,如下:

#include <dlfcn.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdarg.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

typedef int  (*m_open64)(const char *pathname, int flags, ...);

typedef ssize_t (*m_write)(int fd, const void *buf, size_t n);


int mprofiler_fd = -1;

bool endsWith(std::string const &str, std::string const &suffix) {
    if (str.length() < suffix.length()) {
        return false;
    }
    return str.compare(str.length() - suffix.length(), suffix.length(), suffix) == 0;
}

int open64(const char *pathname, int flags, ...) {
    va_list ap;
    va_start(ap, flags);
    int res = va_arg(ap, int);
    va_end(ap);

    m_open64 open64 = (m_open64) dlsym(RTLD_NEXT, "open64");

    // file descriptor (-1 if dump file not open)
    int fd = open64(pathname, flags, res);

    std::string dumppath = pathname;
    if (endsWith(dumppath, ".hprof")) {
        mprofiler_fd = fd;
    }

    return fd;
}

ssize_t write(int fd, const void *buf, size_t n) {
    if (mprofiler_fd == fd) {
        std::cout << "写入dump文件,长度为" << n << std::endl;
    }
    m_write write = (m_write) dlsym(RTLD_NEXT, "write");
    ssize_t size = write(fd, buf, n);

    return size;
}
打开Linux终端,设置:
export LD_PRELOAD="/media/mazhi/sourcecode/workspace/projectjava9/hook/dest/libhook.so"

然后在这个终端启动Java应用程序后,用如下命令jmap导出dump文件:

jmap -dump:format=b,file=dump.hprof  [pid]

会在Java应用程序中打印类似如下内容:

写入dump文件,长度为8388602
写入dump文件,长度为7767794
写入dump文件,长度为4

如上实例hook了库函数open64()和write()函数,可以在这两个hook方法中做一些事情。

有时候,由于dump文件很大,我们可以在导出dump文件时做裁剪,尤其是一些基本类型数组的内容可直接忽略,还能将dump文件分开写入几个文件中,或者做一些压缩处理。如果用户在虚拟机启动时,配置了LD_PRELOAD,那么MProfiler会对dump文件做裁剪,这样文件的大小会大大减小。有利于存储和后序的大对象分析。

LD_PRELOAD是C/C++ Hook最简单的一种手段,还有一些其它Hook手段,如比较相对稳定的PLT Hook和GOT Hook,如果后面MProfiler用了这类型的Hook,也可以到时候具体介绍一下。

MProfiler工具官网:http://mprofiler.com

MProfiler目前已经发布了一个测试版本mprofiler-beta-0.0.1,有兴趣的可以去官网下载尝试,欢迎试用及反馈。反馈可以加作者微信或在官网提issue。

 

点赞收藏
鸠摩

著有《深入解析Java编译器:源码剖析与实例详解》、《深入剖析Java虚拟机:源码剖析与实例详解》等书籍。公众号“深入剖析Java虚拟机HotSpot”作者

请先登录,查看5条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

从 Linux 内核角度探秘 JDK MappedByteBuffer

从 Linux 内核角度探秘 JDK MappedByteBuffer

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

5
5