Arrays of Objects: Holding and Iterating Many Instances

In the Arrays and String Handling in Java lesson you learned to store many values of the same type in an array. In the OOP Fundamentals: Classes, Objects, and Attributes and Constructors, Access Modifiers, and Getters/Setters lessons you learned to design a class that guarantees every object is born valid and stays consistent.

This lesson joins the two, and that intersection is where Java starts being useful for real work:

Person p1 = new Person("Laura", 28);
Person p2 = new Person("Carlos", 35);
Person p3 = new Person("Ana", 41);
// and if there are 500?

Exactly the problem that led you to arrays in the Arrays and String Handling in Java lesson, but now with objects. The solution is the same —an array— except for one detail that changes everything: an array of objects does not hold objects, it holds references to objects.

That detail is responsible for the most common NullPointerException in all of Java, for two slots being able to modify the same object without you noticing, and for Arrays.sort refusing to work until you explain how to compare. Let’s take it apart.


1. An array of objects has two levels of memory

An int[] holds the numbers inside it. A Person[] holds addresses pointing at objects that live elsewhere on the Heap.

An array of primitives holds the values; an array of objects holds references to external objects int[] ages = {28, 35, 41}; 28 35 41 One level. The value sits inside the slot. Person[] people = {new Person("Laura", 28), ...}; ref ref ref Person name="Laura" age=28 Person name="Carlos" age=35 Person name="Ana" age=41 Two levels. The slot only points.
An array of objects is an array of arrows. The objects are independent of it: the array can disappear and the objects survive, and one object can be pointed at from several slots.

Three rules follow from that indirection, and they govern the rest of the lesson:

  • Creating the array does not create the objects. That is two steps, not one.
  • Copying a slot copies the arrow, not the object. Two slots can point at the same place.
  • A slot can point at nothing. That “nothing” is called null, and it is the number-one source of errors.

2. The two steps of creation

This is the opening mistake of everyone starting with object arrays:

Person[] people = new Person[3];

System.out.println(people.length);        // 3    — the array exists
System.out.println(people[0]);            // null — but it is empty
System.out.println(people[0].getName());  // NullPointerException

new Person[3] reserves three slots capable of pointing at a Person. It builds no Person at all. It is the difference between buying three envelopes and writing three letters.

Creating the array and creating the objects are two distinct operations 1 new Person[3] Reserves three slots. All three are null: there is no object yet. null null null length = 3 2 people[i] = new Person(...) Only now are the objects built and each slot starts pointing at one. Laura 28 years old Carlos 35 years old Ana 41 years old length = 3 Skip step 2 for any slot and that slot stays null, blowing up the first time you use it.
With primitives step 2 does not exist: new int[3] already hands you three usable zeroes. With objects, the array is born empty and filling it is your job.

The three ways to fill it:

// A) Literal: array and objects in a single expression
Person[] people = {
    new Person("Laura", 28),
    new Person("Carlos", 35),
    new Person("Ana", 41)
};

// B) Slot by slot
Person[] team = new Person[3];
team[0] = new Person("Laura", 28);
team[1] = new Person("Carlos", 35);
team[2] = new Person("Ana", 41);

// C) In a loop, which is the real-world case
String[] names = {"Laura", "Carlos", "Ana"};
int[] ages = {28, 35, 41};

Person[] roster = new Person[names.length];
for (int i = 0; i < roster.length; i++) {
    roster[i] = new Person(names[i], ages[i]);
}

Notice what form C makes possible: the validating constructor from the Constructors, Access Modifiers, and Getters/Setters lesson runs once per object. If any input datum is invalid, the constructor replaces it with a safe default and logs a notice: the object is still born, just never with broken data. Without constructors you would have to create three empty objects and fill them afterwards — precisely the window of broken objects that lesson set out to close.


3. null in the slots: Java’s most common error

A partially filled array is a time bomb:

Person[] people = new Person[5];
people[0] = new Person("Laura", 28);
people[1] = new Person("Carlos", 35);
// slots 2, 3 and 4 stayed null

for (Person p : people) {
    System.out.println(p.getName());   // NullPointerException on the third pass
}

You have three defenses, in order of preference:

// 1. Best: do not have extra slots at all
Person[] people = new Person[2];   // create exactly as many as you will fill

// 2. If you cannot, check before using
for (Person p : people) {
    if (p != null) {
        System.out.println(p.getName());
    }
}

// 3. If null should never happen, fail loudly and early
import java.util.Objects;

public void register(Person p) {
    this.person = Objects.requireNonNull(p, "The person cannot be null");
}

Option 2 is a patch, not a fix. An array with null inside almost always means you actually needed capacity and count as separate things — that is section 8, and it is the doorway into the List ADT.


4. Iterating and printing

Iterating works just as with primitives, except each element now has methods.

for (Person p : people) {
    System.out.println(p.getName() + " is " + p.getAge() + " years old");
}

// With an index, when you need to replace a slot's reference
for (int i = 0; i < people.length; i++) {
    if (people[i].getAge() < 18) {
        people[i] = new Person(people[i].getName(), 18);
    }
}

toString(): without it, printing an object array is useless

System.out.println(Arrays.toString(people));
// [Person@1b6d3586, Person@4554617c, Person@74a14482]

What you see is the toString() inherited from Object: class name, at sign, and the hash in hexadecimal. Useless. The fix is to override it in your class:

public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        if (name == null || name.isBlank()) {
            System.out.println("Invalid name, used \"No name\" as default.");
            name = "No name";
        }
        if (age < 0 || age > 130) {
            System.out.println("Age out of range, used 0 as default.");
            age = 0;
        }
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge()     { return age; }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}
System.out.println(Arrays.toString(people));
// [Laura (28), Carlos (35), Ana (41)]

Write toString() on every class you plan to put in an array. It costs three lines and saves hours of blind debugging.


5. Searching inside an array of objects

With primitives you searched for a value. With objects you search for the object whose field satisfies something, and that is always a hand-written linear scan:

public static Person findByName(Person[] people, String name) {
    for (Person p : people) {
        if (p != null && p.getName().equalsIgnoreCase(name)) {
            return p;      // found: stop here
        }
    }
    return null;           // scanned everything, not there
}

Three design decisions worth more than the code itself:

  • equalsIgnoreCase, never ==. You are comparing the contents of two Strings, and the Arrays and String Handling in Java lesson already showed why == betrays you the moment the text comes from outside.
  • p != null first. The order matters: evaluating p.getName() before the check blows up.
  • Returning null when absent. That is this lesson’s choice: the caller takes on the responsibility of checking the result before using it.
Person found = findByName(people, "Ana");
if (found != null) {
    System.out.println("Found: " + found);
} else {
    System.out.println("Not on the roster");
}

Forgetting that if (found != null) is the most common way to turn a “not found” into a NullPointerException three lines later.


6. Sorting: writing the criterion by hand

With int[], Arrays.sort(numbers) was enough, because numbers have an obvious order. With objects there is none: are two people ordered by name, by age, by hire date? Someone has to decide, and for now that someone is you: Arrays.sort(people) compiles, but blows up at runtime because Person does not know how to compare itself.

Java has a mechanism to declare that criterion once and reuse it in any Arrays.sort call —you will see it in the Iterators, Ordering, and the equals/hashCode Contract lesson—. Until then, you sort by hand: walk the array and compare the chosen field with < and >, the same way you would sort an int[] if Arrays.sort did not exist.

Selection is the simplest algorithm to write by hand: on every pass you find the smallest-valued element in the rest of the array and move it to the front.

public static void sortByAge(Person[] people) {
    for (int i = 0; i < people.length - 1; i++) {
        int smallestIndex = i;
        for (int j = i + 1; j < people.length; j++) {
            if (people[j].getAge() < people[smallestIndex].getAge()) {
                smallestIndex = j;
            }
        }
        if (smallestIndex != i) {
            Person temp = people[i];
            people[i] = people[smallestIndex];
            people[smallestIndex] = temp;
        }
    }
}
sortByAge(people);
System.out.println(Arrays.toString(people));
// [Laura (28), Carlos (35), Ana (41)]

Notice the swap moves references, not objects: temp holds an arrow, not a copy of Person. Sorting an array of objects never duplicates what the slots point at.

To sort by another field —the name, for instance— you would repeat the same loop and change only the if condition. Repeating that loop once per criterion, with no guarantee about what happens to ties if you also need to break them by a second field, is exactly the problem the Iterators, Ordering, and the equals/hashCode Contract lesson solves: you will declare the ordering criterion once and hand it to Arrays.sort, without repeating the loop.

An object array with null inside breaks any hand-written sort with a NullPointerException, because people[j].getAge() blows up the moment j lands on an empty slot. One more reason not to leave holes.


7. Aliasing: the slot points, it does not contain

This is the least intuitive consequence of the two levels of memory.

Two slots pointing at the same object share every change team[2] = team[0]; team[2].haveBirthday(); team [0] ref [1] ref [2] ref Person "Laura" age: 28 → 29 (changed only once) Person "Carlos" team[0] and team[2] are two arrows to the SAME object. Change it through one and the other sees it: team[0].getAge() is 29. The object originally in slot [2] is left unreferenced and the garbage collector takes it. To get two independent objects you must construct a new one, not copy the reference.
This is the same shallow copy you saw in the [Arrays and String Handling in Java](/en/courses/java/04-arrays-y-strings) lesson, but inside a single array. Assigning one slot to another never duplicates the object.
Person[] team = {
    new Person("Laura", 28),
    new Person("Carlos", 35),
    new Person("Ana", 41)
};

team[2] = team[0];            // now [0] and [2] point at the SAME object
System.out.println(team[0] == team[2]);   // true

The same happens when copying the whole array:

Person[] copy = Arrays.copyOf(team, team.length);

System.out.println(copy == team);          // false — two distinct arrays
System.out.println(copy[0] == team[0]);    // true  — they share the objects

A deep copy has to be written by hand:

Person[] deep = new Person[team.length];
for (int i = 0; i < team.length; i++) {
    deep[i] = new Person(team[i].getName(), team[i].getAge());
}

This problem disappears if your class is immutable —final fields and no setters, like the Person above. If nobody can modify the object, sharing the reference cannot do harm. It is the same reason you never suffer aliasing with String.


8. Capacity versus count: the partially filled array

Most of the time you do not know upfront how many objects you will store. The classic solution is to reserve extra and keep a count of how many slots are actually in use.

Capacity is the array's length; count is how many slots are actually occupied Person[] data = new Person[6]; int count = 3; Laura [0] Carlos [1] Ana [2] null [3] null [4] null [5] count = 3 — the part that actually exists data.length = 6 — the reserved capacity Golden rule: iterate up to count, never up to data.length for (int i = 0; i less than count; i++) → that way you never touch a null slot. A class wrapping this array and this counter, exposing add/remove/get, is exactly The List ADT: Static, Dynamic, and Linked.
Separating capacity from count is the conceptual step that turns a loose array into a data structure. The List ADT from the [The List ADT: Static, Dynamic, and Linked](/en/courses/java/11-tad-listas-estaticas-y-dinamicas) lesson does exactly this internally.
public class Registry {
    private Person[] data = new Person[6];
    private int count = 0;                    // how many slots are in use

    public void add(Person p) {
        if (count == data.length) {
            // full: double the capacity (see the Arrays and String Handling in Java lesson, section 4)
            data = Arrays.copyOf(data, data.length * 2);
        }
        data[count] = p;
        count++;
    }

    public boolean remove(int index) {
        if (index < 0 || index >= count) {
            System.out.println("Invalid index: " + index);
            return false;
        }
        // shift everything to the right of it one position left
        for (int i = index; i < count - 1; i++) {
            data[i] = data[i + 1];
        }
        data[count - 1] = null;   // release the leftover reference
        count--;
        return true;
    }

    public int getCount() {
        return count;
    }

    public void list() {
        for (int i = 0; i < count; i++) {     // up to count, not up to length
            System.out.println(data[i]);
        }
    }
}

Pay attention to data[count - 1] = null; in remove. Without that line the program still works, but the last slot keeps pointing at an object nobody uses any more and the garbage collector cannot free it. It is a small, silent, entirely avoidable memory leak.


9. An object array as a field: composition and defensive copies

The most frequent case is not a loose array in main, it is an array inside another class:

public class Course {
    private final String name;
    private final Person[] students;

    public Course(String name, Person[] students) {
        this.name = name;
        this.students = students;        // BAD
    }

    public Person[] getStudents() {
        return students;                 // BAD
    }
}

Those two marked lines void the encapsulation from the Constructors, Access Modifiers, and Getters/Setters lesson, for the same reason as section 7: the arrow is being shared.

Person[] list = { new Person("Laura", 28) };
Course course = new Course("Java", list);

list[0] = new Person("Intruder", 99);      // modified the array from outside
System.out.println(course.getStudents()[0]);   // Intruder (99)

course.getStudents()[0] = null;            // and through the getter too

The fix is to copy on the way in and on the way out:

public Course(String name, Person[] students) {
    this.name = name;
    this.students = Arrays.copyOf(students, students.length);   // copy on entry
}

public Person[] getStudents() {
    return Arrays.copyOf(students, students.length);            // copy on exit
}

With an immutable Person, a shallow copy is enough: nobody can modify the objects being pointed at. If Person had setters, deep copies would be required to truly shield the Course.


10. Common mistakes

  • Believing new Person[3] creates three people. It creates three nulls.
  • Iterating up to length when you keep a counter. Iterate up to count.
  • Not overriding toString(). You print hex hashes and debug blind.
  • Treating an object array as if Arrays.sort could order it on its own. With objects you need to decide the comparison field and write the loop yourself.
  • Sorting an array with null inside. NullPointerException the moment the comparison touches an empty slot.
  • Comparing objects with ==. That compares identity. Content needs equals() — its full contract arrives in the Iterators, Ordering, and the equals/hashCode Contract lesson.
  • Writing a[i] = a[j] believing it copies. It copies the reference; you end up with two arrows to one object.
  • Exposing a class’s internal array. A private is worth nothing if the getter hands out the reference.
  • Forgetting data[count - 1] = null on removal. It keeps alive a reference the GC cannot free.

11. Guided hands-on exercises

Exercise 1 — Roster statistics

Given a Person[] with no nulls, report the youngest person, the oldest, and the average age in a single pass.

View suggested solution
public class Statistics {

    public static void report(Person[] people) {
        if (people == null || people.length == 0) {
            System.out.println("No people to analyse");
            return;
        }

        // Start from the first element, not from invented values
        Person youngest = people[0];
        Person oldest = people[0];
        long ageSum = 0;

        for (Person p : people) {
            if (p.getAge() < youngest.getAge()) youngest = p;
            if (p.getAge() > oldest.getAge())   oldest = p;
            ageSum += p.getAge();
        }

        double average = (double) ageSum / people.length;

        System.out.println("Youngest: " + youngest);
        System.out.println("Oldest: " + oldest);
        System.out.println("Average: %.1f years".formatted(average));
    }

    public static void main(String[] args) {
        Person[] roster = {
            new Person("Laura", 28),
            new Person("Carlos", 35),
            new Person("Ana", 41)
        };
        report(roster);
    }
}

Note that youngest holds the reference to the object, not the age. That way, when the scan finishes you have the whole person rather than just a number: you can print their name without searching again.

Exercise 2 — Filter into a new array

Write a method that takes a Person[] and returns another array holding only the people above a given age. The result must have no leftover slots.

View suggested solution
import java.util.Arrays;

public static Person[] olderThan(Person[] people, int minimumAge) {
    // 1. Reserve the maximum possible capacity
    Person[] result = new Person[people.length];
    int count = 0;

    // 2. Fill only with those who qualify
    for (Person p : people) {
        if (p != null && p.getAge() >= minimumAge) {
            result[count] = p;
            count++;
        }
    }

    // 3. Trim to the real count: without this step nulls remain at the end
    return Arrays.copyOf(result, count);
}

The three steps are the standard array-filtering pattern: over-reserve, count, trim. Step 3 is the one usually missing, and it is what stops the returned array from carrying nulls that blow up later.

Note too that the resulting array shares its objects with the original (aliasing, section 7). For this case that is correct: filtering should not duplicate people.

Exercise 3 — Sorting by age, descending

Sort a Person[] from oldest to youngest, without using Arrays.sort.

View suggested solution
import java.util.Arrays;

public class SortPeople {
    public static void main(String[] args) {
        Person[] people = {
            new Person("Laura", 35),
            new Person("Ana", 41),
            new Person("Bruno", 35),
            new Person("Carlos", 28)
        };

        for (int i = 0; i < people.length - 1; i++) {
            int largestIndex = i;
            for (int j = i + 1; j < people.length; j++) {
                if (people[j].getAge() > people[largestIndex].getAge()) {
                    largestIndex = j;
                }
            }
            if (largestIndex != i) {
                Person temp = people[i];
                people[i] = people[largestIndex];
                people[largestIndex] = temp;
            }
        }

        System.out.println(Arrays.toString(people));
        // [Ana (41), Laura (35), Bruno (35), Carlos (28)]
    }
}

Laura and Bruno tie on age (35). This loop, written as it is, gives no guarantee about the relative order of ties —in this particular run Laura ends up before Bruno, but that is a consequence of the swaps, not a rule of the algorithm. Breaking ties predictably by a second criterion, such as name, without rewriting the whole loop every time, is exactly what you will learn to do in the Iterators, Ordering, and the equals/hashCode Contract lesson.

Exercise 4 — An address book with dynamic capacity

Implement an AddressBook class that stores Contact objects in an internal array, grows on its own when full, and supports adding, searching by name, and removing by index.

View suggested solution
import java.util.Arrays;

public class AddressBook {
    private Contact[] contacts = new Contact[4];
    private int count = 0;

    public boolean add(Contact c) {
        if (c == null) {
            System.out.println("The contact cannot be null, it was not added.");
            return false;
        }
        if (count == contacts.length) {
            contacts = Arrays.copyOf(contacts, contacts.length * 2);
        }
        contacts[count++] = c;
        return true;
    }

    public Contact find(String name) {
        for (int i = 0; i < count; i++) {
            if (contacts[i].getName().equalsIgnoreCase(name)) {
                return contacts[i];
            }
        }
        return null;
    }

    public boolean remove(int index) {
        if (index < 0 || index >= count) {
            System.out.println("Index " + index + " out of range [0, " + (count - 1) + "].");
            return false;
        }
        for (int i = index; i < count - 1; i++) {
            contacts[i] = contacts[i + 1];
        }
        contacts[--count] = null;
        return true;
    }

    public int getCount() {
        return count;
    }

    public Contact[] list() {
        // defensive, trimmed copy: no internal references, no leftover nulls
        return Arrays.copyOf(contacts, count);
    }
}

This class is already, conceptually, a miniature dynamic list: internal array, doubling capacity, logical size separated from physical size, and shifting on removal. In the The List ADT: Static, Dynamic, and Linked lesson you will formalise it as a List ADT and compare it with the linked version.


Key takeaways

  • An array of objects holds references, not objects. Everything odd about it follows from that.
  • new Person[3] creates three nulls. Creating the array and creating the objects are two steps.
  • An array with empty slots is the cause of Java’s most frequent NullPointerException.
  • Override toString() on every class you store in an array: without it, printing tells you nothing.
  • Searching is always a hand-written linear scan; if you return null when nothing matches, the caller must check != null before using the result.
  • Sorting objects demands choosing a criterion and comparing it field by field in your own loop; the Iterators, Ordering, and the equals/hashCode Contract lesson shows how to declare that criterion once and reuse it.
  • Assigning one slot to another does not copy the object: you get two arrows to the same place.
  • A class’s internal array is copied on the way in and on the way out, or private protects nothing.
  • Separating capacity from count is what turns an array into a data structure. That is the starting point of the List ADT.