CONCURRENCIA EN JAVA

Anuncio
CONCURRENCIA
EN JAVA
Diseño de Sistemas Operativos Avanzados
2000/2001
Pedro Pablo Gómez Martín
Concurrencia en Java
1
ÍNDICE
ÍNDICE................................................................................................................................................................ 1
INTRODUCCIÓN .............................................................................................................................................. 3
CREAR UNA NUEVA HEBRA....................................................................................................................... 3
FINALIZACIÓN ................................................................................................................................................ 5
PRIORIDADES .................................................................................................................................................. 6
OTROS MÉTODOS PARA EL PLANIFICADOR ......................................................................................... 7
CLASES Y EXCEPCIONES............................................................................................................................. 8
CERROJOS....................................................................................................................................................... 10
EXCLUSIÓN MÚTUA.................................................................................................................................... 10
SEÑALIZACIÓN ............................................................................................................................................. 12
MÉTODOS DE SEÑALIZACIÓN ............................................................................................................. 14
INTERBLOQUEOS ......................................................................................................................................... 15
GRUPOS DE HEBRAS ................................................................................................................................... 15
OTROS MÉTODOS (DESACONSEJADOS) ............................................................................................... 15
LA MÁQUINA VIRTUAL.............................................................................................................................. 17
ESTRUCTURA DE LA MÁQUINA VIRTUAL........................................................................................... 18
Synchronized(o)................................................................................................................................................ 21
NUEVAS HEBRAS ......................................................................................................................................... 22
GESTIÓN DE MEMORIA.............................................................................................................................. 23
VARIABLES VOLATILE............................................................................................................................... 24
INICIALIZACIÓN DE CLASES .................................................................................................................... 24
CONCLUSIONES ............................................................................................................................................ 25
BIBLIOGRAFÍA.............................................................................................................................................. 26
APÉNDICE – IMPLEMENTACIÓN DE LOS EJEMPLOS ........................................................................ 27
2
Concurrencia en Java
Concurrencia en Java
3
INTRODUCCIÓN
El lenguaje de programación Java proporciona, sin necesidad de ninguna otra herramienta
adicional, la construcción de programas concurrentes. Para ello pone a disposición del programador la
clase Thread, que permite ejecutar código en un hilo de ejecución independiente. Proporciona además
métodos de sincronización entre hebras, de modo que es posible construir programas concurrentes en los
que los distintos hilos de ejecución puedan sincronizarse utilizando directamente las primitivas que
proporciona el lenguaje, sin necesidad de utilizar técnicas implementadas por los propios programadores.
Java no dispone, no obstante, de control sobre las situaciones peligrosas en los programas
concurrentes que puedan ocasionar malos funcionamientos, como por ejemplo los interbloqueos. Es
responsabilidad del diseñador del sistema dar una correcta estructura al programa que tenga que
implementar, de modo que no aparezcan situaciones indeseadas.
Como es sabido, el lenguaje de programación Java es multiplataforma. Los programas se
compilan, originandose ficheros binarios que son independientes de la plataforma. Posteriormente, en
cada sistema se implementará un intérprete de esos ficheros, que los ejecutará.
Para que el lenguaje sea realmente independiente de la plataforma, todos los compiladores e
intérpretes de ficheros binarios deben actuar de forma similar. Eso evita que distintos compiladores
admitan distintos códigos, anulando la posibilidad de la aparición de “dialectos”. Del mismo modo, para
que la ejecución de un programa compilado sea igual en todos los sistemas, también debe quedar
completamente claro la forma en la que se deben interpretar y ejecutar esos ficheros.
Es el creador del lenguaje, Sun Microsystems, quien especifica tanto el lenguaje como el
intérprete, en lo que se denomina la especificación del lenguaje Java, y la especificación de la máquina
virtual. La mayor parte de este trabajo se basa en la última de esas dos especificaciones.
Inicialmente comienzo explicando las herramientas proporcionadas por Java para permitir la
programación concurrente. En ocasiones será necesario, además, hacer algun recordatorio sobre otras
características del lenguaje para asegurar que la explicación queda completamente clara. No obstante no
haré aquí una descripción detallada del lenguaje.
Una vez visto cómo el programador puede utilizar Java para implementar programas
concurrentes, veremos como consigue Java ejecutarlos. Es decir de qué mecanismos dispone la máquina
virtual Java (el intérprete de los ficheros binarios) para proporcionar la concurrencia y la sincronización.
CREAR UNA NUEVA HEBRA
Cuando se inicia un programa Java, la máquina virtual crea una hebra inicial, que se encargará
de llamar al método public static void main(String[] args) de la clase que se comienza
a ejecutar.
Si se desea crear una nueva hebra, es suficiente construir un nuevo objeto de la clase Thread y
llamar a su método start(). Esa llamada ocasionará que se llame al método run() de la hebra, pero
en un hilo de ejecución nuevo, diferente al que ejecutó la llamada al método start(). La ejecución del
método run() será, por lo tanto, “simultánea” a la ejecución del código que siga a la llamada a
start().
La implementación del método run() implementada en la clase Thread es vacía, no hace
nada1. Para construir una hebra un poco más interesante, habrá que implementar una clase que extienda a
la clase Thread, y sobreescriba el método run(). Debido a la invocación dinámica propia de la
programación orientada a objetos, la llamada al método start() de la clase recién creada llamará al
nuevo método run() implementado. Para construir una hebra, por lo tanto, bastará con meter en el
método run() el código que deseemos que se ejecute de forma independiente en una nueva hebra.
1
Esto no es cierto completamente, como veremos a continuación. No obstante, no realiza ninguna
operación útil al nivel que estamos en este momento.
4
Concurrencia en Java
Veamos un ejemplo2. El siguiente programa:
public class DosHebrasBasicas extends Thread {
int cont;
DosHebrasBasicas(int c) {
cont = c;
}
public void run() {
while (true) {
system.out.println(cont);
}
}
public static void main(String[] args) {
new DosHebrasBasicas(0).start();
new DosHebrasBasicas(1).start();
}
} // class
La ejecución de la aplicación anterior causa la salida por pantalla de líneas con 0’s y 1’s
entrelazados. Eso se debe a que la máquina virtual distribuye el tiempo del procesador entre las dos
hebras, de modo que según quién tenga el procesador se escribirá un 0 o un 1.
Este modo de implementar nuevas hebras tiene una desventaja. Debido a la restricción de
herencia simple impuesta por Java, sería imposible hacer clases ejecutables en hebras independientes que
hereden de cualquier otra clase. Esto ocurre por ejemplo en applets. No es posible implementar la
aplicación anterior en un applet de forma directa, debido a que la clase tendría que heredar
simultáneamente de la clase Thread y de la clase Applet.
Afortunadamente existe el interfaz Runnable. Una clase que desee implementar este interfaz
deberá disponer del método:
public void run();
Es posible comenzar la ejecución del método run() de cualquier objeto que implemente el
interfaz en una hebra independiente. Para eso, es suficiente crear un nuevo objeto de la clase Thread,
pasandole en el constructor el objeto que se desea ejecutar. Cuando se llame al método start() de esa
hebra, el método que se ejecutará será, realmente, el método run() del objeto pasado en el constructor.
El ejemplo anterior se implementaría:
public class DosHebrasBasicas implements Runnable {
int cont;
DosHebrasBasicas(int c) {
cont = c;
}
public void run() {
while (true) {
system.out.println(cont);
}
}
public static void main(String[] args) {
new Thread (new DosHebrasBasicas(0)).start();
new Thread (new DosHebrasBasicas(1)).start();
}
} // class
2
Los ejemplos suministrados no siguen exactamente este código. Puede ver más información sobre ello
en el apéndice.
Concurrencia en Java
5
Ahora la clase no hereda de ninguna otra, por lo que la herencia queda libre para heredar, por
ejemplo, de la clase Applet. También se modifica la forma de crear las hebras. Ahora es necesaria la
creación de dos objetos: el objeto que implementa el interfaz, y la propia hebra que lo ejecutará.
El método start() de la hebra realmente sigue ejecutando el código del método run() del
objeto de la clase Thread. Se ejecutará el método run() del objeto que implementa el interfaz
Runnable porque el código del run() de la hebra no está realmente vacío. En el siguiente código se
esboza la parte del código de la clase Thread que nos interesa ahora mismo:
public class Thread implements Runnable {
/* ... */
protected Runnable _target;
/* ... */
Thread(Runnable target) {
_target = target;
}
/* ... */
public void run() {
if (_target != null)
_target.run();
}
/* ... */
}
Puede verse que el método run() de la hebra se encarga de llamar al método run() del objeto
que implementa el interfaz Runnable, si es que se ha especificado alguno en el constructor. Si no es así,
el método no hace nada.
También puede verse que la clase Thread implementa el interfaz Runnable, y contiene el
método run() que es el que se llama cuando se invoca al método start(), tal y como se ha dicho
antes. Pero eso no debe llevar a confusión con el uso del interfaz Runnable en cualquier otra clase.
La ejecución es completamente igual en los dos casos. No hay reglas de cuando usar una u otra
técnica. No obstante, el uso de la herencia es solo válido cuando la clase que se desea ejecutar no tiene
que heredar de ninguna otra clase. Además, se aconseja que se use herencia únicamente si hay que
sobreescribir algún otro método, además del run(). Por su parte, el uso del interfaz Runnable requiere
la construcción de dos objetos (la hebra y el propio objeto) para poder realizar la ejecución.
FINALIZACIÓN
La finalización de una hebra suele ocurrir cuando se termina de ejecutar el método run(). No
obstante, hay otras tres razones por las que una hebra puede terminar:
• En algún momento de la ejecución del método run() se ha generado una excepción que nadie
ha capturado. La excepción se propaga hasta el propio método run(). Si tampoco éste tiene un
manejador para la excepción, el método run() finaliza abruptamente, terminando la ejecución
de la hebra.
• Si se llama al método stop() o stop(excepción) de la hebra. Estos dos métodos originan
que la hebra termine, y son en realidad un caso particular del anterior. Se comentan más
adelante.
• Cuando se llama al método destroy() de la hebra. También se comenta posteriormente.
En cualquiera de los casos, la hebra finaliza su ejecución, y deja de ser tenida en cuenta por el
planificador.
6
Concurrencia en Java
PRIORIDADES
El planificador es la parte de la máquina virtual que se encarga de decidir qué hebra ejecutar en
cada momento. La especificación de la máquina virtual no fuerza al uso de ningun algoritmo particular en
la planificación de hebras. De hecho, es problema de cada implementación particular decidir si
implementar por sí misma el planificador, o apoyarse en el sistema operativo subyacente.
En cualquier caso si se fuerzan ciertas características que debe tener el planificador. En concreto,
todas las hebras tienen una prioridad, de modo que el planificador dará más ventaja a las hebras con
mayor prioridad que a las de menos.
En principio, se obliga a que exista expropiación entre hebras de igual prioridad, de modo que si
hay varias hebras con igual prioridad todas se ejecuten en algún momento. La especificación de la
máquina virtual no obliga, sin embargo, a la expropiación de hebras de una prioridad mayor si la hebra
que va a pasar a ejecutarse es de prioridad menor. Es decir, no se garantiza que hebras de prioridad baja
pasen a ejecutarse si existe alguna hebra de mayor prioridad.
Un sencillo ejemplo que puede utilizarse para comprobar esto es el siguiente:
public class ComprobarPrioridad implements Runnable {
int num;
ComprobarPrioridad(int c) { num = c; }
public void run() {
while (!parar) {
system.out.println(num);
cont++;
}
}
public static void main(String[] args) {
Thread nueva;
for (int c = 0; c < 10; c++) {
nueva = new Thread(new ComprobarPrioridad(c));
if (c == 0) nueva.setPriority(Thread.MAX_PRIORITY);
nueva.start();
}
}
} // class
Si la máquina virtual que ejecute este ejemplo no realiza expropiación en hebras de mayor
prioridad para ejecutar hebras de menor prioridad, solo se ejecutará la hebra número 0, por lo que sólo se
mostrará ese número en la salida estandar. Si sí se realiza expropiación, otras hebras se ejecutarán, pero,
en principio, una menor cantidad de veces que la hebra 0.
Del código anterior se pueden deducir algunas constantes y funciones definidas en la clase
Thread para controlar la prioridad. Se definen dos funciones:
• setPriority(int): establece la prioridad de la hebra. Puede ocasionar la generación de una
excepción de seguridad si la hebra que solicita el cambio de prioridad de otra no está autorizada
a hacerlo.
• getPriority(): devuelve la prioridad de la hebra.
Para establecer los posibles valores en el parámetro de setPriority o como resultado de
getPriority, la clase Thread define tres constantes estáticas a la clase:
• MAX_PRIORITY (= 10): es el valor que simboliza la máxima prioridad.
• MIN_PRIORITY (= 1): es el valor que simboliza la mínima prioridad.
• NORM_PRIORITY (= 5): es el valor que simboliza la prioridad normal, la que tiene la hebra
creada durante el arranque de la máquina virtual y que se encarga de ejecutar la función
main().
Concurrencia en Java
7
Las librerías gráficas de Java AWT y Swing construyen su propia hebra, que se encargará de
atender los eventos del usuario (ya sean del ratón o del teclado). Cuando, por ejemplo, se pulsa un botón,
es esa hebra la que lo recoge, y la que se encarga de ejecutar el código asociado al evento.
El usuario espera que sus órdenes se ejecuten instantáneamente, lo que da la impresión de un
sistema rápido. Al menos, es deseable que cuando se pulse un botón, éste modifique momentáneamente
su aspecto para darle al usuario la sensación de que el sistema se ha dado cuenta de su acción. Para que
esto pueda realizarse, los desarrolladores de las librerías anteriores decidieron establecer a la hebra que
crean una prioridad ligeramente superior a la normal (=6). Gracias a eso se logra que la interfaz gráfica
responda inmediatamente, incluso aunque haya otras hebras normales ejecutandose. Cuando el usuario no
realiza ninguna acción, la hebra del interfaz estará suspendida, esperando a que lleguen eventos, momento
en el que se permite a esas otras hebras ejecutarse.
Naturalmente, si el programa crea una hebra con una prioridad mayor que la establecida a la
hebra del interfaz, ésta podría dejar de responder rápidamente.
OTROS MÉTODOS PARA EL PLANIFICADOR
Además del uso de las prioridades, la clase Thread implementa otros métodos con los que se
puede conseguir algo de control sobre el comportamiento que toma el planificador respecto a hebras
independientes. Esos métodos son:
• void sleep(long milis): duerme a la hebra durante al menos <milis> milisegundos.
Transcurrido el tiempo, la hebra pasará a estar preparada para ejecutarse, pero eso no implica
que pase inmediatamente a hacerlo (dependerá del planificador), de ahí que pueda estar más
tiempo del que se especifica sin ejecutarse.
• void sleep(long milis, int nanos): duerme a la hebra durante al menos
<milis> milisegundos y <nanos> nanosegundos. Sirve como una implementación con más
precisión que la anterior. En la práctica, la implementación actual no permite tanta parecisión, y
se limita a redondear los <milis> en función de los <nanos> y a llamar al método sleep
anterior con el valor obtenido.
• void yield(): cede el procesador. Pasará a ejecutarse de nuevo el planificador, que decidirá
qué otra hebra ejecutar.
Los dos métodos sleep(...) anteriores pueden ocasionar la generación de la excepción
InterruptedException. Ésta salta si otra hebra llama al método interrupt() de la hebra que
está dormida. Cuando eso ocurre, la hebra que estaba dormida pasa inmediatamente a estar preparada, de
modo que el planificador volverá a tenerla en cuenta. Para que la hebra que ejecuta el sleep(...)
pueda diferenciar si ha pasado a ejecutarse por culpa de que el tiempo solicitado ha pasado, o porque
alguien ha interrumpido su sueño, en este último caso el método retornará con la excepción.
Con el método yield() puede verse qué estrategia sigue el planificador. Para ello se hace que
cada hebra escriba su identificador, e inmediatamente ceda el control al planificador.
El ejemplo siguiente hace eso, además de añadir una hebra extra que escribe su identificador y se
duerme un tiempo aleatorio, para comprobar también el funcionamiento de sleep().
package Ejemplos;
import java.lang.Math;
public class YieldSleep extends Thread {
int num;
boolean yield;
static YieldSleep[] hebras = new YieldSleep[10];
public YieldSleep(int c, boolean yield) {
num = c;
this.yield = yield;
}
8
Concurrencia en Java
public void run() {
while (true) {
if (yield)
Thread.currentThread().yield();
else {
try {
Thread.currentThread().sleep(
(long)(Math.random()*1000.0));
} catch (Exception e) {}
}
system.out.println(num);
}
}
public static void main(String[] args) {
// Establecemos la hebra actual como de maxima prioridad, para
// que no pueda comenzar a ejecutarse ninguna de las hebras
// hasta que no esten todas creadas.
Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
for (int c = 0; c < hebras.length; c++) {
hebras[c] = new YieldSleep(c, (c != hebras.length - 1));
hebras[c].setPriority(Thread.NORM_PRIORITY);
hebras[c].start();
}
system.out.println("Todas creadas");
}
}
Este ejemplo solo sirve para ver el modo en el que la máquina virtual realiza la planificación si
se ejecuta en una consola. Si se ejecuta desde un applet o una aplicación, existirá una hebra añadida, la
del AWT, con mayor prioridad, y que saltará cada vez que alguna hebra trate de mostrar su identificador
en un cuadro de texto, por ejemplo. El planificador tendrá por lo tanto una hebra más, por lo que el
resultado puede ser desconcertante.
CLASES Y EXCEPCIONES
Antes de continuar, haré un rápido recordatorio sobre la estructura de clases que propone Java.
Java tiene dos tipos de datos: los primitivos, y las referencias. Los primeros son los tipos básicos,
(byte, short, char, int, long, float y double). Los segundos son todos los objetos, ya sean
instancias de clases, o arrays.
Se define una clase Object de la que deben heredar todas las demás. Cualquier otra clase que
se defina tendrá, como última superclase, a la clase Object. Por tanto, todos los métodos que se definen
en la clase Object son poseídos por todos los objetos. En concreto, la clase Thread tendrá los mismos
métodos que posee la clase Object, y otros nuevos. Uno de los métodos heredados será, por ejemplo,
toString(), que devuelve una cadena que simboliza al objeto. Este método suele ser sobreescrito en
las clases hijas, y es interesante, entre otras cosas, para implementar tablas hash.
Otra clase curiosa es la clase Class. Hay un objeto de dicha clase por cada clase. Es decir,
tenemos un objeto de la clase Class para la clase String, otro para la clase Thread, etc. Con esos
objetos podemos obtener alguna información sobre la clase, como los métodos que tiene, sus
constructores, y cosas así. También puede utilizarse para crear nuevas instancias (objetos) de esa clase.
Por supuesto, y aunque resulte paradójico por la nomenglatura, la clase Class hereda de la clase
Object.
Concurrencia en Java
9
Cuando se lanza una excepción, ésa excepción es en realidad un objeto. Todos los objetos que se
lanzan como excepciones deben tener como clase a una que sea subclase de la clase Throwable.
Naturalmente, ésta lo es a su vez de la clase Object.
De la clase Throwable heredan dos subclases: la clase Error, y la clase Exception. Las
clases que heredan de Error simbolizan excepciones graves, que en general fuerzan la finalización
drástica de la ejecución. Por ejemplo, excepciones que avisan de falta de memoria para crear nuevos
objetos, desbordamientos de pila, o errores internos de la implementación de la máquina virtual (estos
últimos no deberían darse nunca). También incluyen aquellas excepciones que aparecen por
compilaciones inconsistentes, es decir cuando desde una clase A se llama a métodos de otra clase B que
ya no existen, pero que existieron en el momento en el que se compiló A. Estas excepciones pueden saltar
en cualquier momento, y no suelen ser manejadas por los programas.
De la otra, la clase Exception, hereda otra clase, la clase RuntimeException. De ella
heredan a su vez el resto de las excepciones que puede generar la máquina virtual en tiempo de ejecución.
Entre ellas están las excepciones que avisan de división por 0, de uso de una referencia a null, o de
intento de acceder a posiciones de un array inválidas, que están más allá de su final. Estas excepciones
suelen ser controladas más a menudo por los programas y, como las anteriores, pueden aparecer en
cualquier punto del código.
El programador puede definirse sus propias excepciones para avisar de situaciones incorrectas.
Sun aconseja que esas excepciones de usuario hereden de la clase Exception. Estas excepciones no
pueden saltar en cualquier momento, únicamente en los lugares que se definan. Todos los métodos que
puedan propagar excepciones de este tipo deben indicarlo en su definión. Gracias a eso se consigue que el
compilador pueda forzar al programador a un cierto control de errores.
Cuando el programador quiere controlar todas las excepciones posibles que puedan saltar dentro
de un bloque de código, sin importarle de qué tipo sean, habitualmente insertará un código como el
siguiente:
try {
// Código a controlar
} catch (Exception e) {
// Control de excepciones
}
No suele utilizarse dentro del catch la clase Throwable en lugar de la clase Exception (a
pesar de que es más general), pues incluye a las excepciones que heredan de Error, y estas suelen ser lo
suficientemente graves como para que no sea aconsejable manejar.
La estructura de clases descrita será por lo tanto:
Object
Thread
Throwable
Error
...
Class
...
Exception
RuntimeException
...
Excepciones
de usuario
10
Concurrencia en Java
CERROJOS
Todos las referencias (es decir todos los objetos y arrays) tienen un cerrojo asociado. Un cerrojo
solo puede estar bloqueado por una hebra en cada momento. Se implementa realmente como un contador,
considerandose que el cerrojo está libre cuando está a 0, y bloqueado en caso contrario.
Una hebra es dueña del cerrojo si lo ha bloqueado por última vez, pasando su valor de 0 a 1. Esa
hebra será la única que podrá modificar su valor, ya sea incrementandolo o decrementandolo
(bloqueandolo o desbloqueandolo). El cerrojo quedará completamente libre cuando vuelva a recuperar el
valor 0. Es decir quedará libre cuando la hebra que lo posea lo desbloquee tantas veces como lo haya
bloqueado.
Cuando una hebra trata de obtener un cerrojo que está bloqueado, ésta quedará suspendida hasta
que el cerrojo sea liberado. Cada cerrojo tiene, por lo tanto, una lista de hebras suspendidas a la espera de
que se libere. Cuando esto ocurre, cualquiera de ellas es despertada, y se la cede el cerrojo. La
especificación de la máquina virtual no fuerza a la exisrencia de ningún tipo de planificación en la
asignación de cerrojos a las hebras que estaban suspendidas, por lo que cada implementación es libre de
utilizar una cola, usar las prioridades, o simplemente elegirla aleatoriamente.
Los cerrojos son una capacidad proporcionada por la máquina virtual Java, pero no pueden ser
accedidos de forma directa desde el lenguaje de programación. Los cerrojos los utiliza la propia máquina
virtual, y el compilador, pero el lenguaje impide el acceso a ellos por parte del programador.
EXCLUSIÓN MÚTUA
Los cerrojos pueden utilizarse para garantizar exclusión mútua, es decir para que las hebras
accedan a los recursos de forma controlada, de modo que solo una hebra sea dueña de un recurso en un
determinado momento.
El lenguaje Java permite al programador indicar la ejecución en exclusión mútua de partes del
código. Para eso tanto el compilador como la máquina virtual trabajan de manera conjunta para que la
exclusión mútua se realice.
Gracias a esto, se permite un control sincronizado de acceso a recursos (por ejemplo variables),
pero evitando el peligro del uso incorrecto de los cerrojos (por ejemplo que una hebra olvide liberarlos)
pues éstos no pueden ser manejados de forma directa por el programador.
El lenguaje Java proporciona dos modos principales de sincronización. La primera son los
métodos sincronizados. En la implementación de una clase, pueden especificarse algunos (o todos) los
métodos como sincronizados (synchronized). Cuando una hebra realiza una llamada a un método
sincronizado, antes de que se comience a ejecutar el código del método, la hebra debe conseguir bloquear
el cerrojo asociado con el objeto this que se está utilizando. Gracias a eso, solo una hebra puede estar
ejecutando el código de ese método. Más aún, solo una hebra puede estar ejecutando alguno de todos los
métodos sincronizados de un objeto, pues todos, antes de comenzar a ejecutarse, deben bloquear el mismo
cerrojo. Hay que destacar que esto es a nivel de objetos, pues cada uno tendrá un cerrojo. Dos hebras
podrán estar ejecutando el mismo método sincronizado al mismo tiempo si son de objetos diferentes
(aunque de la misma clase). Esto se debe a que la entrada en un método sincronizado bloquea el cerrojo
del objeto this.
También los métodos de clase (static) pueden ser sincronizados. En ese caso no hay objeto
this cuyo cerrojo bloquear. Lo que se hará será bloquear el cerrojo del objeto de la clase Class
asociado con la clase a la que pertenece el método estático.
•
•
•
Resumiendo:
Los métodos no sincronizados se ejecutan directamente, sin esperar a poder bloquear ningún
cerrojo.
Los métodos de clase sincronizados se ejecutan una vez que la hebra ha bloqueado el cerrojo del
objeto Class asociado con la clase.
Los métodos de objeto sincronizados se ejecutan una vez que la hebra ha bloqueado el cerrojo
del propio objeto this.
Concurrencia en Java
11
Por tanto, de un objeto podrán estar ejecutandose simultáneamente un método de clase sincronizado,
un método de objeto sincronizado, un número cualquiera de métodos sin sincronizar, ya sean de clase o
de objeto.
Como se ha dicho, no hay un orden establecido en la entrega de cerrojos a las hebras que están
esperando a bloquearlos. Por lo tanto, tampoco habrá un orden de ejecución de méotods sincronizados
cuando hay varias hebras esperando.
Debido a que una hebra puede bloquear tantas veces como quiera un mismo cerrojo sin
suspenderse, es posible llamar a métodos sincronizados de un objeto desde otros métodos sincronizados
del mismo objeto. Antes de comenzar a ejecutarse el código del nuevo método se tratará de bloquear el
cerrojo. Éste estará ya bloqueado, pero por la misma hebra que trata de bloquearlo. El cerrojo aumentará
por lo tanto en uno su contador sin que la hebra se suspenda. Cuando la ejecución del método finalice, el
contador se decrementará, de modo que a la vuelta el estado del cerrojo será la misma que antes de llamar
al método.
Por último, un método sincronizado puede ser sobreescrito en las subclases como no
sincronizado. De ese modo las llamadas al nuevo método no estarán controladas por el cerrojo del objeto.
Si lo estarán, no obstante, las llamadas al método super.metodo(...). De igual modo, pueden
sobreescribirse métodos no sincronizados por otros sincronizados en las clases hija.
Los métodos sincronizados se utilizan para conseguir un acceso controlado a los objetos.
Habitualmente se utilizan en objetos con estado, de modo que se evita que varias hebras traten de
modificar el estado de forma simultánea, lo que ocasionaría generalmente que el objeto se quedara en un
estado indefinido. También se suelen hacer en este caso sincronizados los métodos de consulta del estado,
para que no puedan ser consultados durante un cambio, en el que el objeto se encuentra en un estado
intermedio inválido.
También se suelen utilizar métodos sincronizados en objetos que deban realizar las cosas en
orden. Por ejemplo, son sincronizados los métodos en los que se envían bytes a través de un socket, para
evitar problemas si dos hebras tratan de escribir simultáneamente en él.
Un ejemplo sencillo es un objeto almacén, de los habituales productores/consumidores:
class almacen {
int[] almacen = new int[16];
int primero = 0;
int cuantos = 0;
synchronized public int coger() {
int aux;
if (cuantos == 0)
return (-1);
else {
cuantos--;
aux = primero;
primero = (primero + 1) & 15;
return(almacen[aux]);
}
}
synchronized public boolean dejar(char val) {
if (cuantos == 16)
return false;
int aux;
aux = (primero + cuantos) & 15;
cuantos++;
almacen[aux] = val;
return true;
}
} // almacen
12
Concurrencia en Java
Gracias a la sincronización, la ejecución en paralelo de varias hebras productoras y
consumidoras no daña al objeto.
La otra forma de control de exclusión mútua que proporciona Java son los bloques
sincronizados. Éstos se utilizan para hacer sincronizados secciones de código dentro de un método, pero
no un método entero
La forma general es:
...
synchronized(objeto) {
// parte del código que se ejecuta en exclusión mutua.
}
...
Cuando se va a entrar en el bloque del código, la hebra intentará obtener el cerrojo del objeto, de
igual modo que si se ejecutara un método sincronizado de dicho objeto. Si el cerrojo está ocupado, la
hebra se suspenderá como de costumbre, y no existirán preferencias una vez que el cerrojo quede libre.
Es posible bloquear el cerrojo de cualquier objeto dentro de cualquier método. Es decir no es
necesario que el objeto que se bloquea sea el objeto this del método en ejecución. En vez de eso, puede
bloquearse cualquier objeto.
Este método de sincronización puede utilizarse, por ejemplo, para hacer sincronizadas clases
cuyo código no podemos modificar, y que no lo son por no estar pensadas para ser usadas en programas
concurrentes. Para eso todas las llamadas a algún método de un objeto de la clase estará encerrada en un
bloque sincronizado con ese objeto. Naturalmente esto es bastante peligroso, pues es suficiente olvidar
encerrar una llamada en uno de esos bloques para que todo el programa pueda resentirse. Una solución
más segura es realizar una subclase de la original, con todos los métodos sincronizados. No obstante, se
dispone de la primera opción por si el uso de la clase no sincronizada es esporádico (una o dos veces en
todo el programa, y en sitios muy localizados) de modo que no merezca la pena realizar la subclase.
Un uso más acertado de los bloques sincronizados es para ejecutar en exclusión mútua código
que no pertenece claramente a un método, o para agrupar el acceso en exclusión mutua a varios recursos
con un único semáforo.
En este último caso, por ejemplo, podemos construir un objeto global de cualquier clase (lo
lógico sería, no obstante, de la clase Object para no desperdiciar memoria) simplemente para utilizar su
cerrojo. Éste lo podríamos usar, por ejemplo, para controlar el acceso en exclusión mutua a dos objetos
diferentes pero, tan relacionados, como para que sea peligroso acceder a cada uno de ellos de forma
simultánea. Si todos los accesos a cualquiera de los dos objetos se hace dentro de un método sincronizado
del objeto global, aseguraremos que en ningún momento se esté accediendo a los dos de forma
simultánea.
La ejecución de un bloque sincronizado no se comienza hasta que no se bloquea el cerrojo del
objeto en cuestión. Ese será el mismo cerrojo que el bloqueado cuando se ejecuta algun método
sincronizado del objeto. Por lo tanto, un bloque sincronizado no comenzará a ejecutarse mientras alguina
otra hebra esté ejecutando un bloque sincronizado guardado por el mismo objeto, o esté ejecutando un
método sincronizado del objeto.
SEÑALIZACIÓN
Mediante los mecanismos anteriores se puede evitar las interferencias debido a la ejecución
simultánea de varias hebras. También es posible un cierto mecanismo de comunicación entre ellas.
Para ello, todos los objetos implementan los métodos wait() y notify(). A grandes rasgos,
una hebra que llama al método wait() de un cierto objeto queda suspendida hasta que otra hebra llame
al método notify() del mismo objeto. Por lo tanto, todos los objetos tienen una lista de hebras que
están suspendidas a la espera de la llamada a notify() en ese objeto. El método notify() solo
despierta a una de las hebras suspendidas, y, como con los cerrojos, la máquina virtual no obliga a la
existencia de una planificación, por lo que cada implementación tiene libertad para decidir a cual
despertar. Si se llama a notify() y no hay ninguna hebra suspendida esperando, la llamada no hace
nada.
Concurrencia en Java
13
Estos métodos están pensados para avisar de cambios en el estado del objeto a las hebras que
están esperando dichos cambios:
synchronized void doWhenCondition() {
while (!condicion) wait();
...
}
synchronized void changeCondition() {
...
notify();
}
En concreto, en el ejemplo de los productores/consumidores se podría utilizar
wait()/notify() para no finalizar la ejecución del método hasta que no se ha añadido el elemento o
hasta que no se ha obtenido. En la primera versión, cuando no había elementos y un consumidor
solicitaba uno, se devolvía –1, avisando de que no había nada que dar. La versión con wait() y
notify() podría evitar esto. Para eso, cuando un consumidor no puede obtener ningún elemento
llamaría a wait() y se quedaría suspendida. Dentro del código de insertar elemento usado por las hebras
productoras habrá una llamada a notify() de modo que cuando se añada algún elemento se despertará
a una posible hebra consumidora.
No obstante, aquí aparece un problema. Tanto el método que consume como el que produce son
sincronizados. Cuando se ejecuta el wait(), el cerrojo del objeto está bloqueado, por lo que, en
principio, cualquier llamada al método productor suspenderá a la hebra. Eso originará que no sea posible
llamar a notify(), y se produzca un interbloqueo.
Debido a que la finalidad de los métodos wait() y notify() es la explicada anteriormente,
el comportamiento se modifica ligeramente. Todas las llamadas al método wait() de un objeto deben
estar ejecutadas por una hebra que posea el cerrojo del objeto (ya sea desde dentro de un método o un
bloque sincronizado). Además, la ejecución de dicho método se compone de la realización de dos
operaciones de forma atómica: la liberación del cerrojo del objeto, y la suspensión de la hebra. Ambas
deben realizarse seguidas, de forma inseparable.
Del mismo modo, todas las hebras que llamen al método notify() deberán también estar en
posesión del cerrojo del objeto. La llamada al método despierta una de las hebras en espera. Ésta no podrá
comenzar a ejecutarse directamente, pues necesitará volver a bloquear el cerrojo que había liberado
anteriormente. Por lo tanto, la hebra pasará de estar suspendida a la espera de un notify()a estar
suspendida a la espera de conseguir el cerrojo del objeto, que tendrá bloqueado en ese momento la hebra
que ha llamado al notify().
Como siempre, la máquina virtual no exige la existencia de prioridades de ningún tipo para esas
hebras que deben recuperar el cerrojo que cedieron amablemente. De hecho, antes de que la hebra que
ejecutó el wait() comience a ejecutarse, la condición podría modificarse (por parte de alguna otra
hebra), y pasar a no cumplirse de nuevo. Debido a ello, es aconsejable utilizar la construcción
while(!condicion)
wait();
en lugar de
if (!condicion)
wait();
pues con el while repetiremos el wait() y no acabaremos hasta no estar completamente
seguros de que la condición se cumple una vez que hemos recuperado el cerrojo.
14
Concurrencia en Java
Como ya se ha dicho, es necesario que la ejecución del wait() sea atómica, aunque esté
compuesta por dos operaciones. Esto es debido a que el programa puede fallar estrepitosamente si justo
después de que el código del wait() libere el cerrojo (y antes de suspender la hebra metiendola en la
lista de hebras en espera) otra hebra obtiene el cerrojo y llama al método notify(). Esa llamada no
tendrá efecto, pues aún no hay hebras suspendidas, y la hebra que llamó al wait() pasará a estar
suspendida a la espera de un notify() que llegó demasiado pronto y que, quizá, no vuelva a llegar.
Debido al comportamiento de los métodos wait() y notify(), resulta peligroso realizar
clases sincronizadas que actúan de puente de otras que también lo son. Más concretamente, imaginemos
que, por cualquier razón, creamos una nueva clase que envuelve al almacén anterior (que usaba
señalización). Esa nueva clase simplemente pasa las solicitudes a un objeto interno de la clase almacén:
class puenteAlmacen {
Almacen almacenInterno = new Almacen();
synchronized public int coger() {
return almacenInterno.coger();
}
synchronized public boolean dejar(char val) {
return almacenInterno.dejar(val);
}
} // puenteAlmacen
Así expuesta, la clase puenteAlmacen no tiene ninguna razón de existir, pero podría darse el
caso de que fuera necesaria la implementación de una estructura de este tipo en alguna otra situación más
lógica.
Aunque no lo parezca, en esta implementación hay interbloqueo. Si una hebra consumidora
llama a coger() (naturalmente de la clase puenteAlmacen, unica forma de acceder al almacen interno),
el cerrojo del puente se bloqueará. Luego se realizará una llamada al método coger() de la clase
Almacen, que bloqueará a su vez su cerrojo. Debido a que todavía no hay ningún elemento, se llamaría a
wait(). Esto liberaría el cerrojo del objeto interno (el almacén), pero no del externo (el
puenteAlmacen). Cualquier hebra productora que intente llamar a dejar(...) quedará suspendida
a la espera de que se libere este último cerrojo, que no se liberará nunca porque no hay forma de llamar al
método notify() del objeto almacén, al ser éste privado al objeto puente. Existe por lo tanto peligro en
el uso de objetos privados con señalización interna desde otras clases también sincronizadas, que habrá
que controlar.
MÉTODOS DE SEÑALIZACIÓN
En realidad, el método wait() tiene varios hermanos, y el método notify() también. Todos
ellos son finales:
•
•
•
•
•
wait(long milis): espera hasta una notificación, o hasta que pasen <milis>
milisegundos. Si el parámetro es 0, la espera es infinita.
wait(long tiempo, int nanos): igual que la anterior, pero con precisión de
nanosegundos.
wait(): igual que wait(0). Es la explicada anteriormente.
notify(): despierta a una hebra de las que están esperando.
notifyAll(): despierta a todas las hebras que están esperando.
Todas las variantes de wait(...) pueden lanzar la excepción InterruptedException, al
igual que ocurría con sleep(). La excepción se lanzará si se llama al método interrupt() de la
hebra que llamó al wait(...).
notify() y notifyAll() son semejantes, con la diferencia de que notifyAll() despierta a
todas las hebras. Además, notify() es atómica, mientras que notifyAll() no. Al llamar a esta
última, todas las hebras suspendidas pasarán a estar preparadas para la ejecución, y competirán todas por
conseguir el cerrojo que liberaron en la llamada a wait(), sin que exista ningún tipo de preferencia por
ninguna de ellas.
Concurrencia en Java
15
Se aconseja más el uso de notifyAll() que el de notify(). Si el programa está diseñado de tal
forma que se asegura que sólo una hebra estará esperando a un notify() cual se use es indiferente. Sin
embargo si no es así, es preferible utilizar notifyAll(). Más aún, es aconsejable utilizar siempre
notifyAll(), pues no puede saberse con seguridad si en el futuro siempre habrá una única hebra, o
será necesario modificar el comportamiento y cabrá la posibilidad de que haya más. Si en un momento
dado hay más de una hebra y fuera necesario un notifyAll() en lugar de un notify() podrían
aparecer problemas bastante difíciles de entender.
Realmente el uso de notifyAll() está pensado para la posibilidad de que se utilice la espera para
que se cumplan más de una condición. Podríamos tener una hebra esperando a que se cumpla una
condición, y otra esperando a que se cumpla una diferente. Si se llama a notify() cuando un método
hace cierta una de esas condiciones, la máquina virtual podría despertar a la hebra que está a la espera del
cumplimiento de la otra condición, y al no cumplirse volvería a llamar a wait(), y la hebra que podría
ya ejecutarse seguirá dormida, quizá indefinidamente. Naturalmente en este caso es aún más importante el
uso de la estructura while(!condicion) wait(); en lugar de if(!condicion) wait();
INTERBLOQUEOS
La existencia de varias hebras, y el uso de exclusión mútua puede ocasionar la aparición de
interbloqueos, en el que dos o más hebras no pueden ejecutarse porque todas están esperando la liberación
de algún recurso poseído por alguna de las otras. Java no controla el interbloqueo, de modo que es
responsabilidad del diseñador de la aplicación evitarlo.
GRUPOS DE HEBRAS
Durante la ejecución de una aplicación, la máquina virtual ordena las hebras en grupos,
construyendose una estructura jerárquica, donde cada nodo del árbol será un objeto de la clase
ThreadGroup, que contendrá hebras, y quizá otros objetos ThreadGroup. Al iniciarse, la máquina
virtual crea el grupo “main”, que contiene a la hebra “main”, que ejecutará el método del mismo nombre.
A cada grupo de hebras se le puede establecer una prioridad, que actuará como cota superior a
las prioridades de todas las hebras del grupo. Cuando una aplicación se ejecuta normalmente, la prioridad
del grupo “main” es MAX_PRIORITY. Sin embargo, en las pruebas realizadas he visto que si lo que se
ejecuta es un applet dentro de un navegador, la prioridad del grupo es menor. Más concretamente se
limita a la prioridad establecida para la hebra de AWT (cuyo valor es 6, NORM_PRIORITY + 1).
Además, debido a las restricciones de seguridad, esa prioridad no puede modificarse, y tampoco pueden
crearse nuevos grupos (bueno, sí se puede, pero no pueden meterse nuevas hebras en ellos). De esa forma,
la máxima prioridad de las hebras dentro de un applet queda limitada, y el navegador tendrá reservadas
las prioridades mayores para uso propio, sin que los applets puedan interferir.
Cuando cualquiera de las hebras de un grupo finaliza su método run() por la llegada de una
excepción que no se captura, se llamará al método uncaughtException(...) del grupo al que
pertenece, recibiendo como parámetros la hebra que ha terminado abruptamente, y la excepción generada.
Habitualmente éste se limitará a realizar un printStackTrace(...), que muestra por la salida de
error información sobre la excepción. Los programas sofisticados podrían sobreescribir este método en su
propia subclase de ThreadGroup que contendrá a todas sus hebras, para mostrar las excepciones en una
ventana gráfica, por ejemplo.
OTROS MÉTODOS (DESACONSEJADOS)
La clase Thread dispone de otro grupo de métodos para controlar la ejecución, pero todos están
desaconsejados (deprecated).
El primero de ellos es el método stop(), que ocasiona el lanzamiento de la excepción
ThreadDeath en la hebra destino. La excepción se genera sea cual sea el método que está e jecutando la
hebra. La idea es que la excepción no se capture, de modo que vaya subiendo en la pila de llamadas de la
hebra destino, hasta llegar al método run(), que tampoco la capturaría, finalizando la ejecución de la
hebra. La excepción ThreadDeath es la única que es ignorada por el método
uncaughtException(...) de la clase ThreadGroup, de modo que el usuario no recibe ningún
mensaje en la salida de error por culpa de dicha escepción.
16
Concurrencia en Java
ThreadThead hereda intencionadamente de Error, en lugar de Exception, pues lo que se
quiere es que no sea capturada, y como existe el uso generalizado de la construcción:
try {
...
}
catch (Exception e) {
...
}
prefirieron que ThreadThead no heredara de Exception, a pesar de que no entra dentro de
la filosofía de la clase Error.
Una versión más general del método stop() es el método stop(Throwable), que ocasiona
el lanzamiento de la excepción especificada como parámetro. Por tanto, stop() es equivalente a
stop(new ThreadThead());.
Ambos métodos están desaconsejados porque son peligrosos. La generación de la excepción
puede aparecer en cualquier momento, incluso en momentos críticos en los que el código no está
preparado. Por ejemplo, es posible que salte dentro de un método sincronizado. La excepción ocasionará
la finalización abrupta del método, lo que podría ocasionar que el objeto se quedara en un estado
incorrecto, que podría ser visto por otras hebras, volviendo al sistema inestable.
Naturalmente una posible solución sería que en todos los métodos sincronizados o que peligran
ante la aparición de la excepción la capturaran, realizaran una limpieza rápida, y luego volvieran a
generar la misma excepción, para que se propague y la hebra termine.
Esta solución sin embargo es peligrosa, pues es fácil que el programador olvide realizar el
control. Más aún, podría darse el caso de que una segunda excepción ThreadDeath saltara cuando se
estaba tratando la primera, en cuyo caso el método saldría abruptamente antes de realizar la limpieza.
Una solución más aceptada es la utilización de una variable que indica a la hebra si debe detener
su ejecución:
void run() {
while(!acabar) {
...
}
}
/**
* Sustituto de stop();
*/
void acabar() {
acabar = true;
}
Para que la hebra responda de forma fluida, debería comprobar a menudo el valor de la variable
acabar.
Un peligro añadido del método stop(Throwable) es que puede ocasionar que un método
propague una excepción sin que el compilador pueda comprobarla. Por ejemplo:
void excepcionOculta() {
Thread.currentThread.stop(new MiExepcion());
}
El método excepcionOculta() ocasiona la propagación de una excepción (que no heredaría
ni de Error ni de RuntimeException) y que el compilador no puede controlar, es decir no puede
forzar a los programadores a que capturen la excepción en los lugares donde se llame al método (como
haría si se lanzara con throw), pues no es consciente de ese lanzamiento. Esto rompe con toda la
filosofía del compilador de forzar al programador a controlar los errores.
Concurrencia en Java
17
Por otro lado, el método suspend() detiene la hebra hasta que se llama a resume() de la
misma hebra. También están desaconsejados. Eso se debe a que suspend() no libera el cerrojo del
objeto si es llamado dentro de un método sincronizado. Por tanto el uso de suspend() y de resume()
dentro de un mismo objeto sincronizado ocasiona la aparición de interbloqueo.
El último es el método destroy(), que es el más radical de todos. Su llamada ocasiona la
destrucción completa de la hebra, sin liberar ninguno de los cerrojos que tuviera bloqueados. Debido a su
claro peligro, no está implementado. El JDK lo documenta, pero no hace nada. Curiosamente, está
desaconsejado su uso (¡aunque no hace nada!), pues se comportaría igual que un suspend() sin
resume().
LA MÁQUINA VIRTUAL
La máquina virtual es la que se encarga de interpretar y ejecutar el código binario generado por
los compiladores de Java. Gracias a la existencia de la máquina virtual, los ejecutables Java son
independientes de la plataforma. Pueden ser ejecutados en cualquier plataforma, siempre que ésta
disponga de una implementación de la máquina virtual.
Para lograr esto, Sun define la especificación de la máquina virtual, que deberán cumplir todas
las implementaciones para ser válidas. Esta especificación define una serie de comportamientos y
condiciones que deben cumplir todas las implementaciones, pero no se preocupa de la forma en la que
ésta se haga, de su diseño, etc.
La máquina virtual es muy abstracta. No es como la clásica máquina-p usada por Wirth para su
lenguaje Pascal. En esta última, se definen instrucciones de bajo nivel, que tienen una implementación
sencilla y rápida.
La máquina virtual de Java es mucho más abstracta. Dispone de 202 instrucciones
desbalanceadas, es fuertemente tipada, y dispone de instrucciones de muy alto nivel, para, por ejemplo,
crear nuevos objetos. Además controla las excepciones de forma nativa, y gestiona la memoria
directamente, de modo que es la propia máquina virtual la que se encarga de cargar e inicializar las clases
según se van necesitando, de controlar la recogida automática de basura, etc.
La mayor parte de las instrucciones son aritméticas. La máquina virtual es fuertemente tipada, de
modo que existen instrucciones duplicadas. Por ejemplo, existe la instrucción de suma para enteros, long,
flotantes y doubles de forma separada, cada una con su opcode. Del mismo modo existen instrucciones
para restar, multiplicar, dividir, realizar el resto, operaciones lógicas sobre enteros, comparaciones, etc.
También hay instrucciones tipadas para accesos a memoria (hay nada menos que 32 instrucciones load
distintas). También existen instrucciones de conversión entre tipos. Existen incluso dos instrucciones
diferentes que implementan de forma autómática la instrucción switch() de alto nivel.
Además de los tipos básicos, también maneja de forma directa arrays y referencias a objetos.
Existen instrucciones para control de arrays, para crearlos, para obtener su tamaño, para acceder a sus
elementos, etc. También se controlan las referencias, existiendo una instrucción para crear nuevos
objetos, cuatro para llamar a los métodos a partir de su nombre, otras tantas para acceder a los atributos,
etc.
Curiosamente, no existen instrucciones para liberar objetos o arrays, pues es la propia máquina
virtual la que se encarga de la recolección de basura, por lo que no es necesario la liberación explícita de
objetos.
18
Concurrencia en Java
ESTRUCTURA DE LA MÁQUINA VIRTUAL
La máquina virtual consta de varios bloques:
Por hebra
Por JVM
Heap
Pila de
Frames
PC
Área de
Código
(text)
Tabla de
constantes
Como se muestra, todas las hebras comparten el mismo área de código. En él, se almacena el
código e información de todas las clases e interfaces, pero de forma separada. La información de cada
clase o interfaz es independiente; incluso los métodos de cada clase están separados, comenzando cada
uno en el offset 0. Por tanto, el área de código está dividido internamente en partes lógicas más pequeñas.
Esta división hace que el área de código no tenga que ser contigua en la memoria física del sistema que
ejecuta la máquina virtual.
Cada clase o interfaz tiene asociada una tabla de constantes con información referenciada dentro
de su código. Almacena por ejemplo el valor inicial de los campos, o cadenas con sus nombres o los de
los métodos.
El heap es donde se amacenan las instancias de las clases y de los arrays, es decir, los objetos
dinámicos. Es una zona de memoria gestionada mediante el recogedor automático de basura, de modo que
la máquina virtual directamente está encargada de gestionarla, y liberar el espacio ocupado por objetos y
arrays cuyas referencias se han perdido.
El heap es, naturalmente, accedido por la máquina virtual. Los programas binarios Java hacen
referencia a posiciones dentro del heap, pero es la propia máquina virtual la que realiza la indexación, y
obtiene la información. Por tanto, el heap puede estructurarse de cualquier forma; incluso no existe
necesidad de que sea contiguo en la memoria física del sistema. Podría estar almacenado en distintos
bloques, de tal forma que luego la máquina virtual acceda a los bloques correctos a partir de las
referencias de los programas.
En general, la máquina virtual da libertades en algunos aspectos, como puede ser el anterior de la
contigüedad del heap o el área de código, y como algunas cosas en el acceso a memoria. No obstante una
implementación concreta de la máquina virtual no tiene necesidad de utilizar esas libertades. En este
útlimo caso, por ejemplo, no hay necesidad, naturalmente, de que una implementación utilice la
posibilidad de tener el heap discontinuo, de modo que puede ser almacenado en un solo bloque. De
hecho, tanto el heap como el área de código pueden ser estáticos (con un tamaño fijo), o dinámicos, de
modo que la máquina virtual vaya modificando sus tamaño según lo va requiriendo el ejecutable.
Habitualmente, el programa que implementa la máquina virtual aceptará parámetros para especificar el
tipo de heap o área de código y su tamaño máximo.
Esta similitud entre el área de código y el heap puede llevarse aún más allá. Algunas
implementaciones de la máquina virtual podrían considerar que el propio área de código está dentro de la
misma estructura que el heap, de forma que es controlado por de forma semejante. Esto conllevaría a que
también el código estaría gestionado por el recolector automático de basura, de modo que la información
sobre clases que ya no se utilizan podría ser liberada.
Además de el área de código y el heap, que son globales a todas las hebras, la máquina virtual
almacena un contador de programa (PC) por cada hebra. Éste le indica cual es la siguiente instrucción a
ejecutar por la hebra.
Concurrencia en Java
19
Se ha dicho que el área de código está fuertemente dividido, hasta el extremo de que el código de
cada método es completamente independiente. En oposición a lo que ocurre en los procesadores reales,
cada método en la máquina virtual comienza de nuevo en el offset 0. El contador de programa apunta a
una posición de la zona de código. Es simplemente un puntero, por lo que no es suficiente para conocer
cual es el método que se está ejecutando. Para eso, entre otras c osas, está la pila de frames. En cada frame
se almacena información sobre el método que se está ejecutando, y de qué clase es. Cada vez que se llama
a un método, se construye un nuevo frame en la pila, que referenciará al nuevo método. Cuando éste
finalice, el frame se extraerá, de tal forma que la máquina virtual volverá a ejecutar el método que hizo la
llamada.
La máquina virtual Java es semejante a una máquina-p para realizar operaciones. Aunque no
aparece en la estructura anterior, la máquina virtual dispone de una pila de operandos. Así, cuando se
ejecuta una operación aritmética o lógica, los operandos se recogen de la cima de la pila de operandos, y
el resultado se apila en ella. Incluso las instrucciones para saltar a métodos, reciben la información sobre
el método a ejecutar de la pila de operandos.
Las variables locales de los métodos y los parámetros, por el contrario, no van en la pila de
operandos. En lugar de eso, la máquina virtual dispone de otra estructura, que tampoco aparece en el
diagrama anterior, donde se almacenan los parámetros, y las variables locales.
En realidad la pila de operandos, y la estructura para los parámetros y las variables locales son
dependientes de cada método. Es decir, cada método que está en “ejecución” (todos aquellos que están
pendientes de terminarse, y que están esperando a que otro método al que han llamado finalice) tendrá su
propia estructura con sus variables locales y parámetros, así como su propia pila de operandos.
El lugar lógico donde almacenar esto es en la pila de frames. Según esto, cada frame tendrá la
estructura siguiente:
Array de
variables
locales y
parámetros
Pila de
operandos
Referencia
a la tabla de
constantes
El método que se está ejecutando es conocido gracias a una referencia a la tabla de constantes,
que tendrá la cadena con el nombre de la clase y el método.
El frame se construye en el momento en el que se realiza la llamada al método. La construcción
necesita saber cuanto espacio dejar para el array de variables locales y parámetros, y para la pila de
operandos. Es claro que el espacio necesario para el primero se conoce en tiempo de compilación, de
modo que cada método tiene asociada información sobre sus características, entre las que se encontrará el
tamaño que necesita para su array de variables locales y parámetros.
De lo que no es tan fácil darse cuenta es de que realmente también el tamaño de la pila de
operandos se conoce en tiempo de compilación. Para eso bastará ver cuanto espacio necesita cada
expresión para ser calculada, y dejar el máximo valor obtenido entre todas ellas. Por tanto también ese
valor estará almacenado en la información que acompaña a cada método. Llama la atención que los
habituales problemas de desbordamiento de pilas (por recursión infinita, por ejemplo) se traslade de la
pila de operandos a la pila de frames, que es la que se encarga de almacenar los métodos cuya ejecución
está pendiente.
En resumen, y de forma general, veremos ahora la colaboración entre el código del programa y
la máquina virtual para la llamada a un método.
En primer lugar, los ficheros “ejecutables” son mucho más que una serie de opcodes. Cada
fichero .class tiene información detallada sobre la propia clase, como por ejemplo las clases de las que
hereda, si es un interfaz o una clase, etc. También tiene información sobre los nombres de sus atributos,
sus tipos, y sus posibles valores iniciales. Y, por último, tiene los métodos.
20
Concurrencia en Java
Por cada método se guarda mucha información. Además del código, se guarda el nombre del
propio método, el tamaño que necesita para su array de variables locales y parámetros, el espacio para la
pila de operandos, una tabla para gestionar la captura de excepciones, si es o no sincronizado, quizá
información de depuración, etc. Para lo que ahora nos preocupa, nos es suficiente con el tamaño del array
y de la pila de operandos.
Cuando se necesita llamar a un método, el programa primero meterá en la pila de operandos del
método que se está ejecutando la referencia al objeto del que se quiere llamar a un método. Después
apilará todos los parámetros. Y por último, ejecutará una de las 4 posibles instrucciones para llamar a un
método. Esas instrucciones tienen un operando, que será un índice dentro de la tabla de constantes de la
clase cuyo código está en ejecución. Esa entrada de la tabla, contendrá información sobre el nombre del
método que quiere llamarse, y sobre la clase a la que pertenece ese método. La clase es necesaria por si se
desea llamar a métodos de la superclase, por ejemplo. El nombre del método no será directamente el
nombre que ha escrito el programador, sino que estará ligeramente cambiado para que incluya
información sobre los tipos de los parámetros que espera (para soportar sobrecarga).
Una vez que se ha hecho todo eso, el trabajo pasa a ser de la máquina virtual. Se encargará de
comprobar que la referencia es válida, que pertenece a la clase que se le dice, si la clase aún no se ha
cargado hacerlo, comprobar si el método existe, etc.
Tras todas las comprobaciones oportunas, se realiza la llamada en sí. Para eso, se reserva espacio
para un nuevo frame, utilizando la información asociada al método que se está llamando. Una vez hecho
eso, se extraen de la pila la referencia del objeto y todos los parámetros, y se copian en el array de
variables locales y parámetros. Gracias a eso, los métodos tienen acceso a la referencia a this en la
primera entrada de la tabla, y a los parámetros en las siguientes.
Una vez hecho esto, y antes de comenzar a ejecutar el método, se comprueba si éste es
sincronizado. Si es así, antes de comenzar la ejecución se intenta obtener el cerrojo del objeto. De eso, y
de suspender la hebra si no se puede, se encarga directamente la máquina virtual. El código del programa
se limita a incluir la instrucción que llama al método.
Como se ha dicho, hay cuatro instrucciones diferentes de la máquina virtual para ejecutar un
método, según sus características, ya sea estático, un constructor, inicializador, de interface, etc. Los
métodos estáticos no tendrán el primer parámetro implícito, this.
En la descripción anterior, se ha pasado por alto la posibilidad de que el método a ejecutar sea
nativo. En ese caso el comportamiento es diferente, y no lo describiremos aquí.
Las entradas en el array de variables y en la pila de operandos sirven para enteros, flotantes de
simple precisión y referencias. La máquina virtual no maneja de forma directa byte’s, short’s y
char’s, de modo que estos son convertidos a enteros, ocupando también una entrada. Los long’s y
flotantes de doble precisión ocuparán dos entradas en el array y la pila en lugar de uno.
Un método puede acabar por dos causas. La primera es la ejecución de una de las instrucciones
return. Hay distintas instrucciones de ese grupo, en función del valor que devuevan. Cuando se
encuentra esa instrucción, la máquina virtual recoge el valor de la pila de operandos, destruye el frame de
la cima de la pila de frames, y apila el valor que se ha devuelto en la pila de operandos del frame que
quede en la cima. De ese modo, el que llamó al método verá que la referencia al objeto y los parámetros
se convierten en el resultado, al igual que ocurre con el resto de las funciones aritméticas. Naturalmente
también existe una instrucción return que no devuelve nada, para los métodos void. En ese caso, no
se apilará nada en el frame que quede en la cima.
Como es lógico, antes de terminar la ejecución del método, si éste era sincronizado, se libera el
cerrojo del objeto cuyo método se estaba ejecutando.
La otra forma de finalizar un método es por culpa de la aparición de una excepción que no se
captura. A grandes rasgos, cada método contiene entre su información una tabla de excepciones que
pueden saltar durante su ejecución, y la posición del código que lo maneja. Cuando salta una excepción
(debido a que la propia máquina virtual la lanza durante la ejecución de una instrucción o por la ejecución
explícita de una instrucción athrow en el código del programa) la máquina virtual consultará esa tabla.
Si encuentra información sobre como manejarla, llamará al código que contiene el controlador. Si no,
tendrá que finalizar el método. Para eso liberará el cerrojo si el método era sincronizado, desapilará el
frame de la pila, y el resultado de la instrucción de llamada a método que se ejecutó en el método anterior
finalizará con una excepción, comenzandose de nuevo el proceso.
Concurrencia en Java
21
Naturalmente, si al finalizar un método no quedan más frames en la pila, la hebra finalizará,
llamando antes al método uncaughtException(...) si el método acabó con una excepción. Este
método es ejecutado por la propia hebra que termina, como puede verse con el programa:
public class FinHebraConExcepcion implements Runnable {
public static void main(String[] args) {
MiGrupoDeHebras grupo = new MiGrupoDeHebras("Grupo");
new Thread(grupo, new FinHebraConExcepcion(),
"\"Hebra con excepción\"").start();
}
public void run() {
throw new java.lang.NullPointerException();
}
}
class MiGrupoDeHebras extends java.lang.ThreadGroup {
MiGrupoDeHebras(String nombre) {
super(nombre);
}
public void uncaughtException(Thread t, Throwable e) {
System.out.println("uncaughtException ejecutado por la hebra "
+ Thread.currentThread().getName() +
" debido a la finalización de la hebra " +
t.getName() + " por la excepción " + e);
}
}
Este programa no puede meterse en un applet, pues los navegadores no permiten crear hebras en
grupos diferentes al de la hebra principal generando una excepción de seguridad si se intenta, tal y como
ya se comentado.La salida que genera es:
uncaughtException ejecutado por la hebra “Hebra con excepción” debido
a la finalización de la hebra “Hebra con excepción” por la excepción
java.lang.NullPointerException
Synchronized(o)
En la explicación anterior queda dicho que el control del cerrojo de los objetos es manejado
directamente por la máquina virtual en los métodos sincronizados. El compilador no tiene que
preocuparse de añadir código específico para esos casos. Naturalmente, será, no obstante, el encargado de
construir los ficheros de clase con la información adecuada en los métodos sincronizados para que la
máquina virtual bloquee el cerrojo cuando alguien los llame.
Para los bloques de código sincronizados, mediante la estructura syncrhonized(obj) {}
la cosa es diferente. La máquina virtual proporciona dos instrucciones, monitorenter y
monitorexit, que recogen de la pila de operandos la referencia a un objeto cuyo cerrojo bloquear y
liberar, respectivamente. Cuando se llama a monitorenter y el cerrojo está bloqueado, es la máquina
virtual la que se encarga de suspender a la hebra hasta que el cerrojo se libere y pueda ser obtenido, tal y
como ocurría en la llamada a métodos sincronizados.
Es responsabilidad del compilador construir las estructuras syncrhonized(obj)
correctamente, de modo que el cerrojo sea liberado en todas las posibles salidas del bloque. Es decir, la
primera instrucción del bloque será un monitorenter, y la última será, posiblemente, un
monitorexit. Pero el compilador también debe controlar todas las otras posibles formas de finalizar el
bloque, por ejemplo por la existencia de un break en su interior, o incluso vigilando la posibilidad de
aparición de excepciones no capturadas. Todo esto es problema del compilador.
22
Concurrencia en Java
La máquina virtual no confía en que los ficheros con las clases que tiene que cargar sean
correctos, de modo que antes de admitir las clases como válidas las somete a innumerables pruebas para
comprobar su corrección. Comprueba, en primer lugar, la validez del formato del fichero. Además,
analiza el código de los métodos, por si hubiera opcodes inválidos y mira si la tabla con la información
sobre las excepciones capturadas es correcta.
Hace todavía algo más sofisticado. Como se ha dicho, la máquina virtual está fuertemente tipada,
de modo que existe, por ejemplo, una instrucción para sumar enteros, otra para flotantes, etc. Cada vez
que la máquina virtual tiene que ejecutar una de estas instrucciones, los dos valores en la cima de la pila
deben ser del tipo esperado. Una forma de asegurarse de eso es añadir en la pila de operandos y en el
array de variables y parámetros información sobre el tipo, de tal manera que en tiempo de ejecución se
realizan las comprobaciones. Otra forma más eficiente es comprobarlo en tiempo de carga. Para eso
“simula” la ejecución del método, comprobando que todos los parámetros que reciben las instrucciones en
la pila son correctos. Naturalmente esa ejecución tiene en cuenta las bifurcaciones. La simulación
comprueba además que los tipos obtenidos y almacenados en el array de variables locales y parámetros
son los esperados, y que en ningún momento se desbordará la pila de operandos o se realizarán intentos
de acceder a posiciones que están fuera del array de variables y parámetros.
Todas esas comprobaciones son obligatorias para las distintas implementaciones de la máquina
virtual. La especificación también propone la ejecución de una comprobación sobre las instrucciones
monitorenter y monitorexit, de modo que se garantice que por cada monitorenter en un
método se ejecutará uno (y solo uno) monitorexit sea cual sea la forma en la que el método finalice.
Naturalmente, si alguna de las comprobaciones anteriores falla, la clase no se admite, y la
máquina virtual lanzará una excepción. La carga de clases puede ser realizada en cualquier orden; cada
implementación es libre de cargar las clases según las va requiriendo el programa, o todas al principio,
antes de comenzar a ejecutar el principal, o por grupos, etc. Sin embargo, la especificación de la máquina
virtual obliga a que, si se producen errores de carga, éstos se conviertan en excepciones sólo cuando el
programa realmente utilice esa clase. Gracias a eso, si el programa referencia a una clase que no se
encuentra o que es inválida, podrá no obstante ejecutarse siempre que no utilice durante su ejecución a
esa clase. Eso debe cumplirse aunque la máquina virtual haya detectado el fallo antes de comenzar la
ejecución del programa.
NUEVAS HEBRAS
La máquina virtual dispone de 202 instrucciones, de l as cuales la mayoría son aritmético-lógicas,
otras son de control de flujo (salto, comparaciones, y llamadas a métodos), otras para construir objetos,
etc.
Pero no hay ninguna instrucción de, por ejemplo, entrada/salida. Todas las características para
las que no existen instrucciones en la máquina virtual se obtienen mediante métodos nativos.
Cada implementación de la máquina virtual se suministra junto con una serie de librerías que
contienen las clases estandar de java (de los paquetes java.lang, java.io y quizá algun otro, como
java.net) que basan su ejecución principalmente en métodos nativos. Como la máquina virtual se
distribuye inseparable de sus propias librerías, éstas pueden ser todo lo dependientes de la propia máquina
virtual que se desee. Gracias a eso, la máquina virtual puede aumentarse con capacidades que no se
habían pensado en un principio (por ejemplo acceso al hardware de cámaras de videoconferencia)
simplemente añadiendo nuevas clases y librerías, pero sin modificar la implementación de la propia
máquina virtual.
Con la creación de nuevas hebras ocurre eso. Realmente la máquina virtual supone la existencia
de varias hebras, pues, por ejemplo, controla los cerrojos de los objetos directamente con instrucciones
básicas, o suspende hebras si los cerrojos están bloqueados. Sin embargo no existe una instrucción para
crear una nueva hebra, por ejemplo. La solución es que casi toda la clase Thread está implementada
mediante métodos nativos. En particular, lo está el método start(), que se encargará construir un
nuevo motor de ejecución para la nueva hebra en la máquina virtual que está ejecutando el programa. La
implementación será por lo tanto específica de la plataforma y de la propia máquina virtual.
Concurrencia en Java
23
Tampoco existen instrucciones para lo métodos wait(...), notify() ni notifyAll().
Esos métodos están implementados con métodos nativos, que se relacionarán directamente con la
máquina virtual para conseguir su objetivo.
GESTIÓN DE MEMORIA
Aunque en el primer esquema de la estructura de la máquina virtual se ha puesto que el heap es
compartido por todas las hebras, la situación no necesariamente tiene que ser tan simple.
Existirá, en efecto, un heap global a todas las hebras en ejecución. Pero cada una de ellas podrá
tener una copia de las variables que está utilizando, a modo de caché. Esto no tiene sentido para una
ejecución monoprocesador, pero sí podría tenerlo si se ejecutara en un entorno multiprocesador con
memoria distribuida.
La especificación de la máquina virtual marca una serie de directrices en este sentido. Para eso, a
pesar de que las instrucciones de la máquina virtual no son atómicas (durante la ejecución de una
instrucción, la hebra puede ser expropiada para que se ejecute otra hebra), la especificación de la máquina
virtual establece como atómicas una serie de suboperaciones que deben ser ejecutadas sin que se realice
expropiación por parte de otra hebra. Esas operaciones son las referentes al acceso a memoria,
estableciendose además algunas restricciones en el orden en el que son ejecutadas.
•
•
La memoria principal admite, en ese sentido, cuatro operaciones básicas:
read: transmite el contenido de una variable a la memoria local de una hebra.
write: almacena el valor transmitido por la memoria local de una hebra en el espacio reservado
a una variable.
lock: bloquea un cerrojo. Se ejecuta de forma sincronizada con la hebra.
unlock: desbloquea un cerrojo. Se ejecuta de forma sincronizada con la hebra.
•
•
Por su parte, la memoria local de una hebra admite dos operaciones:
load: recoge el valor de un read y lo copia en la memoria local
store: envía el contenido de una variable de la memoria local a la principal.
•
•
Y por último, la ejecución de una hebra ocasionará que la máquina virtual ejecute dos
“microinstrucciones” atómicas cuando convenga:
• use: transfiere el contenido de una variable de la memoria local al motor de ejecución.
• assign: copia un valor desde el motor de ejecución a la memoria local.
Thread
Memoria de trabajo
Memoria principal
load
x
read
x
x
store
use
x
assign
x
Motor de ejecución
Copia maestra
write
24
Concurrencia en Java
La especificación de la máquina virtual marca restricciones en el uso de esas operaciones
atómicas. Por ejemplo, el orden en el que se ejecutan los store de una hebra tiene que ser el mismo que
aquel en el que se ejecutan sus write correspondientes.
Aunque en general no se especifica en qué momento deben actualizarse los valores de memoria
principal con los valores de las locales, sí se obliga a realizar copia ante la ejecución de las
subinstrucciones atómicas lock o unlock (ya sean implicitos por la llamada o finalización de métodos
sincronizados, o por la ejecución de las instrucciones monitorenter y monitorexit). Además, el
lock invalidará todas las variables de la memoria local, pero no el unlock.
Todas las operaciones atómicas anteriores se refieren a accesos a variables de 32 bits. La
especificación de la máquina virtual solo obliga a que sean atómicas esas operaciones, pero no las de 64
bits necesarias para las variables de tipo long y double. Un acceso a variables de ese tipo se realizaría
mediante dos operaciones atómicas consecutivas.
Esto podría ocasionar que durante la ejecución de una hebra, ésta pudiera leer de memoria un
valor de una variable de tipo long (o double) que el programa nunca asigna. Por ejemplo si una hebra
establece el valor 0xFFFF FFFF 0000 0000 y otra 0x0000 0000 FFFF FFFF, una tercera
hebra podría leer el valor 0x0000 0000 0000 0000 o 0xFFFF FFFF FFFF FFFF si accediera a
la variable entre los dos accesos de escritura en memoria principal.
No existe la obligación de que las operaciones de 64 bits sean atómicas para facilitar la
implementación en máquinas de 32 bits. No obstante se aconseja que también las operaciones sobre 64
bits sean atómicas, y no se garantiza que futuras especificaciones de la máquina virtual no obliguen a ello.
VARIABLES VOLATILE
A menos que se utilicen métodos o bloques sincronizados, no existe la seguridad de que un valor
se actualice en memoria principal y el resto de hebras vea el valor modificado. Para conseguir esto,
pueden utilizarse variables volatile. Una variable se marca como volatile en su declaración. Las
reglas con las operaciones atómicas sobre ellas son más estrictas, y se fuerza a que por cada load haya
un read, y por cada store haya un write. Gracias a eso siempre se trabaja con la copia original, pero
la ejecución podría ser más lenta. Además, puede seguir habiendo problemas con variables long y
double tal y como ya se ha explicado.
INICIALIZACIÓN DE CLASES
Algo que no termina de dejar claro la especificación de la máquina virtual respecto a la
concurrencia es la inicialización de clases.
Naturalmente los constructores de las clases no son nunca sincronizados. Eso se debe a que es
una única hebra la que crea el objeto, de modo que éste no podrá tener problemas con las hebras durante
su construcción, pues es imposible que ninguna otra hebra conozca al objeto antes de que se construya.
Sin embargo sí puede haber problemas con la inicialización estática de clases. Cualquier clase
puede tener un código que se ejecuta nada más cargar la clase:
class Dummy {
static {
System.out.println(“Se ejecuta cuando la máquina “ +
“virtual carga la clase.”);
}
}
Ese código debe ejecutarlo alguna hebra. Como más de una hebra puede requerir la carga de una
clase en el mismo instante, es necesario que una, y solo una, ejecute ese código. La especificación deja
claro cómo lograr eso, describiendo un algoritmo en el que se utiliza el cerrojo de la clase, e información
adicional sobre su estado.
Concurrencia en Java
25
De lo que no habla nada, sin embargo, es de la propia carga de la clase. ¿Qué hebra realiza la
carga? ¿Cómo se evita que dos hebras comiencen a cargar la misma clase? De eso no se habla nada en la
especificación.
CONCLUSIONES
El lenguaje Java soporta de forma nativa la programación concurrente gracias a la clase
Thread. Dispone de métodos de sincronización y señalización entre hebras gracias a los cerrojos y a los
métodos wait(...), notify() y notifyAll() definidos en la clase Object.
La máquina virtual es la que se encarga de todo esto, soportando la ejecución concurrente de
varios hilos diferentes. Sin embargo, la implementación de los métodos importantes para la concurrencia
no tienen una traducción directa a instrucciones de la máquina virtual, sino que se implementan mediante
métodos nativos que se relacionan directamente con la máquina virtual para conseguir su objetivo.
La especificación de la máquina virtual propone un modelo de concurrencia para garantizar que
no hay problemas en los accesos a memoria entre varias hebras. Aunque su implementación es sencilla en
sistemas con memoria compartida, no es así en sistemas con memoria distribuida. En ese sentido la
especificación se cubre las espaldas debido a que obliga a una serie de condiciones para que la ejecución
sea correcta, pero que no siempre son fáciles de conseguir. No obstante, a pesar de que e n ese caso no se
preocupan de la implementación, sí lo hacen en el caso de las variables de 64 bits, ocasionando la
aparición de un posible comportamiento anómalo de los programas que dejan sin resolver. Tampoco
dejan claro el modo de evitar problemas por la concurrencia en la carga de clases.
Aunque no se ha dicho hasta ahora, y como curiosidad, es posible ver el código en ensamblador
de una clase. Para ello, puede utilizarse la aplicación “javap” proporcionada con el JDK. La siguiente
orden:
javap –c MiClaseDummy
muestra por la salida estandar el código en ensamblador de la máquina virtual Java del fichero
MiClaseDummy.class, aunque de una forma un poco más inteligible (con un procesamiento para
mostrar el contenido de la tabla de constantes en lugar de los índices, que es lo que verdaderamente
almacenan las instrucciones).
26
Concurrencia en Java
BIBLIOGRAFÍA
•
•
•
•
The Java Language Specification 2nd edition.
The Java Virtual Machine Specification 2nd edition.
The Java Programming Languaje 2nd edition.
D. Lea, Concurrent Programming in Java. Design Principles and Patterns, Addison Wesley 1996.
Concurrencia en Java
27
APÉNDICE – IMPLEMENTACIÓN DE LOS EJEMPLOS
La mayoría de los ejemplos consisten en varias hebras ejecutandose simultáneamente. Cada una
de ellas se limita a mostrar por la salida estandar una cadena, que indica al usuario el orden de ejecución
de las hebras. Se ejecutan, por lo tanto, desde la consola (o ventana MS-DOS).
Deseaba que los ejemplos estuvieran incrustados como applets en páginas Web en las que se
explicara el funcionamiento de cada uno, y pudiera verse su funcionamiento. Convertir en applets los
ejemplos suponía que el usuario tuviera que abrir en cada uno de ellos la consola de Java en su
navegador, lo que es, evidentemente, bastante incómodo. Además, el usuario deja de tener el control
sobre en qué momento ejecutar el ejemplo, y cuando pararlo.
Para evitar todo eso, he construido una especie de entorno de ejecución de los ejemplos. Su
código sigue siendo prácticamente igual que si se construyesen como aplicaciones normales. El entorno
de ejecución es, en principio, un applet que incluye un área de texto, donde aparecerán los mensajes de las
hebras, y un botón para comenzar la ejecución.
El código de uno de los ejemplos es:
package Ejemplos;
public class DosHebrasBasicas extends Thread {
int cont;
/**
* Constructor.
* @param c Número que mostrará en la salida estandar
* contínuamente.
*/
DosHebrasBasicas(int c) { cont = c; }
public void run() {
while(true) {
system.out.println(cont);
}
}
public static void main(String[] args) {
new DosHebrasBasicas(0).start();
new DosHebrasBasicas(1).start();
}
} // class
Esta clase tiene que ejecutarse en el entorno de ejecución construido por el applet. Éste entorno
se encargará de llamar al método main cuando se pulse el botón de comenzar. El problema es la salida
estandar. Cuando el ejemplo escribe algo en la salida estandar utiliza alguno de los métodos del objeto
System.out. La idea original era sustituir ese objeto, es decir implementar una clase que heredara de
PrintStream, pero que tuviera asociado el área de texto del applet. Así, cada vez que los ejemplos
escribían algo en la salida estandar, la solicitud sería recogida por un objeto propio, que mostraría la
cadena en el área de texto. Naturalmente esta redirección debía hacerla el applet que construía el entorno.
Desgraciadamente esto no ha podido hacerse. Redirigir la salida estandar (modificar el objeto
System.out) es considerada una operación peligrosa en los applets, por lo que los navegadores no lo
admiten, y generan una excepción de fallo de seguridad.
Para no ocasionar demasiadas modificaciones a los ejemplos originales, he implementado una
clase sencilla que la he llamado system (con minúscula). Ésta, tiene un único atributo estático, llamado
out, que implementa los métodos estáticos print(...) y println(...) habituales. Gracias a eso,
la única modificación que hay que realizar a los ejemplos es sustituir System.out por system.out,
una modificación que, la mayoría de las veces, pasa desapercibida cuando se examina el código. Para
evitar tener que importar paquetes, las dos clases anteriores las he metido en el mismo paquete que los
propios ejemplos.
28
Concurrencia en Java
Naturalmente, el applet que construye el entorno de ejecución asocia al objeto system.out el
cuadro de texto del applet. Gracias a eso, los ejemplos muestran la información dentro del applet.
Como ya he dicho, los ejemplos originalmente se ejecutaban directamente desde la consola. Los
ejemplos “retocados” seguirán pudiendose ejecutar de la misma forma. Para ello, cuando el objeto
system.out tiene que escribir algo comprueba si tiene algún área de texto asociada. Si la tiene, añade
la cadena a dicho área. Si no, la escribe en la salida estandar. Como es el entorno de ejecución del applet
el que asocia el área de texto, cuando se ejecuta de forma independiente el ejemplo (fuera del entorno) no
se habrá realizado ninguna asociación, por lo que el objeto system.out actuará de forma semejante a
System.out.
La verdadera dificultad está, por lo tanto, en la implementación del entorno. Realmente siempre
se desea que actúe de la misma forma, pero cada vez habrá que llamar al método main() de una clase
diferente, en función de qué ejemplo quiera verse.
En principio hay varias soluciones para lograr esto. Yo he optado por utilizar herencia. He
implementado una clase EsqueletoEjemplos que hereda de la clase Applet, y que implementa la
mayor parte del entorno. Cuando se inicializa, se crea un área de texto y un botón, de Empezar. Al pulsar
el botón se llama al método empezar(), que es vacío. Las subclases deberán sobreescribir ese método
para llamar al método main() de la clase donde se implemente el ejemplo.
Tendremos por lo tanto las clases EsqueletoEjemplos, system y Salida (esta última es
la del objeto system.out) que son generales, para todos los ejemplos. Luego por cada uno de los
ejemplos tendremos una clase, por ejemplo DosHebrasBasicas, que será la clase original, que se
ejecuta en consola. Y tendremos también la clase AppDosHebrasBasicas, que heredará de
EsqueletoEjemplos, y que se encargará de ejecutar DosHebrasBasicas en el entorno del applet.
Además de poderse ejecutar como applets los ejemplos, también he añadido la posibilidad de que
ejecuten como aplicaciones normales.
Es decir, se puede ejecutar directamente la clase
AppDosHebrasBasicas, para poder ver el ejemplo en una ventana sin necesidad de que sea en un
applet. Para eso hay que implementar el método static public void main(String[] args).
Éste deberá crear una ventana, y añadir en ella el applet (todos los applets heredan de
java.awt.Panel, por lo que pueden añadirse en una ventana). Después habrá que llamar a los
métodos init() y start() del applet, tal y como haría el navegador.
Lo interesante, naturalmente, es añadir esto en la clase EsqueletoEjemplos, pues el código
es igual para todos los ejemplos. Lo único que varía es la clase del applet que hay que crear, que variará
con cada ejemplo. Naturalmente, todos serán applets que hereden de la propia clase
EsqueletoEjemplos.
La solución más natural es utilizar el patrón Factory Method. La clase EsqueletoEjemplos tendrá
un nuevo método, llamemosle nuevoApplet() que dará un objeto de la clase del applet. Cada
subclase sobreescribirá el método y devolverá un nuevo objeto. Más o menos sería:
public class EsqueletoEjemplos extends Applet {
/* ... Más cosas ... */
static public void main(String[] args) {
EsqueletoEjemplos elApplet = nuevoApplet();
if (elApplet == null)
throw new java.lang.RuntimeException("No se ha " +
"sobreescrito el método nuevoApplet.");
Frame ventana = new Frame();
ventana.add(elApplet);
elApplet.init();
elApplet.start();
/* ... Más cosas ... */
} // main
// Método a sobreescribir en las clases hijas.
static public EsqueletoEjemplos nuevoApplet() {
return null;
}
}
Concurrencia en Java
29
public class AppDosHebrasBasicas extends EsqueletoEjemplos {
/* ... */
static public EsqueletoEjemplos nuevoApplet() {
return new AppDosHebrasBasicas();
}
}
Desgraciadamente esto no funciona. Cuando se crea una nueva aplicación, se llama al método
main de la clase que se va a ejecutar, que debe ser estático. Cuando se comienza a ejecutar, por ejemplo,
AppDosHebrasBasicas, la máquina virtual detecta que no está el método main, y se utiliza el de la
superclase, EsqueletoEjemplos. Al ser métodos estáticos, no hay ningún objeto de por medio. Por
tanto, y debido a que la llamada al método nuevoApplet se ejecuta dentro de un método de la clase
EsqueletoEjemplos, se llamará al nuevoApplet de dicha clase, no al sobreescrito en
AppDosHebrasBasicas. Eso ocasionará que no se construya el applet que se esperaba.
Hay que buscar una solución diferente. Yo he optado por implementar un pseudo-main en la
clase EsqueletoEjemplos que reciba como parámetro ya un applet. Cada subclase tendrá su propio
método main(), que se limitará a llamar al main() de la superclase pasando como parámetro un objeto
de su clase recién creado. El método nuevoApplet() ya no es necesario:
public class EsqueletoEjemplos extends Applet {
/* ... Más cosas ... */
static public void main(String[] args, EsqueletoEjemplos elApplet){
if (elApplet == null)
throw new java.lang.RuntimeException("El applet no " +
"puede ser null.");
Frame ventana = new Frame();
/* ... etc ... */
} // main
}
public class AppDosHebrasBasicas extends EsqueletoEjemplos {
/* ... */
static public void main(String[] args) {
EsqueletoEjemplos.main(args, new AppDosHebrasBasicas());
// No se usa super.main(...) porque estamos en un método
// estático y no está permitido.
}
}
También es posible, en ocasiones, detener la ejecución del ejemplo. Para ello, el applet añade un
botón extra, que finaliza la ejecución. La clase EsqueletoEjemplos implementa un método,
acabar(), que es llamado cuando se pulsa el botón. Las subclases tendrán que sobreescribirlo, para
realizar las operaciones necesarias. Para saber si se debe mostrar o no el botón de parar, se implementa el
método public boolean botonAcabar() que devuelve falso. Si en algún ejemplo se desea que
aparezca el botón acabar, habrá que sobreescribirlo para que devuelva cierto.
En lo sucesivo, supondremos que aparece el botón de detener. Inicialmente el botón de comenzar
aparecerá activado, y el otro desactivado. Cuando el usuario solicita la ejecución del ejemplo, el botón de
comienzo se desactivará, y se activará el de finalización. Cuando posteriormente se pulsa éste, deberá
desactivarse, y volverse a activar el de comienzo. El problema es que realmente no se sabe si el ejemplo
ha finalizado la ejecución o no. Para saberlo, se deberían controlar las hebras que construye el ejemplo, y
ver si acaban o no.
30
Concurrencia en Java
La ejecución del applet hace uso de la librería gráfica awt. Ésta ejecuta su propia hebra, que se
encarga de atender los eventos del usuario, y propagarlos a sus manejadores. La hebra del awt utiliza una
prioridad ligeramente superior a Thread.NORM_PRIORITY. Si el ejemplo en cuestión crea hebras con
una prioridad mayor que la utilizada por la hebra del awt pueden aparecer problemas de respueta. La idea
es que el entorno responda siempre, por lo que tendrá que tener una prioridad mayor que cualquier otra
hebra. Para limitar la prioridad de las hebras de los ejemplos (sin tener que modificar los propios
ejemplos), la única solución es que se ejecuten dentro de un grupo de hebras que tenga acotada la
prioridad. Si cualquier hebra de ese grupo intenta establecer una prioridad mayor a la establecida en el
grupo, se establece, silenciosamente, la máxima prioridad admitida.
Para que las hebras creadas por el ejemplo se ejecuten en otro grupo, la llamada al método
main() del ejemplo debe realizarse con una hebra que pertenezca a ese grupo. Por tanto, cuando se
pulsa el botón de Empezar, no se llama directamente al método empezar() del entorno tal y como se ha
descrito antes (pues se ejecutaría dentro de la hebra del awt). En su lugar, la clase
EsqueletoEjemplos implementa el interfaz Runnable. Cuando se pulsa el botón de empezar se
crea un nuevo grupo al que se le acota la prioridad máxima, y se crea una nueva hebra dentro de ese
grupo, pasandole al constructor el propio objeto del applet. Eso ocasiona que se comience a ejecutar el
método run(), ya en un grupo con las prioridades acotadas, y desde ahí se llama al método main() del
ejemplo.
Esta nueva hebra podemos utilizarla para solucionar el problema que habíamos dejado pendiente
del botón de finalización. Todas las hebras que cree el ejemplo estarán dentro del grupo de la hebra que
ejecuta el método run() de EsqueletoEjemplos. Para saber si el ejemplo ha terminado podemos
añadir código en ese método para que compruebe si quedan hebras vivas en el grupo, además de la suya
propia. Para comprobarlo se utiliza el método join() de las hebras, junto con algunos métodos de la
clase ThreadGroup. Cuando se detecta que no quedan hebras en ejecución, se desactiva el botón de
acabar, se activa el de empezar, y se termina la hebra. De ese modo el estado de los botones es
consistente, incluso si el ejemplo acaba su ejecución por propia iniciativa.
Realmente la situación no es tan “sencilla”. En primer lugar, la prioridad de las hebras de los
applets ya está restringida desde el principio. El grupo raíz de hebras tiene como máxima prioridad la
utilizada por el AWT. Por tanto, en principio, no sería necesaria restringirla. Si es necesario hacerla si en
lugar de como un applet, el ejemplo se ejecuta como una aplicación. En ese caso la prioridad no está
restringida, y habría que hacerlo, por lo que el uso del grupo sigue teniendo sentido.
El verdadero inconveniente surge porque dentro de applets no se pueden crear hebras que
pertenezcan a grupos distintos al del propio applet. Es decir se pueden crear grupos, pero increíblemente
no se pueden meter hebras en ellos (al menos yo no he podido). En parte, por lo tanto, es una suerte que la
prioridad del grupo principal ya esté restringida, pues no necesitamos crear el grupo. El problema es que
el control de la finalización de todas las hebras del grupo se complica, pues ahora en el grupo puede haber
(de hecho habrá) más hebras además de la que controla la finalización (aquella que ejecutaba el método
run() del applet). Antes de comenzar a ejecutar el ejemplo habrá que mirar todas las hebras que están
en el grupo, y luego controlar la finalización de todas, salvo las que se obtuvieron al principio.
Todo esto origina que el código de la clase EsqueletoEjemplos sea un poco confuso.
Afortunadamente, para entender los ejemplos no es necesario comprenderla completamente.
Por último, la clase EsqueletoEjemplos incluye algunos otros métodos que pueden ser
sobreescritos, y que permiten cierta personalización en el entorno de ejecución.
Descargar