You closed module 4 with a promise and a confession. The promise: BiblioTech's provisional arrays will become collections. The confession: every time the project needs to store many things, it falls back on a Material[] catalog = new Material[10] that has to be copied whole to add an element and walked end to end to find one. Before replacing them it is worth understanding them properly, because the array does not go away: ArrayList is an array inside, HashMap is an array of buckets, ArrayDeque is a circular array, and String is an array of bytes. The whole Collections Framework you will see in the next eight lessons is built on arrays.

An array (the term we will use almost always, because it is the one you will see in all real code) is Java's most primitive and fastest data structure: a contiguous block of memory, of fixed size, that holds elements of the same type and lets you reach any one of them by position in constant time. Those four words —contiguous, fixed, homogeneous, constant— explain at once all its power and all its limitations. In this lesson you will see them in detail, you will finally learn the for-each loop left pending in module 2, you will master the Arrays utility class and you will finish with a clear list of the things an array cannot do: exactly the list of reasons why the Collections Framework exists.

Contents

  1. What an array really is
  2. Declaring, creating and initialising
  3. Default values
  4. Indices, length and the bounds of the array
  5. Traversal with a classic for
  6. The for-each loop
  7. Arrays of primitives versus arrays of objects
  8. Multidimensional and jagged arrays
  9. The Arrays class
  10. Copying arrays
  11. Varargs is an array underneath
  12. Application: the BiblioTech catalogue
  13. The limitations that motivate collections
  14. Common Mistakes and Tips
  15. Exercises

  1. What an array really is

An array is a contiguous block of memory reserved in one go, divided into cells of the same size. When you write:

int[] daysLate = new int[5];

the JVM reserves enough space on the heap for five integers (five times four bytes) plus a small header with the type and the length. All the cells sit one right behind the other:

flowchart LR
    subgraph heap["Array object on the heap"]
        direction LR
        H["header<br/>type: int[]<br/>length: 5"]
        C0["[0]<br/>0"]
        C1["[1]<br/>0"]
        C2["[2]<br/>0"]
        C3["[3]<br/>0"]
        C4["[4]<br/>0"]
    end
    ref["daysLate<br/>(reference on the stack)"] --> H

That contiguity is the key to everything. To read daysLate[3], the JVM does not search for anything: it computes base_address + 3 * 4 bytes and reads. One multiplication and one addition, always the same work, whether the array has five elements or five million. We call that constant-time access, or O(1).

O() notation: how to read it. Throughout this module you will compare data structures by their cost. O() notation describes how the time of an operation grows as the number of elements n grows, ignoring constants and machine details. O(1) means constant time: whether there are 10 or 10 million elements, it costs the same. O(n) means linear: twice the elements, twice the time (walking a whole array). O(log n) means that doubling the elements adds just one more step (binary search: with a million elements, about 20 steps). O(n²) means that doubling the elements quadruples the time (two nested loops; that is what BiblioTech's reportByType does today). It is not a measure of seconds, it is a measure of how it scales.

And there is a second, less obvious advantage: cache locality. Modern processors do not read memory byte by byte, but in blocks (cache lines, typically 64 bytes). When reading daysLate[0], the processor brings in the following elements "for free" as well. That is why walking an array is dramatically faster than walking a structure whose elements are scattered around memory: you will see it with numbers in 05-04, when you compare ArrayList with LinkedList.

The three properties that define an array in Java:

Property What it means Consequence
Fixed size Decided when it is created and cannot be changed To "add" you have to create another array and copy
Homogeneous type All elements are of the same declared type The compiler guarantees what is inside
It is an object It lives on the heap; the variable holds a reference It is passed by reference, accepts null, inherits from Object

That third point surprises a lot of people: an array is an object, even though there is no Array class you can look at. int[], String[] and Material[] are real types that inherit from Object, so an array has hashCode(), toString() and getClass(). That will have important consequences in section 9.

  1. Declaring, creating and initialising

These are three distinct operations and it pays not to confuse them.

Declaring only creates the variable that will hold the reference. There is no array yet:

Material[] catalog;         // recommended form: the brackets next to the TYPE
Material catalog2[];        // legal, inherited from C, DISCOURAGED

Always use the first form. The second compiles, but hides information: in Material catalog[], other;, catalog is an array and other is not, which is a classic source of confusion.

Creating reserves the memory with new, and this is where the size is fixed forever:

Material[] catalog  = new Material[10];      // 10 cells, all null
int[]      daysLate = new int[5];            // 5 cells, all 0

The size can be a variable computed at run time, which is flexible enough:

int howMany = Integer.parseInt(input.nextLine());
Material[] selection = new Material[howMany];

What it cannot do is change afterwards. selection will be howMany long forever.

Initialising with a literal creates and fills in a single expression, and the size is inferred from the elements:

String[] employees = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };   // length = 3
double[] rates     = { 0.25, 0.10, 0.50 };

This shorthand only works in the declaration. If you want to assign an array literal to an already declared variable, or pass it directly as an argument, you need the full form with new:

String[] employees;
// employees = { "Marta Ruiz" };                    // COMPILATION error
employees = new String[] { "Marta Ruiz" };          // correct

register(new String[] { "Marta Ruiz", "Diego Alonso" });   // correct as an argument

  1. Default values

When you create an array with new, Java fills every cell with the default value for the type. This is no minor detail: it means a freshly created array never contains garbage, unlike what happens in C.

Element type Default value
byte, short, int, long 0
float, double 0.0
char '\u0000' (null character, printed as a blank or a square)
boolean false
Any reference type (String, Material, Integer...) null

The most important practical consequence is the last row. A new Material[10] does not contain ten materials: it contains ten null references. Filling them is still your job:

Material[] catalog = new Material[3];
System.out.println(catalog[0]);              // null
// catalog[0].getTitle();                    // NullPointerException

catalog[0] = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
System.out.println(catalog[0].getTitle());   // Effective Java

It also explains the classic trap of counting elements: if you create new Material[10] and only fill three, catalog.length is 10, not 3. The array does not know how many cells you have used; that is why the whole BiblioTech of module 4 drags around helper variables such as int n = 0 to keep the count by hand. Collections remove exactly that problem.

  1. Indices, length and the bounds of the array

Array indices run from 0 to length - 1. Starting at 0 is not a whim: the index is literally the offset from the start of the block. The first element is at distance zero from the base.

String[] employees = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };

System.out.println(employees.length);        // 3
System.out.println(employees[0]);            // Marta Ruiz     (the first)
System.out.println(employees[2]);            // Nuria Vidal    (the last)
System.out.println(employees[employees.length - 1]);   // Nuria Vidal, the standard idiom

length is a field, not a method. You write array.length, without parentheses. It is one of Java's historical inconsistencies that causes the most confusion, because for String it is a method (text.length()) and for collections it is yet another method (list.size()). Memorise all three:

Type How you get the size
Array array.length — field, no parentheses
String text.length() — method
Collection (List, Set, Map...) collection.size() — method

If you go out of range, the JVM detects it and throws ArrayIndexOutOfBoundsException:

System.out.println(employees[3]);      // ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
System.out.println(employees[-1]);     // ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3

This is a virtue, not a defect: Java checks every access and stops the program at the exact point of the error, instead of silently reading somebody else's memory the way C would. The cost of that check is minimal and the JIT compiler removes it when it can prove the index is safe. How to catch and handle that exception is the subject of module 6; for now, avoid it by checking the bounds before you access.

  1. Traversal with a classic for

The for loop you have known since 02-02 is the complete way to walk an array, and it is still the right one when you need the index:

double[] fines = { 3.75, 0.0, 12.50, 1.25 };

for (int i = 0; i < fines.length; i++) {
    System.out.printf("Loan %d -> fine %.2f EUR%n", i + 1, fines[i]);
}

Three details that prevent most traversal bugs:

  • i < fines.length, never i <= fines.length. The last valid index is length - 1.
  • Use fines.length, not a literal constant such as 4. If the array grows tomorrow, the loop adapts on its own.
  • Start at 0, unless you have an explicit reason not to.

The classic for is essential when the traversal needs more than the values:

// Backwards traversal
for (int i = fines.length - 1; i >= 0; i--) { ... }

// Two at a time
for (int i = 0; i < fines.length; i += 2) { ... }

// Compare each element with the next one (mind the bound)
for (int i = 0; i < fines.length - 1; i++) {
    if (fines[i] > fines[i + 1]) { ... }
}

// MODIFY the contents of the array
for (int i = 0; i < fines.length; i++) {
    fines[i] = Math.min(fines[i], 20.0);      // apply the fine cap
}

That last one is the most important to remember for the next section.

  1. The for-each loop

In module 2 the for-each was postponed because there was no point explaining it without something to walk. Now you have it.

The enhanced for loop or for-each (Java 5) walks every element of an array or of any Iterable object —which includes all the collections you will see in this module— without managing indices:

for (Type element : collectionOrArray) {
    // use 'element'
}

It reads literally as "for each element of type Type in collectionOrArray". The colon is pronounced "in".

Compare the two versions of the same traversal:

String[] employees = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };

// classic for: 3 pieces that can go wrong (start, condition, increment)
for (int i = 0; i < employees.length; i++) {
    System.out.println("Employee: " + employees[i]);
}

// for-each: no index, no condition, no increment
for (String name : employees) {
    System.out.println("Employee: " + name);
}

And with the project's objects, where it really shines:

Material[] catalog = {
    new Book("Effective Java",   "Joshua Bloch",  "978-0000000001", 2018),
    new Book("Design Patterns",  "Erich Gamma",   "978-0000000002", 1994),
    new Magazine("Java Magazine", "REV-2024-03",  42, "Monthly"),
    new Dvd("Refactoring Live",  "DVD-0007",      95)
};

for (Material m : catalog) {
    System.out.println(m.describe());      // polymorphism from 03-06, without a single index
}

Advantages

  • Less code and fewer mistakes. The three classic sources of failure disappear: starting at 1, ending with <=, forgetting the i++ (infinite loop).
  • Impossible to go out of range. There is no index to get wrong: ArrayIndexOutOfBoundsException cannot happen.
  • It expresses the intent. "I walk every element, one by one, forwards." Whoever reads your code knows it instantly.
  • It works the same with arrays and with collections. The same loop serves Material[] and List<Material>, which will make the refactoring in section 12 painless.

Limitations

And now the three things it cannot do, which are the reason the classic for still exists:

1. It does not give you the index. If you need to number, compare with the neighbour or write at position i, you need the classic for (or keep a separate counter, which is exactly what the for-each was trying to avoid).

int position = 1;
for (Material m : catalog) {
    System.out.println(position + ". " + m.getTitle());
    position++;                                   // works, but betrays that you wanted a classic for
}

2. It cannot modify the array cell. This is the limitation that is most often misunderstood, so it is worth seeing precisely:

int[] numbers = { 1, 2, 3 };

for (int n : numbers) {
    n = n * 10;                    // does NOTHING to the array
}
System.out.println(Arrays.toString(numbers));    // [1, 2, 3]

The variable n is a copy of the cell's value (pass by value, 03-03). Reassigning it changes the copy, not the array. The same happens with objects:

for (Material m : catalog) {
    m = new Book("Other", "Nobody", "REF-X", 2020);   // does NOT change catalog[i]
}

m is a copy of the reference. Reassigning it points the copy at another object; the array cell still points at the original.

But you can modify the object it points to, because there you reassign nothing, you invoke methods on the same object:

for (Material m : catalog) {
    m.lend();                    // this DOES affect the real object in the catalogue
}

The rule, in one line: the for-each cannot change what each cell points at, but it can change the state of the object pointed at.

3. It only goes forwards and one at a time. There is no reverse traversal, no jumps, no partial traversals.

When to use each one

Situation Recommended loop
Read every element, in order for-each
You need the index (numbering, position, comparing neighbours) classic for
Writing into the cells (array[i] = ...) classic for
Traversing backwards or with jumps classic for
Traversing only part of it classic for
Traversing a collection and removing as you go Neither: Iterator or removeIf (05-02)

Use for-each by default and drop down to the classic for only when you need what the for-each does not give you. In 05-02 you will see that the for-each is not a magic construct: the compiler translates it into a classic for when it walks an array, and into an Iterator when it walks a collection.

  1. Arrays of primitives versus arrays of objects

Here is the structural difference that explains half of the surprises with arrays: what each cell really holds.

In an array of primitives, the cell contains the value:

int[] years = { 2018, 1994, 1999 };

In an array of objects, the cell contains a reference to the object, which lives elsewhere on the heap:

Material[] catalog = new Material[3];
catalog[0] = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
catalog[1] = new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly");
// catalog[2] is still null
flowchart LR
    subgraph prim["int[] years — the values are INSIDE"]
        direction LR
        P0["[0] 2018"]
        P1["[1] 1994"]
        P2["[2] 1999"]
    end

    subgraph obj["Material[] catalog — references"]
        direction LR
        O0["[0] ref"]
        O1["[1] ref"]
        O2["[2] null"]
    end

    L["Book<br/>Effective Java<br/>978-0000000001"]
    R["Magazine<br/>Java Magazine<br/>REV-2024-03"]

    O0 --> L
    O1 --> R

Four consequences follow from that diagram, and they are worth keeping very clear:

  1. An array of objects is smaller than it looks but more expensive to walk. The cells only hold references (4 or 8 bytes), but following each reference means a jump to another area of the heap, which breaks the cache locality of section 1.
  2. Two cells can point at the same object (aliasing, 03-02). catalog[2] = catalog[0]; does not copy the book: it creates a second path to it. Modifying it through one route is visible through the other.
  3. null is a legitimate value in the cells, and every access to catalog[i].something() can throw NullPointerException if that cell was never filled.
  4. The array is covariant, and that has a trap. Material[] accepts any subclass of Material (Book, Magazine, Dvd), which is convenient and is what makes the polymorphic catalogue possible. But it also allows this:
Object[] things = new Book[2];              // COMPILES: Book[] is an Object[]
things[0] = new Magazine("Java Magazine", "REV-1", 1, "Monthly");   // ArrayStoreException at RUN TIME

The compiler accepts it because Book[] is a subtype of Object[], but the JVM checks the real type on every write and throws ArrayStoreException. It is a type-safety hole that Java 1.0 accepted out of pragmatism and that generics fixed: List<Book> is not a List<Object>, precisely so that this error is caught at compile time. The full theory (invariance, wildcards, type erasure) is lesson 10-01.

  1. Multidimensional and jagged arrays

In Java there are no true two-dimensional arrays: what exists are arrays whose elements are, in turn, arrays. Understanding that clears up every doubt at once.

int[][] loansPerMonth = new int[3][12];     // 3 material types x 12 months

This creates an array of 3 elements, each of which is a reference to an array of 12 integers:

flowchart LR
    M["loansPerMonth<br/>int[][] length=3"]
    F0["[0] → int[12]"]
    F1["[1] → int[12]"]
    F2["[2] → int[12]"]
    M --> F0
    M --> F1
    M --> F2
    F0 --> A0["0 0 0 0 0 0 0 0 0 0 0 0"]
    F1 --> A1["0 0 0 0 0 0 0 0 0 0 0 0"]
    F2 --> A2["0 0 0 0 0 0 0 0 0 0 0 0"]

Access takes two indices, in row-column order:

loansPerMonth[0][3] = 12;                    // type 0 (books), month 3 (April)
System.out.println(loansPerMonth.length);           // 3   -> number of rows
System.out.println(loansPerMonth[0].length);        // 12  -> length of row 0

The natural traversal is two nested loops, one per dimension:

String[] types = { "Books", "Magazines", "DVDs" };

for (int t = 0; t < loansPerMonth.length; t++) {
    int total = 0;
    for (int month = 0; month < loansPerMonth[t].length; month++) {
        total += loansPerMonth[t][month];
    }
    System.out.printf("%-10s %3d loans a year%n", types[t], total);
}

Or, if you do not need the indices, with a nested for-each:

for (int[] row : loansPerMonth) {
    int total = 0;
    for (int value : row) { total += value; }
    System.out.println("Row total: " + total);
}

Notice the type of the outer loop variable: int[] row, not int. Every element of an int[][] is an int[].

There is literal initialisation too, and here it is plain to see that these are arrays of arrays:

int[][] matrix = {
    { 12,  8, 15 },
    {  3,  7,  2 },
    { 20, 11,  9 }
};

Jagged arrays

Since each row is an independent array, the rows can have different lengths. That is called a jagged array:

String[][] loansPerEmployee = new String[3][];          // 3 rows, no columns yet

loansPerEmployee[0] = new String[] { "Effective Java", "Refactoring" };         // Marta: 2
loansPerEmployee[1] = new String[] { "Design Patterns" };                       // Diego: 1
loansPerEmployee[2] = new String[0];                                            // Nuria: 0

String[] names = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };

for (int i = 0; i < loansPerEmployee.length; i++) {
    System.out.print(names[i] + ": ");
    if (loansPerEmployee[i].length == 0) {
        System.out.println("(no loans)");
    } else {
        System.out.println(String.join(", ", loansPerEmployee[i]));
    }
}
Marta Ruiz: Effective Java, Refactoring
Diego Alonso: Design Patterns
Nuria Vidal: (no loans)

Notice the syntax new String[3][]: you state the number of rows and leave the second dimension empty, because each row will be created later with its own size. If you leave the rows uncreated, they are null, and accessing loansPerEmployee[0][0] would give a NullPointerException.

This structure —"for each employee, a list of titles of varying length"— is exactly the one you will replace in 05-05 with a Map<Employee, List<Loan>>, far more expressive and with no numeric indices to keep in sync with another array of names.

  1. The Arrays class

java.util.Arrays is a utility class with static methods that solve almost everything you need to do with arrays. It is one of the first things to import in any serious program:

import java.util.Arrays;

toString and deepToString

An array is an object that does not override toString(), so printing it directly shows the Object representation from 03-09 (type name, at sign, hash in hexadecimal):

int[] years = { 2018, 1994, 1999 };
System.out.println(years);                       // [I@1b6d3586    <- useless
System.out.println(Arrays.toString(years));      // [2018, 1994, 1999]

For arrays of more than one dimension, toString is not enough —it would print the reference of each row— and you need deepToString, which descends recursively:

int[][] matrix = { { 1, 2 }, { 3, 4 } };
System.out.println(Arrays.toString(matrix));       // [[I@4554617c, [I@74a14482]
System.out.println(Arrays.deepToString(matrix));   // [[1, 2], [3, 4]]

Arrays.toString is your number one tool for debugging arrays. Use it in every trace.

sort and sort with a Comparator

Arrays.sort sorts the array in place: it modifies the array it receives and returns nothing.

double[] fines = { 12.50, 0.0, 3.75, 1.25 };
Arrays.sort(fines);
System.out.println(Arrays.toString(fines));      // [0.0, 1.25, 3.75, 12.5]

With objects there are two variants. Without a Comparator, it requires the elements to implement Comparable —which Card does, as you saw in 04-07— and uses their natural order:

Card[] cards = {
    new Card("Refactoring",     "Martin Fowler", 1999),
    new Card("Effective Java",  "Joshua Bloch",  2018),
    new Card("Design Patterns", "Erich Gamma",   1994)
};
Arrays.sort(cards);                              // uses Card's compareTo (by title)

And with a Comparator, which is where the whole of module 4 connects:

Arrays.sort(cards, Comparator.comparingInt(Card::year));                       // by year
Arrays.sort(cards, Comparator.comparing(Card::author).thenComparing(Card::title));
Arrays.sort(cards, Comparator.comparingInt(Card::year).reversed());            // newest first

There is also a variant that sorts only a range, useful when the array has unused cells at the end:

Arrays.sort(catalog, 0, n, Comparator.comparing(Material::getTitle));     // only the first n

The full theory —the compareTo contract, stability, which algorithm Java uses— is this module's closing lesson, 05-09.

binarySearch

It looks for an element with a binary search: it checks the middle element, discards half and repeats. Cost O(log n): in an array of a million elements, about twenty checks instead of a million.

int[] references = { 101, 205, 307, 412, 588 };      // ALREADY SORTED
System.out.println(Arrays.binarySearch(references, 307));     // 2  -> index where it is
System.out.println(Arrays.binarySearch(references, 400));     // -4 -> not there

Two essential warnings:

  • The array must be sorted beforehand, by the same criterion you are searching with. On an unsorted array the result is garbage, and there is no warning whatsoever.
  • The negative value is not "-1 and that's it": it is -(insertion_point) - 1. In the example, -4 means 400 would go at index 3. If you want the insertion point: int pos = -result - 1;.

fill

Fills the whole array (or a range) with the same value:

double[] fines = new double[5];
Arrays.fill(fines, -1.0);                        // marks "not calculated" in every cell
Arrays.fill(fines, 0, 2, 0.0);                   // only indices 0 and 1 (2 is excluded)

copyOf and copyOfRange

copyOf creates a new array of the given size, copying what fits and filling the rest with the default value:

Material[] catalog = new Material[3];
// ... the 3 cells get filled ...

Material[] expanded = Arrays.copyOf(catalog, 6);      // 3 originals + 3 nulls
Material[] trimmed  = Arrays.copyOf(catalog, 2);      // only the first 2

This is exactly the trick BiblioTech has been using since module 3 to "grow" an array, and also, literally, what ArrayList does internally (05-03).

copyOfRange copies a stretch, with the lower bound included and the upper one excluded:

int[] numbers = { 10, 20, 30, 40, 50 };
int[] middle  = Arrays.copyOfRange(numbers, 1, 4);    // [20, 30, 40]

equals versus deepEquals

Here is a trap that costs many people hours. Since an array does not override equals, comparing two arrays with == or with .equals() compares references, not contents:

int[] a = { 1, 2, 3 };
int[] b = { 1, 2, 3 };

System.out.println(a == b);              // false
System.out.println(a.equals(b));         // false  <- the equals inherited from Object
System.out.println(Arrays.equals(a, b)); // true   <- what you almost always wanted

And for arrays of more than one dimension, Arrays.equals compares the rows by reference and also fails; you need deepEquals:

int[][] m1 = { { 1, 2 }, { 3, 4 } };
int[][] m2 = { { 1, 2 }, { 3, 4 } };

System.out.println(Arrays.equals(m1, m2));      // false
System.out.println(Arrays.deepEquals(m1, m2));  // true

The same goes for the hash: use Arrays.hashCode and Arrays.deepHashCode, never array.hashCode(), if you are going to put arrays into hash-based structures (which is, moreover, an idea 05-05 will argue you out of).

asList and its trap

Arrays.asList creates a List from an array, and it is tempting because it looks like the obvious conversion. But it returns a fixed-size view backed by the original array, not a normal list:

String[] names = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
List<String> list = Arrays.asList(names);

System.out.println(list.get(0));         // Marta Ruiz
list.set(0, "Marta R.");                 // allowed: it ALSO changes names[0]
System.out.println(names[0]);            // Marta R.

list.add("New");                         // UnsupportedOperationException
list.remove(0);                          // UnsupportedOperationException
Operation on Arrays.asList(array) Result
get, size, contains, indexOf, traversing Works normally
set(i, value) Works and modifies the original array
add, remove, clear UnsupportedOperationException

If you want a real list, independent and modifiable, wrap the view:

List<String> modifiable = new ArrayList<>(Arrays.asList(names));
modifiable.add("New");                   // now it works

And one extra trap that shows up with primitives: Arrays.asList is generic and generics do not accept primitive types, so an int[] is interpreted as a single element:

int[] numbers = { 1, 2, 3 };
List<int[]> odd = Arrays.asList(numbers);        // list of ONE element (the whole array)
System.out.println(odd.size());                  // 1, not 3

With Integer[] it would work the way you expect. The deep cause —generics only operate on reference types— is explained in 10-01.

  1. Copying arrays

There are four ways to copy, and choosing badly is a common source of subtle bugs.

Material[] original = { book1, book2, book3 };

Material[] a = original;                                       // NOT a copy: it is an alias
Material[] b = original.clone();                               // shallow copy
Material[] c = Arrays.copyOf(original, original.length);       // shallow copy
Material[] d = new Material[3];
System.arraycopy(original, 0, d, 0, 3);                        // shallow copy, with control
Form What it does When to use it
b = a Copies nothing. Two variables, one single array Never, if your intention was to copy
a.clone() Shallow copy of the same size A quick identical copy
Arrays.copyOf(a, n) Shallow copy with a new size Growing, trimming, defensive copy
System.arraycopy(src, iSrc, dst, iDst, n) Copies n elements from one array into another existing one Inserting, shifting, fine control

The first row is the blunder: Material[] a = original; creates a second name for the same array. Writing a[0] = other; also changes original[0]. It is the aliasing of 03-02 applied to arrays, and that is why the constructor of Catalog does Arrays.copyOf(materials, materials.length): a defensive copy (03-07) so that whoever built the array cannot alter the catalogue through the back door.

All copies are shallow. They copy the references, not the objects:

Material[] copy = original.clone();
copy[0].lend();                          // affects THE SAME Book that 'original' sees
System.out.println(original[0].isAvailable());     // false

For a deep copy you have to clone element by element, and that is why the immutability of 03-07 is so convenient: if the objects do not change, a shallow copy is enough and there is no risk.

System.arraycopy is the most awkward of the four but the most powerful, because it copies into an array that already exists, at whatever position you want, and it allows overlap with the same array as source and destination. It is what ArrayList.remove(int) uses to close the gap:

// Remove the element at position 1 by shifting the following ones left
Material[] c = { m0, m1, m2, m3 };
System.arraycopy(c, 2, c, 1, c.length - 2);   // copies [2..3] over [1..2]
c[c.length - 1] = null;                       // frees the last cell

It is an O(n) operation: every element after it has to be moved. Remember this detail, because it is exactly the cost ArrayList.remove(0) will pay in 05-03 and the one LinkedList avoids in 05-04.

  1. Varargs is an array underneath

In 03-03 you used varargs to write methods with a variable number of arguments:

public static double sumFines(double... fines) {
    double total = 0;
    for (double f : fines) { total += f; }       // walked like an array... because IT IS one
    return total;
}

The secret is that double... fines is exactly double[] fines with syntactic sugar at the call site: the compiler packs the loose arguments into an array before invoking the method.

sumFines(3.75, 1.25, 12.50);               // the compiler creates new double[]{3.75, 1.25, 12.50}
sumFines();                                // creates new double[0]: an EMPTY array, not null
sumFines(new double[] { 3.75, 1.25 });     // also valid: you pass it the array directly

Three practical rules follow from that:

  • A varargs parameter is never null if it is called with the normal syntax: with no arguments a zero-length array arrives. You can walk it without checking anything.
  • There can be only one and it must come last: method(String label, double... values) is valid; the other way round, it is not.
  • You can pass it an already built array, which is very useful for forwarding arguments between methods.

  1. Application: the BiblioTech catalogue

Let us bring it all together in the project's catalogue, still in its array version, and sort it with the Comparators from module 4.

package com.nexussoftware.bibliotech.service;

import java.util.Arrays;
import java.util.Comparator;
import com.nexussoftware.bibliotech.domain.Material;

/** BiblioTech's catalogue in its final array-based version. */
public class ArrayCatalog {

    private Material[] materials;     // internal array
    private int        n;             // how many cells are actually occupied

    public ArrayCatalog(int initialCapacity) {
        this.materials = new Material[Math.max(initialCapacity, 1)];
        this.n = 0;
    }

    /** Adds a material, growing the array if it is full. */
    public void add(Material m) {
        if (m == null) { return; }
        if (n == materials.length) {
            // the array is full: we create one twice the size and copy
            materials = Arrays.copyOf(materials, materials.length * 2);
        }
        materials[n] = m;
        n++;
    }

    /** Removes by reference, closing the gap with System.arraycopy. */
    public boolean remove(String reference) {
        for (int i = 0; i < n; i++) {
            if (materials[i].getReference().equals(reference)) {
                System.arraycopy(materials, i + 1, materials, i, n - i - 1);
                materials[n - 1] = null;       // avoids a memory leak
                n--;
                return true;
            }
        }
        return false;
    }

    /** Returns a COPY with only the occupied cells: a defensive copy. */
    public Material[] list() {
        return Arrays.copyOf(materials, n);
    }

    /** Sorts the catalogue by any criterion, only the occupied stretch. */
    public void sort(Comparator<Material> criteria) {
        Arrays.sort(materials, 0, n, criteria);
    }

    public int size() { return n; }
}

And its use, applying the comparators from 04-06:

ArrayCatalog catalog = new ArrayCatalog(4);
catalog.add(new Book("Effective Java",  "Joshua Bloch",  "978-0000000001", 2018));
catalog.add(new Book("Design Patterns", "Erich Gamma",   "978-0000000002", 1994));
catalog.add(new Book("Refactoring",     "Martin Fowler", "978-0000000003", 1999));
catalog.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
catalog.add(new Dvd("Refactoring Live", "DVD-0007",      95));   // it grows on its own here

catalog.sort(Comparator.comparing(Material::getType)
                       .thenComparing(Material::getTitle));

for (Material m : catalog.list()) {
    System.out.printf("%-10s %-24s %s%n", m.getType(), m.getTitle(), m.getReference());
}
Book       Design Patterns          978-0000000002
Book       Effective Java           978-0000000001
Book       Refactoring              978-0000000003
DVD        Refactoring Live         DVD-0007
Magazine   Java Magazine            REV-2024-03

It works. And it contains, written by hand, three pieces that in the next lesson the JDK will hand you ready-made: automatic growth, the shift on removal and the distinction between capacity and occupied size.

  1. The limitations that motivate collections

Look at ArrayCatalog with a critical eye. Everything it has beyond a List is infrastructure, not business logic:

Array limitation What it forces you to write What the collection does
Fixed size Keep a counter n, check n == length, double and copy add and nothing else: it grows on its own
No removal System.arraycopy to close the gap and set null at the end remove
No search by criterion A loop with an if for every new criterion contains, indexOf, removeIf
Capacity ≠ size Remembering that length is not "how many there are" and doing copyOf(a, n) on return size() is the truth
No uniqueness guarantee Checking by hand whether an ISBN already exists, with an O(n) loop Set (05-06)
No access by key An O(n) loop over the reference; worse, nested O(n²) loops to group Map (05-05)
No semantics at all A "queue" is an array plus two indices you maintain yourself Queue, Deque (05-07, 05-08)

None of those limitations makes the array obsolete. An array is still the right choice when:

  • The size is known and fixed (a 3×12 matrix, a board, a 4096-byte buffer).
  • You work with primitives and performance matters: an int[] of a million elements takes 4 MB; a List<Integer> can take five times more because of autoboxing (05-02).
  • You need maximum traversal speed and cache locality is decisive.
  • You are implementing a data structure, as ArrayList, HashMap and ArrayDeque do internally.

For everything else —which in a business application is practically everything— the answer is the Collections Framework.

Common Mistakes and Tips

length with parentheses. array.length() does not compile. It is a field on arrays, a method on String and size() on collections. Keep it in your head as a three-row table.

Confusing capacity with content. new Material[10] has length == 10 but zero materials. If you have only filled three cells, walking all ten will give you seven NullPointerExceptions. Always keep a counter or trim with Arrays.copyOf(array, n) before returning.

Printing an array directly. System.out.println(array) shows [I@1b6d3586. Use Arrays.toString(array) and, with more than one dimension, Arrays.deepToString(array).

Comparing arrays with equals or ==. Both compare references. Use Arrays.equals and, with more dimensions, Arrays.deepEquals.

Believing the for-each modifies the array. for (int n : numbers) { n = 0; } changes nothing: n is a copy. If you write into the cells, use a classic for. If you only call methods on the object, the for-each is perfect.

Assigning instead of copying. Material[] copy = original; does not copy; it creates an alias. Use original.clone() or Arrays.copyOf(original, original.length). And remember that both are shallow: the objects pointed at are shared.

Treating Arrays.asList as a normal list. It is a fixed-size view backed by the array: set yes, add/remove throw UnsupportedOperationException. If you need to modify it, wrap it in new ArrayList<>(...).

binarySearch on an unsorted array. It returns meaningless results, with no warning at all. Sort first, by the same criterion you are searching with. And remember that the negative returned is -(insertion point) - 1, not a plain "not found".

Forgetting to null out the freed cell on removal. In remove, after the arraycopy, the last cell still points at the object that is no longer part of the catalogue. As long as the array lives, that object cannot be collected: it is a silent memory leak. ArrayList does exactly that elementData[--size] = null for the same reason.

Style tip: for-each by default. If the loop does not need the index, write for (Material m : catalog). It is shorter, clearer and impossible to break on the bounds. Drop down to the classic for only when the index is genuinely necessary.

Exercises

Exercise 1: catalogue statistics

Write a class CatalogStatistics with static methods that take a Material[]:

  • int countByType(Material[] catalog, String type): how many materials of that type there are.
  • Material mostExpensive(Material[] catalog): the one with the highest daily rate, or null if the array is empty or null.
  • double averageRate(Material[] catalog): the average of the daily rates, 0.0 if there are no elements.
  • String[] titles(Material[] catalog): a new array with the titles only.

All of them must ignore null cells and use for-each whenever possible.

Exercise 2: monthly report with a two-dimensional array

Create MonthlyReport managing an int[][] loans of 3 rows (Book, Magazine, DVD) by 12 columns (months). Implement:

  • void register(int type, int month): increments the corresponding cell.
  • int totalByType(int type) and int totalByMonth(int month).
  • int busiestMonth(): the index of the month with the most loans overall.
  • String table(): a table formatted with printf showing rows, columns and totals.

Exercise 3: a toolbox with Arrays

Write CatalogUtils with static methods that apply the Arrays class:

  • Material[] add(Material[] catalog, Material item): returns a new array with one more element (without modifying the original).
  • Material[] removeAt(Material[] catalog, int index): returns a new array without that element.
  • Material[] sortedByTitle(Material[] catalog): returns a sorted copy, leaving the original untouched.
  • int findByReference(Material[] catalog, String reference): use Arrays.sort + Arrays.binarySearch on a copy and explain in a comment why the returned index is not usable on the original array.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Material;

public final class CatalogStatistics {

    private CatalogStatistics() { }        // utility class: not instantiated

    public static int countByType(Material[] catalog, String type) {
        if (catalog == null || type == null) { return 0; }
        int n = 0;
        for (Material m : catalog) {                  // for-each: we do not need the index
            if (m != null && m.getType().equals(type)) {   // equals, not ==, for strings (01-05)
                n++;
            }
        }
        return n;
    }

    public static Material mostExpensive(Material[] catalog) {
        if (catalog == null) { return null; }
        Material best = null;
        for (Material m : catalog) {
            if (m == null) { continue; }
            // the first time round 'best' is null: it has to be handled separately
            if (best == null || m.getDailyRate() > best.getDailyRate()) {
                best = m;
            }
        }
        return best;
    }

    public static double averageRate(Material[] catalog) {
        if (catalog == null) { return 0.0; }
        double sum = 0.0;
        int    n   = 0;                       // we count ONLY the non-null ones
        for (Material m : catalog) {
            if (m != null) { sum += m.getDailyRate(); n++; }
        }
        return (n == 0) ? 0.0 : sum / n;      // protection against division by zero
    }

    public static String[] titles(Material[] catalog) {
        if (catalog == null) { return new String[0]; }    // never return null: empty array
        String[] result = new String[catalog.length];     // MAXIMUM possible size
        int n = 0;
        for (Material m : catalog) {
            if (m != null) { result[n++] = m.getTitle(); }
        }
        // we trim to what was actually used: the pattern ArrayList will make unnecessary
        return java.util.Arrays.copyOf(result, n);
    }
}

The four methods share the same defensive skeleton: check null on the array, skip null elements and return a neutral value (0, a documented null, an empty array) when there is no data. The important detail in titles is the "create at maximum size, count, trim" pattern: it is the only way to return an array of the exact size when you do not know in advance how many elements there will be. It is also, exactly, what ArrayList.toArray() does for you.

Solution 2

package com.nexussoftware.bibliotech.presentation;

public class MonthlyReport {

    private static final String[] TYPES  = { "Books", "Magazines", "DVDs" };
    private static final String[] MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
                                             "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };

    private final int[][] loans = new int[TYPES.length][MONTHS.length];      // 3 x 12, all 0

    public void register(int type, int month) {
        // we check the bounds by hand: module 6 will teach us to signal it with an exception
        if (type < 0 || type >= TYPES.length || month < 0 || month >= MONTHS.length) {
            System.out.println("WARNING: index out of range, entry ignored");
            return;
        }
        loans[type][month]++;
    }

    public int totalByType(int type) {
        int total = 0;
        for (int value : loans[type]) {        // we walk a whole ROW: it is an int[]
            total += value;
        }
        return total;
    }

    public int totalByMonth(int month) {
        int total = 0;
        for (int[] row : loans) {              // we walk the rows and take one column
            total += row[month];
        }
        return total;
    }

    public int busiestMonth() {
        int bestMonth = 0;
        int bestTotal = totalByMonth(0);
        for (int month = 1; month < MONTHS.length; month++) {   // classic for: we need the index
            int total = totalByMonth(month);
            if (total > bestTotal) { bestTotal = total; bestMonth = month; }
        }
        return bestMonth;
    }

    public String table() {
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("%-10s", ""));
        for (String month : MONTHS) { sb.append(String.format("%5s", month)); }
        sb.append(String.format("%8s%n", "TOTAL"));

        for (int t = 0; t < TYPES.length; t++) {
            sb.append(String.format("%-10s", TYPES[t]));
            for (int value : loans[t]) { sb.append(String.format("%5d", value)); }
            sb.append(String.format("%8d%n", totalByType(t)));
        }

        sb.append(String.format("%-10s", "TOTAL"));
        for (int month = 0; month < MONTHS.length; month++) {
            sb.append(String.format("%5d", totalByMonth(month)));
        }
        sb.append(String.format("%8d%n", grandTotal()));
        sb.append("Busiest month: ").append(MONTHS[busiestMonth()]).append('\n');
        return sb.toString();
    }

    private int grandTotal() {
        int total = 0;
        for (int[] row : loans) {
            for (int value : row) { total += value; }
        }
        return total;
    }
}

Notice the deliberate alternation between the two loops. totalByType walks a row with for-each because it does not need to know which month each value belongs to. totalByMonth walks the rows with for-each but indexes the column, because the month is given. And busiestMonth needs the classic for because what it returns is the index. Each loop uses the tool that belongs to it.

Solution 3

package com.nexussoftware.bibliotech.service;

import java.util.Arrays;
import java.util.Comparator;
import com.nexussoftware.bibliotech.domain.Material;

public final class CatalogUtils {

    private CatalogUtils() { }

    /** Returns a NEW array with one more element. The original is untouched. */
    public static Material[] add(Material[] catalog, Material item) {
        if (catalog == null) { return new Material[] { item }; }
        // copyOf with length+1 creates the expanded array with the last cell at null
        Material[] expanded = Arrays.copyOf(catalog, catalog.length + 1);
        expanded[catalog.length] = item;
        return expanded;
    }

    /** Returns a NEW array without the element at that position. */
    public static Material[] removeAt(Material[] catalog, int index) {
        if (catalog == null || index < 0 || index >= catalog.length) {
            return catalog;                        // nothing to do
        }
        Material[] result = new Material[catalog.length - 1];
        // two copies: the stretch before the index and the one after
        System.arraycopy(catalog, 0, result, 0, index);
        System.arraycopy(catalog, index + 1, result, index,
                         catalog.length - index - 1);
        return result;
    }

    /** A copy sorted by title. The original keeps its order. */
    public static Material[] sortedByTitle(Material[] catalog) {
        if (catalog == null) { return new Material[0]; }
        Material[] copy = catalog.clone();         // clone: shallow copy of the same size
        Arrays.sort(copy, Comparator.comparing(Material::getTitle));
        return copy;                               // the Materials are the SAME objects
    }

    /**
     * Searches by reference with a binary search over a sorted copy.
     *
     * IMPORTANT: the index returned by binarySearch is the position in the SORTED
     * COPY, not in the original array. That is why we do not return it: we retrieve
     * the material found and look for ITS real position in the original. Confusing
     * the two indices is one of the subtlest mistakes when working with arrays.
     */
    public static int findByReference(Material[] catalog, String reference) {
        if (catalog == null || reference == null) { return -1; }

        Comparator<Material> byReference = Comparator.comparing(Material::getReference);
        Material[] copy = catalog.clone();
        Arrays.sort(copy, byReference);

        // binarySearch needs a "probe element" carrying the reference we are after
        Material probe = new Book("", "", reference, 2000);
        int posInCopy = Arrays.binarySearch(copy, probe, byReference);
        if (posInCopy < 0) { return -1; }           // negative = insertion point, not there

        Material found = copy[posInCopy];
        for (int i = 0; i < catalog.length; i++) {   // we translate to the real position
            if (catalog[i] == found) { return i; }
        }
        return -1;
    }
}

This exercise lays bare how awkward the array is for everyday operations. add builds a whole array to insert one element: O(n) every time. removeAt needs two arraycopy calls. And findByReference costs more than the linear search it meant to avoid, because sorting the copy is O(n log n) and then the index has to be translated. The honest conclusion is that for searching by key, the array is the wrong structure: the right answer is a Map<String, Material>, with O(1) lookup and no copies or probes. That is lesson 05-05.

Conclusion

You now master the structure everything else is built on. You know that an array is a contiguous block of memory of fixed size and homogeneous type, that it is a heap object even though it has no visible class, and that its contiguity gives it O(1) access by index and a cache locality no other structure matches. You distinguish declaring, creating with new and initialising with a literal; you know the default values and you know that new Material[10] contains ten nulls, not ten materials; and you are clear that length is a field, that indices run from 0 to length - 1 and that going outside throws ArrayIndexOutOfBoundsException —an exception that module 6 will teach you to handle.

You have finally learned the for-each that was postponed in module 2: its syntax for (Type e : source), its advantages —less code, impossible to go out of range, the same shape for arrays and collections— and its three exact limitations: it does not give the index, it cannot reassign the cell (though it can modify the object pointed at) and it only goes forwards. And you have the criterion: for-each by default, classic for when the index is indispensable.

You know what each cell really holds —values in arrays of primitives, references in arrays of objects—, with everything that follows: aliasing, null, worse locality and the covariance that can throw ArrayStoreException at run time, the hole that the generics of 10-01 came along to plug. You handle multidimensional arrays understanding them as arrays of arrays, including jagged ones with rows of different lengths. And you have the Arrays class in your hands: toString/deepToString for debugging, sort with and without a Comparator, binarySearch with its two warnings, fill, copyOf, copyOfRange, equals/deepEquals versus the inherited equals that compares references, and asList with its fixed-size-view trap. You know how to copy in four ways, and that all of them are shallow. And you know that a varargs parameter is literally an array, never null, always last.

BiblioTech now has an ArrayCatalog that grows on its own, removes by closing the gap, returns defensive copies and sorts itself with any Comparator from module 4. It works. And three quarters of its code is infrastructure that should not be there: a counter n running parallel to length, an Arrays.copyOf to double the capacity, a System.arraycopy to shift, a manual null to avoid leaking memory. None of that talks about libraries or loans. On top of that, it still cannot guarantee there are no duplicate ISBNs without an O(n) loop, nor search by reference in less than O(n), nor group loans by employee without nested O(n²) loops.

In the next lesson, The Collections Framework, you will see the full map of the structures the JDK already has solved: the Iterable → Collection → List/Set/Queue hierarchy, the Map that stands apart and why, the golden rule "declare by the interface, instantiate the implementation", the master table comparing the ten implementations you will use for the rest of your professional life, how the for-each you have just learned works inside —with the Iterator underneath and the dreaded ConcurrentModificationException— and a decision tree for choosing the right collection first time. From there on, each lesson in the module will develop one row of that table, and the ArrayCatalog you have just written will shrink to half the lines.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved