Fine语言在报表开发里用得很多,尤其涉及到数据抽取、批量计算、复杂报表渲染时,单线程跑起来经常让人等到怀疑人生。很多人在社区里问过“子线程怎么传参”“多线程能不能带参数跑”,其实这是个非常典型的场景:你有一个耗时的任务,希望在后台跑,同时你还必须告诉这个后台任务“你要处理的是哪一批数据、哪个报表ID、哪个过滤条件”。如果参数传不进去,线程就只能空转或者写死逻辑,实用性大打折扣。
这篇文章我就是来把这些东西聊透的。我会从Fine语言的线程模型讲起,再到参数传递的三种主流方式、完整的实操案例、以及我在大量项目中踩过的问题和排查方法,尽量让第一次接触子线程的人也能照着手写出来,让已经在用的人也能避开几个特别隐蔽的坑。
这个内容适合报表开发工程师、FineReport二次开发人员、以及用Fine语言做自动化脚本的运维和实施人员。无论你是想把耗时任务从主线程拆出来提高体验,还是想并发处理多个数据集合并结果,这篇文章都覆盖得到。
1. 为什么Fine语言需要“带参数的子线程”
1.1 单线程带来的报表卡顿困境
先聊一个所有报表人都经历过的画面:一张复杂报表,数据集SQL跑了十几秒,前端页面一直转圈,用户不停点刷新,老板在旁边催。你打开FineReport的服务器日志,发现报表线程被长时间占用,整个系统的并发能力被拖垮了。根源在于Fine语言这门脚本语言在设计上是单线程执行的,一个报表初始化的过程里,数据集的获取、单元格的扩展、公式的计算都排着队在这个线程里跑,一旦某一步比较慢,后面的全部被堵住。
有人会说“我可以用数据库层面的优化啊”,但很多时候瓶颈不在SQL本身,而是涉及大量文件IO、第三方接口调用、跨库数据汇总、复杂的JAVA扩展逻辑。这些操作你没法靠一条SQL搞定,用主线程直接调又必定卡界面。这时候就需要把耗时的部分放到子线程里去执行,让主线程先做别的事情,等子线程出结果再来更新报表。
但子线程不是随随便便起的。线程本身是一个小的执行单元,它启动后是独立跑的一段逻辑,如果没有办法把这个逻辑所需要的数据传进去,那这段逻辑就只能访问程序里写死的常量或者全局变量。换句话说,无参数的子线程等于一个不能定制的“通用机器人”,有参数的子线程才是一个可以接受任务书、按照不同指令干活的“定制员工”。
1.2 参数的本质:子线程的“任务书”
我这里说的参数,不只是简单的一个数字或者字符串。在Fine语言的实践中,一个完整的参数可能包含以下几类内容:
- 基础类型参数:如报表ID、数据集ID、类型标识、状态码等,用于确定“处理哪个对象”。
- 集合类型参数:如数组、列表、Map对象,用于传递一批待处理的明细数据。
- 对象类型参数:如你自己创建的一个结构化对象(通过{}定义),里面包含多个字段值。
- 上下文参数:如当前用户信息、当前报表环境变量、甚至一个函数的引用。
子线程拿到了参数,才能知道从哪张表取数、按什么条件过滤、处理完之后把结果写到哪里、通过什么方式通知主线程。这一整套信息如果传递不到位,子线程的输出就没有意义。
在我的实际使用中,携带参数的子线程经常用来做三件事:一是报表加载时的异步初始化数据;二是把大批量数据拆分成多个子线程并发处理再合并;三是定时触发后台逻辑,比如定期清理临时文件、推送报表数据。三件事的共同点在于:都需要把“任务信息”和“执行环境”一起传递到子线程里,否则线程就是无源之水。
2. 底层机制与参数传递原理解读
2.1 Fine语言背后的线程模型
Fine语言虽然是一门脚本语言,但它的运行环境是JVM,也就是说,Fine脚本最终是在Java环境下执行的。这就意味着我们可以在Fine语言里调用Java的多线程API,比如java.lang.Thread、java.util.concurrent系列工具。对只接触过简单脚本开发的同学来说,刚开始会有些不适应,但换个角度想:脚本语言负责灵活编排,Java线程模型负责并发控制,它们之间的配合反而让方案变得很清晰。
在Fine语言里,创建一个子线程的标准做法是:
var t = new java.lang.Thread(function() { // 线程要执行的逻辑 }); t.start();Thread的构造函数可以接收一个实现了Runnable接口的对象,而Fine语言对函数式接口有自动转换能力,所以直接传入一个匿名函数是可行的。这里有个小细节要注意:new java.lang.Thread(...)创建完线程对象之后,必须调用start()方法才会真正启动线程。如果只调用了run()方法,那只是在当前线程里同步执行了一遍逻辑,等于白建线程。
2.2 参数传递的三种主流方式对比
知道怎么创建子线程之后,最核心的问题来了:参数怎么传进去?我总结下来,在Fine语言里可靠的做法有三种,各有各的适用场景。
| 参数传递方式 | 实现思路 | 优点 | 典型风险 |
|---|---|---|---|
| 闭包捕获 | 直接在线程函数体内引用外部变量 | 写法简单、直观,不污染全局空间 | 变量被后续修改时,线程内读到的可能不是预期值 |
| 对象属性 | 定义一个对象,把参数作为属性,在线程回调中读取 | 组合性强,能传递一组关联参数 | 需要额外定义对象模板,代码量略大 |
| 全局变量 | 把参数存到全局容器中,子线程内部再读取 | 通用性强,任何地方都能访问 | 多线程并发访问全局变量容易出数据竞争,且作用域难控制 |
先说闭包捕获。在Fine语言里,你可以在函数内部直接引用函数外定义的变量,这就是闭包的特性。它用起来最顺手,我经常这样写:
var reportId = "RPT-10001"; var task = function() { // 在线程里直接使用 reportId return reportId; }; var t = new java.lang.Thread(task); t.start();这个方法最大的坑在于:如果外部变量在子线程启动之后又被修改了,线程内读取到的值可能已经不是当初预期的那个值。典型的场景是在循环里启动多个线程,循环变量是同一个引用,等线程真正运行时,变量可能已经递增到最后一个值了。这几乎是每个写多线程的人都会踩到的经典问题,后面我会在问题排查部分专门展开。
再说对象属性传递。如果参数比较多,或者这些参数之间有明确的逻辑归属关系,我更建议把它们打包成一个对象。在Fine语言中可以直接用{键:值}的方式创建对象:
var paramObj = { reportId: "RPT-10001", startDate: "2024-01-01", endDate: "2024-01-31", callback: function(result) { // 处理回调 } }; var task = function() { var id = paramObj.reportId; // 其他逻辑 };这种方式比散落的闭包捕获更清晰,也方便统一扩展。比如项目上线后你想多传一个过滤条件,只需要给对象加一个字段就行,线程内部逻辑几乎不用动。
最后说全局变量。全局变量在Fine语言中通常是指绑定在顶层上下文里的变量,任何脚本位置都能访问。这种方式的隐患最大,因为子线程的执行先后顺序是不确定的,如果多个线程同时读写同一个全局变量,很容易出现一个线程改掉了另一个线程刚写进去的数据。它不仅调试困难,而且问题的出现还很随机,我建议只在单线程、参数非常简单、生命周期极短的场景下才考虑这种方式。
2.3 参数传递的时序问题与快照机制
比传参方式更容易忽略的是时序问题。子线程启动以后,它并不会立刻执行完,CPU在线程间的调度是异步的。这意味着你在主线程里创建变量、启动线程、然后继续往下走,子线程可能是在主线程已经走了好几步之后才真正开始读参数。如果这中间有任何代码改了参数变量,子线程读到的值就可能不对。
我建议在执行“启动线程”这个动作之前,就把所有需要传递的参数拷贝到当前线程私有的引用里,比如用对象属性封装一份“线程专用快照”。更直白的解释就是:参数一旦确定要传给子线程,主线程那边就不要再动它了。把这个当成硬性习惯,能帮你挡掉一大半稀奇古怪的多线程问题。
3. 实操案例:报表异步加载带参数的子线程
3.1 典型场景设定
我在一个数据看板项目里遇到过这样的需求:页面打开时要展示一张汇总报表,这张汇总报表需要从五个不同数据源抽取数据,每个数据源接口的平均响应时间大约是2到3秒,按顺序跑完全部数据要至少15秒。主线程卡15秒,用户界面基本就不动了。我当时的方案就是:为每个数据源创建一个携带“数据源编号”参数的子线程,五个线程并发去拉数据,等全部结束后再把结果汇总进报表。
这个场景特别适合拿来说明带参子线程的价值。你不可能为五个数据源写五套几乎一样的线程代码,因为除了编号和取数地址不同,其他逻辑完全一致。更好的做法是写一套通用逻辑,然后通过参数告诉它“你是第几个数据源、该去哪取数”。
3.2 核心代码实现与逐步拆解
我在FineReport的“数据集”或者“报表初始化脚本”中,写了一段类似这样的脚本:
// 定义数据源参数列表 var sources = [ {sourceId: "SRC-001", url: "http://192.168.1.100/api/orders"}, {sourceId: "SRC-002", url: "http://192.168.1.101/api/users"}, {sourceId: "SRC-003", url: "http://192.168.1.102/api/stock"}, {sourceId: "SRC-004", url: "http://192.168.1.103/api/sales"}, {sourceId: "SRC-005", url: "http://192.168.1.104/api/account"} ]; var threadList = new java.util.ArrayList(); var resultMap = new java.util.HashMap(); for (var i = 0; i < sources.length; i++) { // 关键点:把当前数据源对象作为参数传入闭包 (function(source) { var task = function() { try { var result = callRemoteApi(source.url); synchronized(resultMap) { resultMap.put(source.sourceId, result); } } catch (e) { synchronized(resultMap) { resultMap.put(source.sourceId, "ERROR:" + e.toString()); } } }; var t = new java.lang.Thread(task); t.start(); threadList.add(t); })(sources[i]); // 通过立即执行函数固化当前 source 对象 } // 等待所有子线程执行完成 for (var j = 0; j < threadList.size(); j++) { threadList.get(j).join(); } // 后续用 resultMap 生成报表数据这一段代码里有几个地方我要重点说明,它们直接决定了程序能不能正确、稳定地工作。
第一个重点是立即执行函数(function(source){...})(sources[i])。为什么不直接写var task = function(){... callRemoteApi(sources[i].url) ...}?因为在循环里直接引用sources[i]的话,sources[i]读的是循环变量i当前的值,而子线程实际执行时,循环可能已经结束,i已经变成了5,这时候你取到的sources[i]就是undefined了。用立即执行函数把sources[i]作为实参传进一个新的函数作用域,等于给每个子线程保存了一个独立的快照,这个快照在子线程启动时就已经确定,不会受循环变量的变化影响。这就是我之前说的“参数一旦传给子线程,主线程不要再动它”的落地方式。
第二个重点是synchronized(resultMap)。Fine语言对Java并发包的支持比较完整,可以直接使用synchronized关键字来做同步块。五个子线程同时往同一个HashMap里写数据时,如果不加同步,底层可能会出现链表构造错乱,轻则数据丢失,重则导致死循环。不要觉得“这个应用场景写的人少就不会出问题”,多线程下的HashMap在并发put时出问题是有公开共识的,没必要拿生产环境去验证。
第三个重点是join()方法。调用threadList.get(j).join()的目的是让主线程在这个循环里等待当前子线程执行结束后再往下走。这样我们才能保证五个子线程全部执行完之后,resultMap里的数据是完整的,然后才用它对报表进行填充。join()的本质是让主线程进入等待状态,直到对应子线程终止。这个方法用得得当,就是一个线程同步利器;用得不当,也容易把自己搞成死锁。
3.3 更复杂的参数对象:使用线程上下文对象做批量任务拆分
上面的案例是“一个线程一个固定参数的并发”。实际工作中还有一个非常高频的场景:你有一万个订单ID,要逐个查询外部系统获取状态,如果串行跑可能要半小时。合理的做法是把一万个订单拆成10组,每组1000个,起10个子线程分别处理。这时每个子线程除了要知道“处理哪一组”,还要知道“处理完结果放哪”“如何通知”。参数也就从一个简单字符串变成了包含多个字段的对象。
我常写的拆分组件逻辑是这样:
var allIds = getOrderIdsFromDB(); // 假设得到一个数组 var batchSize = 1000; var batchCount = Math.ceil(allIds.length / batchSize); var futures = []; for (var b = 0; b < batchCount; b++) { var startIdx = b * batchSize; var endIdx = Math.min(startIdx + batchSize, allIds.length); var batchIds = java.util.Arrays.copyOfRange(allIds, startIdx, endIdx); var batchParam = { batchNo: b, ids: batchIds, statusContainer: new java.util.HashMap() }; (function(param) { var task = function() { for each (var id in param.ids) { var status = queryStatusFromExternalApi(id); synchronized(param.statusContainer) { param.statusContainer.put(id, status); } } }; var t = new java.lang.Thread(task); t.start(); futures.add({thread: t, param: batchParam}); })(batchParam); } for (var f = 0; f < futures.length; f++) { futures[f].thread.join(); } // 合并每个batchParam.statusContainer得到结果这样的好处是每个线程只处理它自己的那一份数据,彼此不抢、不重、不漏。最终结果放在各自的statusContainer里,主线程等待所有子线程结束后再合并。这种“参数对象+线程列表+join”的模式,是我个人在Fine语言里最推荐的一种异步处理写法,因为它既避免了全局变量的混乱,又保证了线程间的数据隔离。
3.4 参数中的“回调函数”怎么传递与保存
参数不只是数据,有时候还可以是函数。比如子线程完成一批任务后,想把进度回传给主线程,或者在处理完某个节点时触发一段逻辑。Fine语言里函数是一等公民,完全可以作为对象的属性保存。我在脚本里这样写:
var progressTracker = { total: 1000, current: 0, onProgress: function(percent) { // 更新前端进度条或者日志 } }; var task = function() { for (var i = 0; i < progressTracker.total; i++) { // 处理逻辑 synchronized(progressTracker) { progressTracker.current = i + 1; var percent = Math.round((progressTracker.current / progressTracker.total) * 100); // 注意:在主线程中定时读取 progressTracker 来刷新,而不是在这里直接操作报表控件 } } };需要特别强调:子线程中不要直接操作报表控件或前端界面元素,因为FineReport的报表渲染引擎很多控件并不是线程安全的,跨线程操作控件经常会导致界面刷新异常、甚至报表直接报错。更稳妥的做法是子线程只更新自己持有的一份状态数据,主线程通过定时器或者按钮事件去读取这个状态并刷新界面。这其实就是把“状态存储”和“界面渲染”拆开,每一层各司其职。
4. 常见问题与排查技巧实录
4.1 参数“传过去却读到空值”的排查
所有带参子线程的初学者遇到的第一座大山就是:启动前明明打印参数是正常的,但子线程里一读就是null。出现这个现象的绝大多数情况是作用域问题。Fine语言的作用域规则有些地方比较隐晦,如果你在线程函数里引用了一个外部变量,而那个变量是在更外层的报表块中定义的,一旦报表块执行完毕,相关上下文可能就被回收了,或者在线程真正执行时,变量引用已经指向了一个新的空对象。
我排查这类问题时会先做一个最小化验证:在子线程的第一行代码里,把接收到的参数原样打印到日志里,比如log(JSON.stringify(param))。如果打印出来是空的,那是参数根本没传进去;如果打印出来是正常的,就说明参数传进去了,问题出在线程内部后续的某个环节。通过这种二分法,能快速缩小问题范围,而不是去猜。
还有一个很容易混淆的情况:线程函数里定义了和外部同名的局部变量。比如外部有一个var reportId = "RPT-001",线程函数内部又写了一个var reportId = "",那你在函数内部访问到的自然就是内部的空值。这个虽然基础,但我在实际帮同事排查时确实遇到过好几次,写代码的人自己根本意识不到变量被局部声明遮蔽了。
4.2 循环启动多个线程“串号”的深层原因和修复
前面提到过循环变量的经典坑。我再展开说说,因为它值得一个单独的小节。假设你有三个数据源,期望线程1处理A、线程2处理B、线程3处理C,但最终所有线程都处理了C,A和B的数据没人管了。问题的根源在于:线程启动后并不是马上执行,它要进入系统调度队列等待CPU分配时间片。这个等待时间可能是微秒级,也可能在系统繁忙时长到毫秒级。而主线程的for循环执行完三个迭代只需要微秒级的时间。也就是说,当第一个线程真正开始执行并去读取外部循环变量i时,i大概率已经被循环推进到了2或者3。所有线程读到的i都是同一个最终值,自然就都去处理C了。
解决办法就是我前面演示过的,把每次循环的sources[i]通过立即执行函数的实参传入子作用域,或者在循环内部用let声明块级作用域变量。在Fine语言里,如果支持ES6的let语法,直接用let item = sources[i]就能解决;如果不确定版本是否支持,就用立即执行函数稳妥一点。
4.3 死锁与卡死的排查思路
子线程多处使用了synchronized,或者在调用了join()之后又有一个线程反过来等待主线程释放某个锁,就容易出现死锁。直观表现是报表加载进度条永远卡在某个位置,日志里没有任何报错,CPU占用却不见下降。
遇到这种问题,我第一反应会是检查加锁的顺序。比如线程A里先给lock1加锁再申请lock2,线程B里先给lock2加锁再申请lock1,两个线程就会互相持有对方想申请的锁,谁也跑不动。解决办法是让所有线程按同一个全局固定顺序去申请锁,比如都先申请编号小的锁,再申请编号大的锁,这样就不会产生循环等待。
如果你看到的是死循环而不是死锁(CPU占用率直接飙满一个核心),那大概率是for each遍历一个正在被其他线程修改的集合。Java的集合在并发修改时会触发快速失败机制,抛ConcurrentModificationException,但在Fine语言的封装里,异常有可能被吞掉或者只是记录在不是很容易注意到的地方。我的建议是对所有跨线程读写的对象,都使用同步块或者线程安全容器包装,这是成本最低也最可靠的防御。
4.4 常见问题速查表
我把这几年用Fine语言写子线程遇到的最高频问题整理成一个速查表,方便你直接对照:
| 现象 | 大概率原因 | 建议处理方式 |
|---|---|---|
| 线程内读参数为null | 变量作用域不对或参数未传入 | 在线程入口处打日志,用最小化验证定位 |
| 多个线程拿到同一个参数值 | 循环变量引用未固化 | 使用立即执行函数或块级作用域变量 |
| 数据丢失或结果残缺 | 并发修改Map/List | 加synchronized同步块,或用线程安全容器 |
| 页面卡死无响应 | 死锁 | 检查锁顺序是否一致,避免循环等待 |
| 报表控件刷新异常 | 跨线程操作控件 | 控件操作只放在主线程,子线程只更新状态值 |
| 子线程看似没执行 | 只调用run()未调用start() | 确认调用的是start()方法 |
| join等待永远不结束 | 子线程内部异常未捕获 | 在线程逻辑外层加try-catch,避免异常退出 |
4.5 调试子线程时值得借鉴的小习惯
最后分享几个我个人的调试习惯。第一,所有子线程的入口和出口都留日志,包括“线程启动”“参数快照值”“线程结束”“异常信息”四类。多线程的问题一旦出现,往往都是偶发性的,没有日志你根本无从下手。第二,在启动线程的地方尽量把参数的快照值原样打印出来,这样出现问题时你能看到“启动时传入的到底是什么”,和“线程执行时实际读到的是什么”之间有没有偏差。第三,不要在生产环境直接观察多线程问题,尽量先写一个最小复现脚本装在测试环境里跑,等确认逻辑无误之后再搬上去。
排查多线程问题本质上就是一个反复对照“预期值”与“实际值”的过程。参数有没有传对,一个日志就能确认;执行顺序有没有乱,给每个线程加上编号日志就能观察到;锁有没有争用,同步块的起始和结束时间都能说明问题。只要你把这些基础信息都留着,多线程并没有想象中那么不可捉摸。
5. 性能优化与资源释放
5.1 线程数量不是越多越好
有些开发者看到子线程能提升速度,就不管三七二十一创建几十个线程。这个做法我劝你慎重。线程的创建和切换都是有开销的,系统对CPU核心数和内存资源也有限制。在FineReport这类报表服务器上,你还需要考虑同一个用户反复打开报表、多个用户同时并发访问的情况,每个报表请求都创建大量线程,服务器资源很快就会被耗尽。
我更推荐的做法是先评估任务本身的特征。数据源只有五个,就开五个并发;任务拆成了一百个小块,也不要盲目开一百个线程,一般控制在CPU核数+1以内较为稳妥,或者直接使用线程池工具,比如java.util.concurrent.Executors来复用线程资源。
5.2 线程的生命周期和内存释放
子线程跑完之后,它的栈内存和局部对象都会被GC回收,但如果你在线程内部引用了外部的重量级对象,比如大型数据集、整个报表上下文,那么即使在子线程结束后,这些对象可能仍然被引用链持有,无法及时释放,长此以往会造成内存增长。所以在一个子线程里用不到的重量级对象,尽量不要通过闭包捕获进线程里。如果确实需要传递,使用完后最好把引用变量置为null,帮助GC尽早回收。
5.3 异常处理不要停留在口头
子线程内部出现的异常不会像主线程那样直接浮到报表界面上。如果代码里没有捕获,异常可能导致线程静默终止,然后join()永远等不到结束。这是非常致命的问题。我的习惯是每个子线程的执行体都包一层try-catch,在catch里除了记录日志,还应该把失败的状态写回参数对象中的一个状态字段,方便主线程在等待结束后检查每个线程是否成功。这样即使某个子线程失败了,主线程也只知道“哪个失败了”,而不是干等在那里,也不知道到底为什么失败。
6. 工具选型与多方案对比
6.1 Java原生Thread与线程池的取舍
在Fine语言里你可以直接用new java.lang.Thread(...),但它有明确短板:线程不可复用,执行完就被销毁;并发数量也无法控制。如果任务频率高,每次都新建线程的成本就很高。用线程池是更合理的方案,比如Executors.newFixedThreadPool(4),它能让四个线程反复执行多个任务,请求多时会排队,不会一次性打爆系统。
创建固定线程池的示例:
var pool = java.util.concurrent.Executors.newFixedThreadPool(4); for (var i = 0; i < tasks.length; i++) { (function(task) { pool.submit(function() { // 执行任务 }); })(tasks[i]); } pool.shutdown();这里要注意shutdown()的调用时机。调用了shutdown()之后,线程池不再接受新任务,但已经提交的任务会继续执行。如果你还想往池里提交新任务,就不能提前关。在使用join()等待时,你等待的应该是每个具体任务的Future对象或者状态,而不是等线程池关闭。
6.2 使用Future获取返回值
如果你想从子线程拿到计算结果,而不是靠主线程轮询某个共享Map,可以用Future接口。提交任务时会返回一个Future对象,调用它的get()方法可以阻塞等待计算结果。这个模式下,“携带参数”就变成了直接传参给Callable任务,返回值也变得更加结构化:
var executor = java.util.concurrent.Executors.newFixedThreadPool(3); var futureList = new java.util.ArrayList(); for (var i = 0; i < jobs.length; i++) { (function(job) { var callable = new java.util.concurrent.Callable({ call: function() { // 根据job参数执行运算 return computeResult(job); } }); var future = executor.submit(callable); futureList.add(future); })(jobs[i]); } for (var j = 0; j < futureList.size(); j++) { var result = futureList.get(j).get(); // 收集并进行后续处理 } executor.shutdown();这种方式的最大优势是代码意图更清晰:任务返回什么,就写成一个可计算结果的对象;主线程想拿结果,就直接调用get()。相比共享Map再join的做法,Future模式让“等待结果”变成了一句直接的调用,少了很多手工状态管理。不过要注意:Future.get()也会阻塞主线程,如果某个任务一直不结束,主线程一样会卡住。因此还是那句话,超时机制和异常捕获不能省。
6.3 定时器与子线程搭配的注意点
在FineReport中还经常出现“定时刷新报表数据”的需求,做法通常是用定时器周期性地启动一个携带参数的子线程去拉最新数据。这个模式要特别注意:如果上一次的线程还没跑完,定时器又触发了下一次启动,多个线程同时跑同一段逻辑,就会产生重复更新、数据互相覆盖的问题。我自己的处理方式是加一个简单的执行状态锁:
var executing = false; function scheduledRefresh() { synchronzied(executing) { if (executing) return; executing = true; } var task = function() { try { refreshData(); } finally { synchronized(executing) { executing = false; } } }; new java.lang.Thread(task).start(); }这里用executing布尔标记来避免并发重入,保证同一时间只有一轮刷新任务在执行。虽然只是一个很小的变量,但它能挡住很多因定时器触发周期短、任务执行耗时长导致的连锁问题。
7. 进阶经验:从简单传参到线程通信
7.1 “参数不仅传进去,还要传回来”
很多刚接触多线程的人会忽略通信方向的问题。参数不只是从主线程传给子线程,子线程的执行结果、进度、状态也需要回到主线程。一种简单的“传回来”方式就是前面说的共享状态容器;但如果你对实时性有更高要求,比如子线程完成任务后要立刻通知主线程去做某件事,你可以用Java的等待通知机制,或者用一个回调队列来实现。
我在实际项目中用过一个简化方案:主线程在启动子线程时,把一组“处理结果对象”的引用同时传给子线程。子线程在处理完任务后,不直接去碰报表台账,只把结果填入对象,然后尝试获取一个用于通知的信号量,主线程等在信号量上,等收到信号后统一读取结果。这种方式的耦合度比共享Map更低,扩展性更好。
7.2 锁不仅是保护数据,也是在保护顺序
再想深一点:锁的意义不只是让多个线程不能同时写同一块数据,更是在保证逻辑上的处理顺序。比如你的报表有“先取基础数据,再算衍生指标”的先后关系,基础数据子线程必须全部跑完,衍生指标线程才能开始。你可以为衍生指标线程设置一把“等待条件”的锁,也就是让它先获取一个由基础线程释放的锁。这种情况下,你传递给衍生线程的参数里,就应该包含这个锁对象的引用,让线程知道“我该等谁”。
用Fine语言表达的话,锁对象可以直接作为参数对象的字段传入。这里不建议从头自己封装复杂的信号量,能用JVM自带的CountDownLatch就用它,用法简洁,语义也清楚:
var latch = new java.util.concurrent.CountDownLatch(1); // 基础数据线程完成后调用 latch.countDown() // 衍生指标线程启动后先 latch.await(),等待基础数据完成把latch作为参数传给两个方向的线程,基础线程负责数数,衍生线程负责等待。就这样一个简单的工具,就能把“带参数的子线程”从“各跑各的并发”升级为“有依赖关系的协作式并发”。
7.3 结合FineReport数据集的特殊处理
有的朋友会在FineReport的“数据集”里直接写脚本调子线程,这种做法要格外小心。数据集里的脚本通常要求返回一个确定的表格结构,如果你在数据集脚本内部启动一个异步子线程就直接返回,那么返回时数据可能还没有准备好,报表就会拿到空数据。更稳妥的做法是:要么在数据集脚本中使用同步等待(比如join或future.get),等子线程完成后再构造返回结果;要么把异步初始化放到报表的“初始化后”阶段,让主报表先不依赖那个数据结构,等子线程完成后通过刷新事件来更新。
我在看板上采用的就是第二种思路:报表先渲染框架和空表格,每个数据的格子绑定了“定时从resultMap取值”的逻辑,子线程在后台拉数据,拉完后结果写入resultMap,报表用动态刷新的方式把数据填上去。这样用户体验最好,同时也不会遇到数据集返回空结构的问题。
最后再分享一点个人体会
Fine语言里的多线程,并不需要你掌握非常深奥的并发理论,真正难的是把参数的作用域理清楚、把线程的生命周期管明白、把异常处理做扎实。我在处理报表卡顿、异步加载、批量并发这类问题时,最常用的工具也就是Thread、同步块、Future、CountDownLatch这几板斧,但每一样都用得透透的。
你以后写类似逻辑时,可以先把“我要传哪些参数”列出来,再考虑“子线程执行时这些参数会不会变”以及“线程结束后我要从哪里拿结果”,只要这三件事都清楚,代码基本不会跑偏。多线程的坑是躲不完的,但每一次踩坑都是一次宝贵的经验积累,把排查思路固定成习惯,后面你的开发速度反而会更快。