使用Executors.newSingleThreadExecutor时的内存可见性与线程安全问题
代码示例
class MyApi { private val singleThreadExecutor = Executors.newSingleThreadExecutor() private var myApiState: State? = null fun start() { singleThreadExecutor.submit { myApiState = State.Default doStartThings() } } fun stop() { singleThreadExecutor.submit { myApiState = null doOtherStopThings() } } } fun main() { val myApi = MyApi() repeat(20) { myApi.start() myApi.stop() } }
疑问背景
Executors.newSingleThreadExecutor() 保证同一时间只有一个线程操作myApiState。myApiState的默认初始化(依据JLS 4.12.5规范)为null发生在另一个线程,我认为这是安全的,因为「任何对象的默认初始化先行于程序中除默认写入外的其他操作」。
但newSingleThreadExecutor()的文档指出:「需注意,如果该单线程在关闭前因执行失败而终止,后续任务需要时会创建新线程替代它」。假设newSingleThreadExecutor创建的Thread#1成功执行start代码块,执行stop时意外终止,后续start和stop调用将由同一执行器创建的Thread#2执行。此时是两个不同线程先后读写myApiState,但同一时间仅一个线程操作。
所有start和stop调用均来自另一个单线程,未执行额外同步操作。
核心问题
Thread#1对myApiState的所有写入操作是否能保证对Thread#2可见?如果是,原因是什么?是否需要额外同步(比如将myApiState声明为volatile)?
解答
1. Thread#1的写入对Thread#2完全可见,无需额外加volatile
单线程执行器的核心语义已经覆盖了内存可见性保障:
- 执行器内部的任务调度机制天然存在happens-before关系:主线程调用
submit提交任务的操作,先行于新线程执行该任务的所有操作;而Thread#1完成前一个任务的所有写入,先行于后续任务的提交(因为main线程是单线程顺序调用start/stop,任务是按顺序排队的)。 - 更关键的是,
ExecutorService的规范强制要求:即使执行器内部更换线程,也必须维持「所有任务串行执行」的语义——前一个任务的所有写入操作,对后续由同一执行器执行的任务必须可见。这是单线程执行器的核心承诺,和内部是否更换线程无关。
2. 默认初始化的安全性验证
你之前的判断是正确的:对象的默认初始化(myApiState被设为null)先行于所有后续对该字段的读写操作,这是JVM规范的强制要求,所以即使默认初始化在主线程,后续执行器线程的读写都能看到这个初始值。
3. 无需额外同步的原因
执行器内部会通过队列锁、任务提交时的内存屏障等同步机制,确保任务之间的内存可见性。只要所有对myApiState的读写都通过这个单线程执行器的任务完成,就不会出现内存可见性问题,不需要手动添加volatile或同步块。
内容的提问来源于stack exchange,提问作者Víctor J García Granado

