Acredito que, com este exemplo hipotético e simplificado, ninguém mais vai ter dúvidas sobre o motivo de ocorrer NullPointerException.
Vamos imaginar que esse "desenho textual" aqui embaixo é uma memória com 5 posições:
| | | | | |
| vazio | vazio | vazio | vazio | vazio |
| 1 | 2 | 3 | 4 | 5 |
Cada elemento, como esse abaixo, representa um espaço de memória, que tem endereço, representado pelo número que está embaixo, e possui um conteúdo, que inicialmente é "vazio".
| |
| vazio |
| 1 |
Quando você declara uma variável, você está reservando um espaço em algum lugar aleatório da memória, esse espaço vai receber algum valor. Por exemplo a linha abaixo, reserva espaço para que essa variável possa referenciar um Aluno:
Aluno a1;
Vamos imaginar que a variável "a1" vai ser armazenada no endereço 1 da memória. Agora nossa memória hipotética vai ficar assim:
| a1 | | | | |
| vazio | vazio | vazio | vazio | vazio |
| 1 | 2 | 3 | 4 | 5 |
O Endereço 1 corresponde à variável "a1", que ainda não foi inicializada.
Vamos imaginar que um objeto Aluno ocupa 3 espaços de memória, um para seu ponteiro ( ou endereço ) na memória, um para o atributo "nome" e outro para o atributo "matrícula". Quando você usa a instrução new, você está alocando/ocupando memória em algum lugar aleatório da memória. Por exemplo, a linha abaixo aloca memória para um objeto Aluno;
new Aluno();
Vamos supor que o Aluno foi alocado no endereço 5, então nossa memória ficará assim:
| a1 | | - - - - - - Aluno - - - - - - |
| vazio | vazio | matricula | nome | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
Perceba que você no exemplo acima, fizemos "new Aluno();" ao invés de "Aluno a1 = new Aluno();" Dessa forma,ninguém referencia o objeto Aluno que criamos, ele só está lá ocupando memória desnecessariamente. Se observar o "desenho", a variável "a1" continua com "vazio".
Pra você fazer sua variável referenciar o objeto acima, você precisa atribuir ( usando o operador = ) para uma variável, o objeto criado, dessa forma:
Aluno a1 = new Aluno();
Agora nossa memória vai ficar assim:
| a1 | | - - - - - - Aluno - - - - - - |
| 5 | vazio | matricula | nome | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
Perceba que o endereço 1, que corresponde à variável "a1", possui o valor 5, este valor é o endereço do objeto que àquela variável referencia.
Vamos agora declarar mais uma variável do tipo Aluno, a variável "a2";
Aluno a2;
Após a declaração acima nossa memória vai ficar assim:
| a1 | a2 | - - - - - - Aluno - - - - - - |
| 5 | vazio | matricula | nome | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
O endereço 2 para a ficar reservado para a variável "a2", no entanto ela ainda não foi inicializada.
Se fizermos:
a2 = a1;
Nossa memória hipotética vai ficar com esse aspecto:
| a1 | a2 | - - - - - - Aluno - - - - - - |
| 5 | 5 | matricula | nome | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
Perceba que tanto a variável "a1", quanto a variável "a2" estão referenciando o endereço 5, que corresponde ao objeto Aluno que foi criado.
Se fizermos:
a1.nome = "Huguinho";
a1.matricula = "123";
Nossa memória hipotética vai ficar assim:
| a1 | a2 | - - - - - - Aluno - - - - - - |
| 5 | 5 | "123" | "Huguinho" | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
Agora,se fizermos:
a1 = null;
Nós apenas estaremos anulando a referência de "a1" e nossá memória hipotética vai ficar dessa forma:
| a1 | a2 | - - - - - - Aluno - - - - - - |
| vazio | 5 | "123" | "Huguinho" | ponteiro |
| 1 | 2 | 3 | 4 | 5 |
A variável "a2" continua referenciando o objeto Aluno que existe no endereço 5. Entretanto, a variável "a1" voltou a ficar sem referência nenhuma, dessa forma, se alguém tentar acessar fazer isso:
System.out.println(a1.nome);
Vai acontecer uma NullPointerException, pois acabamos de tentar acessar o atributo "nome" de um endereço vazio.
Espero que com esse exemplo fique fácil compreender o que é o tão famigerado NullPointerException, ele simplesmente significa que você está tentando acessar membros de um objeto que não foi inicializado!
Talvez possa surgir a dúvida de porquê nos exemplos acima, o objeto Aluno foi alocado no endereço 5 e não no endereço 3. A resposta é pode parecer idiota: eu quis fazer assim! Não necessariamnete a Máquina Virtual Java tenha um comportamento desses.
No entanto, é comum que na arquitetura de computadores o ponteiro da Heap começar "em uma ponta" da memória e o ponteiro da Stack começar na "outra ponta", dessa forma quando os dois ponteiros atingem um endereço que já foi percorrido pelo outro, significa que houve uma violação de memória.