TL;DR: Today I grappled with a memory leak caused by the fact that, in Java 8, obj::method != obj::method
(where obj::method
is a Java 8 method reference). Key takeaway: Java method references have surprising behaviour (compared to other programming languages). Prefer lambdas over method references.
Read Oracle's definition of method references carefully:
You use lambda expressions to create anonymous methods. ... Method references ... are compact, easy-to-read lambda expressions for methods that already have a name.
If you've used method references in any other language before, this definition should surprise you. Let's see how any mainstream programming language that claims to embrace the functional approach treats method references:
JavaScript:
x = 'abc'
x.split === x.split
// => true
Python:
x = 'abc'
x.split == x.split
# => True
Ruby:
x = 'abc'
x.method(:length) == x.method(:length)
# => true
Hell, even C++ gets it right:
int main() {
cout << (&string::length == &string::length);
// => 1 (true)
}
Along comes Java 8. I was hopeful that verbs would no longer be condemned in this kingdom of nouns. But of course not: Java lambdas are only sugar for anonymous classes! That should've made me more cautious. Oh well.
Java 8 doesn't allow me to directly compare method references (they probably anticipated this!), so I can only demonstrate indirectly:
public class Main {
public static void main(String[] args) {
String x = "abc";
Runnable r1 = x::length;
Runnable r2 = x::length;
System.out.println(r1 == r2);
// => false (!!)
}
}
When I write it out like this, it's painfully obvious that r1 != r2
, I know! How could I be so stupid? But real life isn't quite so neat.
Why this is a problem
Consider the Android Handler
class, it's basically a message queue (shown as an interface here):
public interface Handler {
/**
* Causes the Runnable r to be added to the message
* queue, to be run after the specified amount of time
* elapses.
*/
boolean postDelayed(Runnable r, long delayMillis);
/**
* Remove any pending posts of Runnable r that are in
* the message queue.
*/
void removeCallbacks(Runnable r);
}
Now since I'm quite comfortable with passing references-to-methods around in JavaScript, Ruby, even C++, I did this without thinking twice:
public class MyClass {
private Handler mHandler;
// for non-Android folks: think of this as a kind of initialization function
public void onCreate(/* ... */) {
mHandler = new Handler(Looper.getMainLooper());
// first we post a message to the Handler...
mHandler.postDelayed(this::doSomething, 1000);
}
// sometime later this gets called, when the object is being destroyed...
public void onDestroy() {
mHandler.removeCallbacks(this::doSomething); // uh-oh, doesn't do what we want
}
}
Do you see the problem? Every time I write this::doSomething
, Java creates a new instance of an anonymous class (a Runnable
, in this case), which means this::doSomething != this::doSomething
(even though that expression doesn't compile verbatim), which means my pending callbacks are not removed. Ugh. This code doesn't do what I intended.
In my mind, this is a big design error: it violates the principle of least astonishment for programming language design. Even though this::doSomething
looks like an innocuous reference to a method, it actually creates a new instance of Runnable
. What's more, the Runnable
holds a reference to its enclosing object! This is really bad. It makes wrong code look right. Joel Spolsky has a few words to say on that topic.
The fix here was simple: hold a reference to the created Runnable
and pass in the same reference to removeCallbacks
. But now I have to always remember that I cannot just pass method references around with impunity, like I'm used to!
Gory details for Android developers
I'm sure you've realized by now why this is potentially a source of massive memory leaks on Android. The Runnable
created by this::doSomething
holds an implicit reference to the enclosing instance, and you know what the enclosing instance is: an Activity
! That makes the Activity
stick around on the heap until the Runnable
is GC'ed, and who knows when that will happen? Huge memory leak. Game over. The stuff of Android devs' nightmares.
Moral of the story: avoid method references, use lambdas instead (or use them if you trust yourself to be super careful at all times). You may have to type () -> this.doSomething()
instead, but at least it'll be immediately obvious that you're creating a new object and you won't get an accidental memory leak because of something so simple. To be more specific, here is what the example above might look like using lambdas:
public class MyClass {
private Handler mHandler;
private Runnable mMessage;
// for non-Android folks: think of this as a kind of initialization function
public void onCreate(/* ... */) {
mHandler = new Handler(Looper.getMainLooper());
// we *could* use this::doSomething to create the Runnable here, but
// we just saw how Java method references can make wrong code look right
// let's not tempt fate
mMessage = () -> MyClass.this.doSomething();
// first we post a message to the Handler...
mHandler.postDelayed(mMessage, 1000);
}
// sometime later this gets called, when the object is being destroyed...
public void onDestroy() {
mHandler.removeCallbacks(mMessage); // this will work as intended, obviously
}
}
PS: Square's Leak Canary is awesome
A week ago, Square released Leak Canary, their memory leak detection library for Android. Leak Canary is what caught this leak; I would never have spotted it myself! My thanks to the awesome Android team at Square.
Also, If you're wondering how I'm using Java 8 features on Android: it's courtesy retrolambda.
UPDATE: A request: before blaming retrolambda for the results presented here, please run the Java r1 == r2
example yourself on Oracle's Java 8 VM, think about the message queue example in light of those results, and then post a clear comment explaining the exact flaw in this article. I'm genuinely looking forward to learning from you. I can't really claim to understand how method references are actually implemented, but the results described here are 100% consistent with my observations on Oracle's VM. Thanks for reading!