问题描述
我有以下用例:
为了实现上述用例,我有以下代码。 runTask()
是一种负责每 1 秒获取一次新 Set 的方法。 doesAccountExist
方法由其他并行线程调用以检查该 Set 中是否存在 accountId。
class AccountIDFetcher {
private Set<String> accountIds;
private scheduledexecutorservice scheduledexecutorservice;
public AccountIDFetcher() {
this.accountIds = new HashSet<String>();
scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
scheduledexecutorservice.scheduleWithFixedDelay(this::runTask,1,TimeUnit.SECONDS);
}
// Following method runs every 1 second
private void runTask() {
accountIds = getAccountIds()
}
// other parallel thread calls below method
public boolean doesAccountExist(String accountId) {
return accountIds.contains(instanceId);
}
private Set<String> getAccountIds() {
Set<String> accounts = new HashSet<String>();
// calls Database and put list of accountIds into above set
return accounts;
}
}
我有以下问题
- 在 runTask 方法中,我只是将 accountIds 变量的引用更改为一个新对象。因此,如果 Thread-2 正在 doAccountExist() 方法中搜索 accountId,同时如果 Thread-1 执行 runTask() 并将 accountIds 变量的引用更改为新对象,则旧对象将成为孤立对象。在 Thread-2 完成搜索之前,旧对象是否有可能被垃圾回收?
解决方法
tl;博士
你问:
旧对象是否有可能在 Thread-2 完成搜索之前被垃圾回收?
不,旧的 Set
对象在某些线程仍在使用时不会变成垃圾。
一个对象只有在每个对所述对象的引用(a)超出范围,(b)设置为空,或(c)后才成为garbage-collection的候选对象是 weak reference。
不,方法中使用的对象不会被垃圾收集
在 Java 中,对象引用分配是原子的,如 another Question 中所述。当 this.accountIds
指向一个新的 Set
对象时,这发生在一个逻辑操作中。这意味着访问 accountIds
成员字段的任何其他线程中的任何其他代码将始终成功访问旧的 Set
对象或新的 Set
对象,总是一个或另一个。>
如果在重新分配期间另一个线程访问了旧的 Set
对象,则该其他线程的代码正在使用对象引用的副本。你可以想想你的 doesAccountExist
方法:
public boolean doesAccountExist(String accountId) {
return accountIds.contains(accountId);
}
...因为有一个带有对象引用副本的局部变量,就像这样写:
public boolean doesAccountExist(String accountId) {
Set<String> set = this.accountIds ;
return set.contains(accountId);
}
当一个线程正在替换成员字段 Set
上对新 accountIds
的引用时,doesAccountExist
方法已经拥有对旧 Set
的引用的副本.在那一刻,当一个线程正在更改成员字段引用,而另一个线程具有本地引用时,垃圾收集器将新旧 Set
对象视为(至少)一个引用。所以两者都不是垃圾收集的候选对象。
实际上,在技术上更正确的解释是,在您的行 return accountIds.contains(accountId);
中执行到达 accountIds
部分的点,将访问当前(旧)Set
。片刻之后,contains
方法开始工作,在此期间将新的 Set
重新分配给该成员字段不会影响该方法已经使用旧的 Set
的正在进行的工作.
这意味着即使在一个线程中分配了新的 Set
之后,另一个线程可能仍在继续搜索旧的 Set
。这可能是也可能不是问题,具体取决于您的应用程序的业务环境。但是您的问题没有解决这个陈旧数据交易方面的问题。
关于你的问题:
旧对象是否有可能在 Thread-2 完成搜索之前被垃圾回收?
不,旧的 Set
对象在某些线程仍在使用时不会变成垃圾。
其他问题
您的代码确实存在其他问题。
可见性
您将成员字段声明为 private Set<String> accountIds;
。如果您在具有多个内核的主机上跨线程访问该成员字段,那么您就会遇到可见性问题。当您将不同的对象分配给该成员字段时,每个核心上的缓存可能不会立即刷新。正如目前所写,即使在为该变量分配了新的 this.accountIds
对象之后,一个访问 Set
的线程也完全有可能获得对旧 Set
对象的访问权限。
如果您还不了解我提到的问题,请研究并发。涉及的内容比我在这里涵盖的要多。了解 Java Memory Model。我强烈推荐阅读并重读经典书籍,Java Concurrency in Practice Brian Goetz 等人。
volatile
一种解决方案是将成员字段标记为 volatile
。所以,这:
private Set<String> accountIds;
……变成这样:
volatile private Set<String> accountIds;
标记为 volatile
可避免 CPU 内核上的陈旧缓存指向旧对象引用而不是新对象引用。
AtomicReference
另一种解决方案是使用 AtomicReference
类的对象作为成员字段。我会将它标记为 final
以便一个且只有一个这样的对象被分配给该成员字段,因此该字段是一个常量而不是一个变量。然后将每个新的 Set
对象分配为包含在该 AtomicReference
对象中的负载。需要当前 Set
对象的代码调用该 AtomicReference
对象上的 getter 方法。此调用保证是线程安全的,无需 volatile
。
同时访问现有的 Set
您的代码的另一个可能问题可能是对现有 Set
的并发访问。如果您有多个线程访问现有的 Set
,那么您必须保护该资源。
保护对该 Set
的访问的一种方法是使用 Set
的线程安全实现,例如 ConcurrentSkipListSet
。
根据您在问题中显示的内容,我注意到对现有 Set
的唯一访问是调用 contains
。如果您从不修改现有的 Set
,那么仅跨多个线程调用 contains 可能是安全的 - 我只是不知道,您必须研究它。
如果您不打算修改现有的 Set
,那么您可以使用 unmodifiable set 来强制执行。产生不可修改集的一种方法是构造和填充常规集。然后将该常规集提供给方法 Set.copyOf
。所以你的 getAccountIds
方法看起来像这样:
private Set<String> getAccountIds() {
Set<String> accounts = new HashSet<String>();
// calls Database and put list of accountIds into above set
return Set.copyOf( accounts );
}
返回副本而不是参考
有两种简单的方法可以避免处理并发:
- 使对象immutable
- 提供对象的副本
至于第一种方式,不变性,Java Collections Framework 通常非常好,但不幸的是在其类型系统中缺乏明确的可变性和不变性。 Set.of
方法和 Collections.unmodifiableSet
都提供了一个无法修改的 Set
。但是类型本身并没有声明这个事实。所以我们不能要求编译器强制执行一个规则,比如我们的 AtomicReference
只存储一个不可变的集合。作为替代方案,请考虑使用具有不变性的第三方集合作为其类型的一部分。可能是 Eclipse Collections 或 Google Guava。
对于第二种方式,我们可以在需要访问时复制我们的 Set
帐户 ID。因此,我们需要一个 getCurrentAccountIds
方法进入 AtomicReference
,检索存储在那里的 Set
,并调用 Set.copyOf
以生成一组新的相同包含对象。此复制操作未记录为线程安全的。所以我们应该将方法 synchronized
标记为一次只允许一次复制操作。奖励:我们可以将此方法标记为 public
以允许任何调用程序员访问帐户 ID 的 Set
以供他们自己阅读。
synchronized public Set < UUID > getCurrentAccountIds ( )
{
return Set.copyOf( this.accountIdsRef.get() ); // Safest approach is to return a copy rather than original set.
}
我们的便捷方法 doesAccountExist
应该在执行“包含”逻辑之前调用相同的 getCurrentAccountIds
以获取集合的副本。这样我们就不用关心“包含”的工作是否是线程安全的。
警告:我不满意使用 Set.copyOf
来避免任何可能的线程安全问题。该方法指出,如果被复制的传递集合已经是不可修改的集合,则可能不会进行复制。在实际工作中,我会使用 Set
实现来保证线程安全,无论是与 Java 捆绑在一起还是通过添加第三方库。
不要在构造函数中使用对象
我不喜欢在您的构造函数中看到预定的执行程序服务。我看到了两个问题:(a) 应用生命周期和 (b) 在构造函数中使用对象。
创建执行器服务、在该服务上调度任务以及关闭该服务都与应用程序的生命周期有关。一个对象通常不应该知道它在更大的应用程序中的生命周期。这个帐户 ID 提供者对象应该只知道如何完成它的工作(提供 ID),但不应该负责让自己工作。所以你的代码是 mixing responsibilities,这通常是一种糟糕的做法。
另一个问题是执行器服务被安排为立即开始使用我们仍在构建的对象。通常,最佳实践是不要使用仍在构建中的对象。您可能会逃脱这种使用,但这样做是有风险的,并且容易导致错误。构造函数应该简短而巧妙,仅用于验证输入、建立所需资源并确保所生成对象的完整性。
我没有将服务从你的构造函数中拉出来,只是因为我不想让这个答案走得太远。但是,我确实做了两个调整。 (a) 我将 scheduleWithFixedDelay
调用的初始延迟从零更改为一秒。这是一个技巧,可以让构造函数在第一次使用之前有时间完成对象的生成。 (b) 我添加了 tearDown
方法来正确关闭执行程序服务,因此它的支持线程池不会以僵尸方式无限期地继续运行。
提示
我建议重命名您的 getAccountIds()
方法。 Java 中的 get
措辞通常与访问现有属性的 JavaBeans 约定相关联。在您的情况下,您正在生成一组全新的替换值。所以我会将该名称更改为类似 fetchFreshAccountIds
的名称。
考虑用 try-catch 包装您的计划任务。任何 Exception
或 Error
冒泡到达 ScheduledExecutorService
都会导致任何进一步调度的静默停止。见ScheduledExecutorService Exception handling。
示例代码。
这是我对您的代码的看法的完整示例。
警告:使用风险自负。我不是并发专家。这意味着深思熟虑,而不是生产用途。
为了更加真实和清晰,我使用 UUID
作为帐户 ID 的数据类型。
为了清楚起见,我更改了您的一些类和方法名称。
注意哪些方法是私有的,哪些是公共的。
package work.basil.example;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class AccountIdsProvider
{
// Member fields
private AtomicReference < Set < UUID > > accountIdsRef;
private ScheduledExecutorService scheduledExecutorService;
// Constructor
public AccountIdsProvider ( )
{
this.accountIdsRef = new AtomicReference <>();
this.accountIdsRef.set( Set.of() ); // Initialize to empty set.
this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.scheduleWithFixedDelay( this :: replaceAccountIds,1,TimeUnit.SECONDS ); // I strongly suggest you move the executor service and the scheduling work to be outside this class,to be a different class’ responsibility.
}
// Performs database query to find currently relevant account IDs.
private void replaceAccountIds ( )
{
// Beware: Any uncaught Exception or Error bubbling up to the scheduled executor services halts the scheduler immediately and silently.
try
{
System.out.println( "Running replaceAccountIds. " + Instant.now() );
Set < UUID > freshAccountIds = this.fetchFreshAccountIds();
this.accountIdsRef.set( freshAccountIds );
System.out.println( "freshAccountIds = " + freshAccountIds + " at " + Instant.now() );
}
catch ( Throwable t )
{
t.printStackTrace();
}
}
// Task to be run by scheduled executor service.
private Set < UUID > fetchFreshAccountIds ( )
{
int limit = ThreadLocalRandom.current().nextInt( 0,4 );
HashSet < UUID > uuids = new HashSet <>();
for ( int i = 1 ; i <= limit ; i++ )
{
uuids.add( UUID.randomUUID() );
}
return Set.copyOf( uuids ); // Return unmodifiable set.
}
// Calling programmers can get a copy of the set of account IDs for their own perusal.
// Pass a copy rather than a reference for thread-safety.
// Synchronized in case copying the set is not thread-safe.
synchronized public Set < UUID > getCurrentAccountIds ( )
{
return Set.copyOf( this.accountIdsRef.get() ); // Safest approach is to return a copy rather than original set.
}
// Convenience method for calling programmers.
public boolean doesAccountExist ( UUID accountId )
{
return this.getCurrentAccountIds().contains( accountId );
}
// Destructor
public void tearDown ( )
{
// IMPORTANT: Always shut down your executor service. Otherwise the backing pool of threads may run indefinitely,like a zombie ?.
if ( Objects.nonNull( this.scheduledExecutorService ) )
{
System.out.println( "INFO - Shutting down the scheduled executor service. " + Instant.now() );
this.scheduledExecutorService.shutdown(); // I strongly suggest you move the executor service and the scheduling work to be outside this class,to be a different class’ responsibility.
}
}
public static void main ( String[] args )
{
System.out.println( "INFO - Starting app. " + Instant.now() );
AccountIdsProvider app = new AccountIdsProvider();
try { Thread.sleep( Duration.ofSeconds( 10 ).toMillis() ); } catch ( InterruptedException e ) { e.printStackTrace(); }
app.tearDown();
System.out.println( "INFO - Ending app. " + Instant.now() );
}
}
,
垃圾收集不是此代码的主要问题。缺乏任何同步是主要问题。
如果线程 2 正在“搜索”某个东西,它必然会引用那个东西,所以它不会被垃圾回收。
为什么不使用“同步”来确定会发生什么?
,垃圾收集明智的,你不会得到任何惊喜,但不是因为接受的答案暗示。这在某种程度上更棘手。
想象一下这会有所帮助。
accountIds ----> some_instance_1
假设 ThreadA
现在正在使用 some_instance_1
。它开始在其中搜索 accountId
。在该操作进行时,ThreadB
会更改该引用所指向的内容。所以它变成:
some_instance_1
accountIds ----> some_instance_2
因为引用分配是原子的,这也是 ThreadA
会看到的,如果它再次读取该引用。此时,some_instance_1
有资格进行垃圾回收,因为没有人引用它。请注意,只有ThreadA
看到ThreadB
所做的这篇文章才会发生这种情况。无论哪种方式:您都是安全的(仅限 gc
明智的),因为 ThreadA
要么使用陈旧的副本(您说没问题),要么使用最新的副本。
这并不意味着您的代码一切正常。
这个答案确实正确的是引用分配是atomic
,所以一旦一个线程写入一个引用(accountIds = getAccountIds()
),一个读取线程({{1} }}) 确实会执行读取将看到写入。我说“确实”是因为优化器甚至可能不会发出这样的读取,一开始。用非常简单的(也有些错误)的话来说,每个线程可能会得到自己的 accountIds.contains(instanceId);
副本,因为这是一个没有任何特殊语义的“普通”读取(如 accountIds
、volatile
、 release/acquire
等),读线程没有义务看到写线程的动作。
所以,即使有人真的做了synchronization
,也不意味着阅读线程会看到这一点。它变得更糟。这篇文章可能不会永远被看到。如果你想要保证(你绝对可以),你需要引入特殊的语义。
为此,您需要使 accountIds = getAccountIds()
Set
:
volatile
这样当涉及多线程时,您将获得所需的可见性保证。
然后为了不干扰 private volatile Set<String> accountIds = ...
的任何动态更新,您可以简单地处理它的本地副本:
accountIds
即使在此方法中 public boolean doesAccountExist(String accountId) {
Set<String> local = accountIds;
return local.contains(accountId);
}
发生变化,您也不关心该变化,因为您正在搜索 accountIds
,而后者并不知道该变化。