Источник: Medium
В этом руководстве рассмотрены концепции ленивой загрузки (lazy loading) и нетерпеливой загрузки (eager loading) в синглтонах, а также примеры реализаций, ориентированных на многопотоковое исполнение.
Singleton — это шаблон проектирования в языке Java, который гарантирует, что класс имеет только один экземпляр, и обеспечивает глобальный доступ к этому экземпляру. Синглтоны обычно используются в сценариях, где наличие нескольких экземпляров может привести к непоследовательному или нежелательному поведению.
Ленивая загрузка
Ленивая загрузка (lazy loading) означает, что вы загружаете синглтон только тогда, когда он вам нужен. Иными словами, вы инициируете экземпляр только при вызове getInstance(). То есть, даже если у вас есть другие статические методы в вашем классе Singleton, которые будут, например, загружать конфигурацию, и эти методы используются для запуска вашего приложения, то процесс инициализации все равно ограничен только методом getInstance(). Таким образом, время запуска вашего приложения не будет зависеть от времени загрузки экземпляра.Ленивая загрузка в классическом синглтоне
Ленивая загрузка обычно используется в классическом синглтоне. Давайте сначала рассмотрим классический класс Singleton, который используется в однопоточном приложении. Это простой класс, который можно реализовать там, где у нас есть приватный конструктор, и мы можем вызвать getInstance() для инициализации переменной, если она по-прежнему имеет значение null. Результат должен выглядеть так:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
Это хорошая реализация, если у вас один поток. Но это не относится к большинству приложений, которые, как правило, используют многопоточность. Поэтому все, что остается делать, так это выполнить синхронизацию вашего синглтона.
Синхронизированная отложенная загрузка
Синхронизация может быть немного сложной, поскольку здесь мы можем использовать два разных подхода:- Синхронизируем весь метод getInstance().
- Синхронизируем часть метода.
public class ThreadSafeSingleton {
private static ThreadSafeSingleton instance;
private ThreadSafeSingleton() {
}
public static synchronized ThreadSafeSingleton getInstance() {
if(instance == null){
Main.waitTime();
Main.waitTime();
instance = new ThreadSafeSingleton();
}
Main.waitTime();
return instance;
}
public static ThreadSafeSingleton getInstanceOptimized() {
if(instance == null){
synchronized (ThreadSafeSingleton.class){
if(instance == null){
Main.waitTime();
Main.waitTime();
instance = new ThreadSafeSingleton();
}
}
}
Main.waitTime();
return instance;
}
}
В первом методе: getInstance() синхронизируется, и это означает, что он может быть вызван одним потоком за один раз.
Второй метод: getInstanceOptimized() также синхронизируется, но синхронизация сосредоточена на процессе инициализации переменной. Мы добавили две проверки на null, потому что потоки будут заблокированы после входа в первую нулевую проверку, и нам нужно убедиться, что другие потоки не сбрасывают значение после его инициализации.
Основное отличие здесь заключается в том, что, добавив waitTime(), который в основном является просто методом Thread.sleep(), и вызвав getInstance внутри нескольких потоков, вы обнаружите, что значения, напечатанные потоками, требуют дополнительное время в процессе инициализации и при возврате результата. Причина в том, что они заблокированы от запуска метода и позволяют за один раз запускаться только одному потоку.
Для второго метода мы синхронизируем только процесс инициализации. Так как это основной этап работы синглтона, это может привести к риску наличия нескольких экземпляров, если они не будут синхронизированы.
Какая разница, подумаете вы. Ожидание перед возвратом результата символизирует операции, выполняемые после процесса инициализации. Вопрос, который вы должны задать себе, таков: необходимы ли операции перед возвратом? Влияют ли они на экземпляр или изменяют его состояние или его основные переменные? Отразится ли изменение экземпляра негативно на операциях, которые будут выполняться на экземпляре?
Если ответ да, то синхронизация метода — это подходящий вариант. Если нет, то вы можете ограничить процесс синхронизации только инициализацией.
Теперь результат на 100% безопасен для потоков! Но как насчет использования встроенной синхронизации Java при создании экземпляра?
Это было бы отличным подходом для подражания, и он называется “Держатель по запросу” (On demand Holder).
Initialization-on-demand holder
Держатель инициализации по запросу (Initialization-on-demand holder) является частью ленивой инициализации. Я объясню это сразу после примера кода:
public class OnDemandSingleton {
private OnDemandSingleton() {
}
private static class SingletonHolder{
private static final OnDemandSingleton instance = new OnDemandSingleton();
}
public static OnDemandSingleton getInstance(){
return SingletonHolder.instance;
}
}
Обратите внимание, что у нас есть статический внутренний класс, который содержит статическую переменную. Мы знаем, что инициализация статических переменных вызывается при загрузке класса. В нашем контексте загрузка выполняется, когда мы вызываем getInstance(). То есть, мы все еще находимся в процессе ленивой инициализации.
Java гарантирует безопасность потоков изначально, синхронизируя инициализацию экземпляра во внутреннем классе.
Этот метод аналогичен последнему подходу, который мы реализовали, и который является реализацией потокобезопасности с getInstance Optimized().
Нетерпеливая загрузка
Нетерпеливая загрузка (eager loading) предполагает, что вы загружаете синглтон, когда загружается ваш класс Singleton, а не тогда, когда он вам нужен. Другими словами, вы только инициируете экземпляр в переменной класса и возвращаете его в getInstance(). Таким образом, если у вас есть другие статические методы в вашем классе Singleton, которые, например, будут загружать конфигурацию, и эти методы используются для запуска вашего приложения, то будет вызван процесс инициализации, что повлияет на время запуска вашего приложения. Вот почему мы называем это “нетерпеливой загрузкой”. Код для нетерпеливой загрузки синглтона:
public class EagerSingleton {
private static final EagerSingleton instance = new EagerSingleton();
private EagerSingleton() {
Main.waitTime();
Main.waitTime();
}
public static EagerSingleton getInstance() {
Main.waitTime();
return instance;
}
}
Теперь давайте посмотрим на разницу между двумя вызовами метода main. Для этого выберем классы EagerSingleton и ThreadSafeSingleton. Для последнего класса мы будем использовать оптимизированный метод. Наш main должен выглядеть так:
package com.whitebatcodes.singleton;
public class Main {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
long loadConfigStartTime = System.currentTimeMillis();
ThreadSafeSingleton.loadConfig();
long loadConfigEndTime = System.currentTimeMillis();
long loadConfigTime = loadConfigEndTime - loadConfigStartTime;
long firstCallStartTime = System.currentTimeMillis();
ThreadSafeSingleton.getInstanceOptimized();
long firstCallEndTime = System.currentTimeMillis();
long firstCallTime = firstCallEndTime - firstCallStartTime;
long secondCallStartTime = System.currentTimeMillis();
ThreadSafeSingleton.getInstanceOptimized();
long secondCallEndTime = System.currentTimeMillis();
long secondCallTime = secondCallEndTime - secondCallStartTime;
long endTime = System.currentTimeMillis();
long totalTime = endTime - startTime;
System.out.println("Load Config Time: " + loadConfigTime + " milliseconds");
System.out.println("First Call Time: " + firstCallTime + " milliseconds");
System.out.println("Second Call Time: " + secondCallTime + " milliseconds");
System.out.println("Total Time: " + totalTime + " milliseconds");
}
public static void waitTime(){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
И в завершение давайте заменим нетерпеливый синглтон потокобезопасным синглтоном. Вот результат:
Для потокобезопасного синглтона:
Время загрузки конфигурации: 1 мс
Время первого вызова: 3028 мс
Время второго вызова: 1008 мс
Общее время: 4037 мс
Для нетерпеливого синглтона:
Время загрузки конфигурации: 2022 миллисекунды
Время первого вызова: 1008 миллисекунд
Время второго вызова: 1004 миллисекунды
Общее время: 4034 миллисекунды
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ