揭秘在安卓平臺上奇慢無比的 ClassLoader.getResourceAsStream
我們 NimbleDroid 經過大量的分析,發現了一些避免 APP 整體變慢,讓 APP 快速啟動以及迅速響應的技巧。其中有一個就是奇慢無比的 ClassLoader.getResourceAsStream 函數,這個函數可以讓 APP 通過名字訪問資源。在傳統的 Java 程序開發中,這個函數用得非常普遍,但是在安卓平臺上,這個函數在第一次調用時執行時間非常長,會嚴重拖慢安卓 APP 的運行。在我們分析的 APP 和 SDK 中(我們分析了大量的 APP 和 SDK ),我們發現超過 10% 的 APP 和 20% 的 SDK 都由于使用了這個函數而急劇變慢。那究竟為什么這個函數如此之慢呢?我們將在這邊文章中進行深度揭秘。
榜單 APP 中被拖慢的案例
亞馬遜的 Kindle 安卓版,擁有過億的下載量,4.15.0.48 版本中,由于使用了這個函數,導致了 1315 毫秒的延遲。
另一個例子是 TuneIn 13.6.1 版本,因此導致了 1447 毫秒的延遲。在這里 TuneIn 調用了兩次 getResourceAsStream 函數,第二次調用時就很快了(只需要 6 毫秒)。
下面我們列出了受此問題影響的 APP:
在我們分析的 APP 中,有超過 10% 的 APP 都受此問題的影響。
調用了 getResourceAsStream 函數的 SDK
為了行文簡潔,我們用 SDK 來指代所有的庫,無論是像 Amazon AWS 這樣提供特定服務的庫,還是像 Joda-Time 這樣更通用的庫。
通常,一個 APP 不會直接調用 getResourceAsStream 函數,而是這個 APP 使用的某個 SDK 調用了這個函數。由于開發者通常不會關注使用的 SDK 的實現細節,所以他們通常都不知道自己的 APP 存在這樣的問題。
下面我們列出了一些知名的調用了 getResourceAsStream 函數的 SDK:
-
mobileCore
-
SLF4J
-
StartApp
-
Joda-Time
-
TapJoy
-
Google Dependency Injection
-
BugSense
-
RoboGuice
-
OrmLite
-
Appnext
-
Apache log4j
-
推ter4J
-
Appcelerator Titanium
-
LibPhoneNumbers (Google)
-
Amazon AWS
總的來說,我們分析的 SDK 中,有超過 20% 的 SDK 都存在此問題,由于篇幅有限,上面的列表中我們只列出了少數較為知名的 SDK。 這個問題在 SDK 中如此普遍,原因之一就是 getResourceAsStream() 函數在非安卓平臺上都是很快的。由于很多從 Java 轉型的安卓開發者都使用了他們比較熟悉的庫,例如使用了 Joda-Time 而不是 Dan Lew 開源的 Joda-Time-Android,因此很多 APP 都受到了這個問題的影響。
為什么 getResourceAsStream 函數在安卓平臺如此之慢
發現了 getResourceAsStream 函數在安卓平臺如此之慢,我們理所當然的需要分析一下它為什么如此之慢。經過深入的分析,我們發現這個函數第一次被調用時,系統會執行三個非常耗時的操作:(1) 以 zip 壓縮包的方式打開 APK 文件,為 APK 內的所有內容建立索引;(2) 再次打開 APK 文件,并再次索引所有的內容;(3) 校驗 APK 文件被正確的進行了簽名操作。上述三個操作都非常慢,總的延遲和 APK 文件的大小呈線性關系。例如一個 20MB 的 APK 文件執行上述操作需要 1-2 秒的延遲。在附錄中,我們具體描述了這個分析的過程。
建議:避免調用 ClassLoader.getResource*() 函數,而是使用安卓系統提供的 Resources.get*(resId) 函數
建議:測量你的 APP,查看是否使用的 SDK 調用了 ClassLoader.getResource*() 函數。將這些 SDK 替換為更高效的版本,或者至少不要在主線程觸發這些函數的調用。
立即查看你的 APP 有沒有被 ClassLoader.getResource*() 函數拖慢!
附錄:我們是如何定位 getResourceAsStream 函數中的耗時操作的
為了理解這個問題的根本原因,我們分析一下安卓系統的源碼。我們分析的是 AOSP 的 android-6.0.1_r11 分支。我們首先看一下 ClassLoader 的代碼:
libcore/libart/src/main/java/java/lang/ClassLoader.java
public InputStream getResourceAsStream(String resName) { try { URL url = getResource(resName); if (url != null) { return url.openStream(); } } catch (IOException ex) { // Don't want to see the exception. } return null;}
代碼很簡單,首先我們查找資源對應的路徑,如果不為 null,我們就為它打開一個輸入流。在這里,路徑是一個 java.net.URL 對象,有一個 openStream() 函數。
現在我們看一下 getResource() 的實現:
public URL getResource(String resName) { URL resource = parent.getResource(resName); if (resource == null) { resource = findResource(resName); } return resource; }
繼續跟進 findResource() 函數:
protected URL findResource(String resName) { return null; }
findResource() 在這里沒有被實現,而 ClassLoader 是一個抽象類,所以我們分析一下在 APP 運行時所使用的實現類。查看安卓開發者文檔,我們可以發現安卓系統提供了好幾個 ClassLoader 的實現類,通常情況下使用的是 PathClassLoader。
讓我們 build AOSP 的代碼,并通過日志查看 getResourceAsStream 和 getResource 使用的是哪一個實現類中的方法:
public InputStream getResourceAsStream(String resName) { try { Logger.getLogger("NimbleDroid RESEARCH").info("this: " + this); URL url = getResource(resName); if (url != null) { return url.openStream(); } ... }
測試發現,實際調用的是 dalvik.system.PathClassLoader 類。然而查看 PathClassLoader 我們并未發現 findResource 的實現。這是因為 findResource() 在其父類 BaseDexClassLoader 中實現了。
/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java:
@Override protected URL findResource(String name) { return pathList.findResource(name); }
繼續跟進 pathList:
public class BaseDexClassLoader extends ClassLoader { private final DexPathList pathList; /** * Constructs an instance. * * @param dexPath the list of jar/apk files containing classes and * resources, delimited by {@code File.pathSeparator}, which * defaults to {@code ":"} on Android * @param optimizedDirectory directory where optimized dex files * should be written; may be {@code null} * @param libraryPath the list of directories containing native * libraries, delimited by {@code File.pathSeparator}; may be * {@code null} * @param parent the parent class loader */ public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) { super(parent); this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory); }
繼續跟進 DexPathList:
/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
/** * A pair of lists of entries, associated with a {@code ClassLoader}. * One of the lists is a dex/resource path — typically referred * to as a "class path" — list, and the other names directories * containing native code libraries. Class path entries may be any of: * a {@code .jar} or {@code .zip} file containing an optional * top-level {@code classes.dex} file as well as arbitrary resources, * or a plain {@code .dex} file (with no possibility of associated * resources). * * <p>This class also contains methods to use these lists to look up * classes and resources.</p> */ /*package*/ final class DexPathList {
繼續跟進 DexPathList.findResource:
/** * Finds the named resource in one of the zip/jar files pointed at * by this instance. This will find the one in the earliest listed * path element. * * @return a URL to the named resource or {@code null} if the * resource is not found in any of the zip/jar files */ public URL findResource(String name) { for (Element element : dexElements) { URL url = element.findResource(name); if (url != null) { return url; } } return null; }
Element 是 DexPathList 類的一個靜態內部類。其中就包含了我們尋找的目標代碼:
public URL findResource(String name) { maybeInit(); // We support directories so we can run tests and/or legacy code // that uses Class.getResource. if (isDirectory) { File resourceFile = new File(dir, name); if (resourceFile.exists()) { try { return resourceFile.toURI().toURL(); } catch (MalformedURLException ex) { throw new RuntimeException(ex); } } } if (zipFile == null || zipFile.getEntry(name) == null) { /* * Either this element has no zip/jar file (first * clause), or the zip/jar file doesn't have an entry * for the given name (second clause). */ return null; } try { /* * File.toURL() is compliant with RFC 1738 in * always creating absolute path names. If we * construct the URL by concatenating strings, we * might end up with illegal URLs for relative * names. */ return new URL("jar:" + zip.toURL() + "!/" + name); } catch (MalformedURLException ex) { throw new RuntimeException(ex); } }
現在我們分析一下,我們知道,APK 文件實際上就是一個 zip 文件,從這行代碼我們看到:
if (zipFile == null || zipFile.getEntry(name) == null) {
這里會嘗試查找指定名稱的 ZipEntry,如果查找成功,我們就會返回這個資源對應的 URL。這個查找操作可能是非常耗時的,但是查看 getEntry 的實現,我們它的原理就是遍歷一個 LinkedHashMap:
/libcore/luni/src/main/java/java/util/zip/ZipFile.java
... private final LinkedHashMap<String, ZipEntry> entries = new LinkedHashMap<String, ZipEntry>(); ... public ZipEntry getEntry(String entryName) { checkNotClosed(); if (entryName == null) { throw new NullPointerException("entryName == null"); } ZipEntry ze = entries.get(entryName); if (ze == null) { ze = entries.get(entryName + "/"); } return ze; }
這個操作不會特別快,但肯定也不會特別慢。
這里我們遺漏了一個細節,在讀取這個 zip 文件之前,我們肯定需要打開這個 zip 文件,再次查看 DexPathList.Element.findResource() 函數的代碼,我們發現在第一行調用了 maybeInit():
public synchronized void maybeInit() { if (initialized) { return; } initialized = true; if (isDirectory || zip == null) { return; } try { zipFile = new ZipFile(zip); } catch (IOException ioe) { /* * Note: ZipException (a subclass of IOException) * might get thrown by the ZipFile constructor * (e.g. if the file isn't actually a zip/jar * file). */ System.logE("Unable to open zip file: " + zip, ioe); zipFile = null; } }
找到了!就是這一行:
zipFile = new ZipFile(zip);
打開了 zip 文件讀取內容:
public ZipFile(File file) throws ZipException, IOException { this(file, OPEN_READ); }
在構造函數中初始化了一個叫 entries 的 LinkedHashMap 對象。(如果要查看 ZipFile 內部的數據結構,可以查看源碼) 顯然,APK 文件越大,打開 zip 文件需要的時間就會越長。
這里我們發現了 getResourceAsStream 第一個耗時操作。這個過程很有趣,也很復雜,但這只是開始 :) 如果我們在源碼中加入下面的測量代碼:
public InputStream getResourceAsStream(String resName) { try { long start; long end; start = System.currentTimeMillis(); URL url = getResource(resName); end = System.currentTimeMillis(); Logger.getLogger("NimbleDroid RESEARCH").info("getResource: " + (end - start)); if (url != null) { start = System.currentTimeMillis(); InputStream inputStream = url.openStream(); end = System.currentTimeMillis(); Logger.getLogger("NimbleDroid RESEARCH").info("url.openStream: " + (end - start)); return inputStream; } ...
我們發現打開 zip 文件的耗時并不是 getResourceAsStream 的所有耗時,url.openStream() 耗費的時間遠比 getResource() 要長,所以我們繼續深挖。
查看 url.openStream() 的調用棧,我們發現了 /libcore/luni/src/main/java/libcore/net/url/JarURLConnectionImpl.java
@Override public InputStream getInputStream() throws IOException { if (closed) { throw new IllegalStateException("JarURLConnection InputStream has been closed"); } connect(); if (jarInput != null) { return jarInput; } if (jarEntry == null) { throw new IOException("Jar entry not specified"); } return jarInput = new JarURLConnectionInputStream(jarFile .getInputStream(jarEntry), jarFile); }
先看看 connect():
@Override public void connect() throws IOException { if (!connected) { findJarFile(); // ensure the file can be found findJarEntry(); // ensure the entry, if any, can be found connected = true; } }
繼續跟進:
private void findJarFile() throws IOException { if (getUseCaches()) { synchronized (jarCache) { jarFile = jarCache.get(jarFileURL); } if (jarFile == null) { JarFile jar = openJarFile(); synchronized (jarCache) { jarFile = jarCache.get(jarFileURL); if (jarFile == null) { jarCache.put(jarFileURL, jar); jarFile = jar; } else { jar.close(); } } } } else { jarFile = openJarFile(); } if (jarFile == null) { throw new IOException(); } }
getUseCaches() 會返回 true:
public abstract class URLConnection { ... private static boolean defaultUseCaches = true; ...
跟進 openJarFile():
private JarFile openJarFile() throws IOException { if (jarFileURL.getProtocol().equals("file")) { String decodedFile = UriCodec.decode(jarFileURL.getFile()); return new JarFile(new File(decodedFile), true, ZipFile.OPEN_READ); } else { ...
可以看到,這里打開了一個 JarFile,而不是 ZipFile。不過 JarFile 繼承自 ZipFile。這里我們發現了 getResourceAsStream 的第二個耗時操作:安卓系統需要再次打開 ZipFile 并索引其內容。
讀取 APK 文件內容并建立索引兩次,就使得開銷加大了兩倍,已經是非常嚴重的問題了,但這依然不是 getResourceAsStream 的所有耗時。所以我們繼續跟進 JarFile 的構造函數:
/** * Create a new {@code JarFile} using the contents of file. * * @param file * the JAR file as {@link File}. * @param verify * if this JAR filed is signed whether it must be verified. * @param mode * the mode to use, either {@link ZipFile#OPEN_READ OPEN_READ} or * {@link ZipFile#OPEN_DELETE OPEN_DELETE}. * @throws IOException * If the file cannot be read. */ public JarFile(File file, boolean verify, int mode) throws IOException { super(file, mode); // Step 1: Scan the central directory for meta entries (MANIFEST.mf // & possibly the signature files) and read them fully. HashMap<String, byte[]> metaEntries = readMetaEntries(this, verify); // Step 2: Construct a verifier with the information we have. // Verification is possible *only* if the JAR file contains a manifest // *AND* it contains signing related information (signature block // files and the signature files). // // TODO: Is this really the behaviour we want if verify == true ? // We silently skip verification for files that have no manifest or // no signatures. if (verify && metaEntries.containsKey(MANIFEST_NAME) && metaEntries.size() > 1) { // We create the manifest straight away, so that we can create // the jar verifier as well. manifest = new Manifest(metaEntries.get(MANIFEST_NAME), true); verifier = new JarVerifier(getName(), manifest, metaEntries); } else { verifier = null; manifestBytes = metaEntries.get(MANIFEST_NAME); } }
在這里我們發現了第三個耗時操作,所有的 APK 文件都是被簽名過的,所以 JarFile 會進行簽名驗證。這個驗證過程也會很慢,當然,對簽名過程的深入分析就不是本文的內容了,有興趣可以繼續深入學習。
總結
ClassLoader.getResourceAsStream 之所以慢,是由于以下三個原因:(1) 以 zip 壓縮包的方式打開 APK 文件,為 APK 內的所有內容建立索引;(2) 再次打開 APK 文件,并再次索引所有的內容;(3) 校驗 APK 文件被正確的進行了簽名操作。
其他備注
Q: ClassLoader.getResource*() 在 Dalvik 和 ART 中一樣慢嗎?
A: 是的,我們測試了兩個 AOSP 分支,android-6.0.1_r11 使用了 ART 技術,android-4.4.4_r2 使用的是 Dalvik。兩種環境下 getResource*() 都很慢。
Q: 為什么 ClassLoader.findClass() 沒有如此之慢?
A: 安卓會在安裝 APK 的時候解壓 DEX 文件,因此執行 ClassLoader.findClass() 時,無需再次打開 APK 文件查找內容了。
此外,在 DexPathList 類中我們可以看到:
public Class findClass(String name, List<Throwable> suppressed) { for (Element element : dexElements) { DexFile dex = element.dexFile; if (dex != null) { Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz; } } } if (dexElementsSuppressedExceptions != null) { suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions)); } return null; }
這個過程中沒有涉及到 ZipFile 和 JarFile。
Q: 為什么安卓系統的 Resources.get*(resId) 函數不存在此問題?
A: 安卓系統對資源文件的處理有單獨的索引和加載機制,沒有涉及到 ZipFile 和 JarFile。
原文出處:http://blog.nimbledroid.com/2016/04/06/slow-ClassLoader.getResourceAsStream-zh.html