1、背景
最近参与开发一个java项目,每次修改调试时就需要重启进程,由于工程较大,进程初始化任务较多,重启较慢,严重影响了开发效率,因此花了点时间研究java热更新机制,在项目中引入热更新后,每次的修改可以立即看到结果,提高了开发效率。
2、JavaAgent
JavaAgent是java程序代理,可以在程序启动或运行时插入自定义代码执行指定操作,根据代理时机分为启动时代理和运行时代理,经常被用于字节码修正。
2.1 启动时代理
该特性是在JDK1.5之后引入,在启动程序时通过javaagent参数指定代理类,代理类需要实现静态函数premain,该函数会在main函数前执行,premain函数有两种定义方式:
public static void premain(String args, Instrumentation inst); public static void premain(String args);
JVM首先尝试调用前者,如果没有实现,则尝试调用后者。
2.1.1 简单实现
- 启动类
package com.tencent; public class App { public static void main( String[] args ) { System.out.println("main"); } }
- 代理类
package com.tencent; public class Agent { public static void premain(String args, Instrumentation inst) { System.out.println("premain"); } }
2.1.2 配置manifest
在代理类所在jar包的manifest中指定代理类,Premain-Class: com.tencent.Agent。如果项目是通过maven构建,可配置maven-jar-plugin插件参数,如下:
<configuration> <archive> <manifestEntries> <Premain-Class>com.tencent.Agent</Premain-Class> </manifestEntries> </archive> </configuration>
2.1.3 运行
java -javaagent:agent-1.0-SNAPSHOT.jar -cp ./* com.tencent.App
2.2 运行时代理
该特性是在JDK1.6之后引入,在程序启动后通过加载代理类并运行静态函数agentmain执行代码,agentmain函数有两种定义方式:
public static void agentmain(String args, Instrumentation inst); public static void agentmain(String args);
JVM首先尝试调用前者,如果没有实现,则尝试调用后者。
2.2.1 简单实现
- 加载代理类
import com.sun.tools.attach.VirtualMachine; public class Server { public static void main( String[] args ) { String pid = 目标进程pid; VirtualMachine vm = VirtualMachine.attach(pid); vm.loadAgent("path/to/agent jar"); vm.detach(); } }
- 代理类
public class Agent { public static void agentmain(String args, Instrumentation inst) { System.out.println("agentmain"); } }
一般情况,会启动两个进程,一个是目标进程,用于运行代理类,一个是加载进程,用于等待指令加载代理类。
2.2.2 配置manifest
配置与启动代理类似,Premain-Class改为Agent-Class。
3、Instrument
Instrument技术可以实时修改字节码,使得在不改变原程序的基础上,增加监控等辅助功能,甚至可以修改原程序的类定义等。目前Java字节码生成框架主要有:ASM、Javassist、Byte Buddy。以下使用Javassist实现简单耗时统计。
- 启动类
package com.tencent; public class App { public static void main( String[] args ) { try { App app = new App(); app.fun(); Thread.sleep(500); } catch (Exception e) { System.out.println(e); } } public static void fun() { Thread.sleep(1000); } }
- 代理类
package com.tencent; public class Agent { public static void premain(String args, Instrumentation inst) { inst.addTransformer(new TimeTransformer()); } }
- 字节码修改类
public class TimeTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (!className.replace("/", ".").equals("com.tencent.App")) { return null; } try { ClassPool classPool = ClassPool.getDefault(); CtClass ctClass = classPool.get("com.tencent.App"); CtMethod[] methods = ctClass.getDeclaredMethods(); for (CtMethod orgMethod : methods) { String orgMethodName = orgMethod.getName(); String newMethodName = orgMethodName + "New"; orgMethod.setName(newMethodName); CtMethod newMethod = CtNewMethod.copy(orgMethod, orgMethodName, ctClass, null); StringBuilder body = new StringBuilder(); body.append("{\n"); body.append("long startTime = System.currentTimeMillis();\n"); body.append(newMethodName + "($$);\n"); body.append("long endTime = System.currentTimeMillis();\n"); body.append("System.out.println(\"[" + orgMethodName + "]:\" + " + "(endTime-startTime) + " + "\"ms\");\n"); body.append("}\n"); newMethod.setBody(body.toString()); ctClass.addMethod(newMethod); } return ctClass.toBytecode(); } catch (Exception e) { System.out.println(e.toString()); } return null; } }
TimeTransformer实现在com.tencent.App类中使用原方法名生成新方法,在新方法中调用原方法,并在调用前后加上时间统计,以此计算函数耗时。
- 配置manifest
需要配置Can-Retransform-Classes: true
- 运行
需要加入javassist依赖包。
4、Java热更新
目前Java热更新主要有三种方式:
- 定义不同的ClassLoader,当监听到文件变化后,通过新的ClassLoader加载新文件,已有对象的状态需要更新,如果有类的相关依赖还需要手动设置。
- 通过instrument技术修改字节码,代理class的加载过程。典型的有SpringLoaded、Jrebel框架。
- 修改JVM支持Class动态加载。
方式1实现简单,但当项目复杂时,需要手动维护的状态更新较多。方式2一般以代理参数形式接入应用,对原应用无需做任何修改。方式3并非官方提供,通用性值得考虑。
5、SpringLoaded
Springloaded是一款开源的java热更新工具,可以直接监测jar包变化,能够实时增删改方法、属性。
5.1 简单使用
- 启动类
public class App { public static void main( String[] args ) { Hot hot = new Hot(); while (true) { hot.run(); try { Thread.sleep(1000); } catch (Exception e) { } } } }
- 业务类
修改前
public class Hot { public void run() { System.out.println("run"); } }
修改后
public class Hot { public void run() { System.out.println("run"); extra(); } public void extra() { System.out.println("extra"); } }
此处我将启动类、业务类分别编译为app-1.0-SNAPSHOT.jar、utils-1.0-SNAPSHOT.jar,后者包含经常需要改动的逻辑,修改后重新打包,替换原jar包可看到实时变化。
5.2 运行
java -javaagent:/path/to/springloaded-1.2.9.jar -noverify -cp ./* -Dspringloaded=watchJars=utils-1.0-SNAPSHOT.jar com.tencent.App
参数说明:
javaagent:指定springloaded的jar包所在路径。
watchJars:需要监听变化的jar包,监听多个jar使用:进行分隔。
创建demo使用SpringLoaded时可以正常使用,但我在项目中加入SpringLoaded时,会有很多报错,看日志是很多type无法注册,使用的是最新版1.2.6,因此实际未选择该开源工具。
6、Jrebel
Jrebel是一款商用的热更新工具,收费标准是每年550刀,通过监听指定目录中class文件的变化进行热更新,能够实时增删改方法、属性。
6.1 Jrebel热更新原理
原理说明:
定义一个类C如下:
public class C extends X { int y = 5; int method1(int x) { return x + y; } }
初始启动程序时,jrebel通过instrument技术修改类定义,在方法调用中插入代理层,代理层将请求路由到具体实现上,路由规则为始终选择当前系统中最新版本的实现,插入代理如下:
public class C extends X { int y = 5; int method1(int x) { Object[] o = new Object[1]; o[0] = x; return Runtime.redirect(this, o, "C", "method1", "(I)I"); } }
程序启动加载的类C的初始实现版本如下:
public abstract class C0 { public static int method1(C c, int x) { int tmp1 = Runtime.getFieldValue(c, "C", "y", "I"); return x + tmp1; } }
当类C的定义修改为如下:
public class C { int y = 5; int z() { return 10; } int method1(int x) { return x + y + z(); } ... }
下次系统使用类C时,jrebel检测到类定义发生变化,会重新加载类的实现版本,如下:
public class C1 { public static int z(C c) { return 10; } public static int method1(C c, int x) { int tmp1 = Runtime.getFieldValue(c, "C", "y", "I"); int tmp2 = Runtime.redirect(c, null, "C", "z", "(V)I"); return x + tmp1 + tmp2; } ... }
由于代理规则始终选择最新版本的实现进行路由,因此会执行新逻辑。
6.2 Jrebel使用
- 下载jrebel
下载地址:https://jrebel.com/software/jrebel/download/prev-releases/,本文采用最新版2019.1.1。
- 注册jrebel
注册地址:https://jrebel.com/software/jrebel/trial/,注册后会获得license key,该license免费试用10天。
- 激活jrebel
解压jrebel,运行activate-gui.cmd或activate-gui.sh,选择Activation code,输入license key,激活后当前用户目录下会生成.jrebel文件夹,文件夹下包含许可证jrebel.lic,配置文件jrebel.prefs。
- 启动
java -Drebel.dirs=/path/to/classes/dir -Drebel.log=true -agentpath:/path/to/jrebel/lib/libjrebel64.so -noverify -cp ...
参数说明:
rebel.dirs:jrebel监听的class文件目录,初始时将要监听的jar包解压到此目录,需要修改时,将修改后的jar包覆盖解压到此目录。
agentpath:指定官网下载的jrebel压缩包中的liejrebel64.so路径,热更时需要用到压缩包中的其他文件,如jrebel.jar,需要保持该压缩包的完整性。
6.3 Jrebel破解
我在项目开发中加入Jrebel试用下来还是很不错,大部分情况下都可以热更新,在开发中确实可以节省不少时间,但每年550刀的收费标准还是略高了,于是我花了一点时间大概研究了一下jrebel关于license的反编译代码,总结了下最新版(2019.1.1)破解方式如下。
Jrebel的jar包是经过jar的混淆技术处理过的,反编译后很难重新编译成功,因此如果需要更改jar包的话只能直接修改class文件的字节码,然后重新打包。
Jrebel试用版许可证控制流程:
当使用试用版许可证时,默认使用期限为10天,使用日期相关配置会存储在jrebel.prefs中,程序运行时会根据jrebel.prefs中配置来判断许可证的有效性,在保证许可证不变的情况下,最简单的破解办法是定期删除jrebel.prefs并重建。当然此文并没有采用这种方式,而是通过修改字节码破解。
6.3.1 签名校验破解
修改许可证,那就需要修改签名校验,许可证的结构:数据块、签名块,因为没有加密秘钥,所以签名块没法伪造,只能通过改代码直接绕过签名校验。
许可证信息对应数据结构在com/zeroturnaround/licensing/UserLicense.class中,主要包含两部分:注册信息和签名,其中注册信息我们可以通过反序列化查看,大概信息如下(具体含义可自行研究):
{lastName=miao, GeneratedBy=AUTO, Email=741785694@qq.com, Organization=tc, enterprise=true, Product=JRebel, GeneratedOn=Tue Apr 23 14:39:17 CST 2019, validFrom=Tue Apr 23 14:39:17 CST 2019, OrderId=, limitedFrom=Tue Apr 23 14:39:17 CST 2019, version=1.27, Name=jemuel miao, Seats=1, uid=543971cc72a8e62a983010e17564daaec6ca2e26, firstName=jemuel, Type=evaluation, validUntil=Wed Apr 22 14:39:17 CST 2020, override=false, limitedUntil=Wed Apr 22 14:39:17 CST 2020, validDays=10}
因为没有加密秘钥,所以签名没法伪造,只能通过改代码直接绕过签名校验。签名校验入口在com/zeroturnaround/oc.class中,如下:
public class oc { ... public static boolean a(eca var0, UserLicense var1) { eed var2 = new eed(new dpw()); var2.a(false, var0); var2.a(var1.getLicense(), 0, var1.getLicense().length); return var2.a(var1.getSignature()); } }
由上面反编译的代码可以看出只需要该函数返回true即可跳过签名校验,有两种方式:
(1)修改oc.class,将逻辑改为return true,对应字节码为04 AC。
(2)修改eed.class,将eed.class中return false全改为return true。
为了保证字节码长度不变,此处我选择修改eed.class,本文采用dirtyJOE作为字节码修改工具。
当函数名称被混淆后,可以根据函数签名进行识别,选中函数后双击进入编辑字节码界面
找到所有03 AC的地方修改为04 AC。至此,签名校验已绕过。
6.3.2 许可证日期破解
许可证日期设置入口在com/zeroturnaround/kl.class中,读取日期存在两种情况:不存在jrebel.prefs配置文件和存在jrebel.prefs配置文件。
- 不存在jrebel.prefs配置文件
private static Object[] a() { try { HashMap var0 = new HashMap(); GregorianCalendar var1 = new GregorianCalendar(); Date var2 = (Date)var1.getTime().clone(); Date var3 = (Date)var1.getTime().clone(); var1.add(6, 10); Date var4 = (Date)var1.getTime().clone(); MessageDigest var5 = MessageDigest.getInstance("SHA-1"); var5.update(a((Serializable)var2)); byte[] var6 = var5.digest(); var0.put("start-date", var2); var0.put("digest", var6); byte[] var7 = a((Serializable)var0); return new Object[]{abt.a(var7), var3, var4}; } catch (NoSuchAlgorithmException var8) { throw new RuntimeException("Problem initializing JRebel diff file", var8); } }
- 存在jrebel.prefs配置文件
private static Object[] a(String var0) { try { byte[] var1 = abt.a(var0); ObjectInputStream var2 = new ObjectInputStream(new ByteArrayInputStream(var1)); Map var3 = (Map)var2.readObject(); Date var4 = (Date)var3.get("start-date"); byte[] var5 = (byte[])var3.get("digest"); MessageDigest var6 = MessageDigest.getInstance("SHA-1"); var6.update(a((Serializable)var4)); byte[] var7 = var6.digest(); GregorianCalendar var8 = new GregorianCalendar(); var8.setTime(var4); Date var9 = (Date)var8.getTime().clone(); var8.add(10, 240); Date var10 = (Date)var8.getTime().clone(); return new Object[]{MessageDigest.isEqual(var5, var7), var9, var10}; } catch (NoSuchAlgorithmException var11) { } catch (IOException var12) { } catch (ClassNotFoundException var13) { } return new Object[]{Boolean.FALSE, null, null}; }
由上面反编译的代码可以看出两个函数中的关键代码var1.add(6, 10)和var8.add(10, 240),都是将起始日期加上10天作为终止日期,因此只需将上面数值调整下即可突破有效期的限制。
将10 0A修改为10 7F,将11 00 F0修改为11 7F FF。至此,日期限制已突破。
6.3.3 验证
修改完所有class文件后,使用新的class文件替换原jrebel.jar中的旧文件,然后重新打jar包:jar -cvfm jrebel.jar META-INF/MANIFEST.MF ./*
启动进程,可以看到破解效果
破解前
破解后
参考文献:
- https://jrebel.com/rebellabs/why-hotswap-wasnt-good-enough-in-2001-and-still-isnt-today/
- https://www.jianshu.com/p/3bbfa22ec7f5
- http://bytebuddy.net/javadoc/1.9.12/index.html
- http://manuals.zeroturnaround.com/jrebel/standalone/index.html
- https://www.zhihu.com/question/61040749
文章评论