자바 얕은 복사/깊은 복사 차이 - Arrays.copyOf를 어셈블리까지

자바 얕은 복사/깊은 복사 차이 - Arrays.copyOf를 어셈블리까지

ArrayList가 가득 차면 내부 배열이 1.5배로 리사이징되면서 Arrays.copyOf()로 기존 요소를 복사한다. 이 복사는 얕은 복사다.

먼저: 얕은 복사와 깊은 복사의 차이

  • 얕은 복사(shallow copy): 배열만 새로 만들고, 그 안의 객체는 복사하지 않는다. 참조값만 복사되므로 복사본과 원본이 같은 객체를 가리킨다.
  • 깊은 복사(deep copy): 배열 안의 객체까지 새로 만들어 복사한다. 복사본을 수정해도 원본에 영향이 없다.
Person[] original = { new Person("kim") }; Person[] copy = Arrays.copyOf(original, 1); copy[0].setName("lee"); System.out.println(original[0].getName()); // "lee" — 원본도 바뀐다 (얕은 복사)
원리는 자바의 pass by value와 같다. 자바는 참조형을 넘길 때도 객체가 아니라 참조값의 복사본을 넘긴다. 그래서 메서드 안에서 객체의 내부 상태(필드)는 변경할 수 있다. 하지만 매개변수에 new로 만든 다른 객체를 재할당해도 호출한 쪽의 참조는 변경되지 않는다. 배열 복사도 같은 원리다 — Arrays.copyOf가 복사하는 것은 배열 안의 객체가 아니라 각 요소에 담긴 참조값이다.
그런데 얕은 복사임을 문서 말고 코드로 확인할 수 있을까? JDK 소스부터 HotSpot 소스, 그리고 어셈블리 stub까지 따라간다.

전체 호출 체인

ArrayList.add(E) → ArrayList.grow() → Arrays.copyOf(T[], int) → System.arraycopy(...) // native 진입점 → JVM_ArrayCopy (jvm.cpp) → ObjArrayKlass::copy_array → ArrayAccess::oop_arraycopy → Copy::conjoint_oops_atomic → pd_conjoint_oops_atomic // oop* → jlong* 캐스팅 → _Copy_conjoint_jlongs_atomic // 어셈블리 stub

1. 자바 레이어 — 객체를 새로 만들지 않음

public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) { T[] copy = ((Object)newType == (Object)Object[].class) ? (T[]) new Object[newLength] : (T[]) Array.newInstance(...); System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; }
new Object[newLength]로 만든 것은 null로 채워진 빈 배열이다. 자바 코드 어디에서도 원소 객체를 새로 생성하지 않는다. 실제 복사는 native 메서드인 System.arraycopy로 위임된다.
@IntrinsicCandidate public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
@IntrinsicCandidate 덕분에 핫 코드에서는 JIT가 이 호출을 통째로 어셈블리로 치환한다. 이하 C++ 경로는 인터프리터/콜드 코드 기준이다.

2. HotSpot 진입 — 배열 타입에 따른 분기

hotspot/share/prims/jvm.cppJVM_ArrayCopy로 들어온 뒤 다음 한 줄에서 가상 디스패치가 일어난다.
arrayOop s = arrayOop(JNIHandles::resolve_non_null(src)); s->klass()->copy_array(s, src_pos, d, dst_pos, length, thread);
s->klass()는 객체 헤더의 klass 포인터를 따라가 해당 객체의 메타클래스를 반환한다. 자바의 모든 객체는 헤더에 자신의 타입을 가리키는 klass 포인터를 가지고 있다.
copy_array는 Klass 기반 클래스의 가상 함수이며, 각 하위 클래스가 자기 방식대로 오버라이드한다. 따라서 가상 디스패치는 객체의 실제 타입에 따라 다른 구현으로 분기된다.
  • Object[]로 생성된 배열의 klass는 ObjArrayKlass의 인스턴스 → ObjArrayKlass::copy_array
  • int[]로 생성된 배열의 klass는 TypeArrayKlass의 인스턴스 → TypeArrayKlass::copy_array

3. 인덱스를 메모리 오프셋으로 변환

ObjArrayKlass::copy_array인덱스를 바이트 오프셋으로 바꾼다.
size_t src_offset = (size_t) objArrayOopDesc::obj_at_offset<oop>(src_pos); size_t dst_offset = (size_t) objArrayOopDesc::obj_at_offset<oop>(dst_pos);
obj_at_offset<oop>은 "n번째 oop 슬롯의 위치"를 계산한다. 이 시점에서 코드는 복사 대상을 "객체"가 아니라 "oop 슬롯"으로 명시한다.
oop(Ordinary Object Pointer): HotSpot이 자바 객체를 가리킬 때 쓰는 포인터. 64비트 JVM에서는 8바이트다.

4. GC 배리어 — 객체 배열 경로에만 존재

객체 배열 복사는 GC 배리어로 감싸여 있다.
bool oop_arraycopy_in_heap(...) { bs->write_ref_array_pre(dst_raw, length, false); // pre-barrier Raw::oop_arraycopy(...); // 실제 비트 복사 bs->write_ref_array((HeapWord*)dst_raw, length); // post-barrier (카드 마킹) }

(1) Pre-barrier

dst = [null, null, null, null, null, null, null, null, null, null, ...]
기존 배열에 덮어쓰는 경우 GC에 옛 참조를 알리는 처리가 일어난다. 이 예시처럼 새로 할당된 빈 배열이라면 옛 참조 자체가 없으므로 실질적인 동작이 없다.

(2) 비트 복사

src = [str0, str1, str2, ..., str9] ↓ ↓ ↓ ↓ dst = [str0, str1, str2, ..., str9, null, null, null, null, null]
src 슬롯의 oop 값이 dst 슬롯으로 그대로 옮겨진다.

(3) Post-barrier

G1은 dst의 해당 영역에 대응하는 카드 테이블 엔트리를 "dirty"로 표시한다. 다음 GC가 "이 카드 범위에 새 참조가 생겼으니 스캔해야 한다"는 것을 알게 된다.

왜 post-barrier가 필요한가

세대별 GC는 minor GC를 빠르게 처리하기 위해 "old gen이 young gen을 가리키는 참조"를 따로 추적한다. 카드 테이블 마킹이 그 추적의 핵심 메커니즘이다.
old gen에 있는 객체 배열의 슬롯에 young gen의 객체 참조가 새로 들어가는 상황을 떠올려보자. 이 코드 경로는 모든 객체 배열 복사가 공유하므로 이런 경우도 처리해야 한다.
이때 post-barrier가 없으면 minor GC는 old gen을 스캔하지 않으므로, 그 배열이 young gen의 객체를 참조하고 있다는 사실을 알지 못한다. 그 결과 살아있는 객체가 회수돼버린다.
post-barrier가 카드를 dirty로 마킹해두어야 minor GC가 그 영역을 추가로 스캔해 참조 관계를 발견한다.

비대칭이 보여주는 것

원시 타입 배열 복사 경로에는 이 배리어가 없다. 슬롯에 들어있는 값이 주소가 아니라 값 자체이므로 GC가 추적할 새로운 참조 관계가 발생하지 않기 때문이다.
객체 배열 경로에만 배리어가 붙는 이유는 복사가 새로운 참조를 생성하기 때문이다. 이 비대칭 자체가 "객체 배열 복사는 참조값을 다룬다"는 증거다.

5. oop* → jlong* 캐스팅

비트 복사를 따라가면 hotspot/os_cpu/linux_x86/copy_linux_x86.hpp(OS+CPU 조합별로 정의)에 도착한다.
static void pd_conjoint_oops_atomic(const oop* from, oop* to, size_t count) { #ifdef AMD64 assert(BytesPerLong == BytesPerOop, "jlongs and oops must be the same size"); _Copy_conjoint_jlongs_atomic((const jlong*)from, (jlong*)to, count); #endif }
이 두 줄이 의미하는 바는 다음과 같다.
  • oop*(객체 포인터 배열) → jlong*(8바이트 정수 배열)로 캐스팅
  • 이후 호출되는 _Copy_conjoint_jlongs_atomiclong[] 복사에 쓰이는 바로 그 함수
캐스팅 이후의 코드는 "이 8바이트가 객체 주소인지 단순 정수인지" 구분하지 않는다. 8바이트 단위로 N개를 옮기는 동작만 남는다.

6. 어셈블리 stub — movq 두 줄

_Copy_conjoint_jlongs_atomic은 일반 C++ 함수가 아니라 JVM 부팅 시 메모리에 동적으로 써넣는 어셈블리 stub이다.
L_copy_8_bytes: movq rax, [from + qword_count*8 - 8] movq [to + qword_count*8 - 8], rax dec qword_count jnz L_copy_8_bytes
x86에는 메모리 간 직접 이동 명령이 없으므로 레지스터(rax)를 거쳐 8바이트를 옮긴다. 이 루프가 N번 반복되면서 src 슬롯의 oop 값(=객체 주소)을 dst 슬롯에 복사한다.

복사 결과

복사 전
src[0] → 0x1010 → "hello" 객체 (힙) dst[0] → null
복사 후
src[0] → 0x1010 ─┐ ├──→ "hello" 객체 (힙, 그대로 1개) dst[0] → 0x1010 ─┘
객체 자체는 힙에서 움직이지 않고, dst 슬롯과 src 슬롯이 동일한 주소값을 갖게 된다.

추가: 왜 이름이 memcpy가 아니라 "conjoint"인가

어셈블리 stub 이름의 conjoint는 "겹쳐 있어도 되는(overlapping-safe)" 복사라는 뜻이다. HotSpot의 Copy 유틸리티는 겹침을 허용하지 않는 disjoint 계열과 겹침을 처리하는 conjoint 계열을 구분하는데, System.arraycopy가 conjoint 경로를 타는 이유는 명세가 겹침을 요구하기 때문이다. System.arraycopy의 Javadoc을 보자.
If the src and dest arguments refer to the same array object, then the copying is performed as if the components ... were first copied to a temporary array ... and then ... copied into positions ... of the destination array. — System.arraycopy Javadoc (Java SE 21)
같은 배열 안에서의 시프트(대표적으로 ArrayList.remove(i)가 뒤 원소들을 한 칸 앞으로 당길 때)에서도 결과가 보장돼야 하므로, C로 치면 memcpy가 아니라 `memmove` 시맨틱이 필요한 것이다. 위 stub이 복사 시작 방향을 판단하는 것도 그 때문이다.

정리

  • Arrays.copyOf의 복사는 6단계의 호출 체인을 거쳐 어셈블리 movq 두 줄로 귀결된다.
  • 객체 배열 복사 경로에만 GC 배리어가 붙으며, 이는 복사 대상이 참조값임을 보여준다.
  • pd_conjoint_oops_atomicoop*jlong*로 캐스팅하는 시점부터 코드는 객체 주소를 단순한 8바이트 정수로 취급한다.
  • 따라서 Arrays.copyOf의 복사는 얕은 복사다.