# 3.3 实现原理
在上一节介绍了Attach API的基本使用,本节将结合JDK源码分析其中的原理。Attach机制本质上是进程间的通信,外部进程通过JVM提供的socket连接到目标JVM上并发送指令,目标JVM接受并处理指令然后返回处理结果。
# 3.2.1 Attach客户端源码解析
有了前面一节的使用API使用基础,我们将分析Attach API的实现原理并对相应的源码做解析,从而挖掘更多可用的功能。VirtualMachine
是抽象类,不同厂商的虚拟机可以实现不同VirtualMachine子类,HotSpotVirtualMachine是HotSpot官方提供的VirtualMachine实现,它也是一个抽象类,在不同操作系统上还有各自实现,如Linux系统上的JDK11上实现类的名称为VirtualMachineImpl(JDK8上实现类名称为LinuxVirtualMachine)。JDK8上VirtualMachine实现类的的继承关系如下图3-2所示:
图3-2 VirtualMachine实现类的继承关系
先来看下HotSpotVirtualMachine
类的loadAgentLibrary方法
代码位置:src/jdk.attach/share/classes/sun/tools/attach/HotSpotVirtualMachine.java
private void loadAgentLibrary(String agentLibrary, boolean isAbsolute, String options)
throws AgentLoadException, AgentInitializationException, IOException
{
if (agentLibrary == null) {
throw new NullPointerException("agentLibrary cannot be null");
}
// jdk11返回字符串"return code: 0"
String msgPrefix = "return code: ";
// 执行load指令,给目标 jvm 传输 agent jar路径和参数
InputStream in = execute("load",
agentLibrary,
isAbsolute ? "true" : "false",
options);
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String result = reader.readLine();
// 返回结果
if (result == null) {
throw new AgentLoadException("Target VM did not respond");
} else if (result.startsWith(msgPrefix)) {
int retCode = Integer.parseInt(result.substring(msgPrefix.length()));
if (retCode != 0) {
throw new AgentInitializationException("Agent_OnAttach failed", retCode);
}
} else {
throw new AgentLoadException(result);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
上面的代码是加载一个Java Agent,核心实现在 execute
方法中,来看下execute方法的源码:
// 在目标JVM上执行给定的命令,需要由子类来实现
abstract InputStream execute(String cmd, Object ... args)
throws AgentLoadException, IOException;
2
3
execute是一个抽象方法,需要在子类中实现,HotSpotVirtualMachine类中的其他方法大多数最终都会调用这个execute方法。
再来看下Linux系统上的实现类LinuxVirtualMachine
代码。
代码位置:src/jdk.attach/linux/classes/sun/tools/attach/VirtualMachineImpl.java
VirtualMachineImpl(AttachProvider provider, String vmid)
throws AttachNotSupportedException, IOException
{
super(provider, vmid);
int pid;
try {
pid = Integer.parseInt(vmid);
} catch (NumberFormatException x) {
throw new AttachNotSupportedException("Invalid process identifier");
}
// 在/tmp目录下寻找socket文件是否存在
File socket_file = new File(tmpdir, ".java_pid" + pid);
socket_path = socket_file.getPath();
if (!socket_file.exists()) {
// 创建 attach_pid 文件
File f = createAttachFile(pid);
try {
// 向目标JVM 发送 kill -3 信号
sendQuitTo(pid);
// 等待目标JVM创建socket文件
final int delay_step = 100;
final long timeout = attachTimeout();
long time_spend = 0;
long delay = 0;
do {
// 计算等待时间
delay += delay_step;
try {
Thread.sleep(delay);
} catch (InterruptedException x) { }
time_spend += delay;
if (time_spend > timeout/2 && !socket_file.exists()) {
sendQuitTo(pid); // 发送kill -3 信号
}
} while (time_spend <= timeout && !socket_file.exists());
// 等待时间结束后,确认socket文件是否存在
if (!socket_file.exists()) {
throw new AttachNotSupportedException(
String.format("Unable to open socket file %s: " +
"target process %d doesn't respond within %dms " +
"or HotSpot VM not loaded", socket_path,
pid, time_spend));
}
} finally {
// 最后删除 attach_pid 文件
f.delete();
}
}
// 确认socket文件权限
checkPermissions(socket_path);
// 尝试连接socket,确认可以连接到目标JVM
int s = socket();
try {
connect(s, socket_path);
} finally {
close(s);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
再次梳理下attach通信的过程:
第一步: 发起attach的进程在/tmp目录下查找目标JVM是否已经创建了.java_pid
第二步: attach进程创建socket通信的握手文件.attach_pid
第三步: attach进程给目标JVM发送SIGQUIT(kill -3)信号,提示目标JVM外部进程发起了attach请求;
第四步: attach进程循环等待目标JVM创建.java_pid
第五步: 删除握手文件.attach_pid
第六步: attach进程校验socket文件权限;
第七步: attach进程测试socket连接可用性;
上面详细说明了socket连接的建立过程,下面将介绍发送命令的协议。
代码位置:src/jdk.attach/linux/classes/sun/tools/attach/VirtualMachineImpl.java
InputStream execute(String cmd, Object ... args) throws AgentLoadException, IOException {
// 参数、socket_path校验
// create UNIX socket
int s = socket();
// connect to target VM
try {
connect(s, socket_path);
} catch (IOException x) {
// 错误处理
}
IOException ioe = null;
// 发送attach请求信息
try {
// 发送协议
writeString(s, PROTOCOL_VERSION);
// 发送命令
writeString(s, cmd);
// 发送参数,最多三个参数
for (int i=0; i<3; i++) {
if (i < args.length && args[i] != null) {
writeString(s, (String)args[i]);
} else {
// 没有参数,发送空字符串代替
writeString(s, "");
}
}
} catch (IOException x) {
ioe = x;
}
// 读取执行结果
SocketInputStream sis = new SocketInputStream(s);
// 读取命令的执行状态
int completionStatus;
try {
completionStatus = readInt(sis);
} catch (IOException x) {
// 错误处理
}
if (completionStatus != 0) {
// 错误处理
}
return sis;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
从上面的代码可以知道一次命令发送,先发送版本协议,然后是命令,最后是参数,并且参数的个数最多为3个。
为了更加清晰的看到通信协议的内容,在Linux上使用strace命令能够跟踪attach的系统调用过程。
strace -f java Main 2> main.out
在 main.out 文件中找到attach通信过程,从开始写入部分可以看出依次先写入协议号、命令、命令参数, 然后读取返回结果。
// 建立UDS链接
[pid 31412] socket(AF_LOCAL, SOCK_STREAM, 0) = 6
[pid 31412] connect(6, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid27730"}, 110) = 0
// 开始写入
[pid 31412] write(6, "1", 1) = 1 // 协议号
[pid 31412] write(6, "\0", 1) = 1 // 分割符号
[pid 31412] write(6, "properties", 10) = 10 // 命令
[pid 31412] write(6, "\0", 1) = 1 // 分割符号
[pid 31412] write(6, "\0", 1 <unfinished ...> // 参数1
[pid 31412] write(6, "\0", 1) = 1 // 参数2
[pid 31412] write(6, "\0", 1) = 1 // 参数3
// 读取返回结果
[pid 31412] read(6, "0", 1) = 1
[pid 31412] read(6, "\n", 1) = 1
[pid 31412] read(6, "#Thu Jul 27 17:52:11 CST 2023\nja"..., 128) = 128
[pid 31412] read(6, "oot.loader\nsun.boot.library.path"..., 128) = 128
[pid 31412] read(6, "poration\njava.vendor.url=http\\:/"..., 128) = 128
[pid 31412] read(6, ".pkg=sun.io\nuser.country=CN\nsun."..., 128) = 128
[pid 31412] read(6, "e=Java Virtual Machine Specifica"..., 128) = 128
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
因此Attach客户端的发送协议可以总结为下面的字符串序列。
1 byte PROTOCOL_VERSION
1 byte '\0'
n byte command
1 byte '\0'
n byte arg1
1 byte '\0'
n byte arg2
1 byte '\0'
n byte arg3
1 byte '\0'
2
3
4
5
6
7
8
9
10
# 3.2.2 Attach服务端源码解析
我们再来看下接收Attach命令的服务端是如何实现的,这部分代码是c/c++语言,但是也是不难理解的。 以Linux系统为例子,说明目标JVM如何处理Attach请求和执行指定的命令。
Linux系统下Attach机制信号与线程的创建流程可以描述为下图3-3。
图3-3 Attach机制信号与线程的处理流程
先来看下目标JVM如何处理kill -3
信号。JVM初始化过程中会创建2个线程,线程名称分别为Signal Dispatcher
和Attach Listener
,Signal Dispatcher线程用来处理信号量,Attach Listener线程用来响应Attach请求。
JVM线程的的初始化都在Threads::create_vm
中,当然与Attach有关的线程也在这个方法中初始化。
代码位置:src/hotspot/share/runtime/thread.cpp
// 代码位置 src/hotspot/share/runtime/thread.cpp
jint Threads::create_vm(JavaVMInitArgs* args, bool* canTryAgain) {
// 参数和系统初始化,省略....
// 初始化Signal Dispatcher线程支持信号量处理
os::initialize_jdk_signal_support(CHECK_JNI_ERR);
// 目标JVM没有禁用Attach机制
if (!DisableAttachMechanism) {
// 在JVM启动时删除已经存在的通信文件.java_pid<pid>
AttachListener::vm_start();
// 如果JVM启动参数设置-XX:+StartAttachListener或者
// 减少了信号量的使用而不能延迟启动,则在JVM启动时初始化Attach Listener
// 默认情况下AttachListener是延迟启动模式,即在JVM启动时不会立即创建Attach Listener线程
if (StartAttachListener || AttachListener::init_at_startup()) {
// 初始化Attach Listener线程
AttachListener::init();
}
}
// 参数和系统初始化,省略....
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
上面的代码中分别初始化Signal Dispatcher和Attach Listener线程,Signal Dispatcher在JVM 启动时初始化,Attach Listener则延迟初始化。下面分别详细说下各自的初始化流程。
# 3.2.2.1 Signal Dispatcher线程
initialize_jdk_signal_support
的实现代码如下所示:
代码位置:src/hotspot/share/runtime/os.cpp
// 代码位置 src/hotspot/share/runtime/os.cpp
// 初始化JDK的信号支持系统
void os::initialize_jdk_signal_support(TRAPS) {
// 没有禁止信号量的使用
if (!ReduceSignalUsage) {
// 线程名称 Signal Dispatcher
const char thread_name[] = "Signal Dispatcher";
// ... 线程初始化过程
// 设置线程入口 signal_thread_entry
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
// ...
// 注册SIGBREAK信号处理handler
os::signal(SIGBREAK, os::user_handler());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
JVM创建了一个单独的线程来实现信号处理,这个线程名称为Signal Dispatcher。该线程的入口是signal_thread_entry函数。入口函数代码:
代码清单:Signal Dispatcher线程的入口
代码位置 src/hotspot/share/runtime/os.cpp
#ifndef SIGBREAK
#define SIGBREAK SIGQUIT // SIGBREAK就是SIGQUIT
#endif
// Signal Dispatcher线程的入口
static void signal_thread_entry(JavaThread* thread, TRAPS) {
os::set_priority(thread, NearMaxPriority);
// 处理信号
while (true) {
int sig;
{
sig = os::signal_wait(); //阻塞等待信号
}
if (sig == os::sigexitnum_pd()) {
// 停止Signal Dispatcher信号处理线程
return;
}
// 循环处理各种信号
switch (sig) {
// 当接收到SIGBREAK信号,就执行接下来的代码
case SIGBREAK: {
// 如果没有禁用attach机制并且是attach请求则初始化AttachListener
// 如果AttachListener没有初始化,则进行初始化并返回true
if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
continue;
}
// 如果上面条件不满足,则打印线程栈等信息
VM_PrintThreads op;
VMThread::execute(&op); // 线程栈信息
VM_PrintJNI jni_op;
VMThread::execute(&jni_op);// JNI global references数量
VM_FindDeadlocks op1(tty);
VMThread::execute(&op1); // 死锁信息
Universe::print_heap_at_SIGBREAK(); // 堆、元空间的使用占比
// 启用-XX:+PrintClassHistogram,则强制执行一次full GC
if (PrintClassHistogram) {
// 下面的true表示force full GC before heap inspection
VM_GC_HeapInspection op1(tty, true);
VMThread::execute(&op1);
}
if (JvmtiExport::should_post_data_dump()) {
JvmtiExport::post_data_dump();
}
break;
}
default: {
// Dispatch the signal to java
// ...其他信号处理
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
代码行号1~3定义了宏SIGBREAK,可以看出,SIGBREAK信号就是SIGQUIT。代码26行的DisableAttachMechanism参数可以禁止attach,默认为false,即允许attach。
再来看下AttachListener::is_init_trigger
的实现。
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
// 如果在JVM工作目录或者/tmp目录下存在文件.attach_pid<pid>
// 表示是启动attach机制
bool AttachListener::is_init_trigger() {
// 记录AttachListener的初始状态
// JVM 用一个全局变量_is_initialized记录AttachListener 的状态
if (init_at_startup() || is_initialized()) {
// AttachListener在JVM启动时已经初始化或者已经是初始化的状态
return false;
}
// 检查.attach_pid<pid>是否存在
char fn[PATH_MAX + 1];
int ret;
struct stat64 st;
sprintf(fn, ".attach_pid%d", os::current_process_id());
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == -1) {
// .attach_pid<pid>文件不存在,打印日志,代码省略...
}
// 当前进程的.attach_pid<pid>文件存在,创建AttachListener线程
if (ret == 0) {
// attach文件权限校验(root权限或者权限相同)
if (os::Posix::matches_effective_uid_or_root(st.st_uid)) {
// 创建AttachListener线程
init();
return true;
}
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在Signal Dispatcher线程接收到SIGBREAK信号后,有两种处理方法,第一种是初始化AttachListener线程;第二种打印线程栈等快照信息。处理方式取决于.attach_pid
# 3.2.2.2 Attach Listener
Attach机制通过Attach Listener线程来进行相关命令的处理,下面来看一下Attach Listener线程是如何初始化的。从上面的代码分析可以看出,AttachListener可以在JVM启动时(立即初始化),也可以在首次收到SIGBREAK信号后,由Signal Dispatcher线程完成初始化(延迟初始化)。
来看下Attach Listener初始化过程。
代码清单:Attach Listener初始化过程
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
void AttachListener::init() {
// 线程名称Attach Listener
const char thread_name[] = "Attach Listener";
// ... 线程初始化过程
// 设置AttachListener线程的入口函数attach_listener_thread_entry
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
// ... 设置线程状态
}
2
3
4
5
6
7
8
9
10
11
12
上面的代码初始化了一个线程,并设置线程的入口函数。重点分析下attach_listener_thread_entry函数。
代码位置:src/hotspot/share/services/attachListener.cpp
// Attach Listener线程从队列中获取操作命令,并执行命令对应的函数
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
// STEP1:AttachListener初始化
if (AttachListener::pd_init() != 0) {
return;
}
// STEP2:设置AttachListener的全局状态
AttachListener::set_initialized();
for (;;) {
// STEP3:从队列中取AttachOperation
AttachOperation* op = AttachListener::dequeue();
// find the function to dispatch too
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;
}}
// dispatch to the function that implements this operation
// ... 执行具体的操作
res = (info->func)(op, &st);
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
第一步:先执行AttachListener socket的初始化操作;
第二步:初始化完成后设置,AttachListener的状态为initialized;
第三步:从队列中取AttachOperation,并且调用对应的处理函数处理并返回结果。
下面分别对这个过程详细分析。
# AttachListener::pd_init
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
int AttachListener::pd_init() {
// linux 系统下的初始化操作
int ret_code = LinuxAttachListener::init();
// ...
return ret_code;
}
2
3
4
5
6
7
8
9
实际执行的是LinuxAttachListener::init,不同操作系统执行初始化逻辑不同。在Linux系统中实际执行LinuxAttachListener::init。
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
// 创建了一个socket并监听socket文件
int LinuxAttachListener::init() {
char path[UNIX_PATH_MAX]; // socket file
char initial_path[UNIX_PATH_MAX]; // socket file during setup
int listener; // listener socket (file descriptor)
// register function to cleanup
::atexit(listener_cleanup);
int n = snprintf(path, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
if (n < (int)UNIX_PATH_MAX) {
n = snprintf(initial_path, UNIX_PATH_MAX, "%s.tmp", path);
}
if (n >= (int)UNIX_PATH_MAX) {
return -1;
}
// create the listener socket
listener = ::socket(PF_UNIX, SOCK_STREAM, 0);
if (listener == -1) {
return -1;
}
// 绑定socket
struct sockaddr_un addr;
memset((void *)&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, initial_path);
::unlink(initial_path);
int res = ::bind(listener, (struct sockaddr*)&addr, sizeof(addr));
if (res == -1) {
::close(listener);
return -1;
}
// 开启监听
res = ::listen(listener, 5);
if (res == 0) {
RESTARTABLE(::chmod(initial_path, S_IREAD|S_IWRITE), res);
if (res == 0) {
// make sure the file is owned by the effective user and effective group
// e.g. the group could be inherited from the directory in case the s bit is set
RESTARTABLE(::chown(initial_path, geteuid(), getegid()), res);
if (res == 0) {
res = ::rename(initial_path, path);
}
}
}n'n'n'h
if (res == -1) {
::close(listener);
::unlink(initial_path);
return -1;
}
set_path(path);
set_listener(listener);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
AttachListener::pd_init()方法调用了LinuxAttachListener::init()方法,完成了套接字的创建和监听。
# LinuxAttachListener::dequeue
for循环的执行逻辑,流程简略的概括为下面的步骤:
- 从dequeue拉取一个需要执行的AttachOperation对象;
- 查询匹配的命令处理函数;
- 执行匹配到的命令执行函数并返回结果;
AttachOperation的全部操作函数表如下:
代码位置:src/hotspot/share/services/attachListener.cpp
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", load_agent },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};
2
3
4
5
6
7
8
9
10
11
12
13
对于加载Agent来说,对应的命令就是上面的load。现在,我们知道了Attach Listener大概的工作模式,但是还是不太清楚任务从哪来,这个秘密就藏在AttachListener::dequeue这行代码里面,接下来我们来分析一下dequeue这个函数:
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
// 等待attach进程连接socket
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
// 校验attach进程的权限
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
::close(s);
continue;
}
// 读取socket获取操作的对象
LinuxAttachOperation* op = read_request(s);
return op;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dequeue方法是一个for循环,会循环使用accept方法,接受socket中传过来的数据,并且在验证通信的另一端的uid与gid与自身的euid与egid相同后,执行read_request方法,从socket读取内容,并且把内容包装成AttachOperation类的一个实例。接下来看看read_request是如何解析socket数据流的。
代码位置:src/hotspot/os/linux/attachListener_linux.cpp
LinuxAttachOperation* LinuxAttachListener::read_request(int s) {
// 缓存区最大长度计算,省略...
char buf[max_len];
int str_count = 0;
// 数据流写入buf
// 包括版本去掉命令数据的分割符号代码"\0"
// 版本协议校验等,省略...
// 参数遍历
ArgumentIterator args(buf, (max_len)-left);
// 协议版本
char* v = args.next();
// 命令名称
char* name = args.next();
if (name == NULL || strlen(name) > AttachOperation::name_length_max) {
return NULL;
}
// 创建AttachOperation对象
LinuxAttachOperation* op = new LinuxAttachOperation(name);
// 从buf中读取AttachOperation参数
for (int i=0; i<AttachOperation::arg_count_max; i++) {
char* arg = args.next();
if (arg == NULL) {
op->set_arg(i, NULL);
} else {
if (strlen(arg) > AttachOperation::arg_length_max) {
delete op;
return NULL;
}
op->set_arg(i, arg);
}
}
// 将socket引用设置到op对象中
op->set_socket(s);
return op;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
这是Linux上的实现,不同的操作系统实现方式不一样。Attach Listener线程监听.java_pid
Attach 机制详细的交互流程可以用下面的图3-4描述。
图3-4 Attach交互处理流程
# 3.2.3 Attach机制涉及到的JVM参数
这里重新总结下Attach机制涉及到JVM参数。如下表3-1所示。
表3-1 Attach机制相关的JVM参数
名称 | 含义 | 默认值 |
---|---|---|
ReduceSignalUsage | 禁止信号量使用 | false |
DisableAttachMechanism | 禁止attach到当前JVM | false |
StartAttachListener | JVM 启动时初始化AttachListener | false |
EnableDynamicAgentLoading | 允许运行时加载Agent | true |
JVM 参数都在src/hotspot/share/runtime/globals.hpp
中定义