Nuevas funciones de JDK21 para hilos virtuales

1. Resumen

Los subprocesos virtuales son subprocesos livianos que reducen en gran medida el esfuerzo de escribir, mantener y observar aplicaciones concurrentes de alto rendimiento. Y el programa en el hilo virtual abandonará el hilo de la plataforma mientras espera IO, lo que mejorará exponencialmente el rendimiento de los programas multiproceso sobrecargados sin CPU. Esta es una característica realmente interesante.

2. Historia

Los hilos virtuales fueron propuestos como una característica de vista previa por JEP 425 y lanzados en JDK 19. Para dar tiempo para recibir comentarios y acumular más experiencia, JEP 436 propone una vez más hilos virtuales como una función de vista previa y lo lanza en JDK 20. Este JEP recomienda finalizar los subprocesos virtuales en JDK 21 y realizar los siguientes cambios en JDK 20 según los comentarios de los desarrolladores:

  • Los subprocesos virtuales ahora siempre admiten variables locales de subprocesos. Ya no es posible crear subprocesos virtuales con variables locales de subprocesos, como era el caso en la versión preliminar. El soporte garantizado para variables locales de subprocesos garantiza que más bibliotecas existentes permanezcan sin cambios cuando se utilizan subprocesos virtuales y ayuda a trasladar el código orientado a tareas a subprocesos virtuales.
  • Los subprocesos virtuales creados directamente utilizando la API Thread.Builder (en lugar de crearse a través de Executors.newVirtualThreadPerTaskExecutor()) ahora también se monitorean de forma predeterminada durante todo su ciclo de vida y se puede acceder a ellos a través de la sección "Observar subprocesos virtuales)" para observar el nuevo volcado de subprocesos.

3. Objetivo

  • Permite que las aplicaciones de servidor escritas en un formato simple de subproceso por solicitud escale con una utilización de hardware casi óptima.
  • Permite que el código existente utilizando la API java.lang.Thread adopte subprocesos virtuales con cambios mínimos.
  • Solucione problemas, depure y perfile subprocesos virtuales fácilmente utilizando las herramientas JDK existentes.

4. Qué no son los hilos virtuales

  • En lugar de eliminar las implementaciones de subprocesos tradicionales, las aplicaciones existentes no se migrarán a subprocesos virtuales de forma predeterminada.
  • No cambia el modelo de concurrencia básico de Java.
  • No proporciona nuevas construcciones de datos paralelos en el lenguaje Java ni en las bibliotecas de Java. Las API de streaming siguen siendo la forma preferida de procesar grandes conjuntos de datos en paralelo.
    Nota: El paralelismo utiliza la arquitectura multinúcleo y multiproceso de la CPU, y los programas se ejecutan al mismo tiempo al mismo tiempo, y la concurrencia se ejecuta al mismo tiempo en una perspectiva macro, que puede ser una ejecución paralela o alternativa. El objetivo de la concurrencia es utilizar los recursos de cada núcleo de la CPU. Cuando los programas se ejecutan alternativamente cuando se produce la espera de IO.

5. Motivo

Durante casi tres décadas, los desarrolladores de Java han confiado en subprocesos para crear aplicaciones de servidor concurrentes. Cada declaración en cada método se ejecuta en un subproceso y, dado que Java es multiproceso, se pueden ejecutar varios subprocesos simultáneamente. Los subprocesos son la unidad de concurrencia de Java: un fragmento de código ejecutado secuencialmente se ejecuta en un solo subproceso, se ejecuta simultáneamente con otros subprocesos de la misma estructura y también es en gran medida independiente de otras unidades. Cada subproceso proporciona una pila que almacena variables locales y coordina llamadas a métodos, y proporciona contexto en caso de errores: los métodos en el mismo subproceso generan y detectan excepciones, por lo que los desarrolladores pueden usar el seguimiento de la pila del subproceso para descubrir qué sucedió. Los subprocesos también son un concepto central de la herramienta: los depuradores recorren las declaraciones en los métodos de subprocesos y los perfiladores visualizan el comportamiento de múltiples subprocesos para ayudar a comprender su rendimiento.

6. Método de subproceso por solicitud

Las aplicaciones de servidor normalmente manejan solicitudes de usuarios simultáneas de forma independiente entre sí, por lo que cuando una aplicación procesa una solicitud, puede dedicar un subproceso a esa solicitud durante toda la duración de la solicitud. Esta asignación de subprocesos por solicitud es fácil de entender, fácil de programar, fácil de depurar y fácil de configurar porque utiliza la unidad de concurrencia del sistema operativo para representar la unidad de concurrencia de la aplicación.

La escalabilidad de las aplicaciones de servidor se rige por la Ley de Little, que vincula la latencia, la simultaneidad y el rendimiento: Para una duración determinada del procesamiento de solicitudes (es decir, latencia), la cantidad de solicitudes que una aplicación puede procesar simultáneamente La cantidad de solicitudes (es decir, simultaneidad) debe crecer proporcionalmente a la tasa de llegada (es decir, el rendimiento). Por ejemplo, supongamos que una aplicación con una latencia promedio de 50 milisegundos logra un rendimiento de 200 solicitudes por segundo procesando 10 solicitudes simultáneamente. Para escalar el rendimiento de la aplicación a 2000 solicitudes por segundo, sería necesario procesar 100 solicitudes simultáneamente. Si cada solicitud es manejada por un subproceso durante la duración de la solicitud, la cantidad de subprocesos debe aumentar a medida que aumenta el rendimiento para que la aplicación se mantenga al día.

Desafortunadamente, la cantidad de subprocesos disponibles es limitada porque el JDK implementa subprocesos como envoltorios alrededor de los subprocesos del sistema operativo (SO). El costo de los subprocesos del sistema operativo es muy alto, por lo que no podemos tener demasiados subprocesos, lo que hace que la implementación de subprocesos no sea adecuada para el estilo de subprocesos por solicitud. Si cada solicitud consume un subproceso durante su duración, es decir, un subproceso del sistema operativo, la cantidad de subprocesos a menudo se convertirá en el factor limitante antes de que se agoten otros recursos, como la CPU o las conexiones de red. La implementación actual de subprocesos del JDK limita el rendimiento de la aplicación a niveles muy por debajo de lo que admite el hardware. Esto ocurre incluso si se agrupan subprocesos, porque la agrupación ayuda a evitar el alto costo de iniciar nuevos subprocesos pero no aumenta el número total de subprocesos.

7. Utilice el modo asincrónico para mejorar la escalabilidad.

Algunos desarrolladores que quieren aprovechar al máximo su hardware abandonan el enfoque de subproceso por solicitud en favor del intercambio de subprocesos. En lugar de procesar una solicitud en un subproceso por completo, el código de manejo de solicitudes devuelve su subproceso a un grupo de subprocesos mientras espera que se complete otra operación de E/S para que el subproceso pueda manejar otras solicitudes. Este intercambio de subprocesos detallado (el código solo reserva subprocesos mientras se realizan cálculos, no mientras se espera E/S) permite una gran cantidad de operaciones simultáneas sin consumir una gran cantidad de subprocesos. Si bien elimina el límite de rendimiento impuesto por la escasez de subprocesos del sistema operativo, tiene un alto costo: requiere el llamado estilo de programación asíncrono, que utiliza un conjunto de métodos de E/S independientes que no esperan a que finalice la operación de E/S. completo. En su lugar, se envía una señal de finalización a la devolución de llamada más tarde. En ausencia de subprocesos dedicados, los desarrolladores deben dividir la lógica de procesamiento de solicitudes en pequeñas etapas (a menudo escritas como expresiones lambda) y luego pasarlas a través de una API (consulte, por ejemplo, CompletableFuture o los llamados marcos "reactivos" ) . una canalización secuencial. Por lo tanto, abandonan los operadores de composición secuencial básicos del lenguaje, como bucles y bloques try/catch.

En un estilo asincrónico, cada fase de una solicitud se puede ejecutar en un subproceso diferente, y cada subproceso ejecuta fases que pertenecen a diferentes solicitudes de forma intercalada. Esto tiene profundas implicaciones para comprender el comportamiento del programa: los seguimientos de la pila no pueden proporcionar un contexto utilizable, los depuradores no pueden recorrer la lógica de procesamiento de solicitudes y los perfiladores no pueden relacionar el costo de una operación con su llamador. Es posible componer expresiones lambda cuando se procesan datos en una canalización corta utilizando la API de transmisión de Java, pero surgen problemas cuando todo el código de manejo de solicitudes en una aplicación debe escribirse de esta manera . Este estilo de programación es incompatible con la plataforma Java porque la unidad de concurrencia de la aplicación (la canalización asíncrona) ya no es la unidad de concurrencia de la plataforma.

8. Utilice subprocesos virtuales para preservar el estilo de codificación de subprocesos por solicitud

Para permitir que las aplicaciones escale y al mismo tiempo se mantenga coherente con la plataforma, debemos esforzarnos por preservar un estilo de procesamiento de subprocesos por solicitud. Podemos hacer esto implementando subprocesos de manera más eficiente, de modo que la cantidad de subprocesos que se pueden admitir sea mayor. El sistema operativo no puede implementar subprocesos del sistema operativo de manera más eficiente porque los diferentes lenguajes y tiempos de ejecución usan pilas de subprocesos de manera diferente. Sin embargo, el tiempo de ejecución de Java puede implementar subprocesos de Java de una manera que rompa su correspondencia uno a uno con los subprocesos del sistema operativo. Así como el sistema operativo crea la ilusión de memoria abundante al asignar una gran cantidad de espacio de direcciones virtuales a una cantidad limitada de RAM física, el tiempo de ejecución de Java puede crear la ilusión de abundantes subprocesos al asignar una gran cantidad de subprocesos virtuales a un pequeño número. de subprocesos del sistema operativo.

Un hilo virtual es una implementación de java.lang.Thread que es independiente de un hilo específico del sistema operativo. Por el contrario, los subprocesos de la plataforma son objetos de instancia java.lang.Thread implementados de la forma tradicional y son envoltorios finos alrededor de los subprocesos del sistema operativo.

El código de la aplicación en modo subproceso por solicitud se puede ejecutar en un subproceso virtual durante toda la solicitud, pero el subproceso virtual solo consume subprocesos del sistema operativo mientras realiza cálculos en la CPU. El resultado es la misma escalabilidad que la asincrónica, pero de forma transparente: cuando el código que se ejecuta en un subproceso virtual llama a una operación de E/S de bloqueo en la API java.*, el tiempo de ejecución ejecuta una llamada del sistema operativo sin bloqueo y suspende automáticamente la hilo virtual hasta que pueda reanudarse más tarde. Para los desarrolladores de Java, un subproceso virtual es simplemente un subproceso barato de crear y casi ilimitado. La utilización del hardware es casi óptima, lo que permite una alta concurrencia y, por lo tanto, un alto rendimiento, y las aplicaciones implementadas con subprocesos virtuales son consistentes con el diseño de subprocesos múltiples de la plataforma Java y las herramientas relacionadas, lo que significa que los desarrolladores están aprendiendo, usando, el costo de depurar subprocesos virtuales. Es muy bajo, es fácil de aprender y comenzar.

9. El significado de los hilos virtuales.

Los subprocesos virtuales tienen una sobrecarga baja y, por lo tanto, admiten una gran cantidad, por lo que nunca deben agruparse: se debe crear un nuevo subproceso virtual para cada tarea de la aplicación. Por lo tanto, la mayoría de los subprocesos virtuales son de corta duración y tienen pilas de llamadas poco profundas, que realizan solo una llamada de cliente HTTP o una consulta JDBC. En comparación, los subprocesos de la plataforma son caros y voluminosos, por lo que normalmente es necesario agruparlos. Suelen ser de larga duración, tener pilas de llamadas profundas y pueden compartirse entre múltiples tareas.

En resumen, los subprocesos virtuales conservan un estilo confiable de subproceso por solicitud que es consistente con el diseño de la plataforma Java y al mismo tiempo hace un uso óptimo del hardware disponible. El uso de hilos virtuales no requiere aprender nuevos conceptos, pero puede requerir abandonar hábitos desarrollados en respuesta al alto costo de los hilos actuales. Los hilos virtuales no solo ayudan a los desarrolladores de aplicaciones sino también a los diseñadores de marcos al proporcionar API fáciles de usar que son compatibles con el diseño de la plataforma sin comprometer la escalabilidad.

10. Descripción

Hoy en día, cada instancia de java.lang.Thread en el JDK es un subproceso de plataforma. Los subprocesos de plataforma ejecutan código Java en subprocesos del sistema operativo subyacente y capturan subprocesos del sistema operativo durante toda la vida útil del código. La cantidad de subprocesos de la plataforma está limitada por la cantidad de subprocesos del sistema operativo.

Un subproceso virtual es una instancia de java.lang.Thread que ejecuta código Java en el subproceso del sistema operativo subyacente pero no captura el subproceso del sistema operativo durante toda la vida útil del código. Esto significa que muchos subprocesos virtuales pueden ejecutar código Java en el mismo subproceso del sistema operativo, compartiendo efectivamente el subproceso del sistema operativo. Los subprocesos de la plataforma monopolizan los valiosos subprocesos del sistema operativo, pero los subprocesos virtuales no. La cantidad de subprocesos virtuales puede ser mucho mayor que la cantidad de subprocesos del sistema operativo.

Los subprocesos virtuales son implementaciones ligeras de subprocesos, proporcionadas por el JDK en lugar del sistema operativo. Son una forma de subprocesos en modo de usuario que han tenido éxito en otros lenguajes de subprocesos múltiples, como gorutinas en Go y procesos en Erlang. Los subprocesos en modo de usuario incluso se denominaban " hilos verdes " en las primeras versiones de Java , antes de que los subprocesos del sistema operativo maduraran y se hicieran populares. Sin embargo, todos los subprocesos verdes de Java compartían un subproceso del sistema operativo (programación M:1) y finalmente fueron superados por los subprocesos de la plataforma implementados como envoltorios de subprocesos del sistema operativo (programación 1:1). Los subprocesos virtuales adoptan la programación M:N, es decir, una gran cantidad (M) de subprocesos virtuales están programados para ejecutarse en una cantidad menor (N) de subprocesos del sistema operativo.

11. Uso de hilos virtuales e hilos de plataforma.

Los desarrolladores pueden optar por utilizar subprocesos virtuales o subprocesos de plataforma. A continuación se muestra un programa de muestra que crea una gran cantidad de subprocesos virtuales. El programa primero obtiene un ExecutorService y crea un nuevo hilo virtual para cada tarea enviada. Luego, el programa envía 10.000 tareas y espera a que se completen todas:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    
    
    IntStream.range(0, 10_000).forEach(i -> {
    
    
        executor.submit(() -> {
    
    
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

La tarea en este ejemplo es un código simple que "duerme durante un segundo", y el hardware moderno puede admitir fácilmente 10.000 subprocesos virtuales que ejecutan dicho código simultáneamente. Detrás de escena, el JDK solo ejecuta código en una pequeña cantidad de subprocesos del sistema operativo, tal vez solo uno.

Si el programa utiliza un ExecutorService (como Executors.newCachedThreadPool()) para crear un nuevo hilo de plataforma para cada tarea, la situación será muy diferente. ExecutorService intentará crear 10.000 subprocesos de plataforma, lo que creará 10.000 subprocesos del sistema operativo y, según la máquina y el sistema operativo, el programa puede fallar.

Si el programa utiliza un ExecutorService que obtiene subprocesos de plataforma del grupo de subprocesos, como Executors.newFixedThreadPool (200), la situación no será mucho mejor. ExecutorService creará 200 subprocesos de plataforma para compartir las 10 000 tareas. Las tareas no ejecutadas se almacenarán en caché en la cola como instancias java.util.concurrent.FutureTask. El tamaño máximo de la cola es Integer.MAX_VALUE. Aunque se crea s, el número de Los subprocesos de la plataforma se reducen, pero siempre que la tarea sea reclamada por uno de los 200 subprocesos de la plataforma, no importa cuántas operaciones de espera de IO tenga el código en este subproceso, este subproceso completará la tarea antes de liberar los recursos del subproceso de la plataforma. las tareas se pondrán en cola y se ejecutarán secuencialmente de acuerdo con 200 métodos de procesamiento paralelo, y el programa tardará mucho en completarse.
Aún en el escenario anterior, si se utiliza la tecnología de subproceso virtual, debido a que el subproceso virtual liberará recursos del subproceso de la plataforma mientras espera IO, significa que el subproceso virtual no monopolizará un subproceso de la plataforma hasta que se complete la tarea del subproceso virtual, por lo que cada subproceso virtual El subproceso implica Cuando IO está esperando, los subprocesos de la plataforma se abandonan y cada uno espera. Los subprocesos de la plataforma pueden ejecutar subprocesos virtuales que requieren operaciones de CPU, por lo que el rendimiento aumenta exponencialmente. Un grupo de 200 subprocesos de plataforma solo puede completar 200 tareas por segundo, mientras que los subprocesos virtuales pueden completar aproximadamente 10,000 tareas por segundo (después de un calentamiento suficiente). Además, si 10_000 en el programa de ejemplo se cambia a 1_000_000, entonces el programa enviará 1.000.000 de tareas, creará 1.000.000 de subprocesos virtuales para ejecutarse simultáneamente y (después de un calentamiento suficiente) el rendimiento alcanzará aproximadamente 1.000.000 de tareas por segundo.

Si las tareas de este programa realizan cálculos en un segundo (por ejemplo, ordenar una matriz enorme) y no simplemente duermen, entonces aumentar la cantidad de subprocesos para exceder la cantidad de núcleos de procesador no ayudará, independientemente de si son subprocesos virtuales. o hilo de plataforma. Los subprocesos virtuales no son subprocesos más rápidos, no ejecutan código más rápido que los subprocesos de la plataforma. Existen para proporcionar escala (mayor rendimiento), no velocidad (menor latencia). Puede haber muchos más subprocesos virtuales que subprocesos de plataforma, por lo que, según la ley de Little, los subprocesos virtuales pueden proporcionar la mayor concurrencia necesaria para un mayor rendimiento.

Dicho de otra manera, los subprocesos virtuales pueden mejorar significativamente el rendimiento de la aplicación cuando:

  • El número de tareas simultáneas es grande (más de unos pocos miles) y
  • La carga de trabajo no está vinculada a la CPU, ya que tener más subprocesos que núcleos de procesador no mejora el rendimiento en este caso.

Los subprocesos virtuales ayudan a mejorar el rendimiento de las aplicaciones de servidor típicas precisamente porque dichas aplicaciones constan de una gran cantidad de tareas simultáneas que dedican la mayor parte de su tiempo a realizar varias esperas de E/S.

Los subprocesos virtuales pueden ejecutar cualquier código que los subprocesos de la plataforma puedan ejecutar. En particular, los subprocesos virtuales admiten variables locales de subprocesos e interrupciones de subprocesos, al igual que los subprocesos de plataforma. Esto significa que el código Java existente que maneja solicitudes puede ejecutarse fácilmente en subprocesos virtuales. Muchos marcos de servidores optarán por hacer esto automáticamente, iniciando un nuevo hilo virtual para cada solicitud entrante y ejecutando la lógica empresarial de la aplicación dentro de él.

A continuación se muestra un ejemplo de una aplicación de servidor que agrega los resultados de otros dos servicios. El marco del servidor hipotético crea un nuevo hilo virtual para cada solicitud y ejecuta el código de procesamiento de la aplicación en ese hilo virtual. El código de la aplicación crea dos nuevos subprocesos virtuales para obtener recursos simultáneamente a través del mismo ExecutorService que en el primer ejemplo:

void handle(Request request, Response response) {
    
    
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    
    
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
    
    
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    
    
    try (var in = url.openStream()) {
    
    
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

Una aplicación de servidor como esta, donde el código se bloquea directamente, se escala bien debido a la gran cantidad de subprocesos virtuales que se pueden utilizar.

Executor.newVirtualThreadPerTaskExecutor() no es la única forma de crear un hilo virtual. La nueva API java.lang.Thread.Builder , que se analiza más adelante, puede crear e iniciar subprocesos virtuales. Además, la concurrencia estructurada proporciona una API más poderosa para crear y administrar subprocesos virtuales, especialmente en código como este ejemplo de servidor, donde la plataforma y sus herramientas ya conocen las relaciones entre subprocesos.

12. No agrupe hilos virtuales

Los desarrolladores a menudo migran el código de la aplicación del ExecutorService tradicional basado en un grupo de subprocesos a un ExecutorService de subprocesos virtual dividido por tareas. Los grupos de subprocesos, al igual que otros grupos de recursos, están diseñados para compartir recursos costosos, pero los subprocesos virtuales no son costosos, por lo que no es necesario agruparlos.

A veces, los desarrolladores utilizan grupos de subprocesos para limitar el acceso simultáneo a recursos limitados. Por ejemplo, si un servicio no puede manejar más de 20 solicitudes simultáneas, esto se puede garantizar manejando todas las solicitudes al servicio a través de tareas enviadas a un grupo de subprocesos de tamaño 20. Este modismo se ha vuelto omnipresente debido al alto costo de los subprocesos de la plataforma, lo que hace que los grupos de subprocesos sean ubicuos, pero nunca agrupe subprocesos virtuales para limitar la concurrencia. En su lugar, utilice construcciones diseñadas específicamente para este propósito, como semáforos.

Junto con los grupos de subprocesos, los desarrolladores a veces utilizan variables locales de subprocesos para compartir recursos costosos entre múltiples tareas que comparten el mismo subproceso. Por ejemplo, si crear una conexión a una base de datos es costoso, puede abrirla solo una vez y almacenarla en una variable local del subproceso para su uso posterior en otras tareas en el mismo subproceso. Si está migrando su código de un grupo de subprocesos a un subproceso virtual por tarea, tenga cuidado al utilizar este modismo, ya que la creación de recursos costosos para cada subproceso virtual puede reducir significativamente el rendimiento. Cambie dicho código para utilizar una estrategia de almacenamiento en caché diferente que permita compartir eficientemente recursos costosos entre una gran cantidad de subprocesos virtuales.

13. Programación de hilos virtuales

Para realizar un trabajo útil, es necesario programar los subprocesos, es decir, asignarlos a los núcleos del procesador para su ejecución. Para los subprocesos de plataforma implementados como subprocesos del sistema operativo, el JDK depende del programador del sistema operativo. Por el contrario, para los subprocesos virtuales, el JDK tiene su propio programador. El programador JDK no asigna directamente subprocesos virtuales a los procesadores, sino que asigna subprocesos virtuales a subprocesos de plataforma (esta es la programación M:N de subprocesos virtuales mencionada anteriormente). Luego, el sistema operativo programa el hilo de la plataforma como de costumbre.

El programador de subprocesos virtuales del JDK es un robo de trabajo (una estrategia de programación de subprocesos, especialmente en procesadores multinúcleo y computación paralela. Esta estrategia permite que los subprocesos roben de los procesadores que están realizando otro trabajo (o "robo de trabajo"). ) algo de trabajo (para equilibrar la carga de trabajo entre diferentes procesadores para maximizar el rendimiento y la capacidad de respuesta. Este mecanismo puede ayudar a lograr un mejor rendimiento y utilización de recursos.) ForkJoinPool, con operación en modo primero en entrar, primero en salir (FIFO). El paralelismo del programador se refiere a la cantidad de subprocesos de plataforma disponibles para programar subprocesos virtuales. De forma predeterminada, es igual a la cantidad de subprocesos disponibles para los procesadores disponibles, pero se puede ajustar mediante la propiedad del sistema jdk.virtualThreadScheduler.parallelism. ForkJoinPool es diferente de los grupos normales, que se utilizan para implementar transmisiones paralelas, etc. y se ejecutan en modo último en entrar, primero en salir (LIFO).

El subproceso de plataforma asignado por el programador para un subproceso virtual se denomina portador del subproceso virtual. Un subproceso virtual se puede programar en diferentes operadores durante su vida; en otras palabras, el programador no mantiene afinidad entre el subproceso virtual y ningún subproceso de plataforma específico. Desde la perspectiva del código Java, un hilo virtual en ejecución es lógicamente independiente de su portador actual:

  • El hilo virtual no puede obtener la identidad del transportista. El valor devuelto por Thread.currentThread() es siempre el propio hilo virtual.
  • Los seguimientos de pila están separados para subprocesos virtuales y de operador. Las excepciones lanzadas en un hilo virtual no contienen el marco de pila del operador. Un volcado de subprocesos no muestra el marco de pila del operador en la pila del subproceso virtual y viceversa.
  • Un hilo virtual no puede utilizar las variables locales del hilo de un operador y viceversa.

Además, el hecho de que el hilo virtual y su portador compartan temporalmente el hilo del sistema operativo no es visible desde la perspectiva del código Java. En cambio, desde la perspectiva del código nativo, tanto el hilo virtual como su portador se ejecutan en el mismo hilo nativo. Por lo tanto, el código nativo que se llama varias veces en el mismo subproceso virtual puede observar un identificador de subproceso del sistema operativo diferente en cada llamada.

Actualmente, el programador no implementa el tiempo compartido para subprocesos virtuales. El tiempo compartido se refiere a la preferencia forzada de subprocesos que han consumido una cierta cantidad de tiempo de CPU. Aunque el tiempo compartido puede reducir eficazmente el retraso de ciertas tareas cuando el número de subprocesos de la plataforma es relativamente pequeño y la utilización de la CPU es del 100%, el efecto del tiempo compartido no es obvio para millones de subprocesos virtuales.

14. Ejecutar hilo virtual

Para aprovechar los hilos virtuales, no es necesario reescribir el programa. Los subprocesos virtuales no requieren ni esperan que el código de la aplicación devuelva explícitamente el control al programador; en otras palabras, los subprocesos virtuales no son operables. Así como no podemos especificar cuándo el recolector de basura recolecta basura, el código de usuario no puede controlar el subproceso virtual. Cómo o cuándo No se puede asumir que los subprocesos se asignan a los subprocesos de la plataforma, al igual que cómo o cuándo se asignan los subprocesos de la plataforma a los núcleos del procesador.

Para ejecutar código en un subproceso virtual, el programador de subprocesos virtuales del JDK asigna el subproceso virtual al subproceso de la plataforma para su ejecución montando el subproceso virtual en el subproceso de la plataforma. De esta forma, el hilo de la plataforma se convierte en portador de hilos virtuales. Más tarde, después de ejecutar algún código, el hilo virtual se puede descargar de su portador. En este punto, el subproceso de la plataforma está inactivo, por lo que el programador puede montar otro subproceso virtual en él, convirtiéndolo nuevamente en portador.

Normalmente, un subproceso virtual se descarga cuando bloquea E/S u otras operaciones de bloqueo en el JDK (como BlockingQueue.take()). Cuando la operación de bloqueo está lista para completarse (por ejemplo, se reciben bytes en el socket), envía el subproceso virtual al programador, que monta el subproceso virtual en el portador para continuar la ejecución.

Los subprocesos virtuales se montan y desmontan con frecuencia y de forma transparente sin bloquear ningún subproceso del sistema operativo. Por ejemplo, la aplicación de servidor mostrada anteriormente incluye la siguiente línea de código, que contiene una llamada a una operación de bloqueo:

respuesta.send(future1.get() + Future2.get());
Estas operaciones harán que el hilo virtual se monte y desmonte varias veces, generalmente una vez cada vez que se llama a get(), y se ejecuta en send(...) se puede montar varias veces durante la E/S.

La gran mayoría de las operaciones de bloqueo en el JDK descargan el subproceso virtual, liberando a su operador y a los subprocesos del sistema operativo subyacente para manejar nuevos trabajos. Sin embargo, algunas operaciones de bloqueo en el JDK no descargan el subproceso virtual y, por lo tanto, bloquean su portador y el subproceso del sistema operativo subyacente. Esto se debe a limitaciones en el nivel del sistema operativo (como muchas operaciones del sistema de archivos) o en el nivel JDK (como Object.wait()). Las implementaciones de estas operaciones de bloqueo compensan la captura de subprocesos del sistema operativo extendiendo temporalmente el paralelismo del programador. Por lo tanto, la cantidad de subprocesos de plataforma en ForkJoinPool del programador puede exceder temporalmente la cantidad de procesadores disponibles. El número máximo de subprocesos de plataforma disponibles para el programador se puede ajustar mediante la propiedad del sistema jdk.virtualThreadScheduler.maxPoolSize.

Hay dos situaciones en las que un hilo virtual no se puede descargar durante una operación de bloqueo porque está anclado al operador:

  • Al ejecutar código dentro de un bloque o método sincronizado, o
  • Al ejecutar métodos locales o funciones foráneas .

Tener subprocesos virtuales anclados al operador no provocará que la aplicación sea incorrecta, pero puede dificultar su escalabilidad. Si un subproceso virtual realiza una operación de bloqueo (como E/S o BlockingQueue.take()) mientras está anclado a un operador, su operador y los subprocesos del sistema operativo subyacente se bloquearán mientras dure la operación. La fijación frecuente de subprocesos virtuales al operador durante mucho tiempo hará que el subproceso virtual capture el transportista (lo que puede entenderse como que el subproceso virtual está fuertemente vinculado al subproceso de la plataforma), lo que daña la escalabilidad de la aplicación.

El programador no compensa la fijación de subprocesos virtuales en los operadores extendiendo el paralelismo. En cambio, los portadores de bloqueo frecuentes y largos se pueden evitar modificando bloques o métodos sincronizados que se ejecutan con frecuencia y usando java.util.concurrent.locks.ReentrantLock en lugar de posibles operaciones de E/S largas. No es necesario reemplazar los bloques y métodos sincronizados que se usan con poca frecuencia (como los que solo se realizan al inicio) o que protegen las operaciones de memoria. Como siempre, esfuércese por mantener su estrategia de bloqueo simple y clara.

Las nuevas capacidades de diagnóstico ayudan a migrar código a subprocesos virtuales y también ayudan a evaluar si los usos específicos de sincronizado deben reemplazarse con bloqueos java.util.concurrent:

  • Un evento JDK Flight Recorder (JFR) se genera cuando un subproceso se bloquea mientras bloquea un transportista (consulte JDK Flight Recorder ).
  • La propiedad del sistema jdk.tracePinnedThreads activa un seguimiento de la pila cuando un subproceso virtual bloquea un subproceso de la plataforma. El uso de -Djdk.tracePinnedThreads=full imprimirá un seguimiento de la pila completa cuando el hilo se bloquee, resaltando los fotogramas locales y los fotogramas que sostienen el monitor. El uso de -Djdk.tracePinnedThreads=short limitará la salida al fotograma en cuestión.

15. Uso de memoria y recolección de basura.

La pila del hilo virtual se almacena en la memoria del montón en forma de un objeto de bloque de pila. La pila del subproceso virtual crece y se reduce a medida que se ejecuta la aplicación, lo que ahorra memoria y al mismo tiempo admite una pila tan profunda como el tamaño de pila de subprocesos de la plataforma configurada de la JVM. Es debido a esta eficiencia que las aplicaciones de servidor pueden tener una gran cantidad de subprocesos virtuales, lo que permite que continúe el enfoque de subproceso por solicitud.

En general, la cantidad de espacio de almacenamiento dinámico y actividad del recolector de basura requerida por los subprocesos virtuales es mayor que la del código asincrónico. En primer lugar, desde la perspectiva del consumo del subproceso en sí, un millón de subprocesos virtuales requieren al menos un millón de objetos, y un millón de tareas de grupo de subprocesos de plataforma compartida también requieren un millón de objetos. Aunque existen algunas diferencias en los detalles de la asignación interna, por ejemplo, un programa desarrollado con un enfoque de subproceso por solicitud puede guardar datos en variables locales que se almacenan en una pila de subprocesos virtual en el montón, mientras que el código asincrónico debe almacenar los datos. Los mismos datos se guardan en objetos de montón que se pasan de una etapa de la canalización a la siguiente, y el diseño del marco de montón requerido por los subprocesos virtuales es más derrochador que el de los objetos compactos. Sin embargo, los subprocesos virtuales se pueden usar en muchos casos (dependiendo de en la estrategia de GC subyacente) La pila se reutiliza, mientras que la canalización asincrónica siempre requiere que se asignen nuevos objetos, por lo que los subprocesos virtuales pueden requerir menos asignaciones. En general, el consumo de almacenamiento dinámico y la actividad del recolector de basura por subproceso de solicitud y código asincrónico deberían ser más o menos los mismos en esta etapa. La representación interna de las pilas de subprocesos virtuales puede volverse más compacta en el futuro.

A diferencia de la pila de subprocesos de la plataforma, la pila de subprocesos virtuales no es una raíz de GC. Por lo tanto, las referencias que contienen no se recorrerán durante las pausas de Stop-the-World cuando un recolector de basura (como G1) realiza un escaneo de montón simultáneo. Esto también significa que si un hilo virtual está bloqueado en una operación como BlockingQueue.take() y ningún otro hilo puede obtener una referencia al hilo o cola virtual, entonces el hilo será recolectado como basura porque el hilo virtual nunca será interrumpido. o desbloquear. Por supuesto, si un hilo virtual se está ejecutando o está bloqueado y puede desbloquearse, no se recolectará como basura.

Una limitación actual de los subprocesos virtuales es que G1 GC no admite objetos de bloques de pila enormes . Si la pila de un subproceso virtual alcanza la mitad del tamaño de la región (que puede ser tan pequeña como 512 KB), se puede generar un StackOverflowError.

16. Enhebrar variables locales

Los subprocesos virtuales, como los subprocesos de plataforma, admiten variables locales de subproceso ( ThreadLocal ) y variables locales de subproceso heredables ( InheritableThreadLocal ), por lo que se puede ejecutar el código existente que utiliza variables locales de subproceso. Sin embargo, debido a que puede haber tantos subprocesos virtuales, el uso de variables locales de subprocesos sólo debe realizarse con una cuidadosa consideración.

La propiedad del sistema jdk.traceVirtualThreadLocals se puede utilizar para activar un seguimiento de la pila cuando un subproceso virtual establece el valor de cualquier variable local del subproceso. Este resultado de diagnóstico puede ser útil para eliminar variables locales de subprocesos al migrar código para usar subprocesos virtuales. Establezca la propiedad del sistema en verdadero para activar un seguimiento de la pila; el valor predeterminado es falso.

Anterior

Lanzamiento de JDK 21, descripción general de nuevas funciones e introducción detallada a las plantillas de cadenas

Supongo que te gusta

Origin blog.csdn.net/xieshaohu/article/details/133101349
Recomendado
Clasificación