Nachdem wir vor zwei Monaten die Thread-Deadlock-Erkennung eingeführt haben, bekamen wir Anfragen wie: „Jetzt weiß ich, was die Leistungsprobleme in meinem Programm verursacht, aber wie geht es weiter?“ >Wir bemühen uns, Lösungen für die Probleme zu finden, auf die unsere Produkte stoßen. In diesem Artikel werde ich Ihnen jedoch einige häufig verwendete Techniken vorstellen, darunter getrennte Sperren und parallele Datenstrukturen. Schützen Sie Daten anstelle von Code und reduzieren Sie den Umfang von Sperren Diese Techniken ermöglichen es uns, Deadlocks ohne den Einsatz von Werkzeugen zu erkennen.
Sperren sind nicht die Ursache des Problems, die Konkurrenz zwischen Sperren ist
Wenn Sie in Multithread-Code auf Leistungsprobleme stoßen, beschweren Sie sich normalerweise darüber, dass es sich um ein Sperrproblem handelt. Schließlich ist bekannt, dass Sperren Programme verlangsamen und weniger skalierbar machen. Wenn Sie also mit diesem „gesunden Menschenverstand“ beginnen, Ihren Code zu optimieren, werden später wahrscheinlich lästige Parallelitätsprobleme die Folge sein.
Daher ist es sehr wichtig, den Unterschied zwischen Konfliktsperren und Nichtkonfliktsperren zu verstehen. Ein Sperrenkonflikt wird ausgelöst, wenn ein Thread versucht, in einen synchronisierten Block oder eine synchronisierte Methode einzutreten, die von einem anderen Thread ausgeführt wird. Der Thread wird in einen Wartezustand gezwungen, bis der erste Thread die Ausführung des synchronisierten Blocks abgeschlossen und den Monitor freigegeben hat. Wenn jeweils nur ein Thread versucht, einen synchronisierten Codebereich auszuführen, bleibt die Sperre unbestritten.
Tatsächlich verfügt die JVM in Nicht-Rennsituationen und in den meisten Anwendungen über eine optimierte Synchronisierung. Unbestrittene Sperren verursachen während der Ausführung keinen zusätzlichen Overhead. Daher sollten Sie sich nicht über Sperren aufgrund von Leistungsproblemen beschweren, sondern über Sperrenkonflikte. Schauen wir uns vor diesem Hintergrund an, was wir tun können, um die Wahrscheinlichkeit oder Dauer des Wettbewerbs zu verringern.
Daten schützen, nicht Code
Eine schnelle Möglichkeit, Thread-Sicherheitsprobleme zu lösen, besteht darin, den Zugriff auf die gesamte Methode zu sperren. Im folgenden Beispiel wird beispielsweise versucht, mit dieser Methode einen Online-Pokerspielserver einzurichten:
Die Absicht des Autors ist gut – wenn ein neuer Spieler an den Tisch kommt, muss er sicherstellen, dass der Tisch die Nummer ist Die Anzahl der Spieler wird die Gesamtzahl der Spieler, die der Tisch aufnehmen kann, nicht überschreiten.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) {/*body skipped for brevity*/} public synchronized void createTable() {/*body skipped for brevity*/} public synchronized void destroyTable(Table table) {/*body skipped for brevity*/} }
Aber diese Lösung erfordert tatsächlich die Kontrolle der Spieler, wann immer sie den Tisch betreten – selbst wenn der Server über eine geringe Zugriffsmenge verfügt, müssen die Threads, die auf die Freigabe der Sperre warten, häufig auftreten. Ein Rennereignis, das das auslöst System. Sperrsperren, die die Überprüfung von Kontoständen und Tischlimits beinhalten, dürften den Overhead von Anrufvorgängen erheblich erhöhen, was zweifellos die Wahrscheinlichkeit und Dauer von Konflikten erhöht.
Der erste Schritt der Lösung besteht darin, sicherzustellen, dass wir die Daten schützen und nicht die Synchronisierungsanweisung von der Methodendeklaration in den Methodenkörper verschieben. Für das einfache Beispiel oben gibt es möglicherweise keine große Änderung. Aber wir müssen es von der Schnittstelle des gesamten Spieldienstes aus betrachten, nicht nur von einer join()-Methode.
Es mag ursprünglich nur eine kleine Änderung sein, aber sie wirkt sich auf das Verhalten der gesamten Klasse aus. Die vorherige Synchronisierungsmethode würde die gesamte GameServer-Instanz sperren, wenn ein Spieler dem Tisch beitritt, wodurch Konkurrenz mit Spielern entsteht, die gleichzeitig versuchen, den Tisch zu verlassen. Durch das Verschieben der Sperre von der Methodendeklaration in den Methodenkörper wird das Laden der Sperre verzögert, wodurch die Möglichkeit eines Sperrenkonflikts verringert wird.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) {/* body skipped for brevity */} public void createTable() {/* body skipped for brevity */} public void destroyTable(Table table) {/* body skipped for brevity */} }
Einschränkung des Umfangs von Sperren
Da wir nun davon überzeugt sind, dass es die Daten sind, die geschützt werden müssen und nicht das Programm, sollten wir sicherstellen, dass wir nur dort sperren, wo es notwendig ist – zum Beispiel wenn der obige Code nach der Umgestaltung:
Auf diese Weise wurde der Code, der zeitaufwändige Vorgänge verursachen kann, einschließlich der Erkennung des Spielerkontostands (der IO-Vorgänge auslösen kann), aus dem Geltungsbereich von verschoben Schlosssteuerung. Beachten Sie, dass die Sperre jetzt nur noch dazu dient, zu verhindern, dass die Anzahl der Spieler die Kapazität des Tisches überschreitet, und dass die Überprüfung des Kontostands nicht mehr Teil dieses Schutzes ist.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); } } } } //other methods skipped for brevity }
Separate Sperre
Anhand der letzten Codezeile im obigen Beispiel können Sie deutlich erkennen: Die gesamte Datenstruktur ist durch dieselbe Sperre geschützt. Wenn man bedenkt, dass diese Datenstruktur Tausende von Tischen umfassen kann und wir die Anzahl der Personen an einem Tisch vor einer Überschreitung der Kapazität schützen müssen, besteht in einer solchen Situation immer noch ein hohes Risiko von Konfliktereignissen.
Eine einfache Möglichkeit, dies zu tun, besteht darin, für jeden Kartentisch separate Schlösser einzuführen, wie im folgenden Beispiel gezeigt:
Jetzt sperren wir nur die Schlösser für einen einzelnen Kartentisch Die Zugänglichkeit ist nicht tabellenübergreifend, sondern synchronisiert, was die Wahrscheinlichkeit von Sperrenkonflikten erheblich verringert. Um ein konkretes Beispiel zu nennen: Wenn wir jetzt 100 Instanzen von Pokertischen in unserer Datenstruktur haben, dann ist die Möglichkeit eines Wettbewerbs jetzt 100-mal geringer als zuvor.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); } } } } //other methods skipped for brevity }
Thread-sichere Datenstrukturen verwenden
另一个可以改善的地方就是抛弃传统的单线程数据结构,改用被明确设计为线程安全的数据结构。例如,当采用ConcurrentHashMap来储存你的牌桌实例时,代码可能像下面这样:
public class GameServer { public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void leave(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void createTable() { Table table = new Table(); tables.put(table.getId(), table); } public synchronized void destroyTable(Table table) { tables.remove(table.getId()); } }
在join()和leave()方法内部的同步块仍然和先前的例子一样,因为我们要保证单个牌桌数据的完整性。ConcurrentHashMap 在这点上并没有任何帮助。但我们仍然会在increateTable()和destoryTable()方法中使用ConcurrentHashMap创建和销毁新的牌桌,所有这些操作对于ConcurrentHashMap来说是完全同步的,其允许我们以并行的方式添加或减少牌桌的数量。
其他一些建议和技巧
降低锁的可见度。在上面的例子中,锁被声明为public(对外可见),这可能会使得一些别有用心的人通过在你精心设计的监视器上加锁来破坏你的工作。
通过查看java.util.concurrent.locks 的API来看一下 有没有其它已经实现的锁策略,使用其改进上面的解决方案。
使用原子操作。在上面正在使用的简单递增计数器实际上并不要求加锁。上面的例子中更适合使用 AtomicInteger代替Integer作为计数器。
最后一点,无论你是否正在使用Plumber的自动死锁检测解决方案,还是手动从线程转储获得解决办法的信息,都希望这篇文章可以为你解决锁竞争的问题带来帮助。