源码解析Java Attach处理流程
阅读原文时间:2021年07月18日阅读:1

当Java程序运行时出现CPU负载高、内存占用大等异常情况时,通常需要使用JDK自带的工具jstack、jmap查看JVM的运行时数据,并进行分析。

那么JVM自带的这些工具是如何获取到JVM的相关信息呢?

JVM提供了 Java Attach 功能,能够让客户端与目标JVM进行通讯从而获取JVM运行时的数据,甚至可以通过Java Attach 加载自定义的代理工具,实现AOP、运行时class热更新等功能。

如果我们通过jstack打印线程栈的时候会发现有这么2个线程:Signal DispatcherAttach Listener

"Signal Dispatcher" #4 daemon prio=9 os_prio=2 cpu=0.00ms elapsed=917.19s tid=0x00000164ff377000 nid=0x4ba0 runnable  [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Attach Listener" #5 daemon prio=5 os_prio=2 cpu=0.00ms elapsed=917.19s tid=0x000001648f4d1800 nid=0x1fc0 waiting on condition  [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

Signal Dispatcher用于处理操作系统信号(软中断信号),Attach Listener线程用于JVM进程间的通信。

操作系统支持的信号可以通过kill -l查看。比如我们平时杀进程用kill -9 可以看到9对应的信号就是SIGKILL

其他的信号并不会杀掉JVM进程,而是通知到进程, 具体进程如何处理根据Signal Dispatcher线程处理逻辑决定。

root@DESKTOP-45K54QO:~# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

在虚拟机初始完成后,Signal DispatcherAttach Listener线程会根据配置进行必要的初始化。

jint Threads::create_vm(JavaVMInitArgs* args, bool* canTryAgain) {
...
  //记录虚拟机初始化完成时间
  Management::record_vm_init_completed();
...
  // 初始化Signal Dispatcher
  os::signal_init();

  // 当设置了StartAttachListener或者无法懒加载时启动Attach Listener
  if (!DisableAttachMechanism) {
    AttachListener::vm_start();
    if (StartAttachListener || AttachListener::init_at_startup()) {
      AttachListener::init();
    }
  }
  ...
  // 通知所有的 JVMTI agents 虚拟机初始化完成
  JvmtiExport::post_vm_initialized();
  ...
}

相关JVM参数

JVM相关参数如下,默认都是false

JVM参数

默认值

DisableAttachMechanism

false

StartAttachListener

false

ReduceSignalUsage

false

除了这三个参数以外,我们可以看到AttachListener::init_at_startup()也是用于控制Attach Listener是否初始化。

JDK设计的时候根据不同的操作系统设计了不同的初始化方式。

  • linux支持操作系统信号通知

    • 默认情况下,ReduceSignalUsage配置的是false,初始化完Signal Dispatcher线程就不需要立即初始化Attach Listener线程。而是在收到操作系统通知的时候,去触发Attach Listener线程初始化。
    • 如果ReduceSignalUsage配置的是true,那JVM启动时就不会启动Signal Dispatcher线程。也就无法接收并处理操作系统的信号通知。这时就需要在JVM启动的时候需要立即初始化Attach Listener线程。

    bool AttachListener::init_at_startup() {
    if (ReduceSignalUsage) {
    return true;
    } else {
    return false;
    }
    }

  • windows虽然也有操作系统的信号通知,不过信号通知类型并没有linux那么多,JDK也并没有实现windows下的操作系统信号处理逻辑,因此windows下在JVM启动时就需要直接初始化Attach Listener线程。

    // always startup on Windows NT/2000/XP
    bool AttachListener::init_at_startup() {
    return os::win32::is_nt();
    }

Signal Dispatcher 线程初始化

根据配置ReduceSignalUsage配置决定是否启动Signal Dispatcher线程。

void os::signal_init() {
  if (!ReduceSignalUsage) {
  ...
  JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
  }
}

Signal Dispatcher线程启动后会通过os::signal_wait()等待操作系统信号量。当收到操作系统信号量,且信号量为SIGBREAK时会触发初始化Attach Listener

Attach Listener线程只会初始化一次,如果已初始化过,不会重复初始化。

JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
static void signal_thread_entry(JavaThread* thread, TRAPS) {
  os::set_priority(thread, NearMaxPriority);
  while (true) {
    int sig;
    {
      sig = os::signal_wait();
    }
    ...
    switch (sig) {
      case SIGBREAK: {
        // Check if the signal is a trigger to start the Attach Listener - in that
        // case don't print stack traces.
        if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
          continue;
        }
        ...
      }

需要补充说明的是SIGBREAK实际就是SIGQUIT信号。

#define SIGBREAK SIGQUIT

Attach Listener 线程初始化

...
if (!DisableAttachMechanism) {
    AttachListener::vm_start();
    if (StartAttachListener || AttachListener::init_at_startup()) {
      AttachListener::init();
    }
  }

根据DisableAttachMechanism配置决定是否启动Attach Listener线程;

void AttachListener::vm_start() {
  char fn[UNIX_PATH_MAX];
  struct stat64 st;
  int ret;

  int n = snprintf(fn, UNIX_PATH_MAX, "%s/.java_pid%d",
           os::get_temp_directory(), os::current_process_id());
  assert(n < (int)UNIX_PATH_MAX, "java_pid file name buffer overflow");

  RESTARTABLE(::stat64(fn, &st), ret);
  if (ret == 0) {
    ret = ::unlink(fn);
    if (ret == -1) {
      debug_only(warning("failed to remove stale attach pid file at %s", fn));
    }
  }
}

首先会创建/tmp/.java_pid<pid>文件,该文件用于与socket进行绑定,实现进程间通讯。

这种通讯方式被称为UNIX domain socket,只能用于本机的进程间通讯。

根据StartAttachListener配置决定是否初始化Attach Listener,在初始化时会启动Attach Listener线程

前面说过,具体还是要看操作系统是否支持系统级别的信号通知,如果不支持还是会立即初始化。

AttachListener::init();
void AttachListener::init() {
  ...
  JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
...
}

static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
  os::set_priority(thread, NearMaxPriority);

  thread->record_stack_base_and_size();

  if (AttachListener::pd_init() != 0) {
    return;
  }
  ...

AttachListener::pd_init()初始化逻辑根据实际的操作系统决定。在linux上,最终的初始化工作是由LinuxAttachListener::init()完成。

AttachListener::pd_init()
int AttachListener::pd_init() {
  ...
  int ret_code = LinuxAttachListener::init();
  ...
}

int LinuxAttachListener::init() {
...
  ::atexit(listener_cleanup);

  int n = snprintf(path, UNIX_PATH_MAX, "%s/.java_pid%d",
                   os::get_temp_directory(), os::current_process_id());
...
  listener = ::socket(PF_UNIX, SOCK_STREAM, 0);
...
  int res = ::bind(listener, (struct sockaddr*)&addr, sizeof(addr));
  ...
}

LinuxAttachListener::init()主要做了2件事:

  • 注册清理回调函数,在JVM退出的时候进行资源释放(主要是/tmp/.java_pid<pid>文件的清理)。
  • 将socket绑定到/tmp/.java_pid<pid>用户进程间通讯。

现在我们基本上搞清楚了Signal DispatcherAttach Listener线程启动的情况了。我们再来总结一下。

默认情况下JVM启动的时候并不会立即启动Attach Listener线程。在客户端发送SIGQUIT信号时会启动Attach Listener线程。

或者我们可以通过参数配置在JVM启动时直接启动Attach Listener线程。

前面我们已经了解了Attach Listener启动时会在AttachListener::pd_init()方法中创建socket并监听。接下来我们简单看下Attach Listener是如何执行命令的。

static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
  ...
  if (AttachListener::pd_init() != 0) {
    return;
  }
  ...
  for (;;) {
    //获取命令
    AttachOperation* op = AttachListener::dequeue();
    ...
      AttachOperationFunctionInfo* info = NULL;
      for (int i=0; funcs[i].name != NULL; i++) {
        const char* name = funcs[i].name;
        ...
        if (strcmp(op->name(), name) == 0) {
        //查找命令
          info = &(funcs[i]);
          break;
        }
      }
...
    //执行命令
    res = (info->func)(op, &st);
    // operation complete - send result and output to client
    op->complete(res, &st);
  }
}

执行命令有3个主要步骤:

  1. 获取命令

    获取命令AttachListener::dequeue()就是通过AttachListener线程接收客户端的命令执行请求。

    LinuxAttachOperation* LinuxAttachListener::dequeue() {
    for (;;) {

    //接收客户端连接
    RESTARTABLE(::accept(listener(), &addr, &len), s);

    //读取命令并转化为LinuxAttachOperation
    LinuxAttachOperation* op = read_request(s);

    return op;
    }
    }

  2. 通过命令名从funcs查找需要执行的命令函数,linux支持的命令如下:

    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 }
    };

这些命令实际就与JDK自带的异常排查工具相对应。相关命令和函数对应关系如下。

命令

函数名

jstack -l

threaddump

jmap -dump:file=XXX

dumpheap

jmap -histo:live

inspectheap

jcmd

jcmd

jinfo -flag

setflag

jinfo flag

printflag

  1. 执行命令

    Attach Listener 线程主要用于JVM之间的通讯,部分命令的实际操作最终还是有虚拟机线程完成。比如threaddump函数,实际由vmthread完成命令的执行。

    static jint thread_dump(AttachOperation* op, outputStream* out) {
    bool print_concurrent_locks = false;
    if (op->arg(0) != NULL && strcmp(op->arg(0), "-l") == 0) {
    print_concurrent_locks = true;
    }
    // thread stacks
    VM_PrintThreads op1(out, print_concurrent_locks);
    VMThread::execute(&op1);
    // JNI global handles
    VM_PrintJNI op2(out);
    VMThread::execute(&op2);
    // Deadlock detection
    VM_FindDeadlocks op3(out);
    VMThread::execute(&op3);
    return JNI_OK;
    }

搞清楚了Java Attach服务端的处理逻辑,接下来我们看下客户端是如何连接并执行命令的。

还是以linux环境下客户端的代码在jdk\src\solaris\classes\sun\tools\attach\LinuxVirtualMachine.java

其他操作系统客户端代码在jdk\src\solaris\classes\sun\tools\attach\下也能找到。

LinuxVirtualMachine(AttachProvider provider, String vmid)
        throws AttachNotSupportedException, IOException
    {
        ...
        path = findSocketFile(pid);
        if (path == null) {
            File f = createAttachFile(pid);
            ...
            if (isLinuxThreads) {
            ...
            mpid = getLinuxThreadsManager(pid);
            ...
            sendQuitToChildrenOf(mpid);
            } else {
                sendQuitTo(pid);
            }
...
            int i = 0;
                long delay = 200;
                int retries = (int)(attachTimeout() / delay);
                do {
                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException x) { }
                    path = findSocketFile(pid);
                    i++;
                } while (i <= retries && path == null);
                if (path == null) {
                    throw new AttachNotSupportedException(
                        "Unable to open socket file: target process not responding " +
                        "or HotSpot VM not loaded");
                }
            } finally {
                f.delete();
            }
        }
...
        int s = socket();
        try {
            connect(s, path);
        } finally {
            close(s);
        }
    }

处理流程如下:

  1. 查找/tmp/.java_pid<pid>文件。
  • 若文件已存在,则表示JVM已经初始化了Attach Listener线程,则可以直接连接到JVM。
  • 若文件不存在则表示JVM还没有启用Attach Listener线程。此时需要通过发送SIGQUIT信号量给JVM激活Attach Listener线程
  1. 创建/proc/<pid>/cwd/.attach_pid<pid>/tmp/.attach_pid<pid>,这个文件仅仅时用于attach机制的握手,服务端会检查该文件是否存在,用来确认是Attach机制是JVM启动触发的还是客户端触发的。
  2. 获取JVM的进程id
  • linux操作系统会进程的组ID,通过组ID获取到所有线程并发送SIGQUIT信号,只有Signal Dispatcher线程会处理SIGQUIT信号。从而激活Attach Listener线程。

linux是不区分进程和线程的,通过讲用户级线程映射到轻量级进程。组成一个用户级进程的多用户级线程被映射到共享同一个组ID的多个Linux内核级进程上。《操作系统精髓与设计原理》-4.6.2Linux线程

  • 其他操作系统当前线程的进程id就是进程id
  1. JVM收到信号后会判断若未启动Attach Listener线程,就会启动Attach Listener线程。

这是一种懒加载机制,只有在需要的时候才启动。

  1. 前面讲过。当JVM启动Attach Listener线程后,会创建tmp/java_pid<pid>文件,客户端就通过该文件与服务端进行网络通讯。

默认情况下attachTimeout()为5秒,若JVM 5秒钟没有创建java_pid文件就认为超时了。

那么LinuxVirtualMachine是如何被执行的呢?我们以jstack为例。

jstack代码在jdk\src\share\classes\sun\tools\jstack\JStack.java

当我们通过命令行调用jstack打印线程栈时。若不是SA模式,则会调用到runThreadDump

SA(ServiceAbility)提供了虚拟机调试快照的功能,它内部提供了一些jstack,jmap的一些工具也可以获取到相关的JVM参数。但是如果调试的是运行程序,则会使调试的目标进程完全暂停。

public static void main(String[] args) throws Exception {
    ...
    if (useSA) {
        ...
        runJStackTool(mixed, locks, params);
    } else {
        ...
        runThreadDump(pid, params);
    }
}
private static void runThreadDump(String pid, String args[]) throws Exception {
    ...
    vm = VirtualMachine.attach(pid);
    ...
    InputStream in = ((HotSpotVirtualMachine)vm).remoteDataDump((Object[])args);
    ...
    //和attach相反
    vm.detach();
}

这里做了3件事:

  1. 获取VirtualMachine,并attach到目标JVM

    public static VirtualMachine attach(String id)
    throws AttachNotSupportedException, IOException
    {

    List providers = AttachProvider.providers();

    AttachNotSupportedException lastExc = null;
    for (AttachProvider provider: providers) {
    return provider.attachVirtualMachine(id);
    }
    }

在linux下,provider使用的是LinuxAttachProvider,创建的是LinuxVirtualMachine对象。

public VirtualMachine attachVirtualMachine(VirtualMachineDescriptor vmd)
        throws AttachNotSupportedException, IOException
    {
        ...
        return new LinuxVirtualMachine(this, vmd.id());
        ...
    }
  1. 执行remoteDataDump,实际就是通过socket与目标JVM进行通讯并执行相关的命令。

    public InputStream remoteDataDump(Object … args) throws IOException {
    return executeCommand("threaddump", args);
    }

  2. 调用detach与目标虚拟机断开。实际每次执行命令会重新创建连接,执行完就会关闭连接。这里仅仅把path置空而已,并没有做其他什么工作。

本文对JVM之间使用过Java Attach的交互流程进行了梳理。一开始也提到,Java Attach并不只是在JVM之间获取运行时信息那么简单,load命令让JVM在运行时也能被代理,通过ASM、等字节码修改技术,在运行时对类进行修改。