在Java中同步String对象

时间:2020-03-06 14:43:38  来源:igfitidea点击:

我有一个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 key可能是一个不错的选择一个要同步的对象(这样,对电子邮件[email protected]的此方法的调用不会被对[email protected]的方法调用阻止)。

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

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

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

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

(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 [[email protected]] has expired

  http-80-Processor253 cache.StaticCache - get: key [[email protected]] 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 [[email protected]] has expired

  http-80-Processor263 cache.StaticCache - get: key [[email protected]] 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 [[email protected]] has expired

  http-80-Processor131 cache.StaticCache - get: key [[email protected]] returning value [null]

  http-80-Processor104 jsp.view-page - getSomeDataForEmail: inside synchronization block

  http-80-Processor104 cache.StaticCache - get: object at key [[email protected]] has expired

  http-80-Processor104 cache.StaticCache - get: key [[email protected]] 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

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

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

我在这里做错了什么?

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

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

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

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

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

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

解决方案

快速浏览一下我们所说的内容,而无需全神贯注,就好像我们需要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不忙,并且对服务器的访问不是瓶颈,那么这种方法就显得过大了,我们可能会忘记此锁定和按键锁定,在整个操作过程中放置​​一个大的synced(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伪指令以及执行缓慢的操作,则该代码可能会通用于多个高速缓存。将整个过程都放入缓存中的方法可能不是一个坏主意。

电话:

final String key = "Data-" + email;

每次调用该方法时都会创建一个新对象。因为该对象是我们用来锁定的对象,并且对该方法的每次调用都会创建一个新对象,所以我们实际上并没有基于密钥同步对地图的访问。

这进一步说明了编辑。当我们有静态字符串时,它将起作用。

使用intern()解决了该问题,因为它从String类保留的内部池中返回该字符串,从而确保如果两个字符串相等,则将使用该池中的一个。看

http://java.sun.com/j2se/1.4.2/docs/api/java/lang/String.html#intern()

主要问题不仅是可能存在多个具有相同值的String实例。主要问题是我们只需要在一个监视器上进行同步即可访问StaticCache对象。否则,多个线程可能最终会同时修改StaticCache(尽管使用不同的键),这很可能不支持并发修改。

通过实习生一个intern'd 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的情况下,应从缓存中检索数据后再次检查数据是否为空。

另外,也可以更轻松地使整个缓存查找方法(" getSomeDataByEmail")同步。这将意味着所有线程在访问缓存时都必须进行同步,这可能是性能问题。但是,与往常一样,请首先尝试使用此简单的解决方案,看看是否确实有问题!在许多情况下不应该这样,因为与同步相比,我们可能花费更多的时间来处理结果。

为什么不仅仅呈现一个静态的html页面,该页面会提供给用户并每隔x分钟重新生成一次?

其他人建议对字符串进行intern,这将起作用。

问题在于Java必须保留内部字符串。有人告诉我即使我们没有保留引用,它也会这样做,因为下次有人使用该字符串时,该值必须相同。这意味着对所有字符串进行内部处理可能会开始消耗内存,而我们所描述的负载可能是个大问题。

我已经看到了两种解决方案:

我们可以在另一个对象上同步

代替保存电子邮件,创建一个保存电子邮件的对象(例如User对象),该对象将email的值作为变量保存。如果我们已经有另一个代表此人的对象(例如,我们已经根据他们的电子邮件从数据库中提取了某些东西),则可以使用该对象。通过实现equals方法和hashcode方法,我们可以确保Java在执行静态cache时将对象视为相同。contains()来查找数据是否已在缓存中(我们必须在缓存上进行同步)。

实际上,我们可以保留第二个Map来锁定对象。像这样的东西:

Map<String, Object> emailLocks = new HashMap<String, Object>();

Object lock = null;

synchronized (emailLocks) {
    lock = emailLocks.get(emailAddress);

    if (lock == null) {
        lock = new Object();
        emailLocks.put(emailAddress, lock);
    }
}

synchronized (lock) {
    // See if this email is in the cache
    // If so, serve that
    // If not, generate the data

    // Since each of this person's threads synchronizes on this, they won't run
    // over eachother. Since this lock is only for this person, it won't effect
    // other people. The other synchronized block (on emailLocks) is small enough
    // it shouldn't cause a performance problem.
}

这样可以防止一次在同一电子邮件地址上进行15次抓取。我们需要采取一些措施来防止太多条目最终落入emailLocks映射中。使用来自Apache Commons的LRUMaps可以做到。

这将需要一些调整,但它可以解决问题。

使用其他键

如果我们愿意忍受可能的错误(我不知道这有多重要),则可以使用String的哈希码作为键。 int不需要被拘禁。

概括

我希望这有帮助。线程很有趣,不是吗?我们还可以使用会话设置一个值,表示"我已经在寻找这个",并检查是否第二个(第三个,第N个)线程需要尝试创建或者只是等待结果显示在缓存中。我想我有三个建议。

字符串不是同步的良好候选者。如果必须在字符串ID上进行同步,则可以使用字符串创建互斥锁来完成此操作(请参见"在ID上同步")。该算法的成本是否值得,取决于调用服务是否涉及任何重要的I / O。

还:

  • 我希望StaticCache.get()和set()方法是线程安全的。
  • String.intern()需要付出一定的代价(在VM的实现之间有所不同),因此应谨慎使用。

如果我们不需要它,我也建议完全摆脱字符串连接。

final String key = "Data-" + email;

缓存中是否还有其他类型的对象/类型的对象使用电子邮件地址,因此我们需要在密钥开头添加额外的" Data-"?

如果没有,我只会做那个

final String key = email;

而且我们也避免了所有额外的字符串创建。

我们可以使用1.5并发实用程序来提供旨在允许多个并发访问和单个添加点的高速缓存(即,只有一个线程执行过昂贵的对象"创建"):

private ConcurrentMap<String, Future<SomeData[]> cache;
 private SomeData[] getSomeDataByEmail(final WebServiceInterface service, final String email) throws Exception {

  final String key = "Data-" + email;
  Callable<SomeData[]> call = new Callable<SomeData[]>() {
      public SomeData[] call() {
          return service.getSomeDataForEmail(email);
      }
  }
  FutureTask<SomeData[]> ft; ;
  Future<SomeData[]> f = cache.putIfAbsent(key, ft= new FutureTask<SomeData[]>(call)); //atomic
  if (f == null) { //this means that the cache had no mapping for the key
      f = ft;
      ft.run();
  }
  return f.get(); //wait on the result being available if it is being calculated in another thread
}

显然,这不能像我们期望的那样处理异常,并且缓存没有内置驱逐。但是,我们可以将其用作更改StaticCache类的基础。

使用像ehcache这样的体面缓存框架。

实现良好的缓存并不像某些人所认为的那样容易。

关于String.intern()是内存泄漏的原因的评论,实际上并非如此。
中断字符串是垃圾收集的,可能会花费更长的时间,因为在某些JVM(SUN)上,它们存储在Perm空间中,只有完整的GC才能访问。