动态替换目标进程的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"); } }
main函数的前四句是必须的,否则会有这样的异常:
 
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
-Djava.library.path=xxx
 

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的值,这个方法就不能用了。 但是只要知道源代码,小小的改动就应该可以实现了。

posted @ 2016-02-02 15:37 princessd8251 阅读(...) 评论(...)  编辑 收藏