Java内存模型与延迟实例化-关于DCL

(本篇文章部分翻译,最后总结)

What is DCL?

什么是DCL

The DCL idiom was designed to support lazy initializationDCL是一种延迟实例化的编程技巧), which occurs when a class defers initialization of an owned object until it is actually needed:(也就是说当一个类中的对象被用到时才会被实例化),比如SomeClass这个类:

 

class SomeClass {

  private Resource resource = null;

  public Resource getResource() {

    if (resource == null)

      resource = new Resource();

    return resource;

  }

}

 

Why would you want to defer initialization? (为什么你想要推迟实例化?)

Perhaps creating a Resource is an expensive operation(也许是因为创建这个Resource实例是很昂贵的操作,比如内存消耗大或者费时很长等), and users of SomeClass might not actually call getResource() in any given run.(并且,这个实例不一定会被用到) In that case, you can avoid creating the Resource entirely.(所以,你没必要提前创建这个实例) Regardless, the SomeClass object can be created faster if it doesn't have to also create a Resource at construction time(总之,如果你不用在SomeClass类构造时初始化一个Resource实例的话,那肯定会快一点). Delaying some initialization operations until a user actually needs their results can help programs start up faster(延迟初始化某些操作,能让程序跑得更快一些).

 

What if you try to use SomeClass in a multithreaded application? Then a race condition results: two threads could simultaneously execute the test to see if resource is null and, as a result, initialize resource twice. In a multithreaded environment, you should declare getResource() to be synchronized.

当你在多线程环境下使用这个SomeClass时会怎样呢?有小概率会有两个线程在同一时间执行getResource方法,都去判断resource是否为null,结果实例化了两次resource。因此,你需要用 synchronized 关键字什么 getResource()方法。

 

Unfortunately, synchronized methods run much slower -- as much as 100 times slower -- than ordinary unsynchronized methods. One of the motivations for lazy initialization is efficiency, but it appears that in order to achieve faster program startup, you have to accept slower execution time once the program starts. That doesn't sound like a great trade-off.

不幸的是,用synchronized同步过的方法比普通方法要慢至少100倍。因此“延迟实例化”似乎是一个更有效的方法。

 

DCL purports to give us the best of both worlds. Using DCL, the getResource() method would look like this:DCL的写法如下)

 

class SomeClass {

  private Resource resource = null;

  public Resource getResource() {

    if (resource == null) {

      synchronized {

        if (resource == null) 

          resource = new Resource();

      }

    }

    return resource;

  }

}

 

After the first call to getResource(), resource is already initialized, which avoids the synchronization hit in the most common code path. DCL also averts the race condition by checking resource a second time inside the synchronized block; that ensures that only one thread will try to initialize resource. DCL seems like a clever optimization -- but it doesn't work.(只要在第一次调用getResource()方法时,resource就被初始化了,以后再调用就不会再执行里面的synchronized语句,因此,这貌似是一种很聪明的做法,但是实际上,这种写法并没有达到效果!)

 

-------------导读结束,以下部分翻译,关键之处我有注释-----------

 

Meet the Java Memory Model

 

More accurately, DCL is not guaranteed to work. To understand why, we need to look at the relationship between the JVM and the computer environment on which it runs. In particular, we need to look at the Java Memory Model (JMM), defined in Chapter 17 of the Java Language Specification, by Bill Joy, Guy Steele, James Gosling, and Gilad Bracha (Addison-Wesley, 2000), which details how Java handles the interaction between threads and memory.

为了明白DCL方法为什么有问题,我们来看看JVM和运行环境的关系,来看看Java内存模型。

 

 

Unlike most other languages, Java defines its relationship to the underlying hardware through a formal memory model that is expected to hold on all Java platforms【不像其他大多数语言,Java平台定义了针对底层硬件的内存模型以支持跨平台特性】enabling Java's promise of "Write Once, Run Anywhere." By comparison, other languages like C and C++ lack a formal memory model; in such languages, programs inherit the memory model of the hardware platform on which the program runs.

 

When running in a synchronous (single-threaded) environment, a program's interaction with memory is quite simple, or at least it appears so. Programs store items into memory locations and expect that they will still be there the next time those memory locations are examined.

 

Actually, the truth is quite different, but a complicated illusion maintained by the compiler, the JVM, and the hardware hides it from us. Though we think of programs as executing sequentially -- in the order specified by the program code -- that doesn't always happen. Compilers, processors, and caches are free to take all sorts of liberties with our programs and data, as long as they don't affect the result of the computation.【我们程序的执行顺序,与编译器、处理器、缓存的内部处理顺序,很可能是不一致的】 For example, compilers can generate instructions in a different order from the obvious interpretation the program suggests and store variables in registers instead of memory; processors may execute instructions in parallel or out of order; and caches may vary the order in which writes commit to main memory. The JMM says that all of these various reorderings and optimizations are acceptable, so long as the environment maintains as-if-serial semantics -- that is, so long as you achieve the same result as you would have if the instructions were executed in a strictly sequential environment.

 

Compilers, processors, and caches rearrange the sequence of program operations in order to achieve higher performance. In recent years, we've seen tremendous improvements in computing performance. While increased processor clock rates have contributed substantially to higher performance, increased parallelism (in the form of pipelined and superscalar execution unitsdynamic instruction scheduling and speculative execution, and sophisticated multilevel memory caches) has also been a major contributor. At the same time, the task of writing compilers has grown much more complicated, as the compiler must shield the programmer from these complexities.【随着CPU并行处理、多内存缓存等技术出现,使得编译器也更为复杂,因为编译器要屏蔽底层硬件的复杂性】

 

When writing single-threaded programs, you cannot see the effects of these various instruction or memory operation reorderings.【在单线程下面,你是看不出来指令和内存操作的变化所带来的影响的,但是在多线程下面情况就不同了】 However, with multithreaded programs, the situation is quite different -- one thread can read memory locations that another thread has written. If thread A modifies some variables in a certain order, in the absence of synchronization, thread B may not see them in the same order -- or may not see them at all, for that matter. That could result because the compiler reordered the instructions or temporarily stored a variable in a register and wrote it out to memory later; or because the processor executed the instructions in parallel or in a different order than the compiler specified; or because the instructions were in different regions of memory, and the cache updated the corresponding main memory locations in a different order than the one in which they were written. Whatever the circumstances, multithreaded programs are inherently less predictable, unless you explicitly ensure that threads have a consistent view of memory by using synchronization.

 

What does synchronized really mean?

 

Java treats each thread as if it runs on its own processor with its own local memory, each talking to and synchronizing with a shared main memory. Even on a single-processor system, that model makes sense because of the effects of memory caches and the use of processor registers to store variables. When a thread modifies a location in its local memory, that modification should eventually show up in the main memory as well, and the JMM defines the rules for when the JVM must transfer data between local and main memory. The Java architects realized that an overly restrictive memory model would seriously undermine program performance. They attempted to craft a memory model that would allow programs to perform well on modern computer hardware while still providing guarantees that would allow threads to interact in predictable ways.

 

Java's primary tool for rendering interactions between threads predictably is the synchronized keyword. Many programmers think of synchronized strictly in terms of enforcing a mutual exclusion semaphore (mutex) to prevent execution of critical sections by more than one thread at a time. Unfortunately, that intuition does not fully describe what synchronized means.

 

The semantics of synchronized do indeed include mutual exclusion of execution based on the status of a semaphore, but they also include rules about the synchronizing thread's interaction with main memory. In particular, the acquisition or release of a lock triggers a memory barrier -- a forced synchronization between the thread's local memory and main memory. (Some processors -- like the Alpha -- have explicit machine instructions for performing memory barriers.) When a thread exits a synchronized block, it performs a write barrier -- it must flush out any variables modified in that block to main memory before releasing the lock. Similarly, when entering a synchronized block, it performs a read barrier -- it is as if the local memory has been invalidated, and it must fetch any variables that will be referenced in the block from main memory.

 

The proper use of synchronization guarantees that one thread will see the effects of another in a predictable manner. Only when threads A and B synchronize on the same object will the JMM guarantee that thread B sees the changes made by thread A, and that changes made by thread A inside the synchronized block appear atomically to thread B (either the whole block executes or none of it does.) Furthermore, the JMM ensures that synchronized blocks that synchronize on the same object will appear to execute in the same order as they do in the program.

 

So what's broken about DCL?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

 

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

 

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

 

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.【在Java内存中,resource对象并没有实时同步,多线程操作的时间差会影响到程序的结果】

 

Volatile doesn't mean what you think, either

 

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.【注:在JDK4和更早以前,volatile仍然是无效的,直到JDK5以后,使用volatile可以解决该问题】

 

Alternatives to DCL

 

The most effective way to fix the DCL idiom is to avoid it. The simplest way to avoid it, of course, is to use synchronization. Whenever a variable written by one thread is being read by another, you should use synchronization to guarantee that modifications are visible to other threads in a predictable manner.

 

Another option for avoiding the problems with DCL is to drop lazy initialization and instead use eager initialization. Rather than delay initialization of resource until it is first used, initialize it at construction. The class loader, which synchronizes on the classes' Class object, executes static initializer blocks at class initialization time. That means that the effect of static initializers is automatically visible to all threads as soon as the class loads.

 

One special case of lazy initialization that does work as expected without synchronization is the static singleton. When the initialized object is a static field of a class with no other methods or fields, the JVM effectively performs lazy initialization automatically. In the following example, the Resource will not be constructed until the field resource is first referenced by another class, and any memory writes that result from resource's initialization are automatically visible to all threads:

 

class MySingleton {

  public static Resource resource = new Resource();

}

 

The initialization will be performed when the JVM initializes the class. Since MySingleton has no other fields or methods, class initialization occurs when the resource field is first referenced.【使用“内部类”可以达到目的,JVM的机制保障它能有效避免多线程环境下不正确的初始化问题】

 

DCL also works with 32-bit primitive values. If the resource field in SomeClass were an integer (but not a long or a double), then SomeClass would behave as expected. However, you cannot use this behavior to fix the problems with DCL when you want to lazily initialize an object reference or more than one primitive value.

 

Conclusion

总结

 

对于延迟实例化,有三种写法是可取的:

 

第一种:利用volatile

class Foo { 
  private volatile Helper helper = null;
  public Helper getHelper() {
    if (helper == null) {
      synchronized(this) {
        if (helper == null) 
           helper = new Helper();
        } 
      }    
    return helper;
    }
  }

要求JDK 1.5+

 

第二种:利用ThreadLocal

class Foo {
         private final ThreadLocal perThreadInstance = new ThreadLocal();
         private Helper helper = null;
         public Helper getHelper() {
             if (perThreadInstance.get() == null) createHelper();
             return helper;
         }
         private final void createHelper() {
             synchronized(this) {
                 if (helper == null)
                     helper = new Helper();
             }
             // Any non-null value would do as the argument here
             perThreadInstance.set(perThreadInstance);
         }
}

要求 JDK 1.4+,否则ThreadLocal效率不高

 

 第三种:内部类

 private static class LazySomethingHolder {

   public static Something something = new Something();

}

 public static Something getInstance() {

   return LazySomethingHolder.something;

}


我的建议是用第一种,加volatile,比较简洁。

当然,第三种在单例模式里面也常见,安全性有保障。


© 2009-2020 Zollty.com 版权所有。渝ICP备20008982号