В Go 1.25 появился экспериментальный сборщик мусора Green Tea, а уже в Go 1.26 он стал основным. Разработчики переписали фазу mark: раньше GC тупо шёл по цепочке указателей от корневых объектов — и при разнобое размеров и времени создания ссылки разлетались по всей куче, вызывая дикий random access и промахи мимо кэша. Теперь Green Tea сканирует span объектов, выискивая в нём указатели, и только потом ставит в очередь следующие spans — это резко улучшает локальность обращений.
Чтобы понять, откуда вообще берётся разлёт объектов, авторы сначала сравнили, как память выделяют Go и C#. В Go объекты одного размерного класса группируются в непрерывные spans по 8 KiB и никогда не перемещаются — даже после runtime.GC() адреса остаются прежними. В C# компоновка хаотичная: Small, Medium и Large вперемешку. Для наглядности написали программу, которая создаёт 100 объектов трёх размеров и рисует карту кучи: Go выстраивает ряды S, M, L строго друг за другом, а у C# полная каша. При этом C#, в отличие от Go, умеет двигать объекты, но в этом конкретном тесте после сборки ничего не сдвинулось.
Затем проверили реальную выгоду от Green Tea. Взяли 2 миллиона Node со ссылками a, b, c, d и два варианта их настройки: packed (индексы по порядку) и scattered (случайные). Программу гоняли под perf, измеряя cache-misses и время. На первый взгляд новые цифры смущали: процент промахов L3 даже вырос, хотя настенные часы показали приличное ускорение. Оказалось, нужно смотреть глубже — пришлось заказать bare metal машину на Vultr, потому что виртуалки не дают доступ к счётчикам PMU для L1. С событиями L1-dcache-loads и L1-dcache-load-misses картина прояснилась: L1-miss rate почти не изменился, но Misses Per Kilo Instruction (MPKI) для L1 резко упали. Например, для режима scattered у старого GC было 31,57 L1 MPKI, а у Green Tea — 14,06. Большинство чтений стало укладываться в L1/L2, и возросшие промахи L3 перестали играть роль. Ускорение получили в обоих режимах — packed завершался за 2,7 секунды вместо 4,5, scattered за 7,0 секунды вместо 11,4.
Под конец показали больное место: Go так и не научился двигать объекты, поэтому при освобождении 90% объектов куча почти не сжимается. В эксперименте 50 000 объектов после удаления 9 из каждых 10 оставили выжившие экземпляры разбросанными по страницам. Поскольку span размером 8 KiB нельзя вернуть операционке, если в нём остался хоть один живой объект, реально занятых spans оказалось намного больше, чем потребовалось бы при компактификации. Цифры из runtime.MemStats подтверждали: HeapInuse почти не уменьшился, несмотря на радикальную чистку.