Introdução
Portanto, sabemos que existem threads em Java, sobre os quais você pode ler na revisão “ Você não pode estragar Java com um thread: Parte I - Threads ”. Threads são necessários para trabalhar simultaneamente. Portanto, é muito provável que os threads interajam de alguma forma entre si. Vamos entender como isso acontece e quais controles básicos temos.Colheita
O método Thread.yield() é misterioso e raramente usado. Existem muitas variações de sua descrição na Internet. A tal ponto que alguns escrevem sobre algum tipo de fila de threads, na qual o thread irá descer levando em consideração suas prioridades. Alguém escreve que o thread mudará seu status de em execução para executável (embora não haja divisão nesses status e o Java não faça distinção entre eles). Mas, na realidade, tudo é muito mais desconhecido e, em certo sentido, mais simples. No tópico de documentação do método,yield
há um bug " JDK-6416721: (spec thread) Fix Thread.yield() javadoc ". Se você lê-lo, fica claro que, na verdade, o método yield
apenas transmite alguma recomendação ao agendador de threads Java de que esse thread pode receber menos tempo de execução. Mas o que realmente acontecerá, se o escalonador ouvirá a recomendação e o que fará em geral depende da implementação da JVM e do sistema operacional. Ou talvez de alguns outros fatores. Toda a confusão provavelmente se deveu ao repensar do multithreading durante o desenvolvimento da linguagem Java. Você pode ler mais na revisão " Breve introdução ao Java Thread.yield() ".
Sono - Adormecer fio
Um thread pode adormecer durante sua execução. Este é o tipo mais simples de interação com outros threads. O sistema operacional no qual a máquina virtual Java está instalada, onde o código Java é executado, possui seu próprio agendador de threads, denominado Thread Scheduler. É ele quem decide qual thread executar e quando. O programador não pode interagir com esse escalonador diretamente a partir do código Java, mas pode, por meio da JVM, solicitar ao escalonador que pause o thread por um tempo, para “colocá-lo em suspensão”. Você pode ler mais nos artigos " Thread.sleep() " e " Como funciona o Multithreading ". Além disso, você pode descobrir como os threads funcionam no sistema operacional Windows: " Internals of Windows Thread ". Agora veremos com nossos próprios olhos. Vamos salvar o seguinte código em um arquivoHelloWorldApp.java
:
class HelloWorldApp {
public static void main(String []args) {
Runnable task = () -> {
try {
int secToWait = 1000 * 60;
Thread.currentThread().sleep(secToWait);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(task);
thread.start();
}
}
Como você pode ver, temos uma tarefa que espera 60 segundos, após os quais o programa termina. Compilamos javac HelloWorldApp.java
e executamos java HelloWorldApp
. É melhor iniciar em uma janela separada. Por exemplo, no Windows seria assim: start java HelloWorldApp
. Usando o comando jps, descobrimos o PID do processo e abrimos a lista de threads usando jvisualvm --openpid pidПроцесса
: Como você pode ver, nosso thread entrou no status Sleeping. Na verdade, dormir o thread atual pode ser feito de maneira mais bonita:
try {
TimeUnit.SECONDS.sleep(60);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
Você provavelmente já percebeu que processamos em todos os lugares InterruptedException
? Vamos entender o porquê.
Interrompendo um thread ou Thread.interrupt
Acontece que enquanto o fio espera em um sonho, alguém pode querer interromper essa espera. Nesse caso, tratamos dessa exceção. Isso foi feito depois que o métodoThread.stop
foi declarado obsoleto, ou seja, desatualizado e indesejável para uso. A razão para isso foi que quando o método foi chamado, stop
o thread foi simplesmente “morto”, o que era muito imprevisível. Não podíamos saber quando o fluxo seria interrompido, não podíamos garantir a consistência dos dados. Imagine que você está gravando dados em um arquivo e então o fluxo é destruído. Portanto, decidiram que seria mais lógico não interromper o fluxo, mas informá-lo que deveria ser interrompido. Como reagir a isso depende do próprio fluxo. Mais detalhes podem ser encontrados em " Por que Thread.stop está obsoleto? " da Oracle. Vejamos um exemplo:
public static void main(String []args) {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(60);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
Neste exemplo, não esperaremos 60 segundos, mas imprimiremos imediatamente 'Interrompido'. Isso ocorre porque chamamos o método do thread interrupt
. Este método define "sinalizador interno chamado status de interrupção". Ou seja, cada thread possui um sinalizador interno que não é diretamente acessível. Mas temos métodos nativos para interagir com esse sinalizador. Mas esta não é a única maneira. Um thread pode estar em processo de execução, não esperando por algo, mas simplesmente executando ações. Mas pode prever que eles queiram concluí-lo em determinado ponto de seu trabalho. Por exemplo:
public static void main(String []args) {
Runnable task = () -> {
while(!Thread.currentThread().isInterrupted()) {
//Do some work
}
System.out.println("Finished");
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
No exemplo acima, você pode ver que o loop while
será executado até que o thread seja interrompido externamente. O importante a saber sobre o sinalizador isInterrupted é que, se o capturarmos InterruptedException
, o sinalizador isInterrupted
será redefinido e isInterrupted
retornará falso. Há também um método estático na classe Thread que se aplica apenas ao thread atual - Thread.interrupted() , mas esse método redefine o sinalizador para falso! Você pode ler mais no capítulo " Interrupção de Thread ".
Join – Aguardando a conclusão de outro tópico
O tipo mais simples de espera é aguardar a conclusão de outro thread.public static void main(String []args) throws InterruptedException {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.join();
System.out.println("Finished");
}
Neste exemplo, o novo thread ficará suspenso por 5 segundos. Ao mesmo tempo, o thread principal aguardará até que o thread adormecido acorde e termine seu trabalho. Se você olhar através do JVisualVM, o estado do thread ficará assim: Graças às ferramentas de monitoramento, você pode ver o que está acontecendo com o thread. O método join
é bastante simples, pois é simplesmente um método com código java que é executado wait
enquanto o thread no qual é chamado está ativo. Assim que o thread morre (no encerramento), a espera é encerrada. Essa é toda a magia do método join
. Portanto, vamos para a parte mais interessante.
Monitor de conceito
No multithreading existe algo como Monitor. Em geral, a palavra monitor é traduzida do latim como “superintendente” ou “superintendente”. No âmbito deste artigo, tentaremos relembrar a essência e, para quem quiser, peço que se aprofunde no material dos links para mais detalhes. Vamos começar nossa jornada com a especificação da linguagem Java, ou seja, com JLS: " 17.1. Sincronização ". Diz o seguinte: Acontece que para fins de sincronização entre threads, Java utiliza um determinado mecanismo chamado “Monitor”. Cada objeto possui um monitor associado e os threads podem bloqueá-lo ou desbloqueá-lo. A seguir, encontraremos um tutorial de treinamento no site da Oracle: “ Intrinsic Locks and Synchronization ”. Este tutorial explica que a sincronização em Java é construída em torno de uma entidade interna conhecida como bloqueio intrínseco ou bloqueio de monitor. Freqüentemente, esse bloqueio é simplesmente chamado de “monitor”. Também vemos novamente que todo objeto em Java possui um bloqueio intrínseco associado a ele. Você pode ler " Java - Bloqueios Intrínsecos e Sincronização ". A seguir, é importante entender como um objeto em Java pode ser associado a um monitor. Cada objeto em Java possui um cabeçalho - uma espécie de metadados internos que não estão disponíveis para o programador no código, mas que a máquina virtual precisa para trabalhar corretamente com os objetos. O cabeçalho do objeto inclui um MarkWord parecido com este:https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf
public class HelloWorld{
public static void main(String []args){
Object object = new Object();
synchronized(object) {
System.out.println("Hello World");
}
}
}
Assim, utilizando a palavra-chave, synchronized
o thread atual (no qual essas linhas de código são executadas) tenta utilizar o monitor associado ao objeto object
e “obter um bloqueio” ou “capturar o monitor” (a segunda opção é ainda preferível). Se não houver contenção para o monitor (ou seja, ninguém mais deseja sincronizar no mesmo objeto), Java pode tentar realizar uma otimização chamada "bloqueio tendencioso". O título do objeto no Mark Word conterá a tag correspondente e um registro de qual thread o monitor está anexado. Isso reduz a sobrecarga ao capturar o monitor. Se o monitor já foi vinculado a outro thread antes, esse bloqueio não é suficiente. A JVM muda para o próximo tipo de bloqueio – bloqueio básico. Ele usa operações de comparação e troca (CAS). Ao mesmo tempo, o cabeçalho no Mark Word não armazena mais o próprio Mark Word, mas um link para seu armazenamento + a tag é alterada para que a JVM entenda que estamos usando o bloqueio básico. Se houver disputa pelo monitor de vários threads (um capturou o monitor e o segundo está aguardando a liberação do monitor), a tag no Mark Word muda e o Mark Word começa a armazenar uma referência ao monitor como um objeto - alguma entidade interna da JVM. Conforme consta no PEC, neste caso é necessário espaço na área de memória Native Heap para armazenar esta entidade. O link para o local de armazenamento desta entidade interna estará localizado no objeto Mark Word. Assim, como podemos ver, o monitor é na verdade um mecanismo para garantir a sincronização do acesso de múltiplos threads a recursos compartilhados. Existem várias implementações desse mecanismo entre as quais a JVM alterna. Portanto, para simplificar, quando falamos de monitor, estamos na verdade falando de fechaduras.
Sincronizado e aguardando por bloqueio
O conceito de monitor, como vimos anteriormente, está intimamente relacionado ao conceito de “bloco de sincronização” (ou, como também é chamado, seção crítica). Vejamos um exemplo:public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Runnable task = () -> {
synchronized (lock) {
System.out.println("thread");
}
};
Thread th1 = new Thread(task);
th1.start();
synchronized (lock) {
for (int i = 0; i < 8; i++) {
Thread.currentThread().sleep(1000);
System.out.print(" " + i);
}
System.out.println(" ...");
}
}
Aqui, o thread principal primeiro envia a tarefa para um novo thread e, em seguida, imediatamente “captura” o bloqueio e executa uma longa operação com ele (8 segundos). Todo esse tempo, a tarefa não pode entrar no bloco para sua execução synchronized
, pois a fechadura já está ocupada. Se um thread não conseguir obter um bloqueio, ele aguardará no monitor. Assim que o receber, continuará a execução. Quando um thread sai do monitor, ele libera o bloqueio. No JVisualVM ficaria assim: Como você pode ver, o status no JVisualVM é denominado "Monitor" porque o thread está bloqueado e não pode ocupar o monitor. Você também pode descobrir o estado do thread no código, mas o nome desse estado não coincide com os termos JVisualVM, embora sejam semelhantes. Neste caso, th1.getState()
o loop for
retornará BLOCKED , porque Enquanto o loop está em execução, o monitor lock
é ocupado main
pelo thread, e o thread th1
fica bloqueado e não pode continuar funcionando até que o bloqueio seja retornado. Além dos blocos de sincronização, um método inteiro pode ser sincronizado. Por exemplo, um método da classe HashTable
:
public synchronized int size() {
return count;
}
Em uma unidade de tempo, este método será executado por apenas um thread. Mas precisamos de uma fechadura, certo? Sim, preciso disso. No caso de métodos de objeto, o bloqueio será this
. Há uma discussão interessante sobre este tópico: " Existe vantagem em usar um Método Sincronizado em vez de um Bloco Sincronizado? ". Se o método for estático, então o bloqueio não será this
(já que para um método estático não pode ser this
), mas sim o objeto da classe (por exemplo, Integer.class
).
Espere e espere no monitor. Os métodos notificar e notificarAll
Thread possui outro método de espera, que está conectado ao monitor. Ao contrário desleep
e join
, não pode simplesmente ser chamado. E o nome dele é wait
. O método é executado wait
no objeto em cujo monitor queremos esperar. Vejamos um exemplo:
public static void main(String []args) throws InterruptedException {
Object lock = new Object();
// task будет ждать, пока его не оповестят через lock
Runnable task = () -> {
synchronized(lock) {
try {
lock.wait();
} catch(InterruptedException e) {
System.out.println("interrupted");
}
}
// После оповещения нас мы будем ждать, пока сможем взять лок
System.out.println("thread");
};
Thread taskThread = new Thread(task);
taskThread.start();
// Ждём и после этого забираем себе лок, оповещаем и отдаём лок
Thread.currentThread().sleep(3000);
System.out.println("main");
synchronized(lock) {
lock.notify();
}
}
No JVisualVM ficará assim: Para entender como isso funciona, você deve lembrar que os métodos wait
se referem notify
a java.lang.Object
. Parece estranho que os métodos relacionados ao thread estejam no arquivo Object
. Mas aqui está a resposta. Como lembramos, todo objeto em Java possui um cabeçalho. O cabeçalho contém diversas informações de serviço, incluindo informações sobre o monitor – dados sobre o estado de bloqueio. E como lembramos, cada objeto (ou seja, cada instância) possui uma associação com uma entidade JVM interna chamada bloqueio intrínseco, que também é chamada de monitor. No exemplo acima, a tarefa descreve que inserimos o bloco de sincronização no monitor associado ao lock
. Se for possível obter um bloqueio neste monitor, então wait
. A thread que executa esta tarefa liberará o monitor lock
, mas entrará na fila de threads aguardando notificação no monitor lock
. Essa fila de threads é chamada WAIT-SET, que reflete mais corretamente a essência. É mais um conjunto do que uma fila. O thread main
cria um novo thread com a tarefa task, inicia-o e aguarda 3 segundos. Isso permite, com um alto grau de probabilidade, que um novo thread capture o bloqueio antes do thread main
e fique na fila do monitor. Depois disso, o main
próprio encadeamento entra no bloco de sincronização lock
e executa a notificação do encadeamento no monitor. Após o envio da notificação, o thread main
libera o monitor lock
e o novo thread (que estava aguardando anteriormente) lock
continua em execução após aguardar a liberação do monitor. É possível enviar uma notificação para apenas um dos threads ( notify
) ou para todos os threads da fila de uma vez ( notifyAll
). Você pode ler mais em " Diferença entre notificar() e notificarAll() em Java ". É importante observar que a ordem de notificação depende da implementação da JVM. Você pode ler mais em " Como resolver a fome com notify e notifyall? ". A sincronização pode ser executada sem especificar um objeto. Isso pode ser feito quando não é sincronizada uma seção separada do código, mas um método inteiro. Por exemplo, para métodos estáticos o lock será o objeto da classe (obtido via .class
):
public static synchronized void printA() {
System.out.println("A");
}
public static void printB() {
synchronized(HelloWorld.class) {
System.out.println("B");
}
}
Em termos de uso de bloqueios, os dois métodos são iguais. Se o método não for estático, a sincronização será realizada de acordo com o atual instance
, ou seja, de acordo com this
. A propósito, dissemos anteriormente que usando o método getState
você pode obter o status de um thread. Então aqui está um thread que está na fila do monitor, o status será WAITING ou TIMED_WAITING se o método wait
especificou um limite de tempo de espera.
Ciclo de vida de um thread
Como vimos, o fluxo muda de estado no decorrer da vida. Em essência, essas mudanças constituem o ciclo de vida do thread. Quando um thread acaba de ser criado, ele tem o status NOVO. Nesta posição, ele ainda não foi iniciado e o Java Thread Scheduler ainda não sabe nada sobre o novo thread. Para que o agendador de threads saiba sobre um thread, você deve chamar o métodothread.start()
. Então o thread irá para o estado RUNNABLE. Existem muitos esquemas incorretos na Internet onde os estados Executável e Em execução são separados. Mas isso é um erro, porque... Java não diferencia entre os status "pronto para execução" e "em execução". Quando um thread está ativo, mas não ativo (não executável), ele está em um dos dois estados:
- BLOQUEADO - aguarda entrada em uma seção protegida, ou seja, para
synchonized
o bloco. - WAITING - aguarda outro thread com base em uma condição. Se a condição for verdadeira, o agendador de threads inicia o thread.
getState
. Threads também possuem um método isAlive
que retorna verdadeiro se o thread não for encerrado.
LockSupport e estacionamento de threads
Desde o Java 1.6 existe um mecanismo interessante chamado LockSupport . Esta classe associa uma "permissão" ou permissão a cada thread que a utiliza. A chamada do métodopark
retorna imediatamente se uma licença estiver disponível, ocupando essa mesma licença durante a chamada. Caso contrário, está bloqueado. Chamar o método unpark
disponibiliza a permissão se ainda não estiver disponível. Existe apenas uma permissão. Na API Java, LockSupport
um determinado arquivo Semaphore
. Vejamos um exemplo simples:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(0);
try {
semaphore.acquire();
} catch (InterruptedException e) {
// Просим разрешение и ждём, пока не получим его
e.printStackTrace();
}
System.out.println("Hello, World!");
}
}
Este código irá esperar para sempre porque o semáforo agora tem permissão 0. E quando chamado no código acquire
(ou seja, solicitar permissão), o thread espera até receber permissão. Como estamos aguardando, somos obrigados a processá-lo InterruptedException
. Curiosamente, um semáforo implementa um estado de thread separado. Se olharmos no JVisualVM, veremos que nosso estado não é Wait, mas Park. Vejamos outro exemplo:
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
//Запаркуем текущий поток
System.err.println("Will be Parked");
LockSupport.park();
// Как только нас распаркуют - начнём действовать
System.err.println("Unparked");
};
Thread th = new Thread(task);
th.start();
Thread.currentThread().sleep(2000);
System.err.println("Thread state: " + th.getState());
LockSupport.unpark(th);
Thread.currentThread().sleep(2000);
}
O status do thread será WAITING, mas o JVisualVM distingue wait
entre from synchronized
e park
from LockSupport
. Por que este é tão importante LockSupport
? Vamos voltar novamente para a API Java e examinar Thread State WAITING . Como você pode ver, existem apenas três maneiras de entrar nisso. 2 maneiras - esta wait
e join
. E o terceiro é LockSupport
. Os bloqueios em Java são construídos com base nos mesmos princípios LockSupport
e representam ferramentas de nível superior. Vamos tentar usar um. Vejamos, por exemplo, em ReentrantLock
:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{
public static void main(String []args) throws InterruptedException {
Lock lock = new ReentrantLock();
Runnable task = () -> {
lock.lock();
System.out.println("Thread");
lock.unlock();
};
lock.lock();
Thread th = new Thread(task);
th.start();
System.out.println("main");
Thread.currentThread().sleep(2000);
lock.unlock();
}
}
Como nos exemplos anteriores, tudo é simples aqui. lock
espera que alguém libere um recurso. Se olharmos no JVisualVM, veremos que o novo thread ficará estacionado até que main
o thread lhe conceda o bloqueio. Você pode ler mais sobre bloqueios aqui: " Programação multithread em Java 8. Parte dois. Sincronizando acesso a objetos mutáveis " e " API Java Lock. Teoria e exemplo de uso ". Para entender melhor a implementação de bloqueios, é útil ler sobre Phazer na visão geral " Classe Phaser ". E por falar em vários sincronizadores, você deve ler o artigo do Habré “ Java.util.concurrent.* Synchronizers Reference ”.
Total
Nesta revisão, examinamos as principais maneiras pelas quais os threads interagem em Java. Material adicional:- Monitores – A ideia básica da sincronização Java
- Referência do sincronizador java.util.concurrent.*
- Respostas a perguntas sobre multithreading em uma entrevista
GO TO FULL VERSION