Problema de bloqueo de verificación doble en modo Singleton

El patrón de creación singleton es un lenguaje de programación común. Cuando se usa con varios subprocesos, se requiere algún tipo de sincronización. En un esfuerzo por crear un código más eficiente, los programadores de Java crearon el lenguaje de bloqueo de verificación doble para usar con el patrón de creación de singleton para limitar la cantidad de código de sincronización. Sin embargo, debido a algunos detalles menos comunes del modelo de memoria de Java, no hay garantía de que este modismo de bloqueo verificado dos veces funcione.

Falla de vez en cuando, no siempre. Además, falla por razones no obvias y contiene algunos detalles crípticos del modelo de memoria de Java. Estos hechos harán que el código falle porque el bloqueo verificado dos veces es difícil de rastrear. En el resto de este artículo, detallaremos el idioma de bloqueo verificado dos veces para comprender dónde falla.


Para comprender dónde se originó el idioma de bloqueo de verificación doble, uno debe comprender el idioma común de creación de singleton, como se ilustra en el Listado 1:


Listado 1. El modismo de creación de singleton

copiar codigo
importar java.util.*; 
class Singleton 
{ 
  instancia privada estática de Singleton; 
  Vector privado v; 
  uso booleano privado; 

  singleton privado () 
  { 
    v = nuevo vector (); 
    v.addElement(nuevo Objeto()); 
    enUso = verdadero; 
  } 

  public static Singleton getInstance() 
  { 
    if (instancia == nulo) //1 
      instancia = new Singleton(); //2 
    instancia de retorno; //3 
  } 
}
copiar codigo

 

El diseño de esta clase asegura que solo  Singleton se crea un objeto. Los constructores se declaran como  private, getInstance() los métodos simplemente crean un objeto. Esta implementación es adecuada para programas de un solo subproceso. Sin embargo, cuando se introducen subprocesos múltiples,  getInstance() los métodos deben estar protegidos por sincronización. Si el método no está protegido  getInstance() , Singleton se pueden devolver dos instancias diferentes del objeto. Supongamos que dos subprocesos llaman a  getInstance() métodos al mismo tiempo y las llamadas se realizan en el siguiente orden:

  1. El subproceso 1 llama  getInstance() al método y decide  instance estar en //1  null

  2. El subproceso 1 ingresó  if al bloque de código, pero el subproceso 2 lo adelantó mientras ejecutaba la línea de código en //2. 

  3. El subproceso 2 llama  al método y  decide  getInstance() en //1  instancenull

  4. El subproceso 2 ingresa  if al bloque de código y crea un nuevo  Singleton objeto y asigna la variable a este nuevo objeto en //2  instance . 

  5. El subproceso 2 devuelve la referencia del objeto en //3  Singleton .

  6. El subproceso 2 es reemplazado por el subproceso 1. 

  7. El subproceso 1 comienza donde lo dejó y ejecuta la línea de código //2, lo que hace que  Singleton se cree otro objeto. 

  8. El subproceso 1 devuelve este objeto en //3.

El resultado es que  getInstance() el método crea dos  Singleton objetos cuando debería haber creado solo uno. Este problema se corrige sincronizando  getInstance() métodos para que solo un subproceso pueda ejecutar código a la vez, como se muestra en el Listado 2:


Listado 2. Método getInstance() seguro para subprocesos

singleton sincronizado estático público getInstance() 
{ 
  if (instancia == nulo) //1 
    instancia = new Singleton(); //2 
  instancia de retorno; //3 
}

 

getInstance() El código del Listado 2 funciona bien para los métodos de acceso multiproceso  . Sin embargo, al analizar este código, se da cuenta de que la sincronización solo es necesaria la primera vez que se llama al método. Dado que solo la primera llamada ejecuta el código en //2, y solo esta línea de código debe sincronizarse, no es necesario usar la sincronización en las llamadas posteriores. Todas las demás llamadas se utilizan para determinar  instance true y false  null y devolverlo. Múltiples subprocesos pueden ejecutar de forma segura todas las llamadas al mismo tiempo, excepto la primera. Sin embargo, dado que este método es un método synchronized , debe pagar el precio de la sincronización por cada llamada de este método, incluso si solo necesita sincronizar la primera llamada.

Para hacer que este enfoque sea más eficiente, se desarrolló un modismo llamado bloqueo de doble verificación . La idea es evitar el alto costo de sincronizar todas menos la primera llamada. El costo de la sincronización varía entre diferentes JVM. En los primeros días, el precio era bastante alto. Con la llegada de JVM más avanzadas, el costo de sincronización ha disminuido, pero synchronized aún existe una penalización de rendimiento por ingresar y salir de métodos o bloques. Independientemente de los avances en la tecnología JVM, los programadores nunca quieren perder tiempo de procesamiento innecesariamente.

Dado que solo la línea //2 en el Listado 2 necesita sincronizarse, podemos envolverla en un bloque sincronizado, como se muestra en el Listado 3:


Listado 3. Método getInstance()

copiar codigo
Singleton estático público getInstance() 
{ 
  if (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { 
      instancia = new Singleton(); 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

El código del Listado 3 demuestra el mismo problema que el Listado 1, ilustrado con subprocesos múltiples. Cuando  instance es verdadero null , dos subprocesos pueden ingresar  if la instrucción al mismo tiempo. Luego, un subproceso ingresa  synchronized al bloque para inicializar  instancemientras el otro subproceso está bloqueado. Cuando el primer hilo sale  synchronized del bloque, el hilo en espera entra y crea otro  Singleton objeto. Nota: cuando el segundo subproceso ingresa  synchronized al bloque, no verifica  instance la negación  null.

 

bloqueo de doble control

Para solucionar el problema del Listado 3, necesitamos hacer  instance una segunda verificación de . De ahí viene el nombre de "bloqueo de doble control". El Listado 4 es el resultado de aplicar el idioma de bloqueo verificado dos veces al Listado 3.


Listado 4. Ejemplo de bloqueo verificado dos veces

copiar codigo
singleton estático público getInstance () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      si (instancia == nulo) // 2 
        instancia = nuevo Singleton (); //3 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

La teoría detrás del bloqueo de verificación doble es que la segunda verificación en //2 hace que sea imposible (como en el Listado 3) crear dos  Singleton objetos diferentes. Suponga la siguiente secuencia de eventos:

  1. El subproceso 1 entra en  getInstance() el método. 

  2. El subproceso 1 ingresa al bloque en //1  debido  instance a   . nullsynchronized

  3. El subproceso 1 es sustituido por el subproceso 2.

  4. El subproceso 2 entra en  getInstance() el método.

  5. Dado  instance que todavía es  null, el subproceso 2 intenta adquirir el bloqueo en //1. Sin embargo, subproceso 2 bloques en //1 porque el subproceso 1 mantiene el bloqueo.

  6. El subproceso 2 es reemplazado por el subproceso 1.

  7. El subproceso 1 se ejecuta y, dado que la instancia aún está en //2  null, el subproceso 1 también crea un  Singleton objeto y asigna su referencia a  instance.

  8. El subproceso 1 sale  synchronized del bloque y  getInstance() devuelve la instancia del método. 

  9. El subproceso 1 es sustituido por el subproceso 2.

  10. El subproceso 2 adquiere el bloqueo en //1 y comprueba  instance si es  null

  11. Dado que  instance no  null , el segundo  Singleton objeto no se crea y se devuelve el objeto creado por el subproceso 1.

La teoría detrás del bloqueo de doble verificación es perfecta. Desafortunadamente, la realidad es bastante diferente. El problema con el bloqueo de verificación doble es que no hay garantía de que funcione sin problemas en una computadora monoprocesador o multiprocesador.

La falla del bloqueo de verificación doble no se debe a un error de implementación en la JVM, sino al modelo de memoria de la plataforma Java. El modelo de memoria permite las llamadas "escrituras fuera de orden", y esta es una de las principales razones por las que estos modismos fallan.

 

escribir fuera de orden

Para explicar esto, se debe revisar la línea //3 en el Listado 4 anterior. Esta línea de código crea un  Singleton objeto e inicializa las variables  instance para hacer referencia a este objeto. El problema con esta línea de código es que  la variable  puede ser negada  Singleton antes de que se ejecute el cuerpo del constructor   .instancenull

¿Qué? Esta afirmación puede sorprenderte, pero es cierta. Antes de explicar cómo ocurre este fenómeno, acepte temporalmente este hecho, primero examinemos cómo se rompe el bloqueo de verificación doble. Suponga que el código del Listado 4 ejecuta la siguiente secuencia de eventos:

  1. El subproceso 1 entra en  getInstance() el método.

  2. El subproceso 1 ingresa al bloque en //1  debido  instance a   . nullsynchronized

  3. El subproceso 1 avanza a //3, pero antes de que se ejecute el constructor , niega la instancia  null

  4. El subproceso 1 es sustituido por el subproceso 2.

  5. El subproceso 2 comprueba si la instancia es  null. Debido a que la instancia no es nula, el subproceso 2  instance devuelve una referencia a un  Singletonobjeto completamente construido pero parcialmente inicializado. 

  6. El subproceso 2 es reemplazado por el subproceso 1.

  7. El subproceso 1 completa la inicialización del objeto ejecutando  Singleton el constructor del objeto y devolviéndole una referencia.

Esta secuencia de eventos ocurre cuando el subproceso 2 devuelve un objeto cuyo constructor aún no se ha ejecutado.

Para mostrar que esto sucede, suponga  instance =new Singleton(); que se ejecuta el siguiente pseudocódigo para la línea de código: instancia =nuevo Singleton();

mem = asignar (); //Asignar memoria para el objeto Singleton. 
instancia = memoria; //Tenga en cuenta que la instancia ahora no es nula, pero 
                              //no se ha inicializado. 
ctorSingleton(instancia); //Invocar constructor para Singleton pasando 
                              //instancia.

 

Este pseudocódigo no solo es posible, sino que en realidad ocurre con algunos compiladores JIT. El orden de ejecución se invierte, pero dado el modelo de memoria actual, se permite que esto suceda. Este comportamiento del compilador JIT hace que el problema del bloqueo con verificación doble no sea más que un ejercicio académico.

Para ilustrar esto, asuma el código del Listado 5. Contiene una versión simplificada del  getInstance() método. Eliminé la "doble verificación" para simplificar nuestra revisión del código ensamblador generado (Listado 6). Solo nos importa cómo compila  instance=new Singleton(); el código el compilador JIT. Además, proporciono un constructor simple para ilustrar explícitamente cómo funciona ese constructor en código ensamblador.


Listado 5. Clase Singleton para demostrar escrituras desordenadas

copiar codigo
class Singleton 
{ 
  instancia privada estática de Singleton; 
  uso booleano privado; 
  valor interno privado;  

  singleton privado () 
  { 
    en uso = verdadero; 
    valor = 5; 
  } 
  public static Singleton getInstance() 
  { 
    if (instancia == nulo) 
      instancia = new Singleton(); 
    instancia de retorno; 
  } 
}
copiar codigo

 

getInstance() El Listado 6 contiene el código ensamblador generado por el compilador Sun JDK 1.2.1 JIT para el cuerpo del método del Listado 5  .


Listado 6. Código ensamblador generado a partir del código del Listado 5

copiar codigo
;código asm generado para getInstance 
054D20B0 mov eax,[049388C8] ;carga instancia ref 
054D20B5 test eax,eax ;test for null 
054D20B7 jne 054D20D7 
054D20B9 mov eax,14C0988h 
054D20BE call 503EF8F0 ; asignar memoria 
054D20C3 mov [049388C8],eax; almacenar puntero en 
                                           ;ejemplo ref. instancia   
                                           ;non-null y ctor 
                                           ;no se ha ejecutado 
054D20C8 mov ecx,dword ptr [eax] 
054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true;
054D20D0 mov dword ptr [ecx+4],5 ;inline ctor - val=5;
054D20D7 mov ebx,dword ptr ds:[49388C8h] 
054D20DD jmp 054D20B0
copiar codigo

 

NOTA:  Para referirme a las líneas de código ensamblador en las siguientes instrucciones, me referiré a los dos últimos valores de la dirección de instrucción ya que ambos comienzan con  054D20 . Por ejemplo, B5 rep  test eax,eax.

El código ensamblador se genera ejecutando un programa de pruebagetInstance() que llama  a métodos en un bucle infinito  . Mientras se ejecuta el programa, ejecute el depurador de Microsoft Visual C++ y adjúntelo al proceso de Java que representa el programa de prueba. Luego, salga de la ejecución y encuentre el código ensamblador que representa ese bucle infinito.

B0B5 Las dos primeras líneas de código ensamblador   en y   cargan  la instance referencia desde la ubicación de la memoria   y   la verifican.  Esto corresponde a la primera línea de código en el método del Listado 5 . La primera vez que se llama a este método, el código se ejecuta   hasta  .  El código en   asigna memoria para el objeto del montón y almacena un puntero a ese bloque de memoria en él   . La siguiente línea de código, toma   el puntero y lo almacena en la memoria en  la referencia de la instancia. El resultado es que  ahora NO es   y hace referencia a un   objeto válido. Sin embargo, el constructor de este objeto aún no se ha ejecutado, que es exactamente lo que rompe el bloqueo de verificación doble. Luego,  en la línea,  el puntero se desreferencia y se almacena en  .  Las   líneas y representan constructores en línea que almacenan valores   y   almacenan   objetos. Si este código   es interrumpido por otro subproceso después de la línea de ejecución y antes de que se complete el constructor, el bloqueo verificado fallará.049388C8eaxnullgetInstance()instancenullB9BESingletoneaxC3eax049388C8instancenullSingletonC8instanceecxCAD0true5SingletonC3

No todos los compiladores JIT generan el código anterior. Algunos generan código tal que se niega solo después de que  instance se ejecuta  el constructor null. La versión 1.3 de IBM SDK para tecnología Java y Sun JDK 1.3 generan dicho código. Sin embargo, esto no significa que en estos casos se deba utilizar el bloqueo de verificación doble. Hay algunas otras razones por las que este modismo falla. Además, no siempre sabe en qué JVM se ejecutará su código, y los compiladores JIT siempre pueden cambiar para generar código que rompa este modismo.

 

Bloqueo doble comprobado: adquiere dos

Dado que el bloqueo verificado dos veces actual no funciona, incluí otra versión del código, que se muestra en el Listado 7, para evitar el problema de escritura desordenada que acaba de ver.


Listado 7. Intento de resolver el problema de escritura desordenada

copiar codigo
getInstance de Singleton estático público () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      Singleton inst = instancia; //2 
      if (inst == null) 
      { 
        sincronizado(Singleton.class) { //3 
          inst = new Singleton(); //4 
        } 
        instancia = inst; //5 
      } 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

Mirando el código en el Listado 7, debe darse cuenta de que las cosas se están poniendo un poco ridículas. getInstance() Recuerde que el bloqueo de doble verificación se creó para evitar la sincronización en métodos simples de tres líneas  . El código del Listado 7 se vuelve rebelde. Además, ese código no resuelve el problema. Una inspección cuidadosa revelará la razón.

Este código intenta evitar el problema de escritura desordenada.  Intenta solucionar esto introduciendo variables locales  inst y un segundo  bloque. synchronizedLa teoría se implementa de la siguiente manera:

  1. El subproceso 1 entra en  getInstance() el método.

  2. El subproceso 1 ingresa al primer bloque en //1  debido  instance a   . nullsynchronized

  3. inst El valor que obtiene  la variable local  instance , que está en //2  null

  4. Debido  inst a  null, el subproceso 1 ingresa al segundo  synchronized bloque en //3. 

  5. El subproceso 1 luego comienza a ejecutar el código en //4 mientras se niega  inst ,  nullpero  Singleton antes de que se ejecute el constructor de //4. (Este es el problema de escritura fuera de orden que acabamos de ver). 

  6. El subproceso 1 es sustituido por el subproceso 2.

  7. El subproceso 2 entra en  getInstance() el método.

  8. Debido  instance a  null, el subproceso 2 intenta ingresar al primer  synchronized bloque en //1. Dado que el subproceso 1 actualmente tiene el bloqueo, el subproceso 2 está bloqueado.

  9. El subproceso 1 luego completa la ejecución en //4.

  10. El subproceso 1 luego asigna un  Singleton objeto completamente construido a la variable en //5  instancey sale de ambos  synchronized bloques. 

  11. Vuelve el hilo 1  instance.

  12. instance Luego ejecute el subproceso 2 y asigne a  at //2  inst.

  13. El subproceso 2 descubre  instance que no lo es  nully lo devuelve.

La línea clave aquí es //5. Esta línea debe garantizar  instance que solo  se construya o se haga referencia a null un objeto completo  Singleton . El problema surge cuando la teoría y la práctica se contraponen.

El código del Listado 7 no es válido debido a la definición del modelo de memoria actual. La especificación del lenguaje Java ( JLS) exige  synchronizedque el código dentro de un bloque no se pueda mover. synchronized Sin embargo, no dice que el código fuera del bloque  no se pueda  mover synchronized al bloque.

El compilador JIT verá aquí una oportunidad de optimización. Esta optimización elimina el código en //4 y //5, combinando y generando el código que se muestra en el Listado 8.


Listado 8. El código optimizado del Listado 7.

copiar codigo
getInstance de Singleton estático público () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      Singleton inst = instancia; //2 
      if (inst == null) 
      { 
        sincronizado(Singleton.class) { //3 
          //inst = new Singleton(); //4 
          instancia = nuevo Singleton();               
        } 
        //instancia = inst; //5 
      } 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

Si realiza esta optimización, tendrá el mismo problema de escritura desordenada que discutimos anteriormente.

 

¿Qué hay de declarar cada variable volátil?

Otra idea es apuntar a variables  inst y  instance usar palabras clave  volatile. De acuerdo con el JLS (ver Temas relacionados),  volatile las variables declaradas se consideran coherentes secuencialmente, es decir, no reordenadas. Pero tratando de  volatile solucionar el problema del bloqueo verificado dos veces con los siguientes dos problemas:

  • El problema aquí no es sobre la coherencia secuencial, sino que el código se movió, no se reordenó.

  • Incluso cuando se considera la consistencia secuencial, la mayoría de las JVM no la implementan correctamente  volatile.

El segundo punto merece mayor discusión. Asuma el código en el Listado 9:


Listado 9. Consistencia secuencial usando volatile

copiar codigo
prueba de clase 
{ 
  parada booleana volátil privada = falso; 
  privado volátil int num = 0; 

  public void foo() 
  { 
    num = 100; //Esto puede pasar segundo 
    stop = true; //Esto puede suceder primero 
    //... 
  } 

  public void bar() 
  { 
    if (stop) 
      num += num; //num puede == 0! 
  } 
  //... 
}
copiar codigo

 

Según el JLS, dado que  stop y  num se declaran como  volatile, deben ser secuencialmente coherentes. Esto significa que si  stop alguna vez lo fue  true, num debe haberse configurado en  100. Sin embargo, debido a las funciones de coherencia secuencial que muchas JVM no implementan  volatile , no puede confiar en este comportamiento. Por lo tanto, si el subproceso 1 llama  foo y el subproceso 2 llama al mismo tiempo  bar, el subproceso 1 puede   establecerse en   antes  num de establecerse en  . Esto hará que el subproceso vea   sí  mientras   aún está configurado en  .  Hay problemas adicionales con el uso  de números atómicos con variables de 64 bits, pero están fuera del alcance de este artículo. Consulte Recursos para obtener más información sobre este tema.100stoptruestoptruenum0volatile

 

solución

La conclusión es esta: el bloqueo de doble verificación no debe usarse de ninguna forma, porque no puede garantizar que funcionará sin problemas en cualquier implementación de JVM. JSR-133 trata sobre el modelo de memoria que aborda problemas, sin embargo, el nuevo modelo de memoria no admitirá el bloqueo de verificación doble. Por lo tanto, tienes dos opciones:

  • Acepte el método que se muestra en el Listado 2  getInstance() para la sincronización.

  • Abandone la sincronización y utilice un  static campo en su lugar.

La opción 2 se muestra en el Listado 10


Listado 10. Implementación de singleton usando campos estáticos

copiar codigo
clase Singleton 
{ 
  vector privado v; 
  uso booleano privado; 
  instancia de Singleton estática privada = new Singleton(); 

  singleton privado () 
  { 
    v = nuevo vector (); 
    enUso = verdadero; 
    //... 
  } 

  public static Singleton getInstance() 
  { 
    instancia de retorno; 
  } 
}
copiar codigo

 

El código del Listado 10 no utiliza sincronización y garantiza que  static getInstance() el método no se crea hasta que se  llama Singleton. Esta es una excelente opción si su objetivo es eliminar la sincronización.

 

La cadena no es inmutable

Dado el problema de las escrituras fuera de orden y las referencias que se niegan antes de que se ejecute el constructor  null , podría considerar  String las clases. Supongamos que tiene el siguiente código:

cadena privada str; 
//... 
str = new String("hola");

 

String Las clases deben ser inmutables. Aún así, dado el problema de escritura fuera de servicio que discutimos anteriormente, ¿causaría eso un problema aquí? La respuesta es sí. Considere dos accesos a subprocesos String str. Un subproceso puede ver  str una referencia a un  String objeto en el que el constructor aún no se ha ejecutado. De hecho, el Listado 11 contiene código que muestra que esto sucede. Tenga en cuenta que este código solo falla en la JVM anterior con la que lo probé. Tanto IBM 1.3 como Sun 1.3 JVM generan sin cambios como se esperaba  String.


Listado 11. Ejemplo de cadena mutable

copiar codigo
class StringCreator extiende Thread 
{ 
  MutableString ms; 
  public StringCreator(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
      ms.str = new String("hola"); //1 
  } 
} 
class StringReader extiende Thread 
{ 
  MutableString ms; 
  StringReader público (MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
    { 
      if (!(ms.str.equals("hello"))) //2 
      {
      } 
    } 
        System.out.println("¡La cadena no es inmutable!");
        romper; 
  } 
} 
clase MutableString 
{ 
  public String str; //3 
  public static void main(String args[]) 
  { 
    MutableString ms = new MutableString(); //4 
    nuevo StringCreator(ms).start(); //5 
    nuevo StringReader(ms).start(); //6 
  } 
}
copiar codigo

 

Este código crea una  MutableString clase en //4 que contiene una  String referencia compartida por los dos subprocesos en //3. StringCreator En las líneas //5 y //6, dos objetos y  se crean en dos subprocesos separados  StringReader. Pasar una referencia a un  MutableString objeto. StringCreator La clase entra en un bucle infinito y crea  String el objeto en //1 con el valor "hola". StringReader También ingresa un ciclo infinito y verifica en //2  String si el valor del objeto actual es "hola". Si no, StringReader el hilo imprime un mensaje y se detiene. Si  String la clase es inmutable, no debería ver ningún resultado de este programa. Si ocurre un problema de escritura desordenada, la única forma de  StringReader ver  str una referencia nunca es  String un objeto con un valor de "hola".

La ejecución de este código en una JVM anterior, como Sun JDK 1.2.1, provocará problemas de escritura fuera de servicio. y por lo tanto dar como resultado una no invariante  String.

 

conclusión

Para evitar la costosa sincronización en singletons, los programadores fueron muy inteligentes e inventaron el lenguaje de bloqueo de doble verificación. Desafortunadamente, dado el modelo de memoria actual, este idioma aún no se ha utilizado ampliamente y es claramente una construcción de programación insegura. Se está trabajando en esta área de redefinición del modelo de memoria frágil. Aún así, incluso en el modelo de memoria recientemente propuesto, el bloqueo verificado dos veces es ineficaz. La mejor solución a este problema es aceptar la sincronización o usar una  static field.

 

Referencias

El patrón de creación singleton es un lenguaje de programación común. Cuando se usa con varios subprocesos, se requiere algún tipo de sincronización. En un esfuerzo por crear un código más eficiente, los programadores de Java crearon el lenguaje de bloqueo de verificación doble para usar con el patrón de creación de singleton para limitar la cantidad de código de sincronización. Sin embargo, debido a algunos detalles menos comunes del modelo de memoria de Java, no hay garantía de que este modismo de bloqueo verificado dos veces funcione.

Falla de vez en cuando, no siempre. Además, falla por razones no obvias y contiene algunos detalles crípticos del modelo de memoria de Java. Estos hechos harán que el código falle porque el bloqueo verificado dos veces es difícil de rastrear. En el resto de este artículo, detallaremos el idioma de bloqueo verificado dos veces para comprender dónde falla.


Para comprender dónde se originó el idioma de bloqueo de verificación doble, uno debe comprender el idioma común de creación de singleton, como se ilustra en el Listado 1:


Listado 1. El modismo de creación de singleton

copiar codigo
importar java.util.*; 
class Singleton 
{ 
  instancia privada estática de Singleton; 
  Vector privado v; 
  uso booleano privado; 

  singleton privado () 
  { 
    v = nuevo vector (); 
    v.addElement(nuevo Objeto()); 
    enUso = verdadero; 
  } 

  public static Singleton getInstance() 
  { 
    if (instancia == nulo) //1 
      instancia = new Singleton(); //2 
    instancia de retorno; //3 
  } 
}
copiar codigo

 

El diseño de esta clase asegura que solo  Singleton se crea un objeto. Los constructores se declaran como  private, getInstance() los métodos simplemente crean un objeto. Esta implementación es adecuada para programas de un solo subproceso. Sin embargo, cuando se introducen subprocesos múltiples,  getInstance() los métodos deben estar protegidos por sincronización. Si el método no está protegido  getInstance() , Singleton se pueden devolver dos instancias diferentes del objeto. Supongamos que dos subprocesos llaman a  getInstance() métodos al mismo tiempo y las llamadas se realizan en el siguiente orden:

  1. El subproceso 1 llama  getInstance() al método y decide  instance estar en //1  null

  2. El subproceso 1 ingresó  if al bloque de código, pero el subproceso 2 lo adelantó mientras ejecutaba la línea de código en //2. 

  3. El subproceso 2 llama  al método y  decide  getInstance() en //1  instancenull

  4. El subproceso 2 ingresa  if al bloque de código y crea un nuevo  Singleton objeto y asigna la variable a este nuevo objeto en //2  instance . 

  5. El subproceso 2 devuelve la referencia del objeto en //3  Singleton .

  6. El subproceso 2 es reemplazado por el subproceso 1. 

  7. El subproceso 1 comienza donde lo dejó y ejecuta la línea de código //2, lo que hace que  Singleton se cree otro objeto. 

  8. El subproceso 1 devuelve este objeto en //3.

El resultado es que  getInstance() el método crea dos  Singleton objetos cuando debería haber creado solo uno. Este problema se corrige sincronizando  getInstance() métodos para que solo un subproceso pueda ejecutar código a la vez, como se muestra en el Listado 2:


Listado 2. Método getInstance() seguro para subprocesos

singleton sincronizado estático público getInstance() 
{ 
  if (instancia == nulo) //1 
    instancia = new Singleton(); //2 
  instancia de retorno; //3 
}

 

getInstance() El código del Listado 2 funciona bien para los métodos de acceso multiproceso  . Sin embargo, al analizar este código, se da cuenta de que la sincronización solo es necesaria la primera vez que se llama al método. Dado que solo la primera llamada ejecuta el código en //2, y solo esta línea de código debe sincronizarse, no es necesario usar la sincronización en las llamadas posteriores. Todas las demás llamadas se utilizan para determinar  instance true y false  null y devolverlo. Múltiples subprocesos pueden ejecutar de forma segura todas las llamadas al mismo tiempo, excepto la primera. Sin embargo, dado que este método es un método synchronized , debe pagar el precio de la sincronización por cada llamada de este método, incluso si solo necesita sincronizar la primera llamada.

Para hacer que este enfoque sea más eficiente, se desarrolló un modismo llamado bloqueo de doble verificación . La idea es evitar el alto costo de sincronizar todas menos la primera llamada. El costo de la sincronización varía entre diferentes JVM. En los primeros días, el precio era bastante alto. Con la llegada de JVM más avanzadas, el costo de sincronización ha disminuido, pero synchronized aún existe una penalización de rendimiento por ingresar y salir de métodos o bloques. Independientemente de los avances en la tecnología JVM, los programadores nunca quieren perder tiempo de procesamiento innecesariamente.

Dado que solo la línea //2 en el Listado 2 necesita sincronizarse, podemos envolverla en un bloque sincronizado, como se muestra en el Listado 3:


Listado 3. Método getInstance()

copiar codigo
Singleton estático público getInstance() 
{ 
  if (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { 
      instancia = new Singleton(); 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

El código del Listado 3 demuestra el mismo problema que el Listado 1, ilustrado con subprocesos múltiples. Cuando  instance es verdadero null , dos subprocesos pueden ingresar  if la instrucción al mismo tiempo. Luego, un subproceso ingresa  synchronized al bloque para inicializar  instancemientras el otro subproceso está bloqueado. Cuando el primer hilo sale  synchronized del bloque, el hilo en espera entra y crea otro  Singleton objeto. Nota: cuando el segundo subproceso ingresa  synchronized al bloque, no verifica  instance la negación  null.

 

bloqueo de doble control

Para solucionar el problema del Listado 3, necesitamos hacer  instance una segunda verificación de . De ahí viene el nombre de "bloqueo de doble control". El Listado 4 es el resultado de aplicar el idioma de bloqueo verificado dos veces al Listado 3.


Listado 4. Ejemplo de bloqueo verificado dos veces

copiar codigo
singleton estático público getInstance () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      si (instancia == nulo) // 2 
        instancia = nuevo Singleton (); //3 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

La teoría detrás del bloqueo de verificación doble es que la segunda verificación en //2 hace que sea imposible (como en el Listado 3) crear dos  Singleton objetos diferentes. Suponga la siguiente secuencia de eventos:

  1. El subproceso 1 entra en  getInstance() el método. 

  2. El subproceso 1 ingresa al bloque en //1  debido  instance a   . nullsynchronized

  3. El subproceso 1 es sustituido por el subproceso 2.

  4. El subproceso 2 entra en  getInstance() el método.

  5. Dado  instance que todavía es  null, el subproceso 2 intenta adquirir el bloqueo en //1. Sin embargo, subproceso 2 bloques en //1 porque el subproceso 1 mantiene el bloqueo.

  6. El subproceso 2 es reemplazado por el subproceso 1.

  7. El subproceso 1 se ejecuta y, dado que la instancia aún está en //2  null, el subproceso 1 también crea un  Singleton objeto y asigna su referencia a  instance.

  8. El subproceso 1 sale  synchronized del bloque y  getInstance() devuelve la instancia del método. 

  9. El subproceso 1 es sustituido por el subproceso 2.

  10. El subproceso 2 adquiere el bloqueo en //1 y comprueba  instance si es  null

  11. Dado que  instance no  null , el segundo  Singleton objeto no se crea y se devuelve el objeto creado por el subproceso 1.

La teoría detrás del bloqueo de doble verificación es perfecta. Desafortunadamente, la realidad es bastante diferente. El problema con el bloqueo de verificación doble es que no hay garantía de que funcione sin problemas en una computadora monoprocesador o multiprocesador.

La falla del bloqueo de verificación doble no se debe a un error de implementación en la JVM, sino al modelo de memoria de la plataforma Java. El modelo de memoria permite las llamadas "escrituras fuera de orden", y esta es una de las principales razones por las que estos modismos fallan.

 

escribir fuera de orden

Para explicar esto, se debe revisar la línea //3 en el Listado 4 anterior. Esta línea de código crea un  Singleton objeto e inicializa las variables  instance para hacer referencia a este objeto. El problema con esta línea de código es que  la variable  puede ser negada  Singleton antes de que se ejecute el cuerpo del constructor   .instancenull

¿Qué? Esta afirmación puede sorprenderte, pero es cierta. Antes de explicar cómo ocurre este fenómeno, acepte temporalmente este hecho, primero examinemos cómo se rompe el bloqueo de verificación doble. Suponga que el código del Listado 4 ejecuta la siguiente secuencia de eventos:

  1. El subproceso 1 entra en  getInstance() el método.

  2. El subproceso 1 ingresa al bloque en //1  debido  instance a   . nullsynchronized

  3. El subproceso 1 avanza a //3, pero antes de que se ejecute el constructor , niega la instancia  null

  4. El subproceso 1 es sustituido por el subproceso 2.

  5. El subproceso 2 comprueba si la instancia es  null. Debido a que la instancia no es nula, el subproceso 2  instance devuelve una referencia a un  Singletonobjeto completamente construido pero parcialmente inicializado. 

  6. El subproceso 2 es reemplazado por el subproceso 1.

  7. El subproceso 1 completa la inicialización del objeto ejecutando  Singleton el constructor del objeto y devolviéndole una referencia.

Esta secuencia de eventos ocurre cuando el subproceso 2 devuelve un objeto cuyo constructor aún no se ha ejecutado.

Para mostrar que esto sucede, suponga  instance =new Singleton(); que se ejecuta el siguiente pseudocódigo para la línea de código: instancia =nuevo Singleton();

mem = asignar (); //Asignar memoria para el objeto Singleton. 
instancia = memoria; //Tenga en cuenta que la instancia ahora no es nula, pero 
                              //no se ha inicializado. 
ctorSingleton(instancia); //Invocar constructor para Singleton pasando 
                              //instancia.

 

Este pseudocódigo no solo es posible, sino que en realidad ocurre con algunos compiladores JIT. El orden de ejecución se invierte, pero dado el modelo de memoria actual, se permite que esto suceda. Este comportamiento del compilador JIT hace que el problema del bloqueo con verificación doble no sea más que un ejercicio académico.

Para ilustrar esto, asuma el código del Listado 5. Contiene una versión simplificada del  getInstance() método. Eliminé la "doble verificación" para simplificar nuestra revisión del código ensamblador generado (Listado 6). Solo nos importa cómo compila  instance=new Singleton(); el código el compilador JIT. Además, proporciono un constructor simple para ilustrar explícitamente cómo funciona ese constructor en código ensamblador.


Listado 5. Clase Singleton para demostrar escrituras desordenadas

copiar codigo
class Singleton 
{ 
  instancia privada estática de Singleton; 
  uso booleano privado; 
  valor interno privado;  

  singleton privado () 
  { 
    en uso = verdadero; 
    valor = 5; 
  } 
  public static Singleton getInstance() 
  { 
    if (instancia == nulo) 
      instancia = new Singleton(); 
    instancia de retorno; 
  } 
}
copiar codigo

 

getInstance() El Listado 6 contiene el código ensamblador generado por el compilador Sun JDK 1.2.1 JIT para el cuerpo del método del Listado 5  .


Listado 6. Código ensamblador generado a partir del código del Listado 5

copiar codigo
;código asm generado para getInstance 
054D20B0 mov eax,[049388C8] ;carga instancia ref 
054D20B5 test eax,eax ;test for null 
054D20B7 jne 054D20D7 
054D20B9 mov eax,14C0988h 
054D20BE call 503EF8F0 ; asignar memoria 
054D20C3 mov [049388C8],eax; almacenar puntero en 
                                           ;ejemplo ref. instancia   
                                           ;no nulo y ctor 
                                           ;no se ha ejecutado 
054D20C8 mov ecx,dword ptr [eax] 
054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true;
054D20D0 mov dword ptr [ecx+4],5 ;inline ctor - val=5;
054D20D7 mov ebx,dword ptr ds:[49388C8h] 
054D20DD jmp 054D20B0
copiar codigo

 

NOTA:  Para referirme a las líneas de código ensamblador en las siguientes instrucciones, me referiré a los dos últimos valores de la dirección de instrucción ya que ambos comienzan con  054D20 . Por ejemplo, B5 rep  test eax,eax.

El código ensamblador se genera ejecutando un programa de pruebagetInstance() que llama  a métodos en un bucle infinito  . Mientras se ejecuta el programa, ejecute el depurador de Microsoft Visual C++ y adjúntelo al proceso de Java que representa el programa de prueba. Luego, salga de la ejecución y encuentre el código ensamblador que representa ese bucle infinito.

B0B5 Las dos primeras líneas de código ensamblador   en y   cargan  la instance referencia desde la ubicación de la memoria   y   la verifican.  Esto corresponde a la primera línea de código en el método del Listado 5 . La primera vez que se llama a este método, el código se ejecuta   hasta  .  El código en   asigna memoria para el objeto del montón y almacena un puntero a ese bloque de memoria en él   . La siguiente línea de código, toma   el puntero y lo almacena en la memoria en  la referencia de la instancia. El resultado es que  ahora NO es   y hace referencia a un   objeto válido. Sin embargo, el constructor de este objeto aún no se ha ejecutado, que es exactamente lo que rompe el bloqueo de verificación doble. Luego,  en la línea,  el puntero se desreferencia y se almacena en  .  Las   líneas y representan constructores en línea que almacenan valores   y   almacenan   objetos. Si este código   es interrumpido por otro subproceso después de la línea de ejecución y antes de que se complete el constructor, el bloqueo verificado fallará.049388C8eaxnullgetInstance()instancenullB9BESingletoneaxC3eax049388C8instancenullSingletonC8instanceecxCAD0true5SingletonC3

No todos los compiladores JIT generan el código anterior. Algunos generan código tal que se niega solo después de que  instance se ejecuta  el constructor null. La versión 1.3 de IBM SDK para tecnología Java y Sun JDK 1.3 generan dicho código. Sin embargo, esto no significa que en estos casos se deba utilizar el bloqueo de verificación doble. Hay algunas otras razones por las que este modismo falla. Además, no siempre sabe en qué JVM se ejecutará su código, y los compiladores JIT siempre pueden cambiar para generar código que rompa este modismo.

 

Bloqueo doble comprobado: adquiere dos

Dado que el bloqueo verificado dos veces actual no funciona, incluí otra versión del código, que se muestra en el Listado 7, para evitar el problema de escritura desordenada que acaba de ver.


Listado 7. Intento de resolver el problema de escritura desordenada

copiar codigo
getInstance de Singleton estático público () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      Singleton inst = instancia; //2 
      if (inst == null) 
      { 
        sincronizado(Singleton.class) { //3 
          inst = new Singleton(); //4 
        } 
        instancia = inst; //5 
      } 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

Mirando el código en el Listado 7, debe darse cuenta de que las cosas se están poniendo un poco ridículas. getInstance() Recuerde que el bloqueo de doble verificación se creó para evitar la sincronización en métodos simples de tres líneas  . El código del Listado 7 se vuelve rebelde. Además, ese código no resuelve el problema. Una inspección cuidadosa revelará la razón.

Este código intenta evitar el problema de escritura desordenada.  Intenta solucionar esto introduciendo variables locales  inst y un segundo  bloque. synchronizedLa teoría se implementa de la siguiente manera:

  1. El subproceso 1 entra en  getInstance() el método.

  2. El subproceso 1 ingresa al primer bloque en //1  debido  instance a   . nullsynchronized

  3. inst El valor que obtiene  la variable local  instance , que está en //2  null

  4. Debido  inst a  null, el subproceso 1 ingresa al segundo  synchronized bloque en //3. 

  5. El subproceso 1 luego comienza a ejecutar el código en //4 mientras se niega  inst ,  nullpero  Singleton antes de que se ejecute el constructor de //4. (Este es el problema de escritura fuera de orden que acabamos de ver). 

  6. El subproceso 1 es sustituido por el subproceso 2.

  7. El subproceso 2 entra en  getInstance() el método.

  8. Debido  instance a  null, el subproceso 2 intenta ingresar al primer  synchronized bloque en //1. Dado que el subproceso 1 actualmente tiene el bloqueo, el subproceso 2 está bloqueado.

  9. El subproceso 1 luego completa la ejecución en //4.

  10. El subproceso 1 luego asigna un  Singleton objeto completamente construido a la variable en //5  instancey sale de ambos  synchronized bloques. 

  11. Vuelve el hilo 1  instance.

  12. instance Luego ejecute el subproceso 2 y asigne a  at //2  inst.

  13. El subproceso 2 descubre  instance que no lo es  nully lo devuelve.

La línea clave aquí es //5. Esta línea debe garantizar  instance que solo  se construya o se haga referencia a null un objeto completo  Singleton . El problema surge cuando la teoría y la práctica se contraponen.

El código del Listado 7 no es válido debido a la definición del modelo de memoria actual. La especificación del lenguaje Java ( JLS) exige  synchronizedque el código dentro de un bloque no se pueda mover. synchronized Sin embargo, no dice que el código fuera del bloque  no se pueda  mover synchronized al bloque.

El compilador JIT verá aquí una oportunidad de optimización. Esta optimización elimina el código en //4 y //5, combinando y generando el código que se muestra en el Listado 8.


Listado 8. El código optimizado del Listado 7.

copiar codigo
getInstance de Singleton estático público () 
{ 
  si (instancia == nulo) 
  { 
    sincronizado (Singleton.class) { // 1 
      Singleton inst = instancia; //2 
      if (inst == null) 
      { 
        sincronizado(Singleton.class) { //3 
          //inst = new Singleton(); //4 
          instancia = nuevo Singleton();               
        } 
        //instancia = inst; //5 
      } 
    } 
  } 
  instancia de retorno; 
}
copiar codigo

 

Si realiza esta optimización, tendrá el mismo problema de escritura desordenada que discutimos anteriormente.

 

¿Qué hay de declarar cada variable volátil?

Otra idea es apuntar a variables  inst y  instance usar palabras clave  volatile. De acuerdo con el JLS (ver Temas relacionados),  volatile las variables declaradas se consideran coherentes secuencialmente, es decir, no reordenadas. Pero tratando de  volatile solucionar el problema del bloqueo verificado dos veces con los siguientes dos problemas:

  • El problema aquí no es sobre la coherencia secuencial, sino que el código se movió, no se reordenó.

  • Incluso cuando se considera la consistencia secuencial, la mayoría de las JVM no la implementan correctamente  volatile.

El segundo punto merece mayor discusión. Asuma el código en el Listado 9:


Listado 9. Consistencia secuencial usando volatile

copiar codigo
prueba de clase 
{ 
  parada booleana volátil privada = falso; 
  privado volátil int num = 0; 

  public void foo() 
  { 
    num = 100; //Esto puede pasar segundo 
    stop = true; //Esto puede suceder primero 
    //... 
  } 

  public void bar() 
  { 
    if (stop) 
      num += num; //num puede == 0! 
  } 
  //... 
}
copiar codigo

 

Según el JLS, dado que  stop y  num se declaran como  volatile, deben ser secuencialmente coherentes. Esto significa que si  stop alguna vez lo fue  true, num debe haberse configurado en  100. Sin embargo, debido a las funciones de coherencia secuencial que muchas JVM no implementan  volatile , no puede confiar en este comportamiento. Por lo tanto, si el subproceso 1 llama  foo y el subproceso 2 llama al mismo tiempo  bar, el subproceso 1 puede   establecerse en   antes  num de establecerse en  . Esto hará que el subproceso vea   sí  mientras   aún está configurado en  .  Hay problemas adicionales con el uso  de números atómicos con variables de 64 bits, pero están fuera del alcance de este artículo. Consulte Recursos para obtener más información sobre este tema.100stoptruestoptruenum0volatile

 

solución

La conclusión es esta: el bloqueo de doble verificación no debe usarse de ninguna forma, porque no puede garantizar que funcionará sin problemas en cualquier implementación de JVM. JSR-133 trata sobre el modelo de memoria que aborda problemas, sin embargo, el nuevo modelo de memoria no admitirá el bloqueo de verificación doble. Por lo tanto, tienes dos opciones:

  • Acepte el método que se muestra en el Listado 2  getInstance() para la sincronización.

  • Abandone la sincronización y utilice un  static campo en su lugar.

La opción 2 se muestra en el Listado 10


Listado 10. Implementación de singleton usando campos estáticos

copiar codigo
clase Singleton 
{ 
  vector privado v; 
  uso booleano privado; 
  instancia de Singleton estática privada = new Singleton(); 

  singleton privado () 
  { 
    v = nuevo vector (); 
    enUso = verdadero; 
    //... 
  } 

  public static Singleton getInstance() 
  { 
    instancia de retorno; 
  } 
}
copiar codigo

 

El código del Listado 10 no utiliza sincronización y garantiza que  static getInstance() el método no se crea hasta que se  llama Singleton. Esta es una excelente opción si su objetivo es eliminar la sincronización.

 

La cadena no es inmutable

Dado el problema de las escrituras fuera de orden y las referencias que se niegan antes de que se ejecute el constructor  null , podría considerar  String las clases. Supongamos que tiene el siguiente código:

cadena privada str; 
//... 
str = new String("hola");

 

String Las clases deben ser inmutables. Aún así, dado el problema de escritura fuera de servicio que discutimos anteriormente, ¿causaría eso un problema aquí? La respuesta es sí. Considere dos accesos a subprocesos String str. Un subproceso puede ver  str una referencia a un  String objeto en el que el constructor aún no se ha ejecutado. De hecho, el Listado 11 contiene código que muestra que esto sucede. Tenga en cuenta que este código solo falla en la JVM anterior con la que lo probé. Tanto IBM 1.3 como Sun 1.3 JVM generan sin cambios como se esperaba  String.


Listado 11. Ejemplo de cadena mutable

copiar codigo
class StringCreator extiende Thread 
{ 
  MutableString ms; 
  public StringCreator(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
      ms.str = new String("hola"); //1 
  } 
} 
class StringReader extiende Thread 
{ 
  MutableString ms; 
  StringReader público (MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
    { 
      if (!(ms.str.equals("hello"))) //2 
      {
      } 
    } 
        System.out.println("¡La cadena no es inmutable!");
        romper; 
  } 
} 
clase MutableString 
{ 
  public String str; //3 
  public static void main(String args[]) 
  { 
    MutableString ms = new MutableString(); //4 
    nuevo StringCreator(ms).start(); //5 
    nuevo StringReader(ms).start(); //6 
  } 
}
copiar codigo

 

Este código crea una  MutableString clase en //4 que contiene una  String referencia compartida por los dos subprocesos en //3. StringCreator En las líneas //5 y //6, dos objetos y  se crean en dos subprocesos separados  StringReader. Pasar una referencia a un  MutableString objeto. StringCreator La clase entra en un bucle infinito y crea  String el objeto en //1 con el valor "hola". StringReader También ingresa un ciclo infinito y verifica en //2  String si el valor del objeto actual es "hola". Si no, StringReader el hilo imprime un mensaje y se detiene. Si  String la clase es inmutable, no debería ver ningún resultado de este programa. Si ocurre un problema de escritura desordenada, la única forma de  StringReader ver  str una referencia nunca es  String un objeto con un valor de "hola".

La ejecución de este código en una JVM anterior, como Sun JDK 1.2.1, provocará problemas de escritura fuera de servicio. y por lo tanto dar como resultado una no invariante  String.

 

conclusión

Para evitar la costosa sincronización en singletons, los programadores fueron muy inteligentes e inventaron el lenguaje de bloqueo de doble verificación. Desafortunadamente, dado el modelo de memoria actual, este idioma aún no se ha utilizado ampliamente y es claramente una construcción de programación insegura. Se está trabajando en esta área de redefinición del modelo de memoria frágil. Aún así, incluso en el modelo de memoria recientemente propuesto, el bloqueo verificado dos veces es ineficaz. La mejor solución a este problema es aceptar la sincronización o usar una  static field.

 

Referencias

Supongo que te gusta

Origin blog.csdn.net/qq_34507736/article/details/60598737
Recomendado
Clasificación