Hashtable<Integer, String> tabela = new Hashtable<>();
long tempo1 = System.currentTimeMillis();
String concat = " ms";
for (int i = 0, l = 1000000; i < l; i++) {
String dado = tabela.get(i);
if (dado != null) {
concat += dado.substring(1);
}
}
long tempo2 = System.currentTimeMillis();
for (int i = 0, l = 1000000; i < l; i++) {
try {
concat += tabela.get(i).substring(1);
} catch (RuntimeException re) {
}
}
long tempo3 = System.currentTimeMillis();
System.out.println("Tempo if: " + (tempo2 - tempo1) + concat);
System.out.println("Tempo try/catch: " + (tempo3 - tempo2) + concat);
/**
* Resultado meu teste:
* Tempo if: 31 ms
* Tempo try/catch: 641 ms
*/
Não necessariamente você precisa usar um if para fazer as coisas, você também pode usar um bloco try/catch. Como dá para ver no meu exemplo acima, em termos de processamento é muito mais rápido fazer processar um simples if do que uma exceção, no Java por baixo dos panos, você não passa os objetos como parâmetro, você passa apenas a referência do objeto, e no caso do objeto nulo, sua referência não aponta para lugar nenhum. É até mesmo por isso que não dá para comparar duas String com ==, já que neste caso, você compara apenas a referência do objeto.
Em Java, é muito comum as pessoas usarem os recursos de exceções de modo equivocado, criar uma classe que estenda indiretamente de Throwable (uma classe de exceção), é um recurso destinado aos desenvolvedores de API e de Frameworks, já que é muito difícil prever como o programador de usara a API ou o Framework o utilizara, e já usar o try/catch é um recurso para o programador final gerenciar erros com as APIs, com os Frameworks e com a plataforma Java, mas não deve ser usado para estruturar uma lógica de negócio, isso deixa o software lento, e caso seja em um servidor, podemos dizer que precisara de muitos mais servidores que o normal para rodar uma aplicação que dispare muitas exceções. O throw também é uma instrução que só deve ser usado pelo desenvolvedor de API. Mencionei acima de criar classes de erros, mas também o desenvolvedor final também não deve criar objetos de erros, e nunca, em hipotenso alguma, jamais, verifique um erro com um try/catch para poder disparar outro erro, isso não tem lógica nenhuma. Você já deve ter visto aquela tradicional stak de erro do Java, aquilo só é possível porque após disparar uma exceção, a máquina virtual fica verificando por todos os lugares que a exceção passou, e por isso que é muito mais lento trabalhar com exceções.
Em Java tem dois tipos de erros, os que estendem de Exception como IOException, SQLException, InterruptedException, erros não verificáveis que sempre devem ser circundados por um try/catch ou o código não vai compilar, esses erros são inerentes a lógica do programa, ou seja, o programa sempre estará sujeito a ter esses erros e por isso deve tratá-los no try/catch.
O outro de tipo de erro são os que estendem de RuntimeException como NumberFormatException, NullPointerException, ClassCastException, ArrayIndexOutOfBoundsException, que são os erros verificáveis, ou seja, pela lógica do programa, você consegue impedir que esses erros ocorram e por isso não é obrigatório usar o try/catch. Se uma aplicação dispara erros verificáveis quer dizer que o software não está bem estruturado, e para muitas empresas, este é um motivo suficiente para mandar um programador embora.