Java: Performance-Knick bei selbstentwickeltem Spiel

Hallo lieber Java-Experte,

seit einiger Zeit habe ich ein Spiel in eclipse geschrieben und ins Netz gestellt. Es funktioniert recht gut. Ab und an bekomme ich jedoch ein Feedback von Spielern, dass das Programm irgendwann unvermittelt an Geschwindigkeit verliert.

Das Phänomen hatte ich schon in vorherigen Versionen. Dort baute sich die Anzahl der Threads auf, weil ich vergessen hatte, sie zu beenden. Auch wurden Unmengen an Objekten aufgebaut und wieder vernichtet, so dass der Garbage-Collector offenbar nicht mehr nachkam.

All diese Fragen habe ich beleuchtet und weitgehend eliminiert. Trotz allem fährt das Programm nach etwa einem Tag ununterbrochenen Spielen irgendwann fest.

Nun meine Fragen.

  1. Was könnte ich aus deiner Sicht noch überprüfen?
  2. Welche Diagnose-Möglichkeiten gibt es, um z. B. die aktuelle Anzahl der Threads oder die aktuelle Anzahl der Objekte im Speicher in Echtzeit sichtbar zu machen?
  3. Woran denke ich überhaupt noch nicht, sollte es aber tun?

Hier der Link:
http://www.haller-mtl.de/homepage/breakout/breakout…

Für jede Idee bin ich dankbar.
und tschüs
Uwe

hallo

ein paar grundsätzliche hinweise: der garbace collector von java ist zwar recht nett, aber nicht allmächtig. leider animiert er viele unerfahrene java-programmierer dazu, sich überhaupt nicht um die speichernutzung zu kümmern, was zu unperformanter software führt. daher:

  • nicht wegen jeder kleinigkeit neue objekte erzeugen sondern eher versuchen, mit bestehenden auszukommen. objektinstanzierung ist eine der langsamsten aktionen im java. ist performance tatsächlich ein wesentlicher faktor, verzichtet man manchmal bewusst auf objektorientierung und geht eher klassisch prozedural vor. aber aufpassen: die wartbarkeit des programms geht dann gern schnell flöten.
  • genauso threads: natürlich kann man wegen jeder kleinigkeit einen neuen thread starten. besser ist es, einen threadpool zu haben, mit wenigen, dafür permanent laufenden threads und denen immer nur neue worker-klassen unterzuschieben. das verkompliziert zwar die programmierung anfangs massiv, kann die performance aber merklich erhöhen.
  • höllisch aufpassen mit synchronized. nur einsetzen, wenn unbedingt notwendig und auch nur so lange, wie unbedingt notwendig. das threaddesign so anpassen, dass synchronized-blöcke gar nicht oder nur wenig notwendig sind.
  • die grösse des heaps immer im auge behalten. solange das programm nicht nennenswert mehr zu tun bekommt, sollte die grundlast des heaps konstant bleiben. steigt der permanent verbrauchte speicher im heap ständig an, ist das ein hinweis auf einen memory-leak. wird der heap knapp, muss der garbage collector immer häufiger full-collects machen, und die blockieren dir die vm. im extremfall schmeisst der garbage collector auch klassen aus dem klassenspeicher, wesshalb die dann neu geladen werden müssen. auch dass führt zu hängern der vm. ev. über einen eigenen thread den heap mitprotokollieren oder per management console dich zur vm verbinden und die mitprotokollieren lassen.

lg
erwin

Hier noch ein paar Tipps von mir:

  • möglichst oft final einsetzen

  • nochmal alle Collections überprüfen, ob irgendwo vergessene Referenzen „eingelagert“ werden

  • mit einem Profiler rangehen

Profiler gibt es z.B. http://www.ej-technologies.com/products/jprofiler/ov… und http://www.eclipse.org/mat/.

1 Like

Hallo Erwin,

danke für die schnelle Antwort.

der garbace collector von java ist zwar recht nett, aber nicht allmächtig.

  • nicht wegen jeder kleinigkeit neue objekte erzeugen sondern eher versuchen, mit bestehenden auszukommen.

Wie schon erwähnt hatte ich dieses Thema bereits beleuchtet und weitgehend abgestellt. Dies hat dann die Langzeit-Performance auch deutlich verbessert.

  • genauso threads: natürlich kann man wegen jeder kleinigkeit einen neuen thread starten. besser ist es, einen threadpool zu haben, mit wenigen, dafür permanent laufenden threads und denen immer nur neue worker-klassen unterzuschieben.

Diese Falle ist ebenfalls bereits erkannt und abgestellt und hat ebenfalls die Langzeit-Performance deutlich positiv beeinflusst.

  • höllisch aufpassen mit synchronized. nur einsetzen, wenn unbedingt notwendig und auch nur so lange, wie unbedingt notwendig.

Synchronized setze ich gar nicht ein.

  • die grösse des heaps immer im auge behalten. solange das programm nicht nennenswert mehr zu tun bekommt, sollte die grundlast des heaps konstant bleiben.

Das klingt sehr interessant. Vor allem, weil ich es nicht verstehe. Im Wikipedia habe ich dazu nachgeschlagen und es ebenfalls nicht kapiert.
Bist du so lieb und erklärst mir mit einfachen Worten, was es mit dem HEAP auf sich hat und in welchem Zusammenhang dies mit meiner Frage steht?

steigt der permanent verbrauchte speicher im heap ständig an, ist das ein hinweis auf einen memory-leak. wird der heap knapp, muss der garbage collector immer häufiger full-collects machen, und die blockieren dir die vm. im extremfall schmeisst der garbage collector auch klassen aus dem klassenspeicher, wesshalb diedann neu geladen werden müssen.

Hier haben wir wahrscheinlich den Finger ganz tief in der Wunde.

ev. über einen eigenen thread den heap mitprotokollieren oder per management console dich zur vm verbinden und die mitprotokollieren lassen.

Hierzu benötige ich bitte eine einfache Anleitung. Das habe ich noch nie gemacht.

Es würde mich freuen, wenn wir das noch etwas vertiefen können.
und tschüs
Uwe

Hallo Carsten,

danke für die Antwort.

möglichst oft final einsetzen

final habe ich oft eingesetzt. Konnte aber leider keine signifikante Verbesserung der Performance feststellen.

nochmal alle Collections überprüfen, ob irgendwo vergessene Referenzen „eingelagert“ werden

Das habe ich versucht. Bin dabei aber sicher nicht sehr effektiv.
Kennst du eine Methodik, vergessene Referenzen sicher aufzuspüren?

Profiler gibt es z.B. http://www.eclipse.org/mat/.

Das klingt gut. Weil ich eclipse habe, ist mir dieser Link sympatisch. Das Programm habe ich in eclipse installiert, die Sicht lässt sich aufrufen. Leider protokolliert es nichts, wenn ich das Applet laufen lasse.
Weißt du (oder ein anderer Experte), was da noch zu tun ist?

Danke für die Mühe
und tschüs
Uwe

Naja, eine gute Strategie hab ich jetzt auch nicht. Ich würde erstmal dort anfangen, wo am meisten Objekte in Collections wandern bzw wo am meisten Objekte erstellt werden.

Ok, über Dein Eclipse-Setup weiß ich jetzt nichts. Wie startest Du das Applet? Welchen Report lässt Du mit MAT laufen?

Gibt’s vielleicht die Möglichkeit, den Sourcecode anzusehen? Dann könnt ich’s bei mir mal nachstellen. Ausserdem hättest Du dann gleich noch’n Feedback von einem Mac OS X 10.6-System. :wink:

Hallo Carsten,

Ok, über Dein Eclipse-Setup weiß ich jetzt nichts. Wie startest Du das Applet?

Das Applet starte ich im eclipse mit einer Taste in der Menüleiste, auf der ein grüner Pfeil ist an dem „Run“ steht.

Welchen Report lässt Du mit MAT laufen?

Leider habe ich keine Ahnung, wie man einen Report mit MAT laufen lässt. Da brauche ich mal bitte einen Tipp.
Ziel ist ja, in den Speicher zu schauen und zu sehen was da passiert.

Danke für die Mühe
und tschüs
Uwe

Hallo Carsten,

Gibt’s vielleicht die Möglichkeit, den Sourcecode anzusehen?

Dazu hast du jetzt von mir eine Mail bekommen.

und tschüs
Uwe

hallo

der heap ist jener speicherbereich, in dem die objekte liegen. wenn ein objekt felder hat, dann benötigten diese felder ja speicherplatz. heap (deutsch: haufen) ist einfach ein grosser speicherbereich, in dem das programm relativ frei speicher anfordern kann.

man muss dabei immer unterscheiden: lokale variablen einer methode liegen immer am stack (ein anderer speicherort, der allerdings weitaus weniger flexibel bei der speicherverwaltung ist, dafür automaitsch bereinigt wird, wenn die methode beendet wird). alle felder eines objekts liegen immer am heap. aufpassen bei lokalen objektvariablen: hier liegt nur die objektreferenz (4 byte) am stack, das objekt selbst liegt immer am heap.

ist der stack zu klein, bekommst du einen stack-overflow. ist leicht zu diagnostizieren. auch hast du keine performanceprobleme bis dahin. in deinem fall also völlig auszuschließen. abgesehen davon, dass der stack bei java standardmässig 512 K gross ist, was ganz schön gross ist.

die grösse des heaps kannst du zur laufzeit prüfen:

Runtime.getRuntime().totalMemory() liefert die grösse des heaps in bytes als long-wert.
Runtime.getRuntime().freeMemory() liefert die grösse des freien speichers ebenfalls als long-wert.

total - free ist logischerweise der used-wert.

total kann sich zur laufzeit ändern! je nach startparameter fängt die vm meist mit einem eher kleine heap an und vergrössert den dann bei bedarf. teilweise wird er auch wieder verkleinert, wenn er nicht gebraucht wird. kann man alles über vm-parameter einstellen.

wichtig dabei noch: der heap ist nicht ein grosser block sondern in mehrere segmente aufgeteilt. es gibt einen bereich, in dem neue objekte angelegt werden (eden space), der darauf optimiert ist, die objekte auch recht schnell wieder zu entfernen (in den meisten programmen werden die meisten objekte nur kurz erzeugt und gleich wieder weggeschmissen). dann gibts einen bereich für langlebigere objekte und auch einen bereich für den klassenspeicher, wo die klassen hingeladen werden.

üblicherweise ist der eden space für objektinstanzierung reserviert und kann nicht frei genutzt werden. je nach vm und vm-parameter ist die grösse dieses bereichs unterschiedlich, du musst aber mit ca. 10 % rechnen.

der eden space ist zusätzlich noch zweigeteilt. das hängt mit der strategie des garbage collectors zusammen:
neue objekte werden in der ersten hälte des eden space angelegt.
ist diese hälfte voll, werden alle objekte in die zweite hälfte umkopiert. dabei prüft der garbage collector allerdings, ob diese objekte überhaupt noch referenzen haben. wenn nicht, wird eben nicht kopiert. aus der erfahrung weiss man, dass die meisten objekte nur ganz kurz benötigt werden, daher ist das eine ziemlich gute strategie.
objekte, die öfter umkopiert werden (anzahl pro vm wieder unterschiedlich) sind offenbar langlebiger und werden daher in den langzeitspeicher kopiert.
solange der langzeitspeicher gross genug ist, wird nur sporadisch und nebenbei im langzeitspeicher auf nicht mehr referenzierte objekte geprüft. dies sollte auch der „normale“ zustand eines programms sein und ist normalerweise ausreichend.
geht der langzeitspeicher zur neige, wird eine intensivere suche nach nicht referenzierten objekte durchgeführt. dabei kann es schon zu performanceeinbrüchen kommen. meist fällt das aber nur auf, wenn gerade eine wirklich cpu-intensive aktion erfolgt.
wird auch bei der etwas intensiveren suche nicht genug freigeräumt, muss die vm einen full collect starten. dabei werden alle threads ANGEHALTEN(!). danach wird der gesammte speicher komplett nach nicht referenzierten objekten durchsucht und diese freigegeben. bei einem grossen heap kann das schon mal mehrere sekunden dauern, d.h. das java-programm steht zu dieser zeit komplett und läuft dann erst weiter.
hilft auch das alles nichts, macht die vm die radikalkur: es werden auch die klassen aus dem klassenspeicher entladen, in der hoffnung, dass inzwischen einige klassen nicht mehr benötigt werden (z.b. wenn sie nur für die initialisierung des programms, nicht aber für den normalbetrieb notwendig waren). ist auch dort sinnvoll, wo viele dynamische klassen generiert werden. diese klassen müssen dann natürlich bei bedarf neu geladen werden, was auch die performance ordentlich drückt.

soweit zum hintergrund…

du kannst nun einen hintergrundthread schreiben, der permanent die grösse des heaps sowie den freien speicher ausgibt. bei der grafischen auswertung (z.b. mit excel, wenn man sonst nichts hat) sollte üblicherweise eine art sägezahnmuster entstehen: der heap wird nach und nach gefüllt und dann wieder geleert. das ist das normalverhalten. augenmerk muss man auf die „talsohle“ legen, also die maximale menge an freien speicher, den man hat. nach einer gewissen startphase, die von programm zu programm verschieden ist, sollte sich der speicherbereich einpendeln, d.h. die max. menge an freien speicher sollte in etwa gleich bleiben.

wenn die menge an freien speicher über die zeit hinweg aber permanent abnimmt (kann über längeren zeitraum sein), dann hat man ein problem. das programm läuft solange stabil, solange ausreichend speicher freigeschaufelt werden kann. ist zuwenig platz für den eden space da, kommt der full-collect, der die performance niederreisst.

grund für die permanente abnahme des speichers kann ein memory leak sein. viele programmierer vergessen gerne, alle referenzen wirklich freizugeben. meist sind es objektrefernezen in irgendwelchen collections oder registrierte listener (klassisch: ein fenster registriert sich bei einem anderen als listener. dann wird das fenster geschlossen. es ist nun zwar unsichtbar, da es aber immer noch eine referenz darauf gibt, kann es nicht wirklich gelöscht werden. das remove-listener wird ganz gerne vergessen).

oder aber es ist einfach in der natur der sache, dass das programm ständig speicher braucht. da brauchst du entweder eine komplett andere programmierstrategie, was manchmal ein komplettes redesign und damit neuprogrammieren bedeutet. oder eben mehr speicher.

unter einer 32-bit-java-vm ist aber bei ca. 2 gb schluss (abängig von vm, betriebssystem usw.).

die grösse des heaps steuert man über startparameter der vm, was aber bei applets nicht so leicht ist - könnte den anwender etwas überfordern. seit java 5 kann man den heap auch zur laufzeit regeln, was aber etwas erfahrung mit der management konsole voraussetzt.

bevor du aber jetzt die vielen einstellungsmöglichkeiten für den heap erfragst, solltest du erst prüfen, ob es tatsächlich mit dem heap zusammenhängt. also einfach einen kleinen hintergrundthread, der die werte in einer datei protokolliert.

oder du findest eine möglichkeit, dich mit der management konsole an die vm des applets anzuhängen. da ich aber mit applets wenig arbeite (sind inzwischen ja doch schon grob aus der mode gekommen), kann ich da wenig weiterhelfen.

lg
erwin

2 Like

Hallo Erwin,

erst einmal vielen Dank für die ausführlichen Erläuterungen.
Für mich sind sie sehr gut verständlich.
Dafür habe ich dir ein Sternchen gegeben.

Runtime.getRuntime().totalMemory() liefert die grösse des heaps in bytes als long-wert.
Runtime.getRuntime().freeMemory() liefert die grösse des freien speichers ebenfalls als long-wert.
total - free ist logischerweise der used-wert.

Dazu habe ich einen Tread gebaut, der mir alle 5 Sekunden den Speicherbedarf ausgibt. Funktioniert super.

wird auch bei der etwas intensiveren suche nicht genug freigeräumt, muss die vm einen full collect starten. dabei werden alle threads ANGEHALTEN(!).

Wie mir beschrieben wurde, scheint es genau so zu passieren.

grund für die permanente abnahme des speichers kann ein memory leak sein.

Ich habe den Thread eine Stunde laufen gelassen und dabei gespielt. Der Speicherbedarf erhöht sich tatsächlich langsam aber stetig. Deshalb ist ein Memory Leak wahrscheinlich.

eine komplett andere programmierstrategie, was manchmal ein komplettes redesign und damit neuprogrammieren bedeutet. oder eben mehr speicher.

Mehr Speicher ist nur Symptombehandung. Mir ist es wichtig, die Ursache zu beseitigen. Wenn dies ein komplettes Redesign erfordert, wäre es nicht das erste Mal.
In den letzten Jahren gab es mit zunehmender Komplexität immer mal Phasen, die mich zwangen, das Programm von unten ganz neu zu schreiben.
Mit der Möglichkeit, den aktuellen Speicherbedarf zu ermitteln, kann ich jetzt gezielter nach den Lecks forschen.

Danke schön
und tschüs
Uwe
http://www.haller-mtl.de/homepage/breakout/breakout…