Java 깊이 살펴보기 01: Class
들어가기 전에
Steem 계정을 생성하고 쓰는 첫 글입니다. 새해 Steem을 사용해 볼 요량으로 Java
와 관련된 글을 연재하고자 합니다. 연재에서 다룰 글감의 핵심은 Java
이지만 실제론 Java
만을 다룬다기 보다는 Java
와 웹 개발과 관련된 다양한 주제를 다루고자 합니다.
개인적인 목표는 그동안 의심 없이 익숙하게 써왔던 내용들을 조금 더 깊게 살펴보고 글로 적으면서 지식을 정리하는 것입니다. 가능하면 아무리 업무가 바빠도 한 주의 하나의 주제를 다루는 것이 공개적인 목표입니다. 이 약속을 잘 지킨다면 올 한해 적어도 40개 가량의 주제를 공부할 수 있을 것 같습니다.
이 연재를 준비하면서 이미 조사하고 있거나 하려고 노트에 적어둔 주제들은 다음과 같습니다.
- JVM(스펙, jvm option, gc)
- 서블릿 및 톰캣 구동원리
- http keep alive 및 tcp 상태 변화
- jdbc
- IO, NIO
- 스레드
따라서, 이 연재는 이제 개발을 막 시작한 쥬니어 개발자 혹은 본인이 Java
및 컴퓨터 공학과 관련된 기초가 조금 부족하다고 생각하는 분들을 독자로 가정하고 글을 쓸 생각입니다.
그럼 오늘의 주제인 Java
의 Class
에 대해서 알아봅시다.
Class 들여다 보기
첫 글은 가볍게 객체지향 언어의 핵심이라고 할 수 있는 클래스를 Java
언어에서 어떻게 다루는지 살펴봅시다.
언제나 한결 같은 Java
Java
로 작성한 코드는 운영체제 혹은 특정 하드웨어 사양과 상관없이 어디서든지 동일한 결과물로 컴파일됩니다. 이 결과물을 우리는 바이트 코드라고 부릅니다. 동일한 결과물로 우리는 어느 환경에서도 동일한 내용을 실행시킬 수가 있습니다.
반면 C
나 Go
와 같은 언어는 코드를 컴파일하면 실행환경에 맞는 binary를 제공합니다. 동일한 코드도 윈도우에서 컴파일 할 때와 Mac에서 컴파일 할 때 다른 결과물을 내놓습니다.
JVM을 알아야만 하는 이유
실행환경에 상관없이 동일한 바이트코드를 사용할 수 있는 이유는 JVM
때문입니다.
JVM
(Java Virtual Machine)이 바로 컴파일된 바이트코드를 실행하는 일을 담당합니다. JVM
바이트코드를 해석하여 실행하는 가상의 기계로 바라보는 방식은 JVM
을 블랙박스로 바라보는 추상적인 접근입니다.
주의: 잠시 꼰대스러운 사족이 이어집니다.
이 글과 앞으로의 연재에서 우리는 JVM
을 자주 살펴볼 예정입니다. 사실 Java
언어의 구문을 이해하고 프로그램을 작성하는 것까지가 개발자의 역할이라고 볼 수도 있습니다. 우리가 작성한 Java
코드를 범용적인 바이트코드를 변환하고 이를 JVM 위에서 실행환경에 상관 없이 동일하게 수행할 수 있도록 하는 것은 Java
언어와 JVM
을 만든 이들의 몫입니다.
JVM
을 깊이 살펴보는 것은 여러모로 의미가 있습니다. 물론 실무환경에서 트러블슈팅 및 최적화를 진행하는데 JVM
의 구조와 원리는 큰 도움이 됩니다. 그런데 제가 생각하는 더 중요한 부분은 다른데 있습니다. 한 가지 기술의 원리를 깊이 이해하는 것 그 자체가 가지는 지식의 확장성입니다.
해 아래 새 것은 없습니다. JVM
에서 사용되는 원리나 내용을 이해하면 다른 시스템을 이해하고 분석하는데 큰 도움이 됩니다. 이 연재의 목적도 같은 맥락입니다. 굳이 몰라도 되는 내용이지만 시간이 허락하는 한 집요하게 살펴보고 파보는 것이 목적 입니다.
스펙이라고도 말하는 명세를 직접 읽는 훈련차원에서도 JVM
을 이해하는 것은 큰 도움이 됩니다. Java
뿐 아니라 많은 기술들은 그 기술을 설명하는 고유한 문서 명세가 존재합니다. 명세는 기술의 구현을 설명하는 문서가 아니라 기술 그 자체를 정의합니다. 따라서 기술을 실제 구현하는 방법은 구현하는 조직이나 사람마다 다를 수 있습니다. JVM
만 하더라도 우리가 가장 많이 사용하는 HotSpot JVM 외에도 IBM의 JVM, Azul사의 Zing 등이 있습니다.
바이트코드 한 번 봤니?
우리는 이제 바이트코드에서 시작하려고 합니다. 여기 다음과 같이 간단한 예제 코드가 있습니다.
public class SimpleClass {
public static void main(String[] args) {
System.out.println("Deep dive into Java");
int myData = 0x12345678;
System.out.println("My Data: " + myData);
}
}
Side Note:
이 예제 코드에서는 패키지가 없습니다. 패키지에 포함되지 않는 클래스는 Unnamed Package로 분류됩니다. 일반적인 경우에는 패키지를 반드시 생성하고 클래스를 패키지 단위로 구성하는 것이 관례입니다. 다만 예제와 같이 간단한 프로그램의 경우에는 패키지를 생략할 수 있습니다.
터미널에서 javac
명령을 사용하여 SimpleClass.java
파일을 바이트코드로 컴파일 할 수 있습니다.
$ javac SimpleClass.java
구문에 오류가 없다면 컴파일러는 정상적으로 SimpleClass.java
코드가 위치한 곳에 SimpleClass.class
를 생성합니다. 그럼 이제 바이트코드를 눈으로 직접 살펴봅시다. 터미널에 hexdump
명령을 실행할 수 있다면 다음 명령을 수행하여 바이트코드를 직접 눈으로 볼 수 있습니다.
$ hexdump SimpleMain.class
에디터에서는 Visual Studio Code를 예로 들면 hexdump 플러그인을 설치해서 바이트코드를 볼 수도 있습니다. hexdump
명령을 사용하여 SimpleClass.class
을 보면 다음과 같은 내용을 확인할 수 있습니다.
0000000 ca fe ba be 00 00 00 34 00 2e 0a 00 0d 00 16 09
0000010 00 17 00 18 08 00 19 0a 00 1a 00 1b 03 12 34 56
0000020 78 07 00 1c 0a 00 06 00 16 08 00 1d 0a 00 06 00
0000030 1e 0a 00 06 00 1f 0a 00 06 00 20 07 00 21 07 00
...
위 내용은 바이트 코드의 내용을 hexdump
로 조회했기 때문에 16진수로 표현되어 있습니다. 16진수 한자리를 표현하는데 4비트(2^4)가 필요합니다. 따라서 2자리 16진수는 8비트 즉 1바이트를 나타냅니다. hexdump
가 구분해놓은 16진수 2자리가 바로 하나의 바이트를 나타냅니다.
CA FE BA BE
모든 Java
클래스의 바이트코드는 첫 4 바이트를 지정된 상수 0xCAFEBABE
로 시작합니다. 목적은 간단합니다. JVM
명세에서 정의한 클래스 규칙을 따르는지를 확인하는 첫번째 방법입니다.
Side Note:
이 연재에서 Java와 JVM관련 스펙을 참조한다면 가능하면 글의 작성시점 기준 최신인 Java9의 명세를 참고합니다.
JVM
명세 4장에서 클래스 포맷을 다음과 같이 정의하고 있습니다.
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
명세에서 u1, u2, u4로 표기된 것은 각각 unsigned 1,2 4 바이트를 의미합니다. 2바이트 이상의 바이트 순서는 Java의 명세에 따라 네트워크 순서인 빅엔디안입니다.
엔디안
잠깐 곁가지로 늘 헷갈리고 기억이 가물할 수 있는 엔디안을 다시 떠올려봅시다. 엔디안은 바이트 순서로 컴퓨터 아키텍쳐마다 컴퓨터에서 바이트를 어떻게 표현할 것인가를 다루는 규칙입니다. 리틀엔디안은 데이터의 바이트 배열에서 낮은 바이트가 먼저 오는 방식입니다. 예를 들면 0x12345678
과 같이 8바이트 데이터를 리틀엔디안 방식으로 표현하면 다음과 같습니다.
78 56 34 12
제가 글을 쓰고 있는 컴퓨터는 맥북이고 Intel 칩셋으로 리틀 엔디안을 사용합니다. 만약 다음과 같은 endian.c
코드를 제 컴퓨터에서 컴파일한다고 가정해 봅시다.
#include <stdio.h>
int main() {
int myData = 0x12345678;
printf("Hello, c: %d", myData);
}
오랜만에 c
코드를 한 번 컴파일 해봅시다.
$ gcc -o endian endian.c
코드에 문제가 없다면 정상적으로 endian
바이너리 파일이 생성됩니다. 앞서 SimpleClass.class
의 내용을 조회했던 방식과 동일하게 hexdump
를 사용해 봅시다.
$ hexdump endian
눈을 비비고 터미널 상에 바이트 코드를 잘 찾다보면 다음과 같이 myData
의 값 0x12345678
은 컴파일 후 리터럴로 리틀 엔디안 방식으로 표현된 것을 확인할 수 있습니다.
0000f60 45 fc 78 56 34 12 8b 75 fc b0 00 e8 0e 00 00 00
0f62~0f65 주소(이 주소는 컴파일 환경에 따라 다를 수 있습니다.)의 데이터가 정말myData
의 값 0x12345678
인지 의심이 가는 분들은 코드를 수정하여 수정한 값이 동일한 위치에 반영되었는지 확인해 볼 수 있습니다.
바이트코드를 좀 더 자세하게 알아보기
다시 본론으로 돌아와서 Java
는 빅엔디안 방식이기 때문에 분명 바이트 코드 어딘가에 myData
의 값 0x12345678
가 순서대로 12 34 56 78로 표현되어 있다는 것을 확인할 수 있습니다. 그런데 JVM
명세에 선언된 클래스 포맷을 한 땀 한 땀 바이트와 비교하기가 쉽지 않습니다.
다행히 javap
명령을 사용하면 바이트코드의 내용을 클래스 포맷에 맞게 변환해서 보여줍니다. 다음 명령을 사용하여 좀 더 쉽게 SimpleClass.class
의 바이트코드를 분석해 봅시다.
$ javap -verbose SimpleClass
명령을 실행하면 다음과 같이 SimpleClass.class
의 바이트코드를 JVM
명세의 클래스 포맷에 맞춰 좀 더 이해하기 쉬운 내용으로 보여줍니다.
Classfile (코드의 폴더 path)/SimpleClass.class
Last modified Mar 4, 2018; size 677 bytes
MD5 checksum af7b08791de4e6858436f2a929707744
Compiled from "SimpleClass.java"
public class SimpleClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #13.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #23.#24 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #25 // Deep dive into Java
...
변환된 내용중 minor version
, major version
을 봅시다. 각각 0, 52로 적혀 있습니다. 바이트 코드는 처음 4바이트의 매직넘버 다음으로 2바이트씩 마이너 버전과 메이저 버전 값을 갖습니다. 위에서 잠시 살펴본 SimpleClass.class
의 바이트 코드 첫번째 줄만 보면 다음과 같습니다.
0000000 ca fe ba be 00 00 00 34 00 2e 0a 00 0d 00 16 09
ca fe ba be 다음으로 마이너 버전에 해당하는 두바이트 00 00, 메이저 버전에 해당하는 00 34를 확인할 수 있습니다. 메이저 버전의 값에서 혼동하지 말아야 할 것은 34는 16진수이므로 16^1 * 3 + 16^0 + 4 = 52
이라는 것입니다.
Java 1.1이 메이저 버전 45이고 버전은 하나씩 증가합니다. 따라서 메이저 버전 52는 실제 Java 8을 의미하고 현재 최신 버전인 Java 9 는 53이 됩니다. 간혹 프로젝트를 IDE에서 실행할 때 이런 예외를 볼때가 종종 있습니다.
Java Exception : Unsupported major.minor version 52.0
저 예외의 이유를 이제 아시겠나요? 이 예외는 상위 버전에서 컴파일한 바이트코드를 하위 버전의 JVM에서 실행할 때 발생하는 예외입니다.
정리하며
오늘은 Steem 첫 연재글로 Java
의 클래스를 살펴보았습니다. 실제 코드를 컴파일하고 바이트코드를 JVM
의 명세에 기술된 클래스포맷을 참조하면서 이해해 보았습니다. 더불어 바이트 순서도 다시 살펴보았습니다. 다음에는 설명하지 못한 바이트코드 일부를 다루고 클래스로더를 더 알아보고자 합니다.
스팀잇 환영해요 ㅋ
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
새로운 환경에서 새롭게 인사를 드립니다. ㅋㅋ
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
오래간만에 보는 CA FE 매직코드 입니다.
JVM 은 죽지 않겠지만, 자바는 저물어 가는 분위기...
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
오픈소스 진영이나 해외 신규 프로젝트에서 자바의 인기가 시들한 건 부인할 수 없는 사실이라고 생각합니다... ㅜ
자바 의존도가 높은 국내 개발환경의 독특성 때문에 저도 자바를 이렇게 붙들고 있네요 ㅎ
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit