Executando verificação de segurança...
2
pdrzan
5 min de leitura ·

Como os Garbage Collectors cuidam da sua memória para você

Garbage collectors (GC) são mecanismos que muitas linguagens de programação utilizam para liberar automaticamente a memória que não será mais utilizada dentro de um programa. Dessa forma, tais mecanismos têm por objetivo determinar quando uma parte da memória pode ser desalocada. Determinar se uma parte da memória não é mais necessária para o programa é um problema indecidível.

C é uma linguagem que não possui um GC. Quando escrevemos um código em C, precisamos deliberadamente alocar memória para criar objetos utilizando funções como malloc e calloc. Além disso, é necessário desalocar esses espaços de memória utilizando a função free pois se não o fizermos é provável que a utilização de memória pelo programa aumente consideravelmente, podendo causar problemas inesperados. Assim, a utilização de um GC contribui para a diminuição desse tipo de problema e melhora a experiência do desenvolvedor, já que este não precisa mais se preocupar tanto com o gerenciamento da memória.

Existem vários tipos de GCs, com cada um variando a forma que identifica a memória que pode ser liberada. Uma das formas mais comuns e básicas de fazer essa verificação é por contagem de referências. A ideia aqui é criar um contador para cada objeto criado no programa. Esse contador guarda a quantidade de referências a esse objeto em um dado momento da execução do programa. A partir desses contadores, o GC liberará a memória de um objeto se seu contador chegar em algum momento a zero. Isso pode ser feito porque se nenhuma parte do programa referencia o objeto, ele não pode mais ser acessado. Um exemplo desse método pode ser visto a seguir:

let variable = {
  attribute: 10 // Objeto considerado
};
// Valor do contador do objeto considerado = 1

let anotherVariable = variable;
// Valor do contador do objeto considerado = 2

variable = 5;
// Valor do contador do objeto considerado = 1

anotherVariable = var;
// Valor do contador do objeto considerado = 0 -> memória pode ser liberada

Esse tipo de GC possui uma limitação bem conhecida de não lidar bem com referências circulares. Referências circulares acontecem quando dois objetos referenciam um ao outro. Quando dois objetos que referenciam um ao outro saem do escopo que foram criados, os espaços de memória dos mesmos podem ser liberados. Porém esse tipo de GC não consegue fazer essa identificação. Exemplo:

function exampleOfCircularReference() {
  const firstObject = {};
  // Valor do contador do objeto referenciado por firstObject = 1
  const secondObject = {};
  // Valor do contador do objeto referenciado por secondObject = 1
  secondObject.attribute = firstObject;
  // Valor do contador do objeto referenciado por firstObject = 2
  firstObject.attribute = secondObject;
  // Valor do contador do objeto referenciado por secondObject = 2
}

exampleOfCircularReference();
// As variáveis firstObject e secondObject saem do escopo que foram criados e são 'excluídas'. 
// Com isso, os objetos referenciados por essas variáveis poderiam ser liberados. 
// Mas isso não acontece pois cada objeto ainda é referenciado uma vez pelo outro objeto.

Outro tipo de GC é o Mark-and-sweep. Esse tipo de algoritmo usa a definição de inalcançável (unreachable) para determinar quais objetos não são mais necessários para a execução do programa e assim liberar a memória alocada para os mesmos. Tal algoritmo assume que um conjunto de objetos raízes (roots) existe e que a partir deles é possível acessar qualquer outro objeto do programa. Com isso, a partir dos roots o GC determina todos os objetos que são alcançáveis (reachable) e considera todos os outros objetos inalcançáveis, liberando a memória alocada para estes.

Mark and sweep image

Esse tipo de GC não possui limitações relacionadas a referências circulares, como o último tipo de GC apresentado. No último exemplo, esse tipo de GC identificaria que depois da chamada de função nenhum dos objetos seria alcançável a partir dos roots (que seria o objeto Global no caso de JavaScript) e com isso a memória associada a eles poderia ser liberada.

Um ponto a ser considerado é que os GCs consomem memória e processamento como qualquer outro algoritmo e por isso podem prejudicar a performance de um programa caso não sejam otimizados. Existem diversas formas de otimizar um GC, com essas técnicas tendo como objetivo diminuir o tempo de processamento e recursos utilizados.

Uma dessas formas é dividir a memória em 'gerações' e fazer a 'coleta de lixo' em cada geração (espaço de memória) de forma distinta. A ideia de dividir a memória em gerações é fazer a 'coleta de lixo' mais frequentemente em espaços de memória recentemente alocados e menos frequentemente em espaços de memória 'antigos'. Com isso, os objetos 'antigos' e amplamente utilizados no programa não precisam ser constantemente considerados pelo GC, aumentando assim sua eficiência. Essa técnica funciona porque a maioria dos objetos criados em um programa tem um tempo de vida curto.

Generational garbage collector

Outra forma de otimização é executar o GC paralelamente com o programa, com a utilização de múltiplas threads. Dessa forma, o programa não precisa 'parar' para que o GC seja executado e com isso aumenta a performance do programa.


Achou algum erro no artigo? Mande um email para [email protected]!

Referências

Carregando publicação patrocinada...
2

Se o problema é indecidível, como ele faz?

Para entender melhor vamos entender que existem basicamente duas formas de organizar a memória (certamente poderíamos falar em uma terceira que é estática mas essa é simples de entender e gerenciar, porque ela está lá no programa e não precisa haver nenhuma preocupação ou mecanismo.

Outra memória muito importante é a pilha. O ideal seria usá-la porque o gerenciamento é muito simples e eficiente, tem até instrução no processador para facilitar isso, e o compilador já coloca para você quando desalocar (na verdade quando pode voltar usar aquele trecho de novo). Esta memória é chamada de automática.

Nem sempre o ideal é possível por várias razões e precisamos de alocação de memória dinâmica, algo proibido em certos tipos de aplicação, mas presente na esmagadora maioria do que fazemos. Ela é mais flexível e permite algumas coisas que a pilha não permite, especialmente com objetos grandes, muito objetos ou com tempo de vida difícil de determinar na compilação ou que extrapola a pilha. Aí o programador aloca essa memória de forma explícita ou implícita e depois alguém fica responsável por desalocar ou fazer algo que indique que ela não será mais usada e poder usar para outra coisa, atém mesmo devolver para o sistema operacional. Em alguns caos é fácil determinar quando isso ocorrerá, outros nem tanto. Mesmo sendo fácil o tradicional é contarmos co ma atenção do programar para "desalocar" a memória que ele alocou no momento correto, nem antes nem depois do deveria, especialmente se esse depois for tarde demais.

Memória dinâmica é extremamente ineficiente, muitos programas só podem ser otimizados fortemente lidando bem com ela, mais do que tentar otimizar o processador. Mas é um mal necessário. E tem linguagens que nem ligam, ao ponto que só possuem essa memória, chamada de heap.

Algumas linguagens não querem dar esse fardo para o programador, até porque ele erra muito isso em vários casos, especialmente linguagens que tem controles de fluxos indeterminados (as que tem exceções por exemplo que é um goto muito pior mas que é defendido por muitos, mesmo aqueles que são contra o goto, vai entender...)

Desta forma pode ter vários mecanismos que coleta essa memória que não precisa mais ser usada.

Exatamente o que se caracteriza um GC pode haver controvérsia. Porque um dos mecanismos é usado quando o compilador determina o momento de desalocar e colocar o "comando" necessário lá para fazer isso. Eu tendo a não classificar isso como GC porque todo o processo é feito pelo compilador. Mas não vou dizer que está errado considerar que ele é uma forma de GC.

Outra muito popular é a de contagem de referência, ou seja, o objeto possui um contador que sempre que alguém se referencia ao objeto é incrementado e quando a referência some é decrementado, e chegando a zero ele manda desalocar a memória. Este caso há um mecanismo em tempo de execução que determina a coleta dos dados, pra mim não tem discussão isso é um GC. Mas não vou brigar com quem acha que não é.

Daí temos os GCs mais sofisticados, não determinísticos com suas vantagens e desvantagens, em geral chamados de tracing garbage collectors que vai deixando alocar tudo sem muito controle, em alguns casos chega ser até tão eficiente quanto aloca na pilha e em algum momento ele vai para seu programa, analisar a memória e decidir quais objetos não possuem mais referência para saber que eles podem ser descartados.

Alguns vão apagar os objetos, outros copiarão os objetos que vão sobreviver para outra lugar, que tem vantagens e desvantagens.

Alguns desses GCs já são tão sofisticados (e alguns absurdamente caros) que eles fazem esse trabalho de uma forma que quase não dá para perceber que tem uma parada de execução do programa principal. Mesmo assim não dá para fazer aplicações hard real time, da mesma forma que nem com gerenciamento manual dá.

Um programa que não libera a memória tem potencial de ocupar toda a RAM e até a memória virtual em memória de massa, chegando travar a aplicação. A parte boa é que o fim de um programa libera toda a memória da aplicação de uma vez sem maiores problemas ou preocupações pelo programador.

Em tese um GC elimina esse problema, a não ser que ele seja mal feito ou tenha algum mecanismo na linguagem que complique o GC.

Por exemplo o RefCount que falei antes tem um problema quando há referência circulares entre objetos, mesmo que indiretamente porque um não deixa matar o outro que ainda está vivo mas deveria morrer. Sabendo usar, dá tudo certo, até porque raras as estruturas de dados que se tornam circulares e tem como se proteger nesses casos, mas dá um trabalhinho para o programador.

Quando se busca por referências vivas, o tal do tracing, de uma forma ou de outra quase todos os GCs em algum momento usam um mecanismo de mark & sweep, ou seja, marca os objetos que devem morrer ou viver e depois se livra deles, e a forma pode ser muito simples ou extremamente complexa. Mas GCs mais sofisticados possuem outros mecanismos como prioridade.

Alguns trabalham com gerações já que é comum os objetos durarem muito pouco ou muito tempo. Alguns são tão sofisticados com isso que trabalham como se fosse trens e vagões, não vou entrar em detalhes.

GCs podem ser mais eficientes que o gerenciamento de memória, mas não é fácil isso acontecer e muitas vezes não se está comparando de forma justa ou pegando casos bem isolados. Em geal eles são mais caros. Mas o fato dele alocar de forma simples e deslocar muitas vezes em bloco pode ser mais rápido que ficar dando malloc() e free() na mão, embora é provável que se for uma situação que isso esteja atrapalhando o programar adotará um mecanismo que ele não precise fazer isso tanto, ou seja, ele fará algo parecido com um GC.

GC é ótimo para a maioria das tarefas e é preferível do que gerenciar a memória na não, por isso você precisa ter um bom motivo para usar C, C++, Rust e outras linguagens do tipo, mesmo as últimas tendo uma forma de GC, mas dá trabalho.

GC é uma das áreas que mais se pesquisa para obter melhores resultados.

S2


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui no perfil também).

1