函数 One() 和 Two() 可以同时运行吗?
有两种可能发生的方式:
-
其中一个函数可以调用另一个函数。这可能直接发生(在 One()⁽¹⁾ 中由 // Does something here 表示的代码显式调用 Two())或间接发生(它可以调用其他最终调用 Two() 的东西 - 或者可能是 list 属性有一个自定义设置器,它执行调用 One()) 的操作。
-
一个线程可能正在运行One(),而另一个线程正在运行Two()。如果您的程序直接启动新线程,或者库或框架可以这样做,则可能会发生这种情况。例如,GUI 框架往往有一个线程用于调度事件,而其他线程用于执行可能需要时间的工作; Web 服务器框架倾向于使用不同的线程来处理不同的请求。
如果这些都不适用,那么这些功能就没有机会同时运行。
他们需要用锁保护吗?
如果它们有可能在多个线程上运行,那么是的,它们需要以某种方式受到保护。
在 99.999% 的情况下,代码会完全按照您的预期执行;您会看到旧列表或新列表。然而,它会出现异常行为的可能性很小但非零——从给出稍微错误的结果到崩溃。 (风险取决于操作系统、CPU/缓存拓扑以及系统负载程度等因素。)
但是,确切地解释为什么很难,因为在低级别 Java 虚拟机⁽²⁾ 做了很多你看不到的事情。特别是,为了提高性能,它可以在一定的限制内重新排序操作,只要最终结果是相同的——从那个线程看。事情可能看起来与其他线程非常不同——这使得推理多线程代码变得非常困难!
让我尝试描述一种可能的情况……
假设线程 A 在一个 CPU 内核上运行 One(),线程 B 在另一个内核上运行 Two(),并且每个内核都有自己的缓存。⁽³⁾
线程 B 将创建一个 List 实例(保存对常量池中字符串的引用),并将其分配给 list 属性;对象和属性都可能首先写入其缓存。然后这些缓存行将被刷新回主内存——但无法保证何时发生,也无法保证发生的顺序。假设list 引用首先被刷新;那时,主内存将有新的list 引用指向新对象将去的新内存区域——但由于新对象本身还没有被刷新,谁知道现在有什么?
因此,如果线程 A 在那个精确时刻开始运行 One(),它将获得新的 list 引用⁽⁴⁾,但是当它尝试遍历列表时,它不会看到新的字符串。它可能会在列表对象被构造之前看到它的初始(空)状态,或者在构造过程中看到它⁽⁵⁾。 (我不知道它是否有可能在创建列表之前看到这些内存位置中的任何值;如果是这样,那些可能代表完全不同类型的对象,甚至根本不是有效对象,这可能会导致某种异常或错误。)
在任何情况下,如果涉及多个线程,一个人可能会看到list 既没有原始列表也没有新列表。
因此,如果您希望您的代码健壮且不会偶尔失败⁽⁶⁾,那么您必须防范此类并发问题。
使用@Synchronized 和@Volatile 是传统的,使用显式locks 也是如此。 (在这种特殊情况下,我认为让list volatile 可以解决问题。)
但是那些低级的结构很繁琐,很难很好地使用;幸运的是,在许多情况下有更好的选择。这个问题中的示例已经被简化太多,无法判断什么可能有效(这是最小示例的缺点!),但是工作队列、参与者、执行器、锁存器、信号量,当然还有 Kotlin 的协程都是有用的抽象更安全地处理并发。
归根结底,并发是一个很难的话题,有很多问题和不符合您预期的事情。
有很多进一步的信息来源,例如:
(1)根据Kotlin编码约定,函数名应以小写字母开头;这使它们更容易与类/对象名称区分开来。
(2) 在这个答案中,我假设 Kotlin/JVM。类似的风险也可能适用于其他平台,但细节有所不同。
(3) 这当然是简化了;可能有多个级别的缓存,其中一些可能在内核/处理器之间共享;并且一些系统具有试图确保缓存一致的硬件......
(4) 引用本身是原子的,所以线程要么看到旧引用要么看到新引用——它看不到包含旧引用和新引用的部分的位模式,指向完全随机的地方.所以这是我们没有的一个问题!
(5)虽然reference是不可变的,但对象在构造过程中会发生变异,所以它可能处于不一致的状态。
(6) 而且你的系统负载越重,发生并发问题的可能性就越大,这意味着事情可能会在最糟糕的时候失败!