Guava 的缓存中的 get() 是线程安全的操作吗?
Is get() thread-safe operation in Guava's cache?
我发现使用 CacheLoader 操作的 put 和 get 在后台使用了可重入锁,但为什么 getIfPresent 操作没有实现这一点?
getIfPresent
使用的get
@Nullable
V get(Object key, int hash) {
try {
if (this.count != 0) {
long now = this.map.ticker.read();
ReferenceEntry<K, V> e = this.getLiveEntry(key, hash, now);
Object value;
if (e == null) {
value = null;
return value;
}
value = e.getValueReference().get();
if (value != null) {
this.recordRead(e, now);
Object var7 = this.scheduleRefresh(e, e.getKey(), hash, value, now, this.map.defaultLoader);
return var7;
}
this.tryDrainReferenceQueues();
}
Object var11 = null;
return var11;
} finally {
this.postReadCleanup();
}
}
放
@Nullable
V put(K key, int hash, V value, boolean onlyIfAbsent) {
this.lock();
.....
要在基本 get/put 操作中实现线程安全,我唯一能做的就是在客户端上使用同步吗?
似乎 guava 缓存正在实现 ConcurrentMap api
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>
所以基本的 get 和 put 操作本质上应该是线程安全的
即使 getIfPresent
确实使用了锁,也无济于事。它比那更基本。
换种说法:定义 'threadsafe'.
下面是非线程安全实现中可能发生的情况的示例:
- 您在普通简
j.u.HashMap
上调用 .put
,未持有任何锁。
- 同时,另一个线程也这样做。
- 地图现在处于损坏状态。如果您遍历元素,第一个 put 语句根本不显示,第二个 put 语句出现在您的迭代中,并且完全不相关的键消失了。但是,即使在
.entrySet()
中 returned,使用第二个线程的键在该映射上调用 .get(k)
也找不到它。这没有任何意义,并且违反了 j.u.HashMap 的所有规则。 hashmap 的规范没有解释任何这些,除了 'I am not threadsafe' 并留在那里。
这是一个非线程安全的例子。
这里有一个完美的例子:
- 2 个线程开始。
- 一些外部事件(例如日志)显示线程 1 非常非常非常稍微领先于线程 2,但是 'ahead' 的概念,如果相关,则意味着您的代码已损坏。多核不是这样工作的。
- 线程 1 将事物添加到支持并发的映射中,并记录它已完成的操作。
- 线程 2 记录它开始一个操作。 (从你观察到的几件事来看,它似乎 运行ning 稍微 'later')所以我想我们是在“之后”T1 添加东西的点)现在查询东西和 没有得到结果.1
没关系。那仍然是线程安全的。线程安全并不意味着可以根据 'first this thing happened, then that thing happened' 理解与该数据类型实例的每次交互。想要这样做是 非常有问题的 ,因为计算机真正能给你这种保证的唯一方法是禁用除单个内核之外的所有内核,并且 运行 一切都非常非常缓慢。缓存的目的是加快速度,而不是减慢速度!
此处缺乏保证的问题是,如果您 运行 对同一个对象进行多个单独的操作,您 运行 就会遇到麻烦。这是银行 ATM 机的一些伪代码,在很长一段时间内会出现严重错误 运行:
- 询问用户他们想要多少钱(例如,50 欧元,-)。
- 从 'threadsafe'
Map<Account, Integer>
中检索帐户余额(将帐户 ID 映射到帐户中的美分)。
- 检查是否 50 欧元,-。如果不是,则显示错误。如果是...
- 吐出 50 欧元,-,并用
.put(acct, balance - 5000)
更新线程安全映射。
一切都是线程安全的。然而,这会变得非常非常错误——如果用户同时使用他们的卡,他们在银行通过出纳员取款,那么银行或用户都会在这里变得非常幸运。我希望很明显看到如何以及为什么。
结果是:如果您在操作之间存在依赖关系,那么您无法用 'threadsafe' 可能修复它的概念做任何事情;唯一的方法是实际编写明确标记这些依赖项的代码.
编写该银行代码的唯一方法是使用某种形式的锁定。基本锁定或乐观锁定,任何一种方式都可以,但是某种锁定。它看起来像2:
start some sort of transaction;
fetch account balance;
deal with insufficient funds;
spit out cash;
update account balance;
end transaction;
现在 guava 的代码非常有意义:
没有'earlier'和'later'。您需要停止以这种方式考虑多核。除非您显式编写建立这些东西的原语。缓存接口有这些。使用正确的操作! getIfPresent
如果您的当前线程有可能获取该数据,将为您获取缓存。如果不是,它 returns null
,这就是调用 所做的。
如果你想要这个常见的操作:“给我缓存值。但是,如果它不可用,那么运行这段代码来计算缓存值,缓存结果, return 给我。此外,确保如果 2 个线程同时结束 运行ning 这个确切的操作,只有一个线程 运行s 计算,另一个将等待另一个(不要说 'first' 一个,这不是您应该考虑线程的方式)完成,然后使用该结果代替”。然后,使用正确的调用:.cache.get(key, k -> calculateValueForKey(k))
。作为the docs explicitly call out这将等待另一个线程也'loading'值(这就是番石榴缓存调用计算过程)。
无论您从缓存中调用什么 API,您都不能 'break it',因为我破坏了那个 HashMap。缓存 API 部分通过使用锁(例如 ReentrantLock
对其进行变异操作),部分通过使用 ConcurrentHashMap
在幕后实现。
[1] 通常,日志框架最终会在程序中注入实际的显式锁定,因此在这种情况下您通常会得到保证,但只有 'by accident' 因为日志框架。这并不能保证(例如,您可能正在记录到单独的日志文件!)而且您 'witness' 通常可能是谎言。例如,也许您有 2 个日志语句,它们都记录到单独的文件(并且根本不相互锁定),并且它们将时间戳记录为日志的一部分。一个日志行显示“12:00:05”而另一个日志行显示“12:00:06”这一事实 毫无意义 - 日志线程获取当前时间,创建一个字符串描述消息,并告诉 OS 将其写入文件。显然,您绝对不能保证 2 个日志线程 运行 的速度相同。也许一个线程获取时间 (12:00:05),创建字符串,想要写入磁盘但是 OS 在写入完成之前切换到另一个线程,另一个线程是另一个记录器,它读取时间 (12:00:06),生成字符串,将其写出,完成,然后第一个记录器继续,写入其上下文。 Tada:2 个线程,其中 'observe' 一个线程是 'earlier',但这是不正确的。也许这个例子会进一步强调为什么根据 'first' 来考虑线程会误导你。
[2] 此代码具有额外的复杂性,即您正在与 不能 是事务性的系统进行交互。事务的要点是您可以中止它;您不能中止用户从 ATM 取款。你通过记录你即将吐出钱来解决这个问题,然后吐出钱,然后记录你已经吐出钱。并且最后在用户的账户余额中写入这个日志已经处理过了。其他代码需要检查此日志并采取相应措施。例如,在启动时,银行的数据库机器需要标记 'dangling' ATM 交易,并且必须让人检查视频源。解决了刚要取钞时被银行DB机电源线绊倒的问题
我发现使用 CacheLoader 操作的 put 和 get 在后台使用了可重入锁,但为什么 getIfPresent 操作没有实现这一点?
getIfPresent
使用的get@Nullable
V get(Object key, int hash) {
try {
if (this.count != 0) {
long now = this.map.ticker.read();
ReferenceEntry<K, V> e = this.getLiveEntry(key, hash, now);
Object value;
if (e == null) {
value = null;
return value;
}
value = e.getValueReference().get();
if (value != null) {
this.recordRead(e, now);
Object var7 = this.scheduleRefresh(e, e.getKey(), hash, value, now, this.map.defaultLoader);
return var7;
}
this.tryDrainReferenceQueues();
}
Object var11 = null;
return var11;
} finally {
this.postReadCleanup();
}
}
放
@Nullable
V put(K key, int hash, V value, boolean onlyIfAbsent) {
this.lock();
.....
要在基本 get/put 操作中实现线程安全,我唯一能做的就是在客户端上使用同步吗?
似乎 guava 缓存正在实现 ConcurrentMap api
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>
所以基本的 get 和 put 操作本质上应该是线程安全的
即使 getIfPresent
确实使用了锁,也无济于事。它比那更基本。
换种说法:定义 'threadsafe'.
下面是非线程安全实现中可能发生的情况的示例:
- 您在普通简
j.u.HashMap
上调用.put
,未持有任何锁。 - 同时,另一个线程也这样做。
- 地图现在处于损坏状态。如果您遍历元素,第一个 put 语句根本不显示,第二个 put 语句出现在您的迭代中,并且完全不相关的键消失了。但是,即使在
.entrySet()
中 returned,使用第二个线程的键在该映射上调用.get(k)
也找不到它。这没有任何意义,并且违反了 j.u.HashMap 的所有规则。 hashmap 的规范没有解释任何这些,除了 'I am not threadsafe' 并留在那里。
这是一个非线程安全的例子。
这里有一个完美的例子:
- 2 个线程开始。
- 一些外部事件(例如日志)显示线程 1 非常非常非常稍微领先于线程 2,但是 'ahead' 的概念,如果相关,则意味着您的代码已损坏。多核不是这样工作的。
- 线程 1 将事物添加到支持并发的映射中,并记录它已完成的操作。
- 线程 2 记录它开始一个操作。 (从你观察到的几件事来看,它似乎 运行ning 稍微 'later')所以我想我们是在“之后”T1 添加东西的点)现在查询东西和 没有得到结果.1
没关系。那仍然是线程安全的。线程安全并不意味着可以根据 'first this thing happened, then that thing happened' 理解与该数据类型实例的每次交互。想要这样做是 非常有问题的 ,因为计算机真正能给你这种保证的唯一方法是禁用除单个内核之外的所有内核,并且 运行 一切都非常非常缓慢。缓存的目的是加快速度,而不是减慢速度!
此处缺乏保证的问题是,如果您 运行 对同一个对象进行多个单独的操作,您 运行 就会遇到麻烦。这是银行 ATM 机的一些伪代码,在很长一段时间内会出现严重错误 运行:
- 询问用户他们想要多少钱(例如,50 欧元,-)。
- 从 'threadsafe'
Map<Account, Integer>
中检索帐户余额(将帐户 ID 映射到帐户中的美分)。 - 检查是否 50 欧元,-。如果不是,则显示错误。如果是...
- 吐出 50 欧元,-,并用
.put(acct, balance - 5000)
更新线程安全映射。
一切都是线程安全的。然而,这会变得非常非常错误——如果用户同时使用他们的卡,他们在银行通过出纳员取款,那么银行或用户都会在这里变得非常幸运。我希望很明显看到如何以及为什么。
结果是:如果您在操作之间存在依赖关系,那么您无法用 'threadsafe' 可能修复它的概念做任何事情;唯一的方法是实际编写明确标记这些依赖项的代码.
编写该银行代码的唯一方法是使用某种形式的锁定。基本锁定或乐观锁定,任何一种方式都可以,但是某种锁定。它看起来像2:
start some sort of transaction;
fetch account balance;
deal with insufficient funds;
spit out cash;
update account balance;
end transaction;
现在 guava 的代码非常有意义:
没有'earlier'和'later'。您需要停止以这种方式考虑多核。除非您显式编写建立这些东西的原语。缓存接口有这些。使用正确的操作!
getIfPresent
如果您的当前线程有可能获取该数据,将为您获取缓存。如果不是,它 returnsnull
,这就是调用 所做的。如果你想要这个常见的操作:“给我缓存值。但是,如果它不可用,那么运行这段代码来计算缓存值,缓存结果, return 给我。此外,确保如果 2 个线程同时结束 运行ning 这个确切的操作,只有一个线程 运行s 计算,另一个将等待另一个(不要说 'first' 一个,这不是您应该考虑线程的方式)完成,然后使用该结果代替”。然后,使用正确的调用:
.cache.get(key, k -> calculateValueForKey(k))
。作为the docs explicitly call out这将等待另一个线程也'loading'值(这就是番石榴缓存调用计算过程)。无论您从缓存中调用什么 API,您都不能 'break it',因为我破坏了那个 HashMap。缓存 API 部分通过使用锁(例如
ReentrantLock
对其进行变异操作),部分通过使用ConcurrentHashMap
在幕后实现。
[1] 通常,日志框架最终会在程序中注入实际的显式锁定,因此在这种情况下您通常会得到保证,但只有 'by accident' 因为日志框架。这并不能保证(例如,您可能正在记录到单独的日志文件!)而且您 'witness' 通常可能是谎言。例如,也许您有 2 个日志语句,它们都记录到单独的文件(并且根本不相互锁定),并且它们将时间戳记录为日志的一部分。一个日志行显示“12:00:05”而另一个日志行显示“12:00:06”这一事实 毫无意义 - 日志线程获取当前时间,创建一个字符串描述消息,并告诉 OS 将其写入文件。显然,您绝对不能保证 2 个日志线程 运行 的速度相同。也许一个线程获取时间 (12:00:05),创建字符串,想要写入磁盘但是 OS 在写入完成之前切换到另一个线程,另一个线程是另一个记录器,它读取时间 (12:00:06),生成字符串,将其写出,完成,然后第一个记录器继续,写入其上下文。 Tada:2 个线程,其中 'observe' 一个线程是 'earlier',但这是不正确的。也许这个例子会进一步强调为什么根据 'first' 来考虑线程会误导你。
[2] 此代码具有额外的复杂性,即您正在与 不能 是事务性的系统进行交互。事务的要点是您可以中止它;您不能中止用户从 ATM 取款。你通过记录你即将吐出钱来解决这个问题,然后吐出钱,然后记录你已经吐出钱。并且最后在用户的账户余额中写入这个日志已经处理过了。其他代码需要检查此日志并采取相应措施。例如,在启动时,银行的数据库机器需要标记 'dangling' ATM 交易,并且必须让人检查视频源。解决了刚要取钞时被银行DB机电源线绊倒的问题