Inner 클래스의 문제점
인텔리제이와 같은 IDE에서 내부 클래스를 선언하여 사용하면 다음과 같이 경고 메세지가 뜰 것이다. (내부 클래스가 외부의 멤버를 참조하여 사용하지 않을 경우)
메세지 내용을 보면 Inner class ' ' may be 'static'. 즉, 내부 클래스를 인스턴스가 아닌 static으로 설정하라는 경고이다.
별거 아닌 경고인줄 알고 무시하기에는, inner 클래스가 가지는 심각한 문제를 보면 마음이 바뀔 것이다.
왜냐하면 inner 클래스는 inner static 클래스보다 메모리를 더 먹고, 더 느리고, 바깥 클래스가 GC 대상에서 빠져 버려 메모리 관리가 안될 수 있기 때문이다.
Inner 클래스는 외부 참조를 한다
일반적으로 내부 인스턴스 클래스를 만들기 위해서는 먼저 외부 클래스를 초기화한 뒤 내부 클래스를 초기화해야 한다.
이러한 단계 과정 때문에 inner 클래스는 자신을 만들어준 인스턴스에 대한 '외부 참조'를 갖게 된다.
심지어 내부 클래스가 외부의 멤버를 사용하지 않아도, 숨겨진 외부 참조가 생성되게 된다.
다음 코드는 내부 클래스가 외부의 멤버를 가져와 사용하지 않아도, 외부 참조를 하고 있는지 실험하기 위한 코드이다.
아래의 코드를 컴파일하여 .class 파일로 만들어 보자.
public class Outer_Class {
int field = 10;
class Inner_Class {
int inner_field = 20;
}
}
그러면 위와 같이 Outer_Class$Inner_Class.class 파일이 만들어질텐데, 이 클래스 파일을 인텔리제이 IDE로 열어서 바이트 코드를 디컴파일 시켜보자.
내부 클래스인 Inner_Class의 생성자에서 외부 클래스를 매개변수로 받아 인스턴스 변수로 저장하는 것을 볼 수 있다.
💡TIP
this$0 는 OuterClass 를 참조하고 있는 바이트 코드에서만 보여지는 Hidden 변수이다.
이처럼 비정적(non-static) 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다.
그래서 비정적(non-static) 멤버 클래스의 인스턴스 메서드에서 정규화된 this를 사용해 바깥 인스턴스의 메서드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있게 된다.
정규화된 this란 클래스명.this 형태로 바깥 클래스의 이름을 명시하는 용법을 말한다.
public class Outer_Class {
int field = 10;
int getField() {
return field;
}
class Inner_Class {
int inner_field = 20;
int getOuterfield() {
return Outer_Class.this.getField(); // 숨은 외부 참조가 있기 때문에 가능
}
}
}
Inner 클래스의 메모리 누수 현상
하지만 위와 같은 기능적 장점보다는 '외부 참조'로 인한 메모리 누수라는 치명적인 단점이 존재한다.
Inner 클래스가 바깥 클래스를 외부 참조 함으로써, 만일 외부 클래스는 필요가 없어지고 내부 클래스만 남아있을경우, 필요없어진 외부 클래스를 GC 대상으로 삼아 메모리에서 제거해야 되지만 외부 참조로 내부 클래스와 연결되어 있기 때문에 메모리에서 제거가 안되고 잔존하게 된다. 이는 곧 메모리 누수로 프로그램에 치명적인 오류가 된다.
직접 실전 코드로 현상을 확인해보자.
여기서 int형 배열 필드 data 는 객체의 크기를 불리기 위해 큰 데이터를 저장하기 위한 곳이다.
이곳에 생성자를 통해 100,000,000 크기의 배열 사이즈를 넣어 생성해줌으로써, int형 크기인 4byte x 100000000 = 400MB 크기의 객체를 생성시킬 예정이다.
import java.util.ArrayList;
class Outer_Class {
// 외부 클래스 객체의 크기를 불리기 위한 배열 변수
private int[] data;
// 내부 클래스
class Inner_Class {
}
// 외부 클래스 생성자
public Outer_Class(int size) {
data = new int[size]; // 사이즈를 받아 배열 필드의 크기를 불림
}
// 내부 클래스 객체를 생성하여 반환하는 메소드
Inner_Class getInnerObject() {
return new Inner_Class();
}
}
public class Main {
public static void main(String[] args) {
// inner_Class 객체를 저장할 리스트
ArrayList<Object> al = new ArrayList<>();
for (int counter = 0; counter < 50; counter++) {
// inner_Class 객체를 생성하기 위해 Outer_Class를 초기화하고 메서드를 호출하여 리스트에 넣는다.
// 이때 Outer_Class 객체는 메소드 호출용으로 일회용으로 사용되고 버려지기 때문에 GC 대상이 되어야 한다.
al.add(new Outer_Class(100000000).getInnerObject());
System.out.println(counter);
}
}
}
내부 클래스의 메모리 누수 실험 과정은 다음과 같다.
- 먼저 Inner_Class 객체를 담을 리스트를 생성한다
- 그리고 반복문을 통해 리스트에 Inner_Class 객체를 넣는 코드를 50번을 순회할 것이다.
- 이때 내부 클래스를 생성하기 위해선 먼저 외부 클래스를 인스턴스화 할 필요가 있기 때문에, 우선 바깥 클래스인 Outer_Class 를 new 생성자를 통해 인스턴스화 한다.
- 그러면 Outer_Class 생성자 인수로 인해 1억 사이즈의 int형 배열이 생성되어 400MB 크기의 객체가 힙 메모리에 생성되게 된다.
- 그리고 바로 getInnerObject() 메서드를 호출하여 Inner_Class 객체를 생성하고 반환 받는다.
- 그렇게 Inner_Class 객체를 리스트에 저장한다.
- 메서드 호출용으로 일회용으로 사용된 400MB의 Outer_Class 객체는 더이상 필요없게 되어 GC 대상이 되어 힙 메모리에서 삭제가 되어야 할 것이다?
코드를 실행한 결과는 처참하게도 메모리가 터져 OutOfMemoryError 예외가 발생됨을 볼 수 있다.
왜냐하면 GC가 정상적으로 Unreachable 한 데이터를 수거해가지 못했기 때문에 메모리 관리가 안되어 메모리 누수가 되었기 때문이다.
원래라면 메소드 호출용도로만 쓰여진 일회용 객체는 바로 GC 수거 대상이 되어 제거되어야 되지만, 내부 클래스에서 외부 클래스를 참조하고 있는 관계 때문에, 내부 클래스 데이터가 살아있는 한 외부 클래스 데이터도 계속 살아있어 400MB라는 엄청난 데이터가 지속적으로 메모리에 쌓이게 되어 프로그램이 터지게 된 것이다.
내부 클래스는 static 으로 선언하자
정적 멤버 클래스와 비정적 멤버 클래스의 구문상 차이는 단지 static이 붙어있고 없고 뿐이지만, 의미상 차이는 의외로 꽤 크다.
지금 부터 그 차이를 알아보도록 하자.
static inner 클래스는 외부 참조를 안한다
위에서 사용했던 코드를 그대로 가져와 내부 클래스에 static 키워드를 붙이고 컴파일을 해보자.
public class Outer_Class {
int field = 10;
// static inner class
static class Inner_Class {
int inner_field = 20;
}
}
일반적인 inner 클래스와 달리 static inner 클래스는 외부 참조를 하고 있지 않은 것을 볼 수 있다.
외부 참조가 없으니 따라서 정규환 된 this 기능도 사용하지 못하게 된다.
static inner 클래스는 메모리 누수가 없다
역시 위의 메모리 누수 실험 예제 코드에서 내부 클래스만 static 키워드를 붙이고 실행해보자.
import java.util.ArrayList;
class Outer_Class {
private int[] data;
// static 내부 클래스
static class Inner_Class {
}
public Outer_Class(int size) {
data = new int[size];
}
Inner_Class getInnerObject() {
return new Inner_Class();
}
}
public class Main {
public static void main(String[] args) {
ArrayList<Object> al = new ArrayList<>();
for (int counter = 0; counter < 50; counter++) {
al.add(new Outer_Class(100000000).getInnerObject());
System.out.println(counter);
}
}
}
14번째 순회해서 예외가 발생하던 inner 클래스와 달리 static inner 클래스는 아무 무리없이 모두 순회됨을 볼 수 있다.
정적 멤버 클래스는 외부 인스턴스 없이도 만들어질 수 있기 때문에 '외부 참조'가 존재하지 않게 되고, 이로 인해 일회용으로 사용된 바깥 클래스 객체는 더이상 내부 클래스 객체와 아무런 관계가 아니게 되어 정상적으로 GC 수거 대상이 되어 메모리 관리가 잘 된 것이다.
정리하자면, inner 클래스를 선언할때 static 키워드를 붙여주지 않으면 '외부 참조' 현상 때문에, 내부 클래스 인스턴스를 생성하기 위해 우선적으로 만들었던 외부 클래스 인스턴스가 정상적으로 GC 수거가 안되 메모리에 잔존하게 되어 문제점을 일으키게 된다. 따라서 내부 클래스가 외부 클래스의 멤버를 가져와 사용하는 경우가 아닌 경우 반드시 내부 클래스를 선언 할 때는 static 키워드를 붙여주어야 한다.
참고