oop 는 ordinary object pointer 이다. 직역하면 일반 객체 포인터이다. 자바가 생성하는 여러 객체 형태 중 일반 객체를 가리키는 포인터이다.
이 포인터가 가리키는 객체는 oopDesc 라는 구조체다.
이 구조체는 자바에서 생성되는 객체의 레이아웃을 설명한다.
자바의 객체는 oopDesc 라는 구조체 형태로 메모리에 저장된다.
그림으로 그리자면 다음과 같은 형태가 된다.

객체 헤더는 세가지 영역으로 나뉜다.
마크워드는 64비트 시스템에서는 64비트, 32비트 시스템에서는 32비트이다.
이곳은 여러 정보를 저장하는데, 메모리 공간을 효율적으로 사용하기 위해 동적으로 저장하는 정보가 달라진다. 그리고 해당 시점에 마크 워드가 어떤 정보를 저장하는지에 따라 32비트 마지막 2비트인 lock flag 가 달라진다.
이곳에 저장하는 정보의 종류는 다음과 같다.
| lock flag | 상태 | 저장되는 내용 |
|---|---|---|
| 01 | 잠금없음(non-biasable) | 해시코드와 객체 나이값이 저장됨 |
| 00 | 경량략 | 경량락 저장 |
| 10 | 중량락 | 중량락 저장 |
| 11 | GC Marked | GC 가 될 객체로 Marking 된 상태. flag 외에는 0으로 아무것도 저장되지 않음 |
JDK 18 이후 편향락은 없어져서 표시하지 않았다.
동적으로 의미가 달라지는것을 확인하기 위해 JDK 에서 제공하는 JOL 로 객체 레이아웃을 실제로 보자. 테스트를 위해 org.openjdk.jol:jol-core:0.17 를 사용했다.
import org.openjdk.jol.vm.VM
fun main() {
println(VM.current().details())
}
# VM mode: 64 bits
# Compressed references (oops): 3-bit shift
# Compressed class pointers: 0-bit shift and 0x800000000 base
# Object alignment: 8 bytes
# ref, bool, byte, char, shrt, int, flt, lng, dbl
# Field sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8
# Array base offsets: 16, 16, 16, 16, 16, 16, 16, 16, 16
테스트는 JDK 21 / 64bit 에서 실행했다.
Compressed 는 다른 포스트에서 설명하겠다.
객체를 만들고 기본적인 상태를 보자.
import org.openjdk.jol.info.ClassLayout
fun main() {
val obj = Object()
val classLayout = lassLayout.parseInstance(obj)
println(classLayout.toPrintable())
}
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
64비트 시스템에서 실행했기에 위 결과에서 object header: mark 부분이 8바이트임을 확인할 수 있다.
실제 값은 16진수로 표시된다. 2진수로 변환하면 가장 오른쪽 2비트가 01 로 잠금없음 상태임을 알 수 있다.
import org.openjdk.jol.info.ClassLayout
fun main() {
val obj = Object()
val classLayout = lassLayout.parseInstance(obj)
println(obj.hashCode())
println(classLayout.toPrintable())
}
1795960102
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000006b0c2d2601 (hash: 0x6b0c2d26; age: 0)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
객체의 hashCode() 호출 후에는 해시코드값이 저장됨을 볼 수 있다.
import org.openjdk.jol.info.ClassLayout
import org.openjdk.jol.vm.VM
@Volatile
var consumer: Any? = null
fun main() {
val obj = Object()
var lastAddr: Long = VM.current().addressOf(obj)
obj.hashCode()
val layout = ClassLayout.parseInstance(obj)
repeat(10000) {
val currentAddr: Long = VM.current().addressOf(obj)
if (currentAddr != lastAddr) {
println(layout.toPrintable())
}
repeat(10000) { consumer = Any() }
lastAddr = currentAddr
}
}
객체 Generation 이 올라가가기 위해서는 Minor GC 가 발생해야 한다.
consumer = Any() 코드로 많은 쓰레기 객체를 생성하며, consumer 를 volatile 로 설정하여 jit 컴파일러의 최적화 과정에서 사용되지 않는 코드가 삭제되는것을 방지했다.
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000140e5a1309 (hash: 0x140e5a13; age: 1)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000140e5a1311 (hash: 0x140e5a13; age: 2)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000140e5a1319 (hash: 0x140e5a13; age: 3)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000140e5a1321 (hash: 0x140e5a13; age: 4)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
객체 세대가 달라짐을 볼 수 있다.
일부러 hashCode() 도 호출하여 01 lock flag 에서 hashCode 와 객체 Generation 이 같이 저장됨을 볼 수 있다.
각 마크워드 VALUE 를 2진수로 변환하면 가장 오른쪽 2비트는 Lock Flag, 그다음 1비트는 0으로 고정후 이후 비트들이 Generation Age 를 나타냄을 볼 수 있다.
import org.openjdk.jol.info.ClassLayout
fun main() {
val obj = Object()
obj.hashCode()
val layout = ClassLayout.parseInstance(obj)
synchronized(obj) {
println(layout.toPrintable())
}
}
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000016d2ea9d0 (thin lock: 0x000000016d2ea9d0)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
경량락은 Lock Flag 가 00 이다.
이 때에는 hashCode 나 Generation 정보가 마크워드에서 표시되지 않음을 볼 수 있다.
import org.openjdk.jol.info.ClassLayout
fun main() {
val obj = Object()
obj.hashCode()
val layout = ClassLayout.parseInstance(obj)
synchronized(obj) {
repeat(1000000) { 1 + 1 }
println(layout.toPrintable())
}
}
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00006000032004c2 (fat lock: 0x00006000032004c2)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
경량락이 오랫동안 유지되면 중량락으로 변경된다.
클래스 워드는 클래스 로딩 시점에 생성되는 MetaSpace 에 존재하는 클래스 정보를 가리키는 주소값(클래스 포인터)이 저장된다.
실제로 가리키는건 InstanceKlass 라는 JVM 내부 구조체이다.
메모리를 절약하기 위해 클래스 하나당 InstanceKlass 를 MetaSpace 에 하나만 만들어놓고 객체들이 재사용한다.
JVM 입장에서 객체는 바이트 덩어리에 불과하다.
이 객체가 어떤 타입인지, 어떤 클래스를 상속했는지 등등을 판단하기 위해서 이 클래스워드의 포인터를 통해 MetaSpace 에서 알아낸다.
또, 이 InstanceKlass 가 생성될 때, Java Mirror 라 부르는 객체가 생성된다.
우리가 익히 아는 Class 객체이다. 이는 우리가 사용하는 Class 리플렉션과 관련있다.
코드에서 클래스의 정보를 사용할 때 Class 타입의 객체를 사용한다. 사실 이 정보는 MetaSpace 에 InstanceKlass 로 다 있다. 하지만 우리는 코드에서 이를 사용할 수 없다. 코드에서 사용하는 객체들은 반드시 GC 에 의해 메모리 해제되어야 하며, 그러기 위해서는 힙에 존재하는 객체를 사용해야하기 때문이다. 따라서 MetaSpace 에 존재하는 InstanceKlass 를 사용할 수 없다.
따라서 JVM 은 Java Mirror 로서 Class 객체를 힙에 만들어 사용한다.
클래스 워드는 32비트 시스템에서는 32bits, 64비트 시스템에서는 64bits 이다.
하지만 JVM 에는 Compressed Oops 옵션이 켜져있다.(-XX:+UseCompressedOops) 이 때문에 64비트 시스템에서도 클래스 워드는 32 비트이다.
32bits 로는 최대 4GB(2^32) 만큼의 객체 주소를 표현할 수 있다. Compressed Oops 는 64비트 시스템에서 32비트로 32G(2^35) 만큼의 객체 주소를 표현할 수 있는 최적화이다. 이는 따로 포스팅으로 설명하겠다.
메모리 크기를 재대로 계산하기 위해 배열의 길이를 저장한다.
class Student(
val score: Double,
val name: String,
val age: Int,
)
fun main() {
val student = Student("tomas", 12, 89.3)
println(ClassLayout.parseInstance(student).toPrintable())
}
Student object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00106bf8
12 4 int Student.age 12
16 8 double Student.score 89.3
24 4 java.lang.String Student.name (object)
28 4 (object alignment gap)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
객체 인스턴스 데이터 영역에는 필드 데이터, 부모 클래스의 필드 데이터들이 저장된다.
객체 주소 타입(String. 일반 객체라도 마찬가지)이 8bytes 가 아닌 4bytes 임을 알 수 있는데, 이 또한 Compressed Oops 의 결과다.
위 예시를 보면 선언은 Double(8Bytes) > String(4Bytes) > Int(4Bytes) 순이다.
하지만 객체 레이아웃을 보면 int > double > String 순으로 배치되었다.
Jvm 은 인스턴스 데이터를 선언 순서로 배치하지 않는다.
위의 예에서는 객체 해더 12bytes 를 할당 후 4Bytes 남는 공간을 활용하기 위해 Int 형 데이터를 제일 앞으로 배치한 것이다.
객체 순서는 객체 정렬, 시스템 비트수, 타입, 선언 순서, 정렬 스타일 등에 따라 달라질 수 있다.
중요한건 디버깅시 필드가 어떤 오프셋에 있을거라고 확정지을 수 없다는걸 인지하고 있어야 한다.
-XX:FieldsAllocationStyle : 0, 1, 2 값을 지정할 수 있다. 이에 따라 필드 배치가 달라진다.-XX:CompactFields : 기본적으로 상위 클래스의 필드가 먼저 배치되나, true 로 설정하면 하위 클래스 필드 중 상위 클래스의 패딩을 채워넣을 수 있는 필드들이 있다면 끼워넣는다. 기본값이 true.최상위 클래스 필드 > 마지막 클래스 필드 > 두번째 클래스 필드 로 배치된다.모든 상위 부모 클래스의 필드도 이 곳에 기록된다.
JDK 15 이전에는 상위클래스 마다 구역을 정해서 각자 패딩을 맞췄기에 공간을 많이 차지했다.
JDK 15 이후에는 상위 클래스의 필드가 클래스 필드와 같은 레이어에 있는 것 처럼 배치된다. 따라서 패딩을 고려하여 최적화되어 배치된다.
JDK 15 전에서는 거짓공유 현상을 막기 위해 중간에 의도적인 계층구조를 만드는 방법을 사용했다고 한다.
64 비트 시스템에서는 CPU 가 하나의 레지스터에서 최대로 처리할 수 있는 메모리 바이트는 8bytes(64bits) 이다.
객체도 8Bytes 단위로 존재한다면 CPU 에서의 객체 메모리로의 접근이 최적화된다.
JVM 에서 객체의 시작주소는 반드시 8의 배수여야 한다.
따라서 JVM 은 객체를 8Byte 의 배수가 되도록 객체 패딩을 넣어 정렬한다.
위 기본 레이아웃에서
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00041040
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
mark word 는 8bytes, klass word 는 compressed 되어 4bytes 다.
따라서 객체 패딩 4bytes 를 추가하여 8의 배수인 16bytes 크기로 객체를 맞췄다.
이 패딩이 바뀌는 경우를 정리하여 이해해 보자면,
1. 만약 위 기본 객체에서 4Bytes 크기인 int 형 필드가 존재한다면 8(mark) + 4(klass) + 4(field) = 16bytes 가 되어 이 패딩이 사라진다.
2. -XX:ObjectAlignmentInBytes=24 옵션으로 패딩 기준을 바꾸게 되면 24의 배수가 되기 위해 패딩값이 변경된다.
이러한 특성 때문에 예를 들어 16바이트 객체에 1바이트짜리 Boolean 필드를 추가하면 7바이트 패딩이 추가되어 총 24바이트가 되어 버린다.
반대로 이 상태에서는 4바이트 Int 자료형 필드를 추가한다고 해서 객체의 크기가 증가하지 않는다.
객체 레이아웃
객체 패딩