Understanding Dart Memory: Weak References and Finalizers Demystified
Have you ever had memory issues with your Flutter application? Whether it is through processing huge data or because you somehow created a memory leak. These are common issues that we as developer face from time to time. To make our developer life easier Dart has some ways to prevent us from making common mistakes: Weak references and finalizers.
Understanding how these work and how they can be used can make your code more efficient and reduce unnecessary memory consumption. This article will explore the features of weak references, and finalizers in Dart. Using them helps you to avoid common pitfalls and write more efficient and scalable code.
How does the Dart memory management work?
Dart uses a garbage collector to automatically manage memory. This means that when an object is no longer being used, the garbage collector will automatically free up the memory used by that object.
This is different from languages like C and C++, where the programmer is responsible for manually allocating and deallocating memory for objects.
To help the garbage collector, Dart has a concept of “ownership” of objects. When an object is created, it is owned by the code that created it. When the owner of an object goes out of scope, the object becomes eligible for garbage collection, unless it is being referenced by another object.
In general, it is not necessary for Dart programmers to worry about memory management, as the garbage collector takes care of it automatically. However, it is still possible to create memory leaks that can crash your app and we need to be aware of how to reduce the chance that they will occur or how to identify them in the first place.
How do memory leaks occur?
Memory leaks can happen in Dart when an object is still being referenced in memory but is no longer being used. This can happen, for example, when a reference to an object is stored in a variable and that variable is no longer needed, but the reference to the object is not removed. As a result, the object will continue to be retained in memory even though it is no longer needed. Memory leaks can also happen when an object has references to other objects, but those objects are no longer needed, resulting in a chain of unused objects that are still being retained in memory. Memory leaks can cause the program to consume more and more memory over time, eventually leading to poor performance or even crashing the app.
Example memory leak in Flutter
In this example, we just add a random number every second to a list and display it in a ListView
.
class MyWidget extends StatefulWidget { @override _MyWidgetState createState() => _MyWidgetState(); } class _MyWidgetState extends State<MyWidget> { List<int> _numbers = []; @override void initState() { super.initState(); // Start a timer that generates a new number every second Timer.periodic(Duration(seconds: 1), (timer) { // Add the new number to the list _numbers.add(Random().nextInt(100)); // Tell the widget to rebuild with the updated list of numbers setState(() {}); }); } @override Widget build(BuildContext context) { return ListView.builder( itemCount: _numbers.length, itemBuilder: (context, index) { return Text('Number: ${_numbers[index]}'); }, ); } }
Have you already spotted the issue? In this example, it is easy to investigate because you know that something is wrong in the code but normally you would just notice that your app is getting slower or even crashes after some time. The app that you are working on has probably thousands of lines of code, so where should we start?
Devtools to the rescue
Flutter has really nice development tools that make it easy to analyze and investigate the health of your application. It gives you a view of not only the widget tree and its properties, networking, and logging but also of the device usage of CPU and memory. We will focus on the memory dev tools here because that’s what we are interested in.
To open up the dev tools just start your Flutter application in debug mode. Depending on the IDE that you use it is different how you can open the dev tools. Check out the instructions from the Flutter team.
The view might differ on your side, depending on which IDE you are using; the layout should still be the same. We will also not go into super much detail here. The memory view is already well documented by the Flutter team HERE.
How to locate the memory leak
If we just keep the app running without doing anything we can already see that the used memory especially the heap size increases over time. This clearly indicates that something allocates memory that is not released after some time (garbage collected).
What was the heap again?
The heap is a region of memory that is reserved for dynamic allocation of memory blocks at runtime. It is used to store variables and data structures that are created and destroyed dynamically during the execution of a program (e.g Objects).
If we track the allocations and filter them by the created instances you can easily see what is the reason for the memory leak. In this example, we see that the instances inside the _numbers list increase over time. The issue here can be easily spotted because this is a made-up example to make it more obvious. There are more ways to investigate memory issues and there are even packages out there to find them:
In this example, the _MyWidgetState
class has a List of numbers that is updated every second. Each time a new number is added to the list, the widget is rebuilt to display the updated list. However, since the _numbers
list is a member variable of the _MyWidgetState
class, it will never be garbage collected, even if the widget is removed from the tree. This means that the memory used by the list will continue to grow over time, eventually leading to a memory leak.
There are multiple ways to solve this issue! One elegant solution is to somehow make the _numbers
list garbage collectable. Dart has something built in to do that and it is called: WeakReference.
Weak references
In Dart, a weak reference is a reference to an object that does not prevent the object from being garbage collected. This means that if the only references to an object are weak references, the object can be garbage collected even if the references are still pointing to it. This can be useful in situations where an object is no longer needed but is still being referenced by other objects.
Weak references are created using the WeakReference class, which takes an object as a parameter in its constructor. For example, to create a weak reference to a List object, you could use the following code:
List<int> numbers = [1, 2, 3]; // Create a weak reference to the numbers list WeakReference<List<int>> numbersWeakRef = WeakReference(numbers);
It is important to note that the object referenced by a WeakReference can be garbage collected at any time, even if the WeakReference is still pointing to it. This means that you should always check that the object is not null before accessing it.
This is what the above example with Weak Reference looks like:
class MyWidget extends StatefulWidget { @override _MyWidgetState createState() => _MyWidgetState(); } class _MyWidgetState extends State<MyWidget> { // We will use a weak reference to store the list, // so that it can be garbage collected if it is no longer needed. WeakReference<List<int>> _numbers = WeakReference(<int>[]); @override void initState() { super.initState(); // Start a timer that generates a new number every second Timer.periodic(Duration(seconds: 1), (timer) { // Add the new number to the list _numbers.value.add(Random().nextInt(100)); // Tell the widget to rebuild with the updated list of numbers setState(() {}); }); } @override Widget build(BuildContext context) { // Create the list of numbers if it does not already exist if (_numbers.value == null) { _numbers = WeakReference([]) } return ListView.builder( itemCount: _numbers.value.length, itemBuilder: (context, index) { return Text('Number: ${_numbers.value[index]}'); }, ); } }
In this modified version of the code, the _MyWidgetState
class uses a WeakReference
to store the list of _numbers
. This means that the List can be garbage collected if it is no longer being used by the program. This avoids the potential memory leak, while still allowing the List to be updated and accessed by the widget.
Weak vs Strong reference
A strong reference is a normal reference to an object that prevents the object from being garbage collected. This means that as long as there is at least one strong reference to an object, the memory associated with that object will not be reclaimed by the garbage collector, even if the object is no longer in use.
On the other hand, a weak reference does not prevent the object from being garbage collected. This means that if there are only weak references to an object, the garbage collector is free to reclaim the memory associated with that object, even if the weak references are still present.
The main difference between strong and weak references is that strong references guarantee that the referenced object will not be garbage collected, while weak references do not. This means that using strong references can potentially cause memory leaks if the references are not properly managed, while weak references are less likely to cause memory leaks because they allow the garbage collector to reclaim the memory associated with an object when it is no longer in use. However, weak references also have some limitations, such as not being able to directly access the object they reference, which can make them more difficult to use in some cases.
Drawbacks of Weak References
Weak references solve easily some memory issues without a lot of work but it also has some drawbacks. First of all, you need to identify potential memory issues in the development phase to effectively use it and it also creates some annoying checks and reinstations at runtime. So overusing it is super verbose.
Even worse it does not work for situations where you don’t want that the GC to through your references away. Think of a DB-Connection that has to be (worst-case) reopened every time you use it. This is super resource expensive and makes your app slower instead of more performant. We need somehow a concept to keep the reference around and be sure that is always correctly handled and removed from memory when not used anymore.
Finalizers
A finalizer is a function that is automatically called when an object is about to be destroyed. This allows you to perform any necessary cleanup before the object is removed from memory. Finalizers are particularly useful when dealing with resources that need to be released when an object is no longer in use. For example, when a file handle is no longer needed, it should be closed to prevent a potential memory leak.
Dart 2.17 solved this by introducing the concept of a Finalizers, which includes a Finalizable marker interface for “tagging” objects that shouldn’t be finalized or discarded too early that can be attached to a Dart object to provide a call-back run when the object is about to be garbage collected.
In Dart, finalizers are created using the Finalizer class. This class takes a single argument, which is a function that will be called when the object is destroyed. This function can be used to perform the necessary cleanup. For example, the following code defines a finalizer that closes a file handle when the object is destroyed:
static final Finalizer<DBConnection> _finalizer = Finalizer((connection) => connection.close());
In addition to being used for resource cleanup, finalizers can also be used to perform other tasks. For example, they can be used to log when an object is destroyed, or to call cleanup functions in other objects.
Here is a complete example of how to use finalizers to safely close the connection to a database:
class Database { // Keeps the finalizer itself reachable, otherwise, it might be disposed // before the finalizer callback gets a chance to run. static final Finalizer<DBConnection> _finalizer = Finalizer((connection) => connection.close()); final DBConnection _connection; Database._fromConnection(this._connection); factory Database.connect() { // Wraps the connection in a nice user API, // *and* closes the connection if the user forgets to. final connection = DBConnection.connect(); final wrapper = Database._fromConnection(connection); // Get finalizer callback when `wrapper` is no longer reachable. _finalizer.attach(wrapper, connection, detach: wrapper); return wrapper; } void close() { // User requested close. _connection.close(); // Detach from a finalizer, no longer needed. _finalizer.detach(this); } // Some useful methods. }
Finalizers are an important feature of Dart, as they provide a way to ensure that resources are properly released when an object is no longer needed. When used properly, they can help prevent memory leaks and other issues that can arise from not properly cleaning up resources.
Calling native code
A huge reason why the finalizer interface was introduced was Dart FFI and calling native code. When deeply integrating with native platforms using Dart FFI, you sometimes need to align the cleanup of memory or other resources (ports, files, and so on) allocated by Dart and the native code.
With the Finalizer also the NativeFinalizer class was added to Dart which can be attached to a Dart object to provide a call-back run when the object is about to be garbage collected. Together these allow for running cleanup code in both native and Dart code. For more details, see the description and example in the API documentation for NativeFinalizer.
Conclusion
In conclusion, weak references and finalizers are powerful features in Dart that can help you prevent memory leaks and optimize the performance of your applications. Understanding how these features work and when to use them allows you to avoid common pitfalls and write more efficient and maintainable code. Another essential concept that is widely underused in our opinion is Mixins.
Did you enjoy this article? Tell us your opinion on Twitter! And if you have any open questions, thoughts or ideas, feel free to get in touch with us! Thanks for reading! 🙏