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

时间:2013-08-30 21:56:56

标签: java multithreading unit-testing hashmap

我正在开发应用程序,它将HashMap作为共享状态。我需要通过单元测试证明它在多线程环境中会有问题。

我尝试通过检查这两个HashMap的大小和元素来检查sinlge线程环境和多线程环境中的应用程序状态。但似乎这没有帮助,状态总是一样的。

有没有其他方法可以证明它或证明在地图上执行操作的应用程序适用于并发请求?

11 个答案:

答案 0 :(得分:5)

很难模拟Race但是查看HashMap的put()方法的OpenJDK源:

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;
}

正如您所看到的,put()涉及3个未同步的操作。复合操作非线程安全。因此从理论上证明HashMap不是线程安全的。

答案 1 :(得分:5)

  

我需要通过单元测试证明它在多线程环境中会有问题。

这将非常困难。种族条件很难证明。您当然可以编写一个程序,它会在大量线程中放入并进入HashMap,但是日志记录,volatile字段,其他锁定以及应用程序的其他时间详细信息可能会强制您强制使用特定代码失败。


这是一个愚蠢的小HashMap失败测试用例。它失败是因为由于HashMap的内存损坏,线程进入无限循环时超时。但是,根据内核数量和其他架构详细信息,它可能不会失败。

@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);
}

答案 2 :(得分:5)

它是一个老线程。但只是粘贴我的示例代码,它能够演示hashmap的问题。

看一下下面的代码,我们尝试使用10个线程(每个线程3000个项目)将30000个项目插入到hashmap中。

因此在完成所有线程之后,理想情况下应该看到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());
    }
}

输出1:

Count should be 30000, actual is : 29486

输出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)

但是,如果将行Map<TempValue, TempValue> myMap = new HashMap<>();修改为ConcurrentHashMap,则输出始终为30000。

另一个观察: 在上面的例子中,TempValue类的所有对象的哈希码是相同的(**即1 **)。所以你可能想知道,只有在发生冲突(由于hashcode)的情况下才会出现HashMap的这个问题。 我尝试了另一个例子。

将TempValue类修改为

class TempValue {
    int value = 3;
}

现在重新执行相同的代码。
在每5次运行中,我看到2-3次运行仍然提供不同于30000 的输出 因此,即使您通常没有太多碰撞,您仍可能会遇到问题。 (可能是由于重建了HashMap等)

总的来说,这些示例显示了ConcurrentHashMap处理的HashMap问题。

答案 3 :(得分:5)

这很容易证明。

在意识形态

哈希映射基于数组,其中每个项目代表一个存储桶。随着更多密钥的添加,存储桶增长,并且在某个阈值处重新创建阵列,其尺寸更大,其存储桶重新排列,以便更均匀地传播(性能考虑因素) )。

技术上

这意味着有时HashMap#put()会在内部调用HashMap#resize()以使基础数组更大。

HashMap#resize()table字段分配一个容量更大的新空数组,并用旧项填充它。当这种情况发生时,底层数组不包含所有旧项,使用现有密钥调用HashMap#get()可能会返回null

以下代码演示了这一点。您很可能会得到异常,这意味着HashMap不是线程安全的。我选择目标键为65 535 - 这样它就是数组中的最后一个元素,因此是重新填充期间的最后一个元素,这增加了null HashMap#get()的可能性(了解原因,请参阅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.");
    }
}

一个线程向地图添加新密钥。另一个线程不断检查targetKey是否存在。

如果计算这些例外情况,我会绕过200 000

答案 4 :(得分:2)

阅读API文档是否足够?那里有一个声明:

  

请注意,此实现未同步。如果有多个线程   同时访问哈希映射,以及至少一个线程   从结构上修改地图,必须在外部进行同步。 (一个   结构修改是添加或删除一个或多个的任何操作   更多映射;只是改变与键相关的值   实例已经包含的不是结构修改。)这是   通常通过自然地同步某个对象来完成   封装地图。如果不存在这样的对象,则应该是地图   使用Collections.synchronizedMap方法“包装”。这是最好的   在创建时完成,以防止意外的不同步访问   地图:

线程安全问题是通过测试很难证明。大多数时候可以很好。你最好的选择就是运行一堆正在获取/放置的线程,你可能会遇到一些并发错误。

我建议使用ConcurrentHashMap并相信Java团队说HashMap未同步就足够了。

答案 5 :(得分:2)

  

还有其他方法可以证明吗?

如何阅读documentation(并注意强调的“必须”):

  

如果多个线程同时访问哈希映射,并且至少有一个线程在结构上修改了映射,则必须在外部进行同步

如果您打算尝试编写一个演示错误行为的单元测试,我建议如下:

  • 创建一组密钥,这些密钥都具有相同的哈希码(例如30或40)
  • 为每个键添加值到地图
  • 为密钥生成一个单独的线程,该线程具有无限循环,(1)断言密钥存在于映射中,(2)删除该密钥的映射,(3)将映射添加回来。 / LI>

如果幸运的话,断言会在某些时候失败,因为哈希桶后面的链表会被破坏。如果你运气不好,尽管有文档证明HashMap确实是线程安全的。

答案 6 :(得分:1)

这可能是可能的,但永远不会是一个完美的测试。种族条件太不可预测了。话虽这么说,我写了一个类似的测试,以帮助修复专有数据结构的线程问题,在我的情况下,更容易证明出错(在修复之前),而不是证明什么都不会发生错(修复后)。您可能构建一个多线程测试,最终会以足够的时间和正确的参数失败。

This post可能有助于确定您的测试中需要关注的领域,并对可选替换提出了一些其他建议。

答案 7 :(得分:1)

您可以创建多个线程,每个线程将一个元素添加到哈希图中并对其进行迭代。 也就是说,在run方法中,我们必须使用“ put”,然后使用迭代器进行迭代。

对于HashMap,我们得到ConcurrentModificationException,而对于ConcurrentHashMap,我们没有得到。

答案 8 :(得分:0)

java.util.HashMap实现中最可能的竞争条件

如果我们在调整大小或重新散列步骤执行时尝试读取值,则大多数hashMap都会失败。如果超过存储桶阈值,则通常在某些条件下执行调整大小和重新哈希操作。此代码证明,如果我在外部调用调整大小,或者如果我放置的元素多于阈值,并且倾向于在内部调用调整大小操作,则会导致某些空读取,这表明HashMap不是线程安全的。应该有更多的竞争条件,但足以证明它不是线程安全的。

种族条件的实际证明

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");
    }
}

示例输出

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

答案 9 :(得分:0)

证明这一点的非常简单的解决方案

这是证明Hashmap实现不是线程安全的代码。 在这个例子中,我们只是将元素添加到映射中,而不是从任何方法中删除它。

我们可以看到它打印了不在 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();
        }
    }
}

执行运行的示例输出

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

看到打印的值很奇怪,这就是为什么 hashmap 不是在并发环境中工作的线程安全实现。

答案 10 :(得分:0)

OpenJDK 团队开源了一个很棒的工具,称为 JCStress,用于 JDK 中的并发测试。

https://github.com/openjdk/jcstress

在其中一个示例中: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;
    }

}

标有actor的方法在不同的线程上并发运行。

在我的机器上的结果是:

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.

这表明观察到了 88% 的预期值,但在大约 12% 的时间内看到了不正确的结果。

您可以试用此工具和示例,并编写自己的测试来验证某些代码的并发性是否被破坏。