자바는 자바 가상머신이라는 시스템에서 돌아간다는 것은 자바를 시작할 때 배우는 것이다.
C언어나 파이썬을 배울 때도 그렇지만 프로그래밍 언어는 항상 첫부분에 가르치는 내용이 가장 어려운 주제 중에 하나가 된다.
예를 들어 C언어를 처음 배울 때는 이식성이 좋다고 말하는데 그게 무슨말인지 설명하라고 하면 완전하게 설명이 어렵다. C언어의 이식성은 자바의 그것과는 차이가 있다. 하드웨어의 아키텍쳐까지 고려하는 C언어의 이식성을 논하는 것은 시대에 따른 차이가 있지 않을까 싶다.
C언어의 창시자 데니스 리치와 브라이언 커니핸이 공동 저술한 The C Programming Language 에서도 이식성에 대한 강조는 없다. 책의 서두에 C언어 개발의 목적은 유닉스 시스템을 구현하기 위함이라고 나와있을 뿐이다. C의 이식성은 창시자의 뜻과는 다르게 시대의 변화에 따라 다양하게 해석되 왔다고 추정된다.
결국 데니스 리치가 한 말은 맞다. C는 범용 프로그래밍 언어이다. (General-Purpose Programming Language) 사용자들도 C의 범용성을 활용하다 보니 자체적으로 이식성이 발달한 것이다.
반면 자바는 어떤가? 자바는 그야말로 write once run anywhere (한번 쓰면 모든 곳에서 돌아간다) 라는 모토아래 창시된 객체지향언어이다. 그래서 자바의 이식성은 C언어 보다 체감이 쉽다. 그것은 자바 가상 머신이 있기 때문이다.
자바는 객체지향 기법과 자체 메모리 관리 기술로 현대의 언어가 될 만한 주요 요소를 갖췄다. 프로그램을 설계할 때 하드웨어가 아니라 개념을 중심으로 하도록 되어 있다. 원래라면 하드웨어와 운영체제를 생각할 시간에 실세계의 객체를 설계할 시간을 벌어준다.
자바가 점점 더 버전업이 되고 하드웨어가 발전할 수록 JVM 이 더 많은 일을 처리해줄 것이다. 따라서 프로그래머는 좀 더 실세계의 문제에 집중할 수 있게 된다.
자바 가상 머신 - 위키백과, 우리 모두의 백과사전 (wikipedia.org)
컴파일 할 때 자바는 자바 가상 머신이 사용할 바이트 코드를 생성한다. 그 바이트 코드를 구동시키는 프로그램이 자바 가상 머신 (Java Virtual Machine - 통칭 JVM 이라 한다) 이고 자바 소스 코드를 작성하더라도 JVM 이라는 프로그램 안에서 돌아가는 것이다. JVM 이 바이트 코드를 가져와서 번역(Interpreting) 해서 실행시킨다. 프로그램이 실행되고 종료될때까지 JVM이 메모리 관리나 기타 운영체제와 해야할 일들을 도맡아 처리한다.
여러 플랫폼에서 사용가능한 이유는 바이트 코드에 있다. 윈도우즈, 맥, 리눅스에서 작동하는 법을 JVM이 알고 있기 때문이다. 이런 관리자가 C언어에는 없다. JVM의 원리에 대하여는 영문 싸이트들이 정리가 잘되어 있다. 영어지만 그림만 봐도 확실히 느낌이 오니까 꼭 확인해두자.
Java Virtual Machine (JVM), Difference JDK, JRE & JVM - Core Java (beginnersbook.com)
JVM | What is Java Virtual Machine & its Architecture (guru99.com)
그럼 C언어는 자바와 비교하면 어떨까? 차이점은 운영체제에서 직접 구동한다.
C언어는 컴파일러가 기계어를 목적코드로 생성한다. 이 기계어는 특정한 OS환경에서 CPU에게 직접 명령을 내릴 수 있는 특정 기계어(native machine code) 이다. 이것이 윈도우즈에서 만든 실행파일이 리눅스에서 실행이 되지 않는 이유다. 윈도우즈에는 자신만의 코드 시스템이 있고 리눅스는 또 다른 시스템이다. 프로그램의 코드라는 것은 단 한줄, 아니 한글자의 소스코드만 틀려도 오작동한다. 수십만줄로 작성된 OS의 경우는 모든게 정확해야 비로소 부팅이 되니 얼마나 정확하게 구현되야 하는지 상상하기 힘들다. 다른 운영체제에서 호환이 안되는 것은 당연하다.
자바나 C++ (객체지향이니까)이나 소스코드와 문법만 보면 비슷해 보이는데 내부적으로 엄청난 차이가 있다. JVM이 있고 없고의 차이다. 보통 파이썬이나 자바스크립트 같은 인터프리터 고수준 언어는 이식성이 좋을 수 밖에 없는데 쉽게 말하면 소스 코드만 들고 가면 JVM 같은 가상 머신이 구동을 시키기 때문이다.
이식성을 이야기할 때 여러가지 측면을 고려해야 하는데 크게 두가지로 나눠보면 인간적인 측면과 기계적인 측면이 있다. 예를 들면
*언어가 버전업할 때 소스코드의 문법이 자부 바뀐다.
-> 사람이 하는 작업의 이식성이 떨어지는 것이다. 이를 보완하려고 일부 언어는 가상환경을 제공한다. (파이썬)
*리눅스용 코드와 윈도우용 코드가 따로 있거나 부분적으로라도 차이가 있다.
-> OS계층에 대한 이식성이 떨어진다. (소프트웨어)
* CPU에 따라서 작동하는 코드가 다르다.
-> 하드웨어 이식성이 떨어진다. (어셈블리어)
사람들이 이식성에 대하여 이야기할 때는 포괄적인 이식성을 말한다.
플랫폼을 옮기는데 불편함과 비용이 발생하거나, 아니면 기분이 불쾌하거나(실제적이다) 하면 이식성이 좋지 않다고 한다. 이식성은 딱잘라 점수로만 평가하기도 그렇고, 사람에게 주관적인 측면이 있다.
그럼 이제 실습으로 자바 가상머신을 테스트 해본다.
자바 API인 Runtime 클래스를 사용하면 현재 머신의 상태를 체크할 수 있다. Runtime 은 JVM에 대한 몇가지 메소드를 제공한다.
Runtime (Java Platform SE 7 ) (oracle.com)
자바는 100% 객체지향을 지향하는 언어인 만큼 JVM에 대한 인스턴스도 생성할 수 있다. 마치 자바가 자기 자신의 인스턴스를 생성하는 것 같다.
getRuntime메소드로 간단히 인스턴스를 가지고 온다.
처음 확인할 것은 프로세서의 숫자다. JVM이 사용할 수 있는 CPU 코어의 개수를 리턴한다.
실행한 시스템은 i7-10700 프로세서다. 8코어 16스레드인데 16을 리턴한 것으로 보면 JVM은 스레드가 기준이다. JVM입장에서는 하드웨어에서 물리적으로 제공하는 스레드의 수가 중요한 것 같다.
다음은 자유 메모리 양이다. 자유 메모리양은 힙(Heap) 메모리 사이즈이다. 스택은 기본값이 1메가 정도다.
4기가를 사용할 수 있다고 나와있다. 32비트 주소로 연산할 수 있는 양이 4기가인데 보통의 프로그램에는 차고 넘치는 메모리 크기다. (시스템에는 16기가가 설치되어 있다)
4기가는 사용할 수 있는 양이고 현재 JVM이 할당한 메모리는 256메가이다. 꽤 많은 메모리라 볼 수 있는데 여기를 다 쓰고 있는 것은 아니다. free Memory를 제외하면 현재 사용가능한 메모리의 양이 나온다. 2메가가 조금 넘는다. 소스코드를 몇줄 쓰지 않아도 자바 가상 머신이 스스로를 구동하고 있기 때문이다.
gc 메소드는 가비지 콜렉션(메모리 정리)를 하도록 부르는 것이다. C언어와의 결정적인 차이가 메모리 관리를 직접 하지 않는다는 것이다. 이를 매니지드 언어(managed language) 라고 한다. 구체적으로는 코드의 카운터가 0이 되면 삭제한다고 하는데 초기에는 성능이 좋지 않았다는 평이 있었으나 현재는 속도가 빨라지고 최적화가 이루어졌다고 한다.
안드로이드의 앱의 상당수가 자바로 동작하는데 앱들의 동작 속도를 보면 사람들이 체감할 만큼 빨라진 것 같다.
* 소스 코드
package com;
import java.io.IOException;
public class Main {
public static void main(String[] args) throws IOException {
Runtime jvmRuntime = Runtime.getRuntime();
System.out.println("[JAVA Runtime Test at i7-10700]");
int processors = jvmRuntime.availableProcessors();
long freeMem = jvmRuntime.freeMemory();
long totalMem = jvmRuntime.totalMemory();
long maxMem = jvmRuntime.maxMemory();
long curMem = totalMem - freeMem;
System.out.println("Available processors = " + processors);
System.out.println("- JVM maxMemory = " + (maxMem/(1024*1024)) + " Mbyte available");
System.out.println("- JVM totalMemory = " + (totalMem/(1024*1024)) + " Mbyte");
System.out.println("- JVM freeMemory = " + (freeMem/(1024*1024)) + " Mbyte available");
System.out.println("- currently Memory using = " + (curMem/(1024)) + " Kbyte");
jvmRuntime.gc();
jvmRuntime.exit(0);
}
}
다음은 조금 다른 코드를 실행시켜보자. 자바 클래스에서 자신의 인스턴스를 1000억회 생성해봤다. 시간은 얼마나 걸리고 메모리는 얼마나 사용하는지 궁굼해서이다. 물론 단순 코드로 된 인스턴스니까 별로 사용하지 않을 거라 생각하지만 그래도 1000억 회는 분명 큰 숫자임에 틀림없다.
결과적으로 이상없이 잘 작동한다. 메모리도 고작 10메가 정도밖에 더 사용하지 않았다. 어떻게 이럴 수 있을까?? 실제로는 그렇지 않았다. 연속적으로 루프를 돌려보니 인스턴스를 생성할 수록 메모리를 100메가 이상 사용하다가 좀 떨어질만하면 GC (가비지 컬렉터)가 알아서 메모리를 회수하는 것이다. 아마 인스턴스를 생성만 하고 사용하거나 유지하지 않으니까 프로그램 카운터가 0이 된 것으로 추정한다. 그렇다 하더라도 알아서 메모리를 관리하는 것은 상당한 성능이다.
같은 코드를 C언어에서 free 함수 없이 돌렸다면 바로 메모리 부족으로 종료당했거나 IDE가 다운됬을 수도 있다. 아무리 윈도우의 안정성이 좋아졌어도 그런 일은 위험하니 하지 않는게 좋다.
한편으로는 좀 더 관리를 잘 할려면 gc() 메소드를 적극적으로 사용하는 것도 필요한 것으로 보인다. 자바의 gc 성능이 나쁘다는게 아니라 대용량 파일을 다뤄야 할 때 요긴하게 사용할 수 있다고 생각한다.
long timeStart = System.currentTimeMillis();
for (long i = 0; i < 10000000000L; i++) {
new Main();
if (i % 100000000 == 0)
System.out.println("- JVM freeMemory = " + (jvmRuntime.freeMemory()/(1024*1024)) + " Mbyte available");
}
long timeEnd = System.currentTimeMillis();
long timeDiff = (timeEnd - timeStart)/1000;
System.out.println("[After creating 100000000000 instances]");
System.out.println("- JVM freeMemory = " + (jvmRuntime.freeMemory()/(1024*1024)) + " Mbyte available");
System.out.println("timeDiff = " + timeDiff);
마지막으로 Runtime 으로 할 수 있는 다른 것 하나를 소개한다.
JVM은 운영체제와 소통하니까 운영체제의 명령어를 사용하는 것도 가능하다. cmd 창에서 하는 것 처럼 명령어를 입력하면 프로그램을 사용할 수 있다. 아래의 코드는 윈도우 환경에서 메모장을 실행시킨다.
jvmRuntime.exec("notepad");