我们熟知的建造者(builder)模式、装饰器(decorator)模式、原型(prototype)模式以及许多其他设计模式诞生的时间都不短。这些设计模式为解决典型的软件设计问题提供了久经考验的生产级解决方案。我们强烈建议读者熟练掌握这些设计模式并在代码中使用它们。使用设计模式,代码的可维护性、可扩展性都会更好,也更优雅[关于设计模式的更多内容可以参考Erich Gamma等人合著的经典图书 D esign Patterns : Elements of Reusable Object - Oriented Software (《设计模式:可复用面向对象软件的基础》)]。另外,使用设计模式时,你应该谨慎,因为设计模式的实现对上下文有非常强的依赖。正如本书开篇所述,我们希望帮助你了解软件设计中的取舍及其影响。
我们会以单例模式为例进行介绍,帮助读者从代码层面理解这些取舍。引入单例模式的目的是提供一种所有组件共享通用状态的方式。单例是贯穿你的应用整个生命周期的单一实例,被其他的类所引用。创建单例非常简单,你可以通过创建一个私有构造器来避免创建新的实例,代码实现如代码清单1.3所示。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个例子里获取单例的唯一方法是通过getInstance()方法,该方法返回由所有组件共享的唯一实例。这里有一个假设,即每次调用方代码访问单例时,都通过调用getInstance()方法。后续我们会讨论其他的用例,不一定每次都必须通过该方法。使用单例模式看起来是条捷径,通过全局的单例,你可以安心地共享代码。看到这里,你可能会疑惑:“这哪有什么取舍?”
我们换个上下文,看看使用这种模式是否也恰当。如果我们在一个多线程的环境中使用单例模式会出现什么情况呢?如果多个线程同时调用getInstance()方法,就可能产生竞争。这时,你的代码猝不及防地创建出了单例的两个实例。单例模式存在两个实例会破坏该模式的不变性,最终可能导致系统性的故障。为了避免出现这种情况,你需要在初始化逻辑之前,添加同步机制,如代码清单1.4所示。
public class SystemComponentSingletonSynchronized {
private static SystemComponent instance;
private SystemComponentSingletonSynchronized() {}
public static synchronized SystemComponent getInstance() { ◁--- 同步代码块开始
if (instance == null) {
instance = new SystemComponent();
}
return instance;
}
}
同步代码块避免了两个线程同时访问该逻辑。初始化完成之前,仅有一个线程能进入该逻辑,所有其他的线程都会被阻塞。乍一看,这不就是我们所期望的吗?然而,如果你的代码有比较高的性能要求,采用单例模式的同时使用了多线程,程序的性能可能会受到比较严重的影响。
初始化是多线程因锁竞争而等待的第一个地方。一旦完成单例的创建,接下来每次对该对象的访问还需要进行同步。单例会引起线程争用,进而严重影响程序性能。多线程并发访问同一个共享的对象实例时经常出现这种问题。
同步的getInstance()方法一次只允许一个线程进入临界(同步)区,所有其他的线程都需要等待锁的释放。前一个线程退出临界区后,队列中的第二个线程才能进入。这种方式的问题在于它引入的同步会严重拖慢程序的执行。简而言之,每次执行同步调用,都可能引入额外的开销。
通过这个例子,我们可以得出以下结论:采用单例模式时,单线程与多线程存在性能差异,你需要在二者间做权衡。判断最基本的出发点是应用程序的运行环境。如果你的程序不需要并发运行,或者单例不会在多个线程间共享,那就完全不需要考虑这个问题。一旦你创建的单例需要在多个线程间共享,就需要确保它是线程安全的,从而避免潜在的性能问题。熟稔这些取舍,可以帮助你在做设计、代码实现、方案选择时理性从容。
如果你发现某个设计最初的选择弊大于利,最终可能要变更方案。以上文的单例而言,我们可有两种方式变更方案。
第一种方式是采用双检锁(double-checked locking)技术。采用这种方式,每次进入临界区之前,都要检查实例是否为空。如果实例为空,我们可以继续进入临界区,否则就不进入,直接返回已经存在的单例对象。代码清单1.5展示了双检锁的使用。
private volatile static SystemComponent instance;
public static SystemComponent getInstance() {
if (instance == null) { ◁--- 如果实例不为空,则不需要进入临界区
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new SystemComponent();
}
}
}
return instance;
}
采用这种方式,我们可以显著缓解同步以及线程竞争资源的情况。我们只会在应用启动的时刻观察到发生了同步,该时刻每个线程都试图初始化单例。
我们可以采用的第二种方式是线程限定(thread confinement)。线程限定可以将状态访问限定在特定的线程内。不过,你需要注意,这种方式从应用全局的角度而言就不再是单例模式了。每个线程都会持有一个单例对象的实例。如果你有 N 个线程,就会有 N 个实例。
在这种方式下,每个线程独享一个对象实例,且这个对象仅对相应线程可见。基于这样的设计,多线程之间就不再存在访问对象引起的竞争。每个对象由单一线程独享,而非多线程共享。Java语言提供了ThreadLocal类来实现这一效果。凭借ThreadLocal类,我们可以封装系统组件,并将其绑定到特定的线程。从代码实现的角度而言,对象存在于ThreadLocal实例之内,如代码清单1.6所示。
private static ThreadLocal<SystemComponent> threadLocalValue = new ThreadLocal<>();
public static void set() {
threadLocalValue.set(new SystemComponent());
}
public static void executeAction() {
SystemComponent systemComponent = threadLocalValue.get();
}
public static SystemComponent get() {
return threadLocalValue.get();
}
将SystemComponent与某个线程绑定的逻辑封装在ThreadLocal实例中。例如,线程A调用set()方法时,ThreadLocal中便创建了一个新的SystemComponent实例。我们需要注意,此时该实例只能被线程A访问,这一点非常重要。如果另外一个线程,譬如B线程,之前没有调用过set()方法,试图执行executeAction(),它得到的就是一个空的SystemComponent实例,因为没有任何人为该线程执行组件的set()方法。只有B线程调用set()方法后,该线程独享的新实例才会被创建。
通过为withInitial()方法传递一个提供方(supplier),我们可以简化这段逻辑。如果线程本地对象没有值,该方法就会被调用,这样我们就避免了遇到空对象的风险。代码清单1.7展示了这一实现。
static ThreadLocal<SystemComponent> threadLocalValue = ThreadLocal.withInitial (SystemComponent::new);
采用这一方式,你可以消除竞争,提升程序的性能。不过这种方式也有其弊端,它会使程序的复杂性增加。
注意 在这种方式下,调用方代码访问单例时,不再需要通过getInstance()方法。你可以在第一次访问单例时,将其赋给某个变量(引用)。一旦将单例赋给变量,后续所有的调用都可以通过该变量获得单例对象,不再需要调用getInstance()方法。如此一来,就可减少竞争。
单例对象的实例也可以被注入需要使用它的其他组件中。理想情况下,你的应用在一个地方创建了所有组件,并将它们注入服务中(利用像依赖注入这样的技术)。这种情况下,你甚至根本不需要使用单例模式。你只需要创建一个需要分享的对象实例,并将它注入所有依赖的服务中。当然,你也可以采用其他方式,譬如使用枚举类型,它的底层实现也基于单例模式。接下来让我们通过代码来验证我们的猜测。
到目前为止,我们已经通过3种方式实现了线程安全的单例模式,如下所示。
■ 为所有的操作添加同步机制。
■ 使用双检锁创建单例。
■ 采用线程限定方式(通过ThreadLocal类)创建单例。
在我们的猜测中,第一种方式的性能应该最差,然而目前我们还没有任何证明数据。现在,我们将创建一个性能基准测试来验证这3种实现方式的性能差异。我们会使用性能测试工具JMH进行性能对比测试,本书后续内容也会多次使用该工具对代码的性能进行测试。
我们会创建一个执行50,000次获取SystemComponent(单例)对象操作的基准测试(代码请参考代码清单1.8)。我们会创建3个基准测试,每个基准测试使用不同的单例实现方式。为了验证竞争是如何影响程序性能的,我们会创建100个并发线程执行这段代码逻辑。结果报告中以毫秒为单位呈现测试结果。
@Fork(1)
@Warmup(iterations = 1)
@Measurement(iterations = 1)
@BenchmarkMode(Mode.AverageTime)
@Threads(100) ◁--- 启动100个并发线程执行这段代码逻辑
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class BenchmarkSingletonVsThreadLocal {
private static final int NUMBER_OF_ITERATIONS = 50_000;
@Benchmark
public void singletonWithSynchronization(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonSynchronized.getInstance()); ◁--- 第一个基准测试采用SystemComponentSingletonSynchronized
}
}
@Benchmark
public void singletonWithDoubleCheckedLocking(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonDoubleCheckedLocking.getInstance()); ◁--- 对SystemComponentSingletonDoubleCheckedLocking的基准测试
}
}
@Benchmark
public void singletonWithThreadLocal(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(SystemComponentThreadLocal.get()); ◁--- 获取SystemComponentThreadLocal的基准测试结果
}
}
}
执行这个测试,我们可以得到100个并发线程完成50,000次调用的平均耗时。注意,你的实际耗时可能因环境不同有所差异,不过总体的趋势应该保持一致,如代码清单1.9所示。
Benchmark Mode
Cnt Score Error Units
CH01.BenchmarkSingletonVsThreadLocal.singletonWithDoubleCheckedLocking avgt
2.629 ms/op
CH01.BenchmarkSingletonVsThreadLocal.singletonWithSynchronization avgt
316.619 ms/op
CH01.BenchmarkSingletonVsThreadLocal.singletonWithThreadLocal avgt
5.622 ms/op
查看测试结果,singletonWithSynchronization方式的执行的确是最慢的。完成基准测试逻辑执行的平均时间超过300 ms。其他两个方式对这一行为进行了改进。singletonWithDoubleCheckedLocking的性能最优,只花费了大约2.6 ms,而singletonWithThreadLocal耗时大约为5.6 ms。据此,我们可以得出如下结论:采用线程限定方式可以带来约50倍的性能提升,采用双检锁方式可以带来约120倍的性能提升。
验证我们的猜测后,我们为多线程上下文选择了合适的方式。如果需要在多个方式间做选择,当它们的性能不相上下时,我们建议选择更直观的解决方式。然而,所有这一切的前提都是测试数据,如果没有实际的测试数据,我们很难做出客观和理性的决策。
接下来,我们将讨论涉及架构选型的设计取舍。1.3节中,我们会对比微服务架构与单体系统,了解它们在设计上的权衡。