1. O que é uma fila
Ao ouvir a fila, creio que ninguém desconhece ela. As filas podem ser vistas em todos os lugares da nossa vida real. Quando você vai ao supermercado para fazer o check-out, vê todos em fila esperando pelo checkout. Por que eles ficam enfileirados? Imagine que todo mundo não tem qualidade e se aglomera para o caixa. Não só esse supermercado vai entrar em colapso, mas também vai facilmente causar vários incidentes de debandada. Claro, essas coisas acontecem com frequência em nossa realidade.
É claro que, no mundo da informática, a fila pertence a uma estrutura de dados. A fila usa FIFO (primeiro na primeira saída). Novos elementos (elementos esperando para entrar na fila) são sempre inseridos na cauda e lidos na cabeça. Comece a ler. Na computação, a fila é geralmente usada para enfileiramento (como a fila de espera do pool de threads, a fila de espera do bloqueio), desacoplamento (modo produtor-consumidor), assíncrono e assim por diante.
2. Fila no jdk
Todas as filas em jdk implementam a interface java.util.Queue e são divididas em duas categorias na fila, uma é thread-insegura, ArrayDeque, LinkedList, etc., e a outra está sob o pacote java.util.concurrent É thread-safe, e em nosso ambiente real, nossas máquinas são multi-threaded. Quando vários threads enfileiram a mesma fila, se o thread for usado, aparecerá inseguro, sobrescrevendo dados, perda de dados, etc. Imprevisível , Portanto, neste momento, só podemos escolher filas thread-safe. Abaixo está uma breve lista de algumas das filas thread-safe fornecidas no jdk:
Se o nome da fila está bloqueado. O ponto técnico chave da estrutura de dados é se há um bloqueio ou não
ArrayBlockingQueue é uma matriz de matriz ReentrantLock está bloqueado e vinculado
LinkedBlockingQueue é uma lista vinculada ReentrantLock está bloqueado e vinculado
LinkedTransferQueue é uma lista vinculada CAS sem bloqueio ***
DelayQueue sem heap Heap CAS sem bloqueio * **
Podemos ver que nossa fila livre de bloqueio é *** e a fila bloqueada está limitada. Isso envolverá um problema. Estamos em um ambiente online real, a fila ***, sim O impacto do nosso sistema é relativamente grande, o que pode causar o estouro direto de nossa memória, então devemos primeiro eliminar a fila ***. Claro, não é que a fila *** seja inútil, mas deve ser excluída em certos cenários. Em segundo lugar, há duas filas, ArrayBlockingQueue e LinkedBlockingQueue. Ambas são thread-safe controladas por ReentrantLock. A diferença entre as duas é uma matriz e uma lista vinculada. Na fila, o elemento da fila geralmente é obtido imediatamente após a obtenção É possível obter o próximo elemento, ou obter vários elementos da fila de uma vez, e o endereço do array na memória for contínuo, haverá otimização do cache no sistema operacional (a linha do cache também será apresentada abaixo), então a velocidade de acesso será um pouco melhor Primeiro, tentaremos escolher ArrayBlockingQueue. Acontece que em muitas estruturas de terceiros, como o primeiro log4j assíncrono, ArrayBlockingQueue é a escolha.
Claro, ArrayBlockingQueue também tem suas próprias desvantagens, ou seja, o desempenho é relativamente baixo, porque o jdk adicionará algumas filas sem bloqueio, na verdade, para aumentar o desempenho, é muito angustiante e precisa ser livre de bloqueio e precisa ser limitado. Neste momento, não posso deixar de dizer Por que você não vai para o céu? Mas alguém realmente foi para o céu.
3. Disruptor
Disruptor é o dia mencionado acima. Disruptor é uma fila de alto desempenho desenvolvida pela empresa britânica de comércio de câmbio LMAX e é uma estrutura de concorrência de código aberto e ganhou o Prêmio de Inovação do Programa Duke de 2011. Ele pode realizar a operação simultânea da fila de rede sem bloqueio, e o sistema de thread único baseado no Disruptor pode suportar 6 milhões de pedidos por segundo. No momento, estruturas bem conhecidas, incluindo Apache Storm, Camel, Log4j2, etc., integraram o Disruptor internamente para substituir a fila jdk para obter alto desempenho.
3.1 Por que é tão incrível?
O disruptor foi destruído acima. Você definitivamente terá perguntas. Ele pode realmente ser tão incrível? Minha resposta é: claro. Existem três grandes assassinos no disruptor:
- CASO
- Elimine o falso compartilhamento
- RingBuffer tem esses três assassinos, Disruptor se tornou tão incrível.
3.1.1 Bloqueio e CAS
O motivo pelo qual nosso ArrayBlockingQueue foi abandonado é porque usamos bloqueios pesados. Durante nosso processo de bloqueio, suspenderemos o bloqueio e, após desbloquear, retomaremos o encadeamento. Este processo terá uma certa sobrecarga e Uma vez que não adquirimos o bloqueio, este encadeamento só pode esperar para sempre e não pode fazer nada.
CAS (comparar e trocar), como o nome sugere, é a primeira comparação para trocar. Geralmente, é para comparar se é um valor antigo. Se for para trocar configurações, todos que estão familiarizados com o bloqueio otimista sabem que o CAS pode ser usado para implementar o bloqueio otimista. Não há thread no CAS. A comutação de contexto reduz a sobrecarga desnecessária. JMH é usado aqui, com dois threads, uma chamada de cada vez, e o teste é executado na minha máquina local. O código é o seguinte:
@BenchmarkMode
({
Mode
.
SampleTime
})
@OutputTimeUnit
(
TimeUnit
.
MILLISECONDS
)
@Warmup
(
iterations
=
3
,
time
=
5
,
timeUnit
=
TimeUnit
.
MILLISECONDS
)
@Measurement
(
iterations
=
1
,
batchSize
=
100000000
)
@Threads
(
2
)
@Fork
(
1
)
@State
(
Scope
.
Benchmark
)
public
class
Myclass
{
Lock
lock
=
new
ReentrantLock
();
long
i
=
0
;
AtomicLong
atomicLong
=
new
AtomicLong
(
0
);
@Benchmark
public
void
measureLock
()
{
lock
.
lock
();
i
++;
lock
.
unlock
();
}
@Benchmark
public
void
measureCAS
()
{
atomicLong
.
incrementAndGet
();
}
@Benchmark
public
void
measureNoLock
()
{
i
++;
}
}
Os resultados do teste são os seguintes:
Resultados do teste:
Bloqueio 26000ms
CAS 4840ms
Sem bloqueio 197ms
Pode ser visto que Bloqueio é um número de cinco dígitos, CAS é um número de quatro dígitos e um menor livre de bloqueio é um número de três dígitos. A partir disso, podemos saber Lock> CAS> No lock.
E nosso Disruptor usa CAS, que usa CAS para definir alguns subscritos na fila, o que reduz os conflitos de bloqueio e melhora o desempenho.
Além disso, o CAS também é usado para outras filas sem bloqueio no jdk e o CAS também é usado para classes atômicas.
3.1.2 Compartilhamento falso
Ao falar sobre pseudo-compartilhamento, devo dizer que o cache da CPU do computador. O tamanho do cache é um dos indicadores importantes da CPU, e a estrutura e o tamanho do cache têm uma grande influência na velocidade da CPU. A frequência operacional do cache na CPU é extremamente alta. A mesma operação de freqüência, a eficiência do trabalho é muito maior do que a memória do sistema e disco rígido. No trabalho real, a CPU muitas vezes precisa ler o mesmo bloco de dados repetidamente, e o aumento da capacidade do cache pode melhorar muito a taxa de acerto dos dados lidos na CPU, em vez de pesquisar na memória ou disco rígido, melhorando assim o desempenho do sistema . Mas, considerando os fatores de área e custo do chip da CPU, o cache é muito pequeno.
O cache do processador pode ser dividido em um cache de primeiro nível e um cache de segundo nível.Atualmente, os processadores convencionais também possuem um cache de terceiro nível, e alguns processadores têm até um cache de quarto nível. Todos os dados armazenados em cada nível de cache fazem parte do próximo nível de cache.A dificuldade técnica e o custo de fabricação desses três caches estão relativamente diminuindo, então sua capacidade está relativamente aumentando.
Por que a CPU tem um design de cache como L1, L2, L3? O principal motivo é que o processador atual é muito rápido e muito lento para ler dados da memória (uma é porque a memória em si não é rápida o suficiente e a outra é porque está muito longe da CPU. Em geral, você precisa deixar a CPU esperar alguns minutos. Dez ou até centenas de ciclos de clock) Neste momento, para garantir a velocidade do processador, é necessário um atraso menor e uma memória mais rápida para ajudar, e este é o cache. Se você estiver interessado nisso, pode remover a CPU do computador e brincar com ela você mesmo.
Cada vez que você ouvir a Intel lançar uma nova cpu, como i7-7700k, 8700k, o tamanho do cache da cpu será otimizado. Se você estiver interessado, pode descer e pesquisar essas conferências ou publicar artigos.
cabeçalho 1 cabeçalho 2
linha 1 coluna 1 linha 1 coluna 2
linha 2 coluna 1 linha 2 coluna 2
A apresentação de QConpresentation de Martin e Mike deu parte do tempo para cada cache:
De CPU para ciclos de CPU necessários sobre o tempo aproximado necessário para
a memória principal
de cerca de 60-80 nanossegundos
transferência de barramento QPI (entre soquetes, não desenhado)
cerca de 20ns
L3 Cache cerca de 40-45 ciclos de cerca de 15ns
L2 de cerca de 10 ciclos Cache cerca de 3ns
Ll Cache Cerca de 3-4 ciclos Cerca de 1ns
Registre
1
linha de cache de ciclo
No cache multinível da cpu, ele não é armazenado como um item separado, mas uma estratégia semelhante a pageCahe, que é armazenada em uma linha de cache, e o tamanho da linha de cache é geralmente de 64 bytes. Em Java, Longo São 8 bytes, portanto, 8 Longs podem ser armazenados. Por exemplo, quando você acessa uma variável longa, ele carrega mais 7 auxiliares. Dissemos acima por que escolhemos arrays e não listas vinculadas, é por isso que, Na matriz, você pode contar com linhas em buffer para obter acesso rápido.
A linha do cache é tudo? NÃO, porque ainda traz uma deficiência, darei um exemplo aqui para ilustrar essa deficiência. Você pode imaginar uma fila de array, ArrayQueue, cuja estrutura de dados é a seguinte:
class
ArrayQueue
{
long
maxSize
;
long
currentIndex
;
}
Para maxSize, definimos o tamanho da matriz no início, e para currentIndex, ele marca a posição de nossa fila atual. Essa mudança é relativamente rápida. Você pode imaginar que, ao visitar maxSize, carregará currentIndex? Desta vez Se outros threads atualizarem o currentIndex, eles invalidarão a linha do cache na cpu. Observe que este é um regulamento da CPU. Não é apenas que o currentIndex está invalidado. Se continuar a acessar maxSize neste momento, você ainda terá que continuar a partir da memória. Ler, mas MaxSize é definido no início, devemos acessar o cache, mas é afetado pelo currentIndex que mudamos frequentemente.
A magia do enchimento
A fim de resolver o problema da linha de cache acima, o método Padding é usado no Disruptor,
class
LhsPadding
{
protected
long
p1
,
p2
,
p3
,
p4
,
p5
,
p6
,
p7
;
}
class
Value
extends
LhsPadding
{
protected
volatile
long
value
;
}
class
RhsPadding
extends
Value
{
protected
long
p9
,
p10
,
p11
,
p12
,
p13
,
p14
,
p15
;
}
O valor é preenchido com outras variáveis longas inúteis. Desta forma, ao modificar o valor, ele não afetará as linhas de cache de outras variáveis.
Finalmente, a propósito, a anotação @Contended é fornecida no jdk8. Claro, de modo geral, apenas o Jdk é permitido internamente. Se você usar você mesmo, deve configurar o parâmetro Jvm -RestricContentended = fase, que restringirá a configuração e o cancelamento desta anotação. Muitos artigos analisaram ConcurrentHashMap, mas ignoraram esta anotação. Esta anotação é usada em ConcurrentHashMap. Cada intervalo em ConcurrentHashMap usa um contador separado para cálculo, e esse contador está mudando o tempo todo. Essa anotação é usada para otimizar o preenchimento das linhas de cache para aumentar o desempenho.
3.1.3RingBuffer
No Disruptor, um array é usado para salvar nossos dados. Acima também introduzimos o uso de um array para salvar nosso acesso ao cache, mas no Disruptor, optamos por usar um array circular para salvar os dados, que é o RingBuffer. Deixe-me explicar primeiro que a matriz de anel não é uma matriz de anel real. Em RingBuffer, o restante é usado para acessar. Por exemplo, o tamanho da matriz é 10 e 0 acessa a posição do índice da matriz como 0. Na verdade, 10, 20, etc. É também a posição em que o índice da matriz é 0.
Na verdade, nessas estruturas, o restante não usa a operação%, mas a operação & e, que requer que você defina o tamanho para a enésima potência de 2, ou seja, 10, 100, 1000, etc., então subtraia 1 Se for 1, 11, 111, o índice & (tamanho -1) pode ser usado bem, de modo que o uso de operações de bit aumente a velocidade de acesso. Se você não definir o tamanho no disruptor para a enésima potência de 2, será lançada uma exceção de que o tamanho do buffer deve ser a enésima potência de 2.
Claro, isso não só resolve o problema de acesso rápido ao array, mas também resolve o problema de não precisar alocar memória novamente, e reduz a coleta de lixo, porque nós 0, 10, 20, etc. somos todos executados na mesma área de memória, então não há necessidade de alocar novamente A memória é freqüentemente recuperada pelo coletor de lixo JVM.
Desde então, os três principais assassinos foram eliminados. Com esses três principais assassinos, foi lançada a base para um disruptor de alto desempenho. A seguir, explicarei como usar o disruptor e o princípio de funcionamento específico do disruptor.
3.2 Como usar o disruptor
Aqui está um exemplo simples:
ublic
static
void
main
(
String
[]
args
)
throws
Exception
{
// 队列中的元素
class
Element
{
@Contended
private
String
value
;
public
String
getValue
()
{
return
value
;
}
public
void
setValue
(
String
value
)
{
this
.
value
=
value
;
}
}
// 生产者的线程工厂
ThreadFactory
threadFactory
=
new
ThreadFactory
()
{
int
i
=
0
;
@Override
public
Thread
newThread
(
Runnable
r
)
{
return
new
Thread
(
r
,
"simpleThread"
+
String
.
valueOf
(
i
++));
}
};
// RingBuffer生产工厂,初始化RingBuffer的时候使用
EventFactory
<
Element
>
factory
=
new
EventFactory
<
Element
>()
{
@Override
public
Element
newInstance
()
{
return
new
Element
();
}
};
// 处理Event的handler
EventHandler
<
Element
>
handler
=
new
EventHandler
<
Element
>()
{
@Override
public
void
onEvent
(
Element
element
,
long
sequence
,
boolean
endOfBatch
)
throws
InterruptedException
{
System
.
out
.
println
(
"Element: "
+
Thread
.
currentThread
().
getName
()
+
": "
+
element
.
getValue
()
+
": "
+
sequence
);
// Thread.sleep(10000000);
}
};
// 阻塞策略
BlockingWaitStrategy
strategy
=
new
BlockingWaitStrategy
();
// 指定RingBuffer的大小
int
bufferSize
=
8
;
// 创建disruptor,采用单生产者模式
Disruptor
<
Element
>
disruptor
=
new
Disruptor
(
factory
,
bufferSize
,
threadFactory
,
ProducerType
.
SINGLE
,
strategy
);
// 设置EventHandler
disruptor
.
handleEventsWith
(
handler
);
// 启动disruptor的线程
disruptor
.
start
();
for
(
int
i
=
0
;
i
<
10
;
i
++)
{
disruptor
.
publishEvent
((
element
,
sequence
)
->
{
System
.
out
.
println
(
"之前的数据"
+
element
.
getValue
()
+
"当前的sequence"
+
sequence
);
element
.
setValue
(
"我是第"
+
sequence
+
"个"
);
});
}
}
Existem várias chaves no Disruptor: ThreadFactory: Esta é uma fábrica de threads para as threads necessárias para o produtor em nosso Disruptor quando é consumido. EventFactory: A fábrica de eventos, a fábrica usada para gerar nossos elementos de fila. No Disruptor, ele preencherá o RingBuffer diretamente quando for inicializado e estará no lugar imediatamente. EventHandler: Um manipulador usado para processar o Evento. Aqui, um EventHandler pode ser considerado um consumidor, mas vários EventHandlers são filas para consumo independente. WorkHandler: também é um manipulador usado para processar eventos.A diferença do acima é que vários consumidores compartilham a mesma fila. WaitStrategy: Estratégia de espera Existem várias estratégias no Disruptor para determinar quando os consumidores obtêm o consumo, qual é a estratégia a ser adotada se não houver dados? Aqui está uma breve lista de algumas estratégias no Disruptor
BlockingWaitStrategy: Por meio do bloqueio de thread, aguarde o produtor acordar e, após ser ativado, será reciclado para verificar se a sequência dependente foi consumida.
-
BusySpinWaitStrategy: o encadeamento está girando e esperando, o que pode consumir cpu
-
LiteBlockingWaitStrategy: Blocos de thread esperando o produtor acordar. Comparado com BlockingWaitStrategy, a diferença é signalNeeded.getAndSet. Se dois threads acessam um acesso waitfor e um acesso signalAll ao mesmo tempo, o número de bloqueios de bloqueio pode ser reduzido.
-
LiteTimeoutBlockingWaitStrategy: Comparado com LiteBlockingWaitStrategy, o tempo de bloqueio é definido e uma exceção é lançada após o tempo expirar.
-
YieldingWaitStrategy: tente 100 vezes, então Thread.yield () retorna a cpu
EventTranslator: a implementação dessa interface pode converter nossas outras estruturas de dados em eventos que circulam no disruptor.
3.3 princípio de funcionamento
Os três assassinos do CAS, reduzindo o falso compartilhamento, e o RingBuffer já foram apresentados acima. Vamos apresentar todo o processo de produtores e consumidores no Disruptor.
3.3.1 Produtor
Para produtores, eles podem ser divididos em multiprodutores e produtores individuais, que são diferenciados por ProducerType.Single e ProducerType.MULTI. Para multiprodutores e produtores individuais, há mais CAS, porque os produtores individuais são de thread único. Portanto, não há necessidade de garantir a segurança do thread.
No disruptor, disruptor.publishEvent e disruptor.publishEvents () são geralmente usados para envio individual e em grupo.
Postar um evento no disruptor na fila requer as seguintes etapas:
- Primeiro, obtenha o próximo local no RingBuffer que pode ser publicado no RingBuffer. Isso pode ser dividido em duas categorias:
- Posição nunca escrita
- Foi lido por todos os consumidores e pode estar na posição de escrita. Se você não ler até que continue tentando ler, o disruptor é muito inteligente, e nem sempre ocupa a CPU, mas através do LockSuport.park (), a thread é bloqueada e suspensa por um tempo, para evitar que a CPU continue. Este tipo de loop vazio, caso contrário, nenhum outro thread pode capturar a fatia de tempo da CPU.
Após a obtenção da posição, o cas será eliminado, se for um único fio não será necessário.
- Em seguida, chame o EventTranslator que apresentamos acima para fornecer o evento naquela posição no RingBuffer na primeira etapa ao EventTranslator para reescrita.
- Para publicação, há uma matriz adicional no disruptor para registrar o número de sequência atual do ringBuffer atual. Pegue o acima 0, 10 e 20. Ao gravar em 10, o avliableBuffer é gravado na posição correspondente. No momento, isso pertence a 10. Qual é a utilidade? Vou apresentá-lo mais tarde. Ao publicar, você precisa atualizar este avliableBuffer e, em seguida, despertar todos os produtores bloqueados.
Vamos desenhar resumidamente o processo abaixo. O exemplo acima não está correto quando tomamos 10 como exemplo, porque o bufferSize deve ser 2 elevado à enésima potência, então tomamos Buffersize = 8 como exemplo: o seguinte mostra que quando empurramos 8 eventos, é um círculo. Quando chegar a hora, o próximo processo de empurrar 3 mensagens: 1. Primeira chamada seguinte (3), estamos atualmente na posição 7, então os próximos três são 8, 9, 10 e o restante é 0, 1, 2 . 2. Reescreva os dados nas três áreas de memória 0, 1 e 2. 3. Escreva availableBuffer.
A propósito, não sei se você está familiarizado com o processo acima. É semelhante ao nosso 2PC, envio de dois estágios, primeiro bloqueie a localização do RingBuffer e, em seguida, envie e notifique os consumidores. Para a introdução específica de 2PC, por favor, refira-se a outro artigo meu.Então alguém irá perguntar sobre transações distribuídas e mostrar a ele este artigo.
3.3.1 Consumidor
Para os consumidores, há dois tipos descritos acima: um é que vários consumidores consomem de forma independente e o outro é que vários consumidores consomem a mesma fila. Aqui, apresentamos os mais complicados vários consumidores que consomem a mesma fila. , Pode entender isso também pode entender o consumo independente. Em nosso método disruptor.strat (), nosso thread de consumidor será iniciado para consumo em segundo plano. Existem duas filas em consumidores que precisam de nossa atenção, uma é a fila de progresso compartilhada por todos os consumidores e a outra é a fila de progresso de consumo independente de cada consumidor. 1. Execute a próxima preempção do Next CAS na fila compartilhada do consumidor e marque o progresso atual na fila de seu próprio progresso de consumo. 2. Inscreva-se para a próxima posição do RingBuffer legível. A inscrição aqui não é apenas para o próximo, mas pode se inscrever para um intervalo maior do que o Next. O processo de aplicação da estratégia de bloqueio é o seguinte:
- Obtenha a última posição escrita pelo produtor para RingBuffer
- Determinar se é menor do que a posição para a qual desejo me candidatar
- Se for maior que, prova que a posição foi escrita e retorna ao produtor.
- Caso o menor que a prova não tenha sido gravado nesta posição, ele será bloqueado na estratégia de bloqueio e será despertado durante a fase de submissão do produtor. 3. Realize uma verificação legível nesta posição, porque a posição para a qual você se inscreveu pode ser contínua. Por exemplo, o produtor está atualmente com 7 anos e, em seguida, solicite a leitura. Se o consumidor escreveu a posição dos números de série 8 e 10, Mas a posição de 9 não teve tempo de escrever, porque a primeira etapa retornará 10, mas 9 na verdade não é legível, então você deve encolher a posição para 8.
4. Se for menor que a próxima corrente após a redução, continue aplicando em um loop. 5. Passe para handler.onEvent () para o
mesmo processamento . Vejamos um exemplo. Queremos nos inscrever para a posição next = 8. 1. Primeiro, preencha o progresso 8 na fila compartilhada e grave o progresso 7 na fila independente 2. Obtenha a posição legível máxima de 8. Aqui, de acordo com diferentes estratégias, escolhemos bloquear. Porque o produtor produz 8, 9, 10, Portanto, o valor retornado é 10, portanto, não há necessidade de compará-lo com o availableBuffer novamente. 3. Por fim, entregue-o ao manipulador para processamento.
4. Disruptor em Log4j
A figura a seguir mostra a comparação do uso de Disruptor pelo Log4j, ArrayBlockingQueue e throughput sincronizado do Log4j. Você pode ver que o uso de Disruptor explodiu outros. Claro, existem mais estruturas que usam Disruptor, então não vou apresentá-lo aqui.
Finalmente
Este artigo apresenta as deficiências das filas de bloqueio tradicionais. O artigo a seguir enfoca o Disruptor, o motivo pelo qual ele é tão incrível e o fluxo de trabalho específico.
Se alguém lhe pedir para projetar uma fila sem bloqueio eficiente no futuro, como você a projeta? Acredito que você possa resumir a resposta do artigo. Se você tiver dúvidas sobre isso ou quiser trocar ideias comigo, pode seguir minha conta oficial e adicionar meus amigos para discutir comigo.
Se você tiver alguma dúvida sobre as perguntas acima, pode prestar atenção à conta oficial, venha e discuta comigo, siga-o para receber os vídeos de materiais de aprendizagem Java mais recentes e os materiais de entrevista mais recentes gratuitamente.
Se você acha que este artigo é útil para você, ou se você tem alguma dúvida e deseja fornecer o serviço VIP gratuito 1v1, você pode seguir minha conta pública e receber os vídeos de materiais de aprendizagem java e os materiais de entrevista mais recentes gratuitamente. Seguir e encaminhar são meu maior apoio, O (∩_∩) O: