banner

개요

새로운 것이 등장하게 될 때 완전히 새로운 것인 경우는 드물다. 기존의 것의 보완책인 경우가 많다.
현재도 굉장히 많은 프로그래밍 언어들이 나오고 사용되고 있다. 자바도 그 당시 그랬을 것이다.
어떤 배경에서 등장하게 되었는지 그리고 어떻게 동작하는지 알아보자.

자바의 등장

프로그래밍이란 정해진 작업을 컴퓨터가 순차적으로 수행하도록 정의하는 것을 말한다.
이를 위한 스크립트를 작성하는 규칙이 바로 프로그래밍 언어다.

기계어

기계어란 컴퓨터의 뇌라고 불리는 cpu가 별다른 해석 없이 읽을 수 있는 언어로 모든 프로그래밍 언어의 종착라고 말할 수 있다.

기계어는 2진법으로 표현되고 규칙을 가지고 프로그래밍할 수 있다. 그러나 0과 1로 이루어진 명령어는 사람이 읽고 해석하기 불편하다. 가독성이 0으로 수렴한다. 이렇게 기계어로 프로그램을 작성한다면 생산성이 굉장히 낮아질 수 밖에 없다.

어셈블리어

어셈블리어는 기계어를 사용하여 프로그래밍하는 것의 불편함을 느껴 만들어진 언어로 비교적 사람이 이해하기 쉬운 단어들을 사용할 수 있다.
이렇게 작성된 프로그램은 기계어로 전환되는 과정을 거쳐 cpu에 전달된다.

하지만 어셈블리어도 쓰기 편하지는 않았다. 우선 컴퓨터의 제조사에 따라 어셈블리어가 달랐다. 정확히는 기계어가 달랐다. 별개의 제조사와 그 컴퓨터들이 수행하는 역할이 달랐다.
그렇기 때문에 개발자는 모든 컴퓨터에 대응하기 위해 새롭게 어셈블리어를 배워야했다.

C언어

이러한 상황에서 등장한 언어가 바로 C언어다.

Write Once, Compile Anywhere(WOCA)”

한 번만 작성하고 어느 곳에서나 컴파일한다가 C언어의 슬로건이다.

C언어는 단 하나의 소스파일을 가지고 컴퓨터의 컴파일러가 각 컴퓨터에 맞는 목적파일을 만들어준다.
그래서 C언어를 사용하면 일관된 문법으로 소스코드를 작성하고 컴파일하기만 하면 된다.

그러나 이러한 C언어에도 단점이 있었는데 바로 운영체제에 독립적이지 않다는 것이었다.
운영체제가 하드웨어에 대해 파악하고 각 운영체제에 맞는 컴파일러가 제공된다. 그러나 운영체제별로 다른 점이 있기 때문에 코드를 작성하고 컴파일한 환경과 다르다면 새롭게 컴파일을 해야한다는 한계가 있었다.

Java

그러다 자바가 등장했다.

Write Once Run Anywhere(WORA)”

한 번만 작성하고 어느 곳에서나 실행할 수 있다. C언어는 기종에 따른 목적 파일을 생성했지만 자바는 목적 파일을 만들고 자바언어가 사용자의 컴퓨터 환경에 영향을 받지 않도록 가상 머신인 JVM을 이용해여 자바 코드를 실행할 수 있는 것이다.

여담으로 위에서 설명한 대로 자바는 어느 곳에서나 실행시킬 수 있다. 즉, 운영체제에 독립적이다. 그러나 정작 자바는 설치파일을 운영체제별로 나누어 제공하고있다. 그 이유는 무엇일까?

image 이는 아이러니하게도 운영체제에 독립적일 수 있도록 하기 위해서다.

자바는 사용자의 환경에 맞게 JVM을 융화시키는 것이 중요하다. 각 운영체제는 파일 경로, 환경 변수, 시스템 설정 및 다른 운영 체제 관련 득성에서 차이를 보인다. 따라서 운영체제 별로 설치파일을 제공하고 이러한 문제들을 피할 수 있게 된다.

자바의 동작 원리

image image image image-16

거칠게 이해하기

크게 보면 3가지 과정을 거친다.

  1. 자바 컴파일러가 자바 문법으로 작성된 소스코드를 바이트코드로 변환시킨다.(.java -> .class)
  2. 클래스로더가 바이트코드를 JVM의 RDA에 올린다.
  3. Excution Engine이 interpreter와 JIT compiler를 통해 코드를 해석한다.

위에서 살펴본 대로 자바로 작성한 코드는 어떤 OS에서 실행해도 같은 결과를 보여준다.
이게 가능한 이유는 내부적으로 JVM을 쓰기 때문인데 C언어처럼 소스코드를 바로 기계어로 변환시키지 않고 바이트 코드라는 JVM에서 사용하는 코드로 변환시킨 뒤 인터프리터 방식으로 동적으로 기계어로 변환하여 실행시킨다.

JDK란 개발자가 작성한 코드를 바이트 코드로 변환하는 도구인 컴파일러가 포함되어있는 Java Development Kit의 약자다.
그 이름 그대로 당장 다이소에 가도 공룡 화석 발굴 놀이를 할 수 있게 찰흙과 도구를 셋트로 제공하듯이 JDK도 마찬가지다. 자바 프로그램을 개발할 수 있는 키트다. JRE란 Java Runtime Enviroment의 약자로 자바 실행 환경을 의미한다.

깊게 이해하기

자바 컴파일러가 컴파일한 바이트코드는 클래스로더가 처리한다. 클래스 로더는 동적 로딩을 통해 클래스들을 로드하고 링크하여 RDA에 올린다.
RDA는 자바 프로그램을 실행하기 위해 OS로부터 할당 받은 메모리 공간을 의미한다.

RDA에 로딩된 바이트 코드는 Excution에 의해 기계어로 해석된다.

클래스 로더의 내부에선 Loading, Linking, Initializing과정을 거치게 된다. 로딩 작업은 클래스 파일을 JVM의 메모리에 로드하는 일이 일어난다.

  • 링크란 하나의 클래스에서 어떤 클래스를 만났을 때 하나의 오브젝트 파일만 가지고는 어떤 클래스의 어떤 함수를 실행시켜야하는지 알지 못한다. 따라서 이들을 묶어서 배치하는 작업이 필요하게 되는데 이를 링크라고 한다. 자바에서 링크 작업은 Verify(검증), Prepare(준비), Resolve(분석) 작업을 의미한다.

검증 단계에선 일어들인 클래스가 JVM 바이트 코드가 자바 언어 사양과 일치하는지, 유효한 명령인지를 확인한다.
준비 단계에선 클래스가 필요로 하는 메모리를 할당한다.
분석 단계에선 클래스 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.

초기화 단계에선 클래스 변수들을 적절한 값으로 초기화한다.

프로그래밍 언어는 크게 두 가지 방식으로 컴파일된다. 컴파일 방식, 인터프리터 방식

컴파일 방식은 소스코드를 기계어로 한 번에 해석하고 이를 실행시키는 방식을 말하고 인터프리터 언어는 한 줄씩 해석하고 실행하는 방식을 말한다.
C언어는 컴파일 언어에 해당하고 자바는 인터프리터 언어에 해당한다.

컴파일 언어는 컴파일을 통해 기계어를 만들 때 코드 최적화도 진행하기 때문에 일반적으로 인터프리터 방식보다 높은 성능을 보인다.
그래서 JVM애서는 JIT Compiler를 도입하여 성능 저하 문제를 보완했다.
Just In Time Compiler의 약자로 적시에 기계어로 변환시킨다는 의미다.

JIT 컴파일러는 바이트 코드를 기계어로 변환하는 과정에서 기계어를 캐싱하고 재사용하게 된다. 이를 통해 반복되는 기계어 변환을 줄일 수 있게되어 성능을 향상시킬 수 있게 된다.

클래스 로더는 3가지 종류로 구성된다.

  • 부트스트랩 클래스 로더 : 기본 자바 API를 로드한다. 예를 들어 java.lang, java.util과 같은 클래스들을 로드하고 JRE설치 디렉터리 내의 rt.jar파일에서 로드한다.
  • 확장 클래스로더 : 확장 라이브러리를 로드한다. JAVA_HOME/jre/lib/ext/라이브러리에 있는 클래스들을 로드한다.
  • 어플리케이션 클래스로더 : 사용자 정의 클래스를 로드한다. /classpath에 지정된 경로에서 로드한다.

Execution에는 다음과 같은 작업이 일어난다.

  • Interpreter : 바이트 코드를 하나 씩 읽어서 해석하고 실행한다.
  • JIT compiler : 인터프리터 방식으로 실행하다가 적절한 시점에 바이트 코드 전체를 컴파일하여 네이티브 코드로 변경하고 이후 네이티브 코드를 직접 실행시킨다.

개발자가 객체 인스턴스를 생성하면 RDA의 힙 영역에 저장된다.
다음과 같이 코드를 작성하면 인스턴스는 힙 영역에서 관리하고 sample이라는 변수는 스택 영역에서 힙에 있는 객체를 참조하도록 동작한다.

for(int i = 0; i < 100; i++) {
  Sample sample = new Sample();
  sample.doSomething();
}

그러나 위의 코드와 같이 인스턴스를 만들고 사용하는 코드는 블록 단위, 메소드 단위에서 사용되는 경우가 많은데 이러면 힙 영역은 계속 할당되어 메모리가 흘러 넘치는 메모리 누수를 일으키게 된다. 이를 방지하기 위해 Gabage Collection이 있다.

Gabage Collection 줄여서 GC는 동적 할당된 메모리 영역 중에서 더 이상 사용하지 않는 영역을 탐지하여 자동으로 해지하는 기능으로 개발자가 직접 객체를 닫아서 메모리를 관리해야하는 번거로움이 없어진다.

그 다음으로는 Runtime Data Area를 알아보자.

RDA에서는 쓰레드마다 별도의 공간이 생기고 이들 쓰레드는 힙 영역과 메소드 영역을 공유한다.
메소드 영역은 클래스 영역 혹은 정적 영역이라고도 부른다.
여기선 클래스의 필드 정보, 메소드 정보, 타입 정보를 저장한다.
Runtime Constant Pool은 메소드 영역에서 관리하는 영역으로 자바 클래스에서 사용하는 상수를 저장한다.

예를 들어 String s1 = “hello”; String s2 = “hello”; 와 같이 코드를 작성했을 때
hello문자열은 RCP에 하나만 저장되고 s1과 s2가 같은 문자열 리터럴을 참조하기 때문에 true가 리턴된다.

String s3 = new String("hello");

이렇게 new키워드를 사용하여 String 객체를 생성하면 힙 메모리에 새로운 String 객체를 생성하기 때문에 s2 == s3는 false를 리턴한다.

stack영역은 기본 자료형을 생성할 때 저장하는 공간으로 임시적 변수나 정보들이 저장되는 공간이다.
int와 같은 원시 타입은 스택 영역에 실제 값을 가지고 Integer와 같은 참조 타입 변수는 힙 영역이나 메소드 영역의 객체 주소를 가진다.

그림에서 PC라고 나온 부분은 PC 레지스터를 의미하며 쓰레드가 시작될 때 생성되고 현재 실행중인 JVM명령어 주소를 저장한다.
예를 들어 A와 B의 데이터의 연산과 연산 값이 있다고 했을 때, A를 받고 B를 받는 동안 이 값을 CPU 공간에 저장해두어야한다. JVM은 스택에서 비연산값을 뽑아 별도의 메모리 공간인 PC Register에 저장한다.

Native Method Stack은 바이트 코드가 아닌 실제 실행 가능한 기계어로 작성된 프로그램을 실행시키는 영역을 말한다.

정리

public class Main {
    static int count = 1; 
    static {              
        System.out.println("Main 클래스 초기화");
    }
    
    public static void main(String[] args) {
        System.out.println("메인 메서드 시작");
        Helper.printMessage();
    }
}

class Helper {
    static {
        System.out.println("Helper 클래스 초기화");
    }

    public static void printMessage() {
        System.out.println("Hello from Helper!");
    }
}

위와 같은 프로그램을 실행했을 때 일어나는 일을 정리해보자.

  1. JVM 초기화: 프로그램이 실행되면 JVM이 시작되고 클래스를 메모리에 로드하는 작업을 수행할 클래스 로더를 준비한다.
  2. Main 클래스 로딩 및 실행: 클래스 로더 중 애플리케이션 클래스 로더에 의해 수행되고 해당 클래스의 바이트 코드와 메타데이터가 메소드 영역에 저장된다. JVM은 main메소드의 시작 지점을 찾고 이를 스택에 로드하여 실행한다.
  3. 링크: 클래스가 로드되면 클래스 파일이 유효한지 체크하고 정적 필드를 기본 값으로 초기화한다. 그런 다음 클래스와 인터페이스의 심볼릭 참조를 실제 참조로 바꾼다. 위의 코드의 경우 count는 이 단계에서 0으로 초기화된다.
  4. 초기화: 정적 블록과 정적 변수의 초기화 코드가 실행된다. 이 과정에서 정적변수 count의 값을 명시된 값으로 초기화 시키고 정적 블록이 실행된다.
  5. Helper 클래스 로딩: Helper클래스가 필요해지면 JVM은 해당 클래스를 로드하고 정적 블록을 실행한다.
  6. printMessage 메소드 호출: 메소드 호출 정보가 스택 프레임에 저장되고 이 곳엔 지역 변수, 매개 변수, 리턴 주소 등이 포함됨, printMessage 메소드의 바이트코드를 실행. excution engine이 여기서 동작한다. 바이트 코드를 실행할 때 JVM은 한 줄씩 인터프리터를 통해 실행하는데 바이트 코드를 기계어로 변환하여 즉시 실행한다. 이렇게 실행하다가 JIT컴파일러는 적절한 시점에 바이트 코드를 적절한 시점에 기계어로 컴파일하고 이 기계어는 캐시에 저장되어 이후에 해당 메소드가 호출될 때 인터프리터를 거치지 않고 바로 기계어 코드를 실행한다.
  7. 프로그램 종료 : main메소드가 종료되면 프로그램의 실행이 끝나고 JVM은 사용된 메모리를 해제하고 JVM을 종료
public class Main {
    static int count = 0;  // 메소드 영역에 저장됨 (static 변수)
    
    public static void main(String[] args) {
        Main main = new Main();  // 힙에 객체 생성
        main.increment();  // 스택에 메서드 호출 프레임 생성
        System.out.println("Count: " + count);
    }

    public void increment() {
        int localVar = 1;  // 스택에 저장 (지역 변수)
        count += localVar;  // 정적 변수 count 증가 (메소드 영역)
    }
}
  1. Main.class 파일이 JVM에 의해 로딩될 때 클래스에 대한 메타데이터가 메소드 영역에 저장된다. 정적 변수 또한 메소드 영역에 함께 저장된다.
  2. new 키워드로 생성된 main의 실제 인스턴스는 힙에 저장된다. main은 참조 변수로 스택에 저장되어있다. 이는 힙에 있는 실제 객체의 주소를 참조한다. 객체가 생성될 때 그 객체의 인스턴스 변수도 힙에 저장된다. 이는 main이 가진 일반적인 변수를 말한다.
  3. 메소드 호출시 스택에 스택 프레임이 생성된다. 여기에는 메소드의 지역 변수와 매개변수가 저장된다. 메소드의 호출이 끝나면 스택 프레임이 제거된다.