Cuando Synchronized se encuentra con esta cosa, hay un gran agujero, presta atención.

Hace unos días, vi una pregunta sobre el uso de Synchronized planteada por alguien en una plataforma tecnológica. Pensé que era muy interesante. Esta pregunta era en realidad una pregunta real que encontré cuando entrevisté a una empresa hace tres años. No lo hice. No conozco al entrevistador en ese momento, lo que quería probar, no respondí muy bien, y lo recordé después de investigarlo.

Entonces, cuando vi esta pregunta, me sentí muy amable y lista para compartirla con todos:

En primer lugar, para facilitarle la reproducción del problema cuando lea el artículo, le daré un código que puede ejecutar directamente. Espero que también pueda sacar el código y ejecutarlo si tiene tiempo. :

public class SynchronizedTest {

    public static void main(String[] args) {
        Thread why = new Thread(new TicketConsumer(10), "why");
        Thread mx = new Thread(new TicketConsumer(10), "mx");
        why.start();
        mx.start();
    }
}

class TicketConsumer implements Runnable {

    private volatile static Integer ticket;

    public TicketConsumer(int ticket) {
        this.ticket = ticket;
    }

    @Override
    public void run() {
        while (true) {
            System.out.println(Thread.currentThread().getName() + "开始抢第" + ticket + "张票,对象加锁之前:" + System.identityHashCode(ticket));
            synchronized (ticket) {
                System.out.println(Thread.currentThread().getName() + "抢到第" + ticket + "张票,成功锁到的对象:" + System.identityHashCode(ticket));
                if (ticket > 0) {
                    try {
                        //模拟抢票延迟
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "抢到了第" + ticket-- + "张票,票数减一");
                } else {
                    return;
                }
            }
        }
    }
}

La lógica del programa también es muy simple. Es un proceso de simulación de captura de boletos. Hay 10 boletos en total y se abren dos hilos para capturar boletos.

Los tickets son recursos compartidos y son consumidos por dos subprocesos, por lo que para garantizar la seguridad de los subprocesos, la palabra clave sincronizada se utiliza en la lógica de TicketConsumer.

Este es un ejemplo que todos deben escribir cuando son principiantes en sincronizado, el resultado esperado son 10 boletos, dos personas agarrando, cada boleto solo puede ser agarrado por una persona.

Pero el resultado real de la ejecución es así, solo intercepto el registro al principio:

Hay tres partes en caja en la captura de pantalla.

La parte superior es que dos personas están agarrando el boleto número 10. A partir de la salida del registro, no hay problema en absoluto. Al final, solo una persona toma el boleto y luego entra en el proceso de competir por el noveno boleto.

Pero la competencia por el boleto 9, que se enmarca a continuación, es un poco confusa:

why抢到第9张票,成功锁到的对象:288246497
mx抢到第9张票,成功锁到的对象:288246497

¿Por qué ambos tomaron el noveno boleto y los objetos que se bloquearon con éxito son los mismos?

Esta cosa está más allá del reconocimiento.

¿Cómo pueden estos dos subprocesos obtener el mismo bloqueo y luego ejecutar la lógica empresarial?

Entonces, surge la pregunta del interrogador.

  • 1. ¿Por qué sincronizado no tiene efecto?
  • 2. ¿Por qué la salida del objeto de bloqueo System.identityHashCode es la misma?

¿Por qué no funcionó?

Veamos primero una pregunta.

En primer lugar, ya sabemos muy claramente por la salida del registro que la sincronización falla cuando se toma el noveno boleto en la segunda ronda.

Con el apoyo de conocimientos teóricos, sabemos que si falla la sincronización, debe haber un problema de bloqueo.

Si solo hay un bloqueo y varios subprocesos compiten por el mismo bloqueo, no hay absolutamente nada de malo en sincronizar.

Sin embargo, los dos subprocesos no cumplen las condiciones de exclusión mutua, lo que significa que definitivamente hay más de un bloqueo aquí.

Esta es una conclusión que podemos deducir del conocimiento teórico.

Primero se saca la conclusión, entonces, ¿cómo puedo probar que "hay más de un candado"?

Poder ingresar sincronizado significa que se debe obtener el bloqueo, por lo que solo necesito ver cuáles son los bloqueos que tiene cada hilo.

Entonces, ¿cómo ver qué bloqueo tiene el hilo?

Comando jstack, función de pila de subprocesos de impresión, ¿entendido?

Esta información está oculta en la pila de subprocesos y podemos verla cuando la sacamos.

¿Cómo obtener la pila de hilos en idea?

Este es un pequeño truco para depurar en idea, que debería haber aparecido muchas veces en mis artículos anteriores.

En primer lugar, para la conveniencia de obtener información sobre la pila de subprocesos, ajusté el tiempo de suspensión aquí a 10 s:

Después de ejecutar, haga clic en el icono de "cámara" aquí:

Haga clic varias veces y habrá varias informaciones de volcado correspondientes al momento del clic:

Como necesito observar los dos primeros bloqueos, y cada vez que el subproceso ingresa al bloqueo, esperará 10 segundos, así que solo hago clic una vez entre los primeros 10 y los segundos 10 del inicio del proyecto.

Para observar los datos de manera más intuitiva, elijo hacer clic en el siguiente icono para copiar la información del volcado:

Hay mucha información copiada, pero solo debemos preocuparnos por los dos subprocesos why y mx.

Esta es la información relevante en medio del primer volcado:

El subproceso mx está en estado BLOQUEADO, esperando el bloqueo en la dirección 0x000000076c07b058.

por qué el subproceso está en estado TIMED_WAITING, está inactivo, lo que indica que ha tomado el candado y está ejecutando la lógica empresarial. Y el bloqueo que agarra, dijiste, casualmente, es 0x000000076c07b058 que está esperando el subproceso mx.

A juzgar por el registro de salida, la primera captura de ticket fue capturada por el subproceso por qué:

Según la información de Dump, los dos hilos están compitiendo por el mismo bloqueo, por lo que no hay problema por primera vez.

Ok, veamos la segunda información de volcado:

Esta vez, ambos subprocesos están en TIMED_WAITING y ambos están inactivos, lo que indica que obtuvieron el bloqueo e ingresaron a la lógica comercial.

Pero una mirada más cercana muestra que los candados sostenidos por los dos hilos no son los mismos candados.

El bloqueo mx es 0x000000076c07b058.

¿Por qué el bloqueo es 0x000000076c07b048?

Dado que no es el mismo bloqueo, no existe una relación de competencia, por lo que ambos pueden ingresar sincronizados para ejecutar la lógica comercial, por lo que ambos hilos están dormidos y no hay nada malo.

Luego, juntaré la información de los dos Dumps para que los vean, para que sea más intuitivo:

Si reemplazo 0x000000076c07b058 con "bloqueo uno" y 0x000000076c07b048 con "bloqueo dos".

Entonces el proceso es así:

¿Por qué? Una vez que el bloqueo es exitoso, se ejecuta la lógica de negocios y mx ingresa al estado de espera de bloqueo.

¿Por qué liberar el bloqueo 1, esperar a que se despierte el mx del bloqueo 1, mantener presionado el bloqueo 1 y continuar realizando negocios?

Al mismo tiempo, por qué el segundo bloqueo es exitoso y se ejecuta la lógica comercial.

A partir de la pila de subprocesos, demostramos que la razón por la cual la sincronización no surtió efecto fue que el bloqueo cambió.

Al mismo tiempo, también podemos ver en la pila de subprocesos por qué la salida del objeto de bloqueo System.identityHashCode es la misma.

Durante el primer volcado, los boletos son todos 10, en los que mx no agarró el candado y fue bloqueado por sincronizado.

por qué el subproceso ejecuta la operación ticket--, el ticket se convierte en 9, pero el monitor bloqueado por el subproceso mx sigue siendo el objeto de ticket=10, que sigue esperando en la _EntryList del monitor, no por el cambio de ticket con cambio.

Por lo tanto, cuando el subproceso why libera el bloqueo, el subproceso mx obtiene el bloqueo y continúa ejecutándose, y encuentra que ticket=9.

Y por qué también obtuvo un nuevo bloqueo, también puede ingresar a la lógica sincronizada y encontró que ticket = 9.

Buen chico, los boletos son todos 9, ¿el System.identityHashCode puede ser diferente?

Es lógico que después de liberar el bloqueo uno, ¿por qué debería continuar compitiendo con mx por el bloqueo uno, pero no sabe de dónde obtuvo un nuevo bloqueo?

Entonces surge la pregunta: ¿por qué ha cambiado la cerradura?

¿Quién movió mi candado?

Después del análisis anterior, confirmamos que la cerradura sí ha cambiado, cuando analizaste esto, te enfureciste, golpeaste la mesa y gritaste: ¿Qué melón movió mi cerradura? ¿No es esto una mierda?

Según mi experiencia, no se apresure en este momento, continúe mirando hacia abajo y encontrará que el payaso es en realidad usted mismo:

Después de agarrar el boleto, se realiza la operación de boleto, ¿y no es este boleto el objeto de su candado?

En este momento, te golpeaste el muslo, de repente te diste cuenta y les dijiste a los espectadores: "No es un gran problema, es solo una mano temblorosa".

Entonces agité mi mano y cambié el lugar cerrado a esto:

synchronized (TicketConsumer.class)

El uso del objeto de clase como objeto de bloqueo garantiza la unicidad del bloqueo.

Se ha comprobado que no tiene nada de malo, está perfecto y el trabajo ha terminado.

Pero, ¿realmente ha terminado?

De hecho, sobre por qué ha cambiado el objeto de bloqueo, todavía hay una pequeña cosa que no se ha dicho.

Está oculto en el código de bytes.

Podemos verificar el código de bytes a través del comando javap, y podemos ver esta información:

Integer.value¿De qué es esto?

El caché familiar de números enteros de -128 a 127.

Es decir, en nuestro programa, estará involucrado el proceso de desempaquetado y empaquetado, y durante este proceso se llamará al método Integer.valueOf. Específicamente, es la operación de boleto--.

Para Integer, se devuelve el mismo objeto cuando el valor está en el rango de caché. Cuando se excede el rango de caché, se creará un nuevo objeto cada vez.

Este debería ser un punto de conocimiento imprescindible de Baguwen. ¿Qué quiero expresar al enfatizar esto para ustedes aquí?

Es muy simple, solo cambia el código para entender.

Cambié el número de votos iniciales de 10 a 200, lo que excedía el rango de caché, el resultado del programa es el siguiente:

Obviamente, desde la primera salida del registro, los bloqueos no son el mismo bloqueo.

Esto es lo que dije antes: debido a que se excede el rango de caché, la operación de new Integer (200) se realiza dos veces. Son dos objetos diferentes. Cuando se usan como bloqueos, son dos bloqueos diferentes.

Modifíquelo de nuevo a 10, ejecútelo una vez y lo sentirá:

De la salida del registro, solo hay un bloqueo en este momento, por lo que solo un subproceso toma el ticket.

Debido a que 10 es un número en el rango de caché, es el mismo objeto obtenido del caché cada vez.

El propósito de escribir este breve párrafo es reflejar el punto de conocimiento que Integer tiene caché, todos lo saben. Pero cuando se mezcla con otras cosas, hay que analizar los problemas que causa este caché, que es más efectivo que memorizar puntos secos de conocimiento directamente.

pero...

Nuestro ticket inicial es 10, y ticket-- más tarde el ticket se convierte en 9, que también está dentro del rango del caché ¿Por qué cambió el candado?

Si tienes esta pregunta, entonces te insto a que lo pienses de nuevo.

10 es 10 y 9 es 9.

Aunque todos están dentro del rango del caché, originalmente son dos objetos diferentes y también son nuevos al construir el caché:

¿Por qué estoy agregando esta declaración de aspecto tonto?

Porque cuando veo otras preguntas similares en Internet, algunos artículos no están claramente escritos, lo que hará que los lectores piensen erróneamente que "los valores en el rango de caché son todos el mismo objeto", lo que engañará a los principiantes.

En una palabra:  no use Integer como objeto de bloqueo, no puede agarrarlo.

pero...

desbordamiento de pila

Sin embargo, vi una pregunta similar en stackoverflow cuando escribí el artículo.

El problema con este tipo es: él sabe que Integer no se puede usar como objeto de bloqueo, pero sus requisitos parecen tener que usar Integer como objeto de bloqueo.

https://stackoverflow.com/questions/659915/synchronizing-on-an-integer-value

Te describiré su problema.

En primer lugar, mire el lugar etiquetado como ①. Su programa se obtiene primero del caché. Si no hay caché, se obtiene de la base de datos y luego se coloca en el caché.

Lógica muy simple y clara.

Pero él considera el escenario simultáneo, si hay varios subprocesos para obtener la misma identificación al mismo tiempo, pero los datos correspondientes a esta identificación no están en el caché, entonces estos subprocesos realizarán la acción de consultar la base de datos y mantener el caché. .

Correspondiente a la acción de consulta y almacenamiento, utilizó el término "bastante caro" para describirlo.

Significa "bastante caro".Para decirlo sin rodeos, esta acción es muy "pesada" y es mejor no repetirla.

Así que solo deje que un subproceso realice la operación bastante costosa.

Así que pensó en el código del lugar marcado ②.

Use sincronizado para bloquear la identificación, desafortunadamente, la identificación es de tipo Integer.

En el lugar etiquetado como ③, lo dijo él mismo: diferentes objetos Integer no comparten bloqueos, por lo que sincronizar es inútil.

De hecho, su frase no es rigurosa.Después del análisis anterior, sabemos que los objetos Integer en el rango de caché seguirán compartiendo el mismo bloqueo.El "compartir" aquí significa competencia.

Pero obviamente, su rango de identificación debe ser mayor que el rango de caché de Integer.

Entonces surge la pregunta: ¿qué hacer con esta cosa?

La primera pregunta que vino a mi mente cuando vi esta pregunta fue: Parece que estoy haciendo el requisito anterior a menudo, ¿cómo lo hice?

Después de pensarlo por unos segundos, de repente me di cuenta, oh, ahora son todas las aplicaciones distribuidas, y uso Redis directamente para los bloqueos.

Nunca pensé en eso en absoluto.

Si Redis no está permitido ahora, es una sola aplicación, ¿cómo resolverlo?

Antes de ver la respuesta de gran elogio, echemos un vistazo a un comentario debajo de esta pregunta:

Las tres primeras letras: FYI.

No importa si no lo entiendes, porque ese no es el punto.

Pero ya sabes, mi inglés es muy alto, así que enseñaré algo de inglés por cierto.

FYI, es una abreviatura en inglés de uso común, el nombre completo es para su información, como referencia.

Entonces, debe haberle adjuntado un documento más tarde, y la traducción es: Brian Goetz mencionó en su discurso de Devoxx 2018 que no deberíamos usar Integer como un candado.

Puedes ir directamente a esta parte de la explicación a través de este enlace. Solo tienes menos de 30 segundos para practicar la escucha:
https://www.youtube.com/watch?v=4r2Wg-TY7gU&t=3289s

¿Entonces la pregunta viene de nuevo?

¿Quién es Brian Goetz y por qué suena autoritario?

Java Language Architect de Oracle, el desarrollador del lenguaje Java, le pregunta si tiene miedo.

Al mismo tiempo, es autor del libro "Programación concurrente de Java en la práctica" que he recomendado muchas veces.

Bueno, ahora que he encontrado el respaldo del grandullón, les mostraré lo que dijo Gaozan en la respuesta.

No entraré en detalles en la parte anterior. De hecho, son los puntos que mencionamos anteriormente. No se puede usar un número entero. Involucra el caché interno y externo ...

Preste atención a la parte subrayada, agregaré mi propio entendimiento para traducirlo para usted:

Si realmente tiene que usar Integer como bloqueo, entonces necesita hacer un Map o un Set of Integer, y al hacer el mapeo con la clase de colección, puede asegurarse de que el mapeo sea una instancia clara de lo que desea. Y esta instancia se puede usar como un candado.

Luego da este fragmento de código:

Es usar ConcurrentHashMap y luego usar el método putIfAbsent para hacer un mapeo.

Por ejemplo, si se llama varias veces a locks.putIfAbsent(200, 200), solo hay un objeto Integer con un valor de 200 en el mapa. Esto está garantizado por las características del mapa y no es necesario explicarlo demasiado. .

Pero este amigo es muy bueno Para evitar que alguien no pueda doblar esta esquina, se lo explicó a todos nuevamente.

Primero, dice que también puedes escribir:

Pero de esta manera incurrirás en un pequeño costo, es decir, cada vez que accedas, si el valor no está mapeado, crearás un objeto Object.

Para evitar esto, simplemente mantiene el entero en un Mapa. ¿Cuál es el propósito de hacer esto? ¿En qué se diferencia esto de usar el entero directamente?

Lo explicó así, que en realidad es lo que dije antes "esto es lo que garantizan las características del mapa":

Cuando realiza un get() desde un mapa, se usa el método equals() para comparar las claves.

Dos instancias enteras diferentes del mismo valor, llamando al método equals() se considerarán iguales.

Por lo tanto, puede pasar cualquier cantidad de instancias enteras diferentes de "nuevo entero (5)" como argumentos para getCacheSyncObject, pero siempre obtendrá solo la primera instancia pasada que contiene ese valor.

Eso es lo que quiero decir:

Para resumir una oración: se mapea a través de Map. No importa cuántos enteros nuevos, estos enteros se asignarán al mismo entero, lo que garantiza que incluso si se excede el caché de enteros, solo hay un bloqueo.

Además de la gran respuesta de elogio, hay otras dos respuestas que me gustaría decir.

La primera es esta:

No me importa lo que dijo, pero me sorprendió cuando vi la traducción de esta oración:

despellejar este gato?

es tan cruel

Pensé en ese momento que esta traducción no debe ser correcta, debe ser un poco de jerga. Así que lo revisé y resultó ser esto:

Te enviaré un poco de conocimiento de inglés gratis, de nada.

La segunda respuesta que debería preocuparnos está al final:

Este amigo le dijo que mirara el contenido de la Sección 5.6 de "Programación concurrente de Java en acción", que tiene la respuesta que está buscando.

Casualmente, tenía el libro a mano, así que lo abrí y eché un vistazo.

La Sección 5.6 se titula "Construir cachés de resultados escalables y eficientes":

Hombre, eché un vistazo más de cerca a esta sección y vi que se trata de un bebé.

El código de muestra en el libro que leíste:

¿No es exactamente el mismo que el código del tipo que hizo la pregunta?

Todos se obtienen del caché, y si no están disponibles, se vuelven a construir.

La diferencia es que el libro agrega sincronización al método. Pero el libro también dice que esta es la peor solución, solo para sacar a la luz el problema.

Luego dio una solución relativamente buena con la ayuda de ConcurrentHashMap, putIfAbsent y FutureTask.

Puede ver que el problema se resuelve desde otro ángulo. No hay ningún enredo en la sincronización y el segundo método elimina directamente la sincronización.

Supongo que te gusta

Origin blog.csdn.net/Trouvailless/article/details/124248121
Recomendado
Clasificación