Java passes every value to a method by copying it, but what gets copied depends on the type. For a primitive, the copy is the number itself. For an array, the copy is a reference - a pointer to the same block of memory. That single fact explains why a method can change the contents of your array, why it cannot replace your array variable, and why you sometimes have to make a deliberate copy to protect data you do not want changed.
What actually happens when you call a method with an array?
Two steps, both cheap. First, the variable's value - the reference - is copied into the parameter. Second, the method body runs with that reference. Both the caller's variable and the parameter now name the same array in memory. Writing values[0] = 99 inside the method therefore writes to the array the caller is holding.
public class ArrayArguments {
public static void main(String[] args) {
int[] data = { 1, 2, 3 };
doubleAll(data);
System.out.println("after the method: " + java.util.Arrays.toString(data));
int[] untouched = { 1, 2, 3 };
replaceWithNewArray(untouched);
System.out.println("after replacing: " + java.util.Arrays.toString(untouched));
int[] returned = doubleAllReturningCopy(data);
System.out.println("returned array: " + java.util.Arrays.toString(returned));
System.out.println("original after the copy call: " + java.util.Arrays.toString(data));
}
static void doubleAll(int[] values) {
for (int i = 0; i < values.length; i++) {
values[i] = values[i] * 2;
}
}
static void replaceWithNewArray(int[] values) {
values = new int[] { 9, 9, 9 };
}
static int[] doubleAllReturningCopy(int[] values) {
int[] copy = new int[values.length];
for (int i = 0; i < values.length; i++) {
copy[i] = values[i] * 2;
}
return copy;
}
}after the method: [2, 4, 6]
after replacing: [1, 2, 3]
returned array: [4, 8, 12]
original after the copy call: [2, 4, 6]The first two lines of output are the core of the lesson. After doubleAll returns, the caller's array holds doubled values, because the method mutated the shared array. After replaceWithNewArray returns, the caller's array is unchanged, because the method only pointed its own parameter at a brand new array. Reassigning a parameter never propagates back to the caller - not for arrays, not for objects, not for anything.
Which operations travel back to the caller?
- Writing into an element -
values[0] = 99- is visible to the caller. - Changing the length is not possible at all; array length is fixed.
- Reassigning the parameter -
values = new int[3]- is invisible to the caller. - Passing the array on to another method shares the same array again.
That split is the reason good Java code distinguishes two kinds of method. A mutating method takes an array and changes it in place. A pure method takes an array, works on a copy, and returns the result. Making the distinction visible in the method name - doubleAllInPlace against doubledCopy - removes an entire class of bugs, because the caller can tell from the name whether their data is at risk.
How should a method return an array?
A method can return an array directly, and the returned reference points at real memory. The question is whose memory it is. If the method returns an array it built, the caller owns it. If the method returns an array it received, the caller and the method now share it, and a later edit in either place shows up in the other.
import java.util.Arrays;
public class ArrayReturn {
public static void main(String[] args) {
int[] source = { 5, 1, 4, 2 };
int[] sorted = sortedCopy(source);
System.out.println("source: " + Arrays.toString(source));
System.out.println("sorted copy: " + Arrays.toString(sorted));
sorted[0] = 100;
System.out.println("source after editing the result: " + Arrays.toString(source));
int[] empty = emptyIfNull(null);
System.out.println("empty length: " + empty.length);
int[] head = firstThree(source);
System.out.println("head: " + Arrays.toString(head));
}
static int[] sortedCopy(int[] input) {
int[] copy = Arrays.copyOf(input, input.length);
Arrays.sort(copy);
return copy;
}
static int[] emptyIfNull(int[] input) {
return input == null ? new int[0] : input;
}
static int[] firstThree(int[] input) {
if (input.length < 3) {
return Arrays.copyOf(input, input.length);
}
return Arrays.copyOf(input, 3);
}
}source: [5, 1, 4, 2]
sorted copy: [1, 2, 4, 5]
source after editing the result: [5, 1, 4, 2]
empty length: 0
head: [5, 1, 4]Notice the third line of output: after the method sorted its own copy, the caller's source was still in its original order. That is the behaviour you want from a method named sortedCopy. If it had sorted the incoming array in place and returned it, the source would have been reordered as a side effect - which is why the copy happens before the sort.
The two small methods at the end cover the edge cases you will meet in real code: a null input turning into an empty array, and a source shorter than the slice you wanted. Both avoid throwing, and both return an array the caller can iterate without a null check.
When is a defensive copy the right call?
A defensive copy is a copy you make to keep a caller from changing something you care about, or to stop your own changes from leaking out. You will need it whenever a method exposes an array it holds internally, and whenever it accepts an array it will keep.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ArrayDefensiveCopy {
public static void main(String[] args) {
List<String> tags = new ArrayList<>(List.of("java", "arrays"));
String[] copy = tags.toArray(new String[0]);
copy[0] = "changed";
System.out.println("tags list: " + tags);
System.out.println("array copy: " + Arrays.toString(copy));
int[] buffer = new int[3];
Arrays.fill(buffer, 0);
int[] result = grow(buffer, 2);
System.out.println("buffer: " + Arrays.toString(buffer));
System.out.println("result: " + Arrays.toString(result));
}
static int[] grow(int[] input, int extra) {
int[] result = Arrays.copyOf(input, input.length + extra);
result[result.length - 1] = extra;
return result;
}
}tags list: [java, arrays]
array copy: [changed, arrays]
buffer: [0, 0, 0]
result: [0, 0, 0, 0, 2]The first pair of lines is the classic case. tags.toArray produced a brand new array, so writing to it left the list untouched. The second pair shows the buffer pattern: a method that needs a larger buffer allocates one, fills it, and returns it, leaving the caller's buffer exactly as it was.
What are the practical rules to follow?
| Situation | Right move |
|---|---|
| Method should edit the caller's data | Mutate elements directly; name the method to say so |
| Method should not touch the caller's data | Copy first, work on the copy, return it |
| Method returns an array it received | Return a copy unless sharing is intentional |
| Method returns a fresh array | Return it directly; the caller owns it |
| Parameter may be null | Normalise it at the top of the method, not at the call site |
One last practical point: the array length cannot change, so a method that must grow a collection cannot do it in place. It returns a new, larger array and the caller adopts the new reference. That is why Arrays.copyOf appears so often in real code - it is the only way to change the size of an array.
The rules to keep
- Arrays are passed by reference, so element writes inside a method are visible outside.
- Reassigning the parameter is local to the method and never reaches the caller.
- Return a copy when the caller must not be affected by later changes.
- Copy before sorting or mutating if the caller's order matters.
- Name mutating methods so the side effect is obvious at the call site.
Try it yourself: write a method reverseInPlace(int[] values) that reverses the array by swapping elements, and call it on {1, 2, 3, 4, 5} so you can see the caller's array change. Then write reversedCopy that returns a reversed copy, call it on the same array, and print both to show the original order survived. Finally, write a method that returns the array sorted in descending order and prove it left the input untouched.