Usa la fuerza Luke: ejemplos de sun.misc.Unsafe

0
7737

En este tutorial vamos a ahondar en el oscuro mundo de la clase sun.misc.Unsafe de Java.

0. Índice de contenidos

1. Introducción

Java es un lenguaje de programación seguro y evita que el programador cometa varios errores basados en la gestión de memoria.
Sin embargo, existe una manera de cometer estos errores intencionadamente: usando la clase Unsafe.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2.4 Ghz Intel Core I5, 8GB DDR3).
  • Sistema Operativo: Mac OS Yosemite 10.10.3
  • Entorno de desarrollo:
    • Eclipse Mars
    • Versión de Java: JDK 1.8.0_51

3. Instanciación

Antes de usarse, se debe crear una instancia del objeto Unsafe. Sin embargo, estamos frente a una tarea no trivial,
ya que el constructor de esta clase es privado.

Aunque hay varios métodos de crear esta instancia, usando reflexión el código queda muy limpio:

Nota: Ignora tu IDE (Por ejemplo, en Eclipse sale un error de restricción de acceso, pero el código al ejecutarse funciona).
Si te molestan los mensajes de error, desactívalos para este tipo.

Nota 2: Aunque en el ejemplo se lanza la superclase Exception, en realidad se pueden dar dos tipos de excepciones (NoSuchFieldException, SecurityException).

4. API

La clase sun.misc.Unsafe se compone de 105 métodos, agrupados por manipulación de entidades.

Puedes ver la documentación
aquí.

Algunos de estos grupos con sus métodos más importantes son:

  • Info. Información de memoria a bajo nivel.
    • addressSize
    • pageSize
  • Objetos. Manipulación de objetos y campos.
    • allocateInstance
    • objectFieldOffset
  • Clases. Manipulación de clases y campos estáticos.
    • staticFieldOffset
    • defineClass
    • defineAnonymousClass
    • ensureClassInitialized
  • Arrays. Manipulación de arrays.
    • arrayBaseOffset
    • arrayIndexScale
  • Sincronización. Métodos a bajo nivel para sincronización.
    • monitorEnter
    • tryMonitorEnter
    • monitorExit
    • compareAndSwapInt
    • putOrderedInt
  • Memoria. Acceso directo a memoria.
    • allocateMemory
    • copyMemory
    • freeMemory
    • getAddress
    • getInt
    • putInt

5. Casos de Uso

Vamos a ver algunos casos de uso interesantes con esta Clase.

5.1. Evitar Inicialización.

El método allocateInstance puede resultar útil cuando queremos «saltarnos» la inicialización de un objeto o cuando se quiere
instanciar una clase que no dispone de constructor público.

Ahora creamos una clase cuyo constructor inicialice una variable. Utilizando el método del punto 3
nos saltaremos la inicalización del constructor.

Inicializator.java

El valor sin inicializar es un cero debido a que es el
valor por defecto de la clase long:

Mini-examen: ¿Qué les pasaría a los Singletons?

5.2. Corrupción de Memoria.

Vamos a considerar la siguiente clase que comprueba una regla de acceso:

AccessChecker.java

Suponemos que el código cliente llama al método cada vez que se quiere acceder. Sin embargo, siempre retorna false. ¿Cómo «hackear»
el acceso?

La respuesta, de nuevo, nos la da Unsafe:

Unas pequeñas notas:

  • Aunque se puede hacer por reflexión, de este modo vemos que se puede acceder a cualquier objeto,
    incluso sin referencias. Esto es así gracias al método objectFieldOffset

    • Por ejemplo, si tuvieramos otra instancia de AccessChecker en memoria a continuación
      de la actual, bastaría sumar el tamaño al offset (16 + unsafe.objectFieldOffset(f)). En ningún
      caso hemos hecho alguna referencia al objeto en cuestión.
    • 16 es el tamaño de AccessChecker en una arquitectura de 32 bits. Se puede calcular a mano o usar
      la función sizeOF, la cual definiremos en la próxima sección.
  • Si el atributo de la clase AccessChecker fuera final,
    no habría corrupción de memoria (Mini-examen: ¿Por qué?).

Nota importante: de nuevo, en el ejemplo se captura la superclase Exception, cuando se deberían capturar
las dos específicas del uso de Unsafe.

Nota importante 2: en el ejemplo se usa la variable guard como salida, además de ser una entrada. Esto es una mala práctica.
Aquí se usa con fines ilustrativos.

5.3. sizeOf.

Usando el método objectFieldOffset visto anteriormente se pude implementar una versión de
la conocida función de C sizeOf. Aunque hay formas mas sencillas de hacerlo, la idea clave
es la que sigue:

  1. Tomar la clase del objeto.
  2. Para cada campo no estático (incluyendo los de superclases):
    1. Obtener su offset
    2. Sumarlo al total
  3. Sumar el padding («huecos para que la dirección de memoria sea múltiplo de 8»)

Ya que no hemos puesto código, ahí va una aproximación
(¡OJO! ES PSEUDOCÓDIGO NO FUNCIONAL):


De nuevo repetimos que no es código funcional, sólo es una aproximación a la implementación.
Ni siquiera es código java (usa funciones que no existen).

Para una manera segura y correcta de saber el tamaño de un objeto habría que usar el
paquete java.lang.instrument.

5.4. Copia superficial.

Una vez se dispone de una manera de saber el tamaño de un objeto, se puede implementar una función que
copie objetos (se asume que existe una implementación correcta de sizeOf):

Básicamente toma el tamaño y la dirección de inicio del objeto, reserva memoria para el nuevo
y la rellena con una copia.

Las funciones objectToAddress y objectFromAddress se encargan de dar la dirección
de memoria de un objeto y el objeto que hay en una dirección de memoria, respectivamente.
La función normalize se encarga de «validar» que la dirección de memoria es positiva y
añadir el padding necesario;

Si se quiere copiar un objeto de manera correcta, sin tener que acceder a memoria directamente, se usa la interfaz
Cloneable.

5.5. Esconder Contraseñas.

Un uso muy interesante de la manipulación directa de memoria es el poder «eliminar objetos» de la memoria que no queremos. Por ejemplo,
contraseñas que hemos introducido en una aplicación.

La mayoría de funciones de manipulación de contraseñas tienen retorno de tipo byte[] o char[]. ¿Por qué arrays?

La respuesta es simple: seguridad. Se pueden ‘hacer null’ elementos de un array después de ser útiles. Sin embargo, las cadenas, aunque
las hagamos null, solamente se de-referencia el objeto, de modo que el objeto sigue en memoria hasta que el GC haga una pasada y limpie el entorno.

Usando reflexión se puede esconder la contraseña de manera segura:

Con la clase Unsafe también se puede conseguir, pero de manera algo menos segura:

Las funciones objectToAdress y sizeOf se encuentran descritas en el punto 5.4 y 5.3, respectivamente.

5.6. Herencia Múltiple.

Todos sabemos que no hay herencia múltiple en Java, salvo porque podemos hacer castings de cualquier tipo a cualquier objeto.

Para ello nos valemos de la clase Unsafe de nuevo, añadiendo (por ejemplo) la clase String a las superclases de Integer:

Ahora podemos hacer castings de Integer a String sin tener una excepción en tiempo de ejecución. La única pega es que hay
que hacer una casting a la clase Object primero (para engañar al compilador):

Una manera de implementar esto sin recurrir al Unsafe sería usando Mixins. Nuestro compañero Alejandro Pérez hizo un
tutorial al respecto.

5.7. Clases Dinámicas.

Con el uso de Unsafe podemos crear clases en tiempo de ejecución. Para lograrlo, leemos los contenidos de un fichero compilado .class,
los pasamos a un array de byte y se lo pasamos al método defineClass:

Creamos una clase de ejemplo con un método que imprima un mensaje:

DinamicClass.java

Ahora creamos un método que lea el contenido del fichero .class:

Y probamos la ejecución (de nuevo recordad lo de gestionar de forma correcta las excepciones).

Nota: se puede ver que en la función getMehod se ha añadido el tipo de parámetro del método a invocar,
y a la hora de invocar le pasamos el parámetro.

La forma «correcta» de crear clases dinámicamente sería la que indica Jakob Jenkov aquí.

Como conclusión de este apartado, sólo indicar que esta forma de usar Unsafe puede sernos útil a
la hora de crear Clases de forma dinámica o crear proxies o aspectos
para código existente.

5.8. Lanzar Excepciones.

Si eres de los que no les gustan las excepciones comprobadas (Checked Exceptions), ¡estás de suerte!

El método lanza una excepción controlada, pero no se fuerza al código a contemplarla o re-lanzarla (como una excepción en tiempo de ejecución).

Si queremos realizarlo sin recurrir a la clase Unsafe, basta con crearnos excepciones que extiendan RuntimeException.

5.9. Serialización Rápida.

Todo el mundo sabe que la capacidad de Serializable para serializar (valga la redundancia) es muy lenta. Además requiere que la clase tenga un constructor
público sin argumentos. Externalizable es bastante mejor, pero se necesita un esquema para que la clase se serialice.

Hay librerías como kyro que permiten un alto rendimiento, pero tienen ciertas dependencias, cosa inaceptable si disponemos
de poca memoria. Además, en esta librería se intenta usar Unsafe: https://code.google.com/p/kryo/issues/detail?id=75

Ahora bien, gracias a nuestra «ya casi amiga» clase Unsafe podemos hacer serialización de una manera más rápida:

Para la serialización:

  • Crea un esquema para el objeto usando reflexión. Se puede hacer una única vez por clase.
  • Usa los métodos de Unsafe: getLong, getInt, getObject, etc. para tomar el valor actual de los campos.
  • Añade el identificador class para poder restaurar el objeto.
  • Listo. Escribe el objeto al fichero/salida que quieras.

También podrías añadir compresión para ahorrar espacio.

Para la des-serialización:

  • Crea una instancia de la clase serializada. Para esto puede ser útil el método allocateInstance, ya que no requiere constructor.
  • Construye el esquema, al igual que en el paso 1 de serialización.
  • Lee los campos del fichero/entrada usado.
  • Usa los métodos de Unsafe: getLong, getInt, getObject, etc. para rellenar los valores del objeto.

Aunque hay más detalles que los mostrados aquí, creo que la idea principal está bastante clara. Esta serialización será muy rápida.

5.10. Arrays grandes.

Todos sabemos que la constante Integer.MAX_VALUE es el máximo tamaño que puede tener un array en Java, ¿verdad? ¡PUES NO! Usando la
asignación directa de memoria podemos conseguir arrays tan grandes como nos permita el tamaño del ‘heap’.

Veamos una implementación de este tipo de «monstruos»:

DinamicClass.java

Y ahora, un caso de uso que muestre que efectivamente podemos poner el tamaño que queramos:

La salida mostrará el tamaño del array (4294967294) y 100 veces la línea:

Con esto comprobamos que efectivamente, no hay límite de tamaño en nuestro SuperArray.

NOTA: Adivinad que viene aquí… Efectivamente, ojo con el tratamiento de Excepciones y uso de parámetros
de entrada como salida.

Esta técnica está parcialmente disponible en el paquete java.nio.

Importante: La memoria asiganada de esta forma no está en el heap ni bajo la gestión del GC, así que hay que acordarse de
liberarla una vez la hemos terminado de usar. Además por defecto no se comprueba la posición donde se escribe por lo que un acceso ilegal podría
terminar colgando la JVM.

Pero entonces… ¿Para qué es esto útil? Muy sencillo: Cálculos para matemáticas y programación eficiente, donde el rendimiento es clave y se suele trabajar
con tamaños de arrays de datos muy grandes.

De todas formas, Java incorpora la clase estática BigArrays para ayudar
a tratar con ellos sin usar la clase Unsafe.

5.11. Concurrencia.

Por último (ya era hora :-P), unas pocas palabras sobre concurrencia.

Unsafe nos permite implementar estructuras de datos sin bloqueo de alto rendimiento. Esto es, nos permite usar estrucutras de datos de forma
concurrente (en otro hilo) de forma eficiente y sin bloquear el programa principal.

Por ejemplo, supongamos que necesitamos incrementar un valor en un objeto compartido usando varios hilos. Lo primero que habría que hacer sería crear la interfaz
Counter:

Counter.java

Acto seguido, implementamos un hilo cliente sencillo que utilice el contador:

CounterClient.java

Y un pequeño programa que pruebe su funcionalidad:

Ahora, al lío: Una de las mejores opciones en cuanto a rendimiento/concurrencia es el uso de ReentrantReadWriteLock. Creamos un contador que use la clase:

ReentrantReadWriteLockCounter.java

Y lanzamos el programa anterior con una instancia de nuestra primera implementación de contador usando esta clase. La salida obtenida es:

Ahora probamos una implementación del contador usando la clase Unsafe:

UnsafeCounter.java

Ejecutamos y la salida nos da:

Observamos una cierta mejora en el tiempo de ejecución. De hecho, algunas implementaciones como
Atomic
usa Unsafe.

La función compareAndSwapLong nos permite implementar estructuras de datos libres de bloqueos. La idea subyacente es la que sigue:

  1. Dado un estado, se crea una copia.
  2. Se modifica.
  3. Se compara con el nuevo y se intercambian (se hace swap).
  4. Se repite si falla.

Por otro lado, la palabra volatile se añade al contador para evitar el riesgo de bucles infinitos.

6. Conclusiones

Hemos visto lo que se puede llegar a hacer con esta clase, y vemos que hay métodos bastante útiles para determinadas tareas. Sin embargo, no es recomendable su uso
(quién lo iba a decir, ese nombre de clase inspiraba tanta confianza… :P).

Por lo tanto, resumen de este «tocho»:
Unsafe: está guay, pero… ¡¡NO LO USES!!

Espero que estos párrafos os hayan entretenido y sido de utilidad. El código se encuentra disponible en
Github.

¡Un saludo!

Rodrigo de Blas

7. Referencias

Dejar respuesta

Please enter your comment!
Please enter your name here