Java 中的惰性实例化与急切实例化:哪个更好?

在实例化资源使用方面昂贵的 Java 对象时,我们不希望每次使用它们时都必须实例化它们。 拥有一个我们可以在整个系统中共享的现成可用的对象实例对性能来说要好得多。 在这种情况下,惰性实例化策略非常有效。

然而,惰性实例化有其缺点,在某些系统中,更积极的方法更好。 在急切实例化中,我们通常在应用程序启动后立即实例化对象一次。 两种方法都不是全好或全坏:它们是不同的。 每一种都在特定类型的场景中表现最佳。

本文将向您介绍这两种实例化 Java 对象的方法。 您将看到代码示例,然后通过 Java 代码挑战测试您学到的知识。 我们还将讨论惰性实例化与急切实例化的优缺点。

什么是贵重物品?

昂贵对象的一个​​真实且非常常见的示例是提供数据库连接的对象。 在您的 Java 程序中还有哪些昂贵对象的其他示例?

惰性实例化的天真方法

让我们先来看看创建单个实例并在系统中共享它的天真的方法:


  public static HeroesDB heroesDB;           // #A
  private SingletonNaiveApproach() {}        // #B

  public HeroesDB getHeroesDB() {            // #C
    if (heroesDB == null) {                  // #D   
      heroesDB = new HeroesDB();             // #E    
    }
    
    return heroesDB;                         // #F
  }

  static class HeroesDB { }         
}

这是代码中发生的事情:

    开始(#A),我们声明一个静态内部类, HeroesDB. 我们将变量声明为 static,并且可以在应用程序中共享。 接下来(#B),我们创建一个私有构造函数以避免从此类外部直接实例化。 因此,我们有义务使用 getHeroes() 获取实例的方法。 在下一行 (#C) 中,我们看到了将有效地返回实例的方法 HeroesDB. 接下来(#D),我们检查是否 heroesDB 实例为空。 如果是这样,我们将创建一个新实例。 否则,我们什么都不做。 最后 (#F),我们返回 heroesDB 对象实例。

这种方法适用于小型应用程序。 但是,在具有许多用户的大型多线程应用程序中,很可能会发生数据冲突。 在那种情况下,对象可能会被实例化不止一次,即使我们有 null 检查。 让我们进一步探讨为什么会发生这种情况。

了解竞争条件

竞争条件是两个或多个线程同时竞争同一个变量的情况,这可能会导致意外结果。

在大型多线程应用程序中,许多进程并行并发运行。 在这种类型的应用程序中,一个线程可能会在另一个线程实例化该空对象的同时询问一个对象是否为空。 在那种情况下,我们有一个竞争条件,这可能会导致重复的实例。

我们可以通过使用 synchronized 关键词:


public class SingletonSynchronizedApproach {

  public static HeroesDB heroesDB;
  private SingletonSynchronizedApproach() {}

  public synchronized HeroesDB getHeroesDB() {
    if (heroesDB == null) {
      heroesDB = new HeroesDB();
    }

    return heroesDB;
  }

  static class HeroesDB { }

}

此代码解决了线程在 getHeroesDB(). 但是,我们正在同步整个方法。 这可能会影响性能,因为一次只有一个线程能够访问整个方法。

让我们看看如何解决这个问题。

优化的多线程惰性实例化

从同步战略点 getHeroesDB() 方法,我们需要创建 synchronized 方法中的块。 这是一个例子:


public class ThreadSafeSynchronized {

  public static volatile HeroesDB heroesDB;

  public static HeroesDB getHeroesDB() {
    if(heroesDB == null) {
      synchronized (ThreadSafeSynchronized.class) {
        if(heroesDB == null) {
          heroesDB = new HeroesDB();
        }
      }
    }
    return heroesDB;
  }

  static class HeroesDB { }
}

在此代码中,我们仅在实例为空时才同步对象创建。 否则,我们将返回对象实例。

另请注意,我们同步了 ThreadSafeSynchronized 类,因为我们使用的是静态方法。 然后,我们仔细检查以确保 heroesDB instance 仍然是 null,因为另一个线程可能已经实例化了它。 如果不仔细检查,我们最终可能会遇到不止一个实例。

另一个重要的一点是变量 heroesDB 是不稳定的。 这意味着变量的值不会被缓存。 当线程更改它时,该变量将始终具有最新的更新值。

何时使用急切实例化

最好对您可能永远不会使用的昂贵对象使用惰性实例化。 但是,如果我们正在使用一个我们知道每次启动应用程序时都会使用的对象,并且如果对象的创建在系统资源方面代价高昂,那么最好使用预实例化。

假设我们必须创建一个非常昂贵的对象,例如我们知道我们将始终需要的数据库连接。 等到使用此对象可能会降低应用程序的速度。 在这种情况下,Eager 实例化更有意义。

一种渴望实例化的简单方法

一个简单的实现eager instantiation的方法如下:


public class HeroesDatabaseSimpleEager {

  public static final HeroesDB heroesDB = new HeroesDB();

  static HeroesDB getHeroesDB() {
    return heroesDB;
  }

  static class HeroesDB {
    private HeroesDB() {
      System.out.println("Instantiating heroesDB eagerly...");
    }

    @Override
    public String toString() {
      return "HeroesDB instance";
    }
  }

  public static void main(String[] args) {
    System.out.println(HeroesDatabaseSimpleEager.getHeroesDB());
  }
}

此代码的输出将是:


Instantiating heroesDB eagerly...
HeroesDB instance

请注意,在这种情况下,我们没有空检查。 HeroesDB 在它被声明为实例变量的那一刻被实例化 HeroesDatabaseSimpleEager. 因此,我们每次访问 HeroesDatabaseSimpleEager 类,我们将从 HeroesDB. 我们也推翻了 toString() 制作输出的方法 HeroesDB 实例更简单。

现在让我们看看使用枚举的更强大的急切实例化方法。

枚举的热切实例化

使用枚举是创建急切实例化对象的一种更可靠的方法。 虽然实例只会在访问枚举时创建,但请注意下面的代码,我们没有对对象创建进行空检查:


public enum HeroesDatabaseEnum {

  INSTANCE;

  int value;

  public int getValue() {
    return value;
  }

  public void setValue(int value) {
    this.value = value;
  }
  
  public static void main(String[] args) {
    System.out.println(HeroesDatabaseEnum.INSTANCE);
  }

}

此代码的输出将是:


Creating instance...
INSTANCE

此代码是线程安全的。 它保证我们只创建一个实例并序列化对象,这意味着我们可以更轻松地传输它。 另一个细节是,对于枚举,我们有一个隐式私有构造函数,它保证我们不会不必要地创建多个实例。 由于其简单性和有效性,枚举被认为是使用急切实例化的最佳方法之一。

惰性实例化与急切实例化

当我们知道我们不会总是需要实例化一个对象时,惰性实例化是好的。 当我们知道我们总是需要实例化对象时,Eager 实例化会更好。 考虑每种方法的优缺点:

延迟实例化

优点:

    该对象只会在需要时实例化。

缺点:

    它需要同步才能在多线程环境中工作。 性能较慢,因为 if 检查和同步。 当需要对象时,应用程序可能会有明显的延迟。

渴望实例化

优点:

    在大多数情况下,对象将在应用程序启动时被实例化。 使用该对象时没有延迟,因为它已经被实例化了。 它在多线程环境中运行良好。

缺点:

    您可能会用这种方法不必要地实例化一个对象。

Lazy Homer 啤酒创作挑战

在下面的 Java 代码挑战中,您将看到在多线程环境中发生的惰性实例化。

请注意,我们正在使用 ThreadPool. 我们可以使用 Thread 类,但最好使用 Java 并发 API。

根据您在本文中学到的知识,您认为当我们运行以下代码时最有可能发生什么?


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class LazyHomerBeerCreationChallenge {

  public static int i = 0;
  public static Beer beer;
  
  static void createBeer() {
    if (beer == null) {
      try {
        Thread.sleep(200);
        beer = new Beer();
        i++;
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    executor.submit(LazyHomerChallenge::createBeer);
    executor.submit(LazyHomerChallenge::createBeer);
    
    executor.awaitTermination(2, TimeUnit.SECONDS);
    executor.shutdown();
    System.out.println(i);
  }

  public static class Beer {}
}

以下是应对此挑战的选项。 请仔细看代码,选择其中之一:

    A) 1 B) 0 C) 2 D) 一个 InterruptedException 被抛出

刚刚发生了什么? 惰性实例化解释

这个代码挑战的关键概念是当两个线程访问同一个进程时会有并行性。 因此,由于我们有一个 Thread.sleep 在实例化之前 beer有可能是两个实例 beer 将被创建。

线程不会同时运行的可能性非常小,具体取决于 JVM 实现。 但是由于 Thread.sleep 方法。

现在,再次查看代码,注意我们正在使用一个线程池来创建两个线程,然后我们运行 createBeer 这些线程的方法。

因此,本题的正确答案是:C,即2的值。

结论

惰性和急切实例化是优化昂贵对象性能的重要概念。 以下是关于这些设计策略的一些要点:

    延迟实例化需要在实例化之前进行空检查。 在多线程环境中同步延迟实例化的对象。 Eager 实例化不需要对对象进行空检查。 使用枚举是一种有效且简单的急切实例化方法。

阅读更多

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注