O que é segurança de threads e como alcançá-lo?

O que é segurança de threads e como alcançá-lo?

1. Visão geral

Java suporta multithreading pronto para uso. Isso significa que, ao executar bytecode simultaneamente em threads de trabalho separados,JVM é capaz de melhorar o desempenho nos aplicativos.

Embora o multithreading seja um recurso poderoso, tem um preço. Em ambientes multithread, precisamos escrever implementações de maneira segura para threads. Isso significa que diferentes threads podem acessar os mesmos recursos sem expor comportamentos errados ou produzir resultados imprevisíveis.

Esta metodologia de programação é conhecida como “thread-safety”.

Neste tutorial, veremos diferentes abordagens para alcançá-lo.

2. Implementações sem estado

Na maioria dos casos, erros em aplicativos multithread são o resultado do compartilhamento incorreto do estado entre vários threads.

Portanto, a primeira abordagem que veremos é atingir using stateless implementations de segurança de thread.

Para entender melhor essa abordagem, vamos considerar uma classe de utilitário simples com um método estático que calcula o fatorial de um número:

public class MathUtils {

    public static BigInteger factorial(int number) {
        BigInteger f = new BigInteger("1");
        for (int i = 2; i <= number; i++) {
            f = f.multiply(BigInteger.valueOf(i));
        }
        return f;
    }
}

The factorial() method is a stateless deterministic function. Dada uma entrada específica, ela sempre produz a mesma saída.

O métodoneither relies on external state nor maintains state at all. Portanto, é considerado thread-safe e pode ser chamado com segurança por vários threads ao mesmo tempo.

Todas as threads podem chamar com segurança o métodofactorial() e obterão o resultado esperado sem interferir umas nas outras e sem alterar a saída que o método gera para outras threads.

Portanto,stateless implementations are the simplest way to achieve thread-safety.

3. Implementações imutáveis

If we need to share state between different threads, we can create thread-safe classes by making them immutable.

Imutabilidade é um conceito poderoso e independente de linguagem e é bastante fácil de alcançar em Java.

Simplificando,a class instance is immutable when its internal state can’t be modified after it has been constructed.

A maneira mais fácil de criar uma classe imutável em Java é declarando todos os camposprivateefinal e não fornecendo setters:

public class MessageService {

    private final String message;

    public MessageService(String message) {
        this.message = message;
    }

    // standard getter

}

Um objetoMessageService é efetivamente imutável, uma vez que seu estado não pode mudar após sua construção. Portanto, é thread-safe.

Além disso, seMessageService fosse realmente mutável, mas vários encadeamentos só tivessem acesso somente leitura a ele, ele também seria seguro para encadeamentos.

Portanto,immutability is just another way to achieve thread-safety.

4. Campos Thread-Local

Na programação orientada a objetos (OOP), os objetos realmente precisam manter o estado através dos campos e implementar o comportamento através de um ou mais métodos.

Se realmente precisamos manter o estado,we can create thread-safe classes that don’t share state between threads by making their fields thread-local.

Podemos facilmente criar classes cujos campos são locais de thread simplesmente definindo campos privados nas classesThread.

Poderíamos definir, por exemplo, uma classeThread que armazena umarray deintegers:

public class ThreadA extends Thread {

    private final List numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

    @Override
    public void run() {
        numbers.forEach(System.out::println);
    }
}

Enquanto outro pode conter umarray destrings:

public class ThreadB extends Thread {

    private final List letters = Arrays.asList("a", "b", "c", "d", "e", "f");

    @Override
    public void run() {
        letters.forEach(System.out::println);
    }
}

Em ambas as implementações, as classes têm seu próprio estado, mas não é compartilhado com outros threads. Portanto, as classes são seguras para threads.

Da mesma forma, podemos criar campos locais de thread atribuindo instânciasThreadLocal a um campo.

Vamos considerar, por exemplo, a seguinte classeStateHolder:

public class StateHolder {

    private final String state;

    // standard constructors / getter
}

Podemos facilmente transformá-lo em uma variável local de thread da seguinte maneira:

public class ThreadState {

    public static final ThreadLocal statePerThread = new ThreadLocal() {

        @Override
        protected StateHolder initialValue() {
            return new StateHolder("active");
        }
    };

    public static StateHolder getState() {
        return statePerThread.get();
    }
}

Os campos local do encadeamento são muito parecidos com os campos normais da classe, exceto que cada encadeamento que os acessa por meio de um setter / getter obtém uma cópia inicializada independentemente do campo, para que cada encadeamento tenha seu próprio estado.

5. Coleções Sincronizadas

Podemos criar facilmente coleções thread-safe usando o conjunto de wrappers de sincronização incluídos emcollections framework.

Podemos usar, por exemplo, um dessessynchronization wrappers para criar uma coleção thread-safe:

Collection syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();

Vamos ter em mente que as coleções sincronizadas usam bloqueio intrínseco em cada método (veremos o bloqueio intrínseco mais tarde).

Isso significa que os métodos podem ser acessados ​​por apenas uma thread por vez, enquanto outras threads serão bloqueadas até que o método seja desbloqueado pela primeira thread.

Assim, a sincronização tem uma penalidade no desempenho, devido à lógica subjacente do acesso sincronizado.

6. Coleções simultâneas

Como alternativa às coleções sincronizadas, podemos usar coleções simultâneas para criar coleções seguras para threads.

Java fornece o pacotejava.util.concurrent, que contém várias coleções simultâneas, comoConcurrentHashMap:

Map concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");

Ao contrário de suas contrapartes sincronizadas, concurrent collections achieve thread-safety by dividing their data into segments. Em umConcurrentHashMap, por exemplo, vários encadeamentos podem adquirir bloqueios em diferentes segmentos do mapa, então vários encadeamentos podem acessar oMap ao mesmo tempo.

Concurrent collections aremuch more performant than synchronized collections, devido às vantagens inerentes do acesso à thread simultânea.

Vale a pena mencionar quesynchronized and concurrent collections only make the collection itself thread-safe and not the contents.

7. Objetos atômicos

Também é possível obter segurança de thread usando o conjunto deatomic classes que Java fornece, incluindoAtomicInteger,AtomicLong,AtomicBoolean eAtomicReference.

Atomic classes allow us to perform atomic operations, which are thread-safe, without using synchronization. Uma operação atômica é executada em uma única operação no nível da máquina.

Para entender o problema que isso resolve, vamos dar uma olhada na seguinte classeCounter:

public class Counter {

    private int counter = 0;

    public void incrementCounter() {
        counter += 1;
    }

    public int getCounter() {
        return counter;
    }
}

Vamos supor que em uma condição de corrida, dois threads acessam o métodoincrementCounter() ao mesmo tempo.

Em teoria, o valor final do campocounter será 2. Mas simplesmente não podemos ter certeza sobre o resultado, porque os threads estão executando o mesmo bloco de código ao mesmo tempo e a incrementação não é atômica.

Vamos criar uma implementação thread-safe da classeCounter usando um objetoAtomicInteger:

public class AtomicCounter {

    private final AtomicInteger counter = new AtomicInteger();

    public void incrementCounter() {
        counter.incrementAndGet();
    }

    public int getCounter() {
        return counter.get();
    }
}

This is thread-safe because, while incrementation, ++, takes more than one operation, incrementAndGet is atomic.

8. Métodos Sincronizados

Embora as abordagens anteriores sejam muito boas para coleções e primitivas, às vezes precisaremos de um controle maior do que isso.

Portanto, outra abordagem comum que podemos usar para obter segurança de encadeamento está implementando métodos sincronizados.

Basta colocar, only one thread can access a synchronized method at a time while blocking access to this method from other threads. Outros threads permanecerão bloqueados até que o primeiro thread termine ou o método gere uma exceção.

Podemos criar uma versão thread-safe deincrementCounter() de outra maneira, tornando-a um método sincronizado:

public synchronized void incrementCounter() {
    counter += 1;
}

Criamos um método sincronizado prefixando a assinatura do método com a palavra-chavesynchronized.

Como uma thread por vez pode acessar um método sincronizado, uma thread executará o métodoincrementCounter() e, por sua vez, outras farão o mesmo. Nenhuma execução sobreposta ocorrerá.

Synchronized methods rely on the use of “intrinsic locks” or “monitor locks”. Um bloqueio intrínseco é uma entidade interna implícita associada a uma instância de classe específica.

Em um contexto multithread, o termomonitor é apenas uma referência à função que o bloqueio desempenha no objeto associado, uma vez que impõe acesso exclusivo a um conjunto de métodos ou instruções especificados.

When a thread calls a synchronized method, it acquires the intrinsic lock. Depois que o thread termina de executar o método, ele libera o bloqueio, permitindo que outros threads adquiram o bloqueio e tenham acesso ao método.

Podemos implementar a sincronização em métodos de instância, métodos estáticos e instruções (instruções sincronizadas).

9. Declarações Sincronizadas

Às vezes, a sincronização de um método inteiro pode ser um exagero se for necessário tornar um segmento do método seguro para threads.

Para exemplificar este caso de uso, vamos refatorar o métodoincrementCounter():

public void incrementCounter() {
    // additional unsynced operations
    synchronized(this) {
        counter += 1; 
    }
}

O exemplo é trivial, mas mostra como criar uma instrução sincronizada. Supondo que o método agora execute algumas operações adicionais, que não requerem sincronização, nós apenas sincronizamos a seção de modificação de estado relevante envolvendo-a em um bloco sincronizado.

Ao contrário dos métodos synchronized, as instruções synchronized devem especificar o objeto que fornece o bloqueio intrínseco, geralmente a referênciathis.

Synchronization is expensive, so with this option, we are able to only synchronize the relevant parts of a method.

10. Campos Voláteis

Métodos e blocos sincronizados são úteis para resolver problemas de visibilidade variável entre threads. Mesmo assim, os valores dos campos regulares da classe podem ser armazenados em cache pela CPU. Portanto, as atualizações consequentes para um campo específico, mesmo se estiverem sincronizadas, podem não ser visíveis para outros threads.

Para evitar essa situação, podemos usar os campos de classevolatile:

public class Counter {

    private volatile int counter;

    // standard constructors / getter

}

With the volatile keyword, we instruct the JVM and the compiler to store the counter variable in main memory. Dessa forma, garantimos que toda vez que a JVM ler o valor da variávelcounter, ela realmente o lerá da memória principal, em vez de do cache da CPU. Da mesma forma, sempre que a JVM gravar na variávelcounter, o valor será gravado na memória principal.

Além disso,the use of a volatile variable ensures that all variables that are visible to a given thread will be read from main memory as well.

Vamos considerar o seguinte exemplo:

public class User {

    private String name;
    private volatile int age;

    // standard constructors / getters

}

Nesse caso, cada vez que a JVM grava a variávelagevolatile na memória principal, ela também grava a variávelname não volátil na memória principal. Isso garante que os valores mais recentes de ambas as variáveis ​​sejam armazenados na memória principal; portanto, as atualizações subsequentes nas variáveis ​​serão automaticamente visíveis para outros threads.

Da mesma forma, se um thread lê o valor de uma variávelvolatile, todas as variáveis ​​visíveis para o thread também serão lidas da memória principal.

This extended guarantee that volatile variables provide is known as the full volatile visibility guarantee.

11. Travamento Extrínseco

Podemos melhorar ligeiramente a implementação thread-safe da classeCounter usando um bloqueio de monitor extrínseco em vez de um intrínseco.

Um bloqueio extrínseco também fornece acesso coordenado a um recurso compartilhado em um ambiente multithread,but it uses an external entity to enforce exclusive access to the resource:

public class ExtrinsicLockCounter {

    private int counter = 0;
    private final Object lock = new Object();

    public void incrementCounter() {
        synchronized(lock) {
            counter += 1;
        }
    }

    // standard getter

}

Usamos uma instânciaObject simples para criar um bloqueio extrínseco. Essa implementação é um pouco melhor, pois promove a segurança no nível do bloqueio.

Com bloqueio intrínseco, onde os métodos e blocos sincronizados contam com a referênciathis,an attacker could cause a deadlock by acquiring the intrinsic lock and triggering a denial of service (DoS) condition.

Ao contrário de sua contraparte intrínseca,an extrinsic lock makes use of a private entity, which is not accessible from the outside. Isso torna mais difícil para um invasor obter o bloqueio e causar um deadlock.

12. Reentrant Locks

Java fornece um conjunto aprimorado de implementações deLock, cujo comportamento é um pouco mais sofisticado do que os bloqueios intrínsecos discutidos acima.

With intrinsic locks, the lock acquisition model is rather rigid: um thread adquire o bloqueio, executa um método ou bloco de código e, finalmente, libera o bloqueio, para que outros threads possam adquiri-lo e acessar o método.

Não há mecanismo subjacente que verifica os threads em fila e dá acesso prioritário aos threads de espera mais longos.

As instâncias deReentrantLock nos permitem fazer exatamente isso,hence preventing queued threads from suffering some types of resource starvation:

public class ReentrantLockCounter {

    private int counter;
    private final ReentrantLock reLock = new ReentrantLock(true);

    public void incrementCounter() {
        reLock.lock();
        try {
            counter += 1;
        } finally {
            reLock.unlock();
        }
    }

    // standard constructors / getter

}

O construtorReentrantLock aceita um parâmetro opcionalfairnessboolean. Quando definido comotrue, e vários threads estão tentando adquirir um bloqueio,the JVM will give priority to the longest waiting thread and grant access to the lock.

13. Read/Write Locks

Outro mecanismo poderoso que podemos usar para obter segurança de thread é o uso de implementações deReadWriteLock.

Um bloqueioReadWriteLock realmente usa um par de bloqueios associados, um para operações somente leitura e outro para operações de gravação.

Como resultado,it’s possible to have many threads reading a resource, as long as there’s no thread writing to it. Moreover, the thread writing to the resource will prevent other threads from reading it.

Podemos usar um bloqueioReadWriteLock da seguinte forma:

public class ReentrantReadWriteLockCounter {

    private int counter;
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    public void incrementCounter() {
        writeLock.lock();
        try {
            counter += 1;
        } finally {
            writeLock.unlock();
        }
    }

    public int getCounter() {
        readLock.lock();
        try {
            return counter;
        } finally {
            readLock.unlock();
        }
    }

   // standard constructors

}

14. Conclusão

Neste artigo,we learned what thread-safety is in Java, and took an in-depth look at different approaches for achieving it.

Como de costume, todos os exemplos de código mostrados neste artigo estão disponíveisover on GitHub.