En esta entrada dejaré el resultado de un experimento que surgió por una difícil pregunta que me generó varias dudas...
El experimento es revisar el comportamiento de un programa usando un Map en forma concurrente:
Comenzamos creando la clase con el map, luego agregarle algunos datos y el main:
public class Concurrido {
private final Map<Integer, String> estado = new HashMap<y>();
{
IntStream.rangeClosed(1, 1_000_000)
.forEach(i -> estado.put(i, String.valueOf(1)));
}
public static void main(String[] args) throws InterruptedException {
final var c = new Concurrido();
final var t1 = new Thread(() -> {
c.m1();
System.out.println("m1 terminado");
});
final var t2 = new Thread(() -> {
c.m2();
System.out.println("m2 terminado");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("fin");
}
el main lanzará 2 hilos (t1 y t2), cada uno tendrá acceso al mismo objeto (c) intentando modificar su estado en distintos métodos (m1 y m2) de la misma referencia.
Prueba 1
la primera prueba es crear m1 y m2 sin ninguna modificación especial, uno quita elementos y otro los muestra:
private void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private void m2() {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
al lanzarlo el resultado es el siguiente:
trabajando en m1
trabajando en m2
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.forEach(HashMap.java:1428)
at com.sebastian.concurrente.Concurrido.m2(Concurrido.java:47)
at com.sebastian.concurrente.Concurrido.lambda$main$2(Concurrido.java:26)
at java.base/java.lang.Thread.run(Thread.java:832)
m1 terminado
fin
No se puede ocupar en forma concurrente como m1 y m2 están definidos
Prueba 2
Lo siguiente será usar synchronized solo en un bloque dentro de m1:
private void m1() {
synchronized(estado) {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
}
private void m2() {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
Algo que no será muy útil pues se está sincronizando solo un bloque dentro del método m1:
trabajando en m1
trabajando en m2
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.forEach(HashMap.java:1428)
at com.sebastian.concurrente.Concurrido.m2(Concurrido.java:49)
at com.sebastian.concurrente.Concurrido.lambda$main$2(Concurrido.java:26)
at java.base/java.lang.Thread.run(Thread.java:832)
Prueba 3
Ahora será sincronizar ambos métodos sobre la misma variable de instancia:
private void m1() {
synchronized(estado) {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
}
private void m2() {
synchronized(estado) {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
}
Genera lo siguiente:
trabajando en m1
m1 terminado
trabajando en m2
m2 terminado
fin
En este caso como el lock se obtiene sobre el estado en m1, m2 no podrá obtenerlo hasta que m1 termine. funciona sin generar error.
Prueba 4
lo siguiente será usar synchronized en m1:
private synchronized void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private void m2() {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
y genera lo siguiente:
trabajando en m1
trabajando en m2
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.forEach(HashMap.java:1428)
at com.sebastian.concurrente.Concurrido.m2(Concurrido.java:48)
at com.sebastian.concurrente.Concurrido.lambda$main$2(Concurrido.java:27)
at java.base/java.lang.Thread.run(Thread.java:832)
m1 terminado
fin
en este caso synchronized en el método obtiene el lock sobre la instancia, pero m2 no ocupa lock asi que explota igual
Prueba 5
Ahora dejaré ambos métodos con synchronized, así ambos tienen que competir por el lock sobre la instancia:
private synchronized void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private synchronized void m2() {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
y el resultado es el siguiente:
trabajando en m1
m1 terminado
trabajando en m2
m2 terminado
fin
funciona, como el lock es sobre la instancia es equivalente a:
private synchronized void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private void m2() {
synchronized(this) {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
}
y NO equivale a:
private synchronized void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private void m2() {
synchronized(estado) {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
}
m1 está obteniendo el lock sobre la instancia y m2 sobre una variable de instancia, el resultado es:
trabajando en m1
trabajando en m2
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.forEach(HashMap.java:1428)
at com.sebastian.concurrente.Concurrido.m2(Concurrido.java:49)
at com.sebastian.concurrente.Concurrido.lambda$main$2(Concurrido.java:27)
at java.base/java.lang.Thread.run(Thread.java:832)
m1 terminado
fin
Prueba 6
En esta prueba el map será un ConcurrentHashMap:
private final Map<Integer, String> estado = new ConcurrentHashMap<>();
y los métodos serán sin modificaciones:
private void m1() {
System.out.println("trabajando en m1");
for (var j = 0; j < 100_000_000_0L; j++) {
estado.remove(j);
}
}
private void m2() {
System.out.println("trabajando en m2");
estado.forEach((a, b) -> {});
}
El resultado es:
trabajando en m2
trabajando en m1
m2 terminado
m1 terminado
fin
En este caso ni t1 ni t2 esperan, ambos hilos pasan a m1 y m2 respectivamente y funciona.
Así finalizan las pruebas 😀