維護 WebIDE 免不了要管理很多的文件, 自從我們線上系統增加了資源回收功能,便一直受一個問題困擾:后台線程解綁目錄時偶爾報錯,看症狀因為是某些文件被占用了,目錄不能解綁。但是由於系統中很多地方都有打開文件,各種包也存在復雜的的引用關系,在搜查幾遍代碼后並沒有發現什么明顯的異常。
由於這個功能清理的是既沒在線又沒有在離線列表中的磁盤綁定目錄,那么很可能是文件句柄泄露了,還有一種原因可能是 JVM 延遲釋放文件句柄,不過實際是什么原因還需要用數據說話。
經過一番搜索,發一個工具叫 file-leak-detector, 可以監控什么線程在什么時候打開了哪兒的文件,看起來好酷,官網在這里:
http://file-leak-detector.kohsuke.org
使用方式
監聽 HTTP 端口方式啟動
以 javaagent 方式啟動一個 jar 文件,輸出在 http 19999 端口。$java -javaagent:./file-leak-detector-1.8-jar-with-dependencies.jar=http=19999 -jar ide-backend.jar
然后直接在瀏覽器訪問剛剛啟動時配置的 http 端口:
可以看到當前所有打開中的文件的堆棧信息。
配置參數啟動
配置線程數量限制, 在文件句柄持有數超過設定數值時輸出所有文件打開時的堆棧信息到 System 的 err 日志中。$ java -javaagent:path/to/file-leak-detector.jar=threshold=200 ...your usual Java args follows...
Attach 方式啟動
啟動后直接被加載到運行中的 JAVA 進程里。
$ java -jar path/to/file-leak-detector.jar 1500 threshold=200,strong
strong 代表的含義是把記錄信息的應用變成強引用,防止被 GC 回收掉,不設置在內存不足時文件記錄會丟失。
實際使用體驗
首先我們在測試服務器上部署端口來監控,然后進行各種測試,最后確實找到幾處未關閉的代碼。
$java -javaagent:./file-leak-detector-1.8-jar-with-dependencies.jar=http=19999 -jar xxx.jar
不過有一點比較不爽,綁定的地址是固定的 localhost, 遠程的就不能訪問。
╭─tiangao@tgmbp ~/git/tiangao ‹master*›
╰─$ curl 192.168.31.227:19999
curl: (7) Failed to connect to 192.168.31.227 port 19999: Connection refused
這個先放一邊,官網說還可以 attach 到正在運行的進程中,這點才是我們到線上監控所需要的,有些問題只有在線上才會出現。
不過官網里並沒有發現怎么掛到正在運行中的 java 程序並開啟 http 端口輸出,而且監聽的端口只有 localhost。這就讓我們感覺有點怪異,
也許有安全性的考量吧,只好去看看源碼,才知道怎么個用法,為了更方便還改了下監聽的 host,以便遠程可以訪問。
AgentMain.java
private static void runHttpServer(int port) throws IOException {
final ServerSocket ss = new ServerSocket(); ss.bind(new InetSocketAddress("0.0.0.0", port)); System.err.println("Serving file leak stats on http://0.0.0.0:"+ss.getLocalPort()+"/ for stats"); ... }
改之后使用如下所示:
root@staging-1:~# java -jar file-leak-detector-1.8-jar-with-dependencies.jar 612 http=19999
Connecting to 612
Activating file leak detector at /root/file-leak-detector-1.8-jar-with-dependencies.jar
612
是 java 服務的進程號,19999 是監聽的 http
端口號。
執行后輸出類似如下內容時即表示 attach
到進程成功。
╭─tiangao@tgmbp ~/git/WebIDE-Backend/target ‹master*›
╰─$ java -jar file-leak-detector-1.8-jar-with-dependencies.jar 93739
Connecting to 93739
Activating file leak detector at /Users/shitiangao/git/WebIDE-Backend/target/file-leak-detector-1.8-jar-with-dependencies.jar
然后通過地址加端口就可以訪問, 就可以顯示進程在 attach
之后打開的文件以及相應堆棧信息。
3 descriptors are open
#1 /opt/coding-ide-home/ide-backend.jar by thread:qtp873134840-16 on Tue Nov 29 15:05:34 CST 2016 at java.io.RandomAccessFile.<init>(RandomAccessFile.java:244) at org.springframework.boot.loader.data.RandomAccessDataFile$FilePool.acquire(RandomAccessDataFile.java:252) at org.springframework.boot.loader.data.RandomAccessDataFile$DataInputStream.doRead(RandomAccessDataFile.java:174) at org.springframework.boot.loader.data.RandomAccessDataFile$DataInputStream.read(RandomAccessDataFile.java:152)
如此改動測試后在本地好用,但是一到線上部署就報錯了:
pid: 13546
Connecting to 13546
Exception in thread "main" java.lang.reflect.InvocationTargetException
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:497) at org.kohsuke.file_leak_detector.Main.run(Main.java:54) at org.kohsuke.file_leak_detector.Main.main(Main.java:39) Caused by: com.sun.tools.attach.AttachNotSupportedException: Unable to open socket file: target process not responding or HotSpot VM not loaded at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:106) at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63) at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208) ... 6 more
目測原因是 JVM 運行時反射加載不到類。
第一感覺需要設置一下 JAVA_HOME, 然而結果證明並不是這個原因。
萬能的 google & stackoverflow 找到了解法:
java - AttachNotSupportedException due to missing java_pid file in Attach API
執行 attach 的用戶需要和 Java 服務運行用戶是同一個,另外 JAVA_HOME 環境變量還是需要的。
終於成功了,接下來就是等待錯誤的再次發生,然后分析堆棧信息了。
如此好用的工具是讓我們對其原理很好奇。
工作原理
項目源碼並不是太多,先看 main :
public static void main(String[] args) throws Exception {
Main main = new Main(); CmdLineParser p = new CmdLineParser(main); try { p.parseArgument(args); main.run(); } catch (CmdLineException e) { System.err.println(e.getMessage()); System.err.println("java -jar file-leak-detector.jar PID [OPTSTR]"); p.printUsage(System.err); System.err.println("\nOptions:"); AgentMain.printOptions(); System.exit(1); } }
來到 run() 方法,
public void run() throws Exception { Class api = loadAttachApi(); System.out.println("Connecting to "+pid); Object vm = api.getMethod("attach",String.class).invoke(null,pid); try { File agentJar = whichJar(getClass()); System.out.println("Activating file leak detector at "+agentJar); // load a specified agent onto the JVM api.getMethod("loadAgent",String.class,String.class).invoke(vm, agentJar.getPath(), options); } finally { api.getMethod("detach").invoke(vm); } }
通過 loadAttachApi()
得到 VirtualMachine 類
,然后再用反射獲取 attach()
方法,緊接着執行 attach()
到指定進程 id 上,得到 vm 的實例后執行 loadAgent()
方法,第一個參數為 agentJar 包的路徑,第二個 options 是附加參數。
loadAttachApi()
方法如下:
private Class loadAttachApi() throws MalformedURLException, ClassNotFoundException {
File toolsJar = locateToolsJar(); ClassLoader cl = wrapIntoClassLoader(toolsJar); try { return cl.loadClass("com.sun.tools.attach.VirtualMachine"); } catch (ClassNotFoundException e) { throw new IllegalStateException("Unable to find tools.jar at "+toolsJar+" --- you need to run this tool with a JDK",e); } }
問題來了,VirtualMachine
是個什么功能的類? attach()
loadAgent()
又是什么作用呢?
這個就涉及到 JVM 層面提供的功能,在這之前也沒有研究過,只好看看大拿的研究。
InfoQ JVM 源碼分析之 javaagent 原理完全解讀
關鍵類 Instrument:
簡單總結,JVM 暴露了一些動態操作已加載類型的接口,javaagnet 就是利用這些接口的一個實現,通過 agent 類的固定方法可以執行一些操作,比如對已經加載的類注入字節碼,最常用的是用來監控運行時,進行一些疑難 bug 追蹤。
此項目里 TransformerImpl 類就是字節碼修改的實現類。
關鍵源碼:
instrumentation.addTransformer(new TransformerImpl(createSpec()),true);
instrumentation.retransformClasses( FileInputStream.class, FileOutputStream.class, RandomAccessFile.class, Class.forName("java.net.PlainSocketImpl"), ZipFile.class);
可以看到注冊的類有 FileInputStream、FileOutputStream、RandomAccessFile、ZipFile 和 PlainSocketImpl。
static List<ClassTransformSpec> createSpec() { return Arrays.asList( newSpec(FileOutputStream.class, "(Ljava/io/File;Z)V"), newSpec(FileInputStream.class, "(Ljava/io/File;)V"), newSpec(RandomAccessFile.class, "(Ljava/io/File;Ljava/lang/String;)V"), newSpec(ZipFile.class, "(Ljava/io/File;I)V"), /* java.net.Socket/ServerSocket uses SocketImpl, and this is where FileDescriptors are actually managed. SocketInputStream/SocketOutputStream does not maintain a separate FileDescritor. They just all piggy back on the same SocketImpl instance. */ new ClassTransformSpec("java/net/PlainSocketImpl", // this is where a new file descriptor is allocated. // it'll occupy a socket even before it gets connected new OpenSocketInterceptor("create", "(Z)V"), // When a socket is accepted, it goes to "accept(SocketImpl s)" // where 's' is the new socket and 'this' is the server socket new AcceptInterceptor("accept","(Ljava/net/SocketImpl;)V"), // file descriptor actually get closed in socketClose() // socketPreClose() appears to do something similar, but if you read the source code // of the native socketClose0() method, then you see that it actually doesn't close // a file descriptor. new CloseInterceptor("socketClose") ), new ClassTransformSpec("sun/nio/ch/SocketChannelImpl", new OpenSocketInterceptor("<init>", "(Ljava/nio/channels/spi/SelectorProvider;)V"), new CloseInterceptor("kill") ) ); }
ClassTransformSpec
定義:/** * Creates {@link ClassTransformSpec} that intercepts * a constructor and the close method. */ private static ClassTransformSpec newSpec(final Class c, String constructorDesc) { final String binName = c.getName().replace('.', '/'); return new ClassTransformSpec(binName, new ConstructorOpenInterceptor(constructorDesc, binName), new CloseInterceptor() ); }
關鍵真相在這里,實現了一個方法攔截適配器,在注冊類的某些方法執行后運行 Listener
類的 open()
方法來記錄信息。
/** * Intercepts the this.open(...) call in the constructor. */ private static class ConstructorOpenInterceptor extends MethodAppender { /** * Binary name of the class being transformed. */ private final String binName; public ConstructorOpenInterceptor(String constructorDesc, String binName) { super("<init>", constructorDesc); this.binName = binName; } @Override public MethodVisitor newAdapter(MethodVisitor base, int access, String name, String desc, String signature, String[] exceptions) { final MethodVisitor b = super.newAdapter(base, access, name, desc, signature, exceptions); return new OpenInterceptionAdapter(b,access,desc) { @Override protected boolean toIntercept(String owner, String name) { return owner.equals(binName) && name.startsWith("open"); } @Override protected Class<? extends Exception> getExpectedException() { return FileNotFoundException.class; } }; } protected void append(CodeGenerator g) { g.invokeAppStatic(Listener.class,"open", new Class[]{Object.class, File.class}, new int[]{0,1}); } } ``` 最后的 `append()` 方法可以說是整個流程中最核心的地方,`Listener#open()` 方法如下所示: ``` public static synchronized void open(Object _this, File f) { put(_this, new Listener.FileRecord(f)); Iterator i$ = ActivityListener.LIST.iterator(); while(i$.hasNext()) { ActivityListener al = (ActivityListener)i$.next(); al.open(_this, f); } }
最后說 一下 Listener
這個類,這也是這個工具的一個關鍵的類實現,有許多靜態方法,所有監控的打開的文件相關內容都在 Listener
中保存,內容輸出的操作也在其中。
TABLE 保存打開中的文件,默認是 weak 引用,內存不足時這個對象會被回收掉,以防止程序不會因為監控導致的內存不足而異常退出。
當參數 strong 存在時會 new 一個 LinkedHashMap, 讓監控內容的容器不會被回收掉。
/**
* Files that are currently open, keyed by the owner object (like {@link FileInputStream}.
*/
private static Map<Object,Record> TABLE = new WeakHashMap<Object,Record>(); ``` Record 中有三個字段,一個是用來保存堆棧信息的異常類型,一個是線程名,最后一個是時間。 ``` /** * Remembers who/where/when opened a file. */ public static class Record { public final Exception stackTrace = new Exception(); public final String threadName; public final long time; ... }
到這里已經差不多了,其他細節實現也就不贅述了。
小結
file-leak-detector 查找文件句柄泄露問題,就是用 JVM 提供的接口,以 agent 方式 attach 進正在運行的 JAVA 進程,修改 FileStream
等類型的字節碼,在 open & close 文件時加入攔截操作,記錄線程和堆棧,然后在 http 或者系統日志中輸出記錄。最后通過這些信息查找是哪里導致的問題,然后做針對性的修復。