Capítulo 6: Armadilhas Fundamentais do Java
Este capítulo cobre as questões puras de Java mais frequentes em entrevistas. São as bases que todo desenvolvedor Java deve dominar. Um entrevistador as faz para verificar se você entende como o Java funciona na memória.
1. == vs .equals() — A pergunta mais feita
A regra de ouro
| Operador | Compara o quê? | Usar para |
|---|---|---|
== | Os endereços de memória (referências) | Tipos primitivos (int, boolean, char...) |
.equals() | O conteúdo (valores) | Objetos (String, Integer, List...) |
Exemplos com String
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true ← mesma referência (String Pool)
System.out.println(a == c); // false ← referências diferentes (new cria um novo objeto)
System.out.println(a.equals(c)); // true ← mesmo conteúdo
Por que a == b é true?
O Java mantém um String Pool (cache) na memória. Quando você escreve "hello" diretamente no código, o Java reutiliza o mesmo objeto:
Memória:
┌──────────────────────────────────┐
│ String Pool │
│ ┌─────────┐ │
│ │ "hello" │ ← a e b apontam aqui
│ └─────────┘ │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Heap (heap) │
│ ┌─────────┐ │
│ │ "hello" │ ← c aponta aqui (new = sempre um novo objeto)
│ └─────────┘ │
└──────────────────────────────────┘
Exemplos com Integer (armadilha favorita!)
Integer x = 127;
Integer y = 127;
System.out.println(x == y); // true ← cache Integer [-128, 127]
Integer a = 128;
Integer b = 128;
System.out.println(a == b); // false ← fora do cache!
System.out.println(a.equals(b)); // true ← mesmo valor
O Java faz cache dos Integer de -128 a 127. Acima disso, == retorna false mesmo que os valores sejam idênticos. Por isso, é preciso sempre usar .equals() para comparar objetos.
Boa prática: "CONSTANTE".equals(variável)
String status = getStatus(); // pode retornar null
// PERIGOSO — NullPointerException se status for null
if (status.equals("ACTIVE")) { ... }
// SEGURO — nunca lança NPE
if ("ACTIVE".equals(status)) { ... }
"== compara referências (endereços de memória), .equals() compara conteúdo. Sempre uso .equals() para objetos. Para strings, coloco a constante à esquerda para evitar NullPointerException."
2. hashCode() — O que é e por que é importante?
Definição simples
hashCode() retorna um número inteiro que representa um objeto. É como uma impressão digital: rápido de comparar, mas não 100% único.
O contrato hashCode/equals
Se dois objetos são .equals(), eles DEVEM ter o mesmo hashCode().
Se você sobrescrever .equals(), você deve também sobrescrever hashCode().
// ERRADO — sobrescreve equals sem hashCode
public class User {
private String email;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return Objects.equals(email, other.email);
}
// SEM hashCode() → BUG com HashMap, HashSet!
}
// CERTO — sobrescreve ambos
public class User {
private String email;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return Objects.equals(email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(email);
}
}
Por que isso é crítico? HashMap e HashSet dependem disso!
User user1 = new User("alice@mail.com");
User user2 = new User("alice@mail.com");
// Sem hashCode correto:
Set<User> set = new HashSet<>();
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 2 ← ERRADO! Deveria ser 1
// Com hashCode correto:
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 1 ← CORRETO
Como o HashMap usa hashCode
hashCode("alice@mail.com") → 12345
HashMap interno:
┌──────┬──────────────────┐
│ slot │ conteúdo │
├──────┼──────────────────┤
│ ... │ │
│ 12345│ → User("alice") │ ← hashCode determina o slot
│ ... │ │
└──────┴──────────────────┘
1. hashCode() → encontra o slot certo (rápido, O(1))
2. equals() → verifica se é realmente o mesmo objeto
Com Lombok (o mais simples)
@Data // gera equals(), hashCode(), toString(), getters, setters
public class User {
private String email;
private String name;
}
// Ou para controlar quais campos usar:
@EqualsAndHashCode(of = {"email"})
public class User {
private String email;
private String name;
}
Com Java Records (o mais moderno)
public record User(String email, String name) {}
// equals(), hashCode(), toString() são gerados automaticamente!
"hashCode é como uma impressão digital para objetos. O HashMap usa para encontrar o bucket certo em O(1). O contrato diz: se dois objetos são iguais, eles devem ter o mesmo hashCode. Por isso sempre sobrescrevo ambos juntos — ou uso Lombok @Data ou Java Records."
3. toString() — Para depuração
Sem toString
User user = new User("Alice", "alice@mail.com");
System.out.println(user);
// → com.example.User@1a2b3c4d ← inútil!
Com toString
@Override
public String toString() {
return "User{name='%s', email='%s'}".formatted(name, email);
}
System.out.println(user);
// → User{name='Alice', email='alice@mail.com'} ← útil!
Com Lombok ou Record
@Data // gera toString() automaticamente
public class User { ... }
// Ou
public record User(String name, String email) {}
// toString() → User[name=Alice, email=alice@mail.com]
4. Tipos primitivos vs Wrapper classes
| Primitivo | Wrapper | Tamanho | Valor padrão |
|---|---|---|---|
int | Integer | 4 bytes | 0 |
long | Long | 8 bytes | 0L |
double | Double | 8 bytes | 0.0 |
boolean | Boolean | 1 bit | false |
char | Character | 2 bytes | '\u0000' |
Quando usar cada um?
| Situação | Usar | Por quê |
|---|---|---|
| Variáveis locais, cálculos | int, double | Mais rápido, sem boxing |
| Atributos de entidades JPA | Long, Integer | Pode ser null (importante para id ainda não persistido) |
Coleções (List, Map) | Integer, Long | Coleções não aceitam primitivos |
| Parâmetros opcionais | Integer | Pode ser null para indicar "não fornecido" |
Autoboxing / Unboxing
// Autoboxing: int → Integer (automático)
Integer a = 42;
// Unboxing: Integer → int (automático)
int b = a;
// ARMADILHA: unboxing de null → NullPointerException!
Integer c = null;
int d = c; // 💥 NullPointerException
Integer a = null;
if (a == 0) { ... } // 💥 NullPointerException (unboxing de null)
Sempre verifique null antes de comparar um Wrapper com um primitivo.
"Uso primitivos para variáveis locais e cálculos, e wrappers para IDs de entidades e coleções. O principal perigo é fazer unboxing de null, que lança NullPointerException."
5. final — Três usos diferentes
| Contexto | Significado | Exemplo |
|---|---|---|
| Variável | A referência não pode mudar | final List<String> list = new ArrayList<>() |
| Método | Não pode ser sobrescrito (override) | public final void process() |
| Classe | Não pode ser herdada | public final class String |
Armadilha: final NÃO torna o objeto imutável
final List<String> list = new ArrayList<>();
list.add("hello"); // ✅ OK — modifica o CONTEÚDO
list = new ArrayList<>(); // ❌ Erro — tenta mudar a REFERÊNCIA
Memória:
┌─────────────────┐
list (final) ──→ │ ArrayList │
(referência │ ["hello"] │ ← o conteúdo pode mudar
não muda) └─────────────────┘
"final significa que a referência não pode ser reatribuída, mas o objeto ainda pode ser mutado. Para imutabilidade real, uso List.of() ou Collections.unmodifiableList()."
6. static — Pertence à classe, não à instância
public class Counter {
static int totalCount = 0; // compartilhado entre TODAS as instâncias
int instanceCount = 0; // próprio de cada instância
public Counter() {
totalCount++;
instanceCount++;
}
}
Counter c1 = new Counter();
Counter c2 = new Counter();
Counter c3 = new Counter();
System.out.println(Counter.totalCount); // 3 (compartilhado)
System.out.println(c3.instanceCount); // 1 (próprio do c3)
static na prática
| Uso | Exemplo | Boa prática? |
|---|---|---|
| Constantes | static final int MAX = 100 | Sim |
| Métodos utilitários | Math.max(), Collections.sort() | Sim |
| Variáveis compartilhadas | static int count | Atenção (thread safety) |
| Classes internas | static class Builder | Sim (Builder pattern) |
Evite variáveis static mutáveis em beans Spring. Os beans já são singleton por padrão — use campos de instância normais.
7. String — Imutável por design
Strings são imutáveis
String s = "hello";
s.toUpperCase();
System.out.println(s); // "hello" ← NÃO modificado!
s = s.toUpperCase();
System.out.println(s); // "HELLO" ← nova String atribuída
Cada modificação cria um novo objeto String.
Concatenação em loop: StringBuilder
// RUIM — cria N objetos String intermediários → O(n²)
String result = "";
for (int i = 0; i < 10000; i++) {
result += i + ",";
}
// BOM — um único objeto, modificações in-place → O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(",");
}
String result = sb.toString();
String Pool (cache)
String a = "hello"; // vai para o String Pool
String b = "hello"; // reutiliza o mesmo objeto do Pool
String c = new String("hello"); // cria um NOVO objeto no Heap
System.out.println(a == b); // true (mesma referência no Pool)
System.out.println(a == c); // false (c está no Heap, não no Pool)
Comparação de Strings
| Método | Uso |
|---|---|
.equals() | Comparação sensível a maiúsculas/minúsculas |
.equalsIgnoreCase() | Comparação insensível a maiúsculas/minúsculas |
.compareTo() | Ordem alfabética (retorna int) |
.contains() | Verifica se contém uma substring |
.startsWith() / .endsWith() | Verifica início/fim |
.isBlank() | Vazio ou apenas espaços (Java 11+) |
.isEmpty() | Vazio (comprimento 0) |
"Strings são imutáveis em Java. Toda modificação cria um novo objeto. Para concatenação em loops, uso StringBuilder. Para comparação, sempre uso .equals(), nunca ==."
8. Optional — Evite NullPointerException
O problema
// NullPointerException se findById retornar null
User user = userRepository.findById(1L);
String email = user.getEmail(); // 💥 NPE se user for null
A solução: Optional
Optional<User> optional = userRepository.findById(1L);
// Abordagem 1: isPresent
if (optional.isPresent()) {
User user = optional.get();
}
// Abordagem 2: orElse (valor padrão)
User user = optional.orElse(new User("anonymous"));
// Abordagem 3: orElseThrow (exceção)
User user = optional.orElseThrow(() ->
new ResourceNotFoundException("User not found with id: 1"));
// Abordagem 4: map (transformar)
String email = optional.map(User::getEmail).orElse("N/A");
Anti-patterns a evitar
// ❌ RUIM — equivale a um null check
if (optional.isPresent()) {
return optional.get();
} else {
return null;
}
// ✅ CERTO
return optional.orElse(null);
// ❌ RUIM — Optional como parâmetro
public void process(Optional<String> name) { ... }
// ✅ CERTO — Optional apenas como retorno
public Optional<User> findByEmail(String email) { ... }
"Optional representa um valor que pode ou não estar presente. Uso como tipo de retorno, nunca como parâmetro. Prefiro orElseThrow() para valores obrigatórios e map() para transformações."
9. Passagem por valor vs passagem por referência
Java passa TUDO por valor
// Primitivos: o VALOR é copiado
void increment(int x) {
x++; // modifica a cópia local
}
int a = 5;
increment(a);
System.out.println(a); // 5 ← não modificado
// Objetos: a REFERÊNCIA é copiada (não o objeto)
void addItem(List<String> list) {
list.add("new"); // modifica o objeto apontado pela cópia da referência
}
List<String> myList = new ArrayList<>();
addItem(myList);
System.out.println(myList); // [new] ← modificado!
// Mas reatribuir a referência não muda nada
void replace(List<String> list) {
list = new ArrayList<>(); // muda a cópia local, não o original
}
replace(myList);
System.out.println(myList); // [new] ← ainda a lista antiga
Esquema de memória
Chamada: addItem(myList)
main() addItem()
┌──────┐ ┌──────┐
│myList │──┐ ┌────│ list │ ← cópia da referência
└──────┘ │ │ └──────┘
↓ ↓
┌──────────────┐
│ ArrayList │ ← ambos apontam para o MESMO objeto
│ ["new"] │
└──────────────┘
"Java sempre passa por valor. Para primitivos, o valor é copiado. Para objetos, a referência é copiada — então você pode modificar o objeto pela cópia, mas não pode fazer a variável original apontar para outro objeto."
10. Interfaces vs Classes abstratas
| Aspecto | Interface | Classe abstrata |
|---|---|---|
| Palavra-chave | implements | extends |
| Herança múltipla | Sim (várias interfaces) | Não (uma só classe) |
| Construtor | Não | Sim |
| Campos de instância | Não (apenas static final) | Sim |
| Métodos concretos | Sim (desde Java 8, default) | Sim |
| Quando usar | Contrato / capacidade ("pode fazer") | Base comum ("é um") |
// Interface = contrato (o que o objeto PODE fazer)
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Duck implements Flyable, Swimmable {
public void fly() { ... }
public void swim() { ... }
}
// Classe abstrata = base comum (o que o objeto É)
abstract class Animal {
protected String name;
Animal(String name) { this.name = name; }
abstract void makeSound();
void breathe() { System.out.println(name + " breathes"); }
}
class Dog extends Animal {
Dog(String name) { super(name); }
void makeSound() { System.out.println("Woof!"); }
}
"Uso interfaces para definir contratos (o que um objeto pode fazer) e classes abstratas para compartilhar comportamento comum (o que um objeto é). Desde o Java 8, interfaces podem ter métodos default, o que diminuiu a diferença — mas ainda prefiro interfaces pela flexibilidade e herança múltipla."
Resumo rápido para entrevista
| Pergunta | Resposta em 1 frase |
|---|---|
== vs .equals() | == compara referências, .equals() compara conteúdo |
hashCode() | Impressão digital de um objeto, usada por HashMap/HashSet |
| Contrato equals/hashCode | Se a.equals(b) então a.hashCode() == b.hashCode() |
final | Referência não reatribuível (não imutabilidade do objeto) |
static | Pertence à classe, não à instância |
| String imutável | Cada modificação cria um novo objeto |
Optional | Contêiner que pode estar vazio, evita null checks |
| Passagem por valor | Java copia o valor (ou a referência para objetos) |
| Interface vs Abstrata | Interface = contrato, Abstrata = base comum |