String interning no .NET
String interning no .NET é uma técnica de otimização do Common Language Runtime (CLR) que reduz o uso de memória ao manter uma única instância para cada valor de string repetido. Em vez de guardar várias cópias do mesmo texto, o runtime reaproveita uma referência já existente.
Essa técnica depende de uma estrutura central chamada intern pool, uma tabela de strings únicas mantida pelo runtime. Quando uma string entra nesse pool, qualquer pedido pelo mesmo valor recebe a mesma referência.
Neste artigo você vai entender como o string interning funciona, quais strings entram no pool automaticamente e como usar String.Intern e String.IsInterned. Além disso, vai ver os custos dessa técnica e as alternativas mais seguras para cenários de alto volume.
O que é string interning
Em termos simples, string interning é o processo de garantir que valores de texto iguais compartilhem a mesma instância na memória.
No .NET, uma string é um objeto de referência alocado no heap gerenciado. Por isso, duas variáveis com o mesmo texto podem apontar para objetos diferentes, cada um ocupando seu próprio espaço.
O string interning resolve essa duplicação. Assim, quando duas variáveis apontam para uma string internada, ambas compartilham exatamente o mesmo objeto.
A documentação de String.IsInterned descreve o intern pool como uma tabela que contém uma única instância de cada literal de string declarado no programa. Também entram nela as strings que você adiciona programaticamente com o método Intern.
Se você quer revisar como o tipo string funciona antes de seguir, o artigo sobre string, System.String e StringBuilder é um bom ponto de partida.
Como o intern pool funciona
O CLR gerencia o intern pool e o mantém durante todo o ciclo de vida do processo. Na prática, ele funciona como um dicionário em que o conteúdo do texto aponta para a instância oficial daquele valor.
Uma correção importante merece destaque logo aqui. O CLR não consulta o pool a cada string criada em tempo de execução, porque isso tornaria toda concatenação mais cara.
A documentação de RuntimeHelpers.GetHashCode deixa isso explícito. Segundo ela, o CLR adiciona apenas literais ao pool, e resultados de operações como concatenação ficam de fora.
A exceção acontece quando o compilador resolve a concatenação como um único literal. Fora desse caso, o string interning só ocorre se você chamar String.Intern de forma explícita.
Portanto, existem dois caminhos para uma string chegar ao pool. O primeiro é ser um literal no código, e o segundo é passar pelo método String.Intern.
Literais e o string interning automático
Quando você declara um literal como "Hello World" no código, o runtime normalmente coloca esse valor no intern pool. Então, se o mesmo literal aparecer em outro ponto, ele tende a apontar para a mesma instância.
string first = "hello";
string second = "hello";
// Os dois literais apontam para a mesma instância internada
bool areSameReference = object.ReferenceEquals(first, second);
Console.WriteLine(areSameReference); // True
Aqui, tanto first quanto second apontam para a mesma string internada "hello". Por isso, ReferenceEquals retorna true, mesmo sem nenhuma chamada explícita a String.Intern.
O compilador também resolve concatenações de constantes em tempo de compilação. Dessa forma, uma expressão como "part1" + "_" + "part2" vira um único literal e participa do string interning normalmente.
string folded = "part1" + "_" + "part2";
string literal = "part1_part2";
// O compilador transforma a concatenação de constantes em um único literal
Console.WriteLine(object.ReferenceEquals(folded, literal)); // True
Por outro lado, uma concatenação que envolve uma variável acontece em tempo de execução. Nesse caso, o resultado é uma nova string fora do pool.
string suffix = "A";
string third = "String" + suffix;
string fourth = "String" + suffix;
// Concatenação em tempo de execução gera objetos diferentes
Console.WriteLine(object.ReferenceEquals(third, fourth)); // False
Console.WriteLine(third == fourth); // True
Quando o string interning de literais não é garantido
Existe um detalhe pouco conhecido sobre o string interning automático. A documentação de String.Intern afirma que a internação automática de literais não é garantida.
Segundo a mesma página, o compilador C# emite por padrão o atributo CompilationRelaxationsAttribute com a flag NoStringInterning em cada assembly. Essa flag marca o assembly como não exigindo internação de literais, o que libera o runtime para otimizar.
Na prática, literais repetidos ainda costumam compartilhar a mesma instância. Contudo, depender disso como regra de negócio é um erro, porque o comportamento varia conforme a forma de compilar e executar o assembly.
O cenário de Native AOT reforça esse cuidado. Ao publicar com Native AOT, desligar NoStringInterning não tem suporte, e String.Intern pode não encontrar uma correspondência para algo que parece um literal no código.
A própria documentação traz ainda outra observação. O String.Intern garante que valores iguais devolvem a mesma referência internada, mas não garante que essa referência seja a mesma de um literal.
Por que o string interning é seguro
Compartilhar o mesmo objeto entre várias partes do código só funciona porque strings no .NET são imutáveis.
Depois de criar uma string, ninguém altera o seu conteúdo. Métodos como Replace, ToUpper e Trim sempre devolvem uma nova instância, sem tocar na original.
Assim, se uma parte do sistema pudesse modificar uma string internada, todas as outras referências enxergariam a mudança. A imutabilidade elimina esse risco e torna o string interning seguro por definição.
O artigo sobre strings imutáveis em C# aprofunda esse conceito e mostra por que ele influencia tanto o desempenho.
Internação manual com String.Intern
Strings criadas em tempo de execução ficam fora do pool. No entanto, você pode internar qualquer uma delas manualmente com o método String.Intern.
O método procura no pool uma string com o mesmo valor. Se encontrar, devolve a referência existente; caso contrário, adiciona a string recebida e devolve essa nova referência.
using System.Text;
// Duas strings construídas em tempo de execução, com o mesmo conteúdo
string first = new StringBuilder().Append("My").Append("Test").ToString();
string second = new StringBuilder().Append("My").Append("Test").ToString();
Console.WriteLine(first == second); // True, mesmo valor
Console.WriteLine(object.ReferenceEquals(first, second)); // False, objetos diferentes
string internedFirst = string.Intern(first);
string internedSecond = string.Intern(second);
// Depois do interning, as duas variáveis compartilham a mesma referência
Console.WriteLine(object.ReferenceEquals(internedFirst, internedSecond)); // True
Esse exemplo segue o que a documentação oficial demonstra. Antes do string interning, as duas strings têm o mesmo valor e referências diferentes; depois dele, compartilham a mesma instância.
Repare em um detalhe sutil sobre o retorno. O valor útil é sempre o retorno de String.Intern, e não a variável original.
string original = new string("hello".ToCharArray());
string interned = string.Intern(original);
// O literal "hello" já estava no pool, então Intern devolve a instância do literal
Console.WriteLine(object.ReferenceEquals(original, interned)); // False
Console.WriteLine(object.ReferenceEquals(interned, "hello")); // True
Como o literal "hello" já existe no código, o pool já tem uma instância com esse valor. Portanto, String.Intern devolve a instância do pool, e a variável original continua apontando para outro objeto.
Verificando o string interning com IsInterned
Para saber se um valor já está no pool, use String.IsInterned. Diferente de Intern, esse método nunca adiciona nada ao pool.
Se o valor estiver internado, o método devolve a referência do pool. Caso contrário, devolve null.
string value = new string("hello".ToCharArray());
string? internedValue = string.IsInterned(value);
// IsInterned não devolve bool, então comparamos com null
bool isInterned = internedValue is not null;
Console.WriteLine(isInterned); // True se o valor estiver no pool
Nesse exemplo, o resultado tende a ser true, porque o literal "hello" aparece no próprio código. Por outro lado, um texto montado em tempo de execução, sem literal equivalente, retornaria null.
A documentação lembra que o método não devolve um valor booleano. Por isso, a comparação com null é o jeito idiomático de transformar o retorno em uma resposta de sim ou não.
Benefícios do string interning
O primeiro benefício é a redução do uso de memória. Reutilizar referências evita cópias, algo relevante quando a aplicação lida com muitas strings idênticas.
O segundo benefício aparece nas comparações. Duas strings internadas com o mesmo valor são o mesmo objeto, então uma comparação de referência basta para confirmar a igualdade.
Na prática, a comparação de igualdade do .NET já verifica a referência antes de comparar o conteúdo. Dessa forma, strings internadas tendem a sair desse teste mais cedo.
Ainda assim, não troque == por ReferenceEquals no código de negócio. Essa troca só é correta se você tiver certeza de que todas as strings envolvidas passaram pelo string interning.
Limitações e custos
O string interning também cobra seu preço. A documentação de String.Intern aponta dois efeitos colaterais indesejados para quem quer reduzir a memória total da aplicação.
O primeiro efeito é a permanência. A memória das strings internadas dificilmente é liberada antes do encerramento do CLR, já que a referência do runtime pode sobreviver até ao fim da aplicação.
Em outras palavras, o pool só cresce. Se você internar valores que vêm de entrada do usuário sem controle, o consumo de memória aumenta sem limite durante toda a vida do processo.
O segundo efeito é a alocação prévia. Para internar uma string, você precisa primeiro criá-la, então a memória do objeto temporário ainda é alocada, mesmo que o coletor de lixo a recolha depois.
Há ainda o custo da busca. Cada chamada a String.Intern precisa calcular o hash do conteúdo e consultar o pool, o que pode superar o ganho quando o valor raramente se repete.
Por isso, a internação explícita deve ser usada com cautela e sempre medida. Um benchmark simples antes e depois da mudança evita otimizações que pioram o desempenho.
Literais e o heap congelado no .NET 8
O runtime também evoluiu na forma de armazenar literais. A partir do .NET 8, o runtime pode alocar alguns objetos em heaps que não pertencem ao coletor de lixo, também chamados de heaps congelados.
A nota de compatibilidade do .NET 8 cita os literais de string como exemplo desse tipo de alocação. Por isso, GC.GetGeneration pode retornar Int32.MaxValue para um literal, quando antes retornava 2.
// No .NET 8 ou superior, o literal pode estar em um heap fora do GC
int generation = GC.GetGeneration("string");
Console.WriteLine(generation); // Pode exibir 2147483647
A mesma nota avisa que quando e como o runtime usa esses heaps é um detalhe interno de implementação. Portanto, trate essa informação como curiosidade e nunca como contrato.
Se quiser entender melhor as gerações e o funcionamento do coletor, o artigo sobre o garbage collector em C# explica o assunto em detalhes.
Quando usar string interning manual
O string interning manual geralmente é desnecessário na maioria das aplicações. Afinal, os literais já recebem esse tratamento e a maior parte das strings dinâmicas tem vida curta.
Ainda assim, ele pode ser útil em dois cenários principais:
- A aplicação gera dinamicamente muitas strings com conteúdo idêntico.
- Você deseja garantir que essas strings compartilhem a mesma referência de memória para maior eficiência.
Um exemplo típico é a leitura de um arquivo grande em que poucos valores se repetem muitas vezes. Nesse caso, nomes de colunas, códigos de status ou siglas de estado aparecem milhares de vezes com o mesmo texto.
var records = new List<CustomerRecord>();
foreach (string line in File.ReadLines("customers.csv"))
{
string[] parts = line.Split(',');
// O estado se repete muito, então vale compartilhar a referência
string state = string.Intern(parts[2]);
records.Add(new CustomerRecord(parts[0], parts[1], state));
}
public record CustomerRecord(string Id, string Name, string State);
Nesse cenário, milhares de registros passam a compartilhar poucas instâncias para o campo State. Mesmo assim, lembre que essas strings ficam no pool até o fim do processo.
Se os valores vierem de uma fonte sem limite, como texto livre digitado por usuários, prefira não internar. O risco de crescimento infinito do pool supera o ganho.
Alternativas ao string interning global
Quando o pool global não é adequado, você pode criar um pool próprio com escopo controlado. A vantagem é poder descartá-lo quando o processamento terminar.
public sealed class LocalStringPool
{
private readonly HashSet<string> _values = new(StringComparer.Ordinal);
public string GetOrAdd(string value)
{
// Devolve a instância já armazenada, se existir
if (_values.TryGetValue(value, out string? existing))
return existing;
_values.Add(value);
return value;
}
}
O método HashSet<T>.TryGetValue devolve a instância já armazenada no conjunto. Assim, o pool local reaproveita referências enquanto existir e libera tudo quando o coletor recolher o objeto.
A partir do .NET 9, coleções como HashSet<T> e Dictionary<TKey,TValue> também aceitam buscas com ReadOnlySpan<char> por meio de GetAlternateLookup. Com isso, é possível consultar o pool sem criar a string temporária antes.
Outra opção pronta é o StringPool do pacote CommunityToolkit.HighPerformance. A documentação do StringPool o descreve como um mecanismo parecido com o string interning, porém configurável, com política de melhor esforço e possibilidade de reset.
using CommunityToolkit.HighPerformance.Buffers;
ReadOnlySpan<char> host = "https://balta.io/cursos".AsSpan(8, 8);
// Busca pelo conteúdo do span e só aloca se o valor ainda não estiver no pool
string cachedHost = StringPool.Shared.GetOrAdd(host);
Console.WriteLine(cachedHost); // balta.io
O ponto forte do StringPool é aceitar um ReadOnlySpan<char> como entrada. Dessa forma, ele evita a alocação inicial quando o valor já está em cache, algo que String.Intern não consegue fazer.
Além disso, o pool descarta os valores menos usados quando atinge a capacidade máxima. Isso resolve o principal problema do string interning global, que é o crescimento sem limite.
Conclusão
O string interning é uma otimização elegante do CLR que reaproveita instâncias de strings repetidas. Ele funciona de forma segura graças à imutabilidade das strings no .NET.
Literais costumam entrar no pool automaticamente, embora a documentação não garanta esse comportamento em todos os cenários. Já strings criadas em tempo de execução só entram quando você chama String.Intern.
Use String.IsInterned para verificar se um valor já está no pool e ReferenceEquals apenas para estudar o comportamento. Na lógica de negócio, continue comparando strings por valor.
Por fim, trate a internação manual como uma ferramenta de nicho. Para cenários de alto volume com valores imprevisíveis, um pool local ou o StringPool oferecem os mesmos ganhos com muito mais controle.
FAQ
Todas as strings do .NET passam por string interning?
Não. O CLR adiciona ao pool apenas literais, e strings criadas em tempo de execução só entram quando você chama String.Intern.
Qual a diferença entre String.Intern e String.IsInterned?
O String.Intern adiciona o valor ao pool quando ele não existe e devolve a referência internada. Já o String.IsInterned apenas consulta o pool e devolve null quando não encontra o valor.
Strings internadas são liberadas pelo garbage collector?
Dificilmente. Segundo a documentação, a memória das strings internadas tende a permanecer até o encerramento do CLR.
Posso comparar strings com ReferenceEquals para ganhar desempenho?
Só quando você tem certeza de que todas as strings envolvidas passaram pelo interning. Em código comum, use == ou string.Equals, que já verificam a referência antes do conteúdo.
O string interning funciona com Native AOT?
Funciona com restrições. No Native AOT, a internação de literais não é garantida e desligar NoStringInterning não tem suporte.
Existe alternativa ao intern pool global?
Sim. Você pode usar um pool local baseado em HashSet<string> ou o StringPool do CommunityToolkit.HighPerformance, que aceita spans e descarta valores pouco usados.