English | 简体中文 | 繁體中文 | Русский язык | Français | Español | Português | Deutsch | 日本語 | 한국어 | Italiano | بالعربية
Maneras de mejorar el rendimiento de los bloques de locks en java
Tratamos de pensar en soluciones para los problemas que enfrentamos en nuestros productos, pero en este artículo compartiré varias técnicas comunes, incluyendo el desacoplamiento de cerraduras, estructuras de datos paralelas, proteger datos en lugar de código, y reducir el alcance de las cerraduras, que nos permiten no usar herramientas para detectar bloqueos.
El problema no está en las cerraduras, sino en la competencia entre cerraduras
Generalmente, cuando se encuentran problemas de rendimiento en código multihilo, se suele culpar a los bloqueos. Después de todo, es conocido que los bloqueos reducen la velocidad de ejecución del programa y su baja escalabilidad. Por lo tanto, si se comienza a optimizar el código con esta "conciencia", los resultados podrían ser problemas de concurrencia desagradables en el futuro.
Por lo tanto, es muy importante entender la diferencia entre cerraduras competitivas y no competitivas. Cuando un hilo intenta entrar en un bloque o método sincronizado que está siendo ejecutado por otro hilo, se desencadena una competencia por cerraduras. El hilo se ve obligado a entrar en estado de espera hasta que el primer hilo complete el bloque sincronizado y haya liberado el monitor. Cuando solo un hilo intenta ejecutar un área de código sincronizada al mismo tiempo, la cerradura se mantiene en estado no competitivo.
De hecho, en ausencia de competencia y en la mayoría de las aplicaciones, el JVM ya ha optimizado la sincronización. Las cerraduras no competitivas no llevan consigo ningún costo adicional en su ejecución. Por lo tanto, no debes quejarte de problemas de rendimiento con los bloqueos, sino quejarte de la competencia por los bloqueos. Una vez que tienes esta comprensión, veamos qué podemos hacer para reducir la probabilidad de competencia o reducir la duración de la competencia.
Proteger los datos en lugar del código
Un método rápido para resolver problemas de seguridad de hilos es bloquear la accesibilidad de todo el método. Por ejemplo, en el siguiente ejemplo, se intenta crear un servidor de póquer en línea utilizando este método:
class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } public synchronized void leave(Player player, Table table) {/*El cuerpo se omitió por brevedad*/} public synchronized void createTable() {/*El cuerpo se omitió por brevedad*/} public synchronized void destroyTable(Table table) {/*El cuerpo se omitió por brevedad*/} }
La intención del autor es buena - cuando un nuevo jugador se une a la mesa, debe asegurarse de que el número de jugadores en la mesa no exceda el número total de jugadores que la mesa puede contener9.
Sin embargo, esta solución debe controlar la entrada de jugadores a la mesa en cualquier momento - incluso cuando la cantidad de acceso al servidor es baja, los hilos que esperan la liberación del bloqueo generarán eventos de competencia del sistema con frecuencia. El bloqueo que incluye la verificación del saldo de la cuenta y las limitaciones de la mesa podría aumentar significativamente el costo de las operaciones de llamada, lo que sin duda aumentará la posibilidad y la duración de la competencia.
El primer paso para resolver el problema es asegurarse de que protegemos los datos y no la declaración de sincronización que se movió del cuerpo del método a la declaración del método. Para el ejemplo simple anterior, los cambios podrían ser mínimos. Pero debemos considerar la interfaz del servicio de juego completo, no solo el método join().
class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { synchronized (tables) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } public void leave(Player player, Table table) {/* El cuerpo se omitió por brevedad */} public void createTable() {/* El cuerpo se omitió por brevedad */} public void destroyTable(Table table) {/* El cuerpo se omitió por brevedad */} }
Un cambio pequeño podría haber afectado al comportamiento de toda la clase. Cada vez que un jugador se une a la mesa, el método de sincronización anterior bloquearía toda la instancia de GameServer, lo que podría generar competencias con jugadores que intentan salir de la mesa al mismo tiempo. Mover el bloqueo desde la declaración del método al cuerpo del método retrasa la carga del bloqueo, reduciendo así la posibilidad de competencias.
Reducir el alcance del bloqueo
Ahora, cuando estamos seguros de que lo que necesitamos proteger es los datos y no el programa, debemos asegurarnos de que solo bloqueemos en los lugares necesarios - por ejemplo, después de que el código anterior se reestructuró:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { synchronized (tables) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //otros métodos omitidos por brevedad }
Así, ese código que contiene la verificación del saldo de la cuenta del jugador (que puede desencadenar operaciones de E/S) se ha movido fuera del alcance del control de bloqueo. Nota: ahora, el bloqueo solo se utiliza para evitar que el número de jugadores exceda la capacidad del tavolo, la verificación del saldo de la cuenta ya no es parte de esta medida de protección.
Mutex separados
Puedes ver claramente en la última línea del ejemplo anterior: toda la estructura de datos está protegida por el mismo mutex. Considerando que en esta estructura de datos puede haber miles de mesas y debemos proteger que el número de personas en cualquier mesa no supere la capacidad, aún habrá un alto riesgo de eventos de competencia en estas circunstancias.
Hay una solución simple para esto: introducir mutex separados para cada mesa, como se muestra en el siguiente ejemplo:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); synchronized (tablePlayers) { if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //otros métodos omitidos por brevedad }
Ahora, solo sincronizamos el acceso a una mesa específica en lugar de todas las mesas, lo que reduce significativamente la probabilidad de competencia por el mutex. Por ejemplo, en nuestra estructura de datos actual hay10si no hay instancias de mesas, la probabilidad de competencia ahora será menor que antes100 veces.
Uso de estructuras de datos seguras en hilos
Otro aspecto que puede mejorarse es abandonar la estructura de datos de un solo hilo tradicional, y utilizar estructuras de datos diseñadas explícitamente para ser seguras en múltiples hilos. Por ejemplo, cuando se utiliza ConcurrentHashMap para almacenar instancias de mesa de juego, el código podría ser como el siguiente:
public class GameServer { public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) {/*El cuerpo del método se omite por brevedad*/} public synchronized void leave(Player player, Table table) {/*El cuerpo del método se omite por brevedad*/} public synchronized void createTable() { Table table = new Table(); tables.put(table.getId(), table); } public synchronized void destroyTable(Table table) { tables.remove(table.getId()); } }
El bloque同步 en los métodos join() y leave() es igual que en los ejemplos anteriores, porque debemos garantizar la integridad de los datos de una única mesa de juego. ConcurrentHashMap no ayuda en este punto. Sin embargo, aún usaremos ConcurrentHashMap para crear y destruir nuevas mesas en los métodos increaseTable() y destoryTable(), todas estas operaciones son completamente sincrónicas para ConcurrentHashMap, lo que nos permite agregar o reducir el número de mesas de manera paralela.
Otras sugerencias y técnicas
Disminuir la visibilidad de los locks. En el ejemplo anterior, el lock se declara como public (visible externamente), lo que podría permitir que algunas personas con malas intenciones dañen tu trabajo al bloquear en tus monitores bien diseñados.
Veamos las API de java.util.concurrent.locks para ver si hay otras estrategias de locks ya implementadas, y mejoramos la solución anterior.
Usar operaciones atómicas. En el simple contador de incrementos que se está utilizando actualmente, no se requiere realmente bloqueo. En el ejemplo anterior, es más adecuado usar AtomicInteger en lugar de Integer como contador.
En último lugar, ya sea que esté utilizando la solución de detección automática de deadlocks de Plumber o que obtenga información de solución manualmente desde el dumper de hilos, espero que este artículo pueda ayudarte a resolver problemas de competencia de locks.
Gracias por leer, espero que esto pueda ayudar a todos, ¡gracias por el apoyo a nuestro sitio!