动态替换目标进程的Java类
转自 http://linmingren.me/blog/2013/02/%E5%8A%A8%E6%80%81%E6%9B%BF%E6%8D%A2%E7%9B%AE%E6%A0%87%E8%BF%9B%E7%A8%8B%E7%9A%84java%E7%B1%BB/
我们都知道在Eclipse中调试代码时,可以直接修改代码,然后继续调试,不需要重新启动它,我一直很好奇这是怎么实现的。找了一段时间后,发现做起来很简单,原理如下:
你可以把目标进程想象成你的被调试程序,而客户进程想象成Eclipse本身。当某些类有变化时,客户进程能探测到这些类的变化,然后动态换掉它们,这样目标进程就可以用上新的类了。
对应的Eclipse工程如下:
其中RunAlways工程对应的就是目标进程,它的main函数里会不停new一个新的User类,然后打印它的名字
package test; public class Main { public static void main(String[] args) throws InterruptedException { while (true) { //替换前,打印出 firstName.lastName //被替换后,打印lastName.firstName System.out.println(new User("firstName","lastName").getName()); Thread.sleep(5000); } } }
User类的代码如下:
package test; public class User { private String firstName; private String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getName() { return firstName + "." + lastName; } }
先启动它,假设对应的进程号是1234。
接下来是代理jar的编写,这个jar就包含一个类和一个manifest.mf文件。类的内容是
package agent; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.lang.instrument.ClassDefinition; import java.lang.instrument.Instrumentation; public class MyAgent { //agentArgs就是VirtualMachine.loadAgent()的第二个参数 public static void agentmain(String agentArgs, Instrumentation inst) { try { System.out.println("args: " + agentArgs); System.out.println("重新定义 test.User -- 开始"); //把新的User类文件的内容读出来 File f = new File(agentArgs); byte[] reporterClassFile = new byte[(int) f.length()]; DataInputStream in = new DataInputStream(new FileInputStream(f)); in.readFully(reporterClassFile); in.close(); //把User类的定义与新的类文件关联起来 ClassDefinition reporterDef = new ClassDefinition(Class.forName("test.User"), reporterClassFile); //重新定义User类, 妈呀, 太简单了 inst.redefineClasses(reporterDef); System.out.println("重新定义 test.User -- 完成"); } catch(Exception e) { System.out.println(e); e.printStackTrace(); } } }
就几句话,看注释就明白了。manifest.mf是放在src/META-INF目录下,内容是
Manifest-Version: 1.0
Agent-Class: agent.MyAgent
Can-Redefine-Classes: true
最后一句是必须的,否则运行起来目标程序会有异常:
java.lang.UnsupportedOperationException: redefineClasses is not supported in this environment at sun.instrument.InstrumentationImpl.redefineClasses(Unknown Source) at agent.MyAgent.agentmain(MyAgent.java:24) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source) at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) at java.lang.reflect.Method.invoke(Unknown Source) at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(Unknown Source) at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(Unknown Source)
从Eclipse中把这个工程导出为一个jar文件,记得要包含我们的manifest.mf文件。保存在e:/agent.jar(位置随便).
Client工程的Client类是这样:
package client; import java.lang.reflect.Field; import com.sun.tools.attach.VirtualMachine; public class Client { /** * @param args * @throws Exception */ public static void main(String[] args) throws Exception { //注意,是jre的bin目录,不是jdk的bin目录
//VirtualMachine need the attach.dll in the jre of the JDK. System.setProperty("java.library.path", "C:\\Program Files\\Java\\jdk1.7.0_13\\jre\\bin"); Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths"); fieldSysPath.setAccessible(true); fieldSysPath.set(null, null); //目标进程的进程id -- 记得改成正确的数字 VirtualMachine vm = VirtualMachine.attach("1234"); //参数1:代理jar的位置 //参数2, 传递给代理的参数 vm.loadAgent("e:/agent.jar", "e:/User.class"); } }
java.util.ServiceConfigurationError: com.sun.tools.attach.spi.AttachProvider: Provider sun.tools.attach.WindowsAttachProvider could not be instantiated: java.lang.UnsatisfiedLinkError: no attach in java.library.path Exception in thread "main" com.sun.tools.attach.AttachNotSupportedException: no providers installed at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208) at client.Client.main(Client.java:29)
现在就差最后一步了,就是要提供新的User类,它跟旧的User类的差别就是把lastName和firstName调换了位置:
package test; public class User { private String firstName; private String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } //打印出来的位置变了 public String getName() { return lastName + "." + firstName; } }
直接把这个类对应的class文件复制到e:/User.class (简单起见,你可以随便放在什么地方)。写了半天,终于可以看看成果了,直接运行Client类。然后看看目标进程的输出:
firstName.lastName
args: e:/User.class
重新定义 test.User — 开始
重新定义 test.User — 完成
lastName.firstName
lastName.firstName
妈呀,User类真的被我们改了。有点美中不足的是这个类只能被改一次,能不能像Eclise那样每次e:/User.class有变化都重新加载呢,这对整天写Java的我们来说,简直难度为0,直接启动一个线程,不停看那个文件,只要修改时间有变化,就重新加载它,代理类改成这样,里面包含一个自动侦测的线程。
package agent; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.lang.instrument.ClassDefinition; import java.lang.instrument.Instrumentation; public class MyAgent { public static void agentmain(String agentArgs, Instrumentation inst) { try { System.out.println("args: " + agentArgs); File f = new File(agentArgs); ClassFileWatcher watcher = new ClassFileWatcher(inst, f); watcher.start(); System.out.println("agentmain done "); } catch (Exception e) { System.out.println(e); e.printStackTrace(); } } private static void reDefineClass(Instrumentation inst, File classFile) { byte[] reporterClassFile = new byte[(int) classFile.length()]; DataInputStream in; try { System.out.println("redefiniton test.User -- begin"); in = new DataInputStream(new FileInputStream(classFile)); in.readFully(reporterClassFile); in.close(); ClassDefinition reporterDef = new ClassDefinition(Class.forName("test.User"), reporterClassFile); inst.redefineClasses(reporterDef); System.out.println("redefiniton test.User -- done"); } catch (Exception e) { e.printStackTrace(); } } private static class ClassFileWatcher extends Thread { private File classFile; private long lastModified = 0; private Instrumentation inst; private boolean firstRun = true; public ClassFileWatcher(Instrumentation inst, File classFile) { this.classFile = classFile; this.inst = inst; lastModified = classFile.lastModified(); } @Override public void run() { while (true) { if (firstRun || (lastModified != classFile.lastModified()) ) { firstRun = false; lastModified = classFile.lastModified(); reDefineClass(inst,classFile); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } } }
现在重新把整个过程再跑一遍,然后用新的User.class覆盖e:/User.class,每次覆盖后,你就会发现目标进程已经用上了新的版本,酷啊。
备注:下面这段代码是因为Java的关于动态修改java.library.path而引入的.
具体原因是因为在代码中设置java.library.path时,不会生效,因为JVM在启动时读取值后会一直缓存起来用。
下面的代码就是一个workaroud使修改java.library.path生效。
//注意,是jre的bin目录,不是jdk的bin目录 //VirtualMachine need the attach.dll in the jre of the JDK. System.setProperty("java.library.path","C:\\Program Files\\Java\\jdk1.7.0_13\\jre\\bin"); Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths"); fieldSysPath.setAccessible(true); fieldSysPath.set(null, null);
原因和ClassLoader的实现有关系,使用反射,让sys_paths==null,从而从新加载java.library.path. ClassLoader.loadLibrary() method: if (sys_paths == null) { usr_paths = initializePath("java.library.path"); sys_paths = initializePath("sun.boot.library.path"); }
设置java.library.path
java程序中:
System.setProperty("java.library.path", System.getProperty("java.library.path") + ":/home/terje/offline_devl/hadoop-2.6.0/lib/native/"); Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths"); fieldSysPath.setAccessible(true); fieldSysPath.set(null, null);
可以参考下面的这个bug:
http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4280189 但是从2002年到现在Sun一直都没有改,不知道出于什么原因考虑的。
有问题,就会有人解决问题,antony_miguel在一篇文章中,使用java的反射机制,完成了对于ClassLoader类中的usr_paths变量的动态修改,
public static void addDir(String s) throws IOException { try { Field field = ClassLoader.class.getDeclaredField("usr_paths"); field.setAccessible(true); String[] paths = (String[]) field.get(null); for (int i = 0; i < paths.length; i++) { if (s.equals(paths[i])) { return; } } String[] tmp = new String[paths.length + 1]; System.arraycopy(paths, 0, tmp, 0, paths.length); tmp[paths.length] = s; field.set(null, tmp); } catch (IllegalAccessException e) { throw new IOException("Failed to get permissions to set library path"); } catch (NoSuchFieldException e) { throw new IOException("Failed to get field handle to set library path"); } }
但是这种方法和jvm的实现强关联,只要jvm实现不是用的变量usr_paths来保存java.library.path的值,这个方法就不能用了。 但是只要知道源代码,小小的改动就应该可以实现了。