在 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;
}

因此,为了确保在对象过期时只有一个线程调用 Web 服务,我认为我需要同步 Cache get/set 操作,并且似乎使用缓存键是要同步的对象的良好候选项(这样,对此方法的电子邮件 b@b.com 调用不会被 a@a.com 的方法调用所阻止)。key

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

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;
}

我还为“同步块之前”、“同步块内部”、“即将离开同步块”和“同步块之后”等内容添加了日志记录行,因此我可以确定我是否有效地同步了 get/set 操作。

然而,这似乎并没有奏效。我的测试日志有这样的输出:

(log output is 'threadname' 'logger name' 'message')  
http-80-Processor253 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor253 jsp.view-page - getSomeDataForEmail: inside synchronization block  
http-80-Processor253 cache.StaticCache - get: object at key [SomeData-test@test.com] has expired  
http-80-Processor253 cache.StaticCache - get: key [SomeData-test@test.com] returning value [null]  
http-80-Processor263 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor263 jsp.view-page - getSomeDataForEmail: inside synchronization block  
http-80-Processor263 cache.StaticCache - get: object at key [SomeData-test@test.com] has expired  
http-80-Processor263 cache.StaticCache - get: key [SomeData-test@test.com] returning value [null]  
http-80-Processor131 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor131 jsp.view-page - getSomeDataForEmail: inside synchronization block  
http-80-Processor131 cache.StaticCache - get: object at key [SomeData-test@test.com] has expired  
http-80-Processor131 cache.StaticCache - get: key [SomeData-test@test.com] returning value [null]  
http-80-Processor104 jsp.view-page - getSomeDataForEmail: inside synchronization block  
http-80-Processor104 cache.StaticCache - get: object at key [SomeData-test@test.com] has expired  
http-80-Processor104 cache.StaticCache - get: key [SomeData-test@test.com] returning value [null]  
http-80-Processor252 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor283 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor2 jsp.view-page - getSomeDataForEmail: about to enter synchronization block  
http-80-Processor2 jsp.view-page - getSomeDataForEmail: inside synchronization block  

我想一次只看到一个线程进入/退出围绕 get/set 操作的同步块。

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

我在这里做错了什么?

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

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

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

更新2:感谢大家的帮助!我接受了关于字符串的第一个答案,这解决了我最初的问题 - 多个线程进入我认为不应该进入的同步块,因为's具有相同的值。intern()key

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

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


答案 1

在不让我的大脑完全进入状态的情况下,从你所说的快速扫描来看,看起来你需要实习()你的字符串:

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

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

请注意,这可能会引入新的争用点,因为在 VM 的深处,intern() 可能必须获取锁。我不知道现代虚拟机在这个领域是什么样子的,但人们希望它们得到疯狂的优化。

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

对问题更新的回复

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

编辑

其他人指出,在terntern字符串上进行同步实际上是一个非常糟糕的主意 - 部分原因是允许创建terntern字符串以使它们永久存在,部分原因是如果程序中任何地方的多个代码位在terntern字符串上同步,那么您在这些代码位之间具有依赖关系,并且防止死锁或其他错误可能是不可能的。

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

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

  • 如果 CPU 繁忙,则此方法可能不够用,您需要另一种方法。
  • 如果CPU不繁忙,并且访问服务器不是瓶颈,那么这种方法就大材小用了,你不妨忘记这个和每个密钥的锁定,在整个操作周围放一个大的同步(StaticCache),并以简单的方法做到这一点。

显然,这种方法在使用前需要进行可伸缩性测试 - 我不保证任何事情。

此代码不要求 StaticCache 是同步的或以其他方式实现线程安全的。如果任何其他代码(例如,计划清理旧数据)触及缓存,则需要重新访问。

IN_PROGRESS是一个虚拟值 - 不完全干净,但代码很简单,它节省了两个哈希表。它不处理中断异常,因为我不知道在这种情况下你的应用程序想要做什么。此外,如果 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虚拟的操作以及要执行的慢速操作定义了合适的抽象,则可以通用此代码用于多个缓存。将整个事情滚动到缓存上的方法可能不是一个坏主意。


答案 2

在实习的 String 上进行同步可能根本不是一个好主意 - 通过实习它,String 会变成一个全局对象,如果你在应用程序的不同部分的相同实习字符串上进行同步,你可能会得到非常奇怪且基本上无法调试的同步问题,例如死锁。这似乎不太可能,但当它发生时,你真的搞砸了。作为一般规则,只有在本地对象上进行同步,您绝对确定模块外部的任何代码都不会锁定它。

在您的情况下,您可以使用同步哈希表来存储密钥的锁定对象。

例如:

Object data = StaticCache.get(key, ...);
if (data == null) {
  Object lock = lockTable.get(key);
  if (lock == null) {
    // we're the only one looking for this
    lock = new Object();
    synchronized(lock) {
      lockTable.put(key, lock);
      // get stuff
      lockTable.remove(key);
    }
  } else {
    synchronized(lock) {
      // just to wait for the updater
    }
    data = StaticCache.get(key);
  }
} else {
  // use from cache
}

此代码具有争用条件,其中两个线程可能将对象彼此之后放入锁定表中。但是,这应该不是问题,因为这样您只有一个线程调用Web服务并更新缓存,这应该不是问题。

如果在一段时间后使缓存失效,则应在从缓存中检索数据后再次检查数据是否为 null,在 lock != null 的情况下。

或者,更容易的是,您可以使整个缓存查找方法(“getSomeDataByEmail”)同步。这意味着所有线程在访问缓存时都必须进行同步,这可能是性能问题。但与往常一样,请先尝试这个简单的解决方案,看看它是否真的是一个问题!在许多情况下,它不应该是,因为您可能花费更多的时间来处理结果而不是同步。