Concorrência, I/O e Swing
Coordenar threads, ler e escrever dados e responder a eventos numa interface Java.
Ler um ficheiro grande no listener de um botão impede a interface de responder. Uma thread de trabalho permite continuar a tratar eventos, mas o acesso de duas threads à mesma pontuação exige coordenação. Se ambas leem e escrevem o mesmo valor sem sincronização, uma atualização pode perder-se.
Começar e esperar por uma thread
Uma thread é um fluxo de execução dentro de um processo. Threads do mesmo processo podem partilhar objetos, mas têm pilhas de chamadas próprias. Concorrência significa que as tarefas podem avançar em períodos sobrepostos; não implica que corram em paralelo em todos os instantes.
Runnable descreve uma tarefa. new Thread(tarefa).start() cria uma execução concorrente e a JVM chama run. Chamar run() diretamente executa um método normal na thread atual. join() espera que a thread termine; não a inicia.
Este programa completo é para executar localmente com javac Main.java e java Main:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread trabalho = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("trabalho terminado");
}
});
trabalho.start();
trabalho.join();
System.out.println("resultado recolhido");
}
}
As duas linhas surgem nessa ordem porque join termina depois da thread. Sem join, não poderíamos garantir a ordem entre a impressão do trabalho e a do método main; um sleep não prova que outra tarefa acabou.
Uma operação composta pode perder alterações
contador++ contém leitura, soma e escrita. Se duas threads leem zero antes de qualquer uma escrever, ambas podem escrever um. Duas operações produziram apenas um incremento.
| Passo | Thread A | Thread B |
|---|---|---|
| 1 | Lê 0 | |
| 2 | Lê 0 | |
| 3 | Calcula 1 e escreve 1 | |
| 4 | Calcula 1 e escreve 1 |
Uma race condition faz o resultado depender da intercalação. Uma data race ocorre quando acessos conflituosos ao mesmo estado, pelo menos um de escrita, não estão coordenados pela sincronização exigida. Não basta observar uma execução correta para concluir que não existe o problema.
Proteger estado com o mesmo lock
Um bloco synchronized adquire o monitor de um objeto. Só uma thread de cada vez entra em secções protegidas pelo mesmo monitor. Sair do monitor e voltar a adquiri-lo noutra thread também estabelece visibilidade das alterações segundo as regras de memória de Java.
Um método de instância synchronized usa this. Um método estático synchronized usa o objeto Class dessa classe. Dois objetos Contador diferentes não partilham automaticamente o mesmo lock.
Guarda este programa completo em Main.java e corre-o localmente:
class Contador {
private int valor;
synchronized void incrementar() { valor++; }
synchronized int ler() { return valor; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
final Contador contador = new Contador();
Runnable tarefa = new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) contador.incrementar();
}
};
Thread a = new Thread(tarefa);
Thread b = new Thread(tarefa);
a.start();
b.start();
a.join();
b.join();
System.out.println(contador.ler());
}
}
A saída é 2000: cada incremento termina antes de outro entrar no mesmo contador. Todos os acessos concorrentes a esse estado têm de respeitar a mesma disciplina. volatile ajuda a publicar leituras e escritas de uma variável, mas não transforma valor++ numa operação atómica. Para um contador isolado, AtomicInteger.incrementAndGet() é outra opção.
Esperar por uma condição
Para esperar por uma condição sem largar a proteção do monitor de forma incoerente, wait() liberta o monitor e suspende a thread. Antes de retornar, volta a adquirir o monitor. notifyAll() acorda quem espera nesse objeto, mas a thread que notifica continua com o monitor até sair da secção.
A condição deve ser verificada num while. Quando a thread acorda, ainda precisa de readquirir o monitor antes de continuar, e outra thread pode chegar primeiro e mudar o estado outra vez. No exemplo dos dois consumidores, o segundo acorda para um valor que o primeiro já retirou. Há ainda despertares sem notificação, os chamados despertares espúrios, que a plataforma permite. O while volta a testar a condição com o monitor detido, nos dois casos. Chamamos estes métodos enquanto detemos o monitor correspondente.
Este excerto define uma caixa de um elemento; as chamadas seriam feitas por threads produtoras e consumidoras:
class Caixa {
private Integer valor;
synchronized void colocar(int novo) throws InterruptedException {
while (valor != null) wait();
valor = novo;
notifyAll();
}
synchronized int retirar() throws InterruptedException {
while (valor == null) wait();
int resultado = valor;
valor = null;
notifyAll();
return resultado;
}
}
A caixa vazia tem valor == null. O produtor espera enquanto está cheia; o consumidor espera enquanto está vazia. Mudar o estado e notificar sob o mesmo monitor impede perder a coordenação entre o teste da condição e a espera. Numa aplicação, uma BlockingQueue costuma fornecer este comportamento sem implementares os monitores à mão.
Percorre uma execução com duas threads. O produtor coloca 1 e 2; o consumidor retira dois valores:
- Produtor adquire o monitor, vê
valor == null, guarda 1, notifica e liberta. - Consumidor adquire, vê
valor == 1, retira, põenull, notifica e liberta. - Produtor volta a adquirir, vê
null, guarda 2, notifica. - Consumidor adquire, retira 2.
Outra execução expõe por que a notificação não reserva o valor:
| Ação sob o monitor | Caixa depois | Consequência |
|---|---|---|
C1 e C2 testam a caixa vazia e chamam wait() | Vazia | Ambos libertam o monitor e esperam. |
| Produtor guarda 1 e notifica todos | 1 | C1 e C2 podem tentar readquirir o monitor. |
| C1 readquire o monitor e retira 1 | Vazia | C2 ainda não entrou. |
C2 readquire o monitor e volta ao while | Vazia | Espera novamente, sem ler null. |
Com if, C2 continuaria depois de wait() sem testar de novo. Na caixa apresentada, tentaria converter o Integer nulo para int, lançando NullPointerException.
Se o consumidor chegar primeiro com a caixa vazia, entra no while, chama wait() e suspende, libertando o monitor; quando o produtor notifica, retoma, volta a adquirir o monitor e testa de novo a condição antes de ler.
Interrupção e deadlock
interrupt() pede a uma thread que interrompa o trabalho. Não a termina à força. O código consulta o sinal ou reage a InterruptedException em operações como sleep, wait e join. Se capturas a exceção e não a propagas, normalmente restaura o sinal com Thread.currentThread().interrupt() e termina a tarefa de forma coerente.
Um deadlock aparece, por exemplo, quando A detém o lock 1 e espera pelo 2, enquanto B detém o 2 e espera pelo 1. Adquirir vários locks sempre pela mesma ordem evita este ciclo nesse cenário. Mantém as secções curtas e evita operações externas ou bloqueantes enquanto deténs locks.
Bytes, texto e buffers
InputStream e OutputStream transportam bytes. Reader e Writer transportam texto. Um InputStreamReader descodifica bytes segundo uma codificação; não assumes que a codificação predefinida é igual em todas as máquinas.
BufferedReader acrescenta um buffer e permite ler linhas. Streams binárias como DataInputStream leem tipos primitivos segundo um formato próprio; esse formato não é o texto decimal de um inteiro.
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class Main {
public static void main(String[] args) throws IOException {
byte[] dados = "Ana:3\nRita:5\n".getBytes(StandardCharsets.UTF_8);
int total = 0;
try (BufferedReader leitor = new BufferedReader(
new InputStreamReader(new ByteArrayInputStream(dados),
StandardCharsets.UTF_8))) {
String linha;
while ((linha = leitor.readLine()) != null) {
String[] partes = linha.split(":");
total += Integer.parseInt(partes[1]);
}
}
System.out.println(total);
}
}Executa o código para ver a saída e os gráficos.
Obtemos 8. A origem é memória, para poderes executar o exemplo no navegador. readLine() devolve null no fim, não uma string vazia. O exemplo pressupõe linhas válidas no formato nome:inteiro; um importador real deve tratar linhas incompletas e números inválidos.
O try-with-resources fecha o recurso também se ocorrer uma exceção. Fechar o BufferedReader fecha o leitor e a stream que envolve. A ordem de construção segue os dados, bytes, descodificação, buffer e leitura por linha.
Ficheiros e recursos da aplicação
Este programa completo escreve e lê um ficheiro local. Guarda-o em Main.java e corre javac Main.java seguido de java Main na pasta onde queres criar pontos.txt.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class Main {
public static void main(String[] args) throws IOException {
Path caminho = Paths.get("pontos.txt");
try (BufferedWriter escritor = Files.newBufferedWriter(
caminho, StandardCharsets.UTF_8)) {
escritor.write("120");
escritor.newLine();
}
try (BufferedReader leitor = Files.newBufferedReader(
caminho, StandardCharsets.UTF_8)) {
System.out.println(leitor.readLine());
}
}
}
A saída é 120. O programa cria ou substitui pontos.txt na pasta atual. Um recurso empacotado em src/main/resources tem outro ciclo de vida: faz parte da aplicação e pode estar dentro de um JAR. Usa getResourceAsStream para o ler, em vez de assumir que existe como ficheiro normal no disco. Verifica a possibilidade de a stream ser null quando o recurso não existe.
Swing e eventos
Em Swing, JFrame é uma janela, JPanel agrupa componentes e JButton emite eventos. Um layout manager coloca os componentes de acordo com regras. Evita coordenadas absolutas para a organização da interface, porque tamanhos de letra e janelas variam.
A Event Dispatch Thread, EDT, processa eventos e atualiza a interface. Cria e altera a interface nessa thread. Um listener que demora bloqueia os outros eventos e o redesenho. Trabalho longo corre num SwingWorker, que separa três momentos. Segue o percurso EDT para worker para EDT:
- O listener, na EDT, cria o worker, chama
execute()e termina logo, libertando a interface. doInBackground()corre numa thread de trabalho: lê o ficheiro e devolve uma string. Nunca toca em componentes Swing aqui.done()corre de novo na EDT: chamaget()para obter o resultado, trata exceções da leitura e atualiza oJLabel.
O excerto seguinte mostra só o listener; somar, valor e lerFicheiroGrande() pertencem ao programa da interface.
somar.addActionListener(evento -> {
SwingWorker<String, Void> tarefa = new SwingWorker<String, Void>() {
@Override
protected String doInBackground() throws Exception {
return lerFicheiroGrande();
}
@Override
protected void done() {
try {
valor.setText(get());
} catch (Exception ex) {
valor.setText("falha a ler");
}
}
};
tarefa.execute();
});
Chamar get() no listener enquanto a tarefa decorre voltaria a bloquear a EDT. Envolver tudo em invokeLater sem worker só adia o mesmo bloqueio.
Este programa completo requer um ambiente gráfico local, não o editor do navegador:
import java.awt.BorderLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
public class Main {
public static void main(String[] args) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
JFrame janela = new JFrame("Pontuacao");
final int[] pontos = {0};
final JLabel valor = new JLabel("0");
JButton somar = new JButton("Somar ponto");
somar.addActionListener(evento -> {
pontos[0]++;
valor.setText(Integer.toString(pontos[0]));
});
JPanel painel = new JPanel(new BorderLayout(12, 12));
painel.add(valor, BorderLayout.CENTER);
painel.add(somar, BorderLayout.SOUTH);
janela.setContentPane(painel);
janela.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
janela.pack();
janela.setLocationRelativeTo(null);
janela.setVisible(true);
}
});
}
}
Cada clique acrescenta um ponto. O array de um elemento serve apenas para guardar o estado deste exemplo curto; no MVC, colocaremos o estado num modelo próprio. O listener e a criação da janela correm na EDT, por isso este estado não é partilhado com outra thread.
Para desenho personalizado, redefine paintComponent(Graphics g) e chama super.paintComponent(g). Usa o Graphics recebido, desenha a partir do estado e pede uma atualização com repaint(). Não uses uma chamada direta a getGraphics() como forma de manter um desenho: o próximo redesenho pode apagá-lo.
Exercícios
Dois locks para o mesmo contador
Duas threads incrementam o mesmo campo. Uma sincroniza no objeto A e outra no objeto B, com A e B distintos. O incremento está protegido da concorrência entre elas?
Pista
Mais uma pista
Solução
Não. A disciplina tem de proteger todos os acessos ao campo com o mesmo monitor, ou usar uma operação atómica adequada. Dois synchronized em objetos diferentes não criam essa exclusão.
- Sim, qualquer synchronized impede todas as threads de entrar.. A exclusão só se aplica ao mesmo monitor.
- Não, cada monitor permite que uma thread entre ao mesmo tempo.. As threads não disputam o mesmo lock.
- Sim, desde que ambas usem volatile.. Volatile não torna a operação composta ++ atómica.
Erros frequentes
Ler e fechar uma stream de texto
Recebes bytes UTF-8 de um ficheiro. Indica como ler linhas e garantir o fecho também quando uma linha tem um número inválido.
Pista
Mais uma pista
Um BufferedReader pode envolver o
InputStreamReader e ser o recurso do try.
Solução
Constrói
InputStreamReader(stream, StandardCharsets.UTF_8),
envolve num
BufferedReader e coloca-o num try-with-resources.
readLine devolve null no fim. Um
NumberFormatException é um erro do conteúdo e o recurso
fecha na mesma ao sair do try.
Confere a tua resposta:
- Usa InputStreamReader com UTF-8.
- Envolve num BufferedReader para ler linhas.
- Usa try-with-resources e distingue erro de formato de fim da stream.
Erros frequentes
Uma linha vazia não é o fim da stream. Não ignores uma exceção e continues com dados incompletos.
Acordar não reserva o valor
Dois consumidores esperam numa caixa vazia. O produtor coloca um único valor
e chama notifyAll(). O primeiro consumidor retoma e retira-o; só depois o
segundo readquire o monitor. Explica por que while (valor == null) wait()
está correto e trocar while por if está errado neste cenário.
Pista
Inspeciona o estado quando o segundo consumidor entra, não quando foi notificado.
Mais uma pista
Com if, o segundo consumidor continua depois de wait sem voltar a testar valor.
Solução
Percorre o cenário: produtor coloca um valor e notifica; o primeiro
consumidor retoma primeiro, adquire o monitor e retira. Com while, o
segundo consumidor vê novamente valor == null e volta a esperar. Com if,
passa diretamente à leitura, embora o primeiro já tenha retirado o valor. Na
caixa da lição, converter esse Integer nulo para int lança
NullPointerException. A notificação permite tentar retomar; não entrega um
valor a cada thread nem substitui o teste da condição.
Confere a tua resposta:
- Reconhece que o segundo consumidor readquire o monitor com a caixa vazia.
- Volta a verificar a condição e espera por outro valor.
- Distingue notificação de reserva do recurso.
Erros frequentes
synchronized impede acesso simultâneo, mas não garante que a condição
continua verdadeira quando outra thread readquire o monitor.
Um listener que lê um ficheiro grande
Um listener de um botão lê um ficheiro grande durante cinco segundos e só
depois atualiza um JLabel. Durante a leitura, os botões e o redesenho
deixam de responder. Explica o bloqueio e distribui a leitura e a
atualização entre SwingWorker.doInBackground() e done().
Pista
Mais uma pista
Executar uma tarefa em segundo plano e atualizar a interface têm requisitos diferentes.
Solução
O listener ocupa a EDT durante cinco segundos, por isso os eventos seguintes
esperam. O listener inicia o SwingWorker com execute() e termina.
doInBackground() lê os dados numa thread de trabalho e devolve um
resultado. done() corre na EDT, obtém o resultado e atualiza o JLabel.
Trata a falha da tarefa para apresentar um erro em vez de um resultado
inventado. Chamar get() no listener enquanto a tarefa decorre voltaria a
bloquear a EDT.
Confere a tua resposta:
- Identifica que o listener ocupa a EDT.
- Executa a leitura em doInBackground sem alterar componentes Swing.
- Atualiza o JLabel em done na EDT e trata uma falha da leitura.
Erros frequentes
invokeLater com toda a leitura só adia o bloqueio para a EDT. Uma thread
de trabalho também não deve alterar o JLabel diretamente.