Spring Framework
Spring : cycle de vie des beans, scopes et configuration avancée
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Savoir quand le conteneur crée, initialise et détruit un bean explique la plupart des « pourquoi ce champ est null » et « pourquoi ce code s'exécute deux fois ». Ce guide parcourt le cycle de vie phase par phase, compare les scopes singleton, prototype et web ainsi que leurs proxies, et présente la mécanique de configuration — @Import, @Profile, @Value, @PropertySource, @Conditional, @Lazy — sur laquelle Spring Boot bâtit son auto-configuration.
Ce que vous allez apprendre
- Suivre un bean de sa définition à sa destruction et situer chaque callback dans la bonne phase
- Choisir le bon scope et savoir quand un scoped proxy est nécessaire
- Injecter un prototype dans un singleton sans figer une instance unique
- Découper la configuration avec @Import, @Profile et @PropertySource, et lire des valeurs avec @Value et Environment
- Écrire un BeanPostProcessor et expliquer comment Spring utilise ce même point d'extension pour ses proxies
L'injection de dépendances vous dit ce que reçoit un bean. Le cycle de vie vous dit quand, et le scope vous dit combien. La plupart des comportements déroutants de Spring — un champ injecté qui vaut null dans un constructeur, une méthode d'initialisation qui s'exécute deux fois, un bean de scope request qui fait échouer le démarrage, un prototype qui ne change jamais — sont en réalité des questions de cycle de vie ou de scope. Ce guide cible Spring Framework 6.x et suppose que vous savez déjà déclarer des beans en configuration Java.
Le cycle de vie, phase par phase
La vie d'un bean se découpe nettement en phases qui concernent le conteneur et en phases qui concernent l'instance.
Phase de définition
Lorsqu'un ApplicationContext se rafraîchit, il commence par collecter les définitions de beans — des objets de métadonnées qui contiennent la classe, le scope, les arguments du constructeur, les valeurs de propriétés, les noms des méthodes d'initialisation et de destruction, et l'indicateur lazy. Rien n'est encore instancié. C'est à ce moment que s'exécutent les implémentations de BeanFactoryPostProcessor, qui peuvent ajouter, supprimer ou modifier des définitions. Le mécanisme de placeholders qui résout ${db.url} dans la configuration en est un ; les import selectors de l'auto-configuration de Spring Boot en sont un autre.
Instanciation et injection des propriétés
Pour chaque singleton non lazy, dans un ordre qui respecte les dépendances, le conteneur :
- Instancie l'objet en appelant le constructeur retenu, après avoir résolu chacun de ses arguments. Si le constructeur de
Aa besoin deB,Best entièrement créé avant que le constructeur deAne s'exécute. - Renseigne les points d'injection par setter et par champ (
@Autowired,@Value,@Resource). - Appelle les interfaces Aware si elles sont implémentées :
BeanNameAware.setBeanName,BeanFactoryAware,ApplicationContextAware,EnvironmentAware,ResourceLoaderAware. Elles donnent au bean accès aux entrailles du conteneur ; utiles dans du code de framework, elles sont un signal d'alerte dans du code métier.
Initialisation
- Chaque
BeanPostProcessorenregistré reçoitpostProcessBeforeInitialization(bean, name). C'est ici que se trouve le processor qui gère@PostConstruct. - Les méthodes
@PostConstructs'exécutent, puisInitializingBean.afterPropertiesSet(), puis la méthode désignée dans@Bean(initMethod = "…"). - Chaque
BeanPostProcessorreçoitpostProcessAfterInitialization(bean, name). C'est là qu'a lieu l'auto-proxying AOP : le processor peut renvoyer un autre objet — un proxy — et c'est ce proxy, et non votre instance, qui entre dans le registre des singletons et est injecté ailleurs.
Le bean est maintenant en service. Les beans SmartLifecycle reçoivent en plus start() une fois le contexte entièrement rafraîchi : c'est le bon point d'accroche pour une logique du type « commencer à consommer des messages », qui a besoin que tous les autres beans soient prêts.
Destruction
Quand close() est appelé sur le contexte (ou que se déclenche le shutdown hook de la JVM enregistré par registerShutdownHook()), les singletons sont détruits dans l'ordre inverse des dépendances : @PreDestroy, puis DisposableBean.destroy(), puis la méthode @Bean(destroyMethod). Pour les méthodes @Bean, Spring déduit automatiquement une méthode de destruction nommée close ou shutdown : un bean HikariDataSource est donc fermé sans aucune configuration.
@Component
public class IndexWarmer implements SmartLifecycle {
private final SearchIndex index;
private volatile boolean running;
public IndexWarmer(SearchIndex index) { this.index = index; }
@PostConstruct
void checkConfig() { // injection is complete; safe to use `index`
index.validate();
}
@Override public void start() { index.warm(); running = true; } // whole context is ready
@Override public void stop() { running = false; }
@Override public boolean isRunning() { return running; }
@PreDestroy
void release() { index.close(); }
}
La règle qui résout à elle seule la plupart des bugs de cycle de vie : avec l'injection par champ ou par setter, les champs injectés ne sont pas disponibles dans le constructeur. Ils le sont dans @PostConstruct. Avec l'injection par constructeur, la question ne se pose plus — un argument de plus en sa faveur.
Les scopes
Un scope définit combien d'instances d'une définition de bean existent et combien de temps chacune vit.
| Scope | Instances | Durée de vie | Disponible dans |
|---|---|---|---|
singleton (par défaut) | Une par conteneur | Tout le contexte | Partout |
prototype | Une par injection ou par getBean | Jusqu'à ne plus être référencée ; aucun callback de destruction | Partout |
request | Une par requête HTTP | La requête | Contextes web |
session | Une par session HTTP | La session | Contextes web |
application | Une par ServletContext | L'application web | Contextes web |
websocket | Une par session WebSocket | La session | Applications WebSocket |
Singleton signifie ici un par conteneur, pas un par JVM : deux contextes contiennent deux instances, et une même classe peut être enregistrée deux fois sous des noms différents. Comme les singletons sont partagés entre threads, ils doivent être sans état ou thread-safe. Stocker des données propres à une requête dans un champ de singleton est un bug classique, et grave.
Le scope prototype et le piège du singleton
Déclarer @Scope("prototype") amène le conteneur à créer une nouvelle instance à chaque fois que le bean est demandé. Le piège : un singleton le demande exactement une fois — à sa propre création — puis garde cette instance pour toujours. Trois façons d'en sortir :
@Component
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
public class ReportBuilder { /* holds per-report mutable state */ }
@Service
public class ReportService {
private final ObjectProvider<ReportBuilder> builders;
public ReportService(ObjectProvider<ReportBuilder> builders) { this.builders = builders; }
public Report build(Query q) {
ReportBuilder builder = builders.getObject(); // new instance each call
return builder.with(q).build();
}
}
ObjectProvider est la solution la plus propre. @Lookup sur une méthode abstraite amène Spring à générer une sous-classe dont la méthode renvoie un bean neuf. Un scoped proxy (voir plus bas) fonctionne aussi, mais il est conçu pour les scopes web.
Scopes web et scoped proxies
Un bean de scope request ne peut pas être injecté directement dans un singleton : au démarrage, il n'y a pas de requête, donc rien à injecter, et le conteneur échoue avec un message indiquant qu'aucune requête n'est liée au thread. La solution consiste à injecter un proxy qui résout le véritable objet de scope request à chaque appel de méthode :
@Component
@RequestScope // shorthand for @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestAudit {
private final List<String> events = new ArrayList<>();
public void record(String e) { events.add(e); }
public List<String> events() { return events; }
}
@Service
public class CheckoutService {
private final RequestAudit audit; // actually a CGLIB proxy
public CheckoutService(RequestAudit audit) { this.audit = audit; }
public void checkout() { audit.record("checkout"); }
}
@RequestScope, @SessionScope et @ApplicationScope configurent le mode de proxy à votre place. Le proxy est une sous-classe de la cible (TARGET_CLASS) ou une implémentation d'interface (INTERFACES) ; dans les deux cas, la classe doit pouvoir être proxifiée — pas final, avec un constructeur accessible. Les beans de scope session devraient être Serializable si les sessions peuvent être persistées ou répliquées.
Les fonctionnalités de configuration qui façonnent le contexte
Découper et importer
Les vraies applications répartissent leur configuration en plusieurs classes, par responsabilité : DataConfig, WebConfig, SecurityConfig. On les relie avec @Import, qui est explicite et fonctionne en dehors des packages scannés :
@Configuration
@Import({DataConfig.class, MessagingConfig.class})
public class AppConfig { }
@Import accepte aussi un ImportSelector, une classe qui décide à l'exécution quelles configurations importer : c'est le mécanisme qui se cache derrière chaque annotation @Enable… et derrière l'auto-configuration de Spring Boot.
@Configuration(proxyBeanMethods = false) désactive le sous-classement CGLIB décrit dans le guide sur l'injection de dépendances. Les appels entre méthodes @Bean créent alors de nouveaux objets au lieu de renvoyer l'objet géré : vous devez donc passer les dépendances en paramètres. Les auto-configurations de Spring Boot utilisent elles-mêmes ce mode « lite » pour démarrer plus vite, et c'est un choix par défaut raisonnable pour votre propre code une fois le style par paramètres adopté.
Les profils
Un profil est une tranche nommée de configuration, activée selon l'environnement. N'importe quel @Component, @Configuration ou @Bean peut porter @Profile :
@Configuration
public class DataConfig {
@Bean
@Profile("local")
DataSource embeddedDataSource() {
return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).build();
}
@Bean
@Profile("!local") // expressions: !, &, |
DataSource pooledDataSource(Environment env) {
var ds = new HikariDataSource();
ds.setJdbcUrl(env.getRequiredProperty("db.url"));
return ds;
}
}
Activez les profils avec la propriété système ou la variable d'environnement spring.profiles.active, ou par programmation avec context.getEnvironment().setActiveProfiles("local") avant le rafraîchissement. Quand aucun profil n'est actif, ce sont les beans du profil default qui sont enregistrés. Les profils servent aux environnements ; ne les utilisez pas pour activer ou désactiver des fonctionnalités — une propriété et @Conditional sont les bons outils pour cela.
Propriétés, Environment et @Value
L'abstraction Environment unifie les sources de propriétés — propriétés système, variables d'environnement, fichiers de propriétés et tout ce que vous y ajoutez — dans un ordre de recherche unique. @PropertySource ajoute un fichier :
@Configuration
@PropertySource("classpath:app.properties")
public class AppConfig {
@Bean
Mailer mailer(@Value("${mail.host}") String host,
@Value("${mail.port:587}") int port, // default after the colon
@Value("#{systemProperties['user.name']}") String user) { // SpEL
return new SmtpMailer(host, port, user);
}
}
@Value gère les valeurs simples, avec conversion de type. Pour une configuration structurée — un groupe de réglages liés, avec validation —, @ConfigurationProperties de Spring Boot est bien meilleur ; dans du code Framework pur, injectez Environment et lisez un préfixe, ou faites le binding à la main. Ne codez jamais de secrets en dur dans des fichiers de propriétés commités ; lisez-les depuis l'environnement.
Conditions et chargement lazy
@Conditional(SomeCondition.class) n'enregistre un bean que si la méthode matches de la condition renvoie true. Écrire soi-même des conditions est rare dans le code applicatif, mais les comprendre démystifie le @ConditionalOnMissingBean de Boot : c'est le même mécanisme, avec une condition déjà écrite.
@Lazy diffère la création d'un singleton jusqu'à sa première utilisation. C'est légitime pour un bean lourd que la plupart des requêtes n'utilisent jamais, ou pour casser une référence circulaire tenace en injectant un proxy lazy. Ce n'est pas une stratégie de performance : cela reporte le coût du démarrage sur la première requête malchanceuse et masque les erreurs de configuration jusque-là.
@DependsOn("flywayMigration") impose un ordre de création là où il n'existe aucune relation d'injection — le cas d'école étant « exécuter la migration du schéma avant l'initialisation de l'entity manager factory ».
Étendre le conteneur
Un BeanPostProcessor voit passer chaque bean après son instanciation et peut l'envelopper ou le modifier. Le support de @Transactional, @Cacheable, @Async et de l'AOP dans Spring repose entièrement sur des post-processors qui renvoient un proxy depuis postProcessAfterInitialization. En écrire un petit vous-même rend le mécanisme concret :
@Component
public class TimingPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (!bean.getClass().isAnnotationPresent(Timed.class)) {
return bean; // leave everything else untouched
}
var factory = new ProxyFactory(bean); // spring-aop
factory.addAdvice((MethodInterceptor) invocation -> {
long start = System.nanoTime();
try {
return invocation.proceed();
} finally {
System.out.printf("%s took %d µs%n", invocation.getMethod().getName(),
(System.nanoTime() - start) / 1_000);
}
});
return factory.getProxy();
}
}
Deux conséquences en découlent pour quiconque utilise des proxies : l'objet injecté dans les autres beans est le proxy, si bien que bean.getClass() renvoie une classe générée, et les appels qu'un bean fait à ses propres méthodes contournent entièrement le proxy. Ces deux points sont traités en détail dans le guide sur l'AOP.
Un FactoryBean<T> est l'autre point d'extension classique : un bean dont le rôle est de produire un autre bean de type T, utilisé quand la construction est trop complexe pour une méthode @Bean — LocalContainerEntityManagerFactoryBean et les factories de repositories de Spring Data en sont des exemples. Demander au conteneur le nom du bean renvoie le produit ; préfixer ce nom par & renvoie la factory elle-même.
Erreurs courantes
Utiliser un champ injecté dans le constructeur. Avec l'injection par champ, le champ y vaut encore null. Déplacez la logique dans @PostConstruct ou, mieux, passez à l'injection par constructeur.
Des singletons avec état. Un @Service doté d'un champ private List<Order> current est partagé par tous les threads de requête. Gardez l'état dans la portée de la méthode, dans un bean de scope request ou dans une structure explicite et thread-safe.
Injecter un bean de scope request sans proxy. Le démarrage échoue parce qu'aucune requête n'existe. Utilisez @RequestScope ou renseignez proxyMode.
Attendre des callbacks de destruction sur les prototypes. Ils ne se déclenchent jamais. Fermez les ressources vous-même.
Enregistrer un BeanPostProcessor qui dépend de beans ordinaires. Les post-processors sont créés avant tout le reste : les beans qu'ils entraînent avec eux sont donc instanciés trop tôt et échappent aux autres post-processors — Spring journalise alors « is not eligible for getting processed by all BeanPostProcessors ». Gardez vos post-processors sans dépendances, ou utilisez ObjectProvider à l'intérieur.
Placer @Profile sur une méthode d'une configuration lite et oublier l'alternative. Si tous les candidats pour un type sont conditionnés par un profil et qu'aucun profil ne correspond, l'injection échoue au démarrage. Prévoyez toujours une valeur par défaut inconditionnelle ou !profile, ou rendez la dépendance optionnelle.
Points clés à retenir
- Un bean traverse la définition, l'instanciation, l'injection des propriétés, les callbacks Aware, les hooks des post-processors, les callbacks d'initialisation et enfin la destruction ;
@PostConstructs'exécute après l'injection, jamais avant. postProcessAfterInitializationpeut remplacer un bean par un proxy — c'est ainsi que les transactions, le cache et l'AOP se greffent.- Singleton, c'est une instance par conteneur, partagée entre threads ; prototype, une instance par demande au conteneur, sans callback de destruction ; les scopes web ont besoin d'un scoped proxy pour être injectés dans des singletons.
- Injectez
ObjectProvider<T>quand un singleton a besoin d'un nouveau prototype à chaque appel. @Importcompose la configuration,@Profilefait la sélection par environnement,@PropertySourceet@Valuelisent les réglages, et@Conditionalest l'interrupteur général sur lequel repose l'auto-configuration de Spring Boot.@Lazyet@DependsOnsont des correctifs ciblés, pas des réglages par défaut ; un démarrage qui échoue vite est une qualité.
Sources et lectures complémentaires
Questions fréquentes
- Qu'est-ce qui s'exécute en premier, @PostConstruct ou afterPropertiesSet ?
- @PostConstruct, puis InitializingBean.afterPropertiesSet, puis la méthode @Bean(initMethod) ou l'init-method XML. Les trois s'exécutent une fois l'injection des dépendances terminée et avant que le bean ne soit fourni aux autres beans : chacune peut donc utiliser sans risque les champs injectés. Préférez @PostConstruct, sauf si vous avez une bonne raison de coupler la classe aux interfaces de Spring.
- Pourquoi mon bean prototype est-il toujours la même instance ?
- Parce qu'il a été injecté une seule fois dans un singleton, et qu'un singleton n'est créé qu'une fois. Le conteneur n'a aucune occasion de créer un nouveau prototype à chaque appel. Injectez un ObjectProvider<T> et appelez getObject() quand vous avez besoin d'une nouvelle instance, ou déclarez le prototype avec un scoped proxy, pour que chaque appel de méthode passe par un proxy qui résout une nouvelle cible.
- Spring appelle-t-il @PreDestroy sur les beans prototype ?
- Non. Le conteneur crée le prototype, l'injecte, exécute ses callbacks d'initialisation, puis l'oublie : il ne suit jamais l'instance et ne peut donc pas la détruire. Si un prototype détient des ressources, c'est au client qui l'a obtenu de les libérer, à moins de l'enregistrer auprès d'un bean post-processor personnalisé qui garde la trace des instances.
- Quelle différence entre @Profile et @Conditional ?
- @Profile est une condition précise et intégrée : le bean n'est enregistré que si l'un des profils nommés est actif dans l'Environment. @Conditional est le mécanisme général — vous fournissez une classe Condition qui peut inspecter n'importe quoi (une propriété, une classe présente dans le classpath, un bean existant), et les @ConditionalOnMissingBean, @ConditionalOnProperty et consorts de Spring Boot reposent tous dessus.