Java 多线程应用程序中局部变量的垃圾回收

Java garbage collection in multithreaded application for local variable

我有以下用例:

为了实现上述用例,我有以下代码。 runTask() 是一种负责每 1 秒获取新 Set 的方法。 doesAccountExist 方法被其他并行线程调用以检查 accountId 是否存在于 Set 中。

class AccountIDFetcher {
 private Set<String> accountIds;
 private ScheduledExecutorService scheduledExecutorService;

 public AccountIDFetcher() {
   this.accountIds = new HashSet<String>();
   scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
   scheduledExecutorService.scheduleWithFixedDelay(this::runTask, 0, 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;
 }

}

我有以下问题

  1. 在 runTask 方法中,我只是将 accountIds 变量的引用更改为一个新对象。因此,如果 Thread-2 正在 doesAccountExist() 方法中搜索 accountId,同时如果 Thread-1 执行 runTask() 并将 accountIds 变量的引用更改为新对象,则旧对象将被孤立。是否可以在 Thread-2 完成搜索之前对旧对象进行垃圾回收?

垃圾收集不是这段代码的主要问题。缺少任何同步是主要问题。

如果 thread-2 正在“搜索”某物,它必然有对该物的引用,因此它不会被 GC。

为什么不使用 'synchronized' 这样您就可以确定会发生什么?

tl;博士

您问的是:

is it possible that old object can be garbage collected before Thread-2 finish searching in it?

不,旧的 Set 对象 不会 在某些线程仍在使用它时变成垃圾。

对象仅成为garbage-collection after each and every reference to said object (a) goes out of scope, (b) is set to null, or (c) is a weak reference的候选者。

不,方法中使用的对象不会被垃圾回收

在 Java 中,对象引用分配是原子的,如 中所述。当 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);
 }

当一个线程正在替换成员字段 accountIds 上对新 Set 的引用时,doesAccountExist 方法已经具有对旧 [=17] 的引用的副本=].在那一刻,当一个线程正在更改成员字段引用,而另一个线程有一个本地引用时,垃圾收集器将新旧 Set 对象视为每个(至少)有一个引用。所以也不是垃圾收集的候选人。

实际上,技术上更正确的解释是在您的行 return accountIds.contains(accountId); 中执行到达 accountIds 部分的位置,将访问当前(旧)Set。片刻之后,contains 方法开始工作,在此期间,将新的 Set 重新分配给该成员字段对该方法正在进行的工作没有影响,该方法已经使用旧的 Set .

这意味着即使在一个线程中分配了新的 Set 之后,另一个线程可能仍在继续其搜索旧的 Set 的工作。这可能是问题,也可能不是问题,具体取决于您应用的业务环境。但是你的问题没有解决这个陈旧的数据事务方面。

所以关于你的问题:

is it possible that old object can be garbage collected before Thread-2 finish searching in it?

不,旧的 Set 对象 不会 在某些线程仍在使用它时变成垃圾。

其他问题

您的代码确实存在其他问题。

能见度

您将会员字段声明为 private Set<String> accountIds;。如果您在具有多个内核的主机上跨线程访问该成员字段,那么您就会遇到可见性问题。当您将不同的对象分配给该成员字段时,每个核心上的缓存可能不会立即刷新。正如目前所写,访问 this.accountIds 的一个线程完全有可能获得对旧 Set 对象的访问权限,即使在该变量被分配给新的 Set 对象之后也是如此。

如果您还不了解我提到的问题,请研究并发性。这里涉及的内容超出了我所能涵盖的范围。了解 Brian Goetz 等人的 Java Memory Model. And I strongly recommend reading and re-reading the classic book, Java Concurrency in Practice

volatile

一种解决方案是将成员字段标记为volatile。所以,这个:

private Set<String> accountIds;

…变成这样:

volatile private Set<String> accountIds;

标记为 volatile 可避免 CPU 核心上的陈旧缓存指向旧对象引用而不是新对象引用。

AtomicReference

另一种解决方案是使用AtomicReference class 的对象作为成员字段。我会将其标记为 final,以便将一个且只有一个这样的对象分配给该成员字段,因此该字段是常量而不是变量。然后将每个新的 Set 对象分配为该 AtomicReference 对象中包含的有效负载。需要当前 Set 对象的代码在该 AtomicReference 对象上调用 getter 方法。此调用保证是线程安全的,无需 volatile.

并发访问现有 Set

您的代码的另一个可能问题可能是对现有 Set 的并发访问。如果您有多个线程访问现有 Set,那么您必须保护该资源。

保护对 Set 的访问的一种方法是使用 Set 的线程安全实现,例如 ConcurrentSkipListSet.

根据您在问题中显示的内容,我注意到对现有 Set 的唯一访问是调用 contains。如果您从不修改现有 Set,那么仅仅调用包含多个线程 可能 是安全的 — 我只是不知道,您必须研究它。

如果您打算永远不修改现有的 Set,那么您可以使用 unmodifiable set. One way to produce an unmodifiable set is to construct and populate a regular set. Then feed that regular set to the method 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 );
 }

Return 复制而不是参考

有两种简单的方法可以避免处理并发问题:

  • 制作对象immutable
  • 提供对象的副本

至于第一种方式,不变性,Java Collections Framework is generally very good but unfortunately lacks explicit mutability & immutability in its type system. The Set.of methods and Collections.unmodifiableSet both provide a Set that cannot be modified. But the type itself does not proclaim that fact. So we cannot ask the compiler to enforce a rule such as our AtomicReference only storing an immutable set. As an alternative, consider using a third-party collections with immutability as part of its type. Perhaps Eclipse Collections or Google Guava.

至于第二种方式,我们可以在需要访问时复制我们的 Set 帐户 ID。所以我们需要一个进入 AtomicReferencegetCurrentAccountIds 方法,检索存储在那里的 Set,并调用 Set.copyOf 来生成一组新的相同包含对象。此复制操作未记录为线程安全的。所以我们应该将方法标记为 synchronized 一次只允许一个复制操作。奖励:我们可以标记此方法 public 以允许任何调用的程序员访问 Set 帐户 ID 以供他们自己阅读。

    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 包装你的计划任务。任何 ExceptionError 冒泡到达 ScheduledExecutorService 都会静默停止任何进一步的调度。参见 ScheduledExecutorService Exception handling

示例代码。

这是我对您的代码的完整示例。

警告:使用风险自负。我不是并发专家。这意味着思考,而不是生产用途。

我使用UUID作为账户ID的数据类型是为了更真实和清晰。

为了清楚起见,我更改了您的一些 class 和方法名称。

注意哪些方法是私有的,哪些是 public.

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 , 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() );
    }
}

明智的垃圾收集,你不会感到惊讶,但不是因为接受的答案暗示。它在某种程度上更棘手。

想象这可能会有所帮助。

  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()),一个 reading确实执行读取的线程 (accountIds.contains(instanceId);) 将看到写入。我说“确实”是因为优化器一开始甚至可能不会发出这样的读取。用非常简单(并且不知何故是错误的)的话来说,每个线程都可能获得自己的 accountIds 副本,因为这是一个没有任何特殊语义的“普通”读取(如 volatilerelease/acquiresynchronization, 等等), 读线程没有义务看到写线程的动作。

所以,即使有人真的做到了accountIds = getAccountIds(),也不意味着阅读线程会看到这个。而且情况变得更糟。这篇文章可能 永远不会 被看到。如果你想要保证(你绝对这样做),你需要引入特殊的语义。

为此,您需要 Set volatile:

private volatile Set<String> accountIds = ...

以便在涉及多线程时,您将获得所需的可见性保证。

然后为了不干扰 accountIds 的任何动态更新,您可以简单地处理它的本地副本:

 public boolean doesAccountExist(String accountId) {
      Set<String> local = accountIds;
      return local.contains(accountId);
 }  

即使在您使用此方法时 accountIds 发生变化,您也不会关心该变化,因为您正在搜索 local,而后者并不知道该变化。