JavaRush /Blogue Java /Random-PT /Você não pode estragar o Java com um thread: Parte II - s...
Viacheslav
Nível 3

Você não pode estragar o Java com um thread: Parte II - sincronização

Publicado no grupo Random-PT

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. Você não pode estragar o Java com um thread: Parte II - sincronização - 1

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. Você não pode estragar o Java com um thread: Parte II - sincronização - 2No tópico de documentação do método, yieldhá um bug " JDK-6416721: (spec thread) Fix Thread.yield() javadoc ". Se você lê-lo, fica claro que, na verdade, o método yieldapenas 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 arquivo HelloWorldApp.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.javae 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Процесса: Você não pode estragar o Java com um thread: Parte II - sincronização - 3Como 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étodo Thread.stopfoi declarado obsoleto, ou seja, desatualizado e indesejável para uso. A razão para isso foi que quando o método foi chamado, stopo 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 whileserá executado até que o thread seja interrompido externamente. O importante a saber sobre o sinalizador isInterrupted é que, se o capturarmos InterruptedException, o sinalizador isInterruptedserá redefinido e isInterruptedretornará 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: Você não pode estragar o Java com um thread: Parte II - sincronização - 4Graç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 waitenquanto 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: Você não pode estragar o Java com um thread: Parte II - sincronização - 5Acontece 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: Você não pode estragar o Java com um thread: Parte II - sincronização - 6

https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf

Um artigo de Habr é muito útil aqui: " Mas como funciona o multithreading? Parte I: sincronização ." A este artigo vale a pena adicionar uma descrição do Resumo do bloco de tarefas do bugtaker JDK: “ JDK-8183909 ”. Você pode ler a mesma coisa em " JEP-8183909 ". Então, em Java, um monitor está associado a um objeto e o thread pode bloquear esse thread, ou também dizer “obter um bloqueio”. O exemplo mais simples:
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, synchronizedo thread atual (no qual essas linhas de código são executadas) tenta utilizar o monitor associado ao objeto objecte “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. Você não pode estragar o Java com um thread: Parte II - sincronização - 7

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: Você não pode estragar o Java com um thread: Parte II - sincronização - 8Como 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 forretornará BLOCKED , porque Enquanto o loop está em execução, o monitor locké ocupado mainpelo thread, e o thread th1fica 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 de sleepe join, não pode simplesmente ser chamado. E o nome dele é wait. O método é executado waitno 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: Você não pode estragar o Java com um thread: Parte II - sincronização - 10Para entender como isso funciona, você deve lembrar que os métodos waitse referem notifya 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 maincria 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 maine fique na fila do monitor. Depois disso, o mainpróprio encadeamento entra no bloco de sincronização locke executa a notificação do encadeamento no monitor. Após o envio da notificação, o thread mainlibera o monitor locke o novo thread (que estava aguardando anteriormente) lockcontinua 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 getStatevocê 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 waitespecificou um limite de tempo de espera. Você não pode estragar o Java com um thread: Parte II - sincronização - 11

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étodo thread.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 synchonizedo bloco.
  • WAITING - aguarda outro thread com base em uma condição. Se a condição for verdadeira, o agendador de threads inicia o thread.
Se um thread estiver aguardando por tempo, ele estará no estado TIMED_WAITING. Se o thread não estiver mais em execução (concluído com êxito ou com uma exceção), ele entrará no status TERMINATED. Para descobrir o estado de um thread (seu estado), é usado o método getState. Threads também possuem um método isAliveque 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 . Você não pode estragar o Java com um thread: Parte II - sincronização - 12Esta classe associa uma "permissão" ou permissão a cada thread que a utiliza. A chamada do método parkretorna 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 unparkdisponibiliza a permissão se ainda não estiver disponível. Existe apenas uma permissão. Na API Java, LockSupportum 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. Você não pode estragar o Java com um thread: Parte II - sincronização - 13Vejamos 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 waitentre from synchronizede parkfrom 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 waite join. E o terceiro é LockSupport. Os bloqueios em Java são construídos com base nos mesmos princípios LockSupporte 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. lockespera que alguém libere um recurso. Se olharmos no JVisualVM, veremos que o novo thread ficará estacionado até que maino 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: #Viacheslav
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION