ArrayList가 가득 차면 어떻게 동작할까?
내부 배열이 1.5배로 리사이징되면서 `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.cpp`의 JVM_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 (카드 마킹)
}
각 단계가 무엇을 하는지 구체적인 예시로 살펴보자.
길이 10짜리 String[]을 길이 15짜리 새 배열로 확장하는 상황을 가정한다.
(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_atomic은 long[] 복사에 쓰이는 바로 그 함수
캐스팅 이후의 코드는 "이 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 슬롯이 동일한 주소값을 갖게 된다.
정리
- Arrays.copyOf의 복사는 6단계의 호출 체인을 거쳐 어셈블리 movq 두 줄로 귀결된다.
- 객체 배열 복사 경로에만 GC 배리어가 붙으며, 이는 복사 대상이 참조값임을 보여준다.
- pd_conjoint_oops_atomic이 oop*를 jlong*로 캐스팅하는 시점부터 코드는 객체 주소를 단순한 8바이트 정수로 취급한다.
- 따라서 Arrays.copyOf의 복사는 얕은 복사이다.
'Java' 카테고리의 다른 글
| [Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기 (0) | 2026.05.17 |
|---|---|
| [Java] enum - 왜 안전한가, private 생성자로 강등되는 이유 (0) | 2026.05.14 |
| [Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작 (2) | 2026.05.12 |
| [Java] synchronized 동작 원리 - 모니터 락, CAS (0) | 2026.05.12 |