안녕하세요
오늘은 자바가 어떻게 동작하는지 랑 JVM 관점에서 Static이 어떻게 작동하는지 알아보겠습니다.
자바에서 클래스를 실행시키기 위해서는 메인메소드가 필요합니다.
그러면 메인 메소드는 무엇일까요?
public static void main(String[] args) { } 를 메인 메소드라고 부릅니다.
정확하게 말하면 스프링 환경이 아닌, 일반 자바를 다루는 환경에서 클래스의 어떠한 값을 콘솔에 출력하기 위해서
꼭 있어야 하는 게 바로 메인메소드 입니다.
public class StaticTest {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
위 값이 없다면 출력을 할 수가 없죠.
그리고 값,메소드 등을 출력하기 위해선 메인 메소드 안에서
객체 생성을 통해서 출력을 할 수 있습니다.
그러면 왜 메인 메소드 안에서만 값을 출력할 수 있을까요?
일단 이 동작 과정을 알기 위해선
Java가 동작하는 원리를 간단하게 알고 있어야 합니다.
즉 Java가 실행되는 환경인 JVM을 동작 구조를 알고 있어야 합니다.
JVM을 동작 구조를 알고 있어야 JVM이 static을 처리하는 방식을 알 수 있기 때문입니다.
JVM 동작구조
JVM은 Java JDK를 깔면 그 안에 기본적으로 내장이 되어있습니다
JVM의 특징은 운영체제에 상관 없이 코드가 작동을 합니다.
아래 사진은 JVM을 동작 구조를 간단하게 설명한 사진 입니다.
- 자바로 개발된 프로그램 실행시 JVM은 OS로부터 메모리를 할당받습니다.
- 메모리는 맥이든, Window, Linux 상관 없습니다.
- 자바 컴파일러(Javac=JavaCompiler) 가 자바 소스코드(= .java) 파일을 자바 바이트 코드(= .class)로 컴파일을 시킵니다 ( .java → .class)
- Class Loader를 통해 JVM Runtime Data Area로 로딩합니다.
- 변환된 바이트 코드는 Runtime Data Area에서 각 영역에 맞게 배치되어 수행이 됩니다.
- 이 과정에서 Execution Engine에 의해 가비지 컬렉션 작동 및 스레드 동기화가 이루어집니다.
이정도로 설명을 할 수 있습니다.
그러므로 면접에서 면접관님이 JVM 동작구조를 물어보면 위 과정을 대답을 하셔야 합니다.
❓그렇다면 컴파일은 무엇일까요❓
간단하게 설명을 하면 원시코드를 컴퓨터가 이해할 수 있는 코드(바이트 코드)로 바꾸어주는 것입니다.
컴퓨터는 0과1만 이해할 수 있기 때문에 내가 작성한 자바 코드를 바이트 코드(=기계어 코드, 어셈블리어) 로 바꾸어주는 과정을 컴파일 이라고 하는 것 입니다.
간단하게 다시 설명을 하면
- 내가 작성한 코드를 자바 컴파일러가 JVM이 이해할 수 있도록 바이트 코드로 변환한다
- 컴파일된 바이트 코드를 JVM내부의 클래스 로더가 가져와 동적 로딩을 통해 JVM메모리 영역에 적재한다.
- 동적 로딩 : 프로그램이 실행 중에 클래스를 로드하는 프로세스이다.
- ex) 특정 요청이 들어올 때 필요한 클래스만 동적으로 로드해서 처리한다.
- JVM메모리 영역에서 실행엔진을 통해 실행한다.
❓그렇다면 클래스 로더는 무엇일까요❓
기본적으로 자바는 클래스 들로 이루어져 있습니다.
클래스 로더는 말 그대로, 클래스를 읽어오는 역할을 합니다.
즉 바이트 코드로 변환된 소스를, JVM메모리 영역인, Runtime Data Area로 적재를 합니다.
클래스 로더의 동작구조는 간단하게
Runtime 시 → load(적재) → links(연결) → initialize(초기화) 3가지 과정을 거쳐서 동작을 합니다.
자세한건 검색해보시면 알 수 있습니다..
❓그러면 JVM 메모리에는 무엇이 있을까요❓
앞서 자꾸 Runtime Data Areas 가 나왔습니다. 위 단어가 JVM의 메모리 구조 입니다.
JVM에 의해 프로그램이 실행될 때, 운영체제로부터 할당받은 메모리의 영역 입니다.
쉽게, 자바 프로그램이 실행될 때 사용되는 데이터들을 담는 공간 입니다.
아래 내용을 읽다보면, 또 나오는 내용입니다. 그만큼 중요하다는 거겠죠?
✅ Method Area
JVM이 동작하고, 클래스가 로드될 때 적재되서 프로그램이 종료될 때 까지 저장됩니다.
클래스 이름, 메소드, 변수를 저장합니다.
→ static-zone, none static-zone으로 나누어 집니다.
다른 Thread 사이에서 공유되는 자원 입니다.
Thread의 특징중 하나가 Process와 다르게 자원을 공유하는 특징이 있습니다.
그런 면에서 메소드 또한 공유 된다고 생각하면 될 것 같습니다.
✅ Heap Area
→ 실제 객체가 생성되는 메모리 공간 입니다.(Ex, 배열,객체)
→ GC에 의해 메모리가 관리 됩니다.
위 사진은 Heap Area을 효율적인 GC를 위해 크게 3가지의 영역으로 나눈 것입니다.
- Young Generation : 영역은 자바 객체가 생성되자마자 저장되고, 생긴지 얼마 안되는 객체가 저장됨
- Heap 영역에 객체가 생성되며 최초로 Eden 영역에 할당된다.
- 그리고 이 영역에 데이터가 어느정도 쌓이게 되면 참조정도에 따라 Service의 빈 공간으로 이동되거나 회수된다.
- Young Generation 영역이 차게 되면 참조 정도에 따라 Tenured Generation(Old) 영역으로 이동되거나 회수됨Young Generation, Tenured Generation에서 GC를 Minor GC라고 한다.
- Tenured Generation : Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한번에 삭제한다.
- 시간이 오래 걸리는 작업이고 이때 GC를 실행하는 스레드를 제외한 모든 스레드는 작업을 멈추게 됨(Stop-the-World)Old영역의 메모리를 회수하는 GC를 Major GC라고 한다.
지금은 그냥 한번 쭉 읽어보고 넘어가면 될 것 같습니다. 나중에 깊게 공부해보고 싶을 때 한번 다른 내용을 찾아보는게 좋을 것 같습니다!
✅ Stack Area
→ stack은 Thread가 하나 만들어져서 실행이 된다.
→ 지역변수, 매개변수들이 만들어지는 공간이다.
→ LIFO 구조로 운영이 되는 메모리 공간이다.
→ 원통형 구조로 볼 수 있다. 계단식으로 하나씩 쌓인다. 맨위에 있는 것이 제일 먼저 빠져나간다.
→ 메소드의 호출 순서를 알 수 있다.
✅ Runtime Constant pool
→ Litreal Pool → 문자열 상수가 저장되는 공간이다 ex) String이 저장이됨
→ 상수 값 할당이 되는 메모리 공간입니다.
✅ PC 레지스터
→ Thread가 생성될 때마다 생성되는 영역이다. 프로그램 카운터로 현재 스레드가 실행되는 부분의 주소와 명령을 저장하고 있는 영역 입니다.
❔Gc 란❔
→ Heap 메모리 영역에서 사용하지 않는 메모리들을 자동으로 회수해 준다
→ 참조되지 않은 객체를 찾아서 제거한다
→ GC 역할을 수행하는 스레드를 제외한 나머지 모든 스레드들은 일시정지 상태가 된다.
모든 스레드가 공유해서 사용(가비지 컬렉션의 대상)
- Hear Area
- Method Area
스레드 마다 하나씩 생성
- Stack Area
- PC Register
- Native Method Stack
이제 본론으로 넘어가 JVM이 Static을 처리하는 방식을 알아 보겠습니다.
1) JVM이 실행할 클래스를 찾습니다.
그럼 어떻게 실행할 클래스를 찾는지 궁금해 할 수 있습니다.
바로 main() 메소드를 찾아 시작점으로 삼습니다. 이 메소드가 포함된 클래스가 실행의 출발점 입니다.
public static void main(String[] args)
그리고 위 메인 메소드( =main() )에서
static 키워드가 붙어있는 멤버들을 **정해진 메모리 위치(static-zone)**에 한번 자동으로 로딩 한다.
→ static 멤버들은 클래스를 사용하는 시점에서 딱 한 번 메모리에 로딩된다는 점이 중요하다
→ 여기서는 main() 메서드가 static 이기 때문에 메모리에 자동으로 로딩한다.
이 말이 무슨 말이냐면, 다른 객체들은 new 를 사용해서 객체 생성을 해야지
메모리에 객체가 할당되고, 주소 값이 생깁니다.
그러나 static 메소드는 객체를 생성하는 그 과정이 필요가 없이
객체가 자동으로 JVM에 올라간다는 것 입니다.
static-zone은 static이 들어있는 애들끼리 만 모여있는 메모리 영역을 말합니다.
크게 분류를 한다면 JVM에서 영역은 static-zone 과 None-static-zone 두 개로 구분을 할 수 있습니다.
그렇다면 static 메소드를 쓰면은 객체 생성을 안하고 사용할 수 있으니
덜 귀찮고 개꿀 아닌가요??? 라고 생각할 수 있습니다.
하지만 static 메소드를 남용하면은 OOP에서 캡슐화를 어긋난다고 볼 수 있습니다.
언제나 가져와서 사용할 수 있기 때문에, 객체의 상태를 고려하지 않기 때문에 나중에
유지보수와 테스트에서 어려움을 겪을 수 있습니다.
2) JVM이 static-zone에서 main() 메소드를 호출한다
- JVM이 static-zone에 가서 메소드를 호출을 합니다
3) 호출된 메소드를 Stack Area에 push 한 뒤 동작
- 호출된 메인 메소드가 stack영역에 들어간다.
- 최종적으로 stack영역에서 실행이 된다.
간단하게 static이 붙은 메소드들은 위 동작 과정으로 실행이 됩니다.
전체적인 흐름은 이렇습니다.
한번 코드를 보면서 이해를 도와보겠습니다.
public class StaticTest {
public static void main(String[] args) {
int a = 10;
int b = 20;
int sum = StaticTest.hap(a,b);
System.out.println(sum);
}
public static int hap(int a, int b) {
int v = a+b;
return v;
}
}
- 프로그램 실행을 시킨다
- JVM main() 메소드를 찾아 시작점을 설정한다.
- 그리고 다음으로 static이 붙은 메소드를 찾는다 → hap() 메소드 찾음
- 메소드 area에 static 메소드들이 올라간다
- 그 다음으로 main() 메소드가 호출이 된다.
- 그러면 Stack Area로 main메소드가 push가 됩니다.
- 그리고 push된 메소드는 stack area에서 각 주소를 가지는 영역을 가지게 됩니다.
그리고 각 메서드 안에 변수의 값이 할당되어있는데
그것들을 바로 지역 변수라고 합니다.
그 지역 안에서만 값이 할당이 되어있고 다른 지역에서는 전혀 다른 값을 가질 수 있습니다.
Stack 에서는 현재 프로그램이 실행되고 있는 상태를 파악할 수 있습니다.
Stack(=LIFO) 에 아무것도 없으면 프로그램이 종료가 됩니다.
이렇게 main() 및 static 동작 구조를 알아갈 수 있습니다.
그러면 static은 위 방법처럼 접근을 하는데
none-static 메소드는 어떻게 접근을 할까요?
일단 static처럼 객체에 바로 접근을 할 수 없으니, 객체를 생성을 해야합니다.
public class NoneStaticTest {
public static void main(String[] args) {
NoneStaticTest noneStaticTest = new NoneStaticTest();
int a = 10;
int b = 20;
int sum = noneStaticTest.hap(a,b);
System.out.println(sum);
}
public int hap(int a, int b) {
return a+b;
}
}
위 코드를 바탕으로 설명을 해보겠습니다.
1) 일단 클래스를 실행시킵니다
→ 실행 시키면 main() 메소드가 static-zone에 올라갑니다.
- main()은 static-zone에 저장됨
- hap()은 static이 없으니 none-static zone에 저장이 됩니다.
2) 그리고 실행 후 static이 없는 메소드는 Heap 영역에 생성이 됩니다.
static → Stack 영역
none static → Heap 영역
즉 객체가 생성(=new)되는 메모리 공간을 heap memory 라고 합니다.
그리고 none-static 메소드가 실행이 되고
메인 메소드가 더 이상 호출할게 없으니 종료가 됩니다.
그러면 이번에는 실무에서는 static 메소드를 어떻게 사용하는지를 알아보기 위해
2개의 메소드로 예시를 들어보겠습니다.
public class MyUtil {
public static int hap(int a, int b) {
int v = a+b;
return v;
}
}
public class StaticAccess {
public static void main(String[] args) {
int a = 10;
int b = 20;
int sum = MyUtil.hap(a,b);
System.out.println(sum);
}
}
📌 static 멤버 접근 방법
클래스이름.호출메소드
static 멤버는 클래스를 사용하는 시점에 자동으로 static-zone에 로딩이 됩니다.
따라서 new를 이용해서 객체를 생성할 필요가 없습니다.
static-zone에서 Stack영역으로 push가 되어 보관이 됩니다.
그러면 none-static 메소드는 어떻게 접근하나요?
평소 하던대로 new로 객체를 생성해서 메소드에 접근할 수 있습니다
public class MyUtil {
public int hap(int a, int b) {
int v = a+b;
return v;
}
}
public class StaticAccess {
public static void main(String[] args) {
MyUtil myutil = new MyUtil();
int a = 10;
int b = 20;
int sum = myutil.hap(a,b);
System.out.println(sum);
}
}
위 방법을 사용해서 객체을 메소드에 접근 할 수 있습니다.
다음으로 제가 궁금했던 내용에 대해서 알아보겠습니다.
1) 만약에 객체를 생성할 수 없는 상황이라면?
객체를 생성할 수 없는 상황이라하면은, 생성자의 접근제어자가 public이 아닌, private, protected 둘 중 하나 일 경우 입니다.
자바에서 제공하는 API에는 private 생성자를 가지고 있는 클래스도 있습니다.
ex) System, Math 등등
System system = new System();
Error : 'System()' has private access in 'java.lang.System'
Math math = new Math();
private Math() {}
Math API를 들어가보면 안에 생성자가 Math로 되어 있습니다.
→ 생성자는 반드시 public 이다?? 아님 니다 대부분이 그럴수는 있겠지만
객체에 접근을 못하게 하기 위해 의도적으로 private, protected로 접근을 막을 수 있습니다.
한번 코드로 보겠습니다.
public class AllStatic {
private AllStatic() {
}
public static Integer getSum(int a, int b) {
return a+b;
}
public static Integer getMinus(int a, int b) {
return a-b;
}
public static Integer getMultiple(int a, int b) {
return a*b;
}
}
public class AllStaticTest {
public static void main(String[] args) {
System.out.println(AllStatic.getSum(10,20));
System.out.println(AllStatic.getMinus(10,20));
System.out.println(AllStatic.getMultiple(10,20));
}
}
생성자를 private으로 만듬으로써 객체를 생성을 못하게 막아 둔 예시 입니다.
결국 객체 생성을 못하는 클래스 이므로, static을 통해서 접근하겠다는걸 표시하는 느낌인 것 같습니다.
만약에 코드를 읽다가 생성자가 private,protected가 보이면 static이 있나 없나를 바로 찾아봐야 할 것입니다.
2) 그러면 static을 쓰면 객체 생성없이 메소드에 바로 접근할 수 있으니 간편하게 개꿀 아닌가요?
정답은 No 에 가깝습니다. (무조건 No는 아닙니다. 상황에 알맞게 사용해야 합니다)
왜 No 일 것이라고 생각하나요?
static은 고정된 메모리 영역을 사용하기 때문에, 매번 인스턴스를 생성하며 낭비되는 메모리를 줄일 수 있다는 장점이 있습니다.
하지만 위 장점을 제외하고 단점이 더 많습니다
1) 프로그램 종료시 까지 메모리에 할당된 채로 존재합니다
-> GC가 따로 관리해주지 않아, 정적 메소드를 사용을 하든 안하든 메모리에 계속 존재합니다.
2) static은 객체지향적이지 못합니다
-> 캡슐화를 지키지 못합니다
-> 따로 객체를 생성하지 않고, 클래스에 접근하기 때문에, 객체 데이터들이 캡슐화 되야하는 법칙을 위반합니다.
3) static 은 interface를 구현하는데 사용될 수 없다
-> 객체지향적이지 못한 코드가 된다
-> 재사용성이 적다는 단점이 있다
그러면 static 장점보다 단점이 많은데 사용하면 안되나요?
또 그것은 아닙니다. 무작정 static을 사용하지 않고, 적절한 상황에 맞게 사용한다는 표현이 맞는 것 같습니다.
실제 실무에서도 static 을 사용한 내부 정적 클래스 및 정적 메소드를 사용합니다.
무분별하게 사용이 편하다는 이유로 사용을 하는게 아닌, 꼭 필요한 상황에서만 사용하는 것을 지향합니다.
마지막으로 결국 자바를 배우는 최종 이유중 하나인 스프링 프레임워크에서 메모리에대한 간단한 이야기를 해보려고 합니다.
스프링 부트 어플리케이션을 실행시키면, 하나의 자바 프로세스가 실행이 됩니다.
이 프로세스는 JVM 에 의해 실행되며, 모든 애플리케이션 모든 코드와 데이터를 JVM 메모리에 로드하고 GC에 의해 관리가 된다.
그리고 1개의 프로세스에서 여러개의 스레드를 공유하고, 많은 스레드를 가지고 있습니다.
그 중에서도 스프링부트에서는 스레드 풀 의 사용하여 스레드를 관리를 하고 있습니다.
많은 스레드 중에서 중요한 역할을 가진 스레드가 있습니다.
1. 메인 스레드: 애플리케이션이 실행될 때 시작되는 기본 스레드입니다 => 애플리케이션의 초기화 및 구성을 담당합니다.
2. 요청 처리 스레드: 웹 서버(=Tomcat)를 통해 들어오는 HTTP 요청을 처리하기 위해 스레드 풀을 사용합니다. 각 요청은 스레드 풀에서 하나의 스레드에 할당되어 처리됩니다.
-> deep 하게 들어가면 Netty,Jetty 를 통해 비동기 작업 또한 요청 처리를 받기도 합니다.
3. 백그라운드 스레드: 스프링에서 비동기 작업, 스케줄링된 작업, 메시지 리스너 등은 별도의 백그라운드 스레드에서 실행됩니다.
4. Garbage Collector (GC) 스레드: JVM에서 메모리 관리를 위해 사용하는 스레드로, 사용되지 않는 객체를 메모리에서 해제하는 역할을 합니다.
즉 스프링 부트 애플리케이션은 하나의 자바 프로세스로 실행되며, 그 내부에 다양한 역할을 수행하는 여러 스레드가 존재합니다.
각 스레드는 요청 처리, 비동기 작업, 메모리 관리 등 다양한 작업을 수행하며, 이들 스레드가 동시에 동작함으로써 애플리케이션이 효율적으로 작동할 수 있습니다.
REF
https://github.com/devSquad-study/2023-CS-Study/tree/main/java