一尘不染

在Java中同步String对象

java

我有一个Web应用程序正在进行负载/性能测试,特别是在一项功能上,我们希望数百名用户正在访问同一页面,并且每10秒刷新一次。我们发现可以使用此功能进行改进的一个方面是,由于数据未更改,因此将Web服务的响应缓存了一段时间。

在实现了基本的缓存之后,在进一步的测试中,我发现我没有考虑并发线程如何同时访问缓存。我发现在大约100毫秒内,约有50个线程试图从缓存中获取对象,发现对象已过期,点击Web服务以获取数据,然后将对象放回缓存中。

原始代码如下所示:

private SomeData[] getSomeDataByEmail(WebServiceInterface service, String email) {

  final String key = "Data-" + email;
  SomeData[] data = (SomeData[]) StaticCache.get(key);

  if (data == null) {
      data = service.getSomeDataForEmail(email);

      StaticCache.set(key, data, CACHE_TIME);
  }
  else {
      logger.debug("getSomeDataForEmail: using cached object");
  }

  return data;
}

因此,为了确保当对象key过期时只有一个线程在调用Web服务,我想我需要同步Cache get / set操作,并且似乎使用cache键将是对象的一个​​很好的选择。同步(通过这种方式,对电子邮件b@b.com的此方法的调用不会被对a@a.com的方法调用阻止)。

我将方法更新为如下所示:

private SomeData[] getSomeDataByEmail(WebServiceInterface service, String email) {


  SomeData[] data = null;
  final String key = "Data-" + email;

  synchronized(key) {      
    data =(SomeData[]) StaticCache.get(key);

    if (data == null) {
        data = service.getSomeDataForEmail(email);
        StaticCache.set(key, data, CACHE_TIME);
    }
    else {
      logger.debug("getSomeDataForEmail: using cached object");
    }
  }

  return data;
}

我还为诸如“同步块之前”,“内部同步块”,“即将离开同步块”和“同步块之后”之类的内容添加了日志记录行,因此可以确定我是否在有效地同步获取/设置操作。

但是,这似乎没有奏效。我的测试日志输出如下:

(日志输出为“线程名”“记录器名称”“消息”)
http-80-Processor253 jsp.view-page-getSomeDataForEmail:即将输入同步块 http-80-Processor253 jsp.view-page-getSomeDataForEmail:内部同步块 http -80-Processor253 cache.StaticCache-获取:键[SomeData-test@test.com]上的对象已过期 http-80-Processor253 cache.StaticCache-获取:键[SomeData-test@test.com]返回值[null] http-80-Processor263 jsp.view页-getSomeDataForEmail:即将进入同步块 http-80-Processor263 jsp.view页-getSomeDataForEmail:同步块 http-80-Processor263 cache.StaticCache-get中的对象:键[SomeData -test@test.com]已过期 http-80-Processor263 cache.StaticCache-获取:键[SomeData-test@test.com]返回值[空] http-80-Processor131 jsp.view-page-getSomeDataForEmail:即将进入同步块 http-80-Processor131 jsp .view-page-getSomeDataForEmail:在同步块 http-80-Processor131缓存内。StaticCache-get:密钥[SomeData-test@test.com]上的对象已过期 http-80-Processor131缓存.StaticCache-get:密钥[SomeData- test@test.com]返回值[null] http-80-Processor104 jsp.view-page-getSomeDataForEmail:内部同步块 http-80-Processor104 cache.StaticCache-get:键处的对象[SomeData-test@test.com]已过期 http-80-Processor104 cache.StaticCache-获取:键[SomeData-test@test.com]返回值[空] http-80-Processor252 jsp.view-page-getSomeDataForEmail:即将进入同步块 http-80-Processor283 jsp .view-page-getSomeDataForEmail:即将进入同步块 http-80-Processor2 jsp.view-page-getSomeDataForEmail:即将进入同步块 http-80-Processor2 jsp.view-page-getSomeDataForEmail:在同步块内部

我只想一次只看到一个线程进入/退出获取/设置操作周围的同步块。

在String对象上同步是否存在问题?我认为cache-key将是一个不错的选择,因为它是操作唯一的,即使final String key在方法中声明了cache-key ,我也认为每个线程都将获取对同一对象的引用,因此将对此进行同步单个对象。

我在这里做错了什么?

更新:进一步查看日志后,似乎具有相同同步逻辑的方法的键始终相同,例如

final String key = "blah";
...
synchronized(key) { ...

不会出现相同的并发问题-一次只有一个线程进入该块。

更新2:谢谢大家的帮助!我接受了有关intern()ing字符串的第一个答案,它解决了我的第一个问题-多个线程正在进入同步块,而我认为不应这样做,因为的key值相同。

正如其他人指出的那样,将其intern()用于这样的目的并在这些Strings上进行同步确实是个坏主意-在针对Webapp运行JMeter测试以模拟预期的负载时,我看到已用的堆大小增长到将近1GB。不到20分钟。

当前,我正在使用仅同步整个方法的简单解决方案-但我真的很喜欢martinprobst和MBCook提供的代码示例,但是由于getData()该类中目前有大约7 种类似的方法(因为它需要大约7种不同的数据来自Web服务),我不想添加关于获取和释放每种方法的锁的几乎重复的逻辑。但这对于将来的使用绝对是非常非常有价值的信息。我认为这些最终是关于如何最好地进行这种线程安全的操作的正确答案,如果可以的话,我会给这些答案更多的票!


阅读 505

收藏
2020-03-08

共1个答案

一尘不染

快速浏览一下你所说的内容,而无需全神贯注,就好像你需要intern()你的Strings:

final String firstkey = "Data-" + email;
final String key = firstkey.intern();

否则,两个具有相同值的字符串不一定是同一对象。

请注意,这可能会引入新的争用点,因为在VM的深处,intern()可能必须获取锁。我不知道现代虚拟机在这一领域会是什么样子,但有人希望可以对其进行优化。

我假设你知道StaticCache仍然需要是线程安全的。但是,如果你锁定缓存而不是调用getSomeDataForEmail时的密钥,那么与你的争用应该是很小的。

对问题更新的回复:

我认为这是因为字符串文字总是产生相同的对象。戴夫·科斯塔(Dave Costa)在评论中指出,这甚至比这更好:文字总是产生规范的表示形式。因此,程序中任何地方具有相同值的所有String文字都会产生相同的对象。

编辑

其他人指出,在内部字符串上进行同步实际上是一个糟糕的主意 -部分是因为允许创建内部字符串使它们永久存在,部分原因是,如果程序中任何位置的代码多于一个内部字符串,你在这些代码位之间有依赖性,并且防止死锁或其他错误可能是不可能的。

通过键入其他答案,正在开发通过为每个键字符串存储一个锁定对象来避免这种情况的策略。

这是一种替代方案-它仍然使用单数锁,但是我们知道无论如何我们都将需要其中一个用于缓存,而你正在谈论的是50个线程,而不是5000个线程,因此这可能不是致命的。我还假设这里的性能瓶颈是缓慢阻止DoSlowThing()中的I / O,因此将从未序列化中受益匪浅。如果这不是瓶颈,那么:

  • 如果CPU繁忙,则此方法可能不够用,你需要另一种方法。
  • 如果CPU不忙,并且对服务器的访问不是瓶颈,那么这种方法就显得过大了,你可能会忘记此锁定和按键锁定,并在整个操作过程中放置​​一个较大的sync(StaticCache),然后执行这是简单的方法。
    显然,在使用前需要对这种方法进行可伸缩性测试-我不保证。

此代码不要求StaticCache同步或以其他方式线程安全。如果任何其他代码(例如,计划中的旧数据清除)曾经接触过缓存,则需要重新考虑这一点。

IN_PROGRESS是一个伪值-并非完全干净,但是代码很简单,并且节省了两个哈希表。它不处理InterruptedException,因为我不知道你的应用在这种情况下想要做什么。同样,如果DoSlowThing()对于给定的键始终失败,则该代码本身并不十分优雅,因为每个线程都将重试它。由于我不知道失败标准是什么,也不知道它们是临时的还是永久的,因此我也不处理这种故障,我只是确保线程不会永远阻塞。在实践中,你可能希望将表示“不可用”的数据值放入高速缓存中,这可能是有原因的,以及何时重试的超时。

// do not attempt double-check locking here. I mean it.
synchronized(StaticObject) {
    data = StaticCache.get(key);
    while (data == IN_PROGRESS) {
        // another thread is getting the data
        StaticObject.wait();
        data = StaticCache.get(key);
    }
    if (data == null) {
        // we must get the data
        StaticCache.put(key, IN_PROGRESS, TIME_MAX_VALUE);
    }
}
if (data == null) {
    // we must get the data
    try {
        data = server.DoSlowThing(key);
    } finally {
        synchronized(StaticObject) {
            // WARNING: failure here is fatal, and must be allowed to terminate
            // the app or else waiters will be left forever. Choose a suitable
            // collection type in which replacing the value for a key is guaranteed.
            StaticCache.put(key, data, CURRENT_TIME);
            StaticObject.notifyAll();
        }
    }
}

每次将任何内容添加到缓存中时,所有线程都将唤醒并检查缓存(无论它们使用的是什么键),因此可以使用较少争议的算法来获得更好的性能。但是,大部分工作将发生在I / O上的大量空闲CPU时间阻塞期间,因此这可能不是问题。

如果为高速缓存及其关联的锁定义了合适的抽象,它返回的数据,IN_PROGRESS伪指令以及执行缓慢的操作,则该代码可能会与多个高速缓存一起使用。将整个过程都放入缓存中的方法可能不是一个坏主意。

2020-03-08