Conforme relatado no InfoWorld, Java 22 apresenta vários novos recursos relacionados a threads. Um dos mais importantes é o novo ScopedValue sintaxe para lidar com valores compartilhados em contextos multithread. Vamos dar uma olhada.

Simultaneidade estruturada e valores com escopo definido

ScopedValue é uma nova maneira de alcançar ThreadLocal-comportamento semelhante. Ambos os elementos atendem à necessidade de criar dados que sejam compartilhados com segurança em um único thread, mas ScopedValue busca maior simplicidade. Ele foi projetado para funcionar em conjunto com VirtualThreads e o novo StructuredTaskScope, que juntos simplificam o threading e o tornam mais poderoso. À medida que esses novos recursos passam a ser usados ​​regularmente, o recurso de valores com escopo tem como objetivo atender à crescente necessidade de gerenciamento do compartilhamento de dados dentro de threads.

ScopedValue como alternativa ao ThreadLocal

Em um aplicativo multithread, muitas vezes você precisará declarar uma variável que exista exclusivamente para o thread atual. Pense nisso como algo semelhante a um Singleton – uma instância por aplicativo – exceto que é uma instância por thread.

Embora ThreadLocal funciona bem em muitos casos, tem limitações. Esses problemas se resumem ao desempenho do thread e à carga mental do desenvolvedor. Ambos os problemas provavelmente aumentarão à medida que os desenvolvedores usarem o novo VirtualThreads apresentam e introduzem mais threads em seus programas. O JEP para valores com escopo definido faz um bom trabalho ao descrever as limitações dos threads virtuais.

A ScopedValue instância melhora o que é possível usando ThreadLocal de três maneiras:

  • É imutável.
  • É herdado por threads filhos.
  • Ele é descartado automaticamente quando o método que o contém é concluído.

Como diz a especificação ScopedValue:

O tempo de vida dessas variáveis ​​por thread deve ser limitado: quaisquer dados compartilhados por meio de uma variável por thread devem se tornar inutilizáveis ​​quando o método que inicialmente compartilhou os dados for concluído.

Isto é diferente de ThreadLocal referências, que duram até o próprio thread terminar ou o ThreadLocal.remove() método é chamado.

A imutabilidade torna mais fácil seguir a lógica de um ScopedValue instância e permite que a JVM a otimize agressivamente.

Os valores com escopo usam um retorno de chamada funcional (um lambda) para definir a vida da variável, o que é uma abordagem incomum em Java. Pode parecer estranho no início, mas na prática funciona muito bem.

Como usar uma instância ScopedValue

Existem dois aspectos no uso de um ScopedValue exemplo: fornecer e consumir. Podemos ver isso em três partes no código a seguir.

Etapa 1: declarar o ScopedValue:


final static ScopedValue<...> MY_SCOPED_VALUE = ScopedValue.newInstance();

Etapa 2: preencher o ScopedValue instância:


ScopedValue.where(MY_SCOPED_VALUE, ACTUAL_VALUE).run(() -> { 
  /* ...Code that accesses ACTUAL_VALUE... */
});

Etapa 3: consumir o ScopedValue instância (código que é chamado em algum momento na etapa 2):


var fooBar = DeclaringClass.MY_SCOPED_VALUE

A parte mais interessante deste processo é o apelo à ScopedValue.where(). Isso permite associar o declarado ScopedValue com um valor real e, em seguida, chame .run() método, fornecendo uma função de retorno de chamada que será executada com o valor definido para o ScopedValue instância.

Lembre-se: O valor real associado ao ScopedValue a instância mudará de acordo com o thread que está sendo executado. É por isso que estamos fazendo tudo isso! (A variável específica específica do thread definida no ScopedValue às vezes é chamado de seu encarnação.)

Um exemplo de código geralmente vale mais que mil palavras, então vamos dar uma olhada. No código a seguir, criamos vários threads e geramos um número aleatório exclusivo para cada um. Usamos então um ScopedValue instância para aplicar esse valor a uma variável associada ao thread:


import java.util.concurrent.ThreadLocalRandom;

public class Simple {
  static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

  public static void main(String() args) {
    for (int i = 0; i < 10; i++) {
      new Thread(() -> {
       int randomNumber = ThreadLocalRandom.current().nextInt(1, 101);

       ScopedValue.where(RANDOM_NUMBER, randomNumber).run(() -> {
         System.out.printf("Thread %s: Random number: %dn", Thread.currentThread().getName(), RANDOM_NUMBER.get());
        });
      }).start();
    }
  }
}

A final estática ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance(); chamada nos dá o RANDOM_NUMBER ScopedValue para ser usado em qualquer lugar do aplicativo. Em cada thread, geramos um número aleatório e o associamos a RANDOM_NUMBER.

Então, corremos para dentro ScopedValue.where(). Todo o código dentro do manipulador será resolvido RANDOM_NUMBER para aquele específico definido no thread atual. No nosso caso, apenas enviamos o thread e seu número para o console.

Esta é a aparência de uma corrida:


$ javac --release 23 --enable-preview Simple.java 

Note: Simple.java uses preview features of Java SE 23.
Note: Recompile with -Xlint:preview for details.

$ java --enable-preview Simple

Thread Thread-1: Random number: 45
Thread Thread-2: Random number: 100
Thread Thread-3: Random number: 51
Thread Thread-4: Random number: 74
Thread Thread-5: Random number: 37
Thread Thread-0: Random number: 32
Thread Thread-6: Random number: 28
Thread Thread-7: Random number: 43
Thread Thread-8: Random number: 95
Thread Thread-9: Random number: 21

Observe que atualmente precisamos ativar o enable-preview mude para executar este código. Isso não será necessário quando o recurso de valores com escopo definido for promovido.

Cada thread obtém uma versão distinta de RANDOM_NUMBER. Onde quer que esse valor seja acessado, não importa quão profundamente aninhado, ele obterá a mesma versão – desde que se origine dentro desse valor. run() ligar de volta.

Em um exemplo tão simples, você pode imaginar passar o valor aleatório como parâmetro do método; entretanto, à medida que o código do aplicativo cresce, isso rapidamente se torna incontrolável e leva a componentes fortemente acoplados. Usando um ScopedValue instance é uma maneira fácil de tornar a variável universalmente acessível, mantendo-a restrita a um determinado valor para o thread atual.

Usando ScopedValue com StructuredTaskScope

Porque ScopedValue tem como objetivo facilitar o tratamento de um grande número de threads virtuais e StructuredTaskScope é uma forma recomendada de usar threads virtuais, você precisará saber como combinar esses dois recursos.

O processo geral de combinação ScopedValue com StructuredTaskScope é semelhante ao nosso anterior Thread exemplo; apenas a sintaxe difere:


import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.StructuredTaskScope;

public class ThreadScoped {
  static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

  public static void main(String() args) throws Exception {
    try (StructuredTaskScope scope = new StructuredTaskScope()) {
      for (int i = 0; i < 10; i++) {
        scope.fork(() -> {
          int randomNumber = ThreadLocalRandom.current().nextInt(1, 101);
          ScopedValue.where(RANDOM_NUMBER, randomNumber).run(() -> { 
            System.out.printf("Thread %s: Random number: %dn", Thread.currentThread().threadId(), RANDOM_NUMBER.get());
          });
          return null;
        });
      }
      scope.join();
    }
  }
}

A estrutura geral é a mesma: definimos o ScopedValue e então criamos threads e usamos o ScopedValue (RANDOM_NUMBER) neles. Em vez de criar Thread objetos, usamos scope.fork().

Observe que retornamos null do lambda para o qual passamos scope.fork(), porque em nosso caso não estamos usando o valor de retorno – estamos apenas enviando uma string para o console. É possível retornar um valor de scope.fork() e use-o.

Além disso, observe que acabei de lançar Exception de main(). O scope.fork() método lança um InterruptedException que deve ser tratado corretamente no código de produção.

O exemplo acima é típico de ambos StructuredTaskScope e ScopedValue. Como você pode ver, eles funcionam bem juntos – na verdade, foram projetados para isso.

Valores com escopo definido no mundo real

Vimos exemplos simples para ver como funciona o recurso de valores com escopo definido. Agora vamos pensar em como isso funcionará em cenários mais complexos. Em particular, o JEP de valores com escopo destaca o uso em uma grande aplicação web onde muitos componentes estão interagindo. O componente de tratamento de solicitações pode ser responsável por obter um objeto de usuário (um “princípio”) que representa a autorização para a solicitação atual. Este é um modelo de thread por solicitação usado por muitos frameworks. Os threads virtuais tornam isso muito mais escalável ao separar os threads da JVM dos threads do sistema operacional.

Depois que o manipulador de solicitações obtiver o objeto de usuário, ele poderá expô-lo ao restante da aplicação com o ScopedValue anotação. Então, quaisquer outros componentes chamados de dentro do where() retorno de chamada pode acessar o objeto de usuário específico do thread. Por exemplo, no JEP, o exemplo de código mostra um DBAccess componente que depende do PRINCIPAL ScopedValue para verificar a autorização:


/** https://openjdk.org/jeps/429 */
class Server {
  final static ScopedValue<Principal> PRINCIPAL =  ScopedValue.newInstance();

  void serve(Request request, Response response) {
    var level     = (request.isAdmin() ? ADMIN : GUEST);
    var principal = new Principal(level);
    ScopedValue.where(PRINCIPAL, principal)
      .run(() -> Application.handle(request, response));
    }
}

class DBAccess {
    DBConnection open() {
        var principal = Server.PRINCIPAL.get();
        if (!principal.canOpen()) throw new  InvalidPrincipalException();
        return newConnection(...);
    }
}

Aqui você pode ver os mesmos contornos do nosso primeiro exemplo. A única diferença é que existem componentes diferentes. O serve() imagina-se que o método crie threads por solicitação e, em algum momento, o Application.handle() chamada irá interagir com DBAccess.open(). Como a chamada se origina de dentro do ScopedValue.where()nós sabemos isso PRINCIPAL será resolvido para o valor definido para este thread pelo novo Principal(level) chamar.

Outro ponto importante é que as chamadas subsequentes para scope.fork() herdará o ScopedValue instâncias definidas pelo pai. Assim, por exemplo, mesmo que o serve() método acima era chamar scope.fork() e acesse a tarefa filho dentro PRINCIPAL.get(), obteria o mesmo valor vinculado ao thread que o pai. (Você pode ver o pseudocódigo deste exemplo na seção “Herdando valores com escopo definido“ do JEP.)

A imutabilidade dos valores com escopo definido significa que a JVM pode otimizar esse compartilhamento de thread filho, portanto, podemos esperar uma sobrecarga de baixo desempenho nesses casos.

Conclusão

Embora a simultaneidade multithread seja inerentemente complexa, os recursos Java mais recentes contribuem bastante para torná-la mais simples e poderosa. O novo recurso de valores com escopo definido é outra ferramenta eficaz para o kit de ferramentas do desenvolvedor Java. Ao todo, Java 22 oferece uma abordagem interessante e radicalmente aprimorada para threading em Java.