1. Gambaran keseluruhan
Dalam tutorial ini, kita akan membincangkan prestasi koleksi yang berbeza dari Java Collection API . Apabila kita membincangkan koleksi, kita biasanya memikirkan struktur data Senarai, Peta, dan Set dan pelaksanaannya yang biasa.
Pertama sekali, kita akan melihat wawasan kerumitan Big-O untuk operasi biasa, dan selepas itu, kita akan menunjukkan bilangan sebenar beberapa operasi pengumpulan masa berjalan.
2. Kerumitan Masa
Biasanya, ketika kita bercakap mengenai kerumitan waktu, kita merujuk kepada notasi Big-O . Ringkasnya, notasi menerangkan bagaimana masa untuk menjalankan algoritma berkembang dengan ukuran input.
Tulisan berguna boleh didapati untuk mengetahui lebih lanjut mengenai teori notasi Big-O atau contoh Java praktikal.
3. Senaraikan
Mari kita mulakan dengan senarai ringkas - yang merupakan koleksi tertib.
Di sini, kita akan melihat gambaran keseluruhan prestasi pelaksanaan ArrayList, LinkedList, dan CopyOnWriteArrayList .
3.1. Senarai Array
The ArrayList di Jawa disokong oleh array . Ini membantu memahami logik dalaman pelaksanaannya. Panduan yang lebih komprehensif untuk ArrayList terdapat dalam artikel ini.
Oleh itu, mari kita fokus pada kerumitan masa operasi biasa, pada tahap tinggi:
- menambah () - mengambil O (1) masa
- menambah (indeks, elemen) - dalam larian purata O (n) masa
- get () - selalu merupakanoperasi O (1) masa yang tetap
- remove () - berjalan dalam masa O (n) linear. Kita harus mengulangi keseluruhan array untuk mencari elemen yang layak dikeluarkan
- indexOf () - juga berjalan dalam masa linear. Ia berulang melalui susunan dalaman dan memeriksa setiap elemen satu demi satu. Jadi kerumitan masa untuk operasi ini sentiasa memerlukan O (n) masa
- mengandung () - pelaksanaan berdasarkan indexOf () . Jadi ia juga akan berjalan dalam O (n) masa
3.2. CopyOnWriteArrayList
Pelaksanaan ini daripada Senarai muka adalah sangat berguna apabila bekerja dengan aplikasi pelbagai Thread . Ia selamat untuk benang dan dijelaskan dengan baik dalam panduan ini di sini.
Berikut adalah gambaran keseluruhan prestasi Big-O untuk CopyOnWriteArrayList :
- tambah () - bergantung pada kedudukan yang kita tambah nilai, jadi kerumitannya adalah O (n)
- get () - adakah O (1) operasi masa tetap
- mengeluarkan () - mengambil O (n) masa
- mengandungi () - begitu juga kerumitannya adalah O (n)
Seperti yang kita lihat, penggunaan koleksi ini sangat mahal kerana ciri prestasi kaedah tambah () .
3.3. Senarai Terpaut
LinkedList adalah struktur data linier yang terdiri daripada nod yang memegang medan data dan rujukan ke nod lain . Untuk lebih banyakciri dan keupayaan LinkedList , lihat artikel ini di sini.
Mari tunjukkan anggaran purata masa yang kita perlukan untuk melakukan beberapa operasi asas:
- tambah () - menyokongpenyisipan masa tetap O (1) pada kedudukan apa pun
- get () - mencari elemen memerlukan O (n) masa
- remove () - menghapus elemen juga memerlukanoperasi O (1) , kerana kami memberikan kedudukan elemen
- mengandungi () - juga mempunyaikerumitan masa O (n)
3.4. Memanaskan JVM
Sekarang, untuk membuktikan teori, mari bermain dengan data sebenar. Untuk lebih tepat, kami akan membentangkan hasil ujian JMH (Java Microbenchmark Harness) operasi pengumpulan yang paling biasa .
Sekiranya anda tidak biasa dengan alat JMH, lihat panduan berguna ini.
Pertama, kami membentangkan parameter utama ujian penanda aras kami:
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 10) public class ArrayListBenchmark { }
Kemudian, kami menetapkan nombor lelaran pemanasan kepada 10 . Kami juga ingin melihat purata masa berjalan hasil kami dipaparkan dalam mikrodetik
3.5. Ujian Penanda Aras
Sekarang, sudah tiba masanya untuk menjalankan ujian prestasi kami. Pertama, kita mulakan dengan ArrayList :
@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); } }
Di dalam ArrayListBenchmark kami , kami menambahkan kelas Negeri untuk menyimpan data awal.
Di sini, kami membuat objek ArrayList of Employee . Selepas itu, kami memulakannya dengan 100.000 item di dalam kaedah setUp () . The @State menunjukkan bahawa ujian @Benchmark mempunyai akses penuh ke pemboleh ubah yang dinyatakan di dalamnya dalam urutan yang sama.
Akhirnya, sudah tiba masanya untuk menambahkan ujian penanda aras untuk kaedah add (), mengandungi (), indexOf (), remove (), dan get () :
@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. Keputusan ujian
Semua hasil ditunjukkan dalam mikrodetik:
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. We can also clearly see the huge difference between the testAdd(), testGet() method scores from the rest of the results. Adding an element takes 2.296 microseconds and getting one is 0.007-microsecond operation.
While searching or removing an element roughly costs 700 microseconds. These numbers are the proof of the theoretical part, where we learned that add(), and get() has O(1) time complexity and the other methods are O(n). n=10.000 elements in our example.
Likewise, we can write the same tests for CopyOnWriteArrayList collection. All we need is to replace the ArrayList in employeeList with the CopyOnWriteArrayList instance.
Here are the results of the benchmark test:
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
Here, again, the numbers confirm the theory. As we can see, testGet() on average runs in 0.006 ms which we can consider as O(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.
Now, it's LinkedList time:
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
We can see from the scores, that adding and removing elements in LinkedList are quite fast.
Furthermore, there's a significant performance gap between add/remove and get/contains operations.
4. Map
With the latest JDK versions, we're witnessing significant performance improvement for Map implementations, such as replacing the LinkedList with the balanced tree node structure in HashMap, LinkedHashMap internal implementations. This shortens the element lookup worst-case scenario from O(n) to O(log(n)) time during the HashMap collisions.
However, if we implement proper .equals() and .hashcode() methods collisions are unlikely.
To learn more about HashMap collisions check out this 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. Testing O(1) Operations
Let's show some actual numbers. First, for the HashMap:
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. Now, let's do a comparison of the HashMap test scores with the other Map instance scores.
For all of the listed methods, we have O(1) for HashMap, LinkedHashMap, IdentityHashMap, WeakHashMap, EnumMap and ConcurrentHashMap.
Let's present the results of the remaining test scores in form of one table:
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
From the output numbers, we can confirm the claims of O(1) time complexity.
4.2. Testing O(log(n)) Operations
For the tree structure TreeMap and ConcurrentSkipListMap the put(), get(), remove(), containsKey() operations time is O(log(n)).
Here, we want to make sure that our performance tests will run approximately in logarithmic time. For that reason, we initialize the maps with n=1000, 10,000, 100,000, 1,000,000 items continuously.
In this case, we're interested in the total time of execution:
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
When n=1000 we have the total of 00:03:17 milliseconds execution time. n=10,000 the time is almost unchanged 00:03:18 ms. n=100,000 has minor increase 00:03:30. And finally, when n=1,000,000 the run completes in 00:05:27 ms.
After comparing the runtime numbers with the log(n) function of each n, we can confirm that the correlation of both functions matches.
5. Set
Generally, Set is a collection of unique elements. Here, we're going to examine the HashSet, LinkedHashSet, EnumSet, TreeSet, CopyOnWriteArraySet, and ConcurrentSkipListSet implementations of the Set interface.
To better understand the internals of the HashSet, this guide is here to help.
Now, let's jump ahead to present the time complexity numbers. For HashSet, LinkedHashSet, and EnumSet the add(), remove() and contains() operations cost constant O(1) time. Thanks to the internal HashMap implementation.
Likewise, the TreeSet has O(log(n)) time complexity for the operations listed for the previous group. That's because of the TreeMap implementation. The time complexity for ConcurrentSkipListSet is also O(log(n)) time, as it is based in skip list data structure.
For CopyOnWriteArraySet, the add(), remove() and contains() methods have O(n) average time complexity.
5.1. Test Methods
Now, let's jump to our benchmark tests:
@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); }
Furthermore, we leave the remaining benchmark configurations as they are.
5.2. Comparing the Numbers
Let's see the behavior of the runtime execution score for HashSet and LinkedHashSet having n = 1000; 10,000; 100,000 items.
For the HashSet, the numbers are:
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
Similarly, the results for LinkedHashSet are:
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
Seperti yang kita lihat, skor tetap hampir sama untuk setiap operasi. Lebih-lebih lagi, apabila kita membandingkannya dengan output ujian HashMap , ia juga kelihatan sama.
Hasilnya, kami mengesahkan bahawa semua kaedah yang diuji berjalan dalam masa O (1) tetap .
6. Kesimpulannya
Dalam artikel ini, kami menyajikan kerumitan waktu implementasi struktur data Java yang paling umum.
Secara berasingan, kami menunjukkan prestasi jangka masa sebenar setiap jenis koleksi melalui ujian penanda aras JVM. Kami juga membandingkan prestasi operasi yang sama dalam koleksi yang berbeza. Hasilnya, kami belajar memilih koleksi yang sesuai dengan keperluan kami.
Seperti biasa, kod lengkap untuk artikel ini terdapat di GitHub.