Algoritmos y Estructuras de Datos Herramientas Lenguaje de programación
!Prog C/C++ Rust
Linux Matemáticas
Mates Discretas
Programación Orientada a Objetos Sistemas Operativos

Referencias

[date: 13-01-2024 23:06] [last modification: 15-01-2024 22:24]
[words: 3245] [reading time: 16min] [size: 72857 bytes]

En este artículo se trata en detalle el Aliasing otros detalles importantes sobre la gestión de memoria que realiza la Máquina Virtual de Java.

Referencias y Aliasing

Referencia
Una referencia es un puntero a un objeto.
En Java, es un nombre simbólico que se le da a un objeto.

Por ejemplo:

Persona pepe = new Persona("Pepe Fernández", 27);

Aquí pepe es una referencia a un objeto de tipo Persona.

Esto tiene importantes implicaciones:

objA = objB;

Al asignar una referencia a otra, no se crea una copia del objeto, sino que se copia la dirección de memoria. El objeto A es ahora inaccesible y será liberado por el recolector de basura, mientras que el objeto B tiene dos referencias a él. Se ha hecho una copia por referencia.

Aliasing
Se da Aliasing cuando varias referencias apuntan al mismo mismo objeto. Es decir, los datos son accesibles desde dos nombres simbólicos.

Los programas son más difíciles de mantener dado que la encapsulación no existe y se tiene poco control sobre los datos. Esto sucede en todos los lenguajes de Programación Orientados a Objetos. Aun así, tienen la ventaja que los datos son coherentes en todas partes.

Sin embargo, si se elimina el aliasing, el rendimiento es mucho menor por tener que hacer copias continuamente de los objetos.

Entonces, lo que se hace es utilizar clases inmutables y se clonan determinados objetos para evitar los problemas de aliasing. Existen dos tipos de clones:

Considere el siguiente código para ilustrar la diferencia más claramente:

 1Mutable m = new Mutable(10);
 2Contenedor c1 = new Contenedor(m);
 3m.setValor(20); // Aliasing: c1.valor = 20
 4
 5// Shallow Copy: equivalente a new Contenedor(m)
 6// Se crea un nuevo objeto, pero el atributo interno
 7// m sigue apuntando al mismo objeto.
 8Contenedor c2 = c1.shallowCopy();
 9m.setValor(30);
10// Sigue habiendo aliasing: c2.valor = 30
11
12// Deep Copy: se duplica también el objeto mutable
13Contenedor c3 = c1.deepCopy();
14m.setValor(40);
15// m y c3.m ya no están relacionados: c3.valor = 30

En Java, se puede sobreescribir la función clone() (al igual que toString). Es decisión del programador realizar un tipo de copia u otro. Tenga en cuenta que super.clone() solo devuelve una shallow copy del objeto, por lo que si se quiere hacer una deep copy, es necesario llamar a clone() de cada atributo mutable.

Tipos de datos primitivos

Son aquellos tipos de datos que tienen una correspondencia directa con el hardware:

Wrappers

Los tipos de datos primitivos no son clases: no encapsulan, no hay métodos, etc. Para hacer consistente el esquema, en Java existen unas clases Wrapper:

Tipo de datos primitivoClase Wrapper correspondiente
booleanBoolean
charCharacter
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble

Estos son clases que encapsulan el valor del tipo de dato y proporcionan métodos que facilitan su manejo, como la conversión a otros tipos / desde otros tipos. Nótese que son clases inmutables, el valor interno se declaró como final.

Dado que utilizar métodos sobre estas clases continuamente para operaciones básicas (como sumar dos int), Java permite tratar la clase Wrapper como un tipo de dato primitivo y viceversa:

Nótese que estos mecanismos solo son syntactic sugar para tener que evitar llamar a los métodos intValue() y similares constantemente. Más información.

Buena práctica

Se prefiere usar tipos de datos primitivos siempre que se pueda, simplifican el código y son más eficientes (recuerda que es la representación del propio hardware).

Usar solo Wrappers cuando es necesario realizar conversiones de tipo (sobre todo con Strings) y colecciones (ArrayList<Integer>, no se permite ArrayList<int>).

El puntero this

this es una referencia al objeto actual. Su uso es obligatorio cuando hay una colisión de nombres, un ejemplo muy típico es:

1public class Persona {
2    private final String nombre;
3    private final int edad;
4
5    public Persona(String nombre, int edad) {
6        this.nombre = nombre;
7        this.nombre = edad;
8    }
9}

También se puede usar para llamar al constructor, con la restricción de que sea la primera línea de otro constructor. Continuando con el ejemplo anterior:

 1// ...
 2
 3public Persona(String nombre) {
 4    this(nombre, 32);
 5}
 6
 7public Persona(int edad) {
 8    this("Federico Mercurio", edad);
 9}
10
11public Persona() {
12    this
13}

Este también es un claro ejemplo de uso de sobrecarga de métodos.

Otros usos pueden ser:

No se puede utilizar en métodos estáticos, porque no hay una instancia de un objeto a la que referirse.

Máquina Virtual de Java

Java es a la vez un lenguaje compilado e interpretado:

  1. Primero se compila el código fuente de los archivos .java a .class.
    El contenido de estos archivos no son instrucciones nativas de código máquina, sino que es bytecode o lenguaje intermedio.
  2. Como no se puede ejecutar directamente en el procesador, estos archivos .class se leen e interpretan por la Máquina Virtual de Java (JVM).

Gracias a este mecanismo, se puede escribir un programa en Java para cualquier sistema que tenga una versión de la JVM compatible: es extremadamente portable, a coste de un peor rendimiento.

Debido a esta arquitectura, la JVM no está limitada a solo Java, sino que también es posible crear compiladores para otros lenguajes de programación (Kotlin o Groovy) que generen este bytecode y aprovechar su portabilidad.

Almacenamiento de datos

Antes de nada, se debe discutir donde la JVM guarda los datos necesarios para el programa.

Pila / Stack

Zona de memoria pequeña y de tamaño limitado.

  • Acceso gracias al puntero de pila ==> Es rápida y eficiente.
  • Se debe conocer el tamaño del dato a guardar en tiempo de compilación.
  • Se crean y se destruyen datos automáticamente al inicio y final de cada método.

Aquí se almacenan:

  • Referencias a objetos
  • Variables locales, argumentos del método y su valor de retorno
  • Código correspondiente a cada método (Call Stack).

⚠️ No todos los tipos de datos se almacenan en la pila, como por ejemplo los atributos de una clase.

Montón / Heap

Zona de memoria grande y desordenada. Su gestión corre a cargo del Recolector de Basura.

  • No se necesita conocer el tamaño del dato de antemano, se puede reservar memoria en tiempo de ejecución.

Aquí se almacenan: los objetos creados, todos lo valores de sus atributos.

Nótese que una clase no ocupa memoria, dado que es solo una plantilla.

Recolector de Basura (Garbage Colector)

Dado que el programador no borra los objetos que crea, es necesario de cierto mecanismo para liberar aquella memoria que ya no se usa. El Recolector de Basura se encarga de buscar en la memoria del programa para:

Se opera en segundo plano durante la ejecución del programa, realizando automáticamente la gestión de memoria. Es importante destacar que el rendimiento de un programa Java está condicionado por la gestión eficiente del heap, o lo que es lo mismo, por el rendimiento del Recolector de Basura.

  1. Marcado: El GC recorre todo el heap marcando aquellos objetos que no están referenciados por nadie.
  2. Borrado: Se eliminan los objetos marcados y se mantiene una lista de zonas de memoria libre, que luego usará cuando necesite memoria para un objeto.
  3. Compactación: Opcionalmente, para mejorar el rendimiento, se compacta la memoria moviendo los objetos referenciados a posiciones de memoria consecutivas. Por este motivo, no se usan direcciones de memoria en Java.

Sin embargo, el lector puede advertir que este proceso es muy lento e ineficiente, sobre todo si se aplica sobre todo el sistema.

La solución es cambiar el esquema de gestión de la memoria. En la mayoría de programas, el uso de memoria no es uniforme a lo largo del tiempo:

Entonces, se puede dividir la memoria en generaciones.

El funcionamiento es el siguiente:

  1. Cuando se crea un objeto, se almacena en el Edén.

  2. La Generación Nueva se limpia frecuentemente por RBminor y se van pasando los objetos de un lado a otro:

    En la primera recolección de basura:

    Supervivientes Edén --> S1
    Supervivientes S0   --> S1
    Borrar S0
    

    Y en la siguiente:

    Supervivientes Edén --> S0
    Supervivientes S1   --> S0
    Borrar S1
    
  3. Si algún objeto de mayor edad que la umbral (ha sobrevivido umbral recolecciones de basura menores), se pasa a la generación vieja.

Fíjese que este comportamiento es bastante genérico y hay múltiples maneras de implementarlo y con muchos otros detalles. Por eso, existen un montón de GC diferentes que se pueden utilizar: serial GC, parallel GC, CMS GC, G1 GC, Epsilon GC, ZGC

String Pool

La clase String es la más usada en todo el lenguaje de programación Java. Esta también es inmutable, por lo que la JVM puede almacenar una copia de cada String literal en la llamada String Pool.

El proceso de rellenar la String Pool se llama interning: si el compilador encuentra un nuevo String literal, se buscará en la String Pool:

Entonces, en el siguiente código a y b apuntan al mismo objeto, que se sitúa en la String Pool:

String a = "HOLA";
String b = "HOLA";

A efectos prácticos, sucede lo siguiente:

String a = new String("HOLA");
String b = a;

Tenga en cuenta que al crear un String con new String("HOLA"), el objeto resultante no se almacena en la String Pool, aunque se puede hacer manualmente llamando al método intern().

Antes de Java 7, se almacenaba la String Pool en la generación permanente, lo que implica que los Strings contenidos no son elegibles por el GC para eliminar. Sin embargo, a partir de Java 7, la String Pool se almacena en el Heap por lo que sí es elegible para eliminar. Esto se hizo para reducir el riesgo de OutOfMemory.

Hasta Java 8, los Strings se representaban internamente como un array de caracteres (char[]), codificados en UTF-16, por lo que cada uno ocupa 2 bytes en memoria. Java 9 trajo los Compact Strings, que escoge de forma automática entre char[] y byte[] en función del contenido, con el objetivo de ahorrar memoria.

Gestión avanzada de referencias

¿Existe alguna forma de acceder a memoria cuya referencia no está disponible?

En Java, se pueden distinguir 4 tipos de referencias:

    flowchart LR
    J("java.lang.ref")
    R("Reference#60;T#62;")
    W("WeakReference#60;T#62;")
    S("SoftReference#60;T#62;")
    P("PhantomReference#60;T#62;")

    J ~~~ R
    R --> W
    R --> S
    R --> P

    style J stroke: none
    style R stroke: #0ef, stroke-width: 3px
    style W stroke: #f05, stroke-width: 3px
    style S stroke: #f05, stroke-width: 3px
    style P stroke: #f05, stroke-width: 3px

Reference<T> tiene el método T get() que devuelve una referencia fuerte al objeto.

Referencias Fuertes
  • Son las referencias por defecto
  • Se crean automáticamente con un objeto
  • No se elimina el objeto apuntado hasta que la referencia apunte a null.
    Un ejemplo sería el final del método.
public static void main(String[] args) {
    String referenciaFuerte = new String("HOLA");
} // Aquí se pierde referenciaFuerte (se quita de la stack)
  // Cuando el GC haga una pasada, borrará el String dado
  // que no hay más referencias apuntando
Referencias Débiles
  • Instancia de la clase WeakReference<T>.
  • El objeto apuntado se le pasa al constructor.
  • Esta referencia no obliga al GC a mantener el objeto

Por eso, si el GC ha borrado el objeto, wref.get() puede devolver null.

Referencias Suaves
  • Instancia de la clase SoftReference<T>.
  • El GC solo borrará el objeto apuntado en el único caso en el que necesite memoria para otros objetos.
Referencias Fantasma
  • Instancia de la clase PhantomReference<T>.
  • El constructor toma una referencia fuerte a la que apunta y una cola donde se almacenará, una ReferenceQueue.
  • El acceso a las referencias se hace a través de la cola con el método poll(), get() siempre devolverá null.
  • EL GC añade una referencia fantasma a la cola cuando ejecuta el método finalize()
  • Se utiliza para determinar cuando un objeto se borró de memoria y para evitar el uso del método finalize().
Anterior: Encapsulación Volver a Programación Orientada a Objetos Siguiente: Conjuntos de Datos