Java 文件句柄泄露問題解決小記

維護 WebIDE 免不了要管理不少的文件, 自從咱們線上系統增長了資源回收功能,便一直受一個問題困擾:後臺線程解綁目錄時偶爾報錯,看症狀由於是某些文件被佔用了,目錄不能解綁。可是因爲系統中不少地方都有打開文件,各類包也存在複雜的的引用關係,在搜查幾遍代碼後並無發現什麼明顯的異常。php

因爲這個功能清理的是既沒在線又沒有在離線列表中的磁盤綁定目錄,那麼極可能是文件句柄泄露了,還有一種緣由多是 JVM 延遲釋放文件句柄,不過實際是什麼緣由還須要用數聽說話。html

通過一番搜索,發一個工具叫 file-leak-detector, 能夠監控什麼線程在何時打開了哪兒的文件,看起來好酷,官網在這裏:
http://file-leak-detector.kohsuke.orgjava

使用方式

監聽 HTTP 端口方式啓動

以 javaagent 方式啓動一個 jar 文件,輸出在 http 19999 端口。
$java -javaagent:./file-leak-detector-1.8-jar-with-dependencies.jar=http=19999 -jar ide-backend.jar 
而後直接在瀏覽器訪問剛剛啓動時配置的 http 端口:
圖片
能夠看到當前全部打開中的文件的堆棧信息。git

配置參數啓動

配置線程數量限制, 在文件句柄持有數超過設定數值時輸出全部文件打開時的堆棧信息到 System 的 err 日誌中。
$ java -javaagent:path/to/file-leak-detector.jar=threshold=200 ...your usual Java args follows...spring

Attach 方式啓動

啓動後直接被加載到運行中的 JAVA 進程裏。api

$ 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 到正在運行的進程中,這點纔是咱們到線上監控所須要的,有些問題只有在線上纔會出現。markdown

不過官網裏並無發現怎麼掛到正在運行中的 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:

Package java.lang.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 或者系統日誌中輸出記錄。最後經過這些信息查找是哪裏致使的問題,而後作針對性的修復。

相關文章
相關標籤/搜索