برنامه نویسی

الگوی Singleton در جاوا: بهترین روش‌ها و مثال‌ها

مقدمه

الگوی Singleton در جاوا، یک الگوی طراحی ساختاری (Creational) است که کلاسی را به یک Instance منفرد به‌ازای هر Java Virtual Machine محدود کرده و آن Instance را از طریق نقطه دسترسی سراسری (Global) افشا می‌کند. این الگو از کتاب «Design Patterns» (۱۹۹۴) گروه Gang of Four سرچشمه می‌گیرد و همچنان یکی از پرکاربردترین الگوها برای مدیریت منابع مشترکی مانند Logging، پیکربندی، Connection Poolها و Cacheهاست.

این راهنما هفت نوع پیاده‌سازی در جاوا را پوشش می‌دهد: Eager Initialization، Static Block Initialization، Lazy Initialization، Synchronized Method، Double-Checked Locking، Bill Pugh و Enum Singleton. هم‌چنین دو بردار حمله‌ای که می‌توانند تضمین Singleton را بشکنند—یعنی Reflection و Serialization—را با استراتژی‌های پیشگیری برای هرکدام پوشش می‌دهد. بخش‌های نهایی، Singleton را با کلاس‌های Utility استاتیک مقایسه کرده، توضیح می‌دهد که فریم‌ورک‌های تزریق وابستگی مانند Spring چگونه پیاده‌سازی دستی Singleton را جایگزین می‌کنند؛ و رفتار Singleton را زیر Virtual Threadها و Native Image مربوط به GraalVM بررسی می‌کند.

نکات کلیدی

  • الگوی Singleton تضمین می‌کند کلاسی دقیقاً یک Instance به‌ازای هر JVM داشته باشد و نقطه دسترسی سراسری به آن فراهم می‌کند.
  • جاوا هفت پیاده‌سازی رایج Singleton ارائه می‌دهد: Eager، Static Block، Lazy، Synchronized Method، Double-Checked Locking، Bill Pugh و Enum.
  • Bill Pugh (یعنی initialization-on-demand holder) پیاده‌سازی توصیه‌شده وقتی Lazy Loading لازم است؛ Enum Singleton در همه موارد دیگر توصیه می‌شود.
  • Double-Checked Locking به کلیدواژه volatile نیاز دارد تا JVM از انتشار Instanceِ نیمه-ساخته جلوگیری کند.
  • Reflection و Serialization هر دو می‌توانند تضمین‌های Singleton را در همه پیاده‌سازی‌ها—به‌جز Enum Singleton که در سطح JVM در برابر هر دو حمله مصون است—بشکنند.
  • از Singleton اجتناب کنید وقتی State سراسریِ پنهانی معرفی می‌کند، تست واحد را بلاک می‌کند یا جایگزین مدیریت درست وابستگی‌ها می‌شود.
  • Beanهای Spring به‌طور پیش‌فرض درون ApplicationContext دامنه Singleton دارند؛ که نیاز به پیاده‌سازی دستی الگوی Singleton را در بیشتر اپلیکیشن‌های مدرن جاوا حذف می‌کند.

الگوی طراحی Singleton در جاوا چیست؟

الگوی Singleton، الگوی طراحی ساختاری‌ای است که تضمین می‌کند کلاسی دقیقاً یک Instance به‌ازای هر JVM داشته باشد و نقطه دسترسی سراسری برای بازیابی‌اش فراهم می‌کند. وقتی استفاده می‌شود که یک شیء مشترک باید رفتاری را در سراسر اپلیکیشن هماهنگ کند—مانند Logger، مدیر پیکربندی یا Connection Pool. این الگو درون خود JDK هم در کلاسهایی مانند java.lang.Runtime و java.awt.Desktop استفاده می‌شود.

ویژگی‌های هسته کلاس Singleton

هر پیاده‌سازی Singleton در جاوا سه عنصر ساختاری را شریک است:

  1. Constructor خصوصی. جلوی ساخت Instanceهای جدید توسط کد بیرونی با new را می‌گیرد.
  2. فیلد Instance استاتیک خصوصی. Instance منفرد کلاس را نگه می‌دارد.
  3. متد Accessor استاتیک عمومی. Instance منفرد را برمی‌گرداند—معمولاً با نام getInstance().

الگوی Singleton هم‌چنین به‌عنوان بلوک ساخت در سایر الگوهای طراحی جاوا—including Abstract Factory، Builder، Prototype و Facade—استفاده می‌شود.

چه زمانی از الگوی Singleton استفاده کنیم

  • سرویس‌های Logging که هر کامپوننت به همان مقصد می‌نویسد.
  • پیکربندی اپلیکیشن که باید بین همه Callerها سازگار بماند.
  • Connection Poolهای دیتابیس و Thread Pool که هزینه ساخت‌شان بالاست و فقط یک هماهنگ‌کننده باید وجود داشته باشد.
  • Cacheهای در-حافظه که باید یک منبع واحد حقیقت را سرو کنند.
  • اشیاء دسترسی به سخت‌افزار یا منبع مانند Device Driverها و File System Handleها.

چه زمانی از الگوی Singleton اجتناب کنیم

از Singleton اجتناب کنید وقتی مسائلی معرفی می‌کند که از مزایایش سنگین‌ترند:

  • State سراسریِ Mutable. هر Threadای می‌تواند هر زمانی—بدای آگاهی Caller از Writerهای هم‌زمان—Instance را بخواند و تغییر دهد. مثلاً Singletonِ پیکربندی‌ای که اعتبارنامه‌های دیتابیس را Cache می‌کند، می‌تواند مقدارهای کهنه‌ای به یک Thread سرو کند وقتی Thread دیگری وسط به‌روزرسانی است؛ بدون هیچ قفلی که خواندن را محافظت کند.
  • وابستگی‌های پنهان. کلاسی که درونش Singleton.getInstance() را صدا می‌زند، هیچ وابستگی‌ای در Constructorش تعریف نمی‌کند. همکار برای Caller نامرئی است؛ که استدلال درباره کلاس را سخت‌تر و Rewire کردنش را—بدون ویرایش سورس کد—غیرممکن می‌سازد.
  • تست واحد دشوار. چون getInstance() همیشه Instance زنده را برمی‌گرداند، نمی‌توانید Mock یا Test Doubleای را جانشین کنید—مگر اینکه خود کلاس Singleton را تغییر دهید یا از کتابخانه دستکاری Bytecode استفاده کنید. کلاسهایی که مستقیم به Singleton وابسته‌اند، عملاً به‌صورت ایزوله غیرقابل‌تست‌اند.
  • نقض اصل Single Responsibility. Singletonای که به‌عنوان Logger شروع می‌شود، اغلب به‌مرور—چون سراسرا قابل-دسترس و راحت است—خواندن پیکربندی، انتشار متریک و ارزیابی Feature Flag را انباشته می‌کند. الگو هیچ مقاومت ساختاری در برابر این نوع رشد ارائه نمی‌دهد.
  • محیط‌های Classloader-سنگین. در کانتینرهای OSGi و سرورهای اپلیکیشن Jakarta EE، هر ماژول یا اپلیکیشنِ دیپلوی‌شده در Classloader خودش اجرا می‌شود. Singletonای که در Classloaderای مقداردهی اولیه شده، شیئی کاملاً جدا از همان کلاسِ مقداردهی‌شده در Classloader دیگری است. اپلیکیشنهایی که یکتاییِ در-سطح-JVM را فرض می‌کنند، در این محیط‌ها نادرست رفتار می‌کنند.

در اپلیکیشن‌های مدرن جاوا، تزریق وابستگی با Spring یا Guice جایگزین ترجیح‌داده‌شده برای بیشتر موارد استفاده‌ای است که تاریخی به Singleton دستی تکیه داشتند.

Singleton با Eager Initialization

از Eager Initialization وقتی استفاده کنید که Singletonتان سبک است، هیچ وابستگیِ Constructorای به وضعیت زمان-اجرا ندارد و همیشه در طول عمر اپلیکیشن لازم خواهد بود. این پیش‌فرض درست برای منابع مشترک ساده‌ای مانند Wrapper ابزاریِ Stateless یا Registry ثابتِ در-سطح-اپلیکیشن است. از آن اجتناب کنید وقتی شیء سنگین است یا Constructorش متغیرهای محیطی می‌خواند، File Handle باز می‌کند یا اتصال شبکه برقرار می‌سازد.

در Eager Initialization، Instance مربوط به Singleton وقتی ساخته می‌شود که کلاس توسط JVM بارگذاری شود. Instance قبل از هر کدی که getInstance() را صدا بزند وجود دارد—فارغ از اینکه اپلیکیشن ازش استفاده کند یا نه. JVM تضمین می‌کند که بارگذاری کلاس دقیقاً یک‌بار به‌ازای هر Classloader اجرا شده و Thread-Safe است؛ پس هیچ کلیدواژه Synchronizationای در پیاده‌سازی لازم نیست.

پیاده‌سازی Eager Initialization

package com.journaldev.singleton;

public class EagerInitializedSingleton {

    // Instance هنگام بارگذاری کلاس ساخته و دقیقاً یک‌بار تخصیص می‌یابد
    private static final EagerInitializedSingleton instance = new EagerInitializedSingleton();

    // Constructor خصوصی، ساخت از بیرون با `new` را بلاک می‌کند
    private EagerInitializedSingleton(){}

    // نقطه دسترسی سراسری؛ Instance از-پیش-ساخته را برمی‌گرداند
    public static EagerInitializedSingleton getInstance() {
        return instance;
    }
}

مزایا و محدودیت‌ها

Eager Initialization ساده‌ترین پیاده‌سازی Singleton در جاواست. JVM تضمین می‌کند که مقداردهی اولیه کلاس دقیقاً یک‌بار اجرا شده و Thread-Safe باشد؛ پس هیچ Synchronizationای لازم نیست.

مزایا:

  • به‌طور پیش‌فرض Thread-Safe از طریق تضمین بارگذاری کلاسِ JVM.
  • ساده‌ترین پیاده‌سازی از هر هفت نوع.
  • بدون سربار Synchronization در زمان دسترسی.

محدودیت‌ها:

  • Instance حتی اگر هیچ کدی هرگز getInstance() را صدا نزند ساخته می‌شود؛ که برای اشیاء سنگین، حافظه را هدر می‌دهد.
  • Constructor نمی‌تواند Exception ازنوع Checked پرتاب کند؛ پس مدیریت خطا در زمان Instantiate محدود است.
  • مناسب نیست وقتی Singleton منابع گرونگی مانند اتصال‌های دیتابیس یا File Handleها را نگه می‌دارد که باید Lazy ساخته شوند.

Singleton با Static Block Initialization

از Static Block Initialization وقتی استفاده کنید که Constructor مربوط به Singletonتان می‌تواند شکست بخورد. Eager Initialization به Constructor اجازه پرتاب Exceptionِ Checked نمی‌دهد؛ چون هیچ try/catchای Initializer فیلد را نمی‌پیچد. اگر Singletonتان حین ساخت فایل پیکربندی می‌خواند، Socket باز می‌کند یا کتابخانه Native بارگذاری می‌کند، Static Block به شما جایی برای گرفتن خرابی و تبدیلش به RuntimeExceptionای می‌دهد که بارگذاری کلاس را با خطایی روشن متوقف می‌کند—به‌جای رها کردن اپلیکیشن در وضعیت نیمه-مقداردهی‌شده.

Static Block Initialization از هر جهت دیگر، از نظر ساختاری با Earge Initialization یکسان است: Instance هنگام بارگذاری کلاس ساخته می‌شود، به‌واسطه تضمین بارگذاری کلاسِ JVM، Thread-Safe است و Lazy نیست.

پیاده‌سازی Static Block

package com.journaldev.singleton;

public class StaticBlockSingleton {

    private static StaticBlockSingleton instance;

    private StaticBlockSingleton(){}

    // Static Block یک‌بار هنگام بارگذاری کلاس اجرا؛ ساخت را در try/catch می‌پیچد
    static {
        try {
            instance = new StaticBlockSingleton();
        } catch (Exception e) {
            // تبدیل هر exception ازنوع checked به RuntimeException تا بارگذاری کلاس سریع شکست بخورد
            throw new RuntimeException("Exception occurred in creating singleton instance");
        }
    }

    public static StaticBlockSingleton getInstance() {
        return instance;
    }
}

چه زمانی Static Block را به‌جای Eager Initialization انتخاب کنیم

از Static Block Initialization وقتی استفاده کنید که Constructor مربوط به Singleton ممکن است با Exceptionِ Checked شکست بخورد (مثلاً حین خواندن پیکربندی از دیسک یا مقداردهی اولیه منبع شبکه‌ای). هر دو رویکرد Instance را Eager بارگذاری می‌کنند؛ پس هیچ‌کدام برای منابع سنگینی که باید Lazy ساخته شوند مناسب نیستند.

Singleton با Lazy Initialization

Lazy Initialization ساخت Singleton را تا اولین فراخوانی getInstance() به تعویق می‌اندازد. Instance هنگام بارگذاری کلاس ساخته نمی‌شود؛ که برای Singletonهای سنگینی که ممکن است در هر اجرای برنامه لازم نباشند مناسب است.

پیاده‌سازی Lazy Initialization

package com.journaldev.singleton;

public class LazyInitializedSingleton {

    private static LazyInitializedSingleton instance;

    private LazyInitializedSingleton(){}

    public static LazyInitializedSingleton getInstance() {
        // مواجهه با race condition: چند thread می‌توانند هم‌زمان از این چیز عبور کنند
        if (instance == null) {
            instance = new LazyInitializedSingleton();
        }
        return instance;
    }
}

مسئله Thread Safety در Lazy Initialization ساده

هشدار: این پیاده‌سازی Thread-Safe نیست و تحت دسترسی هم‌زمان، تضمین Singleton را می‌شکند. فقط در زمینه‌های تأیید-شدهٔ تک-Threadای استفاده‌اش کنید.

دو Thread می‌توانند درون چک null درهم‌تنیده شده و دو Instance متمایز تولید کنند:

  1. Thread A مقدار instance == null را ارزیابی می‌کند. نتیجه: true.
  2. Thread B مقدار instance == null را قبل از اتمام ساخت شیء توسط Thread A ارزیابی می‌کند. نتیجه: true.
  3. هر دو Thread new LazyInitializedSingleton() را اجرا می‌کنند.
  4. دو Instance ساخته شده و تضمین Singleton شکسته می‌شود.

بخش‌های بعدی، چهار جایگزین Thread-Safe را—from بیشترین تا کمترین هزینه Synchronization—پوشش می‌دهند.

Singleton امن-برای-Thread با متد Synchronized

اگر به Lazy Initialization و Thread Safety نیاز دارید و نگران کارایی زیر هم‌زمانی بالا نیستید، علامت‌گذاری getInstance() به‌عنوان synchronized ساده‌ترین راه‌حل درست است. فقط یک Thread می‌تواند در هر لحظه وارد متد شود؛ که Race Condition توصیف‌شده در بخش قبلی را می‌بندد. بده‌بستان این است که هر فراخوانی getInstance() قفل Monitor می‌گیرد—even میلیون‌ها فراخوانی‌ای که بعد از ساخته شدن Instance اتفاق می‌افتند. برای اپلیکیشنهایی که getInstance() به‌ندرت صدا زده می‌شود، این سربار بی‌اهمیت است. برای مسیرهای داغ که هزاران Thread در ثانیه صدا می‌زنند، به‌جایش از Bill Pugh استفاده کنید.

پیاده‌سازی

package com.journaldev.singleton;

public class ThreadSafeSingleton {

    private static ThreadSafeSingleton instance;

    private ThreadSafeSingleton(){}

    // synchronized همه callerها را سریال می‌کند؛ فقط یک thread در هر لحظه وارد متد می‌شود
    public static synchronized ThreadSafeSingleton getInstance() {
        if (instance == null) {
            instance = new ThreadSafeSingleton();
        }
        return instance;
    }

}

هزینه کارایی Synchronization

هر فراخوانی getInstance() همگام‌شده، Monitor کلاس را می‌گیرد؛ که شامل Compare-And-Swap روی Lock Word و Memory Barrier است. این هزینه روی هر فراخوانی پرداخت می‌شود—including میلیون‌ها فراخوانی‌ای که بعد از ساخته شدن Instance رخ می‌دهند و قفل دیگر هیچ هدفی را خدمت نمی‌کند. زیر Contention، همه Callerها روی Monitor سریال شده و Throughput فرو می‌ریزد. Double-Checked Locking این را با Synchronize کردن فقط در پنجره کوتاهِ حین ساخته شدن Instance حل می‌کند؛ که پس از آن، فراخوانی‌های بعدی فقط یک خواندنِ volatile انجام می‌دهند.

Double-Checked Locking در جاوا

Double-Checked Locking مقدار instance == null را دو بار چک می‌کند: یک‌بار بیرون بلاک synchronized—برای اجتناب از قفل وقتی Instance از قبل موجود است؛ و یک‌بار درون بلاک synchronized—برای جلوگیری از ساخت Instance توسط دو Thread بعد از عبور هر دو از چک اول.

چرا volatile لازم است

ساخت شیء در JVM در سه قدم رخ می‌دهد: (۱) تخصیص حافظه، (۲) اجرای Constructor برای مقداردهی فیلدها، (۳) تخصیص Reference به instance. بدون volatile، به JVM یا CPU اجازه Reorder کردن قدم‌های ۲ و ۳ داده می‌شود. Thread دوم می‌تواند آنگاه Referenceایِ غیر-null بخواند که به شیءِ نیمه-ساخته اشاره می‌کند.

اعلان متغیر instance به‌عنوان volatile از این Reorder جلوگیری کرده و رابطه Happens-Before بین نوشتن و هر خواندن بعدی بین Threadها برقرار می‌کند.

پیاده‌سازی با کد توضیح‌دار

package com.journaldev.singleton;

public class DoubleCheckedLockingSingleton {

    // volatile دید را بین threadها تضمین و reordering دستورات را نمی‌گذارد
    private static volatile DoubleCheckedLockingSingleton instance;

    private DoubleCheckedLockingSingleton() {}

    public static DoubleCheckedLockingSingleton getInstance() {
        // چک اول: اگر instance از قبل ساخته شده از گرفتن قفل اجتناب کن
        if (instance == null) {
            // فقط در پنجره کوتاه مقداردهی اولیه synchronize کن
            synchronized (DoubleCheckedLockingSingleton.class) {
                // چک دوم: اگر دو thread از چک اول گذشته بودند جلوی ساخت تکراری را بگیر
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

تضمین‌های Memory Model مربوط به JVM

نکته: این تضمین به Memory Model جاوا ۵+ تعریف‌شده توسط JSR-133 وابسته است. روی JDKهای قبل از جاوا ۵، Double-Checked Locking—even با volatile—شکسته بود.

زیر JSR-133، نوشتن روی متغیر volatile قبل از هر خواندن بعدیِ آن متغیر بین Threadها Happens-Before است. JVM موظف است شیء کاملاً-ساخته را قبل از هر Threadای که Reference غیر-null را مشاهده می‌کند منتشر کند؛ پس هیچ Threadای نمی‌تواند از طریق فیلد volatile، Instanceِ نیمه-مقداردهی‌شده‌ای ببیند.

پیاده‌سازی Bill Pugh Singleton

Bill Pugh Singleton—که به initialization-on-demand holder idiom هم معروف است—از کلاس Helper استاتیک داخلی برای به تعویق انداختن ساخت Instance تا اولین دسترسی، بدون هیچ Synchronization صریحی، استفاده می‌کند. این الگو توسط Bill Pugh به‌عنوان پاسخی به غیرقابل-اعتماد بودن Double-Checked Locking زیر Memory Model قبل از جاوا ۵ پیشنهاد شد.

کلاس Helper استاتیک داخلی چگونه کار می‌کند

package com.journaldev.singleton;

public class BillPughSingleton {

    private BillPughSingleton(){}

    // کلاس استاتیک داخلی تا اولین ارجاع بارگذاری نمی‌شود
    private static class SingletonHelper {
        // INSTANCE وقتی ساخته می‌شود که JVM کلاس SingletonHelper را مقداردهی اولیه کند
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        // اولین ارجاع به SingletonHelper مقداردهی اولیه کلاسش را trigger می‌کند
        return SingletonHelper.INSTANCE;
    }
}

SingletonHelper وقتی BillPughSingleton بارگذاری می‌شود، بارگذاری نمی‌شود. JVM فقط وقتی آن را بارگذاری می‌کند که getInstance() برای اولین‌بار صدا زده شود؛ که در آن نقطه مقداردهی اولیه کلاس اجرا شده و INSTANCE ساخته می‌شود. مقداردهی اولیه کلاس ذاتاً Thread-Safe است؛ چون JVM برای مدت مقداردهی اولیه کلاس قفل داخلی می‌گیرد؛ پس هیچ بلاک synchronized یا فیلد volatileای لازم نیست.

چرا این بر Double-Checked Locking ترجیح داده می‌شود

پیاده‌سازی Bill Pugh پیچیدگی و سربار Synchronization مربوط به Double-Checked Locking را حذف می‌کند:

  • هیچ بلاک synchronized صریحی در محل فراخوانی وجود ندارد.
  • هیچ فیلد volatileای وجود ندارد.
  • ذاتاً Lazy است: Instance با اولین دسترسی ساخته می‌شود؛ نه هنگام بارگذاری کلاس.
  • کد تمیزتر بدون چک nullِ دوبل.

Bill Pugh، پیاده‌سازی Singleton همه-منظورهٔ توصیه‌شده است وقتی Lazy Loading لازم باشد.

Enum Singleton در جاوا

Enum Singleton یک نوع Enum تک-مقداری را تعریف می‌کند که ثابت منفردش به‌عنوان Instance مربوط به Singleton عمل می‌کند. JVM تضمین می‌کند ثابت دقیقاً یک‌بار Instantiate شده و محافظت داخلی در برابر هر دو حمله Reflection و Serialization فراهم می‌کند. Joshua Bloch این رویکرد را در کتاب Effective Java (آیتم ۳) توصیه می‌کند.

مثال پیاده‌سازی

package com.journaldev.singleton;

public enum EnumSingleton {

    // INSTANCE تنها ثابت enum و instance مربوط به singleton است
    INSTANCE;

    // فیلدهای instance امن‌اند برای افزودن؛ هر ثابت enum نسخه خودش را می‌گیرد
    private String databaseUrl = "jdbc:postgresql://localhost:5432/appdb";
    private int maxConnections = 10;

    public String getDatabaseUrl() {
        return databaseUrl;
    }

    public void setDatabaseUrl(String url) {
        // در پروداکشن، نوشتن‌ها را با اعتبارسنجی محافظ کنید یا فیلد را final بگذارید
        this.databaseUrl = url;
    }

    public int getMaxConnections() {
        return maxConnections;
    }
}

کد بیرونی از طریق ثابت Enum به Singleton دسترسی پیدا می‌کند:

// بازیابی singleton و خواندن پیکربندی
String url = EnumSingleton.INSTANCE.getDatabaseUrl();
int maxConn = EnumSingleton.INSTANCE.getMaxConnections();

// به‌روزرسانی پیکربندی در زمان اجرا
EnumSingleton.INSTANCE.setDatabaseUrl("jdbc:postgresql://prod-host:5432/appdb");

چرا Joshua Bloch enum Singleton را توصیه می‌کند

سه ویژگی، enum Singleton را امن‌ترین پیاده‌سازی می‌سازند:

  1. ضد Reflection. JVM ساختِ ازنوع Reflectiveِ انواع enum را ممنوع می‌کند. Constructor.newInstance() روی enum، IllegalArgumentException پرتاب می‌کند.
  2. ضد Serialization. JVM دست‌زدلسیِ enum را با برگرداندن ثابتِ موجود—به‌جای صدا زدن Constructor یا readObject()—مدیریت می‌کند. هیچ readResolve()ای لازم نیست.
  3. مختصر. پیاده‌سازی از همه هفت رویکرد کوتاه‌ترین است.

Enum Singleton، پیاده‌سازی Singleton توصیه‌شده در همه مواردی است که Lazy Initialization لازم نیست.

محدودیت‌های Enum Singleton

  • Lazy Initialization ندارد. Instance وقتی ساخته می‌شود که کلاس enum بارگذاری شود.
  • ارث‌بری از Superclass ندارد. Enumها ضمناً java.lang.Enum را Extend می‌کنند و نمی‌توانند هیچ کلاس دیگری را Extend کنند. اما می‌توانند Interface پیاده کنند.

Singleton در مقابل کلاس Static در جاوا

Singleton و کلاسی با فقط اعضای Static، هر دو نقطه دسترسی منفردی به وضعیت مشترک فراهم می‌کنند؛ اما در قابلیت و هدف متفاوت‌اند.

جدول تفاوت‌های کلیدی

ویژگیSingletonکلاس Static
Instantiate شدنInstance منفرد، ساخته‌شده در زمان نیاز یا بارگذاریبدون Instantiate؛ فقط اعضای static
پیاده‌سازی Interfaceبلهخیر
ارث‌بریمی‌تواند superclass را extend کندنمی‌تواند کلاسی را extend کند (عملاً implicit final)
Lazy initializationپشتیبانی‌شده (Bill Pugh، double-checked locking)قابل اعمال نیست
Serializationبا readResolve() پشتیبانی می‌شودقابل اعمال نیست
Testabilityوقتی به‌عنوان وابستگی inject شود قابل Mock استMock کردنش دشوار است
Stateدر instance کپسولهاز طریق فیلدهای static مشترک
Polymorphismپشتیبانی‌شدهپشتیبانی‌نشده

چه زمانی هر رویکرد را انتخاب کنیم

  • از Singleton وقتی استفاده کنید که شیء State نگه می‌دارد، باید برای انتزاعِ Interfaceای را پیاده کند، ممکن است Lazy Loading لازم داشته باشد یا باید در تست واحد با Mock جایگزین شود.
  • از کلاس Static وقتی استفاده کنید که API یک Utilityِ Stateless با هیچ چرخه حیات Instanceای و هیچ نیاز به Polymorphism نیست. مثال‌ها در JDK عبارت‌اند از java.util.Collections و java.util.Arrays.

شکستن Singleton با Reflection و نحوه پیشگیری

Reflection می‌تواند هر پیاده‌سازی Singletonای—به‌جز enum—را با دسترسی به Constructor خصوصی در زمان اجرا بشکند. Enum Singleton در سطح JVM مصون است.

مثال حمله Reflection

هشدار: Reflection پیشوند دسترسی خصوصی روی Constructor را دور زده و تضمین Singleton را روی پیاده‌سازی‌های eager، static block، lazy، synchronized، double-checked locking و Bill Pugh می‌شکند.

package com.journaldev.singleton;

import java.lang.reflect.Constructor;

public class ReflectionSingletonTest {

    public static void main(String[] args) {
        EagerInitializedSingleton instanceOne = EagerInitializedSingleton.getInstance();
        EagerInitializedSingleton instanceTwo = null;
        try {
            Constructor[] constructors = EagerInitializedSingleton.class.getDeclaredConstructors();
            for (Constructor constructor : constructors) {
                // setAccessible(true) پیشوند private روی constructor را دور می‌زند
                constructor.setAccessible(true);
                // newInstance() ساخت instance دوم را می‌سازد و تضمین singleton را می‌شکند
                instanceTwo = (EagerInitializedSingleton) constructor.newInstance();
                break;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(instanceOne.hashCode());
        System.out.println(instanceTwo.hashCode());
    }

}

دو Hash Code چاپ‌شده متفاوت خواهند بود؛ که تأیید می‌کند دو Instance متمایز وجود دارند. مقادیر دقیق به‌ازای هر اجرای JVM متغیر است.

استراتژی پیشگیری

چک Guardای در Constructor خصوصی اضافه کنید که وقتی Instanceای از قبل وجود دارد پرتاب کند:

private EagerInitializedSingleton() {
    // بعد از ساخته شدن singleton، ساختِ reflective را بلاک کن
    if (instance != null) {
        throw new IllegalStateException(
            "Singleton instance already exists. Use getInstance() instead."
        );
    }
}

این Guard برای eager و static block کار می‌کند؛ چون instance تا زمان اجرای هر فراخوانی Reflectionای، غیر-null است. برای پیاده‌سازی‌های Lazy به‌طور قابل-اطمینان کار نمی‌کند؛ چون instance حین اولین ساختِ قانونی null است؛ یعنی فراخوانی Reflectionای که با getInstance() مسابقه دهد همچنان می‌تواند موفق شود.

برای محافظت کامل در برابر حملات Reflection، از Enum Singleton استفاده کنید. JVM فراخوانی‌های Constructor.newInstance() روی انواع enum را با IllegalArgumentException رد می‌کند؛ که تنها رویکرد مصون در برابر Reflection در سطح JVM است.

شکستن Singleton با Serialization و نحوه پیشگیری

Singletonای که Serializable را پیاده می‌کند می‌تواند به فایل یا Stream شبکه‌ای نوشته و از آن خوانده شود. این پشتیبانی در بسیاری از سیستم‌های توزیع‌شده لازم است؛ اما بردار حمله دومی معرفی می‌کند که تضمین Singleton را می‌شکند.

مسئله Serialization

هشدار: سازوکار Deserialize پیش‌فرض جاوا، Constructor را دور زده و Instance شیء جدیدی می‌سازد. بدون رفع صریح، هر فراخوانی readObject() Instance تازه‌ای برمی‌گرداند و تضمین Singleton را می‌شکند.

package com.journaldev.singleton;

import java.io.Serializable;

public class SerializedSingleton implements Serializable {

    private static final long serialVersionUID = -7604766932017737115L;

    private SerializedSingleton(){}

    private static class SingletonHelper {
        private static final SerializedSingleton instance = new SerializedSingleton();
    }

    public static SerializedSingleton getInstance() {
        return SingletonHelper.instance;
    }

}

تست زیر Singleton را Serialize، Deserialize و هر دو Hash Code را چاپ می‌کند:

package com.journaldev.singleton;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;

public class SingletonSerializedTest {

    public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException {
        SerializedSingleton instanceOne = SerializedSingleton.getInstance();
        ObjectOutput out = new ObjectOutputStream(new FileOutputStream(
                "filename.ser"));
        out.writeObject(instanceOne);
        out.close();

        // Deserialize پیش‌فرض constructor را دور زده و instance جدیدی می‌سازد
        ObjectInput in = new ObjectInputStream(new FileInputStream(
                "filename.ser"));
        SerializedSingleton instanceTwo = (SerializedSingleton) in.readObject();
        in.close();

        System.out.println("instanceOne hashCode="+instanceOne.hashCode());
        System.out.println("instanceTwo hashCode="+instanceTwo.hashCode());

    }

}

دو Hash Code چاپ‌شده متفاوت خواهند بود؛ که تأیید می‌کند Deserialize Instance دومی تولید کرده. مقادیر دقیق به‌ازای هر اجرای JVM متغیر است.

استفاده از readResolve() برای حفظ رفتار Singleton

readResolve() را به‌عنوان متد Instanceای مستقیماً درون کلاس Singleton بیرونی اضافه کنید—نه درون SingletonHelper. JVM این متد را روی شیء Deserialize-شده بلافاصله بعد از ساخت صدا زده و مقدار بازگشتی‌اش را جانشین می‌کند؛ و شیء تازه-ساخته را دور می‌ریزد:

package com.journaldev.singleton;

import java.io.Serializable;

public class SerializedSingleton implements Serializable {

    private static final long serialVersionUID = -7604766932017737115L;

    private SerializedSingleton(){}

    private static class SingletonHelper {
        private static final SerializedSingleton instance = new SerializedSingleton();
    }

    public static SerializedSingleton getInstance() {
        return SingletonHelper.instance;
    }

    // readResolve() بعد از deserialize توسط JVM صدا زده می‌شود؛ برگرداندن getInstance()
    // باعث می‌شود JVM شیء deserialized را دور بریزد و instance موجود را برگرداند
    protected Object readResolve() {
        return getInstance();
    }
}

بعد از افزودن readResolve()، دو Hash Code چاپ‌شده مطابقت خواهند کرد؛ که تأیید می‌کند Deserialize، Instance موجود را برگردانده.

Enum Singleton این مورد را—بدون نیاز به readResolve()—خودکار مدیریت می‌کند؛ چون Deserialize مربوط به enum در JVM همیشه ثابت موجود را برمی‌گرداند.

Singleton در Spring و فریم‌ورک‌های تزریق وابستگی

بیشتر اپلیکیشن‌های مدرن جاوا به‌جای پیاده‌سازی دستی الگوی Singleton از کانتینر تزریق وابستگی استفاده می‌کنند. کانتینر، Instance منفرد را مدیریت کرده و به هر همکاری که لازم دارد Inject می‌کند.

دامنه Bean در Spring در مقابل Singleton دستی

در Spring، هر Bean به‌طور پیش‌فرض درون ApplicationContext دامنه Singleton دارد. تعریف کلاسی با @Service، @Component یا @Repository کافی است:

import org.springframework.stereotype.Service;

@Service
// Spring دقیقاً یک instance از این کلاس را به‌ازای هر ApplicationContext مدیریت می‌کند
public class ConfigurationService {

    public String getDatabaseUrl() {
        return "jdbc:postgresql://localhost:5432/mydb";
    }
}

دامنه به‌ازای ApplicationContext است—نه به‌ازای JVM. JVM منفردی که دو Context را Bootstrap کند، دو Instance از Bean می‌سازد. این با تضمین سطح-JVMِ الگوی Singleton تفاوت دارد؛ اما با چیزی که اپلیکیشن‌های واقعی لازم دارند مطابقت دارد: ایزوله‌سازی به-ازای-اپلیکیشن به‌جای State سراسریِ در-سطح-پروسه.

تست کردن Singletonها با تزریق وابستگی

Singletonهای دستی را نمی‌توان در تست‌های واحد—بدون تغییر کلاسِ زیر تست—با Mock جایگزین کرد. تزریق Constructor این مسئله را حذف می‌کند:

// به‌جای صدا زدن ConfigurationService.getInstance()، سرویس را inject کنید
public class DatabaseConnector {

    private final ConfigurationService configService;

    // تزریق constructor اجازه پاس دادن mock در تست‌های واحد را می‌دهد
    public DatabaseConnector(ConfigurationService configService) {
        this.configService = configService;
    }

    // به سرویس inject-شده تفویض می‌کند؛ هیچ فراخوانی static getInstance() در بدنه متد نیست
    public String getDatabaseUrl() {
        return configService.getDatabaseUrl();
    }
}

تزریق Constructor با Spring یا Guice، نیاز به پیاده‌سازی دستی Singleton را در بیشتر اپلیکیشن‌های پروداکشن جاوا حذف کرده و کلاسهایی می‌سازد که به‌صورت ایزوله قابل-تست‌اند.

مثال JUnit 5 و Mockito زیر نشان می‌دهد تستی برای DatabaseConnector وقتی وابستگی Inject شده—to‌جای گرفتن از طریق getInstance()—چگونه است:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class DatabaseConnectorTest {

    @Test
    void testGetUrlUsesInjectedConfig() {
        // ساخت mockای که مقدار کنترل‌شده‌ای به‌جای singleton واقعی برمی‌گرداند
        ConfigurationService mockConfig = Mockito.mock(ConfigurationService.class);
        Mockito.when(mockConfig.getDatabaseUrl()).thenReturn("jdbc:h2:mem:testdb");

        // ت inject از طریق constructor؛ هیچ singletonای در کار نیست
        DatabaseConnector connector = new DatabaseConnector(mockConfig);

        assertEquals("jdbc:h2:mem:testdb", connector.getDatabaseUrl());
    }
}

این تست—بدون Context مربوط به Spring، بدون دیتابیس واقعی و بدون دست زدن به Singleton زندهٔ ConfigurationService—اجرا می‌شود. اگر DatabaseConnector مستقیم ConfigurationService.getInstance() را صدا می‌زد، نوشتن این تست—بدون تغییر کد پروداکشن یا استفاده از کتابخانه دستکاری Bytecode—غیرممکن بود.

رفتار Singleton با Virtual Threadها و GraalVM Native Image

دو افزودنی اخیر به پلتفرم جاوا، رفتار پیاده‌سازی‌های Singleton را در زمان اجرا تغییر می‌دهند: Virtual Threadها از Project Loom و Native Image مربوط به GraalVM.

Virtual Threadها، Heap State را با Threadهای پلتفرمی شریک می‌شوند؛ پس هر هفت پیاده‌سازی Singleton زیر Virtual Threadها درست می‌مانند. در JDK ۲۱ و ۲۲، Virtual Threadای که وارد بلاک synchronized می‌شود، برای مدت بلاک به Carrier Thread خودش Pin می‌شود؛ که Throughput زیر Contention را—نه درستی را—تحت تأثیر قرار می‌دهد. JDK ۲۴ این رفتار Pin را برای بیشتر موارد حذف کرده. Bill Pugh و Enum Singleton این نگرانی را کاملاً دور می‌زنند؛ چون هیچ‌کدام در مسیر دسترسی، قفل synchronized نمی‌گیرند.

GraalVM Native Image به‌طور پیش‌فرض کلاسها را در زمان Build مقداردهی اولیه می‌کند. یعنی Singletonِ Eager یا Static Block حین Build مربوط به Native Image ساخته می‌شود—نه در اولین دسترسی زمان-اجرا. اگر Constructor متغیر محیطی‌ای مانند System.getenv("DB_URL") بخواند یا Socket باز کند، آن فراخوانی روی ماشین Build اجرا می‌شود—جایی که متغیر وجود ندارد و شبکه، شبکه دیپلوی نیست. Singleton آن State را برای همیشه درون Binary Capture می‌کند.

علامت، فیلد null یا اتصالی است که به آدرس زمان-Build اشاره می‌کند که در زمان اجرا وجود ندارد. GraalVM ممکن است هم Build را با خطایی مثل زیر شکست بدهد:

Error: Classes that should be initialized at run time got initialized during image building

برای رفع این، به GraalVM بگویید مقداردهی اولیه کلاس را با فلگ --initialize-at-run-time به زمان اجرا موکول کند. نام کلاسِ کاملاً کیفی‌شدهٔ Singleton را پاس بدهید:

native-image --initialize-at-run-time=com.journaldev.singleton.EagerInitializedSingleton \
  -jar myapp.jar

Singletonهای Bill Pugh و enum—وقتی فقط در زمان اجرا دسترسی شوند—تحت تأثیر نیستند؛ چون هیچ‌کدام تا اولین دسترسی‌شان—which روی هاست دیپلوی بعد از شروع Binary رخ می‌دهد—مقداردهی اولیه نمی‌شوند.

مقایسه همه پیاده‌سازی‌های Singleton

جدول خلاصه: Thread Safety، Lazy Loading، امنیت Reflection، امنیت Serialization

پیاده‌سازیThread SafeLazy LoadingReflection SafeSerialization Safeتوصیه‌شده
Eager Initializationبله (بارگذاری کلاس)خیرخیرخیر (readResolve لازم دارد)فقط منابع کم-سربار
Static Block Initializationبله (بارگذاری کلاس)خیرخیرخیر (readResolve لازم دارد)وقتی constructor پرتاب checked exception می‌کند
Lazy Initializationخیربلهخیرخیرفقط تک-Thread
Synchronized Methodبلهبلهخیرخیرتوصیه‌نشده (کارایی)
Double-Checked Lockingبله (با volatile)بلهخیرخیر (readResolve لازم دارد)قابل‌قبول؛ Bill Pugh را ترجیح بدهید
Bill Pugh (Holder)بلهبلهخیرخیر (readResolve لازم دارد)توصیه‌شده وقتی lazy loading لازم است
Enum Singletonبلهخیربلهبلهتوصیه‌شده در همه موارد دیگر

سوالات متداول

س: الگوی Singleton در جاوا چیست و چرا استفاده می‌شود؟

ج: الگوی Singleton، الگوی طراحی ساختاری‌ای است که کلاسی را به دقیقاً یک Instance محدود کرده و نقطه دسترسی سراسری برای بازیابی‌اش فراهم می‌کند. وقتی استفاده می‌شود که یک منبع مشترک منفرد باید اقداماتی را در سراسر سیستم هماهنگ کند—مانند سرویس Logging، مدیر پیکربندی اپلیکیشن یا Connection Pool دیتابیس. این الگو در کتاب «Design Patterns» (۱۹۹۴) گروه Gang of Four معرفی شد و یکی از شناخته‌شده‌ترین الگوها در جاواست.

س: کدام پیاده‌سازی Singleton برای اپلیکیشن Multi-Threaded جاوا بهترین انتخاب است؟

ج: Bill Pugh Singleton (یعنی initialization-on-demand holder idiom) پیاده‌سازی همه-منظورهٔ توصیه‌شده است وقتی Lazy Loading لازم باشد. بدون Synchronization صریح Thread-Safe است و هیچ کلیدواژه volatileای لازم ندارد. Enum Singleton انتخاب توصیه‌شده است وقتی Lazy Initialization لازم نیست؛ چون در سطح JVM در برابر حملات Reflection و Serialization هم مصون است.

س: Double-Checked Locking در Singleton جاوا چیست و چرا volatile لازم است؟

ج: Double-Checked Locking سربار Synchronization را با چک کردن null بودن Instance قبل و بعد از گرفتن قفل کاهش می‌دهد. بدون کلیدواژه volatile، JVM یا CPU ممکن است قدم‌های ساخت شیء (تخصیص حافظه، مقداردهی اولیه، تخصیص Reference) را Reorder کند؛ که به Thread دوم اجازه می‌دهد Instanceای غیر-null اما ناقص-مقداردهی‌شده بخواند. اعلان متغیر Instance به‌عنوان volatile، رابطه Happens-Beforeای را الزامی می‌کند که از این Reorder جلوگیری می‌نماید.

س: enum Singleton چگونه از حملات Reflection و Serialization جلوگیری می‌کند؟

ج: JVM ساختِ Reflectiveِ انواع enum را ممنوع می‌کند؛ صدا زدن Constructor.newInstance() روی enum، IllegalArgumentException پرتاب می‌کند. برای Serialization، JVM دست‌زدلسیِ enum را با برگرداندن ثابتِ موجود—to‌جای ساخت Instance جدید—مدیریت می‌کند؛ پس readResolve() لازم نیست. این تضمین‌ها در سطح JVM فراهم شده و قابل دور زدن نیستند.

س: تفاوت Singleton جاوا و کلاس Static چیست؟

ج: Singleton، Instance شیءای با State کپسوله است؛ با قابلیت پیاده‌سازی Interface، پشتیبانی ارث‌بری و پاس داده شدن به‌عنوان وابستگی. کلاس Static (کلاسی با فقط متدها و فیلدهای Static) قابل Instantiate نیست، نمی‌تواند در زمینه Polymorphic از Interface پیاده شود و Mock کردنش در تست‌های واحد آسان نیست. وقتی رفتار State-دار یا انتزاع مبتنی بر Interface لازم دارید از Singleton استفاده کنید؛ برای متدهای Utilityِ Stateless از کلاس Static.

س: آیا Singleton می‌تواند در جاوا با Serialization شکسته شود؟

ج: بله. سازوکار Deserialize پیش‌فرض جاوا، Constructor را دور زده و شیء جدیدی می‌سازد؛ که به Instance دومی منجر شده و تضمین Singleton را می‌شکند. برای پیشگیری، متد readResolve() را برای برگرداندن Instance موجود پیاده کنید. به‌عنوان جایگزین، از Enum Singleton استفاده کنید—which در برابر حملات Serialization مصون است؛ چون JVM دست‌زدلسی enum را با برگرداندن ثابت موجود مدیریت می‌کند.

س: چطور از الگوی Singleton در اپلیکیشن Spring استفاده کنم؟

ج: Beanهای Spring به‌طور پیش‌فرض درون ApplicationContext دامنه Singleton دارند. تعریف کلاسی با @Service، @Component یا @Repository باعث می‌شود Spring دقیقاً یک Instance به‌ازای هر Context بسازد و مدیریت کند. این رویکرد ترجیح‌داده‌شده در اپلیکیشن‌های Spring است؛ چون کانتینر مدیریت چرخه حیات را انجام می‌دهد و Bean می‌تواند به‌عنوان پارامتر Constructor تزریق شود؛ که با اشیاء Mock قابل-تستش می‌سازد.

س: آیا الگوی Singleton ضدالگو (Anti-Pattern) تلقی می‌شود؟

ج: الگوی Singleton در زمینه‌هایی ضدالگو تلقی می‌شود که State سراسریِ Mutable معرفی می‌کند، وابستگی‌ها را پنهان می‌سازد یا تست واحد را دشوار می‌کند. این مشکلات اغلب وقتی بروز می‌کنند که Singleton سراسرا—from طریق getInstance() در سراسر Codebase—صدا زده شود؛ نه اینکه به‌عنوان وابستگی Inject شود. در اپلیکیشن‌های مدرن جاوا که از Spring یا Guice استفاده می‌کنند، پیاده‌سازی دستی Singleton به‌ندرت لازم است. الگو برای منابع مشترکِ Stateless یا اشیاء زیرساختی—which در آن‌ها Instance هماهنگ‌کنندهٔ منفرد الزام معماری واقعی است—مناسب است.

نتیجه‌گیری

این مقاله هفت نوع پیاده‌سازی Singleton در جاوا (یعنی eager، static block، lazy، synchronized method، double-checked locking، Bill Pugh و enum)، دو بردار حمله‌ای که می‌توانند تضمین Singleton را بشکنند (یعنی Reflection و Serialization) و نحوه پیشگیری از هرکدام، مقایسه ساختاری بین Singleton و کلاس‌های Utility استاتیک، نقش فریم‌ورک‌های تزریق وابستگی مانند Spring به‌عنوان جایگزین مدرن، و رفتار Singleton زیر Virtual Threadها و GraalVM Native Image را پوشش داد.

حالا می‌توانید پیاده‌سازی Singleton درست را برای مورد استفاده‌تان—بر اساس اینکه آیا Lazy Loading لازم است—انتخاب کنید، volatile را درست هنگام پیاده‌سازی Double-Checked Locking اعمال نمایید، Singletonهای‌تان را در برابر حملات Reflection و Serialization محافظت کنید؛ و تصمیم بگیرید کِی Singleton دستی را با Bean مدیریت‌شدهٔ Spring جایگزین کنید.

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا