Hace tiempo que leo código con los mismos fallos que, creo, son errores de concepto. Mucha gente supone cosas sobre el funcionamiento de los hilos en Java que, directamente, son falsas. En esta entrada aportaré mi granito de arena para aclarar algunos de esos conceptos.
La sincronía es importante
Pongamos por ejemplo esta clase:
public class Prueba {
private int a = 0;
private int b = 0;
public void marcar() {
a = 5;
b = 5;
}
public boolean siempreVerdadero() {
return a == 0 || ( a == b && b == 5);
}
}
Aunque la clase tiene una dudosa aplicación práctica (vale, no tiene nada de dudosa, es inútil), parece claro que el método “siempreVerdadero” retornará invariablemente true. ¿Seguro? No. Esta es una de las suposiciones que hacen muchos programadores y que suele provocar esos misteriosos errores de “algunas veces falla… pero no hay forma de saber cuándo”.
Supongamos que un hilo llama a marcar al mismo tiempo que otro llama a siempreVerdadero. Estamos ante una situación “data race”, vamos, que el resultado de la operación depende del momento en el que la máquina virtual ejecute las instrucciones. ¿Por qué? Voy a tratar de explicarlo:
- Hilo 1 llama a marcar e Hilo 2 llama a siempreVerdadero
- a = 5;
- b = 5;
- return a == 0 || ( a == b && b == 5);
En este caso el orden de ejecución de las instrucciones era el que el programador estaba esperando. Todo perfecto…. pero podría darse el siguiente escenario:
- Hilo 1 llama a marcar e Hilo 2 llama a siempreVerdadero
- a = 5;
- return a == 0 || ( a == b && b == 5); // a vale 5 y b vale0, false
- b=5;
El problema es que suponemos atomicidad donde no la hay. Es evidente que el programador no quiere que ningún método que acceda a “a” o “b” pueda tener lugar mientras se ejecuta el método “marcar”. ¿Cómo deberíamos haberlo hecho? Lo más sencillo sería hacer uso de “synchronized” que nos garantiza precisamente eso:
public synchronized void marcar() {
a = 5;
b = 5;
}
Publicar el objeto antes de finalizar la construcción
Los constructores son métodos especiales. Sirven, valga la redundancia, para construir un objeto. Es decir, una vez el método constructor haya finalizado, el objeto está listo para publicarse. Si se publicase antes de su finalización, los resultados serían impredecible. Y lo que es peor, cada vez que publicas un objeto sin estar totalmente construido, ¡Java mata un gatito ahogándolo en café!
¿Qué mi madre es eso de “publicar un objeto”? Pues básicamente, hacer que otros objetos lo conozcan.
Publicar un objeto antes de tiempo se conoce como “fuga de publicación”. El caso más típico es pasar una referencia a “this” (es decir, el objeto que estamos construyendo) a otro objeto en el código del constructor.
Supongamos el siguiente código:
public class MiFuga {
ArrayList clientes;
public MiFuga(OtraClase otraClase) {
clientes = new ArrayList();
otraClase.subscribeToPeticionCliente(this);
}
public void añadirCliente(Cliente cliente){
clientes.add(cliente)
}
}
La clase crea una lista de clientes y se subscribe al objeto “otraClase” que le informará cuando un cliente deba ser añadido. Obviamente para que el método “añadirCliente” pueda ser ejecutado correctamente, “clientes” ha de ser creado.
El código, aunque pueda parecer correcto, tiene un “data race”, es decir, puede funcionar, o no, dependiendo del orden en que se ejecuten sus sentencias. ¿Dónde está ese data race? Pues en que “otraClase” recibe una referencia a una instancia de MiFuga que podría no estar totalmente construida.
En Java, un constructor debe construir el objeto y NO publicarse a sí mismo. Muchos programadores creen que, dado que la construcción del objeto parece finalizada (clientes ha sido creado) antes de realizar la publicación (es decir, de hacer el subscribe), no hay problema. Lo que nadie les debe haber dicho es que Java no garantiza que el orden en el que escribas las sentencias se cumpla.
El compilador tiene total libertad para reordenar las instrucciones siempre que no se rompa ninguna relación de dependencia. En el caso anterior, la creación de clientes no depende de la llamada a subscribe ni viceversa, por lo que el compilador podría poner la llamada a subscribe delante de la creación de clientes. De ocurrir esto, y “otraClase” lanzar el evento para procesar un cliente antes de la ejecución de la instrucción que crea clientes, obtendríamos un NullPointerException, ya que clientes tendría valor nulo.
En otras palabras, el código anterior puede funcionar, o no, dependiendo del orden que decida el compilador y de que “otraClase” lance el evento en el momento menos oportuno. Un “data race” en toda regla.
Llegados a este punto tienes dos soluciones: evitar la publicación prematura de un objeto o enseñar a nadar en café a los gatitos. Personalmente creo que la segunda sería más divertida… pero es poco práctica.
Si queremos evitar la publicación prematura, la solución no es hacer un constructor “synchronized”. Intuitivamente parece correcto, ya que aseguraría la ejecución de las instrucciones en el orden escrito… pero el compilador se negará a hacer un constructor “synchronized”. Aunque pudiera parecer lógico, realmente carece de sentido. Los constructores sólo son accesibles por el hilo que los crea y por tanto, no requieren de sincronización… simplemente NO debes publicar un objeto en el constructor.
Las cachés existen, tenlas en cuenta
Las cachés se sienten menospreciadas porque la gente no se acuerda de ellas :( Se compasivo y escribe código que las contemple. No sólo harás a las cachés felices y evitarás su suicidio, también lograrás que tu programa falle cuando Jupiter se alinea con Saturno y una paloma te mira por la ventana. (Eh, no me miréis así, que todos hemos llegado a la conclusión de que nuestro programa falla sólo cuando hay un posible comprador/ jefe/profesor delante)
Antes de nada ¿Qué es una caché y por qué tiene que importarme?
Cuando un hilo se crea, puede tomar la decisión de cachear ciertas variables. Es decir, creará copias de los valores de las variables de la memoria principal, a su “espacio de trabajo”. Esto se hace para mejorar la eficiencia. ¿Por qué debe importarte? Sencillo, si un hilo cachea una variable que va a usar luego, y esa variable cambia de valor… su copia cacheada ¿reflejará el cambio? La respuesta es “ni puñetera idea”… a menos que tengas la caché en cuenta.
Para el ejemplo, tomaré un código de CodeCrab que no tiene en cuenta la caché:
public class ThreadClass extends Thread {
private boolean thrbool = true;
public void run(){
while(thrbool){
System.out.println("Hello from thread "+super.getId());
try{
Thread.sleep(300);
}catch(Exception e){}
}
}
public void stopThread(){
thrbool = false;
}
}
El planteamiento es intuitivo. El bucle de run se ejecutará hasta que alguien realice una llamada a “stopThread”, que cambiará el valor de “thrbool” a false y provocará la condición de salida del bucle.
¿Qué os he dicho de las cachés? Son muy rencorosas… si no os acordáis de ellas, se pondrán tristes y se enfadarán con vosotros haciendo que vuestros códigos no funcionen.
El método “run” puede estar viendo versiones cacheadas de las variables de la clase. Vamos, que puede haber cacheado “thrbool” con su valor original, true. Si alguien llama a “stopThread” , no tenemos ninguna garantía de que “run” vea el cambio… porque si ha creado caché, no estamos forzando ningún tipo de actualización.
¿Qué solución tenemos? Una de ellas consiste en marcar “thrbool” como “volatile”. Una variable volatile garantiza que todos los hilos la vean con el mismo valor. Dicho de otra forma, una variable volatile NO será cacheada.
private volatile boolean thrbool = true;
Eso garantizaría que “run” viera siempre los cambios hechos por “stopThread”. ¿Por qué hay tantos códigos con este fallo? Porque no marcarla como volatile no implica que no funcione… simplemente que podría no hacerlo. Dependerá de la plataforma, la máquina virtual y la carga de trabajo de la máquina sobre la que se ejecuta… vamos, todo un encaje de bolillos. (¿Nunca os ha pasado eso de “pues en mi máquina funcionaba”?).
¿Y a mí que me importa si no uso hilos?
Es que SÍ usas hilos. Para empezar, probablemente uses Swing… así que ya hay hilos de por medio. Aún sin usar Swing, lo más probable es que el sistema sea multi-núcleo o tenga hyperthreading… con lo que los efectos serán los mismos. No busques excusas y haz tu código “thread safe” ;)
Un post la mar de trabajado, y con cosas que al menos yo y más del 80% de la población Javera (atención, cifra inventada =P) no sabía. Felicidades por el curro de recopilar la info y publicarla :-)
Aunque el logo de la Java Evil Edition me encante y los gatitos no mucho, voy a intentar ser buena y confesar que he provocado fugas de «thises» en constructores xD
En casos de composición, si te limitas a crear el objeto el diseño no se estará cumpliendo y hasta cierto punto también se puede considerar una inconsistencia, ¿no? A bote pronto lo único que se me ocurre es usar una factoría que cree y vincule el objeto, pero sería una dependencia externa permanente y en casos como el mapeo de una base de datos en JPA puedes acabar con un mogollón :( ¿Alguna otra alternativa?
Buen trabajo, no es habitual ver posts dedicados a la creación de código efectivo concurrente.
Me gustaría hacer una matización sobre el primer escenario, el de métodos synchronized…
Si no me equivoco etiquetar como synchronized el método marcar no garantiza que se produzca la race condition, ya que como se explica en la documentación de referencia…
It is not possible for two invocations of synchronized methods on the same object to interleave. When one thread is executing a synchronized method for an object, all other threads that invoke synchronized methods for the same object block (suspend execution) until the first thread is done with the object.
Dado que sólo marcar está etiquetado como un método syncrhonized, las asignaciones sobre las variables estarían en sección crítica, pero nada garantiza que si sobre la misma instancia se invocase al método no sincronizado esVerdadero, durante la ejecución de marcar, se entrase en race condition.
La solución pasaría por marcar ambos métodos como syncrhronized.
Hola,
Excelente post.
Me gustaría añadir solamente dos cosillas que supongo que ya sabéis pero por si ayudan a alguien. Espero no estar equivocado.
1) todo «inmutable data» (referencias q no cambian) podrían ser definidos como final, de esa forma nos aseguramos un mejor multithreading, dado que eso fuerza al constructor del objeto a ser atomic -> el objeto será totalmente construído con todos sus campos inicializados antes de ser accedido por el programa.
2) Añadir que la razón por la que el constructor no acepta synchronized es bastante simple, los constructores no pueden retornar valores así q no se les puede especificar ningún tipo (ni siquiera void). Tal cual Pablo explica lo que parece lógico (en el ejemplo) no lo es tanto en este caso.
Como decía al comienzo, espero no estar metiendo la pata con mis comentarios ya que llevo menos de dos semanas aprendiendo Java.
Gracias Pablo por el aporte q realizas. Me ha gustado mucho el artículo sobre Java 7, yo también creo q se están liando demasiado tratando de hacer un mix de todo sin empezar desde la base y eso les está complicando el proceso de publicación de esta nueva release. Igualmente entiendo que es un avance en el lenguaje disponer de una versión que incluye más opciones que la actual.