¿Por qué este método imprime 4?


111

Me preguntaba qué sucede cuando intentas detectar un StackOverflowError y se te ocurrió el siguiente método:

class RandomNumberGenerator {

    static int cnt = 0;

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

Ahora mi pregunta:

¿Por qué este método imprime '4'?

Pensé que tal vez era porque System.out.println()necesita 3 segmentos en la pila de llamadas, pero no sé de dónde viene el número 3. Cuando observa el código fuente (y el código de bytes) de System.out.println(), normalmente daría lugar a muchas más invocaciones de métodos que 3 (por lo que 3 segmentos en la pila de llamadas no serían suficientes). Si es debido a las optimizaciones que aplica la máquina virtual de Hotspot (inserción de métodos), me pregunto si el resultado sería diferente en otra máquina virtual.

Editar :

Como la salida parece ser muy específica de JVM, obtengo el resultado 4 usando
Java (TM) SE Runtime Environment (compilación 1.6.0_41-b02)
Java HotSpot (TM) 64-Bit Server VM (compilación 20.14-b01, modo mixto)


Explicación por qué creo que esta pregunta es diferente de Comprender la pila de Java :

Mi pregunta no es por qué hay un cnt> 0 (obviamente porque System.out.println()requiere un tamaño de pila y arroja otro StackOverflowErrorantes de que se imprima algo), sino por qué tiene el valor particular de 4, respectivamente 0,3,8,55 o algo más en otro sistemas.


4
En mi local, obtengo "0" como se esperaba.
Reddy

2
Esto puede tener que ver con muchas cosas de arquitectura. Así que mejor publique su salida con la versión jdk Para mí, la salida es 0 en jdk 1.7
Lokesh

3
Tengo diez 5 , 6y 38con Java 1.7.0_10
Kon

8
@Elist no será el mismo resultado cuando esté haciendo trucos que involucren la arquitectura subyacente;)
m0skit0

3
@flrnb Es solo un estilo que uso para alinear los tirantes. Me facilita saber dónde comienzan y terminan las condiciones y funciones. Puede cambiarlo si lo desea, pero en mi opinión, es más legible de esta manera.
syb0rg

Respuestas:


41

Creo que los demás han hecho un buen trabajo al explicar por qué cnt> 0, pero no hay suficientes detalles sobre por qué cnt = 4 y por qué cnt varía tanto entre diferentes configuraciones. Intentaré llenar ese vacío aquí.

Dejar

  • X sea el tamaño total de la pila
  • M es el espacio de pila utilizado cuando ingresamos a main la primera vez
  • R sea el aumento del espacio de la pila cada vez que ingresamos a main
  • P sea el espacio de pila necesario para ejecutar System.out.println

Cuando entramos por primera vez en main, el espacio que queda es XM. Cada llamada recursiva ocupa R más memoria. Entonces, para 1 llamada recursiva (1 más que la original), el uso de memoria es M + R. Suponga que StackOverflowError se lanza después de C llamadas recursivas exitosas, es decir, M + C * R <= X y M + C * (R + 1)> X. En el momento del primer StackOverflowError, queda memoria X - M - C * R.

Para poder correr System.out.prinln , necesitamos P cantidad de espacio que queda en la pila. Si sucede que X - M - C * R> = P, se imprimirá 0. Si P requiere más espacio, entonces eliminamos marcos de la pila, ganando memoria R a costa de cnt ++.

Cuando println finalmente pueda ejecutarse, X - M - (C - cnt) * R> = P. Entonces, si P es grande para un sistema en particular, entonces cnt será grande.

Veamos esto con algunos ejemplos.

Ejemplo 1: suponga

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

Entonces C = piso ((XM) / R) = 49, y cnt = techo ((P - (X - M - C * R)) / R) = 0.

Ejemplo 2: suponga que

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

Entonces C = 19 y cnt = 2.

Ejemplo 3: suponga que

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

Entonces C = 20 y cnt = 3.

Ejemplo 4: suponga que

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

Entonces C = 19 y cnt = 2.

Por tanto, vemos que tanto el sistema (M, R y P) como el tamaño de la pila (X) afectan a cnt.

Como nota al margen, no importa cuánto espacio se catchrequiera para comenzar. Mientras no haya suficiente espacio para catch, cnt no aumentará, por lo que no habrá efectos externos.

EDITAR

Retiro lo que dije catch. Juega un papel. Suponga que requiere T cantidad de espacio para comenzar. cnt comienza a incrementarse cuando el espacio sobrante es mayor que T, yprintln ejecuta cuando el espacio sobrante es mayor que T + P. Esto agrega un paso adicional a los cálculos y enturbia aún más el análisis ya confuso.

EDITAR

Finalmente encontré tiempo para realizar algunos experimentos para respaldar mi teoría. Desafortunadamente, la teoría no parece coincidir con los experimentos. Lo que realmente sucede es muy diferente.

Configuración del experimento: servidor Ubuntu 12.04 con java predeterminado y jdk predeterminado. Xss a partir de 70.000 en incrementos de 1 byte hasta 460.000.

Los resultados están disponibles en: https://www.google.com/fusiontables/DataSource?docid=1xkJhd4s8biLghe6gZbcfUs3vT5MpS_OnscjWDbM He creado otra versión en la que se eliminan todos los puntos de datos repetidos. En otras palabras, solo se muestran los puntos que son diferentes a los anteriores. Esto facilita la visualización de anomalías. https://www.google.com/fusiontables/DataSource?docid=1XG_SRzrrNasepwZoNHqEAKuZlHiAm9vbEdwfsUA


Gracias por el buen resumen, supongo que todo se reduce a la pregunta: ¿qué afecta a M, R y P (ya que X se puede configurar con la opción VM -Xss)?
flrnb

@flrnb M, R y P son específicos del sistema. No puedes cambiarlos fácilmente. Espero que también difieran entre algunos lanzamientos.
John Tseng

Entonces, ¿por qué obtengo resultados diferentes al modificar Xss (también conocido como X)? Cambiar X de 100 a 10000, dado que M, R y P permanecen iguales, ¿no debería afectar a cnt según su fórmula, o me equivoco?
flrnb

@flrnb X solo cambia cnt debido a la naturaleza discreta de estas variables. Los ejemplos 2 y 3 solo difieren en X, pero cnt es diferente.
John Tseng

1
@JohnTseng También considero que su respuesta es la más comprensible y completa por ahora; de todos modos, estaría realmente interesado en cómo se ve realmente la pila en el momento en que StackOverflowErrorse lanza y cómo esto afecta la salida. Si solo contiene una referencia a un marco de pila en el montón (como sugirió Jay), entonces la salida debería ser bastante predecible para un sistema dado.
flrnb

20

Esta es la víctima de una mala llamada recursiva. Como se pregunta por qué varía el valor de cnt , es porque el tamaño de la pila depende de la plataforma. Java SE 6 en Windows tiene un tamaño de pila predeterminado de 320k en la máquina virtual de 32 bits y 1024k en la máquina virtual de 64 bits. Puedes leer más aquí .

Puede ejecutar usando diferentes tamaños de pila y verá diferentes valores de cnt antes de que la pila se desborde.

java -Xss1024k RandomNumberGenerator

No ve que el valor de cnt se imprima varias veces, aunque el valor es mayor que 1 a veces porque su declaración de impresión también arroja un error que puede depurar para estar seguro a través de Eclipse u otros IDE.

Puede cambiar el código a lo siguiente para depurar por ejecución de declaración si lo prefiere:

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) {   

        }        
    }        
}

ACTUALIZAR:

Como esto recibe mucha más atención, veamos otro ejemplo para aclarar las cosas.

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);

}

Creamos otro método llamado overflow para hacer una recursión incorrecta y eliminamos la declaración println del bloque catch para que no comience a arrojar otro conjunto de errores al intentar imprimir. Esto funciona como se esperaba. Puede intentar poner System.out.println (cnt); declaración después de cnt ++ anterior y compilar. Luego, ejecute varias veces. Dependiendo de su plataforma, puede obtener diferentes valores de cnt .

Es por eso que generalmente no detectamos errores porque el misterio en el código no es una fantasía.


13

El comportamiento depende del tamaño de la pila (que se puede configurar manualmente usando Xss. El tamaño de la pila es específico de la arquitectura. Del código fuente de JDK 7 :

// El tamaño de pila predeterminado en Windows lo determina el ejecutable (java.exe
// tiene un valor predeterminado de 320K / 1MB [32bit / 64bit]). Según la versión de Windows, cambiar
// ThreadStackSize a un valor distinto de cero puede tener un impacto significativo en el uso de la memoria.
// Ver comentarios en os_windows.cpp.

Entonces, cuando StackOverflowErrorse lanza, el error se detecta en el bloque catch. Aquí println()hay otra llamada de pila que arroja una excepción nuevamente. Esto se repite.

¿Cuántas veces se repite? - Bueno, depende de cuándo JVM crea que ya no es stackoverflow. Y eso depende del tamaño de la pila de cada llamada de función (difícil de encontrar) y del Xss. Como se mencionó anteriormente, el tamaño total predeterminado y el tamaño de cada llamada de función (depende del tamaño de la página de memoria, etc.) es específico de la plataforma. De ahí un comportamiento diferente.

Llamar a la javallamada con -Xss 4Mme da 41. De ahí la correlación.


4
No entiendo por qué el tamaño de la pila debería afectar el resultado, ya que ya se superó cuando intentamos imprimir el valor de cnt. Por lo tanto, la única diferencia podría provenir del "tamaño de pila de cada llamada de función". Y no entiendo por qué esto debería variar entre 2 máquinas que ejecutan la misma versión de JVM.
flrnb

El comportamiento exacto solo se puede obtener de la fuente JVM. Pero la razón podría ser esta. Recuerde que incluso catches un bloque y eso ocupa memoria en la pila. No se puede saber cuánta memoria toma cada llamada de método. Cuando la pila se borra, está agregando un bloque más de catchy así. Este podría ser el comportamiento. Esto es solo especulación.
Jatin

Y el tamaño de la pila puede diferir en dos máquinas diferentes. El tamaño de la pila depende de muchos factores basados ​​en el sistema operativo, a saber, el tamaño de la página de memoria
etc.Jatin

6

Creo que el número que se muestra es el número de veces que la System.out.printlnllamada lanza la Stackoverflowexcepción.

Probablemente dependa de la implementación del printlny del número de llamadas de apilamiento que se realice en él.

Como una ilustracion:

La main()llamada activa la Stackoverflowexcepción en la llamada i. La llamada i-1 de main captura la excepción y llama printlnque activa un segundo Stackoverflow. cntobtener incremento a 1. La llamada i-2 de main captura ahora la excepción y la llamada println. En printlnun método se llama desencadenar una tercera excepción. cntobtener el incremento a 2. esto continúa hasta que printlnpueda realizar todas las llamadas necesarias y finalmente mostrar el valor de cnt.

Esto depende entonces de la implementación real de println.

Para el JDK7, detecta la llamada cíclica y lanza la excepción antes, o mantiene algún recurso de pila y lanza la excepción antes de alcanzar el límite para dar espacio para la lógica de corrección o la printlnimplementación no realiza llamadas o bien la operación ++ se realiza después la printlnllamada, por tanto, pasa por alto la excepción.


Eso es lo que quise decir con "Pensé que tal vez era porque System.out.println necesita 3 segmentos en la pila de llamadas", pero me desconcertó por qué es exactamente este número y ahora estoy aún más desconcertado por qué el número difiere tanto entre diferentes Máquinas (virtuales)
flrnb

Estoy de acuerdo parcialmente, pero en lo que no estoy de acuerdo es en la declaración "dependiente de la implementación real de println". Tiene que ver con el tamaño de la pila en cada jvm más que con la implementación.
Jatin

6
  1. mainse repite sobre sí mismo hasta que desborda la pila en profundidad de recursividad R.
  2. Se R-1ejecuta el bloque de captura a la profundidad de recursividad .
  3. Se R-1evalúa el bloque de captura en la profundidad de recursión cnt++.
  4. El bloque de captura en las R-1llamadas de profundidad println, colocando cntel valor anterior en la pila. printlnllamará internamente a otros métodos y utilizará variables y cosas locales. Todos estos procesos requieren espacio de pila.
  5. Debido a que la pila ya estaba sobrepasando el límite, y la llamada / ejecución printlnrequiere espacio en la pila, se activa un nuevo desbordamiento de pila en profundidad en R-1lugar de profundidad R.
  6. Los pasos 2 a 5 se repiten, pero con profundidad de recursividad R-2.
  7. Los pasos 2 a 5 se repiten, pero con profundidad de recursividad R-3.
  8. Los pasos 2 a 5 se repiten, pero con profundidad de recursividad R-4.
  9. Los pasos 2 a 4 se repiten, pero con profundidad de recursividad R-5.
  10. Sucede que ahora hay suficiente espacio en la pila para printlncompletar (tenga en cuenta que este es un detalle de implementación, puede variar).
  11. cntfue post-incrementado a profundidades R-1, R-2, R-3, R-4, y finalmente a R-5. El quinto incremento posterior devolvió cuatro, que es lo que se imprimió.
  12. Una vez maincompletado con éxito en profundidad R-5, toda la pila se desenrolla sin que se ejecuten más bloques de captura y el programa se completa.

1

Después de investigar un poco, no puedo decir que encontré la respuesta, pero creo que ahora está bastante cerca.

Primero, necesitamos saber cuándo se StackOverflowErrorlanzará un will. De hecho, la pila de un hilo de Java almacena marcos, que contienen todos los datos necesarios para invocar un método y reanudar. Según las Especificaciones del lenguaje Java para JAVA 6 , al invocar un método,

Si no hay suficiente memoria disponible para crear dicho marco de activación, se lanza un StackOverflowError.

En segundo lugar, debemos dejar en claro qué es " no hay suficiente memoria disponible para crear un marco de activación de este tipo ". Según las especificaciones de la máquina virtual Java para JAVA 6 ,

las tramas se pueden asignar en montón.

Por lo tanto, cuando se crea un marco, debe haber suficiente espacio de pila para crear un marco de pila y suficiente espacio de pila para almacenar la nueva referencia que apunta al nuevo marco de pila si el marco está asignado a pila.

Ahora volvamos a la pregunta. Por lo anterior, podemos saber que cuando se ejecuta un método, puede costar la misma cantidad de espacio de pila. Y la invocación System.out.println(may) necesita 5 niveles de invocación de método, por lo que es necesario crear 5 marcos. Luego, cuando StackOverflowErrorse descarta, tiene que retroceder 5 veces para obtener suficiente espacio de pila para almacenar referencias de 5 marcos. Por lo tanto, se imprime 4. ¿Por qué no 5? Porque usas cnt++. Cámbielo a ++cnty obtendrá 5.

Y notará que cuando el tamaño de la pila llega a un nivel alto, obtendrá 50 a veces. Esto se debe a que en ese momento debe tenerse en cuenta la cantidad de espacio de pila disponible. Cuando el tamaño de la pila es demasiado grande, es posible que el espacio de pila se agote antes que la pila. Y (tal vez) el tamaño real de los marcos de pila de System.out.printlnes aproximadamente 51 veces main, por lo que retrocede 51 veces e imprime 50.


Mi primer pensamiento también fue contar los niveles de invocaciones de métodos (y tiene razón, no presté atención al hecho de que publico increment cnt), pero si la solución era tan simple, ¿por qué los resultados variarían tanto entre plataformas? e implementaciones de VM?
flrnb

@flrnb Esto se debe a que diferentes plataformas pueden afectar el tamaño del marco de la pila y diferentes versiones de jre afectarán la implementación System.out.printo la estrategia de ejecución del método. Como se describió anteriormente, la implementación de la VM también afecta dónde se almacenará realmente el marco de la pila.
Jay

0

Esta no es exactamente una respuesta a la pregunta, pero solo quería agregar algo a la pregunta original que encontré y cómo entendí el problema:

En el problema original, la excepción se detecta donde era posible:

Por ejemplo, con jdk 1.7 se detecta en el primer lugar de ocurrencia.

pero en versiones anteriores de jdk parece que la excepción no se detecta en el primer lugar de ocurrencia, por lo tanto, 4, 50, etc.

Ahora, si elimina el bloque try catch de la siguiente manera

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

Luego verá todos los valores de cntlas excepciones lanzadas (en jdk 1.7).

Usé netbeans para ver la salida, ya que el cmd no mostrará toda la salida y la excepción lanzada.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.