【问题标题】:Spring singleton beans, maps and multithreadingSpring单例bean、映射和多线程
【发布时间】:2019-03-27 06:57:01
【问题描述】:

有时我会这样做:

@Component class MyBean {
  private Map<TypeKey, Processor> processors;

  @Autowired void setProcessors(List<Processor> processors) {
    this.processors = processors.stream().....toMap(Processor::getTypeKey, x -> x);
  }

  //some more methods reading this.processors
}

但是,严格来说,这是有缺陷的代码,不是吗?

1) this.processors 不是最终的,它的创建也不是在每次访问它时在同一个监视器上同步。因此,每个线程 - 并且可以从处理用户请求的任意线程调用此单例 - 可能会观察其自己的 this.processors 值,该值可能为空。

2) 即使在最初填充 Map 之后没有发生写入,Javadoc 也不保证 Map 将使用哪个实现,因此当 Map 时,它可能是一个无法确保线程安全的实现结构发生变化,或者即使有任何修改,或者根本没有。并且初始人口 is 发生变化,因此它可能会破坏线程安全性,谁知道多久。 Collectors 甚至提供专门的 toConcurrentMap() 方法来解决这个问题 - 所以,至少,我应该改用它。

但即使我在#2 中使用toConcurrentMap(),我也无法将我的字段设为final,因为那样我将无法在setter 中对其进行初始化。所以这是我的选择:

a) 在自动装配的构造函数中初始化并填充Map,坦率地说,我更喜欢这样。但是很少有团队这样做,那么如果我们放弃该解决方案怎么办?还有哪些其他选择?

b) 将 Map 初始化为空的 final ConcurrentHashMap,然后将其填充到 setter 中。这是可能的,但我们必须先list.forEach() 然后map.put()。这看起来仍然是 Java 6;或者我们绝对可以做map.addAll(list....toMap()),但它对Map 的重复是无用的,即使是临时的。

c) 在现场使用volatile。稍微降低性能而没有任何需要,因为在某些时候该字段永远不会改变。

d) 使用synchronized 访问该字段并读取其值。显然比 (c) 还要糟糕。

此外,这些方法中的任何一种都会使读者认为代码实际上需要对Map 进行一些多线程读取/写入,而实际上,它只是多线程读取。

那么,当一个理性的大师想要这样的东西时,他们会怎么做?

此时,最好的解决方案似乎是具有volatile 字段的解决方案,使用toConcurrentMap 在setter 中分配。有更好的吗?或者我只是在编造没有人真正遇到过的问题?

【问题讨论】:

  • So, what does reasonable guru do when they want something like that? -- 你的答案是:synchronized。一段时间后,如果有时间,有人可能会将其更改为某种有效的锁定机制,例如 StampedLock,甚至手动、顺序、不同步的 ConcurrentHashMap 填充 - 这是经典且经过验证的......虽然与流人口相比可能不那么“美丽”。真正的软件工程师并不总是关心美观——可读性、可维护性和稳定性更为重要
  • AFAIR(但我找不到任何参考),Spring 确保有一个发生在之前,即 bean 的注入发生在它们使用之前。应该没有什么可担心的。而且我不知道有任何地图(当然也不是 Collectors.toMap() 使用的 HashMap),当您只读取它时,它本身会决定更改其内部结构。也就是说,我确实更喜欢构造函数注入而不是 setter 注入。
  • 我完全错过了这个问题是关于二传手注入和弹簧单例的事实。那么在这种情况下:是的,这实际上并不重要,因为注入只发生 每个单例 bean 一次
  • "only once" - 无关紧要,我不在乎它发生了多少次,我在乎结果是否被每个人观察到。即使某些事情发生在很久以前,如果没有适当的发生之前,某些线程可能还没有观察到它。 JB Nizet 的回答说 Spring 可以确保这一点 - 我也听说过,我会尝试找到具体细节。
  • " 当你只读取它时,它自己决定改变它的内部结构。" - 不,当然不是,我试图解释说,当它被填充时,它可能并且会改变它的结构,并且由于它不是线程安全的,没有人可以确定进一步阅读它会很好。线程安全不仅意味着“在并发访问中”,而且更普遍 - 来自并发线程的访问。因此 volatile.

标签: java spring multithreading


【解决方案1】:

或者我只是在编造没有人真正遇到过的问题?

我认为可能会将您的任务与历史上从双重检查锁定中看到的问题混为一谈:

private Foo foo;  // this is an instance variable

public Foo getFoo() {
    if (foo != null) {
        synchronized (this) {
            if (foo != null) {
                foo = new Foo();
            }
        }
    }
    return foo;
}

这段代码似乎是线程安全的:你做一个初始的,假设的快速,检查以验证这个值还没有被初始化,如果它还没有你在同步中初始化堵塞。问题是new 操作与构造函数调用不同,一些实现在构造函数运行之前将new 返回的引用分配给变量。结果是另一个线程可以在构造函数完成之前看到该值。

但是,在您的情况下,您是根据函数调用的结果分配变量。函数调用创建的Map 在函数返回之前不会分配给变量。不允许编译器(包括 Hotspot)重新排序此操作,因为这样的更改对于正在执行函数的线程是可见的,因此不会按照JLS 17.4.3 的顺序保持一致。

顺便说一句,这里有一些额外的 cmets:

在自动装配的构造函数中初始化和填充地图,坦率地说,我更喜欢

Guice 依赖注入框架的创建者也是如此。更喜欢构造函数注入的一个原因是您知道您永远不会看到处于不一致状态的 bean。

Spring 鼓励(或至少不阻止)setter 注入的原因是因为它使循环引用成为可能。您可以自行决定循环引用是否是一个好主意。

将 Map 初始化为一个空的 final ConcurrentHashMap,然后在 setter 中填充它

这是个坏主意,因为 可能 其他线程会看到部分构造的地图。最好查看null 或完全构建的地图,因为您可以补偿第一种情况。

在球场上使用 volatile。无需任何操作即可略微降低性能

使用同步访问字段并读取其值。显然比 (c) 还要糟糕。

不要让感知到的性能影响阻碍您编写正确的代码。 唯一同步会显着影响您的性能的时间是并发线程在紧密循环中访问同步变量/方法时。如果您不在循环中,那么内存屏障会为您的调用增加无关紧要的时间(即使在循环中,除非您需要等待值到达核心缓存,否则它也是最小的)。

在这种情况下,这无关紧要,但我猜getProcessors() 只占用了您总执行时间的一小部分,而运行处理器所花费的时间要大得多。

【讨论】:

  • “不要让感知到的性能影响阻止您编写正确的代码。” - 当然,我只是想避免它有一个更清洁的解决方案(或者如果由于某种原因没有问题)。并感谢有关新+分配的有趣见解。但我没有看到实际的答案:你建议我做什么? “在你的情况下,但是”意味着你不认为有问题。
  • 另外,你引用的代码至少包含同步。我显示的初始代码没有。所以,它可能仍然像我说的那样有问题。所以,有一个问题,它需要一个解决方案。你真正说的答案是什么?
  • 只需使用 toConcurrentMap 就可以了。在此之后地图不可能处于不一致的状态 - 您正在尝试解决不存在的问题,您的时间肯定可以以更好的方式投入......通过阅读例如在 spring CDI 和 @Required
  • @MaksimGumerov - 我写的示例代码是一个多线程可见性问题的例子。我写它是为了与您编写的代码进行比较,由于我给您的解释和 JLS 参考,多线程可见性不是问题。
  • 果然,toConcurrentMap 解决了地图一致性问题。我在哪里说过之后可能会变得不一致? toConcurrentMap 根据定义创建了一个线程安全的映射:) 你没有在我最初的问题中添加任何内容,有 4 个选项。但是,是的,我承诺在注入之前阅读 Spring 保证如何发生。还没有这样做。
【解决方案2】:

感谢此处评论者的提示,在谷歌上搜索了一下后,我发现不是对 Spring 手册的引用,但至少是事实保证,请参阅 Should I mark object attributes as volatile if I init them in @PostConstruct in Spring Framework? - 更新接受答案的一部分。从本质上讲,它表示在上下文中对特定 bean 的每次查找,因此,大致上,该 bean 的每次注入,都在锁定某个监视器之前,并且 bean 初始化也发生在锁定在同一个监视器上,建立之前发生bean初始化和bean注入的关系。更具体地说,在 bean 初始化期间完成的所有事情(例如,在 MyBean 初始化中分配 处理器)发生在该 bean 的后续注入之前 - 并且该 bean 仅在它具有被注入。所以作者说没有volatile是必要的,除非我们在那之后改变这个字段。

如果不是 2 个“但是”,那将是我接受的答案(结合 toConcurrentMap)。

1) 这并不意味着注入未初始化的 bean 提供相同的发生之前。某些人认为,注入未初始化的 bean 的频率更高。在循环依赖的情况下,最好保持罕见但有时看起来有效。在惰性初始化 bean 的情况下。一些库(AFAIK 甚至一些 Spring 项目)引入了循环依赖,我自己也看到了。有时你不小心引入了循环依赖,默认情况下不会被视为错误。当然,合理的代码不会使用未初始化的 bean,但是由于 MyBean 可以被注入到一些 bean X before 它被初始化,所以不会有发生之前 after 它的初始化,取消我们的保证。

2) 这甚至不是文档化的功能。仍然!但最近它至少被搁置了。请参阅https://github.com/spring-projects/spring-framework/issues/8986 - 不过,在他们记录下来之前,我们不能假设它不会发生变化。呸,即使他们这样做了,它仍然可能会在下一个版本中进行更改,但至少会反映在一些更改列表或诸如此类的内容中。

因此,考虑到这两个音符,尤其是第一个音符,我倾向于说 volatile+toConcurrentMap 是要走的路。对吧?

【讨论】: