[{"content":"Dans un service Spring Boot rangé par couches techniques (controller, service, repository, entity…), la règle métier finit à côté du code JPA, Kafka et Jackson. Pour la tester, il faut démarrer Spring, ou simuler une bonne partie du framework.\nCet article montre une autre organisation, l\u0026rsquo;architecture hexagonale, sur un petit exemple complet : un service qui lit des mesures de capteurs dans Kafka, les vérifie et calcule des statistiques. Le domaine et les cas d\u0026rsquo;usage sont en Java pur, Spring, Kafka et JPA restent dans des adaptateurs, et un test fait échouer le build si cette règle n\u0026rsquo;est pas respectée. Le code est dans le tag phase-1 du dépôt public iot-monitoring-platform, présenté dans l\u0026rsquo;article Kafka et Spring Boot : un projet complet en local.\nL\u0026rsquo;idée en bref L\u0026rsquo;architecture hexagonale vient d\u0026rsquo;un article d\u0026rsquo;Alistair Cockburn (2005), qui l\u0026rsquo;appelle aussi « Ports and Adapters ». L\u0026rsquo;idée tient en trois mots :\nle cœur (le domaine et les cas d\u0026rsquo;usage) contient les règles de l\u0026rsquo;application, en code simple, sans framework ; les ports sont des interfaces : ce que le cœur offre (ports d\u0026rsquo;entrée) et ce dont il a besoin (ports de sortie) ; les adaptateurs relient les ports au monde réel : un contrôleur REST, un listener Kafka, un repository JPA. Les dépendances pointent toujours vers l\u0026rsquo;intérieur : un adaptateur connaît le cœur, le cœur ne connaît aucun adaptateur.\nL'architecture hexagonale du service iot-backend-processing : au centre, le domaine et les cas d'usage en Java pur ; dans le bas de l'hexagone, les ports d'entrée et de sortie ; autour, les adaptateurs Kafka, REST et JPA. Toutes les dépendances pointent vers l'intérieur. Cas d'usage StoreMessageService ListStatisticsService… Domaine SensorReading ParseResult (sealed) SensorStatistics ports d'entrée StoreMessageUseCase… ports de sortie MessageRepository StatisticsRepository Adaptateurs d'entrée SensorMessageListener lit Kafka MessageController… API REST appellent Adaptateurs de sortie JpaMessageRepository JpaStatisticsRepository PostgreSQL implémentent Les dépendances pointent vers l'intérieur : le cœur ne connaît aucun adaptateur. Le service de l\u0026rsquo;exemple, iot-backend-processing, est volontairement petit : il reçoit une ligne de texte depuis Kafka (par exemple sensor-1 TEMPERATURE 21.5), la vérifie avec une règle métier, stocke le message, met à jour les statistiques du capteur, et les rend par une API REST. C\u0026rsquo;est assez pour voir chaque partie de l\u0026rsquo;architecture, sans bruit autour.\nAvant, après Avant ce travail, le service était rangé par couches techniques (historique public du projet, commit 82b4054) :\nprocessing/ BkProcessingApplication.java config/ CorsConfig, KafkaConsumerConfig controller/ StatsController dto/ SensorEvent, SensorEventEnvelope, SensorStats entity/ SensorReading kafka/ SensorEventConsumer repository/ SensorReadingRepository service/ AggregationService util/ PodInfo Dans le tag phase-1 :\nprocessing/ ProcessingApplication.java domain/ SensorReading, ParseResult, SensorStatistics, SensorType, ReceivedMessage, MessageStatus application/ port/in/ StoreMessageUseCase, ListLastMessagesUseCase, ListStatisticsUseCase, InvalidLimitException port/out/ MessageRepository, StatisticsRepository StoreMessageService, ListLastMessagesService, ListStatisticsService adapter/ in/kafka/ SensorMessageListener in/web/ MessageController, StatisticsController + réponses out/persistence/ entités JPA, repositories Spring Data, adaptateurs config/ ProcessingConfig Les deux versions n\u0026rsquo;ont pas les mêmes fonctions : l\u0026rsquo;ancienne propageait des infos de pod et des enveloppes JSON, sans règle métier ; la nouvelle lit et vérifie chaque ligne. On ne compare donc pas le nombre de classes, seulement la forme. Avant, le nom d\u0026rsquo;un package disait une technologie (kafka, repository) ; maintenant, il dit un rôle.\nLe domaine : la règle, en Java pur Une ligne valide a trois parties : un capteur, un type et une valeur dans la plage de son type. La règle est dans SensorReading.parse :\npublic static ParseResult parse(String text) { if (text == null || text.isBlank()) { return new ParseResult.Rejected(\u0026#34;empty line\u0026#34;); } String[] parts = text.strip().split(\u0026#34;\\\\s+\u0026#34;); if (parts.length != 3) { return new ParseResult.Rejected(\u0026#34;a line needs 3 parts: sensor, type and value\u0026#34;); } String sensorId = parts[0]; if (!SENSOR_ID.matcher(sensorId).matches()) { return new ParseResult.Rejected(\u0026#34;invalid sensor id \u0026#34; + sensorId); } Optional\u0026lt;SensorType\u0026gt; type = SensorType.fromName(parts[1]); if (type.isEmpty()) { return new ParseResult.Rejected(\u0026#34;unknown type \u0026#34; + parts[1]); } if (!DECIMAL.matcher(parts[2]).matches()) { return new ParseResult.Rejected(\u0026#34;invalid value \u0026#34; + parts[2]); } double value = Double.parseDouble(parts[2]); if (!type.get().accepts(value)) { return new ParseResult.Rejected(type.get().name() + \u0026#34; must be between \u0026#34; + type.get().min() + \u0026#34; and \u0026#34; + type.get().max()); } return new ParseResult.Valid(new SensorReading(sensorId, type.get(), value)); } Pour un service qui reçoit des données de capteurs, une ligne invalide est un cas normal, pas une erreur du programme. La méthode ne lance donc pas d\u0026rsquo;exception : elle rend un résultat, l\u0026rsquo;un des deux cas d\u0026rsquo;une interface sealed (Java 17) :\npublic sealed interface ParseResult { record Valid(SensorReading reading) implements ParseResult { } record Rejected(String reason) implements ParseResult { } } Le compilateur connaît tous les cas : un switch sur un ParseResult (pattern matching pour switch, Java 21) doit traiter Valid et Rejected, sinon il ne compile pas.\nLes statistiques d\u0026rsquo;un capteur sont un record immuable. Chaque mesure donne de nouvelles statistiques, sans relire les mesures passées :\npublic SensorStatistics add(SensorReading reading) { if (!sensorId.equals(reading.sensorId()) || type != reading.type()) { throw new IllegalArgumentException(\u0026#34;The reading of \u0026#34; + reading.sensorId() + \u0026#34; \u0026#34; + reading.type() + \u0026#34; does not belong to the statistics of \u0026#34; + sensorId + \u0026#34; \u0026#34; + type); } return new SensorStatistics(sensorId, type, count + 1, Math.min(min, reading.value()), Math.max(max, reading.value()), sum + reading.value()); } Ici, l\u0026rsquo;exception est justifiée : ajouter la mesure d\u0026rsquo;un autre capteur serait un bug du programme, pas une donnée invalide.\nDans tout le package domain, il n\u0026rsquo;y a aucun import de Spring, de Kafka, de JPA ni de Jackson.\nLes ports et les cas d\u0026rsquo;usage Le port d\u0026rsquo;entrée dit ce que le service offre :\npublic interface StoreMessageUseCase { ReceivedMessage store(String text, int partition, long offset); } Les ports de sortie disent ce dont il a besoin, sans dire comment. Par exemple, pour les statistiques :\npublic interface StatisticsRepository { Optional\u0026lt;SensorStatistics\u0026gt; find(String sensorId, SensorType type); void save(SensorStatistics statistics); List\u0026lt;SensorStatistics\u0026gt; findAll(); } Le cas d\u0026rsquo;usage enchaîne la règle et les ports :\npublic class StoreMessageService implements StoreMessageUseCase { private final MessageRepository messageRepository; private final StatisticsRepository statisticsRepository; private final Clock clock; // constructor: the three fields above @Override public ReceivedMessage store(String text, int partition, long offset) { ParseResult result = SensorReading.parse(text); ReceivedMessage stored = messageRepository.save( ReceivedMessage.notStoredYet(text, partition, offset, Instant.now(clock), result)); if (result instanceof ParseResult.Valid valid) { updateStatistics(valid.reading()); } return stored; } private void updateStatistics(SensorReading reading) { SensorStatistics statistics = statisticsRepository.find(reading.sensorId(), reading.type()) .map(current -\u0026gt; current.add(reading)) .orElseGet(() -\u0026gt; SensorStatistics.first(reading)); statisticsRepository.save(statistics); } } Pas de @Service, pas de @Autowired : c\u0026rsquo;est une classe Java. L\u0026rsquo;heure vient d\u0026rsquo;une Clock passée au constructeur, pour qu\u0026rsquo;un test puisse la fixer. Spring la crée dans la classe de configuration, avec les adaptateurs qui implémentent les ports :\n@Bean public StoreMessageUseCase storeMessageUseCase(MessageRepository messageRepository, StatisticsRepository statisticsRepository, Clock clock) { return new StoreMessageService(messageRepository, statisticsRepository, clock); } Tout le câblage Spring est dans config/. Le reste du cœur ignore que Spring existe.\nLes adaptateurs Le listener Kafka est un adaptateur d\u0026rsquo;entrée : il traduit un message Kafka en appel du port d\u0026rsquo;entrée.\n@KafkaListener(topics = \u0026#34;${iot.kafka.topic}\u0026#34;) @Transactional public void onMessage(ConsumerRecord\u0026lt;String, String\u0026gt; consumerRecord) { ReceivedMessage stored = storeMessage.store( consumerRecord.value(), consumerRecord.partition(), consumerRecord.offset()); // … log of the result } La transaction est ouverte ici, dans l\u0026rsquo;adaptateur : le message et les statistiques sont enregistrés ensemble ou pas du tout, et le cas d\u0026rsquo;usage reste en Java pur. Cela marche parce que le conteneur Spring Kafka appelle le listener à travers le bean Spring : en mode proxy (le mode par défaut), Spring n\u0026rsquo;intercepte que les appels qui passent par le proxy.\nLes adaptateurs JPA sont des adaptateurs de sortie. L\u0026rsquo;entité JPA (StatisticsEntity, avec ses annotations @Entity, @Column…) est une autre classe que le record du domaine. L\u0026rsquo;adaptateur fait la traduction :\n@Override public void save(SensorStatistics statistics) { StatisticsEntity entity = springDataRepository .findBySensorIdAndSensorType(statistics.sensorId(), statistics.type()) .orElseGet(() -\u0026gt; new StatisticsEntity(statistics.sensorId(), statistics.type())); entity.update(statistics.count(), statistics.min(), statistics.max(), statistics.sum()); springDataRepository.save(entity); } De même, l\u0026rsquo;API REST rend des records de réponse (StatisticsResponse, MessageResponse), pas les objets du domaine : la forme du JSON peut changer sans toucher au domaine.\nLes tests que cela permet Le domaine, sans rien démarrer Une ligne de test par sorte de ligne invalide (extrait) :\n@ParameterizedTest(name = \u0026#34;[{0}] -\u0026gt; {1}\u0026#34;) @CsvSource(delimiter = \u0026#39;|\u0026#39;, value = { \u0026#34;sensor-1 WIND 12 | unknown type WIND\u0026#34;, \u0026#34;sensor-1 TEMPERATURE 21,5 | invalid value 21,5\u0026#34;, \u0026#34;sensor-1 HUMIDITY -1 | HUMIDITY must be between 0 and 100\u0026#34;}) void parse_badLine_returnsRejectionWithReason(String text, String reason) { assertThat(SensorReading.parse(text)).isEqualTo(new ParseResult.Rejected(reason)); } Le cas d\u0026rsquo;usage, avec de faux ports Les ports sont des interfaces : un test leur donne une version en mémoire, et une horloge fixe :\nprivate final FakeMessageRepository messageRepository = new FakeMessageRepository(); private final FakeStatisticsRepository statisticsRepository = new FakeStatisticsRepository(); private final StoreMessageService service = new StoreMessageService(messageRepository, statisticsRepository, Clock.fixed(NOW, ZoneOffset.UTC)); @Test void store_rejectedLine_savesReasonAndKeepsStatistics() { service.store(\u0026#34;sensor-1 TEMPERATURE 20\u0026#34;, 0, 0L); ReceivedMessage stored = service.store(\u0026#34;sensor-1 WIND 12\u0026#34;, 0, 1L); assertThat(stored.status()).isEqualTo(MessageStatus.REJECTED); assertThat(stored.rejectionReason()).isEqualTo(\u0026#34;unknown type WIND\u0026#34;); assertThat(statisticsRepository.findAll()) .containsExactly(new SensorStatistics(\u0026#34;sensor-1\u0026#34;, SensorType.TEMPERATURE, 1, 20, 20, 20)); } Ni Spring, ni Kafka, ni base de données, ni Mockito. Les tests du domaine et des cas d\u0026rsquo;usage :\nmvn -pl iot-backend-processing test -Dtest=\u0026#39;SensorReadingTest,SensorStatisticsTest,StoreMessageServiceTest,ListLastMessagesServiceTest\u0026#39; [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s -- in dev.aymenjamaaoui.iot.processing.application.StoreMessageServiceTest [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in dev.aymenjamaaoui.iot.processing.application.ListLastMessagesServiceTest [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorStatisticsTest [INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.058 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorReadingTest [INFO] Tests run: 35, Failures: 0, Errors: 0, Skipped: 0 35 tests, chacun en quelques millisecondes. Les adaptateurs ont leurs propres tests : un @WebMvcTest pour l\u0026rsquo;API REST, et un test d\u0026rsquo;intégration avec un vrai Kafka et un vrai PostgreSQL (Testcontainers).\nLa règle, vérifiée par le build Une règle d\u0026rsquo;architecture écrite seulement dans un README finit par être oubliée. Le projet la vérifie avec ArchUnit :\n@AnalyzeClasses(packages = \u0026#34;dev.aymenjamaaoui.iot.processing\u0026#34;, importOptions = ImportOption.DoNotIncludeTests.class) class ArchitectureTest { @ArchTest static final ArchRule DOMAIN_AND_APPLICATION_HAVE_NO_FRAMEWORK = noClasses() .that().resideInAnyPackage(\u0026#34;..domain..\u0026#34;, \u0026#34;..application..\u0026#34;) .should().dependOnClassesThat().resideInAnyPackage( \u0026#34;org.springframework..\u0026#34;, \u0026#34;org.apache.kafka..\u0026#34;, \u0026#34;jakarta..\u0026#34;, \u0026#34;org.hibernate..\u0026#34;, \u0026#34;com.fasterxml.jackson..\u0026#34;, \u0026#34;tools.jackson..\u0026#34;); } Pour le voir échouer, j\u0026rsquo;ajoute exprès @Component (Spring) sur le record SensorStatistics, puis je lance mvn -pl iot-backend-processing test -Dtest=ArchitectureTest (sortie raccourcie) :\nArchitecture Violation [Priority: MEDIUM] - Rule \u0026#39;no classes that reside in any package [\u0026#39;..domain..\u0026#39;, \u0026#39;..application..\u0026#39;] should depend on classes that reside in any package [\u0026#39;org.springframework..\u0026#39;, \u0026#39;org.apache.kafka..\u0026#39;, \u0026#39;jakarta..\u0026#39;, \u0026#39;org.hibernate..\u0026#39;, \u0026#39;com.fasterxml.jackson..\u0026#39;, \u0026#39;tools.jackson..\u0026#39;]\u0026#39; was violated (1 times): Class \u0026lt;dev.aymenjamaaoui.iot.processing.domain.SensorStatistics\u0026gt; is annotated with \u0026lt;org.springframework.stereotype.Component\u0026gt; in (SensorStatistics.java:0) [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 Le message dit la règle, la classe et la cause. Le build échoue, donc la règle ne peut pas être cassée sans qu\u0026rsquo;on le voie.\nLe coût, honnêtement Cette organisation a un prix :\nplus de classes : le service a 28 classes dans src/main, dont 6 ports (interfaces et exception) et 2 entités JPA à côté des records du domaine ; du code de traduction : entité JPA ↔ record du domaine ↔ réponse REST ; plus de fichiers à ouvrir pour suivre un appel, du listener jusqu\u0026rsquo;à la base. Sur un exemple aussi petit, la structure peut sembler lourde : c\u0026rsquo;est voulu ici, pour que chaque partie soit visible. Elle paie quand le service grandit : quand les règles se multiplient, quand une technologie change (une autre base, une autre façon de recevoir les messages), ou quand on veut tester la règle vite et souvent. Pour un service qui ne fait que copier des données d\u0026rsquo;un système à un autre, sans vraie règle, une structure plus simple suffit.\nTesté avec Java 25 (Temurin 25.0.4), Spring Boot 4.1.2, ArchUnit 1.5.1, JUnit 5, AssertJ, Maven 3.10.0. Code : tag phase-1, service iot-backend-processing.\nSources Alistair Cockburn, « Hexagonal architecture » (l\u0026rsquo;article de 2005, « Ports and Adapters ») JEP 409 : Sealed Classes (Java 17) JEP 441 : Pattern Matching for switch (Java 21) ArchUnit, guide utilisateur (vérifications des dépendances entre packages) Spring Framework : Using @Transactional (le mode proxy) Pour aller plus loin L\u0026rsquo;article précédent de la série : Kafka et Spring Boot : un projet complet en local. Le README du tag phase-1 : la règle métier, l\u0026rsquo;API et tous les tests. ","permalink":"https://aymenjamaaoui.dev/blog/architecture-hexagonale-spring-boot/","summary":"\u003cp\u003eDans un service Spring Boot rangé par couches techniques (\u003ccode\u003econtroller\u003c/code\u003e,\n\u003ccode\u003eservice\u003c/code\u003e, \u003ccode\u003erepository\u003c/code\u003e, \u003ccode\u003eentity\u003c/code\u003e…), la règle métier finit à côté du\ncode JPA, Kafka et Jackson. Pour la tester, il faut démarrer Spring, ou\nsimuler une bonne partie du framework.\u003c/p\u003e\n\u003cp\u003eCet article montre une autre organisation, l\u0026rsquo;architecture hexagonale,\nsur un petit exemple complet : un service qui lit des mesures de\ncapteurs dans Kafka, les vérifie et calcule des statistiques. Le\ndomaine et les cas d\u0026rsquo;usage sont en Java pur, Spring, Kafka et JPA\nrestent dans des adaptateurs, et un test fait échouer le build si cette\nrègle n\u0026rsquo;est pas respectée. Le code est dans le tag\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/iot-monitoring-platform/-/tree/phase-1\"\u003e\u003ccode\u003ephase-1\u003c/code\u003e\u003c/a\u003e\ndu dépôt public iot-monitoring-platform, présenté dans l\u0026rsquo;article\n\u003ca href=\"/blog/kafka-spring-boot-local/\"\u003eKafka et Spring Boot : un projet complet en local\u003c/a\u003e.\u003c/p\u003e","title":"Architecture hexagonale avec Spring Boot : un exemple simple"},{"content":"Un service reçoit les mesures de capteurs, un autre doit les traiter et les stocker. Si le premier appelle le second en REST, les deux sont liés : quand le second est arrêté ou lent, le premier ne peut plus rien livrer. Kafka se place entre les deux : le premier publie un message, le second le lit à son rythme.\nCet article monte ce lien de bout en bout, en local : Kafka en mode KRaft dans Docker, un service Spring Boot qui publie une simple ligne de texte, un autre qui la lit, et une page qui affiche la partition et l\u0026rsquo;offset de chaque message. Tout le code est dans le tag phase-1 du dépôt public iot-monitoring-platform.\nLe projet en un schéma Le flux du projet : le simulateur envoie une ligne de texte à iot-backend-ingestion, qui la publie dans le topic Kafka iot-sensor-events ; iot-backend-processing lit le topic, stocke le message dans PostgreSQL et le rend à la page Angular. Kafka et PostgreSQL tournent dans Docker. Docker (compose.yaml) Simulateur iot-backend- ingestion :8081 Kafka 4.4 (KRaft) topic iot-sensor-events partition 0 0 1 2 3 … offsets iot-backend- processing :8082 Page Angular PostgreSQL 18 POST texte publie (String) lit (groupe iot-backend-processing) JPA GET /api/messages iot-backend-ingestion (port 8081) reçoit une ligne de texte, par exemple sensor-1 TEMPERATURE 21.5, et la publie dans le topic iot-sensor-events. iot-backend-processing (port 8082) lit le topic, stocke chaque message dans PostgreSQL et le rend via une API REST. Un simulateur envoie une ligne par seconde, et une page Angular affiche les derniers messages. Le message est un simple String, sans clé, sur un topic d\u0026rsquo;une seule partition. C\u0026rsquo;est volontaire : le but est de voir le chemin complet d\u0026rsquo;un message, avec le moins de réglages possible. Cet article ne parle que de la partie Kafka ; la règle métier et l\u0026rsquo;organisation du code (architecture hexagonale) feront l\u0026rsquo;objet d\u0026rsquo;un autre article.\nÀ la fin, vous aurez Kafka et PostgreSQL dans Docker, les deux services sur votre machine, et des messages dont vous suivez la partition et l\u0026rsquo;offset, de l\u0026rsquo;envoi jusqu\u0026rsquo;à la base.\nKafka en mode KRaft avec l\u0026rsquo;image officielle Depuis Kafka 4.0, Kafka fonctionne entièrement sans ZooKeeper : en mode KRaft, ce sont des nœuds contrôleurs de Kafka qui gèrent les métadonnées du cluster. Pour un poste de développement, un seul nœud peut être à la fois broker et contrôleur : il suffit donc d\u0026rsquo;un seul conteneur.\nLe projet utilise l\u0026rsquo;image officielle du projet Apache Kafka, apache/kafka. Avec sa configuration par défaut, elle démarre un seul nœud, qui est à la fois broker et contrôleur, et écoute sur le port 9092. Le fichier compose.yaml du projet (extrait) :\nservices: kafka: # Official Apache Kafka image. With its default config it starts one # node in KRaft mode (no ZooKeeper) and listens on localhost:9092. image: apache/kafka:4.4.0 container_name: iot-kafka ports: - \u0026#34;9092:9092\u0026#34; Aucune variable d\u0026rsquo;environnement : pas de KAFKA_ZOOKEEPER_CONNECT, pas de liste de listeners à écrire. Le même fichier démarre aussi PostgreSQL.\ndocker compose up -d Les logs du conteneur montrent ce que fait la configuration par défaut (docker logs iot-kafka, extrait) :\nadvertised.listeners = [PLAINTEXT://localhost:9092] node.id = 1 process.roles = [broker, controller] process.roles = [broker, controller] : un seul nœud joue les deux rôles. advertised.listeners est l\u0026rsquo;adresse que Kafka donne aux clients : localhost:9092. Les services Spring Boot tournent sur la machine, cette adresse leur convient telle quelle.\nPublier un String La configuration Le fichier application.yml du service d\u0026rsquo;ingestion :\nspring: application: name: iot-backend-ingestion kafka: # Spring Boot uses String serializers by default: the message is a plain String. bootstrap-servers: localhost:9092 iot: kafka: topic: iot-sensor-events Il n\u0026rsquo;y a pas de classe de configuration Kafka dans le projet. Spring Boot crée un KafkaTemplate, et ses sérialiseurs par défaut sont StringSerializer, pour la clé comme pour la valeur. Pour publier du texte, il n\u0026rsquo;y a donc rien à régler. bootstrap-servers vaut déjà localhost:9092 par défaut ; je l\u0026rsquo;écris quand même, pour qu\u0026rsquo;on voie où le service se connecte.\nL\u0026rsquo;envoi L\u0026rsquo;adaptateur qui publie (extrait de KafkaSensorMessagePublisher) :\n@Override public void publish(SensorMessage message) { // No key: in phase 1 the topic has one partition, so the order is // kept anyway. The key comes in phase 2, with several partitions. kafkaTemplate.send(topic, message.text()) .whenComplete((result, exception) -\u0026gt; { // The send is asynchronous: an error only shows in the log. // Error handling (retry, DLT) comes in phase 2. if (exception != null) { LOGGER.error(\u0026#34;Could not publish the message \u0026#39;{}\u0026#39; to {}\u0026#34;, message.text(), topic, exception); } else { LOGGER.info(\u0026#34;Published \u0026#39;{}\u0026#39; to {} (partition {}, offset {})\u0026#34;, message.text(), topic, result.getRecordMetadata().partition(), result.getRecordMetadata().offset()); } }); } send ne bloque pas : il renvoie un CompletableFuture\u0026lt;SendResult\u0026gt;. Le résultat arrive plus tard, avec la partition et l\u0026rsquo;offset que Kafka a donnés au message. C\u0026rsquo;est pour cela que le contrôleur REST répond 202 Accepted et non 200 OK : le service a confié le message à Kafka, mais il n\u0026rsquo;attend pas la confirmation. La limite est claire : si l\u0026rsquo;envoi échoue, l\u0026rsquo;erreur n\u0026rsquo;apparaît que dans le log. La gestion des erreurs viendra avec la phase 2.\nLe topic Le topic est déclaré par un bean NewTopic (extrait de IngestionConfig) :\n@Bean public NewTopic sensorEventsTopic(IotKafkaProperties properties) { return TopicBuilder.name(properties.topic()) .partitions(1) .replicas(1) .build(); } Au démarrage, Spring crée le topic s\u0026rsquo;il n\u0026rsquo;existe pas ; s\u0026rsquo;il existe déjà, le bean est ignoré. Le service ne dépend donc pas de la création automatique des topics par le broker. Une partition et une réplique : assez pour un seul broker local.\ndocker exec iot-kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic iot-sensor-events Topic: iot-sensor-events\tTopicId: oiLmbWoqSneUtet0x7hxCw\tPartitionCount: 1\tReplicationFactor: 1\tConfigs: min.insync.replicas=1,segment.bytes=1073741824 Topic: iot-sensor-events\tPartition: 0\tLeader: 1\tReplicas: 1\tIsr: 1\tElr: LastKnownElr: Consommer Le listener Côté processing, la configuration ajoute un groupe de consommateurs :\nspring: kafka: # Spring Boot uses String deserializers by default: the message is a plain String. bootstrap-servers: localhost:9092 consumer: group-id: iot-backend-processing # Read the topic from the start when the group has no offset yet, # so messages sent before the first start are not lost. auto-offset-reset: earliest Et le listener (extrait de SensorMessageListener) :\n@KafkaListener(topics = \u0026#34;${iot.kafka.topic}\u0026#34;) @Transactional public void onMessage(ConsumerRecord\u0026lt;String, String\u0026gt; consumerRecord) { ReceivedMessage stored = storeMessage.store( consumerRecord.value(), consumerRecord.partition(), consumerRecord.offset()); // … log of the result } Le ConsumerRecord donne la valeur (notre texte), mais aussi sa place dans le topic : la partition et l\u0026rsquo;offset. Le service les stocke avec le message, pour qu\u0026rsquo;on puisse les voir sur la page.\nLe groupe iot-backend-processing est le nom sous lequel Kafka retient où le consommateur en est : pour chaque partition, une position « commitée », l\u0026rsquo;offset du prochain message à lire. Au redémarrage, le service reprend à cette position.\nLe piège de auto-offset-reset Que fait un consommateur quand son groupe n\u0026rsquo;a encore aucune position enregistrée, par exemple au tout premier démarrage ? C\u0026rsquo;est le rôle de auto.offset.reset. Ses valeurs principales :\nValeur Sans position enregistrée, le consommateur… latest (par défaut) commence à la fin du topic : il ne lit que les messages qui arrivent après earliest commence au début du topic : il lit aussi les messages déjà présents none s\u0026rsquo;arrête avec une erreur Le réglage ne joue que dans ce cas. Dès que le groupe a une position, le consommateur reprend là où il en était.\nPour le voir, deux nouveaux groupes lisent le même topic, qui contient déjà deux messages. D\u0026rsquo;abord avec la valeur par défaut :\ndocker exec iot-kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic iot-sensor-events --group demo-latest --timeout-ms 5000 [2026-10-24 16:59:56,249] ERROR Error processing message, terminating consumer process: (org.apache.kafka.tools.consumer.ConsoleConsumer) org.apache.kafka.common.errors.TimeoutException Processed a total of 0 messages Puis avec earliest :\ndocker exec iot-kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic iot-sensor-events --group demo-from-start --command-property auto.offset.reset=earliest --timeout-ms 5000 sensor-1 TEMPERATURE 21.5 sensor-3 HUMIDITY 47.2 [2026-10-24 17:01:09,610] ERROR Error processing message, terminating consumer process: (org.apache.kafka.tools.consumer.ConsoleConsumer) org.apache.kafka.common.errors.TimeoutException Processed a total of 2 messages Le TimeoutException est normal : --timeout-ms 5000 arrête l\u0026rsquo;outil après cinq secondes sans nouveau message. (J\u0026rsquo;ai retiré de ces sorties une ligne d\u0026rsquo;information sur le protocole KIP-848.)\nAvec latest, le premier groupe n\u0026rsquo;a rien lu : les deux messages étaient là avant lui. C\u0026rsquo;est exactement ce qui arriverait au service de processing s\u0026rsquo;il démarrait après l\u0026rsquo;ingestion avec la valeur par défaut. D\u0026rsquo;où auto-offset-reset: earliest dans son application.yml.\nLancer le projet et voir Kafka travailler Kafka et PostgreSQL tournent déjà (docker compose up -d). Dans deux terminaux, à la racine du projet :\ncd iot-backend-processing mvn spring-boot:run cd iot-backend-ingestion mvn spring-boot:run Puis un message :\ncurl -i -X POST -H \u0026#34;Content-Type: text/plain\u0026#34; --data \u0026#34;sensor-1 TEMPERATURE 21.5\u0026#34; http://localhost:8081/api/messages HTTP/1.1 202 Content-Length: 0 Date: Sat, 24 Oct 2026 16:59:19 GMT Les deux services racontent le trajet du message dans leurs logs (après un deuxième message ; je ne garde que la fin des lignes, sans la date ni le nom du thread) :\nPublished \u0026#39;sensor-1 TEMPERATURE 21.5\u0026#39; to iot-sensor-events (partition 0, offset 0) Published \u0026#39;sensor-3 HUMIDITY 47.2\u0026#39; to iot-sensor-events (partition 0, offset 1) Stored \u0026#39;sensor-1 TEMPERATURE 21.5\u0026#39; (partition 0, offset 0) Stored \u0026#39;sensor-3 HUMIDITY 47.2\u0026#39; (partition 0, offset 1) Le producteur et le consommateur voient la même position : partition 0, offsets 0 puis 1. Sur une partition, l\u0026rsquo;offset est simplement le rang du message, et il ne fait que grandir.\nL\u0026rsquo;outil kafka-consumer-groups.sh montre où en est le groupe du service de processing (sortie raccourcie : sans les colonnes CONSUMER-ID, HOST et CLIENT-ID) :\ndocker exec iot-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group iot-backend-processing GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG iot-backend-processing iot-sensor-events 0 2 2 0 CURRENT-OFFSET est la position commitée du groupe (le prochain message à lire), LOG-END-OFFSET la fin de la partition, et LAG l\u0026rsquo;écart entre les deux : 0, le service est à jour.\nPour envoyer un flux continu, le projet a un simulateur (une ligne par seconde) :\ncd iot-event-simulator mvn compile exec:java La page Angular (npm start dans iot-frontend-angular, puis http://localhost:4200) affiche alors les messages avec leur partition et leur offset, qui augmente à chaque ligne.\nVoir le topic avec Kafdrop kafka-console-consumer.sh est l\u0026rsquo;outil de base. Pour voir le topic dans un navigateur, Kafdrop est une interface web pour Kafka (licence Apache 2.0, Java 17 ou plus). Il ne fait pas partie du projet : je le lance à côté, avec son jar officiel (release 4.3.0), sans rien changer au projet :\njava --add-opens=java.base/sun.nio.ch=ALL-UNNAMED \\ -jar kafdrop-4.3.0.jar --kafka.brokerConnect=localhost:9092 Puis http://localhost:9000, le topic iot-sensor-events, et ses messages :\nOn retrouve tout ce qui précède : une seule partition, des offsets qui se suivent, et Key: empty sur chaque message, puisque le service publie sans clé.\nKafdrop tourne sur la machine et pas dans un conteneur, pour une raison déjà vue : Kafka annonce localhost:9092. Depuis un autre conteneur, cette adresse désigne le conteneur lui-même, et la connexion échoue. Il faudrait un deuxième listener dans compose.yaml ; ce sera pour la phase 2.\nCe qui manque, exprès Ce projet est volontairement minimal. Il lui manque ce qu\u0026rsquo;un vrai système aurait :\nune clé de message : sans clé et avec une seule partition, l\u0026rsquo;ordre est gardé ; avec plusieurs partitions, il faudra une clé (le capteur) pour garder l\u0026rsquo;ordre des messages d\u0026rsquo;un même capteur ; plusieurs partitions et plusieurs consommateurs, pour traiter en parallèle ; la gestion des erreurs : aujourd\u0026rsquo;hui, un envoi raté ne laisse qu\u0026rsquo;une ligne de log, et un message qui ne peut pas être stocké est réessayé avec les réglages par défaut de Spring Kafka, puis perdu ; un événement typé avec un contrat, à la place d\u0026rsquo;une ligne de texte. C\u0026rsquo;est le programme de la phase 2 du projet, avec ses propres articles.\nTesté avec Java 25 (Temurin 25.0.4), Spring Boot 4.1.2, Apache Kafka 4.4.0 (image apache/kafka:4.4.0), PostgreSQL 18.6, Maven 3.10.0, Docker Desktop sur macOS, Kafdrop 4.3.0. Code : tag phase-1.\nSources Apache Kafka 4.0.0 Release Announcement (Kafka sans ZooKeeper, KRaft par défaut) Kafka : Getting Started, Docker (l\u0026rsquo;image apache/kafka et sa configuration par défaut) Kafka : configuration du consommateur, auto.offset.reset Spring Boot : Kafka (le KafkaTemplate auto-configuré, les beans NewTopic) Spring for Apache Kafka : Sending Messages (send renvoie un CompletableFuture) Spring for Apache Kafka : Configuring Topics Kafdrop (README, licence) Pour aller plus loin Le README du tag phase-1 : tout le projet, la page Angular et son proxy de développement. L\u0026rsquo;article suivant de la série : Architecture hexagonale avec Spring Boot : un exemple simple, l\u0026rsquo;organisation du code du service de processing. ","permalink":"https://aymenjamaaoui.dev/blog/kafka-spring-boot-local/","summary":"\u003cp\u003eUn service reçoit les mesures de capteurs, un autre doit les traiter et\nles stocker. Si le premier appelle le second en REST, les deux sont\nliés : quand le second est arrêté ou lent, le premier ne peut plus rien\nlivrer. Kafka se place entre les deux : le premier publie un message, le\nsecond le lit à son rythme.\u003c/p\u003e\n\u003cp\u003eCet article monte ce lien de bout en bout, en local : Kafka en mode\nKRaft dans Docker, un service Spring Boot qui publie une simple ligne de\ntexte, un autre qui la lit, et une page qui affiche la partition et\nl\u0026rsquo;offset de chaque message. Tout le code est dans le tag\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/iot-monitoring-platform/-/tree/phase-1\"\u003e\u003ccode\u003ephase-1\u003c/code\u003e\u003c/a\u003e\ndu dépôt public iot-monitoring-platform.\u003c/p\u003e","title":"Kafka et Spring Boot : un projet complet en local"},{"content":"Mon code Java range des fichiers dans S3 et des données dans DynamoDB. Comment le développer et l\u0026rsquo;essayer sur ma machine, sans compte AWS, sans facture, et sans changer le code qui parle à AWS ?\nCet article présente Floci, un émulateur AWS local, avec un exemple de bout en bout : l\u0026rsquo;AWS CLI crée les ressources, une petite application Java les utilise, et la console web de Floci les montre. Je contribue au projet Floci.\nPourquoi un émulateur local Pour essayer du code qui parle à AWS, on peut utiliser un vrai compte : il faut des identifiants, chaque appel peut coûter, et une erreur de configuration peut toucher autre chose qu\u0026rsquo;un environnement de test. Un émulateur local répond aux mêmes appels, sur la machine, sans compte et sans facture.\nLocalStack est l\u0026rsquo;outil le plus connu dans ce domaine. Depuis mars 2026, d\u0026rsquo;après son propre blog, il est livré en une seule image qui demande un jeton d\u0026rsquo;authentification (« auth token »), avec une offre gratuite liée à un compte pour les particuliers et les projets open source. Floci fonctionne sans compte et sans jeton.\nFloci en bref D\u0026rsquo;après son README, Floci est « a free, open-source local AWS emulator for development, testing, and CI », sous licence MIT. Il fait partie d\u0026rsquo;une famille d\u0026rsquo;émulateurs : floci pour AWS (port 4566), et d\u0026rsquo;autres pour Azure, GCP et OCI.\nTous les services AWS répondent sur http://localhost:4566. N\u0026rsquo;importe quelle région publiée marche, et les identifiants peuvent être n\u0026rsquo;importe quelles valeurs non vides.\nUn émulateur, pas un mock Dans un test, un mock remplace le client AWS par un faux objet. Il répond ce qu\u0026rsquo;on lui a dit de répondre. Le code ne fait aucun appel réseau, et le test vérifie surtout ce qu\u0026rsquo;on croit savoir d\u0026rsquo;AWS.\nFloci est d\u0026rsquo;une autre nature : c\u0026rsquo;est un serveur qui parle le protocole d\u0026rsquo;AWS. Le code garde le vrai client AWS (celui de l\u0026rsquo;AWS CLI, du SDK Java, de Terraform…), qui envoie de vraies requêtes HTTP, signées comme pour AWS. Seule l\u0026rsquo;adresse change : localhost au lieu d\u0026rsquo;AWS.\nLe README décrit son architecture :\nun routeur HTTP reçoit les requêtes au format d\u0026rsquo;AWS ; des services sans état (SQS, SNS, IAM, STS, KMS, Secrets Manager…) ; des services avec état : S3, DynamoDB et DynamoDB Streams ; des services qui démarrent de vrais conteneurs Docker : Lambda, RDS, ElastiCache, MSK, ECS, EC2, EKS… ; un stockage au choix : en mémoire, persistant, hybride, ou avec un journal d\u0026rsquo;écriture (« write-ahead log »). Floci mesure sa compatibilité avec des suites de tests par SDK et par outil, publiées dans son dépôt : par exemple 1 326 tests pour le SDK AWS pour Java v2, 205 pour l\u0026rsquo;AWS CLI v2, 67 pour Terraform.\nDémarrer Floci La façon la plus simple est sa CLI, floci-cli (Docker doit tourner) :\nfloci start floci status Floci tourne alors dans un conteneur, et répond sur le port 4566.\nAvec l\u0026rsquo;AWS CLI floci env affiche les variables qui envoient l\u0026rsquo;AWS CLI vers Floci :\nexport AWS_ENDPOINT_URL=\u0026#39;http://localhost.floci.io:4566\u0026#39; export AWS_ACCESS_KEY_ID=\u0026#39;test\u0026#39; export AWS_SECRET_ACCESS_KEY=\u0026#39;test\u0026#39; export AWS_DEFAULT_REGION=\u0026#39;us-east-1\u0026#39; localhost.floci.io est un nom qui pointe vers 127.0.0.1, comme ses sous-domaines : documents.localhost.floci.io aussi. C\u0026rsquo;est utile pour S3, qui peut mettre le nom du bucket dans le nom d\u0026rsquo;hôte.\nJe crée les ressources de l\u0026rsquo;exemple : un bucket pour les textes, une table pour les informations.\neval \u0026#34;$(floci env)\u0026#34; aws s3 mb s3://documents aws dynamodb create-table \\ --table-name document-info \\ --attribute-definitions AttributeName=id,AttributeType=S \\ --key-schema AttributeName=id,KeyType=HASH \\ --billing-mode PAY_PER_REQUEST Pour voir que la CLI ne fait rien de spécial, l\u0026rsquo;option --debug montre la requête :\naws s3 ls --debug Endpoint provider result: http://localhost.floci.io:4566 Sending http request: \u0026lt;AWSPreparedRequest ... method=GET, url=http://localhost.floci.io:4566/, headers={... \u0026#39;Authorization\u0026#39;: b\u0026#39;AWS4-HMAC-SHA256 Credential=test/..., SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=...\u0026#39; ...}\u0026gt; Response headers: {\u0026#39;Content-Type\u0026#39;: \u0026#39;application/xml\u0026#39;, ..., \u0026#39;x-amz-request-id\u0026#39;: \u0026#39;9f476e72-...\u0026#39;} (J\u0026rsquo;ai raccourci les lignes.) La requête est signée avec SigV4 (AWS4-HMAC-SHA256), exactement comme pour AWS, et Floci répond en XML, comme S3. Pour ne pas exporter ces variables dans chaque terminal, un profil AWS nommé fait la même chose ; je l\u0026rsquo;explique dans l\u0026rsquo;article sur mon poste de dev.\nAvec le SDK Java L\u0026rsquo;exemple est une petite application, publiée sur GitLab : floci-aws-local-demo. Elle range des « documents » : le texte dans S3, les informations (titre, auteur) dans DynamoDB, et la consultation renvoie les deux ensemble. Elle utilise le SDK AWS pour Java v2 ; Quarkus n\u0026rsquo;y sert que de couche web.\nEndpoint Ce qu\u0026rsquo;il fait Service AWS POST /documents (texte) range le texte, renvoie un id S3 POST /documents/{id}/info (JSON) range le titre et l\u0026rsquo;auteur DynamoDB GET /documents/{id} renvoie les informations et le texte DynamoDB + S3 GET /documents liste les documents DynamoDB C\u0026rsquo;est un exemple pédagogique : chaque classe crée son client avec des constantes visibles. Le client S3 (extrait simplifié de DocumentTextResource.java) :\nprivate static final URI ENDPOINT = URI.create(\u0026#34;http://localhost:4566\u0026#34;); private static final Region REGION = Region.US_EAST_1; // Floci accepts any non-empty credentials. private static final AwsBasicCredentials CREDENTIALS = AwsBasicCredentials.create(\u0026#34;test\u0026#34;, \u0026#34;test\u0026#34;); try (S3Client s3 = S3Client.builder() .endpointOverride(ENDPOINT) .region(REGION) .credentialsProvider(StaticCredentialsProvider.create(CREDENTIALS)) .build()) { PutObjectRequest request = PutObjectRequest.builder() .bucket(\u0026#34;documents\u0026#34;) .key(id + \u0026#34;.txt\u0026#34;) .contentType(MediaType.TEXT_PLAIN) .build(); s3.putObject(request, RequestBody.fromString(text)); } C\u0026rsquo;est le client S3 qu\u0026rsquo;on utiliserait avec AWS. La seule différence est endpointOverride : avec le vrai AWS, on retire cette ligne et on utilise de vrais identifiants. Le client DynamoDB se crée de la même façon, dans DocumentInfoResource.java.\nL\u0026rsquo;application se lance sur la machine, avec les versions de Java et de Maven données par son fichier mise.toml :\nmise install mvn quarkus:dev Puis on range un document, ses informations, et on le relit :\ncurl -X POST localhost:8080/documents -H \u0026#39;Content-Type: text/plain\u0026#39; \\ --data \u0026#39;Floci speaks the AWS protocol on port 4566.\u0026#39; {\u0026#34;id\u0026#34;:\u0026#34;3fa7b64a-10e9-4e1e-b9d3-258a043f8122\u0026#34;} curl -X POST localhost:8080/documents/3fa7b64a-10e9-4e1e-b9d3-258a043f8122/info \\ -H \u0026#39;Content-Type: application/json\u0026#39; -d \u0026#39;{\u0026#34;title\u0026#34;:\u0026#34;Hello Floci\u0026#34;,\u0026#34;author\u0026#34;:\u0026#34;Foulen Foulani\u0026#34;}\u0026#39; curl localhost:8080/documents/3fa7b64a-10e9-4e1e-b9d3-258a043f8122 {\u0026#34;id\u0026#34;:\u0026#34;3fa7b64a-…\u0026#34;,\u0026#34;title\u0026#34;:\u0026#34;Hello Floci\u0026#34;,\u0026#34;author\u0026#34;:\u0026#34;Foulen Foulani\u0026#34;,\u0026#34;createdAt\u0026#34;:\u0026#34;2026-10-24T03:12:58.387781Z\u0026#34;,\u0026#34;text\u0026#34;:\u0026#34;Floci speaks the AWS protocol on port 4566.\u0026#34;} Le titre vient de DynamoDB, le texte de S3. Un identifiant inconnu répond 404.\nIci, l\u0026rsquo;adresse de Floci est une constante dans le code. Dans un vrai projet, on peut la laisser hors du code : d\u0026rsquo;après le guide AWS, le SDK pour Java 2.x lit AWS_ENDPOINT_URL, ou endpoint_url dans un profil AWS (le SDK 1.x, non).\nLa console web Floci a une console web : http://localhost:4566/_floci/ui. Elle ne tourne pas au démarrage. À la première visite, Floci lance un conteneur à côté de lui, qui contient la console, puis y redirige le navigateur (ici, vers le port 4500). C\u0026rsquo;est pour cela que Floci a besoin du socket Docker (floci start s\u0026rsquo;en charge). La console ne fait pas que montrer : on peut aussi y créer un bucket, envoyer un fichier, ajouter un élément dans une table ou lancer une requête PartiQL.\nLe bucket documents contient le texte rangé par l\u0026rsquo;application :\nEt la table document-info contient ses informations. Une requête PartiQL les affiche en JSON :\nCe qu\u0026rsquo;il faut savoir Un émulateur n\u0026rsquo;est pas AWS. Floci reproduit le protocole et le comportement des services, et ses suites de tests mesurent cette compatibilité ; mais pour un service précis, il vaut mieux vérifier sa place dans la liste des services et dans ces tests. Les services qui démarrent de vrais conteneurs (Lambda, RDS…) demandent Docker.\nPour aller plus loin, Floci a aussi des modules Testcontainers (Java, Node.js, Python, .NET) qui démarrent un Floci isolé pour chaque test. Tester son code avec Floci fera l\u0026rsquo;objet d\u0026rsquo;un prochain article.\nTesté sur macOS avec Floci 2.3.0 (CLI 0.4.0), l\u0026rsquo;AWS CLI 2.38.5, Java 21 (Temurin 21.0.13), Quarkus 3.40.1.1 et le SDK AWS pour Java 2.46.7.\nSources : le README de Floci (présentation, architecture, console web, tests de compatibilité, Testcontainers), floci-cli, le billet de LocalStack sur la livraison de ses émulateurs, et le guide AWS sur les endpoints.\nPour aller plus loin Poste de dev Java : 8, 17 et 25 côte à côte avec mise Java dans Docker et Kubernetes : éviter OOMKilled ","permalink":"https://aymenjamaaoui.dev/blog/aws-local-floci/","summary":"\u003cp\u003eMon code Java range des fichiers dans S3 et des données dans DynamoDB.\nComment le développer et l\u0026rsquo;essayer sur ma machine, sans compte AWS,\nsans facture, et sans changer le code qui parle à AWS ?\u003c/p\u003e\n\u003cp\u003eCet article présente \u003ca href=\"https://github.com/floci-io/floci\"\u003eFloci\u003c/a\u003e, un\némulateur AWS local, avec un exemple de bout en bout : l\u0026rsquo;AWS CLI crée\nles ressources, une petite application Java les utilise, et la console\nweb de Floci les montre. Je contribue au projet Floci.\u003c/p\u003e","title":"AWS en local avec Floci : un émulateur, pas un mock"},{"content":"Un développeur Java travaille souvent sur plusieurs stacks à la fois : un projet legacy en Java 8, un service en 17, un nouveau projet en 25. Maven ici, Gradle là. Node pour une doc, Python pour MkDocs. Et autour, des outils : aws, gh, kubectl, helm.\nChaque SDK et chaque outil arrive avec son installeur. Peu à peu, le poste se remplit : des versions globales qui se mélangent, des sudo, un .zshrc rempli par les installeurs, une variable AWS restée en global. Ma question : comment avoir la bonne version de chaque SDK et de chaque outil dans chaque projet, sans rien installer en root, avec un seul outil, et un terminal qui reste rapide ?\nVoici mon poste de dev sur macOS, construit sur un OS propre que je veux garder propre.\nMes critères pour un poste de dev Rien en root. Pas de sudo pour installer ou mettre à jour un outil de dev. Tout vit dans mon dossier utilisateur. Un seul outil pour les SDK et les outils. Un seul endroit, une seule commande, un seul fichier à lire. La bonne version au cd. J\u0026rsquo;entre dans un projet, j\u0026rsquo;ai sa version de Java et de Maven, sans y penser. L\u0026rsquo;isolation. Chaque projet a ses versions. Un environnement particulier (par exemple un émulateur AWS) reste dans un onglet. Un terminal qui reste rapide, même avec des complétions pour tous les outils. La suite répond à ces critères, dans l\u0026rsquo;ordre.\nmise, en deux mots mise se présente comme « dev tools, env vars, and tasks in one CLI » : il installe les outils d\u0026rsquo;un projet, règle ses variables d\u0026rsquo;environnement et lance ses tâches. Ici, je m\u0026rsquo;en sers surtout pour les outils. C\u0026rsquo;est un seul binaire, sous licence MIT, qui marche aussi sous Linux et Windows.\nL\u0026rsquo;installation tient en une commande, sans sudo :\ncurl -fsSL https://mise.run | sh Le script vérifie la somme SHA-256 du binaire et le place dans ~/.local/bin/mise. Il ne modifie pas ~/.zshrc : il affiche la ligne à ajouter. Je l\u0026rsquo;ajoute moi-même :\neval \u0026#34;$(\u0026#34;$HOME/.local/bin/mise\u0026#34; activate zsh)\u0026#34; Dans un nouvel onglet, mise doctor confirme :\nactivated: yes ... No problems found mise range tout dans mon dossier : les outils dans ~/.local/share/mise/installs/\u0026lt;outil\u0026gt;/\u0026lt;version\u0026gt;, la configuration dans ~/.config/mise/config.toml.\nUn détail pour IntelliJ : une application lancée depuis le Dock lit l\u0026rsquo;environnement du shell de connexion, pas celui de .zshrc. La doc de mise conseille d\u0026rsquo;ajouter les « shims » dans ~/.zprofile, pour que ces applications trouvent aussi les outils :\neval \u0026#34;$(\u0026#34;$HOME/.local/bin/mise\u0026#34; activate zsh --shims)\u0026#34; Tous les SDK et outils en deux commandes D\u0026rsquo;abord les langages, avec leur version LTS quand elle existe :\nmise use -g java@temurin-25 node@lts maven@3.10 gradle@9 python@3.15 Puis les outils en ligne de commande :\nmise use -g uv starship gh aws-cli helm github:floci-io/floci-cli -g veut dire « global » : ce sont les versions par défaut du poste. La plupart viennent du registre de mise (plus de 1 000 outils). Un outil absent du registre s\u0026rsquo;installe depuis ses versions GitHub, comme ici github:floci-io/floci-cli.\nLe résultat est un fichier lisible, qui décrit le poste :\n# ~/.config/mise/config.toml [tools] java = \u0026#34;temurin-25\u0026#34; node = \u0026#34;lts\u0026#34; maven = \u0026#34;3.10\u0026#34; gradle = \u0026#34;9\u0026#34; python = \u0026#34;3.15\u0026#34; uv = \u0026#34;latest\u0026#34; starship = \u0026#34;latest\u0026#34; gh = \u0026#34;latest\u0026#34; aws-cli = { version = \u0026#34;latest\u0026#34;, symlink_bins = \u0026#34;true\u0026#34; } helm = \u0026#34;latest\u0026#34; \u0026#34;github:floci-io/floci-cli\u0026#34; = \u0026#34;latest\u0026#34; Une version se lit simplement : temurin-25 prend le dernier Temurin 25, lts la dernière LTS de Node, 3.10 le dernier correctif de Maven 3.10. Sur un nouveau poste, il suffit de copier ce fichier, puis de lancer mise install.\nuv, en plus de Python ? mise installe Python lui-même. uv sert à ce qu\u0026rsquo;on fait avec Python : uvx mkdocs serve lance MkDocs sans rien installer en global, et uv sync gère les dépendances d\u0026rsquo;un projet.\nUne version par projet Chaque projet peut avoir son propre fichier. Pour quickfixj, un projet encore en Java 8 :\n# ~/devs/GitHub/quickfixj/mise.local.toml [tools] java = \u0026#34;corretto-8\u0026#34; maven = \u0026#34;3.10\u0026#34; mise.local.toml est un fichier personnel : la doc de mise le décrit comme « personal overrides, not committed ». Je l\u0026rsquo;ajoute à .git/info/exclude du projet, pour que Git ne le voie pas. Pour une version partagée avec l\u0026rsquo;équipe, on met plutôt un mise.toml dans le dépôt. Le fichier le plus proche du dossier courant gagne, et ce qu\u0026rsquo;il ne précise pas vient de la configuration globale.\nC\u0026rsquo;est ce que montre la capture du début. Dans floci, java -version donne Java 25 ; dans quickfixj, Java 8. Et mise ls --current dit d\u0026rsquo;où vient chaque version : Java et Maven du mise.local.toml du projet, Node de ~/.config/mise/config.toml. mise met aussi à jour JAVA_HOME.\nUn projet a déjà un fichier de version, comme un .nvmrc ? mise sait le lire, une fois l\u0026rsquo;option activée :\nmise settings add idiomatic_version_file_enable_tools node Dans un dossier dont le .nvmrc contient 20, j\u0026rsquo;ai Node 20 ; ailleurs, Node 24 (la LTS). Rien à changer dans le projet.\nUn terminal confortable Le prompt : Starship Starship affiche ce qui compte dans le dossier courant : la branche Git, la version du projet, Java, Maven, le profil AWS. Il est déjà installé par mise ; il suffit de l\u0026rsquo;activer à la fin de ~/.zshrc :\neval \u0026#34;$(starship init zsh)\u0026#34; Sa doc demande une Nerd Font, une police avec des icônes. J\u0026rsquo;ai pris JetBrains Mono Nerd Font, depuis les versions officielles de Nerd Fonts (la somme SHA-256 de chaque archive y est publiée). Pour rester sans root, la police va dans ~/Library/Fonts : elle n\u0026rsquo;est installée que pour mon utilisateur. Puis je la choisis dans les réglages du terminal.\nLes complétions, sans ralentir le shell La touche Tab complète les commandes de docker, kubectl, gh… Beaucoup de guides proposent de charger chaque complétion au démarrage :\nsource \u0026lt;(kubectl completion zsh) Le programme tourne alors à chaque nouvel onglet. Docker et kubectl documentent une autre méthode : écrire la complétion une fois dans un fichier, dans un dossier que zsh connaît (le fpath). zsh ne la charge qu\u0026rsquo;au premier Tab.\nmkdir -p ~/.zsh/completions kubectl completion zsh \u0026gt; ~/.zsh/completions/_kubectl docker completion zsh \u0026gt; ~/.zsh/completions/_docker gh completion -s zsh \u0026gt; ~/.zsh/completions/_gh Et dans ~/.zshrc, avant compinit :\nfpath=(\u0026#34;$HOME/.zsh/completions\u0026#34; $fpath) autoload -Uz compinit compinit J\u0026rsquo;ai mesuré l\u0026rsquo;ouverture d\u0026rsquo;un shell avec les mêmes 10 complétions (docker, kubectl, mise, gh, glab, helm, uv, uvx, Starship, hvm), 10 ouvertures pour chaque cas :\nMéthode Ouverture du shell Aucune complétion 93 ms Fichiers dans le fpath 92 ms source \u0026lt;(… completion zsh) 348 ms Les fichiers ne coûtent rien à l\u0026rsquo;ouverture. Leur seule limite : ils décrivent l\u0026rsquo;outil au moment où on les a créés. Après une mise à jour (mise upgrade), je les recrée avec un petit script, ~/.zsh/update-completions.zsh, qui reprend une ligne par outil.\nDeux cas à part. La complétion d\u0026rsquo;aws n\u0026rsquo;est pas un fichier mais un programme ; la doc AWS donne ces deux lignes pour zsh :\nautoload -Uz bashcompinit \u0026amp;\u0026amp; bashcompinit complete -C aws_completer aws Et un alias garde la complétion de sa commande : alias k=kubectl, puis compdef k=kubectl.\nL\u0026rsquo;historique et quelques alias macOS règle l\u0026rsquo;historique à 1 000 lignes (SAVEHIST dans /etc/zshrc). J\u0026rsquo;en garde plus, sans doublons, partagé entre les onglets :\nHISTSIZE=50000 SAVEHIST=50000 setopt HIST_IGNORE_ALL_DUPS SHARE_HISTORY HIST_IGNORE_SPACE Avec HIST_IGNORE_SPACE, une commande qui commence par une espace n\u0026rsquo;est pas enregistrée : pratique pour une commande qui contient un secret. Et quelques alias :\nalias ls=\u0026#39;ls -G\u0026#39; # couleurs alias ll=\u0026#39;ls -lrth\u0026#39; alias lla=\u0026#39;ls -lrtha\u0026#39; alias reload=\u0026#39;exec zsh\u0026#39; # recharger le shell alias ports=\u0026#39;lsof -nP -iTCP -sTCP:LISTEN\u0026#39; # qui écoute sur quel port Isoler un environnement : l\u0026rsquo;exemple de Floci Floci est un émulateur AWS local (je le présente dans AWS en local avec Floci). Pour l\u0026rsquo;utiliser, le client AWS doit viser http://localhost:4566. La solution facile est une variable globale : export AWS_ENDPOINT_URL=http://localhost:4566 dans .zshrc. Mais alors, toutes les commandes AWS du poste partent vers l\u0026rsquo;émulateur, et on finit par l\u0026rsquo;oublier.\nLa doc AWS permet de mettre l\u0026rsquo;adresse dans un profil nommé de ~/.aws/config :\n[profile floci] region = us-east-1 endpoint_url = http://localhost:4566 Les identifiants de test vont dans ~/.aws/credentials, sous [floci]. Je n\u0026rsquo;ai pas de profil par défaut. Résultat :\ndans un onglet normal, aws ne trouve aucun identifiant (« Unable to locate credentials ») : la commande ne part ni vers Floci, ni vers un vrai compte AWS ; avec AWS_PROFILE=floci (ou --profile floci), elle part vers Floci. Pour ne pas taper AWS_PROFILE à chaque fois, un fichier ~/.zsh/profiles/floci.zsh le règle, et charge la complétion de floci :\nexport AWS_PROFILE=floci if command -v floci \u0026gt;/dev/null; then source \u0026lt;(floci completion zsh) fi La complétion de floci est un script qu\u0026rsquo;on ne peut pas mettre dans le fpath. Elle reste donc dans ce profil, et ne coûte rien aux autres onglets.\nDans iTerm2, un « profil dynamique » crée un profil « Floci » à partir d\u0026rsquo;un fichier JSON, déposé dans ~/Library/Application Support/iTerm2/DynamicProfiles/. Il hérite du profil par défaut et affiche un badge :\n{ \u0026#34;Profiles\u0026#34;: [ { \u0026#34;Name\u0026#34;: \u0026#34;Floci\u0026#34;, \u0026#34;Guid\u0026#34;: \u0026#34;647AE153-1576-4C43-A514-1581774C216B\u0026#34;, \u0026#34;Dynamic Profile Parent Name\u0026#34;: \u0026#34;Default\u0026#34;, \u0026#34;Badge Text\u0026#34;: \u0026#34;Floci\u0026#34; } ] } La doc d\u0026rsquo;iTerm2 demande un Guid unique : je l\u0026rsquo;ai créé avec uuidgen. iTerm2 met le nom du profil dans la variable $ITERM_PROFILE (je l\u0026rsquo;ai constaté, mais sa doc n\u0026rsquo;en parle pas). ~/.zshrc charge alors le profil Floci dans ces onglets-là. Dans un autre terminal, comme Ghostty ou celui d\u0026rsquo;IntelliJ, une fonction profile floci fait la même chose.\nSi vous avez déjà des outils installés en root Déjà un outil installé en root ? Prenons aws, souvent installé avec le paquet officiel dans /usr/local. Installez d\u0026rsquo;abord la version mise (mise use -g aws-cli), vérifiez avec which -a aws qu\u0026rsquo;elle passe devant, puis désinstallez l\u0026rsquo;ancienne en suivant la page Uninstall the AWS CLI d\u0026rsquo;AWS. Même idée pour les autres outils : la version mise d\u0026rsquo;abord, l\u0026rsquo;ancienne ensuite.\nEn résumé Critère Ma réponse Rien en root mise et tous les outils dans ~/.local, la police dans ~/Library/Fonts Un seul outil mise use -g pour les SDK et les outils ; un fichier, ~/.config/mise/config.toml La bonne version au cd mise.local.toml ou mise.toml par projet, et les .nvmrc existants L\u0026rsquo;isolation un fichier par projet ; un profil AWS nommé, sans profil par défaut ; un onglet Floci Un terminal rapide les complétions dans des fichiers : 92 ms au lieu de 348 ms Le ~/.zshrc complet :\n# PATH : pas de doublons, les outils utilisateur d\u0026#39;abord typeset -U path PATH path=(\u0026#34;$HOME/.local/bin\u0026#34; $path) # Historique HISTSIZE=50000 SAVEHIST=50000 setopt HIST_IGNORE_ALL_DUPS SHARE_HISTORY HIST_IGNORE_SPACE # Complétions : les fichiers de ~/.zsh/completions fpath=(\u0026#34;$HOME/.zsh/completions\u0026#34; $fpath) autoload -Uz compinit compinit autoload -Uz bashcompinit \u0026amp;\u0026amp; bashcompinit complete -C aws_completer aws # Alias alias ls=\u0026#39;ls -G\u0026#39; alias ll=\u0026#39;ls -lrth\u0026#39; alias lla=\u0026#39;ls -lrtha\u0026#39; alias reload=\u0026#39;exec zsh\u0026#39; alias ports=\u0026#39;lsof -nP -iTCP -sTCP:LISTEN\u0026#39; alias k=kubectl alias update-completions=\u0026#39;~/.zsh/update-completions.zsh\u0026#39; compdef k=kubectl # mise : les versions du dossier courant eval \u0026#34;$(\u0026#34;$HOME/.local/bin/mise\u0026#34; activate zsh)\u0026#34; # Profils de terminal (~/.zsh/profiles), par exemple : profile floci profile() { local target=\u0026#34;$HOME/.zsh/profiles/${1:l}.zsh\u0026#34; if [[ -f \u0026#34;$target\u0026#34; ]]; then source \u0026#34;$target\u0026#34; else print -u2 \u0026#34;Unknown profile: $1 (see the files in ~/.zsh/profiles)\u0026#34; return 1 fi } case \u0026#34;$ITERM_PROFILE\u0026#34; in Floci) profile floci ;; esac # Prompt (à garder à la fin) eval \u0026#34;$(starship init zsh)\u0026#34; Testé sur macOS 27 (Apple Silicon) avec zsh 5.9, mise 2026.10, Starship 1.27, iTerm2 et Ghostty, Floci 2.3.\nSources : la documentation de mise (« Getting started », « Configuration », « Editors and IDEs », Java, Node), le manuel de zsh, les pages de complétion de Docker, de kubectl et de l\u0026rsquo;AWS CLI, le guide AWS sur les endpoints, le guide de Starship et la doc des profils dynamiques d\u0026rsquo;iTerm2.\nPour aller plus loin Un Chrome par compte Google sur macOS Java dans Docker et Kubernetes : éviter OOMKilled ","permalink":"https://aymenjamaaoui.dev/blog/poste-dev-java-mise/","summary":"\u003cp\u003eUn développeur Java travaille souvent sur plusieurs stacks à la fois :\nun projet legacy en Java 8, un service en 17, un nouveau projet en 25.\nMaven ici, Gradle là. Node pour une doc, Python pour MkDocs. Et autour,\ndes outils : \u003ccode\u003eaws\u003c/code\u003e, \u003ccode\u003egh\u003c/code\u003e, \u003ccode\u003ekubectl\u003c/code\u003e, helm.\u003c/p\u003e\n\u003cp\u003eChaque SDK et chaque outil arrive avec son installeur. Peu à peu, le\nposte se remplit : des versions globales qui se mélangent, des \u003ccode\u003esudo\u003c/code\u003e,\nun \u003ccode\u003e.zshrc\u003c/code\u003e rempli par les installeurs, une variable AWS restée en\nglobal. Ma question : comment avoir la bonne version de chaque SDK et\nde chaque outil dans chaque projet, sans rien installer en root, avec\nun seul outil, et un terminal qui reste rapide ?\u003c/p\u003e","title":"Poste de dev Java : 8, 17 et 25 côte à côte avec mise"},{"content":"Le pod redémarre. kubectl get pod affiche OOMKilled, le code de sortie est 137. Pourtant, les logs ne montrent aucune OutOfMemoryError, et le heap était loin d\u0026rsquo;être plein. Ce n\u0026rsquo;est pas la JVM qui a manqué de mémoire : c\u0026rsquo;est le conteneur. Le noyau Linux a tué le processus parce qu\u0026rsquo;il a dépassé sa limite.\nCet article montre ce que la JVM voit dans un conteneur, ce qui s\u0026rsquo;ajoute au heap, comment reproduire le kill, et comment régler la mémoire pour que tout tienne. Tous les exemples sont dans le dossier containers/ du dépôt public java-gc-lab. Ils utilisent les images officielles eclipse-temurin : pas d\u0026rsquo;image à construire.\nLes commandes Docker de cet article Si Docker vous est peu familier, voici les options utilisées plus bas.\nOption Ce qu\u0026rsquo;elle fait docker run \u0026lt;image\u0026gt; \u0026lt;commande\u0026gt; Démarre un conteneur à partir de l\u0026rsquo;image et y lance la commande. --rm Supprime le conteneur quand il s\u0026rsquo;arrête. --name over-limit Donne un nom au conteneur, pour les commandes suivantes. --memory=512m Limite de mémoire du conteneur. C\u0026rsquo;est cette limite que la JVM lit. --memory-swap=512m Limite pour mémoire + swap. Même valeur que --memory : pas de swap. --cpus=2 Limite de CPU : l\u0026rsquo;équivalent de 2 CPU de temps. -v \u0026quot;$PWD/containers\u0026quot;:/app Monte le dossier local containers/ en /app dans le conteneur. -d Lance le conteneur en arrière-plan. docker inspect \u0026lt;nom\u0026gt; Montre l\u0026rsquo;état d\u0026rsquo;un conteneur arrêté : code de sortie, OOMKilled. docker exec \u0026lt;nom\u0026gt; \u0026lt;commande\u0026gt; Lance une commande dans un conteneur qui tourne. docker logs \u0026lt;nom\u0026gt; Montre ce que le conteneur a affiché. Attention au swap : avec --memory seul, Docker donne en plus autant de swap. java -XshowSettings:system le montre : « Memory Limit: 1.00G », « Memory \u0026amp; Swap Limit: 2.00G ». Le conteneur utilise alors le swap au lieu d\u0026rsquo;être tué à la limite. Pour reproduire ce qui se passe dans Kubernetes, je mets --memory-swap à la même valeur.\nCe que la JVM voit dans un conteneur Sous Linux, la JVM lit les limites du conteneur (les cgroups) : la mémoire et le nombre de CPU. C\u0026rsquo;est l\u0026rsquo;option -XX:+UseContainerSupport, active par défaut. Ces limites servent à calculer la taille du heap, le choix du GC et le nombre de threads.\nShowLimits.java affiche ce que la JVM a choisi :\ndocker run --rm --memory=1g --cpus=2 -v \u0026#34;$PWD/containers\u0026#34;:/app eclipse-temurin:25 java /app/ShowLimits.java CPUs: 2 Max heap: 256 MiB GC: Copy, MarkSweepCompact « Copy, MarkSweepCompact », c\u0026rsquo;est le GC Serial. Avec d\u0026rsquo;autres limites, mêmes résultats en Java 17, 21 et 25 :\n--memory --cpus Heap max GC 256m 1 126 Mio Serial 1g 2 256 Mio Serial 1791m 2 448 Mio Serial 1792m 2 448 Mio G1 4g 2 1024 Mio G1 La JVM donne elle-même ces valeurs, sans programme :\ndocker run --rm --memory=1g --cpus=2 eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep -E \u0026#34; MaxHeapSize | UseSerialGC | UseG1GC \u0026#34; docker run --rm --memory=1g --cpus=2 eclipse-temurin:25 java -XshowSettings:system -version La première affiche les options calculées (MaxHeapSize = 268435456, UseSerialGC = true). La deuxième montre les limites lues dans le conteneur : « Provider: cgroupv2 », « Effective CPU Count: 2 », « Memory Limit: 1.00G ».\nLe heap par défaut : 25 %, ou 50 % pour un petit heap Sans -Xmx, le heap maximum vaut 25 % de la mémoire vue par la JVM : c\u0026rsquo;est -XX:MaxRAMPercentage, 25 par défaut. Dans un conteneur de 1 Go, cela fait 256 Mo. Les 75 % restants ne sont pas tous libres : le reste de la JVM en a besoin, on le verra plus bas.\nPour un petit heap, une autre option prend le relais : -XX:MinRAMPercentage, 50 par défaut. La documentation parle d\u0026rsquo;un heap « d\u0026rsquo;environ 125 Mo ». C\u0026rsquo;est ce que montre le tableau : avec 256 Mo, le heap fait 126 Mo, pas 64.\nLes deux options se règlent au lancement :\njava -XX:MaxRAMPercentage=50 -jar app.jar Un pourcentage suit la limite du conteneur : si la limite passe de 1 à 2 Go, le heap suit. Un -Xmx fixe ne bouge pas (voir les options JVM qui comptent).\nSerial ou G1 : une question de CPU et de mémoire Le tableau a une surprise : avec 2 CPU et 1 Go, la JVM choisit Serial, pas G1. Le guide d\u0026rsquo;Oracle sur le GC donne la règle : G1 sur une machine « server-class », Serial sinon. Une machine est « server-class » si la JVM voit « deux processeurs ou plus et une mémoire supérieure ou égale à 1792 Mo ». Les tests le confirment : Serial à 1791 Mo, G1 à 1792 Mo.\nSerial arrête l\u0026rsquo;application pendant chaque GC, avec un seul thread. Pour un service avec peu de mémoire, ce n\u0026rsquo;est pas forcément un problème. Mais il faut le savoir, et le choisir. Pour imposer G1 : -XX:+UseG1GC.\nLe nombre de CPU change aussi le nombre de threads du GC. Avec 2 CPU, G1 a 2 threads parallèles et 1 thread concurrent. L\u0026rsquo;option -XX:ActiveProcessorCount=\u0026lt;n\u0026gt; remplace le nombre de CPU que la JVM utilise pour ces calculs. La documentation la présente comme utile « quand plusieurs processus Java tournent dans des conteneurs docker ».\nEt la demande de CPU de Kubernetes ? Dans un pod avec requests.cpu: 1 et sans limite de CPU, la JVM voit tous les CPU de la machine : 12 sur ma machine. Même chose avec l\u0026rsquo;option Docker --cpu-shares (le paramètre cgroup « cpu.shares »). Une limite, elle, réduit le nombre de CPU vus : avec limits.cpu: \u0026quot;2\u0026quot; (ou --cpus=2), la JVM en voit 2.\nAvant Java 19 : la JVM utilisait aussi « cpu.shares » pour compter les CPU, et pouvait en voir moins que disponible. Java 19 l\u0026rsquo;ignore (JDK-8281181). Dans mes tests, Temurin 17.0.20 l\u0026rsquo;ignore aussi.\nCe qui s\u0026rsquo;ajoute au heap La limite du conteneur couvre tout le processus, pas seulement le heap :\nle Metaspace : la description des classes chargées ; les piles des threads : par défaut 1024 Ko par thread sur Linux/x64, et 2048 Ko sur Linux/Aarch64 (par exemple Docker sur un Mac Apple Silicon) ; le code cache : le code compilé par le JIT ; la mémoire directe : les ByteBuffer.allocateDirect. Sans -XX:MaxDirectMemorySize, la JVM choisit la limite elle-même ; les données du GC et d\u0026rsquo;autres zones internes. Un heap à 75 % de la limite laisse 25 % pour tout cela. Si le reste dépasse, le conteneur est tué, même avec un heap à moitié vide.\nReproduire le kill OverLimit.java garde 250 Mo dans le heap, puis prend de la mémoire directe par blocs de 10 Mo, sans fin. Conteneur de 512 Mo sans swap, heap max à 75 % (384 Mo) :\ndocker run --name over-limit --memory=512m --memory-swap=512m -v \u0026#34;$PWD/containers\u0026#34;:/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 /app/OverLimit.java Heap: 250 MB used Direct memory: 10 MB ... Direct memory: 170 MB La sortie s\u0026rsquo;arrête net. Pas d\u0026rsquo;OutOfMemoryError, pas de pile : le processus a été tué. docker inspect confirme :\ndocker inspect over-limit --format \u0026#34;exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}\u0026#34; exit=137 OOMKilled=true 137, c\u0026rsquo;est 128 + 9 : le processus a reçu le signal 9 (SIGKILL), qu\u0026rsquo;on ne peut pas intercepter. La JVM n\u0026rsquo;a rien pu écrire, et -XX:+HeapDumpOnOutOfMemoryError ne sert à rien ici : il n\u0026rsquo;y a pas eu d\u0026rsquo;OutOfMemoryError. Selon la version, le kill arrive entre 170 et 190 Mo de mémoire directe.\nVoir où part la mémoire avec NMT Native Memory Tracking (NMT) compte la mémoire de la JVM par zone. Je relance avec NMT, et l\u0026rsquo;argument 100 arrête la mémoire directe à 100 Mo ; le programme attend ensuite 30 secondes :\ndocker run -d --name over-limit --memory=512m --memory-swap=512m -v \u0026#34;$PWD/containers\u0026#34;:/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 -XX:NativeMemoryTracking=summary /app/OverLimit.java 100 docker exec over-limit jcmd 1 VM.native_memory summary scale=MB Total: reserved=1875MB, committed=533MB - Java Heap (reserved=384MB, committed=384MB) - Class (reserved=1024MB, committed=2MB) - Thread (reserved=26MB, committed=1MB) - Code (reserved=245MB, committed=9MB) - Other (reserved=100MB, committed=100MB) - Metaspace (reserved=64MB, committed=9MB) (J\u0026rsquo;ai enlevé quelques lignes.) 1 est le numéro du processus Java dans le conteneur. Le heap occupe 384 Mo, et les 100 Mo de mémoire directe sont dans « Other ». Ce petit programme a peu de classes et de threads ; une vraie application a plus de Metaspace, de threads et de code compilé. docker stats --no-stream over-limit montre la mémoire que le conteneur utilise vraiment : environ 430 Mio dans mes essais, sur 512.\nMieux qu\u0026rsquo;un kill : une erreur Java Avec une limite sur la mémoire directe, la JVM s\u0026rsquo;arrête avant le conteneur, avec un message clair :\ndocker run --rm --memory=512m --memory-swap=512m -v \u0026#34;$PWD/containers\u0026#34;:/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 -XX:MaxDirectMemorySize=100m /app/OverLimit.java Exception in thread \u0026#34;main\u0026#34; java.lang.OutOfMemoryError: Cannot reserve 10485760 bytes of direct buffer memory (allocated: 104857600, limit: 104857600) Une erreur avec une pile dit ce qui est plein. Un kill ne dit rien. Les autres messages d\u0026rsquo;OutOfMemoryError sont dans l\u0026rsquo;article sur les OutOfMemoryError.\nChoisir MaxRAMPercentage Il n\u0026rsquo;y a pas de bon pourcentage pour toutes les applications. La règle est simple : le heap maximum plus tout le reste doit tenir dans la limite. Le reste se mesure avec NMT, sur l\u0026rsquo;application en charge.\nDans l\u0026rsquo;exemple, le programme a besoin de 250 Mo de heap et de 200 Mo de mémoire directe. Avec 768 Mo et 50 % :\ndocker run --name over-limit --memory=768m --memory-swap=768m -v \u0026#34;$PWD/containers\u0026#34;:/app eclipse-temurin:25 java -XX:MaxRAMPercentage=50 /app/OverLimit.java 200 Done: 250 MB heap, 200 MB direct, waiting 30 s docker inspect donne exit=0 OOMKilled=false. Le heap peut monter à 384 Mo (50 % de 768), et il reste 384 Mo pour la mémoire directe et le reste de la JVM. Plus une application utilise de mémoire hors heap (threads, mémoire directe, beaucoup de classes), plus le pourcentage doit être bas.\nDans Kubernetes Dans un pod, chaque conteneur a des requests et des limits. D\u0026rsquo;après la documentation de Kubernetes, la demande (requests) sert à choisir le nœud où placer le pod ; la limite (limits) est appliquée par le noyau. Pour la mémoire, un conteneur qui dépasse sa limite peut être tué (OOM kill). Pour le CPU, il est ralenti.\nLa limite de mémoire est donc celle que la JVM lit. Sans limits.memory, la JVM voit la mémoire du nœud : dans un pod avec seulement requests.memory: 1Gi, le heap max fait 1984 Mio sur ma machine (25 % des 7,7 Go de la VM de Docker), bien plus que la demande.\nPour l\u0026rsquo;exemple, le fichier Java arrive dans le pod par une ConfigMap : pas d\u0026rsquo;image à construire. Le manifeste pod-before.yaml reprend le cas tué :\ncommand: [\u0026#34;java\u0026#34;, \u0026#34;-XX:MaxRAMPercentage=75\u0026#34;, \u0026#34;/app/OverLimit.java\u0026#34;] resources: requests: memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;1\u0026#34; limits: memory: \u0026#34;512Mi\u0026#34; Les commandes kubectl utilisées :\nCommande Ce qu\u0026rsquo;elle fait kubectl create configmap over-limit-code --from-file=containers/OverLimit.java Range le fichier Java dans le cluster ; le pod le monte dans /app. kubectl apply -f containers/pod-before.yaml Crée le pod décrit dans le fichier. kubectl get pod over-limit Montre l\u0026rsquo;état du pod. kubectl describe pod over-limit Montre le détail : état, raison, code de sortie, limites. kubectl logs over-limit Montre ce que le programme a affiché. kubectl delete pod over-limit Supprime le pod. kubectl create configmap over-limit-code --from-file=containers/OverLimit.java kubectl apply -f containers/pod-before.yaml kubectl get pod over-limit NAME READY STATUS RESTARTS AGE over-limit 0/1 OOMKilled 0 7s kubectl describe pod over-limit State: Terminated Reason: OOMKilled Exit Code: 137 pod-after.yaml reprend la taille qui tient : limits.memory: 768Mi, -XX:MaxRAMPercentage=50. Après kubectl delete pod over-limit et kubectl apply -f containers/pod-after.yaml, le pod finit en Completed, code de sortie 0.\nCe qui a changé Ce qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis -XX:+UseCGroupMemoryLimitForHeap pour que Java voie la limite Rien à ajouter : la JVM lit les limites seule (UseContainerSupport) ; l\u0026rsquo;ancienne option est refusée JDK 10, et Java 8u191 ; option supprimée en Java 11 -XX:MaxRAMFraction=4 -XX:MaxRAMPercentage=25 (et InitialRAMPercentage, MinRAMPercentage) JDK 10 « cpu.shares » change le nombre de CPU vus par Java « cpu.shares » est ignoré Java 19 (Temurin 17.0.20 aussi, dans mes tests) Testé avec Docker Desktop 29.8 (VM Linux aarch64, cgroup v2), son Kubernetes (v1.36), et les images eclipse-temurin 17.0.20, 21.0.12 et 25.0.4.\nSources : la documentation de la commande java (JDK 25), le guide d\u0026rsquo;Oracle sur le GC, partie « Ergonomics », les notes de version du JDK 10, de Java 8u191 et du JDK 19, et la documentation de Kubernetes (Resource Management for Pods and Containers, Assign Memory Resources to Containers and Pods).\nPour aller plus loin Les options JVM qui comptent, et celles qui ne marchent plus OutOfMemoryError en Java : chaque message, reproduit Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/java-docker-kubernetes-oomkilled/","summary":"\u003cp\u003eLe pod redémarre. \u003ccode\u003ekubectl get pod\u003c/code\u003e affiche \u003ccode\u003eOOMKilled\u003c/code\u003e, le code de\nsortie est 137. Pourtant, les logs ne montrent aucune\n\u003ccode\u003eOutOfMemoryError\u003c/code\u003e, et le heap était loin d\u0026rsquo;être plein. Ce n\u0026rsquo;est pas\nla JVM qui a manqué de mémoire : c\u0026rsquo;est le conteneur. Le noyau Linux a\ntué le processus parce qu\u0026rsquo;il a dépassé sa limite.\u003c/p\u003e\n\u003cp\u003eCet article montre ce que la JVM voit dans un conteneur, ce qui\ns\u0026rsquo;ajoute au heap, comment reproduire le kill, et comment régler la\nmémoire pour que tout tienne. Tous les exemples sont dans le dossier\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/tree/main/containers\"\u003e\u003ccode\u003econtainers/\u003c/code\u003e\u003c/a\u003e\ndu dépôt public java-gc-lab. Ils utilisent les images officielles\n\u003ccode\u003eeclipse-temurin\u003c/code\u003e : pas d\u0026rsquo;image à construire.\u003c/p\u003e","title":"Java dans Docker et Kubernetes : éviter OOMKilled"},{"content":"Dans cette série, on a déjà utilisé une demi-douzaine d\u0026rsquo;outils : les logs du GC, jstat, plusieurs commandes jcmd, Eclipse MAT. Chacun répond à une question précise, et chacun a un coût. Cet article les range, puis montre JFR, l\u0026rsquo;outil le plus complet du JDK, sur un exemple : trouver la méthode qui crée des millions d\u0026rsquo;objets.\nQuel outil pour quelle question Question Outil Commande Coût Le GC fait-il des pauses longues ? Le heap après GC monte-t-il ? Logs du GC -Xlog:gc*:file=gc.log à garder en production Quelle zone se remplit, en direct ? jstat jstat -gcutil \u0026lt;pid\u0026gt; 1s non indiqué ; « expérimental et non supporté » d\u0026rsquo;après sa doc Combien de heap est utilisé ? jcmd jcmd \u0026lt;pid\u0026gt; GC.heap_info moyen Quelle classe grossit ? jcmd jcmd \u0026lt;pid\u0026gt; GC.class_histogram élevé : lance un Full GC Qui retient les objets ? heap dump + Eclipse MAT jcmd \u0026lt;pid\u0026gt; GC.heap_dump élevé : un gros fichier Le Metaspace grossit-il ? jcmd jcmd \u0026lt;pid\u0026gt; VM.metaspace moyen Que fait la mémoire native ? jcmd + NMT -XX:NativeMemoryTracking=summary, puis jcmd \u0026lt;pid\u0026gt; VM.native_memory moyen Que font les threads ? jcmd jcmd \u0026lt;pid\u0026gt; Thread.print moyen Quelle méthode alloue, où sont les pauses, dans le temps ? JFR -XX:StartFlightRecording, puis jfr view faible avec la configuration default Le coût « moyen » ou « élevé » des commandes jcmd est celui qu\u0026rsquo;indique leur documentation. Chacun de ces outils a déjà servi dans un article : les logs du GC, les OutOfMemoryError, les fuites mémoire et le heap dump avec Eclipse MAT.\nJFR pas à pas Java Flight Recorder (JFR) est inclus dans le JDK. Il enregistre des événements de la JVM et de l\u0026rsquo;application : GC, allocations, threads, méthodes les plus utilisées…\nEnregistrer. Au démarrage :\njava -XX:StartFlightRecording:filename=hot.jfr,settings=profile -jar app.jar Ou sur une application déjà lancée : jcmd \u0026lt;pid\u0026gt; JFR.start, puis jcmd \u0026lt;pid\u0026gt; JFR.dump filename=hot.jfr pour écrire l\u0026rsquo;enregistrement.\nChoisir la configuration. D\u0026rsquo;après la documentation d\u0026rsquo;Oracle, la configuration default est recommandée pour les enregistrements continus, avec en général moins de 1 % de surcoût. La configuration profile enregistre plus d\u0026rsquo;événements, comme les échantillons d\u0026rsquo;allocation : elle sert pour analyser un problème. La doc ne donne pas de chiffre pour son surcoût.\nLire. La commande jfr du JDK affiche des tableaux, sans interface graphique :\njfr view allocation-by-site hot.jfr jfr view gc hot.jfr Avant Java 21 : jfr view n\u0026rsquo;existe pas. En Java 17, la commande répond jfr: unknown command 'view' : il faut passer par jfr print ou par JDK Mission Control.\nExemple : la méthode qui crée des millions d\u0026rsquo;objets HotAllocation.java construit 10 millions de libellés avec String.format. On l\u0026rsquo;enregistre avec JFR, puis :\njfr view allocation-by-site hot.jfr Method Allocation Pressure --------------------------------------------------- ------------------- java.util.Formatter.parse(String) 24.70% java.lang.Long.toString(long) 18.83% java.lang.AbstractStringBuilder.\u0026lt;init\u0026gt;(int) 14.20% java.util.Arrays.copyOfRange(byte[], int, int) 10.61% java.util.ArrayList.grow(int) 10.35% Formatter.parse crée le plus d\u0026rsquo;objets. Ce sont des échantillons : les pourcentages changent un peu d\u0026rsquo;un lancement à l\u0026rsquo;autre, mais le haut du tableau reste le même. Reste à trouver l\u0026rsquo;appelant dans notre code : la pile d\u0026rsquo;un échantillon le donne.\njfr print --events jdk.ObjectAllocationSample --stack-depth 6 hot.jfr stackTrace = [ java.util.Formatter.parse(String) line: 2810 java.util.Formatter.format(Locale, String, Object[]) line: 2744 java.util.Formatter.format(String, Object[]) line: 2698 java.lang.String.format(String, Object[]) line: 4455 HotAllocation.label(int) line: 16 HotAllocation.main(String[]) line: 24 Le coupable : String.format, appelé dans label(). Chaque appel analyse le motif \u0026quot;order-%08d\u0026quot; et crée plusieurs objets. Dans FixedAllocation.java, les mêmes libellés sont construits avec un StringBuilder. Même résultat, avec -Xmx256m et JFR actif :\nHotAllocation FixedAllocation Java 25 32 GC, 2,7 s 9 GC, 0,7 s Java 21 44 GC 12 GC Java 17 94 GC 10 GC Moins d\u0026rsquo;objets créés, donc moins de GC : c\u0026rsquo;est le « débit d\u0026rsquo;allocation » de l\u0026rsquo;article sur les logs du GC.\njcmd, le couteau suisse jcmd envoie une commande à une JVM qui tourne. jcmd \u0026lt;pid\u0026gt; help liste toutes les commandes disponibles. Celles de la série : GC.heap_info, GC.class_histogram, GC.heap_dump, GC.run, VM.metaspace, VM.native_memory, Thread.print, et JFR.start / JFR.dump. Sa documentation indique l\u0026rsquo;impact de chacune : à lire avant de lancer une commande « High » sur une production chargée.\nVisualVM et JDK Mission Control Pour ceux qui préfèrent une fenêtre :\nVisualVM suit une JVM en direct (CPU, activité du GC, heap, Metaspace), affiche ses threads, prend et ouvre les heap dumps, et fait du profilage simple. JDK Mission Control ouvre les fichiers .jfr et montre les mêmes données que jfr view, en graphiques. Le même hot.jfr, ouvert dans JMC. JMC compte les allocations par classe, jfr view allocation-by-site par méthode : les deux vues viennent du même enregistrement.\nAvant Java 9 : VisualVM était livré avec le JDK (jvisualvm). Il a été retiré en Java 9 et se télécharge à part.\nLes deux s\u0026rsquo;installent séparément. Les commandes de cet article, elles, sont déjà dans le JDK et marchent sur un serveur sans écran.\nEn production À garder en continu : les logs du GC dans un fichier, et JFR avec la configuration default. Ponctuellement, en connaissant le coût : GC.class_histogram (un Full GC), GC.heap_dump (un gros fichier, avec des données sensibles), JFR avec profile. Avant d\u0026rsquo;agir : relire la ligne « Impact » de la commande dans la documentation de jcmd. Testé avec Java 17, 21 et 25 (Temurin 17.0.20, 21.0.12, 25.0.4), sur macOS ; les programmes compilent avec Java 8.\nSources : la documentation de jcmd, jfr et jstat, le guide de Flight Recorder sur ses configurations (JDK 25), JDK-8306703 (jfr view) et JDK-8180002 (VisualVM retiré du JDK).\nPour aller plus loin Heap dump Java : trouver une fuite mémoire avec Eclipse MAT Fuites mémoire en Java : 6 cas classiques, reproduits Lire un log GC en Java : chaque chiffre expliqué Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/outils-jfr-jcmd-visualvm/","summary":"\u003cp\u003eDans cette série, on a déjà utilisé une demi-douzaine d\u0026rsquo;outils : les\nlogs du GC, \u003ccode\u003ejstat\u003c/code\u003e, plusieurs commandes \u003ccode\u003ejcmd\u003c/code\u003e, Eclipse MAT. Chacun\nrépond à une question précise, et chacun a un coût. Cet article les\nrange, puis montre JFR, l\u0026rsquo;outil le plus complet du JDK, sur un exemple :\ntrouver la méthode qui crée des millions d\u0026rsquo;objets.\u003c/p\u003e\n\u003ch2 id=\"quel-outil-pour-quelle-question\"\u003eQuel outil pour quelle question\u003c/h2\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eQuestion\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eOutil\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eCommande\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eCoût\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eLe GC fait-il des pauses longues ? Le heap après GC monte-t-il ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eLogs du GC\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-Xlog:gc*:file=gc.log\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eà garder en production\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQuelle zone se remplit, en direct ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejstat\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejstat -gcutil \u0026lt;pid\u0026gt; 1s\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003enon indiqué ; « expérimental et non supporté » d\u0026rsquo;après sa doc\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCombien de heap est utilisé ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd \u0026lt;pid\u0026gt; GC.heap_info\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003emoyen\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQuelle classe grossit ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd \u0026lt;pid\u0026gt; GC.class_histogram\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eélevé : lance un Full GC\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQui retient les objets ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eheap dump + Eclipse MAT\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd \u0026lt;pid\u0026gt; GC.heap_dump\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eélevé : un gros fichier\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eLe Metaspace grossit-il ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd \u0026lt;pid\u0026gt; VM.metaspace\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003emoyen\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQue fait la mémoire native ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd\u003c/code\u003e + NMT\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:NativeMemoryTracking=summary\u003c/code\u003e, puis \u003ccode\u003ejcmd \u0026lt;pid\u0026gt; VM.native_memory\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003emoyen\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQue font les threads ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ejcmd \u0026lt;pid\u0026gt; Thread.print\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003emoyen\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eQuelle méthode alloue, où sont les pauses, dans le temps ?\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eJFR\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:StartFlightRecording\u003c/code\u003e, puis \u003ccode\u003ejfr view\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003efaible avec la configuration \u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eLe coût « moyen » ou « élevé » des commandes \u003ccode\u003ejcmd\u003c/code\u003e est celui qu\u0026rsquo;indique\nleur documentation. Chacun de ces outils a déjà servi dans un article :\n\u003ca href=\"/blog/lire-log-gc-java/\"\u003eles logs du GC\u003c/a\u003e,\n\u003ca href=\"/blog/outofmemoryerror-java-messages/\"\u003eles OutOfMemoryError\u003c/a\u003e,\n\u003ca href=\"/blog/fuites-memoire-java/\"\u003eles fuites mémoire\u003c/a\u003e et\n\u003ca href=\"/blog/heap-dump-java-eclipse-mat/\"\u003ele heap dump avec Eclipse MAT\u003c/a\u003e.\u003c/p\u003e","title":"JFR, jcmd, VisualVM : quel outil pour la mémoire Java"},{"content":"Dans l\u0026rsquo;article sur les fuites mémoire, deux histogrammes de jcmd montraient quelle classe grossit. Mais pas qui la retient. Pour ça, il faut un heap dump : une photo de tout le heap, avec les objets et les liens entre eux.\nOn reprend la même fuite : UnboundedCache.java, un cache HashMap statique qui reçoit une nouvelle clé à chaque requête. On l\u0026rsquo;analyse avec Eclipse Memory Analyzer (MAT), un outil open source que le guide d\u0026rsquo;Oracle cite pour analyser les heap dumps. Toutes les commandes sont dans le dossier heap-dump du dépôt java-gc-lab.\nÉtape 1 : prendre le heap dump javac -d out leaks/UnboundedCache.java java -Xmx256m -cp out UnboundedCache jcmd \u0026lt;pid\u0026gt; GC.heap_dump /tmp/cache.hprof Après 20 secondes, le fichier fait 22 Mo. Sur une vraie application, il peut faire plusieurs Go : -gz=1 l\u0026rsquo;écrit compressé. On peut aussi laisser la JVM l\u0026rsquo;écrire à la première OutOfMemoryError, avec -XX:+HeapDumpOnOutOfMemoryError (voir l\u0026rsquo;article sur les OutOfMemoryError).\nDeux précautions :\nLe dump contient les valeurs des objets. MAT affiche même le texte des String. Sur une application réelle, il peut contenir des mots de passe ou des données personnelles : on ne le partage pas, et on le supprime après usage. Compiler avant de lancer. Avec java UnboundedCache.java (sans javac), la classe est chargée en mémoire par le lanceur de fichiers source, et MAT désigne le thread main au lieu de la classe qui fuit. Lancée avec -cp, comme une application normale, la classe est bien désignée. Étape 2 : Leak Suspects Le rapport « Leak Suspects » est le premier à ouvrir. MAT y désigne les objets qui retiennent le plus de mémoire :\nCe qu\u0026rsquo;il faut lire : la classe UnboundedCache retient 93,87 % du heap, et la mémoire s\u0026rsquo;accumule dans un seul tableau, java.util.HashMap$Node[] : le tableau interne d\u0026rsquo;une HashMap. Le coupable est trouvé du premier coup.\nÉtape 3 : shallow heap et retained heap MAT affiche deux tailles pour chaque objet :\nle shallow heap : la mémoire de l\u0026rsquo;objet lui-même ; le retained heap : la mémoire que le GC libérerait si cet objet était libéré, c\u0026rsquo;est-à-dire tout ce qu\u0026rsquo;il garde en vie. Pour une fuite, c\u0026rsquo;est le retained heap qui compte. La classe UnboundedCache en est le meilleur exemple : son shallow heap est de 8 octets, son retained heap de 17,9 Mo.\nÉtape 4 : le dominator tree Pour MAT, un objet x domine un objet y si tout chemin depuis une racine du GC vers y passe par x. Le dominator tree range les objets selon cette règle, du plus gros retained heap au plus petit :\nClass Name Shallow Heap Retained Heap Percentage class UnboundedCache @ 0x124213cd0 8 17,919,200 93.87% java.lang.Object[2708] @ 0x133e00000 JNI Global 10,848 105,528 0.55% java.lang.Object[8187] @ 0x133e71f40 JNI Global 32,768 48,424 0.25% La première ligne écrase toutes les autres : c\u0026rsquo;est là qu\u0026rsquo;il faut creuser.\nÉtape 5 : le chemin vers les racines GC Reste à savoir pourquoi un objet Report reste en vie. « Path to GC Roots » remonte de l\u0026rsquo;objet jusqu\u0026rsquo;à une racine du GC, c\u0026rsquo;est-à-dire un objet accessible depuis l\u0026rsquo;extérieur du heap, comme une classe chargée (« System Class »). On exclut les références faibles et douces : elles n\u0026rsquo;empêchent pas le GC de libérer un objet.\nOn lit le chemin de bas en haut : la racine garde la classe UnboundedCache, son champ statique CACHE garde la HashMap, qui garde son tableau, qui garde chaque Report. C\u0026rsquo;est exactement la chaîne du schéma de l\u0026rsquo;article sur les fuites mémoire, mais cette fois trouvée dans un vrai dump.\nLa correction, et la courbe du heap La correction : donner une taille maximale au cache. Dans BoundedCache.java, une LinkedHashMap retire la plus ancienne entrée au-delà de 500 avec removeEldestEntry. Le heap après chaque GC monte pendant que le cache se remplit, puis ne bouge plus :\njava -Xmx256m -Xlog:gc heap-dump/BoundedCache.java [0.348s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 48M-\u0026gt;3M(256M) 1.559ms [16.656s][info][gc] GC(19) Pause Young (Normal) (G1 Evacuation Pause) 156M-\u0026gt;9M(256M) 0.828ms [35.667s][info][gc] GC(41) Pause Young (Normal) (G1 Evacuation Pause) 156M-\u0026gt;9M(256M) 0.984ms La lecture de ces lignes est expliquée dans l\u0026rsquo;article sur les logs du GC.\nMAT sans fenêtre MAT a aussi un mode batch, pratique sur un serveur sans écran : il analyse le dump et écrit les rapports en HTML. Les captures de cet article viennent de ce mode :\nParseHeapDump.sh /tmp/cache.hprof org.eclipse.mat.api:suspects ParseHeapDump.sh /tmp/cache.hprof -command=dominator_tree org.eclipse.mat.api:query Le premier écrit cache_Leak_Suspects.zip, le second cache_Query.zip. Les commandes complètes, avec le chemin vers les racines GC, sont dans le README du dossier heap-dump. Pensez à supprimer le dump et les fichiers d\u0026rsquo;index que MAT écrit à côté.\nTesté avec Java 25 (Temurin 25.0.4) et Eclipse MAT 1.17, sur macOS ; la correction aussi avec Java 11, 17 et 21.\nSources : l\u0026rsquo;aide d\u0026rsquo;Eclipse MAT (Shallow vs. Retained Heap, Dominator Tree, Garbage Collection Roots, Batch mode), le guide de dépannage d\u0026rsquo;Oracle sur les fuites mémoire et la documentation de jcmd (JDK 25).\nPour aller plus loin Fuites mémoire en Java : 6 cas classiques, reproduits OutOfMemoryError en Java : chaque message, reproduit Lire un log GC en Java : chaque chiffre expliqué Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/heap-dump-java-eclipse-mat/","summary":"\u003cp\u003eDans \u003ca href=\"/blog/fuites-memoire-java/\"\u003el\u0026rsquo;article sur les fuites mémoire\u003c/a\u003e,\ndeux histogrammes de \u003ccode\u003ejcmd\u003c/code\u003e montraient \u003cstrong\u003equelle\u003c/strong\u003e classe grossit. Mais\npas \u003cstrong\u003equi\u003c/strong\u003e la retient. Pour ça, il faut un heap dump : une photo de\ntout le heap, avec les objets et les liens entre eux.\u003c/p\u003e\n\u003cp\u003eOn reprend la même fuite :\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/blob/level-2/leaks/UnboundedCache.java\"\u003eUnboundedCache.java\u003c/a\u003e,\nun cache \u003ccode\u003eHashMap\u003c/code\u003e statique qui reçoit une nouvelle clé à chaque requête.\nOn l\u0026rsquo;analyse avec Eclipse Memory Analyzer (MAT), un outil open source\nque le guide d\u0026rsquo;Oracle cite pour analyser les heap dumps. Toutes les commandes\nsont dans le dossier\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/tree/level-2/heap-dump\"\u003eheap-dump\u003c/a\u003e\ndu dépôt \u003ccode\u003ejava-gc-lab\u003c/code\u003e.\u003c/p\u003e","title":"Heap dump Java : trouver une fuite mémoire avec Eclipse MAT"},{"content":"En Java, le garbage collector libère les objets que plus rien ne référence. Une fuite mémoire, c\u0026rsquo;est donc un objet devenu inutile mais encore référencé : le GC ne peut pas le libérer. Le guide d\u0026rsquo;Oracle le dit ainsi : l\u0026rsquo;application garde des références à des objets « sans le vouloir ».\nVoici six fuites classiques, chacune reproduite par un petit programme du dépôt public java-gc-lab, avec ce qu\u0026rsquo;on voit et la correction.\nCe qui retient un objet La chaîne de références d'une fuite : une racine GC, le champ static AUDIT de la classe StaticCollection, référence une ArrayList, qui référence des objets Order ; ils restent en vie. Un objet temporaire que plus rien ne référence est libéré par le GC. Ce qui retient un objet tout objet atteignable depuis une racine reste en vie Racine GC static AUDIT ArrayList la liste du champ Order Order Order objets Order inutiles, mais atteignables restent en vie : la fuite objet temporaire plus référencé → libéré par le GC Le GC part de « racines » : les champs statiques des classes chargées, les variables locales des threads en cours… Tout objet qu\u0026rsquo;on peut atteindre depuis une racine reste en vie. Une fuite, c\u0026rsquo;est un chemin qu\u0026rsquo;on a oublié de couper.\nCas Ce qui retient l\u0026rsquo;objet Correction Collection statique un champ static retirer les éléments, ou ne pas les garder là Cache sans limite la map du cache une taille maximale ou une durée de vie Listeners la liste des listeners du bus d\u0026rsquo;événements retirer le listener ThreadLocal dans un pool le thread du pool, qui ne s\u0026rsquo;arrête jamais remove() à la fin de la tâche Class loaders un objet de l\u0026rsquo;ancienne version lâcher toutes les références à l\u0026rsquo;ancienne version Ressources pas fermées rien… mais le fichier reste ouvert try-with-resources Comment voir une fuite Premier signe : le heap après chaque GC monte d\u0026rsquo;un GC à l\u0026rsquo;autre. C\u0026rsquo;est expliqué avec de vrais logs dans l\u0026rsquo;article sur les OutOfMemoryError.\nPour savoir quel objet fuit, on prend deux histogrammes des classes, à quelques secondes ou minutes d\u0026rsquo;intervalle, sur l\u0026rsquo;application qui tourne :\njcmd \u0026lt;pid\u0026gt; GC.class_histogram | head La classe dont le nombre d\u0026rsquo;instances monte, c\u0026rsquo;est la fuite. Attention : cette commande lance d\u0026rsquo;abord un Full GC, à éviter en pleine charge sur une production sensible. Chaque programme de java-gc-lab fuit lentement (environ 30 secondes) et affiche son numéro de processus, pour laisser le temps de le faire.\nLes six cas 1. Une collection statique private static final List\u0026lt;Order\u0026gt; AUDIT = new ArrayList\u0026lt;\u0026gt;(); // à chaque requête : AUDIT.add(new Order()); Un champ static vit aussi longtemps que sa classe, donc toute la liste aussi. L\u0026rsquo;histogramme, après 5 s puis après 20 s :\n48: 368 5888 StaticCollection$Order 19: 1584 25344 StaticCollection$Order Correction : retirer les éléments quand ils ne servent plus, ou ne pas les garder dans une collection statique.\n2. Un cache sans limite private static final Map\u0026lt;String, Report\u0026gt; CACHE = new HashMap\u0026lt;\u0026gt;(); // une nouvelle clé à chaque requête : le cache ne fait que grossir CACHE.put(requestId, new Report()); 48: 378 6048 UnboundedCache$Report 19: 1592 25472 UnboundedCache$Report Correction : une taille maximale. Avec le JDK seul, une LinkedHashMap qui redéfinit removeEldestEntry retire la plus ancienne entrée quand la taille dépasse un maximum ; la javadoc la présente justement pour un cache.\n3. Des listeners jamais retirés Screen screen = new Screen(); BUS.register(screen); // l\u0026#39;écran est fermé, mais jamais retiré du bus 48: 375 6000 Listeners$Screen 19: 1592 25472 Listeners$Screen Le bus d\u0026rsquo;événements vit longtemps, et il garde chaque écran. Correction : retirer le listener quand l\u0026rsquo;écran est fermé.\n4. Un ThreadLocal dans un pool de threads pool.submit(() -\u0026gt; { BUFFER.get().add(new Record()); // pas de BUFFER.remove() }); 48: 376 6016 ThreadLocalInPool$Record 20: 1585 25360 ThreadLocalInPool$Record D\u0026rsquo;après la javadoc, chaque thread garde sa copie d\u0026rsquo;un ThreadLocal tant qu\u0026rsquo;il est en vie. Les threads d\u0026rsquo;un pool ne s\u0026rsquo;arrêtent jamais : leurs buffers grossissent sans fin. Correction : BUFFER.remove() à la fin de chaque tâche, dans un finally.\n5. Des class loaders ClassLoader loader = new ClassLoader(parent) { }; PLUGINS.add(Proxy.newProxyInstance(loader, interfaces, handler)); Chaque « rechargement » d\u0026rsquo;un plugin crée un class loader et une nouvelle classe. La liste garde un objet de chaque version, donc aucun ancien class loader ne peut être libéré. Ici, le heap grossit peu : c\u0026rsquo;est le Metaspace qui grossit.\njcmd \u0026lt;pid\u0026gt; VM.metaspace | grep \u0026#34;Total Usage\u0026#34; Total Usage - 393 loaders, 3538 classes (1261 shared): Total Usage - 1515 loaders, 4661 classes (1261 shared): Avec une limite, cela finit en OutOfMemoryError: Metaspace (voir l\u0026rsquo;article sur les OutOfMemoryError). Correction : lâcher toutes les références à l\u0026rsquo;ancienne version (objets, classes, threads, listeners) avant de recharger.\n6. Des ressources pas fermées FileInputStream in = new FileInputStream(file); in.read(); // pas de close() Ce cas est différent. Le flux n\u0026rsquo;est gardé nulle part, donc ce n\u0026rsquo;est pas une fuite du heap : dans mes tests, quand un GC passe, le JDK ferme les fichiers des flux qu\u0026rsquo;il libère (jcmd \u0026lt;pid\u0026gt; GC.run : d\u0026rsquo;environ 830 fichiers ouverts à environ 90). Mais ce programme ne crée presque pas de déchets : aucun GC ne passe, et les fichiers ouverts s\u0026rsquo;accumulent (lsof : environ 400 après 5 s, 2 000 après 25 s). Avec une limite de 512 fichiers ouverts (dans Docker) :\njava.io.FileNotFoundException: /tmp/java-gc-lab8035917117579278064.txt (Too many open files) Le heap va très bien, et pourtant l\u0026rsquo;application tombe. Correction : try-with-resources. La javadoc d\u0026rsquo;AutoCloseable le dit : il assure une libération rapide et évite d\u0026rsquo;épuiser les ressources.\nÉviter les fuites En revue de code, chercher les champs static qui contiennent des collections, les collections sans taille maximale, les register sans unregister, les ThreadLocal sans remove(), les flux ouverts hors d\u0026rsquo;un try-with-resources. Donner une limite à tout ce qui grossit : cache, file d\u0026rsquo;attente, buffer. Les références faibles : une WeakHashMap retire une entrée quand sa clé n\u0026rsquo;est plus utilisée ailleurs. Piège noté dans la javadoc : si la valeur référence sa propre clé, l\u0026rsquo;entrée ne part jamais. Surveiller le heap après GC en production : une fuite lente se voit sur des heures, pas sur une minute. Testé avec Java 8 (compilation, et le cas des fichiers), 11, 17, 21 et 25 (Temurin 11.0.32, 17.0.20, 21.0.9, 25.0.4), sur macOS ; le cas des fichiers aussi dans Docker (eclipse-temurin:8 et :25).\nSources : le guide de dépannage d\u0026rsquo;Oracle sur les fuites mémoire, la documentation de jcmd, et la javadoc JDK 25 de ThreadLocal, LinkedHashMap, WeakHashMap et AutoCloseable.\nPour aller plus loin Heap dump Java : trouver une fuite mémoire avec Eclipse MAT OutOfMemoryError en Java : chaque message, reproduit Lire un log GC en Java : chaque chiffre expliqué Idées reçues sur le garbage collector Java Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/fuites-memoire-java/","summary":"\u003cp\u003eEn Java, le garbage collector libère les objets que plus rien ne\nréférence. Une fuite mémoire, c\u0026rsquo;est donc un objet devenu inutile mais\nencore référencé : le GC ne peut pas le libérer. Le guide d\u0026rsquo;Oracle le\ndit ainsi : l\u0026rsquo;application garde des références à des objets « sans le\nvouloir ».\u003c/p\u003e\n\u003cp\u003eVoici six fuites classiques, chacune reproduite par un petit programme\ndu dépôt public\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/tree/level-2/leaks\"\u003ejava-gc-lab\u003c/a\u003e,\navec ce qu\u0026rsquo;on voit et la correction.\u003c/p\u003e","title":"Fuites mémoire en Java : 6 cas classiques, reproduits"},{"content":"Activer le log du GC est facile. Le lire l\u0026rsquo;est moins : une ligne de G1 contient une dizaine d\u0026rsquo;informations, et les mots changent d\u0026rsquo;une version de Java à l\u0026rsquo;autre.\nCet article lit de vraies lignes, produites par trois petits programmes du dépôt public java-gc-lab : une charge normale, des objets très gros, et un heap presque plein.\nActiver le log java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar gc* : tous les messages du GC (gc seul donne une ligne par GC) ; file=gc.log : dans un fichier, avec une rotation déjà intégrée (5 fichiers d\u0026rsquo;environ 20 Mo par défaut) ; time,uptime,level,tags : les « décorations » au début de chaque ligne : la date et l\u0026rsquo;heure, le temps depuis le démarrage de la JVM, le niveau et les tags. Sans rien préciser, la JVM met uptime, level et tags. Une ligne de pause, avec ces décorations :\n[2026-10-22T23:23:46.842+0200][0.356s][info][gc ] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 117M-\u0026gt;24M(256M) 1.660ms L\u0026rsquo;heure exacte (time) sert à relier une pause à un événement de l\u0026rsquo;application, par exemple une requête lente dans un autre log.\nUne ligne, mot par mot Une ligne de log G1 en Java 25, découpée en cinq parties : GC(1), le numéro du GC ; Pause Young (Normal), le type de pause ; (G1 Evacuation Pause), la cause ; 117M-\u0026gt;23M(256M), le heap avant et après le GC et sa taille ; 5.790ms, la durée de la pause Une ligne de log G1 Java 25, -Xlog:gc GC(1) n° du GC Pause Young (Normal) type de pause (G1 Evacuation Pause) cause du GC 117M-\u0026gt;23M(256M) heap avant -\u0026gt; après (taille) 5.790ms durée de la pause Partie Sens [0.469s] le temps depuis le démarrage de la JVM (uptime) GC(1) le numéro du GC : toutes les lignes d\u0026rsquo;un même GC ont le même Pause Young (Normal) le type de pause : ici une pause young normale. On voit aussi Concurrent Start, Prepare Mixed, Mixed, Remark, Cleanup et Full (G1 Evacuation Pause) la cause du GC, ici le cas courant. Autres causes : G1 Humongous Allocation, System.gc(), G1 Compaction Pause (Full GC) 117M-\u0026gt;23M(256M) le heap utilisé avant le GC, après le GC, et la taille du heap 5.790ms la durée de la pause : l\u0026rsquo;application est arrêtée pendant ce temps Les mots young, mixed et full sont expliqués dans l\u0026rsquo;article sur les générations.\nLes 4 chiffres à suivre Voici le début et la fin du log de NormalLoad.java, une application qui garde environ 16 Mo de données et crée des objets temporaires en continu :\njava -Xmx256m -Xlog:gc gc-logs/NormalLoad.java [0.341s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 47M-\u0026gt;18M(256M) 3.090ms [0.469s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 117M-\u0026gt;23M(256M) 5.790ms [0.646s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 149M-\u0026gt;24M(256M) 4.129ms … [3.240s][info][gc] GC(15) Pause Young (Normal) (G1 Evacuation Pause) 171M-\u0026gt;24M(256M) 0.635ms [3.478s][info][gc] GC(16) Pause Young (Normal) (G1 Evacuation Pause) 171M-\u0026gt;24M(256M) 12.874ms La fréquence des GC : 17 GC en un peu plus de 3 secondes, environ 5 par seconde. Elle monte quand l\u0026rsquo;application crée plus d\u0026rsquo;objets ou quand le heap est trop petit. La durée des pauses : de 0,4 à 12,9 ms ici. On regarde surtout les plus longues : ce sont elles que voient les utilisateurs. Le heap après le GC : 23 à 24 Mo, stable. C\u0026rsquo;est à peu près la taille des données vivantes. S\u0026rsquo;il monte d\u0026rsquo;un GC à l\u0026rsquo;autre, c\u0026rsquo;est une fuite (voir l\u0026rsquo;article sur les OutOfMemoryError). Le débit d\u0026rsquo;allocation : le heap avant un GC moins le heap après le GC précédent, divisé par le temps entre les deux. Entre GC(0) et GC(1) : (117 − 18) Mo en 0,128 s, soit environ 770 Mo/s. Aucune ligne ne le donne directement : il faut le calculer. Ce qui doit alerter Des objets humongous java -Xmx256m -Xlog:gc gc-logs/Humongous.java [0.306s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 146M-\u0026gt;9M(256M) 1.583ms Pour G1, un objet « humongous » fait au moins la moitié d\u0026rsquo;une région. Il ne passe pas par l\u0026rsquo;Eden : il reçoit ses propres régions, directement dans la Old. Ici, le programme crée des tableaux de 2 Mo avec des régions de 1 Mo (Heap Region Size: 1M au démarrage avec -Xlog:gc*), et c\u0026rsquo;est l\u0026rsquo;allocation elle-même qui lance le GC. Beaucoup de lignes G1 Humongous Allocation indiquent de gros tableaux ou de gros buffers créés souvent.\nUn échec d\u0026rsquo;évacuation, puis Pause Full java -Xmx64m -Xlog:gc gc-logs/NearTheLimit.java [0.295s][info][gc] GC(5) Pause Young (Prepare Mixed) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 61M-\u0026gt;47M(64M) 0.250ms [1.348s][info][gc] GC(175) Pause Full (G1 Compaction Pause) 62M-\u0026gt;45M(64M) 5.691ms Le programme garde environ 30 Mo de données dans un heap de 64 Mo. G1 ne trouve plus assez de place pour copier les objets vivants : c\u0026rsquo;est un échec d\u0026rsquo;évacuation. La raison est Allocation (pas de place) ou Pinned (un objet bloqué par du code natif). Si un GC ne libère plus rien, G1 lance un Full GC, qui compacte tout le heap et peut être très lent.\nAvant Java 18 : l\u0026rsquo;échec d\u0026rsquo;évacuation s\u0026rsquo;appelait to-space exhausted. En Java 11 et 17, c\u0026rsquo;était une ligne à part (GC(7) To-space exhausted) ; en Java 8, la fin de la ligne de pause. Java 21 écrit (Evacuation Failure), et Java 25 ajoute la raison.\nUne ligne Pause Full avec G1, quand elle ne vient pas de System.gc(), est toujours à regarder : le heap est trop petit pour les données vivantes, ou il y a une fuite.\nDes pauses qui s\u0026rsquo;allongent Si le dernier chiffre des lignes monte au fil des heures, avec le même type de pause, le GC a de plus en plus de travail : plus de données vivantes à copier, ou un heap qui se remplit. C\u0026rsquo;est un signe à suivre de près.\nLes outils JFR (Java Flight Recorder), dans le JDK : on le lance avec -XX:StartFlightRecording, et on lit l\u0026rsquo;enregistrement avec JDK Mission Control. Il montre les pauses du GC avec le reste de l\u0026rsquo;application. Les détails sont dans l\u0026rsquo;article sur les outils. Des outils tiers lisent aussi les fichiers de log et en font des graphiques, par exemple GCeasy (un service en ligne) ou GCViewer (open source). Ce qui a changé Ce qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis -XX:+PrintGCDetails, -Xloggc:gc.log -Xlog:gc*, -Xlog:gc*:file=gc.log (les anciennes options sont dépréciées ou refusées, voir les options JVM) Java 9 to-space exhausted Evacuation Failure, puis Evacuation Failure: \u0026lt;raison\u0026gt; Java 18 (JDK-8269022), la raison en Java 25 dans mes tests -XX:+PrintGCDateStamps pour avoir l\u0026rsquo;heure la décoration time de -Xlog Java 9 Testé avec Java 8 (Corretto 8u504), 11, 17, 21 et 25 (Temurin 11.0.32, 17.0.20, 21.0.9, 25.0.4), sur macOS, avec G1.\nSources : la documentation de la commande java (JDK 25, -Xlog), le chapitre G1 du guide d\u0026rsquo;Oracle sur le GC (objets humongous, échec d\u0026rsquo;évacuation, Full GC), JDK-8269022 (le nouveau nom de l\u0026rsquo;échec d\u0026rsquo;évacuation).\nPour aller plus loin Générations et cycles de GC : Young, Old, Minor, Major, Full OutOfMemoryError en Java : chaque message, reproduit Les options JVM qui comptent, et celles qui ne marchent plus Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/lire-log-gc-java/","summary":"\u003cp\u003eActiver le log du GC est facile. Le lire l\u0026rsquo;est moins : une ligne de G1\ncontient une dizaine d\u0026rsquo;informations, et les mots changent d\u0026rsquo;une version\nde Java à l\u0026rsquo;autre.\u003c/p\u003e\n\u003cp\u003eCet article lit de vraies lignes, produites par trois petits programmes\ndu dépôt public\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/tree/level-2/gc-logs\"\u003ejava-gc-lab\u003c/a\u003e :\nune charge normale, des objets très gros, et un heap presque plein.\u003c/p\u003e\n\u003ch2 id=\"activer-le-log\"\u003eActiver le log\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ejava -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003e\u003ccode\u003egc*\u003c/code\u003e : tous les messages du GC (\u003ccode\u003egc\u003c/code\u003e seul donne une ligne par GC) ;\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003efile=gc.log\u003c/code\u003e : dans un fichier, avec une rotation déjà intégrée (5\nfichiers d\u0026rsquo;environ 20 Mo par défaut) ;\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003etime,uptime,level,tags\u003c/code\u003e : les « décorations » au début de chaque\nligne : la date et l\u0026rsquo;heure, le temps depuis le démarrage de la JVM, le\nniveau et les tags. Sans rien préciser, la JVM met \u003ccode\u003euptime\u003c/code\u003e, \u003ccode\u003elevel\u003c/code\u003e\net \u003ccode\u003etags\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eUne ligne de pause, avec ces décorations :\u003c/p\u003e","title":"Lire un log GC en Java : chaque chiffre expliqué"},{"content":"Une OutOfMemoryError, ce n\u0026rsquo;est pas un seul problème. Le texte après les deux-points dit quelle zone de la mémoire est pleine : le heap, le Metaspace, la mémoire directe ou les threads. Et chaque zone se vérifie et se corrige autrement.\nPour chaque message, j\u0026rsquo;ai écrit un petit programme qui le provoque. Ils sont dans le dépôt public java-gc-lab, un fichier par message, à lancer avec java sans compiler (Java 11 ou plus).\nLe message dit quelle zone est pleine Les zones de la mémoire d'un processus Java et l'OutOfMemoryError de chacune : unable to create native thread pour les stacks des threads, Java heap space et GC overhead limit exceeded pour le heap, Metaspace pour le Metaspace, Cannot reserve … bytes of direct buffer memory pour la mémoire directe Processus Java chaque message dit quelle zone est pleine Stacks des threads un thread du système par new Thread · -Xss limite : le système unable to create native thread Heap partagé · géré par le GC -Xmx Java heap space GC overhead limit exceeded Parallel ; G1 depuis 25.0.3 Metaspace mémoire native -XX:MaxMetaspaceSize Metaspace Direct memory ByteBuffer.allocateDirect() · -XX:MaxDirectMemorySize Cannot reserve … bytes of direct buffer memory (allocated: …, limit: …) en Java 17, 21 et 25 · hors du heap · Java 8 et 11 : Direct buffer memory Message Zone pleine Première vérification Java heap space le heap le heap après chaque GC (-Xlog:gc*, jcmd \u0026lt;pid\u0026gt; GC.heap_info) GC overhead limit exceeded le heap, presque plein en continu pareil : le GC tourne sans arrêt Metaspace la description des classes jcmd \u0026lt;pid\u0026gt; VM.metaspace Cannot reserve … bytes of direct buffer memory la mémoire directe -XX:MaxDirectMemorySize, jcmd \u0026lt;pid\u0026gt; VM.native_memory unable to create native thread les threads du système jcmd \u0026lt;pid\u0026gt; Thread.print, les limites du système Java heap space java -Xmx64m oom/HeapSpace.java java.lang.OutOfMemoryError: Java heap space Le programme garde dans une liste des tableaux de 1 Mo : le GC ne peut rien libérer, et le heap de 64 Mo est plein en moins d\u0026rsquo;une seconde.\nLe guide d\u0026rsquo;Oracle est clair : ce message ne veut pas forcément dire qu\u0026rsquo;il y a une fuite. Le heap peut simplement être trop petit pour l\u0026rsquo;application. Mais dans une application qui tourne longtemps, c\u0026rsquo;est souvent le signe qu\u0026rsquo;elle garde des références à des objets sans le vouloir.\nPour savoir lequel des deux, regardez le heap après chaque GC : avec -Xlog:gc*, ou avec jcmd \u0026lt;pid\u0026gt; GC.heap_info sur une application qui tourne. S\u0026rsquo;il redescend toujours au même niveau, le heap est juste trop petit. S\u0026rsquo;il monte d\u0026rsquo;un GC à l\u0026rsquo;autre, quelque chose garde les objets.\nGC overhead limit exceeded java -Xmx32m -XX:+UseParallelGC oom/GcOverhead.java java.lang.OutOfMemoryError: GC overhead limit exceeded Ici, le heap se remplit lentement, avec de petits objets. Le GC tourne sans arrêt et ne libère presque rien. La JVM préfère alors s\u0026rsquo;arrêter plutôt que de continuer sans avancer : d\u0026rsquo;après la documentation, elle lève cette erreur si plus de 98 % du temps passe dans le GC et que moins de 2 % du heap est récupéré, sur cinq GC de suite.\nCe message n\u0026rsquo;est pas garanti. Avec le même programme et le GC Parallel, voici ce que j\u0026rsquo;obtiens :\n-Xmx Java 8 Java 11 Java 17 Java 21 Java 25 16m GC overhead Java heap space Java heap space Java heap space Java heap space 32m pas d\u0026rsquo;erreur après 60 s GC overhead GC overhead GC overhead pas d\u0026rsquo;erreur après 60 s 128m pas d\u0026rsquo;erreur après 60 s GC overhead GC overhead GC overhead Java heap space « Pas d\u0026rsquo;erreur après 60 s », c\u0026rsquo;est le pire cas : la JVM passe son temps dans le GC et l\u0026rsquo;application ne fait plus rien, sans erreur.\nLongtemps, seul Parallel levait cette erreur. G1, le GC par défaut, l\u0026rsquo;a aussi depuis Java 26, avec un backport dans Java 25.0.3 (JDK-8212084). Mais dans mes tests avec G1 sur Java 25.0.4, de -Xmx16m à -Xmx256m, j\u0026rsquo;obtiens toujours Java heap space. D\u0026rsquo;après le guide, la cause habituelle est la même : des données vivantes qui tiennent à peine dans le heap.\nMetaspace java -XX:MaxMetaspaceSize=32m oom/MetaspaceFull.java java.lang.OutOfMemoryError: Metaspace Le Metaspace contient la description des classes, en mémoire native. Le programme crée en boucle un nouveau class loader et une nouvelle classe dedans ; comme il les garde, rien ne peut être déchargé.\nPar défaut, le Metaspace n\u0026rsquo;a pas de limite. Si une limite est fixée avec -XX:MaxMetaspaceSize, le guide conseille de l\u0026rsquo;augmenter. Si le Metaspace monte sans arrêt (jcmd \u0026lt;pid\u0026gt; VM.metaspace), le problème vient plutôt de classes qui ne peuvent pas être déchargées, comme dans ce programme.\nDirect buffer memory java -XX:MaxDirectMemorySize=32m oom/DirectBuffer.java java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 32514048, limit: 33554432) Les buffers directs (ByteBuffer.allocateDirect) vivent hors du heap. Leur limite est -XX:MaxDirectMemorySize ; sans cette option, la JVM la choisit elle-même.\nAvant Java 17 : en Java 8 et 11, le message était simplement Direct buffer memory. Depuis Java 17, dans mes tests, il donne la taille demandée, la mémoire déjà prise et la limite, ce qui aide beaucoup.\nPour voir cette mémoire sur une application qui tourne : -XX:NativeMemoryTracking=summary au démarrage, puis jcmd \u0026lt;pid\u0026gt; VM.native_memory.\nUnable to create native thread Ce programme démarre des threads jusqu\u0026rsquo;à ce que le système refuse. Ne le lancez pas sur votre machine sans limite : je l\u0026rsquo;ai lancé dans un conteneur limité à 200 processus.\ndocker run --rm --pids-limit 200 -v \u0026#34;$PWD/oom\u0026#34;:/oom eclipse-temurin:25 java /oom/NativeThreads.java java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached Avant Java 11 : en Java 8, le message était unable to create new native thread.\nChaque thread démarré avec new Thread est un thread du système, avec sa propre pile hors du heap. La cause est donc une limite du système (nombre de processus ou de threads, mémoire), ou une application qui crée des threads sans fin, par exemple un pool sans taille maximale. jcmd \u0026lt;pid\u0026gt; Thread.print montre tous les threads, avec leur pile d\u0026rsquo;appels. Augmenter -Xmx ne change rien à une limite du système.\nLa méthode, quel que soit le message Lire le message : il dit quelle zone est pleine (tableau du début). Avoir un heap dump : avec -XX:+HeapDumpOnOutOfMemoryError, la JVM écrit un fichier .hprof. Dans mes tests, il est écrit pour Java heap space, GC overhead limit exceeded et Metaspace, mais pas pour les buffers directs. Prévoyez la place : le fichier peut faire plusieurs Go. Regarder le heap après chaque GC : stable, le heap est trop petit ; en hausse, c\u0026rsquo;est une fuite (voir ci-dessous). Corriger : agrandir la bonne zone, ou trouver ce qui garde les objets. Heap trop petit, ou fuite ? Avec -Xlog:gc, chaque GC écrit une ligne. Le chiffre qui compte est celui après la flèche : le heap après le GC, donc à peu près les données encore vivantes.\nAvec SteadyLoad.java, les données vivantes ne changent pas. Après chaque GC, le heap revient au même niveau :\njava -Xmx64m -Xlog:gc oom/SteadyLoad.java GC(2) Pause Young (Normal) (G1 Evacuation Pause) 41M-\u0026gt;24M(64M) 0.379ms GC(5) Pause Young (Normal) (G1 Evacuation Pause) 47M-\u0026gt;24M(64M) 0.538ms GC(9) Pause Young (Normal) (G1 Evacuation Pause) 54M-\u0026gt;24M(64M) 0.624ms Avec SlowLeak.java, le programme garde 1 Mo de plus à chaque tour. Après chaque GC, le heap monte, jusqu\u0026rsquo;à Java heap space :\njava -Xmx64m -Xlog:gc oom/SlowLeak.java GC(1) Pause Young (Normal) (G1 Evacuation Pause) 30M-\u0026gt;5M(64M) 2.292ms GC(4) Pause Young (Normal) (G1 Evacuation Pause) 42M-\u0026gt;11M(64M) 0.916ms GC(8) Pause Young (Normal) (G1 Evacuation Pause) 50M-\u0026gt;19M(64M) 0.938ms GC(12) Pause Young (Normal) (G1 Evacuation Pause) 54M-\u0026gt;29M(64M) 1.283ms Sur une application déjà lancée, sans la redémarrer, jstat -gcutil \u0026lt;pid\u0026gt; 1s affiche chaque seconde l\u0026rsquo;occupation de chaque zone : dans le deuxième cas, la colonne O (la Old, en %) monte de la même façon. Sa documentation précise qu\u0026rsquo;il est expérimental et non supporté.\nAugmenter -Xmx n\u0026rsquo;est pas une faute en soi : c\u0026rsquo;est même la première action que propose le guide d\u0026rsquo;Oracle pour Java heap space. L\u0026rsquo;erreur, c\u0026rsquo;est de l\u0026rsquo;augmenter encore et encore quand le heap remonte après chaque GC : la fuite est toujours là, elle prend juste plus de temps à remplir le heap.\nCe qui a changé Ce qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis OutOfMemoryError: Direct buffer memory Cannot reserve … bytes of direct buffer memory (allocated: …, limit: …) Java 17 dans mes tests (11 avait encore l\u0026rsquo;ancien) unable to create new native thread unable to create native thread: possibly out of memory or process/resource limits reached Java 11 dans mes tests (8 avait l\u0026rsquo;ancien) GC overhead limit exceeded vient seulement de Parallel G1 peut aussi la lever Java 26, et Java 25.0.3 par backport PermGen space Metaspace Java 8 Testé avec Java 8 (Corretto 8u504), 11, 17, 21 et 25 (Temurin 11.0.32, 17.0.20, 21.0.9, 25.0.4), sur macOS ; le cas des threads dans Docker (eclipse-temurin).\nSources : le guide de dépannage d\u0026rsquo;Oracle sur les fuites mémoire, la documentation des commandes java, jcmd et jstat (JDK 25), JDK-8212084 (G1 et la limite d\u0026rsquo;overhead).\nPour aller plus loin La mémoire de la JVM : Heap, Stack, Metaspace Les options JVM qui comptent, et celles qui ne marchent plus Garbage collector Java : le guide à jour (Java 25) ","permalink":"https://aymenjamaaoui.dev/blog/outofmemoryerror-java-messages/","summary":"\u003cp\u003eUne \u003ccode\u003eOutOfMemoryError\u003c/code\u003e, ce n\u0026rsquo;est pas un seul problème. Le texte après\nles deux-points dit quelle zone de la mémoire est pleine : le heap, le\nMetaspace, la mémoire directe ou les threads. Et chaque zone se\nvérifie et se corrige autrement.\u003c/p\u003e\n\u003cp\u003ePour chaque message, j\u0026rsquo;ai écrit un petit programme qui le provoque. Ils\nsont dans le dépôt public\n\u003ca href=\"https://gitlab.com/aymenjamaaoui/java-gc-lab/-/tree/level-2/oom\"\u003ejava-gc-lab\u003c/a\u003e,\nun fichier par message, à lancer avec \u003ccode\u003ejava\u003c/code\u003e sans compiler (Java 11 ou\nplus).\u003c/p\u003e","title":"OutOfMemoryError en Java : chaque message, reproduit"},{"content":"Sur le garbage collector Java, on trouve beaucoup d\u0026rsquo;articles, et beaucoup sont anciens : PermGen, CMS, -XX:+PrintGCDetails… Ce guide résume ce qui est vrai en Java 25, en quelques lignes par sujet. Chaque partie renvoie à un article plus complet, où chaque fait est vérifié dans la documentation officielle ou sur une vraie JVM.\nJe mets ce guide à jour quand la série avance, ou quand une nouvelle version de Java change les règles.\nLa mémoire de la JVM La JVM range ses données dans plusieurs zones :\nla stack : une par thread, avec une frame par appel de méthode (paramètres, variables locales). Elle se libère toute seule, sans le GC. Trop d\u0026rsquo;appels empilés donnent un StackOverflowError ; le heap : les objets créés avec new, partagés par tous les threads. C\u0026rsquo;est la zone du garbage collector. Sa taille maximale se règle avec -Xmx ; le Metaspace : la description des classes, en mémoire native, hors du heap. Il a remplacé PermGen en Java 8. La JVM utilise aussi de la mémoire native pour les stacks des threads, le code compilé par le JIT, les buffers directs et ses structures internes. -Xmx ne limite que le heap : le processus Java utilise toujours plus.\n→ La mémoire de la JVM : Heap, Stack, Metaspace\nGénérations et cycles La plupart des objets meurent jeunes. La JVM range donc les objets récents dans la Young (l\u0026rsquo;Eden et deux Survivor) et ceux qui durent dans la Old :\nLe trajet d'un objet dans le heap : il naît dans l'Eden, passe d'un Survivor à l'autre à chaque minor collection, puis il est promu dans la Old Heap Young les objets récents Eden les objets naissent ici objet Survivor 0 les deux Survivor servent Survivor 1 à tour de rôle ; un est vide Old les objets qui durent objet 1 2 3 1 Minor collection : l'objet vivant est copié de l'Eden vers un Survivor. 2 À chaque minor collection, il passe dans l'autre Survivor ; son âge augmente. 3 Après assez de copies (MaxTenuringThreshold, 15 par défaut) : promu dans la Old. Un objet naît dans l\u0026rsquo;Eden, passe d\u0026rsquo;un Survivor à l\u0026rsquo;autre à chaque GC, puis il est promu dans la Old après un certain nombre de copies (15 au maximum par défaut).\nDans le guide d\u0026rsquo;Oracle, une minor collection ne nettoie que la Young, et une major collection nettoie tout le heap. G1, le GC par défaut, n\u0026rsquo;utilise pas ces mots dans ses logs : il parle de pauses Young, Mixed et Full. Et depuis Java 9, il découpe le heap en régions de même taille, au lieu de deux grands blocs.\n→ Générations et cycles de GC : Young, Old, Minor, Major, Full\nLes garbage collectors Java 25 propose plusieurs GC, chacun pour un besoin :\nG1, le GC par défaut depuis Java 9 : quand le temps de réponse compte plus que le débit ; ZGC et Shenandoah : des pauses très courtes, pour la latence ; Parallel : le débit avant tout ; Serial : un seul thread, pour les petites machines ; Epsilon : ne libère jamais rien, pour des tests. CMS n\u0026rsquo;existe plus depuis Java 14. Attention aussi au choix automatique : sur une machine avec un seul processeur ou moins de 1792 Mo de mémoire, la JVM prend Serial au lieu de G1. Un petit conteneur peut donc tourner avec un autre GC que le poste du développeur. Pour le vérifier : -Xlog:gc écrit le GC utilisé dès la première ligne (Using G1).\n→ Les garbage collectors Java en 2026 : lequel tourne chez vous ?\nLes options utiles Quelques options suffisent dans la plupart des cas :\n-Xms et -Xmx : la taille initiale et maximale du heap, souvent la même valeur sur un serveur ; -Xlog:gc*:file=gc.log : les logs du GC dans un fichier, avec une rotation déjà intégrée ; -XX:+HeapDumpOnOutOfMemoryError et -XX:HeapDumpPath : un dump du heap quand il est plein ; -XX:MaxRAMPercentage : le heap maximum en pourcentage de la mémoire (25 % par défaut), utile dans un conteneur. Beaucoup d\u0026rsquo;anciennes options ne marchent plus. Certaines sont seulement dépréciées (-XX:+PrintGCDetails, -Xloggc), mais d\u0026rsquo;autres empêchent la JVM de démarrer en Java 17, 21 et 25 : -XX:MaxPermSize, -XX:+UseConcMarkSweepGC, -XX:+PrintGCDateStamps… Pour tester une configuration, il suffit de lancer java \u0026lt;options\u0026gt; -version.\n→ Les options JVM qui comptent, et celles qui ne marchent plus\nLes idées reçues System.gc() n\u0026rsquo;est qu\u0026rsquo;une demande. Mais avec G1, Parallel et Serial, elle déclenche en général un Full GC, qui coûte cher. finalize() est dépréciée et prévue pour suppression : à la place, try-with-resources ou Cleaner. Plus de heap aide souvent, tant qu\u0026rsquo;il tient dans la mémoire de la machine ou du conteneur. Régler le GC n\u0026rsquo;est pas la première chose à faire : G1 avec ses réglages par défaut, puis les logs, puis l\u0026rsquo;application. Le GC n\u0026rsquo;empêche pas les fuites mémoire : un objet encore référencé n\u0026rsquo;est jamais libéré. → Idées reçues sur le garbage collector Java\nFAQ Quelle différence entre le heap et la stack ? La stack appartient à un thread : elle contient les variables locales et se vide quand la méthode se termine. Le heap est partagé par tous les threads : il contient les objets, et c\u0026rsquo;est le GC qui le nettoie. Une variable locale de type objet est dans la stack, mais l\u0026rsquo;objet lui-même est dans le heap.\nQuel est le GC par défaut en Java 25 ? G1, sur une machine « server-class » : au moins deux processeurs et au moins 1792 Mo de mémoire. Sinon, la JVM choisit Serial.\nPeut-on forcer le GC ? Non. System.gc() est une demande, sans garantie. Avec G1, Parallel et Serial, elle donne en général un Full GC. Avec -XX:+DisableExplicitGC, la JVM ignore ces appels.\nPourquoi mon application se fige quelques instants ? Les pauses du GC sont une cause possible, pas la seule. Pour le savoir, regardez les logs du GC (-Xlog:gc*) : chaque pause y est écrite avec sa durée. Avec G1, un Pause Full qui ne vient pas de System.gc() est le signe d\u0026rsquo;un heap trop plein, et la doc prévient qu\u0026rsquo;il peut être très lent. Des Full GC qui se répètent indiquent un heap trop petit ou une fuite mémoire.\nQuel GC pour une application Spring Boot ? Spring Boot ne choisit pas le GC : c\u0026rsquo;est la JVM, avec les mêmes règles que pour toute application Java. Une application Spring Boot 3.5 sur Java 25 démarre avec G1, et avec Serial si la JVM ne voit qu\u0026rsquo;un seul processeur. Commencez avec G1 et ses réglages par défaut, et vérifiez le GC utilisé avec -Xlog:gc.\nLa suite de la série Ce guide couvre les bases. La suite de la série ira plus loin :\ncomprendre et diagnostiquer : les variantes de OutOfMemoryError, les fuites mémoire, la lecture des logs du GC, les outils (JFR, jcmd, VisualVM) et l\u0026rsquo;analyse d\u0026rsquo;un heap dump ; mesurer et régler : Java dans Docker et Kubernetes, la mémoire hors heap, G1 ou ZGC mesurés, le réglage d\u0026rsquo;un service Spring Boot et Kafka, et le GC avec les threads virtuels. Ces articles seront ajoutés à ce guide au fur et à mesure.\n","permalink":"https://aymenjamaaoui.dev/blog/garbage-collector-java-guide/","summary":"\u003cp\u003eSur le garbage collector Java, on trouve beaucoup d\u0026rsquo;articles, et\nbeaucoup sont anciens : PermGen, CMS, \u003ccode\u003e-XX:+PrintGCDetails\u003c/code\u003e… Ce guide\nrésume ce qui est vrai en \u003cstrong\u003eJava 25\u003c/strong\u003e, en quelques lignes par sujet.\nChaque partie renvoie à un article plus complet, où chaque fait est\nvérifié dans la documentation officielle ou sur une vraie JVM.\u003c/p\u003e\n\u003cp\u003eJe mets ce guide à jour quand la série avance, ou quand une nouvelle\nversion de Java change les règles.\u003c/p\u003e","title":"Garbage collector Java : le guide à jour (Java 25)"},{"content":"Sur le garbage collector, on lit encore beaucoup de conseils. Certains étaient faux dès le départ, d\u0026rsquo;autres ne sont plus vrais aujourd\u0026rsquo;hui.\nVoici cinq idées reçues, et ce que disent la documentation officielle et une vraie JVM en Java 25.\n« System.gc() force un GC » Non : c\u0026rsquo;est une demande, pas un ordre. La javadoc dit que System.gc() « suggère » à la JVM de libérer de la mémoire, sans aucune garantie sur la quantité libérée, ni même que le travail soit fait.\nEn pratique, sur Java 25, la demande est souvent suivie, et elle coûte cher :\nGC Ce que fait System.gc() G1 (par défaut), Parallel, Serial Un Full GC : Pause Full (System.gc()) ZGC Une collection majeure : Major Collection (System.gc()) G1 avec -XX:+ExplicitGCInvokesConcurrent Une pause young, puis un cycle concurrent Avec -XX:+DisableExplicitGC Rien : l\u0026rsquo;appel est ignoré Avec -XX:+DisableExplicitGC, la JVM ignore les appels à System.gc(), mais elle fait toujours un GC quand elle en a besoin.\n« finalize() sert à libérer les ressources » Non. finalize() est dépréciée depuis Java 9, et prévue pour suppression depuis Java 18 (JEP 421). Le GC peut l\u0026rsquo;appeler très tard, sans aucun délai garanti. Elle tourne sur un thread quelconque, dans un ordre quelconque. Et si le thread des finalizers n\u0026rsquo;arrive pas à suivre, les objets en attente peuvent remplir le heap, jusqu\u0026rsquo;à l\u0026rsquo;OutOfMemoryError.\nÀ la place :\nune méthode close(), avec AutoCloseable et un try-with-resources : la fermeture est garantie, même en cas d\u0026rsquo;exception ; Cleaner (Java 9), pour lancer une action de nettoyage quand l\u0026rsquo;objet n\u0026rsquo;est plus accessible. Pour vérifier qu\u0026rsquo;une application ne dépend plus des finalizers, on peut la lancer avec --finalization=disabled (depuis Java 18).\n« Plus de heap, c\u0026rsquo;est toujours mieux » Souvent vrai, mais pas toujours. Le guide d\u0026rsquo;Oracle conseille de donner à la JVM autant de mémoire que possible, sauf si vous avez des problèmes de pauses. La taille par défaut est souvent trop petite.\nMais il y a des limites :\nle heap maximum doit rester sous la mémoire de la machine, sinon le système se met à paginer ; la JVM utilise aussi de la mémoire hors du heap (Metaspace, piles des threads…) : dans un conteneur, tout doit tenir dans la limite ; avec G1, un heap plus grand allonge en général la phase de marquage. Les options pour régler la taille du heap sont dans l\u0026rsquo;article sur les options JVM.\n« Régler le GC est la première chose à faire » Non. Le guide d\u0026rsquo;Oracle dit de commencer par lancer l\u0026rsquo;application et de laisser la JVM choisir le GC. Pour G1, sa recommandation générale est de garder les réglages par défaut, avec au plus un objectif de pause différent et un heap maximum (-Xmx).\nAvant de changer une option, regardez ce qui se passe : les logs du GC sont un bon départ (-Xlog:gc*). Et le guide le rappelle : une optimisation dans l\u0026rsquo;application peut être plus efficace que le réglage de la JVM.\n« Le GC empêche les fuites mémoire » Non. Le GC libère les objets que plus personne ne référence. Mais pour Oracle, une fuite mémoire en Java, c\u0026rsquo;est justement une application qui garde des références à des objets ou à des classes sans le vouloir : un cache qui grossit toujours, une liste jamais vidée… Le GC ne peut pas les libérer. Le heap se remplit, ou la Metaspace pour les classes, jusqu\u0026rsquo;à l\u0026rsquo;OutOfMemoryError.\nEt il existe aussi des fuites de mémoire native, hors du heap. Pour celles de la JVM elle-même, l\u0026rsquo;outil Native Memory Tracking (NMT) aide à les trouver.\nCe qui a changé Ce qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis Libérer les ressources dans finalize() finalize() est dépréciée, prévue pour suppression ; try-with-resources ou Cleaner à la place Java 9 (dépréciée), Java 18 (pour suppression) Une fuite de classes remplit la PermGen La PermGen n\u0026rsquo;existe plus : c\u0026rsquo;est la Metaspace qui se remplit Java 8 Régler les options de CMS CMS est supprimé ; G1 est le GC par défaut, et ses réglages par défaut sont recommandés Java 9 (G1 par défaut), Java 14 (CMS supprimé) Sources : la javadoc de System.gc() et de Object.finalize(), JEP 421 (finalization), la documentation de la commande java, le guide d\u0026rsquo;Oracle sur le garbage collector et le guide de dépannage sur les fuites mémoire (JDK 25).\nEn résumé System.gc() est une demande coûteuse, finalize() est à oublier, un heap plus grand aide tant qu\u0026rsquo;il tient en mémoire, on mesure avant de régler, et le GC ne protège pas des références oubliées.\nPour aller plus loin Fuites mémoire en Java : 6 cas classiques, reproduits Garbage collector Java : le guide à jour (Java 25) Les options JVM qui comptent, et celles qui ne marchent plus Générations et cycles de GC : Young, Old, Minor, Major, Full ","permalink":"https://aymenjamaaoui.dev/blog/idees-recues-garbage-collector-java/","summary":"\u003cp\u003eSur le garbage collector, on lit encore beaucoup de conseils. Certains\nétaient faux dès le départ, d\u0026rsquo;autres ne sont plus vrais aujourd\u0026rsquo;hui.\u003c/p\u003e\n\u003cp\u003eVoici cinq idées reçues, et ce que disent la documentation officielle et\nune vraie JVM en Java 25.\u003c/p\u003e\n\u003ch2 id=\"-systemgc-force-un-gc-\"\u003e« \u003ccode\u003eSystem.gc()\u003c/code\u003e force un GC »\u003c/h2\u003e\n\u003cp\u003eNon : c\u0026rsquo;est une demande, pas un ordre. La javadoc dit que\n\u003ccode\u003eSystem.gc()\u003c/code\u003e « suggère » à la JVM de libérer de la mémoire, sans\naucune garantie sur la quantité libérée, ni même que le travail soit\nfait.\u003c/p\u003e","title":"Idées reçues sur le garbage collector Java"},{"content":"Vous reprenez un vieux script de démarrage, vous passez à Java 25, et la JVM ne démarre plus :\nUnrecognized VM option \u0026#39;PrintGCDateStamps\u0026#39; Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit. Beaucoup d\u0026rsquo;options mémoire et GC qu\u0026rsquo;on trouve encore sur le web ont changé ou disparu. Certaines sont juste ignorées, d\u0026rsquo;autres empêchent la JVM de démarrer. Et les options utiles aujourd\u0026rsquo;hui tiennent en quelques lignes.\nLes options qui comptent Option Rôle Quand la changer Quand ne pas y toucher -Xms, -Xmx Taille initiale et maximale du heap Presque toujours sur un serveur : la valeur par défaut est souvent trop petite — -Xss Taille de la pile de chaque thread Après un StackOverflowError sur une récursion légitime Sinon : la valeur par défaut suffit -XX:+UseG1GC, -XX:+UseParallelGC, -XX:+UseZGC Choix du GC Quand un besoin précis le demande (latence, débit) Par défaut : laissez d\u0026rsquo;abord la JVM choisir -XX:MaxGCPauseMillis Objectif de pause (G1 : 200 ms par défaut) Si les pauses de G1 sont trop longues pour vous Pour « accélérer » le GC : des pauses plus courtes peuvent coûter du débit -Xlog:gc* Logs du GC Toujours utile en production, vers un fichier — -XX:+HeapDumpOnOutOfMemoryError, -XX:HeapDumpPath Dump du heap en cas de heap plein En production, pour comprendre un OutOfMemoryError — -XX:MaxRAMPercentage Heap maximum en pourcentage de la mémoire (25 % par défaut) Dans un conteneur, à la place d\u0026rsquo;un -Xmx fixe — La taille du heap : -Xms et -Xmx -Xms donne la taille initiale du heap, -Xmx sa taille maximale. Sans ces options, la JVM prend 1/64 de la mémoire physique au départ et 1/4 au maximum. Sur ma machine (32 Go), cela donne 512 Mo et 8 Go.\nSur un serveur, on donne souvent la même valeur aux deux. Le guide d\u0026rsquo;Oracle explique pourquoi : c\u0026rsquo;est plus prévisible, car la JVM n\u0026rsquo;a plus à décider de la taille du heap. Mais en contrepartie, si la valeur est mauvaise, la JVM ne peut plus la corriger.\n-Xss fixe la taille de la pile de chaque thread : 1 Mo par défaut sur Linux et macOS en x64, 2 Mo sur Aarch64 (les Mac Apple Silicon, par exemple).\nPauses, logs et heap dump -XX:MaxGCPauseMillis est un objectif, pas une promesse. La documentation parle d\u0026rsquo;un « soft goal » : la JVM fait de son mieux, mais elle peut ne pas l\u0026rsquo;atteindre. Pour tenir un objectif plus court, elle peut lancer le GC plus souvent, et l\u0026rsquo;application fait alors moins de travail utile.\n-Xlog:gc* écrit les logs du GC. Pour les envoyer dans un fichier : -Xlog:gc*:file=gc.log. La rotation est déjà intégrée : par défaut, 5 fichiers de 20 Mo environ, à changer avec filecount et filesize.\n-XX:+HeapDumpOnOutOfMemoryError écrit un fichier .hprof quand le heap est plein. Elle est désactivée par défaut. Sans -XX:HeapDumpPath, le fichier va dans le dossier courant, avec le nom java_pid\u0026lt;pid\u0026gt;.hprof. Attention : elle ne marche que pour un heap plein, pas pour les autres OutOfMemoryError (par exemple quand la JVM ne peut plus créer de thread).\nEn conteneur Sous Linux, la JVM détecte le conteneur toute seule : elle voit sa limite de mémoire et son nombre de CPU. -XX:MaxRAMPercentage donne la part de cette mémoire réservée au heap maximum, 25 % par défaut. Ce pourcentage suit la limite du conteneur, alors qu\u0026rsquo;un -Xmx fixe ne bouge pas quand la limite change. Le détail, avec des mesures : Java dans Docker et Kubernetes.\nCe qui a changé : les options qui ne marchent plus Option Java 8 Java 11 Java 17, 21 et 25 À la place -XX:PermSize, -XX:MaxPermSize Ignorée, avec un avertissement Ignorée, avec un avertissement Refusée -XX:MetaspaceSize, -XX:MaxMetaspaceSize -XX:+UseConcMarkSweepGC Acceptée Acceptée, avec un avertissement (dépréciée) Refusée Un autre GC (CMS est supprimé depuis Java 14) -XX:+UseParNewGC Acceptée Refusée Refusée (supprimée en Java 10) — -XX:+UseCGroupMemoryLimitForHeap Expérimentale Refusée Refusée (supprimée en Java 11) Rien : la JVM détecte le conteneur -XX:+PrintGCDateStamps, -XX:+PrintGCTimeStamps Acceptée Refusée Refusée Rien : -Xlog gère les dates lui-même -XX:+UseGCLogFileRotation Acceptée Refusée Refusée Rien : -Xlog gère la rotation -XX:+PrintTenuringDistribution Acceptée Refusée Refusée -Xlog:gc+age*=debug -XX:+PrintGCDetails Acceptée Dépréciée : un avertissement Dépréciée : un avertissement, puis -Xlog:gc* -Xlog:gc* -Xloggc:\u0026lt;fichier\u0026gt; Acceptée Dépréciée : un avertissement Dépréciée : un avertissement, puis -Xlog:gc:\u0026lt;fichier\u0026gt; -Xlog:gc:\u0026lt;fichier\u0026gt; Les anciennes options de log du GC étaient donc déjà refusées en Java 11.\n« Refusée » veut dire : « Unrecognized VM option », et la JVM ne démarre pas. Une option dépréciée, elle, marche encore, avec un avertissement :\n[0.001s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead. Un cas récent : -XX:+ZGenerational activait le mode générationnel de ZGC en Java 21. Depuis Java 24, ce mode est le seul : en Java 25, l\u0026rsquo;option est ignorée avec un avertissement, et -XX:+UseZGC suffit.\nTester une vieille option avant de la garder Le plus simple est de lancer la JVM avec vos options et -version :\njava -XX:+PrintGCDateStamps -Xmx2g -version Si une option est refusée, vous le voyez tout de suite, sans lancer l\u0026rsquo;application. La documentation de la commande java a aussi trois listes : options dépréciées, obsolètes et supprimées. Mais elles sont courtes : une option supprimée il y a longtemps n\u0026rsquo;y est plus (-XX:PermSize, par exemple). Elle a aussi une table qui donne l\u0026rsquo;équivalent -Xlog de chaque ancienne option de log du GC.\nSources : la documentation de la commande java et le guide d\u0026rsquo;Oracle sur le garbage collector (JDK 25), JEP 122 (PermGen), JEP 271 (logs du GC avec -Xlog), JEP 363 (CMS), JDK-8173421 (ParNew), JDK-8194086 (cgroups), JEP 490 (ZGC).\nÀ retenir Gardez peu d\u0026rsquo;options, et sachez pourquoi chacune est là : la taille du heap, les logs du GC vers un fichier et le heap dump. Ne copiez pas une liste d\u0026rsquo;options d\u0026rsquo;un forum ou d\u0026rsquo;un vieux script sans la comprendre : au mieux, une partie ne sert plus à rien ; au pire, la JVM ne démarre pas.\nPour aller plus loin Garbage collector Java : le guide à jour (Java 25) Idées reçues sur le garbage collector Java Les garbage collectors Java en 2026 : lequel tourne chez vous ? Générations et cycles de GC : Young, Old, Minor, Major, Full ","permalink":"https://aymenjamaaoui.dev/blog/options-jvm-qui-comptent/","summary":"\u003cp\u003eVous reprenez un vieux script de démarrage, vous passez à Java 25, et la\nJVM ne démarre plus :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eUnrecognized VM option \u0026#39;PrintGCDateStamps\u0026#39;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eError: Could not create the Java Virtual Machine.\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eError: A fatal exception has occurred. Program will exit.\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eBeaucoup d\u0026rsquo;options mémoire et GC qu\u0026rsquo;on trouve encore sur le web ont\nchangé ou disparu. Certaines sont juste ignorées, d\u0026rsquo;autres empêchent la\nJVM de démarrer. Et les options utiles aujourd\u0026rsquo;hui tiennent en quelques\nlignes.\u003c/p\u003e","title":"Les options JVM qui comptent, et celles qui ne marchent plus"},{"content":"Votre application tourne peut-être avec un autre GC que celui que vous croyez.\nEn Java 25, G1 est le garbage collector par défaut. Mais la JVM en propose d\u0026rsquo;autres, chacun pour un besoin. Et sur une petite machine, elle en choisit un autre toute seule.\nLes GC disponibles en Java 25 GC Ce qu\u0026rsquo;en dit le guide d\u0026rsquo;Oracle Option Serial Un seul thread pour tout le travail du GC. Pour un petit jeu de données (jusqu\u0026rsquo;à 100 Mo environ) ou un seul processeur -XX:+UseSerialGC Parallel Le « throughput collector » : comme Serial, mais avec plusieurs threads. Si la performance brute compte le plus, et que des pauses d\u0026rsquo;une seconde ou plus sont acceptables -XX:+UseParallelGC G1 Un GC « en grande partie concurrent » : il fait une partie du travail pendant que l\u0026rsquo;application tourne. Si le temps de réponse compte plus que le débit -XX:+UseG1GC ZGC Des pauses de moins d\u0026rsquo;une milliseconde, au prix d\u0026rsquo;un peu de débit. Si la latence est la priorité -XX:+UseZGC Shenandoah Un GC à pauses courtes, lui aussi. Le guide d\u0026rsquo;Oracle n\u0026rsquo;en parle pas ; il fonctionne avec Temurin 25 -XX:+UseShenandoahGC Et un cas à part : Epsilon. Il alloue la mémoire, mais ne la libère jamais. Quand le heap est plein, la JVM s\u0026rsquo;arrête. Il sert pour des tests (mesurer une application sans GC, vérifier qu\u0026rsquo;un test ne crée pas trop d\u0026rsquo;objets) ou des tâches très courtes. En Java 25, il est encore expérimental : il faut -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC.\nLe GC par défaut : G1… sauf sur une petite machine La JVM choisit G1 sur une machine « server-class » : au moins deux processeurs et au moins 1792 Mo de mémoire physique. Sinon, elle choisit Serial.\nSur une machine de développement, c\u0026rsquo;est presque toujours G1. Mais dans un petit conteneur limité à un seul CPU, la JVM passe à Serial, sans rien dire. Le comportement de l\u0026rsquo;application peut alors changer entre le poste du développeur et la production.\nLe conseil du guide d\u0026rsquo;Oracle : sauf si vous avez des contraintes de pause strictes, lancez d\u0026rsquo;abord l\u0026rsquo;application et laissez la JVM choisir.\nComment savoir quel GC tourne Au démarrage, avec -Xlog:gc, la JVM l\u0026rsquo;écrit dès la première ligne :\n[0.015s][info][gc] Using G1 Sans lancer l\u0026rsquo;application, -XX:+PrintFlagsFinal montre le choix de la JVM pour cette machine (ergonomic veut dire « choisi par la JVM ») :\nbool UseG1GC = true {product} {ergonomic} Et pour une application déjà lancée, jcmd \u0026lt;pid\u0026gt; VM.flags affiche ses options, avec par exemple -XX:+UseG1GC.\nCe qui a changé Frise des garbage collectors de Java 8 à Java 25 : G1 par défaut en Java 9, CMS supprimé en Java 14, ZGC et Shenandoah en production en Java 15, puis leurs modes générationnels Les GC de Java 8 à Java 25 Java 8 Parallel par défaut PermGen → Metaspace Java 9 G1 par défaut CMS déprécié Java 11 ZGC, Epsilon (expérimentaux) Java 12 Shenandoah (expérimental) Java 14 CMS supprimé Java 15 ZGC, Shenandoah en production Java 21 ZGC générationnel (option) Java 23 ZGC générationnel par défaut Java 24 ZGC : seulement générationnel Java 25 Shenandoah générationnel stable Ce qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis Parallel est le GC par défaut G1 est le GC par défaut Java 9 CMS, le GC à faibles pauses CMS est supprimé. En Java 17, 21 et 25, -XX:+UseConcMarkSweepGC empêche même la JVM de démarrer Déprécié en Java 9, supprimé en Java 14 ZGC et Shenandoah sont expérimentaux Ils sont prêts pour la production Java 15 ZGC n\u0026rsquo;a pas de générations ZGC est générationnel, et c\u0026rsquo;est le seul mode depuis Java 24 Java 21 à 24 Shenandoah n\u0026rsquo;a pas de générations Son mode générationnel est stable en Java 25, mais ce n\u0026rsquo;est pas le mode par défaut (-XX:ShenandoahGCMode=generational) Java 25 Sources : le guide d\u0026rsquo;Oracle sur le garbage collector (JDK 25), JEP 248 (G1 par défaut), JEP 291 et JEP 363 (CMS), JEP 377 (ZGC), JEP 379 et JEP 521 (Shenandoah), JEP 318 (Epsilon).\nEn résumé G1 est le bon point de départ, et c\u0026rsquo;est celui que la JVM choisit sur une machine normale. ZGC et Shenandoah visent les pauses les plus courtes, Parallel le débit, Serial les petites machines. Avant de changer de GC, vérifiez lequel tourne vraiment, surtout dans un conteneur.\nPour aller plus loin Garbage collector Java : le guide à jour (Java 25) Les options JVM qui comptent, et celles qui ne marchent plus Générations et cycles de GC : Young, Old, Minor, Major, Full La mémoire de la JVM : Heap, Stack, Metaspace ","permalink":"https://aymenjamaaoui.dev/blog/garbage-collectors-java-2026/","summary":"\u003cp\u003eVotre application tourne peut-être avec un autre GC que celui que vous\ncroyez.\u003c/p\u003e\n\u003cp\u003eEn Java 25, G1 est le garbage collector par défaut. Mais la JVM en\npropose d\u0026rsquo;autres, chacun pour un besoin. Et sur une petite machine, elle\nen choisit un autre toute seule.\u003c/p\u003e\n\u003ch2 id=\"les-gc-disponibles-en-java-25\"\u003eLes GC disponibles en Java 25\u003c/h2\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eGC\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eCe qu\u0026rsquo;en dit le guide d\u0026rsquo;Oracle\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eOption\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eSerial\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eUn seul thread pour tout le travail du GC. Pour un petit jeu de données (jusqu\u0026rsquo;à 100 Mo environ) ou un seul processeur\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:+UseSerialGC\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eParallel\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eLe « throughput collector » : comme Serial, mais avec plusieurs threads. Si la performance brute compte le plus, et que des pauses d\u0026rsquo;une seconde ou plus sont acceptables\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:+UseParallelGC\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eG1\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eUn GC « en grande partie concurrent » : il fait une partie du travail pendant que l\u0026rsquo;application tourne. Si le temps de réponse compte plus que le débit\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:+UseG1GC\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eZGC\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eDes pauses de moins d\u0026rsquo;une milliseconde, au prix d\u0026rsquo;un peu de débit. Si la latence est la priorité\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:+UseZGC\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eShenandoah\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eUn GC à pauses courtes, lui aussi. Le guide d\u0026rsquo;Oracle n\u0026rsquo;en parle pas ; il fonctionne avec Temurin 25\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-XX:+UseShenandoahGC\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eEt un cas à part : \u003cstrong\u003eEpsilon\u003c/strong\u003e. Il alloue la mémoire, mais ne la libère\njamais. Quand le heap est plein, la JVM s\u0026rsquo;arrête. Il sert pour des tests\n(mesurer une application sans GC, vérifier qu\u0026rsquo;un test ne crée pas trop\nd\u0026rsquo;objets) ou des tâches très courtes. En Java 25, il est encore\nexpérimental : il faut \u003ccode\u003e-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC\u003c/code\u003e.\u003c/p\u003e","title":"Les garbage collectors Java en 2026 : lequel tourne chez vous ?"},{"content":"Minor GC, Major GC, Full GC : des mots qu\u0026rsquo;on lit partout, mais pas toujours avec le sens que leur donne la documentation officielle.\nLa plupart des objets meurent jeunes. La JVM range donc les objets récents dans la Young, et ceux qui durent dans la Old. Un GC qui ne nettoie que la Young est rapide, et il passe souvent.\nPourquoi des générations Le guide officiel d\u0026rsquo;Oracle sur le garbage collector part d\u0026rsquo;une observation : la plupart des objets ne vivent que très peu de temps. On l\u0026rsquo;appelle l\u0026rsquo;hypothèse générationnelle. Un objet créé pour une requête, une boucle ou un calcul devient inutile presque tout de suite.\nSi la JVM sépare les objets récents des objets qui durent, elle peut nettoyer souvent la petite zone des objets récents, où presque tout est mort, sans parcourir tout le heap à chaque fois.\nLe trajet d\u0026rsquo;un objet : Eden, Survivor, Old Le trajet d'un objet dans le heap : il naît dans l'Eden, passe d'un Survivor à l'autre à chaque minor collection, puis il est promu dans la Old Heap Young les objets récents Eden les objets naissent ici objet Survivor 0 les deux Survivor servent Survivor 1 à tour de rôle ; un est vide Old les objets qui durent objet 1 2 3 1 Minor collection : l'objet vivant est copié de l'Eden vers un Survivor. 2 À chaque minor collection, il passe dans l'autre Survivor ; son âge augmente. 3 Après assez de copies (MaxTenuringThreshold, 15 par défaut) : promu dans la Old. La Young est faite de trois zones : l\u0026rsquo;Eden et deux Survivor.\nUn objet naît presque toujours dans l\u0026rsquo;Eden. Quand la Young est pleine, le GC nettoie la Young. Les objets encore vivants sont copiés dans un Survivor ; l\u0026rsquo;Eden est alors vide. Au GC suivant, les objets vivants passent d\u0026rsquo;un Survivor à l\u0026rsquo;autre. Un des deux Survivor est toujours vide. Après un certain nombre de copies, ou s\u0026rsquo;il n\u0026rsquo;y a plus de place dans les Survivor, l\u0026rsquo;objet est copié dans la Old : on dit qu\u0026rsquo;il est promu. Le nombre maximum de copies se règle avec -XX:MaxTenuringThreshold. Sa valeur par défaut est 15.\nMinor, Major, Full : ce que dit vraiment la doc Dans le guide d\u0026rsquo;Oracle :\nune minor collection ne nettoie que la Young ; une major collection nettoie tout le heap, quand la Old est pleine. Elle dure en général bien plus longtemps ; le mot full GC apparaît, par exemple, pour un nettoyage complet demandé par System.gc(). Beaucoup d\u0026rsquo;articles disent autre chose : « Major GC = la Old seulement, Full GC = tout le heap ». Ce n\u0026rsquo;est pas la définition du guide officiel.\nEt dans les logs, les mots dépendent du GC. G1, le GC par défaut, n\u0026rsquo;écrit jamais « minor » ni « major » :\n[0.238s] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 35M-\u0026gt;3M(64M) 2.406ms [0.287s] GC(34) Pause Young (Mixed) (G1 Evacuation Pause) 60M-\u0026gt;35M(64M) 0.215ms [0.335s] GC(82) Pause Full (System.gc()) 53M-\u0026gt;27M(64M) 3.010ms Une pause Young (Normal) nettoie la Young. Une pause Young (Mixed) nettoie la Young et, en plus, une partie de la Old. ZGC, lui, écrit bien Minor Collection et Major Collection :\n[0.246s] GC(3) Minor Collection (Allocation Rate) 52M(81%)-\u0026gt;52M(81%) 0.008s [0.503s] GC(152) Major Collection (System.gc()) 38M(59%)-\u0026gt;30M(47%) 0.009s Quand on lit un log, il faut donc d\u0026rsquo;abord savoir quel GC l\u0026rsquo;a écrit.\nQuand G1 fait un Full GC Avec G1, le Full GC est une solution de secours : il compacte tout le heap, et la doc prévient qu\u0026rsquo;il peut être très lent. Il arrive dans deux cas principaux :\nle heap est trop plein : l\u0026rsquo;application crée des objets plus vite que G1 ne peut libérer de la place dans la Old ; quelqu\u0026rsquo;un le demande : un appel à System.gc(), dans l\u0026rsquo;application ou dans un outil. Un Full GC de temps en temps n\u0026rsquo;est pas grave. Des Full GC qui se répètent sont un signal : le heap est trop petit, ou il y a une fuite mémoire.\nCe qui a changé Le heap de G1 : des régions de même taille ; les régions Eden, Survivor et Old sont mélangées, entre des régions libres Heap de G1 des régions de même taille (4 Mo ici), mélangées en mémoire E O S O E O O E O O E E O O S O E O O E O E O O E O O O E O S O E Eden S Survivor O Old libre La Young = les régions Eden et Survivor ; la Old = les régions Old. Une pause Mixed nettoie la Young et, en plus, une partie des régions Old. Depuis Java 9, G1 est le GC par défaut. Il ne coupe plus le heap en deux grands blocs : il le découpe en régions de même taille (4 Mo sur une machine de 32 Go). La Young et la Old sont des ensembles de régions, qui ne se suivent pas forcément en mémoire.\nCe qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis Le heap est coupé en deux blocs fixes, la Young et la Old Avec G1, le heap est fait de régions de même taille ; la Young et la Old sont des ensembles de régions Java 9 (G1 par défaut) ZGC n\u0026rsquo;a pas de générations ZGC est générationnel : en option en Java 21, par défaut en Java 23, seul mode depuis Java 24 (-XX:+ZGenerational est ignoré, avec un avertissement) Java 21 à 24 Major GC = nettoyage de la Old Dans le guide d\u0026rsquo;Oracle, une major collection nettoie tout le heap — On règle la Young avec -Xmn ou -XX:NewRatio Avec G1, la doc conseille de ne pas le faire : cela désactive presque le contrôle des pauses — Sources : le guide d\u0026rsquo;Oracle sur le garbage collector (JDK 25), JEP 248 (G1 par défaut), JEP 439, JEP 474 et JEP 490 (ZGC générationnel).\nLes options, en bref -Xmn : la taille de la Young (initiale et maximale) ; -XX:NewRatio : le rapport entre la Old et la Young (2 par défaut : la Old est deux fois plus grande que la Young) ; -XX:SurvivorRatio : le rapport entre l\u0026rsquo;Eden et un Survivor (8 par défaut) ; -XX:MaxTenuringThreshold : le nombre de copies avant la promotion (15 par défaut). Avec G1, mieux vaut ne pas fixer la taille de la Young : G1 l\u0026rsquo;ajuste lui-même pour tenir son objectif de pause.\nEn résumé Les objets naissent dans l\u0026rsquo;Eden, survivent quelques GC dans les Survivor, et les plus durables finissent dans la Old. « Minor » veut dire la Young ; « Major », dans la doc d\u0026rsquo;Oracle, veut dire tout le heap. Avec G1, on parle plutôt de pauses Young, Mixed et Full, et le heap est fait de régions.\nPour aller plus loin Garbage collector Java : le guide à jour (Java 25) Les garbage collectors Java en 2026 : lequel tourne chez vous ? La mémoire de la JVM : Heap, Stack, Metaspace ","permalink":"https://aymenjamaaoui.dev/blog/generations-cycles-gc-young-old-minor-major-full/","summary":"\u003cp\u003eMinor GC, Major GC, Full GC : des mots qu\u0026rsquo;on lit partout, mais pas\ntoujours avec le sens que leur donne la documentation officielle.\u003c/p\u003e\n\u003cp\u003eLa plupart des objets meurent jeunes. La JVM range donc les objets\nrécents dans la \u003cstrong\u003eYoung\u003c/strong\u003e, et ceux qui durent dans la \u003cstrong\u003eOld\u003c/strong\u003e. Un GC qui\nne nettoie que la Young est rapide, et il passe souvent.\u003c/p\u003e\n\u003ch2 id=\"pourquoi-des-générations\"\u003ePourquoi des générations\u003c/h2\u003e\n\u003cp\u003eLe guide officiel d\u0026rsquo;Oracle sur le garbage collector part d\u0026rsquo;une\nobservation : la plupart des objets ne vivent que très peu de temps. On\nl\u0026rsquo;appelle l\u0026rsquo;\u003cstrong\u003ehypothèse générationnelle\u003c/strong\u003e. Un objet créé pour une\nrequête, une boucle ou un calcul devient inutile presque tout de suite.\u003c/p\u003e","title":"Générations et cycles de GC : Young, Old, Minor, Major, Full"},{"content":"Votre vieille configuration Java ne démarre plus en Java 17 ? La réponse est dans la mémoire de la JVM.\nEn Java, chaque thread a sa stack, pour ses variables locales. Les objets vivent dans le heap, la zone que le garbage collector nettoie. Les classes sont décrites dans le Metaspace, hors du heap. Et la JVM utilise encore d\u0026rsquo;autres zones de mémoire, qu\u0026rsquo;on oublie souvent.\nTrois zones à connaître La mémoire d'un processus Java : les stacks des threads, le heap, le Metaspace et les autres zones natives Processus Java tout ce qui est hors du heap est de la mémoire native Stacks une par thread · -Xss Thread main traiter() · client main() Thread 2 lire() run() frames, variables locales Heap partagé · géré par le GC · -Xmx Client String List byte[] les objets créés avec new les chaînes internées les champs statiques des classes Metaspace mémoire native sans limite par défaut Client champs, méthodes… String champs, méthodes… la description des classes -XX:MaxMetaspaceSize Et aussi, en mémoire native Code cache le code compilé par le JIT 240 Mo au maximum par défaut Direct memory ByteBuffer.allocateDirect() -XX:MaxDirectMemorySize Structures internes du garbage collector, du compilateur… La stack : une par thread Chaque thread a sa propre stack. À chaque appel de méthode, la JVM y ajoute une frame : les paramètres et les variables locales de la méthode. Quand la méthode se termine, sa frame disparaît. Pas besoin du garbage collector pour libérer cette mémoire.\nUne variable locale de type objet ne contient pas l\u0026rsquo;objet : elle contient une référence vers un objet du heap.\nSi les appels s\u0026rsquo;empilent trop, par exemple avec une récursion sans fin, la stack déborde : StackOverflowError. Sa taille se règle avec -Xss. La valeur par défaut dépend de la plateforme : 1 Mo sur Linux x64, 2 Mo sur Linux et macOS en ARM (Aarch64).\nLe heap : les objets, et le travail du GC Les objets créés avec new vont dans le heap, partagé par tous les threads. C\u0026rsquo;est la zone du garbage collector : il cherche les objets que plus rien ne référence, et il récupère leur place.\nSa taille maximale se règle avec -Xmx. Sans cette option, la JVM fixe en général le maximum à 25 % de la mémoire. Sur une machine de 32 Go de mémoire, cela donne un heap maximum de 8 Go.\nLe heap contient aussi les chaînes internées (String.intern()) et les champs statiques des classes.\nLe Metaspace : les classes, hors du heap Pour chaque classe chargée, la JVM garde sa description : ses champs, ses méthodes… Ces métadonnées sont dans le Metaspace, en mémoire native, hors du heap.\nPar défaut, sa taille n\u0026rsquo;est pas limitée : il grandit avec le nombre de classes chargées. On peut fixer un maximum avec -XX:MaxMetaspaceSize.\nEt ce qu\u0026rsquo;on oublie souvent Le heap et le Metaspace ne sont pas les seules zones. La JVM utilise aussi de la mémoire native pour :\nles stacks des threads : une par thread, donc beaucoup de threads, c\u0026rsquo;est beaucoup de mémoire ; le code cache : le code machine produit par le compilateur JIT, 240 Mo au maximum par défaut ; la direct memory : les buffers alloués hors du heap avec ByteBuffer.allocateDirect(). Sans -XX:MaxDirectMemorySize, la JVM choisit la limite elle-même ; les structures internes du garbage collector, du compilateur, etc. Ce qui a changé Prenons un développeur qui reprend une vieille configuration, trouvée dans un ancien projet ou sur un forum :\njava -Xms512m -Xmx512m -XX:PermSize=128m -XX:MaxPermSize=256m -jar app.jar Avec Java 8, l\u0026rsquo;application démarre. La JVM affiche seulement deux avertissements, faciles à rater dans les logs :\nOpenJDK 64-Bit Server VM warning: ignoring option PermSize=128m; support was removed in 8.0 OpenJDK 64-Bit Server VM warning: ignoring option MaxPermSize=256m; support was removed in 8.0 Avec Java 17, 21 ou 25, elle ne démarre plus :\nUnrecognized VM option \u0026#39;PermSize=128m\u0026#39; Error: Could not create the Java Virtual Machine. Et la JVM s\u0026rsquo;arrête à la première option inconnue : on retire PermSize, on relance, et c\u0026rsquo;est au tour de MaxPermSize.\nLa raison : PermGen n\u0026rsquo;existe plus depuis Java 8.\nCe qu\u0026rsquo;on lit encore Ce qui est vrai aujourd\u0026rsquo;hui Depuis Les classes sont dans PermGen, une zone de taille fixe Leurs métadonnées sont dans le Metaspace, en mémoire native, sans limite par défaut Java 8 On règle PermGen avec -XX:PermSize et -XX:MaxPermSize Options ignorées avec un avertissement, puis refusées : la JVM ne démarre pas. À la place : -XX:MetaspaceSize et -XX:MaxMetaspaceSize Ignorées jusqu\u0026rsquo;à Java 16, refusées depuis Java 17 OutOfMemoryError: PermGen space PermGen n\u0026rsquo;existe plus, cette erreur non plus Java 8 Les chaînes internées et les champs statiques sont dans PermGen Ils sont dans le heap Java 8 au plus tard Sources : JEP 122, Remove the Permanent Generation, et la documentation de la commande java de JDK 17 (section « Removed Java Options »).\nDeux erreurs courantes « Tout est dans le heap » Le heap contient les objets, avec les chaînes internées et les champs statiques. Les classes, le code compilé, les stacks des threads et les buffers directs sont ailleurs, en mémoire native. -Xmx ne les limite pas.\n« -Xmx, c\u0026rsquo;est la mémoire de mon application » -Xmx ne limite que le heap. Le processus Java utilise toujours plus. Par exemple, avec Java 25 et -Xmx512m, la JVM réserve déjà environ 598 Mo de mémoire utilisable (« committed » dans le Native Memory Tracking), juste pour afficher sa version. Pour dimensionner une machine ou un conteneur, il faut compter tout le processus, pas seulement le heap.\nEn résumé Trois zones à retenir : la stack de chaque thread, le heap des objets et le Metaspace des classes. Seul le heap est limité par -Xmx, et il n\u0026rsquo;est qu\u0026rsquo;une partie de la mémoire du processus. Si une vieille configuration parle de PermGen, il est temps de la nettoyer.\nPour aller plus loin Garbage collector Java : le guide à jour (Java 25) Générations et cycles de GC : Young, Old, Minor, Major, Full ","permalink":"https://aymenjamaaoui.dev/blog/memoire-jvm-heap-stack-metaspace/","summary":"\u003cp\u003eVotre vieille configuration Java ne démarre plus en Java 17 ? La réponse\nest dans la mémoire de la JVM.\u003c/p\u003e\n\u003cp\u003eEn Java, chaque thread a sa \u003cstrong\u003estack\u003c/strong\u003e, pour ses variables locales. Les\nobjets vivent dans le \u003cstrong\u003eheap\u003c/strong\u003e, la zone que le garbage collector nettoie.\nLes classes sont décrites dans le \u003cstrong\u003eMetaspace\u003c/strong\u003e, hors du heap. Et la JVM\nutilise encore d\u0026rsquo;autres zones de mémoire, qu\u0026rsquo;on oublie souvent.\u003c/p\u003e\n\u003ch2 id=\"trois-zones-à-connaître\"\u003eTrois zones à connaître\u003c/h2\u003e\n\n\u003cfigure class=\"aj-svg\"\u003e\u003csvg xmlns=\"http://www.w3.org/2000/svg\" class=\"jvm-memory\" viewBox=\"0 0 720 440\" width=\"720\" height=\"440\" role=\"img\" aria-labelledby=\"jvm-memory-title\"\u003e\n  \u003ctitle id=\"jvm-memory-title\"\u003eLa mémoire d'un processus Java : les stacks des threads, le heap, le Metaspace et les autres zones natives\u003c/title\u003e\n  \u003cstyle\u003e\n    svg.jvm-memory .proc { fill: var(--entry, #ffffff); stroke: var(--tertiary, #d5dbe6); stroke-width: 1.5; }\n    svg.jvm-memory .zone { fill: var(--code-bg, #f3f6fb); stroke: var(--tertiary, #d5dbe6); stroke-width: 1.5; }\n    svg.jvm-memory .heap { fill: color-mix(in srgb, var(--accent, #2563eb) 6%, transparent); stroke: var(--accent, #2563eb); stroke-width: 2; }\n    svg.jvm-memory .item { fill: var(--entry, #ffffff); stroke: var(--tertiary, #d5dbe6); stroke-width: 1; }\n    svg.jvm-memory .obj { fill: var(--entry, #ffffff); stroke: var(--accent, #2563eb); stroke-width: 1.5; }\n    svg.jvm-memory text { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif; fill: var(--primary, #1e1e1e); }\n    svg.jvm-memory .h { font-size: 15px; font-weight: 700; }\n    svg.jvm-memory .t { font-size: 13px; }\n    svg.jvm-memory .s { font-size: 12px; fill: var(--secondary, #6c6c6c); }\n    svg.jvm-memory .m { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }\n    svg.jvm-memory .acc { fill: var(--accent, #2563eb); }\n    svg.jvm-memory .arrow { stroke: var(--accent, #2563eb); stroke-width: 1.5; fill: none; marker-end: url(#jvm-memory-arrow); }\n    svg.jvm-memory .ah { fill: var(--accent, #2563eb); }\n  \u003c/style\u003e\n  \u003cdefs\u003e\n    \u003cmarker id=\"jvm-memory-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"\u003e\n      \u003cpath class=\"ah\" d=\"M0,0 L10,5 L0,10 z\"/\u003e\n    \u003c/marker\u003e\n  \u003c/defs\u003e\n\n  \u003c!-- The Java process --\u003e\n  \u003crect class=\"proc\" x=\"1\" y=\"1\" width=\"718\" height=\"438\" rx=\"12\"/\u003e\n  \u003ctext class=\"h\" x=\"20\" y=\"28\"\u003eProcessus Java\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"148\" y=\"28\"\u003etout ce qui est hors du heap est de la mémoire native\u003c/text\u003e\n\n  \u003c!-- Stacks --\u003e\n  \u003crect class=\"zone\" x=\"20\" y=\"44\" width=\"190\" height=\"250\" rx=\"8\"/\u003e\n  \u003ctext class=\"h\" x=\"34\" y=\"68\"\u003eStacks\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"34\" y=\"86\"\u003eune par thread · \u003ctspan class=\"m\"\u003e-Xss\u003c/tspan\u003e\u003c/text\u003e\n\n  \u003ctext class=\"t\" x=\"34\" y=\"110\"\u003eThread main\u003c/text\u003e\n  \u003crect class=\"item\" x=\"34\" y=\"118\" width=\"162\" height=\"26\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"44\" y=\"135\"\u003etraiter() · client\u003c/text\u003e\n  \u003crect class=\"item\" x=\"34\" y=\"148\" width=\"162\" height=\"26\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"44\" y=\"165\"\u003emain()\u003c/text\u003e\n\n  \u003ctext class=\"t\" x=\"34\" y=\"200\"\u003eThread 2\u003c/text\u003e\n  \u003crect class=\"item\" x=\"34\" y=\"208\" width=\"162\" height=\"26\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"44\" y=\"225\"\u003elire()\u003c/text\u003e\n  \u003crect class=\"item\" x=\"34\" y=\"238\" width=\"162\" height=\"26\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"44\" y=\"255\"\u003erun()\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"34\" y=\"284\"\u003eframes, variables locales\u003c/text\u003e\n\n  \u003c!-- Heap --\u003e\n  \u003crect class=\"heap\" x=\"226\" y=\"44\" width=\"270\" height=\"250\" rx=\"8\"/\u003e\n  \u003ctext class=\"h acc\" x=\"240\" y=\"68\"\u003eHeap\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"240\" y=\"86\"\u003epartagé · géré par le GC · \u003ctspan class=\"m\"\u003e-Xmx\u003c/tspan\u003e\u003c/text\u003e\n\n  \u003crect class=\"obj\" x=\"250\" y=\"110\" width=\"96\" height=\"34\" rx=\"17\"/\u003e\n  \u003ctext class=\"t\" x=\"272\" y=\"132\"\u003eClient\u003c/text\u003e\n  \u003crect class=\"obj\" x=\"376\" y=\"110\" width=\"96\" height=\"34\" rx=\"17\"/\u003e\n  \u003ctext class=\"t\" x=\"398\" y=\"132\"\u003eString\u003c/text\u003e\n  \u003crect class=\"obj\" x=\"250\" y=\"166\" width=\"96\" height=\"34\" rx=\"17\"/\u003e\n  \u003ctext class=\"t\" x=\"277\" y=\"188\"\u003eList\u003c/text\u003e\n  \u003crect class=\"obj\" x=\"376\" y=\"166\" width=\"96\" height=\"34\" rx=\"17\"/\u003e\n  \u003ctext class=\"t\" x=\"395\" y=\"188\"\u003ebyte[]\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"240\" y=\"232\"\u003eles objets créés avec \u003ctspan class=\"m\"\u003enew\u003c/tspan\u003e\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"240\" y=\"252\"\u003eles chaînes internées\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"240\" y=\"272\"\u003eles champs statiques des classes\u003c/text\u003e\n\n  \u003c!-- Reference from a local variable to an object --\u003e\n  \u003cpath class=\"arrow\" d=\"M196,131 C220,131 228,127 248,127\"/\u003e\n\n  \u003c!-- Metaspace --\u003e\n  \u003crect class=\"zone\" x=\"512\" y=\"44\" width=\"188\" height=\"250\" rx=\"8\"/\u003e\n  \u003ctext class=\"h\" x=\"526\" y=\"68\"\u003eMetaspace\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"526\" y=\"86\"\u003emémoire native\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"526\" y=\"102\"\u003esans limite par défaut\u003c/text\u003e\n  \u003crect class=\"item\" x=\"526\" y=\"118\" width=\"160\" height=\"56\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"538\" y=\"140\"\u003eClient\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"538\" y=\"160\"\u003echamps, méthodes…\u003c/text\u003e\n  \u003crect class=\"item\" x=\"526\" y=\"182\" width=\"160\" height=\"56\" rx=\"4\"/\u003e\n  \u003ctext class=\"m\" x=\"538\" y=\"204\"\u003eString\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"538\" y=\"224\"\u003echamps, méthodes…\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"526\" y=\"268\"\u003ela description des classes\u003c/text\u003e\n  \u003ctext class=\"m\" x=\"526\" y=\"284\"\u003e-XX:MaxMetaspaceSize\u003c/text\u003e\n\n  \u003c!-- Other native zones --\u003e\n  \u003ctext class=\"h\" x=\"20\" y=\"326\"\u003eEt aussi, en mémoire native\u003c/text\u003e\n  \u003crect class=\"zone\" x=\"20\" y=\"338\" width=\"216\" height=\"82\" rx=\"8\"/\u003e\n  \u003ctext class=\"t\" x=\"34\" y=\"364\"\u003eCode cache\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"34\" y=\"384\"\u003ele code compilé par le JIT\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"34\" y=\"402\"\u003e240 Mo au maximum par défaut\u003c/text\u003e\n\n  \u003crect class=\"zone\" x=\"252\" y=\"338\" width=\"216\" height=\"82\" rx=\"8\"/\u003e\n  \u003ctext class=\"t\" x=\"266\" y=\"364\"\u003eDirect memory\u003c/text\u003e\n  \u003ctext class=\"m\" x=\"266\" y=\"384\"\u003eByteBuffer.allocateDirect()\u003c/text\u003e\n  \u003ctext class=\"m\" x=\"266\" y=\"402\"\u003e-XX:MaxDirectMemorySize\u003c/text\u003e\n\n  \u003crect class=\"zone\" x=\"484\" y=\"338\" width=\"216\" height=\"82\" rx=\"8\"/\u003e\n  \u003ctext class=\"t\" x=\"498\" y=\"364\"\u003eStructures internes\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"498\" y=\"384\"\u003edu garbage collector,\u003c/text\u003e\n  \u003ctext class=\"s\" x=\"498\" y=\"402\"\u003edu compilateur…\u003c/text\u003e\n\u003c/svg\u003e\n\n\u003c/figure\u003e\n\n\u003ch3 id=\"la-stack--une-par-thread\"\u003eLa stack : une par thread\u003c/h3\u003e\n\u003cp\u003eChaque thread a sa propre stack. À chaque appel de méthode, la JVM y\najoute une \u003cem\u003eframe\u003c/em\u003e : les paramètres et les variables locales de la\nméthode. Quand la méthode se termine, sa frame disparaît. Pas besoin du\ngarbage collector pour libérer cette mémoire.\u003c/p\u003e","title":"La mémoire de la JVM : Heap, Stack, Metaspace"},{"content":"Toute la journée, j\u0026rsquo;ai deux Chrome ouverts : mon profil perso (Gmail, mes comptes) et mon profil pro (un compte Google Workspace pour l\u0026rsquo;entreprise). Et sur macOS, c\u0026rsquo;est pénible.\nLe problème Sur macOS, tous les profils Chrome sont dans une seule application. Le Dock et Cmd+Tab regroupent les fenêtres par application. Résultat :\nle Dock affiche une seule icône Chrome pour tous les profils ; Cmd+Tab affiche un seul Chrome. Pour passer de la fenêtre perso à la fenêtre pro, il faut Cmd+`, le menu Fenêtre ou Mission Control ; dans Mission Control, les fenêtres perso et pro sont mélangées. Je voulais quelque chose de simple : une icône par compte dans le Dock, et une entrée par compte dans Cmd+Tab.\nCe que j\u0026rsquo;ai essayé Les profils Chrome. C\u0026rsquo;est la solution normale, mais c\u0026rsquo;est justement la source du problème : tous les profils restent dans la même app.\nUn lanceur Automator. Beaucoup de guides conseillent une petite app qui lance Chrome avec le bon profil. Ça ouvre le bon profil, mais la fenêtre arrive dans la même app Chrome. Le Dock et Cmd+Tab ne changent pas.\nLes web apps de Chrome. « Installer la page en tant qu\u0026rsquo;application » donne une vraie icône dans le Dock et dans Cmd+Tab. Mais c\u0026rsquo;est une app par site : Gmail, Agenda, Drive… On ne navigue pas librement.\nLes outils existants. Epichrome créait des apps basées sur Chromium. Son auteur a arrêté le projet : la dernière version, la 2.4.27, date de janvier 2022. chrome-schismator, un script sur GitHub, lance plusieurs instances de Chrome avec leur propre icône. Et je ne suis pas le seul à vouloir ça : sur Mozilla Connect, une idée demande la même chose pour Firefox. L\u0026rsquo;auteur écrit qu\u0026rsquo;aujourd\u0026rsquo;hui, on ne l\u0026rsquo;obtient qu\u0026rsquo;avec des « fragile third-party scripts ».\nL\u0026rsquo;idée : une app, un identifiant macOS reconnaît une application par son identifiant, le CFBundleIdentifier du fichier Info.plist. Pour Chrome, c\u0026rsquo;est com.google.Chrome. Tous les profils ont le même, donc macOS les met ensemble.\nAlors, il suffit de copier Google Chrome.app et de changer cet identifiant dans la copie. Pour macOS, com.google.Chrome.Pro est une autre application : elle a sa propre icône dans le Dock et sa propre entrée dans Cmd+Tab.\nIl reste un problème : la copie et le Chrome d\u0026rsquo;origine ne doivent pas partager le même dossier de données (profils, cookies, historique). Là, Chromium a déjà la réponse. Dans son code source (chrome/common/chrome_paths_mac.mm), une clé du Info.plist, CrProductDirName, choisit le dossier de données dans ~/Library/Application Support. Google s\u0026rsquo;en sert pour Chrome Canary, qui utilise Google/Chrome Canary. Ce n\u0026rsquo;est donc pas une astuce : c\u0026rsquo;est un mécanisme prévu par Chromium.\nLa copie change donc trois choses :\nClé de Info.plist Valeur Effet CFBundleIdentifier com.google.Chrome.Pro une app séparée (Dock, Cmd+Tab) CFBundleName, CFBundleDisplayName Chrome Pro le nom affiché CrProductDirName Google/ChromePro un dossier de données séparé Un détail important : dès qu\u0026rsquo;on change le Info.plist, la signature de Google n\u0026rsquo;est plus valide. Il faut signer la copie à nouveau sur le Mac, avec une signature locale (« ad hoc »). Ce point explique presque toutes les limites, j\u0026rsquo;y reviens plus bas.\nLe script J\u0026rsquo;ai mis tout ça dans un script, copy-chrome.sh. Il copie Chrome, change le Info.plist, pose un badge sur l\u0026rsquo;icône si on veut, et signe la copie.\n# Créer une copie, avec le logo de l\u0026#39;entreprise en badge ./copy-chrome.sh Pro --badge ~/Pictures/logo-entreprise.png # Après une mise à jour de Chrome : refaire la copie (le badge est gardé) ./copy-chrome.sh Pro # Supprimer la copie et toutes ses données ./copy-chrome.sh Pro --remove Le badge est une image ronde, posée en haut à gauche de l\u0026rsquo;icône Chrome. Il est dessiné par un petit programme Swift, à côté du script. Avec lui, on reconnaît chaque Chrome d\u0026rsquo;un coup d\u0026rsquo;œil dans le Dock.\nAu premier lancement, la copie démarre vide, comme un Chrome qu\u0026rsquo;on vient d\u0026rsquo;installer. On se connecte au compte Google, on active la synchronisation, et les favoris, mots de passe et extensions reviennent. Les cookies, eux, ne reviennent pas : il faut se reconnecter aux sites. Le Chrome d\u0026rsquo;origine et ses profils ne sont pas touchés.\nCe qui casse C\u0026rsquo;est une solution non officielle, et elle a un prix. Tout vient de la même cause : la copie n\u0026rsquo;est plus signée par Google.\nLes passkeys du Mac Dans une copie, les passkeys enregistrées sur le Mac ne sont pas proposées : ni celles des Mots de passe d\u0026rsquo;Apple, ni celles du Gestionnaire de mots de passe de Google. Les mots de passe normaux, eux, marchent.\nCe que j\u0026rsquo;ai constaté sur une page de connexion :\nProposé à la connexion Chrome d\u0026rsquo;origine Copie Mot de passe (Gestionnaire de mots de passe Google) oui oui Passkey du Gestionnaire de mots de passe Google oui non Passkey d\u0026rsquo;Apple (Mots de passe, trousseau iCloud) oui non Passkey du téléphone (QR code) oui oui Pourquoi ? Le Chrome de Google a des droits spéciaux (des « entitlements ») qu\u0026rsquo;Apple ne donne qu\u0026rsquo;à une app signée par son éditeur : l\u0026rsquo;accès aux passkeys et à certains groupes du trousseau. Une signature locale ne peut pas les avoir. TeamDev, qui fait un navigateur basé sur Chromium, explique par exemple que les passkeys Touch ID demandent l\u0026rsquo;entitlement keychain-access-groups et un « provisioning profile » dans l\u0026rsquo;app. Je n\u0026rsquo;ai pas encore vérifié la cause exacte dans le code de Chromium : c\u0026rsquo;est l\u0026rsquo;explication la plus probable, pas une certitude.\nLe contournement qui marche : « Utiliser la clé d\u0026rsquo;accès d\u0026rsquo;un autre appareil ». Chrome affiche un QR code, je le scanne avec mon téléphone, et la passkey du téléphone valide la connexion. Il faut juste le téléphone à côté, avec le Bluetooth activé.\nLe trousseau redemande l\u0026rsquo;accès Après chaque nouvelle copie, macOS redemande l\u0026rsquo;accès à « Chrome Safe Storage », la clé qui protège les mots de passe et les cookies de Chrome. Le trousseau reconnaît une app signée en local par l\u0026rsquo;empreinte exacte de son code, et cette empreinte change à chaque signature. Il suffit de cliquer sur Toujours autoriser. Surtout pas sur Refuser : Chrome ne pourrait plus lire ses données.\nPas de mise à jour automatique La copie ne se met pas à jour toute seule. Après chaque mise à jour du Chrome d\u0026rsquo;origine, je relance le script. Si la copie est déjà à jour, il ne fait rien.\nDeux pièges pendant le développement Pour ceux qui veulent refaire l\u0026rsquo;exercice :\nLes attributs Finder. Les fichiers de Chrome ont l\u0026rsquo;attribut com.apple.FinderInfo, et codesign refuse de signer : « resource fork, Finder information, or similar detritus not allowed ». Il faut d\u0026rsquo;abord les retirer avec xattr -cr. codesign --deep. Chrome garde parfois l\u0026rsquo;ancienne version de son framework à côté de la nouvelle. --deep essaie de tout signer et casse cette ancienne version. La solution : signer seulement l\u0026rsquo;app principale. Et un mur : le dossier de données du Chrome d\u0026rsquo;origine est protégé par macOS (TCC). Sa lecture échoue avec « Operation not permitted », même avec sudo. On ne peut donc pas copier un profil existant : chaque copie part de zéro, et la synchronisation fait le reste.\nQuel profil mettre où ? À cause des passkeys, le partage est assez clair :\nDans le Chrome d\u0026rsquo;origine : les comptes qui utilisent des passkeys ou des clés de sécurité. C\u0026rsquo;est aussi le navigateur par défaut, qui reçoit les liens ouverts depuis les autres apps. Dans une copie : les comptes avec mot de passe ou SSO, pour un usage bien séparé. Pour moi, c\u0026rsquo;est le compte Google Workspace pro : Gmail, Agenda, Drive et les outils de l\u0026rsquo;entreprise. Mon profil perso utilise beaucoup de passkeys, il reste donc dans le Chrome d\u0026rsquo;origine. Une seule copie suffit, pour le pro.\nEn résumé Solution Dock et Cmd+Tab séparés Passkeys du Mac Navigation libre Profils Chrome non oui oui Web apps de Chrome oui, une par site probable non Une copie avec copy-chrome oui non (QR code) oui Un autre navigateur oui oui oui Pour moi, la copie est le bon compromis : une icône par compte, et toute la navigation de Chrome. La perte des passkeys est réelle, mais le QR code la contourne, et le profil qui en a le plus besoin reste dans le Chrome d\u0026rsquo;origine.\nJe l\u0026rsquo;ai testée avec Chrome 155 sur macOS 27. Attention, c\u0026rsquo;est une solution non officielle : Google ne la supporte pas, et une future version de Chrome ou de macOS peut la casser.\nLe script et les références Le script est sur GitHub, avec son mode d\u0026rsquo;emploi : tips-and-scripts/macos/copy-chrome.\nPour aller plus loin :\nCode source de Chromium : chrome_paths_mac.mm (la clé CrProductDirName) TeamDev : passkeys et Touch ID dans un navigateur Chromium Mozilla Connect : une icône de Dock par profil Epichrome (projet arrêté) chrome-schismator ","permalink":"https://aymenjamaaoui.dev/blog/un-chrome-par-compte-google-macos/","summary":"\u003cp\u003eToute la journée, j\u0026rsquo;ai deux Chrome ouverts : mon profil perso (Gmail, mes comptes) et\nmon profil pro (un compte Google Workspace pour l\u0026rsquo;entreprise). Et sur macOS, c\u0026rsquo;est\npénible.\u003c/p\u003e\n\u003ch2 id=\"le-problème\"\u003eLe problème\u003c/h2\u003e\n\u003cp\u003eSur macOS, tous les profils Chrome sont dans \u003cstrong\u003eune seule application\u003c/strong\u003e. Le Dock et\nCmd+Tab regroupent les fenêtres par application. Résultat :\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ele Dock affiche \u003cstrong\u003eune seule\u003c/strong\u003e icône Chrome pour tous les profils ;\u003c/li\u003e\n\u003cli\u003eCmd+Tab affiche \u003cstrong\u003eun seul\u003c/strong\u003e Chrome. Pour passer de la fenêtre perso à la fenêtre\npro, il faut Cmd+`, le menu Fenêtre ou Mission Control ;\u003c/li\u003e\n\u003cli\u003edans Mission Control, les fenêtres perso et pro sont mélangées.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eJe voulais quelque chose de simple : une icône par compte dans le Dock, et une entrée\npar compte dans Cmd+Tab.\u003c/p\u003e","title":"Un Chrome par compte Google sur macOS"},{"content":" À propos\nAymen Jamaaoui Lead Développeur Java · Backend \u0026amp; Full Stack\nBasé en Île-de-France, je conçois et je fais évoluer des applications Java depuis plus de 15 ans, de la finance au secteur public, là où la fiabilité n’est pas négociable. Microservices distribués, flux temps réel, applications sécurisées : j’aime les sujets où chaque détail compte. Attaché au software craftsmanship, je défends un code propre, testé et lisible, qu’un autre développeur pourra reprendre sans peine des années plus tard.\nCe que je fais\nModerniser, concevoir, transmettre Moderniser l\u0026rsquo;existant : faire évoluer des applications en production petit à petit, sans rien casser pour les utilisateurs. Concevoir des architectures distribuées : découper un monolithe en microservices, conteneurisés et déployés sur Kubernetes. Automatiser les livraisons : mettre en place des pipelines CI/CD pour livrer souvent et sans surprise. Sécuriser les applications : authentification, chiffrement des échanges et correction des vulnérabilités. Faire grandir l\u0026rsquo;équipe : relire le code, aider aux choix techniques et accompagner les développeurs. Développer avec l\u0026rsquo;IA : intégrer les assistants de code et les agents pour aller plus vite sans sacrifier la qualité. Compétences\nMa boîte à outils Langages Java TypeScript Shell Python Groovy Backend Spring Boot Kafka REST SOAP GraphQL WebSockets Frontend Angular PrimeNG React Cloud \u0026amp; Infra Docker Cloud Kubernetes Jenkins Ansible AWS Données PostgreSQL Oracle MySQL MongoDB Redshift IA \u0026amp; agents Claude Code LLM RAG MCP function calling Où j’ai travaillé\nDes institutions exigeantes J\u0026rsquo;ai eu la chance de travailler pour des institutions où l\u0026rsquo;erreur n\u0026rsquo;a pas sa place. Chez BNP Paribas Securities Services, j\u0026rsquo;ai participé à la migration vers T2S, la plateforme européenne de règlement-livraison des titres, jusqu\u0026rsquo;aux bascules en production. À la Bibliothèque nationale de France, j\u0026rsquo;ai conçu de zéro le portail de dépôt légal de l\u0026rsquo;image animée. Chez Euronext, premier opérateur de bourses en Europe, j\u0026rsquo;ai mené la refonte d\u0026rsquo;un outil qui exécute chaque jour des milliers de tests, sans interrompre le travail des équipes.\nVoir mon parcours complet → Open source\nRendre à la communauté Je contribue à l\u0026rsquo;open source. Voici quelques projets auxquels j\u0026rsquo;ai contribué :\nQuickFIX/J : l\u0026rsquo;implémentation open source du protocole FIX en Java. GitHub → Simple Binary Encoding (SBE) : une bibliothèque d\u0026rsquo;encodage binaire très rapide, utilisée en finance. GitHub → Floci : un émulateur local des services AWS, écrit en Java avec Quarkus. GitHub → Formation \u0026amp; certifications\nQualifications Ingénieur en informatique, diplômé de l\u0026rsquo;ENSI en Tunisie en 2010.\nSpring Certified Professional Broadcom / VMware · 2025 AWS Solutions Architect Associate · Amazon Web Services · 2025 OCP Java SE 8 Oracle Certified Professional · Oracle · 2022 OCA Java SE 8 Oracle Certified Associate · Oracle · 2020 Langues\nComment échanger Arabe : langue maternelle Français : courant Anglais : intermédiaire Me contacter Une question, un projet, une idée ?\nMe contacter → ","permalink":"https://aymenjamaaoui.dev/a-propos/","summary":"\u003cheader class=\"about-header about-header-photo\"\u003e\n  \u003cdiv class=\"about-header-text\"\u003e\n    \u003cp class=\"aj-overline\"\u003eÀ propos\u003c/p\u003e\n    \u003ch1 class=\"about-name\"\u003eAymen Jamaaoui\u003c/h1\u003e\n    \u003cp class=\"about-role\"\u003eLead Développeur Java · Backend \u0026amp; Full Stack\u003c/p\u003e\n    \u003cdiv class=\"about-intro\"\u003e\n      \u003cp\u003eBasé en Île-de-France, je conçois et je fais évoluer des applications Java depuis plus de 15 ans, de la finance au secteur public, là où la fiabilité n’est pas négociable. Microservices distribués, flux temps réel, applications sécurisées : j’aime les sujets où chaque détail compte. Attaché au software craftsmanship, je défends un code propre, testé et lisible, qu’un autre développeur pourra reprendre sans peine des années plus tard.\u003c/p\u003e\n\n    \u003c/div\u003e\n    \u003cdiv class=\"social-icons\" \u003e\n    \u003ca href=\"https://github.com/aymenjamaaoui\" target=\"_blank\" rel=\"noopener noreferrer me\"\n        title=\"Github\"\u003e\n        \u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath\n        d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"\u003e\n    \u003c/path\u003e\n\u003c/svg\u003e\n    \u003c/a\u003e\n    \u003ca href=\"https://gitlab.com/aymenjamaaoui\" target=\"_blank\" rel=\"noopener noreferrer me\"\n        title=\"Gitlab\"\u003e\n        \u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath\n        d=\"M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z\"\u003e\n    \u003c/path\u003e\n\u003c/svg\u003e\n    \u003c/a\u003e\n    \u003ca href=\"https://linkedin.com/in/aymen-jamaaoui/\" target=\"_blank\" rel=\"noopener noreferrer me\"\n        title=\"Linkedin\"\u003e\n        \u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"\u003e\u003c/path\u003e\n    \u003crect x=\"2\" y=\"9\" width=\"4\" height=\"12\"\u003e\u003c/rect\u003e\n    \u003ccircle cx=\"4\" cy=\"4\" r=\"2\"\u003e\u003c/circle\u003e\n\u003c/svg\u003e\n    \u003c/a\u003e\n\u003c/div\u003e\n\n  \u003c/div\u003e\n  \u003cimg class=\"about-photo\" src=\"/images/portrait_hu_5ab6f3a5bffa9960.webp\" width=\"240\" height=\"240\" alt=\"Photo d\u0026#39;Aymen Jamaaoui\"\u003e\n\u003c/header\u003e","title":"À propos"},{"content":" Contact\nParlons-en Vous travaillez sur des applications Java, des architectures distribuées ou la modernisation d\u0026rsquo;un existant ? Une question, une idée ou simplement l\u0026rsquo;envie d\u0026rsquo;échanger : écrivez-moi.\nEmailLe plus rapide aymen.jamaaoui [arobase] gmail [point] com Copier → LinkedInLe plus rapide in/aymen-jamaaoui → GitHub @aymenjamaaoui → GitLab @aymenjamaaoui → Pour un échange rapide, l\u0026rsquo;email ou LinkedIn restent le plus simple.\n","permalink":"https://aymenjamaaoui.dev/contact/","summary":"\u003cheader class=\"contact-header\"\u003e\n  \u003cp class=\"aj-overline\"\u003eContact\u003c/p\u003e\n  \u003ch1 class=\"contact-title\"\u003eParlons-en\u003c/h1\u003e\n  \u003cdiv class=\"contact-intro\"\u003e\n    \u003cp\u003eVous travaillez sur des applications Java, des architectures distribuées ou la modernisation d\u0026rsquo;un existant ? Une question, une idée ou simplement l\u0026rsquo;envie d\u0026rsquo;échanger : écrivez-moi.\u003c/p\u003e\n\n  \u003c/div\u003e\n\u003c/header\u003e\n\n\n\u003cdiv class=\"contact-cards\"\u003e\n  \u003cdiv class=\"contact-card contact-email contact-card-strong\"\u003e\n    \u003cspan class=\"contact-icon\"\u003e\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 21\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"\u003e\u003c/path\u003e\n    \u003cpolyline points=\"22,6 12,13 2,6\"\u003e\u003c/polyline\u003e\n\u003c/svg\u003e\u003c/span\u003e\n    \u003cspan class=\"contact-text\"\u003e\n      \u003cspan class=\"contact-name\"\u003e\n        \u003ca class=\"contact-card-link\" data-u=\"YXltZW4uamFtYWFvdWk=\" data-d=\"Z21haWwuY29t\"\u003e\u003cstrong\u003eEmail\u003c/strong\u003e\u003c/a\u003e\u003cspan class=\"contact-badge\"\u003eLe plus rapide\u003c/span\u003e\n      \u003c/span\u003e\n      \u003cspan class=\"contact-line-row\"\u003e\n        \u003cspan class=\"contact-line\"\u003eaymen.jamaaoui [arobase] gmail [point] com\u003c/span\u003e\n        \u003cbutton type=\"button\" class=\"contact-copy\" data-copied=\"Copié\" aria-live=\"polite\" hidden\u003eCopier\u003c/button\u003e\n      \u003c/span\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"contact-arrow\" aria-hidden=\"true\"\u003e→\u003c/span\u003e\n  \u003c/div\u003e\n  \u003ca class=\"contact-card contact-card-strong\" href=\"https://linkedin.com/in/aymen-jamaaoui/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003e\n    \u003cspan class=\"contact-icon\"\u003e\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"\u003e\u003c/path\u003e\n    \u003crect x=\"2\" y=\"9\" width=\"4\" height=\"12\"\u003e\u003c/rect\u003e\n    \u003ccircle cx=\"4\" cy=\"4\" r=\"2\"\u003e\u003c/circle\u003e\n\u003c/svg\u003e\u003c/span\u003e\n    \u003cspan class=\"contact-text\"\u003e\n      \u003cspan class=\"contact-name\"\u003e\u003cstrong\u003eLinkedIn\u003c/strong\u003e\u003cspan class=\"contact-badge\"\u003eLe plus rapide\u003c/span\u003e\u003c/span\u003e\n      \u003cspan class=\"contact-line\"\u003ein/aymen-jamaaoui\u003c/span\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"contact-arrow\" aria-hidden=\"true\"\u003e→\u003c/span\u003e\n  \u003c/a\u003e\n  \u003ca class=\"contact-card\" href=\"https://github.com/aymenjamaaoui\" target=\"_blank\" rel=\"noopener noreferrer\"\u003e\n    \u003cspan class=\"contact-icon\"\u003e\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath\n        d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"\u003e\n    \u003c/path\u003e\n\u003c/svg\u003e\u003c/span\u003e\n    \u003cspan class=\"contact-text\"\u003e\n      \u003cspan class=\"contact-name\"\u003e\u003cstrong\u003eGitHub\u003c/strong\u003e\u003c/span\u003e\n      \u003cspan class=\"contact-line\"\u003e@aymenjamaaoui\u003c/span\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"contact-arrow\" aria-hidden=\"true\"\u003e→\u003c/span\u003e\n  \u003c/a\u003e\n  \u003ca class=\"contact-card\" href=\"https://gitlab.com/aymenjamaaoui\" target=\"_blank\" rel=\"noopener noreferrer\"\u003e\n    \u003cspan class=\"contact-icon\"\u003e\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n    stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\n    \u003cpath\n        d=\"M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z\"\u003e\n    \u003c/path\u003e\n\u003c/svg\u003e\u003c/span\u003e\n    \u003cspan class=\"contact-text\"\u003e\n      \u003cspan class=\"contact-name\"\u003e\u003cstrong\u003eGitLab\u003c/strong\u003e\u003c/span\u003e\n      \u003cspan class=\"contact-line\"\u003e@aymenjamaaoui\u003c/span\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"contact-arrow\" aria-hidden=\"true\"\u003e→\u003c/span\u003e\n  \u003c/a\u003e\n\u003c/div\u003e\n\u003cp class=\"contact-closing\"\u003ePour un échange rapide, l\u0026rsquo;email ou LinkedIn restent le plus simple.\u003c/p\u003e","title":"Contact"},{"content":" Parcours\nPlus de 15 ans de Java, de la Tunisie à la France Tout a commencé en Tunisie, sur des applications Java EE, et m\u0026rsquo;a mené jusqu\u0026rsquo;aux systèmes critiques de la finance en France. Voici les grandes étapes.\nPlus de 15 ans d\u0026rsquo;expérience 2 pays 7 entreprises En France\nFreelance, finance et secteur public Arrivé en France en 2014, je passe des applications de gestion aux systèmes critiques de la finance.\n2023 – aujourd\u0026rsquo;hui\nFondateur · J2RA IT Services\nMa société de conseil et de développement logiciel, à travers laquelle j\u0026rsquo;interviens auprès de mes clients.\n2022 – 2026\nLead Développeur Java \u0026amp; DevOps · Euronext\nRefonte d\u0026rsquo;un outil qui exécute chaque jour des milliers de tests : découpage du monolithe en services conteneurisés et migration vers des versions récentes de Java, Spring Boot et Angular, sans interruption pour les équipes.\nSpring Boot Kafka Angular Kubernetes 2021 – 2022\nDéveloppeur senior Java · Société Générale CIB\nMaintenance des applications Forex du périmètre Kondor pour le front-office, et développement d\u0026rsquo;une application moderne qui reprend certaines de ses fonctionnalités.\nJava Spring Batch Sybase React 2018 – 2021\nDéveloppeur senior Java · Bibliothèque nationale de France\nConception de zéro de DELIA, le portail de dépôt légal de l\u0026rsquo;image animée, et évolution de deux autres applications du dépôt légal numérique.\nSpring Security Spring Batch Angular PostgreSQL 2014 – 2018\nDéveloppeur Java Backend · BNP Paribas Securities Services\nBack-office de gestion des titres : traitement des messages Swift, migration européenne T2S jusqu\u0026rsquo;aux bascules en production, et support de production de nuit.\nJava Hibernate Oracle messages Swift Ce que j\u0026rsquo;en retiens : La rigueur avant tout. Tester, documenter, et ne jamais casser la production.\nEn Tunisie\nMes débuts Avant la France, il y a eu la Tunisie, où j\u0026rsquo;ai appris à toucher à tout : web, mobile, bases de données, automatisation.\n2012 – 2014\nDéveloppeur Java \u0026amp; PHP Full Stack · OpenBee\nAutomatisation de la plateforme de livraison logicielle du portail OpenBee : stockage MongoDB, passerelle OAuth2 et authentification unique.\nPlay Framework Symfony MongoDB OAuth2 2010 – 2012\nDéveloppeur Java EE · Taggist\nApplications de géolocalisation en temps réel et de gestion d\u0026rsquo;immobilisations, sur le web et sur Android. J\u0026rsquo;y ai aussi encadré mon premier stagiaire.\nJava EE EJB JBoss Android Ce que j\u0026rsquo;en retiens : La polyvalence. Passer du web au mobile, de Java à PHP, et apprendre vite.\nFormation\nMes études Les bases : beaucoup de maths, puis l\u0026rsquo;informatique pour de bon.\n2006 – 2010\nDiplôme d\u0026rsquo;ingénieur en informatique · ENSI, Tunis\nOption Ingénierie informatique et application technologique. Projet de fin d\u0026rsquo;études : une solution RFID pour améliorer le suivi des patients dans une clinique.\n2004 – 2006\nClasses préparatoires · IPEIN, Nabeul\nDeux années intensives de mathématiques et de physique pour préparer le concours national d\u0026rsquo;entrée aux écoles d\u0026rsquo;ingénieurs.\n2004\nBaccalauréat section technique, mention Bien · Lycée Ibn El Jazzar, Kairouan\nCe que j\u0026rsquo;en retiens : La méthode. Décomposer un problème difficile jusqu\u0026rsquo;à le rendre simple.\n← En savoir plus sur moi Me contacter Une question, un projet, une idée ?\nMe contacter → ","permalink":"https://aymenjamaaoui.dev/parcours/","summary":"\u003cheader class=\"parcours-header\"\u003e\n  \u003cp class=\"aj-overline\"\u003eParcours\u003c/p\u003e\n  \u003ch1 class=\"parcours-title\"\u003ePlus de 15 ans de Java, de la Tunisie à la France\u003c/h1\u003e\n  \u003cdiv class=\"parcours-intro\"\u003e\n    \u003cp\u003eTout a commencé en Tunisie, sur des applications Java EE, et m\u0026rsquo;a mené jusqu\u0026rsquo;aux systèmes critiques de la finance en France. Voici les grandes étapes.\u003c/p\u003e\n\n  \u003c/div\u003e\n  \u003cul class=\"parcours-numbers\"\u003e\n    \u003cli\u003ePlus de 15 ans d\u0026rsquo;expérience\u003c/li\u003e\n    \u003cli\u003e2 pays\u003c/li\u003e\n    \u003cli\u003e7 entreprises\u003c/li\u003e\n  \u003c/ul\u003e\n\u003c/header\u003e\n\n\n\u003csection class=\"parcours-block\"\u003e\n  \u003cp class=\"aj-overline\"\u003eEn France\u003c/p\u003e\n  \u003ch2 class=\"aj-section-title\" id=\"freelance-finance-et-secteur-public\"\u003eFreelance, finance et secteur public\u003c/h2\u003e\n  \u003cp class=\"parcours-transition\"\u003eArrivé en France en 2014, je passe des applications de gestion aux systèmes critiques de la finance.\u003c/p\u003e\n  \u003col class=\"parcours-timeline\"\u003e\n    \u003cli class=\"parcours-entry\"\u003e\n      \u003cp class=\"parcours-period\"\u003e2023 – aujourd\u0026rsquo;hui\u003c/p\u003e\n      \u003cp class=\"parcours-heading\"\u003e\u003cstrong\u003eFondateur\u003c/strong\u003e · \u003cspan class=\"parcours-name\"\u003eJ2RA IT Services\u003c/span\u003e\u003c/p\u003e\n      \u003cp\u003eMa société de conseil et de développement logiciel, à travers laquelle j\u0026rsquo;interviens auprès de mes clients.\u003c/p\u003e\n    \u003c/li\u003e\n    \u003cli class=\"parcours-entry\"\u003e\n      \u003cp class=\"parcours-period\"\u003e2022 – 2026\u003c/p\u003e\n      \u003cp class=\"parcours-heading\"\u003e\u003cstrong\u003eLead Développeur Java \u0026amp; DevOps\u003c/strong\u003e · \u003cspan class=\"parcours-name\"\u003eEuronext\u003c/span\u003e\u003c/p\u003e\n      \u003cp\u003eRefonte d\u0026rsquo;un outil qui exécute chaque jour des milliers de tests : découpage du monolithe en services conteneurisés et migration vers des versions récentes de Java, Spring Boot et Angular, sans interruption pour les équipes.\u003c/p\u003e\n      \u003cul class=\"aj-tags\"\u003e\n        \u003cli\u003eSpring Boot\u003c/li\u003e\n        \u003cli\u003eKafka\u003c/li\u003e\n        \u003cli\u003eAngular\u003c/li\u003e\n        \u003cli\u003eKubernetes\u003c/li\u003e\n      \u003c/ul\u003e\n    \u003c/li\u003e\n    \u003cli class=\"parcours-entry\"\u003e\n      \u003cp class=\"parcours-period\"\u003e2021 – 2022\u003c/p\u003e\n      \u003cp class=\"parcours-heading\"\u003e\u003cstrong\u003eDéveloppeur senior Java\u003c/strong\u003e · \u003cspan class=\"parcours-name\"\u003eSociété Générale CIB\u003c/span\u003e\u003c/p\u003e\n      \u003cp\u003eMaintenance des applications Forex du périmètre Kondor pour le front-office, et développement d\u0026rsquo;une application moderne qui reprend certaines de ses fonctionnalités.\u003c/p\u003e\n      \u003cul class=\"aj-tags\"\u003e\n        \u003cli\u003eJava\u003c/li\u003e\n        \u003cli\u003eSpring Batch\u003c/li\u003e\n        \u003cli\u003eSybase\u003c/li\u003e\n        \u003cli\u003eReact\u003c/li\u003e\n      \u003c/ul\u003e\n    \u003c/li\u003e\n    \u003cli class=\"parcours-entry\"\u003e\n      \u003cp class=\"parcours-period\"\u003e2018 – 2021\u003c/p\u003e\n      \u003cp class=\"parcours-heading\"\u003e\u003cstrong\u003eDéveloppeur senior Java\u003c/strong\u003e · \u003cspan class=\"parcours-name\"\u003eBibliothèque nationale de France\u003c/span\u003e\u003c/p\u003e\n      \u003cp\u003eConception de zéro de DELIA, le portail de dépôt légal de l\u0026rsquo;image animée, et évolution de deux autres applications du dépôt légal numérique.\u003c/p\u003e\n      \u003cul class=\"aj-tags\"\u003e\n        \u003cli\u003eSpring Security\u003c/li\u003e\n        \u003cli\u003eSpring Batch\u003c/li\u003e\n        \u003cli\u003eAngular\u003c/li\u003e\n        \u003cli\u003ePostgreSQL\u003c/li\u003e\n      \u003c/ul\u003e\n    \u003c/li\u003e\n    \u003cli class=\"parcours-entry\"\u003e\n      \u003cp class=\"parcours-period\"\u003e2014 – 2018\u003c/p\u003e\n      \u003cp class=\"parcours-heading\"\u003e\u003cstrong\u003eDéveloppeur Java Backend\u003c/strong\u003e · \u003cspan class=\"parcours-name\"\u003eBNP Paribas Securities Services\u003c/span\u003e\u003c/p\u003e\n      \u003cp\u003eBack-office de gestion des titres : traitement des messages Swift, migration européenne T2S jusqu\u0026rsquo;aux bascules en production, et support de production de nuit.\u003c/p\u003e\n      \u003cul class=\"aj-tags\"\u003e\n        \u003cli\u003eJava\u003c/li\u003e\n        \u003cli\u003eHibernate\u003c/li\u003e\n        \u003cli\u003eOracle\u003c/li\u003e\n        \u003cli\u003emessages Swift\u003c/li\u003e\n      \u003c/ul\u003e\n    \u003c/li\u003e\n  \u003c/ol\u003e\n  \u003caside class=\"parcours-takeaway\"\u003e\n    \u003cp\u003e\u003cstrong\u003eCe que j\u0026rsquo;en retiens :\u003c/strong\u003e La rigueur avant tout. Tester, documenter, et ne jamais casser la production.\u003c/p\u003e\n  \u003c/aside\u003e\n\u003c/section\u003e","title":"Parcours"}]