一、字節碼是什么
Java程序都是跑在JVM上的,我們日常所編寫的 java文件需要先編譯為.class文件然后才可以被類加載器加載后進入到JVM中,被正確識別后才能運行,而這個.class文件里的內容就是我們今天要說的字節碼。
我們可以通過命令:javap -verbose + 類名 查看字節碼內容,如下:
二、實現思路
我們先看一張流程圖:
我們手工編寫 Java 文件,然后編譯成 .class文件,最終通過類加載器將類加載進 JVM。如果我們不想更改源碼,但是又想對程序做一些修改,讓程序按照我們預期去運行,那么我們可以在上圖中的編譯和加載這兩個步驟去進行,即:如果我們可以把.class文件的內容改成我們所需要的,我們的預期就得以實現了。
三、應用場景
1、流量回放
我們可以在程序的入口處,修改入口處的字節碼,增加流量進入時的存儲邏輯,當流量進來時,就可以按照我們自己的格式和要求將流量落地,進而給后續的回放提供支撐.有些團隊會選擇在代碼中加注解來識別,比如基於Spring的aop或cglib來達到這種效果,但是這樣會對代碼有一定的侵入性.個人感覺不如原生字節碼注入好.
2、各類開關
我們經常會有一些需求比如線上線下環境要實現不同的邏輯處理,如調用第三方接口有驗簽或加密時,可能為了線下的mock方便就會加各種開關來關閉掉,此時其實我們就可以使用字節碼技術來做到不修改源代碼邏輯,隨意控制開關的有無.
3、異常代碼注入
現在有一個比較好玩的技術叫混沌工程,幾年前有一些說法叫故障演練,在注入故障時,除了系統級的故障外,其實我們也可以模擬一些代碼級的故障,比如各種異常返回,拋出異常,方法執行超時等等.那么這些隨機的,各種異常的代碼故障,都可以應用字節碼修改的手段來完成.目前這塊也有比較成熟的框架供使用,后續會有介紹
四、JVMTI
- JVMTI: JVM Tool Interface 是JVM提供的native編程接口,是JVM為我們提供的一套針對java程序的"后門"API,方便我們做字節碼增強,剖析,調試,監控,分析線程等。
- Instrument: JVM提供的一個可以修改已加載類的接口,可以對java程序做agent和attach兩種方式的修改。
- agent: 借助於Instrument相關api,在啟動程序時指定本地一個jar包,在將.class文件加載進內存之后,運行main方法之前,完成相關增強任務.這種方式有一個問題就是每次想要完成增強都需要重新啟動一下進程來完成增強任務,如果不想重新啟動就完成修改,那么就需要使用第二種方式attach。
- attach: 與agent的區別是,此種方式不需要重新啟動,可以對運行中的進程直接掛載來完成增強的任務,在attach成功之后,具體實現即對已經加載的class重新加載一次,來完成增強任務。
五、類庫支持
當前主要有大神級的ASM, 以及大俠級的 javassist,其他也有一些,不過多是基於ASM做的各種二次開發和擴展來的。我們的演示將會以 javassist來展開,這款框架主要是簡單,上手快,可以快速出結果。
六、agent演示
1、創建一個 SpringBoot 工程,我們要對這個工程的代碼進行無修改式的侵入

@RestController public class PingController { @RequestMapping("/ping") public String ping(){ System.out.println("ping init"); return "pong"; } }

@SpringBootApplication public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } }
我們的目的是要修改 PingController 中的 ping() 方法的邏輯,在未修改時,ping()方法應該返回的是 “pong”。
2、新建普通工程 agent
導入 javassist 依賴
jar 包配置
編寫agent入口方法 premain
實現字節碼的增強

public class AccessLogTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String classPath, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { CtClass ctClass = null; try { if (null == classPath || classPath.trim().length() == 0) { return classfileBuffer; } // 將classPath轉成className形式 String className = classPath.replaceAll("/", "."); if (!TARGET_CLASS_NAME.equals(className)) { // 如果不是目標要增強的類則結束 return classfileBuffer; } // 獲取class加載的池子,從中取出我們要去增強的類 ClassPool classPool = ClassPool.getDefault(); classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader())); // 目標類: PingController ctClass = classPool.get(className); // 目標方法: ping CtMethod ctMethod = ctClass.getDeclaredMethod(TARGET_METHOD_NAME); // 對目標方法的前后實施增強 // ctMethod.insertBefore("Thread.sleep(10000L);"); ctMethod.insertBefore("if(1>0) {return \"hello world.\";}"); // ctMethod.insertBefore("System.out.println(\"access log begin.\");"); ctMethod.insertAfter("System.out.println(\"access log end.\");"); // 指定行數進行增強 // ctMethod.insertAt(18,"System.out.println(\"access log 18.\");"); // 增強結束后寫回結果 ctClass.writeFile(); return ctClass.toBytecode(); } catch (Exception e) { // 暫時不做相關異常處理 e.printStackTrace(); } finally { if (null != ctClass) { ctClass.detach(); } } return classfileBuffer; } }
我們可以在指定方法的最前面、最后面以及某一行加上指定的代碼來改變方法的邏輯。
上面這段代碼我們是在目標類的目標方法的最前面加上了:
if(1>0) { return "hello world."; }
方法的最后加上了:
System.out.println("access log end.");
打包 + 測試
- 打包 SpringBoot工程;
- 打包 agent 模塊;
- 使用如下命令運行 SpringBoot 工程
- java -javaagent:/path/to/local/agent-1.0.0-SNAPSHOT.jar -jar /path/to/springboot-service/web-server-1.0.0-SNAPSHOT.jar
再次請求,返回的是“Hello,world.”,沒有 agent侵入之前,返回的是“pong”。
七、attach演示
1、SpringBoot工程還是使用 agent演示中的那一個
2、新建普通工程 agent
導入 javassist 依賴
jar 包配置
編寫attach入口方法agentmain
實現字節碼增強的AccessLogTransformer與agent一致
編寫attach main方法

public class AttachMain { public static void main(String[] args) { // String targetPid = "基於jps命令獲取到的web進程id"; // String agentPath = "將attach打包后的jar包路徑"; /** * 這里attach的是本地的進程 * 如果需要attach遠程的進程,則需要讓遠程機器開啟rmi的支持並且給rmi開個端口 * rmi://192.168.182.128:8000 */ String targetPid = "27156"; String agentPath = "E:\\Java_Project\\jvm-demo\\attach\\target\\libs\\attach-1.0.0-SNAPSHOT.jar"; List<VirtualMachineDescriptor> vmDescriptors = VirtualMachine.list(); VirtualMachineDescriptor targetJvmDescriptor = vmDescriptors.stream() .filter(descriptor -> descriptor.id().equals(targetPid)) .findFirst() .orElseThrow(() -> new IllegalStateException("none target jvm exists")); VirtualMachine virtualMachine = null; try { virtualMachine = VirtualMachine.attach(targetJvmDescriptor); virtualMachine.loadAgent(agentPath); } catch (Exception e) { e.printStackTrace(); } finally { if (virtualMachine != null) { try { virtualMachine.detach(); } catch (IOException e) { // ignore e.printStackTrace(); } } } } }
測試
- 啟動 SpringBoot 程序,並獲取 PID;
- 打包attach模塊,並獲取jar包路徑;
- 將 AttachMain 類的 main 方法中的 targetPid 和agentPath 替換為以上兩個值;
- 運行AttachMain 類的 main 方法;
再次請求,返回的是“Hello,world.”,沒有 agent侵入之前,返回的是“pong”。
attach 方式的優點是相對於 agent方式,不用重啟目標服務,但是得獲取到目標服務的 PID,以及 attach包的路徑。