우리가 흔히 쓰는 오버라이딩과 캐스팅은 어떤 원리로 작동이 되는지 의문이 생겼다.
일단 내가 의문을 가진 상황을 먼저 2가지 보여주고 시작하겠다.
기초 설정
public class Parent {
public void commonMethod() {
System.out.println("Parent -> commonMethod");
}
}
public class Child extends Parent{
@Override
public void commonMethod() {
System.out.println("Child -> commonMethod");
}
public void onlyChildMethod() {
System.out.println("Only Child");
}
}
Parent 클래스를 Child 클래스가 상속받고 있다.
Parent 클래스와 Child 클래스 모두 commonMethod() 를 가지고 있고
Child 클래스만 onlyChildMethod() 를 가지고 있다.
내가 가졌던 의문 2가지
public class Main {
public static void main(String args[]) throws Exception {
Parent person = new Child();
person.commonMethod();
// person.onlyChildMethod(); 컴파일 에러 발생
((Child)person).onlyChildMethod();
}
}
<결과>
Child -> commonMethod
onlyChildMethod
우리가 익숙하게 사용하는 코드들이다. 이 결과에 불만을 가지는 사람은 없을 것이다. 하지만 조금 더 깊이 생각해보았을때 의문이 생길 수 있다. 아래는 내가 가진 의문들이다.
하지만 person.onlyChildMethod() 는 컴파일 에러가 발생한다.
그런데 바로 아래의 코드를 보면 ((Child)person).onlyChildMethod() 는 정상적으로 동작한다.
((Child)person) 과 person 의 주소값은 당연히 동일하다.
그렇다면 자바는 어떻게 다운캐스팅을 구분하여 onlyChildMethod() 를 발생시키는가? -> 1번째 의문
Parent person = new Child(); 를 사용하였기 때문에 person.commonMethod() 는 오버라이딩 된 메서드를 최우선으로 선정하고 Child 의 commonMethod() 를 실행한다.
Child 만 가지고 있는 onlyChildMethod() 는 실행을 못하면서 어떻게 Child 에서 오버라이딩 된 메서드를 실행할 수 있었던 걸까? -> 2번째 의문
오늘의 포스팅 내용은 이 2가지의 의문에 대한 해결이다.
의문을 해결하기 위한 배경 지식
컴파일러와 JVM
자바의 실행과정
소스 파일(.java) -> [컴파일러] -> 바이트 파일(.class) -> [ JVM ]
1.컴파일러가 소스코드(.java) 를 읽어들여 .class 파일로 변환시킨다.
2. .class 파일들을 클래스 로더가 JVM 으로 불러들인다.
3. 불러들인(로딩된) .class 파일을 JVM 의 Execution engine 을 통해 해석한다.
4. 해석된 바이트 코드는 JVM 의 Runtime Data Area 에 배치되어 실질적으로 수행된다.
이후 new 명령어를 사용하여 객체에 메모리를 할당한다.
다형성의 메모리 할당
우리가 아까 작성한 Parent person = new Child(); 의 경우 메모리가 어떻게 할당이 될까?
Child 인스턴스를 만들었다. new Child() 로 자식 타입인 Child 를 생성했기 때문에 메모리 상에 Child 와 Parent 가 모두 생성된다.
이후 생성된 참조값을 Parent 타입인 person 에 담는다.
person 은 먼저 참조값을 통해 인스턴스를 찾는다. 그리고 인스턴스 안에서 실행할 타입을 찾는다.
person 의 타입은 Parent 이기에 Parent 클래스부터 부모방향으로 필요한 기능을 찾는다.
컴파일러와 JVM 을 곁들인 원리
A 클래스의 생성자를 처음 호출 시 JVM 에 A 클래스가 올라온다.
new A() 를 수행하게 되면 메모리 영역에 올라가게 된다 (primitive type = 0, reference type = null)
이는 다시 말해, 컴파일러의 변환 과정에서는 참조변수의 타입은 확인하더라도 new A() 와 연결지어 검증하지는 않는다는 것이다. (어휘력이 부족하여 표현을 모호하게 되었다.)
컴파일러는
Parent person = new Child(); 에서 Child() 가 Parent 의 자식 타입인것은 확인한다.
-> person 객체의 타입 오류를 확인한다.
어찌되었건 person 의 타입 자체는 Parent 이다.
Parent 에는 Child 클래스에 대한 내용이 없다.
따라서 person.onlyChildMethod() 는 컴파일러에서 오류가 난다.
((Child)person) 으로 다운캐스팅을 사용하여 타입을 Child 로 바꿔주어야 한다.
그러면 결국 컴파일러는 불확실성을 담은 .class 파일을 JVM 에게 넘겨주게 된다.
여기서 말하는 불확실성은 ((Child)person) 으로 타입을 바꿔주었다 했을 때, 과연 person 객체의 메모리에 Child 가 존재하는가? 이다.
컴파일러는 문법에 문제가 없기에 .class 파일을 생성하게 되지만,
Child 클래스가 메모리에 존재하지 않는다면 ClassCastException 이 발생하게 된다.
동적 바인딩
즉, 실행시간에 객체의 타입이 확정되게 된다. 이를 동적바인딩이라고 말한다.
이러한 동적 바인딩은 Runtime 시점에 해당 메서드를 구현하고 있는 실제 객체 타입을 기준으로 실행될 함수를 호출한다.
그러니까 우리가 궁극적으로 궁금한건 어떻게 실제 객체 타입을 기준으로 삼을 수 있는가? 이다.
자바 메모리 구조는 메서드 영역, 스택 영역, 힙 영역이 있다.
메서드 영역에는 클래스의 바이트 코드, 필드, 메서드와 생성자 코드 등 모든 실행 코드가 존재한다.
힙 영역은 객체가 생성되는 영역이다.
Parent person = new Child(); 에서
참조변수(person)는 객체의 주소를 담고 있다. 객체의 주소는 힙 영역에 있고 메서드는 메서드 영역에 존재한다.
그렇다면 어떻게 메서드 영역에 있는 정보를 참조변수가 사용할 수 있는 것일까?
객체 안에 가상 메서드 테이블의 주소가 있기 때문이다.
객체는 생성되는 시점에 메서드 테이블을 생성한다.
이후, 메서드 테이블에 method area 에 있는 주소값들을 넣으면서 실행계획을 세운다.
주소값들은 상위 클래스에서 하위 클래스 방향으로 진행이 되는데, 이 과정에서 오버라이딩에 대한 의문이 해소된다.
객체 생성과정에서 method area 에 있는 주소값을 자식 버전으로 점점 덮어씌우게 되면서 결국 오버라이딩이 된 부모의 메서드는 자식의 메서드로 덮어씌워지게 된다.
따라서 ((Child)person) 로 타입을 다운캐스팅을 해줬을 때 Child 만 가지고 있었던 메서드를 실행할 수 있었고, 오버라이딩 된 메서드를 실행할 수 있었다.
'Java > Java 기본' 카테고리의 다른 글
함수 파라미터에 final 을 붙이는 이유 (매개변수의 재할당 금지) (0) | 2024.08.30 |
---|---|
record 에 대한 나의 생각 (0) | 2024.08.26 |
DIP 와 인터페이스 소유권의 역전 (0) | 2024.08.26 |
[간단] id 의 타입을 long 이 아니라 굳이 Long 으로 주는 이유 (0) | 2024.08.19 |
클래스와 다형성, 그리고 오버라이딩 (2) | 2024.07.23 |