The real issue here is that calling global.gc() does not run a full GC pass. In my tests below, only when I allowed 10 seconds of idle time did I get full GC.
Here are some observations about your specific code. If I add a single await delay(5000) pause in the GC, then your object is still not GCed before the while loop. But, if I add two await delay(5000) statements or one await delay(10000) statement, it is GCed before the while loop. So, the GC is clearly timing sensitive and calling global.gc() is apparently not a full run of GC. For example, here's a version of your code where the weakref is GCed!
function delay(t, v) {
return new Promise(resolve => {
setTimeout(resolve, t, v);
});
}
async function run() {
const lookup = new Map();
let element = new Object({ id: "someid", data: {} });
lookup.set(element.id, new WeakRef(element));
console.dir(lookup.get("someid").deref());
// as expected output is { id: 'someid', data: {} }
element = null;
await delay(10000);
console.log(element);
// as expected output is null
// if above is delay(5000), then it logs "in while loop"
// if above is delay(10000), then it does NOT log "in while loop"
// so the amount of time is important to allow the GC to do its thing
while (lookup.get("someid").deref()) {
console.log("in while loop");
break;
}
console.dir(lookup.get("someid").deref());
}
run();
Before I discovered that your code would GC with a delay, I set out to just run an experiement to see whether a WeakRef works or not. This is the code that showed (that with the right delays to allow full GC), the WeakRef does work in node v14.15.
Here's my test code:
// to make memory usage output easier to read
function addCommas(str) {
var parts = (str + "").split("."),
main = parts[0],
len = main.length,
output = "",
i = len - 1;
while (i >= 0) {
output = main.charAt(i) + output;
if ((len - i) % 3 === 0 && i > 0) {
output = "," + output;
}
--i;
}
// put decimal part back
if (parts.length > 1) {
output += "." + parts[1];
}
return output;
}
function delay(t, v) {
return new Promise(resolve => {
setTimeout(resolve, t, v);
});
}
function logUsage() {
let usage = process.memoryUsage();
console.log(`heapUsed: ${addCommas(usage.heapUsed)}`);
}
const numElements = 10000;
const lenArrays = 10000;
async function run() {
const cache = new Map();
const holding = [];
function checkItem(n) {
let item = cache.get(n).deref();
console.log(item);
}
// fill all the arrays and the cache
// and put everything into the holding array too
let arr, element;
for (let i = 0; i < numElements; i++) {
arr = new Array(lenArrays);
arr.fill(i);
element = { id: i, data: arr };
// temporarily hold onto each element by putting a
// full reference (not a weakRef) into an array
holding.push(element);
// add a weakRef to the Map
cache.set(i, new WeakRef(element));
}
// clean up locals we don't need any more
element = array = null;
// should have a big Map holding lots of data
// all items should still be available
checkItem(numElements - 1);
logUsage();
await delay(5000);
logUsage();
// make whole holding array contents eligible for GC
holding.length = 0;
// pause for GC, then see if items are available
// and what memory usage is
await delay(5000);
checkItem(0);
checkItem(1);
checkItem(numElements - 1);
// count how many items are still in the Map
let cnt = 0;
for (const [index, item] of cache) {
if (item.deref()) {
++cnt;
console.log(`Index item ${index} still in cache`);
}
}
console.log(`There are ${cnt} items that haven't be GCed in the map`);
logUsage();
}
run();
And, the output I get is this:
{
id: 9999,
data: [
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
... 9900 more items
]
}
heapUsed: 806,706,120
heapUsed: 806,679,456
undefined
undefined
undefined
There are 0 items that haven't be GCed in the map
heapUsed: 3,412,144
The two undefined lines in the output and the last heapUsed show that the objects wrapped in weakRef references did get GCed.
So, after enough of a time delay with nothing else for the interpreter to do, data with only a weakRef does appear to be GCed. I don't know yet exactly why your example is not showing that except that my experience has shown that merely calling global.gc() does not necessarily do all the same GC that an actual idle interpreter will do. So, I'd suggest you insert a legit pause (like I'm doing in my example) and see if you do eventually get the memory back.
P.S. I posted this other question about a GC anomaly I discovered while working on this answer.