在JVM运行时加载一个Agent的jar包是Java agent的一种更加灵活的实现方式,因为动态Attach时不需要停止目标JVM进程,这个特性给Java Agent 的部署带来极大的便利。通常我们使用如下API将Agent的jar包Attach到目标JVM上。
import com.sun.tools.attach.*;
import java.io.IOException;
public class Main {
public static void main(String[] args) {
VirtualMachine vm = null;
try {
// 50447是目标JVM进程的PID
vm = VirtualMachine.attach("50447");
// 指定Java Agent的jar包路径
String agentPath = "/path/to/your/simple-agent.jar";
vm.loadAgent(agentPath, "agent init");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
vm.detach();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
Attach的本质是外部进程与Java 进程的建立通信和传输命令的过程,典型的 Attach 过程如下图所示:
在JVM中,SIGQUIT的默认行为是 dump 线程堆栈的信息,在类 Unix 系统中使用 kill -3 pid 来发送 SIGQUIT 信号。在执行 attach 时,外部进程发送 SIGQUIT给目标 JVM,目标 JVM 收到 SIGQUIT后检查临时文件是否有.attach_pid 文件,如果存在则响应Attach 事件创建 socket 文件,如果不存在.attach_pid文件,仅作 dump 线程堆栈。(详见下面的attach 流程图)
Unix domain socket又叫 IPC(inter-process communication 进程间通信) socket,用于实现同一主机上的进程间通信;
虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX domain socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。
UNIX domain socket 是全双工的,API 接口语义丰富,相比其它 IPC 机制有明显的优越性,目前已成为使用最广泛的 IPC 机制,Unix domain socket 是 POSIX 标准中的一个组件,linux 系统也是支持它的。使用 UNIX domain socket 的过程和网络 socket 十分相似。
在 UNIX世界中,一切皆文件,UNIX 域套接字也是一个文件,下面是在某个文件夹下执行 ls -al 命令的输出结果:
rasp.sock 文件描述符的第一个字母“s” 表示是一个 UNIX 域套接字。两个进程通过读写这个文件就实现了进程间的信息传递。UNIX 套接字文件的大小始终为0。
信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;
操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号;
目标进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后在回复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度;
信号名称 | 编号 | 描述 |
---|---|---|
SIGINT | 2 | 终端中断信号(Ctrl+C) |
SIGQUIT | 3 | 终端退出信号(Ctrl+),产生进程core dump文件 |
SIGKILL | 9 | 强制杀死信号,应用程序无法忽略或者捕获 |
SIGTERM | 15 | 结束进程信号,shell下执行kill 进程pid发送该信号 |
以上流程图中有两个文件 .attach_pid 和 .java_pid 分别由发起 attach 的进程和目标Java进程创建,.attach_pid文件用于通知目标Java进程创建 .java_pid 文件, .java_pid 文件则用于进程间socket 通信。
与 attach相关的代码在 /src/jdk.attach 下, share/classes 目录下代码是主要实现,其他目录下是平台相关代码。在 Linux 系统下,我们看 share/classes + linux 目录下代码。
attach方法入口:
// attach方法
public static VirtualMachine attach(String id)
throws AttachNotSupportedException, IOException {
// 方法入参非空检查(省略...)
List<AttachProvider> providers = AttachProvider.providers();
// providers非空检查(省略...)
AttachNotSupportedException lastExc = null;
// attach功能的实现完全依赖于 provider
// 在不同的操作系统中都提供了AttachProvider的实现,
// 实现类是VirtualMachineImpl.java
for (AttachProvider provider : providers) {
// 用户自己实现的 provider使用 SPI 机制
// 加载并注册到 providers链表中
try {
// 实际功能的实现依赖于provider的attachVirtualMachine方法
return provider.attachVirtualMachine(id);
} catch (AttachNotSupportedException x) {
lastExc = x;
}
}
throw lastExc;
}
attachVirtualMachine根据操作系统的不同new 一个VirtualMachineImpl实现类:
// AttachProviderImpl.java 类中
public VirtualMachine attachVirtualMachine(String vmid)
throws AttachNotSupportedException, IOException{
// 安全检查等代码(省略...)
// 创建一个 VirtualMachineImpl 对象
return new VirtualMachineImpl(this, vmid);
}
再来看看VirtualMachineImpl的构造器中实现逻辑
public class VirtualMachineImpl extends HotSpotVirtualMachine {
// UDS文件创建的目录
private static final String tmpdir = "/tmp";
String socket_path;
// attach 逻辑的实现
VirtualMachineImpl(AttachProvider provider, String vmid)
throws AttachNotSupportedException, IOException{
super(provider, vmid);
// This provider only understands pids
int pid = Integer.parseInt(vmid);
// 字符串转int的异常(省略...)
// 尝试获取容器中的 pid
int ns_pid = getNamespacePid(pid);
// 查找 .java_pid 文件是否创建
File socket_file = findSocketFile(pid, ns_pid);
socket_path = socket_file.getPath();
if (!socket_file.exists()) {
// .java_pid文件不存在,则创建
File f = createAttachFile(pid, ns_pid);
try {
// 发送 SIGQUIT信号给目标JVM,native方法
sendQuitTo(pid);
final int delay_step = 100;
final long timeout = attachTimeout();// 5000ms
long time_spend = 0;
long delay = 0;
do {
// 发送SIGQUIT后等待 socket文件创建
delay += delay_step;
try {
Thread.sleep(delay);
} catch (InterruptedException x) { }
time_spend += delay;
if (time_spend > timeout/2 && !socket_file.exists()) {
// 最后再发送一次SIGQUIT信号给目标JVM
sendQuitTo(pid);
}
} while (time_spend <= timeout && !socket_file.exists());
// 超时之后,.java_pid 文件还是没有创建,抛出异常
// 详见attach连接踩坑
if (!socket_file.exists()) {
throw new Exception("attach 失败");
}
} finally {
// attach文件用完就删
f.delete();
}
}
// 检查 .java_pid 文件的权限(详见下面的“attach踩坑记录”)
checkPermissions(socket_path);
//尝试下socket是否创建成功,连接成功后什么也不干,立即关闭
int s = socket();
try {
connect(s, socket_path);
} finally {
close(s);
}
}
// socket_path 路径置为 null,
// 往socket中发送消息之前会检查这个路径的值
public void detach() throws IOException {
synchronized (this) {
if (socket_path != null) {
socket_path = null;
}
}
}
// native 方法(省略...)
}
.java_pid文件查找实现(创建文件过程类似,省略…)
// 查找 /tmp 目录下的 .java_pid 文件
private File findSocketFile(int pid, int ns_pid) {
String root = "/proc/" + pid + "/root/" + tmpdir;
return new File(root, ".java_pid" + ns_pid);
}
JVM进程已经创建过.java_pid 文件,如果删除了这个文件,在/tmp/.java_pidXXX文件不存在的情况下会抛出jstack执行失败异常,解决办法是重启 Java 应用程序。非常关键的一点是别去删除.java_pid文件,由于我们使用的时centos7, 者默认一段时间之后会自动删除该文件,需要系统配置禁止删除该文件。
在建立连接之前,主动发起 attach 的进程会检查 socket 文件的权限,如果目标 JVM 创建 这个socket的权限与attach 进程权限不相同,抛出异常。
参考attach通信流程图。下面分四个步骤实现Go版本的 Attach。
保存 socket 文件目录和socket连接。
type VirtualMachine struct {
Pid int32 // 目标进程 PID
SocketFile string // .java_pidXXX 文件
AttachFile string // .attach_pid 文件
Socket *Socket // Socket 连接
SockFileLock sync.RWMutex // socketFile 的锁,线程安全的将sockPath置为空字符串
}
func NewVirtualMachine(pid int32) *VirtualMachine {
socketFile := filepath.Join(os.TempDir(), fmt.Sprintf(".java_pid%d", pid))
attachPath := filepath.Join(os.TempDir(), fmt.Sprintf(".attach_pid%d", pid))
return &VirtualMachine{
Pid: pid,
SocketFile: socketFile,
AttachFile: attachPath,
}
}
如果 .java_pidXXX 文件不存在,则Attach 进程在/tmp目录下创建 .attach_pidXXX 文件(XXX表示目标 JVM 进程 PID)。
// 与目标 JVM 建立连接
func (this *VirtualMachine) Attach() error {
if !this.existsSocketFile() {
err := this.createAttachFile()
if err != nil {
return err
}
err = syscall.Kill(int(this.Pid), syscall.SIGQUIT)
if err != nil {
return fmt.Errorf("canot send sigquit to java[%d],%v", this.Pid, err)
}
timeSpend := 0
delay := 0
// 循环条件:socket文件不存在并且未超时
// 参考代码open Jdk13下的src/jdk.attach/linux/classes/sun/tools/attach/VirtualMachineImpl.java
for ; !this.existsSocketFile() && timeSpend <= timeOut; {
delay += delayStep
time.Sleep(time.Duration(delay) * time.Millisecond)
timeSpend += delay
if timeSpend > timeOut/2 && !this.existsSocketFile() {
// 最后一次尝试发送SIGQUIT信号给目标JVM
err = syscall.Kill(int(this.Pid), syscall.SIGQUIT)
if err != nil {
return fmt.Errorf("canot send sigquit to java[%d],%v", this.Pid, err)
}
}
}
if !this.existsSocketFile() {
return fmt.Errorf("unable to open socket file %s: "+
"target process %d doesn't respond within %dms "+
"or HotSpot VM not loaded", this.SocketFile, this.Pid,
timeSpend)
}
// 用完attach_pidXXX就可以删除了,避免占用空间
this.deleteAttachFile()
}
// 监听 connet
addr, err := net.ResolveUnixAddr("unix", this.SocketFile)
if err != nil {
return err
}
c, err := net.DialUnix("unix", nil, addr)
if err != nil {
return err
}
this.Socket = &Socket{c}
return nil
}
func (this *VirtualMachine) LoadAgent(agentJarPath string) error {
this.SockFileLock.Lock()
if this.SocketFile == "" {
return fmt.Errorf("detach function has run")
}
this.SockFileLock.Unlock()
err := this.Socket.Execute("load", "instrument", "false", agentJarPath)
if err != nil {
return fmt.Errorf("execute load %s, %v", agentJarPath, err)
}
s, err := this.Socket.ReadString()
if err != nil {
return err
}
//如果发送 agent jar 包成功,
if s != "0" {
return fmt.Errorf("load agent jar err")
}
defer func() {
_ = this.Socket.Close()
}()
return nil
}
package main
import (
"Attach/app"
"fmt"
)
func main() {
vm := app.NewVirtualMachine(25112)
err := vm.Attach()
if err != nil {
fmt.Printf("attach err,%v", err)
}
err = vm.LoadAgent("/Users/xule05/Downloads/SimpleAgent/target/simple-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=xxxx")
if err != nil {
fmt.Printf("loadAgent err,%v", err)
}
vm.Detach()
}
打印出 “(V2)args: xxxx” 说明 Agent 已经被加载并初始化了。
参考了Java API和对应的native方法,使用Go语言实现了与JVM 进程通信,并发送loadAgent指令。使用Go语言来实现Attach,发起attach 的进程不再局限于Java进程,相比于Java进程占用内存非常少。 完整代码详参考github