1. Gambaran keseluruhan
Peta secara semula jadi merupakan salah satu gaya koleksi Java yang paling meluas.
Dan yang penting, HashMap bukanlah implementasi yang selamat untuk thread, sementara Hashtable memberikan keselamatan thread dengan menyegerakkan operasi.
Walaupun Hashtable selamat di benang, ia tidak begitu cekap. Peta lain yang disegerakkan sepenuhnya , Collections.synchronizedMap, juga tidak menunjukkan kecekapan yang hebat. Sekiranya kita mahukan keselamatan utas dengan hasil yang tinggi di bawah kesesuaian yang tinggi, pelaksanaan ini bukanlah cara yang tepat.
Untuk menyelesaikan masalah tersebut, Java Collections Framework memperkenalkan ConcurrentMap di Java 1.5 .
Perbincangan berikut berdasarkan Java 1.8 .
2. Peta Bersama
ConcurrentMap adalah lanjutan antara muka Peta . Ini bertujuan untuk menyediakan struktur dan panduan untuk menyelesaikan masalah mendamaikan throughput dengan keselamatan benang.
Dengan mengesampingkan beberapa kaedah lalai antara muka, ConcurrentMap memberikan garis panduan untuk pelaksanaan yang sahih untuk menyediakan operasi atom yang keselamatan dan ingatan yang konsisten.
Beberapa pelaksanaan lalai diganti, melumpuhkan sokongan kunci / nilai nol :
- getOrDefault
- untuk setiap
- gantiSemua
- mengiraIfAbsent
- computeIfPresent
- pengiraan
- bergabung
API berikut juga diganti untuk menyokong keberanian, tanpa pelaksanaan antara muka lalai:
- putIfAbsent
- buang
- ganti (kunci, nilai lama, nilai baru)
- ganti (kunci, nilai)
Tindakan selebihnya secara langsung diwarisi dengan asasnya selaras dengan Peta .
3. Peta Bersama
ConcurrentHashMap adalah pelaksanaan ConcurrentMap yang sudah siap sedia .
Untuk prestasi yang lebih baik, ia terdiri daripada pelbagai simpul sebagai baldi meja (dulu segmen meja sebelum Java 8 ) di bawah tudung, dan terutama menggunakan operasi CAS semasa mengemas kini.
Baldi meja dimulakan dengan malas, semasa penyisipan pertama. Setiap baldi boleh dikunci secara bebas dengan mengunci simpul pertama di dalam baldi. Operasi baca tidak menyekat, dan kemas kini kandungan dikurangkan.
Jumlah segmen yang diperlukan adalah relatif dengan jumlah utas yang mengakses jadual sehingga kemas kini yang sedang berlangsung setiap segmen tidak lebih dari satu waktu.
Sebelum Java 8 , jumlah "segmen" yang diperlukan relatif terhadap jumlah utas yang mengakses tabel sehingga pembaruan yang sedang berlangsung setiap segmen tidak lebih dari satu waktu.
Itulah sebabnya konstruktor, berbanding dengan HashMap , memberikan argumen level concurrencyLevel tambahan untuk mengawal bilangan untaian anggaran yang akan digunakan:
public ConcurrentHashMap(
public ConcurrentHashMap( int initialCapacity, float loadFactor, int concurrencyLevel)
Dua argumen lain: initialCapacity dan loadFactor berfungsi sama seperti HashMap .
Namun, sejak Java 8 , konstruktor hanya ada untuk keserasian mundur: parameter hanya dapat mempengaruhi ukuran awal peta .
3.1. Keselamatan Benang
ConcurrentMap menjamin konsistensi memori pada operasi kunci / nilai dalam persekitaran multi-threading.
Tindakan dalam utas sebelum meletakkan objek ke dalam ConcurrentMap sebagai kunci atau nilai berlaku-sebelum tindakan selepas akses atau penghapusan objek itu di utas lain.
Untuk mengesahkan, mari kita lihat memori yang tidak konsisten:
@Test public void givenHashMap_whenSumParallel_thenError() throws Exception { Map map = new HashMap(); List sumList = parallelSum100(map, 100); assertNotEquals(1, sumList .stream() .distinct() .count()); long wrongResultCount = sumList .stream() .filter(num -> num != 100) .count(); assertTrue(wrongResultCount > 0); } private List parallelSum100(Map map, int executionTimes) throws InterruptedException { List sumList = new ArrayList(1000); for (int i = 0; i < executionTimes; i++) { map.put("test", 0); ExecutorService executorService = Executors.newFixedThreadPool(4); for (int j = 0; j { for (int k = 0; k value + 1 ); }); } executorService.shutdown(); executorService.awaitTermination(5, TimeUnit.SECONDS); sumList.add(map.get("test")); } return sumList; }
Untuk setiap tindakan map.computeIfPresent secara selari, HashMap tidak memberikan pandangan yang konsisten mengenai apa yang seharusnya menjadi nilai bilangan bulat sekarang, yang membawa kepada hasil yang tidak konsisten dan tidak diingini.
Bagi ConcurrentHashMap , kita dapat hasil yang konsisten dan betul:
@Test public void givenConcurrentMap_whenSumParallel_thenCorrect() throws Exception { Map map = new ConcurrentHashMap(); List sumList = parallelSum100(map, 1000); assertEquals(1, sumList .stream() .distinct() .count()); long wrongResultCount = sumList .stream() .filter(num -> num != 100) .count(); assertEquals(0, wrongResultCount); }
3.2. Kekunci / Nilai Null
Kebanyakan API s disediakan oleh ConcurrentMap tidak membenarkan null kunci atau nilai, sebagai contoh:
@Test(expected = NullPointerException.class) public void givenConcurrentHashMap_whenPutWithNullKey_thenThrowsNPE() { concurrentMap.put(null, new Object()); } @Test(expected = NullPointerException.class) public void givenConcurrentHashMap_whenPutNullValue_thenThrowsNPE() { concurrentMap.put("test", null); }
Namun, untuk tindakan komputasi * dan penggabungan , nilai yang dikira boleh menjadi nol , yang menunjukkan pemetaan nilai-kunci dikeluarkan jika ada atau tetap tidak ada jika sebelumnya tidak ada .
@Test public void givenKeyPresent_whenComputeRemappingNull_thenMappingRemoved() { Object oldValue = new Object(); concurrentMap.put("test", oldValue); concurrentMap.compute("test", (s, o) -> null); assertNull(concurrentMap.get("test")); }
3.3. Sokongan Aliran
Java 8 provides Stream support in the ConcurrentHashMap as well.
Unlike most stream methods, the bulk (sequential and parallel) operations allow concurrent modification safely. ConcurrentModificationException won't be thrown, which also applies to its iterators. Relevant to streams, several forEach*, search, and reduce* methods are also added to support richer traversal and map-reduce operations.
3.4. Performance
Under the hood, ConcurrentHashMap is somewhat similar to HashMap, with data access and update based on a hash table (though more complex).
And of course, the ConcurrentHashMap should yield much better performance in most concurrent cases for data retrieval and update.
Let's write a quick micro-benchmark for get and put performance and compare that to Hashtable and Collections.synchronizedMap, running both operations for 500,000 times in 4 threads.
@Test public void givenMaps_whenGetPut500KTimes_thenConcurrentMapFaster() throws Exception { Map hashtable = new Hashtable(); Map synchronizedHashMap = Collections.synchronizedMap(new HashMap()); Map concurrentHashMap = new ConcurrentHashMap(); long hashtableAvgRuntime = timeElapseForGetPut(hashtable); long syncHashMapAvgRuntime = timeElapseForGetPut(synchronizedHashMap); long concurrentHashMapAvgRuntime = timeElapseForGetPut(concurrentHashMap); assertTrue(hashtableAvgRuntime > concurrentHashMapAvgRuntime); assertTrue(syncHashMapAvgRuntime > concurrentHashMapAvgRuntime); } private long timeElapseForGetPut(Map map) throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(4); long startTime = System.nanoTime(); for (int i = 0; i { for (int j = 0; j < 500_000; j++) { int value = ThreadLocalRandom .current() .nextInt(10000); String key = String.valueOf(value); map.put(key, value); map.get(key); } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); return (System.nanoTime() - startTime) / 500_000; }
Keep in mind micro-benchmarks are only looking at a single scenario and aren't always a good reflection of real world performance.
That being said, on an OS X system with an average dev system, we're seeing an average sample result for 100 consecutive runs (in nanoseconds):
Hashtable: 1142.45 SynchronizedHashMap: 1273.89 ConcurrentHashMap: 230.2
In a multi-threading environment, where multiple threads are expected to access a common Map, the ConcurrentHashMap is clearly preferable.
However, when the Map is only accessible to a single thread, HashMap can be a better choice for its simplicity and solid performance.
3.5. Pitfalls
Retrieval operations generally do not block in ConcurrentHashMap and could overlap with update operations. So for better performance, they only reflect the results of the most recently completed update operations, as stated in the official Javadoc.
There are several other facts to bear in mind:
- results of aggregate status methods including size, isEmpty, and containsValue are typically useful only when a map is not undergoing concurrent updates in other threads:
@Test public void givenConcurrentMap_whenUpdatingAndGetSize_thenError() throws InterruptedException { Runnable collectMapSizes = () -> { for (int i = 0; i { for (int i = 0; i < MAX_SIZE; i++) { concurrentMap.put(String.valueOf(i), i); } }; executorService.execute(updateMapData); executorService.execute(collectMapSizes); executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); assertNotEquals(MAX_SIZE, mapSizes.get(MAX_SIZE - 1).intValue()); assertEquals(MAX_SIZE, concurrentMap.size()); }
If concurrent updates are under strict control, aggregate status would still be reliable.
Although these aggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposes.
Note that usage of size() of ConcurrentHashMap should be replaced by mappingCount(), for the latter method returns a long count, although deep down they are based on the same estimation.
- hashCode matters: note that using many keys with exactly the same hashCode() is a sure way to slow down a performance of any hash table.
To ameliorate impact when keys are Comparable, ConcurrentHashMap may use comparison order among keys to help break ties. Still, we should avoid using the same hashCode() as much as we can.
- iterators are only designed to use in a single thread as they provide weak consistency rather than fast-fail traversal, and they will never throw ConcurrentModificationException.
- the default initial table capacity is 16, and it's adjusted by the specified concurrency level:
public ConcurrentHashMap( int initialCapacity, float loadFactor, int concurrencyLevel) { //... if (initialCapacity < concurrencyLevel) { initialCapacity = concurrencyLevel; } //... }
- caution on remapping functions: though we can do remapping operations with provided compute and merge* methods, we should keep them fast, short and simple, and focus on the current mapping to avoid unexpected blocking.
- keys in ConcurrentHashMap are not in sorted order, so for cases when ordering is required, ConcurrentSkipListMap is a suitable choice.
4. ConcurrentNavigableMap
For cases when ordering of keys is required, we can use ConcurrentSkipListMap, a concurrent version of TreeMap.
As a supplement for ConcurrentMap, ConcurrentNavigableMap supports total ordering of its keys (in ascending order by default) and is concurrently navigable. Methods that return views of the map are overridden for concurrency compatibility:
- subMap
- headMap
- tailMap
- subMap
- headMap
- tailMap
- descendingMap
keySet() views' iterators and spliterators are enhanced with weak-memory-consistency:
- navigableKeySet
- keySet
- descendingKeySet
5. ConcurrentSkipListMap
Previously, we have covered NavigableMap interface and its implementation TreeMap. ConcurrentSkipListMap can be seen a scalable concurrent version of TreeMap.
In practice, there's no concurrent implementation of the red-black tree in Java. A concurrent variant of SkipLists is implemented in ConcurrentSkipListMap, providing an expected average log(n) time cost for the containsKey, get, put and remove operations and their variants.
In addition to TreeMap‘s features, key insertion, removal, update and access operations are guaranteed with thread-safety. Here's a comparison to TreeMap when navigating concurrently:
@Test public void givenSkipListMap_whenNavConcurrently_thenCountCorrect() throws InterruptedException { NavigableMap skipListMap = new ConcurrentSkipListMap(); int count = countMapElementByPollingFirstEntry(skipListMap, 10000, 4); assertEquals(10000 * 4, count); } @Test public void givenTreeMap_whenNavConcurrently_thenCountError() throws InterruptedException { NavigableMap treeMap = new TreeMap(); int count = countMapElementByPollingFirstEntry(treeMap, 10000, 4); assertNotEquals(10000 * 4, count); } private int countMapElementByPollingFirstEntry( NavigableMap navigableMap, int elementCount, int concurrencyLevel) throws InterruptedException { for (int i = 0; i < elementCount * concurrencyLevel; i++) { navigableMap.put(i, i); } AtomicInteger counter = new AtomicInteger(0); ExecutorService executorService = Executors.newFixedThreadPool(concurrencyLevel); for (int j = 0; j { for (int i = 0; i < elementCount; i++) { if (navigableMap.pollFirstEntry() != null) { counter.incrementAndGet(); } } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); return counter.get(); }
Penjelasan lengkap mengenai masalah prestasi di belakang tabir adalah di luar ruang lingkup artikel ini. Perinciannya boleh didapati di Javadoc ConcurrentSkipListMap , yang terletak di bawah java / util / serentak dalam fail src.zip .
6. Kesimpulannya
Dalam artikel ini, kami terutamanya memperkenalkan antara muka ConcurrentMap dan ciri-ciri ConcurrentHashMap dan dibahas pada ConcurrentNavigableMap yang memerlukan pesanan kunci.
Kod sumber lengkap untuk semua contoh yang digunakan dalam artikel ini boleh didapati dalam projek GitHub.