ExecutorService in Android Java: Guida Completa

ExecutorService in Android Java: guida completa all’uso corretto dei thread pool

In Android, gestire correttamente i thread è fondamentale per mantenere l’interfaccia utente fluida e reattiva. L’API ExecutorService di Java è uno degli strumenti più potenti e flessibili per eseguire operazioni in background, molto più scalabile rispetto a creare Thread manualmente uno per uno. In questo articolo vedremo come usare ExecutorService in Android Java, i diversi tipi di executor disponibili, esempi pratici con Callable, Future e runOnUiThread, la gestione di errori e shutdown, e un confronto con AsyncTask e thread tradizionali.

ExecutorService Android Java

Perché usare ExecutorService in Android Java

In Android, tutto il codice che tocca la UI deve girare sul main thread (o UI thread). Operazioni lente come chiamate di rete, accesso al database o elaborazioni pesanti devi spostarle su thread in background per evitare blocchi, ANR (Application Not Responding) e una pessima esperienza utente. È qui che entra in gioco ExecutorService:

  • Gestisce automaticamente un pool di thread riutilizzabili.
  • Permette di inviare task (oggetti Runnable o Callable) da eseguire in background.
  • Offre API avanzate per pianificare esecuzioni future, gestire il risultato con Future e controllare lo shutdown.
  • Riduce l’overhead di creazione/distruzione di thread, migliorando le prestazioni.

A differenza di creare manualmente un new Thread(...), con ExecutorService puoi centralizzare e controllare tutte le operazioni in background di un’app Android, ottenendo un codice più pulito, testabile e manutenibile.

Tipi di ExecutorService in Android Java

La classe Executors fornisce diversi metodi statici per creare implementazioni di ExecutorService. I più usati in Android sono:

  • newSingleThreadExecutor()
  • newFixedThreadPool(int nThreads)
  • newCachedThreadPool()
  • newScheduledThreadPool(int corePoolSize)

newSingleThreadExecutor

newSingleThreadExecutor() crea un executor con un solo thread in background. Tutti i task girano in modo seriale, uno dopo l’altro, nell’ordine in cui li invii.

È utile quando vuoi garantire che un certo tipo di operazione non giri mai in parallelo, ad esempio scritture su disco o su database sensibili all’ordine.

ExecutorService singleExecutor = Executors.newSingleThreadExecutor();

singleExecutor.execute(new Runnable() {
    @Override
    public void run() {
        // Codice eseguito in background, in modo seriale
        saveUserToDatabase(user);
    }
});

newFixedThreadPool

newFixedThreadPool(int nThreads) crea un pool con un numero fisso di thread. Se invii più task rispetto ai thread disponibili, il pool mette in coda i task extra.

È ideale quando vuoi limitare il grado di parallelismo, ad esempio per un insieme di operazioni di rete o di I/O che non devono saturare le risorse del dispositivo.

int NUMBER_OF_THREADS = 4;
ExecutorService fixedExecutor = Executors.newFixedThreadPool(NUMBER_OF_THREADS);

for (int i = 0; i < 10; i++) {
    final int index = i;
    fixedExecutor.execute(new Runnable() {
        @Override
        public void run() {
            // Esegui fino a 4 task in parallelo
            downloadImage("https://example.com/image" + index + ".png");
        }
    });
}

newCachedThreadPool

newCachedThreadPool() crea un pool dinamico che può crescere e ridursi a seconda del carico. Riutilizza i thread inattivi per i nuovi task, ma se necessario ne crea di nuovi.

È adatto a eseguire molti task brevi e indipendenti tra loro, ma va usato con cautela in Android: se invii troppi task rischi di creare un numero eccessivo di thread e consumare molte risorse.

ExecutorService cachedExecutor = Executors.newCachedThreadPool();

for (int i = 0; i < 100; i++) {
    cachedExecutor.execute(new Runnable() {
        @Override
        public void run() {
            // Task veloce
            logEventToServer();
        }
    });
}

newScheduledThreadPool

newScheduledThreadPool(int corePoolSize) permette di pianificare l’esecuzione futura di un task, oppure eseguirlo periodicamente. Implementa l’interfaccia ScheduledExecutorService, che estende ExecutorService.

Può essere utile in Android per operazioni ripetitive in background, come sincronizzazioni periodiche o cleanup, purché tu rispetti i vincoli di risparmio energetico e le linee guida di Android (in molti casi conviene usare WorkManager).

ScheduledExecutorService scheduledExecutor =
        Executors.newScheduledThreadPool(2);

// Esegui un task una volta dopo 5 secondi
scheduledExecutor.schedule(new Runnable() {
    @Override
    public void run() {
        syncDataWithServer();
    }
}, 5, TimeUnit.SECONDS);

// Esegui un task ripetuto ogni 10 secondi
scheduledExecutor.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        cleanOldCache();
    }
}, 0, 10, TimeUnit.SECONDS);

Esempio pratico: ExecutorService con Callable, Future e runOnUiThread

Uno degli scenari più comuni in Android è eseguire una operazione che restituisce un risultato dal background thread e poi aggiornare la UI con quel risultato. Con ExecutorService puoi farlo usando Callable<T> e Future<T>.

Supponiamo di dover caricare una lista di elementi da API e mostrarla in una RecyclerView. Useremo un ExecutorService per la chiamata di rete e runOnUiThread per aggiornare la UI:

public class MainActivity extends AppCompatActivity {

    private ExecutorService executorService;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        executorService = Executors.newFixedThreadPool(3);

        loadItems();
    }

    private void loadItems() {
        Callable<List<Item>> loadItemsTask = new Callable<List<Item>>() {
            @Override
            public List<Item> call() throws Exception {
                // Questo gira in background
                return apiClient.fetchItems();
            }
        };

        final Future<List<Item>> future = executorService.submit(loadItemsTask);

        executorService.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    final List<Item> items = future.get(); // Bloccante, ma siamo in background

                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            // Questo gira sul main thread: aggiorna la UI
                            showItemsInRecyclerView(items);
                        }
                    });

                } catch (final Exception e) {
                    e.printStackTrace();
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            showErrorMessage(e.getMessage());
                        }
                    });
                }
            }
        });
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        executorService.shutdownNow();
    }
}

In questo esempio:

  • Callable<List<Item>> definisce un task che restituisce un valore e può lanciare eccezioni.
  • submit() restituisce un Future<List<Item>>, che rappresenta il risultato futuro.
  • In un altro task in background chiamiamo future.get(), che blocca fino a quando i dati sono pronti.
  • Infine usiamo runOnUiThread() per aggiornare in sicurezza la RecyclerView con i dati caricati.

In un codice di produzione potrebbe essere preferibile usare una sola chiamata a submit(Callable) e gestire il risultato all’interno dello stesso task, ma l’esempio mette in evidenza l’uso combinato di ExecutorService, Future e aggiornamento UI.

Gestione errori e shutdown di ExecutorService in Android

Quando usi ExecutorService in un’app Android è fondamentale:

  • Gestire le eccezioni dei task in background.
  • Chiudere correttamente il pool quando non serve più (ad esempio in onDestroy()).

Gestione delle eccezioni

Se invii un Runnable con execute() e al suo interno lanci un’eccezione non catturata, questa non arriva al thread principale e diventa difficile da tracciare. È buona pratica avvolgere il codice in un blocco try/catch e propagare l’errore alla UI se necessario.

executorService.execute(new Runnable() {
    @Override
    public void run() {
        try {
            uploadFile(file);
        } catch (final IOException e) {
            e.printStackTrace();
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    showErrorMessage("Upload fallito: " + e.getMessage());
                }
            });
        }
    }
});

Shutdown e gestione del ciclo di vita

Un ExecutorService mantiene vivi i thread finché non lo chiudi. In Android, se colleghi un executor a un componente con ciclo di vita limitato (Activity, Fragment, ViewModel), è importante chiamare shutdown() o shutdownNow() nel momento giusto, per evitare memory leak e spreco di risorse.

@Override
protected void onDestroy() {
    super.onDestroy();
    executorService.shutdown();
}

shutdown() impedisce l’invio di nuovi task ma lascia terminare quelli già in esecuzione. shutdownNow() prova a interrompere immediatamente i task in corso, restituendo una lista di quelli non ancora eseguiti. In molte app Android, shutdown() è la scelta più sicura, mentre shutdownNow() va usato solo se sei davvero certo di poter interrompere le operazioni in corso.

ExecutorService vs AsyncTask vs Thread: tabella comparativa

Storicamente, in Android gli sviluppatori usavano spesso AsyncTask e thread manuali per gestire le operazioni in background. Tuttavia, Google ha deprecato AsyncTask e lo sconsiglia, mentre l’uso diretto di Thread è poco scalabile. La combinazione di ExecutorService (o soluzioni più moderne come WorkManager e coroutine in Kotlin) è oggi la scelta più robusta.

CaratteristicaExecutorServiceAsyncTaskThread
Gestione dei threadPool riutilizzabile, configurabileGestita internamente, poco flessibileOgni task crea un nuovo thread
Stato APISupportato e raccomandato (side Java)Deprecato nelle ultime APISupportato ma di basso livello
Ritorno di un risultatoSì, con Callable/FutureSì, con onPostExecute()No, serve implementazione custom
Composizione di più taskBuona (invokeAll, Future, ecc.)LimitataManuale e verbosa
Controllo sul parallelismoSì (single, fixed, cached…)Limitato, dipende dalla versioneNo, devi gestire tu il numero di thread
Integrazione con ciclo di vitaManuale, ma chiaraProblematico, facile creare leakManuale, facilmente soggetto a errori
Facilità d’usoMedia: leggermente più complesso ma potenteFacile, ma ormai obsoletoSemplice per casi banali, complesso per casi reali

Se stai iniziando un nuovo progetto Android in Java, usare ExecutorService per la logica in background è una scelta molto più futura-proof rispetto a AsyncTask e più ordinata rispetto a creare Thread a mano.

Best practice per usare ExecutorService in Android Java

  • Centralizza la creazione dell’ExecutorService: invece di creare un nuovo executor in ogni Activity, considera di gestirlo in un singleton, in un livello di data layer o in un ViewModel, così puoi riutilizzare lo stesso pool.
  • Scegli il tipo di executor giusto: usa newSingleThreadExecutor() per operazioni seriali sensibili all’ordine, newFixedThreadPool() per un numero limitato di task paralleli, newCachedThreadPool() solo se sei sicuro di non saturare il dispositivo.
  • Non bloccare il main thread: evita di chiamare future.get() sul main thread, per non rischiare ANR. Se devi attendere un risultato, fallo in background e poi passa alla UI con runOnUiThread() o Handler.
  • Rispetta il ciclo di vita: se l’executor è legato a una schermata, ricordati di chiamare shutdown() in onDestroy(). Se invece deve vivere quanto l’app, valuta di integrarlo a livello di Application.
  • Gestisci i timeout: quando usi Future, valuta di usare get(long timeout, TimeUnit unit) per evitare che un task bloccato rimanga per sempre in attesa.
  • Gestisci bene gli errori: cattura e registra le eccezioni, mostra messaggi chiari all’utente e, se necessario, permetti di riprovare l’operazione.
  • Considera le alternative moderne: in molti casi, specialmente per lavori pianificati o che devono sopravvivere alla chiusura dell’app, è più adatto usare WorkManager. Se usi Kotlin, coroutines e Dispatchers.IO offrono un modello ancora più espressivo che internamente può appoggiarsi a executor pool.

Conclusione

Usare ExecutorService in Android Java ti permette di gestire in modo ordinato ed efficiente tutte le operazioni in background della tua app, evitando i limiti e i problemi di strumenti più vecchi come AsyncTask e l’uso ingenuo di Thread singoli. Scegliendo il tipo di executor più adatto, gestendo correttamente Callable, Future e runOnUiThread, e chiudendo il pool al momento opportuno, puoi garantire un’esperienza utente fluida e un codice manutenibile.

Nei prossimi passi, puoi approfondire come funzionano i Thread in Java per capire meglio cosa c’è sotto il cofano di ExecutorService, e come collegare al meglio i risultati dei tuoi task in background a una RecyclerView performante e reattiva.

Commenti

2 risposte a “ExecutorService in Android Java: Guida Completa”

  1. […] modalità più comune per usare Callable in Android/Java è tramite un ExecutorService, ovvero un pool di thread gestito che esegue task asincroni per […]

  2. […] Se vuoi fare un passo in più oltre ai Thread classici, ti consiglio di approfondire ExecutorService, che rappresenta un’evoluzione moderna nella gestione dei thread in Java e Android. Grazie ai thread pool e a un modello di esecuzione più strutturato, ti permette di evitare la creazione manuale di troppi thread, migliorare le prestazioni e mantenere il codice più pulito. Puoi leggere la guida completa qui: ExecutorService in Android Java: Guida Completa. […]

Rispondi

Scopri di più da App Tutorial

Abbonati ora per continuare a leggere e avere accesso all'archivio completo.

Continua a leggere