Java并发编程之由于静态变量错误使用可能导致的并发问题
- 1.1 前言
- 1.2 业务背景
- 1.3 问题分析
- 1.4 为什么呢?
- 1.5 修复方案
- 2 演示示例源码下载
1.1 前言
我们知道在 Java 后端服务开发中,如果出现并发问题一般都是由于在多个线程中使用了共享的变量导致的。
今天我们就一起来看一个由于静态变量错误使用可能导致的并发问题。
PS:
- 今天分享的这个案例,只有程序启动后,首次调用的时候就开并发调用才可能出现,所以相对比较隐蔽。
- 由于这个并发问题不是百分百出现,所以差点埋下雷,幸好在 QA发现后,在不相信玄学的组长的领导下,终于发现了罪魁祸首。
- 当时发现问题的表象就是:同样的代码同样的镜像,服务部署完成后是有问题的,重启后可能就没问题了。
- 以下代码示例均已脱敏,简化了非问题相关部分。
1.2 业务背景
为了优化程序的执行性能,避免每次创建都初始化一个集合,因此创建了一个静态工具类ConstantUtils.java
。
import java.util.HashSet;
import java.util.Set;public class ConstantUtils {private static Set<String> goodApiList=new HashSet<>();public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;}/*** 静态工具类应该禁用其构造方法*/private ConstantUtils(){}
}
最开始的思路是尝试通过懒加载在静态方法中初始化了一些值,这样只有调用ConstantUtils.getGoodApiList()
方法的时候才会初始化。
然后接下来,如果并发调用接口则会并发执行calculate 这个方法,也就是说会并发执行new MyCounter();
public class MyProscessor{public void calculate(Map<String, String> dbResult) throws Exception {MyProxy myProxy = new MyProxy(); myProxy.setCounter(new MyCounter());return myProxy.doCount(dbResult);}
}
然后我们在这个MyCounter的成员变量中调用了ConstantUtils.getGoodApiList();
方法,避免在new出来的不同的MyCounter
实例中多次创建 goodApiList集合。
public class MyCounter(){private final Set<String> goodApiList= ConstantUtils.getGoodApiList();public void doCount(Map<String, String> dbResult){Long count=0L;if(goodApiList.contains("xxxx")){count++;....}...}
}
最后判断做了统计数量。
可以确定的是 dbResult 中的结果是固定不变的,但是同样的代码,同样的镜像,部署两次后,却统计出来的 count 数量却不一致。
而且经过最终排查后发现 goodApiList 的结果可能出现多种情况,比如下面两种情况:
情况一:HashSet 集合里面确实 A,B,C,D,E,F,G,H,I,J,K
,但是调用size方法不是 11 而是 13 甚至 15
情况二:HashSet 集合里面确实 A,B,C,D,E,
,但是调用size方法不是 11 而是 13 甚至 16
看到这里,你看到问题出在哪里了么?
1.3 问题分析
如果你看出来了,那么恭喜你,很机智。
如果没看出来,也没关系,我们一起梳理下思路:
- 首先我们在一个类中,如果并发调用接口导致并发执行了
new MyCounter();
- 然后在
new MyCounter();
的实例中调用了ConstantUtils.getGoodApiList()
; - 然而
getGoodApiList()
方法是这样实现的:
public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;
}
上面代码执行可能出现这样一个情况,当程序刚部署玩后,假设一个线程A执行到添加 B之后,两外一个线程也进来了。
public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");// ... 假设线程 A 此时执行到这里了goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;
}
线程 B 执行的时候,由于线程 A已经给goodApiList放入了几个值了,因此此时goodApiList.isEmpty 为 false,直接返回了 goodApiList.
那等十分钟后再次非并发调用呢?结果会恢复正常么?
按照最开始的想法,不管怎么样,最终线程 A 执行完,
那么goodApiList里面放的元素肯定就是 A,B,C,D,E,F,G,H,I,J,K
且 goodApiList.size() 就是 11了,是么?
如果你也这么认为那就大错特错了。
为了测试,我们这里用一个测试类来复现这个问题
import junit.framework.TestCase;import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/**** @author qingfeng.zhao* @date 2024/6/6* @apiNote*/
public class ConstantUtilsTest extends TestCase {private static final int THREAD_NUM = 100; // 模拟100个并发线程private static final int COUNT_DOWN = THREAD_NUM; // 计数器public void setUp() throws Exception {super.setUp();}public void tearDown() throws Exception {}public void testGetGoodApiList()throws InterruptedException {ExecutorService executorService = Executors.newFixedThreadPool(THREAD_NUM);CountDownLatch latch = new CountDownLatch(COUNT_DOWN);for (int i = 0; i < THREAD_NUM; i++) {executorService.submit(() -> {try {// 模拟业务逻辑,调用ConstantUtils.getGoodsList()方法Set<String> goodList= ConstantUtils.getGoodApiList();} finally {latch.countDown(); // 线程执行完毕,计数器减一}});}latch.await(); // 等待所有线程执行完毕executorService.shutdown(); // 关闭线程池System.out.println("所有线程执行完毕!");for (int i = 0; i < 10; i++) {Set<String> goodList=ConstantUtils.getGoodApiList();System.out.println("-----start--------------");for (String item:goodList){System.out.println(item+"---"+goodList.size());}System.out.println("-----end--------------");}}
}
程序执行有时候会是这个样子:
有时候又会是这个样子:
1.4 为什么呢?
我们知道,HashSet 是一个非线程安全的集合.
当在多线程环境下,执行 HashSet的 add方法,如果算哈希槽的时候,如果发生冲突就会导致两次 add都失败,从而发生添加元素丢失。
1.5 修复方案
当然解决的方案有很多种,这里采用最简单的一种解决方案。
将数据初始化部分放到静态代码块中
import java.util.HashSet;
import java.util.Set;/*** @author xing yun*/
public class ConstantUtils {private static final Set<String> goodApiList=new HashSet<>();static {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}}public static Set<String> getGoodApiList() {return goodApiList;}/*** 静态工具类应该禁用其构造方法*/private ConstantUtils(){}
}
2 演示示例源码下载
- 命令行下载
git clone https://github.com/geekxingyun/concurrent-question-fixed-sample.git
- 访问 github首页
https://github.com/geekxingyun/concurrent-question-fixed-sample