Comparación y semántica de intercambio y rendimiento de Java
performance concurrency (3)
¿Cuál es la semántica de comparar y cambiar en Java? A saber, el método de comparación y canje de un AtomicInteger
garantiza el acceso ordenado entre diferentes hilos a la ubicación de memoria particular de la instancia de número entero atómico, o garantiza el acceso ordenado a todas las ubicaciones en la memoria, es decir, actúa como si fuera un volátil (una valla de memoria).
De los documentos :
-
weakCompareAndSet
lee atómicamente y escribe de manera condicional una variable, pero no crea ninguna orden deweakCompareAndSet
anterior, por lo que no ofrece garantías con respecto a las lecturas y escrituras anteriores o posteriores de cualquier variable que no sea el destino deweakCompareAndSet
. -
compareAndSet
y todas las demás operaciones de lectura y actualización, comogetAndIncrement
tienen los efectos de memoria de lectura y escritura de variables volátiles.
Es evidente a partir de la documentación de la API que compareAndSet
actúa como si fuera una variable volátil. Sin embargo, se supone que weakCompareAndSet
simplemente cambia su ubicación de memoria específica. Por lo tanto, si esa ubicación de memoria es exclusiva de la memoria caché de un solo procesador, se supone que weakCompareAndSet
es mucho más rápido que la compareAndSet
.
threadnum
esto porque he comparado los siguientes métodos ejecutando threadnum
hilos diferentes, variando threadnum
de 1 a 8, y teniendo totalwork=1e9
(el código está escrito en Scala, un lenguaje JVM estáticamente compilado, pero tanto su significado como la traducción de bytecode es isomorfa a la de Java en este caso; estos breves fragmentos deben ser claros):
val atomic_cnt = new AtomicInteger(0)
val atomic_tlocal_cnt = new java.lang.ThreadLocal[AtomicInteger] {
override def initialValue = new AtomicInteger(0)
}
def loop_atomic_tlocal_cas = {
var i = 0
val until = totalwork / threadnum
val acnt = atomic_tlocal_cnt.get
while (i < until) {
i += 1
acnt.compareAndSet(i - 1, i)
}
acnt.get + i
}
def loop_atomic_weakcas = {
var i = 0
val until = totalwork / threadnum
val acnt = atomic_cnt
while (i < until) {
i += 1
acnt.weakCompareAndSet(i - 1, i)
}
acnt.get + i
}
def loop_atomic_tlocal_weakcas = {
var i = 0
val until = totalwork / threadnum
val acnt = atomic_tlocal_cnt.get
while (i < until) {
i += 1
acnt.weakCompareAndSet(i - 1, i)
}
acnt.get + i
}
en un AMD con 4 núcleos duales de 2,8 GHz y un procesador i7 de 4 núcleos a 2,67 GHz. La JVM es Sun Server Hotspot JVM 1.6. Los resultados no muestran diferencia de rendimiento.
Especificaciones: AMD 8220 4x de doble núcleo a 2,8 GHz
Nombre de la prueba: loop_atomic_tlocal_cas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 7504.562 7502.817 7504.626 (promedio = 7415.637 min = 7147.628 max = 7504.886)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 3751.553 3752.589 3751.519 (promedio = 3713.5513 min = 3574.708 max = 3752.949)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 1890.055 1889.813 1890.047 (avg = 2065.7207 min = 1804.652 max = 3755.852)
- Número de tema: 8
Tiempos de ejecución: (mostrando los últimos 3) 960.12 989.453 970.842 (promedio = 1058.8776 min = 940.492 max = 1893.127)
Nombre de la prueba: loop_atomic_weakcas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 7325.425 7057.03 7325.407 (avg = 7231.8682 min = 7057.03 max = 7325.45)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 3663.21 3665.838 3533.406 (promedio = 3607.2149 min = 3529.177 máximo = 3665.838)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 3664.163 1831.979 1835.07 (promedio = 2014.2086 min = 1797.997 max = 3664.163)
- Número de tema: 8
Tiempos de ejecución: (mostrando los últimos 3) 940.504 928.467 921.376 (promedio = 943.665 min = 919.985 max = 997.681)
Nombre de la prueba: loop_atomic_tlocal_weakcas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 7502.876 7502.857 7502.933 (promedio = 7414.8132 min = 7145.869 max = 7502.933)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 3752.623 3751.53 3752.434 (promedio = 3710.1782 min = 3574.398 máximo = 3752.623)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 1876.723 1881.069 1876.538 (promedio = 4110.4221 min = 1804.62 max = 12467.351)
- Número de tema: 8
Tiempos de ejecución: (mostrando los últimos 3) 959.329 1010.53 969.767 (promedio = 1072.8444 min = 959.329 máximo = 1880.049)
Especificaciones: Intel i7 quad-core a 2.67 GHz
Nombre de la prueba: loop_atomic_tlocal_cas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 8138.3175 8130.0044 8130.1535 (avg = 8119.2888 min = 8049.6497 max = 8150.1950)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 4067.7399 4067.5403 4068.3747 (promedio = 4059.6344 min = 4026.2739 máximo = 4068.5455)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 2033.4389 2033.2695 2033.2918 (promedio = 2030.5825 min = 2017.6880 max = 2035.0352)
Nombre de la prueba: loop_atomic_weakcas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 8130.5620 8129.9963 8132.3382 (avg = 8114.0052 min = 8042.0742 max = 8132.8542)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 4066.9559 4067.0414 4067.2080 (prom = 4086.0608 min = 4023.6822 max = 4335.1791)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 2034.6084 2169.8127 2034.5625 (avg = 2047.7025 min = 2032.8131 max = 2169.8127)
Nombre de la prueba: loop_atomic_tlocal_weakcas
- Número de hilo .: 1
Tiempos de ejecución: (mostrando los últimos 3) 8132.5267 8132.0299 8132.2415 (avg = 8114.9328 min = 8043.3674 max = 8134.0418)
- Número de hilo: 2
Tiempos de ejecución: (mostrando los últimos 3) 4066.5924 4066.5797 4066.6519 (promedio = 4059.1911 min = 4025.0703 max = 4066.8547)
- Número de hilo: 4
Tiempos de ejecución: (mostrando los últimos 3) 2033.2614 2035.5754 2036.9110 (avg = 2033.2958 min = 2023.5082 max = 2038.8750)
Si bien es posible que los locals en el ejemplo anterior terminen en las mismas líneas de caché, me parece que no existe una diferencia de rendimiento observable entre el CAS regular y su versión débil.
Esto podría significar que, de hecho, una comparación y un intercambio débiles actúan como una valla de memoria en toda regla, es decir, actúa como si fuera una variable volátil.
Pregunta: ¿Es correcta esta observación? Además, ¿existe una arquitectura conocida o distribución de Java para la cual una comparación y un conjunto débiles son realmente más rápidos? Si no, ¿cuál es la ventaja de usar un CAS débil en primer lugar?
La instrucción x86 para "comparar y cambiar atómicamente" es LOCK CMPXCHG
. Esta instrucción crea una valla de memoria completa.
No hay instrucciones que hagan este trabajo sin crear una valla de memoria, por lo que es muy probable que ambos compareAndSet
y weakCompareAndSet
mapa de LOCK CMPXCHG
y realicen una valla de memoria completa.
Pero eso es para x86, otras arquitecturas (incluidas las variantes futuras de x86) pueden hacer las cosas de manera diferente.
Una comparación y un intercambio débiles podrían actuar como una variable completamente volátil, dependiendo de la implementación de la JVM, seguro. De hecho, no me sorprendería que en ciertas arquitecturas no sea posible implementar un CAS débil de una manera notablemente más eficiente que el CAS normal. En estas arquitecturas, bien puede ocurrir que los CASOS débiles se implementen exactamente igual que un CAS completo. O simplemente puede ser que su JVM no haya tenido mucha optimización para hacer que los CASOS débiles sean particularmente rápidos, por lo que la implementación actual solo invoca un CAS completo porque es rápido de implementar, y una versión futura lo refinará.
El JLS simplemente dice que un CAS débil no establece una relación de pasar antes , así que es simplemente que no hay garantía de que la modificación que causa sea visible en otros hilos. Todo lo que obtiene en este caso es la garantía de que la operación de comparación y ajuste es atómica, pero sin garantías sobre la visibilidad del valor (potencialmente) nuevo. Eso no es lo mismo que garantizar que no se verá, por lo que sus pruebas son consistentes con esto.
En general, trate de evitar hacer conclusiones sobre el comportamiento relacionado con la concurrencia a través de la experimentación. Hay tantas variables a tener en cuenta, que si no sigue lo que JLS garantiza que es correcto, entonces su programa podría romperse en cualquier momento (tal vez en una arquitectura diferente, tal vez bajo una optimización más agresiva que es provocada por un ligero cambio en el diseño de su código, tal vez bajo futuras versiones de la JVM que aún no existen, etc.). Nunca hay una razón para suponer que puede salirse con la suya con algo que no está garantizado, porque los experimentos demuestran que "funciona".
weakCompareAndSwap
no se garantiza que sea más rápido; solo se permite ser más rápido. Puede ver el código de código abierto de OpenJDK para ver lo que algunas personas inteligentes decidieron hacer con este permiso:
A saber: ambos están implementados como el one-liner
return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
¡Tienen exactamente el mismo rendimiento, porque tienen exactamente la misma implementación! (en OpenJDK al menos). Otras personas han comentado sobre el hecho de que realmente no se puede hacer nada mejor en x86 de todos modos, porque el hardware ya te da un montón de garantías "gratis". Solo debe preocuparse por arquitecturas simples como ARM.