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 :

  1. Instancie l'objet en appelant le constructeur retenu, après avoir résolu chacun de ses arguments. Si le constructeur de A a besoin de B, B est entièrement créé avant que le constructeur de A ne s'exécute.
  2. Renseigne les points d'injection par setter et par champ (@Autowired, @Value, @Resource).
  3. 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

  1. Chaque BeanPostProcessor enregistré reçoit postProcessBeforeInitialization(bean, name). C'est ici que se trouve le processor qui gère @PostConstruct.
  2. Les méthodes @PostConstruct s'exécutent, puis InitializingBean.afterPropertiesSet(), puis la méthode désignée dans @Bean(initMethod = "…").
  3. Chaque BeanPostProcessor reçoit postProcessAfterInitialization(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.

ScopeInstancesDurée de vieDisponible dans
singleton (par défaut)Une par conteneurTout le contextePartout
prototypeUne par injection ou par getBeanJusqu'à ne plus être référencée ; aucun callback de destructionPartout
requestUne par requête HTTPLa requêteContextes web
sessionUne par session HTTPLa sessionContextes web
applicationUne par ServletContextL'application webContextes web
websocketUne par session WebSocketLa sessionApplications 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 ; @PostConstruct s'exécute après l'injection, jamais avant.
  • postProcessAfterInitialization peut 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.
  • @Import compose la configuration, @Profile fait la sélection par environnement, @PropertySource et @Value lisent les réglages, et @Conditional est l'interrupteur général sur lequel repose l'auto-configuration de Spring Boot.
  • @Lazy et @DependsOn sont 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.