Comment ne pas jeter dans Java

Il existe une idée fausse très répandue selon laquelle si vous n'aimez pas la récupération de place, vous devez écrire non pas en Java, mais en C / C ++. Depuis trois ans, j'écris du code Java à faible latence pour le trading de devises, et j'ai dû éviter de créer des objets inutiles à tous points de vue. En conséquence, j'ai formulé quelques règles simples pour moi-même, comment réduire les allocations en Java, sinon à zéro, puis à un minimum raisonnable, sans recourir à la gestion manuelle de la mémoire. Ce sera peut-être aussi utile à quelqu'un de la communauté.


Pourquoi éviter les ordures du tout


A propos de ce qu'est GC et comment les configurer, il a été dit et écrit beaucoup. Mais en fin de compte, peu importe la façon dont vous configurez le GC, le code de cette litière ne fonctionnera pas de manière optimale. Il y a toujours un compromis entre le débit et la latence. Il devient impossible d'améliorer l'un sans aggraver l'autre. En règle générale, les frais généraux du GC sont mesurés en étudiant les journaux - vous pouvez comprendre d'eux à quels moments il y a eu des pauses et combien de temps ils ont pris. Cependant, les journaux GC ne contiennent pas toutes les informations sur cette surcharge. L'objet créé par le thread est automatiquement placé dans le cache L1 du cœur de processeur sur lequel le thread s'exécute. Cela conduit à l'éviction d'autres données potentiellement utiles. Avec un grand nombre d'allocations, les données utiles peuvent également être extraites du cache L3. La prochaine fois que le thread accédera à ces données, un cache manquant se produira, ce qui entraînera des retards dans l'exécution du programme. De plus, comme le cache L3 est commun à tous les cœurs du même processeur, un flux de déchets poussera les données et autres threads / applications du cache L3, et ils rencontreront déjà des échecs de cache supplémentaires, même s'ils sont écrits en C nu et ne créez pas de déchets. Aucun paramètre, aucun récupérateur de place (ni C4, ni ZGC) n'aidera à résoudre ce problème. La seule façon d'améliorer la situation dans son ensemble est de ne pas créer inutilement des objets inutiles. Java, contrairement à C ++, ne dispose pas d'un arsenal riche de mécanismes pour travailler avec la mémoire, mais il existe néanmoins un certain nombre de façons de minimiser les allocations. Ils seront discutés.


Digression lyrique

Bien sûr, vous n'avez pas besoin d'écrire tout le code sans déchets. Le truc avec le langage Java, c'est que vous pouvez grandement vous simplifier la vie en supprimant uniquement les principales sources de déchets. Vous ne pouvez pas non plus gérer la récupération de mémoire en toute sécurité lors de l'écriture d'algorithmes sans verrouillage. Si un certain code n'est exécuté qu'une seule fois au démarrage de l'application, il peut en allouer autant que vous le souhaitez, et ce n'est pas grave. Eh bien, bien sûr, le principal outil de travail pour se débarrasser des déchets en excès est le profileur d'allocation.


Utilisation de types primitifs


La chose la plus simple qui peut être faite dans de nombreux cas est d'utiliser des types primitifs au lieu de types d'objets. La machine virtuelle Java dispose d'un certain nombre d'optimisations pour minimiser la surcharge des types d'objet, comme la mise en cache de petites valeurs de types entiers et l'inclusion de classes simples. Mais ces optimisations ne valent pas toujours la peine d'être utilisées, car elles peuvent ne pas fonctionner: une valeur entière peut ne pas être mise en cache et l'inlining peut ne pas se produire. De plus, lorsque nous travaillons avec un entier conditionnel, nous sommes obligés de suivre le lien, ce qui conduit potentiellement à un échec de cache. De plus, tous les objets ont des en-têtes qui occupent de l'espace supplémentaire dans le cache, évinçant ainsi d'autres données. Prenons-le: un primitif int prend 4 octets. L'objet Integer prend 16 octets + la taille du lien vers cet Integer est de 4 octets minimum (dans le cas de oops compressés). Au total, il s'avère que Integer occupe cinq (!) Fois plus d'espace int . Par conséquent, il est préférable d'utiliser vous-même les types primitifs. Je vais donner quelques exemples.


Exemple 1. Calculs conventionnels


Disons que nous avons une fonction régulière qui compte juste quelque chose.


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

Un tel code est susceptible de devenir en ligne (à la fois la méthode et les classes) et ne conduira pas à des allocations inutiles, mais vous ne pouvez pas en être sûr. Même si cela se produit, il y aura un problème avec le fait qu'une NullPointerException pourrait voler d'ici. D'une manière ou d'une autre, la JVM devra soit insérer null vérifications null sous le capot, soit comprendre d'une manière ou d'une autre du contexte que null ne peut pas être un argument. Quoi qu'il en soit, il vaut mieux simplement écrire le même code sur les primitives.


 int getValue(int a, int b, int c) { return (a + b) / c; } 

Exemple 2. Lambdas


Parfois, des objets sont créés à notre insu. Par exemple, si nous transmettons des types primitifs à l'endroit où les types d'objets sont attendus. Cela se produit souvent lors de l'utilisation d'expressions lambda.
Imaginez que nous ayons ce code:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Malgré le fait que la variable x soit une primitive, un objet de type Integer sera créé, qui sera transmis à la calculatrice. Pour éviter cela, utilisez IntConsumer au lieu de Consumer<Integer> :


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Un tel code ne conduira plus à la création d'un objet supplémentaire. Java.util.function dispose d'un ensemble complet d'interfaces standard adaptées à l'utilisation de types primitifs: DoubleSupplier , LongFunction , etc. Eh bien, si quelque chose manque, vous pouvez toujours ajouter l'interface souhaitée avec des primitives. Par exemple, au lieu de BiConsumer<Integer, Double> vous pouvez utiliser une interface maison.


 interface IntDoubleConsumer { void accept(int x, double y); } 

Exemple 3. Collections


L'utilisation d'un type primitif peut être difficile car une variable de ce type se trouve dans une collection. Supposons que nous ayons une List<Integer> et que nous voulons savoir quels nombres s'y trouvent et calculer combien de fois chacun des nombres est répété. Pour cela, nous utilisons HashMap<Integer, Integer> . Le code ressemble à ceci:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

Ce code est mauvais de plusieurs façons à la fois. Premièrement, il utilise une structure de données intermédiaire, ce qui pourrait probablement se faire sans. Eh bien, pour plus de simplicité, nous supposons que cette liste sera nécessaire plus tard, c'est-à-dire vous ne pouvez pas le supprimer complètement. Deuxièmement, l'objet Integer utilisé aux deux endroits au lieu de primitive int . Troisièmement, il existe de nombreuses allocations dans la méthode de compute . Quatrièmement, un itérateur est attribué. Cette allocation est susceptible de devenir en ligne, mais néanmoins. Comment transformer ce code en code sans ordures? Vous avez juste besoin d'utiliser la collection sur les primitives d'une bibliothèque tierce. Il existe un certain nombre de bibliothèques contenant de telles collections. Le morceau de code suivant utilise la bibliothèque agrona .


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

Les objets qui sont créés ici sont deux collections et deux int[] , qui sont situés à l'intérieur de ces collections. Les deux collections peuvent être réutilisées en appelant la méthode clear() sur elles. En utilisant des collections sur des primitives, nous n'avons pas compliqué notre code (et nous l'avons même simplifié en supprimant la méthode de calcul avec un lambda complexe à l'intérieur) et avons reçu les bonus supplémentaires suivants par rapport à l'utilisation de collections standard:


  1. Absence presque totale d'allocations. Si les collections sont réutilisées, il n'y aura aucune allocation du tout.
  2. IntArrayList mémoire importantes ( IntArrayList prend environ cinq fois moins d'espace que ArrayList<Integer> . Comme déjà mentionné, nous nous soucions de l'utilisation économique des caches de processeur, pas de RAM.
  3. Accès série à la mémoire. Beaucoup de choses ont été écrites sur la raison pour laquelle cela est important, donc je ne m'arrêterai pas là. Voici quelques articles: Martin Thompson et Ulrich Drepper .

Un autre petit commentaire sur les collections. Il peut s'avérer que la collection contient des valeurs de différents types et qu'il n'est donc pas possible de la remplacer par une collection avec des primitives. À mon avis, c'est un signe de mauvaise conception de la structure des données ou de l'algorithme dans son ensemble. Dans ce cas, très probablement, l'allocation d'objets supplémentaires n'est pas le problème principal.


Objets mutables


Mais que faire si les primitives ne peuvent pas être supprimées? Par exemple, dans le cas où la méthode dont nous avons besoin devrait renvoyer plusieurs valeurs. La réponse est simple: utilisez des objets mutables.


Petite digression

Certaines langues mettent l'accent sur l'utilisation d'objets immuables, par exemple en Scala. L'argument principal en leur faveur est que l'écriture de code multithread est grandement simplifiée. Cependant, il existe également des frais généraux associés à une allocation excessive des ordures. Si nous voulons les éviter, nous ne devons pas créer d’objets immuables de courte durée.


À quoi cela ressemble-t-il dans la pratique? Supposons que nous devions calculer le quotient et le reste de la division. Et pour cela, nous utilisons le code suivant.


 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; } 

Comment se débarrasser de l'allocation dans ce cas? C'est vrai, passez IntPair comme argument et écrivez-y le résultat. Dans ce cas, vous devez écrire un javadoc détaillé et, mieux encore, utiliser une sorte de convention pour les noms de variable, où le résultat est écrit. Par exemple, ils peuvent commencer par le préfixe out. Le code sans déchets dans ce cas ressemblera à ceci:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

Je tiens à noter que la méthode de divide ne doit pas enregistrer un lien à associer n'importe où ni le transmettre à des méthodes qui peuvent le faire, sinon nous pourrions avoir de gros problèmes. Comme nous pouvons le voir, les objets mutables sont plus difficiles à utiliser que les types primitifs, donc si vous pouvez utiliser des primitives, il vaut mieux le faire. En fait, dans notre exemple, nous avons transféré le problème de l'allocation de l'intérieur de la méthode de division vers l'extérieur. Dans tous les endroits où nous appelons cette méthode, nous aurons besoin d'un mannequin IntPair , que nous passerons à divide . Assez souvent pour stocker ce mannequin dans le final champ de l'objet, d'où nous appelons la méthode de divide . Permettez-moi de vous donner un exemple artificiel: supposons que notre programme ne traite que de la réception d'un flux de nombres sur le réseau, les divise et envoie le résultat à la même socket.


 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); } } } 

Par souci de concision, je n'ai pas écrit de code «supplémentaire» pour la gestion des erreurs, la terminaison correcte du programme, etc. L'idée principale de ce morceau de code est que l'objet IntPair nous IntPair est créé une fois et stocké dans le champ final .


Pools d'objets


Lorsque nous utilisons des objets mutables, nous devons d'abord prendre un objet vide de quelque part, puis y écrire les données dont nous avons besoin, l'utiliser quelque part, puis retourner l'objet «en place». Dans l'exemple ci-dessus, l'objet était toujours «en place», c'est-à-dire dans le final champ. Malheureusement, ce n'est pas toujours possible de le faire de manière simple. Par exemple, nous pouvons ne pas savoir à l'avance exactement combien d'objets nous avons besoin. Dans ce cas, les pools d'objets viennent à notre aide. Lorsque nous avons besoin d'un objet vide, nous l'obtenons à partir du pool d'objets, et lorsqu'il cesse d'être nécessaire, nous le renvoyons là. S'il n'y a pas d'objet libre dans le pool, le pool crée un nouvel objet. Il s'agit en fait d'une gestion manuelle de la mémoire avec toutes les conséquences qui en découlent. Il est conseillé de ne pas recourir à cette méthode s'il est possible d'utiliser les méthodes précédentes. Qu'est-ce qui pourrait mal tourner?


  • Nous pouvons oublier de renvoyer l'objet dans le pool, puis des ordures ("fuite de mémoire") seront créées. Il s'agit d'un petit problème - les performances diminueront légèrement, mais le GC fonctionnera et le programme continuera de fonctionner.
  • Nous pouvons renvoyer l'objet dans le pool, mais enregistrer le lien vers celui-ci quelque part. Ensuite, quelqu'un d'autre récupérera l'objet du pool, et à ce stade de notre programme, il y aura déjà deux liens vers le même objet. Il s'agit d'un problème classique d'utilisation après libération. C'est difficile de faire ses débuts parce que contrairement à C ++, le programme ne se bloquera pas et continuera à fonctionner de manière incorrecte .

Afin de réduire la probabilité de faire les erreurs ci-dessus, vous pouvez utiliser la construction standard try-with-resources. Cela peut ressembler à ceci:


 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); } } 

La méthode de division pourrait ressembler à ceci:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

Et la méthode listenSocket comme ceci:


 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); } } } 

Dans l'EDI, vous pouvez généralement configurer la mise en surbrillance de tous les cas lorsque des objets à fermeture AutoCloseable sont utilisés en dehors du bloc try-with-resources. Mais ce n'est pas une option absolue, car la mise en surbrillance dans l'IDE peut simplement être désactivée. Par conséquent, il existe un autre moyen de garantir le retour de l'objet à l'inversion de contrôle de pool. Je vais donner un exemple:


 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); } } 

Dans ce cas, nous ne pouvons fondamentalement pas accéder à l'objet de la classe IntPair extérieur. Malheureusement, cette méthode ne fonctionne pas toujours. Par exemple, cela ne fonctionnera pas si un thread obtient des objets du pool et les place dans une file d'attente, et qu'un autre thread les sort de la file d'attente et revient dans le pool.


Évidemment, si nous ne stockons pas d'objets génériques dans le pool, mais certains objets de bibliothèque qui AutoCloseable pas AutoCloseable , alors l'option essayer avec des ressources ne fonctionnera pas non plus.


Un problème supplémentaire ici est le multithreading. L'implémentation du pool d'objets doit être très rapide, ce qui est assez difficile à réaliser. Un pool lent peut faire plus de mal que de bien aux performances. À son tour, l'allocation de nouveaux objets dans TLAB est très rapide, beaucoup plus rapide que malloc en C. L'écriture d'un pool d'objets rapide est un sujet distinct que je ne voudrais pas développer maintenant. Je peux seulement dire que je n'ai vu aucune bonne implémentation «prête à l'emploi».


Au lieu d'une conclusion


En bref, la réutilisation d'objets avec des pools d'objets est une grave hémorroïde. Heureusement, vous pouvez presque toujours vous en passer. Mon expérience personnelle est que l'utilisation excessive des pools d'objets signale des problèmes avec l'architecture de l'application. En règle générale, une instance de l'objet mis en cache dans le champ final nous suffit. Mais même cela est exagéré s'il est possible d'utiliser des types primitifs.


Mise à jour:


Oui, je me suis souvenu d'une autre façon pour ceux qui n'ont pas peur des changements au niveau du bit: regrouper plusieurs petits types primitifs en un seul grand. Supposons que nous devions retourner deux int . Dans ce cas particulier, vous ne pouvez pas utiliser l'objet IntPair , mais renvoyer un long , les 4 premiers octets dans lesquels correspondra le premier int , et les 4 derniers octets au second. Le code pourrait ressembler à ceci:


 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)); } } 

De telles méthodes, bien sûr, doivent être testées à fond, car il est assez facile de les écrire. Mais alors utilisez-le.

Source: https://habr.com/ru/post/fr436024/


All Articles