简体   繁体   English

java中如何证明HashMap不是线程安全的

[英]How to prove that HashMap in java is not thread-safe

I'm working on an application, that has uses a HashMap to share state.我正在开发一个应用程序,它使用HashMap来共享状态。 I need to prove via unit tests that it will have problems in a multi-threaded environment.我需要通过单元测试证明它在多线程环境中会有问题。

I tried to check the state of the application in a single thread environment and in a multi-threaded environment via checking the size and elements of the HashMap in both of them.我试图通过检查两者中HashMap的大小和元素来检查应用程序在单线程环境和多线程环境中的状态。 But seems this doesn't help, the state is always the same.但这似乎无济于事,状态始终相同。

Are there any other ways to prove it or prove that an application that performs operations on the map works well with concurrent requests?有没有其他方法可以证明它或证明在地图上执行操作的应用程序可以很好地处理并发请求?

This is quite easy to prove.这很容易证明。

Shortly不久

A hash map is based on an array, where each item represents a bucket.哈希映射基于一个数组,其中每个项目代表一个桶。 As more keys are added, the buckets grow and at a certain threshold the array is recreated with a bigger size so that its buckets are spread more evenly (performance considerations).随着更多键的添加,存储桶会增长,并且在某个阈值处以更大的大小重新创建阵列,以便其存储桶分布更均匀(性能考虑)。 During the array recreation, the array becomes empty, which results in empty result for the caller, until the recreation completes.在数组重新创建期间,数组变为空,这导致调用者的结果为空,直到重新创建完成。

Details and Proof细节和证明

It means that sometimes HashMap#put() will internally call HashMap#resize() to make the underlying array bigger.这意味着有时HashMap#put()会在内部调用HashMap#resize()以使底层数组更大。

HashMap#resize() assigns the table field a new empty array with a bigger capacity and populates it with the old items. HashMap#resize()table字段分配一个容量更大的新空数组,并用旧项目填充它。 While this population happens, the underlying array doesn't contain all of the old items and calling HashMap#get() with an existing key may return null .当这种填充发生时,底层数组不包含所有旧项,并且使用现有键调用HashMap#get()可能会返回null

The following code demonstrates that.下面的代码演示了这一点。 You are very likely to get the exception that will mean the HashMap is not thread safe.您很可能会遇到异常,这意味着HashMap不是线程安全的。 I chose the target key as 65 535 - this way it will be the last element in the array, thus being the last element during re-population which increases the possibility of getting null on HashMap#get() (to see why, see HashMap#put() implementation).我将目标键选择为65 535 - 这样它将成为数组中的最后一个元素,因此是重新填充期间的最后一个元素,这增加了在HashMap#get()上获得null的可能性(要了解原因,请参阅HashMap#put()实现)。

final Map<Integer, String> map = new HashMap<>();

final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
final String targetValue = "v";
map.put(targetKey, targetValue);

new Thread(() -> {
    IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
}).start();


while (true) {
    if (!targetValue.equals(map.get(targetKey))) {
        throw new RuntimeException("HashMap is not thread safe.");
    }
}

One thread adds new keys to the map.一个线程向地图添加新键。 The other thread constantly checks the targetKey is present.另一个线程不断检查targetKey是否存在。

If count those exceptions, I get around 200 000 .如果算上这些例外,我会得到大约200 000

It is hard to simulate Race but looking at the OpenJDK source for put() method of HashMap:很难模拟 Race 但查看 OpenJDK 源代码的 HashMap 的put()方法:

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);

    //Operation 1       
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    } 

    //Operation 2
    modCount++;

    //Operation 3
    addEntry(hash, key, value, i);
    return null;
}

As you can see put() involves 3 operations which are not synchronized .如您所见, put()涉及 3 个不同步的操作 And compound operations are non thread safe .并且复合操作是非线程安全的 So theoretically it is proven that HashMap is not thread safe.所以理论上证明HashMap不是线程安全的。

I need to prove via unit tests that it will have problems in multithread environment.我需要通过单元测试证明它在多线程环境中会有问题。

This is going to be tremendously hard to do.这将是非常难以做到的。 Race conditions are very hard to demonstrate.竞争条件很难证明。 You could certainly write a program which does puts and gets into a HashMap in a large number of threads but logging, volatile fields, other locks, and other timing details of your application may make it extremely hard to force your particular code to fail.您当然可以编写一个程序,该程序在大量线程中执行放入和获取 HashMap 的操作,但是您的应用程序的日志记录、 volatile字段、其他锁和其他时间细节可能会很难强制您的特定代码失败。


Here's a stupid little HashMap failure test case.这是一个愚蠢的小HashMap失败测试用例。 It fails because it times out when the threads go into an infinite loop because of memory corruption of HashMap .它失败是因为当线程由于HashMap的内存损坏而进入无限循环时超时。 However, it may not fail for you depending on number of cores and other architecture details.但是,根据内核数量和其他架构细节,它可能不会失败。

@Test(timeout = 10000)
public void runTest() throws Exception {
    final Map<Integer, String> map = new HashMap<Integer, String>();
    ExecutorService pool = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 10; i++) {
        pool.submit(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    map.put(i, "wow");
                }
            }
        });
    }
    pool.shutdown();
    pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
}

Its an old thread.它是一个旧线程。 But just pasting my sample code which is able to demonstrate the problems with hashmap.但是只需粘贴我的示例代码,它就可以演示 hashmap 的问题。

Take a look at the below code, we try to insert 30000 Items into the hashmap using 10 threads (3000 items per thread).看看下面的代码,我们尝试使用 10 个线程(每个线程 3000 个项目)将 30000 个项目插入到哈希图中。

So after all the threads are completed, you should ideally see that the size of hashmap should be 30000. But the actual output would be either an exception while rebuilding the tree or the final count is less than 30000 .因此,在所有线程完成后,您应该理想地看到 hashmap 的大小应为 30000。但实际输出将是重建树时的异常或最终计数小于 30000

class TempValue {
    int value = 3;

    @Override
    public int hashCode() {
        return 1; // All objects of this class will have same hashcode.
    }
}

public class TestClass {
    public static void main(String args[]) {
        Map<TempValue, TempValue> myMap = new HashMap<>();
        List<Thread> listOfThreads = new ArrayList<>();

        // Create 10 Threads
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {

                // Let Each thread insert 3000 Items
                for (int j = 0; j < 3000; j++) {
                    TempValue key = new TempValue();
                    myMap.put(key, key);
                }

            });
            thread.start();
            listOfThreads.add(thread);
        }

        for (Thread thread : listOfThreads) {
            thread.join();
        }
        System.out.println("Count should be 30000, actual is : " + myMap.size());
    }
}

Output 1 :输出 1:

Count should be 30000, actual is : 29486

Output 2 : (Exception)输出 2:(例外)

java.util.HashMap$Node cannot be cast to java.util.HashMap$TreeNodejava.lang.ClassCastException: java.util.HashMap$Node cannot be cast to java.util.HashMap$TreeNode
    at java.util.HashMap$TreeNode.moveRootToFront(HashMap.java:1819)
    at java.util.HashMap$TreeNode.treeify(HashMap.java:1936)
    at java.util.HashMap.treeifyBin(HashMap.java:771)
    at java.util.HashMap.putVal(HashMap.java:643)
    at java.util.HashMap.put(HashMap.java:611)
    at TestClass.lambda$0(TestClass.java:340)
    at java.lang.Thread.run(Thread.java:745)

However if you modify the line Map<TempValue, TempValue> myMap = new HashMap<>();但是,如果您修改Map<TempValue, TempValue> myMap = new HashMap<>(); to a ConcurrentHashMap the output is always 30000.对于ConcurrentHashMap ,输出始终为 30000。

Another Observation : In the above example the hashcode for all objects of TempValue class was the same(** ie, 1**).另一个观察:在上面的例子中, TempValue类的所有对象的哈希码都是相同的(** 即 1**)。 So you might be wondering, this issue with HashMap might occur only in case there is a collision (due to hashcode).所以你可能想知道,HashMap 的这个问题可能只有在发生冲突的情况下才会发生(由于哈希码)。 I tried another example.我尝试了另一个例子。

Modify the TempValue class to将 TempValue 类修改为

class TempValue {
    int value = 3;
}

Now re-execute the same code.现在重新执行相同的代码。
Out of every 5 runs, I see 2-3 runs still give a different output than 30000 .在每 5 次运行中,我看到 2-3 次运行仍然给出与 30000 不同的输出
So even if you usually don't have much collisions, you might still end up with an issue.因此,即使您通常不会发生太多冲突,您最终仍可能会遇到问题。 (Maybe due to rebuilding of HashMap, etc.) (可能是因为重建了HashMap等)

Overall these examples show the issue with HashMap which ConcurrentHashMap handles.总的来说,这些示例显示了 ConcurrentHashMap 处理的 HashMap 问题。

Is reading the API docs enough?阅读 API 文档就够了吗? There is a statement in there:里面有一句话:

Note that this implementation is not synchronized.请注意,此实现不是同步的。 If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, it must be synchronized externally.如果多个线程并发访问一个散列映射,并且至少有一个线程在结构上修改了映射,则必须在外部进行同步。 (A structural modification is any operation that adds or deletes one or more mappings; merely changing the value associated with a key that an instance already contains is not a structural modification.) This is typically accomplished by synchronizing on some object that naturally encapsulates the map. (结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已包含的键关联的值不是结构修改。)这通常是通过同步一些自然封装映射的对象来完成的. If no such object exists, the map should be "wrapped" using the Collections.synchronizedMap method.如果不存在这样的对象,则应使用 Collections.synchronizedMap 方法“包装”地图。 This is best done at creation time, to prevent accidental unsynchronized access to the map:这最好在创建时完成,以防止对地图的意外不同步访问:

The problem with thread safety is that it's hard to prove through a test.线程安全的问题在于很难通过测试来证明。 It could be fine most of the times.可能是最细的时代。 Your best bet would be to just run a bunch of threads that are getting/putting and you'll probably get some concurrency errors.最好的办法是只运行一堆正在获取/放置的线程,你可能会遇到一些并发错误。

I suggest using a ConcurrentHashMap and trust that the Java team saying that HashMap is not synchronized is enough.我建议使用ConcurrentHashMap并相信 Java 团队说HashMap不同步就足够了。

Are there any other ways to prove it?还有其他方法可以证明吗?

How about reading thedocumentation (and paying attention to the emphasized "must"):如何阅读文档(并注意强调的“必须”):

If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, it must be synchronized externally如果多个线程并发访问一个哈希映射,并且至少有一个线程在结构上修改了映射,则必须进行外部同步

If you are going to attempt to write a unit test that demonstrates incorrect behavior, I recommend the following:如果您要尝试编写一个演示错误行为的单元测试,我建议您执行以下操作:

  • Create a bunch of keys that all have the same hashcode (say 30 or 40)创建一堆具有相同哈希码的键(比如 30 或 40)
  • Add values to the map for each key为每个键向地图添加值
  • Spawn a separate thread for the key, which has an infinite loop that (1) asserts that the key is present int the map, (2) removes the mapping for that key, and (3) adds the mapping back.为键生成一个单独的线程,它有一个无限循环,(1) 断言键存在于映射中,(2) 删除该键的映射,以及 (3) 重新添加映射。

If you're lucky, the assertion will fail at some point, because the linked list behind the hash bucket will be corrupted.如果幸运的话,断言会在某个时候失败,因为哈希桶后面的链表将被破坏。 If you're unlucky, it will appear that HashMap is indeed threadsafe despite the documentation.如果您不走运,尽管有文档说明,但HashMap似乎确实是线程安全的。

It may be possible, but will never be a perfect test.这可能是可能的,但永远不会是一个完美的测试。 Race conditions are just too unpredictable.竞争条件太不可预测了。 That being said, I wrote a similar type of test to help fix a threading issue with a proprietary data structure, and in my case, it was much easier to prove that something was wrong (before the fix) than to prove that nothing would go wrong (after the fix).话虽如此,我编写了一个类似类型的测试来帮助修复具有专有数据结构的线程问题,在我的情况下,证明有问题(在修复之前)比证明什么都不会发生要容易得多错误(修复后)。 You could probably construct a multi-threaded test that will eventually fail with sufficient time and the right parameters.您可能会构建一个多线程测试,如果有足够的时间和正确的参数,它最终会失败。

This post may be helpful in identifying areas to focus on in your test and has some other suggestions for optional replacements. 这篇文章可能有助于确定测试中要关注的领域,并为可选替换提供一些其他建议。

You can create multiple threads each adding an element to a hashmap and iterating over it.您可以创建多个线程,每个线程向哈希映射添加一个元素并对其进行迭代。 ie In the run method we have to use "put" and then iterate using iterator.即在 run 方法中,我们必须使用“put”,然后使用迭代器进行迭代。

For the case of HashMap we get ConcurrentModificationException while for ConcurrentHashMap we dont get.对于 HashMap 我们得到 ConcurrentModificationException 而对于 ConcurrentHashMap 我们没有得到。

Most probable race condition at java.util.HashMap implementation java.util.HashMap 实现中最可能的竞争条件

Most of hashMaps failing if we are trying to read values while resizing or rehashing step executing.如果我们在调整大小或重新哈希步骤执行时尝试读取值,则大多数 hashMap 都会失败。 Resizing and rehashing operation executed under certain conditions most commonly if exceed bucket threshold.如果超过存储桶阈值,则最常在某些条件下执行调整大小和重新散列操作。 This code proves that if I call resizing externally or If I put more element than threshold and tend to call resizing operation internally causes to some null read which shows that HashMap is not thread safe.这段代码证明,如果我在外部调用调整大小,或者如果我放置的元素多于阈值并且倾向于在内部调用调整大小操作会导致一些空读取,这表明 HashMap 不是线程安全的。 There should be more race condition but it is enough to prove it is not Thread Safe.应该有更多的竞争条件,但这足以证明它不是线程安全的。

Practically proof of race condition竞争条件的实际证明

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;

public class HashMapThreadSafetyTest {
    public static void main(String[] args) {
        try {
            (new HashMapThreadSafetyTest()).testIt();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void threadOperation(int number, Map<Integer, String> map) {
        map.put(number, "hashMapTest");
        while (map.get(number) != null);
        //If code passes to this line that means we did some null read operation which should not be
        System.out.println("Null Value Number: " + number);
    }
    private void callHashMapResizeExternally(Map<Integer, String> map)
            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Method method = map.getClass().getDeclaredMethod("resize");
        method.setAccessible(true);
        System.out.println("calling resize");
        method.invoke(map);
    }

    private void testIt()
            throws InterruptedException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
        final Map<Integer, String> map = new HashMap<>();
        IntStream.range(0, 12).forEach(i -> new Thread(() -> threadOperation(i, map)).start());
        Thread.sleep(60000);
        // First loop should not show any null value number untill calling resize method of hashmap externally.
        callHashMapResizeExternally(map);
        // First loop should fail from now on and should print some Null Value Numbers to the out.
        System.out.println("Loop count is 12 since hashmap initially created for 2^4 bucket and threshold of resizing"
                + "0.75*2^4 = 12 In first loop it should not fail since we do not resizing hashmap. "
                + "\n\nAfter 60 second: after calling external resizing operation with reflection should forcefully fail"
                + "thread safety");

        Thread.sleep(2000);
        final Map<Integer, String> map2 = new HashMap<>();
        IntStream.range(100, 113).forEach(i -> new Thread(() -> threadOperation(i, map2)).start());
        // Second loop should fail from now on and should print some Null Value Numbers to the out. Because it is
        // iterating more than 12 that causes hash map resizing and rehashing
        System.out.println("It should fail directly since it is exceeding hashmap initial threshold and it will resize"
                + "when loop iterate 13rd time");
    }
}

Example output示例输出

No null value should be printed untill thread sleep line passed
calling resize
Loop count is 12 since hashmap initially created for 2^4 bucket and threshold of resizing0.75*2^4 = 12 In first loop it should not fail since we do not resizing hashmap. 

After 60 second: after calling external resizing operation with reflection should forcefully failthread safety
Null Value Number: 11
Null Value Number: 5
Null Value Number: 6
Null Value Number: 8
Null Value Number: 0
Null Value Number: 7
Null Value Number: 2
It should fail directly since it is exceeding hashmap initial threshold and it will resizewhen loop iterate 13th time
Null Value Number: 111
Null Value Number: 100
Null Value Number: 107
Null Value Number: 110
Null Value Number: 104
Null Value Number: 106
Null Value Number: 109
Null Value Number: 105

Very Simple Solution to prove this证明这一点的非常简单的解决方案

Here is the code, which proves the Hashmap implementation is not thread safe.这是代码,它证明 Hashmap 实现不是线程安全的。 In this example, we are only adding the elements to map, not removing it from any method.在这个例子中,我们只是将元素添加到映射中,而不是从任何方法中删除它。

We can see that it prints the keys which are not in map, even though we have put the same key in map before doing get operation.我们可以看到它打印了不在 map 中的键,即使我们在执行 get 操作之前已经将相同的键放在 map 中。

package threads;

import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HashMapWorkingDemoInConcurrentEnvironment {

    private Map<Long, String> cache = new HashMap<>();

    public String put(Long key, String value) {
        return cache.put(key, value);
    }

    public String get(Long key) {
        return cache.get(key);
    }

    public static void main(String[] args) {

        HashMapWorkingDemoInConcurrentEnvironment cache = new HashMapWorkingDemoInConcurrentEnvironment();

        class Producer implements Callable<String> {

            private Random rand = new Random();

            public String call() throws Exception {
                while (true) {
                    long key = rand.nextInt(1000);
                    cache.put(key, Long.toString(key));
                    if (cache.get(key) == null) {
                        System.out.println("Key " + key + " has not been put in the map");
                    }
                }
            }
        }

        ExecutorService executorService = Executors.newFixedThreadPool(4);

        System.out.println("Adding value...");

        try  {
            for (int i = 0; i < 4; i++) {
                executorService.submit(new Producer());
            }
        } finally {
            executorService.shutdown();
        }
    }
}

Sample Output for a execution run执行运行的示例输出

Adding value...
Key 611 has not been put in the map
Key 978 has not been put in the map
Key 35 has not been put in the map
Key 202 has not been put in the map
Key 714 has not been put in the map
Key 328 has not been put in the map
Key 606 has not been put in the map
Key 149 has not been put in the map
Key 763 has not been put in the map

Its strange to see the values printed, that's why hashmap is not thread safe implementation working in concurrent environment.看到打印的值很奇怪,这就是为什么 hashmap 不是在并发环境中工作的线程安全实现。

There is a great tool open sourced by the OpenJDK team called JCStress which is used in the JDK for concurrency testing. OpenJDK 团队开源了一个很棒的工具 JCStress,它在 JDK 中用于并发测试。

https://github.com/openjdk/jcstress https://github.com/openjdk/jcstress

In one of its sample: https://github.com/openjdk/jcstress/blob/master/tests-custom/src/main/java/org/openjdk/jcstress/tests/collections/HashMapFailureTest.java在其示例之一: https : //github.com/openjdk/jcstress/blob/master/tests-custom/src/main/java/org/openjdk/jcstress/tests/collections/HashMapFailureTest.java

@JCStressTest
@Outcome(id = "0, 0, 1, 2", expect = Expect.ACCEPTABLE, desc = "No exceptions, entire map is okay.")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "Something went wrong")
@State
public class HashMapFailureTest {

    private final Map<Integer, Integer> map = new HashMap<>();

    @Actor
    public void actor1(IIII_Result r) {
        try {
            map.put(1, 1);
            r.r1 = 0;
        } catch (Exception e) {
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(IIII_Result r) {
        try {
            map.put(2, 2);
            r.r2 = 0;
        } catch (Exception e) {
            r.r2 = 1;
        }
    }

    @Arbiter
    public void arbiter(IIII_Result r) {
        Integer v1 = map.get(1);
        Integer v2 = map.get(2);
        r.r3 = (v1 != null) ? v1 : -1;
        r.r4 = (v2 != null) ? v2 : -1;
    }

}

The methods marked with actor are run concurrently on different threads.用actor 标记的方法在不同的线程上并发运行。

The result for this on my machine is:在我的机器上的结果是:

Results across all configurations:

       RESULT     SAMPLES     FREQ       EXPECT  DESCRIPTION
  0, 0, -1, 2   3,854,896    5.25%  Interesting  Something went wrong
  0, 0, 1, -1   4,251,564    5.79%  Interesting  Something went wrong
  0, 0, 1, 2  65,363,492   88.97%   Acceptable  No exceptions, entire map is okay.

This shows that 88% of the times expected values were observed but in around 12% of the times, incorrect results were seen.这表明在 88% 的时间内观察到了预期值,但在大约 12% 的时间内看到了不正确的结果。

You can try out this tool and the samples and write your own tests to verify that concurrency of some code is broken.您可以试用这个工具和示例,并编写自己的测试来验证某些代码的并发性是否被破坏。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM