Cosas de Mapas

Al Construir la librería Bikey, para hacer que fuera lo más fácil y natural de usar para cualquier programador con experiencia en Java, intenté que el API siguiera los mismos patrones y semánticas que la librería de Collecciones de Java. Para eso estudié y repliqué el comportamiento (e incluso Javadoc) del API de Map.

Al hacerlo, me di cuenta de una cosa de la que no era consciente (o nunca me había planteado): los métodos Collection<V> values(), Set<K> keySet(), y Set<Map.Entry<K,​V>> entrySet() devuelven vistas del mapa:

API HashMap values

¿Qué significa eso? Los Sets y Collections devueltos por esos métodos no son nuevas instancias de colecciones que contengan referencias a las claves y/o valores del mapa, sino que son implementaciones que simulan su comportamiento y que por detrás usan directamente los elementos que forman Map.

Bugs

No tener esto en cuenta puede ser una fuente de bugs, y dependiendo de lo lejos que esté el código que obtiene esa colecciones de su uso, puede traerte más de un quebradero de cabeza.

Cada acción sobre uno de esos Sets o Collections se ve reflejado en el mapa asociado:

  • Cuando iteras un values(), estas iterando los elementos que forman el mapa y obteniendo sólo el valor asociado a cada clave.
  • Cuando preguntas si existe un valor de keySet(), estás indirectamente llamando al método containsKey del mapa.
  • Cuando eliminas un valor de keySet(), estas eliminando un elementos del mapa.
  • Cuando llamas al método clear() de entrySet(), estás vaciando el mapa.
  • Cuando añades elementos al mapa, estás modificando las colecciones asociadas.

Por tanto cuando uses las colecciones procedentes de un mapa tienes que ser consciente de que al modificar el mapa estas modificando los objetos Collection<V> o Set<K> del mapa que tuvieras referenciados. Es decir, tiene efectos secundarios.

Si quieres asegurarte de no tener side effects, deberás hacerte una copia de las colecciones:

  • Set<K> keysCopy = new HashSet<>(map.keySet());
  • List<V> valuesCopy = new ArrayList<>(map.values());
  • Set<Map.Entry<K,​V>> entryCopy = new HashSet<>(map.entrySet());

Ejemplo

Mejor verlo con un ejemplo real de código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
void foo() {
    mapChangeModifiesValuesSet();
    mapChangeModifiesKeysSet();
    valuesSetChangeModifiesMap();
}

void mapChangeModifiesValuesSet() {
    Map<Integer, User> usersMap = loadUsersFromWherever();
    UseValues withValues = new UseValues(usersMap.values());
    usersMap.put(4, new User(4, "Donald"));
    withValues.doSomething();
}

UseKeys mapChangeModifiesKeysSet() {
    Map<Integer, User> usersMap = loadUsersFromWherever();
    UseKeys withKeys = new UseKeys(usersMap.keySet());
    usersMap.put(4, new User(4, "Donald"));
    withKeys.doSomething();
    return withKeys;
}

void valuesSetChangeModifiesMap() {
    Map<Integer, User> usersMap = loadUsersFromWherever();
    UseValues withValues = new UseValues(usersMap.values());
    withValues.clear();
    System.out.println("Map content: " + usersMap);
}

class UseValues {

    private final Collection<User> users;

    public UseValues(Collection<User> users) {
        this.users = users;
        System.out.println("UseValues constructor: " + users);
    }

    public void doSomething() {
        System.out.println("UseValues doSomething: " + users);
    }

    public void clear() {
        users.clear();
    }

}

class UseKeys {

    private final Collection<Integer> ids;

    public UseKeys(Collection<Integer> ids) {
        this.ids = ids;
        System.out.println("UseKeys constructor: " + ids);
    }

    public void doSomething() {
        System.out.println("UseKeys doSomething: " + ids);
    }

}

class User {
    private final Integer id;
    private final String name;
    public User(Integer i, String name) {
        this.id = i;
        this.name = name;
    }
    public Integer getId() { return id; }
    public String getName() { return name; }
    public String toString() { return id + ": " + name; }
}

Map<Integer, User> loadUsersFromWherever() {
    Map<Integer, User> users = new HashMap<>();
    users.put(1, new User(1, "John"));
    users.put(2, new User(2, "Peter"));
    users.put(3, new User(3, "Rose"));
    return users;
}

Al ejecutar el método mapChangeModifiesValuesSet, vemos que la colección de usuarios se vé modificada:

UseValues constructor: [1: John, 2: Peter, 3: Rose]
UseValues doSomething: [1: John, 2: Peter, 3: Rose, 4: Donald]

Al ejecutar el método mapChangeModifiesKeysSet, vemos que la colección de identificadores también se modifica:

UseKeys constructor: [1, 2, 3]
UseKeys doSomething: [1, 2, 3, 4]

Al ejecutar el método valuesSetChangeModifiesMap vemos que la modificación va en los dos sentidos, y que el mapa también se puede cambiar indirectamente:

UseValues constructor: [1: John, 2: Peter, 3: Rose]
Map content: {}

Por tanto, si vas a usar estas colecciones asegúrate de:

  • o bien realizar una copia de las mismas si no sabes donde van a acabar
  • o de usarlas en un ámbito o contexto muy cercano a su obtención para iterar.

Mi recomendación es usar sólo esas colecciones cuando necesitas iterar el mapa por clave y/o valor:

for (Set<Integer> id: users.keySet()) {
  ...
}

users.entrySet().stream()
     .filter(u -> u.getValue().getAge() > 18)
     .collect(toMap(u -> u.getKey(), u -> createReccord(u.getValue())));

Memory Leak

Si te quedas con alguna referencia a cualquiera de las tres colecciones, otro problema importante que puedes encontrar es el de tener un memory leak.

Las colecciones que devuelven los tres métodos están implementadas como inner classes no estáticas dentro de HashMap, y por tanto contienen una referencia al Map y los atributos miembros de la clase.

Si de un mapa te guardas la referencia de su keySet/values/entrySet, te estarás quedando con todo el HashMap, y con los valores que lo forman. Por lo que el recolector de basura no podrá liberar el espacio ocupado por el mapa y los valores asociados.

En el ejemplo, si nos guardamos la referencia al objeto UseKeys devuelto por el método mapChangeModifiesKeysSet, aunque el ámbito de la variable usersMap termine y se libere la referencia, el mapa y todos sus valores de tipo User seguirán vivos en memoria porque su referencia es capturada indirectamente por el objeto UseKeys. El mapa estará en memoria tanto tiempo como lo esté la instancia de UseKeys devuelta.

Aunque el Set sólo parezca que contiene las claves (simples Integers), estaremos consumiendo toda la memoria asociada a cada Map.Entry del mapa, y la memoria asociada a cada valor del mapa, que dependiendo de su tamaño puede ser aún peor.