안녕하세요 여러분!
이번시간에는 JVM에 대해서 알아볼게요.
만약 에러 해결 과정이 궁금하신 분들은 오른쪽의 목차의 해결 부분 클릭을 부탁드려요 :)
JVM이란 Java Virtual Machine, 자바 가상 머신의 약자를 따서 줄여 부르는 용어에요.
Java를 컴퓨터에서 실행하기 위한 가상의 기계라고도 정의할 수 있는데 이때 특징으로는 컴퓨터의 OS에 종속적이지 않다는 것이에요. 즉, Window, Mac, Linux 어디에서도 JVM만 있으면 Java 애플리케이션을 클래스 로더로 읽어들여 실행할 수 있게 되요.
그리고 핵심 역할 중 하나인 메모리 관리를 Garbage collection(가비지 컬렉션) 을 수행해요.
이렇게 말해서는 JVM이 정확하게 무엇을 의미하는지 조금 헷갈리실 거에요.
로컬 컴퓨터에서 자바 코드를 실행하면 어떤일이 발생하는지 간략하게 알아보면서 JVM에 대해 설명해보도록 할게요!
본격적으로 JVM을 얘기하기 전에 JVM이 탄생한 배경에 대해서 알아볼게요.
C/C++ 는 컴파일 플랫폼과 타겟 플랫폼이 다를 경우, 프로그램이 동작하지 않는다 그래서 매우 종속적이라고 할 수 있어요.
여기서 플랫폼이란 ?
- 운영체제 + CPU 아키텍처 / linux + arm64, window + intel core i7 등을 플랫폼이라 한다
이렇게 종속적인 이유는 OS마다 지원하는 시스템 콜 인터페이스가 다르고 CPU 아키텍처마다 지원하는 명령어 아키텍처가 다르기 때문이에요.
만약 동일한 플랫폼에서 컴파일과 실행을 같이 한다면 문제가 없어요.
하지만 linux에서 컴파일한 실행파일을 window와 같이 다른 플랫폼에서 실행하면 앞서 말씀드린 콜 인터페이스가 다르고, 명령어 아키텍처가 다르기 때문에 동작하지 못해요.
그래서 매번 해당 아키텍처에 맞게 작업을 추가적으로 해야하는 번거로움이 있었어요.
크로스 컴파일 - 타겟 플랫폼에 맞춰 컴파일
그래서 이러한 번거로움을 해소하기 위해 야침차게 JVM이라는 가상머신이 탄생하게 되었어요.
직역하면 '자바를 실행하기 위한 가상 기계(컴퓨터)'라고 할 수 있어요.
Java 는 OS에 종속적이지 않다는 특징을 가지고 있다. OS에 종속받지 않고 실행되기 위해선 OS 위에서 Java 를 실행시킬 무언가가 필요하다. 그게 바로 JVM이다.
즉, OS에 종속받지 않고 CPU 가 Java를 인식, 실행할 수 있게 하는 가상 컴퓨터이다.
종속이 뭐지 ? 컴파일 하고 실행하고 한다는데 이 모든게 대체 무슨말일까요 ??
차근차근히 알아볼게요.
보통 저희가 작성하는 코드들이 어떻게 실행이 될까요 ?
저희가 작성하고 IDE에서 볼 수 있는 형태의 Java 코드를 (*.java)
원시코드라고 해요. 이 java 코드를 저희는 보고 이해할 수 있지만 컴퓨터는 바로 이해할 수 없어요. 컴퓨터가 이 코드를 동작하게 하려면 컴퓨터가 이해할 수 있는 언어인 기계어 (01001110101...) 로 바꿔줘야 하고 이 과정을 컴파일이라고 해요.
만약 컴파일, 컴파일러가 궁금하신 분들은 아래 블로그도 참고 부탁드려요 :)
여기서 중요한 개념이 나와요.
C 나 Rust 의 경우에는 컴파일러가 기계어로 변경을 해주어요. 하지만 Java, Kotlin의 바로 기계어로 언어를 바꾸지 않고
*.class
파일로 변경을 해요. 여기서*.class
파일은 JVM이 인식할 수 있는 파일로 변경이 되어요.
이 중간과정을 거쳐 *.class
파일을 얻게 되었고 이 *.class
파일은 어떤 OS에서든 관계없이 동작이 가능해요. 왜냐하면 어떤 OS 에서건 JVM이 이해할 수 있는 동일한 형태의 *.class
파일을 만들기 때문이에요.
쉽게 생각하면 OS가 다른 Mac 환경에서 컴파일을 하고 Window 환경에서 실행을 하려고 하면 아키텍처가 달라 다른 형태의 기계어가 생성되니 다시 Window 실행환경에 맞게 컴파일을 해줘야 하는 불편함이 있으니 code와 OS 사이에 컴퓨터(JVM)를 하나 더 두고 그 컴퓨터가 이해할 수 있는 파일 (*.class) 을 통해서 컴파일과 실행환경에 관계없이 어디에서도 실행할 수 있게 하자! 가 Java의 생각이었어요.
여기서 실행환경을 타겟플랫폼이라고 해요.
다시 정리해보면
- 자바 바이트 코드는 타겟 플랫폼에 상관없이 JVM 위에서 동작함
- 자바 소스코드가 javac 라는 컴파일러를 거치고 나면 자바 바이트코드가 됨
- 이 자바 바이트코드는 JVM이 설치된 플랫폼이라면 어디에서든 동작함
WORA
"Write Once, Run Anywhere - Sun microsystems""네가 짠 자바 코드를 컴파일 해서 배포하면, 어떤 플랫폼이든 다시 컴파일할 필요 없이 실행할 수 있다."
그래서 실행하려면 그 플랫폼에 맞는 JVM이 설치되어 있어야 해요.
자바의 야심
A.java b.java 자바 파일이 java compiler를 거처 a.class 파일이 됨 (a.class 파일은 바이트 코드)
위 클래스 파일을 네트워크를 통해 전달하면 웹 브라우저에 JVM이 설치되어 있어서 어디서든지 실행만 하면 되겠다
하지만.. 결론적으로는 자바는 실패하였지만 이 방식이 자바 스크립트에서 정확히 동일하게 이대로 동작해요
이제는 자바 코드가 실행되기까지에 대해서 자세히 알아볼게요.
컴파일러에도 프론트와 백엔드가 존재해요.
- 웹에서는 백엔드는 크게 바뀌지 않고 프론트 엔드가 클라이언트 종류에 따라 바뀐다
- 컴파일러는 반대이다 컴파일러에서 프론트엔드는 바뀌지 않는다
- 왜냐하면 프론트엔드가 하는것이 우리가 작성한 소스코드를 분석해서 의미를 파악하는 것이다
- 하지만 백엔드는 바뀐다
- 자바 바이트코드를 어셈블리 언어로 바꾸는 과정에서 어셈블리 언어들은 운영체제나 기기에 의존적이다
- 따라서 백엔드에서는 window용, linux용, mac용이 있는 것이다
- 자바에서는 프론트에서는 javac(자바 컴파일러)가 해주고 백엔드는 JVM이 한다
즉, 하는 일을 분리하는 것이에요.
일을 분리함으로써 좋은 점은
- Runtime에 어떤일이 벌어질지 컴파일을 하기 전에 모든 정보들을 다 알 수 없기때문에
- Runtime에서만 발생하는 소중한 정보들이 있기 때문 이 정보들을 이용해서 최적화를 하는것이 JIT 컴파일러이다
JVM의 내부 구조와 Heap 동작 원리를 살펴보도록 할게요.
주목할 부분은 Runtime area 에요.
이 중 스레드가 공유하는 부분은 Method area, Heap을 공유하며 공유하지 않는 부분은 Java stack, Program counter register, Name method stack 이며 이는 스레드별로 가지게 되요.
그래서 저희가 가끔 메모리가 부족할때에는 스레드가 공유하는 Heap 부분이 자주 메모리 부족현상이 발생하게 되고 더욱 유의해야해요.
Method area는 클래스 정보를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이에요.
여기에서 변수, 메소드, 정적변수가 어떤것이 있는가 바이트 코드는 어떤가 와 같은 부분을 저장하는 것이죠.
Static 영역에 존재하는 별도의 관리영역이에요.
상수 자료형을 저장하여 참조하고, 중복을 막는 역할을 수행해요.
Heap은 객체를 저장하는 가상 메모리 공간으로 new
와 같은 연산자로 생성되는 객체와 배열을 저장하는 공간이에요. 이때 Class Area(Static Area)에 올라온 클래스들만 객체로 생성할 수 있어요.
Heap 공간은 크게 3가지로 나뉘어요.
이 영역은 영구적인 세대로 new
와 같은 연산자로 생성된 객체 정보의 주소값이 저장되는 공간이에요. 클래스 로더에 의해 load되는 Class, Method 등에 대한 Meta 정보가 저장되는 영역이고 JVM에 의해 사용되어요.
Reflection을 사용하여 동적으로 클래스가 로딩되는 경우에 사용되어요.
여기서 Reflection 이란 ?
객체를 통해 클래스의 정보를 분석해 내는 프로그래밍 기법
구체적인 클래스 타입을 알지 못해도, 컴파일된 바이트 코드를 통해
역으로 클래스의 정보를 알아내어 사용할 수 있다는 뜻입니다.
이 영역에 저장되는 객체 인스턴스는 GC라는 가비지 콜렉터에 의해 사라져요. New/Young 이름에서 짐작할 수 있다 시피 여기에는 생명주기가 짧은 객체들을 대상으로 하는 영역이에요.
여기에서 일어나는 가비지 콜렉트를 Minor GC라고도 해요.
Eden
Survivor
동작과정
Eden 영역에 객체가 가득차게되면 첫번째 가비지 콜렉트가 발생한다.
Eden영역에 있는 값등 Survivor 1 영역에 복사하고 이 영역을 제외한 나머지 객체를 삭제한다.
이곳의 인스턴스들은 추후 가비지 콜렉터에 의해 사라져요. New/Young에 비해 생명주기가 긴 객체를 대상으로 하는 영역이에요. 여기서 일어나는 가비지 콜렉트를 Major GC라고 해요. Minor GC에 비해 속도가 느리며 New/Young Area에서 일정 시간 참조되고 있는, 살아남은 객체들이 저장되는 공간이에요.
그러면 JVM은 왜 이렇게 New와 Old 로 나눌까요 ?
왜냐하면 한 번 삭제되지 않는 객체들은 이후에도 살아남을 확률이 90%가 넘기 때문이에요.
메모리는 제한적이므로 관리를 해주어야 하는데, 이때 모든 객체에 대해 사용하되고 있는지 아닌지를 체킹하는 것 보다 영역을 구분하여 어차피 이 객체가 오래 사용될 객체라면 Old에 놔두어 체킹하고 그게 아니라면 Young에 놔두어 더 자주 체킹을 하여 보다 빠르게 메모리를 확보하기 위해서에요.
앞서 말씀드린 스레드마다 존재하는 부분은 Heap외에 추가적으로 Java stack, Pc register, Native method stacks가 있어요. (여기서 Pc는 program counter를 의미해요) 저희는 동작중인 프로그램에 대해 알고 있는 점이 있어요!
각 스레드는 항상 어떤 메서드를 실행하고 있다
이 점을 생각해보며 아래 그림을 보고 설명을 들어주세요.
스레드가 프로그램을 실행할때 어디서 몇번째 코드가 실행되는지를 알아야겠죠 ?
PC (program counter)는 그 메서드 안에서 바이트코드 몇번째줄을 실행하고 있는지를 나타내는 역할을 해요.
스택은 스레드별로 한개만 존재하고 스택 프레임은 메서드가 호출될 때마다 생성되요.
메서드 실행이 끝나면 스택 프레임은 pop이 되어 스택에서 제거되죠.
- thread1을 예시로 들면 이 스택은 아래로 성장하는 것임
- 이때 최상단의 스택프레임이 메인 함수이고 그 아래는 메인함수에서 호출된 메서드이며 그 아래는 호출된 메서드가 다시 호출한 형태로 이어지게 되요
여기서 스택프레임은 local variables array, operand stack, frame data 등을 가져요.
이렇게 자바 내부가 어떻게 동작하는지 알아보았어요.
그렇다면 저의 문제는 무엇이었지는지를 다시 살펴보도록 할게요.
에러 메세지를 보게 되면 connection과 관련된 에러 같아요. 제가 생각하기에는 너무 많은 양의 데이터(100만개) 를 한꺼번에 MySQL에 저장을 하려고 시도하였고, 이때 사용된 connection과 memory에 대해 문제가 발생한 것 같았어요.
메모리는 매우 소중하고 제한적이죠 ? 자바는 JVM 위에서 동작해요. 그리고 OS로 부터 컴퓨터의 메모리를 할당받게 되고 그 메모리를 사용하는 것이에요. (마치 스터디 카페에서 스터디 룸을 대여하는 것과 유사해요) 그래서 .. 메모리가 제한적이에요.
그래서 생성하는 방법을 바꾸어야겠다고 생각했어요.
저는 그래서 객체 생성을 Batch를 통해 1000건의 데이터들에 대해서만 객체를 생성하고 저장하는 방식으로 변경하여 해결하였습니다.
아래는 데이터 생성 테스트 코드입니다.
@Test
void bulkPostList() {
List<Post> postList = new ArrayList<>();
Faker faker = new Faker(new Locale("ko"));
String input = "2022-01-01 11:22:33";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime createdDate = LocalDateTime.parse(input, formatter);
for (int i=0; i < 1000000; i++) {
Post post = new Post();
post.setSubject("Poster : " + faker.name().fullName());
post.setContent("Poster ip 주소 : " + faker.internet().ipV4Address());
post.setLikes(255);
post.setCreatedDate(createdDate);
post.setModifiedDate(LocalDateTime.now());
postList.add(post);
}
System.out.println("postList = " + postList.size());
int batchSize = 1000; // 배치 단위 크기
int listSize = postList.size();; // 전체 데이터 크기
for (int i=0; i<listSize; i += batchSize) {
int endIndex = i + batchSize; // 최소 index를 구한다 save를 할때 batch 단위만큼 subList로 저장하기 위해서
List<Post> batchList = postList.subList(i, endIndex);
jpaPostRepository.saveAll(batchList);
jpaPostRepository.flush();
}
}
참고