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.