Há um equívoco popular de que se você não gostar da coleta de lixo, precisará escrever não em Java, mas em C / C ++. Nos últimos três anos, escrevi código Java de baixa latência para negociação de moedas e tive que evitar a criação de objetos desnecessários de todas as formas. Como resultado, eu formulei algumas regras simples para mim, como reduzir alocações em Java, se não para zero, e depois para um mínimo razoável, sem recorrer ao gerenciamento manual de memória. Talvez também seja útil para alguém da comunidade.
Por que evitar o lixo
Sobre o que é o GC e como configurá-lo, foi dito e escrito muito. Mas, em última análise, não importa como você configure o GC, o código dessa maca funcionará abaixo do ideal. Sempre há uma troca entre taxa de transferência e latência. Torna-se impossível melhorar um sem piorar o outro. Como regra geral, a sobrecarga do GC é medida estudando os logs - você pode entender deles em que momentos houve pausas e quanto tempo levaram. No entanto, os logs do GC não contêm todas as informações sobre essa sobrecarga. O objeto criado pelo encadeamento é automaticamente colocado no cache L1 do núcleo do processador no qual o encadeamento está sendo executado. Isso leva à exclusão de outros dados potencialmente úteis. Com um grande número de alocações, dados úteis também podem ser enviados para fora do cache L3. Na próxima vez em que o encadeamento acessar esses dados, o cache de erros ocorrerá, o que levará a atrasos na execução do programa. Além disso, como o cache L3 é comum a todos os núcleos do mesmo processador, um fluxo de lixo enviará dados e outros threads / aplicativos a partir do cache L3, e eles já encontrarão falhas de cache extremamente caras, mesmo que sejam escritas em C simples e não crie lixo. Nenhuma configuração, nenhum coletor de lixo (nem o C4 nem o ZGC) ajudará a lidar com esse problema. A única maneira de melhorar a situação como um todo não é criar objetos desnecessários desnecessariamente. O Java, diferentemente do C ++, não possui um rico arsenal de mecanismos para trabalhar com memória, mas, no entanto, existem várias maneiras de minimizar as alocações. Eles serão discutidos.
Digressão líricaObviamente, você não precisa escrever todo o código sem lixo. A questão da linguagem Java é que você pode simplificar bastante sua vida removendo apenas as principais fontes de lixo. Você também não pode lidar com a recuperação de memória segura ao escrever algoritmos sem bloqueio. Se algum código for executado apenas uma vez na inicialização do aplicativo, ele poderá alocar o quanto você quiser e não será assustador. Bem, é claro, a principal ferramenta de trabalho para se livrar do excesso de lixo é o perfilador de alocação.
Usando tipos primitivos
A coisa mais simples que pode ser feita em muitos casos é usar tipos primitivos em vez de tipos de objetos. A JVM possui diversas otimizações para minimizar a sobrecarga de tipos de objetos, como armazenar em cache pequenos valores de tipos inteiros e incluir classes simples. Mas nem sempre vale a pena confiar nessas otimizações, porque elas podem não funcionar: um valor inteiro pode não ser armazenado em cache e o inlining pode não ocorrer. Além disso, ao trabalhar com um inteiro condicional, somos forçados a seguir o link, o que potencialmente leva a uma falha no cache. Além disso, todos os objetos têm cabeçalhos que ocupam espaço extra no cache, eliminando outros dados a partir daí. Vamos assumir: um int primitivo leva 4 bytes. O Integer
objeto ocupa 16 bytes + o tamanho do link para esse número inteiro é de no mínimo 4 bytes (no caso de oops compactado). No total, verifica-se que Integer
ocupa cinco (!) Vezes mais espaço que int
. Portanto, é melhor usar os tipos primitivos por conta própria. Vou dar alguns exemplos.
Exemplo 1. Cálculos convencionais
Digamos que temos uma função regular que conta apenas algo.
Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; }
É provável que esse código fique embutido (o método e as classes) e não levará a alocações desnecessárias, mas você não pode ter certeza disso. Mesmo se isso acontecer, haverá um problema com o fato de uma NullPointerException
poder sair daqui. De uma forma ou de outra, a JVM precisará inserir verificações null
sob o capô ou entender de alguma forma a partir do contexto que null
não pode vir como argumento. De qualquer forma, é melhor escrever o mesmo código nas primitivas.
int getValue(int a, int b, int c) { return (a + b) / c; }
Exemplo 2. Lambdas
Às vezes, os objetos são criados sem o nosso conhecimento. Por exemplo, se passarmos tipos primitivos para onde os tipos de objetos são esperados. Isso geralmente acontece ao usar expressões lambda.
Imagine que temos este código:
void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); }
Apesar de a variável x ser uma primitiva, será criado um objeto do tipo Inteiro, que será passado para a calculadora. Para evitar isso, use IntConsumer
vez de Consumer<Integer>
:
void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); }
Esse código não levará mais à criação de um objeto extra. O Java.util.function possui todo um conjunto de interfaces padrão adaptadas para o uso de tipos primitivos: DoubleSupplier
, LongFunction
, etc. Bem, se algo estiver faltando, você sempre poderá adicionar a interface desejada com as primitivas. Por exemplo, em vez de BiConsumer<Integer, Double>
você pode usar uma interface caseira.
interface IntDoubleConsumer { void accept(int x, double y); }
Exemplo 3. Coleções
O uso de um tipo primitivo pode ser difícil porque uma variável desse tipo está em uma coleção. Suponha que tenhamos alguma List<Integer>
e queremos descobrir quais números estão nela e calcular quantas vezes cada um dos números é repetido. Para isso, usamos o HashMap<Integer, Integer>
. O código fica assim:
List<Integer> numbers = new ArrayList<>();
Este código está incorreto de várias maneiras ao mesmo tempo. Primeiro, ele usa uma estrutura de dados intermediária, o que provavelmente poderia ser feito sem. Bem, ok, para simplificar, assumimos que essa lista será necessária mais tarde, ou seja, você não pode removê-lo completamente. Em segundo lugar, o objeto Integer
usado nos dois lugares, em vez do primitivo int
. Em terceiro lugar, existem muitas alocações no método de compute
. Quarto, um iterador é alocado. É provável que essa alocação se torne inline, mas mesmo assim. Como transformar esse código em código sem lixo? Você só precisa usar a coleção nas primitivas de alguma biblioteca de terceiros. Há várias bibliotecas que contêm essas coleções. O seguinte pedaço de código usa a biblioteca agrona .
IntArrayList numbers = new IntArrayList();
Os objetos que são criados aqui são duas coleções e dois int[]
, localizados dentro dessas coleções. Ambas as coleções podem ser reutilizadas chamando o método clear()
nelas. Usando coleções em primitivas, não complicamos nosso código (e até o simplificamos removendo o método de computação com um lambda complexo dentro dele) e recebemos os seguintes bônus adicionais em comparação ao uso de coleções padrão:
- Ausência quase completa de alocações. Se as coleções forem reutilizadas, não haverá alocações.
- Economia significativa de memória (o
IntArrayList
ocupa cerca de cinco vezes menos espaço que o ArrayList<Integer>
. Como já mencionado, nos preocupamos com o uso econômico de caches de processador, não de RAM. - Acesso serial à memória. Muito já foi escrito sobre o assunto por que isso é importante, então não vou parar por aí. Aqui estão alguns artigos: Martin Thompson e Ulrich Drepper .
Outro pequeno comentário sobre coleções. Pode ser que a coleção contenha valores de tipos diferentes e, portanto, não é possível substituí-la por uma coleção por primitivas. Na minha opinião, isso é um sinal de projeto inadequado da estrutura de dados ou do algoritmo como um todo. Provavelmente, nesse caso, a alocação de objetos extras não é o principal problema.
Objetos mutáveis
Mas e se os primitivos não puderem ser dispensados? Por exemplo, no caso em que o método que precisamos deve retornar vários valores. A resposta é simples - use objetos mutáveis.
Pequena digressãoAlgumas línguas enfatizam o uso de objetos imutáveis, por exemplo, no Scala. O principal argumento a seu favor é que escrever código multithread é bastante simplificado. No entanto, também existem despesas gerais associadas à alocação excessiva de lixo. Se queremos evitá-los, não devemos criar objetos imutáveis de vida curta.
Como é na prática? Suponha que precisamos calcular o quociente e o restante da divisão. E para isso, usamos o seguinte código.
class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; }
Como alguém pode se livrar da alocação neste caso? É isso mesmo, passe o IntPair
como argumento e escreva o resultado lá. Nesse caso, você precisa escrever um javadoc detalhado e, melhor ainda, usar algum tipo de convenção para nomes de variáveis, onde o resultado é gravado. Por exemplo, eles podem começar com o prefixo out. O código sem lixo nesse caso terá a seguinte aparência:
void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; }
Quero observar que o método de divide
não deve salvar um link para parear em qualquer lugar ou passá-lo a métodos que possam fazer isso; caso contrário, podemos ter grandes problemas. Como podemos ver, objetos mutáveis são mais difíceis de usar do que tipos primitivos; portanto, se você pode usar primitivas, é melhor fazê-lo. De fato, em nosso exemplo, transferimos o problema de alocar de dentro do método de divisão para o exterior. Em todos os lugares em que chamamos esse método, precisaremos ter algum manequim IntPair
, que passaremos para divide
. Frequentemente, o suficiente para armazenar esse manequim no campo final
do objeto, de onde chamamos o método de divide
. Deixe-me dar um exemplo improvável: suponha que nosso programa lida apenas com o recebimento de um fluxo de números pela rede, os divide e envia o resultado para o mesmo soquete.
class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } }
Por uma questão de brevidade, não escrevi código “extra” para tratamento de erros, encerramento correto do programa etc. A idéia principal desse trecho de código é que o objeto IntPair
que IntPair
seja criado uma vez e armazenado no campo final
.
Conjuntos de objetos
Quando usamos objetos mutáveis, primeiro precisamos pegar um objeto vazio de algum lugar, depois gravar os dados que precisamos nele, usá-lo em algum lugar e depois retornar o objeto "no lugar". No exemplo acima, o objeto estava sempre "no lugar", ou seja, no campo final
. Infelizmente, isso nem sempre é possível de forma simples. Por exemplo, podemos não saber com antecedência exatamente quantos objetos precisamos. Nesse caso, os pools de objetos vêm em nosso auxílio. Quando precisamos de um objeto vazio, o obtemos do pool de objetos e, quando deixa de ser necessário, retornamos para ele. Se não houver objeto livre no pool, ele cria um novo objeto. Na verdade, trata-se de um gerenciamento manual de memória com todas as conseqüências resultantes. É aconselhável não recorrer a esse método se for possível usar os métodos anteriores. O que poderia dar errado?
- Podemos esquecer de retornar o objeto ao pool e, em seguida, o lixo ("vazamento de memória") será criado. Esse é um pequeno problema - o desempenho diminuirá levemente, mas o GC funcionará e o programa continuará funcionando.
- Podemos devolver o objeto ao pool, mas salvar o link em algum lugar. Depois, alguém obterá o objeto do pool e, neste ponto do nosso programa, já haverá dois links para o mesmo objeto. Esse é um problema clássico de uso após livre. É difícil estrear porque ao contrário do C ++, o programa não falha e continua a funcionar incorretamente .
Para reduzir a probabilidade de cometer os erros acima, você pode usar a construção padrão try-with-resources. Pode ser assim:
public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } }
O método de divisão pode ficar assim:
IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; }
E o método listenSocket
assim:
void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } }
No IDE, geralmente você pode configurar o destaque de todos os casos quando objetos AutoCloseable
são usados fora do bloco try-with-resources. Mas essa não é uma opção absoluta, porque o destaque no IDE pode ser desativado. Portanto, existe outra maneira de garantir o retorno do objeto à piscina - inversão de controle. Vou dar um exemplo:
class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } }
Nesse caso, basicamente não podemos acessar o objeto da classe IntPair
fora. Infelizmente, esse método também nem sempre funciona. Por exemplo, ele não funcionará se um thread obtiver objetos do pool e colocá-los em uma fila, e outro thread os removerá da fila e retornará ao pool.
Obviamente, se não armazenarmos objetos genéricos no pool, mas alguns objetos de biblioteca que não implementam o AutoCloseable
, a opção try-with-resources também não funcionará.
Um problema adicional aqui é multithreading. A implementação do pool de objetos deve ser muito rápida, o que é bastante difícil de alcançar. Um pool lento pode causar mais danos ao desempenho do que benefícios. Por sua vez, a alocação de novos objetos no TLAB é muito rápida, muito mais rápida que o malloc no C. Escrever um pool de objetos rápido é um tópico separado que eu não gostaria de desenvolver agora. Só posso dizer que não vi boas implementações "prontas".
Em vez de uma conclusão
Em resumo, a reutilização de objetos com pools de objetos é uma hemorróida grave. Felizmente, quase sempre você pode ficar sem ele. Minha experiência pessoal é que o uso excessivo de pools de objetos sinaliza problemas com a arquitetura do aplicativo. Como regra, uma instância do objeto armazenado em cache no campo final
é suficiente para nós. Mas mesmo isso é exagero se for possível usar tipos primitivos.
Atualização:
Sim, lembrei-me de outra maneira para aqueles que não têm medo de mudanças bit a bit: agrupar vários pequenos tipos primitivos em um grande. Suponha que precisamos retornar duas int
. Nesse caso específico, você não pode usar o objeto IntPair
, mas retornar um long
, os primeiros 4 bytes nos quais corresponderá ao primeiro int
'y, e os segundos 4 bytes ao segundo. O código pode ficar assim:
long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } }
É claro que esses métodos precisam ser testados minuciosamente, porque é muito fácil anotá-los. Mas então apenas use-o.