domain: java regeln
Ein Pfad, optimiert auf den Eingaben von gestern, deoptimiert, wenn die von heute eintreffen [R:a-speculated-path-deoptimises-when-the-input-changes]
Niedergeschrieben nach Aleksey Shipilev, "JVM Anatomy Quarks", über Profilverschmutzung, Inlining und Deoptimierung; HotSpots Uncommon-Trap-Mechanismus.
Ein JIT kompiliert nicht deinen Code. Er kompiliert eine Wette über deinen Code, und die Wette wird eingelöst in dem Moment, in dem die Wette nicht mehr wahr ist.
HotSpot kompiliert auf Basis des Profils: Diese Aufrufstelle hat nur einen einzigen Empfängertyp gesehen, also inline sie und verwende einen Guard; dieser Zweig wurde nie genommen, also emittiere ihn nicht; dieses Feld war nie null, also überspringe die Prüfung; diese Klasse wurde nie geladen, also nimm an, dass es keine Unterklasse gibt. Jede Annahme erhält einen Guard, und der kompilierte Pfad ist genau deshalb schnell, weil die Guards billig und die Körper abwesend sind. Wenn ein Guard fehlschlägt, wird der Frame deoptimiert: ein Uncommon Trap, Interpreter-Frames werden aus den kompilierten neu aufgebaut, die Ausführung wird interpretiert fortgesetzt und es wird darauf gewartet, mit dem neuen Profil neu kompiliert zu werden.
Der Zeitpunkt ist der Grund, warum dies eher eine Latenzregel als eine Durchsatzregel ist. Die Annahmen brechen, wenn sich die Eingabe ändert – der ungewöhnliche Ordnungstyp, die erste Ausnahme des Tages, das Nachrichtenformat eines neuen Veranstaltungsortes, die zweite Implementierung eines Interfaces, die zur sechsten Stunde geladen wird. Die schlechteste Latenz, die der Pfad jemals produzieren wird, wird also bei dem ungewöhnlichsten Ereignis geliefert, was im Allgemeinen das Ereignis ist, das am wichtigsten war. Lasttests mit repräsentativem Durchschnittsverkehr reproduzieren nichts davon.
Es ist auch standardmäßig still. Nichts wird protokolliert, keine Metrik bewegt sich, und die einzige Spur ist ein Latenzausreißer, der von einer Sammlungspause oder einer Planungsverzögerung nicht zu unterscheiden ist. -XX:+PrintCompilation und -XX:+TraceDeoptimization sagen, was passiert ist; JFR trägt die Ereignisse mit weniger Overhead und ist diejenige, die es wert ist, eingeschaltet zu lassen.
Erwärme den Pfad also auch mit den ungewöhnlichen Fällen sowie den üblichen – was hier auf [R:a-measurement-matches-the-regime-it-reports] trifft: Ein Aufwärmen, das nur aus typischem Verkehr aufgebaut wird, erzeugt ein Profil, das typischer Verkehr bestätigt und atypischer Verkehr zerstört. Halte heiße Aufrufstellen bewusst monomorph; ein megamorpher Interface-Aufruf auf dem heißen Pfad ist eine permanente Inlining-Barriere und keine einmalige Falle. Lade die Klassen, die der Pfad benötigt, bevor der Pfad live ist. Und behandle einen Lambda- oder dynamischen Aufruf, der zu einem heißen Pfad hinzugefügt wird, als neues Profil, das erst wieder verdient werden muss.
Fehlermodus-Prüfung: Was hat der JIT über diesen Pfad angenommen, und was passiert beim ersten Mal, wenn eine dieser Annahmen falsch ist? Wenn die ehrliche Antwort ist, dass niemand weiß, was angenommen wurde, dann ist das der Befund – die Annahmen sind lesbar, und sie zu lesen ist die Arbeit.
Eine Sammlungsansicht ist ein Fenster auf die Daten von jemand anderem [R:a-view-is-not-a-copy]
Wisse, ob du Daten oder ein Fenster auf Daten hältst. Der statische Typ ist in beiden Fällen List<T> und wird es dir nie verraten.
Die Bibliothek gibt weit häufiger Ansichten zurück, als der meiste Code annimmt, und jede hat ihre eigene Art zu überraschen:
Arrays.asList(a)ist ein Wrapper fester Größe über dem Array.setschreibt durch in das Array;addundremovewerfenUnsupportedOperationException.List.of(...)ist unveränderlich und unabhängig und ist normalerweise das, was gemeint war.list.subList(from, to)ist ein Live-Fenster. Schreibvorgänge gehen durch, und jede strukturelle Änderung am Elternteil führt dazu, dass die Subliste bei ihrer nächsten VerwendungConcurrentModificationExceptionwirft – einschließlich des üblichen Idioms, eine Subliste zu nehmen und dann das Elternteil zu leeren.map.keySet(),values(),entrySet()sind Ansichten.keySet().remove(k)entfernt den Eintrag aus der Map. Das ist gelegentlich das, was du willst, und es ist immer wert, sich dessen bewusst zu sein.Collections.unmodifiableList(l)umschließt ohne Kopie: Der Wrapper verweigert Schreibvorgänge und das Original funktioniert immer noch, also schützt es vor dem Aufrufer und nicht vor dir. Es ist keine defensive Kopie, weshalb[R:no-reference-to-internals-escapes]stattdessen zuList.copyOfgreift.Stream.toList()ist unveränderlich;collect(Collectors.toList())gibt keine solche Garantie und kann es sein oder auch nicht. Verlasse dich auf keinen von beiden als veränderlich.
Gib also an, was du zu dem Zeitpunkt willst, an dem du es erstellst. List.copyOf, wenn der Empfänger unabhängig sein muss, die Ansicht, wenn das Teilen die Absicht ist und die Lebensdauer kurz ist. Eine Ansicht, die in einem Feld gespeichert ist, ist die Form, die man am genauesten betrachten sollte: Sie überlebt den Ausdruck, der sie erstellt hat, und die zugrunde liegende Sammlung kann sich zu jedem späteren Zeitpunkt unter ihr ändern.
Die gleiche Unterscheidung gilt für die Iteration. Das Ändern einer Sammlung während der Iteration wirft ConcurrentModificationException, sogar single-threaded, weil der Iterator auch eine Ansicht ist – verwende removeIf, oder iteriere über eine Kopie, oder sammle die Entfernungen und wende sie danach an.
Fehlermodus-Prüfung, für jede empfangene oder zurückgegebene Sammlung: Wenn jemand hier schreibt, was ändert sich dann noch? Wenn du das nicht beantworten kannst, ohne das Javadoc der Factory-Methode zu öffnen, kopiere es.
Ein Wrapper-Typ kostet ein Objekt, bis die Escape-Analyse es entfernt [R:a-wrapper-type-is-not-free-here]
Gib Domänenkonzepten auch hier ihre eigenen Typen – eine OrderId, die nicht dort übergeben werden kann, wo eine AccountId hingehört, ist in Java genauso viel wert wie anderswo. Aber übertrage nicht das Kosten-Argument mit dem Design-Argument, weil nur eines von beiden reist.
In Rust ist ein Newtype ein Konstrukt zur Compile-Zeit: struct OrderId(u64) ist zur Laufzeit ein u64, und der Compiler beweist es. In Java ist record OrderId(long value) ein Objekt – ein Header, ein Feld, eine Referenz, der man nachjagen muss, und eine separate Cache-Zeile von dem, was darauf zeigte. Der JIT kann es entfernen: Escape-Analyse plus skalare Ersetzung werden einen Wrapper glätten, der nachweislich seine Kompilierungseinheit nicht verlässt, was einen Großteil des gewöhnlichen Codes abdeckt. Was es nicht abdeckt, ist der Fall, der dich interessiert – ein Wrapper, der in einem Feld gespeichert, in eine Sammlung gelegt, über eine Methode gereicht wird, die der JIT nicht inlinen wollte, oder in einem Array, wo OrderId[] ein Array von Zeigern ist und long[] ein Array von Zahlen.
Die Disziplin teilt sich also danach, wo der Wert lebt:
- An einer API-Grenze und überall dort, wo Korrektheit das Anliegen ist, verwende den Wrapper. Der vertauschte Parameterfehler, den er verhindert, ist eine Allokation wert, die der JIT normalerweise ohnehin entfernt.
- Innerhalb einer heißen Schleife, in einem großen Array oder in einer Pro-Nachricht-Struktur, messe, bevor du annimmst, dass der Wrapper verschwunden ist. Dies ist
[R:verify-the-abstraction-compiled-away], das in einer Sprache ankommt, in der die Entscheidung des Optimierers dynamisch ist:-XX:+PrintInlining, ein JFR-Allokationsprofil oder ein Escape-Analyse-Dump sagt, was tatsächlich passiert ist, und die Antwort kann zwischen verschiedenen Läufen desselben Binärprogramms unterschiedlich ausfallen. - Wo er nicht verschwindet und das Budget real ist, behalte den primitiven Typ und platziere die Sicherheit woanders, wo sie nichts kostet – einen benannten Parameter, einen Builder, eine statische Factory, deren Signatur nicht falsch aufgerufen werden kann.
Die Version dieser Regel, die kommt, ist es wert, zu wissen, anstatt darauf zu warten: Die Wertklassen von Project Valhalla wurden entwickelt, um genau diesen Wrapper durch Spezifikation zu glätten, anstatt nach dem Ermessen des Optimierers. Bis der Code auf einer JVM läuft, auf der das wahr ist, sind die Kosten dynamisch und müssen gemessen werden, anstatt angenommen zu werden.
Fehlermodus-Prüfung, bevor ein primitiver Typ auf einem Pfad mit Budget umschlossen wird: Existiert dieses Objekt noch, nachdem der JIT fertig ist, und was hat es mir gesagt? "Es ist nur ein Wrapper" ist die Rust-Antwort, und sie ist diejenige, die nicht übertragen wird.
Eine Ressource wird durch das Konstrukt geschlossen, das sie geöffnet hat, auf jedem Pfad [R:close-what-you-open]
Öffne eine Ressource in einem try-mit-Ressourcen-Header und nirgendwo sonst. Das Konstrukt schließt bei jedem Austritt, in umgekehrter Reihenfolge und – der Teil, den ein handgeschriebenes finally nicht kann – es behält die ursprüngliche Ausnahme, wenn das Schließen ebenfalls fehlschlägt.
try (var conn = pool.take(); var stmt = conn.prepare(sql)) {
return stmt.execute();
}
Der Fehler, den ein finally-Block einführt, ist es wert, klar ausgesprochen zu werden, weil er richtig aussieht. Wenn der Rumpf wirft und close() auch wirft, ersetzt die Ausnahme des finally die des Rumpfes: Das Programm meldet einen Fehler beim Schließen einer Verbindung und sagt nichts über die Abfrage, die zuerst fehlgeschlagen ist. Try-mit-Ressourcen unterdrückt stattdessen den Schließfehler und hängt ihn an den ursprünglichen an, sodass beide überleben und die Ursache diejenige ist, die du wolltest. Ein Leck kostet dich einen Handle; eine verlorene Ursache kostet dich die Untersuchung.
Drei Fälle, in denen das Konstrukt nicht zutrifft und die Disziplin dennoch gilt:
- Eine Ressource, deren Lebensdauer ein Feld ist, nicht ein Block – ein Pool, ein Client, ein Executor. Das besitzende Objekt wird
AutoCloseableund die Disziplin steigt eine Ebene nach oben; die Sache, die man ablehnen sollte, ist ein Feld, das geöffnet und von niemand Bestimmtem geschlossen wird. - Ein
ExecutorServicewird nicht durchshutdown()allein geschlossen.shutdown()dannawaitTermination()dannshutdownNow()ist die Sequenz, und das Überspringen hinterlässt Nicht-Daemon-Threads, die die JVM blockieren. - Ein
Streamüber eine Datei (Files.lines,Files.walk) hält einen Handle und muss geschlossen werden. Es ist der eine Stream, der leckt, und er sieht genauso aus wie die, die es nicht tun.
Greife nicht zu einem Finalizer oder Cleaner als primärem Mechanismus. Sie laufen zu einem nicht näher bezeichneten Zeitpunkt oder nie, und sie existieren, um den Fall abzufangen, in dem die Disziplin bereits versagt hat.
Fehlermodus-Prüfung, für jede Ressource: Welches Konstrukt schließt diese, wenn die nächste Zeile wirft? Wenn die Antwort ein close()-Aufruf weiter unten in der Methode ist, tut es nichts.
Eine Klasse ist final, oder ihre Vererbung ist entworfen und dokumentiert [R:design-for-inheritance-or-forbid-it]
Mache die Klasse final, es sei denn, du hast für eine Unterklasse entworfen, und wenn du es hast, sage genau, was eine Unterklasse überschreiben darf und was sie nicht darf.
Vererbung ist nicht der Wiederverwendungsmechanismus, als der sie erscheint. Eine Unterklasse hängt davon ab, welche Methoden der Oberklasse welche anderen aufrufen – addAll, das add aufruft, ist das kanonische Beispiel – und das ist ein Implementierungsdetail, das die Oberklasse in einem Patch-Release ändern darf. Eine funktionierende Unterklasse bricht also, wenn das Elternteil umgestaltet wird, ohne dass einer der Autoren etwas falsch gemacht hat. Komposition hat diese Eigenschaft nicht: Ein Wrapper hängt vom öffentlichen Vertrag und nichts anderem ab.
Der Konstruktorfall ist schlimmer als fragil, er ist von Anfang an kaputt. Ein Konstruktor, der eine überschreibbare Methode aufruft, führt die Überschreibung der Unterklasse bevor die eigenen Felder der Unterklasse zugewiesen werden aus, sodass die Überschreibung null und 0 in Feldern sieht, die sie als final deklariert und initialisiert hat. Nichts warnt. Dies ist die gleiche Publikationsgefahr, die [R:no-reference-to-internals-escapes] als entweichendes this bezeichnet, erreicht über einen Weg, der wie gewöhnlicher Methodenversand aussieht – und [R:publish-safely-or-not-at-all] ist der Grund, warum es wichtig ist, selbst wenn es nur einen Thread gibt.
Also:
finalstandardmäßig bei Klassen und bei jeder Methode, die eine Unterklasse nichts zu ersetzen hat. Versiegelung ([R:seal-the-alternatives]) ist die stärkere Form, bei der die Menge der Untertypen geschlossen und bekannt ist.- Rufe niemals eine überschreibbare Methode von einem Konstruktor, einem Initialisierer oder
clone()oderreadObject()auf, die Konstruktoren unter anderen Namen sind. - Wenn eine Klasse für Vererbung entworfen ist, dokumentiere die Selbstnutzung: Welche Methoden rufen welche auf, welche sind sicher zu überschreiben, was eine Unterklasse aufrufen muss. Diese Dokumentation ist Teil des Vertrags, und die Kosten dafür sind der ehrliche Grund, warum die meisten Klassen stattdessen
finalsein sollten. - Bevorzuge Komposition. Ein Wrapper, der die Instanz hält und weiterleitet, ist länger zu schreiben, immun gegen die Interna des Elternteils und dasjenige, zu dem man greifen sollte, wenn die Motivation Wiederverwendung und nicht Ersetzbarkeit war.
Fehlermodus-Prüfung, für jede nicht-finale Klasse: Welche meiner Methoden rufen sich gegenseitig auf, und wäre eine Unterklasse, die eine von ihnen überschreibt, immer noch korrekt, nachdem ich sie neu angeordnet habe? Wenn das nicht beantwortet werden kann, war die Klasse versehentlich erweiterbar.
equals und hashCode sind eine Entscheidung, und ein veränderlicher Schlüssel bricht beide [R:equality-is-one-contract]
Entscheide dich einmal für die Gleichheit, implementiere beide Hälften in derselben Änderung und leite sie von Feldern ab, die sich nicht ändern können, während das Objekt von einer Sammlung aus erreichbar ist.
Der Vertrag ist mechanisch: Gleiche Objekte müssen gleiche Hash-Codes haben. Brichst du ihn, sucht eine HashMap im falschen Bucket, sodass get null für einen Schlüssel zurückgibt, den sie enthält, und remove keinen Eintrag finden kann, den es zu entfernen gilt – ein Leck, dessen Größe mit dem Verkehr wächst. Nichts erkennt dies. Die Sammlung verhält sich genau wie spezifiziert; der Spezifikation wurde ein Objekt übergeben, das gelogen hat.
Die Hälfte des veränderlichen Schlüssels ist diejenige, die eine Überprüfung überlebt, weil die Klasse korrekt ist, wenn sie geschrieben wird, und falsch wird, wenn jemand einen Setter hinzufügt. Ein Objekt, dessen hashCode von einem Feld abhängt, das später mutiert wird, hat den Bucket gewechselt, ohne dass die Map informiert wurde, und ist jetzt durch genau den Schlüssel, unter dem es abgelegt wurde, nicht mehr erreichbar. Also ist ein Typ, der als Schlüssel verwendet wird, unveränderlich, oder er wird nicht als Schlüssel verwendet. Ein record gibt dir beide Hälften, die aus den Komponenten generiert werden, und endgültige Felder durch die Konstruktion, was der Grund ist, warum es die Standardform für einen Werttyp ist und warum das handschriftliche Schreiben dieser beiden Methoden wie eine Entscheidung und nicht wie Boilerplate-Code wirken sollte.
Zwei weitere, die aus demselben Vertrag folgen und es wert sind, erwähnt zu werden, weil jede ihre eigene Art hat, schiefzugehen:
compareTomuss mitequalsübereinstimmen, wo immer der Typ eineTreeMapoder ein sortiertes Set erreicht, die Ordnung verwenden undequalsüberhaupt nicht aufrufen. Ein Comparator, der sagt, dass zwei Objekte äquivalent sind, währendequalssagt, dass sie sich unterscheiden, erzeugt ein Set, das stillschweigend eines von ihnen hält.- Vererbung und Gleichheit komponieren nicht. Ein
equals, das mitinstanceofgeschrieben wurde, akzeptiert eine Unterklasse und ist dann asymmetrisch; eines, das mitgetClass()geschrieben wurde, ist symmetrisch und lehnt jede Unterklasse ab. Es gibt keine dritte Option, was einer der Gründe ist, warum[R:design-for-inheritance-or-forbid-it]finalbevorzugt.
Fehlermodus-Prüfung, bevor ein Typ als Schlüssel verwendet oder in ein Set gesetzt wird: Von welchen Feldern hängt seine Gleichheit ab, und ändert sich eines von ihnen, nachdem das Objekt konstruiert wurde? Schreibe die equals-Methode erst, wenn diese Antwort klar ist.
Eine Ausnahme hat einen Namen, und dieser Name ist das, was fehlgeschlagen ist [R:exceptions-name-what-failed]
Benenne die Ausnahme nach dem, was schiefgegangen ist, nicht nach dem, wohin sie geworfen wird. DuplicateOrderException ist ein Name, der sein sollte, was passiert ist; PersistenceException ist ein Name, der sagt, wo der Fehler begann, und ist das, was die Disziplin dieser Regel am meisten verlangt.
Wähle also Ausnahmetypen, die den Fehler beschreiben, nicht die Schicht. Eine UserNotFoundException teilt dem Aufrufer mit, dass ein Benutzer nicht gefunden wurde, unabhängig davon, ob die Datenbank nicht erreichbar war oder der Benutzer noch nie existiert hat. Eine PersistenceException teilt dem Aufrufer mit, dass etwas mit der Datenbank schief gelaufen ist, aber nicht, ob eine UserNotFoundException ein erwartetes Ergebnis (Benutzer existiert nicht) oder ein Systemproblem (Datenbank ist ausgefallen) sein soll.
Die große Faustregel: Wenn der Aufrufer unterschiedlich reagiert, verwende unterschiedliche Typen. Wenn der Aufrufer nichts unterschiedlich macht, verwende gar keine benutzerdefinierte Ausnahme.
Verwende IllegalArgumentException für eine ganze Klasse von Programmierfehlern, bei denen der Empfänger die Antwort ohnehin ist, die oberste Methode abzubrechen. Verwende eine eigene Ausnahme, wenn der Aufrufer den Fehler speziell behandeln muss. Und verwende eine Ausnahme mit dem Namen eines anderen, wenn der Fehler derselbe ist: DuplicateOrderException extends IllegalArgumentException, wenn der doppelte Benutzer ein Programmierfehler auf der gleichen Ebene ist wie eine null-Referenz.
Die Regel zur Prüfung auf geprüfte versus ungeprüfte Ausnahmen ist einfach: Wenn der Aufrufer den Fehler nicht erwarten kann, ist es ungeprüft. Wenn der Aufrufer den Fehler auf eine Weise erwarten sollte, die das Design des Aufrufers beeinflusst, ist es geprüft.
Fehlermodus-Prüfung: Wird der Aufrufer jemals eine separate catch-Klausel für diesen Typ schreiben? Wenn nicht, ist der neue Typ nicht nützlich.
Identität ist nicht gleich Gleichheit für Boxen [R:identity-is-not-equality-for-boxes]
"Verbessere niemals die Leistung, indem du == anstelle von equals für einen Referenztyp verwendest, es sei denn, du weißt zu 100 %, dass der JIT die Identitätsprüfung optimieren wird. Und selbst dann ist es normalerweise nicht die richtige Antwort."
Javas Autoboxing und Integer-Caching bedeuten, dass Integer.valueOf(42) == Integer.valueOf(42) wahr sein kann, während Integer.valueOf(200) == Integer.valueOf(200) falsch ist. Das ist kein Fehler; es ist eine Implementierung eines Caches von -128 bis 127, der das stille Brechen des Vertrags zwischen Identität und Gleichheit verbirgt. Der Code scheint zu funktionieren, bis die Werte den Cache-Bereich verlassen.
Gleiches gilt für String. intern ist eine Optimierung, kein Vertrag. Zwei gleiche Strings können unterschiedliche Referenzen haben.
Fehlermodus-Prüfung: Verwendest du == auf einem Referenztyp, der equals überschreibt? Wenn ja, bist du wahrscheinlich falsch.
Keine Referenz auf Interna entweicht [R:no-reference-to-internals-escapes]
Gib niemals eine Referenz auf ein veränderliches internes Objekt zurück, es sei denn, der Aufrufer ist vertrauenswürdig (was er normalerweise nicht ist) und der Vertrag besagt dies ausdrücklich.
public class Order {
private final List<Item> items;
public List<Item> getItems() { // Schlecht: Verleiht die Kontrolle über die innere Liste
return items;
}
}
Stattdessen: Gib eine unveränderliche Ansicht zurück (Collections.unmodifiableList), oder gib eine Kopie zurück (List.copyOf für Unveränderlichkeit, new ArrayList<>(items) für eine momentane Kopie). Die Kosten der Kopie sind die Versicherung gegen den Fall, dass ein Aufrufer die innere Struktur ändert.
Umgekehrt, beschaffe niemals eine Referenz auf ein internes Objekt und ändere es dann. Das ist dasselbe Problem von der anderen Seite.
Dies gilt auch für Arrays: return items.toArray() gibt eine Kopie zurück. return items.toArray(new Item[0]) nicht unbedingt, also dokumentiere dies.
Noch gefährlicher: Gib this nicht aus einem Konstruktor heraus frei. Eine Referenz auf ein noch nicht vollständig konstruiertes Objekt wird beim Veröffentlichen gesehen.
Fehlermodus-Prüfung, für jede öffentliche Methode, die einen veränderlichen Typ zurückgibt: Kann der Aufrufer den internen Zustand nach dem Aufruf dieser Methode ändern? Wenn ja, wurde der Vertrag verletzt.
null ist kein Wert, also repräsentiere es nicht als einen [R:null-is-not-a-value]
null bedeutet "kein Wert". Ein Optional<T> bedeutet "null oder nicht". Eine @Nullable-Annotation sagt dem Aufrufer, dass etwas null sein kann, aber es ist ein schwacher Vertrag.
Die moderne Java-Propaganda: Vermeide null, wo immer möglich. Verwende Optional für Rückgabetypen, wo die Abwesenheit eines Werts ein erwartetes und behandelbares Ergebnis ist. Verwende keine null-Referenzen als Methodenparameter – verwende Überladung, Builder oder separate Methoden, um Klarheit zu schaffen.
@Nullable ist besser als nichts, aber schlechter als Optional. @NonNull ist besser als nichts, aber schlechter als den Parameter als nicht-null zu dokumentieren.
Fehlermodus-Prüfung: Kann dieser Wert jemals null sein? Wenn ja, wie geht der Aufrufer damit um? Wenn die Antwort "durch einen NullPointerException" ist, wurde die Wahl getroffen, nicht zu prüfen.
Publiziere sicher oder gar nicht [R:publish-safely-or-not-at-all]
Das Java-Speichermodell ist nicht "es könnte passieren"; es ist "es wird passieren, aber nur auf bestimmten Hardware- und JVM-Kombinationen, und nur dann, wenn du nicht damit rechnest". Ein Objekt, das ohne korrekte Synchronisation veröffentlicht wird (safe publication) oder durch einen Konstruktor, der this entweichen lässt, kann von einem anderen Thread in einem teilweise konstruierten Zustand gesehen werden.
Die sicheren Methoden der Veröffentlichung:
- Durch ein
final-Feld (garantiert durch das JMM: nach der Konstruktion sichtbar). - Durch ein flüchtiges (
volatile) Feld. - Durch eine Sperre (
synchronized,Lock). - Durch eine thread-sichere Sammlung (
ConcurrentHashMap,CopyOnWriteArrayList). - Durch einen
java.util.concurrent-Container (AtomicReference,Exchanger).
Alles andere ist unsichere Veröffentlichung. Ein Objekt, das in einem regulären HashMap gespeichert und von einem anderen Thread gelesen wird, kann teilweise konstruiert erscheinen.
Das bedeutet auch: Gib this nicht aus einem Konstruktor heraus frei (wie in [R:no-reference-to-internals-escapes]). Verwende ein separates Startverfahren (z.B. start()), um die Referenz zu veröffentlichen, nachdem die Konstruktion abgeschlossen ist.
Fehlermodus-Prüfung: Werden die Werte in diesem Objekt jemals von einem anderen Thread gelesen, nachdem sie von diesem Thread geschrieben wurden, ohne eine der oben genannten Veröffentlichungstechniken? Wenn ja, ist der Datenwettlauf spezifiziert und kein Fehler.
Versiegele die Alternativen [R:seal-the-alternatives]
Ein sealed-Class ist ein Vertrag: "Diese Klasse hat genau diese Untertypen, und keine anderen." Ein final-Class ist ein Vertrag: "Diese Klasse hat keine Untertypen." Beide sind stärker als die Standardvererbung, und beide sind vorzuziehen, wo die Menge der Alternativen geschlossen ist.
public sealed class Payment permits CreditCard, BankTransfer, Crypto {}
Der Compiler erzwingt, dass CreditCard, BankTransfer und Crypto entweder final oder ebenfalls sealed sind. Ein switch über eine versiegelte Art gibt dir eine vollständige Abdeckung, ohne einen Standardwert zu benötigen.
Verwende sealed, wenn:
- Die Menge der Alternativen von der Domäne festgelegt ist (z.B. Zahlungsarten, Fehlercodes).
- Du Ausdrucksstärke durch Musterabgleich erreichen möchtest.
- Du dokumentieren möchtest, dass keine alternativen Implementierungen erwartet werden.
Verwendefinal standardmäßig und sealed, wenn du eine geschlossene und dokumentierte Menge von Alternativen benötigst.
sealed wird auch dann nützlich, wenn du eine Schnittstelle (Interface) entwirfst – du kannst ein Interface versiegeln, sodass nur bestimmte Typen es implementieren können.
Fehlermodus-Prüfung: Wenn ich dieser Domäne eine neue Alternative hinzufüge, wie viele Code-Stellen müssen sich ändern? Eine versiegelte Hierarchie beantwortet das: genau eine Datei pro Änderung und die Deklaration selbst.
Serializable ist ein zweiter Konstruktor [R:serializable-is-a-second-constructor]
Serializable ist nicht eine kostenlose Funktion; es ist eine zweite Möglichkeit, ein Objekt zu erstellen. Standardmäßig verwendet Java die Reflexion, um das Objekt zu erstellen, ohne einen Konstruktor aufzurufen, und ruft dann readObject() auf. Das bedeutet, dass alle Garantien, die durch den Konstruktor durchgesetzt werden (finale Felder, invariante Überprüfungen, sicherheitskritische Initialisierungen), umgangen werden können.
Wenn du Serializable implementierst, musst du:
serialVersionUIDdeklarieren, sonst bricht die Serialisierung bei jeder Änderung der Klasse.writeObjectundreadObjectkorrekt definieren, wenn das Standardverhalten nicht ausreicht.- Bedenke, dass
readObject()ein neues Objekt aus einem Datenstrom erstellt, ohne den Konstruktor aufzurufen. Wenn die Klasse Sicherheitsinvarianten hat, die im Konstruktor durchgesetzt werden, mussreadObject()sie erneut anwenden. - Niemals einen finalen Feld-Wert in
readObject()überschreiben (es sei denn, du verwendestObjectStreamField, was selten der richtige Weg ist).
Wenn Serializable nicht benötigt wird, implementiere es nicht. Wenn es benötigt wird, behandele readObject() als einen zweiten Konstruktor, der die gleichen Invarianten wie der normale Konstruktor durchsetzen muss.
Fehlermodus-Prüfung: Kann das Standard-Serialisierungsformat die Invarianten dieser Klasse verletzen? Wenn ja, implementiere readObject() oder vermeide die Serialisierung.
Verifiziere, dass die Abstraktion wegkompiliert wurde [R:verify-the-abstraction-compiled-away]
Das ist keine separate Regel, sondern ein allgemeines Prinzip: Miss, bevor du annimmst, dass der Compiler (hier der JIT) die Abstraktion entfernt hat. Dies ist besonders relevant für [R:a-wrapper-type-is-not-free-here], aber es gilt auch für virtuelle Methodenaufrufe, die inlined werden müssen, für Lambda-Ausdrücke, die inlined oder als separate Methoden ausgegeben werden, und für jeden anderen Fall, in dem der JIT eine Abstraktion entfernen muss, um Leistung zu erzielen.
Verwende -XX:+PrintInlining oder JFR, um zu sehen, ob ein Methodenaufruf inlined wurde. Verwende JFRs Allokationsprofil, um zu sehen, ob eine Allokation tatsächlich stattfindet. Verwende Escape-Analyse-Dumps, um zu sehen, ob ein Objekt auf dem Stack oder Heap landet.
Fehlermodus-Prüfung: Habe ich gemessen, ob diese Abstraktion tatsächlich Kosten verursacht, oder habe ich nur angenommen, dass sie es tut/nicht tut? Wenn die Antwort "nur angenommen" ist, ist die Annahme das Risiko.
Die Messung stimmt mit dem Regime überein, das sie meldet [R:a-measurement-matches-the-regime-it-reports]
Dies ist eine allgemeine Messtugend, aber sie hat besondere Bedeutung in einem JIT-kompilierten Umfeld. Ein Benchmark, der mit nicht aufgewärmtem Code ausgeführt wird, misst nicht die gleichen Dinge wie einer mit aufgewärmtem Code. Ein Benchmark, der mit einem einzelnen Thread ausgeführt wird, sagt nichts über die Leistung unter Parallelität aus. Ein Benchmark, der mit kleinen Datenmengen läuft, sagt nichts über die Leistung mit großen Datenmengen aus.
Stelle also sicher, dass deine Messung dem Regime entspricht, über das du berichten möchtest. Wärme den Code auf, verwende repräsentative Daten, verwende realistische Thread-Pools und sammle genügend Stichproben, um statistische Signifikanz zu erreichen.
Fehlermodus-Prüfung: Welches Regime repräsentiert diese Messung? Wenn die Antwort "kein bestimmtes" oder "ich weiß es nicht" ist, ist die Messung wertlos.
Zusammenfassung
Diese Regeln sind nicht absolut, aber sie sind eine Richtschnur für das Java Engineering. Die Fehlermodus-Prüfungen am Ende jeder Regel sind das, was sie praktisch macht: Sie geben dir eine klare Frage, die du bei jeder Codestelle stellen kannst, die diese Regel betrifft. Entwickle die Gewohnheit, diese Fragen zu stellen, und die Regeln werden zu einem natürlichen Teil deines Schreibens.