Scala3中Future引用外部作用域val触发阻塞问题咨询
问题背景
测试代码修改自Scala Cookbook示例,将原示例中硬编码的常量替换为val定义的sleeptime变量,代码片段如下:
val sleeptime = 1000
版本运行差异
- Scala 2.13.8环境:运行结果符合预期,执行输出如下:
$ scala FuturesExample3 1 - starting calculation ... 2- before onComplete A ... B ... Got the callback, meaning = 42 C ... D ... E ... F ...
- Scala 3.1.2环境:相同代码编译后运行,
onComplete回调始终未触发,输出存在显著差异:
$ scala FuturesExample3 1 - starting calculation ... 2- before onComplete A ... B ... C ... D ... E ... F ...
通过jstack抓取线程栈分析发现:Scala 3.1.2环境中承载Future任务的工作线程,在sleeptime对象上执行object.wait()进入阻塞状态。
完整测试代码
import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util.{Failure, Success} object FuturesExample3 extends App { val sleeptime = 1000 println("1 - starting calculation ...") val f = Future { sleep(sleeptime*2) 42 } println("2- before onComplete") f.onComplete { case Success(value) => println(s"Got the callback, meaning = $value") case Failure(e) => e.printStackTrace() } // do the rest of your work println("A ..."); sleep(sleeptime) println("B ..."); sleep(sleeptime) println("C ..."); sleep(sleeptime) println("D ..."); sleep(sleeptime) println("E ..."); sleep(sleeptime) println("F ..."); sleep(sleeptime) def sleep(duration: Long): Unit = { Thread.sleep(duration) } }
待解答疑问
- 为何Scala3生成的字节码在读取外部作用域的val时会产生阻塞?
- 这类不符合直觉的行为差异提升了Scala 2.x到3.x的升级难度,该如何理解、规避此类兼容性问题?
问题解答
1. 阻塞产生的根本原因
这个问题和读取外部val的字节码逻辑无关,是Scala 3对App特质、对象初始化语义的改动触发的,具体逻辑链:
- Scala 2的
App靠特殊的延迟初始化逻辑实现,对象里的业务代码不会在类加载阶段执行,等main方法触发后按代码顺序执行,内部val执行到对应行才赋值,不会有额外的锁逻辑。 - Scala 3完全重写了
App的实现:App对象的业务代码默认在类的静态初始化阶段执行,这个阶段JVM会持有该类的初始化锁,所有外部线程如果要访问这个类还没完成初始化的final val字段,就会自动在这个锁上wait,等类初始化完成才会被唤醒。 - 测试代码刚好踩中了这个机制的阻塞+进程提前退出逻辑:
- 主线程持有类初始化锁,按顺序执行代码,先把Future提交到全局线程池(这个线程池里的线程全是daemon线程,不会阻止JVM退出),然后开始执行后续的6次sleep。
- Future的工作线程启动后,第一行代码就要读
sleeptime字段,这时候类还在初始化阶段(主线程还在跑sleep没跑完初始化流程),于是工作线程直接wait在类锁上,根本没往下执行sleep和计算逻辑。 - 等主线程跑完6次sleep,类初始化完成,释放锁,这时候主线程已经执行完所有main方法逻辑,JVM一检查发现没有非daemon线程在跑,直接就退出了。被唤醒的Future线程还没来得及执行任务、触发回调,进程就已经结束了,所以看不到回调输出,jstack抓到的wait就是线程等类初始化锁的状态。
2. 兼容性问题的理解与规避
怎么理解这类差异
这类不兼容改动不是Scala 3的bug,是为了修复Scala 2里存在了十几年的初始化语义漏洞做的调整:Scala 2的App延迟初始化逻辑一直有初始化顺序混乱、多线程下字段可见性无保障的问题,Scala 3对齐了JVM标准的类初始化规则,长期来看语义更严谨,但确实会让依赖旧版App行为的老代码出问题。
具体规避方法
- 最稳妥的方案:别用
App特质当入口,手动写def main(args: Array[String]): Unit方法作为程序入口。手动写main方法不会把业务代码放到静态初始化块里跑,字段初始化顺序和代码编写顺序完全一致,不会出现跨线程等初始化锁的问题。 - 如果要继续用
App,可以选两种改法:- 把跨线程访问的
val改成lazy val,lazy字段不会触发类初始化锁的阻塞逻辑,第一次访问的时候才会赋值。 - 把跨线程访问的常量挪到
App对象外面,要么定义成顶级常量,要么放到单独的object里,避开内部字段的初始化锁影响。
- 把跨线程访问的
- 跨版本升级的通用注意点:
- 写并发代码别靠主线程sleep等异步任务,不管Scala2还是3,这种写法本身就不可靠,异步任务要主动用
Await等机制等待完成再退出进程。 - 多线程场景下不要依赖语言的隐式初始化、隐式执行顺序语义,跨线程访问的变量要明确初始化时机、明确可见性。
- 升级版本时优先跑全量并发测试用例,重点排查用了
App入口、隐式延迟执行的逻辑块。
- 写并发代码别靠主线程sleep等异步任务,不管Scala2还是3,这种写法本身就不可靠,异步任务要主动用
内容的提问来源于stack exchange,提问作者mbsf
相关产品推荐
相关产品推荐

