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
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 } }
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:
- El subproceso 1 llama
getInstance()
al método y decideinstance
estar en //1null
.
- 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.
- El subproceso 2 llama al método y decide
getInstance()
en //1 .instance
null
- El subproceso 2 ingresa
if
al bloque de código y crea un nuevoSingleton
objeto y asigna la variable a este nuevo objeto en //2instance
.
- El subproceso 2 devuelve la referencia del objeto en //3
Singleton
.
- El subproceso 2 es reemplazado por el subproceso 1.
- 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.
- 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()
Singleton estático público getInstance() { if (instancia == nulo) { sincronizado (Singleton.class) { instancia = new Singleton(); } } instancia de retorno; }
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 instance
mientras 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
singleton estático público getInstance () { si (instancia == nulo) { sincronizado (Singleton.class) { // 1 si (instancia == nulo) // 2 instancia = nuevo Singleton (); //3 } } instancia de retorno; }
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:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al bloque en //1 debido
instance
a .null
synchronized
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 entra en
getInstance()
el método.
- Dado
instance
que todavía esnull
, el subproceso 2 intenta adquirir el bloqueo en //1. Sin embargo, subproceso 2 bloques en //1 porque el subproceso 1 mantiene el bloqueo.
- El subproceso 2 es reemplazado por el subproceso 1.
- El subproceso 1 se ejecuta y, dado que la instancia aún está en //2
null
, el subproceso 1 también crea unSingleton
objeto y asigna su referencia ainstance
.
- El subproceso 1 sale
synchronized
del bloque ygetInstance()
devuelve la instancia del método.
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 adquiere el bloqueo en //1 y comprueba
instance
si esnull
.
- Dado que
instance
nonull
, el segundoSingleton
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 .instance
null
¿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:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al bloque en //1 debido
instance
a .null
synchronized
- El subproceso 1 avanza a //3, pero antes de que se ejecute el constructor , niega la instancia
null
.
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 comprueba si la instancia es
null
. Debido a que la instancia no es nula, el subproceso 2instance
devuelve una referencia a unSingleton
objeto completamente construido pero parcialmente inicializado.
- El subproceso 2 es reemplazado por el subproceso 1.
- 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
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; } }
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
;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
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.
B0
B5
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á.049388C8
eax
null
getInstance()
instance
null
B9
BE
Singleton
eax
C3
eax
049388C8
instance
null
Singleton
C8
instance
ecx
CA
D0
true
5
Singleton
C3
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
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; }
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. synchronized
La teoría se implementa de la siguiente manera:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al primer bloque en //1 debido
instance
a .null
synchronized
inst
El valor que obtiene la variable localinstance
, que está en //2null
.
- Debido
inst
anull
, el subproceso 1 ingresa al segundosynchronized
bloque en //3.
- El subproceso 1 luego comienza a ejecutar el código en //4 mientras se niega
inst
,null
peroSingleton
antes de que se ejecute el constructor de //4. (Este es el problema de escritura fuera de orden que acabamos de ver).
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 entra en
getInstance()
el método.
- Debido
instance
anull
, el subproceso 2 intenta ingresar al primersynchronized
bloque en //1. Dado que el subproceso 1 actualmente tiene el bloqueo, el subproceso 2 está bloqueado.
- El subproceso 1 luego completa la ejecución en //4.
- El subproceso 1 luego asigna un
Singleton
objeto completamente construido a la variable en //5instance
y sale de ambossynchronized
bloques.
- Vuelve el hilo 1
instance
.
instance
Luego ejecute el subproceso 2 y asigne a at //2inst
.
- El subproceso 2 descubre
instance
que no lo esnull
y 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 synchronized
que 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.
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; }
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
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! } //... }
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.100
stop
true
stop
true
num
0
volatile
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
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; } }
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
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 } }
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
-
- Puede leer la versión original en inglés de este artículo en el sitio global de developerWorks .
- En el libro de Peter Haggar, Guía práctica del lenguaje de programación de Java (Addison-Wesley, 2000), cubre varios temas de programación de Java, incluido un capítulo completo sobre problemas de subprocesos múltiples y técnicas de programación.
- La especificación del lenguaje Java, segunda edición de Bill Joy y otros (Addison-Wesley, 2000) es la referencia técnica definitiva sobre el lenguaje de programación Java.
- The Java Virtual Machine Specification, Second Edition (Addison-Wesley, 1999) , de Tim Lindholm y Frank Yellin, es el documento definitivo sobre el compilador Java y el entorno de tiempo de ejecución.
- Visite el sitio web del modelo de memoria Java de Bill Pugh para obtener una gran cantidad de información sobre este tema.
- Para obtener más información acerca
volatile
de las variantes de 64 bits, consulte el artículo de Peter Haggar "¿Garantiza Java la seguridad de subprocesos?" en la edición de junio de 2002 de Dr. Dobb's Journal .
- JSR-133 se ocupa de las revisiones del modelo de memoria y la especificación de subprocesos de la plataforma Java.
- El consultor de software de Java, Brian Goetz , explica cuándo usar la sincronización en " Threading made easy: La sincronización no es el enemigo " ( developerWorks , julio de 2001).
- En " Threading made easy: A veces dejar de compartir es mejor " ( developerWorks , octubre de 2001), Brian Goetz lo presenta
ThreadLocal
y ofrece algunos consejos para explotar su poder.
- En " Threading made easy: La sincronización no es el enemigo " ( developerWorks , febrero de 2001), Alex Roetter presenta la API de subprocesos de Java, brinda una descripción general de los problemas asociados con los subprocesos múltiples y brinda soluciones a problemas comunes.
- Allen Holub propone importantes cambios y adiciones al lenguaje Java en " If I Were King: Propuestas para resolver problemas de subprocesos en el lenguaje de programación Java " ( developerWorks , octubre de 2000).
- Encuentre material adicional sobre tecnología Java en la zona de tecnología Java de developerWorks.
- Puede leer la versión original en inglés de este artículo en el sitio global de developerWorks .
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
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 } }
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:
- El subproceso 1 llama
getInstance()
al método y decideinstance
estar en //1null
.
- 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.
- El subproceso 2 llama al método y decide
getInstance()
en //1 .instance
null
- El subproceso 2 ingresa
if
al bloque de código y crea un nuevoSingleton
objeto y asigna la variable a este nuevo objeto en //2instance
.
- El subproceso 2 devuelve la referencia del objeto en //3
Singleton
.
- El subproceso 2 es reemplazado por el subproceso 1.
- 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.
- 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()
Singleton estático público getInstance() { if (instancia == nulo) { sincronizado (Singleton.class) { instancia = new Singleton(); } } instancia de retorno; }
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 instance
mientras 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
singleton estático público getInstance () { si (instancia == nulo) { sincronizado (Singleton.class) { // 1 si (instancia == nulo) // 2 instancia = nuevo Singleton (); //3 } } instancia de retorno; }
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:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al bloque en //1 debido
instance
a .null
synchronized
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 entra en
getInstance()
el método.
- Dado
instance
que todavía esnull
, el subproceso 2 intenta adquirir el bloqueo en //1. Sin embargo, subproceso 2 bloques en //1 porque el subproceso 1 mantiene el bloqueo.
- El subproceso 2 es reemplazado por el subproceso 1.
- El subproceso 1 se ejecuta y, dado que la instancia aún está en //2
null
, el subproceso 1 también crea unSingleton
objeto y asigna su referencia ainstance
.
- El subproceso 1 sale
synchronized
del bloque ygetInstance()
devuelve la instancia del método.
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 adquiere el bloqueo en //1 y comprueba
instance
si esnull
.
- Dado que
instance
nonull
, el segundoSingleton
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 .instance
null
¿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:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al bloque en //1 debido
instance
a .null
synchronized
- El subproceso 1 avanza a //3, pero antes de que se ejecute el constructor , niega la instancia
null
.
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 comprueba si la instancia es
null
. Debido a que la instancia no es nula, el subproceso 2instance
devuelve una referencia a unSingleton
objeto completamente construido pero parcialmente inicializado.
- El subproceso 2 es reemplazado por el subproceso 1.
- 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
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; } }
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
;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
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.
B0
B5
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á.049388C8
eax
null
getInstance()
instance
null
B9
BE
Singleton
eax
C3
eax
049388C8
instance
null
Singleton
C8
instance
ecx
CA
D0
true
5
Singleton
C3
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
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; }
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. synchronized
La teoría se implementa de la siguiente manera:
- El subproceso 1 entra en
getInstance()
el método.
- El subproceso 1 ingresa al primer bloque en //1 debido
instance
a .null
synchronized
inst
El valor que obtiene la variable localinstance
, que está en //2null
.
- Debido
inst
anull
, el subproceso 1 ingresa al segundosynchronized
bloque en //3.
- El subproceso 1 luego comienza a ejecutar el código en //4 mientras se niega
inst
,null
peroSingleton
antes de que se ejecute el constructor de //4. (Este es el problema de escritura fuera de orden que acabamos de ver).
- El subproceso 1 es sustituido por el subproceso 2.
- El subproceso 2 entra en
getInstance()
el método.
- Debido
instance
anull
, el subproceso 2 intenta ingresar al primersynchronized
bloque en //1. Dado que el subproceso 1 actualmente tiene el bloqueo, el subproceso 2 está bloqueado.
- El subproceso 1 luego completa la ejecución en //4.
- El subproceso 1 luego asigna un
Singleton
objeto completamente construido a la variable en //5instance
y sale de ambossynchronized
bloques.
- Vuelve el hilo 1
instance
.
instance
Luego ejecute el subproceso 2 y asigne a at //2inst
.
- El subproceso 2 descubre
instance
que no lo esnull
y 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 synchronized
que 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.
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; }
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
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! } //... }
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.100
stop
true
stop
true
num
0
volatile
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
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; } }
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
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 } }
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
-
- Puede leer la versión original en inglés de este artículo en el sitio global de developerWorks .
- En el libro de Peter Haggar, Guía práctica del lenguaje de programación de Java (Addison-Wesley, 2000), cubre varios temas de programación de Java, incluido un capítulo completo sobre problemas de subprocesos múltiples y técnicas de programación.
- La especificación del lenguaje Java, segunda edición de Bill Joy y otros (Addison-Wesley, 2000) es la referencia técnica definitiva sobre el lenguaje de programación Java.
- The Java Virtual Machine Specification, Second Edition (Addison-Wesley, 1999) , de Tim Lindholm y Frank Yellin, es el documento definitivo sobre el compilador Java y el entorno de tiempo de ejecución.
- Visite el sitio web del modelo de memoria Java de Bill Pugh para obtener una gran cantidad de información sobre este tema.
- Para obtener más información acerca
volatile
de las variantes de 64 bits, consulte el artículo de Peter Haggar "¿Garantiza Java la seguridad de subprocesos?" en la edición de junio de 2002 de Dr. Dobb's Journal .
- JSR-133 se ocupa de las revisiones del modelo de memoria y la especificación de subprocesos de la plataforma Java.
- El consultor de software de Java, Brian Goetz , explica cuándo usar la sincronización en " Threading made easy: La sincronización no es el enemigo " ( developerWorks , julio de 2001).
- En " Threading made easy: A veces dejar de compartir es mejor " ( developerWorks , octubre de 2001), Brian Goetz lo presenta
ThreadLocal
y ofrece algunos consejos para explotar su poder.
- En " Threading made easy: La sincronización no es el enemigo " ( developerWorks , febrero de 2001), Alex Roetter presenta la API de subprocesos de Java, brinda una descripción general de los problemas asociados con los subprocesos múltiples y brinda soluciones a problemas comunes.
- Allen Holub propone importantes cambios y adiciones al lenguaje Java en " If I Were King: Propuestas para resolver problemas de subprocesos en el lenguaje de programación Java " ( developerWorks , octubre de 2000).
- Encuentre material adicional sobre tecnología Java en la zona de tecnología Java de developerWorks.
- Puede leer la versión original en inglés de este artículo en el sitio global de developerWorks .