Technology/Java

Java의 Object 클래스가 제공하는 기능 분석

ikjo 2022. 8. 26. 02:24

Object 클래스란?

객체지향 프로그래밍 언어인 자바에서 모든 코드는 반드시 클래스 안에서 존재하며, 이러한 클래스는 서로 관련된 속성과 기능 간의 그룹으로 묶이게 된다. 이때 Object 클래스는 모든 클래스들의 최상위 클래스로, 모든 클래스들은 Obejct 클래스를 상속받는다.

 

우리가 어떤 클래스를 작성할 때 별도로 Object 클래스를 상속하지 않아도 됐었던 이유는 컴파일 시 자바 컴파일러가 'extents Object'를 자동으로 추가해주기 때문이다. 참고로 Object가 아닌 다른 클래스를 상속받도록 명기해준 경우에는 해당 클래스가 이미 Object를 상속받기 때문에 중복해서 추가해주진 않는다.

 

 

Object 클래스가 제공하는 여러가지 기능들

하위 클래스가 상위 클래스를 상속받으면 상위 클래스의 모든 멤버를 상속받고 private 또는 default 멤버를 제외한 나머지 모든 멤버를 사용할 수 있다. 그렇다면 모든 클래스들이 Object 클래스를 상속받음으로써 활용할 수 있는 멤버는 무엇인가? Object 클래스에는 멤버 변수는 없고 다음과 같은 11개의 메서드를 제공한다.

 

equals 메서드

자주 사용되는 equals 메서드부터 살펴보자. 우선 Object 클래스에서 equals 메서드는 다음과 같이 구현되어있다.

 

    public boolean equals(Object obj) {
        return (this == obj);
    }

 

이처럼 equals 메서드는 객체 자기 자신에 대한 참조 변수(this)와 매개변수로 받는 객체(Object 타입) 간의 값이 일치하는지 검증하는 것이다. 이때 두 변수는 자신들이 참조하고 있는 객체의 메모리 주소를 저장한다. 즉, 두 개의 객체간 참조하고있는 메모리 주소를 비교하는 것인데, 참조하고 있는 메모리 주소가 같다는 것은 메모리 상에 동일한 객체를 참조하는 것을 의미한다.

 

하지만 String 클래스의 경우 Object 클래스의 equals 메서드를 다음과 같이 오버라이딩 했기 때문에 참조하고있는 메모리 주소가 아닌 내부 문자열 값을 비교할 수 있다.

 

    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String aString = (String)anObject;
            if (coder() == aString.coder()) {
                return isLatin1() ? StringLatin1.equals(value, aString.value)
                                  : StringUTF16.equals(value, aString.value);
            }
        }
        return false;
    }

 

(참고) 이때 값을 비교하기 전 String coder() 메서드를 통해 서로의 coder(인코딩 정보)가 일치하는지 확인 후 내부 값을 비교해주고 있는 것을 확인할 수 있다.

 

이처럼 equals 메서드를 오버라이딩하지 않았을 때는 단순히 메모리 상에 동일한 객체를 참조하고 있는지 또는 다른 객체를 참조하고 있는지를 검증하는 역할밖에 하지 못한다. 하지만 String 클래스의 예시처럼 equals 메서드에 대한 오버라이딩을 어떻게 하느냐에 따라 클래스의 인스턴스간 "서로 다름에 대한 기준"을 정의할 수 있게 된다.

 

hashCode 메서드

hashCode 메서드는 객체 자기 자신의 해시 코드를 반환하는 기능이다. 그렇다면 해시코드란 무엇일까? 해시코드가 무엇인지 알기 위해서는 '해싱', '해시 함수', '해시 테이블'에 대해 알아야 한다.

 

우선 '해싱'이란 '해시 함수'를 이용하여 데이터를 '해시 테이블'에 저장하고 검색하는 기법을 말한다. 여기서 '해시 함수'는 특정 값을 입력받아 이를 '해시 코드'로 변환해주는데, 이는 해당 데이터를 저장하고 검색하는데 사용되는 위치 정보이다. 이렇게 해시 함수로 생성된 해시 코드는 다시 인덱스로 적당히 환산되어 해시 테이블에 접근할 수 있게 된다.

 

해시 테이블은 배열과 연결리스트의 조합으로 구성된 자료구조로서 앞서 환산된 인덱스별로 키(Key)와 값(Value by Key)을 엔트리, 노드 등의 형태로 저장한다. 

 

키(Key) 값으로 해시 테이블(HashMap)에 저장된 데이터를 찾는 과정(해싱 기법)

 

리스트 같은 자료구조를 사용할 때 인덱스가 아닌 특정 값으로 해당 데이터를 탐색(예를 들어, contains 메서드)하는데 소요되는 시간 복잡도는 O(N)이다. 데이터가 1만 개 저장되어 있다면 최악의 경우 1만 개를 모두 탐색해야될 수도 있는 것이다.

 

반면에, 해싱을 적용한 자료구조에서 특정 데이터를 탐색하는데 소요되는 시간 복잡도는 무려 O(1)이다. 이는 배열의 인덱스로 데이터에 접근 시 시간 복잡도와 같은데, 앞서 언급했듯이 해싱 기법을 적용한 자료구조(해시 테이블)는 배열과 링크드 리스트의 조합으로 이루어져 있기 때문이다.

 

다시 Object 클래스의 hashCode 메서드로 돌아오자. 이제 앞서 언급한 hashCode 메서드의 기능인 "객체 자신의 해시 코드를 반환한다."의 의미가 한결 와닿는다. hashCode 메서드는 바로 '해시 함수' 부분을 구현한 것이다. 해싱을 구현하는 과정에서 가장 중요한 것이 바로 이 해시 함수의 알고리즘인데, 이는 해시 코드의 중복을 막기 위해서이다.

 

여러 데이터들에 대해 해시 코드에서 중복이 많이 생기면 하나의 배열 요소에 연결 리스트로 많은 데이터가 집중될 수 있다. 해당 배열 요소에 접근 했는데, 여러개의 노드가 있으면 결국 해당 노드들을 모두 순차 탐색할 수밖에 없다. 이렇게 되면 빠른 탐색을 위한 생긴 해싱 기법이 무색해진다. 

 

이때 Object 클래스에 정의된 hashCode는 기본적으로 객체의 주소를 이용하는 알고리즘으로 해시 코드를 만들기 때문메모리상 독립적으로 존재하는 각각의 객체들에 대해선 모두 다른 해시 코드를 반환한다. 다만, 64bit JVM에서는 8 byte 주소값으로 해시 코드(4 byte)를 만들기 때문에 중복될 수도 있다.

 

Object 클래스의 hashCode 메서드는 메모리 주소 데이터를 처리해야하므로 네이티브 메서드로 별도 구현되어 있다.

 

하지만 앞서 String 클래스처럼 객체의 같고 다름의 기준이 메모리 주소가 아니라 내부 값이라면 마찬가지로 hashCode 메서드를 오버라이딩하여 해당 값을 기준으로 해시 코드를 만들어줘야 한다. 이렇게 오버라이딩 한 이후에 기존 Object 클래스의 hashCode 메서드처럼 객체 주소 값으로 해시 코드를 생성하고자 하는 경우에는 System.indentityHashCode(Object o)를 이용해볼 수 있다. 다음은 String 클래스가 구현한 hashCode 메서드이다. 해시 코드를 내부 값을 기준으로 생성해주고 있는 것을 확인할 수 있다.

 

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            hash = h = isLatin1() ? StringLatin1.hashCode(value)
                                  : StringUTF16.hashCode(value);
        }
        return h;
    }

 

특히, 어떤 객체 데이터를 해싱 기법을 사용하는 HashMap이나 HashSet 같은 자료구조에 저장 시 hashCode 메서드를 오버라이딩하지 않으면 해당 객체들이 내부적으로 같은 값을 같더라도 다른 해시 코드가 생성되므로 중복 제거 처리가 안되니 유의해야한다.

 

    static class Example {
        int value;

        public Example(int value) {
            this.value = value;
        }
    }
    
    // ...

        List<Integer> a = Arrays.asList(1, 2);
        List<Integer> b = Arrays.asList(1, 2);
        Example c = new Example(1);
        Example d = new Example(1);

        System.out.println(a.hashCode()); // 994
        System.out.println(b.hashCode()); // 994
        System.out.println(c.hashCode()); // 557041912
        System.out.println(d.hashCode()); // 1134712904

        HashSet<Example> set = new HashSet<>();
        set.add(c);
        set.add(d);
        System.out.println(set.size()); // 2

 

ArrayList 클래스의 경우 내부 값을 기준으로 해시 코드를 생성하도록 hashCode 메서드를 오버라이딩했기에 a와 b는 다른 객체이지만 같은 값들을 가졌기에 같은 해시 코드 값이 반환되는 것을 확인할 수 있다. 반면 Example 클래스의 경우 별도 hashCode 메서드를 오버라이딩 하지 않았기에 Object 클래스의 hashCode 메서드 기능을 그대로 상속받는다. 결과적으로 같은 내부 값을 가져도 다른 해시 코드가 반환됨으로써 HashSet에 저장 시 중복 제거가 안되는 것을 확인할 수 있다.

 

toString 메서드

toString 메서드는 인스턴스에 대한 정보를 문자열로 제공한다. Object 클래스에서는 아래와 같이 구현된다.

 

    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

 

이때 getClass 메서드 역시 Object 클래스에 구현된 메서드로서 자신이 속한 클래스의 'Class 객체'를 반환한다.(Class 객체에 대한 자세한 내용은 아래에서 살펴보고자 한다.) 해당 객체로부터 getName 메서드를 호출할 경우 해당 클래스의 이름을 얻을 수 있다. 해당 클래스가 inner class인 경우에는 "부모 클래스명$자식 클래스(inner class)명" 형태로 반환된다. 또한 Integer 클래스의 toHexString 메서드가 해시 코드를 인자로 받고있는데 이는 16진수의 해시코드를 반환한다.

 

따라서 어떤 클래스를 구현할 때 별도로 toString 메서드를 오버라이딩 하지 않는 경우, toString 호출 시 "클래스 이름@16진수의 해시코드" 형태의 문자열 데이터를 얻을 수 있게 된다.

 

public class Test {

    public static void main(String[] args) {
        Example example = new Example(1);

        System.out.println(example.toString()); // Example@3ac3fd8b
        System.out.println(example); // Example@3ac3fd8b
    }


class Example {
    int value;

    public Example(int value) {
        this.value = value;
    }
}

 

참고로 println 등의 출력 메서드가 String이나 배열이 아닌 그 외 객체를 파라미터로 받은 경우에는 null이 아닌 이상 내부적으로 toString을 호출해주도록 구현되어있으므로 출력 메서드 호출 시 반드시 toString 호출한 결과를 인자로 둘 필요는 없다.

 

다음은 Example 클래스의 toString을 오버라이딩 했을 경우의 출력 결과이다. 오버라이딩 시 구현한대로 문자열을 반환해주는 것을 확인할 수 있다.

 

public class Test {

    public static void main(String[] args) {
        Example example = new Example(1);

        System.out.println(example.toString()); // Example{value=1}
        System.out.println(example); // Example{value=1}
    }
}

class Example {
    int value;

    public Example(int value) {
        this.value = value;
    }
    
    @Override
    public String toString() {
        return "Example{" +
            "value=" + value +
            '}';
    }
}

 

clone 메서드

Object의 clone 메서드는 객체 자기 자신을 복제하여 "새로운" 인스턴스를 생성해주는 기능이다.

 

Object 클래스에 네이티브 메서드로 정의된 clone 메서드

 

이때 clone 메서드는 단순히 멤버 변수의 값만 복사하기 때문에 참조 타입의 변수의 경우 얕은 복사(shallow copy)가 된다. 바로 코드를 살펴보자.

 

public class Test {

    public static void main(String[] args) {
        Example example = new Example(1, new Refer(10));

        Example clone = example.clone();

        clone.value = 10;
        clone.refer.value = 100;

        System.out.println(clone); // Example{value=10, refer=Refer{value=100}}
        System.out.println(example); // Example{value=1, refer=Refer{value=100}}
    }
}

class Example implements Cloneable {

    int value;
    Refer refer;

    public Example(int value, Refer refer) {
        this.value = value;
        this.refer = refer;
    }

    @Override
    public String toString() {
        return "Example{" +
            "value=" + value +
            ", refer=" + refer +
            '}';
    }

    @Override
    protected Example clone() {
        Object obj = null;
        try {
            obj = super.clone();
        } catch (CloneNotSupportedException e) { }

        return (Example) obj;
    }
}

class Refer {

    int value;

    public Refer(int value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Refer{" +
            "value=" + value +
            '}';
    }
}

 

복제된 Example 인스턴스(clone)의 멤버 변수인 value와 refer에 대해 각각 수정 처리를 해주었다. 이후 기존 example 인스턴스와 복제된 clone 인스턴스를 출력(toString)해본 결과 앞선 수정 작업이 기존 exmaple 인스턴스의 기본 타입 변수 value에는 영향을 주지 않았지만 참조 타입 변수 refer에는 영향을 주었다. 이는 복제 시 참조 변수에 할당된 값인 메모리 주소를 그대로 복제했기 때문에 참조되고있는 객체가 수정되니 당연히 영향을 받게 된 것이다.

 

참고로 Example 클래스에서 clone 메서드를 오버라이딩 할 때 반환 타입이 Object가 아니라 Example일 수 있는 이유는 JDK1.5부터 오버라이딩할 때 상위 클래스의 반환타입을 자손 클래스의 타입으로 변경하는 것을 허용(공변 반환타입)해주었기 때문이다.

 

아무튼 clone 시 기존 인스턴스의 참조 타입 변수를 독립화하려면 다음과 같이 깊은 복사(deep copy)가 필요하다.

 

    @Override
    protected Example clone() {
        Object obj = null;
        try {
            obj = super.clone();
        } catch (CloneNotSupportedException e) { }

        Example clone = (Example) obj;
        clone.refer = new Refer(this.refer.value);

        return clone;
    }

 

getClass 메서드

앞서 toString 메서드에서 잠시 언급했듯이 Object의 getClass 메서드는 객체 자기 자신이 속한 클래스의 Class 객체를 반환한다.

 

그렇다면 Class 객체란 무엇인가? 

 

java.lang 패키지에 속하는 Class 클래스

 

Class 객체란 말그대로 Class라는 이름의 클래스의 객체특정 클래스의 모든 정보를 저장하고 클래스 당 1개만 존재하는데, 해당 클래스 파일이 클래스 로더에 의해 메모리에 올라갈 때 생성된다. 클래스 로더는 최초 해당 Class 객체가 존재하는지 확인 후 없으면 클래스 패스(class path)에 지정된 경로를 따라 해당 클래스 파일을 찾아 Class 객체로 변환시킨다.

 

이러한 Class 객체는 앞서 언급한 Object의 getClass 메서드로도 얻을 수 있지만, 다음과 같이 클래스 리터럴이나 Class 클래스의 forName 메서드를 이용하여 얻을 수도 있다. 이때 클래스 리터럴이란 "클래스명.class" 형태로 해당 클래스의 Class 객체를 나타내며, forName 메서드의 인자로는 선언된 클래스의 FQCN(Fully Qualified Class Name)이 나와야 한다.

 

        Class<? extends Example> classObject1 = new Example(1, new Refer(10)).getClass();
        Class<Example> classObject2 = Example.class;
        Class<?> classObject3 = Class.forName("Example"); // 현재 Example 클래스는 '이름없는 패키지'에 존재

 

그렇다면 이 Class 객체를 얻고나면 무엇을 할 수 있을까? Class 객체를 얻고나면 이를 통해 해당 클래스에 정의된 멤버 이름, 개수 등 클래스에 대한 정보를 얻을 수 있고 객체를 생성하거나 메서드를 호출할 수 있는데, 이는 리플렉션을 통한 동적인 코드를 작성하는데 사용될 수 있다. (리플렉션에 대해선 별도로 다루고자 한다.)

 

        Class<Example> classObject = Example.class;

        Field[] declaredFields = classObject.getDeclaredFields();

        System.out.println(declaredFields.length); // 2

        for (Field declaredField : declaredFields) {
            System.out.println(declaredField.getName()); // value, refer
        }

 

wait 메서드와 notify 메서드

wait 메서드와 notify 메서드는 쓰레드와 관련된 메서드로서, synchronized로 동기화 시 특정 쓰레드가 오랫 동안 특정 객체의 락을 가진 상태를 유지할 경우 다른 쓰레드들이 해당 객체의 락을 기다리느라 전체 작업의 효율이 낮아지는 현상을 개선하기 위해 나온 것이다.

 

우선 wait 메서드를 호출하면 해당 객체의 lock을 얻은(실행 중인) 쓰레드가 lock을 반환한 후 해당 객체의 waiting pool에 들어가서 notify 메서드 호출을 기다리게 된다. 참고로 wait 메서드의 경우 다른 쓰레드에서 notify()나 notifyAll()를 호출해줄 때까지 waiting pool에서 대기하는 시간에 따라 다음과 같이 3개의 메서드로 오버로딩 된다.

 

  1. wait() : 무한히 대기
  2. wait(long timeout) : ms 단위의 지정된 시간(timeout) 동안 대기
  3. wait(long timeout, int nanos) : ns 단위의 지정된 시간(timeout) 동안 대기(0 <= timeout, 0 <= nanos <= 999999)

이후 notify 메서드가 호출되면 해당 객체의 waiting pool에 있는 모든 쓰레드 중 하나의 임의의 쓰레드에 통지가 되고, 해당 쓰레드는 다시 lock을 얻을 수 있다.

 

이때 중요한 것은 notify는 특정 쓰레드를 지정하여 통지할 수 없다는 것이다. 때문에 최악의 경우 특정 쓰레드의 경우 계속 통지를 받지 못하는 기아(starvation 또는 '아사') 현상이 일어날 수 있다. 이때 그마나 notifyAll 메서드를 이용하면 waiting pool에 있는 모든 쓰레드에 통지가 되긴하나, 이마저도 waiting pool에 있는 모든 쓰레드들이 하나의 lock을 얻기 위해 경쟁하는 '경쟁 상태(race condition)'에 놓이는 문제점이 있다.

 

이러한 문제는 notify와 notifyAll 메서드가 waiting pool에 있는 특정 쓰레드를 선별하여 통지할 수 없다는 데 있다. 이를 개선하기 위해 java.util.concurrent.locks 패키지에서 제공하는 Lock과 Condition 관련 클래스 등을 이용할 수 있다. (이에 대해선 별도로 다루고자 한다.)

 

 

참고자료

  • 도우출판 "자바의 정석"