본문 바로가기
빌드노트 검색

빌드노트

Arrays.fill 뒤 한 목록에 add했는데 모든 칸에 보인다

Arrays.fill로 채운 목록 배열은 같은 목록 참조를 공유할 수 있다. 한 번의 add, == 비교, 한 칸 교체와 칸마다 새 목록을 만드는 초기화를 실제 JDK에서 비교해 공유 여부와 수정 위치를 판단한다.

ArrayList 배열을 Arrays.fill로 채운 뒤 groups[0].add("A")만 호출했는데 모든 칸에서 [A]가 보일 수 있다. 객체 배열의 fill은 전달받은 객체 참조를 각 칸에 대입한다. 목록 하나를 넘겼다면 여러 칸이 그 목록 하나를 가리킨다. 칸마다 목록이 따로 필요하다면 초기화할 때 각 인덱스에서 새 목록을 만들어야 한다.

기존 코드에서는 배열 초기화 직후, 값이 들어 있는 두 칸을 groups[0] == groups[1]로 비교해 본다. 두 원소가 null이 아닌데 true라면 같은 목록을 가리킨다. Java 17의 Arrays.fill 문서 (새 탭에서 열림)는 이 메서드가 객체 참조를 대입한다고 명시한다. Java 21 문서 (새 탭에서 열림)도 같은 계약이다. 객체를 칸 수만큼 복제하거나 깊은 복사를 수행한다는 뜻이 아니다.

빈 목록 하나를 세 칸이 같이 가리킨다

다음은 합성 문자열로 확인한 작은 예제다. JDK 17의 java와 jshell을 사용할 수 있는 빈 폴더에서 아래 명령으로 버전을 확인하고 JShell을 연다. 외부 라이브러리는 필요 없다. 명령을 찾지 못하면 JDK 설치와 PATH를 먼저 확인한다.

java --version
jshell --class-path . --execution local --no-startup --feedback silent

세 칸짜리 배열에 목록 하나를 넣고, 참조 비교와 한 번의 add 결과를 확인한다. JShell에 다음을 입력한다.

import java.util.ArrayList;
import java.util.Arrays;

ArrayList<String>[] groups = (ArrayList<String>[]) new ArrayList<?>[3];
Arrays.fill(groups, new ArrayList<String>());
System.out.println("fill same: " + (groups[0] == groups[1]) + ", " + (groups[1] == groups[2]));
groups[0].add("A");
System.out.println("fill add: " + Arrays.toString(groups));

Windows의 OpenJDK·JShell 17.0.2에서 println 출력은 다음과 같았다.

fill same: true, true
fill add: [[A], [A], [A]]

배열의 0번과 1번, 1번과 2번이 각각 같은 목록이었다. add가 세 번 실행된 것이 아니다. 목록 하나에 추가한 결과를 세 칸을 통해 읽은 것이다. fill의 인자로 쓴 new ArrayList<String>()는 이 호출에서 한 번 평가된다. fill이 각 칸을 채울 때마다 그 생성 코드를 다시 실행하지 않는다.

배열 선언에는 제네릭 배열을 만들기 위한 캐스트가 들어 있다. 이 선언에서 JShell 17.0.2는 unchecked cast 경고도 출력했다. 이 예제는 해당 배열에 ArrayList<String>만 넣으며, 경고와 별개로 위 출력까지 실행됐다. 독자의 코드에 이미 배열이 선언돼 있다면 그 선언을 바꿀 필요 없이 초기화 부분과 참조 비교를 확인하면 된다.

new ArrayList<?>[3] 자체는 목록 세 개를 만들지 않는다. 별도로 출력한 초기 상태는 [null, null, null]이었다. 이는 참조형 배열 원소의 기본값이 null이라는 언어 규칙 (새 탭에서 열림)과 맞는다. 아직 목록을 넣지 않은 두 칸은 둘 다 null이라서 ==가 true일 수 있으므로, 그 결과를 목록 공유의 증거로 삼지 않는다.

한 칸을 교체하면 그 칸만 새 목록을 가리킨다

같은 JShell에서 이번에는 groups[0] 자체를 새 목록으로 교체하고 그 목록에 B를 추가한다.

groups[0] = new ArrayList<>();
groups[0].add("B");
System.out.println("replace: " + Arrays.toString(groups));
System.out.println("replace same: " + (groups[0] == groups[1]) + ", " + (groups[1] == groups[2]));

출력은 다음과 같았다.

replace: [[B], [A], [A]]
replace same: false, true

groups[0] = ...는 배열 0번 칸에 저장된 참조를 바꾼다. 반면 앞서 실행한 groups[0].add("A")는 그 칸이 가리키던 목록의 내용을 바꿨다. 그래서 새 목록의 B는 0번에만 보이고, 나머지 두 칸은 여전히 기존 목록의 [A]를 보여 준다. 별도로 이어 실행한 groups[1].add("C")의 결과도 [[B], [A, C], [A, C]]였다. 0번 교체가 다른 칸의 공유까지 해제하지는 않았다.

칸마다 독립적으로 쌓으려면 new를 반복문 안에 둔다

이제 같은 배열의 모든 칸을 각각 새 빈 목록으로 초기화한다. 아래 코드는 앞서 넣은 A·B·C를 보존하는 복사가 아니라, 초기화 방식의 대조다. 실제 코드에서는 데이터를 쌓기 전의 Arrays.fill 호출을 이 반복문으로 바꾼다.

for (int i = 0; i < groups.length; i++) {
    groups[i] = new ArrayList<>();
}
System.out.println("per-index same: " + (groups[0] == groups[1]) + ", " + (groups[1] == groups[2]));
System.out.println("per-index equals: " + groups[0].equals(groups[1]));
groups[0].add("A");
System.out.println("per-index add: " + Arrays.toString(groups));

앞의 fill 조건과 똑같이 빈 목록에서 시작해 0번에 A를 한 번 추가했지만, 결과는 달랐다.

per-index same: false, false
per-index equals: true
per-index add: [[A], [], []]

반복문 안의 new가 실행될 때마다 새 목록이 만들어졌으므로 세 칸이 서로 다른 목록을 가리킨다. equals를 호출한 시점에는 모두 빈 목록이라 내용 비교는 true였다. List.equals의 계약 (새 탭에서 열림)은 원소 구성과 순서를 비교한다. 같은 객체인지를 확인할 때는 참조 동일성을 비교하는 == (새 탭에서 열림)를 사용한다. 출력 내용이 같다는 사실만으로 공유 여부를 판단하지 않는다.

각 칸의 목록을 따로 늘리고 줄여야 한다면 새 목록을 만드는 위치를 반복문 안으로 옮긴다. 같은 객체의 변경을 모든 칸에서 함께 봐야 한다면 fill로 참조를 공유할 수 있다. 반복문을 쓰더라도 밖에서 만든 목록 하나를 groups[i] = shared처럼 반복 대입하면 공유는 그대로다.

이 예제에서 독립적으로 만든 것은 각 칸의 목록이다. 기존 목록 안에 든 가변 객체까지 자동으로 복제한 실험은 아니다. fill 이후 이미 데이터가 섞인 상태를 원래 칸별 데이터로 되돌리는 방법도 다루지 않았다. 초기화와 참조 비교를 고쳤는데도 변경이 함께 보이면, 이후 다른 코드가 같은 목록 참조를 다시 대입하는지와 목록 안 원소의 공유 여부를 확인한다.

직접 실행한 범위는 OpenJDK 17.0.2, 문자열을 담은 세 칸짜리 ArrayList 배열, 단일 스레드다. Java 21은 문서만 확인했고 해당 JDK에서 실행하지 않았다. 실습을 마치면 JShell에 /exit를 입력한다.

← 전체 글 보기