锁定任意键的处理程序

时间:2017-01-27 16:13:49

标签: java concurrency java-8 out-of-memory concurrenthashmap

我有代码为任意键实现“锁定处理程序”。给定key,它确保一次只有一个线程可以process该(或等于)密钥(这意味着调用externalSystem.process(key)调用)。

到目前为止,我的代码是这样的:

public class MyHandler {
    private final SomeWorkExecutor someWorkExecutor;
    private final ConcurrentHashMap<Key, Lock> lockMap = new ConcurrentHashMap<>();

    public void handle(Key key) {
        // This can lead to OOM as it creates locks without removing them
        Lock keyLock = lockMap.computeIfAbsent( 
            key, (k) -> new ReentrantLock()
        );
        keyLock.lock();
        try {
            someWorkExecutor.process(key);
        } finally {
            keyLock.unlock();
        }
    }
}

我知道这段代码可以导致OutOfMemoryError,因为没有一张清晰的地图。

我想到如何制作积累有限数量元素的地图。当超过限制时,我们应该用new替换最旧的访问元素(此代码应与作为监视器的最旧元素同步)。但是我不知道怎么回调会说我超出限制。

请分享您的想法。

P.S。

我重读了这个任务,现在我发现我有一个限制,handle方法不能被调用超过8个线程。我不知道它对我有什么帮助,但我刚提到它。

P.S.2

由@Boris提出了很好的简单解决方案:

} finally {
      lockMap.remove(key);
      keyLock.unlock();
}

但是在鲍里斯注意到代码之后我们没有线程安全,因为它打破了行为:
让研究用同样密钥调用3个线程:

  1. 线程#1获取锁定,现在在map.remove(key);
  2. 之前
  3. 线程#2使用等号键调用,因此它在线程#1释放锁定时等待。
  4. 然后线程#1执行map.remove(key);。在这个线程#3之后调用方法handle。它检查映射中是否存在此密钥的锁定,因此它会创建新锁并获取它。
  5. 线程#1释放锁定,因此线程#2获取它 因此,对于等号键,可以并行调用线程#2和线程#3。但不应该允许它。
  6. 为了避免这种情况,在映射清除之前,我们应该阻止任何线程获取锁,而waitset的所有线程都没有获取并释放锁。看起来需要足够复杂的同步,这将导致算法运行缓慢。也许我们应该在地图大小超过某些有限值时不时清除地图。

    我浪费了很多时间,但不幸的是我没有想法如何实现这一目标。

10 个答案:

答案 0 :(得分:6)

你不需要尝试将大小限制为某个任意值 - 事实证明,你可以完成这种“锁定处理程序”习惯,同时只存储完全键的数量目前锁定在地图上。

这个想法是使用一个简单的约定:成功添加映射到地图计为“锁定”操作,并将其删除计为“解锁”操作。这可以很好地避免在某些线程仍然锁定映射和其他竞争条件时删除映射的问题。

此时,映射中的value仅用于阻止使用相同密钥到达的其他线程,并且需要等到映射被删除。

以下是一个示例 1 ,其中CountDownLatch而不是Lock作为地图值:

public void handle(Key key) throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(1);

    // try to acquire the lock by inserting our latch as a
    // mapping for key        
    while(true) {
        CountDownLatch existing = lockMap.putIfAbsent(key, latch);
        if (existing != null) {
            // there is an existing key, wait on it
            existing.await();
        } else {
            break;
        }
    }

    try {
        externalSystem.process(key);
    } finally {
        lockMap.remove(key);
        latch.countDown();
    }
}

这里,映射的生命周期只有保持锁定。地图永远不会有比不同密钥的并发请求更多的条目。

与您的方法的不同之处在于映射不会“重复使用” - 每个handle调用都会创建一个新的锁存和映射。由于您已经在进行昂贵的原子操作,因此在实践中这可能不会太慢​​。另一个缺点是,对于许多等待线程,当锁存器倒计时,所有都会被唤醒,但只有一个会成功地将新映射放入并因此获取锁定 - 其余的则返回睡眠状态新锁。

可以构建另一个版本,当线程出现并等待现有映射时重新使用映射。基本上,解锁线程只是对其中一个等待线程进行“切换”。只有一个映射将用于等待同一个密钥的整个线程集 - 它按顺序传递给每个线程。大小仍然有限,因为没有更多的线程在等待给定的映射,它仍然被移除。

要实现这一点,可以使用可以计算等待线程数的映射值替换CountDownLatch。当一个线程执行解锁时,它首先检查是否有任何线程在等待,如果是,则唤醒一个线程进行切换。如果没有线程在等待,它会“销毁”该对象(即,设置一个标志,该对象不再位于该映射中),并将其从地图中删除。

你需要在适当的锁定下进行上述操作,并且有一些棘手的细节。在实践中,我发现上面简短而又甜蜜的例子很有用。

1 动态编写,未编译且未经过测试,但这个想法有效。

答案 1 :(得分:3)

您可以依靠方法compute(K key, BiFunction<? super K,? super V,? extends V> remappingFunction)将调用同步到您的方法process以获取给定密钥,您甚至不再需要使用Lock作为地图的价值,因为你不再依赖它了。

这个想法是依靠你的ConcurrentHashMap的内部锁定机制来执行你的方法,这将允许线程并行执行process方法,用于其对应的哈希值不属于同一个箱子。这相当于基于条带锁的方法,除了您不需要额外的第三方库。

条纹锁&#39;方法很有意思,因为它在内存占用方面非常轻,因为你只需要有限数量的锁即可,因此锁所需的内存占用是已知的并且永远不会改变,这不是使用方法的方法。锁定每个键(如在您的问题中),以便通常更好/建议使用基于条带锁的方法来满足此类需求。

所以你的代码可能是这样的:

// This will create a ConcurrentHashMap with an initial table size of 16   
// bins by default, you may provide an initialCapacity and loadFactor
// if too much or not enough to get the expected table size in order
// increase or reduce the concurrency level of your map
// NB: We don't care much of the type of the value so I arbitrarily
// used Void but it could be any type like simply Object
private final ConcurrentMap<Key, Void> lockMap = new ConcurrentHashMap<>();

public void handle(Key lockKey) {
    // Execute the method process through the remapping Function
    lockMap.compute(
        lockKey,
        (key, value) -> {
            // Execute the process method under the protection of the
            // lock of the bin of hashes corresponding to the key
            someWorkExecutor.process(key);
            // Returns null to keep the Map empty
            return null;
        }
    );
}

NB 1:由于我们总是返回null,因此地图将始终为空,这样您就不会因为此地图而耗尽内存。

NB 2:由于我们从不影响给定密钥的值,请注意也可以使用computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)方法完成:

public void handle(Key lockKey) {
    // Execute the method process through the remapping Function
    lockMap.computeIfAbsent(
        lockKey,
        key -> {
            // Execute the process method under the protection of the
            // lock of the segment of hashes corresponding to the key
            someWorkExecutor.process(key);
            // Returns null to keep the Map empty
            return null;
        }
    );
}

注意3:确保您的方法process从不为任何键调用方法handle,因为您最终会遇到无限循环(相同的密钥)或死锁(其他非有序键,例如:如果一个线程调用handle(key1),然后process在内部调用handle(key2),另一个线程并行调用handle(key2),然后在内部调用process调用handle(key1),无论采用何种方法,都会遇到死锁。此行为并非特定于此方法,它将以任何方法发生。

答案 2 :(得分:2)

一种方法是完全省去并发哈希映射,只需使用带锁定的常规HashMap来执行映射所需的操作并以原子方式锁定状态。

乍一看,这似乎会降低系统的并发性,但是如果我们假设process(key)调用相对于非常快速的锁定操作而言是冗长的,那么它运行良好,因为process()调用仍然同时运行。在专属关键部分只会进行少量且固定的工作。

这是草图:

public class MyHandler {

    private static class LockHolder {
        ReentrantLock lock = new ReentrantLock();
        int refcount = 0;
        void lock(){
            lock.lock();
        }
    } 

    private final SomeWorkExecutor someWorkExecutor;
    private final Lock mapLock = new ReentrantLock();
    private final HashMap<Key, LockHolder> lockMap = new HashMap<>();

    public void handle(Key key) {

        // lock the map
        mapLock.lock();
        LockHolder holder = lockMap.computeIfAbsent(key, k -> new LockHolder());
        // the lock in holder is either unlocked (newly created by us), or an existing lock, let's increment refcount
        holder.refcount++;
        mapLock.unlock();

        holder.lock();

        try {
            someWorkExecutor.process(key);
        } finally {
            mapLock.lock()
            keyLock.unlock();
            if (--holder.refcount == 0) {
              // no more users, remove lock holder
              map.remove(key);
            }
            mapLock.unlock();
        }
    }
}

我们使用refcount,它只在共享mapLock下操作,以跟踪锁的用户数。每当refcount为零时,我们就可以在退出处理程序时删除该条目。这种方法很好,因为如果process()调用与锁定开销相比相对昂贵,那么它很容易推理并且表现良好。由于地图操作发生在共享锁下,因此添加其他逻辑也很简单,例如,在地图中保留一些Holder个对象,跟踪统计数据等。

答案 3 :(得分:0)

谢谢Ben Mane
我找到了这个变种。

public class MyHandler {
    private final int THREAD_COUNT = 8;
    private final int K = 100;
    private final Striped<Lock> striped = Striped.lazyWeakLock(THREAD_COUNT * K);
    private final SomeWorkExecutor someWorkExecutor = new SomeWorkExecutor();

    public void handle(Key key) throws InterruptedException {
        Lock keyLock = striped.get(key);

        keyLock.lock();
        try {
            someWorkExecutor.process(key);
        } finally {
            keyLock.unlock();
        }       
    }
}

答案 4 :(得分:0)

这是一个简短而又甜蜜的版本,它利用了weak版本的番石榴Interner课程,大力提升了#34;规范&#34 ;每个键的对象用作锁,并实现弱引用语义,以便清除未使用的条目。

public class InternerHandler {
    private final Interner = Interners.newWeakInterner();

    public void handle(Key key) throws InterruptedException {
        Key canonKey = Interner.intern(key);
        synchronized (canonKey) {
            someWorkExecutor.process(key);
        }       
    }
}

基本上我们要求规范 canonKey equal()key,然后锁定此canonKey。每个人都会同意规范密钥,因此所有传递相同密钥的呼叫者都会同意锁定的对象。

Interner的弱特性意味着只要不使用规范键,就可以删除该条目,这样就可以避免在条目中累积条目。之后,如果再次出现相等的密钥,则会选择新的规范条目。

上面的简单代码依赖于synchronize的内置监视器 - 但如果这对您不起作用(例如,它已经用于其他目的),您可以包含锁定Key类中的对象或创建持有者对象。

答案 5 :(得分:0)

class MyHandler {
    private final Map<Key, Lock> lockMap = Collections.synchronizedMap(new WeakHashMap<>());
    private final SomeWorkExecutor someWorkExecutor = new SomeWorkExecutor();

    public void handle(Key key) throws InterruptedException {
        Lock keyLock = lockMap.computeIfAbsent(key, (k) -> new ReentrantLock()); 
        keyLock.lock();
        try {
            someWorkExecutor.process(key);
        } finally {
            keyLock.unlock();
        }
    }
}

答案 6 :(得分:0)

每次为key创建和删除锁定对象在性能方面是一项代价高昂的操作。当你从并发映射(比如缓存)添加/删除锁时,必须确保从缓存中放置/删除对象本身是线程安全的。所以这似乎不是一个好主意,但可以通过ConcurrentHashMap

实现

条带锁定方法(也由内部并发哈希映射使用)是更好的方法。从Google Guava docs起,它被解释为

  

当您想要将锁与对象关联时,关键保证   你需要的是,如果key1.equals(key2),那么与之关联的锁   key1与key2关联的锁相同。

     

最简单的方法是将每个密钥与相同的密钥相关联   锁定,这导致最粗糙的同步。在   另一方面,您可以将每个不同的密钥与不同的密钥相关联   锁,但这需要线性内存消耗和并发   在发现新密钥时管理锁本身系统。

     

Striped允许程序员选择多个锁   基于哈希码在密钥之间分配。这允许   程序员动态选择并发和之间的权衡   内存消耗,同时保留密钥不变量if   key1.equals(key2),then striped.get(key1)== striped.get(key2)

代码:

//declare globally; e.g. class field level
Striped<Lock> rwLockStripes = Striped.lock(16);

    Lock lock = rwLockStripes.get("key");
    lock.lock();
    try {
        // do you work here
    } finally {
        lock.unlock();
    }

剪切代码后,可以帮助实现锁定的放置/删除。

private ConcurrentHashMap<String, ReentrantLock> caches = new ConcurrentHashMap<>();

public void processWithLock(String key) {
    ReentrantLock lock = findAndGetLock(key);
    lock.lock();
    try {
        // do you work here

    } finally {
        unlockAndClear(key, lock);
    }
}

private void unlockAndClear(String key, ReentrantLock lock) {
    // *** Step 1: Release the lock.
    lock.unlock();
    // *** Step 2: Attempt to remove the lock
    // This is done by calling compute method, if given lock is present in
    // cache. if current lock object in cache is same instance as 'lock'
    // then remove it from cache. If not, some other thread is succeeded in
    // putting new lock object and hence we can leave the removal of lock object to that
    // thread.
    caches.computeIfPresent(key, (k, current) -> lock == current ? null : current);

}

private ReentrantLock findAndGetLock(String key) {
    // Merge method given us the access to the previously( if available) and
    // newer lock object together.
    return caches.merge(key, new ReentrantLock(), (older, newer) -> nonNull(older) ? older : newer);
}

答案 7 :(得分:0)

您可以尝试使用JKeyLockManager之类的内容,而不是自己编写。从项目描述:

  

JKeyLockManager通过应用程序提供细粒度锁定   特定的密钥。

网站上提供的示例代码:

public class WeatherServiceProxy {
  private final KeyLockManager lockManager = KeyLockManagers.newManager();

  public void updateWeatherData(String cityName, float temperature) {
    lockManager.executeLocked(cityName, () -> delegate.updateWeatherData(cityName, temperature)); 
  }

答案 8 :(得分:-2)

调用

时会添加新值
lockMap.computeIfAbsent()

因此,您只需检查lockMap.size()项目计数即可。

但你怎么会找到第一个添加的项目?使用它们后删除项目会更好。

答案 9 :(得分:-2)

您可以使用存储对象引用的进程内缓存,如Caffeine,Guava,EHCache或cache2k。以下是如何使用cache2k构建缓存的示例:

final Cache<Key, Lock> locks =
  new Cache2kBuilder<Key, Lock>(){}
    .loader(
      new CacheLoader<Key, Lock>() {
        @Override
        public Lock load(Key o) {
          return new ReentrantLock();
        }
      }
    )
    .storeByReference(true)
    .entryCapacity(1000)
    .build();

使用模式与问题中的一样:

    Lock keyLock = locks.get(key);
    keyLock.lock();
    try {
        externalSystem.process(key);
    } finally {
        keyLock.unlock();
    }

由于缓存限制为1000个条目,因此会自动清除不再使用的锁。

如果应用程序中的容量和线程数不匹配,则缓存可能会导致锁定使用中断。该解决方案在我们的应用中可以使用多年。当存在足够长的运行任务且超出容量时,缓存将驱逐正在使用的锁。在实际应用程序中,您始终可以控制生命线程的数量,例如在Web容器中,您将处理线程的数量限制为(示例)100。因此,您知道使用的锁永远不会超过100个。如果考虑到这一点,该解决方案的开销最小。

请记住,只要您的应用程序在单个VM上运行,锁定就会起作用。您可能想看看分布式锁管理器(DLM)。提供分布式锁的产品示例:淡褐色,无影,赤褐色,红色/红色。