Pourquoi cette méthode imprime-t-elle 4?

111

Je me demandais ce qui se passe lorsque vous essayez d'attraper une StackOverflowError et que vous avez proposé la méthode suivante:

class RandomNumberGenerator {

    static int cnt = 0;

    public static void main(String[] args) {
        try {
            main(args);
        } catch (StackOverflowError ignore) {
            System.out.println(cnt++);
        }
    }
}

Maintenant ma question:

Pourquoi cette méthode imprime-t-elle «4»?

Je pensais que c'était peut-être parce qu'il System.out.println()fallait 3 segments sur la pile d'appels, mais je ne sais pas d'où vient le numéro 3. Lorsque vous regardez le code source (et le bytecode) de System.out.println(), cela conduirait normalement à beaucoup plus d'appels de méthode que 3 (donc 3 segments sur la pile d'appels ne seraient pas suffisants). Si c'est à cause des optimisations que la VM Hotspot applique (méthode inlining), je me demande si le résultat serait différent sur une autre VM.

Modifier :

Comme la sortie semble être très spécifique à la JVM, j'obtiens le résultat 4 en utilisant
Java (TM) SE Runtime Environment (build 1.6.0_41-b02)
Java HotSpot (TM) 64-Bit Server VM (build 20.14-b01, mode mixte)


Explication pourquoi je pense que cette question est différente de Comprendre la pile Java :

Ma question n'est pas de savoir pourquoi il y a un cnt> 0 (évidemment parce qu'il System.out.println()nécessite une taille de pile et en jette une autre StackOverflowErroravant que quelque chose ne soit imprimé), mais pourquoi il a la valeur particulière de 4, respectivement 0,3,8,55 ou autre chose systèmes.

flrnb
la source
4
Dans ma section locale, j'obtiens "0" comme prévu.
Reddy
2
Cela peut traiter beaucoup de choses d'architecture. Donc, mieux vaut poster votre sortie avec la version jdk Pour moi, la sortie est 0 sur jdk 1.7
Lokesh
3
J'ai obtenu 5, 6et 38avec Java 1.7.0_10
Kon
8
@Elist Ne sera pas le même résultat lorsque vous faites des astuces impliquant une architecture sous-jacente;)
m0skit0
3
@flrnb C'est juste un style que j'utilise pour aligner les accolades. Cela me permet de savoir plus facilement où les conditions et les fonctions commencent et se terminent. Vous pouvez le changer si vous le souhaitez, mais à mon avis, il est plus lisible de cette façon.
syb0rg

Réponses:

41

Je pense que les autres ont fait un bon travail pour expliquer pourquoi cnt> 0, mais il n'y a pas assez de détails sur pourquoi cnt = 4, et pourquoi cnt varie si largement entre les différents paramètres. Je vais essayer de combler ce vide ici.

Laisser

  • X est la taille totale de la pile
  • M être l'espace de pile utilisé lorsque nous entrons dans main la première fois
  • R soit l'augmentation de l'espace de pile chaque fois que nous entrons
  • P être l'espace de pile nécessaire pour exécuter System.out.println

Lorsque nous entrons pour la première fois dans main, l'espace restant est XM. Chaque appel récursif occupe R plus de mémoire. Donc, pour 1 appel récursif (1 de plus que l'original), l'utilisation de la mémoire est M + R. Supposons que StackOverflowError soit levé après C appels récursifs réussis, c'est-à-dire M + C * R <= X et M + C * (R + 1)> X. Au moment du premier StackOverflowError, il reste de la mémoire X - M - C * R.

Pour pouvoir fonctionner System.out.prinln, nous avons besoin de P d'espace libre sur la pile. S'il arrive que X - M - C * R> = P, alors 0 sera imprimé. Si P nécessite plus d'espace, alors nous supprimons les cadres de la pile, gagnant de la mémoire R au prix de cnt ++.

Quand printlnest enfin capable de fonctionner, X - M - (C - cnt) * R> = P. Donc si P est grand pour un système particulier, alors cnt sera grand.

Regardons cela avec quelques exemples.

Exemple 1: Supposons

  • X = 100
  • M = 1
  • R = 2
  • P = 1

Alors C = sol ((XM) / R) = 49, et cnt = plafond ((P - (X - M - C * R)) / R) = 0.

Exemple 2: supposons que

  • X = 100
  • M = 1
  • R = 5
  • P = 12

Alors C = 19 et cnt = 2.

Exemple 3: Supposons que

  • X = 101
  • M = 1
  • R = 5
  • P = 12

Alors C = 20 et cnt = 3.

Exemple 4: Supposons que

  • X = 101
  • M = 2
  • R = 5
  • P = 12

Alors C = 19 et cnt = 2.

Ainsi, nous voyons que le système (M, R et P) et la taille de la pile (X) affectent cnt.

En remarque, peu importe l'espace catchrequis pour démarrer. Tant qu'il n'y a pas assez de place pour catch, alors cnt n'augmentera pas, donc il n'y a pas d'effets externes.

ÉDITER

Je reprends ce que j'ai dit catch. Cela joue un rôle. Supposons qu'il nécessite une quantité d'espace T pour démarrer. cnt commence à s'incrémenter lorsque l'espace restant est supérieur à T, et printlns'exécute lorsque l'espace restant est supérieur à T + P. Cela ajoute une étape supplémentaire aux calculs et brouille encore l'analyse déjà trouble.

ÉDITER

J'ai finalement trouvé le temps de mener des expériences pour étayer ma théorie. Malheureusement, la théorie ne semble pas correspondre aux expériences. Ce qui se passe réellement est très différent.

Configuration de l'expérience: serveur Ubuntu 12.04 avec java par défaut et default-jdk. Xss commençant à 70 000 par incréments de 1 octet jusqu'à 460 000.

Les résultats sont disponibles à l' adresse : https://www.google.com/fusiontables/DataSource?docid=1xkJhd4s8biLghe6gZbcfUs3vT5MpS_OnscjWDbM J'ai créé une autre version dans laquelle chaque point de données répété est supprimé. En d'autres termes, seuls les points différents des précédents sont affichés. Cela facilite la détection des anomalies. https://www.google.com/fusiontables/DataSource?docid=1XG_SRzrrNasepwZoNHqEAKuZlHiAm9vbEdwfsUA

John Tseng
la source
Merci pour le bon résumé, je suppose que tout se résume à la question: quels impacts M, R et P (puisque X peut être défini par l'option VM -Xss)?
flrnb
@flrnb M, R et P sont spécifiques au système. Vous ne pouvez pas facilement les changer. Je m'attends à ce qu'ils diffèrent également entre certaines versions.
John Tseng
Alors, pourquoi est-ce que j'obtiens des résultats différents en modifiant Xss (aka X)? Changer X de 100 à 10000, étant donné que M, R et P restent les mêmes, ne devrait pas affecter cnt selon votre formule, ou est-ce que je me trompe?
flrnb
@flrnb X seul change cnt en raison de la nature discrète de ces variables. Les exemples 2 et 3 ne diffèrent que sur X, mais cnt est différent.
John Tseng
1
@JohnTseng Je considère également votre réponse comme la plus compréhensible et complète à ce jour - de toute façon, je serais vraiment intéressé par ce à quoi ressemble réellement la pile au moment où elle StackOverflowErrorest lancée et comment cela affecte la sortie. S'il ne contenait qu'une référence à un cadre de pile sur le tas (comme Jay l'a suggéré), alors la sortie devrait être tout à fait prévisible pour un système donné.
flrnb
20

C'est la victime d'un mauvais appel récursif. Comme vous vous demandez pourquoi la valeur de cnt varie, c'est parce que la taille de la pile dépend de la plate-forme. Java SE 6 sur Windows a une taille de pile par défaut de 320 Ko dans la machine virtuelle 32 bits et de 1024 Ko dans la machine virtuelle 64 bits. Vous pouvez en savoir plus ici .

Vous pouvez exécuter en utilisant différentes tailles de pile et vous verrez différentes valeurs de cnt avant que la pile ne déborde.

java -Xss1024k RandomNumberGenerator

Vous ne voyez pas la valeur de cnt imprimée plusieurs fois même si la valeur est parfois supérieure à 1 car votre instruction d'impression génère également une erreur que vous pouvez déboguer pour être sûr via Eclipse ou d'autres IDE.

Vous pouvez changer le code comme suit pour déboguer par exécution d'instruction si vous préférez:

static int cnt = 0;

public static void main(String[] args) {                  

    try {     

        main(args);   

    } catch (Throwable ignore) {

        cnt++;

        try { 

            System.out.println(cnt);

        } catch (Throwable t) {   

        }        
    }        
}

METTRE À JOUR:

Au fur et à mesure que cela suscite beaucoup plus d'attention, prenons un autre exemple pour clarifier les choses.

static int cnt = 0;

public static void overflow(){

    try {     

      overflow();     

    } catch (Throwable t) {

      cnt++;                      

    }

}

public static void main(String[] args) {

    overflow();
    System.out.println(cnt);

}

Nous avons créé une autre méthode nommée overflow pour effectuer une mauvaise récursivité et supprimé l' instruction println du bloc catch afin qu'elle ne commence pas à lancer un autre ensemble d'erreurs lors de la tentative d'impression. Cela fonctionne comme prévu. Vous pouvez essayer de mettre System.out.println (cnt); instruction après cnt ++ ci-dessus et compilez. Puis exécutez plusieurs fois. En fonction de votre plate-forme, vous pouvez obtenir différentes valeurs de cnt .

C'est pourquoi généralement nous ne détectons pas les erreurs car le mystère dans le code n'est pas de la fantaisie.

Sajal Dutta
la source
13

Le comportement dépend de la taille de la pile (qui peut être définie manuellement à l'aide de Xss. La taille de la pile est spécifique à l'architecture. À partir du code source JDK 7 :

// La taille de la pile par défaut sous Windows est déterminée par l'exécutable (java.exe
// a une valeur par défaut de 320 Ko / 1 Mo [32 bits / 64 bits]). Selon la version de Windows, la modification de
// ThreadStackSize sur une valeur non nulle peut avoir un impact significatif sur l'utilisation de la mémoire.
// Voir les commentaires dans os_windows.cpp.

Ainsi, lorsque le StackOverflowErrorest lancé, l'erreur est interceptée dans le bloc catch. Voici println()un autre appel de pile qui lève à nouveau une exception. Cela se répète.

Combien de fois ça se répète? - Eh bien, cela dépend du moment où JVM pense qu'il ne s'agit plus d'un stackoverflow. Et cela dépend de la taille de la pile de chaque appel de fonction (difficile à trouver) et du fichier Xss. Comme mentionné ci-dessus, la taille totale et la taille par défaut de chaque appel de fonction (dépend de la taille de la page mémoire, etc.) sont spécifiques à la plate-forme. D'où un comportement différent.

Appeler l' javaappel avec -Xss 4Mme donne 41. D'où la corrélation.

Jatin
la source
4
Je ne comprends pas pourquoi la taille de la pile devrait avoir un impact sur le résultat, car elle est déjà dépassée lorsque nous essayons d'imprimer la valeur de cnt. Ainsi, la seule différence pourrait provenir de la "taille de pile de chaque appel de fonction". Et je ne comprends pas pourquoi cela devrait varier entre 2 machines exécutant la même version JVM.
flrnb
Le comportement exact ne peut être obtenu qu'à partir de la source JVM. Mais la raison pourrait être la suivante. Rappelez-vous que même catchest un bloc et qui occupe la mémoire sur la pile. La quantité de mémoire que prend chaque appel de méthode ne peut pas être connue. Lorsque la pile est effacée, vous ajoutez un autre bloc de catchet ainsi de suite. Cela pourrait être le comportement. Ce n'est que spéculation.
Jatin
Et la taille de la pile peut différer dans deux machines différentes. La taille de la pile dépend de nombreux facteurs basés sur le système d'exploitation, à savoir la taille de la page de mémoire, etc.
Jatin
6

Je pense que le nombre affiché est le nombre de fois où l' System.out.printlnappel lève l' Stackoverflowexception.

Cela dépend probablement de l'implémentation du printlnet du nombre d'appels d'empilement qu'il est effectué.

Pour illustrer:

L' main()appel déclenche l' Stackoverflowexception à l'appel i. L'appel i-1 de main capture l'exception et l'appel printlnqui en déclenche une seconde Stackoverflow. cntobtenir un incrément de 1. L'appel i-2 de main catch maintenant l'exception et l'appel println. Dans printlnune méthode, on appelle déclencher une 3e exception. cntobtenir un incrément de 2. cela continue jusqu'à ce que vous printlnpuissiez effectuer tous les appels nécessaires et finalement afficher la valeur de cnt.

Cela dépend alors de la mise en œuvre réelle de println.

Pour le JDK7, soit il détecte l'appel cyclique et lève l'exception plus tôt, soit il conserve une ressource de pile et lance l'exception avant d'atteindre la limite pour donner de la place à la logique de correction soit l' printlnimplémentation ne fait pas d'appels soit l'opération ++ est effectuée après l' printlnappel est donc contourné par l'exception.

Kazaag
la source
C'est ce que je voulais dire par "Je pensais que c'était peut-être parce que System.out.println avait besoin de 3 segments sur la pile d'appels" - mais je me demandais pourquoi c'est exactement ce numéro et maintenant je suis encore plus perplexe pourquoi le nombre diffère si largement entre les différents (virtuelles) machines
flrnb
Je suis partiellement d'accord, mais ce que je ne suis pas d'accord, c'est sur la déclaration «dépendante de l'implémentation réelle de println». Cela concerne la taille de la pile dans chaque jvm plutôt que l'implémentation.
Jatin
6
  1. main se répète sur lui-même jusqu'à ce qu'il déborde de la pile à la profondeur de récursivité R .
  2. Le bloc catch à la profondeur de récursivité R-1 est exécuté.
  3. Le bloc catch à la profondeur de récursivité R-1évaluecnt++ .
  4. Le bloc catch en profondeur R-1appelle println, plaçant cntl'ancienne valeur de la pile. printlnappellera en interne d'autres méthodes et utilisera des variables locales et des choses. Tous ces processus nécessitent un espace de pile.
  5. Étant donné que la pile dépassait déjà la limite et que l'appel / l'exécution printlnnécessite de l'espace dans la pile, un nouveau débordement de pile est déclenché en profondeur R-1au lieu de profondeur R.
  6. Les étapes 2 à 5 se reproduisent, mais à une profondeur de récursivité R-2.
  7. Les étapes 2 à 5 se reproduisent, mais à une profondeur de récursivité R-3.
  8. Les étapes 2 à 5 se reproduisent, mais à la profondeur de récursivité R-4.
  9. Les étapes 2 à 4 se reproduisent, mais à la profondeur de récursivité R-5 .
  10. Il se trouve qu'il y a maintenant assez d'espace de pile pour println terminer (notez qu'il s'agit d'un détail d'implémentation, cela peut varier).
  11. cnta été post-incrémenté à des profondeurs R-1, R-2, R-3, R-4et enfin àR-5 . Le cinquième post-incrément a renvoyé quatre, ce qui a été imprimé.
  12. Une fois mainterminé avec succès en profondeur R-5, la pile entière se déroule sans que d'autres blocs catch soient exécutés et le programme se termine.
Craig Gidney
la source
1

Après avoir fouillé pendant un moment, je ne peux pas dire que je trouve la réponse, mais je pense que c'est assez proche maintenant.

Premièrement, nous devons savoir quand un StackOverflowErrortestament sera lancé. En fait, la pile d'un thread java stocke des frames, qui contiennent toutes les données nécessaires pour appeler une méthode et reprendre. Selon les spécifications du langage Java pour JAVA 6 , lors de l'appel d'une méthode,

S'il n'y a pas suffisamment de mémoire disponible pour créer une telle trame d'activation, une StackOverflowError est levée.

Deuxièmement, nous devons préciser ce que signifie " il n'y a pas suffisamment de mémoire disponible pour créer une telle trame d'activation ". Selon les spécifications de la machine virtuelle Java pour JAVA 6 ,

les cadres peuvent être alloués en tas.

Ainsi, quand un cadre est créé, il doit y avoir suffisamment d'espace de tas pour créer un cadre de pile et suffisamment d'espace de pile pour stocker la nouvelle référence qui pointe vers le nouveau cadre de pile si le cadre est alloué au tas.

Revenons maintenant à la question. D'après ce qui précède, nous pouvons savoir que lorsqu'une méthode est exécutée, elle peut simplement coûter la même quantité d'espace de pile. Et l'invocation System.out.println(peut) nécessite 5 niveaux d'invocation de méthode, donc 5 cadres doivent être créés. Ensuite, lorsqu'il StackOverflowErrorest jeté, il doit revenir 5 fois en arrière pour obtenir suffisamment d'espace de pile pour stocker les références de 5 cadres. Par conséquent, 4 est une impression. Pourquoi pas 5? Parce que vous utilisez cnt++. Changez-le en ++cnt, et vous obtiendrez 5.

Et vous remarquerez que lorsque la taille de la pile atteint un niveau élevé, vous en obtiendrez parfois 50. En effet, la quantité d'espace de tas disponible doit alors être prise en compte. Lorsque la taille de la pile est trop grande, il se peut que l'espace du tas soit épuisé avant la pile. Et (peut-être) la taille réelle des cadres de pile System.out.printlnest d'environ 51 fois main, donc elle revient 51 fois et imprime 50.

Geai
la source
Ma première pensée a également été de compter les niveaux d'appels de méthodes (et vous avez raison, je n'ai pas fait attention au fait que je poste l'incrément cnt), mais si la solution était aussi simple, pourquoi les résultats varieraient-ils autant d'une plate-forme à l'autre? et implémentations VM?
flrnb
@flrnb C'est parce que différentes plates-formes peuvent affecter la taille du frame de pile et différentes versions de jre affecteront l'implémentation System.out.printou la stratégie d'exécution de la méthode. Comme décrit ci-dessus, l'implémentation de la VM affecte également l'endroit où la trame de pile sera réellement stockée.
Jay
0

Ce n'est pas exactement une réponse à la question, mais je voulais juste ajouter quelque chose à la question originale que j'ai rencontrée et comment j'ai compris le problème:

Dans le problème d'origine, l'exception est interceptée là où c'était possible:

Par exemple, avec jdk 1.7, il est capturé au premier lieu d'occurrence.

mais dans les versions antérieures de jdk, il semble que l'exception ne soit pas interceptée au premier lieu d'occurrence donc 4, 50 etc.

Maintenant, si vous supprimez le bloc try catch comme suit

public static void main( String[] args ){
    System.out.println(cnt++);
    main(args);
}

Ensuite, vous verrez toutes les valeurs de cntant les exceptions levées (sur jdk 1.7).

J'ai utilisé netbeans pour voir la sortie, car la cmd n'affichera pas toute la sortie et l'exception levée.

me_digvijay
la source