3. Excepciones e hilos

 

3.1. Excepciones

Las excepciones son eventos que ocurren durante la ejecución de un programa y hacen que éste salga de su flujo normal de instrucciones. Este mecanismo permite tratar los errores de una forma elegante, ya que separa el código para el tratamiento de errores del código normal del programa. Se dice que una excepción es lanzada cuando se produce un error, y esta excepción puede ser capturada para tratar dicho error.

3.1.1. Tipos de excepciones

Tenemos diferentes tipos de excepciones dependiendo del tipo de error que representen. Todas ellas descienden de la clase Throwable, la cual tiene dos descendientes directos:

Dentro de Exception, cabe destacar una subclase especial de excepciones denominada RuntimeException, de la cual derivarán todas aquellas excepciones referidas a los errores que comúnmente se pueden producir dentro de cualquier fragmento de código, como por ejemplo hacer una referencia a un puntero null, o acceder fuera de los límites de un array.

Estas RuntimeException se diferencian del resto de excepciones en que no son de tipo checked. Una excepción de tipo checked debe ser capturada o bien especificar que puede ser lanzada de forma obligatoria, y si no lo hacemos obtendremos un error de compilación. Dado que las RuntimeException pueden producirse en cualquier fragmento de código, sería impensable tener que añadir manejadores de excepciones y declarar que éstas pueden ser lanzadas en todo nuestro código. Deberemos:

Dentro de estos grupos principales de excepciones podremos encontrar tipos concretos de excepciones o bien otros grupos que a su vez pueden contener más subgrupos de excepciones, hasta llegar a tipos concretos de ellas. Cada tipo de excepción guardará información relativa al tipo de error al que se refiera, además de la información común a todas las excepciones. Por ejemplo, una ParseException se suele utilizar al procesar un fichero. Además de almacenar un mensaje de error, guardará la línea en la que el parser encontró el error.

3.1.2. Captura de excepciones

Cuando un fragmento de código sea susceptible de lanzar una excepción y queramos tratar el error producido o bien por ser una excepción de tipo checked debamos capturarla, podremos hacerlo mediante la estructura try-catch-finally, que consta de tres bloques de código:

Para el bloque catch además deberemos especificar el tipo o grupo de excepciones que tratamos en dicho bloque, pudiendo incluir varios bloques catch, cada uno de ellos para un tipo/grupo de excepciones distinto. La forma de hacer esto será la siguiente:

try 
{  	
	// Código regular del programa  	
	// Puede producir excepciones  
} catch(TipoDeExcepcion1 e1) {  	
	// Código que trata las excepciones de tipo   	
	// TipoDeExcepcion1 o subclases de ella.  	
	// Los datos sobre la excepción los encontraremos   	
	// en el objeto e1.  
} catch(TipoDeExcepcion2 e2) {	  	
	// Código que trata las excepciones de tipo   	
	// TipoDeExcepcion2 o subclases de ella.  	
	// Los datos sobre la excepción los encontraremos   	
	// en el objeto e2.  ...  
} catch(TipoDeExcepcionN eN) {  	
	// Código que trata las excepciones de tipo   	
	// TipoDeExcepcionN o subclases de ella.  	
	// Los datos sobre la excepción los encontraremos   	
	// en el objeto eN.  
} finally {  	
	// Código de finalización (opcional)  
}

Si como tipo de excepción especificamos un grupo de excepciones este bloque se encargará de la captura de todos los subtipos de excepciones de este grupo. Por lo tanto, si especificamos Exception capturaremos cualquier excepción, ya que ésta es la superclase común de todas las excepciones.

En el bloque catch pueden ser útiles algunos métodos de la excepción (que podemos ver en la API de la clase padre Exception):

String getMessage()
void printStackTrace()

con getMessage obtenemos una cadena descriptiva del error (si la hay). Con printStackTrace se muestra por la salida estándar la traza de errores que se han producido (en ocasiones la traza es muy larga y no puede seguirse toda en pantalla con algunos sistemas operativos).

Un ejemplo de uso:

try 
{  	
	... // Aqui va el codigo que puede lanzar una excepcion  
} catch (Exception e) {  	
	System.out.println ("El error es: " + e.getMessage());  	
	e.printStackTrace();  
}

Nunca deberemos dejar vacío el cuerpo del catch, porque si se produce el error, nadie se va a dar cuenta de que se ha producido. En especial, cuando estemos con excepciones no-checked.

3.1.3. Lanzamiento de excepciones

Hemos visto cómo capturar excepciones que se produzcan en el código, pero en lugar de capturarlas también podemos hacer que se propaguen al método de nivel superior (desde el cual se ha llamado al método actual). Para esto, en el método donde se vaya a lanzar la excepción, se siguen 2 pasos:

public void lee_fichero() throws IOException, FileNotFoundException  
{
	// Cuerpo de la función  
}

Podremos indicar tantos tipos de excepciones como queramos en la claúsula throws. Si alguna de estas clases de excepciones tiene subclases, también se considerará que puede lanzar todas estas subclases.

throw new IOException(mensaje_error);
public void lee_fichero() throws IOException, FileNotFoundException  
{
	...  	
	throw new IOException(mensaje_error);  	
	...  
}

Podremos lanzar así excepciones en nuestras funciones para indicar que algo no es como debiera ser a las funciones llamadoras. Por ejemplo, si estamos procesando un fichero que debe tener un determinado formato, sería buena idea lanzar excepciones de tipo ParseException en caso de que la sintaxis del fichero de entrada no sea correcta. 

NOTA: para las excepciones que no son de tipo checked no hará falta la cláusula throws en la declaración del método, pero seguirán el mismo comportamiento que el resto, si no son capturadas pasarán al método de nivel superior, y seguirán así hasta llegar a la función principal, momento en el que si no se captura provocará la salida de nuestro programa mostrando el error correspondiente.

3.1.4. Creación de nuevas excepciones

Además de utilizar los tipos de excepciones contenidos en la distribución de Java, podremos crear nuevos tipos que se adapten a nuestros problemas.

Para crear un nuevo tipo de excepciones simplemente deberemos crear una clase que herede de Exception o cualquier otro subgrupo de excepciones existente. En esta clase podremos añadir métodos y propiedades para almacenar información relativa a nuestro tipo de error. Por ejemplo:

public class MiExcepcion extends Exception  
{  	
	public MiExcepcion (String mensaje)  	
	{  		
		super(mensaje);  	
	}  
}

Además podremos crear subclases de nuestro nuevo tipo de excepción, creando de esta forma grupos de excepciones. Para utilizar estas excepciones (capturarlas y/o lanzarlas) hacemos lo mismo que lo explicado antes para las excepciones que se tienen definidas en Java.

3.2. Hilos

Un hilo es un flujo de control dentro de un programa. Creando varios hilos podremos realizar varias tareas simultáneamente. Cada hilo tendrá sólo un contexto de ejecución (contador de programa, pila de ejecución). Es decir, a diferencia de los procesos UNIX, no tienen su propio espacio de memoria sino que acceden todos al mismo espacio de memoria común, por lo que será importante su sincronización cuando tengamos varios hilos accediendo a los mismos objetos.

3.2.1. Creación de hilos

En Java los hilos están encapsulados en la clase Thread. Para crear un hilo tenemos dos posibilidades:

En ambos casos debemos definir un método run() que será el que contenga el código del hilo. Desde dentro de este método podremos llamar a cualquier otro método de cualquier objeto, pero este método run() será el método que se invoque cuando iniciemos la ejecución de un hilo. El hilo terminará su ejecución cuando termine de ejecutarse este método run().

Para crear nuestro hilo mediante herencia haremos lo siguiente:

public class EjemploHilo extends Thread  
{      
	public void run() 
	{
		// Código del hilo      
	}  
}

Una vez definida la clase de nuestro hilo deberemos instanciarlo y ejecutarlo de la siguiente forma:

Thread t = new EjemploHilo();  
t.start();

Al llamar al método start del hilo, comenzará ejecutarse su método run. Crear un hilo heredando de Thread tiene el problema de que al no haber herencia múltiple en Java, si heredamos de Thread no podremos heredar de ninguna otra clase, y por lo tanto un hilo no podría heredar de ninguna otra clase.

Este problema desaparece si utilizamos la interfaz Runnable para crear el hilo, ya que una clase puede implementar varios interfaces. Definiremos la clase que contenga el hilo como se muestra a continuación:

public class EjemploHilo implements Runnable  
{
	public void run() 
	{          
		// Código del hilo      
	}  
}

Para instanciar y ejecutar un hilo de este tipo deberemos hacer lo siguiente:

Thread t = new Thread(new EjemploHilo());  
t.start();

Esto es así debido a que en este caso EjemploHilo no deriva de una clase Thread, por lo que no se puede considerar un hilo, lo único que estamos haciendo implementando la interfaz es asegurar que vamos a tener definido el método run(). Con esto lo que haremos será proporcionar esta clase al constructor de la clase Thread, para que el objeto Thread que creemos llame al método run() de la clase que hemos definido al iniciarse la ejecución del hilo, ya que implementando la interfaz le aseguramos que esta función existe.

3.2.2. Estado y propiedades de los hilos

Un hilo pasará por varios estados durante su ciclo de vida.

Thread t = new Thread(this);

Una vez se ha instanciado el objeto del hilo, diremos que está en estado de Nuevo hilo.

t.start();

Cuando invoquemos su método start() el hilo pasará a ser un hilo vivo, comenzándose a ejecutar su método run(). Una vez haya salido de este método pasará a ser un hilo muerto.

La única forma de parar un hilo es hacer que salga del método run() de forma natural. Podremos conseguir esto haciendo que se cumpla una condición de salida de run() (lógicamente, la condición que se nos ocurra dependerá del tipo de programa que estemos haciendo). Las funciones para parar, pausar y reanudar hilos están desaprobadas en las versiones actuales de Java.

Mientras el hilo esté vivo, podrá encontrarse en dos estados: Ejecutable y No ejecutable. El hilo pasará de Ejecutable a No ejecutable en los siguientes casos:

Lo único que podremos saber es si un hilo se encuentra vivo o no, llamando a su método isAlive().

Prioridades de los hilos

Además, una propiedad importante de los hilos será su prioridad. Mientras el hilo se encuentre vivo, el scheduler de la máquina virtual Java le asignará o lo sacará de la CPU, coordinando así el uso de la CPU por parte de todos los hilos activos basándose en su prioridad. Se puede forzar la salida de un hilo de la CPU llamando a su método yield(). También se sacará un hilo de la CPU cuando un hilo de mayor prioridad se haga Ejecutable, o cuando el tiempo que se le haya asignado expire.

Para cambiar la prioridad de un hilo se utiliza el método setPriority(), al que deberemos proporcionar un valor de prioridad entre MIN_PRIORITY y MAX_PRIORITY (tenéis constantes de prioridad disponibles dentro de la clase Thread, consultad el API de Java para ver qué valores de constantes hay).

Hilo actual

En cualquier parte de nuestro código Java podemos llamar al método currentThread de la clase Thread, que nos devuelve un objeto hilo con el hilo que se encuentra actualmente ejecutando el código donde está introducido ese método. Por ejemplo, si tenemos un código como:

public class EjemploHilo implements Runnable  
{      
	public EjemploHilo()      
	{          
		...          
		int i = 0;          
		Thread t = Thread.currentThread();  		
		t.sleep(1000);      
	}  
}

La llamada a currentThread dentro del constructor de la clase nos devolverá el hilo que corresponde con el programa principal (puesto que no hemos creado ningún otro hilo, y si lo creáramos, no ejecutaría nada que no estuviese dentro de un método run.

Sin embargo, en este otro caso:

public class EjemploHilo implements Runnable  
{      
	public EjemploHilo()      
	{          
		Thread t1 = new Thread(this);          
		Thread t2 = new Thread(this);          
		t1.start();          
		t2.start();      
	}        
	
	public void run()      
	{          
		int i = 0;          
		Thread t = Thread.currentThread();  		
		t.sleep(1000);      
	}  
}

Lo que hacemos es crear dos hilos auxiliares, y la llamada a currentThread se produce dentro del run, con lo que se aplica a los hilos auxiliares, que son los que ejecutan el run: primero devolverá un hilo auxiliar (el que primero entre, t1 o t2), y luego el otro (t2 o t1).

Dormir hilos

Como hemos visto en los ejemplos anteriores, una vez obtenemos el hilo que queremos, el método sleep nos sirve para dormirlo, durante los milisegundos que le pasemos como parámetro (en los casos anteriores, dormían durante 1 segundo). El tiempo que duerme el hilo, deja libre el procesador para que lo ocupen otros hilos. Es una forma de no sobrecargar mucho de trabajo a la CPU con muchos hilos intentando entrar sin descanso.

3.2.3. Sincronización de hilos

Muchas veces los hilos deberán trabajar de forma coordinada, por lo que es necesario un mecanismo de sincronización entre ellos.

Un primer mecanismo de comunicación es la variable cerrojo incluida en todo objeto Object, que permitirá evitar que más de un hilo entre en la sección crítica para un objeto determinado. Los métodos declarados como synchronized utilizan el cerrojo del objeto al que pertenecen evitando que más de un hilo entre en ellos al mismo tiempo.

public synchronized void seccion_critica()  
{ // Código sección crítica }

Todos los métodos synchronized de un mismo objeto (no clase, sino objeto de esa clase), comparten el mismo cerrojo, y es distinto al cerrojo de otros objetos (de la misma clase, o de otras).

También podemos utilizar cualquier otro objeto para la sincronización dentro de nuestro método de la siguiente forma:

synchronized (objeto_con_cerrojo) 
{ // Código sección crítica }

de esta forma sincronizaríamos el código que escribiésemos dentro, con el código synchronized del objeto objeto_con_cerrojo.

Además podemos hacer que un hilo quede bloqueado a la espera de que otro hilo lo desbloquee cuando suceda un determinado evento. Para bloquear un hilo usaremos la función wait(), para lo cual el hilo que llama a esta función debe estar en posesión del monitor, cosa que ocurre dentro de un método synchronized, por lo que sólo podremos bloquear a un proceso dentro de estos métodos.

Para desbloquear a los hilos que haya bloqueados se utilizará notifyAll(), o bien notify() para desbloquear sólo uno de ellos aleatoriamente. Para invocar estos métodos ocurrirá lo mismo, el hilo deberá estar en posesión del monitor.

Cuando un hilo queda bloqueado liberará el cerrojo para que otro hilo pueda entrar en la sección crítica del objeto y desbloquearlo.

Por último, puede ser necesario esperar a que un determinado hilo haya finalizado su tarea para continuar. Esto lo podremos hacer llamando al método join() de dicho hilo, que nos bloqueará hasta que el hilo haya finalizado.