Совет 67 Java: Ленивое создание экземпляра

Не так давно мы были взволнованы перспективой увеличения объема встроенной памяти в 8-битном микрокомпьютере с 8 КБ до 64 КБ. Судя по постоянно растущему числу требовательных к ресурсам приложений, которые мы сейчас используем, удивительно, что кому-то когда-либо удавалось написать программу, которая поместилась бы в этот крошечный объем памяти. Хотя в наши дни у нас гораздо больше памяти, с которой можно поиграть, можно извлечь некоторые ценные уроки из методов, созданных для работы в таких жестких ограничениях.

Более того, программирование на Java - это не просто написание апплетов и приложений для развертывания на персональных компьютерах и рабочих станциях; Java также сильно завоевала рынок встраиваемых систем. Текущие встроенные системы имеют относительно скудные ресурсы памяти и вычислительную мощность, поэтому многие старые проблемы, с которыми сталкиваются программисты, вновь всплыли на поверхность для разработчиков Java, работающих в сфере устройств.

Уравновешивание этих факторов - увлекательная проблема дизайна: важно признать тот факт, что ни одно решение в области встроенного дизайна не будет идеальным. Итак, нам нужно понимать типы методов, которые будут полезны для достижения тонкого баланса, необходимого для работы в рамках ограничений платформы развертывания.

Один из способов сохранения памяти, который Java-программисты считают полезным, - это отложенное создание экземпляров. При ленивом создании экземпляра программа воздерживается от создания определенных ресурсов до тех пор, пока ресурс не понадобится впервые, что освободит ценное пространство памяти. В этом совете мы исследуем ленивые методы создания экземпляров в загрузке классов Java и создании объектов, а также особые соображения, необходимые для шаблонов Singleton. Материал в этом совете основан на работе в главе 9 нашей книги « Java на практике: стили дизайна и идиомы для эффективной Java» (см. Ресурсы).

Жадное против ленивого создания экземпляров: пример

Если вы знакомы с веб-браузером Netscape и использовали обе версии 3.x и 4.x, вы, несомненно, заметили разницу в том, как загружается среда выполнения Java. Если вы посмотрите на экран-заставку при запуске Netscape 3, вы заметите, что он загружает различные ресурсы, включая Java. Однако, когда вы запускаете Netscape 4.x, он не загружает среду выполнения Java - он ждет, пока вы не посетите веб-страницу, содержащую тег. Эти два подхода иллюстрируют методы активного создания экземпляров (загрузка его в случае необходимости) и ленивого создания экземпляров (подождите, пока он не будет запрошен, прежде чем загружать его, поскольку он может никогда не понадобиться).

У обоих подходов есть недостатки: с одной стороны, постоянная загрузка ресурса может тратить драгоценную память, если ресурс не используется во время этого сеанса; с другой стороны, если он не был загружен, вы платите цену в виде времени загрузки, когда ресурс требуется в первый раз.

Рассматривайте ленивое создание экземпляров как политику экономии ресурсов

Ленивое создание экземпляров в Java делится на две категории:

  • Ленивая загрузка класса
  • Ленивое создание объекта

Ленивая загрузка класса

Среда выполнения Java имеет встроенное ленивое создание экземпляров для классов. Классы загружаются в память только при первой ссылке. (Они также могут быть сначала загружены с веб-сервера через HTTP.)

MyUtils.classMethod (); // первый вызов статического метода класса Vector v = new Vector (); // первый вызов оператора new

Ленивая загрузка классов - важная функция среды выполнения Java, поскольку при определенных обстоятельствах она может уменьшить использование памяти. Например, если часть программы никогда не выполняется во время сеанса, классы, на которые ссылается только эта часть программы, никогда не будут загружены.

Ленивое создание объекта

Ленивое создание объекта тесно связано с отложенной загрузкой класса. При первом использовании ключевого слова new для типа класса, который ранее не загружался, среда выполнения Java загрузит его за вас. Ленивое создание объекта может сократить использование памяти в гораздо большей степени, чем отложенная загрузка классов.

Чтобы представить концепцию ленивого создания объекта, давайте рассмотрим простой пример кода, в котором a Frameиспользует a MessageBoxдля отображения сообщений об ошибках:

открытый класс MyFrame расширяет фрейм {частный MessageBox mb_ = новый MessageBox (); // частный помощник, используемый этим классом private void showMessage (String message) {// установить текст сообщения mb_.setMessage (message); mb_.pack (); mb_.show (); }}

В приведенном выше примере, когда создается экземпляр, также MyFrameсоздается MessageBoxэкземпляр mb_. Те же правила применяются рекурсивно. Таким образом, любые переменные экземпляра, инициализированные или назначенные в конструкторе класса MessageBox, также выделяются из кучи и так далее. Если экземпляр MyFrameне используется для отображения сообщения об ошибке в сеансе, мы без надобности тратим память.

В этом довольно простом примере мы не особо выиграем. Но если вы рассмотрите более сложный класс, который использует много других классов, которые, в свою очередь, рекурсивно рекурсивно рекурсивно реализуют и реализуют больше объектов, потенциальное использование памяти становится более очевидным.

Рассматривайте ленивое создание экземпляров как политику сокращения требований к ресурсам

Ленивый подход к приведенному выше примеру приведен ниже, где object mb_экземпляр создается при первом вызове showMessage(). (То есть до тех пор, пока это действительно не понадобится программе.)

публичный финальный класс MyFrame расширяет фрейм {private MessageBox mb_; // null, неявный // частный помощник, используемый этим классом private void showMessage (String message) {if (mb _ == null) // первый вызов этого метода mb_ = new MessageBox (); // устанавливаем текст сообщения mb_.setMessage (message); mb_.pack (); mb_.show (); }}

If you take a closer look at showMessage(), you'll see that we first determine whether the instance variable mb_ is equal to null. As we haven't initialized mb_ at its point of declaration, the Java runtime has taken care of this for us. Thus, we can safely proceed by creating the MessageBox instance. All future calls to showMessage() will find that mb_ is not equal to null, therefore skipping the creation of the object and using the existing instance.

A real-world example

Let's now examine a more realistic example, where lazy instantiation can play a key role in reducing the amount of resources used by a program.

Assume that we have been asked by a client to write a system that will let users catalog images on a filesystem and provide the facility to view either thumbnails or complete images. Our first attempt might be to write a class that loads the image in its constructor.

public class ImageFile { private String filename_; private Image image_; public ImageFile(String filename) { filename_=filename; //load the image } public String getName(){ return filename_;} public Image getImage() { return image_; } } 

In the example above, ImageFile implements an overeager approach to instantiating the Image object. In its favor, this design guarantees that an image will be available immediately at the time of a call to getImage(). However, not only could this be painfully slow (in the case of a directory containing many images), but this design could exhaust the available memory. To avoid these potential problems, we can trade the performance benefits of instantaneous access for reduced memory usage. As you may have guessed, we can achieve this by using lazy instantiation.

Here's the updated ImageFile class using the same approach as class MyFrame did with its MessageBox instance variable:

public class ImageFile { private String filename_; private Image image_; //=null, implicit public ImageFile(String filename) { //only store the filename filename_=filename; } public String getName(){ return filename_;} public Image getImage() { if(image_==null) { //first call to getImage() //load the image... } return image_; } } 

In this version, the actual image is loaded only on the first call to getImage(). So to recap, the trade-off here is that to reduce the overall memory usage and startup times, we pay the price for loading the image the first time it is requested -- introducing a performance hit at that point in the program's execution. This is another idiom that reflects the Proxy pattern in a context that requires a constrained use of memory.

The policy of lazy instantiation illustrated above is fine for our examples, but later on you'll see how the design has to alter in the context of multiple threads.

Lazy instantiation for Singleton patterns in Java

Let's now take a look at the Singleton pattern. Here's the generic form in Java:

public class Singleton { private Singleton() {} static private Singleton instance_ = new Singleton(); static public Singleton instance() { return instance_; } //public methods } 

In the generic version, we declared and initialized the instance_ field as follows:

static final Singleton instance_ = new Singleton(); 

Readers familiar with the C++ implementation of Singleton written by the GoF (the Gang of Four who wrote the book Design Patterns: Elements of Reusable Object-Oriented Software -- Gamma, Helm, Johnson, and Vlissides) may be surprised that we didn't defer the initialization of the instance_ field until the call to the instance() method. Thus, using lazy instantiation:

public static Singleton instance() { if(instance_==null) //Lazy instantiation instance_= new Singleton(); return instance_; } 

The listing above is a direct port of the C++ Singleton example given by the GoF, and frequently is touted as the generic Java version too. If you already are familiar with this form and were surprised that we didn't list our generic Singleton like this, you'll be even more surprised to learn that it is totally unnecessary in Java! This is a common example of what can occur if you port code from one language to another without considering the respective runtime environments.

For the record, the GoF's C++ version of Singleton uses lazy instantiation because there is no guarantee of the order of static initialization of objects at runtime. (See Scott Meyer's Singleton for an alternative approach in C++ .) In Java, we don't have to worry about these issues.

The lazy approach to instantiating a Singleton is unnecessary in Java because of the way in which the Java runtime handles class loading and static instance variable initialization. Previously, we have described how and when classes get loaded. A class with only public static methods gets loaded by the Java runtime on the first call to one of these methods; which in the case of our Singleton is

Singleton s=Singleton.instance(); 

The first call to Singleton.instance() in a program forces the Java runtime to load the class Singleton. As the field instance_ is declared as static, the Java runtime will initialize it after successfully loading the class. Thus guarantees that the call to Singleton.instance() will return a fully initialized Singleton -- get the picture?

Lazy instantiation: dangerous in multithreaded applications

Using lazy instantiation for a concrete Singleton is not only unnecessary in Java, it's downright dangerous in the context of multithreaded applications. Consider the lazy version of the Singleton.instance() method, where two or more separate threads are attempting to obtain a reference to the object via instance(). If one thread is preempted after successfully executing the line if(instance_==null), but before it has completed the line instance_=new Singleton(), another thread can also enter this method with instance_ still ==null -- nasty!

The outcome of this scenario is the likelihood that one or more Singleton objects will be created. This is a major headache when your Singleton class is, say, connecting to a database or remote server. The simple solution to this problem would be to use the synchronized key word to protect the method from multiple threads entering it at the same time:

synchronized static public instance() {...} 

However, this approach is a bit heavy-handed for most multithreaded applications using a Singleton class extensively, thereby causing blocking on concurrent calls to instance(). By the way, invoking a synchronized method is always much slower than invoking a nonsynchronized one. So what we need is a strategy for synchronization that doesn't cause unnecessary blocking. Fortunately, such a strategy exists. It is known as the double-check idiom.

The double-check idiom

Use the double-check idiom to protect methods using lazy instantiation. Here's how to implement it in Java:

public static Singleton instance() { if(instance_==null) //don't want to block here { //two or more threads might be here!!! synchronized(Singleton.class) { //must check again as one of the //blocked threads can still enter if(instance_==null) instance_= new Singleton();//safe } } return instance_; } 

The double-check idiom improves performance by using synchronization only if multiple threads call instance() before the Singleton is constructed. Once the object has been instantiated, instance_ is no longer ==null, allowing the method to avoid blocking concurrent callers.

Использование нескольких потоков в Java может быть очень сложным. Фактически, тема параллелизма настолько обширна, что Дуг Ли написал по ней целую книгу: « Параллельное программирование на Java». Если вы новичок в параллельном программировании, мы рекомендуем вам получить копию этой книги, прежде чем приступать к написанию сложных систем Java, основанных на нескольких потоках.