[Java] static과 final의 공통점과 차이점, static 자세히 알아보기
목차
1. final과 static이 헷갈리는 이유
2. final과 static을 붙이면 무슨 일이 일어날까?
1) final이 변수, 메서드, 클래스에 붙으면 일어나는 일
2) static이 변수, 메서드, 블록에 붙으면 일어나는 일
3. static 자세히 알아보기 (feat.클래스 로더)
1) 먼저, JVM에 대해 간략히 알아보자
2) 클래스 로더가 하는 일
3) 클래스 로더는 클래스를 로딩할 때 static 변수에 메모리를 할당한다
4. static, 언제 써야 할까?
1. final과 static이 헷갈리는 이유
구글에 'final과 static'이라고 검색하면 이 둘의 차이를 설명하는 많은 글들을 볼 수 있다. 그 이유는 나를 포함해 자바 공부를 시작하시는 많은 분들이 이 둘을 헷갈려하기 때문일 것이다. final과 static, 비슷해 보이는 이유가 뭘까?
1) 변하지 않는다.
final과 static은 모두 '변하지 않음'과 관련이 있다. final은 '한번 값을 초기화하면 그 값을 변경할 수 없도록' 하며, static은 클래스에 '고정되어 객체 간 공유'된다.
final은 값 변경이 불가하다는 점, static은 객체 간 널리널리 공유된다는 점이 특징! static은 상수가 아니므로 원한다면 값도 바꿀 수 있다. static final은 절대 변하지 않는 값을 공유해야 할 때 사용하며, 생성과 동시에 꼭 초기화해 주어야 한다.
2) 여기저기 적용되는 제어자.
final은 변수, 메서드, 클래스에 적용할 수 있는 제어자다. static은 변수, 메서드, 블럭(static { }) 에 적용할 수 있는 제어자다. (내부 클래스에도 붙는데, 이는 다음 시간에...) 유사해 보이는 두 제어자가 비슷한 곳에 사용되니 헷갈리는 것. final과 static을 붙이면 각각 어떤 일이 일어나는지, 이 둘의 차이점은 무엇인지 밑에서 자세히 살펴보자.
2. final과 static을 붙이면 어떤 일이 일어날까?
1) final이 변수, 메소드, 클래스에 붙을 때 일어나는 일
final이 변수에 붙으면 초기화 후 값을 변경할 수 없다.
public class FinalVariableExample {
public static void main(String[] args) {
final int MAX_VALUE = 100; // final 변수 선언과 동시에 초기화
// MAX_VALUE = 200; // 이 부분을 주석 해제하면 컴파일 에러 발생 (값 재할당 시도)
}
}
final이 메서드에 붙으면 해당 메서드는 오버라이딩할 수 없다.
class Parent {
final void display() {
System.out.println("부모 클래스의 display 메서드");
}
}
class Child extends Parent {
// 아래 코드에서 컴파일 에러 발생. Parent class의 display method가 final선언되었기 때문에
// 자식 클래스인 Child에서 오버라이드하려고 하면 에러가 발생한다.
// @Override
// void display() {
// System.out.println("자식 클래스에서 오버라이딩된 display 메서드");
// }
}
final이 클래스에 붙으면 해당 클래스를 상속할 수 없다.
final class FinalClassExample {
// ... 클래스 내용 생략 ...
}
// 아래 코드에서 FinalClassExample 클래스를 상속하려고 하면 에러 발생
// class AnotherClass extends FinalClassExample {
// // ...
// }
2) static이 변수, 메서드, 블록에 붙으면 일어나는 일
static이 변수에 붙으면 해당 변수는 클래스 변수가 되어 객체 간 공유되며, 객체 생성 없이 클래스 이름으로 접근 가능하다.
public class pair {
public static void main(String[] args) {
// 객체 생성 없이 클래스 이름으로 직접 접근. static 변수 값 변경 가능
Counter.count = 5;
// final 변수에 클래스 이름으로 접근하여 컴파일 에러 발생
// Counter.number = 3;
Counter c = new Counter();
// final 변수의 값을 변경하려 하여 컴파일 에러 발생
// c.number = 3;
System.out.println("Counter 클래스의 count 변수 값: " + Counter.count); // 출력: 5
}
}
class Counter {
static int count = 0; // static 변수 생성 및 초기화
final int number = 10; // final 변수 생성 및 초기화
}
static이 메서드에 붙으면 해당 메서드가 클래스의 객체와 관련 없이 클래스 자체에 속하는 메서드가 된다. 따라서 객체 생성 없이 클래스 이름으로 호출이 가능하다.
public class Main {
public static void main(String[] args) {
// 클래스 이름으로 직접 호출
StaticMethodExample.showMessage(); // 출력: "이것은 static 메서드입니다."
}
}
class StaticMethodExample {
static void showMessage() { // static 메서드
System.out.println("이것은 static 메서드입니다.");
}
}
static이 붙은 블록(정적 블록)은 클래스가 처음으로 로딩될 때 단 한 번 실행되며, 주로 초기화 작업에 사용된다. 파일이나 외부 리소스를 로드(txt 파일의 내용을 읽어와서 저장한다거나)하거나 정적 변수를 초기화하는 등의 작업을 수행할 때 활용된다.
public class StaticBlockExample {
static String message;
static {
message = "이것은 정적 블록에서 초기화된 메시지입니다!";
}
public static void main(String[] args) {
System.out.println("정적 블록에서 초기화된 메시지: " + message);
}
}
3. static 더 깊게 알아보기 (feat. 클래스 로더)
1) 먼저, JVM에 대해 간략히 알아보자.
JVM은 Java Virtual Machine의 약자로, Java가 OS에 종속되지 않고 프로그램을 실행할 수 있는 환경을 조성한다. 하는 일은 compile된 class 파일을 실행하는 것.
다음은 JVM의 구조를 그린 그림이다.
출처: Java virtual machine - Wikipedia
2) 클래스 로더가 하는 일
오늘은 여기서 클래스 로더에 대해서만 이야기해 볼 것이다. 클래스 로더는 JVM 내로 클래스 파일(.class 파일)을 찾아 동적으로 로딩한다. 이렇게 JVM에 로딩된 클래스들은 JVM에서 객체로 사용할 수 있다.
여기서 동적 로딩이란, 실행에 필요한 정보들을 그때그때 메모리에 로드하는 것을 말한다. 클래스 로더는 내부에 이런저런 클래스를 먼저 로드해야지, 하는 내부 규칙을 가지고 있다. 예를 들어, main class에서 "Hello world!"를 출력하려고 하면, 최상위 클래스 로더인 부트스트랩 클래스로더가 실행된다. 이후 모든 클래스가 상속받고 있는 최고 조상인 Object 클래스를 읽어오고, main.class 파일을 메모리에 로드한다. 이외에 다른 class들이 있다면 클래스를 호출할 때 메모리에 로딩된다. 따라서 우리는 클래스가 동적으로 로딩된다는 것을 알 수 있다.
클래스 로더는 클래스를 로딩하는 과정에서 다음과 같은 세 가지 단계를 거친다.
- 로딩(Loading) : 클래스 파일을 가져와 JVM의 메모리 영역에 로딩한다.
- 링크: 클래스 파일이 유효한지 검사하고 클래스 변수(여기서 static이 붙은 변수가 등장!), 메서드, 인터페이스 등을 메모리에 할당한다. 이후 클래스 상수 풀 내에 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다. (대상의 이름으로 참조되어 있었던 것에 특정 메모리 주소를 넣어준다.)
- 초기화: 클래스 변수들을 설정된 값으로 초기화한다.
3) classloader는 클래스를 로딩할 때 static 변수에 메모리를 할당한다.
정리하자면, 컴파일러는 자바 소스 파일을 자바 바이트 코드로 변환(.java -> .class)한다. 클래스 로더는 컴파일된 바이트 코드를 읽어내 Runtime Data Areas에 올려준다. 클래스 로더는 런타임 시점에 일을 시작하기 때문에, 정확히 말하면 static 변수는 클래스가 로딩될 때 메모리가 할당된다.
몇몇 글에서 'static 변수는 compile 단계에 결정된다'고 이야기한 것이 나에게 엄청난 혼란을 초래했는데, 이 말도 맞는 이야기다. 컴파일 단계에서는 Syntactic Analysis(구조에 맞게 잘 쓰여졌는지), Semantic Analysis(코드의 의미가 어떤지) 확인하고 바이트 코드를 생성한다. 이때 컴파일러는 코드에서 사용된 메서드나 변수의 이름과 타입 등을 보고 어떻게 사용할지 결정한다. static 변수는 compile 단계에 결정된다는 것은 이런 뜻. 실제 메모리 할당 및 초기화는 클래스가 로딩될 때 일어난다. 이 과정은 불변한 값이 전 객체에 공유되는 static final 에서도 똑같이 일어난다.
잠깐 다른 이야기를 하자면, 컴파일 단계에 대해 공부하며 정적 바인딩과 동적 바인딩에 대해 알게 되었다. 바인딩은 프로그램에 사용되는 변수나 메서드를 실제 구현체나 메모리에 연결하는 작업으로, 그중 정적 바인딩은 컴파일 단계에서 결정된다. 동적 바인딩은 러타임 단계에서 결정된다. 오버로딩과 오버라이딩은 각각 정적 바인딩과 동적 바인딩의 대표 예시인데, 이에 대해서는 나중에 더 깊게 살펴봐야겠다.
4. static, 언제 써야 할까?
- 모든 인스턴스가 공유해서 사용해야 하는 변수와 메서드에 static을 붙인다.
- static을 쓰지 않으면 같은 데이터를 객체마다 갖게 되어 메모리 사용면에서 매우 비효율적이다. 따라서 객체들이 같은 값을 공유한다면 static을 붙여 독립적으로 관리한다.
- 메서드 내에서 instance 변수를 사용하지 않는다면 static을 붙이는 것을 고려한다.
static이 붙은 class 메서드는 instance 멤버를 사용할 수 없다. class 메서드는 객체 생성 없이 호출가능한 반면, instance 멤버는 객체를 생성해야만 존재가능하며, class 메서드 입장에서 instance 멤버를 호출할 경우 객체가 생성되어 있는 상태인지 아닌지 구분할 수 없기 때문이다.
따라서 메서드가 instance 멤버를 사용하지 않으며 메서드 호출 시간을 단축하고 싶다면 static을 붙이는 것이 좋다. static을 붙이지 않을 경우 메서드를 찾고 로딩하는 시간이 추가로 소요되기 때문이다. - 추가로 final은 값이 변하지 않아야 하는 경우, static final은 변하지 않는 값이 객체 간 공유되어야 할 경우 사용한다.