Perguntas da entrevista sobre simultaneidade Java (+ Respostas)
1. Introdução
A simultaneidade em Java é um dos tópicos mais complexos e avançados apresentados durante entrevistas técnicas. Este artigo fornece respostas para algumas das perguntas da entrevista sobre o tópico que você pode encontrar.
Q1. Qual é a diferença entre um processo e um thread?
Os processos e os threads são unidades de simultaneidade, mas eles têm uma diferença fundamental: os processos não compartilham uma memória comum, enquanto os threads fazem.
Do ponto de vista do sistema operacional, um processo é uma peça independente de software que roda em seu próprio espaço de memória virtual. Qualquer sistema operacional multitarefa (o que significa quase qualquer sistema operacional moderno) tem que separar processos na memória para que um processo com falha não arraste todos os outros processos para baixo embaralhando a memória comum.
Os processos são, portanto, geralmente isolados e cooperam pelos meios de comunicação entre processos, definidos pelo sistema operacional como um tipo de API intermediária.
Pelo contrário, um encadeamento é parte de um aplicativo que compartilha uma memória comum com outros encadeamentos do mesmo aplicativo. O uso de memória comum permite reduzir a sobrecarga, projetar os threads para cooperar e trocar dados entre eles muito mais rapidamente.
Q2. Como você pode criar uma instância de thread e executá-la?
Para criar uma instância de um encadeamento, você tem duas opções. Primeiro, passe uma instânciaRunnable para seu construtor e chamestart(). Runnable é uma interface funcional, portanto, pode ser passada como uma expressão lambda:
Thread thread1 = new Thread(() ->
System.out.println("Hello World from Runnable!"));
thread1.start();
Thread também implementaRunnable, então outra maneira de iniciar um thread é criar uma subclasse anônima, sobrescrever seu métodorun() e então chamarstart():
Thread thread2 = new Thread() {
@Override
public void run() {
System.out.println("Hello World from subclass!");
}
};
thread2.start();
Q3. Descreva os diferentes estados de um thread e quando as transições de estado ocorrem.
O estado de aThread pode ser verificado usando o métodoThread.getState(). Diferentes estados de aThread são descritos na enumThread.State. Eles são:
-
NEW - uma nova instânciaThread que ainda não foi iniciada porThread.start()
-
RUNNABLE - um tópico em execução. É chamado de executável porque, a qualquer momento, pode estar em execução ou aguardar o próximo quantum de tempo do planejador de encadeamentos. Um encadeamentoNEW entra no estadoRUNNABLE quando você chamaThread.start() nele
-
BLOCKED - um thread em execução fica bloqueado se precisar entrar em uma seção sincronizada, mas não pode fazer isso devido a outro thread segurando o monitor desta seção
-
WAITING - um thread entra neste estado se esperar que outro thread execute uma ação específica. Por exemplo, um thread entra neste estado ao chamar o métodoObject.wait() em um monitor que ele mantém, ou o métodoThread.join() em outro thread
-
TIMED_WAITING - igual ao anterior, mas uma thread entra neste estado após chamar as versões cronometradas deThread.sleep(),Object.wait(),Thread.join()e alguns outros métodos
-
TERMINATED - uma thread concluiu a execução de seu métodoRunnable.run() e terminou
Q4. Qual é a diferença entre as interfaces executáveis e chamadas? Como eles são usados?
A interfaceRunnable tem um único métodorun. Representa uma unidade de computação que precisa ser executada em um encadeamento separado. A interfaceRunnable não permite que este método retorne valor ou lance exceções não verificadas.
A interfaceCallable possui um único métodocall e representa uma tarefa que possui um valor. É por isso que o métodocall retorna um valor. Também pode gerar exceções. Callable geralmente é usado em instâncias deExecutorService para iniciar uma tarefa assíncrona e, em seguida, chamar a instânciaFuture retornada para obter seu valor.
Q5. O que é um thread daemon, quais são seus casos de uso? Como você pode criar um thread daemon?
Um encadeamento daemon é um encadeamento que não impede a saída da JVM. Quando todos os encadeamentos não daemon são encerrados, a JVM simplesmente abandona todos os encadeamentos restantes do daemon. Os encadeamentos daemon geralmente são usados para executar algumas tarefas de suporte ou serviço para outros encadeamentos, mas você deve levar em consideração que eles podem ser abandonados a qualquer momento.
Para iniciar um thread como um daemon, você deve usar o métodosetDaemon() antes de chamarstart():
Thread daemon = new Thread(()
-> System.out.println("Hello from daemon!"));
daemon.setDaemon(true);
daemon.start();
Curiosamente, se você executar isso como parte do métodomain(), a mensagem pode não ser impressa. Isso poderia acontecer se o encadeamentomain() fosse encerrado antes que o daemon chegasse ao ponto de imprimir a mensagem. Você geralmente não deve fazer I / O em threads daemon, pois eles não serão capazes de executar seus blocosfinallye fechar os recursos se abandonados.
Q6. Qual é o sinalizador de interrupção do segmento? Como você pode definir e verificar? Como isso se relaciona com a exceção interrompida?
O sinalizador de interrupção, ou status de interrupção, é um sinalizadorThread interno que é definido quando o thread é interrompido. Para defini-lo, basta chamarthread.interrupt() no objeto de thread.
Se uma thread estiver atualmente dentro de um dos métodos que lançamInterruptedException (wait,join,sleep etc.), esse método imediatamente gera InterruptedException. O encadeamento é livre para processar essa exceção de acordo com sua própria lógica.
Se uma thread não estiver dentro desse método ethread.interrupt() for chamado, nada de especial acontecerá. É responsabilidade do thread verificar periodicamente o status de interrupção usando o métodostatic Thread.interrupted() ou o método de instânciaisInterrupted(). A diferença entre esses métodos é questatic Thread.interrupt() limpa o sinalizador de interrupção, enquantoisInterrupted() não.
Q7. O que são executores e executores? Quais são as diferenças entre essas interfaces?
Executor eExecutorService são duas interfaces relacionadas da estruturajava.util.concurrent. Executor é uma interface muito simples com um único métodoexecute aceitandoRunnable instâncias para execução. Na maioria dos casos, é nessa interface que o código de execução da tarefa deve depender.
ExecutorService estende a interfaceExecutor com vários métodos para lidar e verificar o ciclo de vida de um serviço de execução de tarefa simultânea (término de tarefas em caso de desligamento) e métodos para manipulação de tarefas assíncronas mais complexas, incluindoFutures.
Para obter mais informações sobre como usarExecutor eExecutorService, consulte o artigoA Guide to Java ExecutorService.
Q8. Quais são as implementações disponíveis do Executorservice na biblioteca padrão?
A interfaceExecutorService tem três implementações padrão:
-
ThreadPoolExecutor - para executar tarefas usando um pool de threads. Depois que um thread termina de executar a tarefa, ele volta ao pool. Se todos os encadeamentos no pool estiverem ocupados, a tarefa deverá aguardar sua vez.
-
ScheduledThreadPoolExecutor permite agendar a execução da tarefa em vez de executá-la imediatamente quando um thread estiver disponível. Também pode agendar tarefas com taxa fixa ou atraso fixo.
-
ForkJoinPool é umExecutorService especial para lidar com tarefas de algoritmos recursivos. Se você usar umThreadPoolExecutor regular para um algoritmo recursivo, descobrirá rapidamente que todas as suas threads estão ocupadas esperando que os níveis mais baixos de recursão terminem. OForkJoinPool implementa o chamado algoritmo de roubo de trabalho que permite usar threads disponíveis com mais eficiência.
Q9. O que é o Java Memory Model (Jmm)? Descreva seu propósito e idéias básicas.
O modelo de memória Java é uma parte da especificação da linguagem Java descrita emChapter 17.4. Ele especifica como vários threads acessam a memória comum em um aplicativo Java simultâneo e como as alterações de dados em um thread são tornadas visíveis para outros threads. Embora seja bastante curto e conciso, o JMM pode ser difícil de entender sem um forte fundo matemático.
A necessidade de modelo de memória decorre do fato de que a maneira como seu código Java está acessando dados não é o que realmente acontece nos níveis mais baixos. As gravações e leituras de memória podem ser reordenadas ou otimizadas pelo compilador Java, pelo compilador JIT e até pela CPU, desde que o resultado observável dessas leituras e gravações seja o mesmo.
Isso pode levar a resultados contra-intuitivos quando o aplicativo é dimensionado para vários encadeamentos, porque a maioria dessas otimizações leva em consideração um único encadeamento de execução (os otimizadores de encadeamento ainda são extremamente difíceis de implementar). Outro grande problema é que a memória nos sistemas modernos é multicamada: vários núcleos de um processador podem manter alguns dados não liberados em seus caches ou buffers de leitura / gravação, o que também afeta o estado da memória observada em outros núcleos.
Para piorar as coisas, a existência de diferentes arquiteturas de acesso à memória quebraria a promessa do Java de "escrever uma vez, executar em qualquer lugar". Felizmente para os programadores, o JMM especifica algumas garantias nas quais você pode confiar ao projetar aplicativos multithread. A adesão a essas garantias ajuda o programador a escrever código multithread que é estável e portátil entre várias arquiteturas.
As principais noções do JMM são:
-
Actions, são ações entre threads que podem ser executadas por uma thread e detectadas por outra thread, como ler ou escrever variáveis, bloquear / desbloquear monitores e assim por diante
-
Synchronization actions, um certo subconjunto de ações, como ler / gravar uma variávelvolatile ou bloquear / desbloquear um monitor
-
Program Order (PO), a ordem total observável de ações dentro de um único thread
-
Synchronization Order (SO), a ordem total entre todas as ações de sincronização - tem que ser consistente com a Ordem do Programa, ou seja, se duas ações de sincronização vêm uma antes da outra no PO, elas ocorrem na mesma ordem no SO
-
Relaçãosynchronizes-with (SW) entre certas ações de sincronização, como desbloqueio do monitor e bloqueio do mesmo monitor (em outro ou no mesmo thread)
-
Happens-before Order - combina PO com SW (isso é chamado detransitive closure na teoria dos conjuntos) para criar uma ordem parcial de todas as ações entre threads. Se uma açãohappens-beforefor outra, então os resultados da primeira ação são observáveis pela segunda ação (por exemplo, escrever de uma variável em um segmento e ler em outro)
-
Happens-before consistency - um conjunto de ações é consistente com HB se cada leitura observar a última gravação naquele local na ordem acontece antes ou alguma outra gravação por corrida de dados
-
Execution - um certo conjunto de ações ordenadas e regras de consistência entre elas
Para um determinado programa, podemos observar várias execuções diferentes com vários resultados. Mas se um programa écorrectly synchronized, então todas as suas execuções parecem sersequentially consistent, o que significa que você pode raciocinar sobre o programa multithread como um conjunto de ações ocorrendo em alguma ordem sequencial. Isso evita que você pense em reordenamentos, otimizações ou cache de dados ocultos.
Q10. O que é um campo volátil e quais as garantias que o Jmm oferece para esse campo?
Um campovolatile tem propriedades especiais de acordo com o modelo de memória Java (consulte Q9). As leituras e gravações de uma variávelvolatile são ações de sincronização, o que significa que elas têm uma ordem total (todos os threads observarão uma ordem consistente dessas ações). A leitura de uma variável volátil é garantida para observar a última gravação nessa variável, de acordo com esta ordem.
Se você tem um campo que é acessado de vários tópicos, com pelo menos um tópico escrevendo nele, então você deve considerar torná-lovolatile, ou então há uma pequena garantia do que um determinado tópico leria neste campo .
Outra garantia paravolatile é a atomicidade de escrita e leitura de valores de 64 bits (longedouble). Sem um modificador volátil, uma leitura desse campo poderia observar um valor parcialmente escrito por outro encadeamento.
Q11. Quais das seguintes operações são atômicas?
-
escrever para um não -volatileint;
-
escrever para avolatile int;
-
escrever para um não -volatile long;
-
escrever para avolatile long;
-
incrementando avolatile long?
Uma gravação em uma variávelint (32 bits) tem garantia de ser atômica, sejavolatile ou não. Uma variávellong (64 bits) pode ser escrita em duas etapas separadas, por exemplo, em arquiteturas de 32 bits, portanto, por padrão, não há garantia de atomicidade. No entanto, se você especificar o modificadorvolatile, é garantido que uma variávellong será acessada atomicamente.
A operação de incremento geralmente é feita em várias etapas (recuperar um valor, alterá-lo e escrever de volta), portanto, nunca é garantido que seja atômico, seja a variávelvolatile ou não. Se você precisa implementar o incremento atômico de um valor, você deve usar as classesAtomicInteger,AtomicLong etc.
Q12. Que garantias especiais o Jmm oferece para os campos finais de uma aula?
A JVM basicamente garante que os camposfinal de uma classe serão inicializados antes que qualquer thread se apegue ao objeto. Sem essa garantia, uma referência a um objeto pode ser publicada, ou seja, fique visível para outro encadeamento antes que todos os campos desse objeto sejam inicializados, devido a novos pedidos ou outras otimizações. Isso pode causar acesso agressivo a esses campos.
É por isso que, ao criar um objeto imutável, você deve sempre fazer todos os seus camposfinal, mesmo que não sejam acessíveis através dos métodos getter.
Q13. Qual é o significado de uma palavra-chave sincronizada na definição de um método? de um método estático? Antes de um bloco?
A palavra-chavesynchronized antes de um bloco significa que qualquer thread que entra neste bloco deve adquirir o monitor (o objeto entre colchetes). Se o monitor já foi adquirido por outro encadeamento, o encadeamento anterior entrará no estadoBLOCKED e aguardará até que o monitor seja liberado.
synchronized(object) {
// ...
}
Um método de instânciasynchronized tem a mesma semântica, mas a própria instância atua como um monitor.
synchronized void instanceMethod() {
// ...
}
Para um métodostatic synchronized, o monitor é o objetoClass que representa a classe declarante.
static synchronized void staticMethod() {
// ...
}
Q14. Se dois segmentos chamam um método sincronizado em diferentes instâncias de objeto simultaneamente, um desses segmentos pode bloquear? E se o método for estático?
Se o método for um método de instância, a instância atuará como um monitor para o método. Dois threads que chamam o método em instâncias diferentes adquirem monitores diferentes, portanto, nenhum deles é bloqueado.
Se o método forstatic, então o monitor é o objetoClass. Para ambos os threads, o monitor é o mesmo, então um deles provavelmente irá bloquear e esperar que outro saia do métodosynchronized.
Q15. Qual é o objetivo dos métodos Wait, Notify e Notifyall da classe de objeto?
Um thread que possui o monitor do objeto (por exemplo, um thread que entrou em uma seçãosynchronized protegida pelo objeto) pode chamarobject.wait() para liberar temporariamente o monitor e dar a outros threads a chance de adquirir o monitor . Isso pode ser feito, por exemplo, para aguardar uma determinada condição.
Quando outro thread que adquiriu o monitor cumpre a condição, ele pode chamarobject.notify() ouobject.notifyAll()e liberar o monitor. O métodonotify desperta um único encadeamento no estado de espera e o métodonotifyAll desperta todos os encadeamentos que aguardam esse monitor e todos competem para readquirir o bloqueio.
A implementação deBlockingQueue a seguir mostra como vários threads trabalham juntos por meio do padrãowait-notify. Seput um elemento em uma fila vazia, todos os threads que estavam esperando no métodotake despertam e tentam receber o valor. Seput um elemento em uma fila cheia, o métodoputwaits para a chamada ao métodoget. O métodoget remove um elemento e notifica os threads que aguardam no métodoput que a fila tem um lugar vazio para um novo item.
public class BlockingQueue {
private List queue = new LinkedList();
private int limit = 10;
public synchronized void put(T item) {
while (queue.size() == limit) {
try {
wait();
} catch (InterruptedException e) {}
}
if (queue.isEmpty()) {
notifyAll();
}
queue.add(item);
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {}
}
if (queue.size() == limit) {
notifyAll();
}
return queue.remove(0);
}
}
Q16. Descreva as condições de deadlock, livelock e inanição. Descreva as possíveis causas dessas condições.
Deadlock é uma condição dentro de um grupo de threads que não pode progredir porque cada thread no grupo deve adquirir algum recurso que já foi adquirido por outro thread no grupo. O caso mais simples é quando dois threads precisam bloquear os dois recursos para progredir, o primeiro recurso já está bloqueado por um thread e o segundo por outro. Esses encadeamentos nunca adquirem um bloqueio para os dois recursos e, portanto, nunca progridem.
Livelock é um caso de várias threads reagindo a condições ou eventos gerados por si mesmas. Um evento ocorre em um segmento e deve ser processado por outro segmento. Durante esse processamento, ocorre um novo evento que deve ser processado no primeiro thread e assim por diante. Tais threads estão vivos e não estão bloqueados, mas ainda assim, não progridem porque se sobrecarregam com trabalho inútil.
Starvation é um caso de thread incapaz de adquirir recursos porque outro thread (ou threads) o ocupa por muito tempo ou tem prioridade mais alta. Um encadeamento não pode progredir e, portanto, é incapaz de realizar um trabalho útil.
Q17. Descreva o objetivo e os casos de uso da estrutura Fork / Join.
A estrutura fork / join permite algoritmos recursivos paralelos. O principal problema de paralelizar a recursão usando algo comoThreadPoolExecutor é que você pode rapidamente ficar sem threads porque cada etapa recursiva exigiria sua própria thread, enquanto as threads na pilha estariam ociosas e esperando.
O ponto de entrada da estrutura fork / join é a classeForkJoinPool, que é uma implementação deExecutorService. Ele implementa o algoritmo de roubo de trabalho, em que threads ociosos tentam "roubar" o trabalho de threads ocupados. Isso permite espalhar os cálculos entre diferentes segmentos e progredir usando menos threads do que seria necessário em um pool de threads usual.
Mais informações e exemplos de código para a estrutura fork / join podem ser encontrados no artigo“Guide to the Fork/Join Framework in Java”.