Use Collections Effectively

Problem

The Java Collections Framework offers many implementations (ArrayList, LinkedList, HashMap, TreeMap, etc.), and choosing the wrong one can lead to poor performance or unnecessary complexity.

This guide shows how to select the right collection for your use case and use it effectively.

Choosing the Right Collection

Decision Tree

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
flowchart TD
    Start[What are you storing?] --> Type{Need unique<br/>elements?}
    Type -->|Yes| Set[Use Set]
    Type -->|No| Ordered{Need<br/>order?}
    Ordered -->|Yes| List[Use List]
    Ordered -->|No| KV{Key-value<br/>pairs?}
    KV -->|Yes| Map[Use Map]
    KV -->|No| List2[Use List]

    Set --> SetSort{Need<br/>sorted?}
    SetSort -->|Yes| TreeSet[TreeSet]
    SetSort -->|No| HashSet[HashSet]

    List --> ListAccess{Frequent<br/>random access?}
    ListAccess -->|Yes| ArrayList[ArrayList]
    ListAccess -->|No| LinkedList[LinkedList]

    Map --> MapSort{Need<br/>sorted?}
    MapSort -->|Yes| TreeMap[TreeMap]
    MapSort -->|No| HashMap[HashMap]

    style Start fill:#0173B2,stroke:#000,color:#fff
    style Type fill:#DE8F05,stroke:#000,color:#000
    style Set fill:#029E73,stroke:#000,color:#fff
    style Ordered fill:#DE8F05,stroke:#000,color:#000
    style List fill:#029E73,stroke:#000,color:#fff
    style KV fill:#DE8F05,stroke:#000,color:#000
    style Map fill:#029E73,stroke:#000,color:#fff

When to Use List

Use List when:

  • Order matters
  • Duplicates allowed
  • Need index-based access

ArrayList vs LinkedList:

// ✅ ArrayList - frequent random access
public class UserCache {
  private List<User> users = new ArrayList<>();

  public User getUserAt(int index) {
    return users.get(index); // O(1) - fast
  }

  public void addUser(User user) {
    users.add(user); // O(1) amortized
  }
}

// ✅ LinkedList - frequent insertions/deletions at beginning/middle
public class TaskQueue {
  private List<Task> tasks = new LinkedList<>();

  public void addHighPriorityTask(Task task) {
    tasks.add(0, task); // O(1) - fast for LinkedList
  }

  public Task getNextTask() {
    return tasks.remove(0); // O(1) - fast for LinkedList
  }
}

Performance comparison:

OperationArrayListLinkedList
get(i)O(1)O(n)
add(e)O(1)O(1)
add(i, e)O(n)O(n)
remove(i)O(n)O(n)
remove(0)O(n)O(1)

Rule of thumb: Use ArrayList unless you have specific reason for LinkedList.

When to Use Set

Use Set when:

  • Uniqueness required
  • Frequent membership checks
  • Order doesn’t matter (or need sorted order)

HashSet vs TreeSet:

// ✅ HashSet - fast lookups, no ordering needed
public class UniqueVisitorTracker {
  private Set<String> visitorIds = new HashSet<>();

  public boolean isNewVisitor(String visitorId) {
    return visitorIds.add(visitorId); // O(1) - returns false if already present
  }

  public int getVisitorCount() {
    return visitorIds.size();
  }
}

// ✅ TreeSet - need sorted unique elements
public class LeaderboardService {
  private Set<Score> topScores = new TreeSet<>();

  public void addScore(Score score) {
    topScores.add(score); // O(log n) - maintains sorted order
    if (topScores.size() > 10) {
      topScores.remove(topScores.last()); // Keep top 10
    }
  }

  public List<Score> getTopScores() {
    return new ArrayList<>(topScores); // Already sorted
  }
}

Performance comparison:

OperationHashSetTreeSet
add(e)O(1)O(log n)
contains(e)O(1)O(log n)
remove(e)O(1)O(log n)
OrderingNoYes (sorted)

When to Use Map

Use Map when:

  • Key-value associations
  • Fast lookups by key
  • Unique keys required

HashMap vs TreeMap vs LinkedHashMap:

// ✅ HashMap - fast lookups, no ordering needed
public class UserRepository {
  private Map<String, User> usersById = new HashMap<>();

  public User findById(String id) {
    return usersById.get(id); // O(1) - fast lookup
  }

  public void save(User user) {
    usersById.put(user.getId(), user); // O(1)
  }
}

// ✅ TreeMap - need sorted keys
public class TimeSeriesData {
  private Map<LocalDateTime, Double> data = new TreeMap<>();

  public void addDataPoint(LocalDateTime time, Double value) {
    data.put(time, value); // O(log n) - maintains time order
  }

  public List<Double> getValuesInRange(LocalDateTime start, LocalDateTime end) {
    return data.subMap(start, end)
      .values()
      .stream()
      .collect(Collectors.toList());
  }
}

// ✅ LinkedHashMap - preserve insertion order
public class LRUCache<K, V> {
  private Map<K, V> cache = new LinkedHashMap<>(16, 0.75f, true); // access-order

  public V get(K key) {
    return cache.get(key); // Moves to end
  }

  public void put(K key, V value) {
    cache.put(key, value);
    if (cache.size() > 100) {
      K eldest = cache.keySet().iterator().next();
      cache.remove(eldest);
    }
  }
}

Performance comparison:

OperationHashMapTreeMapLinkedHashMap
get(k)O(1)O(log n)O(1)
put(k,v)O(1)O(log n)O(1)
OrderingNoSorted keysInsertion order

Common Operations

Initialize Collections

// ❌ Old way - verbose
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");

// ✅ Factory methods (Java 9+)
List<String> names = List.of("Alice", "Bob", "Charlie");
Set<Integer> numbers = Set.of(1, 2, 3, 4, 5);
Map<String, Integer> ages = Map.of(
  "Alice", 30,
  "Bob", 25,
  "Charlie", 35
);

// Note: Factory methods create immutable collections

// ✅ Mutable initialization
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
Set<Integer> numbers = new HashSet<>(Set.of(1, 2, 3, 4, 5));
Map<String, Integer> ages = new HashMap<>(Map.of("Alice", 30, "Bob", 25));

Check for Empty

// ❌ Wrong - can throw NPE
if (list.size() == 0) {
  // ...
}

// ✅ Correct - handles null safely
if (list != null && list.isEmpty()) {
  // ...
}

// ✅ Better - return empty collection instead of null
public List<Order> getOrders() {
  return orders != null ? orders : Collections.emptyList();
}

// Then caller can safely check
if (getOrders().isEmpty()) {
  // ...
}

Iterate Efficiently

List<User> users = getUsers();

// ❌ Avoid index-based iteration for LinkedList
for (int i = 0; i < users.size(); i++) {
  User user = users.get(i); // O(n) for LinkedList - total O(n²)
}

// ✅ Use enhanced for-loop
for (User user : users) {
  // Process user
}

// ✅ Use streams for transformation
List<String> names = users.stream()
  .map(User::getName)
  .collect(Collectors.toList());

// ✅ Use forEach for side effects
users.forEach(user -> {
  System.out.println(user.getName());
});

Remove During Iteration

List<User> users = getUsers();

// ❌ ConcurrentModificationException
for (User user : users) {
  if (user.isInactive()) {
    users.remove(user); // Throws exception
  }
}

// ✅ Use iterator
Iterator<User> iterator = users.iterator();
while (iterator.hasNext()) {
  User user = iterator.next();
  if (user.isInactive()) {
    iterator.remove(); // Safe removal
  }
}

// ✅ Use removeIf (Java 8+)
users.removeIf(User::isInactive);

// ✅ Collect filtered stream
List<User> activeUsers = users.stream()
  .filter(user -> !user.isInactive())
  .collect(Collectors.toList());

Sort Collections

List<User> users = getUsers();

// ✅ Sort in-place (modifies original list)
users.sort(Comparator.comparing(User::getName));

// ✅ Reverse order
users.sort(Comparator.comparing(User::getName).reversed());

// ✅ Multiple criteria
users.sort(
  Comparator.comparing(User::getAge)
    .thenComparing(User::getName)
);

// ✅ Create sorted copy (preserves original)
List<User> sorted = users.stream()
  .sorted(Comparator.comparing(User::getName))
  .collect(Collectors.toList());

// ✅ Null-safe sorting
users.sort(
  Comparator.comparing(User::getName, Comparator.nullsLast(String::compareTo))
);

Search Collections

List<User> users = getUsers();

// ❌ Linear search in ArrayList
User found = null;
for (User user : users) {
  if (user.getId().equals(targetId)) {
    found = user;
    break;
  }
}

// ✅ Use Map for frequent lookups
Map<String, User> usersById = users.stream()
  .collect(Collectors.toMap(User::getId, Function.identity()));

User found = usersById.get(targetId); // O(1) lookup

// ✅ Use Stream for one-time search
Optional<User> found = users.stream()
  .filter(user -> user.getId().equals(targetId))
  .findFirst();

// ✅ Binary search (for sorted lists)
List<Integer> sortedNumbers = List.of(1, 3, 5, 7, 9, 11);
int index = Collections.binarySearch(sortedNumbers, 7); // O(log n)

Performance Optimization

Size Hints

Provide initial capacity to avoid resizing.

// ❌ Default capacity - may need to resize multiple times
List<User> users = new ArrayList<>(); // Starts with capacity 10
for (int i = 0; i < 1000; i++) {
  users.add(createUser(i)); // Resizes several times
}

// ✅ Provide initial capacity
List<User> users = new ArrayList<>(1000); // Preallocated
for (int i = 0; i < 1000; i++) {
  users.add(createUser(i)); // No resizing needed
}

// ✅ Map with expected size
Map<String, User> usersById = new HashMap<>(users.size());
for (User user : users) {
  usersById.put(user.getId(), user);
}

Bulk Operations

Use bulk operations instead of individual operations.

List<User> newUsers = loadNewUsers();
List<User> allUsers = new ArrayList<>();

// ❌ Individual adds
for (User user : newUsers) {
  allUsers.add(user); // Multiple array copies
}

// ✅ Bulk add
allUsers.addAll(newUsers); // Single operation

// ✅ Collectors.toCollection for streams
List<User> activeUsers = users.stream()
  .filter(User::isActive)
  .collect(Collectors.toCollection(ArrayList::new));

Immutable vs Mutable

Choose based on mutability needs.

// ✅ Immutable - thread-safe, can be shared
public class UserService {
  private static final List<String> ADMIN_ROLES = List.of("ADMIN", "SUPER_ADMIN");

  public boolean isAdmin(User user) {
    return user.getRoles().stream()
      .anyMatch(ADMIN_ROLES::contains);
  }
}

// ✅ Defensive copy for mutable collections
public class Order {
  private final List<Item> items;

  public Order(List<Item> items) {
    this.items = new ArrayList<>(items); // Defensive copy
  }

  public List<Item> getItems() {
    return new ArrayList<>(items); // Return copy, not internal reference
  }
}

// ✅ Unmodifiable wrapper
public List<User> getUsers() {
  return Collections.unmodifiableList(users); // Prevents modification
}

Common Pitfalls

Using Wrong Collection for Use Case

// ❌ ArrayList for frequent lookups
public class ProductCatalog {
  private List<Product> products = new ArrayList<>();

  public Product findBySku(String sku) {
    // O(n) search every time
    for (Product product : products) {
      if (product.getSku().equals(sku)) {
        return product;
      }
    }
    return null;
  }
}

// ✅ Map for O(1) lookups
public class ProductCatalog {
  private Map<String, Product> productsBySku = new HashMap<>();

  public Product findBySku(String sku) {
    return productsBySku.get(sku); // O(1)
  }
}

Modifying During Iteration

// ❌ ConcurrentModificationException
Map<String, Integer> scores = new HashMap<>();
for (String key : scores.keySet()) {
  if (scores.get(key) < 10) {
    scores.remove(key); // Throws exception
  }
}

// ✅ Collect keys to remove first
Set<String> toRemove = scores.entrySet().stream()
  .filter(entry -> entry.getValue() < 10)
  .map(Map.Entry::getKey)
  .collect(Collectors.toSet());

toRemove.forEach(scores::remove);

// ✅ Use removeIf on values
scores.entrySet().removeIf(entry -> entry.getValue() < 10);

Not Overriding hashCode and equals

// ❌ Using object in Set without proper equals/hashCode
public class User {
  private String id;
  private String name;
  // No equals/hashCode override
}

Set<User> users = new HashSet<>();
users.add(new User("1", "Alice"));
users.add(new User("1", "Alice")); // Both added - treated as different

// ✅ Override equals and hashCode
public class User {
  private String id;
  private String name;

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    User user = (User) o;
    return Objects.equals(id, user.id);
  }

  @Override
  public int hashCode() {
    return Objects.hash(id);
  }
}

Set<User> users = new HashSet<>();
users.add(new User("1", "Alice"));
users.add(new User("1", "Alice")); // Only one added - same ID

Collections Utility Methods

// Sorting
Collections.sort(list);
Collections.reverse(list);
Collections.shuffle(list);

// Searching
int index = Collections.binarySearch(sortedList, target);

// Min/Max
Integer min = Collections.min(numbers);
Integer max = Collections.max(numbers);

// Frequency
int count = Collections.frequency(list, element);

// Fill
Collections.fill(list, "default");

// Rotate
Collections.rotate(list, 2); // Shift elements

// Empty immutable collections
List<String> empty = Collections.emptyList();
Set<String> empty = Collections.emptySet();
Map<String, String> empty = Collections.emptyMap();

// Singleton collections
List<String> single = Collections.singletonList("one");
Set<String> single = Collections.singleton("one");
Map<String, String> single = Collections.singletonMap("key", "value");

// Unmodifiable wrappers
List<String> readonly = Collections.unmodifiableList(list);
Set<String> readonly = Collections.unmodifiableSet(set);
Map<String, String> readonly = Collections.unmodifiableMap(map);

Summary

Effective collection usage starts with understanding your access patterns and choosing the right tool for the job. When you need random access to elements by index and insertions mostly happen at the end, ArrayList gives you the best performance. If you’re frequently inserting or removing elements at the beginning or middle, LinkedList’s pointer-based structure shines. For uniqueness guarantees with fast lookups, HashSet provides constant-time operations without any ordering. When you need both uniqueness and sorted order, TreeSet maintains elements in order at the cost of logarithmic operations.

Key-value associations call for Map implementations. HashMap delivers the fastest lookups when you don’t care about order. TreeMap keeps keys sorted, enabling range queries and ordered iteration. LinkedHashMap sits in the middle, preserving insertion order while maintaining fast lookups. Choose based on whether you value speed, sorting, or order preservation.

Beyond choosing the right collection, several practices improve your code’s performance and maintainability. When you know the expected size, provide capacity hints to avoid expensive resizing operations. Use bulk operations like addAll instead of repeated individual adds - they’re both clearer and more efficient.

Never return null for collections - return empty collections instead. This simple practice eliminates null checks throughout your codebase and makes iteration safe by default. For custom objects used in Sets or as Map keys, always override equals and hashCode properly. These methods determine how your objects behave in collections, and getting them wrong leads to subtle bugs.

Use immutable collections for constants and shared data - they’re thread-safe without synchronization and communicate that the data won’t change. Most importantly, regularly review whether your collection choice matches your actual access pattern. The collection you chose initially might not be optimal as your code evolves, and switching to a better fit can dramatically improve performance.

Related Content

Last updated