揭秘在安卓平台上奇慢无比的 ClassLoader.getResourceAsStream

Cha67N 8年前
   <p>我们 NimbleDroid 经过大量的分析,发现了一些避免 APP 整体变慢,让 APP 快速启动以及迅速响应的技巧。其中有一个就是奇慢无比的 ClassLoader.getResourceAsStream 函数,这个函数可以让 APP 通过名字访问资源。在传统的 Java 程序开发中,这个函数用得非常普遍,但是在安卓平台上,这个函数在第一次调用时执行时间非常长,会严重拖慢安卓 APP 的运行。在我们分析的 APP 和 SDK 中(我们分析了<a href="/misc/goto?guid=4959670351289800347">大量的 APP 和 SDK </a>),我们发现超过 10% 的 APP 和 20% 的 SDK 都由于使用了这个函数而急剧变慢。那究竟为什么这个函数如此之慢呢?我们将在这边文章中进行深度揭秘。</p>    <p> </p>    <h3>榜单 APP 中被拖慢的案例</h3>    <p>亚马逊的 Kindle 安卓版,拥有<a href="https://play.google.com/store/apps/details?id=com.amazon.kindle&hl=en">过亿的下载量</a>,4.15.0.48 版本中,由于<a href="/misc/goto?guid=4959670351463826250">使用了这个函数</a>,导致了 1315 毫秒的延迟。</p>    <p>另一个例子是 TuneIn 13.6.1 版本,因此导致了 <a href="/misc/goto?guid=4959670351548182446">1447 毫秒</a>的延迟。在这里 TuneIn 调用了两次 getResourceAsStream 函数,第二次调用时就很快了(只需要 6 毫秒)。</p>    <p>下面我们列出了受此问题影响的 APP:</p>    <p> </p>    <p><img alt="blob.png" src="https://simg.open-open.com/show/aeb9ee41ec3cdaf92d9f1ac83aa66a52.png"></p>    <p>在我们分析的 APP 中,有超过 10% 的 APP 都受此问题的影响。</p>    <h3>调用了 getResourceAsStream 函数的 SDK</h3>    <p>为了行文简洁,我们用 SDK 来指代所有的库,无论是像 Amazon AWS 这样提供特定服务的库,还是像 Joda-Time 这样更通用的库。</p>    <p>通常,一个 APP 不会直接调用 getResourceAsStream 函数,而是这个 APP 使用的某个 SDK 调用了这个函数。由于开发者通常不会关注使用的 SDK 的实现细节,所以他们通常都不知道自己的 APP 存在这样的问题。</p>    <p>下面我们列出了一些知名的调用了 getResourceAsStream 函数的 SDK:</p>    <ul>     <li> <p>mobileCore</p> </li>     <li> <p>SLF4J</p> </li>     <li> <p>StartApp</p> </li>     <li> <p>Joda-Time</p> </li>     <li> <p>TapJoy</p> </li>     <li> <p>Google Dependency Injection</p> </li>     <li> <p>BugSense</p> </li>     <li> <p>RoboGuice</p> </li>     <li> <p>OrmLite</p> </li>     <li> <p>Appnext</p> </li>     <li> <p>Apache log4j</p> </li>     <li> <p>推ter4J</p> </li>     <li> <p>Appcelerator Titanium</p> </li>     <li> <p>LibPhoneNumbers (Google)</p> </li>     <li> <p>Amazon AWS</p> </li>    </ul>    <p>总的来说,我们分析的 SDK 中,有超过 20% 的 SDK 都存在此问题,由于篇幅有限,上面的列表中我们只列出了少数较为知名的 SDK。 这个问题在 SDK 中如此普遍,原因之一就是 getResourceAsStream() 函数在非安卓平台上都是很快的。由于很多从 Java 转型的安卓开发者都使用了他们比较熟悉的库,例如使用了 Joda-Time 而不是 <a href="/misc/goto?guid=4959630846280144142">Dan Lew</a> 开源的 <a href="/misc/goto?guid=4958863494281342462">Joda-Time-Android</a>,因此很多 APP 都受到了这个问题的影响。</p>    <h3>为什么 getResourceAsStream 函数在安卓平台如此之慢</h3>    <p>发现了 getResourceAsStream 函数在安卓平台如此之慢,我们理所当然的需要分析一下它为什么如此之慢。经过深入的分析,我们发现这个函数第一次被调用时,系统会执行三个非常耗时的操作:(1) 以 zip 压缩包的方式打开 APK 文件,为 APK 内的所有内容建立索引;(2) 再次打开 APK 文件,并再次索引所有的内容;(3) 校验 APK 文件被正确的进行了签名操作。上述三个操作都非常慢,总的延迟和 APK 文件的大小呈线性关系。例如一个 20MB 的 APK 文件执行上述操作需要 1-2 秒的延迟。在<a href="/misc/goto?guid=4959670351697502903">附录</a>中,我们具体描述了这个分析的过程。</p>    <p>建议:避免调用 ClassLoader.getResource*() 函数,而是使用安卓系统提供的 Resources.get*(resId) 函数</p>    <p>建议:测量你的 APP,查看是否使用的 SDK 调用了 ClassLoader.getResource*() 函数。将这些 SDK 替换为更高效的版本,或者至少不要在主线程触发这些函数的调用。</p>    <p>立即查看你的 APP 有没有被 ClassLoader.getResource*() 函数拖慢!</p>    <h3>附录:我们是如何定位 getResourceAsStream 函数中的耗时操作的</h3>    <p>为了理解这个问题的根本原因,我们分析一下安卓系统的源码。我们分析的是 <a href="/misc/goto?guid=4958968995939785722">AOSP</a> 的 <a href="/misc/goto?guid=4959670351814173769">android-6.0.1_r11</a> 分支。我们首先看一下 ClassLoader 的代码:</p>    <p>libcore/libart/src/main/java/java/lang/ClassLoader.java</p>    <pre>  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;}</pre>    <p>代码很简单,首先我们查找资源对应的路径,如果不为 null,我们就为它打开一个输入流。在这里,路径是一个 java.net.URL 对象,有一个 openStream() 函数。</p>    <p>现在我们看一下 getResource() 的实现:</p>    <pre>  public URL getResource(String resName) {      URL resource = parent.getResource(resName);      if (resource == null) {          resource = findResource(resName);      }      return resource;  }</pre>    <p>继续跟进 findResource() 函数:</p>    <pre>  protected URL findResource(String resName) {      return null;  }</pre>    <p>findResource() 在这里没有被实现,而 ClassLoader 是一个抽象类,所以我们分析一下在 APP 运行时所使用的实现类。查看<a href="/misc/goto?guid=4959670351894877019">安卓开发者文档</a>,我们可以发现安卓系统提供了好几个 ClassLoader 的实现类,通常情况下使用的是 PathClassLoader。</p>    <p>让我们 build AOSP 的代码,并通过日志查看 getResourceAsStream 和 getResource 使用的是哪一个实现类中的方法:</p>    <pre>  public InputStream getResourceAsStream(String resName) {    try {        Logger.getLogger("NimbleDroid RESEARCH").info("this: " + this);        URL url = getResource(resName);        if (url != null) {            return url.openStream();        }        ...  }</pre>    <p>测试发现,实际调用的是 dalvik.system.PathClassLoader 类。然而查看 PathClassLoader 我们并未发现 findResource 的实现。这是因为 findResource() 在其父类 BaseDexClassLoader 中实现了。</p>    <p>/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java:</p>    <pre>  @Override  protected URL findResource(String name) {      return pathList.findResource(name);  }</pre>    <p>继续跟进 pathList:</p>    <pre>  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);    }</pre>    <p>继续跟进 DexPathList:</p>    <p>/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java</p>    <pre>  /**   * A pair of lists of entries, associated with a {@code ClassLoader}.   * One of the lists is a dex/resource path &mdash; typically referred   * to as a "class path" &mdash; 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 {</pre>    <p>继续跟进 DexPathList.findResource:</p>    <pre>  /**   * 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;  }</pre>    <p>Element 是 DexPathList 类的一个静态内部类。其中就包含了我们寻找的目标代码:</p>    <pre>  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);    }  }</pre>    <p>现在我们分析一下,我们知道,APK 文件实际上就是一个 zip 文件,从这行代码我们看到:</p>    <pre>  if (zipFile == null || zipFile.getEntry(name) == null) {</pre>    <p>这里会尝试查找指定名称的 ZipEntry,如果查找成功,我们就会返回这个资源对应的 URL。这个查找操作可能是非常耗时的,但是查看 getEntry 的实现,我们它的原理就是遍历一个 LinkedHashMap:</p>    <p>/libcore/luni/src/main/java/java/util/zip/ZipFile.java</p>    <pre>  ...    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;    }</pre>    <p>这个操作不会特别快,但肯定也不会特别慢。</p>    <p>这里我们遗漏了一个细节,在读取这个 zip 文件之前,我们肯定需要打开这个 zip 文件,再次查看 DexPathList.Element.findResource() 函数的代码,我们发现在第一行调用了 maybeInit():</p>    <pre>  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;    }  }</pre>    <p>找到了!就是这一行:</p>    <pre>  zipFile = new ZipFile(zip);</pre>    <p>打开了 zip 文件读取内容:</p>    <pre>  public ZipFile(File file) throws ZipException, IOException {      this(file, OPEN_READ);  }</pre>    <p>在构造函数中初始化了一个叫 entries 的 LinkedHashMap 对象。(如果要查看 ZipFile 内部的数据结构,可以查看<a href="/misc/goto?guid=4959670351989209814">源码</a>) 显然,APK 文件越大,打开 zip 文件需要的时间就会越长。</p>    <p>这里我们发现了 getResourceAsStream 第一个耗时操作。这个过程很有趣,也很复杂,但这只是开始 :) 如果我们在源码中加入下面的测量代码:</p>    <pre>   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;        }        ...</pre>    <p>我们发现打开 zip 文件的耗时并不是 getResourceAsStream 的所有耗时,url.openStream() 耗费的时间远比 getResource() 要长,所以我们继续深挖。</p>    <p>查看 url.openStream() 的调用栈,我们发现了 /libcore/luni/src/main/java/libcore/net/url/JarURLConnectionImpl.java</p>    <pre>  @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);  }</pre>    <p>先看看 connect():</p>    <pre>  @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;      }  }</pre>    <p>继续跟进:</p>    <pre>  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();      }  }</pre>    <p>getUseCaches() 会返回 true:</p>    <pre>  public abstract class URLConnection {  ...    private static boolean defaultUseCaches = true;    ...</pre>    <p>跟进 openJarFile():</p>    <pre>  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 {      ...</pre>    <p>可以看到,这里打开了一个 JarFile,而不是 ZipFile。不过 JarFile 继承自 ZipFile。这里我们发现了 getResourceAsStream 的第二个耗时操作:安卓系统需要再次打开 ZipFile 并索引其内容。</p>    <p>读取 APK 文件内容并建立索引两次,就使得开销加大了两倍,已经是非常严重的问题了,但这依然不是 getResourceAsStream 的所有耗时。所以我们继续跟进 JarFile 的构造函数:</p>    <pre>  /**   * 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);      }  }</pre>    <p>在这里我们发现了第三个耗时操作,所有的 APK 文件都是被签名过的,所以 JarFile 会进行签名验证。这个验证过程也会很慢,当然,对签名过程的深入分析就不是本文的内容了,有兴趣可以继续<a href="/misc/goto?guid=4959670352074035473">深入学习</a>。</p>    <p>总结</p>    <p>ClassLoader.getResourceAsStream 之所以慢,是由于以下三个原因:(1) 以 zip 压缩包的方式打开 APK 文件,为 APK 内的所有内容建立索引;(2) 再次打开 APK 文件,并再次索引所有的内容;(3) 校验 APK 文件被正确的进行了签名操作。</p>    <h3>其他备注</h3>    <p>Q: ClassLoader.getResource*() 在 Dalvik 和 ART 中一样慢吗?</p>    <p>A: 是的,我们测试了两个 AOSP 分支,android-6.0.1_r11 使用了 ART 技术,android-4.4.4_r2 使用的是 Dalvik。两种环境下 getResource*() 都很慢。</p>    <p>Q: 为什么 ClassLoader.findClass() 没有如此之慢?</p>    <p>A: 安卓会在安装 APK 的时候解压 DEX 文件,因此执行 ClassLoader.findClass() 时,无需再次打开 APK 文件查找内容了。</p>    <p>此外,在 DexPathList 类中我们可以看到:</p>    <pre>  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;  }</pre>    <p>这个过程中没有涉及到 ZipFile 和 JarFile。</p>    <p>Q: 为什么安卓系统的 Resources.get*(resId) 函数不存在此问题?</p>    <p>A: 安卓系统对资源文件的处理有单独的索引和加载机制,没有涉及到 ZipFile 和 JarFile。</p>    <p>原文出处:<a href="/misc/goto?guid=4959670352146780781">http://blog.nimbledroid.com/2016/04/06/slow-ClassLoader.getResourceAsStream-zh.html</a> </p>