프로그래밍/Java

[Java] JDK 부수기 - (1) java.lang.Object - 1. clone

Churnobyl 2023. 11. 20. 00:49
728x90
반응형


개요

 

 기계를 뜯어보는 것은 기계가 어떻게 동작하는지 이해하는 데 많은 도움이 된다. 예를 들어 필자가 잠자기 전 뒤척일 때, asmr처럼 틀어놓는 손목시계 복구 영상처럼 시계를 쌀알 크기의 톱니바퀴 하나까지 다 뜯어보는 것은 이 톱니바퀴가 왜 필요한지 이해하는 데 도움이 될 수 있고, 결과적으로 같은 모델의 시계가 고장 났을 때 이상 현상만 보고도 어떤 부분이 문제가 있는지 예측할 수 있다.

 

Red Dead Restoration

 

 JDK 또한 하나의 기계라고 생각하면 각 부분이 어떻게 구현되어 있는지 살펴보는 것이 자바를 이해하는 데 도움이 될 것으로 판단된다. 또한 수십년에 걸쳐 깎아온 코드를 보면 더 효율적이고 직관적인 코드를 어떻게 짤 수 있는지 도움을 받을 수 있지 않을까 하는 마음에 JDK 부수기 컨텐츠를 하려고 한다. 전부 다 하기는 현실적으로 불가능하지만 핵심적인 구현 코드라도 틈날 때마다 해야겠다.

 


java.lang

Every compilation unit implicitly imports every public type name declared in the predefined package java.lang, as if the declaration import java.lang.*; appeared at the beginning of each compilation unit immediately after any package declaration. As a result, the names of all those types are available as simple names in every compilation unit.
모든 컴파일 단위(Compilation unit)들은 미리 정의되어 있는 java.lang 패키지에 선언된 public type들을 암시적으로 import한다. 이것은 마치 package선언문 아래에 import java.lang.*;가 이미 있는 것처럼 행동한다. 결론적으로 java.lang 패키지의 public type 이름들은 import없이 간단한 이름으로 사용할 수 있다.

 

 우선 java의 lang패키지는 자바 프로그래밍 언어 구현의 기초가 되는 클래스다. 즉, JDK에 있는 수많은 클래스들은 lang패키지를 기초로 파생된다. Comparable, Readable과 같은 Interface나 Boolean, Integer, Long 등과 같은 Class, 그리고 Exception이나 Error에 관련된 클래스들도 여기 lang에 들어가 있다.

 

 그만큼 중요하고 자주 쓰이는 패키지이므로 위의 원문처럼 모든 컴파일 단위는 기본적으로 java.lang을 import하며 별도로 import없이 간단한 이름으로 내부의 클래스들을 사용할 수 있다.

 

 java.lang에 대한 전체 내용은 docs참조

 

 그렇다면 자바 언어의 근간이 되는 java.lang 중에서도 가장 기초적인 Object클래스에 대해서 공부해보자.

 

 


java.lang.Object

Class Object is the root of the class hierarchy. Every class has Object as a superclass. All objects, including arrays, implement the methods of this class.
Object 클래스는 클래스 계층 구조 가장 상위에 있는 root다. 모든 클래스는 Object를 부모 클래스(Superclass)로 가진다. 배열을 포함한 모든 객체는 Object클래스의 메서드를 구현한다.

 

 정의에 따르면 Object 클래스는 모든 클래스가 공통적으로 포함하고 있어야 할 기능을 제공하는 모든 클래스의 조상 클래스다. 그런데 보면 우리가 클래스를 새로 만든다 하더라도 Object클래스를 따로 상속시키지 않는다. 우리가 클래스를 만들면 Java Compiler가  암시적으로 Object클래스를 상속시키기 때문이다.

 

 JVM-version/lib/src/java.base/java/lang/Object.java으로 가서 보면 주석을 제외한 코드 자체는 너무 간단하게 쓰여져 있다. 왜냐하면 Object 클래스의 대부분의 메서드가 속도나 최적화의 측면에서 이점을 얻기 위해 자바가 아닌  C나 C++ 같은 더 낮은 수준의 언어로 구현되어 있기 때문이다. Object 클래스의 일부를 보자.

 

package java.lang;

import jdk.internal.vm.annotation.IntrinsicCandidate;

public class Object {

	@IntrinsicCandidate
	public Object() {}
    
    
	@IntrinsicCandidate
	public final native Class<?> getClass();
    
	/**
	* 메서드들
	*/
}

 

 

 위의 getClass 메서드 같이 앞에 native 키워드가 붙어 있으면 JVM은 이 메서드가 일반적인 Java 코드가 아닌 다른 언어로 구현되어 있다고 인식하고 JNI를 이용해 해당 네이티브 메서드를 호출하고 결과를 처리한 뒤 결과물을 받아온다. 추가적인 내용은 아래에서 메서드를 차근차근 살펴보면서 이해해보자.

 

 Object클래스는 멤버 변수 없이 다음의 11개의 메서드 만으로 이루어져 있다. 

 


메서드 (Methods)

 

제어자 및 타입
(Modifier and Type)
메서드(Method) 설명(Description)
protected Object clone() 해당 객체의 복사본을 만들고 리턴한다.
boolean equals(Object obj) 또 다른 객체 obj가 해당 객체와 동등(equal to)한지 보여준다.
protected void finalize() 향후 Deprecated 예정
GC에 의해 메모리에서 제거될 때 해당 메서드가 실행된다.
final Class<?> getClass() 해당 객체가 실제로 속한 클래스(runtime class)를 리턴한다.
int hashCode() 해당 객체의 해시 코드 값을 리턴한다.
String toString() 해당 객체의 문자열 표현을 리턴한다.
final void notify() 해당 객체에 대해 대기중인 임의의 스레드 하나를 깨운다.
final void notifyAll() 해당 객체에 대해 대기중인 스레드를 전부 깨운다.
final void wait() notify()가 호출되거나 인터럽트되어 현재 스레드가 깨어날 때까지 기다리게 한다.
final void wait(long timeoutMillis) notify()의 호출 혹은 인터럽트, 아니면 특정 시간이 경과할 때까지 기다리게 한다.
final void wait(long timeoutMillis, int nanos) notify()의 호출 혹은 인터럽트, 아니면 특정 시간이 경과할 때까지 기다리게 한다.

*JDK17.0.9 (2023-10-17 LTS 기준)

 

 이 메서드들은 모든 클래스가 공통적으로 갖고 있는 메서드들이며 우리가 클래스를 만들 때 적당히 오버라이딩해서 사용할 수 있다. 아래 다섯개 메서드 notify ~ wait는 쓰레드 관련 메서드들이므로 이를 제외한 각 메서드를 좀 더 자세히 알아보자.

 


1. clone()

해당 객체의 복사본을 만들고 반환한다.

 

JVM 뜯어보기

 

 Object.clone() 메서드는 메서드를 호출한 객체를 복사하는 메서드다. 이 부분은 Object클래스를 보면 해당 코드는 네이티브 메서드로 구현되어 있다.

 

public class Object {

	@IntrinsicCandidate
	protected native Object clone() throws CloneNotSupportedException;
    
}

 

 코드를 보면 @IntrinsicCandidate 어노테이션과 native 키워드가 붙어 있다. @IntrinsicCandidate 어노테이션은 이 메서드가 JVM에 의해 내장함수화될 수 있는 가능성이 있다는 뜻이다. JVM은 성능 향상을 위해 함수 일부를 자바 코드가 아닌 C나 C++같은 저수준 언어를 이용해 대체하는데, clone메서드 또한 그렇게 구현되어 있다는 것이다. 그래서 clone()메서드가 실제로 구현되어 있는  부분은 src\hotspot\share\prims\jvm.cpp에 C++을 이용해 구현되어 있으며, 해당 네이티브 메서드의 구현 방식은 넘겨 받은 객체가 clonable한지 검사하고 clonable하다면 객체를 복사한 뒤 다시 자바로 보내주고, clonable하지 않다면 CloneNotSupportedException를 throw한다.

 

 이제 다시 자바 코드로 돌아와서 실제 clone() 메서드를 사용해보자.

 

사용해보기

 

public class Person implements Cloneable {
    int id;

    public Person(int id) {
        this.id = id;
    }

    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {
        Person person_1 = new Person(988515);
        Person person_2 = (Person) person_1.clone();

        // person_1과 person_2(복사)는 서로 다른 메모리 주소를 가진다.
        System.out.println("person_1 == person_2 : " + (person_1 == person_2));
        System.out.println(person_1);
        System.out.println(person_2);

        System.out.println("========================");
        // person_1과 person_2(복사)는 같은 Class 객체를 가진다.
        System.out.println(person_1.getClass() == person_2.getClass());

        System.out.println("========================");
        // person_1과 person_2(복사)의 id값은 서로 같다.
        System.out.println(person_1.id == person_2.id);
    }
}
person_1 == person_2 : false
Person@2328c243
Person@bebdb06
========================
true
========================
true

 

 첫번째 코드를 보면 Person이라는 클래스를 만들었고 id값을 멤버변수로 가지고 있다. 가장 주목할 만한 점은 Person 클래스는 Object 클래스를 암시적으로 상속받았기 때문에 extends를 이용해 Object를 상속받았다는 것을 명시할 필요없이 clone()메서드를 오버라이딩할 수 있다는 점이다. 이 때 clone() 메서드를 오버라이딩하기 위해서는 상속받는 클래스가 복사(copy)되어도 안전하다는 것을 알리기 위한 Clonable 인터페이스를 필수적으로 구현해야 한다. 이 부분은 jvm.cpp의 JVM_Clone부분을 살펴보자. 그렇지 않으면 CloneNotSupportedException이 발생한다. 또한 관례적으로 clone() 메서드를 오버라이딩할 때는 접근자를 protected가 아니라 public으로 바꿀 것을 권고하고 있다. clone() 메서드의 구현 부분은 상위 클래스의 clone() 메서드를 그대로 사용할 수 있도록 super.clone()을 리턴해주었다. 이를 통해 Object 클래스에 구현된 clone() 메서드가 정확히 어떤 역할을 하고 있는지 살펴 볼 수 있을 것이다.

 

 이제 Main 클래스를 보자. 아주 간단하게 988515라는 id값을 가진 person_1라는 Person 객체를 생성하고 우리가 오버라이딩한 clone() 메서드를 이용해 person_1 인스턴스를 person_2로 복사해 주었다. 이때, clone() 메서드는 Object 타입으로 값을 리턴하므로 (Person) 을 붙여 하위 클래스로의 타입 캐스팅을 해주었다.

 

 이제 복사가 잘 되었는지 두 인스턴스를 비교해보자. 아래 person_1와 person_2를 비교한 결과들을 아래 println문으로 나열했다. 첫번째로 두 인스턴스를 ==(메모리 주소 비교)로 비교했을 때 false가 출력됐고, 각각을 출력했을 때도 서로 다른 16진수 해시코드가 출력됐다. 즉 person_2는 person_1으로부터 복사되었지만 두 변수가 같은 메모리 주소를 가리키고 있는 것이 아니라 각각 다른 객체를 가리키고 있는 것이다. 두번째와 세번째는 각각의 클래스와 안의 id값을 비교한 것인데 둘 다 true가 산출됐다. 즉, 인스턴스가 잘 복사되어 같은 정보를 담고 있음을 알 수 있다.

 

얕은 복사 (Shallow copy) vs 깊은 복사 (Deep copy)

 

 하지만 문제가 하나 있다. clone() 메서드가 실제로 구현되어 있는 코드를 확인하면 다음과 같은 부분이 있다.

 

// jvm.cpp

JVM_ENTRY(jobject, JVM_Clone(JNIEnv* env, jobject handle))

  // ...

  // Make shallow object copy
  const size_t size = obj->size();
  oop new_obj_oop = nullptr;
  if (obj->is_array()) {
    const int length = ((arrayOop)obj())->length();
    new_obj_oop = Universe::heap()->array_allocate(klass, size, length,
                                                   /* do_zero */ true, CHECK_NULL);
  } else {
    new_obj_oop = Universe::heap()->obj_allocate(klass, size, CHECK_NULL);
  }

  HeapAccess<>::clone(obj(), new_obj_oop, size);
  
  // ...
  
JVM_END

 

 주석을 보면 Make shallow object copy. 즉 clone()을 이용한 복사는 얕은 복사(shallow copy)를 수행하는 것이었다. 얕은 복사는 비트 단위 복사(bitwise copy)로 객체의 모든 비트를 그대로 다른 메모리 위치로 복사한다. 다시 말해 객체의 모든 필드가 그대로 복사되는 것이어서 만약 원본 객체가 또 다른 객체를 참조하고 있었다면 복사된 객체는 원본 객체와 동일한 객체를 참조하게 된다. 예를 들어, 위의 Person 클래스의 id는 int형으로 원시 타입(Primitive Type)이라서 리터럴를 참조하므로 문제가 없지만, 만약 id가 int형이 아니라 0x100에 있는 객체를 참조하고 있었다면 clone()을 이용해 객체를 복사한다고 하더라도 복사된 객체 역시 0x100에 있는 객체를 참조한다.

 

Shallow Copy vs Deep Copy (Oracle)

 

 그렇다면 얕은 복사면 어떤 문제가 생길까. 만약 원본 객체인 person_1에서 id값을 988515가 아니라 100000으로 변경한다고 했을 때, person_2의 id값이 함께 바뀌는 것을 기대하진 않았을 것이다. 하지만 id 변수가 참조형일 경우 복사된 객체인 person_2 역시 같은 0x100을 참조하고 있으므로 id값이 함께 바뀌게 된다. 다음 예제로 살펴보자.

 

public class IdCard {
    int idNumber;

    public IdCard(int id) {
        this.idNumber = id;
    }
}
public class Person implements Cloneable {
    IdCard id;

    public Person(int id) {
        this.id = new IdCard(id);
    }

    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {
        Person person_1 = new Person(988515);
        Person person_2 = (Person) person_1.clone();

        System.out.println(person_1.id.idNumber == person_2.id.idNumber);
        System.out.println(person_1.id.idNumber);
        System.out.println(person_2.id.idNumber);

        person_1.id.idNumber = 100000;

        System.out.println("=============================");
        System.out.println(person_1.id.idNumber == person_2.id.idNumber);
        System.out.println(person_1.id.idNumber);
        System.out.println(person_2.id.idNumber);
        
        System.out.println("=============================");
        System.out.println(person_1.id);
        System.out.println(person_2.id);
    }
}
true
988515
988515
=============================
true
100000
100000
=============================
IdCard@5ef04b5
IdCard@5ef04b5

 

 Person의 멤버 변수인 id가 int가 아니라 참조형 변수인 IdCard가 되었다. 이제 person_1를 다시 clone()해 person_2를 생성하고 두 id값을 비교했을 때는 true가 출력됐고 두 값은 같은 값을 가진다. 하지만 person_1의 id라는 IdCard객체의 idNumber값을 100000로 수정하고 다시 비교했을 때도 마찬가지 true가 출력됐고 두 값이 같은 값을 가진다. 이유는 아래 나와 있듯이 얕은 복사를 통해 person_1과 person_2가 같은 id를 가리키고 있었기 때문이다.

 

 그렇다면 깊은 복사를 하려면 어떻게 해야 할까. 여러 가지 방법이 있지만 여기서는 직접 객체를 생성해 복사하는 방법으로 구현해보자. 

 

public class Person implements Cloneable {
    IdCard id;

    public Person(int id) {
        this.id = new IdCard(id);
    }

    @Override
    public Object clone() throws CloneNotSupportedException {
        Object obj = super.clone();
        Person person = (Person) obj;
        person.id = new IdCard(this.id.idNumber);
        return person;
    }
}

 

 이렇게 Person 클래스에서 clone() 메서드를 오버라이딩할 때 새로운 IdCard 객체를 생성해서 가리키도록 해주면 된다. 그럼 결과는 다음과 같다.

 

true
988515
988515
=============================
false
100000
988515
=============================
IdCard@5ef04b5
IdCard@5f4da5c3

 

 이제 person_1와 person_2의 id는 서로 다른 메모리 주소를 가리키고 있으므로 둘 중 하나의 값을 바꾸더라도 값이 함께 바뀌지 않는다. 

반응형