Ir al contenido principal

Buscar en CooLWJ

选择搜索范围,输入关键词后按回车。

Idioma

Una visión global de la tecnología: historia de la evolución de los múltiples hilos en Java

Introducción

El 22 de 09 de 2022 se lanzó JDK19. Su mayor novedad fue la compatibilidad con hilos virtuales, un nuevo miembro destacado de la familia de los hilos ligeros. Los hilos virtuales liberan a la JVM de depender del sistema operativo para planificar los hilos y permiten que los planifique la propia JVM. De hecho, en sus primeros años, Sun ya había implementado la planificación de hilos por la JVM en su máquina virtual para Solaris, pero, por su complejidad y por motivos de mantenibilidad, terminó volviendo al modelo de planificación a cargo del sistema operativo.

“长安归来锦衣客,昨日城南起新宅。” («De Chang'an regresa un viajero vestido de brocado; ayer, al sur de la ciudad, se levantó una nueva morada»). Al recordar el camino recorrido, los conceptos de los hilos múltiples resultan deslumbrantes y abrumadores, y las explicaciones disponibles en internet son innumerables, pero siempre parece faltar una visión de conjunto. Por eso, he organizado sistemáticamente la historia de la evolución de los hilos múltiples en Java, con la esperanza de que te ayude a dominar estos conocimientos.

Qué no aborda este artículo:

1 No se explican en detalle los principios de implementación de determinados aspectos técnicos, no se analiza el código fuente ni se dibujan diagramas; si encuentras en este artículo conceptos o tecnologías que te interesen, puedes investigarlos por tu cuenta 2 No se abordan bibliotecas ni marcos que admiten concurrencia, como Quasar, Akka, Guava, etc.

De qué trata este artículo

1 Explicar la evolución histórica de los múltiples hilos en JDK 2 Explicar las funciones, los principios y el contexto de ciertos aspectos técnicos de esa evolución, así como los problemas que resolvieron 3 Exponer mi opinión sobre ciertos aspectos técnicos; quienes tengan opiniones diferentes pueden debatirlas en los comentarios

Hitos

Como de costumbre, empecemos con una tabla de síntesis. En ella se organizan los conceptos fundamentales relacionados con los hilos en las distintas generaciones del JDK. Aquí emplearé una comparación que quizá no sea del todo acertada: podemos equiparar la evolución de la programación multihilo con la del automóvil, pasando por la era del cambio manual (JDK1.4 y anteriores), la era del cambio automático(JDK5-JDK18)y la era de la conducción autónoma (JDK19 y posteriores). Esta comparación solo pretende mostrar al lector que, desde JDK5, manejar múltiples hilos resulta más cómodo; a partir de JDK19, el avance va más allá de la comodidad y supone un salto cualitativo en el rendimiento de los servicios con uso intensivo de E/S.

ÉpocaVersiónFecha de publicaciónConceptos fundamentales
Modo manualJDK1.01996-01-23Thread y Runnable
Modo manualJDK1.21998-12-04ThreadLocal、Collections
Modo automáticoJDK1.5/5.02004-09-30Definir claramente el modelo de memoria de Java e introducir el paquete de concurrencia
Modo automáticoJDK1.6/6.02006-12-11Optimización de synchronized
Modo automáticoJDK1.7/7.02011-07-28Framework Fork/Join
Modo automáticoJDK1.8/8.02014-03-18CompletableFuture、Stream
Modo automáticoJDK1.9/9.02014-09-08Mejorar el mecanismo de contención de bloqueos
Modo automáticoJDK102018-03-21Control local de hilos
Modo automáticoJDK152020-09-15Deshabilitar y declarar obsoletos los bloqueos sesgados
Conducción autónomaJDK192022-09-22Hilos virtuales

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

La era del cambio manual

Llamo a la época de JDK1.4 y anteriores la era de los hilos en «modo manual», también conocida como la era de los hilos nativos. Las operaciones con hilos seguían siendo relativamente primitivas y no había grupos de hilos disponibles. Los desarrolladores debían escribir sus propias herramientas para evitar el desperdicio de recursos causado por la creación frecuente de hilos y bloquear manualmente los recursos compartidos. Fue precisamente en esta época cuando surgieron muchos excelentes marcos de programación multihilo; el más famoso fue incorporado a JDK5.0.

JDK 1.0 Thread y Runnable

La versión JDK1.0, publicada en el mes 1 de 1996, estableció desde el principio el modelo de hilos más básico de Java. Este modelo no sufrió cambios sustanciales con las correcciones posteriores, por lo que puede considerarse un buen diseño que ha perdurado. La planificación expropiativa y la cooperativa son dos formas habituales de planificar procesos/hilos. La planificación expropiativa resulta muy adecuada para que un sistema operativo gestione sus procesos: asigna intervalos de tiempo a los distintos procesos y puede retirar los recursos a aquellos que llevan mucho tiempo sin responder, e incluso detenerlos por la fuerza. La planificación cooperativa requiere que los procesos liberen sus recursos voluntariamente y por iniciativa propia. Con este tipo de planificación, un hilo que se ejecute durante mucho tiempo puede provocar que todos los demás hilos que necesitan CPU sufran «inanición». La máquina virtual Java HotSpot utiliza planificación expropiativa, por lo que Java adoptó este tipo de planificación de hilos desde el principio. En JDK 1.0, los hilos se creaban principalmente heredando de la clase Thread o implementando la interfaz Runnable; se iniciaban mediante el método start de la instancia del objeto, y el código que debía procesarse en paralelo se colocaba en el método run. La comunicación y cooperación entre hilos utilizaba métodos tan rudimentarios como stop/resume/suspend.

Diagrama de los estados de los hilos de Java y sus transiciones

¿Cómo se explican los conceptos stop/resume/suspend? El hilo principal puede invocar directamente los métodos para detener, pausar y reanudar los hilos secundarios. Si de pequeño usaste un reproductor portátil de música, tenía tres botones: detener, pausar y reanudar. Imagina que estás escuchando 3 reproductores a la vez: los tres reproductores son tres hilos secundarios y tú eres el hilo principal; puedes controlar a tu antojo el inicio y la detención de estos tres dispositivos.

Este mecanismo tiene un problema fatal: puede provocar fácilmente un interbloqueo. La razón es que, cuando el hilo A bloquea un recurso y el hilo principal lo suspende antes de que lo libere (el método suspend no libera el bloqueo), si el hilo B quiere adquirir ese recurso, solo puede esperar a que el hilo A reanude su ejecución (resume) y lo libere; de lo contrario, nunca podrá obtenerlo y se producirá un interbloqueo.

JDK 1.2

En esta versión se prohibió el uso del mecanismo brusco stop/resume/suspend y se pasó a utilizar formas de coordinación entre varios hilos, como wait/notify/sleep. Cabe mencionar que, en esta versión, los objetos atómicos AtomicityXXX ya estaban diseñados, principalmente para resolver la falta de atomicidad de i++. La incorporación de ThreadLocal y Collections amplió las posibilidades de uso de múltiples hilos; por estas dos tecnologías, llamo a esta etapa la era del turbo de Java.

ThreadLocal

ThreadLocal es una solución que permite que varios hilos utilicen objetos que no son seguros para el acceso concurrente mediante un mecanismo sin bloqueos. No puede resolver problemas como los «incrementos y deducciones en cuentas bancarias o inventarios»; su punto fuerte consiste en ejecutar de forma segura los métodos de clases con carácter de «herramienta» mediante copias. Algunos ejemplos típicos son SimpleDateFormat y las conexiones a bases de datos. Cabe destacar lo ingenioso de su diseño. Imagina que tuvieras que diseñarlo: una idea sencilla habitual sería mantener en ThreadLocal un Map global seguro para el acceso concurrente, con el hilo como clave y el objeto compartido como valor. Este diseño tiene un inconveniente: las fugas de memoria, porque ese Map crecería sin límite conforme se incorporasen más hilos. Para resolverlas, habría que limpiar el Map al finalizar cada hilo, lo que exigiría ampliar las capacidades del GC; evidentemente, la relación entre esfuerzo y beneficio no sería adecuada. Por eso, ThreadLocal se diseñó de modo que el Map perteneciera al propio Thread, con la variable ThreadLocal como clave y el dato como valor. Cada Thread guarda en él los ThreadLocal que utiliza(por supuesto, este diseño también plantea otros problemas derivados que no abordaremos aquí; quienes tengan interés pueden investigarlos por su cuenta).

Collections

La clase de utilidades Collections se diseñó en esta versión e incluía envoltorios de colecciones seguras para múltiples hilos, como SynchronizedList. En una época en la que solo existían colecciones seguras para múltiples hilos como Hashtable, Vector y Stack, su aparición también marcó un hito. La idea básica de Collections es envolver las colecciones que no son seguras para múltiples hilos y convertirlas en colecciones que sí lo son. Así, no necesitas dedicar mucho tiempo a actualizar y adaptar tu código: basta con inicializar la colección mediante los métodos que proporciona al crearla. Se parece bastante a la tecnología de turbocompresión de los automóviles, que aumenta la potencia y el par motor sin modificar la cilindrada. Ha llegado la era del turbo de Java ^\_^

La era del cambio automático

JDK 5.0

Importar el paquete de concurrencia

Doug Lea, cuyo nombre en chino es 道格·利, es un profesor universitario estadounidense, una figura de talla legendaria y el creador de J.U.C. Antes de JDK1.5, solo podíamos usar synchronized para controlar el acceso concurrente del programa al código sincronizado. En aquella época, el rendimiento de synchronized aún no se había optimizado y era deficiente, y para controlar los hilos solo podíamos usar los métodos wait y notify de Object. Fue entonces cuando Doug Lea presentó al JCP la propuesta JSR-166. Antes de presentar JSR-166, Doug Lea ya llevaba más de tres años utilizando código con funciones similares a las del paquete J.U.C; ese código fue el prototipo de J.U.C.

J.U.C ofrece varias grandes categorías de herramientas, como objetos atómicos, bloqueos y utilidades, grupos de hilos y contenedores seguros para múltiples hilos. Los desarrolladores pueden combinar libremente estas capacidades para crear sus propios productos: desde construir frameworks de bajo nivel con ReentrantLock hasta escribir código de negocio utilizando directamente herramientas o contenedores ya disponibles. Desde una perspectiva histórica, en 2004 J.U.C podía calificarse sin discusión como un «producto de tecnología punta». Contribuyó enormemente a la difusión de Java. Había llegado la era del cambio automático para Java: al igual que un coche automático facilita la conducción, J.U.C facilitó enormemente a los programadores el uso de múltiples hilos. Fue un producto que inauguró una nueva era.

Por supuesto, J.U.C también presenta un defecto:

Alto consumo de CPU:si los intentos de CAS en espera activa no tienen éxito durante mucho tiempo, ocasionarán una sobrecarga muy elevada en la CPU.

Solución:en algunos puntos de JUC se limita el número de iteraciones de espera activa de CAS, por ejemplo en SynchronousQueue de BlockingQueue.

Problema ABA:Si un valor era originalmente A, cambia a B y luego vuelve a A, la comprobación CAS determinará que no ha cambiado, aunque en realidad sí lo ha hecho; esto es el problema ABA. En la mayoría de los casos, el problema ABA no afecta a la corrección de la concurrencia del programa.

Solución: añadir un número de versión a cada variable e incrementarlo en 1 con cada cambio; así, A —> B —> A pasa a ser 1A —> 2B —> 3A. Java proporciona AtomicStampedReference para resolverlo. AtomicStampedReference encapsula una tupla [E,Integer] para marcar el objeto con un sello de versión (stamp), evitando así el problema ABA.

Solo puede garantizar operaciones atómicas sobre una variable compartida:el mecanismo CAS solo garantiza la atomicidad de las operaciones sobre una variable, pero no la de todo el bloque de código.

Solución: por ejemplo, si es necesario garantizar que 3 variables se actualicen conjuntamente de forma atómica, habrá que utilizar Synchronized. También se puede considerar AtomicReference para encapsular varias variables y gestionar así el caso de múltiples variables compartidas.

Comprender con claridad el modelo de memoria de Java

Esta versión del JDK volvió a definir con claridad el modelo de memoria de Java. Antes de ella, entre los modelos de memoria habituales se encontraban el modelo de consistencia secuencial y el modelo de precedencia temporal (happens-before). En el modelo de consistencia secuencial, el orden de ejecución del programa coincide exactamente con el orden que aparece en el código. Esto resulta muy difícil de garantizar en las CPU modernas de varios núcleos que optimizan la ejecución de instrucciones. Además, garantizar la consistencia secuencial limita considerablemente las optimizaciones del código que la JVM puede realizar durante la ejecución.

Sin embargo, la relación de precedencia (Happens-before) definida por la especificación JSR 133 de esta versión permite flexibilizar el orden de ejecución de las instrucciones:

Dentro de un mismo hilo, según el orden de ejecución del código (es decir, el orden semántico del código), una operación ocurre antes que la siguiente La liberación del bloqueo de un objeto monitor ocurre antes que una adquisición posterior del bloqueo del mismo objeto monitor La escritura en un campo volatile ocurre antes que una lectura posterior de ese campo La operación start de un hilo (la llamada al método start() del objeto hilo) ocurre antes que cualquier otra operación de ese hilo Todas las operaciones de un hilo ocurren antes de que cualquier otro hilo retorne de una llamada al método join() de ese hilo Si la operación A ocurre antes que B y la operación B ocurre antes que C, entonces la operación A ocurre antes que C

En cuanto a la asignación de memoria, separar de la memoria principal la memoria de trabajo de cada hilo da a la JVM un amplio margen para optimizar la ejecución de las instrucciones dentro de cada hilo. Las variables de la memoria principal pueden copiarse a la memoria de trabajo del hilo para operar sobre ellas de forma independiente y, una vez terminada la ejecución, los resultados pueden volcarse a la memoria principal en algún momento: Pero, ¿cómo se garantiza la coherencia de los datos entre los distintos hilos? La solución que ofrece la JLS (Java Language Specification, especificación del lenguaje Java) es que, por defecto, no se garantiza la coherencia de los datos en todo momento, pero puede lograrse mediante el uso de las palabras clave synchronized, volatile y final, cuya semántica se ha reforzado.

Optimización de synchronized en JDK 6.0

La palabra clave synchronized, como «hijo primogénito de la República», vio cómo ReentrantLock le robaba protagonismo en la versión 5.0. En esta versión tenía que recuperar terreno, por lo que JDK 6.0 incorporó algunas optimizaciones de los bloqueos, como la espera activa, la eliminación y la fusión de bloqueos, los bloqueos ligeros y los bloqueos sesgados. Esta optimización ilustra la idea de una «gestión minuciosa». Antes de ella, los objetos bloqueados con synchronized solo tenían dos estados: sin bloqueo y con bloqueo (bloqueo pesado). Tras la optimización, existen 4 estados de bloqueo, ordenados de menor a mayor nivel: sin bloqueo, bloqueo sesgado, bloqueo ligero y bloqueo pesado. Estos estados ascienden gradualmente según la contención, pero no pueden descender; el objetivo es mejorar la eficiencia de la adquisición y liberación de bloqueos (en mi opinión, en realidad era demasiado complejo y los desarrolladores de la JVM se echaron atrás).

Esta optimización permitió a synchronized levantar la cabeza con orgullo; desde entonces, ya no admitía que nadie dijera que su rendimiento era inferior al de ReentrantLock. Pero lo mejor aún estaba por llegar: el bloqueo sesgado quedó obsoleto en JDK 15(─.─||). A mi juicio, synchronized sale perjudicado por ser simplemente una palabra clave: la JVM se encarga de sus operaciones subyacentes y tiene que «adivinar» qué forma de bloqueo resulta más adecuada para la aplicación. ReentrantLock es diferente: deja esa decisión directamente en manos del programador. Si quieres equidad, utilizas un bloqueo equitativo; si prefieres que no la haya, utilizas uno no equitativo. Desde el punto de vista del diseño, supone ahorrarse cierto trabajo, pero también aporta flexibilidad.

JDK 7.0 Marco Fork/Join

Fork/Join también surgió como una solución bastante avanzada. Su principal ventaja reside en que permite descomponer tareas de forma recursiva y combinar sus resultados. Sin embargo, es una tecnología que nos resulta a la vez familiar y desconocida: familiar porque se utiliza en muchos ámbitos, como CompletableFuture y Stream, que trataremos a continuación en JDK8.0; desconocida porque parece que rara vez la utilizamos al desarrollar aplicaciones de negocio.

Incluso hay quienes la consideran de poca utilidad. En mi opinión, si desarrollas funciones ligadas a requisitos de negocio, resulta poco útil porque apenas se usa: para grandes volúmenes de datos están las herramientas de los almacenes de datos y, en los demás escenarios, puede sustituirse por un grupo de hilos; si desarrollas frameworks de middleware, sí tiene utilidad y quizá llegues a necesitarla. En la internet en chino, pocas personas cuestionan esta tecnología, pero en otros países ya se está debatiendo. Si te interesa, puedes acceder directamente para consultarlo ¿Está averiado el framework Fork-Join de Java?

JDK 8.0

El lanzamiento de esta versión marcó un hito para Java, hasta el punto de que actualmente representa más de la mitad de los programas Java que se ejecutan en todo el mundo. Sin embargo, sus novedades relacionadas con los múltiples hilos no fueron tan revolucionarias como las de JDK5.0. Además de añadir algunos objetos atómicos, lo más destacado de esta versión fueron las dos actualizaciones siguientes.

CompletableFuture

En internet hay muchas introducciones a CompletableFuture; la mayoría explican sus principios y cómo utilizarlo. Sin embargo, siempre había una cuestión que no lograba comprender: si ya existen tantas herramientas de pools de hilos, ¿por qué apareció CompletableFuture y qué problema resuelve? ¿Cuál es exactamente su principal ventaja competitiva? Creo que, si te has detenido a reflexionar, también te habrás planteado esta pregunta. No te preocupes, ya he encontrado la respuesta por ti.

Conclusión: la principal ventaja competitiva de CompletableFuture es Orquestación de tareas。CompletableFuture hereda las características de la interfaz Future y permite ejecutar tareas de forma concurrente, entre otras capacidades, todas ellas sustituibles por otras alternativas. Sin embargo, su capacidad para orquestar tareas no tiene sustituto:sus API principales, entre ellas la construcción de cadenas de tareas y la combinación de resultados, están diseñadas para la orquestación de tareas. Por eso, la principal razón por la que JDK introdujo este marco en esta versión fue resolver la necesidad cada vez más acuciante de orquestar tareas en el desarrollo de aplicaciones de negocio.

Por último, añadiré que CompletableFuture utiliza internamente el marco Fork/Join para su implementación.

Flujo

En «Arquitectura limpia» se mencionan tres paradigmas de programación: programación estructurada (programación procedimental), programación orientada a objetos y programación funcional. Stream es una manifestación de la programación funcional en Java. En mi opinión, dominar Stream es un paso imprescindible para que un programador principiante avance al nivel intermedio. Al principio, sin duda cuesta acostumbrarse a Stream, pero, una vez que lo domines, descubrirás una nueva forma de abordar la programación. Como desarrolladores, a menudo confundimos tres conceptos: programación funcional, Stream y expresiones Lambda, creyendo que los tres se refieren a lo mismo. Esta es mi interpretación:

•La programación funcional es un paradigma de programación que se aplica en diversos lenguajes

•Stream es una nueva función de JDK8.0,también puede entenderse como un concepto nuevo cuyo propósito es adaptarse a la idea de la programación funcional; mediante Stream se puede aplicar la programación funcional a las clases de colecciones

•Una expresión lambda es una función anónima que permite expresar la programación funcional de forma más concisa y eficiente

Después de todo esto, ¿qué relación hay entre Stream y los múltiples hilos? Los métodos de paralelismo de Stream se implementan internamente mediante el marco Fork/Join. 《Effective Java》 incluye la recomendación relacionada “usar con prudencia el paralelismo de Stream”, porque todas las operaciones paralelas se ejecutan en un grupo común de Fork/Join, y el funcionamiento anómalo de una canalización puede perjudicar el rendimiento de otras partes no relacionadas.

JDK 9.0

Mejorar el mecanismo de contención de bloqueos

La contención de bloqueos limita el rendimiento de muchas aplicaciones Java con múltiples hilos. El nuevo mecanismo de contención de bloqueos mejora el rendimiento de los monitores de objetos de Java, como han confirmado diversas pruebas de rendimiento (por ejemplo, Volano), que permiten estimar el rendimiento máximo de la JVM. En la práctica, el nuevo mecanismo ha obtenido excelentes resultados en 22 pruebas de rendimiento distintas. Si este mecanismo pudiera incorporarse a Java 9, el rendimiento de las aplicaciones mejoraría considerablemente. Una explicación sencilla es que, cuando varios hilos compiten por un bloqueo, antes de la optimización: todos los hilos que llegan más tarde siguen el mismo procedimiento estándar para esperar el bloqueo. Después de la optimización: cuando la JVM identifica determinadas situaciones optimizables, permite directamente que los hilos que llegan más tarde compitan por el bloqueo mediante una especie de «vía VIP».

Para una explicación detallada, consulta: Los bloqueos con contención, explicados desde la perspectiva del rendimiento

Flujos reactivos

Los flujos reactivos (Reactive Streams) son un estándar para procesar flujos de datos asíncronos con contrapresión no bloqueante, que proporciona un conjunto mínimo de interfaces, métodos y protocolos para describir las operaciones y entidades necesarias.

¿Qué es la contrapresión no bloqueante? Contrapresión es la forma abreviada de back pressure; explicado de manera sencilla, el productor envía datos al consumidor y, cuando este ya no puede procesarlos, avisa al productor, que reduce entonces su ritmo de producción. La forma más sencilla de implementar este mecanismo es mediante bloqueo, devolviendo directamente los datos de presión al enviar. Una implementación no bloqueante aumenta la complejidad del diseño, pero también mejora el rendimiento. PD: Creo que la traducción “背压” no es muy acertada; no se puede deducir su significado de los caracteres. ¿No sería mejor “反压”, presión inversa? ^\_^

Para resolver el problema de que los consumidores puedan colapsar por una enorme presión sobre sus recursos, es necesario controlar la velocidad del flujo de datos, es decir, aplicar un control de flujo, para impedir que un flujo rápido sature el destino. Por ello se necesita contrapresión, y los productores y consumidores deben interoperar mediante un mecanismo que la implemente. Este mecanismo de contrapresión debe ser asíncrono y no bloqueante; si fuera síncrono y bloqueante, el productor tendría que esperar mientras el consumidor procesa los datos, lo que ocasionaría problemas de rendimiento。

Reactive Streams establece un estándar para implementar la contrapresión no bloqueante mediante la definición de un conjunto de entidades, interfaces y métodos de interoperabilidad. Los terceros siguen este estándar para desarrollar soluciones concretas; entre las más habituales están Reactor, RxJava, Akka Streams y Ratpack.

JDK 10 Control local de hilos

Safepoint y sus limitaciones:

Safepoint es un mecanismo de HotSpot JVM que permite detener todas las aplicaciones. Para realizar ciertos trabajos internos, la JVM debe ejecutar un Stop The World y detener todos los hilos de la aplicación. Sin embargo, no puede detenerlos directamente de forma brusca, sino que les envía una señal con una instrucción que les indica que deben detenerse. Cuando un hilo de la aplicación llega a un Safepoint, obedece la instrucción y responde. De ahí el nombre Safepoint. La palabra safe subraya que la JVM va a realizar ciertas operaciones globales de forma segura, por eso se añade safe al nombre de este punto.

Las operaciones que requieren un punto seguro global incluyen las siguientes: 1、Pausas para la recolección de basura 2、Desoptimización del código. 3、Vaciado de la caché de código. 4、Redefinición de archivos de clases (por ejemplo, actualización en caliente o instrumentación). 5、Revocación de bloqueos sesgados. 6、Diversas operaciones de depuración (por ejemplo: detección de interbloqueos o volcado de trazas de pila).

Sin embargo, hacer que todos los hilos se detengan en el punto seguro más cercano ya requiere bastante tiempo. Además, ¿no resulta demasiado brusco y arbitrario detenerlos a todos? Por ello, Java10 introdujo un mecanismo que permite evitar detener todos los hilos: el protocolo de coordinación local de hilos (Thread Local Handshake).

Por ejemplo, estos son algunos escenarios que se pueden resolver sin detener todos los hilos: 1、Revocación de bloqueos sesgados. Para revocar un bloqueo sesgado solo es necesario detener un único hilo, sin detener todos los demás. 2、Reducir el impacto general sobre la latencia de la VM de distintos tipos de consultas de diagnóstico; por ejemplo, obtener las trazas de pila de todos los hilos de una VM con muchos hilos Java puede ser una operación lenta. 3、Realizar un muestreo más seguro de las trazas de pila reduciendo la dependencia de las señales (signals). 4、Utilizar la denominada técnica de sincronización asimétrica de Dekker para eliminar algunas barreras de memoria mediante la coordinación con los hilos Java. Por ejemplo, el «código de marcado condicional de tarjetas» (conditional card mark code) utilizado en G1 y CMS dejará de necesitar esas «barreras de memoria». Así, se podrá optimizar la «barrera de escritura (write barrier)» que emite G1, y también se podrán eliminar las ramas que intentan evitar las «barreras de memoria».

JDK 15 desactiva y declara obsoletos los bloqueos sesgados

¿Por qué retirar los bloqueos sesgados? La mejora de rendimiento que proporcionaban en el pasado ya no resulta tan evidente hoy. Las aplicaciones que se benefician de ellos suelen utilizar las primeras API de colecciones de Java(JDK 1.1), cuyas clases(Hashtable y Vector)sincronizan cada acceso. JDK 1.2 introdujo colecciones sin sincronización para escenarios de un solo hilo(HashMap y ArrayList), y JDK 1.5 incorporó estructuras de datos concurrentes de mayor rendimiento para escenarios con múltiples hilos. Esto significa que las aplicaciones que se beneficiaban de los bloqueos sesgados debido a una sincronización innecesaria podrían experimentar una gran mejora de rendimiento si su código se actualiza para utilizar clases más recientes. Además, las aplicaciones construidas en torno a colas de grupos de hilos e hilos de trabajo suelen rendir mejor con los bloqueos sesgados desactivados.

A continuación se muestra una implementación con las API de Hashtable y Vector: java.lang.Classloader usa Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL utiliza Hashtable java.net.URConnection utiliza Hashtable java.util.ZipOutputStream usa Vector javax.management.timer.TimerMBean tiene Vector en la interfaz

La era de la conducción autónoma

Los hilos virtuales han llevado a Java a la era de la conducción autónoma. Muchos lenguajes, como Go, C#, Erlang y Lua, cuentan con tecnologías similares a los «hilos virtuales», a las que llaman «corrutinas». Esta vez Java no ha añadido ninguna palabra clave, ni siquiera un concepto nuevo. Los hilos virtuales son mucho más fáciles de comprender que las goroutines y las corrutinas:con solo ver el nombre ya te haces una idea de lo que hacen.

JDK 19 Hilos virtuales

En Java tradicional, los hilos se corresponden con los del sistema operativo en una relación 1:1; crearlos y cambiar entre ellos resulta costoso y, por las limitaciones del sistema operativo, solo se puede crear una cantidad limitada. Cuando la concurrencia es muy alta, no es posible crear un hilo para cada solicitud. Utilizar un grupo de hilos puede aliviar el problema: reduce el coste de crear hilos, pero tampoco permite aumentar su número. Si la concurrencia es de 2000 y el grupo solo tiene 1000 hilos, únicamente se pueden procesar 1000 solicitudes a la vez; quedan otras 1000 que no se pueden atender y que pueden rechazarse o dejarse en espera hasta que se libere un hilo.

Antes de los hilos virtuales, la solución consistía en adoptar un estilo asíncrono. Muchos frameworks ya implementaban la programación concurrente asíncrona, como Reactor de Spring5, y lograban una mayor disponibilidad compartiendo los hilos. El principio consiste en reducir los cambios de hilo al compartirlos, disminuyendo el consumo de recursos y evitando también los bloqueos: solo se utilizan hilos cuando el programa se ejecuta y no se ocupa ninguno cuando debe esperar. El estilo asíncrono aporta bastantes mejoras, pero también tiene inconvenientes. La mayoría de los frameworks asíncronos utilizan una sintaxis encadenada y dividen el programa en numerosos pasos, cada uno de los cuales puede ejecutarse en un hilo diferente. Ya no puedes utilizar las API de programación concurrente que conoces, como ThreadLocal, porque podrían producirse errores. El estilo de programación también cambia considerablemente: es mucho más complejo que el tradicional, requiere un gran esfuerzo de aprendizaje y puede que además tengas que modificar muchos módulos existentes del proyecto para adaptarlos al modelo asíncrono.

El principio de implementación de los hilos virtuales se parece al de algunos marcos asíncronos:también se basa en compartir hilos y, por tanto, no requiere agruparlos en un pool. Al utilizarlos, puedes considerar que los hilos virtuales son ilimitadamente abundantes y crear tantos como quieras sin preocuparte por posibles problemas. Además, los hilos virtuales admiten depuración y son compatibles con las herramientas de monitorización de Java, algo muy importante. Los hilos virtuales reducen considerablemente el consumo de memoria de tu programa y permiten que todas las aplicaciones con uso intensivo de E/S, como los servidores web, aumenten mucho su rendimiento de E/S con el mismo hardware. Antes, con 1 GB de memoria se podían atender 1000 accesos simultáneos. Según las afirmaciones oficiales, con hilos virtuales se pueden gestionar fácilmente 100 decenas de miles de accesos concurrentes. Que esto se sostenga en un escenario de negocio concreto dependerá de las pruebas de carga, pero, rebajando la estimación, 10 decenas de miles deberían ser fáciles de alcanzar sin que tengas que pagar ningún precio por ello; quizá ni siquiera tengas que cambiar el código. Los hilos virtuales permiten mantener el estilo de programación tradicional, es decir, un hilo por solicitud, y usarlos igual que los hilos habituales, por lo que el programa solo necesita unos pocos cambios. Tampoco introducen sintaxis nueva, así que el coste de aprendizaje y migración es muy bajo.

Cabe señalar que los hilos virtuales siguen utilizando el framework Fork/Join como base.


Comentarios

Enviando

Elige tu tema

El tema se aplica a todo el sitio.