当Hashtable遇上parallelStream时的线程安全问题
先说结论:理论上Hashtable是线程安全的,但依然不建议使用parallelStream操作Hashtable。
前置参考信息:
1. HashTable和ConcurrentHashMap是如何实现线程安全的? :https://blog.csdn.net/zhanglei082319/article/details/87888250(remove,put,get做了同步方法,所以Hashtable是线程安全的。)
2. 深入浅出parallelStream : https://www.cnblogs.com/pengzhizhong/p/10191842.html
3. 使用parallelStream进行遍历的坑:https://blog.csdn.net/qq_31840023/article/details/100579117
4. parallelStream引起的线程不安全 : https://segmentfault.com/a/1190000012755594
[注] 本文仅限相互参考交流。
正文
需求中,我们对一个 List<Object> 进行循环,并计算出Object某个属性值的累加和,并将结果放在total中,将另外两个属性组装成( key, value) 的形式放在 info 中,进行返回。
我们在循环的时候使用了java8中的parallelStream(),以下是list.parallelStream().forEach 执行的三种结果, 请大家以上往下阅读,后面附加了改良后的demo代码
{"result": { "total": 131, "info": { "1936": 13 } } }
{"result": { "total": 131, "info": {} } }
{"result": { "total": 131, "info": { "2368": 24, "1971": 24 } } }
其余代码均不变,这是使用 list.forEach,该结果是绝对正确的。
{"result": { "total": 131, "info": { "2030": 13, "2040": 1, "1602": 17, "1128": 3, "1": 22, "662": 4, "2368": 24, "1971": 24, "754": 1, "1277": 3, "1430": 1, "2541": 5, "1936": 13 } } }
以下精简了上述结果代码的精简测试类:
class test174{
public static void main (String a[]){
//构建一个 hashTable 容器,初始化一个key value
Map<String,Object> map = new Hashtable<>();
map.put("total",0);
map.put("info",new Hashtable<>());
//构建一个10000大小整形的list
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
list.add(i);
}
//
list.parallelStream().forEach( v -> getObjMap(map, v));
System.err.println(map);
}
private static void getObjMap(Map<String,Object> map,Integer v) {
//这里写一些复杂的sql查询,或者再次嵌套list.parallelStream()的复杂查询
Map<Integer,Integer> var = (Map<Integer,Integer>)map.get("info");
var.put(v,v);
map.put("total",Integer.parseInt(map.get("total").toString()) + v);
map.put("info",var);
}
}
离奇的是,上面这段代码无论执行多少次,依然没有复现之前出现的情况,map.get("key")的大小始终是10000。
笔者已经反复检查了不下于20遍,基本的逻辑就是这样。
然而事实胜于雄辩,仅仅是将list.parallelStream().forEach改为list.forEach,“info”中的内容就没有缺失了。
值得注意的是,“total”的累加值始终是正确的,唯独“info”的累加出了问题。
已经持续看了6个小时了,没有结果,故记录下这个问题,将来会再来补充回答这个问题。
-- 2022年6月22日
一两年过去了,突然发现自己还留过这么一个笔记, 趁找个机会,说一说自己的理解,顺便解答一下吧。
parallelStream是并行流,可以理解为多线程同步运行, 比如将一个list分为3个list,用3个线程去执行,parallelStream预计最多会分为16个线程去消化这个list。
既然是多线程,那就要考虑线程安全的,原子一致性的,以上面的例子来看:
1. 历史遗留问题
“值得注意的是,“total”的累加值始终是正确的,唯独“info”的累加出了问题。” 这句话是不对的, 应该是total始终是不正确的,info始终是正确。
1) 为什么total始终是不正确的:因为total是从map中get旧值,然后累加,如果多线程同时去get值,并累加后重新赋值,是不是就会出现冲突?
所以这里应该使用线程安全的map,即 ConcurrentHashMap 来进行累加值计算,后续会放上例子。
2) 为什么info始终是正确的:这应该是map基于哈希寻址存放数据的特点,所以才比较难出现info内数据缺少的情况,而正巧 精简测试类 中只涉及到了向map中 put 唯一值,却没有get之后重新赋值的操作,因此没能最开始就定位到问题。
大家可以展开想一想,例如2个线程, 一个要去存Map(1,v1), 另一个要去存(2,v2)。 有于两个key寻的地址一定不一致,所以无论谁先放谁后放,都无所谓,殊途同归从而达到最终数据一致性。如果我们存放的时候有同key的数据需要put,就会出现灾难性的后果。
接下来我们针对1.1节做一些例子:
class Test202 {
public static void main(String[] args) {
//构建一个 hashTable 容器,初始化一个key value
//构建一个10大小整形的list
Integer num = 0;
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(i);
num = num + i;
}
System.err.println("正确的结果:" + num);
//parallelStream 下 使用原子一致性的方法累加循环累加
ConcurrentHashMap<String, Integer> map1 = new ConcurrentHashMap<>();
map1.put("total", 0);
list.parallelStream().forEach(v -> atomicCompute(map1, v));
System.err.println("parallelStream 下 使用原子一致性的方法循环累加和:" + map1.get("total"));
//parallelStream 下 使用读写一致性的方法累加循环累加
ConcurrentHashMap<String, Integer> map2 = new ConcurrentHashMap<>();
map2.put("total", 0);
list.parallelStream().forEach(v -> compute(map2, v));
System.err.println("parallelStream 下 使用读写一致性的方法循环累加和:" + map2.get("total"));
}
private static void atomicCompute(ConcurrentHashMap<String, Integer> map, Integer v) {
map.computeIfPresent("total", (key, value) -> {
// System.err.println("value:" + value);
// System.err.println("getkey:" + map.get(key));
// System.err.println("v:" + v);
return value + v;
});
}
private static void compute(ConcurrentHashMap<String, Integer> map, Integer v) {
map.put("total", map.get("total") + v);
}
}
如果大家运行过上述代码,就会得到以下结果:
正确的结果:45
parallelStream 下 使用原子一致性的方法循环累加和:45
parallelStream 下 使用读写一致性的方法循环累加和:31
关于线程下重新赋值的情况,大家可以找找原子类型方面的知识:https://blog.csdn.net/wyaoyao93/article/details/115868025
在多线程下其他的封装类型想要修改值也都是一样需要注意的,例如常见的Boolean,Integer等,都是需要使用AtomicReference进行包装修饰。
下面是针对1.2节做一些例子:
class Test203 {
public static void main(String[] args) {
//构建一个1000大小整形的list
List<Integer> list = new ArrayList<>();
List<Integer> list2 = new ArrayList<>(1000);
for (int i = 0; i < 1000; i++) {
list.add(i);
}
list.parallelStream().forEach(v -> list2.add(v));
System.err.println("list2.size()=" + list2.size());
}
}
注:如果我们在定义list2的时候没有生命初始大小,甚至可能会抛出数组越界异常,因此我们给了一个同的大小的初始值,运行结果如下:
list2.size()=964
与map不同,由于list的add是基于下角标入值,所以问题一下子就暴露出来了。
如果我们本身就是要将list做一些复杂操作之后重新放入一个新的数组中时,我们应该也要对这个新数组做线程安全的升级操作。
例如:
class Test203 {
public static void main(String[] args) {
//构建一个1000大小整形的list
List<Integer> list = new ArrayList<>();
List<Integer> list2 = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 1000; i++) {
list.add(i);
}
list.parallelStream().forEach(v -> list2.add(v));
System.err.println("list2.size()=" + list2.size());
}
}
运行结果如下:
list2.size()=1000
2. 我们应该在什么样的场景下使用 parallelStream 进行循环?
2.1: 通过上述的例子,我们应该已经基本理解了,parallelStream 就是内部开线程对list进行处理,所以当我们遇到list数据量较大,我们需要逐一循环做复杂处理,或者要想通过流过滤一些数据的时候都是可以使用的。
2.2: 在迫于无奈的情况下,有时我们不得不在循环中调用持久层,而parallelStream 在这方面的表现也是大放光彩,相关的知识大家可以去找找其他博文:parallelStream 对线程池的影响。
3.我们使用parallelStream 应该需要注意什么?
3.1: 如第1点,凡是在外部定义的数组/对象,如果我们需要对其进行赋值/修改都要格外注意它们的原子一致性。
3.2: 在使用parallelStream 要避免从上下文中拿数据,如:RequestContextHolder.getRequestAttributes(),因为 parallelStream 本身是使用的匿名线程,所以上下文在获取线程的时候会报出空指针异常。
3.3:尽量避免在parallelStream 嵌套使用parallelStream 以及线程/线程池的使用。
以上只是笔者临时起意写下的,如果不对或者不完整的地方欢迎大家指出,让我们共同学习,共同进步。
后续我还会继续探讨和补充parallelStream 的使用场景以及使用注意事项。
浙公网安备 33010602011771号