Comentários
As threads são utilizadas para destravar parte da aplicação quando algum processamento pesado está sendo executado e acelerar o processamento em alguma aplicação que possa ser paralelizada.
Fonte: Shutterstock.
Deseja ouvir este material?
Áudio disponível no material digital.
Caro aluno, bem-vindo à terceira seção da quarta e última unidade de estudos sobre Linguagem Orientada a Objetos. Qual desenvolvedor nunca desejou criar uma aplicação que execute em paralelo ou que não trave quando um processo pesado inicia a sua execução? Acredito que muitos desenvolvedores já desejaram isso. Atualmente, a maioria dos processadores vendidos são multicores, ou seja, possuem mais de um núcleo de processamento, mas pouco adianta ter um hardware robusto sem que o software consiga extrair o máximo desse hardware. Nesta seção, estudaremos as threads que nos auxiliam a criar programas que executem em paralelo ou concorrentemente.
De forma a contextualizar a sua aprendizagem, lembre-se de que você está trabalhando em um simulador de robô e que seu gestor reviu o seu código e percebeu que na sala em que o robô opera, você ainda não colocou, na sua simulação, a máquina que carrega as caixas da área de depósito. Ele explicou que o seu simulador ficará mais realista se possuir uma pequena animação dessa máquina, tal como na aplicação que desenvolveu usando a ferramenta Greenfoot. Diante disso, ele pediu a você que colocasse a máquina carregadora de caixas e criasse uma pequena animação em zigue-zague para ela, bem como recomendou que utilize threads para isso, de forma que a animação da máquina carregadora seja independente do movimento executado pelo robô.
Diante do desafio que lhe foi apresentado, como você criará essa animação da máquina? Como você criará essa thread em Java? O que é uma thread? Para que servem as threads? Esta seção o auxiliará na resposta de tais perguntas.
Muito bem, agora que você foi apresentado à sua nova situação-problema, estude esta seção e compreenda como a linguagem Java trata as threads; esse conceito é fundamental para que você consiga fazer grandes projetos de programação.
E aí, vamos juntos compreender esses conceitos e resolver esse desafio?
Bom estudo!
A presente seção tem por objetivo lhe mostrar como criar aplicações em Java para serem executadas em paralelo ou concorrentemente, logo, vamos estudar alguns aspectos de hardware e software que permitem a concorrência e o paralelismo. Os aspectos de hardware estudados são processadores single-core e multi-core, já os aspectos de software estudados são processos e threads.
Um computador, de forma simples, possui um conjunto de dispositivos de hardware que auxiliam no seu funcionamento, como processador, memória cache, memória RAM, disco rígido e dispositivos de entrada e saída de dados.
De forma a avançarmos nesta seção, vamos nos concentrar apenas no processador. Existem, basicamente, dois tipos de processadores, que são: single-core (com apenas um núcleo) ou multi-core (com mais de um núcleo), como ilustra a Figura 4.11.
Na Figura 4.11, à esquerda, temos uma ilustração de um processador com um único núcleo (core). Os processadores single-core são mais simples e só conseguem executar uma tarefa por vez; eles foram criados primeiro e não suportam a execução em paralelo devido ao único núcleo; apesar disso, diversas aplicações podem ser executadas concorrentemente por meio de mecanismos de compartilhamento do tempo de processamento.
Já os processadores à direita, na Figura 4.11, possuem 2, 4 e 8 núcleos (cores), logo, são processadores multi-core. Esse tipo de processador foi criado depois e é um pouco mais complexo; nesse tipo de arquitetura de hardware, é permitida a execução em paralelo.
Os processadores multi-core estão ficando cada vez mais presentes no mercado e o número de núcleos tem aumentado, tornando o poder de processamento cada vez mais poderoso. Atualmente, a maioria dos computadores, celulares e dispositivos de computação embarcada, como Raspberry Pi, é multi-core.
Leitor, procure descobrir quantos núcleos o seu processador possui, pois a resposta a essa questão o auxiliará na compreensão do restante da seção e na construção de aplicações paralelas.
Bem, até aqui, falamos dos aspectos de hardware, agora, vamos falar de dois conceitos importantes de software, que são: os processos e as threads. Um processo tem o seu próprio ambiente de execução e seu próprio espaço de memória, além disso, é muito comum associar um processo a um programa ou aplicação, no entanto, isso não é exatamente correto e tais detalhes são estudados na disciplina de Sistemas Operacional (SO). Apesar disso, neste livro, para simplificarmos as coisas, podemos pensar em um processo como sendo uma aplicação, sem grandes perdas de generalidade, pois a maioria das aplicações em Java roda em apenas um processo. Uma thread é uma linha de execução; ao criarmos uma thread, esta cria também um ambiente de execução, mas compartilha recursos como memória e arquivos abertos. É importante que saiba que cada processo tem, pelo menos, uma thread, mas que é possível criar mais threads dentro desse processo se desejar. Um dos aspectos interessantes das threads é que elas são mais leves do que os processos, e a execução de uma thread pode ser interrompida quantas vezes forem necessárias, continuando, sempre, do ponto em que parou. É uma tarefa do SO, em específico do escalonador de processos, decidir qual thread executará e em qual núcleo do processador.
Inicialmente, vamos mostrar uma simples aplicação em Java que apenas imprime o nome da thread principal que está em execução. Dessa maneira, analise o Código 4.15.
public class SimplesApp {
public static void main(String[] args) {
Thread thFluxo = Thread.currentThread();
System.out.printf("Nome Thread: %s%n", thFluxo.getName());
}
}
Nas linhas 1 e 2 do Código 4.15, temos a declaração da classe e a definição do método principal (main); na linha 3, foi criado um objeto chamado thFluxo, que recebe a thread atual; e na linha 4, é impresso na tela o nome da thread atual em execução.
Bem, até aqui, discutimos o código, mas não vimos o seu comportamento durante o fluxo de execução, assim, imagine que você desenvolveu esse código e clicou no botão para iniciar a sua execução; dessa maneira, a JVM criará um processo para que essa aplicação possa ser executada (toda aplicação está associada a um processo); em seguida, a JVM criará, também, uma thread dentro desse processo, e é importante que se lembre de que cada processo possui pelo menos uma thread, e a partir desse ambiente criado (processo + thread), a aplicação possa ser executada. Dessa forma, essa aplicação imprimirá o nome da thread, que se chama “main”. (Experimente implementar e executar esse código.)
A Figura 4.12 é uma ilustração que nos auxilia a entender as relações possíveis entre processos e threads. Dito isso, analise esta figura:
Na Figura 4.12 (a), temos a relação mais simples possível na construção de uma aplicação em que temos um processo e uma thread que está vinculada a ele. Esse tipo de relação ilustra, por exemplo, o Código 4.15 e todos os outros códigos anteriormente vistos neste livro. A Figura 4.12 (b) nos mostra um processo que possui duas threads vinculadas a ele; já a Figura 4.12 (c) nos mostra, de forma genérica, que um processo pode estar vinculado a várias threads.
A seguir, neste livro, construiremos exemplos de aplicações que funcionam como representado na Figura 4.12 (b) e (c). Na Figura 4.12 (d), temos a relação em que uma aplicação cria vários processos e cada processo possui apenas uma thread; na Figura 4.12 (e), temos uma última relação possível na construção de aplicações em que podemos criar vários processos e cada processo pode possuir várias threads; já as Figuras 4.12 (d) e (e) nos mostram relações mais complexas e não serão tratadas neste livro, pois necessitam de um curso mais avançado de Java.
O exemplo do Código 4.15 foi utilizado apenas para discutirmos as ideias de processos e threads; a partir de agora, vamos nos concentrar apenas nas threads e trabalharmos com apenas o processo principal.
É muito importante sabermos quando utilizar as threads; no geral, elas são utilizadas para:
De forma a ilustrar o primeiro caso de aplicação, vamos considerar a Figura 4.13 mostrada a seguir.
A Figura 4.13 nos mostra o rascunho de código de um programa qualquer em que as reticências na vertical indicam linhas de código que possuem processamento leve e que não queremos destacar; em seguida, nesse código, temos um loop infinito que executa alguma verificação rotineira, porém importante; posteriormente, temos um loop que executa algum cálculo super pesado que demora alguns minutos ou até horas para ser executado; por fim, temos um loop com uma condição que não sabemos quando será satisfeita.
Esse código da Figura 4.13, apesar de ser abstrato, pode representar um código que desejamos implementar em alguma aplicação do mundo real, no entanto, de acordo com os estudos, sabemos que o código nunca sairá do primeiro loop, pois se trata de um loop infinito. As threads, por sua vez, solucionam problemas de travamento de código como esse, logo, basta colocar uma thread para executar cada um dos loops mostrados, e é importante destacarmos que duas ou mais tarefas podem ser executadas ao mesmo tempo, mesmo que haja apenas um processador. Esse tipo de execução é chamado de concorrente, pois a aplicação concorre à utilização do processador, e isso é feito de forma automática pelo sistema operacional, que escalona as tarefas a ocuparem o núcleo do processador.
O Código 4.16 a seguir nos mostra como seria uma implementação da ideia sugerida na Figura 4.13. Por esse código ser simples, não será explicado.
Implemente este código e veja qual será a saída.
public class ProgramaLoopSemThread {
public static void main(String[] args) {
ProgramaLoopSemThread p = new ProgramaLoopSemThread();
p.programa();
}
public void programa() {
System.out.println("inicio");
while (true) {
if (1 % 2 == 2) break;//artimanha p/ compilar o código
System.out.println("loop infinito");
}
System.out.println("passou do primeiro loop");
for (long i = 0; i < 1000000000000l; i++) {
System.out.println("loop super pesado");
}
System.out.println("passou do segundo loop");
boolean condicaoLoop = true;
do {
System.out.println("loop condição complexa");
} while (condicaoLoop);
System.out.println("fim");
}
}
Você deve ter percebido que a aplicação ficou travada no primeiro loop, assim, as linhas 12 a 21 do Código 4.16 nunca serão executadas. Vamos, agora, mostrar como colocar threads nessa aplicação genérica para que os três loops sejam executados concorrentemente ou paralelamente (se tiver mais de um núcleo em seu processador).
Analise o código 4.17 a seguir.
public class ProgramaLoopComThread {
public static void main(String[] args) {
ProgramaLoopComThread p = new ProgramaLoopComThread();
p.programa();
}
public void programa() {
System.out.println("inicio");
Thread thread1 = new Thread(loop1());
thread1.start();
System.out.println("passou do primeiro loop");
Thread thread2 = new Thread(loop2());
thread2.start();
System.out.println("passou do segundo loop");
Thread thread3 = new Thread(loop3());
thread3.start();
System.out.println("fim");
}
public Runnable loop1() {
Runnable run1 = new Runnable() {
@Override
public void run() {
while (true) {
if (1 % 2 == 2) break;//artimanha p/ compilar
System.out.println("loop infinito");
}
}
};
return run1;
}
public Runnable loop2() {
Runnable run2 = new Runnable() {
@Override
public void run() {
for (long i = 0; i < 1000000000000l; i++) {
System.out.println("loop super pesado");
}
}
};
return run2;
}
public Runnable loop3() {
Runnable run3 = new Runnable() {
@Override
public void run() {
boolean condicaoLoop = true;
do {
System.out.println("loop condição complexa");
} while (condicaoLoop);
}
};
return run3;
}
}
Nas linhas 18 a 29 do Código 4.17, colocamos o loop infinito dentro de um objeto do tipo Runnable (na Unidade 3, Seção 2, estudamos um pouco sobre a interface Runnable), que se trata de uma interface que possui um método chamado run, que deve ser obrigatoriamente sobrescrito; de forma geral, colocamos dentro do método run tudo o que queremos paralelizar ou executar de forma concorrente. Na linha 28, por sua vez, retornamos o objeto Runnable criado; de forma semelhante, nas linhas 30 a 40, o loop super pesado foi colocado dentro de um objeto também do tipo Runnable, e o mesmo foi feito nas linhas 41 a 52 com o loop que possui a condição complexa. Já na linha 8, criamos um objeto do tipo Thread que recebe como argumento um objeto Runnable (nesse caso, a primeira thread executará o loop infinito); na linha 9, mandamos a thread para ser executada, de fato, por meio do método start; nas linhas 11 e 12, criamos uma nova thread que executará o loop super pesado; por fim, nas linhas 14 e 15, criamos a nossa última thread, que executará o loop com condição complexa.
Implemente esse código e analise a saída impressa; compare essa saída com a saída do Código 4.16, que não possuía threads; faça diversas execuções e repare que, cada vez que executar o programa, a saída será diferente, pois, a cada vez, o escalonador de processos do SO selecionará as threads de outra forma.
Vamos, agora, criar uma aplicação em que desejamos realizar algum processamento em paralelo. Para isso, vamos criar um simples programa que calcula se um número de entrada é primo ou não; caso seja primo, então, o número será impresso na tela. Analise o Código 4.18 a seguir.
public class Primo implements Runnable {
private final int inicio;
private final int fim;
public Primo(int inicio, int fim) {
this.inicio = inicio;
this.fim = fim;
}
public boolean isPrime(int n) {
if (n < 2) {
return false;
}
for (int i = 2; i < (int)(Math.sqrt(n) + 1); i++) {
if (n % i == 0) {
return false;
}
}
return true;
}
@Override
public void run() {
for (int i = inicio; i < fim; i++) {
boolean ehPrimo = isPrime(i);
if (ehPrimo) {
System.out.println("é primo: " + i);
}
}
}
}
public class AppPrimoThread {
public static void main(String[] args) {
Thread thr1 = new Thread(new Primo(0, 1000000));
thr1.start();
Thread thr2 = new Thread(new Primo(1000001, 2000000));
thr2.start();
Thread thr3 = new Thread(new Primo(2000001, 3000000));
thr3.start();
Thread thr4 = new Thread(new Primo(3000001, 4000000));
thr4.start();
}
}
Na linha 1 do Código 4.18, criamos uma classe chamada Primo, que implementa a interface Runnable (lembre-se de que a ideia é, a partir da implementação dessa interface, paralelizarmos os cálculos dos números primos); nas linhas 2 e 3, definimos duas variáveis para armazenar o número de início e fim dos cálculos de números primos; nas linhas 4 a 7, criamos o construtor da classe com a especificação dos valores de início e de fim; nas linhas 8 a 18, definimos um método que, dado um número de entrada, verifica se este é primo ou não; nas linhas 19 a 27, sobrescrevemos o método run da interface Runnable e colocamos o cálculo que deverá ser paralelizado (neste caso, queremos calcular todos os números primos do intervalo início ao fim e imprimir, na tela, apenas os que forem primos); nas linhas 29 a 40, criamos uma classe para testar a nossa aplicação; na linha 31, foi criado um objeto do tipo Thread que recebe um objeto do tipo Runnable (neste caso, queremos calcular, nessa thread, todos os primos de 0 a 1.000.000); na linha 32, mandamos iniciar essa execução; por fim, o mesmo raciocínio foi posto em prática nas linhas 33 a 38, em que mais três threads foram criadas com novos valores de início e fim do cálculo.
Quando temos uma aplicação semelhante à mostrada no Código 4.18 e queremos acelerar os cálculos, devemos sempre pensar na arquitetura de hardware que executará o programa. Por exemplo: imagine que o processador que executará esse código é um Intel i5 com oito núcleos, assim, o número máximo de threads que faz sentido de se criar é oito. Se quisermos, até podemos criar mais threads, pois elas são entidades lógicas, porém, se fizermos isso, não conseguiremos extrair o máximo do hardware. Lembre-se de que, ao se criar mais threads do que o número de núcleos da CPU, um overhead é gerado nas trocas de contexto das threads. É importante lembrar, também, que as threads ficam concorrendo para fazer uso dos núcleos da CPU e executarem o processamento, dessa maneira, caso o seu computador seja um dual core com dois núcleos, para obter um maior desempenho, remova duas threads do Código 4.18.
No Código 4.18, a classe Primo implementou a interface Runnable e, assim, sobrescreveu o método run. Existe outra forma de se fazer a criação da classe Primo, que é herdando da classe Thread em vez de implementar a interface Runnable, e as duas formas são equivalentes e funcionam perfeitamente. Sugerimos, até mesmo, que você, aluno, após implementar o Código 4.18, faça essa alteração e confirme essa informação.
A seguir, seguem as partes que podem ser alteradas e que obtêm o mesmo efeito na paralelização de código.
Classe Primo implementado Runnable: public class
Primo implements
Runnable
Classe Primo herdando Thread: public class
Primo extends
Thread
Abaixo, listamos alguns métodos estáticos e não estáticos importantes da classe Thread:
Caro aluno, procure criar aplicações simples, que utilizem os métodos acima da classe Thread, para ver, na prática, a sua utilização.
Um método muito importante das threads é o sleep, que faz com que a thread atual durma por um determinado tempo. Esse método recebe como argumento um valor em milissegundos, que indica quanto tempo a thread deverá dormir. De forma a ilustrar esse método, analise o Código 4.19.
System.out.println("inicio");
try {
System.out.println("inicio sleep");
Thread.sleep(5000); //valor em milissegundos
System.out.println("fim sleep");
} catch (InterruptedException ex) {
System.out.println(ex);
}
System.out.println("fim");
No código acima, podemos reparar que o método sleep pode lançar uma exceção do tipo InterruptedException, e isso se dá quando a thread que está dormindo é interrompida por meio do método interrupted.
Frente a isso, sugerimos que você, aluno, tente criar uma aplicação simples e que force a interrupção da thread enquanto ela estiver dormindo para ver essa exceção sendo lançada.
As threads, ao serem criadas, podem assumir qualquer um dos seguintes estados:
A Figura 4.14 nos mostra o fluxo seguido do ciclo de vida dos estados de uma thread dentro da linguagem Java. Inicialmente, quando a thread é criada, ela se encontra no estado NEW, em seguida, com o comando start, a thread muda para o estado RUNNABLE, e ela pode permanecer nesse estado até terminar, indo, então, para TERMINATED. A thread pode também ir do estado RUNNABLE para TIMED_WAITING com o comando sleep, e quando o intervalo de tempo expirar, ela voltará para RUNNABLE, bem como pode ir do estado RUNNABLE para WAITING se usado o comando wait, e uma vez utilizado o comando notifiy, voltar ao estado RUNNABLE. O estado BLOCKED também pode ser atingido a partir de RUNNABLE por meio de solicitações de Entrada e Saído (E/S) ou quando outra thread estiver utilizando o recurso (syncronized), e quando essa solicitação terminar, o estado RUNNABLE retornará.
A Figura 4.14 nos mostra os ciclos de vida de uma thread a partir do momento de sua criação até o seu término. Diante disso, gostaríamos de convidá-lo a refletir sobre cada uma dessas transições de estados do ciclo de vida; utilize essa figura e tente descrever quais foram os estados ocupados pelas threads criadas nos códigos 4.17 e 4.18. Neles, teve algum estado não atingido? Se sim, quais? Reflita também sobre o porquê desses estados não terem sido atingidos.
Caro estudante, nesta seção você estudou os conteúdos relacionados à criação de threads; foram dados alguns exemplos de como as threads são criadas e como auxiliam na construção de aplicações concorrentes e paralelas na linguagem Java. Frente a isso, lembre-se de que todos os códigos aqui mostrados podem ser acessados no GitHub do autor.
ARANTES, J. da S. Livro-POO-Java. 2020. Disponível em: https://bit.ly/3eiUMcF. Acesso em: 8 set. 2020.
DEITEL, P. J.; DEITEL, H. M. Java: como programar. 10. ed. São Paulo: Pearson Education, 2016.
LOIANE GRONER. Curso de Java 67: criando threads + métodos start, run e sleep. 2016. Disponível em: https://bit.ly/2HaVs9f. Acesso em: 8 set. 2020.
LOIANE GRONER. Curso de Java 68: threads: interface runnable. 2016. Disponível em: https://bit.ly/32Ghd9m. Acesso em: 8 set. 2020.
LOIANE GRONER. Curso de Java 69: criando várias threads + métodos isAlive e join. Disponível em: https://bit.ly/3iIR1k5. 2016. Acesso em: 8 set. 2020.
ORACLE. Enum Thread State. [s.d.]. Disponível em: https://bit.ly/32IZiio. Acesso em: 8 set. 2020.
ORACLE. Thread. [s.d.]. Disponível em: https://bit.ly/3mDcVYq. Acesso em: 8 set. 2020.