ejemplo - En Java, ¿cuál es el rendimiento de AtomicInteger compareAndSet() en comparación con la palabra clave sincronizada?
atomicinteger java ejemplo (4)
Estaba implementando una cola FIFO de instancias de solicitudes (objetos de solicitud preasignados para velocidad) y comencé con el uso de la palabra clave "sincronizada" en el método de adición. El método fue bastante corto (verifique si hay espacio en el búfer de tamaño fijo, luego agregue valor a la matriz). Usando visualVM, parecía que el hilo se bloqueaba más a menudo de lo que me gustaba ("monitor" para ser precisos). Así que convertí el código para usar los valores de AtomicInteger para cosas como hacer un seguimiento del tamaño actual, luego usar compareAndSet () en bucles while (como AtomicInteger lo hace internamente para métodos como incrementAndGet ()). El código ahora parece un poco más largo.
Lo que me preguntaba es cuál es la sobrecarga de rendimiento del uso de código sincronizado y más corto en comparación con el código más largo sin la palabra clave sincronizada (por lo que nunca debe bloquearse en un bloqueo).
Aquí está el viejo método de obtención con la palabra clave sincronizada:
public synchronized Request get()
{
if (head == tail)
{
return null;
}
Request r = requests[head];
head = (head + 1) % requests.length;
return r;
}
Aquí está el nuevo método de obtención sin la palabra clave sincronizada:
public Request get()
{
while (true)
{
int current = size.get();
if (current <= 0)
{
return null;
}
if (size.compareAndSet(current, current - 1))
{
break;
}
}
while (true)
{
int current = head.get();
int nextHead = (current + 1) % requests.length;
if (head.compareAndSet(current, nextHead))
{
return requests[current];
}
}
}
Supongo que la palabra clave sincronizada es peor debido al riesgo de bloqueo en el bloqueo (lo que podría causar cambios de contexto de subproceso, etc.), aunque el código sea más corto.
¡Gracias!
Mi conjetura fue que la palabra clave sincronizada es peor debido al riesgo de bloqueo en el bloqueo (lo que podría causar cambios en el contexto del hilo, etc.)
Sí, en el caso común tienes razón. Java Concurrency in Practice discute esto en la sección 15.3.2:
[...] en niveles de contención altos, el bloqueo tiende a superar las variables atómicas, pero a niveles de contención más realistas las variables atómicas superan a los bloqueos. Esto se debe a que un bloqueo reacciona a la contención suspendiendo subprocesos, reduciendo el uso de la CPU y el tráfico de sincronización en el bus de memoria compartida. (Esto es similar a cómo el bloqueo de los productores en un diseño de productor-consumidor reduce la carga de los consumidores y, por lo tanto, les permite ponerse al día). Por otro lado, con variables atómicas, la gestión de la contención se empuja de nuevo a la clase de llamada. Como la mayoría de los algoritmos basados en CAS,
AtomicPseudoRandom
reacciona a la contención al intentarlo de nuevo inmediatamente, lo que suele ser el enfoque correcto, pero en un entorno de alta contención solo crea más contención.Antes de condenar
AtomicPseudoRandom
comoAtomicPseudoRandom
mal escritas o atómicas como una mala elección en comparación con los bloqueos, debemos darnos cuenta de que el nivel de contención en la Figura 15.1 es demasiado alto: ningún programa real hace nada más que luchar por un bloqueo o una variable atómica. En la práctica, los atómicos tienden a escalarse mejor que los bloqueos porque los atómicos se ocupan más efectivamente de los niveles de contención típicos.La inversión de rendimiento entre bloqueos y atómicas en diferentes niveles de contención ilustra las fortalezas y debilidades de cada uno. Con contención de baja a moderada, los atómicos ofrecen una mejor escalabilidad; Con alta contención, las cerraduras ofrecen una mejor prevención de contención. (Los algoritmos basados en CAS también superan a los basados en bloqueo en sistemas de una sola CPU, ya que un CAS siempre tiene éxito en un sistema de una sola CPU, excepto en el improbable caso de que una secuencia se sustituya en el medio de la operación de lectura-modificación-escritura. )
(En las cifras a las que se hace referencia en el texto, la Figura 15.1 muestra que el rendimiento de AtomicInteger y ReentrantLock es más o menos igual cuando la contención es alta, mientras que la Figura 15.2 muestra que bajo una contención moderada, la primera supera a la última en un factor de 2-3). .)
Actualización: en algoritmos de no bloqueo
Como han señalado otros, los algoritmos de no bloqueo, aunque potencialmente más rápidos, son más complejos, por lo tanto, más difíciles de corregir. Un indicio de la sección 15.4 de JCiA:
Se conocen buenos algoritmos de no bloqueo para muchas estructuras de datos comunes, incluidas pilas, colas, colas de prioridad y tablas hash, aunque diseñar nuevos es una tarea que debe dejarse en manos de los expertos.
Los algoritmos de no bloqueo son considerablemente más complicados que sus equivalentes basados en bloqueo. La clave para crear algoritmos de no bloqueo es descubrir cómo limitar el alcance de los cambios atómicos a una sola variable mientras se mantiene la coherencia de los datos. En las clases de recopilación vinculadas, como las colas, a veces puede evitar expresar las transformaciones de estado como cambios en los enlaces individuales y usar una
AtomicReference
para representar cada enlace que debe actualizarse atómicamente.
Antes de hacer este tipo de optimizaciones de sincronización, realmente necesita un generador de perfiles para decirle que es absolutamente necesario.
Sí, la sincronización en algunas condiciones puede ser más lenta que la operación atómica, pero compare sus métodos originales y de reemplazo. El primero es realmente claro y fácil de mantener, el segundo, bueno, definitivamente es más complejo. Debido a esto, puede haber errores de concurrencia muy sutiles, que no encontrará durante las pruebas iniciales. Ya veo un problema, el size
y la head
realmente pueden desincronizarse porque, aunque cada una de estas operaciones es atómica, la combinación no lo es, y algunas veces esto puede llevar a un estado inconsistente.
Por lo tanto, mi consejo:
- Comience simple
- Perfil
- Si el rendimiento es suficientemente bueno, deje la implementación simple como está
- Si necesita mejorar el rendimiento, entonces comience a ser más inteligente (posiblemente al principio use un bloqueo más especializado), y PRUEBA , PRUEBA , PRUEBA
Aquí está el código para un bloqueo de espera ocupado.
public class BusyWaitLock
{
private static final boolean LOCK_VALUE = true;
private static final boolean UNLOCK_VALUE = false;
private final static Logger log = LoggerFactory.getLogger(BusyWaitLock.class);
/**
* @author Rod Moten
*
*/
public class BusyWaitLockException extends RuntimeException
{
/**
*
*/
private static final long serialVersionUID = 1L;
/**
* @param message
*/
public BusyWaitLockException(String message)
{
super(message);
}
}
private AtomicBoolean lock = new AtomicBoolean(UNLOCK_VALUE);
private final long maximumWaitTime ;
/**
* Create a busy wait lock with that uses the default wait time of two minutes.
*/
public BusyWaitLock()
{
this(1000 * 60 * 2); // default is two minutes)
}
/**
* Create a busy wait lock with that uses the given value as the maximum wait time.
* @param maximumWaitTime - a positive value that represents the maximum number of milliseconds that a thread will busy wait.
*/
public BusyWaitLock(long maximumWaitTime)
{
if (maximumWaitTime < 1)
throw new IllegalArgumentException (" Max wait time of " + maximumWaitTime + " is too low. It must be at least 1 millisecond.");
this.maximumWaitTime = maximumWaitTime;
}
/**
*
*/
public void lock ()
{
long startTime = System.currentTimeMillis();
long lastLogTime = startTime;
int logMessageCount = 0;
while (lock.compareAndSet(UNLOCK_VALUE, LOCK_VALUE)) {
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime - lastLogTime > 5000) {
log.debug("Waiting for lock. Log message # {}", logMessageCount++);
lastLogTime = waitTime;
}
if (waitTime > maximumWaitTime) {
log.warn("Wait time of {} exceed maximum wait time of {}", waitTime, maximumWaitTime);
throw new BusyWaitLockException ("Exceeded maximum wait time of " + maximumWaitTime + " ms.");
}
}
}
public void unlock ()
{
lock.set(UNLOCK_VALUE);
}
}
Me pregunto si JVM ya hace algunos giros antes de suspender realmente el hilo. Anticipa que las secciones críticas bien escritas, como la suya, son muy cortas y completas casi de inmediato. Por lo tanto, debería ser optimista ocupado, esperar, no sé, docenas de bucles, antes de abandonar y suspender el hilo. Si ese es el caso, debería comportarse igual que su segunda versión.
lo que muestra un generador de perfiles podría ser muy diferente de lo que realmente sucedió en un jvm a toda velocidad, con todo tipo de optimizaciones locas. Es mejor medir y comparar el rendimiento sin el perfilador.