Skip to content

Java · Fundamentals

Java Arrays — Declaration, the Arrays Utility Class & Gotchas

13 min read Updated 2026-06-20 Share:

Practice Arrays interview questions

Arrays are the foundation everything else is built on

The array is Java's most primitive container, and almost every richer data structure — ArrayList, HashMap, StringBuilder — is an array underneath. That makes arrays a favourite interview warm-up: the questions look easy, but they quietly probe whether you understand references, the heap, covariance, and the difference between a field and a method. This guide walks through declaring arrays, the values they hold, the Arrays utility class, and the handful of gotchas that separate someone who "uses arrays" from someone who understands them.

Declaring and initializing an array

An array is declared with a type[] and brought to life with new, an array initializer { ... }, or both at once. The brackets can legally sit on the variable (int d[]) as a leftover from C, but the convention is to put them on the type so the type reads as "array of int."

int[] a = new int[3];          // size only — Java fills it with {0, 0, 0}
int[] b = new int[]{1, 2, 3};  // size + values together
int[] c = {1, 2, 3};           // shorthand — only valid at the declaration
int   d[] = {1, 2, 3};         // legal C-style, but discouraged
// c = {4, 5, 6};              // ERROR — bare {...} only works at declaration
c = new int[]{4, 5, 6};        // reassigning needs the full new int[]{...} form

The key subtlety is that the bare { ... } shorthand is declaration-only. Once the variable exists you must use new int[]{...} to give it a fresh array. Either way, the length is locked in the moment the array is created.

An array is an object, with default values

Even an array of primitives is a full object on the heap — the variable holds a reference to it. That object has a runtime class (int[].class, printed as [I), inherits from Object, and can therefore be null, be stored in an Object variable, and answer clone(). Allocating with new also zero-initializes every slot, so you never read garbage. The default depends on the element type.

int[]     nums  = new int[3];      // {0, 0, 0}        — numeric default 0
boolean[] flags = new boolean[2];  // {false, false}   — boolean default false
String[]  names = new String[2];   // {null, null}     — references default null
Object o = nums;                   // arrays ARE Objects
System.out.println(nums.getClass().getName()); // "[I" — int array

This differs from local variables, which get no default and must be assigned before use. Array slots, like instance fields, are always initialized. Note the consequence for object arrays: new String[2] creates the array but not the strings — every slot is null until you populate it, so calling a method on an unfilled slot throws NullPointerException.

Fixed length, and length vs length() vs size()

An array's length is fixed at creation and can never change. To "grow" one you allocate a new, larger array and copy the elements across — which is exactly what ArrayList does for you internally. The size itself is exposed through a field, and this is where three look-alike members trip people up.

int[] arr = {1, 2, 3};
String s  = "hello";
List<Integer> list = List.of(1, 2);

arr.length    // 3 — a FIELD on arrays (no parentheses)
s.length()    // 5 — a METHOD on String
list.size()   // 2 — a METHOD on collections

int[] bigger = Arrays.copyOf(arr, arr.length + 1); // {1, 2, 3, 0} — the "grow" idiom
// arr.length()  // compile error
// s.length      // compile error

Memorize the split: arrays have a length field, String has a length()method, and List/Set/Map have a size() method. Writing the wrong one is a compile error, not a runtime surprise.

Bounds checking and invalid sizes

Java checks every array access. A valid index runs from 0 to length - 1; anything outside throws ArrayIndexOutOfBoundsException at runtime. There is no negative indexinga[-1] is an error, not "the last element." Separately, the size you pass to new is evaluated at runtime, so a computed negative length throws NegativeArraySizeException (zero is fine — it gives a valid empty array).

int[] a = {10, 20, 30};
a[2];                 // 30 — last element, index length - 1
// a[3];              // ArrayIndexOutOfBoundsException
// a[-1];             // also AIOOBE — no negative indexing in Java

int[] empty = new int[0];   // legal — empty array, length 0
int n = -1;
// int[] bad = new int[n];  // NegativeArraySizeException at runtime

These runtime checks are what make Java memory-safe with arrays — you cannot walk off the end into arbitrary memory the way C lets you.

Multidimensional and jagged arrays

Java has no true 2D array; int[][] is an array of arrays. The outer array holds references to inner arrays, each a separate heap object. Because the rows are independent, they need not all be the same length — an array whose rows differ is called a jagged (or ragged) array, and it is fully legal.

int[][] grid = new int[2][3];     // 2 rows, each an int[3]
grid.length;       // 2 — number of rows
grid[0].length;    // 3 — columns in row 0

int[][] jagged = new int[3][];    // 3 rows, none allocated yet (each null)
jagged[0] = new int[]{1};         // length 1
jagged[1] = new int[]{2, 3};      // length 2
jagged[2] = new int[]{4, 5, 6};   // length 3
for (int[] row : jagged)          // each row is itself an int[]
  System.out.println(row.length); // 1, 2, 3

Always iterate inner rows with row.length rather than a hard-coded column count, since rows can differ and an unallocated row stays null. Accessing grid[i][j] is two pointer hops — outer array to row, row to element — which is why dense numeric code sometimes flattens a 2D grid into a single int[].

Array covariance and ArrayStoreException

Java arrays are covariant: if Sub extends Super, then Sub[] is a subtype of Super[], so you can assign a String[] to an Object[] variable. Convenient — but not type-safe at compile time. To keep that loophole from corrupting memory, the JVM checks at runtime that every value you store matches the array's actual element type, and throws ArrayStoreException when it doesn't.

Object[] arr = new String[2];   // legal — covariance; actual type is String[]
arr[0] = "ok";                  // fine, it really is a String[]
// arr[1] = 42;                 // compiles, but ArrayStoreException at runtime

Contrast this with generics, which are deliberately invariantList<String> is not a List<Object>. Generics chose compile-time safety, so no runtime store check is needed; arrays chose flexibility and pay for it with ArrayStoreException. Knowing why the two differ is a frequent senior-level follow-up.

The Arrays utility class: searching, comparing, printing

java.util.Arrays is the toolbox you reach for constantly. sort orders in place (primitive arrays use a dual-pivot quicksort; object arrays use a stable merge sort and accept a Comparator), and parallelSort does the same across the common ForkJoinPool for large arrays. binarySearch is O(log n) but requires a sorted array — on a miss it returns -(insertionPoint) - 1. Crucially, the plain equals/toString are shallow, so nested arrays need the deep variants.

int[] nums = {3, 1, 2};
Arrays.sort(nums);                       // {1, 2, 3}
String[] words = {"banana", "apple"};
Arrays.sort(words, Comparator.reverseOrder()); // Comparator only on object arrays

int idx = Arrays.binarySearch(nums, 2);  // 1 (must be sorted first!)
int miss = Arrays.binarySearch(nums, 5); // negative; insert at -miss - 1

int[][] m = {{1, 2}, {3, 4}}, n = {{1, 2}, {3, 4}};
Arrays.equals(m, n);      // false — compares inner int[] REFERENCES
Arrays.deepEquals(m, n);  // true  — recurses into rows
Arrays.toString(nums);    // "[1, 2, 3]"   (vs nums.toString() -> "[I@1b6d…")
Arrays.deepToString(m);   // "[[1, 2], [3, 4]]"

Two rules: Arrays.sort on primitives can't take a Comparator (box to Integer[] if you need custom ordering of numbers), and use the deep* methods for any multidimensional or nested array — the shallow ones compare and print inner arrays by reference, which is almost never what you want.

Copying arrays: copyOf, arraycopy, and shallow clone

There are three ways to copy. Arrays.copyOf/copyOfRange return a new array, truncating or zero-padding as needed — copyOf(a, a.length + n) is the canonical "grow" idiom. System.arraycopy is a native bulk copy into an array you already allocated, and it's the fast path that copyOf and ArrayList use internally. And clone() makes a shallow copy — fine for primitives, but for object or 2D arrays it copies the references, so both arrays share the same inner objects.

int[] a = {1, 2, 3};
Arrays.copyOf(a, 5);           // {1, 2, 3, 0, 0} — padded with defaults
Arrays.copyOfRange(a, 1, 3);   // {2, 3} — [from, to), to is exclusive

int[] dest = new int[5];
System.arraycopy(a, 0, dest, 1, 3); // {0, 1, 2, 3, 0} — into existing array

int[][] grid = {{1, 2}, {3, 4}};
int[][] copy = grid.clone();   // SHALLOW — rows are shared
copy[0][0] = 99;               // grid[0][0] is now 99 too!
for (int i = 0; i < grid.length; i++) copy[i] = grid[i].clone(); // deep-copy rows

Reach for copyOf when you just want a fresh array and let it allocate; use System.arraycopy when copying into a destination you already have or inserting a range; and remember that "copy" never means "deep copy" unless you clone each level yourself.

Bridging arrays, Lists, and Streams — and the asList trap

Arrays convert cleanly to Streams and Lists, but Arrays.asList hides two traps. It returns a fixed-size list backed by the array — a view, not an ArrayList — so set works (and writes through to the array) while add/remove throw UnsupportedOperationException. Worse, passing a primitive int[] gives a one-element List<int[]>, not a List<Integer>. For streaming, prefer Arrays.stream over Stream.of for the same reason.

Integer[] arr = {1, 2, 3};
List<Integer> view = Arrays.asList(arr);
view.set(0, 99);     // OK — also changes arr[0]
// view.add(4);      // UnsupportedOperationException — fixed size
List<Integer> real = new ArrayList<>(Arrays.asList(arr)); // mutable copy

int[] prims = {1, 2, 3};
IntStream is = Arrays.stream(prims);   // IntStream — no boxing
int[] doubled = is.map(x -> x * 2).toArray();
// Stream.of(prims) would give a Stream<int[]> of ONE element — wrong
String[] back = real.stream().map(String::valueOf).toArray(String[]::new);

When you need a genuinely resizable list, wrap with new ArrayList<>(...); and use Arrays.stream(int[]) to get a primitive IntStream rather than a boxed Stream<Integer>.

Varargs are just arrays

A varargs parameter (Type... args) is an array — the compiler packages the supplied arguments into a Type[]. Inside the method it behaves like any array, with .length and indexing, which is why varargs must be the last parameter and why an existing array can be passed straight through.

static int sum(int... nums) {       // nums is really an int[]
  int total = 0;
  for (int n : nums) total += n;    // iterate like any array
  return total;
}
sum(1, 2, 3);          // compiler builds new int[]{1, 2, 3}
sum(new int[]{1, 2});  // an existing array works directly
sum();                 // empty array, length 0 — never null

This connection explains the earlier Arrays.asList(1, 2, 3) behaviour: asList is itself a varargs method, so the boxed Integer literals collapse into an Integer[] while a raw int[] becomes a single element.

Array vs ArrayList: when to choose which

Arrays and ArrayList solve overlapping problems with different trade-offs. An array is fixed size, holds primitives or objects with no boxing, exposes a[i] and the .length field, and is covariant (runtime-checked). An ArrayList is dynamic, holds objects only (boxing primitives), uses get/set/size(), and is generic and invariant (compile-time checked) — at the cost of boxing and occasional resize.

int[] arr = new int[3];                 // fixed, primitive, fast, no boxing
List<Integer> list = new ArrayList<>(); // resizable, boxes ints
list.add(1); list.add(2);               // grows automatically — backed by an array

Choose an array for fixed-size, primitive-heavy, performance-critical code; choose an ArrayList when the size varies or you want the rich List API. It's worth saying out loud in an interview that ArrayList is itself backed by a resizing array — the two aren't rivals so much as different layers of the same idea.

Recap

A Java array is an object on the heap with a fixed length decided at creation and slots that are zero-initialized by default. Keep the trio straight — length is a field, length() and size() are methods. Multidimensional arrays are arrays of arrays, which makes jagged rows natural and means clone() is only ever shallow. Arrays are covariant, so an incompatible store throws ArrayStoreException at runtime — the price generics avoid by being invariant. Lean on the Arrays class for sort/binarySearch (sort first!), equals/toString for flat arrays and the deep variants for nested ones, and copyOf/System.arraycopy for copying. Mind the Arrays.asList traps, remember varargs are arrays, and reach for ArrayList whenever the size needs to change. Rule of thumb: use a raw array for fixed, primitive, hot-path data and an ArrayList for everything that grows.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel