Explicação detalhada da programação simultânea Java

No artigo anterior, a combinação de conceitos relacionados a multithreading (entendimento pessoal) falou principalmente sobre alguns conceitos de concorrência multithreading no nível macro.Este artigo foca em Java e fala sobre programação concorrente.

sychronizedpalavras-chave

Na verdade, a JVM fornece apenas um tipo de bloqueio, sychronizeda palavra-chave , que podemos ver Threadna definição de classes Java State.

Há um total de estados de encadeamento em Java NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Corresponde BLOCKEDapenas ao caso em que a thread entra sychronizerno bloco e não consegue adquirir o bloqueio. Para o bloqueio baseado em AQS, se o encadeamento estiver bloqueado, o status é WAITING, porque o AQS realmente chama LockSupporto método da classe para obter o bloqueio de encadeamento e o WAITINGstatus correspondente desses métodos java.lang.Thread.Stateé escrito nos comentários.

Também é importante notar que, embora os encadeamentos Java tenham uma correspondência um-para-um com o sistema operacional real (no HotSpot), na verdade, se a chamada do sistema executar operações de E/S e bloquear o encadeamento, o estado atual do encadeamento ainda será o estado, RUNNABLEporque O estado do encadeamento do sistema operacional e o estado do encadeamento de Java não correspondem exatamente um ao outro, nem são completamente unificados.

cabeçalho do objeto

Conforme mencionado acima, sychronizedo bloqueio fornecido é implementado pela JVM, o que significa que deve haver algumas variáveis ​​na JVM para armazenar o estado do bloqueio. Este é realmente o caso.Em Java, há uma parte do espaço dentro de cada objeto para armazenar as informações do cabeçalho do objeto .

O layout de memória de um objeto JVM é dividido em três áreas: cabeçalho do objeto , dados da instância e preenchimento de alinhamento . Os dados de instância armazenam as informações reais do objeto.
Para obter detalhes sobre o layout de memória de objetos JVM, consulte este artigo: Princípios de estrutura de objeto Java e implementação de bloqueio e explicação detalhada de MarkWord .

O cabeçalho do objeto contém muitas informações, entre as quais Mark Word é usado para armazenar hashCodee bloquear informações do objeto.

No Mark Word, se o objeto tiver um bloqueio, monitorserá armazenado um ponteiro para ele, e esse monitorobjeto equivale ao bloqueio do objeto. Os comentários do código-fonte Java são geralmente chamados de bloqueios de monitor .

Sabemos que todo objeto em Java tem um monitormonitor correspondente a ele. Ao javapdescompilar um sychronizedcódigo-fonte contendo blocos, podemos encontrar monitorentere monitorexit.

Fechaduras Pesadas e Leves

Eu sempre ouço que sychronizedé uma trava pesada, então o que exatamente são travas pesadas e travas leves?

Um bloqueio pesado significa que os threads que falham em competir pelo bloqueio entrarão em um estado bloqueado e precisarão esperar para serem ativados após a execução novamente.

Em termos simples, um bloqueio leve significa que as threads que competem pelo bloqueio continuarão a adquirir o bloqueio por meio de spins CAS, ou seja, não abrirão mão da oportunidade de executar o thread. Obviamente, girar o tempo todo desperdiçará o desempenho da CPU em vão, então sychronizedele será atualizado para um bloqueio pesado após um certo número de giros, de modo a evitar o bloqueio de encadeamento e o despertar até certo ponto e afetar o desempenho (porque essas operações não podem ser concluído no modo de usuário, todos envolvem alternar entre o modo de usuário e o modo de kernel).

Além disso, existe um outro conceito de biased locks , a cena onde nasceu é porque descobrimos que uma mesma thread está competindo por locks em muitos casos, então foi desenhado o conceito de biased locks, ou seja, uma thread que adquiriu o bloqueio Se o bloqueio for adquirido novamente, não é necessário passar pelo processo de rotação de bloqueio leve para adquirir o bloqueio, mas pode ser adquirido diretamente, melhorando assim o desempenho.

Um objeto está em um estado livre de bloqueio no início e, em seguida, passará pelos estágios de bloqueios tendenciosos, bloqueios leves e bloqueios pesados. O sychronizedbloqueio adicionado é um processo durante o qual o bloqueio só pode ser atualizado e não pode ser rebaixado.

Os detalhes específicos dos bloqueios Java sychronizedpodem se referir a este artigo: Java "bloqueios" que devem ser ditos
.

Mecanismo de espera/notificação

Com sychronizedo bloqueio, podemos garantir a consistência dos dados em multi-threading e, por meio do método wait/notifyObject fornecido pela classe , podemos realizar a comunicação entre multi-threading.

Por que esperar/notificar deve ser bloqueado

Vale a pena notar que wait/notify deve ser usado com um bloqueio, por causa dos dois pontos a seguir:

  1. Sem travamento, não há garantia da regra de acontecer antes . Assim, as modificações feitas na variável de condição podem não ser visíveis temporariamente para outros encadeamentos. Mas isso volatilepode obter o mesmo efeito, desde que você adicione a modificação da palavra-chave à variável de condição.
  2. Isso causará um problema de despertar perdido . Por exemplo, se não houver bloqueio, um thread apenas espera e outro thread notifica outros threads neste momento, porque o primeiro thread não foi adicionado à sequência de espera, a notificação não será recebida desta vez.

Com base nos dois pontos acima, os designers Java determinam que os bloqueios devem ser adicionados ao usar wait/notify, e o objeto que chama wait/notify deve ser o objeto correspondente ao bloqueio mantido pelo thread atual. (A fechadura deve ser a mesma, deve ser apenas um design conveniente, afinal, a questão é que deve haver uma fechadura)

Para uma discussão detalhada sobre esse ponto, consulte: Por que as chamadas em Java notify()requerem bloqueios .

notify()Será que realmente vai despertar o fio?

Dissemos acima que notify()precisamos usá-lo com um bloqueio, então isso exige que notifiquemos primeiro e depois desbloqueemos. Mas de acordo com os comentários, sabemos que notify()o thread esperando pelo bloqueio será despertado novamente, então não fará com que o thread notificado apenas acorde e seja bloqueado novamente porque não pode adquirir o bloqueio (supondo que não haja desbloqueio Neste momento)?

Obviamente, notify()o thread não pode ser ativado, caso contrário, tal acidente ocorrerá. java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObjectPodemos confirmar isso com a AQS . AQS mantém dois tipos de filas, a fila de espera aguardando para adquirir o bloqueio e a await()fila condicional do encadeamento bloqueado . Quando signal()a fila condicional é retirada da fila, o elemento principal da fila condicional é adicionado à fila de espera para se preparar para a competição de bloqueio . (Sobre o código-fonte AQS será analisado em detalhes posteriormente)

Portanto, podemos especular razoavelmente que notify()o thread não pode ser ativado, o que também pode ser confirmado escrevendo uma pequena demonstração.

Sabemos que notify()é um método local e a camada inferior é implementada pela linguagem C, por isso não é fácil visualizar diretamente o código-fonte. wait()Comparado com o padrão em C em Java pthread_cond_wait(), esse método tem requisitos muito vagos, e nem exige um bloqueio para ser chamado, então encontrei essa dúvida: Devo desbloquear antes ou depois de notificar em C , você pode ver o Gao Zan's answer A explicação é que, de um modo geral, o designer considerará que, se o notify for desbloqueado novamente, o thread ativado será bloqueado novamente, portanto, o designer não projetará o notification para ativar diretamente o thread.

por que wait()em loop corpo

Observe que java.lang.Object#wait()há este comentário:

Um encadeamento também pode ser ativado sem ser notificado, interrompido ou expirado, o que é chamado de ativação espúria. Embora isso raramente ocorra na prática, os aplicativos devem se proteger testando a condição que deveria ter causado o despertar do encadeamento e continuando a aguardar se a condição não for satisfeita. Em outras palavras, as esperas sempre devem ocorrer em loops, como este:
sincronizado (obj) { while (condição não segura) obj.wait(timeout); … // Execute a ação apropriada para condicionar }



Diz-se que wait()pode haver falsos despertares , o que nos obriga a verificar se a variável de condição atende à condição em um loop e chamar wait().

Sabemos que wait()essas funções requerem chamadas de sistema no final, porque estão envolvidas operações como bloqueio de thread, o que não pode ser feito no modo de usuário. E essas chamadas de sistema de bloqueio retornarão quando forem interrompidas EINTR. Se você esperar neste momento, podem ocorrer problemas, porque você não pode ter certeza se outras threads chamaram para notify()acordá-lo durante esse processo. Esperar pode perder a ativação, resultando em uma situação de impasse permanente. Portanto, no nível do sistema operacional, enquanto você chamar wait(), se for interrompido, não escolherá continuar esperando, mesmo que ocorram falsos despertares.

Para uma discussão sobre este ponto, consulte esta resposta: Por que os bloqueios condicionais geram ativações espúrias? .

Vale ressaltar também que java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject#await()não haverá falso wake-up, pois sua implementação elimina completamente a ocorrência desta situação (também é uma condição de julgamento do loop wireless).

notify()Os despertares são aleatórios?

De acordo com java.lang.Object#notifyos comentários, sabemos que esse método acorda aleatoriamente um thread em espera, mas, na verdade, também é baseado na implementação:

A escolha é arbitrária e fica a critério da implementação.

Na máquina virtual HotSpot, notify()a ordem do FIFO é seguida, enquanto notifyAll()a ordem do LIFO é seguida.

Para notify()sequência:

public class Main {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
    
    
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
    
    
                main.await(name);
            }).start();
        }
        for (int i = 0; i < 5; i++) {
    
    
            Thread.sleep(1000); // 由于synchronized不是公平锁,这里得每隔一段时间notify一次
            main.signal();
        }
    }

    private synchronized void await(String name){
    
    
        try {
    
    
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
    
    
        notify();
    }

    private synchronized void signalAll(){
    
    
        notifyAll();
    }
}

O programa imprime como:

Thread-0 está bloqueado
Thread-1 está bloqueado
Thread-2 está bloqueado
Thread-3 está bloqueado Thread-
4 está bloqueado
Thread-0 continua a execução
Thread-1 continua a execução Thread-2 continua a execução
Thread-3 continua a
execução
Thread-4 continua a execução

Para notifyAll()sequência:

public class Main {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
    
    
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
    
    
                main.await(name);
            }).start();
        }
        Thread.sleep(1000); // 这里得等所有线程被wait方法阻塞后再notifyAll
        main.signalAll();
    }

    private synchronized void await(String name){
    
    
        try {
    
    
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
    
    
        notify();
    }

    private synchronized void signalAll(){
    
    
        notifyAll();
    }
}

O programa imprime como:

Thread-0 está bloqueado
Thread-1 está bloqueado
Thread-2 está bloqueado
Thread-3 está bloqueado Thread-4 está bloqueado
Thread-4
continua a execução Thread -
3 continua a execução
Thread-2 continua a execução
Thread-1 continua a execução
Thread-0 continua a execução

AQS

Além dos bloqueios fornecidos pela JVM sychronized, também podemos contar AbstractQueuedSynchronizercom o AQS para implementar bloqueios. Bloqueios típicos como ReentrantLock, ReentrantReadWriteLocketc. são baseados no AQS. Pode-se dizer que o núcleo do JUC é o AQS.

Os bloqueios baseados em AQS fornecem bloqueios mais refinados, várias filas condicionais ( Condition), etc., que são superiores aos sychronizedbloqueios e podem substituir completamente sychronizedos bloqueios. Talvez sychronizedo único benefício do bloqueio seja que é mais fácil de usar e mais fácil de ler.

Se você entender o código-fonte do AQS, terá uma compreensão mais profunda dos bloqueios. Como eu disse no meu artigo anterior, os bloqueios são uma abstração de alto nível que nos facilita a resolução de problemas de simultaneidade.A camada inferior ainda é construída sobre as instruções da CPU. O AQS usa muitas operações e LockSupportclasses CAS para construir, aqui está um resumo macro:

Análise do código-fonte

AQS é uma fila síncrona. Usando esta fila, podemos realizar o conceito de bloqueios. lock()Corresponde a chamar o de AQS acquire(), unlock()e corresponde a chamar o de AQS release().

acquire()É um método de modelo, que chama tryAcquire()e por padrão acquireQueued(). O primeiro é realmente adquirir o bloqueio e não há nenhuma implementação fornecida no AQS. O último é equivalente a adicionar o tryAcquire()nó com falha - o nó que falhou ao adquirir o bloqueio, para a fila e aquisição cíclica De um modo geral, se a aquisição do bloqueio falhar no loop, ele será bloqueado, aguardando o nó predecessor - o nó que detém o bloqueio para ativá-lo.

release()É também um método template, que será chamado tryRelease(). Este também é um método para liberar o bloqueio a ser implementado pela subclasse. Após a liberação ser bem-sucedida, se o nó atual for o nó principal (porque a fila está vazia, em desta vez, o primeiro thread A que adquiriu o bloqueio não será enfileirado, o enfileiramento é enq()implementado apenas em ), ele será executado novamente unparkSuccessor()e o próximo thread será ativado.

Outros, como acquireShared()etc., também são métodos de modelo.

Sobre ConditionObject:

O AQS também implementa a função Condition, que mantém uma fila de condição, que é diferente da fila de espera mantida pelo AQS, e as duas filas são diferentes. Os métodos nele ConditionObjectrecebem uma implementação completa. O nó na fila condicional waitStatusé CONDITION.

Em await(), o método no AQS será chamado fullyRelease()para perceber que o thread libera o bloqueio, sai da fila de espera e entra na fila condicional. Em seguida, no signal()método, chame camada por camada e, finalmente, chame transferForSignal()para realizar a operação de sair da fila condicional e ingressar na fila de espera.

Vale ressaltar que seja uma fila condicional ou uma fila de espera, a ordem do FIFO é sempre seguida, não a ordem aleatória. Além disso, objetos em Object notify()também seguem FIFO, mas notifyAll()seguem LIFO. Mas esses são métodos nativos, depende principalmente da implementação da JVM, pelo menos no HotSpot.

Sobre bloqueios exclusivos e bloqueios compartilhados:

Conforme mencionado anteriormente, o AQS mantém dois tipos de filas, filas de espera e filas condicionais . Na classe Node, há uma variável de membro nextWaiterpara registrar o próximo nó na fila condicional.

Na fila de espera , esta variável é usada para marcar se o modo do nó é um modo compartilhado ou um modo exclusivo, e uma variável de membro é usada na fila de espera nextpara indicar o próximo nó. Você pode estar curioso, qual variável é usada na fila condicional para identificar o modo do nó da fila, na verdade, não há necessidade de identificar o modo do nó na fila condicional, porque a fila condicional só pode ser acessada em modo exclusivo.

A partir deste aspecto, a legibilidade do código-fonte AQS não é realmente muito boa. Afinal, uma variável é usada para várias finalidades e o código-fonte AQS possui um grande número de códigos de uma linha para implementar várias funções, portanto, a legibilidade é muito pobre. Mas é inegável que o desempenho é realmente alto, pelo menos sob a premissa de sacrificar a legibilidade.

Na fila de espera, ao acquireShared()adquirir o bloqueio, quando o bloqueio compartilhado for obtido, o setHeadAndPropagate()método será chamado ciclicamente para determinar se o nó subsequente que obteve o bloqueio compartilhado waitStautsé menor que 0 (porque PROPAGATEpode ou se tornar SIGNAL), e se for , em seguida, julgue o nó subsequente Se o estado é um bloqueio compartilhado ou se for null, ative os nós subsequentes.

Também é necessário julgar se nullé porque nullnão significa que o nó está no final da fila. Isso ocorre porque enq()a operação de ingressar na fila no meio é primeiro node.prev = t;apontar o nó predecessor do nó de ingresso para o final da fila, depois compareAndSetTail(t, node)substituir o final da fila pelo CAS e, finalmente,t.next = node;

Sabemos que, se vários threads adquirirem bloqueios compartilhados, precisamos apenas aumentar continuamente o estado, mas pode haver um bloqueio exclusivo adicionado em um determinado momento. Durante o período, muitos nós com bloqueios compartilhados se acumularam. Se o bloqueio for liberado, depois que o primeiro nó de bloqueio compartilhado obtiver o bloqueio, ele deverá notificar todos os nós de bloqueio compartilhado subsequentes.

Expanda aqui. Um nó que adquira um bloqueio não entrará na fila, ele só entrará na fila (passar) se a aquisição falhar enq(), após a aquisição bem-sucedida, ele será removido da fila (por exemplo, acquireQueued()). E o algoritmo que queremos implementar envolve apenas como adquirir bloqueios e como liberar bloqueios.Quanto ao gerenciamento de filas, toda a lógica foi implementada no AQS. Por exemplo, a lógica de bloqueios justos e bloqueios injustos deve ser implementada em subclasses, porque isso pertence a como adquirir e liberar bloqueios.

Sobre o cancelamento de nós:

Na classe Nó do AQS, waitStatushá outro valor CANCELLEDque indica que o nó expirou ou foi interrompido.

Ele passa cancelAcquire()configurações. Considerando que os métodos são chamados em blocos em cancelAcquire()todos os métodos que adquirem bloqueios (por exemplo , .acquireQueued()finally

cancelAcquire()Este método irá apenas apontar para si mesmo o próximo nó a ser cancelado, e não irá removê-lo da fila.A operação de desenfileiramento será executada em outros métodos como shouldParkAfterFailedAcquire()ou novamente .cancelAcquire()

Em relação à existência de interrupção no método:

Métodos com essa palavra-chave lançarão uma exceção de interrupção. Caso contrário, ele chama o thread atual interrupt(). Quanto ao que acontecerá ao chamar esse método, você pode consultar java.lang.Thread#interruptos comentários. Situações diferentes terão reações diferentes:

Os números de série correspondem às situações em que os quatro threads são interrompidos

Acho que você gosta

Origin blog.csdn.net/weixin_55658418/article/details/130795566
Recomendado
Clasificación