web-dev-qa-db-fra.com

Le finaliseur Java doit-il vraiment être évité également pour la gestion du cycle de vie des objets homologues natifs?

En tant que développeur C++/Java/Android, j’ai appris que les finaliseurs sont presque toujours une mauvaise idée, la seule exception étant la gestion d’un objet "pair natif" requis par le développeur Java pour appeler du code C/C++. par JNI.

Je suis conscient de la JNI: gérer correctement la durée de vie d'une question d'objet Java }, mais cette question adresse les raisons de ne pas utiliser un finaliseur de toute façon, ni pour les pairs natifs . C'est donc une question/discussion sur une confutation des réponses dans la question susmentionnée.

Joshua Bloch dans son Effective Java mentionne explicitement ce cas comme une exception à son célèbre conseil de ne pas utiliser de finaliseurs:

Une deuxième utilisation légitime des finaliseurs concerne les objets avec des pairs natifs. Un homologue natif est un objet natif auquel un objet normal délègue via des méthodes natives. Etant donné qu’un homologue natif n’est pas un objet normal, le ramasse-miettes ne le connaît pas et ne peut pas le récupérer lorsque son homologue Java est récupéré. Un finaliseur est un véhicule approprié pour effectuer cette tâche, en supposant que l'homologue natif ne possède aucune ressource critique. Si l'homologue natif contient des ressources qui doivent être terminées rapidement, la classe doit avoir une méthode de terminaison explicite, comme décrit ci-dessus. La méthode de terminaison doit faire le nécessaire pour libérer la ressource critique. La méthode de terminaison peut être une méthode native ou en appeler une.

(Voir aussi "Pourquoi la méthode finalisée est-elle incluse dans Java?" Question sur stackexchange)

Puis j’ai regardé la conversation très intéressante Comment gérer la mémoire native sous Android au Google I/O '17, où Hans Boehm prône réellement contre l’utilisation de finaliseurs pour la gestion des homologues natifs d’un objet Java , citant également Effective Java comme référence. Après avoir rapidement expliqué pourquoi la suppression explicite de l'homologue natif ou la fermeture automatique en fonction de la portée pouvait ne pas constituer une alternative viable, il recommande d'utiliser Java.lang.ref.PhantomReference à la place.

Il soulève des points intéressants, mais je ne suis pas complètement convaincu. Je vais essayer de passer en revue certaines d’entre elles et de faire part de mes doutes, en espérant que quelqu'un pourra leur en dire plus.

À partir de cet exemple:

class BinaryPoly {

    long mNativeHandle; // holds a c++ raw pointer

    private BinaryPoly(long nativeHandle) {
        mNativeHandle = nativeHandle;
    }

    private static native long nativeMultiply(long xCppPtr, long yCppPtr);

    BinaryPoly multiply(BinaryPoly other) {
        return new BinaryPoly ( nativeMultiply(mNativeHandle, other.mNativeHandler) );
    }

    // …

    static native void nativeDelete (long cppPtr);

    protected void finalize() {
        nativeDelete(mNativeHandle);
    }
}

Lorsqu'une classe Java contient une référence à un homologue natif qui est supprimé dans la méthode de finalisation, Bloch répertorie les inconvénients d'une telle approche.

Les finaliseurs peuvent être exécutés dans un ordre arbitraire

Si deux objets deviennent inaccessibles, les finaliseurs fonctionnent en fait dans un ordre arbitraire, ce qui inclut le cas où deux objets qui se désignent deviennent inaccessibles au même moment, ils peuvent être finalisés dans le mauvais ordre, ce qui signifie que le second à finaliser tente d'accéder à un objet déjà finalisé. [...] En conséquence, vous pouvez obtenir des pointeurs en suspens et voir des objets c ++ désalloués [...]

Et à titre d'exemple:

class SomeClass {
    BinaryPoly mMyBinaryPoly:
    …
    // DEFINITELY DON’T DO THIS WITH CURRENT BinaryPoly!
    protected void finalize() {
        Log.v(“BPC”, “Dropped + … + myBinaryPoly.toString());   
    }
}

Ok, mais n'est-ce pas vrai aussi si myBinaryPoly est un objet Java pur? Si je comprends bien, le problème vient du fait d’opérer sur un objet éventuellement finalisé dans le finaliseur de son propriétaire. Si nous n'utilisons que le finaliseur d'un objet pour supprimer son propre homologue natif privé et ne faisons rien d'autre, cela devrait aller, n'est-ce pas?

Le finaliseur peut être invoqué tant que la méthode native est en cours d'exécution

Selon les règles Java, mais pas actuellement sur Android:
Le finaliseur d’objet x peut être appelé alors qu’une des méthodes de x est toujours en cours d’exécution et accède à l’objet natif.

Le pseudo-code de ce à quoi multiply() est compilé est montré pour expliquer ceci:

BinaryPoly multiply(BinaryPoly other) {
    long tmpx = this.mNativeHandle; // last use of “this”
    long tmpy = other.mNativeHandle; // last use of other
    BinaryPoly result = new BinaryPoly();
    // GC happens here. “this” and “other” can be reclaimed and finalized.
    // tmpx and tmpy are still neeed. But finalizer can delete tmpx and tmpy here!
    result.mNativeHandle = nativeMultiply(tmpx, tmpy)
    return result;
}

C’est effrayant, et je suis vraiment soulagé que cela ne se produise pas sur Android, car ce que je comprends, c’est que this et other se ramassent avant qu’ils ne disparaissent! C'est encore plus étrange si l'on considère que this est l'objet sur lequel la méthode est appelée et que other est l'argument de la méthode. Ils doivent donc déjà être "en vie" dans l'étendue où la méthode est appelée.

Une solution rapide consisterait à appeler des méthodes factices à la fois sur this et other (moche!), Ou en les transmettant à la méthode native (où nous pouvons ensuite extraire la mNativeHandle et l'exploiter). Et attendez ... this est déjà par défaut l'un des arguments de la méthode native!

JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply
(JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {}

Comment peut-on this être éventuellement ramassé des ordures?

Les finaliseurs peuvent être différés trop longtemps

«Pour que cela fonctionne correctement, si vous exécutez une application qui alloue beaucoup de mémoire native et relativement peu de mémoire Java, il se peut que le récupérateur de place ne s'exécute pas assez rapidement pour appeler les finaliseurs [...] de sorte que vous puissiez réellement Il faut parfois invoquer System.gc () et System.runFinalization (), ce qui est délicat à faire [...] ”

Si l'homologue natif n'est vu que par un seul objet Java auquel il est lié, cela n'est-il pas transparent pour le reste du système, et le GC devrait donc simplement gérer le cycle de vie de l'objet Java tel qu'il était pur Java un? Il y a clairement quelque chose que je ne vois pas ici.

Les finaliseurs peuvent réellement prolonger la durée de vie de l'objet Java

[...] Parfois, les finaliseurs prolongent réellement la durée de vie de l'objet Java pour un autre cycle de récupération de place, ce qui signifie que, pour les éboueurs de génération, ils peuvent en réalité le faire survivre dans l'ancienne génération et que la durée de vie peut être considérablement allongée avoir un finaliseur.

J'admets que je ne comprends pas vraiment quel est le problème ici et quel est le lien avec le fait d'avoir un pair autochtone, je vais faire quelques recherches et éventuellement mettre à jour la question :)

En conclusion

Pour le moment, je continue de penser qu'utiliser une sorte d'approche RAII où l'homologue natif est créé dans le constructeur de l'objet Java et supprimé dans la méthode finalize n'est pas réellement dangereux, à condition que:

  • l'homologue natif ne contient aucune ressource critique (dans ce cas, il doit exister une méthode distincte pour libérer la ressource; l'homologue natif doit uniquement agir en tant qu'objet Java "homologue" dans le domaine natif).
  • le pair natif ne couvre pas les threads et ne fait pas de trucs concurrents dans son destructeur (qui voudrait faire ça?!?)
  • le pointeur homologue natif n'est jamais partagé en dehors de l'objet Java, n'appartient qu'à une seule instance et n'est accessible qu'à l'intérieur des méthodes de l'objet Java. Sous Android, un objet Java peut accéder à l'homologue natif d'une autre instance de la même classe, juste avant d'appeler une méthode jni acceptant différents homologues natifs ou, mieux, en transmettant simplement les objets Java à la méthode native.
  • le finaliseur de l'objet Java ne supprime que son propre homologue natif et ne fait rien d'autre

Y a-t-il une autre restriction à ajouter, ou il n'y a vraiment aucun moyen de s'assurer qu'un finaliseur est sûr même si toutes les restrictions sont respectées?

34
athos

Selon moi, il faut publier les objets natifs dès que vous en avez terminé, d’une manière déterministe. En tant que tel, utiliser scope pour les gérer est préférable à l'aide du finaliseur. Vous pouvez utiliser le finaliseur pour le nettoyage en dernier recours, mais je ne l’utiliserais pas uniquement pour gérer la durée de vie réelle pour les raisons que vous avez réellement mentionnées dans votre propre question.

En tant que tel, laissez le finaliseur être la dernière tentative, mais pas la première.

5
cineam mispelt

Je pense que la majeure partie de ce débat découle du statut hérité de finalize (). Il a été introduit en Java pour traiter des problèmes que la récupération de place ne couvrait pas, mais pas nécessairement des ressources telles que les ressources système (fichiers, connexions réseau, etc.), de sorte qu’il se sentait toujours à moitié cuit. Je ne suis pas nécessairement d'accord avec l'utilisation de quelque chose comme phantomreference, qui prétend être un meilleur finaliseur que finalize () lorsque le motif lui-même est problématique.

Hugues Moreau a fait remarquer que finalize () sera obsolète en Java 9. Le modèle préféré de l’équipe Java semble traiter des éléments comme des pairs natifs en tant que ressource système et les nettoyer via des ressources d’essai avec ressources. Implémenter AutoCloseable vous permet de le faire. Notez que try-with-resources et AutoCloseable post-datent l'implication directe de Josh Bloch avec Java et Effective Java 2nd edition.

4
Scott

finalize et d’autres approches utilisant la connaissance de la durée de vie des objets par le GC présentent quelques nuances:

  • visibilité : garantissez-vous que toutes les méthodes d'écriture de l'objet o made sont visibles pour le finaliseur (c'est-à-dire qu'il existe une relation arrive-avant entre le dernier action sur l'objet o et le code effectuant la finalisation)?
  • accessibilité : comment pouvez-vous garantir qu’un objet o ne soit pas détruit prématurément (par exemple, pendant qu’une de ses méthodes est en cours d’exécution), qui est autorisé par le JLS? Il faitarrive et provoque des accidents.
  • ordering : pouvez-vous appliquer un certain ordre dans lequel les objets sont finalisés?
  • terminaison : devez-vous détruire tous les objets lorsque votre application se termine?

Il est possible de résoudre tous ces problèmes avec les finaliseurs, mais cela nécessite une bonne quantité de code. Hans-J. Boehm a une excellente présentation qui montre ces problèmes et les solutions possibles.

Pour garantir visibilité, vous devez synchroniser votre code, c'est-à-dire mettre des opérations avec la sémantique Release dans vos méthodes régulières, et une opération avec la sémantique Acquire dans votre finaliseur. . Par exemple:

  • Un magasin dans une volatile à la fin de chaque méthode + lire la même volatile dans un finaliseur.
  • Libère le verrou sur l'objet à la fin de chaque méthode + acquiert le verrou au début d'un finaliseur (voir la section relative à la mise en oeuvre de keepAlive dans les diapositives de Boehm).

Pour garantir (accessibilité) (lorsque ce n'est pas déjà garanti par la spécification de langue), vous pouvez utiliser:

La différence entre finalize et PhantomReferences est que ce dernier vous donne plus de contrôle sur les divers aspects de la finalisation:

  • Plusieurs files d'attente peuvent recevoir des références fantômes et / choisir un thread effectuant la finalisation pour chacune d'entre elles.
  • Peut finaliser dans le même thread que l'allocation (par exemple, thread local ReferenceQueues).
  • Plus facile à appliquer lors du classement: conservez une référence forte à un objet B qui doit rester en vie lorsque A est finalisé sous la forme d'un champ de PhantomReference à A;
  • Il est plus facile de mettre en œuvre une terminaison sécurisée, car vous devez gardez PhantomRefereces fortement accessible jusqu'à ce qu'ils soient mis en file d'attente par GC.
4
Dmitry Timofeev

Comment cela peut-il être éventuellement des ordures collectées?

Parce que la fonction nativeMultiply(long xCppPtr, long yCppPtr) est statique. Si une fonction native est statique, son deuxième paramètre est jclass qui pointe vers sa classe au lieu de jobject qui pointe vers this. Donc, dans ce cas, this n'est pas l'un des arguments. 

S'il n'avait pas été statique, il n'y aurait qu'un problème avec l'objet other.

1
ferini
1
wanpen

Permettez-moi de présenter une proposition provocante. Si votre côté C++ d'un objet Java géré peut être alloué dans une mémoire contiguë, vous pouvez utiliser un pointeur long native traditionnel, mais utilisez un DirectByteBuffer . Cela peut vraiment changer la donne: maintenant, GC peut être assez intelligent pour utiliser ces petits wrappers Java autour d’énormes structures de données natives (par exemple, décidez de les collecter plus tôt).

Malheureusement, la plupart des objets C++ réels ne font pas partie de cette catégorie ...

0
Alex Cohn