Suggerimento Java: quando utilizzare ForkJoinPool rispetto a ExecutorService

La libreria Fork / Join introdotta in Java 7 estende il pacchetto di concorrenza Java esistente con il supporto per il parallelismo hardware, una caratteristica chiave dei sistemi multicore. In questo suggerimento su Java, Madalin Ilie dimostra l'impatto sulle prestazioni della sostituzione della ExecutorServiceclasse Java 6 con Java 7 ForkJoinPoolin un'applicazione web crawler.

I web crawler, noti anche come web spider, sono fondamentali per il successo dei motori di ricerca. Questi programmi scansionano continuamente il Web, raccogliendo milioni di pagine di dati e rinviandoli ai database dei motori di ricerca. I dati vengono quindi indicizzati ed elaborati in modo algoritmico, ottenendo risultati di ricerca più rapidi e accurati. Sebbene siano più famosi per l'ottimizzazione della ricerca, i web crawler possono essere utilizzati anche per attività automatizzate come la convalida dei collegamenti o la ricerca e la restituzione di dati specifici (come gli indirizzi e-mail) in una raccolta di pagine web.

Dal punto di vista architettonico, la maggior parte dei web crawler sono programmi multithread ad alte prestazioni, sebbene con funzionalità e requisiti relativamente semplici. La creazione di un web crawler è quindi un modo interessante per esercitarsi e confrontare tecniche di programmazione multithread o simultanee.

Il ritorno di Java Tips!

I Java Tips sono brevi articoli guidati dal codice che invitano i lettori di JavaWorld a condividere le loro capacità di programmazione e scoperte. Facci sapere se hai un suggerimento da condividere con la comunità JavaWorld. Controlla anche l'archivio dei suggerimenti Java per ulteriori suggerimenti di programmazione dai tuoi colleghi.

In questo articolo illustrerò due approcci alla scrittura di un web crawler: uno utilizzando Java 6 ExecutorService e l'altro ForkJoinPool di Java 7. Per seguire gli esempi, dovrai avere (al momento della stesura di questo documento) Java 7 update 2 installato nel tuo ambiente di sviluppo, oltre alla libreria di terze parti HtmlParser.

Due approcci alla concorrenza Java

La ExecutorServiceclasse fa parte della java.util.concurrentrivoluzione introdotta in Java 5 (e parte di Java 6, ovviamente), che ha semplificato la gestione dei thread sulla piattaforma Java. ExecutorServiceè un Executor che fornisce metodi per gestire il monitoraggio dello stato di avanzamento e la terminazione di attività asincrone. Prima dell'introduzione di java.util.concurrent, gli sviluppatori Java si affidavano a librerie di terze parti o scrivevano le proprie classi per gestire la concorrenza nei loro programmi.

Fork / Join, introdotto in Java 7, non è destinato a sostituire o competere con le classi di utilità di concorrenza esistenti; invece li aggiorna e li completa. Fork / Join risponde alla necessità di divide et impera o elaborazione di attività ricorsiva nei programmi Java (vedi Risorse).

La logica di Fork / Join è molto semplice: (1) separa (fork) ogni attività di grandi dimensioni in attività più piccole; (2) elaborare ogni attività in un thread separato (separandole in attività ancora più piccole, se necessario); (3) unisciti ai risultati.

Le due implementazioni del web crawler che seguono sono semplici programmi che dimostrano le caratteristiche e le funzionalità di Java 6 ExecutorServicee Java 7 ForkJoinPool.

Creazione e benchmarking del web crawler

Il compito del nostro web crawler sarà quello di trovare e seguire i link. Il suo scopo potrebbe essere la convalida del collegamento o la raccolta di dati. (Potresti, ad esempio, istruire il programma a cercare sul web le immagini di Angelina Jolie o Brad Pitt.)

L'architettura dell'applicazione è composta da quanto segue:

  1. Un'interfaccia che espone le operazioni di base per interagire con i collegamenti; cioè, ottenere il numero di collegamenti visitati, aggiungere nuovi collegamenti da visitare in coda, contrassegnare un collegamento come visitato
  2. Un'implementazione per questa interfaccia che sarà anche il punto di partenza dell'applicazione
  3. Un thread / azione ricorsiva che manterrà la logica aziendale per verificare se un collegamento è già stato visitato. In caso contrario, raccoglierà tutti i collegamenti nella pagina corrispondente, creerà un nuovo thread / attività ricorsiva e lo invierà a ExecutorServiceoForkJoinPool
  4. Un ExecutorServiceo ForkJoinPoolper gestire le attività in attesa

Notare che un collegamento è considerato "visitato" dopo che tutti i collegamenti nella pagina corrispondente sono stati restituiti.

Oltre a confrontare la facilità di sviluppo utilizzando gli strumenti di concorrenza disponibili in Java 6 e Java 7, confronteremo le prestazioni delle applicazioni in base a due benchmark:

  • Copertura della ricerca : misura il tempo necessario per visitare 1.500 link distinti
  • Potenza di elaborazione : misura il tempo in secondi necessario per visitare 3.000 collegamenti non distinti ; è come misurare quanti kilobit al secondo elabora la tua connessione Internet.

Sebbene relativamente semplici, questi benchmark forniranno almeno una piccola finestra sulle prestazioni della concorrenza Java in Java 6 rispetto a Java 7 per determinati requisiti dell'applicazione.

Un web crawler Java 6 costruito con ExecutorService

Per l'implementazione del web crawler Java 6 utilizzeremo un pool di thread fisso di 64 thread, che creiamo chiamando il Executors.newFixedThreadPool(int)metodo factory. Il listato 1 mostra l'implementazione della classe principale.

Listato 1. Costruire un WebCrawler

package insidecoding.webcrawler; import java.util.Collection; import java.util.Collections; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import insidecoding.webcrawler.net.LinkFinder; import java.util.HashSet; /** * * @author Madalin Ilie */ public class WebCrawler6 implements LinkHandler { private final Collection visitedLinks = Collections.synchronizedSet(new HashSet()); // private final Collection visitedLinks = Collections.synchronizedList(new ArrayList()); private String url; private ExecutorService execService; public WebCrawler6(String startingURL, int maxThreads) { this.url = startingURL; execService = Executors.newFixedThreadPool(maxThreads); } @Override public void queueLink(String link) throws Exception { startNewThread(link); } @Override public int size() { return visitedLinks.size(); } @Override public void addVisited(String s) { visitedLinks.add(s); } @Override public boolean visited(String s) { return visitedLinks.contains(s); } private void startNewThread(String link) throws Exception { execService.execute(new LinkFinder(link, this)); } private void startCrawling() throws Exception { startNewThread(this.url); } /** * @param args the command line arguments */ public static void main(String[] args) throws Exception { new WebCrawler("//www.javaworld.com", 64).startCrawling(); } }

Nel WebCrawler6costruttore sopra , creiamo un pool di thread a dimensione fissa di 64 thread. Avviamo quindi il programma chiamando il startCrawlingmetodo, che crea il primo thread e lo invia a ExecutorService.

Successivamente, creiamo LinkHandlerun'interfaccia che espone metodi di supporto per interagire con gli URL. I requisiti sono i seguenti: (1) contrassegnare un URL come visitato utilizzando il addVisited()metodo; (2) ottenere il numero degli URL visitati tramite il size()metodo; (3) determinare se un URL è già stato visitato utilizzando il visited()metodo; e (4) aggiungere un nuovo URL nella coda tramite il queueLink()metodo.

Listato 2. L'interfaccia LinkHandler

package insidecoding.webcrawler; /** * * @author Madalin Ilie */ public interface LinkHandler { /** * Places the link in the queue * @param link * @throws Exception */ void queueLink(String link) throws Exception; /** * Returns the number of visited links * @return */ int size(); /** * Checks if the link was already visited * @param link * @return */ boolean visited(String link); /** * Marks this link as visited * @param link */ void addVisited(String link); }

Ora, mentre scansioniamo le pagine, dobbiamo avviare il resto dei thread, cosa che facciamo tramite l' LinkFinderinterfaccia, come mostrato nel Listato 3. Notate la linkHandler.queueLink(l)riga.

Listato 3. LinkFinder

package insidecoding.webcrawler.net; import java.net.URL; import org.htmlparser.Parser; import org.htmlparser.filters.NodeClassFilter; import org.htmlparser.tags.LinkTag; import org.htmlparser.util.NodeList; import insidecoding.webcrawler.LinkHandler; /** * * @author Madalin Ilie */ public class LinkFinder implements Runnable { private String url; private LinkHandler linkHandler; /** * Used fot statistics */ private static final long t0 = System.nanoTime(); public LinkFinder(String url, LinkHandler handler) { this.url = url; this.linkHandler = handler; } @Override public void run() { getSimpleLinks(url); } private void getSimpleLinks(String url) { //if not already visited if (!linkHandler.visited(url)) { try { URL uriLink = new URL(url); Parser parser = new Parser(uriLink.openConnection()); NodeList list = parser.extractAllNodesThatMatch(new NodeClassFilter(LinkTag.class)); List urls = new ArrayList(); for (int i = 0; i < list.size(); i++) { LinkTag extracted = (LinkTag) list.elementAt(i); if (!extracted.getLink().isEmpty() && !linkHandler.visited(extracted.getLink())) { urls.add(extracted.getLink()); } } //we visited this url linkHandler.addVisited(url); if (linkHandler.size() == 1500) { System.out.println("Time to visit 1500 distinct links = " + (System.nanoTime() - t0)); } for (String l : urls) { linkHandler.queueLink(l); } } catch (Exception e) { //ignore all errors for now } } } }

La logica di LinkFinderè semplice: (1) iniziamo ad analizzare un URL; (2) dopo aver raccolto tutti i collegamenti all'interno della pagina corrispondente, contrassegniamo la pagina come visitata; e (3) inviamo ogni collegamento trovato a una coda chiamando il queueLink()metodo. Questo metodo creerà effettivamente un nuovo thread e lo invierà a ExecutorService. Se nel pool sono disponibili thread "liberi", il thread verrà eseguito; in caso contrario verrà messo in coda di attesa. Dopo aver raggiunto 1.500 collegamenti distinti visitati, stampiamo le statistiche e il programma continua a funzionare.

Un web crawler Java 7 con ForkJoinPool

Il framework Fork / Join introdotto in Java 7 è in realtà un'implementazione dell'algoritmo Divide and Conquer (vedi Risorse), in cui una centrale ForkJoinPoolesegue le ramificazioni ForkJoinTask. Per questo esempio useremo un ForkJoinPool"backed" da 64 thread. Dico sostenute perché le ForkJoinTasks sono più leggere dei thread. In Fork / Join, un numero elevato di attività può essere ospitato da un numero inferiore di thread.

Analogamente all'implementazione di Java 6, iniziamo creando un'istanza nel WebCrawler7costruttore di un ForkJoinPoologgetto supportato da 64 thread.

Listato 4. Implementazione di Java 7 LinkHandler

package insidecoding.webcrawler7; import java.util.Collection; import java.util.Collections; import java.util.concurrent.ForkJoinPool; import insidecoding.webcrawler7.net.LinkFinderAction; import java.util.HashSet; /** * * @author Madalin Ilie */ public class WebCrawler7 implements LinkHandler { private final Collection visitedLinks = Collections.synchronizedSet(new HashSet()); // private final Collection visitedLinks = Collections.synchronizedList(new ArrayList()); private String url; private ForkJoinPool mainPool; public WebCrawler7(String startingURL, int maxThreads) { this.url = startingURL; mainPool = new ForkJoinPool(maxThreads); } private void startCrawling() { mainPool.invoke(new LinkFinderAction(this.url, this)); } @Override public int size() { return visitedLinks.size(); } @Override public void addVisited(String s) { visitedLinks.add(s); } @Override public boolean visited(String s) { return visitedLinks.contains(s); } /** * @param args the command line arguments */ public static void main(String[] args) throws Exception { new WebCrawler7("//www.javaworld.com", 64).startCrawling(); } }

Nota che l' LinkHandlerinterfaccia nel Listato 4 è quasi la stessa dell'implementazione Java 6 del Listato 2. Manca solo il queueLink()metodo. I metodi più importanti da osservare sono il costruttore e il startCrawling()metodo. Nel costruttore, creiamo un nuovo ForkJoinPoolsupportato da 64 thread. (Ho scelto 64 thread invece di 50 o qualche altro numero tondo perché in ForkJoinPoolJavadoc si afferma che il numero di thread deve essere una potenza di due.) Il pool invoca un nuovo LinkFinderAction, che richiamerà ricorsivamente ulteriormente ForkJoinTasks. Il listato 5 mostra la LinkFinderActionclasse: