Java 기초 문법부터 JVM 메모리 구조까지
Java는 원래 임베디드 기기 용으로 설계됐다. 당시 문제는 하드웨어 아키텍처가 제각각이라 플랫폼마다 코드를 따로 짜야 했다는 것. 해결책이 바로 JVM이다. 임베디드 기기 안에 JVM을 넣고, 자바 코드는 바이트코드로 컴파일한 뒤 JVM 위에서 실행하면 하드웨어에 종속되지 않는다. Write Once, Run Anywhere. 지금은 서버사이드 개발의 주력 언어가 됐지만, 탄생 배경을 알면 설계 철학이 훨씬 납득이 간다.
Java가 엔터프라이즈 시장에서 오래 살아남은 이유는 세 가지라고 생각한다.
1. 객체지향 강제: 수백 명이 협업하는 대규모 서비스에서 코드 구조가 자연스럽게 잡힌다.
2. 풍부한 오픈소스 생태계: Spring, Hibernate 등 검증된 라이브러리가 넘친다.
3. GC 자동화: 메모리 관리를 JVM이 해주니까 개발 생산성이 올라간다.
요즘 시대에 하드웨어 성능은 계속 올라가고, 클라우드 인프라 비용보다 개발자 인건비가 훨씬 비싸다. Java/Spring Boot의 생산성과 유지보수성이 그 비용을 충분히 상쇄한다.
자바 타입 시스템의 핵심은 값이 어디에 저장되느냐다.
| 구분 | 종류 | 저장 위치 | 특징 |
|---|---|---|---|
| Primitive | int, double, boolean, char 등 8가지 | Stack | 실제 값 직접 저장, 빠른 접근 |
| Reference | Class, Interface, Array, Enum | Heap (객체) + Stack (주소값) | 객체는 힙에, 스택엔 hashcode(주소)만 |
C 포인터와 비교하면 Java 참조는 주소 연산이 안 되고 JVM이 관리하는 논리적 주소만 보유한다. 덕분에 NullPointerException 말고는 메모리 관련 사고가 거의 안 난다. C는 free() 직접 호출해야 하고 잘못 건드리면 세그멘테이션 폴트가 난다. 안정성 측면에서 Java가 훨씬 낫다.
객체가 생성되면 Heap 안에서 이렇게 이동한다.
[Eden] → [Survivor1 or Survivor2] → (왔다갔다) → [Old] → GC로 회수
↑
오래 살아남은 객체
New/Young 영역
Old 영역
age bit (Minor GC 생존할 때마다 +1)MaxTenuringThreshold 초과 시 Old로 승격Major GC (Full GC)
Java 8 이전에는 Permanent 영역에 클래스 메타데이터, static 객체, 상수 등이 저장됐다. 문제는 Perm 영역 크기가 고정이라 OutOfMemoryError: PermGen space가 자주 터졌다.
// 이런 코드가 문제였음
static List<Object> list = new ArrayList<>(); // 계속 추가되면 Perm 터짐
Java 8부터 Metaspace로 교체됐다. Metaspace는 Native 메모리 영역(OS 관리)이라 JVM 힙 제한을 안 받는다. static 객체는 Heap으로 이동시켜 GC 대상이 되도록 개선했다.
| GC | 특징 | 단점 |
|---|---|---|
| Serial GC | 싱글스레드, JDK 5/6 | STW 길다, 실무 비권장 |
| Parallel GC | Minor GC 멀티스레드 | Full GC는 여전히 느림 |
| Parallel Old GC | Full GC도 병렬 처리 | Mark-Summary-Compaction |
| CMS GC | STW 최소화 목표, 4단계로 정밀하게 | Compaction 미지원 → 메모리 단편화 |
| G1 GC | Region 단위 관리, STW 예측 가능 | 현재 Java 기본 GC |
G1 GC가 핵심인 이유: 힙을 Region이라는 논리 단위로 잘게 나눠서 Eden/Survivor/Old 역할을 동적으로 부여한다. CMS와 달리 Compaction도 하고 STW 시간도 예측할 수 있다. JVM 힙은 최대 2048개 Region, 각 Region은 1MB~32MB.
long l1 = 2222222222L;
float f1 = l1; // long → float 자동 형변환
System.out.println("f1:" + f1); // f1:2.22222221E9 (정밀도 손실!)
long l2 = (long) f1;
System.out.println("l2:" + l2); // l2:2222222208 (원래 값과 다름)
long → float 변환에서 정밀도가 손실된다. 자동 형변환이라고 다 안전한 게 아니다. 실제로 금융 계산에서 float/double 쓰면 큰일 난다. 이럴 때 BigDecimal을 써야 한다는 걸 나중에 배울 것 같다.
int n2 = -8;
System.out.println("-8 >> 1 : " + (n2 >> 1)); // -4 (부호 유지)
System.out.println("-8 >>> 1 : " + (n2 >>> 1)); // 2147483644 (부호 비트도 0으로)
>>는 부호 비트를 유지하면서 이동 (산술 우측 시프트), >>>는 그냥 0으로 채운다 (논리 우측 시프트). 음수에 >>>를 쓰면 엄청 큰 양수가 나온다.
boolean result = i++ > 10 && j++ > 5;
&&는 앞이 false면 뒤를 평가 안 한다 (단락 평가). 그래서 i++만 실행되고 j++는 실행 안 됨. &는 무조건 둘 다 평가한다. 조건문에서 &&를 쓰는 이유가 성능뿐 아니라 부작용(side effect) 방지이기도 하다.
public static void passReference(int[] data) {
data[0] = 100; // 원본 배열이 바뀜!
}
자바는 "항상 Pass By Value"다. 근데 참조형의 경우 참조값(주소)이 복사되기 때문에 메서드 안에서 배열 내용을 바꾸면 원본도 바뀐다. 이게 Pass By Reference처럼 보이지만 엄밀히는 아니다. 배열 자체를 새로 할당(data = new int[5])하면 원본에 영향 없음.
오늘 수업에서 생긴 의문:
AI 코딩 시대에 성능 최적화를 위해 더 로우레벨 언어가 필요해지지 않을까?
LLM에게 답을 물었다.
결론적으로, 인간이 더 고수준의 설계 의도를 정의하고 AI가 코드를 작성하는 방향으로 가는 것 같다. 이미 지금도 그렇게 되고 있다. 그러면 개발자에게 중요한 능력은 AI가 작성한 코드를 읽고, 위험한 지점을 감지하고, 설계 의도에 맞는지 판단하는 것이다.
최근에 실리콘밸리 개발자님의 유투브에서 오히려 인간이 하는 코드리뷰 단계가 병목이 된단 영상을 보았다. 코드 생산성이 말도 안되게 올라간만큼 설계할때가 더 중요해진것 같다.