Complexidade temporal das coleções Java

Complexidade temporal das coleções Java

1. Visão geral

Neste tutorial,we’ll talk about the performance of different collections from the Java Collection API. Quando falamos sobre coleções, geralmente pensamos nas estruturas de dadosList, Map,eSet e suas implementações comuns.

Em primeiro lugar, veremos os insights de complexidade Big-O para operações comuns e, depois, mostraremos os números reais do tempo de execução de algumas operações de coleta.

2. Complexidade temporal

Normalmente,when we talk about time complexity, we refer to Big-O notation. Simplificando, a notação descreve como o tempo para executar o algoritmo aumenta com o tamanho da entrada.

Artigos úteis estão disponíveis para aprender mais sobre a notação Big-Otheory ouJava examples prático.

3. List

Vamos começar com uma lista simples - que é uma coleção ordenada.

Aqui, vamos dar uma olhada em uma visão geral do desempenho das implementaçõesArrayList, LinkedList,eCopyOnWriteArrayList.

3.1. ArrayList

The ArrayList in Java is backed by an array. Isso ajuda a entender a lógica interna de sua implementação. Um guia mais abrangente paraArrayList está disponívelin this article.

Então, vamos primeiro nos concentrar na complexidade do tempo das operações comuns, em um alto nível:

  • add() - levaO(1) tempo

  • add(index, element) - em execuções médias emO(n) tempo

  • get() - é sempre uma operação deO(1) de tempo constante

  • remove() - roda no tempo linearO(n). Temos que repetir toda a matriz para encontrar o elemento qualificado para remoção

  • *indexOf()* – também funciona em tempo linear. Ele itera pela matriz interna e verifica cada elemento um por um. Portanto, a complexidade de tempo para esta operação sempre requerO(n) tempo

  • contains() - a implementação é baseada emindexOf(). Portanto, ele também será executado emO(n) tempo

3.2. CopyOnWriteArrayList

Esta implementação da interfaceList évery useful when working with multi-threaded applications. É thread-safe e bem explicado emthis guide here.

Aqui está a visão geral da notação Big-O de desempenho paraCopyOnWriteArrayList:

  • add() - depende da posição em que adicionamos valor, então a complexidade éO(n)

  • get() - éO(1) operação de tempo constante

  • remove() - levaO(n) time

  • contains() - da mesma forma, a complexidade éO(n)

Como podemos ver, o uso desta coleção é muito caro devido às características de desempenho do métodoadd().

3.3. LinkedList

LinkedList is a linear data structure which consists of nodes holding a data field and a reference to another node. Para mais recursos e capacidades deLinkedList, dê uma olhada emthis article here.

Vamos apresentar a estimativa média do tempo que precisamos para realizar algumas operações básicas:

  • add() - suporta inserção de tempo constanteO(1) em qualquer posição

  • get() - a busca por um elemento levaO(n) tempo

  • remove() - remover um elemento também requer a operaçãoO(1), pois fornecemos a posição do elemento

  • contains() - também temO(n) complexidade de tempo

3.4. Aquecendo a JVM

Agora, para provar a teoria, vamos brincar com os dados reais. To be more precise, we’ll present the JMH (Java Microbenchmark Harness) test results of the most common collection operations.

Caso você não esteja familiarizado com a ferramenta JMH, verifique esteuseful guide.

Primeiro, apresentamos os principais parâmetros de nossos testes de benchmark:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 10)
public class ArrayListBenchmark {
}

Em seguida, definimos o número de iterações de aquecimento para10. Além disso, queremos ver o tempo médio de execução de nossos resultados exibido em microssegundos.

3.5. Testes de referência

Agora, é hora de fazer nossos testes de desempenho. Primeiro, começamos comArrayList:

@State(Scope.Thread)
public static class MyState {

    List employeeList = new ArrayList<>();

    long iterations = 100000;

    Employee employee = new Employee(100L, "Harry");

    int employeeIndex = -1;

    @Setup(Level.Trial)
    public void setUp() {
        for (long i = 0; i < iterations; i++) {
            employeeList.add(new Employee(i, "John"));
        }

        employeeList.add(employee);
        employeeIndex = employeeList.indexOf(employee);
    }
}

Dentro de nossoArrayListBenchmark, adicionamos a classeState para armazenar os dados iniciais.

Aqui, criamos umArrayList de objetosEmployee. Depois,we initialize it with 100.000 items inside of the setUp() method. The @State indicates that the @Benchmark tests have full access to the variables declared in it within the same thread.

Finalmente, é hora de adicionar os testes de benchmark para os métodosadd(), contains(), indexOf(), remove(),eget():

@Benchmark
public void testAdd(ArrayListBenchmark.MyState state) {
    state.employeeList.add(new Employee(state.iterations + 1, "John"));
}

@Benchmark
public void testAddAt(ArrayListBenchmark.MyState state) {
    state.employeeList.add((int) (state.iterations), new Employee(state.iterations, "John"));
}

@Benchmark
public boolean testContains(ArrayListBenchmark.MyState state) {
    return state.employeeList.contains(state.employee);
}

@Benchmark
public int testIndexOf(ArrayListBenchmark.MyState state) {
    return state.employeeList.indexOf(state.employee);
}

@Benchmark
public Employee testGet(ArrayListBenchmark.MyState state) {
    return state.employeeList.get(state.employeeIndex);
}

@Benchmark
public boolean testRemove(ArrayListBenchmark.MyState state) {
    return state.employeeList.remove(state.employee);
}

3.6. Resultado dos testes

Todos os resultados são apresentados em microssegundos:

Benchmark                        Mode  Cnt     Score     Error
ArrayListBenchmark.testAdd       avgt   20     2.296 ±   0.007
ArrayListBenchmark.testAddAt     avgt   20   101.092 ±  14.145
ArrayListBenchmark.testContains  avgt   20   709.404 ±  64.331
ArrayListBenchmark.testGet       avgt   20     0.007 ±   0.001
ArrayListBenchmark.testIndexOf   avgt   20   717.158 ±  58.782
ArrayListBenchmark.testRemove    avgt   20   624.856 ±  51.101

From the results we can learn, that testContains() and testIndexOf() methods run in approximately the same time. Também podemos ver claramente a enorme diferença entre as pontuações do métodotestAdd(), testGet() do resto dos resultados. Adicionar um elemento leva 2.296 microssegundos e obter um é uma operação de 0,007 microssegundo.

A busca ou remoção de um elemento custa aproximadamente700 microssegundos. Esses números são a prova da parte teórica, onde aprendemos queadd(),eget() tem complexidade de tempoO(1) e os outros métodos sãoO(n). Elementosn=10.000 em nosso exemplo.

Da mesma forma, podemos escrever os mesmos testes para a coleçãoCopyOnWriteArrayList. Tudo o que precisamos é substituir oArrayList em employeeList pela instânciaCopyOnWriteArrayList.

Aqui estão os resultados do teste de benchmark:

Benchmark                          Mode  Cnt    Score     Error
CopyOnWriteBenchmark.testAdd       avgt   20  652.189 ±  36.641
CopyOnWriteBenchmark.testAddAt     avgt   20  897.258 ±  35.363
CopyOnWriteBenchmark.testContains  avgt   20  537.098 ±  54.235
CopyOnWriteBenchmark.testGet       avgt   20    0.006 ±   0.001
CopyOnWriteBenchmark.testIndexOf   avgt   20  547.207 ±  48.904
CopyOnWriteBenchmark.testRemove    avgt   20  648.162 ± 138.379

Aqui, novamente, os números confirmam a teoria. Como podemos ver,testGet() em média é executado em 0,006 ms, que podemos considerar comoO(1). Comparing to ArrayList, we also notice the significant difference between testAdd() method results. As we have here O(n) complexity for the add() method versus ArrayList’s O(1). 

We can clearly see the linear growth of the time, as performance numbers are 878.166 compared to 0.051.

Agora, é hora deLinkedList:

Benchmark        Cnt     Score       Error
testAdd          20     2.580        ± 0.003
testContains     20     1808.102     ± 68.155
testGet          20     1561.831     ± 70.876
testRemove       20     0.006        ± 0.001

Podemos ver pelas pontuações que adicionar e remover elementos emLinkedList é bastante rápido.

Além disso, há uma lacuna de desempenho significativa entre as operações adicionar / remover e obter / conter.

4. Map

Com as versões mais recentes do JDK, estamos testemunhando uma melhoria significativa de desempenho para as implementações deMap, como a substituição deLinkedList pela estrutura de nó de árvore balanceada emHashMap, LinkedHashMap implementações externas. This shortens the element lookup worst-case scenario from O(n) to O(log(n)) time during the HashMap collisions.

No entanto, se implementarmos os métodos.equals()e.hashcode() adequados, as colisões são improváveis.

Para saber mais sobreHashMap colisões, verifiquethis write-up. From the write-up, we can also learn, that storing and retrieving elements from the HashMap takes constant O(1) time.

4.1. TestandoO(1) Operações

Vamos mostrar alguns números reais. Primeiro, paraHashMap:

Benchmark                         Mode  Cnt  Score   Error
HashMapBenchmark.testContainsKey  avgt   20  0.009 ± 0.002
HashMapBenchmark.testGet          avgt   20  0.011 ± 0.001
HashMapBenchmark.testPut          avgt   20  0.019 ± 0.002
HashMapBenchmark.testRemove       avgt   20  0.010 ± 0.001

As we see, the numbers prove the O(1) constant time for running the methods listed above. Agora, vamos fazer uma comparação das pontuações de teste deHashMap com as outras pontuações de instância deMap.

Para todos os métodos listados,we have O(1) for HashMap, LinkedHashMap, IdentityHashMap, WeakHashMap, EnumMap and ConcurrentHashMap.

Vamos apresentar os resultados das pontuações dos testes restantes na forma de uma tabela:

Benchmark      LinkedHashMap  IdentityHashMap  WeakHashMap  ConcurrentHashMap
testContainsKey    0.008         0.009          0.014          0.011
testGet            0.011         0.109          0.019          0.012
testPut            0.020         0.013          0.020          0.031
testRemove         0.011         0.115          0.021          0.019

A partir dos números de saída, podemos confirmar as alegações de complexidade de tempo deO(1).

4.2. TestandoO(log(n)) Operações

Para a estrutura de árvoreTreeMap and ConcurrentSkipListMap the put(), get(), remove(), containsKey()  operations time is O(log(n)).

[.pl-smi] #Aqui,we want to make sure that our performance tests will run approximately in logarithmic time. Por esse motivo, inicializamos os mapas com n=1000, 10,000, 100,000, 1,000,000itens continuamente. #

Nesse caso, estamos interessados ​​no tempo total de execução:

items count (n)         1000      10,000     100,000   1,000,000
all tests total score   00:03:17  00:03:17   00:03:30  00:05:27

[.pl-smi] #Quandon=1000 temos o total de00:03:17 milissegundos de tempo de execução. n=10,000 o tempo está quase inalterado00:03:18 ms. n=100,000 tem um pequeno aumento00:03:30. E, finalmente, quandon=1,000,000, a execução é concluída em00:05:27 ms. #

Depois de comparar os números do tempo de execução com a funçãolog(n) de cadan, podemos confirmar que a correlação de ambas as funções é compatível.

5. Set

Geralmente,Set é uma coleção de elementos únicos. Aqui, vamos examinar as implementaçõesHashSet,LinkedHashSet,EnumSet, TreeSet, CopyOnWriteArraySet,eConcurrentSkipListSet da interfaceSet.

Para entender melhor os detalhes internos deHashSet,this guide está aqui para ajudar.

Agora, vamos pular para apresentar os números da complexidade do tempo. For HashSetLinkedHashSet, and EnumSet the add(), remove() and contains() operations cost constant O(1) time. Thanks to the internal HashMap implementation.

Da mesma forma, o TreeSet has O(log(n)) time complexity para as operações listadas para o grupo anterior. Isso se deve à implementação deTreeMap. A complexidade de tempo paraConcurrentSkipListSet também éO(log(n)) tempo, pois é baseada na estrutura de dados da lista de ignorar.

ParaCopyOnWriteArraySet,, os métodosadd(), remove() areiacontains() têm complexidade de tempo médio O (n).

5.1. Métodos de teste

Agora, vamos pular para nossos testes de benchmark:

@Benchmark
public boolean testAdd(SetBenchMark.MyState state) {
    return state.employeeSet.add(state.employee);
}

@Benchmark
public Boolean testContains(SetBenchMark.MyState state) {
    return state.employeeSet.contains(state.employee);
}

@Benchmark
public boolean testRemove(SetBenchMark.MyState state) {
    return state.employeeSet.remove(state.employee);
}

Além disso, deixamos as configurações de benchmark restantes como estão.

5.2. Comparando os Números

Vamos ver o comportamento da pontuação de execução do tempo de execução paraHashSeteLinkedHashSet cortando itens den = 1000; 10,000; 100,000.

ParaHashSet, , os números são:

Benchmark      1000    10,000    100,000
.add()         0.026   0.023     0.024
.remove()      0.009   0.009     0.009
.contains()    0.009   0.009     0.010

Da mesma forma, os resultados paraLinkedHashSet são:

Benchmark      1000    10,000    100,000
.add()         0.022   0.026     0.027
.remove()      0.008   0.012     0.009
.contains()    0.008   0.013     0.009

Como vemos, as pontuações permanecem quase as mesmas para cada operação. Ainda mais, quando os comparamos com os resultados de teste deHashMap, eles parecem iguais também.

Como resultado, confirmamos que todos os métodos testados são executados em tempoO(1) constante.

6. Conclusão

Neste artigo,we present the time complexity of the most common implementations of the Java data structures.

Separadamente, mostramos o desempenho real do tempo de execução de cada tipo de coleção através dos testes de benchmark da JVM. Também comparamos o desempenho das mesmas operações em diferentes coleções. Como resultado, aprendemos a escolher a coleção certa que atenda às nossas necessidades.

Como de costume, o código completo deste artigo está disponívelover on GitHub.