在这里以07版excel导出为例,
问题描述:多线程导出excel,一个线程负责一个sheet的数据写入,出现数据混乱,可能会有大面积的重复数据,或者你设置的表头被设置到内容里去了。

1,出发点:
我有个需求(随便假设的),导出半个月的账单,每一天作为一个sheet。
先创建一个XSSFWork,然后分配多个线程,每个线程负责一个sheet,每个单元格分别写入行号跟列号

public static void main(String[] args){
        int days = 15;
        LocalDate today = LocalDate.now();
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        XSSFWorkbook workbook = new XSSFWorkbook();
        ExecutorService executorService = Executors.newFixedThreadPool(4);
        CountDownLatch cdl = new CountDownLatch(days);
        List<Future<Boolean>> list = IntStream.range(0,days).mapToObj(i->{
            String date = dtf.format(today.plusDays(i));
            XSSFSheet sheet = workbook.createSheet(date);
            Future<Boolean> future = executorService.submit(()->{
                try{
                    for(int j=0;j<100;j++){
                        XSSFRow row = sheet.createRow(j);
                        for(int k=0;k<100;k++){
                            row.createCell(k).setCellValue(j+"-"+k);
                        }
                    }
                    return true;
                }finally {
                    cdl.countDown();
                }
            });
            return future;
        }).collect(Collectors.toList());

        try{
            cdl.await(2,TimeUnit.MINUTES);

            for(Future<Boolean> future : list){
                future.get();
            }
            String path = "C:\\Users\\admin\\Desktop\\"+DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now())+".xls";
            FileOutputStream os = new FileOutputStream(path);
            workbook.write(os);
            os.close();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            executorService.shutdown();
        }
    }

运行生成文件截图示例:

java poi多线程导出 poi多线程写入excel_字符串

2,原因在于SharedStringsTable这个类,

java poi多线程导出 poi多线程写入excel_java_02


java poi多线程导出 poi多线程写入excel_字符串_03


需要注意的是,XSSFWork里面的sharedStringSource跟XSSFCell的_sharedStringSource两个相同类型的属性,其实都指向同一个对象,看XSSFCell的初始化就可以知道

java poi多线程导出 poi多线程写入excel_多线程_04


因此只要是用的同一个XSSFWork,那么所有的这个变量其实都是指向了同一个实例。

现在讲下这个实例,看这个类的注释:

java poi多线程导出 poi多线程写入excel_java_05


简单点说,就是一个excel里面的值重复率比较高,因此准备了这个类,每个唯一的字符串只会存一次,照这样设计,每个单元格,如果值相同,那么指向的就是这个实例里的一个唯一索引。

设计是好的,单线程跑也没问题,如果多线程,每个线程有自己的workBook,那也没问题,如果多线程,用了这同一个workbook,那就只有一份sharedStringsTable了,问题在这。

刚开始看,以为是这个类里的两个线程非安全的集合搞的鬼,

java poi多线程导出 poi多线程写入excel_poi_06


一个是存每个唯一字符串转换出来的CTRstImpl对象,用的ArrayList

一个是存每个唯一字符串和对应在上述列表集合中的 索引之间的键值对,用的HashMap

二话不说,通过反射将这两个属性分别重新赋值

strings = new CopyOnWriteArrayList();

stmap = new ConcurrentHashMap<>();

编译运行,问题照样存在。

因此直接加日志,在setCellValue之后,马上取出来,两者值一比较,发现不对,点进去看setCellValue实现,

java poi多线程导出 poi多线程写入excel_字符串_07


这里再进去两层,到这里:

java poi多线程导出 poi多线程写入excel_java poi多线程导出_08


3,问题在这:

3.1,stmap.containsKey(s),完全有可能多个相同的字符串同时没通过这一关,从而代码执行到下面,stmap put两个相同的键,而后发的键值对覆盖了前一个键值对,但这两个相同的字符串经过处理都转换成CTRst存入strings,而这两个CTRst对象的索引很有可能被当做其他的stmap value值存到stmap了

3.2,获取strings的大小来作为唯一字符串的映射值,多线程并发时,有N个线程在插数据到strings,又有N个线程在获取strings的大小并写入stmap,导致唯一字符串对应的strings索引值可能不对4,解决办法:

最好的办法是拿到POI源码,在ShardStringsTable.addEntry 方法上加上synchronized,我用的poi版本是4.0.0,我检查了调用setCellValue的时候,只有这里存在并发问题,使用到strings.size()的地方只有这里,使用到stmap.put的地方另有一处,是在读取输入流生成excel的时候用到的,这个就不会有冲突了,

java poi多线程导出 poi多线程写入excel_多线程_09


解决方案:

4.1,在setCellValue调用的时候,获取到sharedStringsTable实例,并以它为锁,执行setCellValue方法,保证setCellValue执行的时候,addEntry方法只能线程同步执行(需要将代码调用setCellValue统一封装在一个方法体内,并且锁的粒度稍微大了点,另外在这里通过反射从XSSFCell对象中获取到_sharedStringsTable会比较消耗性能,设置一个cell就用调一次反射,当然也可以在最顶层从XSSFwork中取到这个属性并一层层传过来)

public static void main(String[] args){
        int days = 15;
        LocalDate today = LocalDate.now();
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        XSSFWorkbook workbook = new XSSFWorkbook();
        SharedStringsTable sstLock = getSstLock(workbook);
        ExecutorService executorService = Executors.newFixedThreadPool(4);
        CountDownLatch cdl = new CountDownLatch(days);
        List<Future<Boolean>> list = IntStream.range(0,days).mapToObj(i->{
            String date = dtf.format(today.plusDays(i));
            XSSFSheet sheet = workbook.createSheet(date);
            Future<Boolean> future = executorService.submit(()->{
                try{
                    for(int j=0;j<100;j++){
                        XSSFRow row = sheet.createRow(j);
                        for(int k=0;k<100;k++){
                            synchronized (sstLock){
                                row.createCell(k).setCellValue(j+"-"+k);
                            }
                        }
                    }
                    return true;
                }finally {
                    cdl.countDown();
                }
            });
            return future;
        }).collect(Collectors.toList());

        try{
            cdl.await(2,TimeUnit.MINUTES);

            for(Future<Boolean> future : list){
                future.get();
            }
            String path = "C:\\Users\\admin\\Desktop\\"+DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now())+".xls";
            FileOutputStream os = new FileOutputStream(path);
            workbook.write(os);
            os.close();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            executorService.shutdown();
        }
    }

    private static SharedStringsTable getSstLock(XSSFWorkbook workbook){
        try{
            Field field = workbook.getClass().getDeclaredField("sharedStringSource");
            field.setAccessible(true);
            return (SharedStringsTable)field.get(workbook);
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

4.2,把源码拉过来,在SharedStringsTable.addEntry方法上加synchronized,改个版本号,编译构建打包,放maven私服去,并引用新的包

新的excel截图应该是这样:

java poi多线程导出 poi多线程写入excel_poi_10

备注:如果你用的是HSSFWork,也就是excel03版本的,那问题就是另外分析了,因为HSSFwork没有SharedStringsTable的设计