Through our extensive analysis at NimbleDroid, we’ve picked up on a few tricks that help prevent monolithic lags in Android apps, boosting fluidity and response time. One of the things we’ve learned to watch out for is the dreaded ClassLoader.getResourceAsStream , a method that allows an app to access a resource with a given name. This method is pretty popular in the Java world, but unfortunately it causes gigantic slowdown in an Android app the first time it is invoked. Of all the apps and SDKs we’ve analyzed (and we’ve analyzeda ton), we’ve seen that over 10% of apps and 20% of SDKs are slowed down significantly by this method . What exactly is going on? Let’s take an in-depth look in this post.

Example Slowdowns in Top Apps

Another example is TuneIn 13.6.1, which is delayed by1447ms. Here TuneIn calls getResourceAsStream twice, and the second call is much faster (6ms).

Here are some more apps that suffer from this problem:

Again, more than 10% of the apps we analyzed suffer from this issue.

SDKs That Call getResourceAsStream

For brevity, we use SDKs to refer to both libraries that are attached to certain services, such as Amazon AWS, and those that aren’t, such as Joda-Time.

Oftentimes, an app doesn’t call getResourceAsStream directly; instead, the dreaded method is called by one of the SDKs used by the app. Since developers don’t typically pay attention to an SDK’s internal implementation, they often aren’t even aware that their app contains the issue.

Here is a partial list of the most popular SDKs that call getResourceAsStream :

mobileCore

SLF4J

StartApp

Joda-Time

TapJoy

Google Dependency Injection

BugSense

RoboGuice

OrmLite

Appnext

Apache log4j

Twitter4J

Appcelerator Titanium

LibPhoneNumbers (Google)

Amazon AWS

Overall, 20% of the SDKs we analyzed suffer from this issue – the list above covers only a small number of these SDKs because we don’t have space to list them all here. One reason that this issue plagues so many SDKs is that getResourceAsStream() is pretty fast outside of Android, despite the method’s slow implementation in Android. Consequently, many Android apps are affected by this issue because many Android developers come from the Java world and want to use familiar libraries in their Android apps (e.g., Joda-Time instead of Dan Lew ’s Joda-Time-Android ).

Why getResourceAsStream Is So Slow in Android

A logical thing to be wondering right now is why this method takes so long in Android. After a long investigation, we discovered that the first time this method is called, Android executes three very slow operations: (1) it opens the APK file as a zip file and indexes all zip entries; (2) it opens the APK file as a zip file again and indexes all zip entries; and (3) it verifies that the APK is correctly signed. All three operations are cripplingly slow, and the total delay is proportional to the size of the APK. For example, a 20MB APK induces a 1-2s delay. We describe our investigation in greater detail in.

Recommendation: Profile your app to see if any SDKs call ClassLoader.getResource*(). Replace these SDKs with more efficient ones, or at the very least don’t do these slow calls in the main thread.

Appendix: How We Pinpointed the Slow Operations in getResourceAsStream

To really understand the issue here, let’s investigate some actual code. We will use branch android-6.0.1_r11 from AOSP . We’ll begin by taking a look at the ClassLoader code:

libcore/libart/src/main/java/java/lang/ClassLoader.java

publicInputStreamgetResourceAsStream(StringresName){try{URLurl=getResource(resName);if(url!=null){returnurl.openStream();}}catch(IOExceptionex){// Don't want to see the exception.}returnnull;}

Everything looks pretty straightforward here. First we find a path for resources, and if it’s not null, we open a stream for it. In this case, the path is java.net.URL class, which has method openStream().

So findResource() isn’t implemented. ClassLoader is an abstract class, so we need to find the subclass that is actually implemented in real apps. If we open android docs , we see that Android provides several concrete implementations of the class, with PathClassLoader being the one typically used.

Let’s build AOSP and trace the call to getResourceAsStream and getResource in order to determine which ClassLoader is used:

We get what we expected, dalvik.system.PathClassLoader. However, checking the methods of PathClassLoader, we don’t find an implementation for findResource . This is because findResource() is implemented in the parent of PathClassLoader – BaseDexClassLoader .

/** * 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*/finalclassDexPathList{

Let’s check out 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 */publicURLfindResource(Stringname){for(Elementelement:dexElements){URLurl=element.findResource(name);if(url!=null){returnurl;}}returnnull;}

Elementis just a static inner class in DexPathList. Inside there is much more interesting code:

publicURLfindResource(Stringname){maybeInit();// We support directories so we can run tests and/or legacy code// that uses Class.getResource.if(isDirectory){FileresourceFile=newFile(dir,name);if(resourceFile.exists()){try{returnresourceFile.toURI().toURL();}catch(MalformedURLExceptionex){thrownewRuntimeException(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). */returnnull;}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. */returnnewURL("jar:"+zip.toURL()+"!/"+name);}catch(MalformedURLExceptionex){thrownewRuntimeException(ex);}}

Let’s stop and think for a bit. We know that the APK file is just a zip file. As we see here:

if(zipFile==null||zipFile.getEntry(name)==null){

We try to find ZipEntry by a given name. If we do this successfully, we return the corresponding URL. This can be a slow operation, but if we check the implementation of getEntry , we see that it’s just iterating over LinkedHashMap:

We missed one thing though – before working with zip files, they should be opened. If we look once again at the DexPathList.Element.findResource() method implementation, we will find the maybeInit() call. Let’s check it out:

This constructor initializes a LinkedHashMap object called entries . (To investigate more about the internal structure of ZipFile, check this out.) Obviously, as our APK file gets larger, we will need more time to open the zip file.

We’ve found the first slow operation of getResourceAsStream. The journey so far has been interesting (and complicated), but still only the beginning. If we patch the source code like the following:

We see that the zip file operation cannot account for all the delay in getResourceAsStream: url.openStream() takes much longer than getResource() , so let’s investigate further.

####url.openStream() Following the call stack of url.openStream(), we get to /libcore/luni/src/main/java/libcore/net/url/JarURLConnectionImpl.java

@OverridepublicInputStreamgetInputStream()throwsIOException{if(closed){thrownewIllegalStateException("JarURLConnection InputStream has been closed");}connect();if(jarInput!=null){returnjarInput;}if(jarEntry==null){thrownewIOException("Jar entry not specified");}returnjarInput=newJarURLConnectionInputStream(jarFile.getInputStream(jarEntry),jarFile);}

Let’s check connect() first:

@Overridepublicvoidconnect()throwsIOException{if(!connected){findJarFile();// ensure the file can be foundfindJarEntry();// ensure the entry, if any, can be foundconnected=true;}}

As you can see, here we open a JarFile , not a ZipFile . However, JarFile extends ZipFile. Here we’ve found the second slow operation in getResourceAsStream – Android needs to open the APK file again as a ZipFile and index all its entries.

Opening the APK file as a zip file twice doubles the overhead, which is already significantly noticeable. However, this overhead still doesn’t account for all observed overhead. Let’s look at the JarFile constructor:

/** * 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. */publicJarFile(Filefile,booleanverify,intmode)throwsIOException{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=newManifest(metaEntries.get(MANIFEST_NAME),true);verifier=newJarVerifier(getName(),manifest,metaEntries);}else{verifier=null;manifestBytes=metaEntries.get(MANIFEST_NAME);}}

So here we find the third slow operation. All APK files are signed, so JarFile will execute the “verify” path. This verification process is cripplingly slow. While further discussion regarding verification is outside the scope of this post, you can learn more about it here .

Summary

To summarize, ClassLoader.getResourceAsStream is slow because of three slow operations: (1) opening the APK as a ZipFile; (2) opening the APK as JarFile which requires opening the APK as ZipFile again ; (3) verifying that the JarFile is properly signed.

Additional Notes

Q: Is ClassLoader.getResource*() slow for both Dalvik and ART?

A:Yes. We checked 2 branches, android-6.0.1_r11 with ART and android-4.4.4_r2 with Dalvik. The slow operations in getResource*() are present in both versions.

Q: Why doesn’t ClassLoader.findClass() have a similar slowdown?

A:Android extracts DEX files from an APK during installation. Therefore, to find a class, there is no need to open the APK as a ZipFile or JarFile.