Executando verificação de segurança...
27

Entender memória é o próximo passo (e o mais importante)

Já que o pessoal curtiu meu último post sobre Por que entender percentis é importante para sua carreira (e vida), eu resolvi trazer mais um conteúdo didático hoje.

Obs.: Valeu @maniero pelas correções e clarificações sobre pontos que ficaram confusos ou mal explicados no post. O resultado abaixo é o texto com essas correções.

Percebo que muita gente na área tem um hiper-foco em frameworks, CRUD, cloud, e muitas outras ferramentas de alto nível. Isso vem, muitas vezes, acompanhado de uma negligência à conceitos de mais “baixo nível” e super importantes como o funcionamento da memória.

Vejo o pessoal compartilhando snippets de código no LinkedIn com formas mais “clean” ou “espertas” de fazer uma certa coisa, como por exemplo:

// ruim :(
public string GetSubLanguage(string subName)
{
    if (subName == “u/brdev”)
    {
        return “brazilian portuguese”;
    }

    if (subName == “u/cscareerquestions”)
    {
        return  “american english”;
    }
    // … continua
}
// bom ❤️🙈💕
public string GetSubLanguage(string subName)
{
    var languagesPerSubName = new Dictionary<string, string>
    {
        { “u/brdev”, “brazilian portuguese” },
        { “u/cscareerquestions”, “american english” },
        // … continua
    };
    
    return languagesPerSubName[subName];
}

Se você não entende o por que o exemplo “bom” é, na verdade, pior que o exemplo “ruim”, acredito que esse post vai te agregar alguma coisa.

Antes de seguir: a vasta maioria das aplicações não tem uma lógica complicada o suficiente para que analises de tempo de execução sejam relevantes. Na vasta maioria das vezes, a interação com a memória é o que dita a performance.

Vários dos conceitos explicados aqui tem como fundamentação o funcionamento no .NET.

Antes de falarmos sobre memória, precisamos entender o que é uma thread

Antes de ir diretamente a como a memória funciona, é importante entender um pouco sobre o que é uma thread. Bem curto, uma thread é um constructo do sistema operacional para interagir com os núcleos do seu processador. Existem vários tipos de threads, inclusive aquelas que não são gerenciadas pelo sistema operacional, mas essas não vem ao caso agora.

Sua linguagem de programação favorita muitas vezes vai gerenciar as threads para você. Em Node, por exemplo, temos uma thread rodando que reage ao “event-loop” para processar o que precisa. Em C#, temos uma threadpool com várias threads que são criadas e cacheadas sobre demanda, de acordo ao nível de concorrência/paralelismo que precisamos - async/await pode necessitar de uma nova thread, o ASP.NET tenta rodar cada request em uma nova thread (até um certo limite).

Antes de falar sobre a memória na thread, é muito importante que você entenda que cada programa tem PELO MENOS uma thread principal, afinal você precisa de uma thread para executar qualquer coisa.

A thread tem uma memória privada

Finalmente, toda thread tem uma região de memória “privada” e uma região de memória “compartilhada”. A região privada é a mais performática, pois menos gerenciamento e coleção de lixo vai rolar por lá. Tudo que é escrito na região privada, geralmente, deve cumprir os seguintes requisitos:

  1. O tamanho do dado deve ser conhecido no momento da alocação
  2. O tempo de vida não deve exceder o escopo

Sobre o item 2., o que é o tempo de vida do dado (ou lifetime)? O tempo de vida do dado é o tempo que o dado demora para ter a região ocupada por ele na memória liberada.

Seu professor da Alura ou da faculdade deve ter te ensinado o mínimo sobre tipos de dados, incluindo tipos de valor (ints, chars, structs, …) e tipos de referência (strings, classes de tamanho variável, etc.). Um exemplo simples para entender o lifetime é notar que tipos de valor tem a memória liberada assim que essas saem de escopo (fim de uma chamada de função, por exemplo) e portanto devem ser copiadas quando passadas de um escopo para o outro, enquanto tipos de referência não necessariamente tem a memória liberada dessa forma, podendo durar por muito mais tempo.

Finalmente, geralmente os dados que são guardados na memória privada e cumprem os dois requisitos são: tipos de valor, ponteiros, e chamadas de função.

Sim, chamadas de função ocupam espaço na memória devido a alocação de seus parâmetros. Talvez isso te faça lembrar de alguma coisa, já que o nome da memória privada é STACK e chamar funções em recursão sem fim pode causar um STACK OVERFLOW (acabou memória na stack).

Cada sistema operacional e arquitetura de hardware tem um limite de memória privada por thread. No Windows 11 x64, por exemplo, o limite padrão pode ir de 1MB a 4MB, mas pode ser modificado.

A thread tem uma memória compartilhada

Existe uma região de memória compartilhada no sistema, em que sua linguagem vai ter que pedir “espaço” ao sistema operacional. O OS decide qual região da memória compartilhada você vai receber, e ela não necessariamente é contígua. Via de regra, toda alocação na memória compartilhada está acompanhada de uma alocação na stack de um ponteiro.

Por ter que fazer mais gerenciamento para alocar itens da memória compartilhada, geralmente a alocação é MENOS performática que na stack. Os itens alocados na memória compartilhada, geralmente, seguem as seguintes características:

  1. Tem tamanho variável, grande o suficiente, ou atrelado a um tipo de tamanho variável
  2. Sobrevivem ao final do escopo

Geralmente os itens que respeitam 1. e 2. são tipos de referência, como coleções, classes customizadas, etc.

O nome da memória compartilhada é HEAP

Por que evitar alocações no HEAP é tão importante

Já que grande parte do pessoal trabalha com linguagens com GC (garbage collector), vamos entender o que o GC faz.

O papel do GC é liberar a memória que está sendo ocupada desnecessariamente em ambas a memórias compartilhada e privada. Para isso, ele pode PAUSAR completamente a execução do seu programa para fazer a limpeza. Um exemplo extremo disso, foi quando o Discord migrou alguns serviços de Go para Rust por que o GC do Go pausava a aplicação por 2s.

Chamadas excessivas ao GC destroem a performance da aplicação. E nem todas as chamadas tem o mesmo custo, tipos de coleta podem acontecer em diferentes lifetimes, que são geralmente chamados de “gerações”:

  1. Gen0 - geralmente coletas de memória ocupada muito recentemente
  2. Gen1 - lifetime maior que Gen0
  3. Gen2 - lifetime maior que Gen1

A coleta de Gen0 é a menos custosa, e roda com mais frequência, enquanto Gen1 e Gen2 são mais custosas e rodam com menos frequência.

Entendemos então que o GC afeta muito a performance de um app, mas como podemos evitar coleções excessivas? Vamos voltar um pouco no post para:

“Um exemplo simples para entender o lifetime é notar que tipos de valor tem a memória liberada assim que essas saem de escopo - sobre dados em STACK”

Ou seja, a vasta maioria das stack allocations com lifetime baixo NÃO precisam ser coletada por GC, por que a coleta acontece de forma automática, assim que o escopo termina. Enquanto a VASTA maioria de alocações no HEAP vai precisar ser coletada pelo GC em algum momento.

Resumindo, tente evitar heap allocations desnecessárias.

Aprendendo a substituir heap allocations por stack allocations

Aqui vai depender muito da sua linguagem, então por favor vá pesquisar mais sobre ela. Vou dar umas dicas pra galera do C# que é o que tenho mais experiência atualmente.

  1. Favoreça tipos de valor para retornar mais de um dado numa função. ValueTuple ou uma struct personalizada substituem uma classe criada apenas para agregar esses dados de retorno.
  2. Se está lidando com async e sua função só sofre um await e nada mais, ValueTask é uma alternativa sobre Task.
  3. Se pretende fazer operações MUITO custosas com string, aprenda a usar o StringBuilder ao invés de usar “+“ ou interpolação para tudo.
  4. Se a intenção é operar sobre uma região de uma collection, aprenda a usar Span e ReadOnlySpan ao invés de LINQ.
  5. Dê um tamanho máximo, conhecido em compile time para suas coleções sempre que possível. A maioria das ICollection aceitam um atributo capacity no construtor que vai reduzir a quantidade de alocações internamente.
  6. Estude a linguagem para reconhecer quando heap allocations vão acontecer, falo também das classes de sistema, você sabe como a List<> funciona internamente?
  7. Aprenda a usar Pools para não alocar objetos pesados no heap desnecessariamente.

Bônus, configurando o GC

Várias linguagens permitem ativar um modo de GC chamado de GC concorrente. Basicamente, isso é voltado para servidores multi-threaded, em que a maioria das alocações não vão ser efetivamente acessadas por várias threads ao mesmo tempo.

O GC concorrente, ao invés de pausar todas as threads do sistemas, vai tentar pausar apenas a thread em que está coletando atualmente, evitando bloquear o seu app.

Isso geralmente aumenta o consumo de recursos do seu app, mas pode ser que valha a pena.

Desafio

Volta lá no início do post e tenta ver se entendeu o por que o exemplo “bom” é , na verdade, pior que o ruim.

Bônus final é se descobrir outro problema de performance no exemplo “bom”, que não está relacionado à memória.

EDIT: Seguindo os posts didáticos diários, aqui está um sobre Flighting vai salvar a sua vida (e a dos seus usuarios)

Carregando publicação patrocinada...
13

Algumas questões que coloco abaixo foram resolvidas, outras não, mas são menos importantes.

Eu levei um susto quando vi o exemplo que estava comentando como BOM. Depois eu vi que explica que é o que o pessoal posta e de fato é uma solução pior em quase todos critérios, especialmente no gerenciamento de memória. O BOM faz zero alocações dinãmicas, e uma automática (o parâmetro) e algumas alocações estáticas. O RUIM faz como extra uma alocação dinâmica.

O artigo é interessante, parabéns por levantar a questão.

Mas tenho que corrigir o que está falho ou pode ser mal interpretado. Propositalmente não vou falar de todos os detalhes, o assunto é vasto.

Boa parte das linguagens não precisam ter conhecimento do tamanho do objeto para alocar na pilha. Java precisa, no momento, isso pode ser mudado. Se a intenção era falar especificamente de Java, está certo, mas não ficou claro. Todas as linguagens que alcançam um nível um pouco mais baixo, até mesmo C#, consegue alocar na stack sabendo o tamanho apenas no momento.

Quando fala em requisito e diz que "de preferência", não é requisito. E na verdade, não só não é um requisito como muito objeto na pilha tem um tempo de vida bastante longo, até mesmo por toda aplicação, especialmente na função de entrada. O requisito é sobre o objeto precisar sobreviver ao fim do escopo. Vou deixar links onde explico todos esses conceitos.

Em "na memória liberada" dito ali acho que queria dizer memória alocada.

Há uma confusão sobre tipos por valor serem liberados quando saem de escopo. Existem tipos por valor que são liberados pelo GC, afinal se um objeto está no heap e ele contém objetos por valor dentro dele, é o GC que o libera, não é sobre o escopo. Tipo por valor é diferente de objeto alocado na stack. Tipos por valores podem estar nas duas áreas.

Tipos por referência também podem estar nas duas áreas, não só no heap. Algumas linguagens limitam ao heap. Java não limita, mas favorece muito. Tanto não limita que tem implementações que o JITter é capaz de alocar uma classe, portanto um tipo por referência, na stack, como otimização. Isso não acontece muito, mas é possível. O programador não tem controle sobre isso. C#/.NET ainda não faz isso por gerar bem menos lixo e ter mais controle sobre a alocação de memória do que Java, mas nada impede de um dia ter.

Objetos alocados na stack são liberados quando sai do escopo. Objetos no heap duram até que o código ou o runtime (GC) mande liberar. Então eles podem até ter o tempo de vida definido de acordo com a execução, por isso chama-se memória dinâmica.

Como adendo, a memória das strings fixas, como as usadas nos códigos exemplo, não ficam nem no heap, nem na stack, ficam em memória estática e duram por toda a aplicação, a alocação é feita já na carga do executável.

Não sei se entendi bem o que quis dizer com "chamadas de função ocupam espaço na memória". Pelo que entendi, isso não é verdade. A chamada em si não ocupa espaço de memória algum (a não ser a memória para o código que faz a chamada, uma instrução), o que vai ocupar memória é a alocação dos objetos que estão no escopo da função chamada, e só assim pode acontecer o stack overflow, uma função que não aloca não tem esse problema. Pelo menos é assim em quase todas as linguagens.

Em geral os sistemas operacionais não impõem limite de tamanho para a stack de cada thread e todas podem ser configuradas durante a sua criação (a principal na carga). No Windows, até a última vez que eu vi, o padrão era 1MB, mas nada impede de criar com 4KB ou 4GB. É possível até ultrapassar esse limite.

Para alocar no heap, que costuma na maioria das linguagens, mas não necessariamente precisa ser compartilhado pelas threads, os objetos precisam ter tamanho conhecido no momento da alocação e não podem variar (sendo pedante). Seria fisicamente muito complicado dar variabilidade no tamanho do objeto. Pense em um caminhão onde os pacotes dentro dele podem mudar de tamanho, para onde vão os pacotes que estão do lado dele? É uma questão bem concreta. Por isso não pode encher até a boca caminhão tanque. E assim os motoristas roubam(vam) combustível, tirando uma parte e deixando o caminhão no sol para dar o mesmo volume (por isso hoje se mede a temperatura quando vai descarregar). Objetos na memória não são líquidos.

O tempo de vida do objeto no heap pode ser conhecido, o ideal até é que seja em tempo de compilação, e por sorte a maioria é. É mais complicado ainda quando só se conhece no momento da execução. Por isso o GC existe, ele facilita muito isso, e de forma segura. Quando há um GC não determinístico, caso da maioria das linguagens, então o tempo de vida importa menos, mas ainda importa. Em um GC determinístico, ou se sabe quando vai liberar a memória, ou tem um controle bem mais estrito de quando vai liberar (usa uma contagem de referência - algumas pessoas consideram esse tipo de GC como semi determinístico).

A única forma do tipo ter um tamanho determinado apenas no momento da alocação é ser uma coleção (um array). Em algumas linguagens é possível ter um array dentro de um outro tipo (uma classe por exemplo), o que fará que esse novo tipo tenha o tamanho variável em relação à sua criação, nunca depois. Pelo menos nunca vi linguagem que fugisse disso.

Alocar no heap é pior para a eficiência da execução e consumo de memória, sempre, até mesmo com gerenciamento manual de memória. Em alguns casos, ao contrário da crença popular, é possível ser pior fazendo manual do que com GC. O GC não pode ser sempre culpado pela ineficiência. Em muitos casos o GC piorará, e provavelmente gerará pausa de forma não determinística, que pode ser ruim em certos cenários.

O GC não manipula a tal da memória privada (não gosto do nome já que o heap também pode ser privado), ele só é necessário no heap se não quiser o gerenciamento manual. Na pilha o gerenciamento é sempre automático, por isso essa memória chama-se automática.

Existem GCs que pausam por tempo limitado, e o programador consegue minimizar isso também se ele souber usar corretamente todas essas coisas que estamos falando aqui.

Linguagens com GC são usadas em jogos de "tempo real", em locais de alta eficiência como o site Stack Overflow. Basta saber fazer. Nem todo mundo sabe, aí precisa achar outro tipo de solução. É tipo microsserviço, a pessoa não sabe fazer eficiente, então ela resolve quebrar em vários serviços para dar conta, ficando absurdamente mais caro e mais difícil de gerenciar e manter. O site SO não sofre com pausas, inclusive porque o GC avisa que vai coletar e pode tirar aquele nó do load balancer durante a pausa, que costuma ser de poucos milissegundos, nos piores casos, a maioria fica na casa dos poucos microssegundos.

Sem o GC não é de graça, ele não costuma dar pausas grandes, mas na soma total tem casos que consome mais tempo que o garbage collector.

Alocações no heap desnecessárias podem destruir a performance da aplicação. O GC pode agravar isso, mas nem sempre. Java e C# têm GCs muito modernos que são melhores em alguns cenários do que fazer em C, C++ ou Rust (ele aloca essencialmente no mesmo tempo que na stack que é muito rápida - só move um ponteiro, enquanto nas linguagens de mais baixo nível a alocação pode custar bem caro por ter que buscar um lugar para alocar). Claro que é possível fazer essas linguagens baterem C#, mas dá muito mais trabalho, você praticamente escreve um GC no seu código.

A explicação sobre as gerações do GC é sobre o .NET. Java é ligeiramente diferente, outras linguagens são bem diferentes, e não possuem gerações.

A Gen0 costuma levar microssegundos, a Gen1 não costuma passar de 1 milissegundo, e a Gen2 pode demorar bastante, mas a maior parte do tempo é feito em background e a pausa costuma ser bem pequena. As duas primeiras fazem uma cópia dos objetos sobreviventes para a próxima geração e a Gen2 faz o chamado mark & sweep (pelo menos algo muito parecido).

Todos os objetos precisam ser coletados pelo GC quando o heap é gerenciado por ele. Até existem linguagens que tem heap gerenciado e não gerenciado, mas é raro e não popular.

Chegando ao fim, quando a pessoa descobre o StringBuilder ela tende a abusar dele e fazer o código ficar ineficiente quando ele não é a melhor opção.

Usar algo que tem tamanho suficiente ajuda porque se precisar aumentar o tamanho do objeto, não pode, então a solução é criar um outro objeto maior em outro lugar e copiar os dados para esse novo lugar, e depois o GC terá que coletar o objeto velho. Isso pode gerar uma progressão geométrica e te destruir.

Depois veremos se o problema no exemplo que eu achei é o mesmo que outros acharam.

Se você não seguir todos os links (em recursão) não vai aprender. Eu sei que dá trabalho, mas é assim que evolui. Eu percebo que as pessoas clicam só no primeiro link. Essa é a diferença de que aprende e que patina (os que já sabem disso tudo são os que mais vão clicar, tô certo kht? Foi uma total perda de tempo ler isso mesmo já sabendo?).

Faz sentido para você?

Espero ter ajudado.


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente. Para saber quando, me segue nas suas plataformas preferidas. Quase não as uso, não terá infindas notificações (links aqui).

6

Muito bom os dois posts! Mas gostaria de corrigir um único ponto na resposta do maniero, onde ele fala sobre objetos não mudarem de tamanho na heap:

Para alocar no heap, que costuma na maioria das linguagens, mas não necessariamente precisa ser compartilhado pelas threads, os objetos precisam ter tamanho conhecido no momento da alocação e não podem variar (sendo pedante). Seria fisicamente muito complicado dar variabilidade no tamanho do objeto. Pense em um caminhão onde os pacotes dentro dele podem mudar de tamanho, para onde vão os pacotes que estão do lado dele? É uma questão bem concreta. Por isso não pode encher até a boca caminhão tanque. E assim os motoristas roubam(vam) combustível, tirando uma parte e deixando o caminhão no sol para dar o mesmo volume (por isso hoje se mede a temperatura quando vai descarregar). Objetos na memória não são líquidos.

Na realidade, em C, é possível alterar o tamanho de objetos alocados na heap em tempo de execução com o comando realloc().

void *realloc(void *ptr, size_t new_size);

O realloc recebe o endereço do objeto a ser redimensionado e o novo tamanho pra esse objeto. Caso consiga alterar o tamanho do objeto, ele retornará o mesmo endereço de memória, caso contrário, ele executará os passos a seguir:

  1. Alocará um novo espaço de memória para o objeto;
  2. Copiará o conteúdo atual do objeto para o novo espaço de memória;
  3. Desalocará o espaço de memória anterior; e
  4. Retornará o endereço do novo espaço de memória.

É bem mais fácil visualizar seu funcionamento com arrays, mas em C, essa operação pode ser feita com qualquer tipo de dados. Por exemplo, se alocarmos um array de int de 10 posições (40 bytes) e depois quisermos reduzir seu tamanho para 5 posições (20 bytes), é bem provável (uma "quase-certeza") que o endereço de memória retornado seja o mesmo, o array só ficou menor e deixou um espaço livre de 5 posições (20 bytes) ao seu lado.

Exemplo 1:

#include <stdio.h>
#include <stdlib.h>

int main() {
  int *array = malloc(10 * sizeof(int));
  printf("endereço anterior: %p\n", array);
  array = realloc(array, 5 * sizeof(int));
  printf("novo endereço:     %p\n", array);
  return 0;
}

Output do Exemplo 1:

endereço anterior: 0x1ccf2a0
novo endereço:     0x1ccf2a0

Obs: os endereços mudam em cada execução

Mas e se quisermos aumentar o tamanho do nosso array? Bom, às vezes ele vai alocar um novo espaço de memória, realizando toda a etapa de cópia do objeto pro novo espaço, como dito anteriormente, quando não houver espaço disponível ao seu redor. No exemplo a seguir, alocamos um array de 5 posições e depois tentamos redimensioná-lo para 10 posições, como "não existe" espaço livre ao seu redor, será retornado um novo endereço de memória.

Obs: usei "não existe" entre aspas porque existem detalhes sobre como o SO aloca esses espaços e como eles são expostos para o processo que não tenho conhecimento aprofundado pra explicar aqui

Exemplo 2:

#include <stdio.h>
#include <stdlib.h>

int main() {
  int *array = malloc(5 * sizeof(int));
  printf("endereço anterior: %p\n", array);
  array = realloc(array, 10 * sizeof(int));
  printf("novo endereço:     %p\n", array);
  return 0;
}

Output do Exemplo 2:

endereço anterior: 0x229c2a0
novo endereço:     0x229c6d0

Mas ele também pode reaproveitar espaços liberados por objetos que estavam na heap mas foram desalocados. Nesse caso, ele vai simplesmente alterar o tamanho do objeto e retornar o mesmo endereço. No próximo exemplo, são alocados dois arrays de 10 posições, depois o primeiro array é desalocado e, quando o segundo é redimensionado para 20 posições, o realloc() reaproveitará o espaço livre deixado pelo array desalocado.

Exemplo 3:

#include <stdio.h>
#include <stdlib.h>

int main() {
  puts("Alocação inicial");
  int *array1 = malloc(10 * sizeof(int));
  int *array2 = malloc(10 * sizeof(int));

  printf("array1: %p\n", array1);
  printf("array2: %p\n", array2);
  puts("");

  puts("Removendo array1 e realocando array2");

  free(array1);  // liberando o array1
  array1 = NULL; // atribuindo o ponteiro nulo pra ficar mais bonito no print :D
  array2 = realloc(array2, 20 * sizeof(int));

  printf("array1: %p\n", array1);
  printf("array2: %p\n", array2);

  return 0;
}

Output do Exemplo 3:

Alocação inicial
array1: 0x16836b0
array2: 0x16836e0

Removendo array1 e realocando array2
array1: (nil)
array2: 0x16836e0

Obs: se analisarem os endereços retornados, verão que eles não se crusam, mas novamente, isso é por conta de detalhes de como o SO expõe esses endereços da heap para o processo, que não tenho conhecimento suficiente pra explicar como funciona

Acredito que a maioria das linguagens não adota a estratégia do realloc por ser bem complicada de gerenciar. Acho que é como a herança múltipla em OOP, onde a maioria das linguagens prefere não permitir para mitigar possíveis problemas.

Espero que eu tenha contribuído pra discussão e que tenham gostado da explicação. Gostaria de saber onde estou errado também e, principalmente, sobre esses detalhes de como o SO expõe os endereços de memória pro processo.

3

Essa explicação cabe e está correta.

Vou tentar deixar mais claro o que eu disse. De fato existe uma situação que você pode interpretar que o objeto pode aumentar de tamanho, mas nada garante que isso vai acontecer.

Como o nome diz, o realloc() realoca o espaço onde estava o objeto, conforme meu texto expllica. Inclsuive essa função sequer modifica o objeto, só trata da alocação de memória. Ele copiará o dado para outro local, portanto terá um novo objeto. Em certo momento haverá os dois objetos na memória. Ele não mexe no objeto antigo de forma alguma.

Como agora provavelmente tem mais espaço (no exemplo que pediu para realocar para um espaço maior) então poderá fazer com que o novo objeto colocado ali será maior que o anterior. Mas é outro objeto, não é o mesmo. Pode ser idêntico, mas a cópia já garante que será outro objeto.

Conforme eu falei, é concreto. Se você tem um monte de caixas no caminhão, todas adjacentes às outras sem espaço algum sobrando entre elas, só tem espaço no fim do baú, se elas tinham 10cm3 e passa ter 12cm3, como isso é possível? O único jeito é pegar um outro espaço em local vazio para colcoar essa caixa maior, não dá para colocar onde estava aquele objeto, não tem espaço. Você pode até tirar a caixa menor de lá, mas o espaço ficará lá, até que alguém coloque outra caixa no mesmo lugar, mas essa nova caixa terá 10cm3 ou menos.

O realloc() realoca em todas as situações? Não tem nada que mande ele fazer isso, e de fato algumas implementações não mudam de lugar quando o que está pedindo de alocação tem o mesmo tamanho ou menor. Para tamanhos maiores é impossível fisicamente não mudar de lugar, a não ser que se saiba que tem um espaço sobrando ali. Nem sempre se sabe, mas se souber também é uma situação que poderá não mudar de lugar.

Só note que isso é uma otimização, não é garantido que aconteça. Se você pediu um espaço maior e conseguiu deixar no mesmo lugar você já tinha o espaço maior.

Então se você tiver espaço entre as caixas é possível colocar uma caixa maior que caiba no espaço deixado ali. Sempre será feito assim? Não. Não é problema seu se vai acontecer ou não, não conte com isso, interprete que sempre haverá a relocação da caixa. Nunca conte que seja assim só porque fez um teste e deu esse resultado.

Fiat 147 todo detonado andando pelas ruas

O realloc() sequer é sobre o objeto e sim sobre a alocação. Em C a alocação e o objeto quase se confudem. Se você tem o espaço para aumentar o objeto pode interpretar que o tamanho real do objeto é todo o espaço disponível e ele não é aumentado. O aumento real só ocorre quando se cria uma nova alocação/objeto.

Inclusive nada impede de você aumentar o tamanho do objeto sem o realloc(). C tem dessas. Só que eu não considero isso porque você está corrompendo a memória e potencialmente está sobrescrevendo em cima de outro objeto.

Se eu não posso garantir que será sempre assim, eu prefiro considerar que o aumento só ocorre, garantidamente, criando um novo objeto, o resto é lucro.

1

Boa parte das linguagens não precisam ter conhecimento do tamanho do objeto para alocar na pilha. Java precisa, no momento, isso pode ser mudado. Se a intenção era falar especificamente de Java, está certo, mas não ficou claro. Todas as linguagens que alcançam um nível um pouco mais baixo, até mesmo C#, consegue alocar na stack sabendo o tamanho apenas no momento.

Boa, aqui estava querendo falar de "tempo de alocação", vou corrigir.

Há uma confusão sobre tipos por valor serem liberados quando saem de escopo. Existem tipos por valor que são liberados pelo GC, afinal se um objeto está no heap e ele contém objetos por valor dentro dele, é o GC que o libera, não é sobre o escopo

Boa, não tratei de casos em que os tipos de valor estão atrelados a um tipo de referência e tem um lifetime maior. Nesse caso realmente é o GC que trata de liberá-los.

No post tentei dissociar completamente os dois tipos de tipos, por brevidade.

Como adendo, a memória das strings fixas, como as usadas nos códigos exemplo, não ficam nem no heap, nem na stack, ficam em memória estática e duram por toda a aplicação, a alocação é feita já na carga do executável.

Boa, isso é algo que não tratei no artigo, infelizmente não dá pra entrar em todos os detalhes.

No C#, e outras linguagens, além da memória estática existe a string pool, que evita a alocação desnecessária de strings quando possível.

O tempo de vida do objeto no heap pode ser conhecido, o ideal até é que seja em tempo de compilação, e por sorte a maioria é

De fato, como exemplo tipos de referência que não tem a referência propagada e perdem escopo ao sair do local que o instanciou. Não queria dar a entender que objetos no heap nunca vão ter um lifetime definido.

Em geral os sistemas operacionais não impõem limite de tamanho para a stack de cada thread e todas podem ser configuradas durante a sua criação (a principal na carga). No Windows, até a última vez que eu vi, o padrão era 1MB, mas nada impede de criar com 4KB ou 4GB. É possível até ultrapassar esse limite.

Isso é outra coisa em que falhei em dizer que o limite padrão, mas como disse, pode ser configurado. Falhei também nos 2MB, para processos de 64bit está em 4MB.

Linguagens com GC são usadas em jogos de "tempo real", em locais de alta eficiência como o site Stack Overflow. Basta saber fazer.

Com certeza, o intuito aqui não é criticar o GC ou implementação específica de algum GC. Mas é evidente que, em busca de performance, é necessário facilitar seu trabalho

Usar algo que tem tamanho suficiente ajuda porque se precisar aumentar o tamanho do objeto, não pode, então a solução é criar um outro objeto maior em outro lugar e copiar os dados para esse novo lugar, e depois o GC terá que coletar o objeto velho. Isso pode gerar uma progressão geométrica e te destruir.

Assumo que isso ainda seja sobre o StringBuilder. É verdade, e o benefício dele começa a vir quando o custo de "gerar" a string final e seu processo é maior que o custo do SB em si.


Concordo que o post ficou muito específico para o C#, e não evidencio isso o suficiente, outra alteração que vou fazer.

Valeu por entrar mais em detalhes sobre as especificidades de stack e heap, e também sobre os tempos de execução e algoritmos do GC

4

Quando vi no começo você dizendo que o segundo exemplo era o bom, já me doeou, mas eu fui lendo a explicação, e te perdoei 😀.

Esse sim é um conteúdo... parabéns!

O pessoal não ta preocupado com isso, porque o essencial hoje é programar com strategy(acho importante), mas as vezes custa. E no fim o problema nunca é custo, e sim saber porque custa e saber se vale a pena.

Em "Aprendendo a substituir heap alloc..." no número 5, foi dito "capacity no construtor que vai reduzir a quantidade de alocações internamente".
Ai se você me permitir contribuir...

Pra quem não sabe (certeza que não é o teu caso), por debaixo dos panos a ICollection vai usar array comum, e toda vez que precisamo aumentar uma array não tem segredo, temos que criar uma nova com mais espaço e a antiga liberar da memória.

Nesse sentido se você tive 500 itens pra colocar na sua collection, e sua collection tiver um capacity de 50 itens, então quando chegar a 50 o .NET (ou qualquer outra), vai precisa alocar e liberar, fazendo isso pelo menos umas 10x (não sei a heurística para isso, só ex) até chegar em 500, agora se você já sabe que são 500 itens já inicializa alocando 500 "itens".

Normalmente utilizamos coisas dinâmicas então não temos como saber exatamente a quantidade de itens, então eu coloco algo próximo (tiro do 👌🏻 essa ideia)...
var capacity_qtde_kids = familys.Lenght * 2

óbvio que as vezes pode passar, como pode ser pouco também, mas é melhor do que não ter número nenhum.

Se tiverem afim de ver uma coisa muito é massa...

🐓 Rinha de compiladores (Vlang) - Me acompanha

2

Posso acrescentar um pouco mais à tua resposta! Mas minha contribuição vai ser exclusivamente acerca do C#.NET

Primeiro, nem toda classe que implementa a ICollection<T> utilizará um array. Mas o List<T>, que com certeza é a mais utilizada, usa um array em sua implementação.

Segundo, a eurística implementada em duas partes do código da List<T>. Os códigos que mostrarei aqui estão no repositório do .NET no GitHub, aqui o link pro arquivo de implementação da List<T> https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs.

A primeira parte está no método List<T>.Grow(), que é chamado sempre que a lista excede a capacidade anterior. É possível ver que a eurística é dobrar a capacidade até alcançar o patamar máximo que é Array.MaxLength.

/// <summary>
/// Increase the capacity of this list to at least the specified <paramref name="capacity"/>.
/// </summary>
/// <param name="capacity">The minimum capacity to ensure.</param>
internal void Grow(int capacity)
{
    Debug.Assert(_items.Length < capacity);

    int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;

    // Allow the list to grow to maximum possible capacity (~2G elements) before encountering overflow.
    // Note that this check works even when _items.Length overflowed thanks to the (uint) cast
    if ((uint)newCapacity > Array.MaxLength) newCapacity = Array.MaxLength;

    // If the computed capacity is still less than specified, set to the original argument.
    // Capacities exceeding Array.MaxLength will be surfaced as OutOfMemoryException by Array.Resize.
    if (newCapacity < capacity) newCapacity = capacity;

    Capacity = newCapacity;
}

A segunda parte está implementada na propriedade List<T>.Capacity que é modificada no método List<T>.Grow(), onde é implementada toda a lógica de criação do novo array e cópia dos elementos do antigo pro novo. Não é feita a liberação do antigo array porque isso é feito pelo GC.

// Gets and sets the capacity of this list.  The capacity is the size of
// the internal array used to hold items.  When set, the internal
// array of the list is reallocated to the given capacity.
//
public int Capacity
{
    get => _items.Length;
    set
    {
        if (value < _size)
        {
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }

        if (value != _items.Length)
        {
            if (value > 0)
            {
                T[] newItems = new T[value];
                if (_size > 0)
                {
                    Array.Copy(_items, newItems, _size);
                }
                _items = newItems;
            }
            else
            {
                _items = s_emptyArray;
            }
        }
    }
}
1

Opa, iae comentário valioso!

Sim você está certo! Falha minha esquecendo que nem todos conhecem C#, Java, Js e etc...

Toda liberação é feita pelo GC, mas existem caso que é possível forçar prioridade de liberação para o GC.

Mas não é o caso do List, pois ele não implementa IDisposable.
Ai nesse caso, a nossa lista fica ao Deus dará... 😆