Java 101: concurrencia de Java sin el dolor, parte 1

Con la creciente complejidad de las aplicaciones concurrentes, muchos desarrolladores encuentran que las capacidades de subprocesamiento de bajo nivel de Java son insuficientes para sus necesidades de programación. En ese caso, podría ser el momento de descubrir las utilidades de concurrencia de Java. Comience con java.util.concurrentla introducción detallada de Jeff Friesen al marco Executor, los tipos de sincronizadores y el paquete Java Concurrent Collections.

Java 101: la próxima generación

El primer artículo de esta nueva serie JavaWorld presenta la API de fecha y hora de Java .

La plataforma Java proporciona capacidades de subprocesos de bajo nivel que permiten a los desarrolladores escribir aplicaciones concurrentes donde diferentes subprocesos se ejecutan simultáneamente. Sin embargo, el subproceso estándar de Java tiene algunas desventajas:

  • Primitivas de bajo nivel de concurrencia de Java ( synchronized, volatile, wait(), notify(), y notifyAll()) no son fáciles de usar correctamente. Los peligros de subprocesos como el punto muerto, la falta de subprocesos y las condiciones de carrera, que resultan del uso incorrecto de primitivas, también son difíciles de detectar y depurar.
  • Confiar en synchronizedcoordinar el acceso entre subprocesos conduce a problemas de rendimiento que afectan la escalabilidad de la aplicación, un requisito para muchas aplicaciones modernas.
  • Las capacidades básicas de subprocesamiento de Java son de un nivel demasiado bajo. Los desarrolladores a menudo necesitan construcciones de alto nivel como semáforos y grupos de subprocesos, que las capacidades de subprocesamiento de bajo nivel de Java no ofrecen. Como resultado, los desarrolladores construirán sus propias construcciones, lo que requiere mucho tiempo y es propenso a errores.

El marco JSR 166: Concurrency Utilities se diseñó para satisfacer la necesidad de una función de subprocesamiento de alto nivel. Iniciado a principios de 2002, el marco se formalizó e implementó dos años más tarde en Java 5. Se realizaron mejoras en Java 6, Java 7 y el próximo Java 8.

Esta serie de Java 101: La próxima generación de dos partes presenta a los desarrolladores de software familiarizados con el subproceso básico de Java en los paquetes y el marco de Java Concurrency Utilities. En la Parte 1, presento una descripción general del marco de Java Concurrency Utilities y presento su marco Executor, las utilidades del sincronizador y el paquete Java Concurrent Collections.

Comprender los hilos de Java

Antes de sumergirse en esta serie, asegúrese de estar familiarizado con los conceptos básicos de enhebrado. Comience con la introducción de Java 101 a las capacidades de subprocesamiento de bajo nivel de Java:

  • Parte 1: Introducción a subprocesos y ejecutables
  • Parte 2: sincronización de subprocesos
  • Parte 3: programación de subprocesos, espera / notificación e interrupción de subprocesos
  • Parte 4: grupos de subprocesos, volatilidad, variables locales de subprocesos, temporizadores y muerte de subprocesos

Dentro de las utilidades de concurrencia de Java

El marco de Java Concurrency Utilities es una biblioteca de tipos que está diseñada para usarse como bloques de construcción para crear clases o aplicaciones concurrentes. Estos tipos son seguros para subprocesos, se han probado exhaustivamente y ofrecen un alto rendimiento.

Los tipos de las utilidades de concurrencia de Java están organizados en pequeños marcos; a saber, marco de ejecución, sincronizador, colecciones concurrentes, bloqueos, variables atómicas y Fork / Join. Además, están organizados en un paquete principal y un par de subpaquetes:

  • java.util.concurrent contiene tipos de utilidades de alto nivel que se utilizan comúnmente en la programación concurrente. Los ejemplos incluyen semáforos, barreras, grupos de subprocesos y hashmaps concurrentes.
    • El subpaquete java.util.concurrent.atomic contiene clases de utilidad de bajo nivel que admiten la programación segura para subprocesos sin bloqueo en variables individuales.
    • El subpaquete java.util.concurrent.locks contiene tipos de utilidad de bajo nivel para bloquear y esperar condiciones, que son diferentes del uso de los monitores y la sincronización de bajo nivel de Java.

El marco de Java Concurrency Utilities también expone la instrucción de hardware de comparación e intercambio (CAS) de bajo nivel , cuyas variantes son comúnmente compatibles con los procesadores modernos. CAS es mucho más ligero que el mecanismo de sincronización basado en monitores de Java y se utiliza para implementar algunas clases concurrentes altamente escalables. La java.util.concurrent.locks.ReentrantLockclase basada en CAS , por ejemplo, es más eficaz que la synchronizedprimitiva equivalente basada en monitor . ReentrantLockofrece más control sobre el bloqueo. (En la Parte 2 explicaré más sobre cómo funciona CAS java.util.concurrent).

System.nanoTime ()

El marco de Java Concurrency Utilities incluye long nanoTime(), que es un miembro de la java.lang.Systemclase. Este método permite el acceso a una fuente de tiempo de granularidad de nanosegundos para realizar mediciones de tiempo relativo.

En las siguientes secciones, presentaré tres características útiles de las utilidades de concurrencia de Java, primero explicando por qué son tan importantes para la concurrencia moderna y luego demostrando cómo funcionan para aumentar la velocidad, confiabilidad, eficiencia y escalabilidad de las aplicaciones concurrentes de Java.

El marco del Ejecutor

En el subproceso, una tarea es una unidad de trabajo. Un problema con los subprocesos de bajo nivel en Java es que el envío de tareas está estrechamente relacionado con una política de ejecución de tareas, como lo demuestra el Listado 1.

Listado 1. Server.java (Versión 1)

import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; class Server { public static void main(String[] args) throws IOException { ServerSocket socket = new ServerSocket(9000); while (true) { final Socket s = socket.accept(); Runnable r = new Runnable() { @Override public void run() { doWork(s); } }; new Thread(r).start(); } } static void doWork(Socket s) { } }

El código anterior describe una aplicación de servidor simple (que se doWork(Socket)deja en blanco para abreviar). El subproceso del servidor llama repetidamente socket.accept()para esperar una solicitud entrante y luego inicia un subproceso para atender esta solicitud cuando llega.

Debido a que esta aplicación crea un nuevo hilo para cada solicitud, no escala bien cuando se enfrenta a una gran cantidad de solicitudes. Por ejemplo, cada subproceso creado requiere memoria y demasiados subprocesos pueden agotar la memoria disponible, obligando a la aplicación a finalizar.

Puede resolver este problema cambiando la política de ejecución de tareas. En lugar de crear siempre un hilo nuevo, puede utilizar un grupo de hilos, en el que un número fijo de hilos atenderá las tareas entrantes. Sin embargo, tendría que volver a escribir la aplicación para realizar este cambio.

java.util.concurrentincluye el marco Executor, un pequeño marco de tipos que desacopla el envío de tareas de las políticas de ejecución de tareas. Utilizando el marco Executor, es posible ajustar fácilmente la política de ejecución de tareas de un programa sin tener que reescribir significativamente su código.

Dentro del marco del Ejecutor

El marco del Ejecutor se basa en la Executorinterfaz, que describe un ejecutor como cualquier objeto capaz de ejecutar java.lang.Runnabletareas. Esta interfaz declara el siguiente método solitario para ejecutar una Runnabletarea:

void execute(Runnable command)

Envías una Runnabletarea pasándola a execute(Runnable). Si el ejecutor no puede ejecutar la tarea por algún motivo (por ejemplo, si el ejecutor ha sido cerrado), este método arrojará un RejectedExecutionException.

El concepto clave es que el envío de tareas está desacoplado de la política de ejecución de tareas , que se describe mediante una Executorimplementación. Por tanto, la tarea ejecutable se puede ejecutar a través de un nuevo hilo, un hilo agrupado, el hilo de llamada, etc.

Tenga en cuenta que Executores muy limitado. Por ejemplo, no puede cerrar un ejecutor o determinar si una tarea asincrónica ha finalizado. Tampoco puede cancelar una tarea en ejecución. Por estas y otras razones, el marco Executor proporciona una interfaz ExecutorService, que se extiende Executor.

Cinco de ExecutorServicelos métodos son especialmente dignos de mención:

  • boolean awaitTermination (tiempo de espera prolongado, unidad TimeUnit) bloquea el subproceso de llamada hasta que todas las tareas hayan completado la ejecución después de una solicitud de apagado , se agote el tiempo de espera o se interrumpa el subproceso actual, lo que ocurra primero. El tiempo máximo de espera está especificado por timeout, y este valor se expresa en las unitunidades especificadas por la TimeUnitenumeración; por ejemplo TimeUnit.SECONDS,. Este método se lanza java.lang.InterruptedExceptioncuando se interrumpe el hilo actual. Devuelve verdadero cuando se termina el ejecutor y falso cuando transcurre el tiempo de espera antes de la terminación.
  • boolean isShutdown () devuelve verdadero cuando el ejecutor ha sido cerrado.
  • void shutdown () inicia un apagado ordenado en el que se ejecutan las tareas enviadas anteriormente, pero no se aceptan nuevas tareas.
  • El envío futuro (tarea invocable) envía una tarea de devolución de valor para su ejecución y devuelve un que Futurerepresenta los resultados pendientes de la tarea.
  • El envío futuro (tarea ejecutable) envía una Runnabletarea para su ejecución y devuelve una Futurerepresentación de esa tarea.

La Futureinterfaz representa el resultado de un cálculo asincrónico. El resultado se conoce como futuro porque normalmente no estará disponible hasta algún momento en el futuro. Puede invocar métodos para cancelar una tarea, devolver el resultado de una tarea (esperar indefinidamente o que transcurra un tiempo de espera cuando la tarea no ha finalizado) y determinar si una tarea se ha cancelado o ha finalizado.

La Callableinterfaz es similar a la Runnableinterfaz en que proporciona un método único que describe una tarea para ejecutar. A diferencia Runnabledel void run()método de Callable' , el método de ' V call() throws Exceptionpuede devolver un valor y lanzar una excepción.

Métodos de fábrica de ejecutores

At some point, you'll want to obtain an executor. The Executor framework supplies the Executors utility class for this purpose. Executors offers several factory methods for obtaining different kinds of executors that offer specific thread-execution policies. Here are three examples:

  • ExecutorService newCachedThreadPool() creates a thread pool that creates new threads as needed, but which reuses previously constructed threads when they're available. Threads that haven't been used for 60 seconds are terminated and removed from the cache. This thread pool typically improves the performance of programs that execute many short-lived asynchronous tasks.
  • ExecutorService newSingleThreadExecutor() creates an executor that uses a single worker thread operating off an unbounded queue -- tasks are added to the queue and execute sequentially (no more than one task is active at any one time). If this thread terminates through failure during execution before shutdown of the executor, a new thread will be created to take its place when subsequent tasks need to be executed.
  • ExecutorService newFixedThreadPool(int nThreads) creates a thread pool that re-uses a fixed number of threads operating off a shared unbounded queue. At most nThreads threads are actively processing tasks. If additional tasks are submitted when all threads are active, they wait in the queue until a thread is available. If any thread terminates through failure during execution before shutdown, a new thread will be created to take its place when subsequent tasks need to be executed. The pool's threads exist until the executor is shut down.

The Executor framework offers additional types (such as the ScheduledExecutorService interface), but the types you are likely to work with most often are ExecutorService, Future, Callable, and Executors.

See the java.util.concurrent Javadoc to explore additional types.

Trabajar con el marco del ejecutor

Descubrirá que es bastante fácil trabajar con el marco Executor. En el Listado 2, he usado Executory Executorspara reemplazar el ejemplo de servidor del Listado 1 con una alternativa basada en un grupo de subprocesos más escalable.

Listado 2. Server.java (Versión 2)

import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.Executor; import java.util.concurrent.Executors; class Server { static Executor pool = Executors.newFixedThreadPool(5); public static void main(String[] args) throws IOException { ServerSocket socket = new ServerSocket(9000); while (true) { final Socket s = socket.accept(); Runnable r = new Runnable() { @Override public void run() { doWork(s); } }; pool.execute(r); } } static void doWork(Socket s) { } }

El Listado 2 se utiliza newFixedThreadPool(int)para obtener un ejecutor basado en un grupo de subprocesos que reutiliza cinco subprocesos. También reemplaza new Thread(r).start();con pool.execute(r);para ejecutar tareas ejecutables a través de cualquiera de estos subprocesos.

El Listado 3 presenta otro ejemplo en el que una aplicación lee el contenido de una página web arbitraria. Emite las líneas resultantes o un mensaje de error si el contenido no está disponible en un máximo de cinco segundos.