Por que este método imprime 4?

111

Eu queria saber o que acontece quando você tenta capturar um StackOverflowError e veio com o seguinte método:

class RandomNumberGenerator {

    static int cnt = 0;

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

Agora minha pergunta:

Por que este método imprime '4'?

Pensei que talvez fosse porque System.out.println()precisa de 3 segmentos na pilha de chamadas, mas não sei de onde vem o número 3. Quando você olha o código-fonte (e bytecode) de System.out.println(), normalmente levaria a muito mais invocações de método do que 3 (portanto, 3 segmentos na pilha de chamadas não seriam suficientes). Se for por causa das otimizações que o Hotspot VM aplica (método inlining), eu me pergunto se o resultado seria diferente em outra VM.

Editar :

Como a saída parece ser altamente específica da JVM, obtenho o resultado 4 usando
Java (TM) SE Runtime Environment (versão 1.6.0_41-b02)
Java HotSpot (TM) Servidor VM de 64 bits (versão 20.14-b01, modo misto)


Explicação por que acho que essa pergunta é diferente de Noções básicas sobre a pilha Java :

Minha pergunta não é sobre por que há um cnt> 0 (obviamente porque System.out.println()requer tamanho de pilha e joga outro StackOverflowErrorantes de algo ser impresso), mas por que ele tem o valor particular de 4, respectivamente 0,3,8,55 ou outra coisa em outro sistemas.

flrnb
fonte
4
No meu local, estou obtendo "0" conforme o esperado.
Reddy
2
Isso pode lidar com muitas coisas de arquitetura. Portanto, é melhor postar sua saída com a versão jdk. Para mim, a saída é 0 em jdk 1.7
Lokesh
3
Eu obtive 5, 6e 38com Java 1.7.0_10
Kon
8
@Elist não terá a mesma saída quando você estiver fazendo truques envolvendo arquitetura subjacente;)
m0skit0
3
@flrnb É apenas um estilo que uso para alinhar os colchetes. Fica mais fácil saber onde as condições e funções começam e terminam. Você pode alterá-lo se quiser, mas na minha opinião é mais legível assim.
syb0rg

Respostas:

41

Acho que os outros fizeram um bom trabalho explicando por que cnt> 0, mas não há detalhes suficientes sobre por que cnt = 4 e por que cnt varia tão amplamente entre as diferentes configurações. Vou tentar preencher esse vazio aqui.

Deixei

  • X é o tamanho total da pilha
  • M é o espaço de pilha usado quando entramos em main pela primeira vez
  • R ser o espaço da pilha aumenta cada vez que entramos no principal
  • P seja o espaço de pilha necessário para executar System.out.println

Quando entramos pela primeira vez em main, o espaço restante é XM. Cada chamada recursiva ocupa R mais memória. Portanto, para 1 chamada recursiva (1 a mais que o original), o uso de memória é M + R. Suponha que StackOverflowError seja lançado após C chamadas recursivas bem-sucedidas, ou seja, M + C * R <= X e M + C * (R + 1)> X. No momento do primeiro StackOverflowError, há X - M - C * R de memória restante.

Para poder funcionar System.out.prinln, precisamos de P espaço restante na pilha. Se acontecer de X - M - C * R> = P, então 0 será impresso. Se P requer mais espaço, então removemos os quadros da pilha, ganhando memória R ao custo de cnt ++.

Quando printlnfinalmente puder ser executado, X - M - (C - cnt) * R> = P. Portanto, se P for grande para um sistema particular, então cnt será grande.

Vejamos isso com alguns exemplos.

Exemplo 1: Suponha

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

Então C = piso ((XM) / R) = 49, e cnt = teto ((P - (X - M - C * R)) / R) = 0.

Exemplo 2: suponha que

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

Então, C = 19 e cnt = 2.

Exemplo 3: suponha que

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

Então C = 20 e cnt = 3.

Exemplo 4: suponha que

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

Então, C = 19 e cnt = 2.

Assim, vemos que tanto o sistema (M, R e P) e o tamanho da pilha (X) afetam o cnt.

Como observação lateral, não importa quanto espaço seja catchnecessário para começar. Enquanto não houver espaço suficiente para catch, o cnt não aumentará, portanto, não há efeitos externos.

EDITAR

Retiro o que disse sobre catch. Ele desempenha um papel. Suponha que seja necessária uma quantidade T de espaço para começar. cnt começa a aumentar quando o espaço restante é maior do que T e printlné executado quando o espaço restante é maior do que T + P. Isso adiciona uma etapa extra aos cálculos e confunde ainda mais a análise já confusa.

EDITAR

Finalmente encontrei tempo para fazer alguns experimentos para apoiar minha teoria. Infelizmente, a teoria não parece corresponder aos experimentos. O que realmente acontece é muito diferente.

Configuração da experiência: servidor Ubuntu 12.04 com java e jdk padrão. Xss começando em 70.000 em incrementos de 1 byte até 460.000.

Os resultados estão disponíveis em: https://www.google.com/fusiontables/DataSource?docid=1xkJhd4s8biLghe6gZbcfUs3vT5MpS_OnscjWDbM Criei outra versão em que todos os pontos de dados repetidos são removidos. Em outras palavras, apenas os pontos diferentes dos anteriores são mostrados. Isso torna mais fácil ver as anomalias. https://www.google.com/fusiontables/DataSource?docid=1XG_SRzrrNasepwZoNHqEAKuZlHiAm9vbEdwfsUA

John Tseng
fonte
Obrigado pelo bom resumo, acho que tudo se resume à pergunta: o que impacta M, R e P (já que X pode ser definido pela opção VM -Xss)?
flrnb
@flrnb M, R e P são específicos do sistema. Você não pode mudar isso facilmente. Eu espero que eles sejam diferentes entre alguns lançamentos também.
John Tseng
Então, por que obtenho resultados diferentes alterando Xss (também conhecido como X)? Mudar X de 100 para 10000, visto que M, R e P permanecem iguais, não deve afetar o cnt de acordo com sua fórmula, ou estou enganado?
flrnb
@flrnb X sozinho muda o cnt devido à natureza discreta dessas variáveis. Os exemplos 2 e 3 diferem apenas em X, mas cnt é diferente.
John Tseng
1
@JohnTseng Também considero sua resposta a mais compreensível e completa até agora - de qualquer maneira, eu estaria realmente interessado em como a pilha realmente se parece no momento em que StackOverflowErroré lançada e como isso afeta a saída. Se contiver apenas uma referência a um quadro de pilha no heap (como Jay sugeriu), a saída deve ser bastante previsível para um determinado sistema.
flrnb
20

Esta é a vítima de uma chamada recursiva incorreta. Como você está se perguntando por que o valor de cnt varia, é porque o tamanho da pilha depende da plataforma. Java SE 6 no Windows tem um tamanho de pilha padrão de 320k na VM de 32 bits e 1024k na VM de 64 bits. Você pode ler mais aqui .

Você pode executar usando tamanhos de pilha diferentes e você verá valores diferentes de cnt antes de a pilha estourar-

java -Xss1024k RandomNumberGenerator

Você não vê o valor de cnt sendo impresso várias vezes, embora o valor seja maior que 1 às vezes, porque sua instrução de impressão também está gerando um erro que você pode depurar com certeza por meio do Eclipse ou outros IDEs.

Você pode alterar o código a seguir para depurar por execução de instrução, se preferir

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

        }        
    }        
}

ATUALIZAR:

Como está recebendo muito mais atenção, vamos ter outro exemplo para tornar as coisas mais claras-

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

}

Criamos outro método chamado overflow para fazer uma recursão incorreta e removemos a instrução println do bloco catch para que não comece a lançar outro conjunto de erros ao tentar imprimir. Isso funciona conforme o esperado. Você pode tentar colocar System.out.println (cnt); declaração após cnt ++ acima e compilar. Em seguida, execute várias vezes. Dependendo da sua plataforma, você pode obter valores diferentes de cnt .

É por isso que geralmente não detectamos erros, porque mistério no código não é fantasia.

Sajal Dutta
fonte
13

O comportamento depende do tamanho da pilha (que pode ser definido manualmente usando Xss. O tamanho da pilha é específico da arquitetura. Do código-fonte do JDK 7 :

// O tamanho da pilha padrão no Windows é determinado pelo executável (java.exe
// tem um valor padrão de 320 K / 1 MB [32 bits / 64 bits]). Dependendo da versão do Windows, alterar
// ThreadStackSize para diferente de zero pode ter um impacto significativo no uso da memória.
// Veja os comentários em os_windows.cpp.

Portanto, quando o StackOverflowErroré lançado, o erro é capturado no bloco catch. Aqui println()está outra chamada de pilha que lança exceção novamente. Isso se repete.

Quantas vezes ele se repete? - Bem, depende de quando a JVM pensa que não é mais stackoverflow. E isso depende do tamanho da pilha de cada chamada de função (difícil de encontrar) e do Xss. Conforme mencionado acima, o tamanho total padrão e o tamanho de cada chamada de função (depende do tamanho da página de memória, etc.) é específico da plataforma. Daí um comportamento diferente.

Ligar javapara -Xss 4Mme dá 41. Daí a correlação.

Jatin
fonte
4
Não entendo por que o tamanho da pilha deve impactar o resultado, uma vez que já é excedido quando tentamos imprimir o valor de cnt. Assim, a única diferença poderia vir do "tamanho da pilha de cada chamada de função". E eu não entendo por que isso deve variar entre 2 máquinas executando a mesma versão de JVM.
flrnb
O comportamento exato só pode ser obtido na fonte JVM. Mas a razão pode ser esta. Lembre-se de que mesmo catché um bloco e que ocupa memória na pilha. Quanta memória cada chamada de método leva não pode ser conhecida. Quando a pilha for limpa, você estará adicionando mais um bloco de catche assim por diante. Esse pode ser o comportamento. Isso é apenas especulação.
Jatin
E o tamanho da pilha pode ser diferente em duas máquinas diferentes. O tamanho da pilha depende de vários fatores baseados no sistema operacional, como o tamanho da página da memória, etc.
Jatin
6

Acho que o número exibido é o número de vezes que a System.out.printlnchamada gera a Stackoverflowexceção.

Provavelmente depende da implementação do printlne do número de chamadas de empilhamento feitas nele.

Como uma ilustração:

A main()chamada dispara a Stackoverflowexceção na chamada i. A chamada i-1 de main captura a exceção e a chamada printlnque dispara um segundo Stackoverflow. cntobtém incremento para 1. A chamada i-2 de captura principal agora é a exceção e a chamada println. Em printlnum método é chamado de desencadear uma terceira exceção. cntobtenha incremento para 2. isso continua até que printlnpossa fazer todas as chamadas necessárias e, finalmente, exibir o valor de cnt.

Isso depende da implementação real de println.

Para o JDK7, ele detecta a chamada cíclica e lança a exceção mais cedo ou mantém algum recurso da pilha e lança a exceção antes de atingir o limite para dar algum espaço para a lógica de correção ou a printlnimplementação não faz chamadas ou a operação ++ é feita depois a printlnchamada, portanto, é ignorada pela exceção.

Kazaag
fonte
Isso é o que eu quis dizer com "Eu pensei que talvez fosse porque System.out.println precisa de 3 segmentos na pilha de chamadas" - mas eu estava intrigado por que é exatamente esse número e agora estou ainda mais intrigado por que o número difere tanto entre os diferentes máquinas (virtuais)
flrnb
Eu concordo parcialmente com isso, mas o que discordo é sobre a declaração `dependente da implementação real de println`. Ela tem a ver com o tamanho da pilha em cada jvm ao invés da implementação.
Jatin
6
  1. mainrecorre sobre si mesmo até que estourou a pilha na profundidade de recursão R.
  2. O bloco catch na profundidade de recursão R-1é executado.
  3. O bloco catch na profundidade de recursão é R-1avaliado cnt++.
  4. O bloco catch nas R-1chamadas de profundidade println, colocando cnto valor antigo de na pilha. printlnirá chamar internamente outros métodos e usar variáveis ​​locais e coisas assim. Todos esses processos requerem espaço de pilha.
  5. Como a pilha já estava ultrapassando o limite e a chamada / execução printlnrequer espaço de pilha, um novo estouro de pilha é disparado na profundidade em R-1vez de na profundidade R.
  6. As etapas 2 a 5 acontecem novamente, mas em profundidade de recursão R-2.
  7. As etapas 2 a 5 acontecem novamente, mas em profundidade de recursão R-3.
  8. As etapas 2 a 5 acontecem novamente, mas em profundidade de recursão R-4.
  9. As etapas 2 a 4 acontecem novamente, mas em profundidade de recursão R-5.
  10. Acontece que agora há espaço de pilha suficiente para printlnser concluído (observe que este é um detalhe de implementação, pode variar).
  11. cntfoi pós-incrementado em profundidades R-1, R-2, R-3, R-4, e, finalmente, R-5. O quinto pós-incremento retornou quatro, que é o que foi impresso.
  12. Com a mainconclusão bem-sucedida em profundidade R-5, toda a pilha se desenrola sem que mais blocos sejam executados e o programa seja concluído.
Craig Gidney
fonte
1

Depois de cavar um pouco, não posso dizer que encontrei a resposta, mas acho que está bem perto agora.

Primeiro, precisamos saber quando um StackOverflowErrorserá lançado. Na verdade, a pilha de um thread java armazena quadros, que contêm todos os dados necessários para invocar um método e continuar. De acordo com as especificações da linguagem Java para JAVA 6 , ao invocar um método,

Se não houver memória suficiente disponível para criar esse quadro de ativação, um StackOverflowError é lançado.

Em segundo lugar, devemos deixar claro o que é " não há memória suficiente disponível para criar esse quadro de ativação ". De acordo com as especificações de máquina virtual Java para JAVA 6 ,

os quadros podem ser alocados no heap.

Portanto, quando um quadro é criado, deve haver espaço de heap suficiente para criar um quadro de pilha e espaço de pilha suficiente para armazenar a nova referência que aponta para o novo quadro de pilha se o quadro estiver alocado em heap.

Agora vamos voltar à questão. Do exposto, podemos saber que quando um método é executado, ele pode custar apenas a mesma quantidade de espaço de pilha. E a invocação System.out.println(pode) precisa de 5 níveis de invocação de método, portanto, 5 quadros precisam ser criados. Então, quando StackOverflowErroré descartado, tem que voltar 5 vezes para obter espaço de pilha suficiente para armazenar referências de 5 quadros. Portanto, 4 é impresso. Por que não 5? Porque você usa cnt++. Altere para ++cnte você obterá 5.

E você notará que quando o tamanho da pilha atinge um nível alto, às vezes você obtém 50. Isso ocorre porque a quantidade de espaço de heap disponível precisa ser levada em consideração. Quando o tamanho da pilha é muito grande, talvez o espaço da pilha acabe antes da pilha. E (talvez) o tamanho real dos frames de pilha de System.out.printlné cerca de 51 vezes de main, portanto, volta 51 vezes e imprime 50.

Jay
fonte
Meu primeiro pensamento também foi contar os níveis de invocações de método (e você está certo, não prestei atenção ao fato de que posto cnt de incremento), mas se a solução fosse tão simples, por que os resultados variam tanto entre as plataformas e implementações de VM?
flrnb
@flrnb Isso ocorre porque diferentes plataformas podem afetar o tamanho do frame da pilha e diferentes versões de jre afetarão a implementação System.out.printou a estratégia de execução do método. Conforme descrito acima, a implementação da VM também afeta onde o quadro de pilha será armazenado.
Jay
0

Esta não é exatamente uma resposta à pergunta, mas eu só queria acrescentar algo à pergunta original que encontrei e como entendi o problema:

No problema original, a exceção é detectada onde era possível:

Por exemplo, com o jdk 1.7 ele é capturado no primeiro lugar de ocorrência.

mas em versões anteriores do jdk, parece que a exceção não está sendo capturada no primeiro lugar de ocorrência, portanto, 4, 50 etc.

Agora, se você remover o bloco try catch como segue

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

Então você verá todos os valores das cntexceções lançadas (no jdk 1.7).

Usei o netbeans para ver a saída, pois o cmd não mostrará todas as saídas e exceções lançadas.

me_digvijay
fonte