前幾篇文章主要是去理解JVM類加載的原理和應用,這一回講一個可以自己動手的例子,希望能從頭到尾的理解類加載以及執行的整個過程。
這個例子是從周志明的著作《深入理解Java虛擬機》第9章里抄來的。原作者因為有豐富的經驗,可以站在一個很高的高度去描述整個過程。而我只能以現有的水平,簡單的理解這個例子。
如果讀者感覺不錯,那都是原作者的智慧;如果覺得不過爾爾,那就是我水平有限。
先說說日志。原先,我特別不喜歡在自己的程序里輸出日志。寫的時候那叫一個爽,可是一旦運行出錯,那就麻煩了。因為不知道具體執行到哪一步出的錯,所以就要調試一大片代碼。尤其是大的項目,是要經常去分析日志的。所以,我們都盡量在代碼里輸出詳細的日志。
但是,我們不可能把所有的情況考慮到。也就是說,當程序在服務器上跑的時候,我們想查看某個運行時的狀態和數據,如果沒有日志輸出,就無能為力。
當然,並不是真的無能為力。這篇文章就是教你一些思考,以及解決這個問題的一個思路。
說白了,要是服務器能夠臨時去執行一段代碼,輸出日志,問題迎刃而解。有了前面類加載的知識,我們應該會想到:我們自己寫一個類,然后動態加載到服務器的JVM進程的方法區,最后反射調用輸出日志的那個方法。
但是,仔細想想,需要考慮的事情還有許多:
1)這個類可能會經常的被修改,經常的被加載,所以,執行完之后,要能夠從方法區卸載。而能夠被卸載的條件之一,就是它的類加載器被回收。之前已經加載了多個類的類加載器,是不可能那么快被回收的。所以,這里要自定義一個類加載器去加載待執行的類。
2)待執行的類要能夠訪問原來項目里的類,比如說WEB-INF下面的那些類。那怎么辦呢?就要用到雙親委派模型了,將自定義類加載器的父類加載器設置為加載這個類加載器的類加載器。聽起來有點繞,沒關系,直接上代碼
/** * 為了多次載入執行類而加入的加載器<br> * 把defineClass方法開放出來,只有外部顯式調用的時候才會使用到loadByte方法 * 由虛擬機調用時,仍然按照原有的雙親委派規則使用loadClass方法進行類加載 * * @author zzm */ public class HotSwapClassLoader extends ClassLoader { public HotSwapClassLoader() { // 設置父類加載器,用以訪問JVM進程中的原來的類 super(HotSwapClassLoader.class.getClassLoader()); } /** * 加載待執行的類 */ public Class loadByte(byte[] classByte) { return defineClass(null, classByte, 0, classByte.length); } }
3)待執行類的方法里面的日志輸出到哪里?你可能脫口而出,System.out.println()。但是System.out是標准輸出,是整個JVM進程的資源,也不利於查看。也許,你會想通過System.setOut()指定一個文件作為輸出。可是,一旦設定,那以后整個JVM進程的輸出都會寫到這個文件里面,這樣就影響了原來的程序,這不是我們想要的。所以,我們必須寫一個類來代替System類的作用。
/** * 為JavaClass劫持java.lang.System提供支持 * 除了out和err外,其余的都直接轉發給System處理 * * @author zzm */ public class HackSystem { public final static InputStream in = System.in; private static ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public final static PrintStream out = new PrintStream(buffer); public final static PrintStream err = out; public static String getBufferString() { return buffer.toString(); } public static void clearBuffer() { buffer.reset(); } public static void setSecurityManager(final SecurityManager s) { System.setSecurityManager(s); } public static SecurityManager getSecurityManager() { return System.getSecurityManager(); } public static long currentTimeMillis() { return System.currentTimeMillis(); } public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length) { System.arraycopy(src, srcPos, dest, destPos, length); } public static int identityHashCode(Object x) { return System.identityHashCode(x); } // 下面所有的方法都與java.lang.System的名稱一樣 // 實現都是字節轉調System的對應方法 // 因版面原因,省略了其他方法 }
那就有人問了,既然能代替System類,就直接用這個類不就完了唄,也沒有System類的事了?問得好,這就是下面第4點。
4)我們在客戶端編寫待執行類時,不能依賴特定的類;如果依賴了特定的類,就只有在能夠訪問到特定類的地方才能編譯通過,受限制太多。也就是說,我們在寫執行類時,不能用到HackSystem類,但是執行的時候,卻又必須是HackSystem類。所以思路應該是這樣的:在執行類里面輸出時,還是用System.out,編譯完成后,再去修改編譯成的class文件,將常量池中java.lang.System這個符號替換成HackSystem。這里的難點是在程序中修改class文件,需要你特別熟悉class文件的每個數據項。
/** * 修改Class文件,暫時只提供修改常量池常量的功能 * @author zzm */ public class ClassModifier { /** * Class文件中常量池的起始偏移 */ private static final int CONSTANT_POOL_COUNT_INDEX = 8; /** * CONSTANT_Utf8_info常量的tag標志 */ private static final int CONSTANT_Utf8_info = 1; /** * 常量池中11種常量所占的長度,CONSTANT_Utf8_info型常量除外,因為它不是定長的 */ private static final int[] CONSTANT_ITEM_LENGTH = { -1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5 }; private static final int u1 = 1; private static final int u2 = 2; private byte[] classByte; public ClassModifier(byte[] classByte) { this.classByte = classByte; } /** * 修改常量池中CONSTANT_Utf8_info常量的內容 * @param oldStr 修改前的字符串 * @param newStr 修改后的字符串 * @return 修改結果 */ public byte[] modifyUTF8Constant(String oldStr, String newStr) { int cpc = getConstantPoolCount(); int offset = CONSTANT_POOL_COUNT_INDEX + u2; for (int i = 0; i < cpc; i++) { int tag = ByteUtils.bytes2Int(classByte, offset, u1); if (tag == CONSTANT_Utf8_info) { int len = ByteUtils.bytes2Int(classByte, offset + u1, u2); offset += (u1 + u2); String str = ByteUtils.bytes2String(classByte, offset, len); if (str.equalsIgnoreCase(oldStr)) { byte[] strBytes = ByteUtils.string2Bytes(newStr); byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2); classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen); classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes); return classByte; } else { offset += len; } } else { offset += CONSTANT_ITEM_LENGTH[tag]; } } return classByte; } /** * 獲取常量池中常量的數量 * @return 常量池數量 */ public int getConstantPoolCount() { return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2); } } /** * Bytes數組處理工具 * @author */ public class ByteUtils { public static int bytes2Int(byte[] b, int start, int len) { int sum = 0; int end = start + len; for (int i = start; i < end; i++) { int n = ((int) b[i]) & 0xff; n <<= (--len) * 8; sum = n + sum; } return sum; } public static byte[] int2Bytes(int value, int len) { byte[] b = new byte[len]; for (int i = 0; i < len; i++) { b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff); } return b; } public static String bytes2String(byte[] b, int start, int len) { return new String(b, start, len); } public static byte[] string2Bytes(String str) { return str.getBytes(); } public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) { byte[] newBytes = new byte[originalBytes.length + (replaceBytes.length - len)]; System.arraycopy(originalBytes, 0, newBytes, 0, offset); System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length); System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.length, originalBytes.length - offset - len); return newBytes; } }
最后,來看看實現替換符號引用以及得到輸出日志的類
/** * JavaClass執行工具 * * @author zzm */ public class JavaClassExecuter { /** * 執行外部傳過來的代表一個Java類的Byte數組<br> * 將輸入類的byte數組中代表java.lang.System的CONSTANT_Utf8_info常量修改為劫持后的HackSystem類 * 執行方法為該類的static main(String[] args)方法,輸出結果為該類向System.out/err輸出的信息 * @param classByte 代表一個Java類的Byte數組 * @return 執行結果 */ public static String execute(byte[] classByte) { HackSystem.clearBuffer(); ClassModifier cm = new ClassModifier(classByte); byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem"); HotSwapClassLoader loader = new HotSwapClassLoader(); Class clazz = loader.loadByte(modiBytes); try { Method method = clazz.getMethod("main", new Class[] { String[].class }); method.invoke(null, new String[] { null }); } catch (Throwable e) { e.printStackTrace(HackSystem.out); } return HackSystem.getBufferString(); } }
傳進來待執行類的class文件的字節數組,先將符號替換,然后加載該類,反射調用該類的main方法,最后將HackSystem類收集到的輸出日志返回。
為了更直觀的看到運行的結果,可以寫一個jsp文件,通過瀏覽器去訪問。
<%@ page import="java.lang.*" %> <%@ page import="java.io.*" %> <%@ page import="org.fenixsoft.classloading.execute.*" %> <% InputStream is = new FileInputStream("c:/TestClass.class"); byte[] b = new byte[is.available()]; is.read(b); is.close(); out.println("<textarea style='width:1000;height=800'>"); out.println(JavaClassExecuter.execute(b)); out.println("</textarea>"); %>
這里將待執行類TestClass.class放到服務器的C盤。只要TestClass里面main方法,有調用System.out,就可以將輸出內容展現到頁面上。我自己在Tomcat上面的項目里也測試了一把,現在把代碼也貼出來
public class TestClass { public static void main(String[] args) { System.out.println("hello world!!!"); ClassLoader cl = TestClass.class.getClassLoader(); System.out.println("self: " + cl); while (cl.getParent() != null) { System.out.println(cl.getParent().getClass()); cl = cl.getParent(); } } }
大家可以那我這個類去試一試,而且還可以根據輸出結果去溫習一下Tomcat的類加載體系。
整體流程講完了,感覺還是很燒腦。不經意間,我們就充當了一回黑客,將系統類的調用變成了調用我們自己的邏輯。Java引入JVM的目的就是提高靈活性,可以動態的運行,但是也引入了一定的安全問題。
回想整個流程,其實也有可替代的方案。比如jdk1.6引入了動態編譯,可以在運行時動態的編譯和執行我們的待執行類,但還是依賴了特定類。
我這里只是拋磚引玉,推薦大家去看原作者的書,去看看更詳細的講解。