Points Clés
- Des performances élevées sont cruciales pour l'adoption d'applications et peuvent être affectées négativement par le traitement d'ensembles de données volumineux ou disparates
- Les développeurs Java doivent savoir utiliser les fonctionnalités intégrées telles que les collections pour optimiser le traitement des données et les performances
- Les développeurs doivent tenir compte de la façon dont diverses approches de traitement des streams parallèles peuvent affecter les performances des applications
- L'application de la bonne stratégie de traitement des streams parallèle aux collections peut faire la différence entre une adoption accrue et la perte de clients
Aujourd'hui, la programmation implique de travailler avec de grands ensembles de données, comprenant souvent de nombreux types de données différents. La manipulation de ces ensembles de données peut être une tâche complexe et frustrante. Pour faciliter le travail du programmeur, Java a introduit les collections dans le framework Collections en 1998.
Cet article explique l'objectif du framework Collections, le fonctionnement des collections en Java et la manière dont les développeurs et les programmeurs peuvent tirer le meilleur parti des collections en Java.
Qu'est-ce qu'une collection Java ?
Bien qu'il ait dépassé l'âge vénérable de 25 ans, Java reste aujourd'hui l'un des langages de programmation les plus populaires. Plus d'un million de sites Web utilisent Java sous une forme ou une autre, et plus d'un tiers des développeurs de logiciels ont Java dans leur boîte à outils.
Tout au long de sa vie, Java a subi une évolution substantielle. L'un des premiers progrès a eu lieu en 1998 lorsque Java a introduit le Collection Framework (JCF), qui a simplifié le travail avec les objets Java. Le JCF a fourni une interface standardisée et des méthodes communes pour les collections, a réduit l'effort de programmation et a augmenté la vitesse des programmes Java.
Il est essentiel de comprendre la distinction entre les collections Java et le Java Collections Framework. Les collections Java sont simplement des structures de données représentant un groupe d'objets Java. Les développeurs peuvent travailler avec des collections de la même manière qu'ils travaillent avec d'autres types de données, en effectuant des tâches courantes telles que des recherches ou en manipulant le contenu de la collection.
Un exemple de collection en Java est l'interface Set
(java.util.Set)
. Un Set
est une collection qui n'autorise pas les éléments en double et ne stocke pas les éléments dans un ordre particulier. L'interface Set
hérite ses méthodes de Collection (java.util.Collection)
et ne contient que ces méthodes.
En plus des ensembles, il existe des files d'attente (java.util.Queue
) et des maps (java.util.Map
). Les maps ne sont pas des collections au sens le plus vrai car elles n'étendent pas les interfaces de collection, mais les développeurs peuvent manipuler des Maps
comme s'il s'agissait de collections. Sets
, Queues
, Lists
et Maps
ont chacun des descendants, tels que des ensembles triés (java. util.SortedSet
) et des maps navigables (java.util.NavigableMap
).
Lorsqu'ils travaillent avec des collections, les développeurs doivent connaître et comprendre certains termes spécifiques liés aux collections :
- Modifiable vs non modifiable : comme ces termes le suggèrent à première vue, différentes collections peuvent ou non prendre en charge les opérations de modification
- Mutable ou immuable : les collections immuables ne peuvent pas être modifiées après leur création. Bien qu'il existe des situations où les collections non modifiables peuvent encore changer en raison de l'accès par un autre code, les collections immuables empêchent de telles modifications. Les collections qui peuvent garantir qu'aucune modification n'est visible avec les objets Collection sont immuables, tandis que les collections non modifiables sont des collections qui n'autorisent pas les opérations de modification telles que « add » ou « clear »
- Taille fixe ou taille variable : ces termes se réfèrent uniquement à la taille de la collection et n'indiquent pas si la collection est modifiable ou mutable
- Accès aléatoire ou accès séquentiel : si une collection permet l'indexation d'éléments individuels, il s'agit d'un accès aléatoire. Dans les collections à accès séquentiel, vous devez parcourir tous les éléments précédents pour atteindre un élément donné. Les collections à accès séquentiel peuvent être plus faciles à étendre mais prennent plus de temps à rechercher
Les programmeurs débutants peuvent avoir du mal à saisir la différence entre les collections non modifiables et immuables. Les collections non modifiables ne sont pas nécessairement immuables. En effet, les collections non modifiables sont souvent des enveloppes autour d'une collection modifiable à laquelle un autre code peut toujours accéder et modifier. Un autre code peut en fait être en mesure de modifier la collection sous-jacente. Il faudra un certain temps de travail avec les collections pour acquérir un degré de confort avec des collections non modifiables et immuables.
Par exemple, envisagez de créer une liste modifiable des cinq principales crypto-monnaies par capitalisation boursière. Vous pouvez créer une version non modifiable de la liste modifiable sous-jacente à l'aide de la méthode java.util.Collections.unmodifiableList()
. Vous pouvez toujours modifier la liste sous-jacente, qui apparaîtra dans la liste non modifiable. Mais vous ne pouvez pas modifier directement la version non modifiable.
import java.util.*;
public class UnmodifiableCryptoListExample {
public static void main(String[] args) {
List<String> cryptoList = new ArrayList<>();
Collections.addAll(cryptoList, "BTC", "ETH", "USDT", "USDC", "BNB");
List<String> unmodifiableCryptoList = Collections.unmodifiableList(cryptoList);
System.out.println("Unmodifiable crypto List: " + unmodifiableCryptoList);
// try to add one more cryptocurrency to modifiable list and show in unmodifiable list
cryptoList.add("BUSD");
System.out.println("New unmodifiable crypto List with new element:" + unmodifiableCryptoList);
// try to add one more cryptocurrency to unmodifiable list and show in unmodifiable list - unmodifiableCryptoList.add would throw an uncaught exception and the println would not run.
unmodifiableCryptoList.add("XRP");
System.out.println("New unmodifiable crypto List with new element:" + unmodifiableCryptoList);
}
}
A l'exécution, vous verrez qu'un ajout à la liste modifiable sous-jacente apparaît comme une modification de la liste non modifiable.
Notez la différence, cependant, si vous créez une liste immuable, puis essayez de modifier la liste sous-jacente. Il existe de nombreuses façons de créer des listes immuables à partir de listes modifiables existantes, et ci-dessous, nous utilisons la méthode List.copyOf()
.
import java.util.*;
public class UnmodifiableCryptoListExample {
public static void main(String[] args) {
List<String> cryptoList = new ArrayList<>();
Collections.addAll(cryptoList, "BTC", "ETH", "USDT", "USDC", "BNB");
List immutableCryptoList = List.copyOf(cryptoList);
System.out.println("Underlying crypto list:" + cryptoList)
System.out.println("Immutable crypto ist: " + immutableCryptoList);
// try to add one more cryptocurrency to modifiable list and show immutable does not display change
cryptoList.add("BUSD");
System.out.println("New underlying list:" + cryptoList);
System.out.println("New immutable crypto List:" + immutableCryptoList);
// try to add one more cryptocurrency to unmodifiable list and show in unmodifiable list -
immutableCryptoList.add("XRP");
System.out.println("New unmodifiable crypto List with new element:" + immutableCryptoList);
}
}
Après avoir modifié la liste sous-jacente, la liste immuable n'affiche pas la modification. Et essayer de modifier la liste immuable entraîne directement une UnsupportedOperationException
:
Comment les collections sont-elles liées au Java Collections Framework ?
Avant l'introduction du JCF, les développeurs pouvaient regrouper des objets à l'aide de plusieurs classes spéciales, à savoir les classes Array, Vector et HashTable. Malheureusement, ces classes présentaient des limites importantes. En plus de manquer d'interface commune, ils étaient difficiles à étendre.
Le JCF a fourni une architecture commune globale pour travailler avec les collections. L'interface des collections contient plusieurs composants différents, notamment :
- Interfaces communes : des représentations des principaux types de collections, y compris les ensembles, les listes et les maps
- Implémentations : des implémentations spécifiques des interfaces de collection, allant de l'usage général à l'usage spécial en passant par l'abstrait ; en outre, il existe des implémentations héritées liées aux anciennes classes Array, Vector et HashTable
- Algorithmes : des méthodes statiques pour manipuler les collections
- Infrastructure : la prise en charge sous-jacente des différentes interfaces de collections
Le JCF offrait aux développeurs de nombreux avantages par rapport aux méthodes de regroupement d'objets précédentes. Notamment, le JCF a rendu la programmation Java plus efficace en réduisant la nécessité pour les développeurs d'écrire leurs propres structures de données.
Mais le JCF a également fondamentalement modifié la façon dont les développeurs travaillaient avec les API. Avec un nouveau langage commun pour traiter les différentes API, le JCF a simplifié l'apprentissage et la conception des API et leur mise en œuvre pour les développeurs. De plus, les API sont devenues beaucoup plus interopérables. Un exemple est Eclipse Collections, une bibliothèque de collections Java open source entièrement compatible avec différents types de collections Java.
Des gains d'efficacité de développement supplémentaires sont apparus parce que le JCF a fourni des structures qui ont facilité la réutilisation du code. En conséquence, le temps de développement a diminué et la qualité du programme a augmenté.
Le JCF a une hiérarchie définie d'interfaces. java.util.collection
étend la superinterface Iterable. Dans Collection, il existe de nombreuses interfaces et classes filles, comme indiqué ci-dessous :
Comme indiqué précédemment, les ensembles sont des groupes non ordonnés d'objets uniques. Les listes, en revanche, sont des collections ordonnées qui peuvent contenir des doublons. Bien que vous puissiez ajouter des éléments à n'importe quel endroit d'une liste, le reste de l'ordre est conservé.
Les files d'attente (queues) sont des collections où des éléments sont ajoutés à une extrémité et supprimés à l'autre extrémité, c'est-à-dire qu'il s'agit d'une interface premier entré, premier sorti (FIFO). Les Deques (files d'attente à double extrémité) permettent l'ajout ou la suppression d'éléments à chaque extrémité.
Les méthodes pour utiliser des collections Java
Chaque interface du JCF, y compris java.util.Collection
, possède des méthodes spécifiques disponibles pour accéder et manipuler des éléments individuels de la collection. Parmi les méthodes les plus couramment utilisées dans les collections, citons :
size()
: renvoie le nombre d'éléments dans une collectionadd(Collection element) / remove(Collection object)
: comme suggéré, ces méthodes modifient le contenu d'une collection ; notez que dans le cas où une collection a des doublons, la suppression n'affecte qu'une seule instance de l'élémentequals(Collection object)
: compare un objet pour l'équivalence avec une collectionclear()
: supprime tous les éléments d'une collection
Chaque sous-interface peut également avoir des méthodes supplémentaires. Par exemple, bien que l'interface Set
n'inclue que les méthodes de l'interface Collection
, l'interface List
possède de nombreuses méthodes supplémentaires basées sur l'accès à des éléments de liste spécifiques, y compris :
get(int index)
: renvoie l'élément de la liste à partir de l'emplacement d'index spécifiéset(int index, element)
: définit le contenu de l'élément de liste à l'emplacement d'index spécifiéremove(int,index)
: supprime l'élément à l'emplacement d'index spécifié
Les performances des collections Java
À mesure que la taille des collections augmente, elles peuvent développer des problèmes de performances notables. Et il s'avère que la sélection appropriée des types de collections et la conception des collections associées peuvent également affecter considérablement les performances.
La quantité toujours croissante de données disponibles pour les développeurs et les applications a conduit Java à introduire de nouvelles façons de traiter les collections pour augmenter les performances globales. Dans Java 8, sorti en 2014, Java a introduit les Streams - une nouvelle fonctionnalité dont le but était de simplifier et d'augmenter la vitesse de traitement des objets en masse. Depuis leur introduction, les streams ont eu de nombreuses améliorations.
Il est essentiel de comprendre que les streams ne sont pas eux-mêmes des structures de données. Au lieu de cela, comme l'explique Java, les streams sont des "classes qui prennent en charge les opérations de style fonctionnel sur les flux d'éléments, telles que les transformations map/reduced sur les collections".
Les streams utilisent des pipelines de méthodes pour traiter les données reçues d'une source de données telle qu'une collection. Chaque méthode d'un stream est soit une méthode intermédiaire (méthodes qui renvoient de nouveaux stream pouvant être traités ultérieurement) soit une méthode terminale (après laquelle aucun traitement de stream supplémentaire n'est possible). Les méthodes intermédiaires du pipeline sont lazy ; c'est-à-dire qu'ils ne sont évalués que lorsque cela est nécessaire.
Des options d'exécution parallèle et séquentielles existent pour les streams. Les streams sont séquentiels par défaut.
L'application du traitement parallèle pour améliorer les performances
Le traitement de grandes collections en Java peut être fastidieux. Alors que les Streams simplifiait le traitement des grandes collections et les opérations de codage sur les grandes collections, cela n'était pas toujours une garantie d'amélioration des performances ; en effet, les programmeurs ont souvent constaté que l'utilisation de Streams ralentissait en fait le traitement.
Comme il est bien connu en ce qui concerne les sites Web, en particulier, les utilisateurs n'accorderont que quelques secondes pour les chargements avant de passer à autre chose par frustration. Ainsi, pour offrir la meilleure expérience client possible et maintenir la réputation du développeur pour offrir des produits de qualité, les développeurs doivent réfléchir à la manière d'optimiser le traitement efforts pour de grandes collectes de données. Et bien que le traitement parallèle ne puisse pas garantir des vitesses améliorées, c'est un point de départ prometteur.
Le traitement parallèle, c'est-à-dire diviser la tâche de traitement en petits morceaux et les exécuter simultanément, offre un moyen de réduire la surcharge de traitement lorsqu'il s'agit de grandes collections. Mais même le traitement de stream parallèle peut entraîner une diminution des performances, même s'il est plus simple à coder. Essentiellement, la surcharge associée à la gestion de plusieurs threads peut compenser les avantages de l'exécution de threads en parallèle.
Étant donné que les collections ne sont pas thread-safe, le traitement parallèle peut entraîner des interférences de threads ou des erreurs d'incohérence de la mémoire (lorsque les threads parallèles ne voient pas les modifications apportées aux autres threads et ont donc des vues différentes des mêmes données). L'infrastructure de collections tente d'empêcher les incohérences de threads lors du traitement parallèle à l'aide de wrappers de synchronisation. Bien que le wrapper puisse rendre une collection thread-safe, permettant un traitement parallèle plus efficace, il peut avoir des effets indésirables. Plus précisément, la synchronisation peut provoquer des conflits de threads, ce qui peut entraîner une exécution plus lente ou un arrêt de l'exécution des threads.
Java dispose d'une fonction de traitement parallèle native pour les collections : Collection.parallelstream
. Une différence significative entre le traitement de stream séquentiel par défaut et le traitement parallèle est que l'ordre d'exécution et de sortie, qui est toujours le même lors du traitement séquentiel, peut varier d'une exécution à l'autre lors de l'utilisation du traitement parallèle.
Par conséquent, le traitement parallèle est particulièrement efficace dans les situations où l'ordre de traitement n'affecte pas la sortie finale. Cependant, dans les situations où l'état d'un thread peut affecter l'état d'un autre, le traitement parallèle peut créer des problèmes.
Prenons un exemple simple où nous créons une liste de comptes clients courants pour une liste de 1000 clients. Nous voulons déterminer combien de ces clients ont des créances supérieures à 25 000 $. Nous pouvons effectuer cette vérification séquentiellement ou en parallèle avec des vitesses de traitement différentes.
Pour configurer l'exemple pour le traitement parallèle, nous utiliserons le code ci-dessous
import java.util.Random;
import java.util.ArrayList;
import java.util.List;
class Customer {
static int customernumber;
static int receivables;
Customer(int customernumber, int receivables) {
this.customernumber = customernumber;
this.receivables = receivables;
}
public int getCustomernumber() {
return customernumber;
}
public void setCustomernumber(int customernumber) {
this.customernumber = customernumber;
}
public int getReceivables() {
return receivables;
}
public void setReceivables() {
this.receivables = receivables;
}
}
public class ParallelStreamTest {
public static void main( String args[] ) {
Random receivable = new Random();
int upperbound = 1000000;
List < Customer > custlist = new ArrayList < Customer > ();
for (int i = 0; i < upperbound; i++) {
int custnumber = i + 1;
int custreceivable = receivable.nextInt(upperbound);
custlist.add(new Customer(custnumber, custreceivable));
}
long t1 = System.currentTimeMillis();
System.out.println("Sequential Stream count: " + custlist.stream().filter(c ->
c.getReceivables() > 25000).count());
long t2 = System.currentTimeMillis();
System.out.println("Sequential Stream Time taken:" + (t2 - t1));
t1 = System.currentTimeMillis();
System.out.println("Parallel Stream count: " + custlist.parallelStream().filter(c ->
c.getReceivables() > 25000).count());
t2 = System.currentTimeMillis();
System.out.println("Parallel Stream Time taken:" + (t2 - t1));
}
}
L'exécution de code démontre que le traitement parallèle peut entraîner des améliorations de performances lors du traitement des données de la collection :
Notez cependant qu'à chaque fois que vous exécuterez le code, vous obtiendrez des résultats différents. Dans certains cas, le traitement séquentiel surpassera toujours le traitement parallèle.
Dans cet exemple, nous avons utilisé les processus natifs de Java pour diviser les données et attribuer des threads.
Malheureusement, les efforts de traitement parallèle natif de Java ne sont pas toujours plus rapides dans toutes les situations que le traitement séquentiel, et en effet, ils sont souvent plus lents.
Par exemple, le traitement parallèle n'est pas utile lorsqu'il s'agit de listes chaînées. Alors que les sources de données comme ArrayLists
sont simples à diviser pour un traitement parallèle, il n'en va pas de même pour les LinkedLists
. Les TreeMaps
et HashSets
se situent quelque part entre les deux.
Le modèle NQ d'Oracle est une méthode permettant de décider d'utiliser ou non le traitement parallèle. Dans le modèle NQ, N représente le nombre d'éléments de données à traiter. Q, à son tour, est la quantité de calculs requis par élément de données. Dans le modèle NQ, vous calculez le produit de N et Q, avec des nombres plus élevés indiquant des possibilités plus élevées que le traitement parallèle conduise à des améliorations de performances.
Lors de l'utilisation du modèle NQ, il existe une relation inverse entre N et Q. Autrement dit, plus la quantité de calcul requise par élément est élevée, plus l'ensemble de données peut être petit pour que le traitement parallèle ait des avantages. En règle générale, pour les faibles exigences de calcul, un ensemble de données minimum de 10 000 est la base de référence pour l'utilisation du traitement parallèle.
Bien que cela sorte du cadre de cet article, il existe des méthodes plus avancées pour optimiser le traitement parallèle dans les collections Java. Par exemple, les développeurs avancés peuvent ajuster le partitionnement des éléments de données dans la collection pour optimiser les performances de traitement parallèle. Il existe également des compléments et remplacements tiers pour le JCF qui peuvent améliorer les performances. Cependant, les développeurs débutants et intermédiaires doivent se concentrer sur la compréhension des opérations qui bénéficieront des fonctionnalités de traitement parallèle natives de Java pour les collectes de données.
Conclusion
Dans un monde de mégadonnées, trouver des moyens d'améliorer le traitement de grandes collections de données est indispensable pour créer des pages Web et des applications performantes. Java fournit des fonctionnalités de traitement de collection intégrées qui aident les développeurs à améliorer le traitement des données, y compris le framework collections et les fonctions de traitement parallèle natives. Les développeurs doivent se familiariser avec l'utilisation de ces fonctionnalités et comprendre quand les fonctionnalités natives sont acceptables et quand elles doivent passer au traitement parallèle.