Código Java com bugs: os 10 principais erros mais comuns que os desenvolvedores Java cometem

Publicados: 2022-03-11

Java é uma linguagem de programação que foi inicialmente desenvolvida para televisão interativa, mas com o tempo se tornou difundida em todos os lugares onde o software pode ser usado. Projetado com a noção de programação orientada a objetos, abolindo as complexidades de outras linguagens como C ou C++, coleta de lixo e uma máquina virtual arquiteturalmente agnóstica, Java criou uma nova forma de programação. Além disso, tem uma curva de aprendizado suave e parece aderir com sucesso ao seu próprio moto - “Escreva uma vez, corra em todos os lugares”, o que quase sempre é verdade; mas os problemas de Java ainda estão presentes. Estarei abordando dez problemas de Java que acho que são os erros mais comuns.

Erro comum nº 1: negligenciar bibliotecas existentes

É definitivamente um erro para os desenvolvedores Java ignorar a quantidade incontável de bibliotecas escritas em Java. Antes de reinventar a roda, tente pesquisar as bibliotecas disponíveis - muitas delas foram aprimoradas ao longo dos anos de existência e são de uso gratuito. Podem ser bibliotecas de registro, como logback e Log4j, ou bibliotecas relacionadas à rede, como Netty ou Akka. Algumas das bibliotecas, como Joda-Time, tornaram-se um padrão de fato.

O seguinte é uma experiência pessoal de um dos meus projetos anteriores. A parte do código responsável pelo escape do HTML foi escrita do zero. Ele estava funcionando bem por anos, mas eventualmente encontrou uma entrada do usuário que fez com que ele girasse em um loop infinito. O usuário, descobrindo que o serviço não estava respondendo, tentou novamente com a mesma entrada. Eventualmente, todas as CPUs do servidor alocadas para esse aplicativo estavam sendo ocupadas por esse loop infinito. Se o autor dessa ingênua ferramenta de escape HTML tivesse decidido usar uma das bibliotecas mais conhecidas disponíveis para escape HTML, como HtmlEscapers do Google Guava, isso provavelmente não teria acontecido. No mínimo, verdadeiro para as bibliotecas mais populares com uma comunidade por trás, o erro teria sido encontrado e corrigido anteriormente pela comunidade dessa biblioteca.

Erro comum nº 2: falta da palavra-chave 'break' em um bloco de switch-case

Esses problemas de Java podem ser muito embaraçosos e, às vezes, permanecem desconhecidos até serem executados em produção. O comportamento de fallthrough em instruções switch geralmente é útil; no entanto, perder uma palavra-chave “break” quando tal comportamento não é desejado pode levar a resultados desastrosos. Se você esqueceu de colocar um “break” no “case 0” no exemplo de código abaixo, o programa escreverá “Zero” seguido de “One”, pois o fluxo de controle aqui dentro passará por toda a instrução “switch” até chega a uma “pausa”. Por exemplo:

 public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }

Na maioria dos casos, a solução mais limpa seria usar polimorfismo e mover código com comportamentos específicos em classes separadas. Erros Java como este podem ser detectados usando analisadores de código estáticos, por exemplo, FindBugs e PMD.

Erro comum nº 3: Esquecer de liberar recursos

Toda vez que um programa abre um arquivo ou uma conexão de rede, é importante que os iniciantes em Java liberem o recurso assim que você terminar de usá-lo. Cuidado semelhante deve ser tomado se qualquer exceção for lançada durante as operações em tais recursos. Pode-se argumentar que o FileInputStream tem um finalizador que invoca o método close() em um evento de coleta de lixo; no entanto, como não podemos ter certeza de quando um ciclo de coleta de lixo será iniciado, o fluxo de entrada pode consumir recursos do computador por um período indefinido. Na verdade, há uma instrução realmente útil e organizada introduzida no Java 7 especialmente para este caso, chamada try-with-resources:

 private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }

Essa instrução pode ser usada com qualquer objeto que implemente a interface AutoClosable. Ele garante que cada recurso seja fechado até o final da instrução.

Relacionado: 8 Perguntas Essenciais da Entrevista Java

Erro comum nº 4: vazamentos de memória

Java usa gerenciamento automático de memória e, embora seja um alívio esquecer de alocar e liberar memória manualmente, isso não significa que um desenvolvedor Java iniciante não deva estar ciente de como a memória é usada no aplicativo. Problemas com alocações de memória ainda são possíveis. Enquanto um programa criar referências a objetos que não são mais necessários, ele não será liberado. De certa forma, ainda podemos chamar isso de vazamento de memória. Vazamentos de memória em Java podem ocorrer de várias maneiras, mas o motivo mais comum são referências de objeto eternas, porque o coletor de lixo não pode remover objetos do heap enquanto ainda houver referências a eles. Pode-se criar tal referência definindo classe com um campo estático contendo alguma coleção de objetos e esquecendo de definir esse campo estático como nulo depois que a coleção não for mais necessária. Campos estáticos são considerados raízes GC e nunca são coletados.

Outra razão potencial por trás desses vazamentos de memória é um grupo de objetos referenciando uns aos outros, causando dependências circulares para que o coletor de lixo não possa decidir se esses objetos com referências de dependência cruzada são necessários ou não. Outro problema são os vazamentos na memória não heap quando o JNI é usado.

O exemplo de vazamento primitivo pode ter a seguinte aparência:

 final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }

Este exemplo cria duas tarefas agendadas. A primeira tarefa pega o último número de um deque chamado “numbers” e imprime o número e o tamanho do deque caso o número seja divisível por 51. A segunda tarefa coloca os números no deque. Ambas as tarefas são agendadas a uma taxa fixa e executadas a cada 10 ms. Se o código for executado, você verá que o tamanho do deque está aumentando permanentemente. Isso eventualmente fará com que o deque seja preenchido com objetos consumindo toda a memória heap disponível. Para evitar isso preservando a semântica deste programa, podemos usar um método diferente para obter números do deque: “pollLast”. Ao contrário do método “peekLast”, “pollLast” retorna o elemento e o remove do deque enquanto “peekLast” retorna apenas o último elemento.

Para saber mais sobre vazamentos de memória em Java, consulte nosso artigo que desmistificou esse problema.

Erro comum nº 5: alocação excessiva de lixo

Alocação de lixo excessiva pode acontecer quando o programa cria muitos objetos de curta duração. O coletor de lixo trabalha continuamente, removendo objetos desnecessários da memória, o que impacta negativamente o desempenho dos aplicativos. Um exemplo simples:

 String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));

No desenvolvimento Java, as strings são imutáveis. Assim, a cada iteração, uma nova string é criada. Para resolver isso, devemos usar um StringBuilder mutável:

 StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));

Enquanto a primeira versão requer um pouco de tempo para ser executada, a versão que usa StringBuilder produz um resultado em um período de tempo significativamente menor.

Erro comum nº 6: usar referências nulas sem necessidade

Evitar o uso excessivo de null é uma boa prática. Por exemplo, é preferível retornar matrizes ou coleções vazias de métodos em vez de nulos, pois isso pode ajudar a evitar NullPointerException.

Considere o seguinte método que percorre uma coleção obtida de outro método, conforme mostrado abaixo:

 List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }

Se getAccountIds() retornar null quando uma pessoa não tiver conta, então NullPointerException será gerado. Para corrigir isso, será necessária uma verificação nula. No entanto, se em vez de um null ele retornar uma lista vazia, NullPointerException não será mais um problema. Além disso, o código é mais limpo, pois não precisamos verificar a variável accountIds com null.

Para lidar com outros casos em que se deseja evitar nulos, diferentes estratégias podem ser usadas. Uma dessas estratégias é usar o tipo Optional que pode ser um objeto vazio ou um wrap de algum valor:

 Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }

Na verdade, o Java 8 fornece uma solução mais concisa:

 Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);

O tipo opcional faz parte do Java desde a versão 8, mas é bem conhecido há muito tempo no mundo da programação funcional. Antes disso, estava disponível no Google Guava para versões anteriores do Java.

Erro comum nº 7: ignorando exceções

Muitas vezes é tentador deixar exceções sem tratamento. No entanto, a melhor prática para desenvolvedores Java iniciantes e experientes é lidar com eles. As exceções são lançadas de propósito, então, na maioria dos casos, precisamos resolver os problemas que causam essas exceções. Não ignore esses eventos. Se necessário, você pode relançá-lo, mostrar uma caixa de diálogo de erro ao usuário ou adicionar uma mensagem ao log. No mínimo, deve ser explicado por que a exceção foi deixada sem tratamento para que outros desenvolvedores saibam o motivo.

 selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }

Uma maneira mais clara de destacar a insignificância de uma exceção é codificar esta mensagem no nome da variável das exceções, assim:

 try { selfie.delete(); } catch (NullPointerException unimportant) { }

Erro comum nº 8: exceção de modificação simultânea

Essa exceção ocorre quando uma coleção é modificada durante a iteração sobre ela usando métodos diferentes daqueles fornecidos pelo objeto iterador. Por exemplo, temos uma lista de chapéus e queremos remover todos aqueles que têm abas de orelha:

 List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }

Se executarmos esse código, “ConcurrentModificationException” será gerado, pois o código modifica a coleção enquanto a itera. A mesma exceção pode ocorrer se um dos vários threads trabalhando com a mesma lista estiver tentando modificar a coleção enquanto outros iteram sobre ela. A modificação simultânea de coleções em vários threads é uma coisa natural, mas deve ser tratada com ferramentas usuais da caixa de ferramentas de programação simultânea, como bloqueios de sincronização, coleções especiais adotadas para modificação simultânea, etc. Existem diferenças sutis em como esse problema de Java pode ser resolvido em casos de rosca única e casos multiencadeados. Abaixo está uma breve discussão de algumas maneiras que isso pode ser tratado em um único cenário encadeado:

Colete objetos e remova-os em outro loop

Coletar chapéus com abas de orelha em uma lista para removê-los posteriormente de dentro de outro laço é uma solução óbvia, mas requer uma coleção adicional para armazenar os chapéus a serem removidos:

 List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }

Use o método Iterator.remove

Essa abordagem é mais concisa e não precisa de uma coleção adicional para ser criada:

 Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }

Use os métodos do ListIterator

O uso do iterador de lista é apropriado quando a coleção modificada implementa a interface de lista. Os iteradores que implementam a interface ListIterator suportam não apenas operações de remoção, mas também operações de adição e definição. ListIterator implementa a interface Iterator para que o exemplo pareça quase o mesmo que o método remove Iterator. A única diferença é o tipo de iterador hat e a forma como obtemos esse iterador com o método “listIterator()”. O trecho abaixo mostra como substituir cada chapéu com abas de orelha com sombreros usando os métodos “ListIterator.remove” e “ListIterator.add”:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }

Com ListIterator, as chamadas de método remove e add podem ser substituídas por uma única chamada para definir:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }

Use métodos de fluxo introduzidos no Java 8 Com o Java 8, os programadores têm a capacidade de transformar uma coleção em um fluxo e filtrar esse fluxo de acordo com alguns critérios. Aqui está um exemplo de como a API de fluxo pode nos ajudar a filtrar chapéus e evitar “ConcurrentModificationException”.

 hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));

O método “Collectors.toCollection” criará um novo ArrayList com chapéus filtrados. Isso pode ser um problema se a condição de filtragem for satisfeita por um grande número de itens, resultando em uma grande ArrayList; portanto, deve ser usado com cuidado. Use o método List.removeIf apresentado no Java 8 Outra solução disponível no Java 8, e claramente a mais concisa, é o uso do método “removeIf”:

 hats.removeIf(IHat::hasEarFlaps);

É isso. Sob o capô, ele usa “Iterator.remove” para realizar o comportamento.

Use coleções especializadas

Se no início decidimos usar “CopyOnWriteArrayList” em vez de “ArrayList”, não haveria nenhum problema, pois “CopyOnWriteArrayList” fornece métodos de modificação (como set, add e remove) que não mudam a matriz de apoio da coleção, mas sim criar uma nova versão modificada dela. Isso permite iteração sobre a versão original da coleção e modificações nela ao mesmo tempo, sem o risco de “ConcurrentModificationException”. A desvantagem dessa coleção é óbvia - geração de uma nova coleção a cada modificação.

Existem outras coleções ajustadas para diferentes casos, por exemplo, “CopyOnWriteSet” e “ConcurrentHashMap”.

Outro possível erro com modificações de coleção simultâneas é criar um fluxo de uma coleção e, durante a iteração do fluxo, modificar a coleção de apoio. A regra geral para fluxos é evitar a modificação da coleção subjacente durante a consulta de fluxo. O exemplo a seguir mostrará uma maneira incorreta de lidar com um stream:

 List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));

O método peek reúne todos os elementos e executa a ação prevista em cada um deles. Aqui, a ação está tentando remover elementos da lista subjacente, o que é errôneo. Para evitar isso, tente alguns dos métodos descritos acima.

Erro comum nº 9: quebra de contratos

Às vezes, o código fornecido pela biblioteca padrão ou por um fornecedor terceirizado depende de regras que devem ser obedecidas para que as coisas funcionem. Por exemplo, pode ser um contrato hashCode e equals que, quando seguido, garante o funcionamento para um conjunto de coleções da estrutura de coleção Java e para outras classes que usam métodos hashCode e equals. Desobedecer contratos não é o tipo de erro que sempre leva a exceções ou quebra a compilação do código; é mais complicado, porque às vezes muda o comportamento do aplicativo sem nenhum sinal de perigo. O código errado pode entrar na versão de produção e causar um monte de efeitos indesejados. Isso pode incluir mau comportamento da interface do usuário, relatórios de dados incorretos, desempenho insatisfatório do aplicativo, perda de dados e muito mais. Felizmente, esses bugs desastrosos não acontecem com muita frequência. Já mencionei o hashCode e o contrato de igual. Ele é usado em coleções que dependem de hashing e comparação de objetos, como HashMap e HashSet. Simplificando, o contrato contém duas regras:

  • Se dois objetos são iguais, seus códigos de hash devem ser iguais.
  • Se dois objetos tiverem o mesmo código de hash, eles podem ou não ser iguais.

Quebrar a primeira regra do contrato leva a problemas ao tentar recuperar objetos de um hashmap. A segunda regra significa que objetos com o mesmo código hash não são necessariamente iguais. Vamos examinar os efeitos de quebrar a primeira regra:

 public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }

Como você pode ver, a classe Boat substituiu os métodos equals e hashCode. No entanto, ele quebrou o contrato, porque hashCode retorna valores aleatórios para o mesmo objeto toda vez que é chamado. O código a seguir provavelmente não encontrará um barco chamado “Enterprise” no hashset, apesar de termos adicionado esse tipo de barco anteriormente:

 public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }

Outro exemplo de contrato envolve o método finalize. Aqui está uma citação da documentação oficial do java descrevendo sua função:

O contrato geral de finalize é que ele é invocado se e quando a máquina virtual JavaTM determinar que não há mais nenhum meio pelo qual esse objeto possa ser acessado por qualquer thread (que ainda não tenha morrido), exceto como resultado de uma ação tomada pela finalização de algum outro objeto ou classe que está pronto para ser finalizado. O método finalize pode realizar qualquer ação, incluindo tornar este objeto disponível novamente para outros threads; o propósito usual de finalize, entretanto, é realizar ações de limpeza antes que o objeto seja descartado irrevogavelmente. Por exemplo, o método finalize para um objeto que representa uma conexão de entrada/saída pode executar transações de E/S explícitas para interromper a conexão antes que o objeto seja descartado permanentemente.

Pode-se decidir usar o método finalize para liberar recursos como manipuladores de arquivos, mas isso seria uma má ideia. Isso ocorre porque não há garantias de quando finalize será invocado, pois é invocado durante a coleta de lixo e o tempo do GC é indeterminável.

Erro comum nº 10: usar o tipo bruto em vez de um parametrizado

Tipos brutos, de acordo com as especificações Java, são tipos que não são parametrizados ou membros não estáticos da classe R que não são herdados da superclasse ou superinterface de R. Não havia alternativas para tipos brutos até que os tipos genéricos fossem introduzidos em Java . Ele suporta programação genérica desde a versão 1.5, e os genéricos foram, sem dúvida, uma melhoria significativa. No entanto, devido a motivos de compatibilidade com versões anteriores, foi deixada uma armadilha que poderia quebrar o sistema de tipos. Vejamos o seguinte exemplo:

 List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

Aqui temos uma lista de números definidos como um ArrayList bruto. Como seu tipo não é especificado com o parâmetro type, podemos adicionar qualquer objeto a ele. Mas na última linha nós convertemos elementos para int, dobramos e imprimimos o número dobrado na saída padrão. Esse código compilará sem erros, mas, uma vez executado, ele gerará uma exceção de tempo de execução porque tentamos converter uma string em um inteiro. Obviamente, o sistema de tipos não pode nos ajudar a escrever código seguro se ocultarmos as informações necessárias dele. Para corrigir o problema, precisamos especificar o tipo de objetos que vamos armazenar na coleção:

 List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

A única diferença do original é a linha que define a coleção:

 List<Integer> listOfNumbers = new ArrayList<>();

O código fixo não compilaria porque estamos tentando adicionar uma string em uma coleção que deve armazenar apenas números inteiros. O compilador mostrará um erro e apontará para a linha onde estamos tentando adicionar a string “Twenty” à lista. É sempre uma boa ideia parametrizar tipos genéricos. Dessa forma, o compilador é capaz de fazer todas as verificações de tipo possíveis e as chances de exceções de tempo de execução causadas por inconsistências do sistema de tipos são minimizadas.

Conclusão

Java como plataforma simplifica muitas coisas no desenvolvimento de software, contando tanto com uma JVM sofisticada quanto com a própria linguagem. No entanto, seus recursos, como remover o gerenciamento manual de memória ou ferramentas OOP decentes, não eliminam todos os problemas e problemas que um desenvolvedor Java comum enfrenta. Como sempre, conhecimento, prática e tutoriais de Java como este são os melhores meios para evitar e resolver erros de aplicativos - portanto, conheça suas bibliotecas, leia java, leia a documentação da JVM e escreva programas. Também não se esqueça dos analisadores de código estáticos, pois eles podem apontar para os bugs reais e destacar possíveis bugs.

Relacionado: Tutorial Avançado de Classe Java: Um Guia para Recarregamento de Classe