Java

Java는 제임스 고슬링을 중심으로 Sun Microsystems에서 개발하여 1995년에 공개한 범용 프로그래밍 언어이자, 해당 언어로 작성된 프로그램을 여러 운영체제와 하드웨어에서 실행하기 위한 소프트웨어 플랫폼이다. Java 소스 코드는 일반적으로 기계어에 직접 고정되지 않는 .c...

분류범용 프로그래밍 언어, 객체 지향 프로그래밍 언어
패러다임객체 지향 프로그래밍, 명령형 프로그래밍, 구조적 프로그래밍, 함수형 프로그래밍
설계자제임스 고슬링
개발 기관Sun Microsystems
최초 공개1995년
선행 언어C++, C (프로그래밍 언어), Smalltalk, Objective-C, Mesa
타입 체계정적 타입, 강한 타입, 명목적 타입 체계
실행 방식바이트코드, JVM, JIT 컴파일
메모리 관리가비지 컬렉션
표준 플랫폼Java SE
최신 표준Java SE 26
최신 LTSJava SE 25
주요 구현체OpenJDK, Oracle JDK, Eclipse Temurin, GraalVM
파일 확장자.java, .class, .jar

Java제임스 고슬링을 중심으로 Sun Microsystems에서 개발하여 1995년에 공개한 범용 프로그래밍 언어이자, 해당 언어로 작성된 프로그램을 여러 운영체제와 하드웨어에서 실행하기 위한 소프트웨어 플랫폼이다. Java 소스 코드는 일반적으로 기계어에 직접 고정되지 않는 .class 바이트코드로 컴파일되며, 각 환경에 맞게 구현된 Java 가상 머신이 이를 해석하거나 실행 중 기계어로 컴파일한다.

Java는 C와 C++에서 익숙한 중괄호 기반 문법을 받아들이면서 포인터 산술, 명시적인 메모리 해제, 다중 클래스 상속과 같은 저수준 또는 복잡한 기능을 언어의 일반적인 사용 경로에서 제외했다. 대신 클래스와 인터페이스, 자동 메모리 관리, 예외 처리, 실행 시점 타입 검사와 표준화된 실행 환경을 결합하여 대규모 프로그램을 비교적 일관된 방식으로 개발하고 배포할 수 있도록 설계되었다.

Java 프로그램은 보통 Java 컴파일러가 생성한 클래스 파일과 필요한 자원을 하나 이상의 JAR 파일로 묶어 배포한다. JVM은 클래스 로딩, 바이트코드 검증, 메모리 관리, 스레드 실행과 네이티브 코드 연결을 담당하며, 자주 실행되는 코드를 JIT 컴파일하여 현재 프로세서에 맞는 기계어로 변환할 수 있다. 이러한 구조는 같은 바이트코드를 서로 다른 시스템에서 실행한다는 Java의 이식성 모델을 형성했으며, 언어와 운영체제 사이에 JVM이라는 독립된 실행 계층을 정착시켰다.

Java라는 이름은 프로그래밍 언어만을 가리키기도 하지만, 실제 개발에서는 언어 명세와 JVM 명세, 표준 클래스 라이브러리, 개발 도구를 포함하는 Java 플랫폼 전체를 가리키는 경우가 많다. JDK는 컴파일러와 실행 환경, 디버거, 문서 생성기, 패키징 및 진단 도구를 포함하며, Java SE는 언어와 가상 머신 및 핵심 API의 표준 기반을 정의한다. Java 언어와 JVM은 서로 밀접하게 연결되어 있지만 동일한 개념은 아니며, JVM에서는 Kotlin, Scala, Clojure와 같이 Java가 아닌 다른 언어로 작성된 프로그램도 실행할 수 있다.

Java SE는 정해진 주기에 따라 새로운 기능 릴리스를 제공한다. 2026년 3월 공개된 Java SE 26이 현재 최신 기능 릴리스이며, 2025년 9월 공개된 Java SE 25는 장기 지원을 제공하는 최신 LTS 릴리스다.[1][2] Java SE 8, 11, 17, 21과 25도 장기 지원 계열로 운영되고 있어 실제 시스템에서는 최신 기능 릴리스와 여러 세대의 LTS가 함께 사용된다.[3]

Java는 기업용 서버와 웹 서비스, 금융 및 업무 시스템, 분산 처리, 데이터 처리 도구, 개발 도구와 Android 애플리케이션 등에서 폭넓게 사용되어 왔다. 특히 오랜 기간 유지되는 대규모 시스템에서는 바이너리 호환성, 풍부한 표준 라이브러리, 다양한 JVM 구현체와 성숙한 프레임워크 생태계가 중요한 기반으로 작용한다. 현대 Java는 전통적인 클래스 중심 객체 지향 기능뿐 아니라 람다 표현식, 스트림, 레코드, 봉인 클래스, 패턴 매칭과 가상 스레드 등을 도입하면서 데이터 처리와 동시성 표현 방식도 지속해서 확장하고 있다.

역사

Green 프로젝트와 Oak

Java의 기원은 1991년 Sun Microsystems에서 시작된 Green 프로젝트에 있다. 이 프로젝트는 당시의 주력 시장이었던 워크스테이션과 서버를 넘어, 대화형 텔레비전과 셋톱박스 같은 차세대 소비자 전자 기기를 위한 소프트웨어 환경을 연구하기 위해 조직되었다. 제임스 고슬링, 마이크 셰리던, 패트릭 노턴 등이 초기 팀에 참여했다.[4]

소비자 전자 기기는 제조사와 프로세서 구성이 다양하고 제품 수명이 길었기 때문에, 특정 CPU나 운영체제에 종속되지 않으면서 제한된 자원에서도 안정적으로 실행되는 소프트웨어가 필요했다. Green 팀은 처음에는 C++를 이용하려 했지만, 복잡한 메모리 관리와 플랫폼 의존성, 당시 C++ 구현체의 차이로 인해 프로젝트의 요구를 충족하기 어렵다고 판단했다. 이에 고슬링은 C와 C++에 익숙한 개발자가 쉽게 접근할 수 있으면서도, 포인터 산술과 수동 메모리 해제 같은 기능을 제거한 새로운 언어를 설계하기 시작했다.[5]

이 언어는 처음에 Oak라는 이름으로 불렸다. Oak 프로그램은 특정 프로세서의 기계어가 아니라 중간 명령 형식으로 컴파일되고, 장치마다 구현된 가상 머신이 이를 실행하도록 설계되었다. 객체 지향 모델과 자동 메모리 관리, 실행 전 코드 검증, 동적 클래스 적재도 이 시기의 설계에 포함되었다. 이러한 구조는 이후 Java 바이트코드와 JVM으로 발전했다.

Green 팀은 Oak와 전용 하드웨어를 이용하여 가정용 멀티미디어 기기를 제어하는 시제품을 제작했다. 대표적인 시제품인 Star7은 터치스크린 기반 사용자 인터페이스와 애니메이션 안내 캐릭터를 제공했으며, Oak가 네트워크에 연결된 소형 기기에서 동적인 사용자 인터페이스를 실행할 수 있음을 보여 주었다. 그러나 당시 소비자 전자 기기 시장에서는 이 기술을 상용화할 계약을 확보하지 못했고, 프로젝트는 새로운 활용 분야를 찾게 되었다.

인터넷과 Java로의 전환

1990년대 중반 이 빠르게 확산되면서 Green 팀은 Oak의 이식성과 네트워크 중심 실행 모델이 웹 환경에도 적합하다고 판단했다. 웹 브라우저가 서로 다른 운영체제에서 실행되더라도 동일한 중간 코드를 내려받아 실행할 수 있다는 점이 소비자 전자 기기를 위해 설계했던 구조와 잘 맞았다.

Oak라는 이름은 이미 다른 기업이 상표로 사용하고 있었기 때문에 언어의 이름은 Java로 변경되었다. Sun은 Java 언어와 함께 Java 코드를 웹 페이지 안에서 실행할 수 있는 브라우저 HotJava를 개발했다. HotJava는 브라우저가 네트워크에서 프로그램을 내려받아 즉시 실행할 수 있다는 개념을 시연했으며, 이렇게 웹 페이지에 포함된 소형 Java 프로그램은 애플릿이라고 불렸다.

Sun은 1995년 5월 23일 Java 기술을 공식 발표했다.[6] 같은 시기 Netscape Navigator가 Java 지원 계획을 발표하면서 Java는 웹 브라우저를 통해 빠르게 알려졌다. Java의 초기 확산은 소스 코드를 운영체제별로 다시 작성하지 않고, JVM이 설치된 환경에서 같은 바이트코드를 실행한다는 이식성에 기반했다.

1996년 1월 23일 첫 정식 개발 키트인 JDK 1.0이 공개되었다.[7] JDK 1.0에는 Java 컴파일러와 JVM, 기본 클래스 라이브러리, 애플릿 실행 도구와 개발 도구가 포함되었다. 이 시기의 Java는 애플릿으로 널리 알려졌지만, 독립 실행형 응용 프로그램과 서버 프로그램도 작성할 수 있는 범용 언어로 설계되어 있었다.

초기 플랫폼의 성장

1997년 공개된 JDK 1.1은 내부 클래스, 리플렉션, JavaBeans, JDBC, 새로운 이벤트 처리 모델과 국제화 기능 등을 추가했다. 초기 버전에서 빠르게 확장되던 API가 정리되면서 Java는 웹 애플릿뿐 아니라 데이터베이스와 연결되는 업무용 응용 프로그램에도 사용되기 시작했다.

1998년에는 J2SE 1.2가 공개되면서 플랫폼 이름에 Java 2가 사용되기 시작했다. Java 2는 Java 플랫폼을 용도에 따라 다음 계열로 나누는 체계를 확립했다.

  • J2SE: 데스크톱과 범용 응용 프로그램을 위한 표준 플랫폼
  • J2EE: 서버와 기업용 분산 시스템을 위한 플랫폼
  • J2ME: 휴대전화와 임베디드 장치를 위한 축소 플랫폼

J2SE 1.2에는 컬렉션 프레임워크, Swing 그래픽 사용자 인터페이스 도구 모음, Java 2D, 보안 정책과 클래스 라이브러리 확장이 포함되었다. 이 시기부터 Java는 단일 언어를 넘어 JVM, 표준 API, 배포 형식과 다양한 실행 환경을 포함하는 플랫폼으로 발전했다.

서버 분야에서는 서블릿, JavaServer Pages, Enterprise JavaBeans와 트랜잭션 API 등이 J2EE로 통합되었다. 이는 Java가 브라우저의 애플릿 기술에서 기업용 웹 애플리케이션과 다계층 서버 시스템으로 활용 범위를 넓히는 계기가 되었다.

언어 기능의 확장

2004년 공개된 J2SE 5.0은 Java 언어 자체에 대규모 변화를 도입했다. 제네릭, 애너테이션, 열거형, 가변 인자, 오토박싱과 언박싱, 향상된 for 문, 정적 임포트가 이 버전에서 추가되었다. 버전 번호는 내부적으로 1.5를 사용했지만 제품명에는 5.0이 사용되었으며, 이후 일반적으로 Java 5라고 불렸다.

Java 5는 라이브러리와 프레임워크가 컴파일 시점 타입 정보를 더 적극적으로 활용할 수 있게 했다. 애너테이션과 리플렉션을 결합한 선언적 프로그래밍 방식도 널리 확산되었고, 이는 이후 기업용 Java 프레임워크의 구성 방식에 큰 영향을 주었다.

2006년 공개된 Java 6부터는 제품명에서 J2SE 대신 Java SE가 사용되었다. 기업용 플랫폼도 Java EE로, 소형 장치용 플랫폼도 Java ME로 이름이 바뀌었다. 이 시기 Java는 서버와 업무 시스템에서 강한 기반을 확보했으며, JVM의 JIT 컴파일과 가비지 컬렉션도 지속적으로 개선되었다.

OpenJDK와 공개 개발

Sun은 2006년 11월 JDK 소스 코드의 일부를 GPLv2로 공개하면서 OpenJDK 프로젝트를 시작했다. 이후 주요 구성 요소가 단계적으로 공개되었고, OpenJDK는 Java SE의 오픈 소스 참조 구현을 공동 개발하는 중심 프로젝트가 되었다.[8]

OpenJDK의 공개는 여러 기업과 개발자가 JVM, 컴파일러, 클래스 라이브러리 개발에 직접 참여할 수 있는 기반을 마련했다. OpenJDK 소스를 바탕으로 서로 다른 운영체제와 지원 정책, 배포 방식에 맞춘 여러 JDK 배포판도 만들어졌다.

2010년 Oracle이 Sun Microsystems를 인수하면서 Java와 OpenJDK의 관리 주체도 Oracle로 넘어갔다. Oracle은 2010년 1월 Sun 인수를 완료했으며, 이후 Java 플랫폼의 표준화와 주요 OpenJDK 프로젝트를 주도했다.[9]

인수 전후의 개발 지연으로 Java 7은 2011년에 공개되었다. Java 7은 try-with-resources, 다이아몬드 연산자, 문자열을 사용하는 switch, 새로운 파일 시스템 API인 NIO.2, Fork/Join 프레임워크 등을 도입했다.

Java 8과 함수형 기능

2014년 공개된 Java 8은 Java 5 이후 가장 큰 언어 변화 가운데 하나로 평가된다. 람다 표현식과 메서드 참조, 인터페이스 기본 메서드, 향상된 타입 추론이 도입되었으며, 표준 라이브러리에는 컬렉션 데이터를 선언적인 연산으로 처리하는 Stream API와 새로운 날짜·시간 API가 추가되었다.[10]

람다 표현식은 Java의 기존 객체 지향 모델을 대체하지 않고, 동작을 함수형 인터페이스의 구현으로 전달하는 방식으로 통합되었다. 이를 통해 컬렉션 변환과 필터링, 병렬 처리, 비동기 API를 이전보다 간결하게 표현할 수 있게 되었다.

Java 8은 장기간 지원된 버전이었으며 많은 기업용 시스템과 Android 개발 환경에서 오랫동안 기준 버전으로 사용되었다. 그 결과 후속 Java 버전이 공개된 이후에도 Java 8을 대상으로 작성된 라이브러리와 응용 프로그램이 광범위하게 유지되었다.

모듈화와 정기 릴리스 체계

2017년 공개된 Java 9Java 플랫폼 모듈 시스템을 도입했다. 모듈 시스템은 JDK와 응용 프로그램을 명시적인 모듈 단위로 나누고, 모듈 사이의 의존성과 공개 패키지를 선언할 수 있도록 했다. Java 9에서는 JDK 자체도 모듈화되었으며, 대화형 Java 셸인 JShell도 추가되었다.

Java 9 이후 Java 개발은 기능이 완성될 때까지 대규모 버전을 장기간 준비하는 방식에서 벗어나, 일정한 시간에 맞추어 기능 릴리스를 제공하는 방식으로 전환되었다. OpenJDK는 6개월마다 새로운 JDK 기능 릴리스를 제공하는 시간 기반 개발 모델을 채택했으며, 이 체계는 2018년 Java 10부터 본격적으로 적용되었다.[11]

정기 릴리스 체계에서는 준비가 완료된 기능만 해당 릴리스에 포함되고, 완료되지 않은 기능은 다음 릴리스로 이동한다. 영구 사양으로 확정하기 전에 실제 사용 경험을 수집할 필요가 있는 기능에는 프리뷰 기능 또는 인큐베이터 모듈이라는 형태가 사용되기 시작했다.

2018년 공개된 Java 11은 이 체계에서 제공된 첫 장기 지원 릴리스였다.[12] Java 11에서는 Java EE와 CORBA 관련 모듈, Java Web Start와 브라우저 플러그인 등 일부 오래된 구성 요소가 JDK에서 제거되었다. 브라우저가 플러그인 실행 모델을 중단하면서 Java 애플릿도 사실상 역사적인 기술로 전환되었고, Java의 중심 활용 분야는 서버, 클라우드, 업무 시스템과 독립 실행형 응용 프로그램으로 이동했다.

현대 Java

정기 릴리스 체계 아래에서 Java는 기존 호환성을 유지하면서도 언어와 JVM 기능을 단계적으로 확장하고 있다. Java 14부터 16까지는 record가 프리뷰를 거쳐 정식 기능이 되었고, Java 15부터 17까지는 봉인 클래스가 도입되었다. switch 표현식과 instanceof 패턴 매칭도 여러 릴리스에 걸쳐 정식 기능으로 완성되었다.

2021년 공개된 Java 17은 장기 지원 릴리스로 자리 잡았으며, 모듈 체계 이후 축적된 언어 기능과 JVM 개선을 안정적인 기준으로 제공했다. 2023년의 Java 21은 레코드 패턴과 switch 패턴 매칭을 정식으로 포함했으며, 기존 운영체제 스레드보다 많은 동시 작업을 비교적 단순한 코드로 처리할 수 있도록 하는 가상 스레드를 도입했다.

2025년 공개된 Java 25는 장기 지원 릴리스로 지정되었으며, Java 26은 2026년 3월 공개된 후속 기능 릴리스다. 현재 OpenJDK의 JDK 프로젝트는 Java SE 참조 구현을 공개적으로 개발하면서 6개월 주기의 기능 릴리스를 계속 제공하고 있다.[13]

현대 Java의 발전은 단일 버전에서 대규모 기능을 한꺼번에 도입하기보다, 언어 문법과 JVM, 가비지 컬렉터, 네이티브 연동, 동시성 모델을 여러 릴리스에 걸쳐 시험하고 완성하는 방식으로 이루어진다. Java는 초기의 소비자 전자 기기용 언어에서 웹 애플릿 플랫폼으로, 다시 기업용 서버와 범용 가상 머신 생태계로 중심 영역을 옮겨 왔으며, 오늘날에는 Java 언어뿐 아니라 다양한 JVM 언어와 도구가 공유하는 실행 기반으로 발전했다.

언어 설계와 실행 모델

Java의 실행 모델은 Java 언어, 클래스 파일 형식, Java 가상 머신이라는 서로 구분된 계층으로 구성된다. Java 언어 명세는 소스 코드의 문법과 타입 검사, 이름 해석, 표현식 평가와 프로그램 동작을 정의하고, JVM 명세는 컴파일된 프로그램을 표현하는 클래스 파일 형식과 명령어 집합, 메모리 영역, 클래스 생명 주기와 실행 규칙을 정의한다.

따라서 Java 언어와 JVM은 동일한 개념이 아니다. Java 컴파일러는 Java 소스 코드를 JVM이 처리할 수 있는 형식으로 변환하지만, JVM은 Java 문법 자체를 직접 실행하지 않는다. 반대로 JVM이 실행하는 프로그램도 반드시 Java로 작성될 필요는 없으며, JVM 클래스 파일 형식을 생성할 수 있는 Kotlin, Scala, Clojure 등의 언어도 같은 실행 환경을 사용할 수 있다.

소스 코드와 컴파일 단위

Java 소스 프로그램은 하나 이상의 컴파일 단위로 구성된다. 일반적인 컴파일 단위는 선택적인 패키지 선언과 임포트 선언, 하나 이상의 최상위 클래스 또는 인터페이스 선언을 포함한다. 패키지는 관련된 타입을 계층적인 이름 공간으로 묶으며, 모듈은 여러 패키지와 그 의존 관계를 더 큰 배포 단위로 구성한다.[14]

Java 소스 파일은 일반적으로 .java 확장자를 사용한다. public으로 선언된 최상위 클래스나 인터페이스가 있는 경우, 일반적인 파일 기반 구현에서는 파일 이름을 해당 타입의 단순 이름과 일치시킨다.

package example.application;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, Java");
    }
}

위 소스에서 example.application은 패키지 이름이고, Main은 최상위 클래스다. 전통적인 Java 애플리케이션은 JVM이 지정된 클래스의 public static void main(String[] args) 메서드를 호출하면서 시작한다. 최신 Java는 더 작은 프로그램을 위해 단순화된 소스 파일과 인스턴스 main 메서드도 지원하지만, 클래스와 정적 main 메서드를 사용하는 형식은 기존 프로그램과 실행 도구에서 계속 사용되는 기본 구조다.

Java 컴파일러는 소스 코드를 처리하면서 구문 분석, 이름 해석, 타입 검사와 접근 제어 검사를 수행한다. 지역 변수가 사용되기 전에 반드시 값이 할당되었는지를 검사하는 확정 할당, 검사 예외의 처리 여부, 제네릭 타입의 적합성 등도 컴파일 단계에서 확인된다. 이러한 정적 검사를 통과한 프로그램만 정상적인 클래스 파일로 변환된다.

바이트코드와 클래스 파일

javac과 같은 Java 컴파일러는 .java 소스 파일을 JVM의 클래스 파일 형식으로 변환한다. 클래스 파일은 보통 .class 확장자로 저장되며, JVM 명령어로 이루어진 바이트코드와 타입·필드·메서드·상수·부가 정보를 포함한다.[15]

하나의 클래스 파일은 기본적으로 하나의 클래스 또는 인터페이스 정의를 나타낸다. 다만 JVM 명세에서 클래스 파일은 반드시 디스크에 존재하는 실제 파일만을 의미하지 않는다. 네트워크에서 전달되거나 실행 중 생성된 바이트 배열처럼, 클래스 로더가 JVM에 제공할 수 있는 유효한 바이너리 표현도 같은 클래스 파일 형식으로 취급된다.[16]

클래스 파일에는 다음과 같은 정보가 저장된다.

  • 클래스 파일 형식의 버전
  • 현재 클래스와 상위 클래스 및 구현 인터페이스
  • 필드와 메서드의 이름 및 디스크립터
  • 접근 제한자와 기타 속성
  • JVM 바이트코드 명령어
  • 예외 처리 영역
  • 디버깅과 애너테이션 정보
  • 심벌 참조를 보관하는 상수 풀

클래스 파일의 상수 풀에는 문자열과 숫자 상수뿐 아니라 클래스, 필드와 메서드를 참조하기 위한 심벌 정보가 포함된다. 컴파일러는 다른 클래스의 실제 메모리 주소를 기록하지 않고 이름과 타입을 기반으로 한 심벌 참조를 남긴다. JVM은 프로그램을 로딩하고 링킹하는 과정에서 이러한 참조를 현재 실행 환경의 클래스와 멤버에 연결한다.

Java 바이트코드는 특정 CPU의 범용 레지스터를 직접 대상으로 하는 기계어가 아니다. JVM은 각 메서드의 지역 변수 배열과 피연산자 스택을 중심으로 명령을 실행하는 스택 기반 명령어 집합을 정의한다. 예를 들어 값을 지역 변수에서 스택으로 올리고, 연산 명령이 스택의 값을 소비한 뒤 결과를 다시 스택에 넣는 방식으로 계산이 진행된다.

다음과 같은 Java 메서드는 컴파일 후 여러 JVM 명령으로 표현된다.

static int add(int left, int right) {
    return left + right;
}

개념적으로는 두 매개변수를 피연산자 스택에 적재하고, 정수 덧셈을 수행한 다음 결과를 반환하는 명령 흐름으로 변환된다.

iload_0
iload_1
iadd
ireturn

실제 클래스 파일에는 명령어뿐 아니라 각 메서드의 최대 피연산자 스택 크기, 지역 변수 슬롯 수, 예외 처리 표와 검증에 필요한 타입 정보도 함께 저장될 수 있다.

JVM의 추상 구조

JVM은 특정 구현체의 내부 구조가 아니라 Java 프로그램을 실행하기 위한 추상 기계를 정의한다. JVM 명세는 프로그램에서 관찰할 수 있는 동작과 클래스 파일 형식, 명령어 의미를 규정하지만, 객체를 실제 메모리에 어떻게 배치하는지나 가비지 컬렉터와 JIT 컴파일러를 어떤 알고리즘으로 구현할지는 대부분 구현체에 맡긴다.

JVM의 주요 실행 시점 데이터 영역은 다음과 같다.[17]

  • : 클래스 인스턴스와 배열이 할당되는 공유 메모리 영역
  • 메서드 영역: 타입 정보와 메서드 코드, 런타임 상수 풀 등의 클래스 수준 정보를 저장하는 논리적 영역
  • JVM 스택: 스레드마다 생성되며 메서드 호출 프레임을 저장하는 영역
  • PC 레지스터: 각 스레드에서 현재 실행 중인 JVM 명령의 위치를 나타내는 영역
  • 네이티브 메서드 스택: 네이티브 메서드 실행을 지원하기 위한 구현별 영역
  • 런타임 상수 풀: 클래스 파일의 상수 풀을 실행 시점 표현으로 변환한 영역

각 Java 스레드는 독립된 JVM 스택을 가진다. 메서드가 호출되면 해당 스택에 새로운 프레임이 생성되고, 메서드가 정상적으로 반환하거나 예외로 종료되면 프레임이 제거된다.

프레임에는 일반적으로 다음 요소가 들어간다.

  • 메서드 매개변수와 지역 변수를 저장하는 지역 변수 배열
  • 계산 중간값을 저장하는 피연산자 스택
  • 현재 클래스의 런타임 상수 풀에 대한 참조
  • 메서드의 정상 반환과 예외 전파를 처리하기 위한 정보

인스턴스 메서드에서는 지역 변수 배열의 첫 번째 슬롯이 일반적으로 현재 객체를 가리키는 this 참조에 사용된다. 그 뒤에 메서드 매개변수와 지역 변수가 저장된다. 정적 메서드는 특정 인스턴스에 속하지 않으므로 this 슬롯을 사용하지 않는다.

클래스 로딩

JVM은 프로그램에 포함된 모든 클래스를 시작할 때 한꺼번에 메모리에 올릴 필요가 없다. 클래스와 인터페이스는 일반적으로 실제로 필요해질 때 동적으로 로딩된다.

클래스 로딩은 특정 바이너리 이름을 가진 클래스나 인터페이스의 바이너리 표현을 찾고, 그 표현으로부터 JVM 내부의 클래스 또는 인터페이스를 생성하는 과정이다. JVM은 부트스트랩 클래스 로더와 플랫폼 클래스 로더, 애플리케이션 클래스 로더 및 사용자가 정의한 클래스 로더를 통해 클래스를 가져올 수 있다.[18]

클래스 로더는 파일 시스템 외에도 다음과 같은 위치에서 클래스를 제공할 수 있다.

  • JAR 파일과 모듈 이미지
  • 네트워크 응답
  • 데이터베이스나 암호화된 저장소
  • 실행 중 생성한 바이트코드
  • 다른 프로그램 형식에서 변환한 클래스 정의

JVM에서 클래스의 실행 시점 정체성은 바이너리 이름만으로 결정되지 않는다. 같은 이름의 클래스라도 서로 다른 클래스 로더가 정의했다면 JVM에서는 서로 다른 타입으로 취급될 수 있다. 이 구조는 애플리케이션 서버와 플러그인 시스템, 모듈 격리, 동적 코드 교체와 같은 환경에서 독립적인 클래스 이름 공간을 구성하는 데 사용된다.

배열 클래스는 일반 클래스와 달리 클래스 로더가 클래스 파일에서 직접 정의하지 않는다. JVM이 필요할 때 배열 클래스를 생성하며, 배열 요소 타입의 클래스 로더와 접근 규칙을 기반으로 해당 배열 타입을 구성한다.

링킹

로드된 클래스는 실행에 사용되기 전에 링킹 과정을 거친다. 링킹은 클래스를 JVM의 실행 시점 상태에 결합하는 과정이며, 검증·준비·해결 단계로 구성된다.[19]

검증

검증 단계에서는 클래스 파일이 구조적으로 올바르고 JVM의 타입 및 실행 규칙을 위반하지 않는지 확인한다. JVM은 파일 형식과 명령어 배열, 분기 대상, 지역 변수와 피연산자 스택의 타입 흐름 등을 검사한다.

검증기는 다음과 같은 조건을 확인할 수 있다.

  • 클래스 파일의 구조와 버전이 유효한가
  • 존재하지 않는 명령어나 잘못된 피연산자가 사용되지 않았는가
  • 분기 명령이 유효한 명령 위치로 이동하는가
  • 피연산자 스택이 넘치거나 부족해지지 않는가
  • 각 명령에 전달되는 값의 타입이 적합한가
  • 초기화되지 않은 객체 참조가 잘못 사용되지 않는가
  • final 클래스와 메서드 등의 제약을 위반하지 않는가

이 검증을 통해 JVM은 이후 바이트코드를 실행할 때 모든 명령마다 동일한 타입 안전성 검사를 반복하지 않아도 된다. 다만 검증은 프로그램의 업무 논리나 모든 실행 시점 오류를 보장하는 과정은 아니며, 배열 경계 검사와 널 참조 검사, 실제 타입 변환 검사 등은 실행 중에도 수행된다.

준비

준비 단계에서는 클래스의 정적 필드를 위한 저장 공간을 만들고 JVM이 정한 기본값으로 초기화한다. 숫자 타입은 0, booleanfalse, 참조 타입은 null에 해당하는 값으로 준비된다.

class Counter {
    static int count = 10;
}

이 필드는 준비 단계에서 먼저 0으로 설정된다. 소스 코드에 작성된 10을 실제로 대입하는 작업은 일반적으로 이후 클래스 초기화 단계에서 수행된다. 다만 컴파일 시간 상수로 처리되는 일부 static final 필드는 클래스 파일의 상수 속성을 통해 준비 단계에서 지정된 값으로 설정될 수 있다.

해결

해결은 런타임 상수 풀에 저장된 클래스, 인터페이스, 필드와 메서드의 심벌 참조를 구체적인 실행 시점 참조로 바꾸는 과정이다.

해결은 링킹 과정에서 미리 수행될 수도 있고, 특정 참조가 실제로 처음 사용될 때까지 지연될 수도 있다. 이러한 지연 해결은 시작 시점에 필요한 작업을 줄이고, 사용되지 않는 클래스와 멤버를 불필요하게 처리하지 않게 한다.

심벌 참조를 해결하는 과정에서는 대상 타입이나 멤버가 존재하는지, 접근 가능한지, 필드와 메서드의 종류 및 디스크립터가 일치하는지 확인한다. 조건을 만족하지 않으면 NoClassDefFoundError, NoSuchMethodError, NoSuchFieldError, IllegalAccessError와 같은 링킹 오류가 발생할 수 있다.

클래스 초기화

링킹을 마친 클래스나 인터페이스는 특정한 적극적 사용이 발생할 때 초기화된다. 클래스 초기화는 정적 필드 초기화식과 static 초기화 블록을 소스 코드에 나타난 순서대로 실행하는 과정이다.

class RuntimeState {
    static int count = loadInitialCount();

    static {
        System.out.println("RuntimeState initialized");
    }

    private static int loadInitialCount() {
        return 100;
    }
}

컴파일러는 이러한 정적 초기화 작업을 JVM이 인식하는 특수한 클래스 초기화 메서드인 <clinit>에 해당하는 형태로 구성한다. <clinit>은 개발자가 직접 호출하는 일반 Java 메서드가 아니라 JVM이 필요한 시점에 실행하는 메서드다.

클래스 초기화를 유발하는 대표적인 동작은 다음과 같다.

  • 클래스의 인스턴스를 처음 생성하는 경우
  • 클래스에 선언된 정적 메서드를 처음 호출하는 경우
  • 컴파일 시간 상수가 아닌 정적 필드를 처음 읽거나 쓰는 경우
  • 특정 리플렉션 API를 통해 클래스를 적극적으로 사용하는 경우
  • JVM이 애플리케이션의 시작 클래스를 초기화하는 경우

클래스를 초기화하기 전에 해당 클래스의 상위 클래스가 먼저 초기화된다. JVM은 여러 스레드가 같은 클래스를 동시에 초기화하려 할 때 초기화 절차가 한 번만 수행되도록 동기화한다. 초기화 도중 예외가 발생하면 해당 클래스는 오류 상태가 될 수 있으며, 이후 사용 시 NoClassDefFoundError 등이 발생할 수 있다.

클래스가 단순히 로드되거나 타입 선언이 참조되었다고 해서 항상 즉시 초기화되는 것은 아니다. 예를 들어 컴파일 시점에 값이 결정되는 static final 상수를 읽는 코드는 해당 값을 사용하는 클래스 파일에 상수가 직접 포함될 수 있으므로, 상수를 선언한 클래스의 초기화를 일으키지 않을 수 있다.

객체 생성과 메서드 호출

new 표현식으로 객체를 생성하면 JVM은 대상 클래스가 로딩·링킹·초기화되었는지 확인하고, 힙에서 객체를 위한 공간을 확보한다. 새 객체의 인스턴스 필드는 먼저 기본값으로 설정된 뒤, 상위 클래스부터 생성자 실행이 진행된다.

생성자 호출은 일반 메서드 호출과 비슷해 보이지만 JVM에서는 <init>이라는 특수한 인스턴스 초기화 메서드로 표현된다. 객체는 메모리 할당 직후부터 완전히 초기화된 것으로 간주되지 않으며, 바이트코드 검증기는 초기화되지 않은 객체 참조가 생성자 호출 전 잘못 사용되지 않도록 제한한다.

Java의 메서드 호출은 대상에 따라 서로 다른 JVM 명령으로 표현될 수 있다.

  • invokestatic: 정적 메서드 호출
  • invokevirtual: 일반적인 인스턴스 메서드의 동적 디스패치
  • invokeinterface: 인터페이스를 통한 메서드 호출
  • invokespecial: 생성자, 비공개 메서드와 특정 상위 클래스 메서드 호출
  • invokedynamic: 실행 시점에 호출 대상을 연결하는 동적 호출 지점

가상 메서드 호출에서는 참조 변수의 선언 타입만으로 최종 메서드가 결정되지 않는다. JVM은 실제 객체의 클래스를 기준으로 오버라이딩된 메서드를 선택한다. 이 동적 디스패치가 Java의 런타임 다형성을 구성한다.

invokedynamic은 처음부터 고정된 클래스의 메서드만 호출하는 방식과 달리, 부트스트랩 메서드가 실행 시점에 호출 지점의 동작을 연결할 수 있도록 한다. Java 컴파일러는 람다 표현식과 문자열 연결 등을 구현할 때 이 명령을 활용할 수 있으며, 다른 JVM 언어도 동적 언어 기능을 구현하는 데 사용할 수 있다.

예외와 제어 흐름

Java의 예외는 JVM 수준에서 일반적인 분기 반환과 구분되는 비정상 제어 흐름으로 처리된다. 메서드 실행 중 예외 객체가 던져지면 JVM은 현재 명령 위치를 포함하는 적절한 예외 처리기를 찾는다.

현재 메서드의 예외 처리 표에서 호환되는 처리기를 찾지 못하면 현재 프레임이 제거되고 호출한 메서드로 예외가 전달된다. 이 과정은 적절한 처리기를 찾을 때까지 호출 스택을 거슬러 올라간다. 끝까지 처리되지 않은 예외는 해당 스레드를 종료하며, 애플리케이션의 실행 결과는 남아 있는 비데몬 스레드와 실행 환경에 따라 결정된다.

소스 수준의 try, catch, finallytry-with-resources는 컴파일러가 JVM의 예외 처리 표와 분기 명령, 필요에 따라 추가적인 정리 코드로 변환한다. 검사 예외와 비검사 예외의 구분은 주로 Java 언어의 컴파일 시간 규칙이며, JVM은 두 종류를 별개의 예외 실행 메커니즘으로 처리하지 않는다.

인터프리터와 JIT 컴파일

JVM 명세는 바이트코드 명령의 의미와 프로그램에서 관찰되는 동작을 규정하지만, 모든 명령을 어떤 방식으로 실행해야 하는지는 강제하지 않는다. JVM 구현체는 바이트코드를 순차적으로 해석하거나, 실행 전에 또는 실행 중 네이티브 기계어로 컴파일할 수 있다.

대표적인 OpenJDK JVM 구현체인 HotSpot은 일반적으로 프로그램 실행을 빠르게 시작한 뒤, 실행 중 수집한 프로파일 정보를 바탕으로 자주 실행되는 메서드와 반복 구간을 네이티브 코드로 컴파일한다. 이 방식을 JIT 컴파일이라고 한다.

초기 실행에서는 인터프리터나 비교적 빠른 저단계 컴파일러가 코드를 처리할 수 있다. 실행 횟수와 분기 경향, 실제로 사용되는 객체 타입 등의 정보가 충분히 쌓이면 최적화 컴파일러가 해당 코드를 더 적극적으로 최적화한다. HotSpot은 이러한 여러 실행 단계를 조합하는 계층형 컴파일을 지원한다.[20]

JIT 컴파일러는 실행 중 확인한 조건을 바탕으로 다음과 같은 최적화를 적용할 수 있다.

  • 짧은 메서드의 인라인화
  • 가상 메서드 호출의 직접 호출 전환
  • 중복 계산과 도달할 수 없는 코드 제거
  • 루프 최적화
  • 범위 검사 제거
  • 객체 할당과 잠금의 일부 제거
  • CPU별 명령어와 레지스터 활용
  • 탈출 분석을 이용한 객체 수명 최적화

이러한 최적화는 현재까지 관찰된 타입이나 실행 경로가 계속 유지된다는 가정 아래 적용될 수 있다. JVM은 이후 새로운 클래스가 로딩되거나 가정과 다른 실행 상황이 나타나면 최적화된 네이티브 코드를 무효화하고, 실행 상태를 다시 인터프리터나 덜 최적화된 코드로 되돌릴 수 있다. 이를 역최적화라고 한다.

이 구조 덕분에 JVM은 프로그램의 실제 실행 특성을 알 수 없는 정적 컴파일 시점보다 더 구체적인 정보를 사용해 최적화할 수 있다. 반면 프로그램 시작 직후에는 클래스 로딩과 프로파일 수집, JIT 컴파일이 진행되므로 장시간 실행 후의 최고 성능에 도달하기까지 준비 시간이 필요할 수 있다.

네이티브 코드와 사전 컴파일

Java 프로그램의 일반적인 배포 단위는 플랫폼 중립적인 클래스 파일이지만, 모든 실행이 항상 인터프리터와 JIT 컴파일만으로 이루어지는 것은 아니다. JVM과 관련 도구는 사용 목적에 따라 클래스 데이터와 실행 상태를 미리 준비하거나 일부 코드를 네이티브 형식으로 변환할 수 있다.

GraalVM Native Image와 같은 네이티브 이미지 도구는 애플리케이션과 필요한 라이브러리를 분석하여 특정 운영체제와 CPU를 대상으로 하는 실행 파일을 만들 수 있다. 이러한 방식은 JVM을 시작하고 코드를 단계적으로 최적화하는 전통적인 실행 방식과 달리 시작 시간과 메모리 사용량을 줄이는 데 초점을 둔다.

다만 네이티브 이미지에는 특정 플랫폼을 대상으로 다시 빌드해야 한다는 특성이 있으며, 실행 중 임의의 클래스를 로딩하거나 리플렉션으로 프로그램 구조를 탐색하는 기능은 빌드 시점 분석과 추가 설정의 영향을 받을 수 있다. 따라서 Java의 표준 클래스 파일 실행 모델과 네이티브 실행 파일 모델은 배포 환경과 성능 목표에 따라 선택되는 서로 다른 실행 경로다.

이식성과 플랫폼 의존성

Java의 이식성은 같은 소스 코드가 모든 환경에서 아무 조건 없이 동일하게 동작한다는 의미보다, 표준화된 클래스 파일과 JVM 동작 및 Java SE API를 기준으로 프로그램과 실행 환경 사이의 계약을 제공한다는 의미에 가깝다.

Java 컴파일러가 생성한 클래스 파일은 해당 클래스 파일 버전과 기능을 지원하는 JVM에서 실행할 수 있다. 일반적으로 새로운 JDK로 컴파일한 클래스 파일은 더 높은 클래스 파일 버전을 사용하므로 오래된 JVM에서 직접 실행되지 않는다. 필요한 경우 컴파일러의 --release 옵션을 사용하여 이전 Java SE 릴리스의 언어 기능, 클래스 파일 형식과 공개 API를 대상으로 컴파일할 수 있다.

실제 프로그램의 이식성은 다음 요소의 영향을 받는다.

  • 사용하는 Java SE 버전과 클래스 파일 버전
  • JVM 구현체가 지원하는 운영체제와 CPU
  • 파일 경로와 문자 인코딩 및 로케일
  • 네이티브 라이브러리와 JNI 사용 여부
  • 운영체제별 프로세스와 네트워크 기능
  • 그래픽 환경과 장치 접근
  • 구현체별 명령행 옵션과 비표준 API
  • 시간대 데이터와 보안 정책 및 외부 환경 설정

Java 프로그램이 JNI나 특정 운영체제 명령, 플랫폼 전용 파일 구조에 의존하면 해당 부분은 환경별 구현이나 배포 파일을 필요로 한다. 반대로 표준 Java API와 명세에서 보장하는 동작을 중심으로 구성하면 동일한 클래스 파일을 여러 JVM 구현체와 운영체제에서 실행할 수 있다.

Java의 실행 모델은 소스 언어를 특정 CPU에서 분리하면서도, JVM 구현체가 현재 시스템에 맞는 메모리 관리와 기계어 최적화를 수행할 수 있게 한다. 이 분리는 Java의 이식성을 형성하는 동시에, JVM을 Java 이외의 여러 언어가 공유할 수 있는 범용 실행 플랫폼으로 발전시킨 핵심 구조다.

타입 체계와 객체 모델

Java는 모든 변수와 표현식의 타입이 컴파일 시점에 결정되는 정적 타입 언어다. 타입은 변수가 가질 수 있는 값의 범위와 사용할 수 있는 연산을 제한하며, 메서드 호출과 대입, 변환이 유효한지를 컴파일러가 검사한다. Java는 타입이 허용하는 연산과 값의 종류를 엄격하게 구분하므로 일반적으로 강한 타입 언어로 분류된다.[21]

Java의 타입은 크게 기본 타입참조 타입으로 나뉜다.

  • 기본 타입은 논리값과 숫자 값을 직접 나타낸다.
  • 참조 타입의 값은 객체나 배열을 가리키는 참조 또는 null이다.
  • 클래스와 인터페스, 배열, 타입 변수는 참조 타입을 구성한다.
  • 객체의 실제 클래스는 실행 시점에 결정되지만, 표현식과 변수에는 컴파일 시점 타입이 존재한다.

Java의 객체 모델은 클래스 기반이다. 클래스는 객체의 필드와 메서드, 초기화 과정 및 상속 관계를 정의한다. 인터페이스는 구현체가 제공해야 하는 동작의 계약을 표현하며, 클래스와 분리된 다중 타입 계층을 구성한다. 모든 클래스는 직접 또는 간접적으로 java.lang.Object를 상속하지만, 기본 타입은 객체가 아니며 Object의 하위 타입도 아니다.

기본 타입

Java는 다음 여덟 개의 기본 타입을 정의한다.

분류타입크기 또는 값의 종류
논리 타입booleantrue, false
정수 타입byte8비트 부호 있는 정수
정수 타입short16비트 부호 있는 정수
정수 타입int32비트 부호 있는 정수
정수 타입long64비트 부호 있는 정수
정수 타입char16비트 부호 없는 UTF-16 코드 단위
부동소수점 타입float32비트 IEEE 754 부동소수점 수
부동소수점 타입double64비트 IEEE 754 부동소수점 수

Java의 정수 타입 크기와 표현 범위는 플랫폼에 따라 달라지지 않는다. 예를 들어 int는 모든 Java 구현에서 32비트 부호 있는 정수이며, C나 C++처럼 대상 시스템의 데이터 모델에 따라 크기가 달라지지 않는다.

boolean active = true;
byte level = 10;
short port = 8080;
int count = 1_000_000;
long distance = 9_000_000_000L;
char initial = 'J';
float ratio = 0.5F;
double temperature = 21.75;

boolean은 정수 타입과 서로 변환되지 않는다. 조건식에는 boolean 값만 사용할 수 있으며, 01을 거짓과 참으로 취급하지 않는다.

char는 하나의 Unicode 문자를 일반적인 의미에서 완전히 표현하는 타입이 아니라 하나의 UTF-16 코드 단위를 저장한다. 기본 다국어 평면 밖의 문자는 서로 이어지는 두 개의 char 값인 서로게이트 쌍으로 표현될 수 있다. 전체 Unicode 코드 포인트를 처리할 때는 intCharacter의 코드 포인트 API를 사용할 수 있다.

기본 타입은 객체가 아니므로 직접 메서드를 가지지 않는다. 다만 각 기본 타입에는 대응하는 래퍼 클래스가 있다.

기본 타입래퍼 클래스
booleanBoolean
byteByte
shortShort
intInteger
longLong
charCharacter
floatFloat
doubleDouble

컴파일러는 필요한 문맥에서 기본 타입 값을 래퍼 객체로 변환하는 박싱과 래퍼 객체를 기본 타입 값으로 변환하는 언박싱을 자동으로 삽입할 수 있다.

Integer boxed = 42;  // int 값을 Integer 객체로 박싱
int value = boxed;   // Integer 객체를 int 값으로 언박싱

자동 박싱은 문법을 단순화하지만 기본 타입과 객체의 차이를 없애지는 않는다. 래퍼 참조는 null일 수 있으며, 이를 자동 언박싱하면 NullPointerException이 발생한다. 래퍼 객체에 ==를 사용하면 숫자 값이 아니라 객체 참조의 동일성을 비교할 수 있으므로 값 비교에는 일반적으로 equals를 사용한다.

참조 타입과 객체 참조

참조 타입에는 클래스 타입, 인터페이스 타입, 배열 타입과 타입 변수가 포함된다. 참조 타입 변수는 객체 자체를 변수 안에 직접 포함하기보다 객체나 배열을 가리키는 참조 값을 가진다.

String first = new String("Java");
String second = first;
String empty = null;

위 코드에서 firstsecond는 같은 String 객체를 가리킨다. second에 다른 값을 대입해도 first 자체가 변경되는 것은 아니다. 참조를 메서드에 전달할 때도 참조 값이 복사되므로 Java의 인수 전달 방식은 기본 타입과 참조 타입 모두 값에 의한 전달이다.

static void replace(StringBuilder value) {
    value = new StringBuilder("new");
}

static void append(StringBuilder value) {
    value.append(" updated");
}

replace는 매개변수에 저장된 참조 사본만 바꾸므로 호출자의 변수가 가리키는 객체를 교체하지 않는다. append는 복사된 참조를 통해 원래 객체의 상태를 변경하므로 호출자에서도 변화가 관찰된다.

참조 타입에는 특별한 널 타입의 값인 null을 대입할 수 있다. null은 어떤 객체도 가리키지 않으며 클래스나 인터페이스의 인스턴스가 아니다. null 참조를 통해 인스턴스 필드나 메서드에 접근하면 NullPointerException이 발생한다.

Java의 타입 체계는 일반 참조 타입에서 널 가능성을 타입 인수로 직접 구분하지 않는다. 따라서 String 타입 변수에는 String 객체와 null이 모두 들어갈 수 있다. 애너테이션과 정적 분석 도구를 이용하여 널 가능성을 별도로 표현할 수 있지만, 이는 전통적인 Java 타입 체계의 내장 구분과는 다르다.

클래스와 객체

클래스는 필드, 메서드, 생성자, 초기화 블록과 중첩 타입을 선언할 수 있는 객체 설계 단위다. 클래스의 인스턴스는 해당 클래스와 상위 클래스에서 정의된 인스턴스 상태를 가지며, 객체의 실제 동작은 실행 시점 클래스와 메서드 오버라이딩 관계에 따라 결정된다.[22]

class Account {
    private final String owner;
    private long balance;

    Account(String owner, long balance) {
        this.owner = owner;
        this.balance = balance;
    }

    String owner() {
        return owner;
    }

    long balance() {
        return balance;
    }

    void deposit(long amount) {
        balance += amount;
    }
}

ownerbalance는 각 객체에 속하는 인스턴스 필드다. 서로 다른 Account 인스턴스는 같은 클래스 정의를 공유하지만 각자 독립적인 필드 값을 가진다.

클래스 멤버에 static을 붙이면 특정 객체가 아니라 클래스 자체에 속한다.

class IdGenerator {
    private static long nextId;

    static long next() {
        return ++nextId;
    }
}

정적 메서드는 현재 객체를 나타내는 this 참조를 가지지 않으며 인스턴스 없이 호출할 수 있다. 정적 멤버는 클래스 로더와 클래스 정의 단위로 존재하므로, 같은 이름의 클래스를 서로 다른 클래스 로더가 각각 정의하면 정적 상태도 분리될 수 있다.

생성자와 객체 초기화

생성자는 새 객체의 초기 상태를 구성하는 특별한 선언이다. 생성자는 클래스와 같은 이름을 가지며 반환 타입을 선언하지 않는다.

class Point {
    private final int x;
    private final int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

객체 생성 과정에서는 메모리가 할당된 뒤 모든 인스턴스 필드가 기본값으로 설정된다. 이후 상위 클래스 생성자가 먼저 실행되고, 현재 클래스의 필드 초기화식과 인스턴스 초기화 블록 및 생성자 본문이 정해진 순서로 실행된다.

생성자의 첫 문장은 같은 클래스의 다른 생성자를 호출하는 this(...) 또는 직접 상위 클래스 생성자를 호출하는 super(...)가 될 수 있다. 명시하지 않으면 컴파일러는 가능한 경우 인수가 없는 super() 호출을 삽입한다.

class Rectangle {
    private final int width;
    private final int height;

    Rectangle() {
        this(1, 1);
    }

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
}

생성자는 상속되지 않으며 일반 메서드처럼 오버라이딩되지 않는다. 같은 클래스 안에서 매개변수 목록이 다른 여러 생성자를 선언하는 생성자 오버로딩은 가능하다.

상속

Java 클래스는 extends를 사용하여 하나의 직접 상위 클래스만 지정할 수 있다. 상위 클래스를 명시하지 않은 일반 클래스는 암시적으로 Object를 직접 상속한다. Object 자체만 상위 클래스가 없다.

class Animal {
    void speak() {
        System.out.println("sound");
    }
}

class Dog extends Animal {
    @Override
    void speak() {
        System.out.println("bark");
    }
}

클래스 단일 상속은 객체의 구현 상태와 클래스 계층이 여러 경로로 중복되는 문제를 피한다. 다만 하나의 클래스는 여러 인터페이스를 구현할 수 있으므로 동작 계약과 타입 관계는 다중으로 구성할 수 있다.

상위 클래스의 접근 가능한 필드와 메서드는 하위 클래스에 상속된다. 생성자와 비공개 멤버는 직접 상속되지 않는다. private 멤버는 상위 클래스 객체 상태의 일부로 존재할 수 있지만 하위 클래스 코드에서 이름으로 직접 접근할 수 없다.

final 클래스는 상속할 수 없으며, final 메서드는 하위 클래스에서 오버라이딩할 수 없다.

final class ImmutableToken {
}

// class DerivedToken extends ImmutableToken {}  // 컴파일 오류

Java의 클래스 상속은 명목적이다. 두 클래스가 우연히 같은 필드와 메서드 구조를 가지더라도 명시적인 상속이나 인터페이스 구현 관계가 없다면 서로 대입 가능한 타입으로 취급되지 않는다.

메서드 오버로딩과 오버라이딩

오버로딩은 같은 이름을 가지지만 매개변수 타입이나 개수가 다른 여러 메서드를 선언하는 기능이다. 어떤 오버로드를 호출할지는 컴파일 시점의 인수 타입을 기준으로 결정된다.

class Printer {
    void print(int value) {
        System.out.println("int: " + value);
    }

    void print(String value) {
        System.out.println("String: " + value);
    }
}

반환 타입만 다른 메서드는 오버로딩할 수 없다. 호출 문맥만으로 반환 타입을 항상 구분할 수 없기 때문이다.

오버라이딩은 하위 클래스가 상위 클래스에서 상속한 인스턴스 메서드의 구현을 교체하는 기능이다. 호출할 실제 메서드는 실행 시점 객체의 클래스에 따라 선택된다.

Animal animal = new Dog();
animal.speak();  // Dog.speak 호출

변수 animal의 컴파일 시점 타입은 Animal이지만 실제 객체의 클래스는 Dog다. 컴파일러는 Animal 타입에 speak 메서드가 존재하는지 검사하고, JVM은 실행 시점에 Dog가 오버라이딩한 구현을 선택한다.

정적 메서드는 객체에 대한 동적 디스패치 대상이 아니다. 하위 클래스가 상위 클래스와 같은 서명의 정적 메서드를 선언하면 오버라이딩이 아니라 이름 숨김이 발생하며, 호출 대상은 참조 표현식의 컴파일 시점 타입을 기준으로 결정된다.

필드도 다형적으로 오버라이딩되지 않는다. 하위 클래스가 같은 이름의 필드를 선언하면 상위 필드를 숨기지만 두 필드는 별도로 존재할 수 있다.

추상 클래스

abstract 클래스는 직접 인스턴스를 생성할 수 없는 클래스다. 일부 동작을 구현하면서 나머지 동작을 추상 메서드로 남겨 하위 클래스가 완성하도록 할 수 있다.

abstract class Shape {
    abstract double area();

    void printArea() {
        System.out.println(area());
    }
}

class Circle extends Shape {
    private final double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * radius * radius;
    }
}

추상 클래스는 인스턴스 필드와 생성자, 구체 메서드, 정적 멤버를 모두 가질 수 있다. 따라서 공통 상태와 구현을 공유해야 하는 클래스 계층에 사용할 수 있다.

추상 클래스의 구체적인 하위 클래스는 상속받은 모든 추상 메서드를 구현해야 한다. 구현하지 않으면 해당 하위 클래스도 abstract로 선언해야 한다.

인터페이스

인터페이스는 클래스와 별도의 참조 타입이며, 클래스가 제공하는 동작의 계약을 표현한다. 클래스는 여러 인터페이스를 구현할 수 있고, 하나의 인터페이스도 여러 상위 인터페이스를 확장할 수 있다.[23]

interface Drawable {
    void draw();
}

interface Transformable {
    void move(int x, int y);
}

class Sprite implements Drawable, Transformable {
    @Override
    public void draw() {
        System.out.println("draw sprite");
    }

    @Override
    public void move(int x, int y) {
        System.out.println("move to " + x + ", " + y);
    }
}

인터페이스의 인스턴스 메서드는 선언 형태에 따라 추상 메서드, 기본 메서드, 정적 메서드 또는 비공개 메서드가 될 수 있다.

  • 추상 메서드는 구현 클래스가 제공할 동작을 선언한다.
  • default 메서드는 인터페이스가 기본 구현을 제공한다.
  • static 메서드는 인터페이스 자체에 속한다.
  • private 메서드는 인터페이스 내부 구현을 공유하는 데 사용한다.

인터페이스에 선언된 필드는 암시적으로 public static final이며 객체별 상태를 저장하는 인스턴스 필드가 아니다.

interface Named {
    String name();

    default String displayName() {
        return "[" + name() + "]";
    }
}

클래스가 여러 인터페이스에서 같은 서명의 기본 메서드를 상속하여 충돌이 발생하면 구현 클래스가 해당 메서드를 명시적으로 오버라이딩해야 한다. 클래스의 구체 메서드와 인터페이스 기본 메서드가 경쟁하는 경우에는 클래스 계층의 메서드가 우선한다.

인터페이스 타입 변수는 해당 인터페이스를 구현한 어떤 클래스의 객체든 참조할 수 있다. 이를 통해 구현 클래스에 직접 의존하지 않는 다형적 API를 구성할 수 있다.

함수형 인터페이스와 람다 표현식

추상 인스턴스 메서드를 하나만 가지는 인터페이스는 함수형 인터페이스가 될 수 있다. 함수형 인터페이스는 람다 표현식이나 메서드 참조의 대상 타입으로 사용된다.

@FunctionalInterface
interface IntOperation {
    int apply(int left, int right);
}

IntOperation add = (left, right) -> left + right;
int result = add.apply(10, 20);

람다 표현식 자체는 독립적인 함수 타입을 선언하지 않는다. 람다의 매개변수와 반환 타입은 대입이나 메서드 호출 문맥에서 요구되는 함수형 인터페이스를 기준으로 결정된다. 같은 람다 표현식도 호환되는 서로 다른 함수형 인터페이스 타입을 대상으로 사용할 수 있다.

함수형 인터페이스는 default 메서드와 정적 메서드를 여러 개 가질 수 있다. 함수형 인터페이스 여부를 결정하는 것은 상속된 메서드까지 고려한 하나의 추상 함수 계약이다.

Object와 공통 객체 동작

모든 일반 클래스는 직접 또는 간접적으로 java.lang.Object를 상속한다. 따라서 모든 객체는 Object에 정의된 공통 메서드를 가진다.

대표적인 메서드는 다음과 같다.

  • equals(Object): 논리적 동등성 비교
  • hashCode(): 해시 기반 자료구조에 사용하는 해시 값
  • toString(): 객체의 문자열 표현
  • getClass(): 객체의 실행 시점 클래스
  • clone(): 조건부 객체 복제
  • wait(), notify(), notifyAll(): 객체 모니터 기반 스레드 협력

Object의 기본 equals 구현은 참조 동일성을 비교한다. 값 객체는 필요한 경우 equalshashCode를 함께 오버라이딩하여 논리적 동등성을 정의한다.

final class UserId {
    private final long value;

    UserId(long value) {
        this.value = value;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof UserId id
            && value == id.value;
    }

    @Override
    public int hashCode() {
        return Long.hashCode(value);
    }
}

equals가 같다고 판단하는 두 객체는 같은 hashCode 값을 반환해야 한다. 이 계약을 지키지 않으면 HashMapHashSet 같은 해시 기반 컬렉션에서 객체를 정상적으로 찾지 못할 수 있다.

참조 동일성과 객체 동등성

참조 타입에서 ==는 두 피연산자가 같은 객체를 가리키는지 비교한다. 객체의 내용이나 의미가 같은지는 자동으로 판단하지 않는다.

String first = new String("Java");
String second = new String("Java");

System.out.println(first == second);       // false
System.out.println(first.equals(second));  // true

첫 번째 비교는 두 참조가 서로 다른 객체를 가리키므로 false다. 두 번째 비교는 String이 구현한 내용 기반 동등성에 따라 true다.

String 리터럴은 문자열 풀을 통해 같은 객체가 재사용될 수 있으므로 일부 코드에서 ==가 우연히 true가 될 수 있다. 문자열 내용 비교에는 이러한 객체 재사용 여부와 관계없이 equals를 사용한다.

배열 타입

Java 배열은 객체이며 고정된 길이와 하나의 구성 요소 타입을 가진다. 배열 타입은 별도의 클래스 선언 없이 JVM이 생성하지만 Object의 하위 타입이며 Cloneablejava.io.Serializable을 구현한다.

int[] numbers = {1, 2, 3};
String[] names = new String[10];

기본 타입 배열은 기본 타입 값을 직접 구성 요소로 가진다. 참조 타입 배열의 각 요소는 객체 참조이며 생성 직후 null로 초기화된다.

배열은 참조 타입이므로 클래스 배열에 공변성이 적용된다. 예를 들어 String[]Object[]에 대입할 수 있다.

Object[] values = new String[1];
values[0] = "Java";

그러나 실제 배열 객체의 구성 요소 타입은 계속 String이다. 호환되지 않는 객체를 저장하면 실행 시점에 ArrayStoreException이 발생한다.

Object[] values = new String[1];
values[0] = Integer.valueOf(10);  // ArrayStoreException

배열 공변성은 일부 오류를 컴파일 시점이 아니라 실행 시점에 발견하게 만든다. 제네릭 컬렉션은 기본적으로 불공변이므로 이와 다른 타입 안전성 모델을 사용한다.

타입 변환

Java의 타입 변환은 모든 타입 사이에서 자유롭게 이루어지지 않으며, 언어가 정의한 변환 문맥에 따라 허용 여부가 결정된다.[24]

주요 변환은 다음과 같다.

  • 기본 타입 확대 변환
  • 기본 타입 축소 변환
  • 참조 타입 확대 변환
  • 참조 타입 축소 변환
  • 박싱과 언박싱 변환
  • 문자열 문맥 변환
  • 메서드 호출 변환
  • 대입과 캐스팅 변환

작은 정수 타입에서 더 넓은 정수 타입으로의 변환은 일반적으로 명시적인 캐스트 없이 가능하다.

int count = 100;
long total = count;
double average = total;

반대 방향은 값의 범위나 정밀도를 잃을 수 있으므로 명시적인 캐스트가 필요하다.

long total = 3_000_000_000L;
int count = (int) total;

참조 타입 확대 변환은 하위 타입 참조를 상위 타입으로 다루는 변환이다.

Dog dog = new Dog();
Animal animal = dog;
Object object = animal;

상위 타입 참조를 더 구체적인 하위 타입으로 바꾸려면 캐스트가 필요하다. 실제 객체가 대상 타입과 호환되지 않으면 ClassCastException이 발생한다.

Animal animal = new Dog();
Dog dog = (Dog) animal;

instanceof와 타입 패턴

instanceof는 참조 값이 특정 타입의 인스턴스인지 실행 시점에 검사한다. 전통적인 형태에서는 검사 후 별도의 캐스트가 필요했다.

if (value instanceof String) {
    String text = (String) value;
    System.out.println(text.length());
}

타입 패턴을 사용하면 검사와 지역 변수 선언을 하나로 결합할 수 있다.

if (value instanceof String text) {
    System.out.println(text.length());
}

패턴 변수 text는 컴파일러가 패턴 일치가 확실하다고 판단하는 흐름 범위 안에서만 사용할 수 있다. 조건식의 논리 구조도 패턴 변수의 유효 범위에 반영된다.

if (value instanceof String text && !text.isEmpty()) {
    System.out.println(text);
}

오른쪽 피연산자는 왼쪽의 타입 패턴이 성공한 경우에만 평가되므로 text를 사용할 수 있다.

Java SE 26에서는 기본 타입 패턴과 기본 타입을 대상으로 하는 instanceofswitch 확장이 프리뷰 기능으로 제공된다. 이는 참조 타입 중심이던 기존 패턴 매칭을 기본 타입으로 확장하는 단계이며, 프리뷰 기능을 사용하려면 컴파일과 실행 시 별도 옵션이 필요하다.[25]

제네릭

제네릭은 클래스, 인터페이스와 메서드가 특정 참조 타입을 매개변수처럼 받아 타입 안전한 재사용 구조를 구성하도록 한다.

class Box<T> {
    private T value;

    Box(T value) {
        this.value = value;
    }

    T get() {
        return value;
    }

    void set(T value) {
        this.value = value;
    }
}

T는 타입 변수다. Box<String>에서 TString으로, Box<Integer>에서는 Integer로 사용된다.

Box<String> textBox = new Box<>("Java");
String text = textBox.get();

Box<Integer> numberBox = new Box<>(42);
Integer number = numberBox.get();

제네릭은 기본적으로 참조 타입만 타입 인수로 받는다. Box<int>는 사용할 수 없으므로 기본 타입을 저장하려면 Box<Integer>와 같은 래퍼 타입을 사용한다.

제네릭 타입은 불공변이다. IntegerNumber의 하위 타입이어도 List<Integer>List<Number>의 하위 타입이 아니다.

List<Integer> integers = new ArrayList<>();

// List<Number> numbers = integers;  // 컴파일 오류

이 제한은 List<Number> 참조를 통해 Double 같은 다른 숫자 타입이 List<Integer>에 삽입되는 상황을 방지한다.

타입 변수의 경계

타입 변수에는 extends를 사용하여 상한을 지정할 수 있다.

class NumericBox<T extends Number> {
    private final T value;

    NumericBox(T value) {
        this.value = value;
    }

    double doubleValue() {
        return value.doubleValue();
    }
}

T extends NumberTNumber 또는 그 하위 타입이어야 한다는 뜻이다. 클래스 하나와 여러 인터페이스를 결합한 다중 경계도 사용할 수 있다.

static <T extends Comparable<T> & java.io.Serializable>
T select(T first, T second) {
    return first.compareTo(second) >= 0 ? first : second;
}

다중 경계에서 클래스 타입이 있다면 가장 먼저 와야 하며 클래스 경계는 하나만 지정할 수 있다. 여러 인터페이스 경계는 뒤에 이어서 지정할 수 있다.

와일드카드

와일드카드 ?는 정확히 어떤 타입 인수인지 알 필요가 없거나 타입의 허용 범위만 표현할 때 사용한다.

static int sizeOf(List<?> values) {
    return values.size();
}

List<?>는 어떤 타입 인수를 사용하는 List도 받을 수 있다. 다만 실제 요소 타입을 알 수 없으므로 null 이외의 임의 값을 안전하게 추가할 수 없다.

상한 와일드카드는 특정 타입의 하위 타입을 읽는 데 사용한다.

static double sum(List<? extends Number> values) {
    double total = 0;

    for (Number value : values) {
        total += value.doubleValue();
    }

    return total;
}

List<? extends Number>에는 List<Integer>, List<Long>, List<Double> 등을 전달할 수 있다. 요소를 Number로 읽을 수 있지만 정확한 구성 요소 타입을 알 수 없어 일반적으로 값을 추가할 수 없다.

하한 와일드카드는 특정 타입이나 그 상위 타입의 컬렉션에 값을 쓰는 데 사용한다.

static void addDefaults(List<? super Integer> values) {
    values.add(0);
    values.add(1);
}

List<? super Integer>에는 List<Integer>, List<Number>, List<Object>를 전달할 수 있다.

일반적으로 값을 생산하는 쪽에는 extends, 값을 소비하는 쪽에는 super를 사용하는 규칙을 PECS라고 부르기도 한다.

타입 소거

Java 제네릭은 대부분 타입 소거를 통해 구현된다. 컴파일러는 타입 인수를 이용해 소스 수준의 타입 안전성을 검사한 뒤, 클래스 파일에서는 타입 변수를 상한 또는 Object에 가까운 표현으로 변환하고 필요한 캐스트를 삽입한다.[26]

따라서 List<String>List<Integer>는 실행 시점에 서로 별도의 JVM 클래스로 생성되지 않는다. 두 객체 모두 일반적으로 List 구현 클래스의 인스턴스다.

이 구조는 제네릭 도입 이전에 작성된 Java 라이브러리와의 바이너리 호환성을 유지하는 데 기여했지만 다음과 같은 제약도 만든다.

  • 실행 시점에 구체적인 타입 인수를 직접 검사하기 어렵다.
  • new T()와 같은 타입 변수의 직접 인스턴스 생성이 불가능하다.
  • 제네릭 타입의 배열을 직접 생성하기 어렵다.
  • 기본 타입을 타입 인수로 사용할 수 없다.
  • 타입 인수만 다른 메서드를 같은 소거 서명으로 오버로딩할 수 없다.
  • 일부 경우 컴파일러가 브리지 메서드를 생성해야 한다.
if (value instanceof List<?>) {
    System.out.println("List instance");
}

// if (value instanceof List<String>) {}  // 허용되지 않음

실행 시점에는 객체가 List<String>인지 List<Integer>인지 일반적인 instanceof로 구분할 수 없으므로 비한정 와일드카드 형태를 사용한다.

원시 타입

제네릭 도입 이전 코드와의 호환성을 위해 타입 인수를 생략한 원시 타입을 사용할 수 있다.

List values = new ArrayList();
values.add("Java");
values.add(42);

원시 타입은 제네릭 타입 검사의 일부를 우회하므로 컴파일러가 비검사 경고를 발생시킬 수 있다. 값을 꺼낼 때 잘못된 캐스트가 삽입되면 실행 시점에 ClassCastException이 발생할 수 있다.

List values = new ArrayList();
values.add(42);

String text = (String) values.get(0);  // ClassCastException

원시 타입은 기존 API와의 상호 운용을 위한 기능이며, 새 코드에서는 구체적인 타입 인수나 와일드카드를 사용하는 방식이 일반적이다.

타입 추론

Java 컴파일러는 대입 문맥과 메서드 인수, 반환 타입을 분석하여 일부 타입 인수를 추론할 수 있다.

List<String> names = new ArrayList<>();

다이아몬드 연산자 <>를 사용하면 오른쪽 생성자 호출의 타입 인수 String을 대입 대상에서 추론한다.

제네릭 메서드에서도 타입 인수를 생략할 수 있다.

static <T> T first(T left, T right) {
    return left;
}

String value = first("Java", "JVM");

지역 변수에는 var를 사용하여 초기화식으로부터 타입을 추론하도록 할 수 있다.

var names = new ArrayList<String>();
var count = names.size();

var는 동적 타입이나 새로운 타입이 아니다. 컴파일러가 초기화식을 통해 구체적인 정적 타입을 결정하며, 이후 변수의 타입은 바뀌지 않는다. 필드와 메서드 매개변수 및 일반 반환 타입에는 사용할 수 없다.

열거형

열거형 클래스는 정해진 수의 이름 있는 인스턴스를 선언하는 특수한 클래스다.

enum Direction {
    NORTH,
    EAST,
    SOUTH,
    WEST
}

각 열거형 상수는 해당 열거형 클래스의 public static final 인스턴스다. 열거형은 필드와 메서드, 생성자를 가질 수 있다.

enum HttpStatus {
    OK(200),
    NOT_FOUND(404),
    INTERNAL_SERVER_ERROR(500);

    private final int code;

    HttpStatus(int code) {
        this.code = code;
    }

    int code() {
        return code;
    }
}

열거형 생성자는 외부에서 직접 호출할 수 없으며 상수 인스턴스는 클래스 초기화 과정에서 생성된다. 모든 열거형은 암시적으로 java.lang.Enum의 하위 클래스가 되므로 다른 클래스를 직접 상속할 수 없지만 여러 인터페이스를 구현할 수 있다.

열거형 상수에는 각자 별도의 클래스 본문을 부여하여 동작을 다르게 구현할 수도 있다. 이러한 경우 열거형 클래스는 허용된 상수별 하위 클래스만 가지는 암시적 봉인 계층으로 취급될 수 있다.

레코드

레코드는 고정된 데이터 구성 요소를 표현하기 위한 특수한 클래스다. 레코드 선언은 상태 설명을 기반으로 접근자와 생성자, equals, hashCode, toString을 자동으로 제공한다.[27]

record Point(int x, int y) {
}

위 선언은 xy라는 레코드 구성 요소를 가지며 다음 요소를 자동으로 제공한다.

  • 구성 요소에 대응하는 비공개 최종 필드
  • x()y() 접근자
  • 모든 구성 요소를 받는 표준 생성자
  • 구성 요소 값을 기반으로 하는 equals
  • 구성 요소 값을 기반으로 하는 hashCode
  • 구성 요소 이름과 값을 포함하는 toString

레코드는 암시적으로 final이므로 다른 클래스가 상속할 수 없다. 모든 레코드는 java.lang.Record를 직접 상속하며 다른 클래스를 직접 상속할 수 없지만 인터페이스는 구현할 수 있다.

record Range(int start, int end) {
    Range {
        if (start > end) {
            throw new IllegalArgumentException(
                "start must not exceed end"
            );
        }
    }
}

위와 같은 압축 생성자는 매개변수 목록을 반복하지 않고 레코드 구성 요소를 검증하거나 정규화할 수 있다.

레코드는 참조 타입이며 자동으로 깊은 불변성을 제공하지 않는다. 레코드 필드의 참조 자체는 최종이지만, 해당 참조가 가리키는 객체가 변경 가능한 경우 내부 상태의 변화가 외부에서 관찰될 수 있다.

record NameList(List<String> names) {
}

names 필드에 다른 목록을 다시 대입할 수는 없지만 전달된 목록 자체의 요소는 변경될 수 있다. 완전한 불변 값 객체가 필요하면 방어적 복사나 불변 컬렉션을 사용할 수 있다.

봉인 클래스와 봉인 인터페이스

봉인 타입은 직접 하위 타입이 될 수 있는 클래스를 제한한다. sealed 클래스나 인터페이스는 허용되는 직접 하위 타입을 선언하거나 같은 컴파일 단위와 모듈 규칙을 통해 제한된 계층을 구성한다.[28]

sealed interface Shape
    permits Circle, Rectangle, Triangle {
}

record Circle(double radius) implements Shape {
}

record Rectangle(double width, double height)
    implements Shape {
}

final class Triangle implements Shape {
}

봉인 타입의 직접 하위 타입은 다음 중 하나를 명시해야 한다.

  • final: 더 이상 상속을 허용하지 않는다.
  • sealed: 다시 제한된 하위 계층을 선언한다.
  • non-sealed: 이후 하위 클래스에 대한 제한을 해제한다.
sealed class Message
    permits TextMessage, SystemMessage {
}

non-sealed class TextMessage extends Message {
}

final class SystemMessage extends Message {
}

봉인 클래스는 기존 클래스 단일 상속 모델을 유지하면서도 특정 도메인의 가능한 구현 종류를 명시적으로 제한한다. 컴파일러는 봉인 계층 정보를 이용해 패턴 switch가 모든 가능한 하위 타입을 처리하는지 검사할 수 있다.

패턴 매칭과 데이터 지향 처리

패턴은 값이 특정 구조와 조건에 맞는지 검사하면서 그 구성 요소를 변수에 바인딩하는 기능이다. 타입 패턴, 레코드 패턴과 switch 패턴을 조합하면 객체 계층을 검사하고 데이터를 추출하는 코드를 선언적으로 작성할 수 있다.

static double area(Shape shape) {
    return switch (shape) {
        case Circle(double radius) ->
            Math.PI * radius * radius;

        case Rectangle(double width, double height) ->
            width * height;

        case Triangle triangle ->
            triangle.area();
    };
}

레코드 패턴은 레코드 객체가 가진 구성 요소를 직접 분해한다.[29]

record Point(int x, int y) {
}

static int coordinateSum(Object value) {
    if (value instanceof Point(int x, int y)) {
        return x + y;
    }

    return 0;
}

레코드 패턴은 중첩될 수 있다.

record Point(int x, int y) {
}

record Line(Point start, Point end) {
}

static int startX(Object value) {
    if (value instanceof Line(
        Point(int x, int y),
        Point end
    )) {
        return x;
    }

    return 0;
}

패턴을 사용하는 switch는 타입에 따라 분기하면서 패턴 변수를 제공한다. 봉인 타입과 함께 사용하면 컴파일러가 가능한 직접 하위 타입을 파악하여 완전성을 검사할 수 있다.[30]

패턴 switch에서는 더 일반적인 패턴이 앞에 와서 뒤의 구체적인 패턴을 가리지 않도록 지배 관계를 검사한다.

static String describe(Object value) {
    return switch (value) {
        case String text -> "text: " + text;
        case CharSequence sequence ->
            "sequence: " + sequence;
        default -> "other";
    };
}

StringCharSequence의 하위 타입이므로 더 구체적인 String 패턴이 먼저 와야 한다.

중첩 클래스

클래스와 인터페이스는 다른 클래스나 인터페이스 안에 중첩될 수 있다. 중첩 클래스는 정적 중첩 클래스와 내부 클래스로 나뉜다.

class Container {
    static class Entry {
        private final String key;

        Entry(String key) {
            this.key = key;
        }
    }
}

정적 중첩 클래스는 바깥 클래스의 인스턴스와 자동으로 연결되지 않는다. 바깥 클래스의 정적 멤버처럼 클래스 이름을 통해 접근한다.

비정적 멤버 클래스는 내부 클래스이며 바깥 객체에 대한 참조를 가진다.

class Document {
    private String title;

    class Section {
        String documentTitle() {
            return title;
        }
    }
}

내부 클래스의 인스턴스는 일반적으로 특정 Document 인스턴스와 연결된다. 내부 클래스 메서드는 연결된 바깥 객체의 인스턴스 멤버에 접근할 수 있다.

메서드 안에는 지역 클래스를 선언할 수 있고, 이름 없는 일회성 하위 클래스나 인터페이스 구현체가 필요할 때는 익명 클래스를 사용할 수 있다.

Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("running");
    }
};

함수형 인터페이스를 구현하는 단순한 익명 클래스는 람다 표현식으로 대체할 수 있지만, 익명 클래스와 람다는 this의 의미와 클래스 생성 여부 등에서 동일한 구조는 아니다.

접근 제어

Java는 멤버와 최상위 타입의 접근 범위를 제어하기 위해 접근 제한자를 제공한다.

제한자같은 클래스같은 패키지하위 클래스모든 위치
private가능불가능불가능불가능
제한자 없음가능가능같은 패키지에서 가능불가능
protected가능가능조건부 가능불가능
public가능가능가능가능

최상위 클래스와 인터페이스는 public 또는 패키지 접근 수준만 가질 수 있다. 중첩 타입은 다른 멤버처럼 privateprotected도 사용할 수 있다.

protected 멤버는 같은 패키지에서는 일반적인 패키지 접근처럼 사용할 수 있다. 다른 패키지의 하위 클래스에서는 상속 관계를 통한 접근에 추가 규칙이 적용되며, 임의의 상위 클래스 인스턴스를 통해 자유롭게 접근할 수 있는 것은 아니다.

Java의 접근 제어는 컴파일 시점과 링킹 및 실행 시점에 모두 검사될 수 있다. 리플렉션과 모듈 시스템도 별도의 접근 및 캡슐화 규칙을 적용한다.

불변성과 final

final은 적용 위치에 따라 의미가 달라진다.

  • final 변수는 한 번만 대입할 수 있다.
  • final 필드는 생성 과정에서 한 번 설정한 뒤 다시 대입할 수 없다.
  • final 메서드는 하위 클래스에서 오버라이딩할 수 없다.
  • final 클래스는 상속할 수 없다.
final List<String> names = new ArrayList<>();
names.add("Java");

// names = new ArrayList<>();  // 다시 대입할  없음

참조 변수를 final로 선언해도 참조 대상 객체가 자동으로 불변이 되는 것은 아니다. 위 코드에서는 names가 다른 목록을 가리키도록 바꿀 수 없지만 현재 목록의 요소는 수정할 수 있다.

불변 객체는 보통 다음 요소를 함께 사용해 구성한다.

  • 객체 상태를 외부에서 변경할 수 없도록 필드를 비공개로 선언
  • 필요한 필드를 final로 선언
  • 변경 메서드를 제공하지 않음
  • 변경 가능한 객체를 입력받거나 반환할 때 방어적 복사 사용
  • 상속을 통한 상태 변경을 막도록 클래스 계층 제한
  • 생성 과정에서 유효성 조건 확인

레코드는 이러한 구조의 일부를 자동으로 제공하지만 참조 구성 요소의 깊은 불변성까지 보장하지는 않는다.

타입과 실행 시점 클래스

참조 표현식에는 컴파일 시점 타입과 실행 시점 클래스가 함께 존재할 수 있다.

Animal animal = new Dog();
  • 변수의 컴파일 시점 타입은 Animal이다.
  • 객체의 실행 시점 클래스는 Dog다.
  • 접근 가능한 멤버는 주로 Animal 타입을 기준으로 검사된다.
  • 오버라이딩된 인스턴스 메서드 구현은 Dog를 기준으로 선택된다.

getClass()는 객체의 실제 실행 시점 클래스를 나타내는 Class 객체를 반환한다.

System.out.println(animal.getClass().getName());

Class 객체는 리플렉션 API와 클래스 로딩, 애너테이션 조회 및 실행 시점 타입 조사에 사용된다. 다만 일반적인 다형성 코드에서는 구체 클래스 이름을 직접 비교하기보다 인터페이스와 오버라이딩, 패턴 매칭을 사용하는 편이 타입 계층의 확장에 더 자연스럽다.

배열과 제네릭의 객체 모델 차이

배열과 제네릭 컬렉션은 모두 여러 값을 다루지만 타입 모델에는 중요한 차이가 있다.

특성배열제네릭 컬렉션
길이생성 후 고정구현에 따라 변경 가능
기본 타입 직접 저장가능불가능
타입 관계공변기본적으로 불공변
요소 타입 정보실행 시점에 유지일반적으로 타입 소거
잘못된 값 저장실행 시점 검사 가능주로 컴파일 시점 검사
API언어와 JVM 내장클래스 라이브러리
Number[] numberArray = new Integer[10];
List<? extends Number> numberList =
    new ArrayList<Integer>();

배열은 실제 구성 요소 타입이 Integer라는 정보를 실행 중에도 유지하므로 잘못된 저장을 검사할 수 있다. 제네릭 컬렉션은 타입 소거를 사용하지만 컴파일러가 소스 수준에서 허용되는 읽기와 쓰기 연산을 제한한다.

객체 모델의 성격

Java의 객체 모델은 모든 값을 객체로 통일하지 않는다. 기본 타입은 객체와 분리되어 있으며 성능과 표현 효율을 위해 직접적인 값으로 처리된다. 래퍼 클래스와 자동 박싱이 두 영역을 연결하지만 의미와 실행 비용은 완전히 같지 않다.

클래스는 구현 상태와 단일 상속 계층을 구성하고, 인터페이스는 여러 타입 계약을 결합한다. 제네릭은 컴파일 시점의 타입 안전성을 확장하며, 레코드는 데이터 중심 클래스를 간결하게 표현한다. 봉인 타입과 패턴 매칭은 가능한 하위 타입을 제한하고 객체의 구조를 안전하게 분해하는 기능을 제공한다.

이 구조는 Java의 초기 클래스 중심 객체 지향 모델을 유지하면서도, 인터페이스 기반 설계와 함수형 인터페이스, 명시적인 데이터 모델 및 패턴 기반 분기를 함께 사용할 수 있도록 확장된 형태다.

문법과 핵심 기능

Java는 C (프로그래밍 언어)C++에서 이어진 중괄호 기반 문법을 사용한다. 문장은 일반적으로 세미콜론으로 끝나며, 중괄호는 클래스와 메서드, 조건문, 반복문 등의 범위를 나타낸다. 그러나 Java의 문법은 단순한 표기법만 정의하지 않고 이름의 유효 범위, 표현식 평가 순서, 타입 변환, 예외 전파와 객체 초기화 같은 실행 의미까지 함께 규정한다.

Java 프로그램은 전통적으로 클래스와 인터페이스 등의 타입 선언으로 구성된다. 메서드 밖에는 일반 실행문을 직접 작성하지 않으며, 애플리케이션의 실행은 시작 메서드인 main을 통해 이루어진다. Java 25부터는 작은 프로그램을 위한 간결한 소스 파일과 인스턴스 main 메서드가 정식 기능으로 추가되었지만, 이 경우에도 소스의 필드와 메서드는 컴파일러가 암시적으로 선언하는 클래스의 멤버로 처리된다.[31]

소스 코드의 어휘 구조

Java 소스 코드는 유니코드 문자로 작성된다. 컴파일러는 입력 문자를 유니코드 이스케이프, 줄 구분자, 토큰의 순서로 처리하며, 토큰은 식별자와 키워드, 리터럴, 구분자, 연산자로 나뉜다.[32]

package example;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, Java");
    }
}

공백과 줄바꿈은 대부분 토큰을 구분하는 역할만 한다. 따라서 다음 두 표현은 문법적으로 같은 의미를 가진다.

int result = left + right;

int result
    =
    left
    +
    right;

다만 문자열과 문자 리터럴, 텍스트 블록, 주석 내부에서는 공백과 줄바꿈이 데이터의 일부가 되거나 별도의 규칙을 따른다.

주석

Java는 세 종류의 주석을 지원한다.

// 한 줄 주석

/*
 * 여러 줄에 걸친
 * 블록 주석
 */

/**
 * 문서화 주석
 */

// 주석은 현재 줄의 끝까지 이어지고, /* ... */ 주석은 여러 줄을 포함할 수 있다. 블록 주석은 중첩되지 않는다.

/** ... */ 형태는 일반 블록 주석으로도 처리되지만, javadoc 도구가 클래스와 메서드, 필드 등의 API 문서를 생성할 때 사용하는 문서화 주석이기도 하다.

/**
 * 두 정수의 합을 계산한다.
 *
 * @param left 첫 번째 값
 * @param right 두 번째 값
 * @return 두 값의 합
 */
public static int add(int left, int right) {
    return left + right;
}

문서화 주석의 @param, @return, @throws 등의 태그는 Java 언어의 실행 문법은 아니지만, 소스 코드와 API 문서를 함께 관리하는 표준적인 방식으로 사용된다.

식별자와 키워드

식별자는 변수와 메서드, 클래스, 인터페이스, 패키지 등의 이름이다.

int count;
String userName;
class NetworkClient {
}

식별자는 숫자로 시작할 수 없으며 키워드와 true, false, null을 이름으로 사용할 수 없다.

// int 1stValue;  // 숫자로 시작할 수 없음
// int class;     // 키워드를 사용할 수 없음
// int null;      // null은 식별자가 아님

Java 식별자는 ASCII 영문자에 한정되지 않고 유니코드 문자도 사용할 수 있다. 다만 여러 언어와 도구를 함께 사용하는 프로젝트에서는 영문자와 숫자, 밑줄을 중심으로 이름을 구성하는 경우가 일반적이다.

Java의 키워드에는 class, interface, if, switch, return, new, public, private 등이 있다. 일부 단어는 모든 위치에서 항상 예약되는 키워드가 아니라 특정 문법 위치에서만 특별한 의미를 갖는 문맥 키워드다.

대표적인 문맥 키워드는 다음과 같다.

  • var
  • record
  • sealed
  • permits
  • yield
  • module
  • requires
  • exports
  • opens
  • transitive

예를 들어 record는 레코드 선언이 시작되는 위치에서는 특별한 의미를 갖지만, 일부 다른 문맥에서는 기존 코드와의 호환성을 위해 일반 이름으로 사용될 수 있다.

리터럴

리터럴은 소스 코드에 직접 작성하는 값이다.

int decimal = 42;
int hexadecimal = 0x2A;
int binary = 0b101010;
long large = 9_000_000_000L;

double ratio = 0.75;
float progress = 0.75F;

boolean enabled = true;
char grade = 'A';
String language = "Java";
Object missing = null;

정수 리터럴은 10진수와 16진수, 8진수, 2진수로 표현할 수 있다.

int decimal = 255;
int hexadecimal = 0xFF;
int octal = 0377;
int binary = 0b1111_1111;

숫자 리터럴에는 가독성을 위해 밑줄을 넣을 수 있다. 밑줄은 숫자의 값에 영향을 주지 않지만 리터럴의 시작과 끝, 소수점이나 접미사 바로 주변처럼 허용되지 않는 위치가 있다.

정수 리터럴은 기본적으로 int이며, L 또는 l 접미사가 붙으면 long이다. 소문자 l은 숫자 1과 구분하기 어려우므로 대문자 L이 일반적으로 사용된다.

부동소수점 리터럴은 기본적으로 double이며 F 접미사를 붙이면 float가 된다.

double first = 3.14159;
double scientific = 1.25e3;
float second = 3.14F;

문자 리터럴은 작은따옴표, 문자열 리터럴은 큰따옴표를 사용한다.

char newline = '\n';
char unicode = '\u004A';
String message = "Hello\nJava";

이스케이프 시퀀스는 줄바꿈과 탭, 따옴표, 역슬래시 등의 문자를 표현한다.

이스케이프의미
\n줄바꿈
\r캐리지 리턴
\t
\b백스페이스
\f폼 피드
\'작은따옴표
\"큰따옴표
\\역슬래시

텍스트 블록

여러 줄 문자열은 세 개의 큰따옴표를 사용하는 텍스트 블록으로 표현할 수 있다.

String html = """
    <html>
        <body>
            <h1>Hello</h1>
        </body>
    </html>
    """;

텍스트 블록은 JSON과 HTML, SQL, 코드 조각처럼 여러 줄로 이루어진 텍스트를 기존 문자열 연결보다 읽기 쉽게 작성하는 데 사용된다.

String query = """
    SELECT id, name
    FROM users
    WHERE active = true
    ORDER BY name
    """;

컴파일러는 닫는 구분자의 위치와 각 줄의 공통 들여쓰기를 기준으로 부수적인 들여쓰기를 제거한다. 텍스트 블록도 결과적으로는 String 값을 생성하며 별도의 문자열 타입을 만들지 않는다.

변수 선언

변수 선언은 타입과 이름, 선택적인 초기화식으로 구성된다.

int count;
int limit = 100;
String name = "Java";

하나의 문장에서 같은 타입의 변수를 여러 개 선언할 수도 있다.

int x = 10, y = 20;

그러나 각 변수의 초기화와 수정이 독립적인 경우에는 별도 문장으로 나누는 편이 일반적으로 읽기 쉽다.

변수는 선언 위치에 따라 다음과 같이 구분된다.

  • 인스턴스 필드
  • 정적 필드
  • 메서드 매개변수
  • 생성자 매개변수
  • 지역 변수
  • 예외 매개변수
  • 람다 매개변수
  • 패턴 변수

필드는 객체나 클래스가 유지하는 상태이며 자동으로 기본값을 받는다. 지역 변수는 사용 전에 컴파일러가 값이 확실히 할당되었다고 판단할 수 있어야 한다.

int value;

// System.out.println(value);  // 초기화되지 않아 컴파일 오류

value = 10;
System.out.println(value);

지역 변수 타입 추론

지역 변수 선언에서는 명시적인 타입 대신 var를 사용할 수 있다.

var name = "Java";
var count = 10;
var values = new ArrayList<String>();

컴파일러는 초기화식으로부터 변수의 정적 타입을 추론한다. var는 동적 타입이나 모든 값을 담는 범용 타입이 아니다.

var value = 10;

// value = "Java";  // value의 타입은 int이므로 컴파일 오류

var 선언에는 초기화식이 필요하며 여러 변수를 한 번에 선언할 수 없다.

// var value;
// var left = 1, right = 2;
// var nothing = null;

null만으로는 구체적인 타입을 추론할 수 없으므로 var nothing = null도 허용되지 않는다.

var는 지역 변수와 향상된 for 문의 변수, 일부 람다 매개변수에 사용할 수 있지만 클래스 필드와 일반 메서드 매개변수, 반환 타입에는 사용할 수 없다.

상수와 final

변수에 final을 붙이면 초기화된 뒤 다시 대입할 수 없다.

final int maxConnections = 128;

static final 필드는 클래스 수준 상수를 표현할 때 자주 사용된다.

class Protocol {
    static final int DEFAULT_PORT = 8080;
}

기본 타입 또는 String 타입의 static final 필드가 상수 표현식으로 초기화되면 컴파일 시간 상수가 될 수 있다. 컴파일러는 이러한 값을 사용하는 클래스 파일에 직접 포함할 수 있다.

static final int BUFFER_SIZE = 1024 * 8;

참조 변수의 final은 참조를 다시 대입하지 못하게 할 뿐, 참조 대상 객체를 자동으로 불변으로 만들지는 않는다.

final List<String> names = new ArrayList<>();

names.add("Java");

// names = new ArrayList<>();  // 다시 대입할  없음

연산자

Java는 산술과 비교, 논리, 비트, 대입, 조건 연산자를 제공한다.[33]

산술 연산자

int sum = left + right;
int difference = left - right;
int product = left * right;
int quotient = left / right;
int remainder = left % right;

정수 나눗셈은 소수 부분을 버리고 0 방향으로 절단한다.

int result = 7 / 2;  // 3

정수를 0으로 나누면 ArithmeticException이 발생한다. 부동소수점 나눗셈은 IEEE 754 규칙에 따라 무한대 또는 NaN을 생성할 수 있다.

double infinity = 1.0 / 0.0;
double notANumber = 0.0 / 0.0;

+ 연산자는 숫자 덧셈뿐 아니라 문자열 연결에도 사용된다.

String message = "count = " + count;

피연산자 중 하나가 문자열이면 다른 값도 문자열 표현으로 변환되어 연결된다. 연산은 왼쪽부터 결합되므로 순서에 따라 결과가 달라질 수 있다.

System.out.println(1 + 2 + " Java");  // 3 Java
System.out.println("Java " + 1 + 2);  // Java 12

증가와 감소

count++;
count--;
++count;
--count;

후위 연산자는 기존 값을 표현식 결과로 사용한 뒤 값을 변경하고, 전위 연산자는 값을 먼저 변경한 뒤 변경된 값을 결과로 사용한다.

int value = 10;

int first = value++;  // first는 10, value는 11
int second = ++value; // value는 12, second도 12

한 표현식 안에서 같은 변수에 여러 부수 효과를 섞으면 평가 순서를 이해하기 어려워질 수 있으므로 보통 별도 문장으로 분리한다.

비교와 논리 연산자

boolean same = left == right;
boolean different = left != right;
boolean less = left < right;
boolean valid = enabled && count > 0;
boolean fallback = primary || secondary;
boolean disabled = !enabled;

&&||는 단락 평가를 수행한다. 결과가 왼쪽 피연산자만으로 결정되면 오른쪽 피연산자를 평가하지 않는다.

if (value != null && !value.isEmpty()) {
    System.out.println(value);
}

valuenull이면 두 번째 조건을 평가하지 않으므로 NullPointerException이 발생하지 않는다.

&|boolean 값에 사용할 수 있지만 양쪽 피연산자를 모두 평가한다. 정수 타입에 사용하면 비트 단위 연산을 수행한다.

비트와 이동 연산자

int and = left & right;
int or = left | right;
int xor = left ^ right;
int inverted = ~left;

int shiftedLeft = value << 2;
int shiftedRight = value >> 2;
int unsignedRight = value >>> 2;

>>는 부호를 유지하는 산술 오른쪽 이동이고, >>>는 왼쪽을 0으로 채우는 논리 오른쪽 이동이다.

Java에는 정수 타입의 부호 없는 표현 타입이 별도로 없지만, IntegerLong의 부호 없는 비교·나눗셈·문자열 변환 API를 이용하여 비트 패턴을 부호 없는 값처럼 처리할 수 있다.

대입 연산자

count = 10;
count += 5;
count -= 2;
count *= 3;
count /= 2;
count %= 4;

복합 대입 연산자는 단순히 왼쪽 피연산자를 두 번 작성한 문법과 완전히 같지는 않다. 왼쪽 표현식은 한 번만 평가되며, 필요한 경우 연산 결과를 왼쪽 변수의 타입으로 암시적으로 변환한다.

byte value = 10;

value += 1;

// value = value + 1;  // value + 1은 int이므로 명시적 변환 필요

조건 연산자

조건 연산자는 조건에 따라 두 표현식 중 하나의 값을 선택한다.

String status = active ? "enabled" : "disabled";

조건 연산자는 값을 생성하는 표현식이므로 변수 초기화나 메서드 인수, 반환문 안에서 사용할 수 있다. 복잡한 분기를 중첩하면 읽기 어려워질 수 있으므로 단순한 값 선택에 주로 사용된다.

표현식 평가 순서

Java는 표현식의 피연산자를 일반적으로 왼쪽에서 오른쪽으로 평가한다. 이는 컴파일러 구현에 따라 순서가 달라질 수 있는 일부 언어와 구분되는 특성이다.

int result = first() + second();

위 표현식에서는 first()가 먼저 호출되고 정상적으로 값을 반환한 뒤 second()가 호출된다.

메서드 호출에서도 대상 표현식을 먼저 평가한 뒤 인수를 왼쪽에서 오른쪽으로 평가한다. 평가 도중 예외가 발생하면 이후 피연산자는 평가되지 않는다.

연산자 우선순위는 표현식이 어떤 구조로 묶이는지를 결정하지만 실제 코드에서는 괄호를 사용해 의도를 명확히 할 수 있다.

int result = left + right * scale;

int explicit = left + (right * scale);

블록과 유효 범위

블록은 중괄호로 묶인 문장과 선언의 집합이다.

{
    int temporary = 10;
    System.out.println(temporary);
}

지역 변수는 선언된 위치부터 해당 블록의 끝까지 사용할 수 있다.

int outer = 10;

if (outer > 0) {
    int inner = 20;
    System.out.println(outer + inner);
}

// System.out.println(inner);  // 범위 밖이므로 컴파일 오류

내부 블록에서는 바깥 지역 변수와 같은 이름의 지역 변수를 다시 선언할 수 없는 경우가 있다. 필드와 지역 변수의 이름이 같다면 this를 사용하여 현재 객체의 필드를 명시할 수 있다.

class User {
    private String name;

    User(String name) {
        this.name = name;
    }
}

조건문

if 문은 boolean 조건에 따라 문장을 실행한다.

if (temperature > 30) {
    System.out.println("hot");
} else if (temperature < 10) {
    System.out.println("cold");
} else {
    System.out.println("normal");
}

조건식은 반드시 boolean 타입이어야 한다.

int count = 1;

// if (count) {}  // 정수를 조건으로 사용할 수 없음

if (count != 0) {
    System.out.println("non-zero");
}

중괄호는 문장이 하나일 때 생략할 수 있다.

if (enabled)
    start();

그러나 이후 문장을 추가할 때 범위를 잘못 해석할 수 있으므로 일반적으로 중괄호를 유지한다.

전통적인 switch

switch는 하나의 선택자 값을 여러 case와 비교한다.

switch (command) {
    case "start":
        start();
        break;

    case "stop":
        stop();
        break;

    default:
        showHelp();
        break;
}

콜론 형식의 case에서는 break를 만나거나 switch가 끝날 때까지 다음 레이블의 문장으로 실행이 이어질 수 있다.

switch (level) {
    case 3:
        enableAdvanced();

    case 2:
        enableStandard();

    case 1:
        enableBasic();
        break;

    default:
        break;
}

이러한 폴스루는 여러 조건에서 공통 작업을 수행할 때 사용할 수 있지만, 의도하지 않은 누락과 혼동을 만들 수 있다.

switch 표현식

화살표 레이블을 사용하는 switch는 각 분기가 독립적으로 실행되며 값을 생성하는 표현식으로 사용할 수 있다.

String label = switch (status) {
    case 200 -> "OK";
    case 404 -> "Not Found";
    case 500 -> "Internal Server Error";
    default -> "Unknown";
};

화살표 오른쪽에는 표현식과 블록 또는 throw 문을 사용할 수 있다.

int score = switch (grade) {
    case "A" -> 100;
    case "B" -> 80;

    case "C" -> {
        int base = 60;
        int bonus = calculateBonus();
        yield base + bonus;
    }

    default -> throw new IllegalArgumentException(
        "unknown grade: " + grade
    );
};

블록 형태의 분기에서 값을 반환하려면 return이 아니라 yield를 사용한다. return은 현재 메서드 전체를 종료하지만 yield는 현재 switch 표현식의 값을 제공한다.

switch 표현식은 가능한 입력을 모두 처리해야 한다. 열거형이나 봉인 타입에서는 컴파일러가 알려진 가능한 값을 이용해 완전성을 검사할 수 있다.

패턴 switch

패턴 매칭을 사용하는 switch는 값의 실행 시점 타입을 검사하면서 패턴 변수를 선언할 수 있다.

static String describe(Object value) {
    return switch (value) {
        case Integer number ->
            "integer: " + number;

        case String text ->
            "text: " + text;

        case null ->
            "null";

        default ->
            "other";
    };
}

패턴에는 추가 조건인 when 가드를 붙일 수 있다.

static String classify(Object value) {
    return switch (value) {
        case Integer number when number > 0 ->
            "positive integer";

        case Integer number when number < 0 ->
            "negative integer";

        case Integer number ->
            "zero";

        default ->
            "not an integer";
    };
}

더 일반적인 패턴이 먼저 등장해 뒤의 구체적인 패턴을 항상 가리는 경우 컴파일 오류가 발생한다. 따라서 하위 타입이나 구체적인 조건을 먼저 배치하고 상위 타입과 기본 분기를 뒤에 배치한다.

whiledo-while

while 문은 조건이 참인 동안 본문을 반복한다.

int count = 0;

while (count < 10) {
    System.out.println(count);
    count++;
}

조건은 본문 실행 전에 검사되므로 처음부터 거짓이면 본문이 한 번도 실행되지 않는다.

do-while은 본문을 먼저 실행한 뒤 조건을 검사한다.

int value;

do {
    value = readValue();
} while (value < 0);

따라서 do-while의 본문은 최소 한 번 실행된다.

기본 for

기본 for 문은 초기화, 조건, 갱신 표현식을 한곳에 배치한다.

for (int index = 0; index < 10; index++) {
    System.out.println(index);
}

세 부분은 각각 생략할 수 있다.

for (;;) {
    process();

    if (shouldStop()) {
        break;
    }
}

초기화 영역에 선언한 변수는 for 문의 조건과 갱신, 본문 안에서만 사용할 수 있다.

향상된 for

배열이나 Iterable을 순회할 때는 향상된 for 문을 사용할 수 있다.

List<String> languages = List.of(
    "Java",
    "Kotlin",
    "Scala"
);

for (String language : languages) {
    System.out.println(language);
}

배열에도 같은 형식을 사용한다.

int[] values = {10, 20, 30};

for (int value : values) {
    System.out.println(value);
}

향상된 for 문의 변수에는 각 요소의 값 또는 참조가 대입된다. 변수 자체에 다른 값을 대입해도 원래 배열이나 컬렉션의 요소를 교체하지 않는다.

컬렉션을 순회하면서 구조를 변경해야 한다면 Iterator의 제거 기능이나 컬렉션이 제공하는 별도 연산을 사용할 수 있다.

break, continue와 레이블

break는 현재 반복문이나 switch를 종료한다.

for (String value : values) {
    if (value.equals(target)) {
        break;
    }
}

continue는 현재 반복의 남은 부분을 건너뛰고 다음 반복으로 이동한다.

for (String value : values) {
    if (value.isBlank()) {
        continue;
    }

    process(value);
}

Java 문장에는 레이블을 붙일 수 있으며, 레이블이 지정된 breakcontinue로 중첩된 구조의 바깥쪽 반복문을 제어할 수 있다.

search:
for (int row = 0; row < matrix.length; row++) {
    for (int column = 0;
         column < matrix[row].length;
         column++) {

        if (matrix[row][column] == target) {
            break search;
        }
    }
}

레이블은 제한적으로 유용하지만 중첩 흐름이 복잡해질 경우 메서드 추출이나 별도 탐색 함수로 구조를 분리할 수도 있다.

메서드 선언

메서드는 수정자와 반환 타입, 이름, 매개변수 목록, 선택적인 throws 절과 본문으로 구성된다.

public static int add(int left, int right) {
    return left + right;
}

값을 반환하지 않는 메서드는 반환 타입으로 void를 사용한다.

static void printMessage(String message) {
    System.out.println(message);
}

반환 타입이 void가 아닌 메서드의 정상적인 모든 실행 경로는 호환되는 값을 반환해야 한다.

static int absolute(int value) {
    if (value < 0) {
        return -value;
    }

    return value;
}

메서드 호출 시 인수 표현식은 왼쪽에서 오른쪽으로 평가되고, 계산된 값이 매개변수로 전달된다. Java에서는 기본 타입의 값과 객체 참조 모두 값으로 전달된다.

메서드 시그니처와 오버로딩

Java 언어에서 메서드 시그니처는 주로 메서드 이름과 타입 매개변수, 형식 매개변수 타입으로 결정된다. 반환 타입만 다른 메서드는 같은 클래스에 함께 선언할 수 없다.

static int parse(String value) {
    return Integer.parseInt(value);
}

static int parse(String value, int radix) {
    return Integer.parseInt(value, radix);
}

컴파일러는 호출 위치의 인수 개수와 정적 타입, 허용되는 변환을 이용해 가장 구체적인 오버로드를 선택한다.

static void print(Object value) {
    System.out.println("Object");
}

static void print(String value) {
    System.out.println("String");
}

String text = "Java";
print(text);  // String 오버로드

오버로딩은 컴파일 시점에 결정되므로 인수 객체의 실행 시점 클래스가 아니라 표현식의 컴파일 시점 타입이 기준이 된다.

가변 인자

마지막 매개변수에 ...를 사용하면 호출자가 여러 인수를 전달할 수 있다.

static int sum(int... values) {
    int result = 0;

    for (int value : values) {
        result += value;
    }

    return result;
}

호출할 때는 개별 인수 또는 배열을 전달할 수 있다.

int first = sum(1, 2, 3);
int second = sum(new int[]{4, 5, 6});

가변 인자는 메서드 내부에서 배열로 처리된다. 가변 인자 매개변수는 매개변수 목록의 마지막에 하나만 선언할 수 있다.

제네릭 가변 인자는 타입 소거와 배열의 실행 시점 타입이 결합되면서 힙 오염 경고를 만들 수 있다. 구현이 안전한 경우 @SafeVarargs를 사용하여 조건을 명시할 수 있다.

람다 표현식

람다 표현식은 함수형 인터페이스의 구현을 간결하게 작성한다.

Function<String, Integer> length =
    text -> text.length();

매개변수가 하나일 때 타입과 괄호를 생략할 수 있다.

Predicate<String> empty = text -> text.isEmpty();

BiFunction<Integer, Integer, Integer> add =
    (left, right) -> left + right;

여러 문장을 실행하려면 블록 본문을 사용한다.

Consumer<String> printer = text -> {
    String normalized = text.trim();
    System.out.println(normalized);
};

값을 반환하는 블록 람다에서는 return을 사용한다.

Function<String, Integer> parser = text -> {
    String normalized = text.trim();
    return Integer.parseInt(normalized);
};

람다는 바깥 지역 변수에 접근할 수 있지만 해당 변수는 final이거나 사실상 최종이어야 한다.

int offset = 10;

Function<Integer, Integer> addOffset =
    value -> value + offset;

// offset++;  // 람다에서 사용했으므로 변경할  없음

인스턴스 필드는 지역 변수와 다른 수명을 가지므로 람다 내부에서 읽고 변경할 수 있다.

메서드 참조

기존 메서드를 함수형 인터페이스의 구현으로 사용할 때는 :: 메서드 참조를 사용할 수 있다.

Function<String, Integer> parser =
    Integer::parseInt;

Consumer<String> printer =
    System.out::println;

대표적인 형태는 다음과 같다.

ClassName::staticMethod
object::instanceMethod
ClassName::instanceMethod
ClassName::new
TypeName[]::new

메서드 참조의 실제 대상은 대입 문맥의 함수형 인터페이스 타입을 기준으로 결정된다. 따라서 :: 표현식만으로 독립된 함수 타입이 생성되는 것은 아니다.

패키지 선언

패키지는 타입 이름을 구분하고 관련 코드를 묶는 이름 공간이다.

package com.example.application;

패키지 선언은 일반 소스 파일에서 주석과 공백을 제외하면 파일의 앞부분에 배치한다. 패키지 선언이 없으면 이름 없는 패키지에 속한다.

패키지 이름은 일반적으로 조직의 인터넷 도메인을 역순으로 사용한다.

com.example.project
org.openjdk.tools
io.wave.runtime

패키지 구조와 파일 시스템 디렉터리가 언어 차원에서 반드시 동일해야 하는 것은 아니지만, 일반적인 컴파일러와 빌드 도구는 패키지 이름에 맞춘 디렉터리 구조를 사용한다.

임포트 선언

임포트는 다른 패키지나 타입의 이름을 현재 컴파일 단위에서 단순 이름으로 사용할 수 있게 한다.

import java.util.ArrayList;
import java.util.List;

임포트는 클래스를 메모리에 로딩하거나 다른 파일의 코드를 현재 파일에 복사하지 않는다. 컴파일러의 이름 해석에 사용할 별칭 범위를 제공한다.

별표를 사용하면 한 패키지의 공개 최상위 타입을 필요할 때 이름으로 찾을 수 있다.

import java.util.*;

이 형식은 하위 패키지를 가져오지 않는다. java.util.*java.util.concurrent의 타입을 포함하지 않는다.

정적 임포트는 다른 타입의 정적 멤버를 단순 이름으로 사용할 수 있게 한다.

import static java.lang.Math.PI;
import static java.lang.Math.sqrt;

double radius = 10;
double circumference = 2 * PI * radius;
double diagonal = sqrt(2);

같은 단순 이름을 가진 여러 타입이 임포트되어 모호해지면 정규화된 이름을 직접 사용해야 한다.

java.util.Date utilDate = new java.util.Date();
java.sql.Date sqlDate =
    java.sql.Date.valueOf("2026-07-18");

java.lang 패키지의 공개 타입은 일반 소스 파일에 자동으로 임포트되므로 String, Object, System, Integer 등을 별도 임포트 없이 사용할 수 있다.

모듈 임포트

Java 25부터 import module 선언을 사용하여 모듈이 내보내는 여러 패키지의 공개 최상위 타입을 한 번에 임포트할 수 있다.[34]

import module java.base;

이는 모듈 자체를 변수나 이름 공간으로 가져오는 문법이 아니다. 대상 모듈이 현재 모듈에 내보내는 패키지와 전이적으로 읽을 수 있는 관련 모듈의 공개 최상위 타입을 필요에 따라 단순 이름으로 사용할 수 있게 한다.

import module java.base;

class Example {
    List<String> names =
        List.of("Java", "Kotlin", "Scala");

    BigInteger value =
        new BigInteger("12345678901234567890");
}

명시적인 단일 타입 임포트는 패키지 단위나 모듈 단위의 요구 시 임포트보다 구체적이므로 이름 해석에서 우선할 수 있다. 동일한 단순 이름이 여러 경로에서 제공되어 모호하면 정규화된 이름이나 명시적인 임포트가 필요하다.

접근 제한자

Java는 타입과 멤버의 접근 범위를 제한하기 위해 public, protected, private와 패키지 접근을 제공한다.

public class Account {
    private long balance;

    protected void validate() {
    }

    public long balance() {
        return balance;
    }

    void updateInternalState() {
    }
}

제한자를 생략한 멤버는 같은 패키지에서 접근할 수 있다.

접근 제한은 단순히 이름을 숨기는 표기가 아니라 컴파일러와 JVM이 검사하는 규칙이다. 모듈 시스템에서는 패키지가 public 타입을 포함하더라도 해당 패키지를 다른 모듈에 내보내지 않았다면 모듈 외부에서 일반적으로 접근할 수 없다.

애너테이션

애너테이션은 프로그램 선언이나 타입 사용 위치에 구조화된 메타데이터를 붙이는 기능이다.[35]

@Override
public String toString() {
    return "Example";
}

@Override는 해당 메서드가 상위 클래스나 인터페이스의 메서드를 오버라이딩한다는 의도를 컴파일러에 전달한다. 실제로 오버라이딩하지 않으면 컴파일 오류가 발생한다.

대표적인 표준 애너테이션은 다음과 같다.

  • @Override
  • @Deprecated
  • @SuppressWarnings
  • @SafeVarargs
  • @FunctionalInterface

사용자 정의 애너테이션은 @interface로 선언한다.

@interface Route {
    String path();

    String method() default "GET";
}

애너테이션 요소는 매개변수가 없는 메서드와 비슷한 형태로 선언한다.

@Route(
    path = "/users",
    method = "POST"
)
class UserController {
}

value라는 이름의 요소 하나만 지정할 때는 요소 이름을 생략할 수 있다.

@interface Author {
    String value();
}

@Author("James Gosling")
class LanguageHistory {
}

메타 애너테이션

애너테이션 선언 자체에 붙여 동작을 지정하는 애너테이션을 메타 애너테이션이라고 한다.

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({
    ElementType.TYPE,
    ElementType.METHOD
})
@interface Audited {
}

@Target은 애너테이션을 사용할 수 있는 위치를 지정한다.

  • TYPE
  • FIELD
  • METHOD
  • PARAMETER
  • CONSTRUCTOR
  • LOCAL_VARIABLE
  • ANNOTATION_TYPE
  • PACKAGE
  • MODULE
  • TYPE_PARAMETER
  • TYPE_USE
  • RECORD_COMPONENT

@Retention은 애너테이션 정보가 어느 단계까지 유지되는지를 지정한다.

정책의미
SOURCE소스 처리 후 클래스 파일에 남지 않을 수 있음
CLASS클래스 파일에는 남지만 실행 시 리플렉션에 보장되지 않음
RUNTIME실행 시 리플렉션으로 조회 가능

@Inherited는 클래스에 붙은 애너테이션을 하위 클래스 조회에서 상속된 것처럼 취급하도록 한다. 인터페이스와 메서드, 필드의 애너테이션을 일반적으로 상속시키는 기능은 아니다.

@Repeatable은 같은 선언에 같은 종류의 애너테이션을 여러 번 사용할 수 있게 한다.

타입 애너테이션

애너테이션은 선언뿐 아니라 타입이 사용되는 위치에도 붙을 수 있다.

List<@NonNull String> names;

@NonNull String loadName() {
    return "Java";
}

타입 애너테이션은 Java 언어가 자체적으로 널 안전성이나 불변성 의미를 부여하는 기능이 아니다. 정적 분석기와 컴파일러 플러그인, 프레임워크가 애너테이션을 해석하여 별도의 규칙을 적용할 수 있게 한다.

선언 애너테이션과 타입 애너테이션은 같은 @ 문법을 사용하지만 적용 대상과 리플렉션에서 조회되는 경로가 다르다.

리플렉션

실행 시점까지 유지된 애너테이션은 리플렉션 API로 조회할 수 있다.

if (UserController.class
        .isAnnotationPresent(Audited.class)) {
    System.out.println("audited component");
}

Class, Method, Field, Constructor, Parameter, RecordComponent 등은 애너테이션을 조회할 수 있는 AnnotatedElement를 구현한다.[36]

Audited annotation =
    UserController.class.getAnnotation(Audited.class);

var annotations =
    UserController.class.getDeclaredAnnotations();

리플렉션은 클래스와 멤버의 구조를 실행 중에 조사하고 메서드를 호출하거나 필드에 접근하는 기능도 제공한다. 다만 접근 제어와 모듈 캡슐화가 적용되며, 일반적인 정적 호출보다 실행 시점 검사가 많고 최적화 조건도 다를 수 있다.

예외 계층

예외는 정상적인 반환과 다른 경로로 메서드 실행을 중단하고 호출자에게 오류 상태를 전달한다. Java에서 던질 수 있는 객체는 Throwable의 하위 타입이어야 한다.[37]

주요 계층은 다음과 같다.

Throwable
├── Error
└── Exception
    └── RuntimeException

Error는 JVM과 실행 환경 수준의 심각한 상태를 나타내는 데 주로 사용된다.

OutOfMemoryError
StackOverflowError
LinkageError

Exception은 프로그램이 처리할 수 있는 예외 상황을 나타낸다. 그중 RuntimeException과 그 하위 타입은 비검사 예외다.

NullPointerException
IllegalArgumentException
IndexOutOfBoundsException
ClassCastException

검사 예외와 비검사 예외

Java는 예외를 컴파일러가 처리 여부를 검사하는지에 따라 구분한다.

검사 예외RuntimeException의 하위 타입이 아닌 일반적인 Exception 하위 타입이다. 메서드가 검사 예외를 발생시킬 수 있다면 해당 예외를 잡거나 throws로 선언해야 한다.

static String read(Path path)
        throws IOException {
    return Files.readString(path);
}

호출자는 예외를 처리하거나 다시 선언한다.

try {
    String content = read(path);
    System.out.println(content);
} catch (IOException exception) {
    System.err.println(exception.getMessage());
}

비검사 예외는 RuntimeException, Error와 그 하위 타입이다. 컴파일러는 이들의 처리나 선언을 강제하지 않는다.

static void setPort(int port) {
    if (port < 0 || port > 65535) {
        throw new IllegalArgumentException(
            "invalid port: " + port
        );
    }
}

검사 여부는 예외의 심각도와 동일한 개념이 아니다. 비검사 예외도 프로그램을 종료시킬 수 있고, 검사 예외도 상위 계층에서 일괄 처리될 수 있다.

throwthrows

throw는 예외 객체를 실제로 발생시킨다.

if (name == null) {
    throw new NullPointerException("name");
}

throws는 메서드나 생성자가 호출자에게 전달할 수 있는 검사 예외 타입을 선언한다.

static Configuration load(Path path)
        throws IOException, ParseException {
    String content = Files.readString(path);
    return parseConfiguration(content);
}

throws 선언은 예외를 자동으로 처리하지 않는다. 현재 메서드에서 잡지 않은 예외가 호출자 방향으로 전파될 수 있음을 타입 계약에 표시한다.

메서드 오버라이딩에서는 하위 클래스 메서드가 상위 메서드보다 더 넓은 새로운 검사 예외를 선언할 수 없다. 같은 예외나 더 구체적인 하위 예외는 선언할 수 있다.

try, catch, finally

try 블록에서 발생한 예외는 호환되는 catch 절에서 처리할 수 있다.

try {
    process();
} catch (IOException exception) {
    log(exception);
}

여러 예외 타입을 서로 다른 방식으로 처리할 수 있다.

try {
    loadConfiguration();
} catch (FileNotFoundException exception) {
    createDefaultConfiguration();
} catch (IOException exception) {
    reportReadFailure(exception);
}

더 구체적인 예외 타입을 상위 타입보다 먼저 배치해야 한다. 상위 타입이 먼저 오면 뒤의 하위 타입 catch는 도달할 수 없기 때문이다.

여러 예외를 같은 방식으로 처리할 때는 다중 catch를 사용할 수 있다.

try {
    processInput();
} catch (IOException | ParseException exception) {
    reportFailure(exception);
}

finally 블록은 정상 종료와 예외 종료에 관계없이 자원 정리 등의 작업을 수행한다.

Connection connection = openConnection();

try {
    useConnection(connection);
} finally {
    connection.close();
}

다만 finally 안에서 return하거나 새로운 예외를 던지면 원래 반환값이나 예외를 가릴 수 있으므로 일반적으로 정리 작업만 수행한다.

try-with-resources

AutoCloseable을 구현한 자원은 try-with-resources 문으로 관리할 수 있다.

try (BufferedReader reader =
         Files.newBufferedReader(path)) {

    String line;

    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
}

try 블록을 벗어나면 컴파일러가 자원의 close() 호출을 보장한다. 여러 자원을 선언하면 선언의 역순으로 닫힌다.

try (
    InputStream input = Files.newInputStream(source);
    OutputStream output = Files.newOutputStream(target)
) {
    input.transferTo(output);
}

본문에서 예외가 발생하고 자원을 닫는 중에도 다른 예외가 발생하면, 본문의 예외가 기본 예외로 유지되고 닫기 예외는 억제된 예외로 연결된다.

for (Throwable suppressed :
        exception.getSuppressed()) {
    log(suppressed);
}

이미 선언된 final 또는 사실상 최종 지역 변수도 자원으로 사용할 수 있다.

BufferedReader reader =
    Files.newBufferedReader(path);

try (reader) {
    System.out.println(reader.readLine());
}

사용자 정의 예외

도메인에 맞는 예외 타입은 Exception이나 RuntimeException을 상속하여 정의할 수 있다.

class ConfigurationException extends Exception {
    ConfigurationException(String message) {
        super(message);
    }

    ConfigurationException(
        String message,
        Throwable cause
    ) {
        super(message, cause);
    }
}

비검사 예외로 정의하려면 RuntimeException을 상속한다.

class InvalidStateException
        extends RuntimeException {

    InvalidStateException(String message) {
        super(message);
    }
}

원인이 된 예외를 cause로 보존하면 상위 계층에서 전체 오류 경로를 확인할 수 있다.

try {
    return parse(content);
} catch (ParseException exception) {
    throw new ConfigurationException(
        "configuration parsing failed",
        exception
    );
}

단언

assert 문은 개발자가 프로그램 내부에서 참이라고 기대하는 조건을 검사한다.

assert count >= 0;

조건이 거짓이면 AssertionError가 발생한다. 추가 메시지를 지정할 수도 있다.

assert index < values.length
    : "index out of range: " + index;

단언은 실행 옵션에 따라 비활성화될 수 있으므로 외부 입력 검증이나 반드시 실행되어야 하는 상태 검사에 사용하지 않는다.

if (port < 0 || port > 65535) {
    throw new IllegalArgumentException(
        "invalid port: " + port
    );
}

단언은 주로 개발 중 내부 불변 조건과 도달할 수 없다고 가정한 상태를 확인하는 보조 수단이다.

전통적인 애플리케이션 시작점

일반적인 Java 애플리케이션은 다음 형태의 main 메서드를 시작점으로 사용한다.

public class Application {
    public static void main(String[] args) {
        System.out.println("Application started");
    }
}

args에는 명령행 인수가 문자열 배열로 전달된다.

public class Arguments {
    public static void main(String[] args) {
        for (String argument : args) {
            System.out.println(argument);
        }
    }
}

JDK 실행 도구에서는 다음과 같이 실행할 수 있다.

java Arguments first second third

main 메서드는 일반적으로 public static void로 선언하고 String[] 또는 String... 매개변수를 사용한다.

public static void main(String... args) {
}

인스턴스 main 메서드

Java 25부터 실행 가능한 main 메서드는 반드시 public static void main(String[] args) 형식일 필요가 없다. 인스턴스 메서드와 인수가 없는 형태도 시작점으로 사용할 수 있다.[38]

class Application {
    void main() {
        System.out.println("Application started");
    }
}

인스턴스 main을 실행할 때 런처는 시작 클래스의 인스턴스를 생성한 뒤 해당 메서드를 호출한다. 클래스는 사용할 수 있는 인수 없는 생성자를 제공해야 한다.

명시적인 클래스 안에서도 인스턴스 main을 사용할 수 있다.

class CounterApplication {
    private int count;

    void main() {
        count++;
        System.out.println(count);
    }
}

인스턴스 시작점은 작은 프로그램에서 모든 필드와 보조 메서드에 static을 반복하지 않고 객체의 일반적인 구조로 확장할 수 있게 한다.

간결한 소스 파일

작은 프로그램은 명시적인 클래스 선언 없이 필드와 메서드를 작성하는 간결한 소스 파일로 구성할 수 있다.

void main() {
    System.out.println("Hello, Java");
}

다른 메서드와 필드도 함께 선언할 수 있다.

String greeting = "Hello, Java";

void printGreeting() {
    System.out.println(greeting);
}

void main() {
    printGreeting();
}

이 코드는 최상위 실행문을 허용하는 별도 Java 방언이 아니다. 컴파일러는 필드와 메서드를 이름이 소스 코드에서 직접 사용되지 않는 암시적 클래스의 멤버로 처리한다.

간결한 소스 파일은 다음과 같은 성격을 가진다.

  • 이름 없는 패키지에 속한다.
  • 암시적으로 선언된 최종 클래스를 사용한다.
  • 실행 가능한 main 메서드를 포함해야 한다.
  • 필드와 메서드는 암시적 클래스의 멤버가 된다.
  • 일반적인 Java 컴파일러와 실행 도구를 사용한다.
  • 프로그램이 커지면 명시적인 클래스 선언으로 감쌀 수 있다.

간결한 소스 파일에서는 java.base 모듈이 자동으로 모듈 임포트된 것처럼 처리된다. 따라서 java.util, java.io, java.mathjava.base가 내보내는 패키지의 공개 타입을 별도 임포트 없이 사용할 수 있다.

void main() {
    var languages = List.of(
        "Java",
        "Kotlin",
        "Scala"
    );

    for (var language : languages) {
        IO.println(language);
    }
}

간결한 소스 파일도 실행문을 파일 최상위에 직접 나열하지 않는다. 실행할 코드는 main 또는 다른 메서드의 본문에 작성한다.

단일 소스 파일 실행

java 실행 도구는 소스 파일 하나를 명시적으로 컴파일하지 않고 직접 실행하는 형태도 지원한다.

java Application.java

실행 도구는 메모리 안에서 소스를 컴파일한 뒤 시작 클래스를 실행한다. 이는 별도의 스크립트 언어가 아니라 일반 Java 소스를 간편하게 실행하는 경로다.

class Application {
    public static void main(String[] args) {
        System.out.println("single-file program");
    }
}

간결한 소스 파일과 결합하면 다음처럼 실행할 수 있다.

void main() {
    IO.println("single-file program");
}
java Application.java

이 방식은 작은 명령행 도구와 예제, 실험용 프로그램에 사용할 수 있으며, 같은 파일을 javac으로 컴파일하여 일반 클래스 파일로 만들 수도 있다.

핵심 문법의 구성

Java의 핵심 문법은 선언과 표현식, 문장을 분리하면서 서로 결합한다.

  • 선언은 타입과 변수, 메서드, 패키지 등의 이름과 구조를 만든다.
  • 표현식은 값을 계산하거나 객체를 생성하고 메서드를 호출한다.
  • 문장은 표현식 실행과 분기, 반복, 반환, 예외 처리를 구성한다.
  • 블록은 선언과 문장을 범위 단위로 묶는다.
  • 타입 검사는 대입과 호출, 반환 및 연산이 허용되는지 컴파일 시점에 확인한다.
  • 예외 처리는 정상 반환과 다른 제어 흐름을 호출 계층으로 전달한다.
  • 애너테이션은 컴파일러와 도구, 실행 환경이 사용할 메타데이터를 선언에 결합한다.

이 구조는 클래스 중심의 대규모 프로그램뿐 아니라 람다와 패턴 매칭, switch 표현식, 간결한 소스 파일을 사용하는 작은 프로그램에도 동일한 언어 규칙을 적용한다. Java의 문법은 기능이 추가되면서 확장되어 왔지만, 새 기능을 별도 언어로 분리하지 않고 기존 타입 체계와 블록, 표현식 및 클래스 모델 위에 통합하는 방향을 유지한다.

메모리와 자원 관리

Java는 객체의 메모리를 개발자가 직접 해제하지 않고 JVM의 자동 메모리 관리에 맡긴다. 프로그램은 new를 통해 객체와 배열을 생성하지만, C와 C++의 freedelete에 대응하는 일반적인 객체 해제 연산을 제공하지 않는다. JVM은 실행 중 객체의 도달 가능성을 추적하고, 프로그램에서 더 이상 사용할 수 없다고 판단한 객체의 메모리를 가비지 컬렉션으로 회수한다.

그러나 자동 메모리 관리가 프로그램이 사용하는 모든 자원의 자동 반환을 의미하지는 않는다. 파일 디스크립터와 네트워크 소켓, 데이터베이스 연결, 잠금, 네이티브 메모리와 그래픽 장치 객체처럼 JVM 힙 밖에 존재하거나 외부 시스템과 연결된 자원은 사용이 끝나는 시점에 명시적으로 닫아야 한다. Java는 이러한 자원을 AutoCloseabletry-with-resources를 통해 결정적으로 관리한다.[39]

따라서 Java의 자원 관리는 크게 다음 두 영역으로 나뉜다.

  • 관리되는 메모리: Java 객체와 배열의 수명을 JVM과 가비지 컬렉터가 관리한다.
  • 명시적인 자원: 파일과 소켓, 연결, 네이티브 메모리 등의 수명을 프로그램이 close() 또는 별도의 수명 객체로 관리한다.

JVM의 메모리 영역

JVM은 프로그램 실행에 필요한 메모리를 여러 논리적 실행 시점 데이터 영역으로 나눈다. JVM 명세는 각 영역의 역할과 오류 조건을 정의하지만, 실제 메모리 배치와 내부 자료구조는 구현체가 선택할 수 있다.[40]

주요 영역은 다음과 같다.

  • JVM 스택
  • 메서드 영역
  • 런타임 상수 풀
  • PC 레지스터
  • 네이티브 메서드 스택

이 구분은 JVM의 추상 실행 모델이다. HotSpot과 같은 실제 구현체는 코드 캐시와 메타스페이스, 스레드 로컬 할당 버퍼, 가비지 컬렉터 내부 자료구조 등 추가적인 메모리 영역을 사용할 수 있다.

은 모든 JVM 스레드가 공유하는 실행 시점 데이터 영역이며, 클래스 인스턴스와 배열이 할당되는 곳이다. 힙은 JVM이 시작될 때 생성되며 자동 메모리 관리 시스템의 대상이 된다.[41]

User user = new User("Luna");
int[] values = new int[1024];

위 코드에서 User 객체와 int 배열은 개념적으로 힙에 할당된다. 지역 변수 uservalues에 저장된 참조는 현재 메서드의 스택 프레임에 존재할 수 있지만, 실제 객체와 배열의 저장 위치는 JVM 구현체와 최적화에 따라 달라질 수 있다.

JVM 명세는 힙이 연속된 물리 메모리여야 한다고 요구하지 않는다. 힙의 초기 크기와 최대 크기는 고정하거나 동적으로 확장할 수 있으며, 필요한 객체를 할당할 수 없고 더 이상 확장할 수도 없으면 JVM은 OutOfMemoryError를 발생시킨다.

List<byte[]> blocks = new ArrayList<>();

while (true) {
    blocks.add(new byte[1024 * 1024]);
}

이 코드는 생성한 배열을 목록에서 계속 참조하므로 배열이 가비지 컬렉션 대상이 되지 않는다. 사용 가능한 힙을 모두 소비하면 일반적으로 OutOfMemoryError가 발생한다.

JVM 스택과 프레임

각 JVM 스레드는 자신만의 JVM 스택을 가진다. 메서드가 호출될 때마다 새로운 프레임이 생성되고, 메서드가 반환하거나 예외로 종료되면 해당 프레임이 제거된다.

프레임에는 일반적으로 다음 정보가 포함된다.

  • 지역 변수 배열
  • 피연산자 스택
  • 현재 클래스의 런타임 상수 풀에 대한 참조
  • 메서드 호출과 반환을 위한 상태
static int calculate(int left, int right) {
    int result = left + right;
    return result;
}

left, right, result는 메서드 프레임의 지역 변수 슬롯에 저장될 수 있다. 기본 타입 변수는 값을 직접 저장하고, 참조 타입 변수는 객체를 가리키는 참조 값을 저장한다.

JVM 스택의 크기는 고정하거나 동적으로 확장할 수 있다. 스레드가 허용된 스택 깊이보다 많은 프레임을 생성하면 StackOverflowError가 발생할 수 있다.

static void recurse() {
    recurse();
}

이 메서드는 종료 조건 없이 자기 자신을 호출하므로 호출 프레임이 계속 쌓인다. 스택이 더 이상 프레임을 수용할 수 없으면 StackOverflowError가 발생한다.

스택 프레임이 제거되면 프레임 안의 지역 변수도 더 이상 사용할 수 없다. 이로 인해 객체를 가리키는 마지막 참조가 사라질 수 있지만, 객체의 힙 메모리가 그 순간 즉시 반환된다는 의미는 아니다. 객체는 이후 가비지 컬렉터가 회수할 수 있는 상태가 된다.

메서드 영역과 클래스 메타데이터

메서드 영역은 JVM이 로드한 클래스와 인터페이스의 구조, 메서드 코드, 런타임 상수 풀, 정적 필드 등의 정보를 저장하는 논리적 영역이다. 모든 JVM 스레드가 공유하며 JVM 시작 시 생성된다.[42]

JVM 명세는 메서드 영역을 힙의 논리적인 일부로 설명하지만, 실제 구현이 힙과 같은 방식으로 관리해야 하는 것은 아니다. HotSpot은 현대 JDK에서 클래스 메타데이터를 주로 네이티브 메모리에 위치한 메타스페이스에 저장한다.

클래스 로더가 더 이상 도달 가능하지 않고 해당 로더가 정의한 클래스도 사용되지 않으면 JVM은 클래스 언로딩을 수행할 수 있다. 따라서 동적으로 많은 클래스 로더를 생성하면서 참조를 계속 유지하면 클래스 메타데이터가 누적될 수 있다.

List<ClassLoader> loaders = new ArrayList<>();

while (running) {
    ClassLoader loader = createPluginClassLoader();
    loaders.add(loader);
}

각 클래스 로더가 로드한 클래스와 관련 메타데이터는 로더가 도달 가능한 동안 유지될 수 있다. 장시간 실행되는 플러그인 시스템과 애플리케이션 서버에서는 클래스 로더를 통한 메모리 보존도 관리해야 한다.

객체 할당

Java에서 객체는 일반적으로 new 표현식으로 생성된다.

Account account = new Account("Luna", 1000);

개념적인 객체 생성 과정은 다음과 같다.

  1. 대상 클래스가 로딩·링킹·초기화되었는지 확인한다.
  2. 객체에 필요한 메모리를 확보한다.
  3. 인스턴스 필드를 기본값으로 설정한다.
  4. 상위 클래스부터 생성자와 초기화 코드를 실행한다.
  5. 완성된 객체의 참조를 반환한다.

힙에 충분한 공간이 없으면 JVM은 가비지 컬렉션을 시도하거나 힙을 확장할 수 있다. 그래도 할당할 수 없으면 OutOfMemoryError가 발생한다.

실제 JVM은 객체 할당 비용을 줄이기 위해 스레드별 할당 공간을 사용할 수 있다. HotSpot의 스레드 로컬 할당 버퍼는 여러 스레드가 공유 힙의 같은 할당 위치를 매번 동기화하지 않고도 빠르게 객체를 할당할 수 있게 한다.

JIT 컴파일러는 객체가 현재 메서드나 스레드 밖으로 전달되지 않는다고 판단하면 탈출 분석을 적용할 수 있다. 그 결과 객체의 필드를 개별 값처럼 처리하거나 실제 객체 할당 일부를 제거할 수 있다. 다만 이러한 최적화는 JVM 구현의 선택이며 Java 언어가 객체를 반드시 스택에 할당한다고 보장하는 기능은 아니다.

객체의 도달 가능성

가비지 컬렉터는 일반적으로 객체가 프로그램에서 도달 가능한지를 기준으로 수명을 판단한다. 객체는 실행 중인 스레드와 정적 필드, JNI 참조 등으로부터 시작되는 루트 집합에서 참조 경로를 따라 접근할 수 있으면 강하게 도달 가능한 상태다.

대표적인 GC 루트는 다음과 같다.

  • 실행 중인 스레드의 스택에 있는 지역 변수와 매개변수
  • 로드된 클래스의 정적 필드
  • JVM 내부에서 유지하는 참조
  • JNI 전역 참조
  • 실행 중인 동기화와 클래스 로딩 관련 객체
static User currentUser;

static void loadUser() {
    User local = new User("Luna");
    currentUser = local;
}

loadUser가 반환된 뒤 지역 변수 local은 사라지지만, 정적 필드 currentUser가 객체를 계속 참조하므로 객체는 도달 가능하다.

static void createTemporaryUser() {
    User temporary = new User("temporary");
}

메서드가 반환되고 다른 곳에 참조를 저장하지 않았다면 생성된 객체는 더 이상 프로그램에서 도달할 수 없게 된다. 이 객체는 GC가 회수할 수 있지만, 정확히 언제 회수되는지는 보장되지 않는다.

가비지 컬렉션

가비지 컬렉션은 더 이상 사용할 수 없는 객체의 메모리를 자동으로 회수하는 과정이다. JVM 명세는 자동 저장 공간 관리 시스템이 힙을 관리해야 한다고 규정하지만, 어떤 수집 알고리즘을 사용하고 언제 수집할지는 특정하지 않는다.

가비지 컬렉터는 일반적으로 다음 작업을 조합한다.

  • 도달 가능한 객체 식별
  • 도달 불가능한 객체의 메모리 회수
  • 단편화를 줄이기 위한 객체 이동 또는 압축
  • 객체 참조 갱신
  • 세대와 영역별 메모리 관리
  • 애플리케이션 스레드와 병행하는 추적 및 정리

객체가 순환 참조를 구성해도 외부 루트에서 도달할 수 없다면 회수할 수 있다.

class Node {
    Node next;
}

static void createCycle() {
    Node first = new Node();
    Node second = new Node();

    first.next = second;
    second.next = first;
}

메서드가 반환된 뒤 firstsecond를 가리키는 외부 참조가 없다면 두 객체는 서로를 참조하더라도 전체 순환 구조가 도달 불가능해진다. Java의 GC는 단순한 참조 횟수 방식에만 의존하지 않으므로 이러한 순환 객체도 회수할 수 있다.

세대별 수집

많은 JVM 가비지 컬렉터는 대부분의 객체가 생성 후 짧은 시간 안에 사용되지 않게 된다는 관찰을 활용하여 힙을 세대나 영역으로 나눈다.

전통적인 세대 구분은 다음과 같다.

  • 젊은 세대: 새로 생성된 객체가 주로 위치한다.
  • 오래된 세대: 여러 번의 수집을 살아남은 객체가 이동하거나 유지된다.

젊은 세대의 수집은 비교적 자주 발생하며, 살아남는 객체 수가 적을 때 빠르게 처리할 수 있다. 오래된 세대는 장기간 유지되는 객체가 많아 수집 빈도와 비용이 다를 수 있다.

그러나 모든 가비지 컬렉터가 동일한 물리 구조와 수집 방식을 사용하는 것은 아니다. 영역 기반 컬렉터는 힙을 여러 영역으로 나누고 객체 생존 상태와 수집 효율을 기준으로 처리할 영역을 선택할 수 있다.

Stop-the-world와 동시 처리

가비지 컬렉터가 객체 그래프를 안정적으로 검사하거나 객체를 이동하려면 일부 시점에 애플리케이션 스레드를 멈출 수 있다. 모든 Java 애플리케이션 스레드가 일시 중단되는 구간을 일반적으로 stop-the-world 일시 정지라고 부른다.

수집기의 일부 작업은 애플리케이션과 동시에 수행할 수 있다.

  • 객체 그래프 추적
  • 참조 정보 처리
  • 사용하지 않는 영역 정리
  • 객체 이동을 위한 준비
  • 힙 상태 분석

낮은 지연 시간을 목표로 하는 수집기는 가능한 많은 작업을 동시 수행하고 일시 정지 구간을 줄인다. 반대로 처리량을 목표로 하는 수집기는 애플리케이션을 잠시 멈추더라도 전체 수집 작업을 빠르게 끝내는 방향을 선택할 수 있다.

완전히 일시 정지가 없는 가비지 컬렉션을 일반적으로 보장하는 것은 아니며, JVM 초기화와 안전 지점 처리, 스레드 스택 검사 등의 이유로 짧은 정지 구간이 남을 수 있다.

주요 가비지 컬렉터

JDK는 실행 환경과 목표에 따라 선택할 수 있는 여러 가비지 컬렉터를 제공한다. 실제 지원 여부와 기본 수집기는 JDK 배포판과 플랫폼에 따라 달라질 수 있다.

Serial GC

Serial GC는 주요 수집 작업을 하나의 GC 스레드에서 수행한다. 작은 힙과 단순한 응용 프로그램, 제한된 프로세서 환경에서 사용할 수 있다.

java -XX:+UseSerialGC Application

구조가 단순하지만 수집 중 애플리케이션이 멈추므로 큰 힙과 낮은 지연 시간이 필요한 서버에는 적합하지 않을 수 있다.

Parallel GC

Parallel GC는 여러 스레드를 사용하여 가비지 컬렉션 작업을 병렬 처리한다. 일시 정지 시간을 최소화하기보다 전체 애플리케이션 처리량을 높이는 데 중점을 둔다.

java -XX:+UseParallelGC Application

대량 계산과 배치 처리처럼 처리량이 중요한 환경에서 사용할 수 있다.

G1 GC

G1은 힙을 같은 크기의 여러 영역으로 나누고, 회수 효율이 높은 영역부터 선택적으로 수집한다. 젊은 세대와 오래된 세대의 수집을 영역 기반 구조 안에서 수행하며, 큰 힙에서 일시 정지 목표를 관리하는 범용 수집기로 사용된다.

java -XX:+UseG1GC Application

G1은 일부 표시 작업을 애플리케이션과 동시에 수행하고, 정지 구간에 처리할 영역의 양을 조정하여 사용자가 지정한 일시 정지 목표에 접근한다. 목표는 절대적인 최대 정지 시간 보장이 아니라 수집 정책을 위한 기준이다.

ZGC

ZGC는 대용량 힙에서도 짧은 일시 정지를 목표로 하는 동시 가비지 컬렉터다. 객체 추적과 재배치의 대부분을 애플리케이션 스레드와 동시에 처리한다.

java -XX:+UseZGC Application

ZGC의 일시 정지는 일반적으로 전체 힙 크기에 비례하지 않도록 설계되어 있으며, 지연 시간에 민감한 서버와 큰 메모리 환경에서 사용할 수 있다.

Shenandoah GC

Shenandoah는 객체 압축과 재배치를 포함한 주요 작업을 애플리케이션 스레드와 동시에 수행하는 낮은 일시 정지 시간 수집기다.

java -XX:+UseShenandoahGC Application

Java 26의 java 명령 문서는 Shenandoah를 일시 정지 시간이 힙 크기에 비례하지 않도록 설계된 동시 수집기로 설명한다.[43]

수집기 선택은 힙 크기만으로 결정되지 않는다. 처리량과 지연 시간, CPU 사용량, 객체 할당 속도, 컨테이너 제한, 장시간 유지되는 객체의 비율과 운영 환경을 함께 고려한다.

GC 실행 요청

System.gc()는 JVM에 가비지 컬렉션을 요청한다.

System.gc();

그러나 이 호출은 특정 객체가 즉시 회수되거나 전체 수집이 반드시 실행된다는 보장이 아니다. JVM 구현체와 실행 옵션은 요청을 무시하거나 다른 방식으로 처리할 수 있다.

일반 애플리케이션은 메모리 압력이 발생했을 때 JVM이 적절한 시점을 선택하도록 맡기는 경우가 많다. 성능 측정이나 특정 네이티브 자원 구조처럼 명확한 이유가 있을 때만 실행 요청을 고려할 수 있다.

객체가 회수되었는지 확인하기 위해 System.gc()와 시간 지연에 의존하는 코드는 안정적인 수명 관리 방식이 아니다.

메모리 누수

가비지 컬렉션을 사용하는 언어에서도 메모리 누수는 발생할 수 있다. Java에서 메모리 누수는 주로 더 이상 필요하지 않은 객체가 여전히 도달 가능한 상태로 유지되어 GC가 회수할 수 없는 상황을 뜻한다.

대표적인 원인은 다음과 같다.

  • 정적 컬렉션에 객체를 계속 추가함
  • 캐시에 제거 정책이 없음
  • 이벤트 리스너와 콜백 등록을 해제하지 않음
  • 스레드 로컬 값을 제거하지 않음
  • 종료되지 않는 스레드가 객체 그래프를 유지함
  • 클래스 로더를 전역 객체가 참조함
  • 큐에서 처리된 요소를 제거하지 않음
  • 내부 클래스나 람다가 바깥 객체를 예상보다 오래 참조함
class UserCache {
    private static final Map<Long, User> users =
        new HashMap<>();

    static void store(User user) {
        users.put(user.id(), user);
    }
}

이 캐시에 제거 조건이 없다면 저장된 모든 User 객체는 정적 필드에서 계속 도달 가능하다. 사용되지 않는 객체라도 프로그램이 참조를 유지하므로 GC가 회수할 수 없다.

제한된 크기와 만료 시간을 가진 캐시를 사용하거나, 필요에 따라 약한 참조 기반 구조를 사용할 수 있다.

class UserCache {
    private final Map<Long, User> users =
        new LinkedHashMap<>();

    void remove(long id) {
        users.remove(id);
    }

    void clear() {
        users.clear();
    }
}

스레드 로컬과 메모리 보존

ThreadLocal은 스레드별로 서로 다른 값을 저장한다.

private static final ThreadLocal<Context> context =
    new ThreadLocal<>();

스레드 풀의 작업 스레드는 여러 요청 사이에서 오래 유지된다. 작업이 끝난 뒤 ThreadLocal 값을 제거하지 않으면 이전 요청의 객체가 스레드와 함께 계속 유지될 수 있다.

context.set(createContext());

try {
    processRequest();
} finally {
    context.remove();
}

finally에서 remove()를 호출하면 작업이 정상적으로 끝나거나 예외가 발생해도 스레드별 값이 제거된다.

강한 참조

일반적인 Java 참조는 강한 참조다.

User user = new User("Luna");

객체가 강한 참조 경로를 통해 GC 루트에서 도달 가능하면 가비지 컬렉터는 해당 객체를 회수하지 않는다.

강한 참조를 null로 설정하면 참조 경로 하나를 제거할 수 있다.

user = null;

그러나 다른 참조가 남아 있다면 객체는 계속 도달 가능하다. 또한 지역 변수는 JIT 컴파일러가 실제 마지막 사용 이후 더 이상 살아 있지 않은 것으로 판단할 수 있으므로, 소스 코드의 블록 끝과 객체의 정확한 수명이 항상 일치하는 것은 아니다.

소프트 참조

SoftReference는 메모리 부족에 민감한 참조를 표현한다. 강하게 도달할 수 없는 객체가 소프트 참조를 통해서만 도달 가능한 경우, JVM은 메모리 요구에 대응하기 위해 참조 대상을 제거할 수 있다.

SoftReference<byte[]> cache =
    new SoftReference<>(loadLargeData());

byte[] data = cache.get();

if (data == null) {
    data = loadLargeData();
    cache = new SoftReference<>(data);
}

소프트 참조는 JVM의 메모리 상태에 따라 제거 시점이 결정되므로 정확한 캐시 크기와 만료 정책을 제어하기 어렵다. 예측 가능한 캐시가 필요하면 크기 제한과 시간 기반 만료 정책을 가진 일반 캐시 구현을 사용할 수 있다.

약한 참조

WeakReference의 대상은 더 이상 강하게 또는 소프트하게 도달할 수 없으면 다음 GC 과정에서 제거될 수 있다.

WeakReference<User> reference =
    new WeakReference<>(user);

user = null;

User value = reference.get();

if (value != null) {
    use(value);
}

get()을 호출하는 사이에도 다른 스레드나 GC 상태에 따라 객체가 사라질 수 있으므로, 반환값을 지역 변수에 저장하고 null을 확인한 뒤 사용한다.

약한 참조는 객체의 존재가 다른 객체의 수명을 강제로 연장해서는 안 되는 보조 매핑과 등록 구조에 활용할 수 있다. WeakHashMap은 키가 일반적인 다른 강한 참조에서 도달할 수 없게 되면 관련 항목이 제거될 수 있는 맵이다.

Map<ClassLoader, Metadata> metadata =
    new WeakHashMap<>();

다만 값이 다시 키를 강하게 참조하면 전체 항목이 계속 도달 가능한 상태가 될 수 있으므로 객체 그래프를 함께 고려해야 한다.

팬텀 참조와 참조 큐

PhantomReference는 객체가 더 이상 일반적인 방식으로 접근될 수 없고, 수집기가 해당 객체를 회수할 수 있는 단계에 도달했음을 감지하는 데 사용한다. 팬텀 참조의 get()은 객체를 다시 사용할 수 없도록 항상 null을 반환한다.

팬텀 참조는 ReferenceQueue와 함께 사용한다.

ReferenceQueue<ResourceOwner> queue =
    new ReferenceQueue<>();

PhantomReference<ResourceOwner> reference =
    new PhantomReference<>(owner, queue);

참조 대상이 적절한 도달 가능성 상태에 이르면 JVM은 팬텀 참조를 큐에 등록할 수 있다. 별도 관리 코드는 큐에서 참조 객체를 꺼내 관련 보조 정보를 정리할 수 있다.

Reference<? extends ResourceOwner> reference;

while ((reference = queue.poll()) != null) {
    cleanupMetadata(reference);
}

팬텀 참조는 객체의 메서드를 호출하여 자원을 해제하는 구조가 아니다. 정리 작업에 필요한 상태는 참조 대상 객체와 독립적으로 저장해야 한다.

Java의 참조 API는 강한 도달 가능성 외에도 소프트, 약한, 최종화 가능, 팬텀 도달 가능성과 도달 불가능 상태를 구분한다.[44]

reachabilityFence

JIT 컴파일러는 객체의 마지막 실제 사용이 끝났다고 판단하면 소스 코드의 블록이 끝나기 전에도 객체를 도달 불가능한 것으로 취급할 수 있다. 객체의 수명과 연결된 네이티브 자원을 직접 다루는 저수준 코드에서는 이 최적화가 문제가 될 수 있다.

Reference.reachabilityFence는 특정 지점까지 객체가 강하게 도달 가능한 상태로 유지되어야 함을 명시한다.

class NativeResource {
    private long address;

    void process() {
        try {
            nativeProcess(address);
        } finally {
            Reference.reachabilityFence(this);
        }
    }
}

이 기능은 일반적인 Java 객체 사용에서 자주 필요하지 않다. 객체의 Java 참조와 네이티브 수명이 분리되는 JNI, 직접 메모리, Cleaner 기반 구현과 같은 저수준 코드에서 사용한다.

자동 메모리 관리와 외부 자원의 차이

힙 메모리는 객체가 더 이상 도달 가능하지 않을 때 GC가 회수할 수 있다. 반면 파일과 소켓 같은 운영체제 자원은 객체가 도달 불가능해질 때까지 기다리면 자원 한도에 먼저 도달할 수 있다.

for (Path path : paths) {
    InputStream input = Files.newInputStream(path);
    process(input);
}

이 코드는 각 스트림을 명시적으로 닫지 않는다. 스트림 객체 자체는 나중에 GC될 수 있지만, 파일 디스크립터가 필요한 시점에 반환된다고 보장되지 않는다.

외부 자원은 객체 메모리와 수명을 분리해 생각해야 한다.

  • 객체의 힙 메모리: GC가 비결정적인 시점에 회수
  • 객체가 소유한 외부 자원: 사용이 끝난 직후 명시적으로 반환
  • 보조 정리 장치: 명시적 반환을 놓친 경우의 최후 방어선

AutoCloseable

명시적으로 닫아야 하는 객체는 AutoCloseable을 구현할 수 있다.

class Session implements AutoCloseable {
    private boolean closed;

    void execute() {
        if (closed) {
            throw new IllegalStateException(
                "session is closed"
            );
        }

        System.out.println("executing");
    }

    @Override
    public void close() {
        if (closed) {
            return;
        }

        closed = true;
        releaseResources();
    }

    private void releaseResources() {
    }
}

AutoCloseable.close()는 객체가 보유한 기반 자원을 반환한다. 인터페이스의 메서드는 Exception을 던질 수 있도록 선언되어 있지만, 구현체는 가능한 경우 더 구체적인 예외를 선언하거나 예외를 던지지 않을 수 있다.

close()는 한 번 이상 호출될 가능성을 고려하여 가능하면 멱등적으로 설계할 수 있다. 자원 반환 도중 예외가 발생하더라도 내부 상태를 먼저 닫힘으로 표시하고 이미 반환 가능한 자원을 정리하면 중복 사용과 불완전한 종료 문제를 줄일 수 있다.

try-with-resources

try-with-resources는 AutoCloseable 객체를 블록이 끝날 때 자동으로 닫는다.

try (InputStream input =
         Files.newInputStream(path)) {
    process(input);
}

실행 흐름은 개념적으로 다음과 같다.

  1. 자원 객체를 생성한다.
  2. try 본문을 실행한다.
  3. 정상 반환과 예외 발생 여부에 관계없이 close()를 호출한다.
  4. 필요한 경우 본문과 종료 중 발생한 예외를 함께 보존한다.

여러 자원을 선언하면 선언한 순서의 역순으로 닫는다.

try (
    InputStream input =
        Files.newInputStream(source);

    OutputStream output =
        Files.newOutputStream(target)
) {
    input.transferTo(output);
}

위 코드에서는 output이 먼저 닫히고 input이 나중에 닫힌다.

이미 선언된 final 또는 사실상 최종 변수도 자원 목록에 사용할 수 있다.

BufferedReader reader =
    Files.newBufferedReader(path);

try (reader) {
    System.out.println(reader.readLine());
}

블록이 끝난 뒤 변수 이름은 범위에 남을 수 있지만 자원은 이미 닫혀 있으므로 다시 사용하면 안 된다.

억제된 예외

try 본문에서 예외가 발생하고 자원을 닫는 중에도 예외가 발생할 수 있다.

try (Resource resource = openResource()) {
    resource.process();
}

본문의 예외가 기본 예외로 유지되고, close()에서 발생한 예외는 억제된 예외로 연결된다. 이를 통해 자원 정리 오류가 원래 실패 원인을 덮어쓰지 않는다.

try {
    processResource();
} catch (Exception exception) {
    System.err.println(exception);

    for (Throwable suppressed :
            exception.getSuppressed()) {
        System.err.println(
            "suppressed: " + suppressed
        );
    }
}

직접 finally에서 자원을 닫는 코드에서는 이러한 예외 관계를 수동으로 처리해야 하므로, AutoCloseable 자원에는 try-with-resources가 기본적인 관리 방식으로 사용된다.

스트림과 자원 소유권

모든 Stream 객체를 반드시 닫아야 하는 것은 아니다. 컬렉션이나 배열에서 생성한 메모리 기반 스트림은 일반적으로 닫을 외부 자원을 보유하지 않는다.

List<String> names = List.of(
    "Java",
    "Kotlin",
    "Scala"
);

long count = names.stream()
    .filter(name -> name.length() > 4)
    .count();

반면 Files.lines, 디렉터리 탐색, 일부 I/O 채널에서 생성한 스트림은 파일과 같은 외부 자원을 보유할 수 있다.

try (Stream<String> lines =
         Files.lines(path)) {
    long count = lines
        .filter(line -> !line.isBlank())
        .count();
}

API 문서는 반환된 스트림을 닫아야 하는지 명시한다. 자원 소유 스트림은 try-with-resources로 관리한다.

자원 소유권 설계

객체가 다른 자원을 전달받을 때 누가 그 자원을 닫을지 명확히 정해야 한다.

class MessageReader implements AutoCloseable {
    private final BufferedReader reader;

    MessageReader(BufferedReader reader) {
        this.reader = reader;
    }

    String readMessage() throws IOException {
        return reader.readLine();
    }

    @Override
    public void close() throws IOException {
        reader.close();
    }
}

이 구현은 MessageReader가 전달받은 BufferedReader의 소유권을 넘겨받고 함께 닫는 구조다.

다른 설계에서는 호출자가 자원을 계속 소유하고 래퍼 객체는 닫지 않을 수 있다. 어느 방식이든 API 문서와 타입 구조에서 소유권을 명확히 표현해야 한다.

일반적으로 다음 질문을 기준으로 결정한다.

  • 자원을 누가 생성했는가
  • 자원을 누가 마지막으로 사용하는가
  • 여러 객체가 자원을 공유하는가
  • 래퍼를 닫으면 기반 자원도 닫히는가
  • 자원 수명이 메서드, 객체 또는 애플리케이션 전체 중 어디에 속하는가

Cleaner

Cleaner는 객체가 팬텀 도달 가능 상태가 되었을 때 등록된 정리 작업을 실행할 수 있게 한다. 명시적인 close()가 호출되지 않은 경우 네이티브 자원을 정리하기 위한 보조 장치로 사용할 수 있다.

class NativeBuffer implements AutoCloseable {
    private static final Cleaner CLEANER =
        Cleaner.create();

    private static final class State
            implements Runnable {

        private long address;

        State(long address) {
            this.address = address;
        }

        @Override
        public void run() {
            if (address == 0) {
                return;
            }

            freeNativeMemory(address);
            address = 0;
        }
    }

    private final State state;
    private final Cleaner.Cleanable cleanable;

    NativeBuffer(long size) {
        state = new State(
            allocateNativeMemory(size)
        );

        cleanable = CLEANER.register(
            this,
            state
        );
    }

    @Override
    public void close() {
        cleanable.clean();
    }

    private static native long
        allocateNativeMemory(long size);

    private static native void
        freeNativeMemory(long address);
}

정리 상태 객체는 소유 객체를 강하게 참조해서는 안 된다. 위 예제의 StateNativeBuffer 인스턴스를 참조하면 Cleaner 등록 자체가 객체를 계속 도달 가능하게 만들어 정리가 실행되지 않을 수 있다.

Cleaner의 실행 시점은 보장되지 않는다. 따라서 필수적인 자원 반환은 close()try-with-resources로 수행하고, Cleaner는 호출자가 명시적인 종료를 놓친 경우를 위한 보조 장치로 사용한다.[45]

Finalization

과거 Java는 클래스가 Object.finalize()를 오버라이딩하여 객체가 회수되기 전에 정리 코드를 실행할 수 있게 했다.

class LegacyResource {
    @Override
    protected void finalize()
            throws Throwable {
        try {
            releaseResource();
        } finally {
            super.finalize();
        }
    }

    private void releaseResource() {
    }
}

Finalization은 실행 시점과 실행 여부가 예측하기 어렵고, 객체 회수를 지연하며, 객체가 종료 과정에서 자신을 다시 도달 가능하게 만들 수 있다. 또한 종료 코드가 임의의 스레드에서 실행되면서 보안과 동시성 및 안정성 문제를 만들 수 있다.

JEP 421은 finalization을 제거 예정으로 폐기했으며, 명시적 자원 관리와 Cleaner 등의 대체 방식을 사용하도록 한다.[46]

Java SE 26에서도 finalization 관련 API는 제거 예정으로 폐기된 상태다. 새로운 클래스는 finalize()에 자원 관리를 의존하지 않는다.

네이티브 메모리

Java 프로그램은 JVM 힙 밖의 네이티브 메모리도 사용할 수 있다. 네이티브 메모리는 다음 영역에서 사용될 수 있다.

  • JVM 자체 자료구조
  • 클래스 메타데이터
  • JIT 컴파일된 코드
  • 스레드 스택
  • 직접 바이트 버퍼
  • 메모리 매핑 파일
  • JNI 라이브러리
  • Foreign Function and Memory API
  • 그래픽과 데이터베이스 및 네트워크 라이브러리

네이티브 메모리 사용량은 Java 힙 최대 크기와 별개다. 따라서 힙에 여유가 있어도 프로세스의 주소 공간이나 운영체제 메모리가 부족하면 오류가 발생할 수 있다.

ByteBuffer buffer =
    ByteBuffer.allocateDirect(1024 * 1024);

직접 바이트 버퍼는 네이티브 I/O와 상호 운용하기 적합한 힙 밖 메모리를 사용할 수 있다. 기반 메모리의 반환은 버퍼 객체의 도달 가능성과 JVM 내부 정리에 연결되므로, 많은 대형 버퍼를 짧은 시간에 생성하면 힙 사용량과 별개로 네이티브 메모리 압력이 발생할 수 있다.

Foreign Function and Memory API

Foreign Function and Memory API는 Java 코드가 외부 함수와 힙 밖 메모리를 표준 API로 다룰 수 있게 한다. MemorySegment는 연속된 메모리 영역을 표현하고, Arena는 해당 영역의 수명과 접근 가능한 스레드를 관리한다.[47]

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment =
        arena.allocate(1024);

    segment.set(
        ValueLayout.JAVA_INT,
        0,
        42
    );

    int value = segment.get(
        ValueLayout.JAVA_INT,
        0
    );

    System.out.println(value);
}

Arena.ofConfined()으로 만든 아레나는 하나의 스레드에서 사용하는 결정적인 수명을 가진다. try-with-resources 블록이 끝나면 아레나가 닫히고, 아레나에서 할당한 모든 메모리 세그먼트가 함께 무효화된다.

닫힌 아레나의 메모리 세그먼트에 접근하면 예외가 발생한다.

MemorySegment segment;

try (Arena arena = Arena.ofConfined()) {
    segment = arena.allocate(100);
}

// 닫힌 아레나에 속하므로 접근할 수 없음
segment.get(ValueLayout.JAVA_BYTE, 0);

메모리 세그먼트는 공간적 경계와 시간적 경계를 제공한다.

  • 공간적 경계: 접근할 수 있는 메모리 주소 범위를 제한한다.
  • 시간적 경계: 메모리 영역을 사용할 수 있는 수명을 제한한다.

이 구조는 허용된 범위를 벗어난 메모리 접근과 해제 후 사용을 Java 수준에서 검사할 수 있게 한다.

아레나의 종류

Foreign Function and Memory API는 서로 다른 수명과 공유 조건을 가진 아레나를 제공한다.

전역 아레나

MemorySegment segment =
    Arena.global().allocate(1024);

전역 아레나의 수명은 JVM 전체와 연결된다. 명시적으로 닫을 수 없으며 할당한 메모리는 프로그램이 실행되는 동안 유지된다. 애플리케이션 전체 수명의 상수 데이터처럼 제한적인 경우에 사용할 수 있다.

자동 아레나

MemorySegment segment =
    Arena.ofAuto().allocate(1024);

자동 아레나의 메모리는 연결된 세그먼트가 더 이상 도달 가능하지 않을 때 GC와 연계하여 해제된다. 명시적인 종료 시점이 필요하지 않은 보조 메모리에 사용할 수 있지만, 정확한 반환 시점은 결정적이지 않다.

제한 아레나

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment =
        arena.allocate(1024);
}

제한 아레나는 생성한 스레드에서만 접근할 수 있으며 close()로 결정적으로 메모리를 반환한다.

공유 아레나

try (Arena arena = Arena.ofShared()) {
    MemorySegment segment =
        arena.allocate(1024);

    processAcrossThreads(segment);
}

공유 아레나는 여러 스레드에서 세그먼트에 접근할 수 있다. 어느 스레드든 아레나를 닫을 수 있으며, 종료 시 진행 중인 접근과 동기화해야 하므로 제한 아레나와 비용 특성이 다를 수 있다.

JNI와 자원 관리

JNI는 Java 코드와 네이티브 코드가 서로 호출하고 데이터를 교환할 수 있게 한다.

class NativeLibrary {
    static {
        System.loadLibrary("native_library");
    }

    static native long allocate(long size);

    static native void free(long address);
}

JNI로 할당한 메모리와 운영체제 핸들은 Java GC가 직접 관리하지 않는다. 네이티브 코드나 Java 래퍼가 명시적으로 반환해야 한다.

class NativeAllocation implements AutoCloseable {
    private long address;

    NativeAllocation(long size) {
        address = NativeLibrary.allocate(size);
    }

    @Override
    public void close() {
        if (address == 0) {
            return;
        }

        NativeLibrary.free(address);
        address = 0;
    }
}
try (NativeAllocation allocation =
         new NativeAllocation(4096)) {
    process(allocation);
}

JNI 코드에서 Java 객체 참조를 장기간 유지하려면 JNI 전역 참조를 관리해야 한다. 사용이 끝난 전역 참조를 삭제하지 않으면 Java 객체가 계속 GC 루트에서 도달 가능한 상태가 되어 메모리 누수가 발생할 수 있다.

네이티브 메서드가 고정한 배열이나 문자열, 획득한 핸들도 JNI 계약에 따라 반환해야 한다. Java 객체의 자동 메모리 관리가 네이티브 라이브러리 내부의 수명 규칙을 대체하지 않는다.

메모리 가시성과 객체 수명

객체가 도달 가능하다는 사실과 여러 스레드에서 최신 값을 관찰할 수 있다는 사실은 서로 다른 문제다. 객체가 힙에 존재하더라도 적절한 동기화 없이 여러 스레드가 변경 가능한 상태를 읽고 쓰면 오래된 값이나 일관되지 않은 상태를 관찰할 수 있다.

class State {
    private boolean running = true;

    void stop() {
        running = false;
    }

    void runLoop() {
        while (running) {
            performWork();
        }
    }
}

한 스레드가 stop()을 호출하고 다른 스레드가 runLoop()를 실행할 때 running에 동기화 규칙이 없으면 변경이 적절히 관찰된다고 보장되지 않는다.

class State {
    private volatile boolean running = true;

    void stop() {
        running = false;
    }

    void runLoop() {
        while (running) {
            performWork();
        }
    }
}

volatile, synchronized, 잠금과 동시성 도구는 Java 메모리 모델에 따라 스레드 사이의 가시성과 순서를 구성한다. 가비지 컬렉션은 객체 저장 공간의 수명을 관리하지만 객체 상태의 스레드 안전성을 자동으로 제공하지 않는다.

객체 풀링

가비지 컬렉션 비용을 줄이기 위해 모든 객체를 직접 풀링할 필요는 없다. 현대 JVM은 짧은 수명의 작은 객체를 빠르게 할당하고 회수하도록 최적화되어 있다.

class PointPool {
    private final Deque<Point> pool =
        new ArrayDeque<>();
}

단순 객체 풀은 다음 비용을 추가할 수 있다.

  • 동기화와 경쟁
  • 오래된 세대로 객체가 승격되는 현상
  • 풀 크기 관리
  • 객체 상태 초기화 오류
  • 사용 후 반환 누락
  • 불필요하게 긴 객체 수명

객체 생성 비용이 크고 외부 자원을 보유하거나 동시 사용량을 제한해야 하는 경우에는 풀링이 유용하다. 데이터베이스 연결과 스레드, 대형 버퍼 등의 풀은 단순한 힙 객체 재사용보다 외부 자원 비용과 동시성 제어를 목적으로 한다.

메모리 사용 진단

Java 애플리케이션의 메모리 문제를 분석할 때는 힙 사용량만 보지 않고 여러 영역을 구분한다.

  • Java 힙과 객체 수
  • 클래스와 클래스 로더
  • 스레드와 스택
  • 직접 버퍼와 네이티브 메모리
  • JIT 코드 캐시
  • JNI 할당
  • 파일 디스크립터와 소켓
  • 가비지 컬렉션 빈도와 일시 정지
  • 컨테이너와 운영체제의 메모리 제한

JDK는 다음과 같은 진단 도구를 제공한다.

  • jcmd
  • jmap
  • jstack
  • jstat
  • Java Flight Recorder
  • JDK Mission Control
  • 힙 덤프
  • GC 로그
  • Native Memory Tracking

실행 중인 JVM에 기본 정보를 요청할 수 있다.

jcmd <pid> VM.info

힙 상태를 확인할 수 있다.

jcmd <pid> GC.heap_info

클래스별 객체 통계를 확인할 수 있다.

jcmd <pid> GC.class_histogram

힙 덤프를 생성할 수 있다.

jcmd <pid> GC.heap_dump heap.hprof

힙 덤프에는 객체와 참조 관계가 포함되므로 어떤 GC 루트가 객체를 계속 유지하는지 분석할 수 있다. 실제 사용자 데이터와 인증 정보가 포함될 수 있으므로 저장과 전송 시 보호가 필요하다.

GC 로그

통합 로깅 옵션을 사용하여 가비지 컬렉션 활동을 기록할 수 있다.

java \
    -Xlog:gc*:file=gc.log:time,uptime,level,tags \
    Application

GC 로그에서는 다음 정보를 확인할 수 있다.

  • 수집 발생 시점
  • 수집 원인
  • 수집 전후 힙 사용량
  • 일시 정지 시간
  • 동시 수집 단계
  • 영역과 세대 상태
  • 참조 처리
  • 메타데이터와 클래스 언로딩

로그를 통해 메모리 누수와 높은 할당 속도, 너무 작은 힙, 장시간 일시 정지와 수집기 설정 문제를 구분할 수 있다.

힙 크기 설정

JVM의 초기 힙 크기와 최대 힙 크기는 실행 옵션으로 조정할 수 있다.

java \
    -Xms512m \
    -Xmx4g \
    Application
  • -Xms는 초기 힙 크기를 지정한다.
  • -Xmx는 최대 힙 크기를 지정한다.

힙을 크게 설정하면 수집 사이에 더 많은 객체를 유지할 수 있지만 전체 메모리 사용량과 일부 수집 작업량이 증가할 수 있다. 힙을 너무 작게 설정하면 가비지 컬렉션이 지나치게 자주 발생하거나 객체 할당에 실패할 수 있다.

프로세스는 힙 외에도 스레드 스택과 메타데이터, 직접 버퍼, JIT 코드, 네이티브 라이브러리를 위해 메모리를 사용한다. 따라서 운영체제나 컨테이너의 메모리 제한 전체를 -Xmx에 할당하지 않고 비힙 영역을 위한 공간을 남겨야 한다.

자원 관리의 기본 구조

Java의 메모리와 자원 관리는 다음 흐름으로 정리할 수 있다.

try (Resource resource = openResource()) {
    resource.use();
}
  • Java 객체는 JVM 힙에서 생성된다.
  • 강한 참조 경로가 유지되는 동안 객체는 살아 있다.
  • 객체가 도달 불가능해지면 GC가 메모리를 회수할 수 있다.
  • GC의 정확한 실행 시점은 프로그램이 결정하지 않는다.
  • 파일과 소켓 등의 외부 자원은 close()로 즉시 반환한다.
  • AutoCloseable 자원은 try-with-resources로 관리한다.
  • Cleaner와 참조 큐는 명시적 종료를 대체하지 않는 보조 수단이다.
  • 네이티브 메모리는 아레나 또는 명확한 소유 객체로 수명을 제한한다.
  • 장기간 실행되는 프로그램은 객체뿐 아니라 클래스 로더와 스레드, 네이티브 메모리 및 운영체제 핸들도 함께 관찰한다.

Java의 자동 메모리 관리는 객체 해제 시점을 JVM에 위임하여 잘못된 수동 해제와 일반적인 사용 후 해제 문제를 줄인다. 동시에 외부 자원에는 결정적인 종료 구조를 제공하고, 최신 Foreign Function and Memory API에서는 힙 밖 메모리에도 범위와 수명 검사를 적용한다. 이 두 방식을 구분하여 사용하는 것이 Java 자원 관리 모델의 핵심이다.

동시성과 비동기 처리

Java는 언어와 JVM 차원에서 스레드, 객체 모니터와 메모리 가시성 규칙을 제공하고, 표준 라이브러리에서 실행자와 잠금, 원자 변수, 동시성 컬렉션, 비동기 계산을 위한 고수준 API를 제공한다. 초기 Java의 동시성 모델은 Thread, synchronized, wait, notify를 중심으로 구성되었으며, 이후 java.util.concurrent와 가상 스레드, 구조적 동시성 및 Scoped Values가 추가되면서 대규모 서버와 비동기 작업을 표현하는 방식이 확장되었다.

동시성은 여러 작업이 진행 중인 상태를 의미하며, 병렬성은 여러 작업이 실제로 같은 시점에 서로 다른 처리 장치에서 실행되는 상태를 의미한다. 하나의 CPU 코어에서도 스레드를 번갈아 실행하여 동시성을 구성할 수 있지만, 실제 병렬 실행에는 여러 코어나 처리 장치가 필요하다.

Java 프로그램에서 여러 스레드가 같은 변경 가능한 상태에 접근하면 실행 순서와 메모리 가시성을 함께 고려해야 한다. 단순히 각 문장이 소스 코드에 작성된 순서대로 모든 스레드에 관찰된다고 가정할 수 없으며, 올바른 동기화가 없는 프로그램은 실행 시점과 JVM 최적화 및 하드웨어에 따라 서로 다른 결과를 낼 수 있다. Java 메모리 모델은 스레드 사이에서 어떤 쓰기가 어떤 읽기에 관찰될 수 있는지와 동기화 연산의 순서를 정의한다.[48]

스레드

스레드는 하나의 JVM 프로세스 안에서 독립적인 호출 스택과 실행 위치를 가지는 실행 단위다. Java에서는 java.lang.Thread 객체로 표현된다.

Thread worker = new Thread(() -> {
    System.out.println("worker started");
});

worker.start();

start()를 호출하면 JVM은 새로운 스레드가 run() 메서드를 실행하도록 예약한다. run()을 직접 호출하면 새로운 스레드가 만들어지지 않고 현재 스레드에서 일반 메서드처럼 실행된다.

Thread worker = new Thread(() -> {
    System.out.println(
        Thread.currentThread().getName()
    );
});

worker.run();   // 현재 스레드에서 실행
worker.start(); //  스레드에서 실행

하나의 Thread 객체는 한 번만 시작할 수 있다. 종료된 스레드에 다시 start()를 호출하면 IllegalThreadStateException이 발생한다.

스레드 실행이 끝날 때까지 기다리려면 join()을 사용한다.

Thread worker = new Thread(() -> {
    performWork();
});

worker.start();
worker.join();

join()은 대상 스레드가 종료되거나 현재 스레드가 인터럽트될 때까지 기다린다. 제한 시간을 지정할 수도 있다.

worker.join(Duration.ofSeconds(5));

if (worker.isAlive()) {
    System.out.println("worker still running");
}

플랫폼 스레드

플랫폼 스레드는 일반적으로 운영체제 스레드와 일대일로 연결되는 Java 스레드다. JVM은 운영체제의 스케줄러를 이용하여 플랫폼 스레드를 실행한다.

Thread thread = Thread
    .ofPlatform()
    .name("database-worker")
    .start(() -> queryDatabase());

플랫폼 스레드는 운영체제 스택과 스케줄링 자원을 사용하므로 생성과 유지 비용이 비교적 크다. 수천 개 이상의 장시간 대기 스레드를 직접 만드는 구조에서는 메모리와 운영체제 스케줄링 비용이 커질 수 있다.

전통적인 서버 애플리케이션은 제한된 수의 플랫폼 스레드를 스레드 풀에 배치하고 여러 작업이 이를 공유하도록 구성해 왔다.

스레드 상태

Thread.State는 스레드의 실행 상태를 다음과 같이 구분한다.

상태의미
NEW생성되었지만 시작되지 않음
RUNNABLE실행 중이거나 실행 가능한 상태
BLOCKED모니터 락 획득을 기다림
WAITING시간 제한 없이 다른 동작을 기다림
TIMED_WAITING제한된 시간 동안 기다림
TERMINATED실행이 끝남

RUNNABLE은 CPU에서 실제 명령을 실행 중인 상태뿐 아니라 운영체제의 실행 대기열에서 기다리는 상태도 포함할 수 있다.

스레드 상태는 관찰 시점의 순간적인 정보다. 상태를 조회한 직후 다른 상태로 바뀔 수 있으므로 상태 검사만으로 동기화 제어를 구성하지 않는다.

데몬 스레드

플랫폼 스레드는 데몬 또는 비데몬 스레드로 동작할 수 있다. JVM은 시작된 비데몬 스레드가 모두 종료되면 프로그램 종료를 진행할 수 있다.

Thread background = Thread
    .ofPlatform()
    .daemon()
    .start(() -> runBackgroundTask());

데몬 스레드는 JVM 종료 시 작업을 끝낼 기회를 보장받지 않는다. 데이터 저장이나 외부 자원 반환처럼 반드시 완료되어야 하는 작업을 데몬 스레드의 종료 동작에만 맡기지 않는다.

가상 스레드는 항상 데몬 스레드로 동작한다. 따라서 가상 스레드만 남았다는 이유로 JVM 프로세스가 계속 유지되지는 않는다.

인터럽트

Java의 스레드 중단은 강제 종료가 아니라 인터럽트 요청을 전달하는 협력적 방식으로 이루어진다.

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        performWork();
    }
});

worker.start();
worker.interrupt();

interrupt()는 대상 스레드의 인터럽트 상태를 설정한다. 스레드가 sleep, wait, join이나 일부 동시성 API에서 대기 중이면 InterruptedException이 발생할 수 있다.

try {
    Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();
    return;
}

InterruptedException이 발생하면 일반적으로 인터럽트 상태가 해제된다. 현재 메서드에서 인터럽트를 완전히 처리하지 않고 호출자에게 중단 의도를 전달하려면 Thread.currentThread().interrupt()로 상태를 복원할 수 있다.

인터럽트를 무시하면 실행자 종료와 취소 요청이 제대로 전달되지 않을 수 있다.

try {
    blockingOperation();
} catch (InterruptedException exception) {
    // 아무 처리도 하지 않으면 중단 요청이 사라짐
}

반복 작업은 적절한 지점에서 인터럽트 상태를 확인하거나, 인터럽트 가능한 블로킹 API를 사용하여 취소에 반응하도록 설계할 수 있다.

공유 상태와 데이터 경쟁

여러 스레드가 같은 변수에 접근하고, 그중 하나 이상이 값을 쓰며, 접근 사이에 적절한 동기화가 없다면 데이터 경쟁이 발생할 수 있다.

class Counter {
    private int value;

    void increment() {
        value++;
    }

    int value() {
        return value;
    }
}

value++는 개념적으로 하나의 원자적 연산이 아니다.

현재 값 읽기
현재 값에 1 더하기
결과를 다시 저장하기

두 스레드가 동시에 같은 값을 읽고 각각 증가시킨 뒤 같은 결과를 저장하면 한 번의 증가가 사라질 수 있다.

Counter counter = new Counter();

Thread first = new Thread(() -> {
    for (int index = 0; index < 100_000; index++) {
        counter.increment();
    }
});

Thread second = new Thread(() -> {
    for (int index = 0; index < 100_000; index++) {
        counter.increment();
    }
});

올바른 동기화가 없으면 최종 결과가 200000보다 작을 수 있으며, 다른 스레드가 최신 값을 관찰한다는 보장도 없다.

Java 메모리 모델

Java 메모리 모델은 여러 스레드에서 공유 변수의 읽기와 쓰기가 어떻게 관찰되는지를 정의한다. JVM과 CPU는 단일 스레드에서 관찰되는 결과를 유지하는 범위 안에서 명령과 메모리 연산을 재배치하거나 캐시할 수 있다.

동기화되지 않은 다음 코드는 한 스레드에서 readytrue로 보이더라도 다른 스레드에서 data의 최신 값이 반드시 보인다고 보장하지 않는다.

class Message {
    int data;
    boolean ready;
}
message.data = 42;
message.ready = true;
if (message.ready) {
    System.out.println(message.data);
}

프로그램이 스레드 사이의 메모리 가시성과 실행 순서를 요구한다면 synchronized, volatile, 스레드 시작과 종료, 락, 원자 변수, 동시성 컬렉션 등의 동기화 동작을 사용해야 한다.

happens-before 관계

happens-before는 한 동작의 결과가 다른 동작에 보이도록 보장하는 순서 관계다. 한 동작이 다른 동작보다 happens-before에 있다면 첫 번째 동작의 메모리 효과는 두 번째 동작에서 관찰 가능해야 한다.

대표적인 happens-before 규칙은 다음과 같다.[49]

  • 한 스레드 안에서 앞의 동작은 프로그램 순서상 뒤의 동작보다 앞선다.
  • 모니터 락의 해제는 같은 락을 이후 획득하는 동작보다 앞선다.
  • volatile 필드 쓰기는 같은 필드의 이후 읽기보다 앞선다.
  • Thread.start() 호출 이전 동작은 시작된 스레드의 동작보다 앞선다.
  • 스레드의 모든 동작은 다른 스레드가 해당 스레드의 종료를 성공적으로 감지하는 동작보다 앞선다.
  • 객체 생성자의 정상적인 종료와 final 필드 읽기에는 특별한 초기화 안전성 규칙이 적용된다.

happens-before는 실제 벽시계 시간만을 의미하지 않는다. 프로그램에서 메모리 가시성과 순서를 보장하는 형식적인 관계다.

synchronized

synchronized는 객체 모니터를 이용하여 상호 배제와 메모리 가시성을 함께 제공한다.

class Counter {
    private int value;

    synchronized void increment() {
        value++;
    }

    synchronized int value() {
        return value;
    }
}

한 스레드가 특정 객체의 동기화된 인스턴스 메서드를 실행하는 동안 다른 스레드는 같은 객체의 다른 동기화된 인스턴스 메서드를 동시에 실행할 수 없다.

동기화 블록을 사용하면 보호할 범위를 더 작게 지정할 수 있다.

class Counter {
    private final Object lock = new Object();
    private int value;

    void increment() {
        synchronized (lock) {
            value++;
        }
    }

    int value() {
        synchronized (lock) {
            return value;
        }
    }
}

정적 동기화 메서드는 클래스의 Class 객체를 모니터로 사용한다.

class IdGenerator {
    private static long nextId;

    static synchronized long next() {
        return ++nextId;
    }
}

이는 다음 구조와 개념적으로 같다.

static long next() {
    synchronized (IdGenerator.class) {
        return ++nextId;
    }
}

모니터의 재진입

Java의 내장 모니터 락은 재진입 가능하다. 한 스레드가 이미 획득한 모니터를 다시 획득할 수 있다.

class Service {
    synchronized void first() {
        second();
    }

    synchronized void second() {
        performWork();
    }
}

first()를 실행 중인 스레드는 같은 객체의 모니터를 이미 보유하고 있지만 second()를 호출하면서 다시 들어갈 수 있다. 재진입 횟수에 맞춰 동기화 영역을 모두 벗어났을 때 락이 완전히 해제된다.

락 범위와 객체 노출

동기화에는 모든 접근자가 같은 락 규칙을 따라야 한다.

class State {
    private int value;

    synchronized void update(int value) {
        this.value = value;
    }

    int value() {
        return value;
    }
}

쓰기만 동기화하고 읽기를 동기화하지 않으면 읽는 스레드가 최신 값을 관찰한다고 보장되지 않는다.

다음처럼 같은 락을 사용해 읽기와 쓰기를 모두 보호할 수 있다.

class State {
    private int value;

    synchronized void update(int value) {
        this.value = value;
    }

    synchronized int value() {
        return value;
    }
}

외부 코드에서도 접근할 수 있는 객체를 락으로 사용하면 다른 코드가 같은 모니터를 장시간 점유하거나 예상하지 못한 락 순서를 만들 수 있다.

class State {
    private final Object lock = new Object();
    private int value;
}

전용 비공개 락 객체를 사용하면 동기화 정책을 클래스 내부에 캡슐화할 수 있다.

volatile

volatile 필드는 여러 스레드 사이의 가시성과 해당 필드 주변의 메모리 순서를 제공한다.

class Worker {
    private volatile boolean running = true;

    void stop() {
        running = false;
    }

    void run() {
        while (running) {
            performWork();
        }
    }
}

한 스레드가 runningfalse를 쓰면 다른 스레드의 이후 volatile 읽기는 그 값을 관찰할 수 있다. volatile 쓰기 이전의 메모리 효과도 같은 필드의 이후 읽기를 통해 전달될 수 있다.

volatile은 여러 단계로 구성된 복합 연산 전체를 원자적으로 만들지는 않는다.

class Counter {
    private volatile int value;

    void increment() {
        value++;
    }
}

value++는 읽기·계산·쓰기의 결합이므로 여러 스레드가 동시에 실행하면 갱신이 사라질 수 있다. 단일 값의 원자적인 갱신에는 AtomicInteger나 락을 사용할 수 있다.

volatile은 일반적으로 다음 상황에 적합하다.

  • 하나의 상태 플래그
  • 다른 불변 객체를 가리키는 최신 참조
  • 쓰기 연산끼리 결합된 불변 조건이 없는 상태
  • 읽기와 쓰기만 필요하고 복합 갱신이 없는 값

여러 필드 사이의 불변 조건을 유지해야 한다면 락이나 하나의 불변 상태 객체를 사용할 수 있다.

final 필드와 안전한 공개

생성자에서 정상적으로 초기화된 final 필드는 객체가 생성 후 올바르게 공개되었을 때 특별한 가시성 보장을 받는다.

final class Configuration {
    private final String host;
    private final int port;

    Configuration(String host, int port) {
        this.host = host;
        this.port = port;
    }
}

생성자가 끝나기 전에 this를 외부로 노출하면 다른 스레드가 완전히 초기화되지 않은 객체를 관찰할 수 있다.

class Listener {
    private final int value;

    Listener(EventSource source) {
        source.register(() -> use(this));
        value = 42;
    }
}

콜백이 생성자 종료 전에 실행되면 value 초기화가 완료되기 전 객체를 사용할 수 있다. 생성 중인 객체는 외부 저장소와 콜백, 새 스레드에 노출하지 않고 생성이 끝난 뒤 등록할 수 있다.

안전한 공개에는 다음과 같은 방식이 사용된다.

  • 정적 초기화 과정에서 공개
  • volatile 필드를 통해 공개
  • 올바르게 잠긴 영역을 통해 공개
  • 동시성 컬렉션에 저장
  • 스레드를 시작하기 전에 객체를 전달
  • 생성이 끝난 불변 객체를 안전한 구조에 배치

wait, notify, notifyAll

모든 Java 객체는 모니터와 연결된 대기 집합을 가질 수 있다. wait()는 현재 스레드가 객체 모니터를 해제하고 알림을 기다리게 한다.

class MessageBox {
    private String message;

    synchronized void put(String message) {
        while (this.message != null) {
            try {
                wait();
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt();
                return;
            }
        }

        this.message = message;
        notifyAll();
    }

    synchronized String take()
            throws InterruptedException {

        while (message == null) {
            wait();
        }

        String result = message;
        message = null;
        notifyAll();

        return result;
    }
}

wait, notify, notifyAll은 해당 객체의 모니터를 현재 스레드가 소유한 상태에서 호출해야 한다. 그렇지 않으면 IllegalMonitorStateException이 발생한다.

wait()는 알림 없이도 반환할 수 있는 허위 깨움을 허용하므로 조건은 if가 아니라 반복문에서 다시 검사한다.

synchronized (lock) {
    while (!conditionIsTrue()) {
        lock.wait();
    }

    performAction();
}

notify()는 대기 중인 스레드 하나를 선택하고, notifyAll()은 모든 대기 스레드를 깨운다. 깨어난 스레드는 즉시 실행되는 것이 아니라 모니터를 다시 획득해야 한다.

현대 Java에서는 생산자·소비자 큐와 동기화 지점에 BlockingQueue, CountDownLatch, Semaphore, Condition 등의 고수준 도구를 사용하는 경우가 많다.

명시적인 락

java.util.concurrent.locks.Lock은 내장 모니터보다 확장된 락 연산을 제공한다.

class Counter {
    private final Lock lock =
        new ReentrantLock();

    private int value;

    void increment() {
        lock.lock();

        try {
            value++;
        } finally {
            lock.unlock();
        }
    }
}

unlock()finally에서 호출하여 예외가 발생해도 락이 해제되도록 한다.

ReentrantLock은 다음 기능을 제공한다.

  • 인터럽트 가능한 락 대기
  • 시간 제한이 있는 락 획득
  • 즉시 획득 시도
  • 여러 Condition
  • 선택적인 공정성 정책
  • 대기 상태 조회를 위한 진단 기능
if (lock.tryLock()) {
    try {
        updateState();
    } finally {
        lock.unlock();
    }
}

제한 시간을 지정할 수 있다.

if (lock.tryLock(
        500,
        TimeUnit.MILLISECONDS
)) {
    try {
        updateState();
    } finally {
        lock.unlock();
    }
}

읽기·쓰기 락

ReadWriteLock은 여러 읽기 작업을 동시에 허용하면서 쓰기 작업은 배타적으로 수행한다.

class Cache {
    private final ReadWriteLock lock =
        new ReentrantReadWriteLock();

    private final Map<String, String> values =
        new HashMap<>();

    String get(String key) {
        Lock readLock = lock.readLock();
        readLock.lock();

        try {
            return values.get(key);
        } finally {
            readLock.unlock();
        }
    }

    void put(String key, String value) {
        Lock writeLock = lock.writeLock();
        writeLock.lock();

        try {
            values.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }
}

읽기 비율이 높고 임계 영역이 충분히 큰 경우 동시 읽기를 통해 처리량을 높일 수 있다. 임계 영역이 매우 짧거나 쓰기가 잦으면 락 관리 비용이 이점을 상쇄할 수 있다.

StampedLock은 읽기 락과 쓰기 락 외에도 낙관적 읽기를 제공한다.

class Point {
    private final StampedLock lock =
        new StampedLock();

    private double x;
    private double y;

    double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();

        double currentX = x;
        double currentY = y;

        if (!lock.validate(stamp)) {
            stamp = lock.readLock();

            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }

        return Math.hypot(currentX, currentY);
    }
}

StampedLock은 재진입 가능하지 않으며 스탬프를 올바르게 보관하고 해제해야 한다.

원자 변수

java.util.concurrent.atomic은 단일 변수나 배열 요소를 원자적으로 읽고 갱신하는 클래스를 제공한다.

class Counter {
    private final AtomicInteger value =
        new AtomicInteger();

    void increment() {
        value.incrementAndGet();
    }

    int value() {
        return value.get();
    }
}

대표적인 원자 타입은 다음과 같다.

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong
  • AtomicReference
  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

비교 후 교환 연산을 이용하여 현재 값이 예상값과 같을 때만 새 값으로 바꿀 수 있다.

AtomicInteger state = new AtomicInteger();

boolean changed =
    state.compareAndSet(0, 1);

복합 상태도 불변 객체 하나에 넣고 AtomicReference로 교체할 수 있다.

record State(int version, String value) {
}

AtomicReference<State> state =
    new AtomicReference<>(
        new State(0, "initial")
    );

state.updateAndGet(previous ->
    new State(
        previous.version() + 1,
        "updated"
    )
);

원자 변수는 단일 상태 전환에는 유용하지만 여러 독립 변수 사이의 불변 조건을 자동으로 보호하지 않는다.

LongAdder와 누적기

많은 스레드가 같은 카운터를 매우 자주 갱신하면 하나의 AtomicLong에서 경쟁이 집중될 수 있다. LongAdder는 내부적으로 값을 여러 셀에 분산하여 갱신 경쟁을 줄인다.

LongAdder requests = new LongAdder();

requests.increment();
requests.add(10);

long total = requests.sum();

sum()은 호출 순간의 완전한 원자적 스냅샷을 보장하는 용도가 아니다. 통계와 측정처럼 갱신 처리량이 중요하고 순간적인 정확한 원자 읽기가 필요하지 않은 경우에 적합하다.

동시성 컬렉션

일반적인 ArrayList, HashMap, HashSet은 동시 수정에 대한 스레드 안전성을 제공하지 않는다. 여러 스레드가 공유할 때는 외부 동기화나 동시성 컬렉션을 사용할 수 있다.

대표적인 동시성 컬렉션은 다음과 같다.

  • ConcurrentHashMap
  • ConcurrentLinkedQueue
  • ConcurrentLinkedDeque
  • CopyOnWriteArrayList
  • CopyOnWriteArraySet
  • ConcurrentSkipListMap
  • ConcurrentSkipListSet
  • BlockingQueue 구현체
ConcurrentMap<String, User> users =
    new ConcurrentHashMap<>();

users.putIfAbsent(
    user.id(),
    user
);

users.computeIfAbsent(
    id,
    this::loadUser
);

ConcurrentHashMap은 전체 맵을 하나의 전역 락으로 묶지 않고 여러 스레드의 읽기와 갱신을 지원한다. 그러나 여러 호출을 조합한 전체 절차가 자동으로 원자적이 되는 것은 아니다.

if (!users.containsKey(id)) {
    users.put(id, loadUser(id));
}

두 스레드가 동시에 조건을 통과할 수 있으므로 computeIfAbsent처럼 의도를 하나의 원자 연산으로 표현한다.

복사 후 쓰기 컬렉션

CopyOnWriteArrayList는 수정할 때 내부 배열을 복사하고, 읽기는 변경되지 않는 스냅샷을 대상으로 수행한다.

CopyOnWriteArrayList<Listener> listeners =
    new CopyOnWriteArrayList<>();

listeners.add(listener);

for (Listener current : listeners) {
    current.onEvent(event);
}

반복 중 구조가 변경되어도 현재 반복자는 생성 당시의 스냅샷을 사용한다. 읽기와 순회가 매우 많고 수정이 드문 리스너 목록 등에 사용할 수 있다.

수정할 때 전체 배열 복사가 필요하므로 요소 수가 많거나 수정이 잦은 구조에는 비용이 커질 수 있다.

블로킹 큐

BlockingQueue는 요소가 없거나 공간이 부족한 경우 스레드를 대기시키는 생산자·소비자 큐다.

BlockingQueue<Task> queue =
    new ArrayBlockingQueue<>(100);

생산자는 put()으로 항목을 추가하고 큐가 가득 차면 기다린다.

void produce(Task task)
        throws InterruptedException {
    queue.put(task);
}

소비자는 take()로 항목을 가져오고 큐가 비어 있으면 기다린다.

void consume()
        throws InterruptedException {
    Task task = queue.take();
    task.execute();
}

제한된 큐는 생산 속도가 소비 속도를 지속적으로 초과할 때 메모리가 무제한 증가하지 않도록 역압을 구성할 수 있다.

대표적인 구현체는 다음과 같다.

  • ArrayBlockingQueue
  • LinkedBlockingQueue
  • PriorityBlockingQueue
  • DelayQueue
  • SynchronousQueue
  • LinkedTransferQueue

동기화 보조 도구

java.util.concurrent는 스레드 사이의 진행 조건을 표현하는 여러 동기화 도구를 제공한다.

CountDownLatch

CountDownLatch는 정해진 수의 작업이 완료될 때까지 하나 이상의 스레드를 기다리게 한다.

CountDownLatch ready =
    new CountDownLatch(3);

for (int index = 0; index < 3; index++) {
    new Thread(() -> {
        try {
            initializeComponent();
        } finally {
            ready.countDown();
        }
    }).start();
}

ready.await();
startApplication();

카운트는 감소만 가능하며 0이 된 래치는 다시 사용할 수 없다.

CyclicBarrier

CyclicBarrier는 정해진 수의 스레드가 같은 지점에 도착할 때까지 서로 기다리게 한다.

CyclicBarrier barrier =
    new CyclicBarrier(
        4,
        () -> mergeResults()
    );

각 참여 스레드는 작업 단계가 끝난 뒤 await()를 호출한다. 마지막 스레드가 도착하면 선택적인 장벽 작업이 실행되고 모든 참여자가 다음 단계로 진행한다. 장벽은 여러 주기에 걸쳐 다시 사용할 수 있다.

Phaser

Phaser는 참여자 수가 동적으로 바뀔 수 있는 여러 단계의 동기화를 제공한다.

Phaser phaser = new Phaser(1);

for (Task task : tasks) {
    phaser.register();

    new Thread(() -> {
        try {
            task.run();
        } finally {
            phaser.arriveAndDeregister();
        }
    }).start();
}

phaser.arriveAndAwaitAdvance();

Semaphore

Semaphore는 동시에 사용할 수 있는 허가 수를 제한한다.

Semaphore connections =
    new Semaphore(10);

void useConnection()
        throws InterruptedException {

    connections.acquire();

    try {
        accessRemoteService();
    } finally {
        connections.release();
    }
}

세마포어는 외부 서비스 연결과 제한된 장치, 동시 요청 수를 제어하는 데 사용할 수 있다.

Exchanger

Exchanger는 두 스레드가 특정 지점에서 객체를 교환하도록 한다.

Exchanger<Buffer> exchanger =
    new Exchanger<>();

Buffer received =
    exchanger.exchange(filledBuffer);

실행자 프레임워크

작업마다 직접 스레드를 생성하면 생성·종료와 오류 처리 및 종료 정책이 작업 코드에 섞이기 쉽다. 실행자 프레임워크는 작업 제출실행 방식을 분리한다.

Executor는 실행할 Runnable을 받는 최소 인터페이스다.

Executor executor =
    command -> new Thread(command).start();

executor.execute(() -> performWork());

ExecutorService는 작업 제출과 결과, 종료 및 대량 실행 기능을 추가한다.

try (ExecutorService executor =
         Executors.newFixedThreadPool(4)) {

    executor.submit(() -> performWork());
}

ExecutorServiceAutoCloseable을 구현한다. try-with-resources를 사용하면 블록 종료 시 새로운 작업 제출을 중단하고 기존 작업의 종료를 기다리는 구조를 만들 수 있다.[50]

스레드 풀

고정 크기 스레드 풀은 제한된 수의 작업 스레드로 여러 작업을 실행한다.

ExecutorService executor =
    Executors.newFixedThreadPool(8);

CPU 중심 작업은 일반적으로 사용 가능한 프로세서 수와 작업의 블로킹 비율을 기준으로 크기를 조정한다. I/O 중심 작업은 대기 시간이 많으므로 더 많은 스레드를 사용할 수 있지만, 플랫폼 스레드를 지나치게 늘리면 메모리와 문맥 전환 비용이 증가한다.

단일 스레드 실행자는 작업을 하나씩 순서대로 실행한다.

ExecutorService executor =
    Executors.newSingleThreadExecutor();

예약 실행자는 지연 실행과 반복 작업을 지원한다.

ScheduledExecutorService scheduler =
    Executors.newScheduledThreadPool(2);

scheduler.schedule(
    this::refresh,
    10,
    TimeUnit.SECONDS
);

고정 주기로 실행할 수 있다.

scheduler.scheduleAtFixedRate(
    this::collectMetrics,
    0,
    1,
    TimeUnit.MINUTES
);

ScheduledExecutorService는 시스템 시각이 아니라 상대적인 지연 시간을 기반으로 예약한다. 긴 작업과 예외 및 종료 정책을 함께 고려해야 한다.

RunnableCallable

Runnable은 값을 반환하지 않고 검사 예외를 선언할 수 없는 작업을 표현한다.

Runnable task = () -> {
    performWork();
};

Callable<V>는 결과를 반환하고 예외를 던질 수 있다.

Callable<String> task = () -> {
    return loadMessage();
};

ExecutorService.submit()은 작업 상태와 결과를 나타내는 Future를 반환한다.

Future<String> future =
    executor.submit(task);

Future

Future는 아직 완료되지 않았을 수 있는 계산의 결과를 나타낸다.

Future<String> future =
    executor.submit(this::loadMessage);

String message = future.get();

get()은 계산이 완료될 때까지 현재 스레드를 대기시킨다. 작업에서 예외가 발생하면 ExecutionException으로 감싸서 전달된다.

제한 시간을 지정할 수 있다.

String message = future.get(
    5,
    TimeUnit.SECONDS
);

작업 취소를 요청할 수 있다.

boolean cancelled =
    future.cancel(true);

true는 작업이 실행 중이라면 해당 스레드에 인터럽트를 요청할 수 있음을 뜻한다. 작업이 인터럽트를 무시하거나 인터럽트할 수 없는 네이티브 연산에 있으면 즉시 종료되지 않을 수 있다.

Future는 단일 계산을 기다리고 취소하는 데 적합하지만, 여러 결과의 조합과 후속 작업 연결은 직접 구성해야 한다.

CompletableFuture

CompletableFuture는 완료될 값이나 예외를 나타내며, 결과가 준비된 뒤 실행할 후속 단계를 선언적으로 연결할 수 있다. FutureCompletionStage를 모두 구현한다.

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        this::loadMessage
    );

결과를 변환할 수 있다.

CompletableFuture<Integer> length =
    future.thenApply(String::length);

결과를 소비하는 단계를 연결할 수 있다.

future.thenAccept(System.out::println);

비동기 작업을 이어서 실행하고 중첩된 미래를 평탄화할 수 있다.

CompletableFuture<User> user =
    loadUserId()
        .thenCompose(this::loadUser);

thenApply는 일반 값을 반환하는 변환에 사용하고, thenCompose는 다른 CompletionStage를 반환하는 비동기 작업을 연결할 때 사용한다.

비동기 단계의 실행 위치

CompletableFuture의 비동기 생성 메서드는 실행자를 명시하지 않으면 일반적으로 공용 ForkJoinPool을 사용할 수 있다.

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        this::loadMessage
    );

실행자를 명시할 수 있다.

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        this::loadMessage,
        executor
    );

thenApply처럼 Async 접미사가 없는 후속 단계는 이전 단계를 완료한 스레드나 단계를 등록한 스레드에서 실행될 수 있다.

future.thenApply(this::transform);

thenApplyAsync는 비동기 실행 시설을 통해 실행한다.

future.thenApplyAsync(
    this::transform,
    executor
);

긴 블로킹 I/O 작업을 공용 ForkJoinPool에서 무분별하게 실행하면 다른 공용 작업의 진행을 방해할 수 있다. 실행 특성이 분명한 별도 실행자나 가상 스레드를 사용할 수 있다.

여러 비동기 결과 조합

서로 독립적인 두 결과를 결합할 수 있다.

CompletableFuture<User> userFuture =
    loadUser();

CompletableFuture<List<Order>> ordersFuture =
    loadOrders();

CompletableFuture<UserPage> pageFuture =
    userFuture.thenCombine(
        ordersFuture,
        UserPage::new
    );

여러 미래가 모두 끝날 때까지 기다릴 수 있다.

CompletableFuture<Void> all =
    CompletableFuture.allOf(
        first,
        second,
        third
    );

allOf 자체는 개별 결과 목록을 반환하지 않으므로 완료 후 각 미래에서 결과를 읽어야 한다.

CompletableFuture<List<String>> results =
    CompletableFuture
        .allOf(futures.toArray(
            CompletableFuture[]::new
        ))
        .thenApply(ignored ->
            futures.stream()
                .map(CompletableFuture::join)
                .toList()
        );

여러 작업 중 먼저 완료한 결과를 사용할 수도 있다.

CompletableFuture<Object> first =
    CompletableFuture.anyOf(
        primary,
        backup
    );

비동기 예외 처리

비동기 단계에서 발생한 예외는 미래를 예외 상태로 완료한다.

CompletableFuture<String> result =
    loadMessage()
        .exceptionally(exception ->
            "fallback"
        );

정상 결과와 예외를 함께 처리할 수 있다.

CompletableFuture<String> result =
    loadMessage()
        .handle((value, exception) -> {
            if (exception != null) {
                return "fallback";
            }

            return value;
        });

결과를 변경하지 않고 완료 상태를 관찰하려면 whenComplete를 사용할 수 있다.

future.whenComplete((value, exception) -> {
    if (exception != null) {
        log(exception);
    }
});

join()은 완료를 기다린다는 점에서 get()과 비슷하지만 검사 예외 대신 CompletionException을 사용한다.

String value = future.join();

시간 제한

CompletableFuture에 시간 제한을 적용할 수 있다.

CompletableFuture<String> future =
    loadMessage()
        .orTimeout(
            5,
            TimeUnit.SECONDS
        );

시간이 지나면 기본값으로 완료할 수도 있다.

CompletableFuture<String> future =
    loadMessage()
        .completeOnTimeout(
            "fallback",
            5,
            TimeUnit.SECONDS
        );

미래가 시간 초과로 완료되었다고 해서 기반 작업이 반드시 즉시 중단되는 것은 아니다. 외부 I/O와 실행 중 작업의 취소 정책을 별도로 구성해야 할 수 있다.

Fork/Join 프레임워크

Fork/Join 프레임워크는 큰 작업을 작은 하위 작업으로 나누어 병렬로 실행한 뒤 결과를 결합하는 계산 모델이다.

class SumTask extends RecursiveTask<Long> {
    private final long[] values;
    private final int start;
    private final int end;

    SumTask(
        long[] values,
        int start,
        int end
    ) {
        this.values = values;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        int length = end - start;

        if (length <= 1_000) {
            long result = 0;

            for (int index = start;
                 index < end;
                 index++) {
                result += values[index];
            }

            return result;
        }

        int middle = start + length / 2;

        SumTask left =
            new SumTask(values, start, middle);

        SumTask right =
            new SumTask(values, middle, end);

        left.fork();
        long rightResult = right.compute();
        long leftResult = left.join();

        return leftResult + rightResult;
    }
}

Fork/Join 풀은 작업이 다른 작업의 완료를 기다릴 때 유휴 작업자 스레드가 다른 큐의 작업을 가져오는 작업 훔치기 방식을 사용한다.

CPU 계산을 작은 작업으로 재귀 분할하는 데 적합하며, 일반적인 장시간 블로킹 I/O를 제한 없이 실행하는 용도로 설계된 것은 아니다.

병렬 스트림도 일반적으로 Fork/Join 공용 풀을 활용한다.

long total = values
    .parallelStream()
    .mapToLong(Value::amount)
    .sum();

병렬 스트림은 데이터 크기와 연산 비용, 분할 가능성, 공용 풀 공유와 순서 요구를 고려해 사용한다. 병렬화 자체가 항상 순차 실행보다 빠른 것은 아니다.

가상 스레드

가상 스레드는 JVM이 관리하는 경량 스레드다. Java 21에서 정식 기능으로 도입되었으며, 많은 수의 동시 I/O 작업을 스레드 기반 코드로 표현할 수 있게 한다.[51]

Thread thread = Thread
    .ofVirtual()
    .name("request-worker")
    .start(() -> handleRequest());

간단한 시작 메서드도 제공된다.

Thread thread =
    Thread.startVirtualThread(
        this::handleRequest
    );

가상 스레드는 운영체제 스레드와 일대일로 연결되지 않는다. JVM은 많은 가상 스레드를 소수의 플랫폼 스레드인 캐리어 스레드 위에서 실행할 수 있다.

가상 스레드가 지원되는 블로킹 I/O에서 대기하면 JVM은 해당 가상 스레드를 캐리어에서 분리하고, 캐리어가 다른 가상 스레드를 실행하도록 할 수 있다. I/O가 준비되면 가상 스레드는 다시 실행 대상으로 예약된다.

스레드별 작업 실행자

가상 스레드는 일반적으로 재사용 풀에 제한된 수로 넣기보다 작업마다 하나씩 생성한다.

try (ExecutorService executor =
         Executors
             .newVirtualThreadPerTaskExecutor()) {

    Future<String> first =
        executor.submit(this::loadFirst);

    Future<String> second =
        executor.submit(this::loadSecond);

    String result =
        first.get() + second.get();
}

newVirtualThreadPerTaskExecutor()는 제출된 각 작업을 새 가상 스레드에서 실행한다.[52]

가상 스레드는 생성 비용이 낮기 때문에 스레드 자체를 제한된 자원처럼 풀링하지 않는다. 외부 데이터베이스 연결이나 원격 서비스가 허용하는 동시 요청 수는 세마포어와 연결 풀 등 실제 제한 자원을 기준으로 별도 제어한다.

Semaphore databaseSlots =
    new Semaphore(50);

void queryWithLimit()
        throws InterruptedException {

    databaseSlots.acquire();

    try {
        queryDatabase();
    } finally {
        databaseSlots.release();
    }
}

가상 스레드와 처리량

가상 스레드는 CPU 연산을 더 빠르게 만들거나 사용 가능한 CPU 코어 수를 늘리지 않는다. 주요 목적은 네트워크와 파일, 데이터베이스처럼 대기 시간이 긴 작업을 많은 동시 스레드로 표현하면서 플랫폼 스레드 비용을 줄이는 것이다.

다음과 같은 작업에 적합하다.

  • 요청별 독립적인 서버 처리
  • 네트워크 서비스 호출
  • 데이터베이스 질의
  • 파일과 소켓 I/O
  • 많은 동시 연결을 가진 서비스
  • 순차적으로 읽히는 블로킹 API 조합

CPU를 계속 사용하는 작업의 최대 병렬성은 여전히 CPU 코어와 스케줄링 자원에 의해 제한된다. CPU 중심 작업에는 제한된 병렬 실행자나 Fork/Join 풀을 사용할 수 있다.

가상 스레드의 고정

가상 스레드가 특정 네이티브 동작 중이면 캐리어 스레드에서 분리되지 못할 수 있다. 이를 고정이라고 한다.

Java 24의 JEP 491은 가상 스레드가 synchronized 블록 안에서 대기할 때 발생하던 주요 고정 원인을 제거하여, 모니터를 보유한 가상 스레드도 많은 블로킹 작업에서 캐리어를 해제할 수 있도록 HotSpot을 변경했다.[53]

다만 일부 네이티브 메서드와 외부 함수 호출에서는 여전히 캐리어 고정이 발생할 수 있다. 장시간 네이티브 블로킹 호출을 대량으로 수행하는 코드는 실제 실행 특성을 측정해야 한다.

스레드 로컬

ThreadLocal은 같은 변수 객체를 통해 스레드마다 서로 다른 값을 보관한다.

private static final ThreadLocal<RequestContext>
    CONTEXT = new ThreadLocal<>();
CONTEXT.set(context);

try {
    handleRequest();
} finally {
    CONTEXT.remove();
}

스레드 풀에서는 작업이 끝난 뒤 값을 제거하지 않으면 다음 작업이 이전 값을 관찰하거나 객체가 장기간 유지될 수 있다.

가상 스레드도 스레드 로컬을 지원하지만, 매우 많은 가상 스레드 각각에 큰 상태를 복사하거나 저장하면 메모리 비용이 증가할 수 있다. 변경되지 않는 문맥 데이터를 호출 트리와 자식 스레드에 전달하는 경우 Scoped Values를 사용할 수 있다.

Scoped Values

ScopedValue는 특정 동적 실행 범위 안에서 변경 불가능한 데이터를 메서드 호출 계층과 자식 스레드에 전달한다. Java 25에서 정식 API가 되었다.[54]

private static final ScopedValue<RequestContext>
    CONTEXT = ScopedValue.newInstance();

값을 특정 실행 범위에 바인딩할 수 있다.

ScopedValue.where(CONTEXT, context)
    .run(() -> handleRequest());

호출된 메서드는 매개변수로 직접 전달받지 않아도 현재 바인딩을 읽을 수 있다.

void authorize() {
    RequestContext context = CONTEXT.get();
    checkPermission(context.user());
}

바인딩은 지정된 실행 범위가 끝나면 자동으로 사라지고, 내부 코드에서 다른 값으로 변경할 수 없다. 이는 명시적인 remove()가 필요한 변경 가능한 ThreadLocal보다 수명과 데이터 흐름을 추론하기 쉽게 한다.

Scoped Values는 일반 전역 변수와 다르다. 값은 현재 실행 범위와 그 안에서 호출된 코드에만 보이며, 외부 코드나 실행이 끝난 뒤에는 바인딩되지 않는다.

구조적 동시성

구조적 동시성은 하나의 작업에서 생성한 여러 하위 작업의 수명을 부모 작업의 블록 범위에 포함한다. 부모는 모든 하위 작업이 성공하거나 실패 또는 취소로 종료되기 전에 범위를 벗어나지 않는다.

Java 26의 StructuredTaskScope는 여섯 번째 프리뷰 API다. 프리뷰 기능을 활성화한 프로그램에서 사용할 수 있으며 향후 릴리스에서 API가 변경되거나 정식 기능으로 전환될 수 있다.[55]

Response loadResponse()
        throws InterruptedException {

    try (var scope =
             StructuredTaskScope.open()) {

        var user =
            scope.fork(this::loadUser);

        var orders =
            scope.fork(this::loadOrders);

        scope.join();

        return new Response(
            user.get(),
            orders.get()
        );
    }
}

하위 작업은 범위 안에서 시작되고, join()은 범위의 정책이 완료 조건을 만족할 때까지 기다린다. 범위를 닫을 때 남아 있는 작업은 취소될 수 있으며, 부모 작업의 취소와 인터럽트도 하위 작업으로 전달하기 쉬워진다.

프리뷰 API를 사용하려면 컴파일과 실행에서 프리뷰 기능을 활성화해야 한다.

javac \
    --release 26 \
    --enable-preview \
    Application.java

java \
    --enable-preview \
    Application

구조화된 실패 처리

여러 하위 작업이 모두 성공해야 하는 작업에서는 하나가 실패하면 나머지를 취소하고 실패를 전파하는 정책을 사용할 수 있다.

Response loadResponse()
        throws InterruptedException {

    try (var scope =
             StructuredTaskScope.open(
                 Joiner.allSuccessfulOrThrow()
             )) {

        var user =
            scope.fork(this::loadUser);

        var orders =
            scope.fork(this::loadOrders);

        scope.join();

        return new Response(
            user.get(),
            orders.get()
        );
    }
}

여러 대안 중 가장 먼저 성공한 결과를 사용할 수도 있다.

String loadFromReplicas()
        throws InterruptedException {

    try (var scope =
             StructuredTaskScope.open(
                 Joiner.anySuccessfulResultOrThrow()
             )) {

        scope.fork(this::loadFromPrimary);
        scope.fork(this::loadFromReplica);
        scope.fork(this::loadFromBackup);

        return scope.join();
    }
}

성공한 결과가 결정되면 나머지 하위 작업은 취소 대상으로 처리할 수 있다.

Java 26의 구조적 동시성 API는 시간 제한이 만료되었을 때 결과를 결정하는 Joiner.onTimeout() 정책도 지원한다.

비구조적 작업과의 차이

독립적인 실행자에 작업을 제출한 뒤 일부 Future를 잃어버리면 작업 수명과 오류가 부모 코드의 범위를 벗어날 수 있다.

Future<User> user =
    executor.submit(this::loadUser);

Future<List<Order>> orders =
    executor.submit(this::loadOrders);

return new Response(
    user.get(),
    orders.get()
);

user.get()에서 예외가 발생하면 orders 작업을 취소하지 않은 채 메서드를 빠져나갈 수 있다. 별도의 finally와 취소 처리를 직접 구성해야 한다.

구조적 동시성에서는 작업이 생성된 범위가 소유권과 취소 및 완료를 함께 관리한다.

  • 부모와 자식 작업의 수명 관계가 명시된다.
  • 실패 시 관련 작업을 함께 취소할 수 있다.
  • 부모의 인터럽트를 자식에게 전파할 수 있다.
  • 스레드 덤프와 진단 도구에서 작업 계층을 파악하기 쉽다.
  • Scoped Values의 바인딩을 하위 작업이 상속할 수 있다.

비동기 콜백과 스레드 기반 코드

CompletableFuture는 콜백 기반 비동기 파이프라인을 구성하고 작업의 완료 순서에 따라 변환을 연결하는 데 적합하다.

return loadUserAsync()
    .thenCompose(user ->
        loadOrdersAsync(user.id())
            .thenApply(orders ->
                new Response(user, orders)
            )
    );

가상 스레드를 사용하면 같은 동작을 동기적인 코드 형태로 표현할 수 있다.

User user = loadUser();
List<Order> orders =
    loadOrders(user.id());

return new Response(user, orders);

가상 스레드에서 블로킹 API를 사용하는 코드는 호출 스택과 예외 흐름이 일반적인 순차 코드와 같아 디버깅이 단순할 수 있다.

두 방식은 서로를 완전히 대체하지 않는다.

  • 이벤트가 연속적으로 도착하는 스트림 처리
  • 외부 비동기 API와 직접 결합
  • 여러 완료 단계를 데이터 흐름으로 조합
  • 스레드를 점유하지 않는 특수 런타임 연동

위와 같은 경우에는 CompletionStage와 반응형 API가 자연스러울 수 있다.

독립적인 요청 단위와 블로킹 I/O를 조합하는 서버 코드에서는 가상 스레드와 구조적 동시성이 단순한 구조를 제공할 수 있다.

Flow와 반응형 스트림

java.util.concurrent.Flow는 게시자와 구독자 사이의 비동기 스트림 처리를 위한 인터페이스를 제공한다.

주요 인터페이스는 다음과 같다.

  • Flow.Publisher
  • Flow.Subscriber
  • Flow.Subscription
  • Flow.Processor
class TextSubscriber
        implements Flow.Subscriber<String> {

    private Flow.Subscription subscription;

    @Override
    public void onSubscribe(
            Flow.Subscription subscription) {

        this.subscription = subscription;
        subscription.request(1);
    }

    @Override
    public void onNext(String item) {
        try {
            process(item);
        } finally {
            subscription.request(1);
        }
    }

    @Override
    public void onError(Throwable throwable) {
        log(throwable);
    }

    @Override
    public void onComplete() {
        System.out.println("completed");
    }
}

구독자는 request(n)으로 처리할 수 있는 항목 수를 게시자에게 알린다. 이 요청 기반 흐름 제어가 생산자가 소비자보다 빠르게 데이터를 무제한 전달하지 않도록 하는 역압을 구성한다.

SubmissionPublisher는 표준 라이브러리가 제공하는 기본 게시자 구현이다.

try (SubmissionPublisher<String> publisher =
         new SubmissionPublisher<>()) {

    publisher.subscribe(
        new TextSubscriber()
    );

    publisher.submit("Java");
    publisher.submit("JVM");
}

Flow는 기본 프로토콜을 정의하지만 복잡한 변환 연산자와 실행 정책은 별도의 반응형 라이브러리에서 제공되는 경우가 많다.

교착 상태

교착 상태는 두 개 이상의 스레드가 서로 상대가 보유한 자원을 기다리면서 영원히 진행하지 못하는 상태다.

Object firstLock = new Object();
Object secondLock = new Object();

Thread first = new Thread(() -> {
    synchronized (firstLock) {
        synchronized (secondLock) {
            performFirst();
        }
    }
});

Thread second = new Thread(() -> {
    synchronized (secondLock) {
        synchronized (firstLock) {
            performSecond();
        }
    }
});

첫 번째 스레드가 firstLock을, 두 번째 스레드가 secondLock을 획득하면 서로 다른 락을 기다리며 멈출 수 있다.

모든 코드에서 락 획득 순서를 일관되게 유지하면 순환 대기를 줄일 수 있다.

synchronized (firstLock) {
    synchronized (secondLock) {
        performWork();
    }
}

다른 방법은 다음과 같다.

  • 여러 락을 동시에 보유하는 범위를 줄임
  • tryLock과 시간 제한 사용
  • 불변 데이터와 메시지 전달 사용
  • 하나의 락으로 관련 상태를 통합
  • 작업을 독립적인 소유 스레드에 위임
  • 동시성 컬렉션과 원자 연산 사용

라이브락과 기아

라이브락은 스레드가 실행되고 상태도 바꾸지만 서로에게 양보하는 동작만 반복하여 실제 작업이 진행되지 않는 상태다.

while (!acquireFirstResource()) {
    releaseSecondResource();
    Thread.yield();
}

여러 참여자가 같은 대응 규칙을 동시에 반복하면 계속 충돌할 수 있다. 무작위 지연과 우선순위 또는 중앙 조정 정책을 사용할 수 있다.

기아는 특정 스레드가 필요한 CPU 시간이나 락을 계속 얻지 못하는 상태다. 긴 임계 영역과 편향된 작업 큐, 불공정한 락 사용 등이 원인이 될 수 있다.

공정한 ReentrantLock을 생성할 수 있다.

Lock lock =
    new ReentrantLock(true);

공정성은 오래 기다린 스레드에 우선권을 주는 방향으로 동작하지만 처리량이 낮아질 수 있으며 완전한 스케줄링 보장을 의미하지 않는다.

경쟁 상태를 줄이는 설계

공유되는 변경 가능 상태를 줄이면 동기화 부담도 줄어든다.

대표적인 방식은 다음과 같다.

  • 불변 객체 사용
  • 메서드 지역 상태 사용
  • 상태의 단일 소유자 지정
  • 메시지 큐를 통한 전달
  • 작업 결과를 값으로 반환
  • 원자적인 고수준 컬렉션 연산 사용
  • 관련 상태를 하나의 락 아래 배치
  • 외부 콜백을 락을 보유한 상태에서 호출하지 않음
record State(
    long version,
    List<String> values
) {
}

AtomicReference<State> state =
    new AtomicReference<>(
        new State(0, List.of())
    );

새 상태 객체를 만들어 하나의 원자 참조로 교체하면 여러 필드를 따로 갱신하는 중간 상태가 외부에 노출되는 것을 줄일 수 있다.

락 안의 외부 호출

락을 보유한 채 외부 코드와 느린 I/O를 호출하면 임계 영역이 길어지고 교착 상태 가능성이 커질 수 있다.

synchronized void update() {
    state = calculateState();
    listener.onChanged(state);
}

listener가 다시 현재 객체를 호출하거나 다른 락을 획득하면 예상하지 못한 순환 관계가 만들어질 수 있다.

필요한 상태를 락 안에서 복사한 뒤 외부 호출은 락 밖에서 수행할 수 있다.

void update() {
    State snapshot;

    synchronized (this) {
        state = calculateState();
        snapshot = state;
    }

    listener.onChanged(snapshot);
}

시간 제한과 취소

외부 시스템을 기다리는 동시 작업에는 무제한 대기 대신 시간 제한과 취소 경로를 둘 수 있다.

try {
    return future.get(
        5,
        TimeUnit.SECONDS
    );
} catch (TimeoutException exception) {
    future.cancel(true);
    throw exception;
}

시간 제한은 호출 계층 전체에서 일관되게 전달하는 것이 중요하다. 하위 작업마다 독립적으로 동일한 시간 제한을 적용하면 전체 요청 시간이 제한의 합보다 길어질 수 있다.

마감 시각이나 남은 시간 값을 문맥으로 전달하면 하위 작업이 같은 요청 제한을 공유할 수 있다.

record Deadline(Instant expiresAt) {
    Duration remaining() {
        Duration value = Duration.between(
            Instant.now(),
            expiresAt
        );

        return value.isNegative()
            ? Duration.ZERO
            : value;
    }
}

Scoped Values와 구조적 동시성을 사용하면 요청의 마감과 취소 문맥을 자식 작업에 전달하는 구조를 만들 수 있다.

스레드 안전성 문서화

클래스가 여러 스레드에서 어떻게 사용될 수 있는지 API 수준에서 명확히 해야 한다.

일반적인 분류는 다음과 같다.

  • 불변: 생성 후 상태가 변하지 않아 안전하게 공유 가능
  • 스레드 안전: 외부 동기화 없이 여러 스레드에서 사용 가능
  • 조건부 스레드 안전: 일부 작업 조합에 외부 동기화 필요
  • 스레드 호환: 인스턴스를 공유하지 않거나 외부 동기화 시 사용 가능
  • 스레드 전용: 특정 스레드에서만 사용 가능
/**
 * 여러 스레드에서 안전하게 사용할 수 있다.
 * 모든 공개 연산은 내부 락으로 보호된다.
 */
final class SharedCache {
}

스레드 안전성은 메서드 하나의 구현뿐 아니라 객체가 반환하는 컬렉션과 반복자, 콜백, 외부 자원 및 상속 가능성까지 포함하는 계약이다.

동시성 진단

JDK 도구를 이용하여 실행 중인 스레드와 락 상태를 확인할 수 있다.

jcmd <pid> Thread.print

또는 다음 도구를 사용할 수 있다.

jstack <pid>

스레드 덤프에는 일반적으로 다음 정보가 포함된다.

  • 스레드 이름과 상태
  • 현재 호출 스택
  • 대기하거나 보유한 모니터
  • 네이티브 스레드 정보
  • 감지된 교착 상태
  • 플랫폼 스레드 실행 위치

Java Flight Recorder와 JDK Mission Control은 다음 항목을 장기간 기록하고 분석할 수 있다.

  • 스레드 생성과 종료
  • 모니터 진입 대기
  • 락 경쟁
  • 스레드 파킹
  • 가상 스레드 이벤트
  • CPU 사용 시간
  • I/O 대기
  • 실행자와 작업 상태

가상 스레드가 매우 많은 프로그램에서는 전통적인 스레드 하나당 긴 목록보다 구조적 작업 관계와 JFR 이벤트를 함께 사용하는 것이 유용하다.

동시성 모델의 구성

Java의 동시성과 비동기 처리 방식은 작업 성격에 따라 다음과 같이 나눌 수 있다.

  • 소수의 직접 제어 스레드에는 Thread
  • 공유 상태 보호에는 synchronizedLock
  • 단일 값 갱신에는 원자 변수
  • 공유 자료구조에는 동시성 컬렉션
  • 생산자·소비자 흐름에는 BlockingQueue
  • 제한된 플랫폼 스레드 실행에는 스레드 풀
  • 결과 대기에는 Future
  • 완료 단계 조합에는 CompletableFuture
  • CPU 분할 계산에는 Fork/Join
  • 많은 블로킹 I/O 작업에는 가상 스레드
  • 부모·자식 작업의 수명 관리에는 구조적 동시성
  • 변경 불가능한 요청 문맥 전달에는 Scoped Values
  • 비동기 데이터 스트림과 역압에는 Flow

Java의 현대적인 동시성 모델은 비동기성을 반드시 콜백 체인으로 표현하도록 요구하지 않는다. 가상 스레드는 블로킹 코드를 대규모 동시 처리에 사용할 수 있게 하고, 구조적 동시성은 생성된 하위 작업을 부모의 수명 안에 묶는다. 동시에 CompletableFutureFlow, 실행자 프레임워크는 완료 단계와 데이터 흐름 중심의 비동기 처리를 계속 제공한다.

어떤 방식을 선택하더라도 공유 상태의 동기화와 인터럽트 및 취소 전달, 시간 제한, 자원 반환과 오류 전파를 함께 설계해야 한다. Java 메모리 모델과 happens-before 관계는 이러한 여러 API가 스레드 사이의 일관된 실행을 구성하는 공통 기반이다.

표준 라이브러리와 모듈

Java는 언어 문법과 JVM만으로 구성되지 않으며, 문자열과 컬렉션, 파일, 네트워크, 동시성, 보안, 데이터베이스 등 일반적인 프로그램 개발에 필요한 표준 API를 함께 제공한다. 이러한 API의 집합을 Java 표준 라이브러리 또는 Java SE API라고 한다.

Java SE API는 java.*와 일부 javax.* 패키지를 중심으로 구성된다. Java 9부터는 패키지들이 다시 모듈 단위로 묶이며, Java SE에 포함되는 표준 모듈의 이름은 주로 java.로 시작한다. JDK 구현과 개발 도구에 특화된 API는 주로 jdk. 모듈에 포함되며, 모든 Java SE 구현에서 동일하게 제공된다고 보장되지는 않는다.[56]

이 절에서는 표준 라이브러리와 모듈의 전체 구조만 다룬다. 각 패키지와 클래스의 기능, 사용법과 예제는 Java 표준 라이브러리 문서에서 자세히 다룬다.

기본 API

java.base는 Java 플랫폼의 가장 기본적인 모듈이다. 모든 이름 있는 모듈은 java.base에 암시적으로 의존하므로 별도의 requires java.base 선언을 작성하지 않아도 된다.

java.base에는 다음과 같은 핵심 기능이 포함된다.

  • 기본 객체와 문자열
  • 기본 타입 래퍼 클래스
  • 수학과 임의 정밀도 수
  • 컬렉션 프레임워크
  • 스트림 API
  • 파일과 기본 입출력
  • 네트워크 소켓과 URI
  • 스레드와 동시성 도구
  • 리플렉션과 메서드 핸들
  • 클래스 로딩과 모듈 조회
  • 보안과 암호화의 기본 API
  • 날짜와 시간
  • 정규 표현식
  • ZIP과 JAR 처리

String, Object, System, Math, Thread, List, Map, Stream, Path, Optional처럼 Java 프로그램에서 자주 사용하는 타입 대부분이 이 모듈에 포함된다. java.base는 Java SE 플랫폼의 기반 API를 정의한다.[57]

컬렉션과 데이터 처리

Java Collections Framework는 여러 값을 저장하고 탐색하기 위한 인터페이스와 구현체를 제공한다.

대표적인 계열은 다음과 같다.

  • List
  • Set
  • Map
  • Queue
  • Deque
  • 정렬 컬렉션
  • 불변 컬렉션 생성 API
  • 동시성 컬렉션

Stream API는 컬렉션과 배열 등의 데이터를 필터링하고 변환하며 집계하는 연산을 제공한다. 스트림은 자료를 직접 저장하는 컬렉션이 아니라 데이터 처리 파이프라인을 표현한다.

List<String> names =
    List.of("Java", "Kotlin", "Scala");

List<String> result = names.stream()
    .filter(name -> name.length() > 4)
    .map(String::toUpperCase)
    .toList();

컬렉션과 스트림의 구현체별 특성, 시간 복잡도와 선택 기준은 Java 표준 라이브러리에서 다룬다.

입출력과 파일 시스템

Java는 바이트와 문자 스트림을 위한 전통적인 java.io API와, 파일 시스템 및 채널 기반 입출력을 위한 java.nio 계열 API를 제공한다.

주요 기능은 다음과 같다.

  • 파일과 디렉터리 생성 및 탐색
  • 바이트와 문자열 읽기 및 쓰기
  • 버퍼와 채널
  • 비동기 채널
  • 파일 잠금
  • 메모리 매핑 파일
  • 문자 인코딩과 디코딩
  • ZIP과 JAR 파일
  • 객체 직렬화

현대적인 파일 작업에서는 PathFiles가 주로 사용된다.

Path path = Path.of("config.txt");

String content =
    Files.readString(path);

파일과 스트림처럼 외부 자원을 보유하는 객체는 일반적으로 try-with-resources로 관리한다.

네트워크와 HTTP

java.net 패키지는 IP 주소와 URL, URI, TCP 및 UDP 소켓과 네트워크 인터페이스를 다루는 API를 제공한다.

java.net.http 모듈은 HTTP 클라이언트와 WebSocket API를 제공한다.[58]

HttpClient client =
    HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://example.com"))
    .build();

HttpResponse<String> response =
    client.send(
        request,
        HttpResponse.BodyHandlers.ofString()
    );

HTTP 클라이언트는 동기식 요청과 CompletableFuture를 반환하는 비동기 요청을 모두 지원한다.

날짜와 시간

java.time 패키지는 날짜와 시간, 시간대, 기간 및 시간 간격을 표현한다.

대표적인 타입은 다음과 같다.

  • Instant
  • LocalDate
  • LocalTime
  • LocalDateTime
  • ZonedDateTime
  • ZoneId
  • Duration
  • Period
  • DateTimeFormatter
Instant now = Instant.now();

ZonedDateTime seoulTime =
    now.atZone(ZoneId.of("Asia/Seoul"));

이 API는 대부분 불변 객체로 구성되어 여러 스레드에서 공유하기 쉽다.

동시성

java.util.concurrent와 관련 패키지는 스레드보다 높은 수준에서 동시 작업을 구성하는 API를 제공한다.

주요 기능은 다음과 같다.

  • 실행자와 스레드 풀
  • FutureCompletableFuture
  • 가상 스레드 실행자
  • 락과 조건 변수
  • 원자 변수
  • 동시성 컬렉션
  • 블로킹 큐
  • 세마포어와 래치
  • Fork/Join 프레임워크
  • 반응형 스트림 인터페이스

구체적인 동작과 선택 기준은 이 문서의 동시성과 비동기 처리 절에서 다룬다.

데이터베이스

java.sql 모듈은 관계형 데이터베이스에 접근하기 위한 JDBC API를 제공한다.

JDBC는 다음과 같은 공통 인터페이스를 정의한다.

  • 데이터베이스 연결
  • SQL 문 실행
  • 준비된 SQL 문
  • 결과 집합
  • 트랜잭션
  • 데이터베이스 메타데이터
  • 저장 프로시저 호출
try (Connection connection =
         DriverManager.getConnection(url);

     PreparedStatement statement =
         connection.prepareStatement(
             "SELECT name FROM users WHERE id = ?"
         )) {

    statement.setLong(1, userId);

    try (ResultSet result = statement.executeQuery()) {
        if (result.next()) {
            System.out.println(
                result.getString("name")
            );
        }
    }
}

JDBC는 데이터베이스 제품별 드라이버가 공통 Java 인터페이스를 구현하는 구조다.

XML과 문서 처리

java.xml 모듈은 XML 문서의 파싱과 변환, 검증을 위한 API를 제공한다.

주요 기능은 다음과 같다.

  • DOM
  • SAX
  • StAX
  • XPath
  • XSLT
  • XML Schema 검증

JSON은 Java SE의 독립적인 핵심 API로 포함되지 않으며, 일반적으로 외부 라이브러리나 Jakarta 계열 API를 사용한다.

데스크톱과 그래픽

java.desktop 모듈은 데스크톱 사용자 인터페이스와 그래픽을 위한 기능을 제공한다.

  • AWT
  • Swing
  • Java 2D
  • 이미지 입출력
  • 인쇄
  • 오디오
  • 클립보드와 데이터 전송
  • 접근성
  • JavaBeans

java.desktop은 AWT와 Swing 사용자 인터페이스 도구 모음뿐 아니라 이미지, 오디오, 인쇄와 접근성 API를 함께 정의한다.[59]

JavaFX는 현대 JDK에서 Java SE 표준 모듈에 포함되지 않으며 별도의 프로젝트와 라이브러리로 배포된다.

보안과 암호화

Java 표준 라이브러리는 다음과 같은 보안 기능을 제공한다.

  • 메시지 다이제스트
  • 전자 서명
  • 공개키와 대칭키 암호화
  • 인증서와 키 저장소
  • 보안 난수
  • TLS
  • 접근 제어와 권한 관련 API
  • SASL과 GSS-API

암호화 API는 서비스 제공자 구조를 사용한다. 동일한 상위 API 아래에서 서로 다른 암호 알고리즘 구현체와 보안 제공자를 선택할 수 있다.

리플렉션과 동적 실행

Java는 실행 중 클래스와 메서드, 필드, 생성자 및 애너테이션을 조사할 수 있는 리플렉션 API를 제공한다.

관련 기능은 다음 패키지에 분산되어 있다.

  • java.lang.reflect
  • java.lang.invoke
  • java.lang.constant
  • java.lang.classfile
  • java.lang.module

메서드 핸들은 일반적인 리플렉션 호출보다 JVM의 호출 모델과 밀접하게 결합된 동적 호출 기능을 제공한다. 클래스 파일 API는 클래스 파일 구조를 읽고 생성하거나 변환하는 기능을 제공한다.

국제화와 지역화

표준 라이브러리는 여러 언어와 지역에 맞춘 프로그램을 만들기 위한 기능을 제공한다.

  • 로케일
  • 숫자와 통화 형식
  • 날짜와 시간 형식
  • 메시지 형식
  • 리소스 번들
  • 문자 집합
  • 텍스트 정렬과 경계 분석
NumberFormat format =
    NumberFormat.getCurrencyInstance(
        Locale.KOREA
    );

String result =
    format.format(10000);

문자열 자체는 유니코드를 사용하지만, 표시 형식과 정렬 및 대소문자 변환은 로케일의 영향을 받을 수 있다.

서비스 제공자 구조

표준 라이브러리의 일부 API는 공통 인터페이스와 교체 가능한 구현체를 분리하는 서비스 제공자 인터페이스 구조를 사용한다.

대표적인 예는 다음과 같다.

  • JDBC 드라이버
  • 문자 집합 제공자
  • 파일 시스템 제공자
  • 보안 제공자
  • 로케일 데이터 제공자
  • 네트워크 주소 해석 제공자

Java 모듈 시스템에서는 usesprovides ... with ... 지시어로 서비스 소비자와 제공자를 선언할 수 있다.

패키지

패키지는 관련된 클래스와 인터페이스를 묶고 이름 충돌을 방지하는 단위다.

package com.example.library;

패키지는 타입 이름 공간과 접근 제어에 관여한다. 제한자를 생략한 클래스와 멤버는 같은 패키지 안에서 접근할 수 있다.

패키지 이름은 디렉터리와 대응하여 배치하는 것이 일반적이지만, 패키지와 모듈은 서로 다른 개념이다.

  • 패키지는 타입의 이름 공간이다.
  • 모듈은 여러 패키지와 의존 관계를 묶는다.
  • 하나의 패키지는 하나의 이름 있는 모듈에만 속한다.
  • 모듈은 어떤 패키지를 외부에 공개할지 지정한다.

Java 플랫폼 모듈 시스템

Java 플랫폼 모듈 시스템은 Java 9에서 도입되었다. 모듈은 패키지와 자원을 묶고 다른 모듈에 대한 의존성, 외부에 공개할 패키지와 서비스 제공 관계를 선언한다.

모듈 시스템은 컴파일 시점과 링킹 시점 및 실행 시점에 신뢰할 수 있는 의존성 구성을 제공하고, 공개하지 않은 내부 패키지를 강하게 캡슐화하기 위해 설계되었다.[60]

모듈 선언은 일반적으로 module-info.java 파일에 작성한다.

module com.example.application {
    requires java.net.http;

    exports com.example.application.api;
}

이 선언은 현재 모듈이 java.net.http 모듈에 의존하며, com.example.application.api 패키지를 다른 모듈에 공개한다는 뜻이다.

모듈 지시어

모듈 선언에서 사용하는 주요 지시어는 다음과 같다.

지시어역할
requires다른 모듈에 의존
requires transitive의존성을 현재 모듈의 사용자에게 전달
requires static컴파일 시 필요하지만 실행 시 선택 가능
exports패키지의 공개 타입을 다른 모듈에 공개
opens리플렉션을 위해 패키지를 개방
uses서비스를 사용하는 모듈임을 선언
provides ... with ...서비스 구현체를 제공
module com.example.provider {
    exports com.example.api;

    provides com.example.api.Storage
        with com.example.internal.FileStorage;
}

exports는 일반적인 컴파일과 실행 시 접근을 허용하고, opens는 주로 리플렉션 접근을 허용한다. 두 지시어는 같은 기능이 아니다.

이름 있는 모듈과 이름 없는 모듈

명시적인 module-info.java를 가진 애플리케이션은 이름 있는 모듈로 실행할 수 있다.

기존 클래스 경로에 배치된 코드는 이름 없는 모듈에 속한다. 이름 없는 모듈은 기존 Java 애플리케이션과 라이브러리가 모듈 선언 없이 계속 실행될 수 있도록 하는 호환 구조다.

모듈 경로에 배치된 모듈형 JAR은 이름 있는 모듈로 처리되며, 일반 JAR도 조건에 따라 자동 모듈로 처리될 수 있다.

표준 모듈

java.se는 Java SE 플랫폼 전체의 표준 API를 나타내는 집합 모듈이다. 이 모듈은 java.base, java.compiler, java.desktop, java.logging, java.management, java.net.http, java.sql, java.xml 등 Java SE 모듈을 전이적으로 요구한다.[61]

주요 표준 모듈은 다음과 같다.

모듈주요 영역
java.base핵심 언어 지원과 기본 API
java.compiler컴파일러와 언어 모델 API
java.desktopAWT, Swing, 이미지와 오디오
java.logging기본 로깅
java.managementJVM 관리와 JMX
java.namingJNDI
java.net.httpHTTP 클라이언트와 WebSocket
java.sqlJDBC
java.transaction.xa분산 트랜잭션 인터페이스
java.xmlXML 처리
java.instrumentJava 에이전트와 클래스 계측

jdk.* 모듈에는 컴파일러 구현과 디버거, JFR, 관리 도구 등 특정 JDK의 기능이 포함된다. 이들은 JDK의 일부지만 Java SE 표준 API와는 구분된다.

사용자 정의 실행 이미지

모듈화된 애플리케이션은 jlink를 사용하여 필요한 모듈만 포함하는 사용자 정의 실행 이미지를 만들 수 있다.

jlink는 애플리케이션 모듈과 전이적 의존 모듈을 결합하여 전용 실행 환경을 생성한다. Java 모듈 시스템은 컴파일과 실행 사이에 선택적인 링크 단계를 추가하며, jlink는 이 단계에서 사용자 정의 런타임 이미지를 구성한다.[62]

표준 라이브러리와 외부 생태계

Java 표준 라이브러리는 일반적인 프로그램에 필요한 넓은 기능을 제공하지만 모든 개발 영역을 직접 포함하지는 않는다.

다음 기능은 주로 외부 라이브러리나 별도 플랫폼에서 제공된다.

  • 웹 애플리케이션 프레임워크
  • 객체 관계 매핑
  • JSON 직렬화
  • 의존성 주입
  • 로깅 구현과 통합 로깅 API
  • 머신러닝
  • 게임과 멀티미디어 엔진
  • JavaFX
  • Jakarta EE
  • Android API

표준 라이브러리는 이러한 생태계가 공통으로 사용하는 문자열과 컬렉션, 입출력, 네트워크, 동시성, 리플렉션 및 모듈 기반을 제공한다.

Java 표준 라이브러리의 세부 패키지 구성과 주요 클래스, 컬렉션 선택, 파일 입출력, HTTP, 날짜와 시간 및 API별 사용법은 Java 표준 라이브러리 문서에서 다룬다.

JVM과 구현체

Java 프로그램의 실행 환경은 하나의 단일 제품으로만 구현되지 않는다. JVM 명세와 Java SE 명세를 따르는 여러 가상 머신 및 JDK 구현체가 존재하며, 기업과 오픈 소스 프로젝트는 이들을 서로 다른 운영체제, 프로세서, 성능 목표와 지원 정책에 맞추어 개발하고 배포한다.

Java 실행 환경을 구분할 때는 다음 개념을 분리할 필요가 있다.

  • JVM 구현체는 클래스 파일 로딩과 바이트코드 실행, JIT 컴파일, 가비지 컬렉션 및 스레드 실행을 담당한다.
  • 클래스 라이브러리 구현체java.lang, java.util, java.io 등 Java SE API를 제공한다.
  • JDK는 JVM과 클래스 라이브러리, 컴파일러 및 개발·진단 도구를 결합한다.
  • JDK 배포판은 특정 프로젝트나 기업이 JDK 소스를 빌드하고 시험하여 운영체제별 설치 패키지와 업데이트 정책을 제공하는 제품이다.

같은 OpenJDK 소스를 기반으로 만든 배포판들은 언어와 표준 API 수준에서는 대체로 호환되지만, 포함된 JVM과 지원 플랫폼, 인증, 업데이트 기간, 설치 방식 및 추가 도구에서 차이가 날 수 있다.

OpenJDK

OpenJDK는 Java SE 플랫폼의 오픈 소스 참조 구현을 개발하는 프로젝트다. OpenJDK의 JDK 프로젝트는 Java SE 명세에 대응하는 참조 구현을 공개적으로 개발하며, 현재 6개월 간격의 기능 릴리스 체계를 사용한다.[63]

OpenJDK 소스 트리는 하나의 JVM만이 아니라 다음과 같은 JDK 전체 구성 요소를 포함한다.

  • HotSpot JVM
  • Java 클래스 라이브러리
  • javac 컴파일러
  • java 실행 도구
  • javadoc
  • jlinkjpackage
  • 디버깅 및 진단 도구
  • Java Flight Recorder
  • 운영체제별 네이티브 코드

OpenJDK는 사용자가 설치하는 단일 공식 제품명으로만 쓰이지 않는다. Oracle과 Eclipse Adoptium, Amazon, Microsoft, Azul, BellSoft 등 여러 조직이 OpenJDK 소스를 바탕으로 독자적인 빌드와 장기 지원 배포판을 제공한다.

HotSpot

HotSpot은 OpenJDK의 기본 JVM 구현체다. 클래스 로더와 바이트코드 인터프리터, JIT 컴파일러, 가비지 컬렉터, 동기화 런타임과 진단 기능을 포함한다. OpenJDK의 HotSpot 소스는 주로 C++로 구현되어 있다.[64]

HotSpot이라는 이름은 실행 중 자주 사용되는 메서드와 반복 구간인 핫스폿을 찾아 최적화한다는 방식에서 유래한다. 프로그램은 처음부터 모든 코드를 최고 수준으로 컴파일하지 않고, 실제 실행 정보를 수집하면서 자주 실행되는 코드를 네이티브 기계어로 변환한다.

HotSpot은 계층형 컴파일을 통해 빠른 시작과 장기 실행 성능을 조합한다. 구현과 실행 설정에 따라 바이트코드 인터프리터와 여러 단계의 JIT 컴파일러를 사용할 수 있다.

대표적인 최적화는 다음과 같다.

  • 메서드 인라인화
  • 가상 호출 최적화
  • 탈출 분석
  • 객체 할당 제거
  • 루프 최적화
  • 범위 검사 제거
  • 중복 코드 제거
  • 프로세서별 명령어 선택

실행 중 새로운 클래스가 로딩되거나 기존 최적화의 전제가 맞지 않게 되면 HotSpot은 최적화된 코드를 무효화하고 이전 실행 상태로 되돌리는 역최적화를 수행할 수 있다.

HotSpot의 가비지 컬렉터

HotSpot은 하나의 고정된 가비지 컬렉터만 제공하지 않는다. 처리량과 일시 정지 시간, 힙 크기와 사용 환경에 따라 여러 수집기 중 하나를 선택할 수 있다.

대표적인 수집기는 다음과 같다.

가비지 컬렉터중심 목표
Serial GC작은 힙과 단순한 실행 환경
Parallel GC높은 전체 처리량
G1 GC범용 서버와 예측 가능한 일시 정지 목표
ZGC대용량 힙과 낮은 일시 정지 시간
Shenandoah GC동시 객체 이동과 낮은 일시 정지 시간

가비지 컬렉터의 제공 여부와 기본 선택은 JDK 버전 및 배포판과 플랫폼에 따라 달라질 수 있다.

JIT 컴파일러

JVM의 JIT 컴파일러는 자주 실행되는 바이트코드를 현재 운영체제와 CPU에 맞는 기계어로 변환한다. 컴파일러는 단순히 명령어를 일대일로 바꾸는 것이 아니라 실행 중 수집한 타입과 분기, 호출 빈도 정보를 이용하여 코드를 최적화한다.

HotSpot은 전통적으로 서로 다른 목적의 컴파일 단계를 조합한다.

  • 빠르게 네이티브 코드를 생성하는 초기 컴파일
  • 실행 프로파일을 수집하는 계측 코드
  • 수집된 정보를 이용하는 고수준 최적화
  • 전제가 무효화되었을 때의 역최적화

JVM 명세는 특정 JIT 컴파일러 사용을 요구하지 않는다. JVM 구현체는 바이트코드를 해석만 할 수도 있고, 자체 JIT 또는 사전 컴파일 방식을 사용할 수도 있다.

Eclipse OpenJ9

Eclipse OpenJ9는 Eclipse Foundation에서 개발하는 오픈 소스 JVM 구현체다. IBM이 개발한 J9 가상 머신을 기반으로 하며, 빠른 시작과 낮은 메모리 사용량, 짧은 성능 준비 시간 및 서버 처리량을 주요 목표로 한다.[65]

OpenJ9는 OpenJDK 클래스 라이브러리와 결합하여 완전한 Java 실행 환경을 구성할 수 있다. OpenJ9 프로젝트 자체는 일반 사용자를 위한 JDK 바이너리를 직접 배포하기보다 JVM 소스와 관련 기술을 개발한다.

OpenJ9를 사용하는 대표적인 배포판은 IBM Semeru Runtimes다. Semeru의 OpenJ9 계열 빌드는 OpenJDK 클래스 라이브러리와 Eclipse OpenJ9 JVM을 결합한다.[66]

OpenJ9는 다음과 같은 기능과 배포 환경을 중점적으로 다룬다.

  • 낮은 메모리 사용량
  • 빠른 JVM 시작
  • 공유 클래스 캐시
  • 클라우드 및 컨테이너 환경
  • JIT 컴파일 서버
  • 여러 CPU 아키텍처와 기업용 운영체제

HotSpot과 OpenJ9는 같은 Java 클래스 파일을 실행할 수 있지만 JVM 내부 구조와 JIT, 가비지 컬렉터, 진단 옵션이 동일하지는 않다.

GraalVM

GraalVM은 Java와 다른 JVM 언어를 위한 고성능 JDK이자 다중 언어 실행 환경이다. 일반 JVM 실행에서는 HotSpot에 Java로 작성된 Graal JIT 컴파일러를 결합할 수 있다.[67]

GraalVM은 크게 두 가지 Java 실행 방식을 제공한다.

  • HotSpot JVM에서 Graal JIT 컴파일러를 사용하여 클래스 파일 실행
  • Native Image로 애플리케이션을 특정 플랫폼의 네이티브 실행 파일로 사전 컴파일

GraalVM Native Image는 애플리케이션과 라이브러리를 정적으로 분석하여 도달 가능한 코드와 데이터 및 필요한 런타임 구성 요소를 네이티브 실행 파일에 포함한다.[68]

네이티브 이미지는 JVM을 시작한 뒤 실행 중 프로파일을 수집하는 전통적인 방식과 달리 빌드 시점에 코드 범위를 결정하고 기계어를 생성한다. 이에 따라 빠른 시작과 낮은 초기 메모리 사용량을 제공할 수 있지만, 동적 클래스 로딩과 리플렉션, 프록시, JNI 및 자원 탐색에는 빌드 시점 분석이나 추가 구성이 필요할 수 있다.

GraalVM에는 Java 외의 언어를 구현하기 위한 Truffle 프레임워크도 포함된다. 이를 통해 여러 언어를 하나의 런타임에서 실행하고 상호 운용하는 환경을 구성할 수 있다.

AriaJDK

AriaJDK는 Wave Foundation에서 개발하는 오픈 소스 JDK 구현체다. OpenJDK의 HotSpot JVM을 다시 패키징하는 배포판이 아니라, 현대적인 시스템을 대상으로 JVM과 JDK 구성 요소를 새로 구현하는 독립 프로젝트다.[69]

AriaJDK는 JVM과 핵심 런타임을 Rust로 구현하여 메모리 안전성과 모듈성, 예측 가능한 실행 특성을 제공하는 것을 목표로 한다. 현재 프로젝트가 명시하는 호환 목표는 Java SE 17이며, Java 클래스 라이브러리는 Kotlin으로 구현한다.

AriaJDK의 저장소는 다음 구성 요소를 하나의 빌드 과정으로 결합한다.

  • Rust로 구현된 JVM 코어
  • Java 프로그램 실행 런처
  • JAR 도구
  • Kotlin으로 구현된 클래스 라이브러리
  • Kotlin 기반 Java 컴파일러 도구
  • Windows, Linux와 macOS용 JDK 패키징

통합 빌드 스크립트는 Rust 구성 요소와 Kotlin 구성 요소를 빌드하고, 실행 파일을 dist/bin에 모은 뒤 운영체제별 배포 압축 파일을 생성한다. 빌드된 환경은 aria 명령을 진입점으로 사용한다.

AriaJDK는 기존 OpenJDK 배포판과 달리 JVM 구현 언어와 클래스 라이브러리 구현 언어를 모두 교체한다는 점에서 독립 JVM 연구·개발 계열에 속한다. Java SE 호환 JDK로 완성되기 위해서는 JVM 명세뿐 아니라 클래스 파일 형식과 Java SE API, 표준 도구 및 호환성 검증을 함께 구현해야 한다.

프로젝트의 구조는 Java 플랫폼의 각 계층을 다음처럼 분리한다.

  • JVM 코어: 클래스 로딩과 바이트코드 실행, 메모리 및 런타임 관리
  • 클래스 라이브러리: Java SE API 구현
  • 도구: 컴파일러와 JAR 처리 등의 개발 도구
  • 런처와 배포: 사용자 명령과 운영체제별 JDK 패키지

AriaJDK는 Java 생태계의 주류 배포판이라기보다, Java SE 17을 대상으로 새로운 JVM 구조와 안전한 시스템 구현 방식을 실험하고 완성하는 독립 JDK 프로젝트로 분류할 수 있다.

JVM 구현체와 JDK 배포판의 차이

JVM과 JDK 배포판은 서로 같은 분류가 아니다.

예를 들어 Eclipse Temurin과 Oracle JDK는 JDK 배포판이며, 일반적으로 HotSpot을 포함한다. IBM Semeru의 OpenJ9 계열은 OpenJDK 클래스 라이브러리와 OpenJ9 JVM을 결합한다. GraalVM은 HotSpot과 Graal 컴파일러를 결합한 JVM 실행 환경과 Native Image를 함께 제공한다.

이름성격주요 JVM 또는 실행 방식
OpenJDKJava SE 참조 구현 개발 프로젝트HotSpot
HotSpotJVM 구현체인터프리터와 계층형 JIT
Eclipse OpenJ9독립 JVM 구현체OpenJ9 JIT와 자체 런타임
GraalVM고성능 JDK 및 다중 언어 런타임HotSpot과 Graal JIT, Native Image
AriaJDK독립 JDK 구현 프로젝트Rust 기반 자체 JVM
Eclipse TemurinOpenJDK 배포판HotSpot
Oracle JDKOracle의 JDK 배포판HotSpot
IBM Semeru RuntimesOpenJDK 기반 배포판주로 OpenJ9
Amazon CorrettoOpenJDK 배포판HotSpot
Microsoft Build of OpenJDKOpenJDK 배포판HotSpot
Azul ZuluOpenJDK 배포판HotSpot
Liberica JDKOpenJDK 배포판HotSpot

Oracle JDK

Oracle JDK는 Oracle이 제공하는 JDK 배포판이다. 현대 Oracle JDK와 OpenJDK 빌드는 대부분의 소스와 기능을 공유하지만, 배포 조건과 상용 지원, 설치 패키지, 업데이트 정책 및 일부 구성 요소에서 차이가 있을 수 있다.[70]

Oracle은 기능 릴리스와 장기 지원 릴리스에 대해 서로 다른 지원 기간을 제공한다. 배포판 선택 시에는 기술적 호환성뿐 아니라 사용 허가 조건과 보안 업데이트 기간 및 상용 지원 계약을 함께 확인해야 한다.

Eclipse Temurin

Eclipse Temurin은 Eclipse Adoptium이 제공하는 OpenJDK 기반 JDK 및 런타임 배포판이다. 여러 운영체제와 프로세서를 지원하는 오픈 소스 바이너리를 제공하며, Java SE TCK와 Adoptium의 AQAvit 시험을 거친다.[71]

Temurin은 별도의 JVM을 새로 구현하지 않고 일반적으로 OpenJDK의 HotSpot JVM을 포함한다. 개발자 환경과 서버, 컨테이너 및 CI 환경에서 범용 OpenJDK 배포판으로 사용된다.

Amazon Corretto

Amazon Corretto는 Amazon이 제공하는 OpenJDK 배포판이다. AWS 서비스뿐 아니라 일반적인 Linux, Windows와 macOS 환경에서도 사용할 수 있으며, Amazon이 장기 지원과 보안 업데이트를 제공한다.

Corretto도 기본적으로 HotSpot 기반 OpenJDK 계열에 속하며, AWS 환경에만 제한된 별도의 Java 언어나 JVM 명세를 사용하지 않는다.

Microsoft Build of OpenJDK

Microsoft Build of OpenJDK는 Microsoft가 빌드하고 지원하는 OpenJDK 배포판이다. Azure와 Microsoft 내부 서비스 및 일반적인 개발 환경을 대상으로 제공된다.

Microsoft의 배포판 역시 OpenJDK와 HotSpot을 기반으로 하며, 운영체제별 설치 패키지와 컨테이너 이미지 및 업데이트 정책을 제공한다.

Azul Zulu와 Prime

Azul Zulu는 Azul Systems가 제공하는 OpenJDK 배포판이다. 다양한 운영체제와 CPU 아키텍처 및 임베디드 환경을 지원하는 여러 패키지를 제공한다.

Azul Prime은 일반적인 OpenJDK 배포판과 달리 Azul의 자체 JVM 기술과 가비지 컬렉터 및 성능 기능을 포함하는 상용 실행 환경이다. 같은 기업의 제품이라도 Zulu와 Prime은 JVM 구성과 지원 기능에서 구분된다.

Liberica JDK

Liberica JDK는 BellSoft가 제공하는 OpenJDK 배포판이다. 서버와 데스크톱 및 ARM 기반 임베디드 환경을 포함한 여러 플랫폼을 지원하며, Java SE 호환성 검증을 받은 배포판을 제공한다.[72]

일반 JDK 외에도 JavaFX 포함 여부와 크기 및 대상 환경에 따라 여러 패키지 구성을 제공한다.

배포판 호환성

Java SE 호환 배포판은 같은 버전의 표준 클래스 파일과 API를 실행할 수 있어야 한다. 그러나 모든 배포판이 완전히 동일한 운영 특성을 갖는 것은 아니다.

차이가 발생할 수 있는 영역은 다음과 같다.

  • 지원하는 운영체제와 CPU 아키텍처
  • HotSpot 또는 OpenJ9 등 포함된 JVM
  • 제공되는 가비지 컬렉터
  • 기본 JVM 옵션
  • 암호화 제공자와 인증
  • JavaFX 포함 여부
  • 설치 패키지와 컨테이너 이미지
  • 보안 업데이트 제공 기간
  • 장기 지원과 상용 기술 지원
  • 디버깅 및 관리 도구
  • 비표준 JVM 옵션과 진단 출력

표준 API만 사용하는 일반적인 애플리케이션은 같은 Java 버전의 다른 배포판으로 이동할 수 있다. 반면 특정 JVM의 내부 API와 가비지 컬렉터 옵션, 에이전트, JIT 동작 및 네이티브 라이브러리에 의존하는 프로그램은 별도의 검증이 필요하다.

호환성 검증

Java 구현체는 Java SE 명세에 정의된 언어와 JVM 및 API 동작을 구현해야 한다. 완전한 Java SE 호환 구현으로 인정받기 위해서는 해당 Java SE 버전의 기술 호환성 시험인 TCK를 통과하는 절차가 사용된다.

TCK는 다음과 같은 영역을 검증한다.

  • Java 언어와 클래스 파일 동작
  • JVM 명령어와 실행 의미
  • 표준 API의 형식과 동작
  • 예외와 오류 조건
  • 리플렉션과 모듈
  • 운영체제에 독립적인 호환 동작

TCK 통과 여부와 별개로 각 프로젝트는 자체 단위 시험과 회귀 시험, 실제 애플리케이션 호환성 시험 및 성능 시험을 수행할 수 있다.

새로운 JVM 구현체인 AriaJDK와 같은 프로젝트에서는 바이트코드 명령어를 실행하는 것만으로 JDK 호환성이 완성되지 않는다. 클래스 로딩과 검증, 스레드 및 메모리 모델, JNI, 표준 클래스 라이브러리와 개발 도구를 함께 구현하고 광범위한 시험을 통과해야 한다.

JVM 선택 기준

JVM과 JDK 배포판은 애플리케이션의 실행 특성과 운영 조건에 따라 선택할 수 있다.

  • 범용 OpenJDK 환경에는 HotSpot 기반 배포판
  • 낮은 메모리 사용량과 빠른 시작이 중요한 환경에는 OpenJ9 계열
  • JIT 최적화와 다중 언어 실행에는 GraalVM
  • 네이티브 실행 파일과 빠른 시작에는 GraalVM Native Image
  • 독립 JVM 구조와 Rust 기반 구현 연구에는 AriaJDK
  • 특정 클라우드와 기업 지원에는 해당 공급자의 OpenJDK 배포판
  • 임베디드와 특수 아키텍처에는 지원 범위가 넓은 배포판

JVM 구현체가 다르더라도 Java 언어와 클래스 파일 및 표준 라이브러리 계약은 공통 기반을 제공한다. OpenJDK와 HotSpot이 Java 생태계의 중심 구현으로 자리 잡고 있지만, OpenJ9와 GraalVM 및 AriaJDK 같은 다른 구현은 메모리 사용량과 컴파일 방식, 구현 언어 및 런타임 구조에서 서로 다른 방향을 탐구한다.

개발 도구와 생태계

Java는 JDK에 포함된 명령행 도구만으로도 소스 코드 작성과 컴파일, 실행, 문서 생성, 디버깅, 패키징 및 성능 분석을 수행할 수 있다. 실제 개발에서는 여기에 IDE와 빌드 자동화 도구, 의존성 저장소, 시험 프레임워크, 정적 분석기, 버전 관리, 코드 리뷰와 지속적 통합 시스템을 결합한다.

Java 개발 도구는 특정 IDE 하나에 종속되지 않는다. Maven과 Gradle 같은 빌드 정의를 프로젝트 저장소에 포함하면 개발자가 서로 다른 IDE와 운영체제를 사용하더라도 대체로 같은 의존성과 컴파일 옵션, 시험 절차를 재현할 수 있다. 현대적인 Java 협업에서는 IDE의 자체 프로젝트 설정만 공유하기보다 빌드 도구와 버전 관리 저장소를 프로젝트 구성의 기준으로 삼는 경우가 많다.

JDK 도구

JDK는 Java 프로그램을 개발하고 운영하기 위한 여러 명령행 도구를 포함한다. JDK 버전과 배포판에 따라 제공되는 세부 도구에는 차이가 있을 수 있지만, 주요 도구는 다음과 같다.[73]

도구주요 역할
javaJVM을 시작하고 Java 애플리케이션 실행
javacJava 소스를 클래스 파일로 컴파일
jar클래스와 자원을 JAR 파일로 묶고 관리
javadoc소스의 문서화 주석에서 API 문서 생성
javap클래스 파일의 선언과 바이트코드 정보 출력
jdb명령행 Java 디버거
jshellJava 코드를 대화형으로 실행
jdeps클래스와 모듈 의존성 분석
jlink필요한 모듈로 사용자 정의 실행 이미지 생성
jpackage운영체제별 애플리케이션 설치 패키지 생성
jcmd실행 중인 JVM에 진단 명령 전달
jstack스레드 스택과 잠금 상태 확인
jmap힙과 객체 및 메모리 정보 확인
jstatJVM 성능 통계 관찰
jfrJava Flight Recorder 기록 관리
jconsoleJMX 기반 JVM 감시
keytool키와 인증서 및 키 저장소 관리

javac는 소스 파일을 읽고 타입 검사와 애너테이션 처리를 수행한 뒤 JVM 클래스 파일을 생성한다. 클래스 경로와 모듈 경로, 대상 Java 릴리스, 경고와 디버깅 정보 등을 명령행 옵션으로 제어할 수 있다.[74]

javac \
    --release 25 \
    -d build/classes \
    src/com/example/Main.java

컴파일된 프로그램은 java 명령으로 실행한다.

java \
    --class-path build/classes \
    com.example.Main

규모가 큰 프로젝트에서는 이러한 명령을 개발자가 매번 직접 입력하지 않고 Maven이나 Gradle과 같은 빌드 도구가 실행하도록 구성한다.

JShell

jshell은 Java 선언과 표현식 및 문장을 대화형으로 실행하는 REPL이다.

jshell
int value = 10;
value * value

JShell은 작은 API 실험과 언어 기능 확인, 교육 및 문제 진단에 적합하다. 전체 애플리케이션의 재현 가능한 빌드와 시험을 대체하기보다는 빠른 탐색을 위한 보조 도구로 사용된다.

디버깅과 진단

Java Platform Debugger Architecture는 디버거와 JVM 사이의 통신 및 디버깅 API를 정의한다. IDE의 중단점과 단계 실행, 변수 조사, 원격 디버깅 기능도 이 계층을 이용할 수 있다.

JVM은 다음과 같은 운영 진단 수단도 제공한다.

  • 스레드 덤프
  • 힙 덤프
  • 클래스별 객체 통계
  • 가비지 컬렉션 로그
  • JMX 관리 인터페이스
  • Java Flight Recorder
  • Native Memory Tracking

Java Flight Recorder는 JVM과 애플리케이션의 실행 이벤트를 비교적 낮은 부하로 기록한다. 기록은 JDK Mission Control과 같은 도구에서 분석할 수 있다.

java \
    -XX:StartFlightRecording=filename=recording.jfr \
    com.example.Main

개발 중의 소스 수준 디버깅과 운영 환경의 성능 진단은 목적이 다르다. 운영 환경에서는 중단점으로 JVM을 정지하기보다 기록과 메트릭, 프로파일 및 덤프를 이용하는 경우가 많다.

IntelliJ IDEA

IntelliJ IDEA는 JetBrains가 개발하는 Java 및 JVM 언어 중심 IDE다. 코드 완성과 탐색, 리팩터링, 디버깅, 시험 실행, 데이터베이스 도구와 프레임워크 지원을 제공한다.

Maven과 Gradle 프로젝트를 직접 불러와 빌드 구조와 의존성을 IDE 모델로 변환하며, Git과 GitHub 등의 버전 관리 및 코드 호스팅 기능을 통합한다.[75][76]

IntelliJ IDEA는 자체 증분 컴파일러를 사용할 수도 있지만, Maven이나 Gradle 프로젝트에서는 실제 자동화 빌드와 IDE 내부 빌드의 결과가 달라지지 않도록 빌드 도구에 위임하는 구성을 사용할 수 있다.

Community Edition은 오픈 소스로 제공되며 기본적인 Java와 Kotlin 개발을 지원한다. Ultimate Edition은 기업용 프레임워크와 데이터베이스, 웹 개발 및 여러 통합 기능을 추가로 제공한다.

Eclipse IDE

Eclipse IDE는 Java 생태계의 대표적인 오픈 소스 IDE 가운데 하나다. Java Development Tools를 통해 편집과 증분 컴파일, 디버깅, 리팩터링, 시험 및 프로젝트 관리를 제공한다.

Eclipse의 Java 개발자용 패키지에는 Java IDE뿐 아니라 Git 클라이언트와 XML 편집기, Maven 및 Gradle 통합이 포함된다.[77]

Eclipse는 플러그인 기반 구조와 Eclipse Platform을 통해 IDE 자체뿐 아니라 다른 데스크톱 개발 도구의 기반으로도 사용되어 왔다. Java EE와 임베디드, 모델링, 정적 분석 및 각종 기업용 도구를 위한 플러그인 생태계가 형성되었다.

2000년대와 2010년대 초반에는 많은 기업과 교육 환경에서 Java IDE의 사실상 표준 중 하나였으며, 현재도 지속적으로 개발되고 있다. 다만 신규 Java 프로젝트와 개발자 개인 환경에서는 IntelliJ IDEA와 경량 편집기 계열로 선택지가 분산되면서 과거와 같은 독점적인 중심 위치에서는 벗어났다.

Apache NetBeans

Apache NetBeans는 Java 개발과 데스크톱 애플리케이션 플랫폼을 제공하는 오픈 소스 IDE다. 처음에는 Sun Microsystems가 후원했으며, Oracle을 거쳐 2016년 Apache Software Foundation에 기증되었다. 2019년에는 Apache 최상위 프로젝트가 되었다.[78]

NetBeans는 다음 기능을 제공한다.

  • Java 코드 편집과 리팩터링
  • Maven과 Gradle 프로젝트
  • 디버깅과 프로파일링
  • Swing GUI 편집
  • Java 모듈 프로젝트
  • Jakarta EE와 웹 개발 확장
  • NetBeans Platform 기반 데스크톱 애플리케이션 개발

NetBeans는 Sun과 Oracle 시기에 JDK 및 Java EE 기술을 소개하는 대표 IDE로 사용되었다. 현재도 정기적으로 릴리스되는 활성 프로젝트지만, Java IDE 시장에서의 영향력은 Eclipse와 IntelliJ IDEA보다 작은 편이다.

Visual Studio Code

Visual Studio Code는 Java 전용 IDE가 아니라 확장 기반 코드 편집기다. Java Extension Pack을 설치하면 코드 완성과 탐색, 컴파일, 실행, 디버깅, 시험, Maven 프로젝트와 프로젝트 관리 기능을 사용할 수 있다.[79]

Java Extension Pack에는 다음과 같은 확장이 포함된다.

  • Red Hat의 Java 언어 지원
  • Java 디버거
  • Java 시험 실행기
  • Maven 지원
  • Java 프로젝트 관리자
  • IntelliCode

Gradle과 Spring Boot, Quarkus, MicroProfile, 애플리케이션 서버 및 원격 컨테이너 개발도 별도 확장으로 지원된다.

Visual Studio Code는 필요한 기능만 확장으로 추가하는 가벼운 환경과 원격 개발 및 컨테이너 통합에 강점이 있다. 반면 대규모 Java 프로젝트에서 제공되는 분석과 리팩터링 범위는 설치한 확장 및 언어 서버의 지원 수준에 영향을 받는다.

기타 개발 환경

Java 소스는 일반 텍스트이므로 특정 IDE가 필수는 아니다. 다음과 같은 편집기와 개발 환경도 사용된다.

  • Vim과 Neovim
  • Emacs
  • Sublime Text
  • Fleet
  • Zed
  • Eclipse Theia 기반 환경
  • 웹 기반 클라우드 개발 환경
  • GitHub Codespaces와 유사한 원격 컨테이너 환경

언어 서버 프로토콜과 디버그 어댑터, Maven·Gradle의 표준 프로젝트 모델이 확산되면서 전용 IDE가 아닌 편집기에서도 코드 완성과 탐색 및 디버깅을 구성할 수 있게 되었다.

빌드 자동화

Java 프로젝트는 여러 소스 파일과 외부 라이브러리, 자원, 생성 코드와 시험 코드로 구성된다. 빌드 도구는 다음 절차를 자동화한다.

  • 소스 컴파일
  • 의존성 다운로드
  • 시험 실행
  • 코드 생성
  • 자원 처리
  • JAR과 WAR 패키징
  • API 문서 생성
  • 정적 분석
  • 배포 파일 생성
  • 라이브러리 저장소 게시

빌드 정의를 버전 관리에 포함하면 개발자 PC와 CI 서버에서 같은 절차를 반복할 수 있다.

Apache Maven

Apache Maven은 프로젝트 객체 모델인 pom.xml을 중심으로 Java 프로젝트의 빌드와 의존성 및 배포 정보를 선언하는 도구다.

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>application</artifactId>
    <version>1.0.0</version>
</project>

Maven은 정해진 빌드 수명 주기를 제공한다.

  • validate
  • compile
  • test
  • package
  • verify
  • install
  • deploy

특정 단계를 실행하면 그 단계까지 필요한 앞 단계도 순서대로 실행된다.[80]

mvn clean verify

Maven은 좌표로 선언한 외부 라이브러리와 전이 의존성을 저장소에서 내려받는다.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.0.0</version>
    <scope>test</scope>
</dependency>

Maven은 규약과 표준적인 프로젝트 구조를 강조한다. 설정이 예측 가능하고 여러 도구가 POM을 이해한다는 장점이 있지만, 복잡하고 비정형적인 빌드에서는 XML 설정과 플러그인 동작을 파악하기 어려울 수 있다.

Gradle

Gradle은 선언적인 의존성 관리와 프로그래밍 가능한 작업 모델을 결합한 빌드 도구다. Groovy 또는 Kotlin DSL로 빌드 파일을 작성할 수 있다.

plugins {
    java
}

java {
    toolchain {
        languageVersion =
            JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(
        platform(
            "org.junit:junit-bom:6.0.0"
        )
    )

    testImplementation(
        "org.junit.jupiter:junit-jupiter"
    )
}

tasks.test {
    useJUnitPlatform()
}

Gradle은 작업 그래프와 증분 빌드, 빌드 캐시, 병렬 실행, 다중 프로젝트 빌드를 지원한다. Java Toolchains를 사용하면 프로젝트에 필요한 JDK 버전을 명시하고 해당 JDK를 빌드 작업에 연결할 수 있다.[81]

Gradle은 Android의 공식 빌드 기반으로 사용되며, 일반적인 Java와 Kotlin 서버 및 라이브러리 프로젝트에도 사용된다. 높은 확장성을 제공하지만 빌드 스크립트가 복잡한 프로그램처럼 발전하면 구성 단계와 작업 실행 관계를 이해하기 어려워질 수 있다.

Apache Ant

Apache Ant는 XML 빌드 파일에 대상과 작업을 직접 정의하는 Java 기반 빌드 도구다.

<project name="application" default="build">
    <target name="compile">
        <javac
            srcdir="src"
            destdir="build/classes"
        />
    </target>
</project>

Ant는 프로젝트 디렉터리 구조와 빌드 수명 주기를 강제하지 않고, 개발자가 작업 순서를 직접 구성할 수 있다. 운영체제별 셸 명령에 의존하던 make 기반 Java 빌드보다 이식성 있는 빌드를 제공하기 위해 만들어졌다.[82]

2000년대 Java 프로젝트에서 Ant는 널리 사용되었으며, Eclipse와 NetBeans 및 초기 지속적 통합 환경과 결합되었다. 그러나 의존성 관리와 표준 수명 주기를 기본 제공하는 Maven, 증분 빌드와 확장성을 제공하는 Gradle이 확산되면서 새 프로젝트의 기본 선택으로서의 비중은 줄었다.

Ant 자체는 여전히 유지되며 기존 대형 프로젝트와 맞춤형 빌드, 다른 시스템을 제어하는 작업 도구로 사용된다. 따라서 폐기된 도구라기보다 신규 Java 프로젝트의 주류에서 물러난 범용 빌드 도구로 보는 것이 적절하다.

의존성 저장소

Java 라이브러리는 일반적으로 다음 세 요소로 식별된다.

group
artifact
version

Maven 저장소 형식은 Maven뿐 아니라 Gradle과 여러 JVM 빌드 도구에서 공통으로 사용된다.

대표적인 저장소와 서비스는 다음과 같다.

  • Maven Central
  • 조직 내부 Maven 저장소
  • Sonatype Nexus Repository
  • JFrog Artifactory
  • GitHub Packages
  • GitLab Package Registry
  • 클라우드 공급자의 아티팩트 저장소

공개 라이브러리는 Maven Central을 통해 널리 배포된다. Gradle도 Maven 저장소 형식으로 게시된 모듈을 사용할 수 있다.[83]

조직에서는 외부 의존성의 프록시와 캐시, 접근 권한, 사내 라이브러리 배포와 공급망 감사를 위해 별도의 저장소 관리자를 운영할 수 있다.

시험 도구

Java 생태계는 단위 시험과 통합 시험, 모의 객체, 컨테이너 기반 시험 및 성능 시험을 위한 여러 도구를 제공한다.

대표적인 도구는 다음과 같다.

  • JUnit
  • TestNG
  • Mockito
  • AssertJ
  • Hamcrest
  • Testcontainers
  • ArchUnit
  • Awaitility
  • REST Assured
  • JMH

JUnit은 Java 단위 시험의 대표적인 프레임워크다. 현대 JUnit은 시험 실행 기반인 JUnit Platform과 시험 작성 API 및 실행 엔진을 분리한다.

class CalculatorTest {
    @Test
    void addsTwoNumbers() {
        Calculator calculator =
            new Calculator();

        assertEquals(
            5,
            calculator.add(2, 3)
        );
    }
}

Maven과 Gradle, IDE 및 CI 시스템은 시험 결과를 공통 보고서 형식으로 수집할 수 있다.

Testcontainers는 Docker 호환 컨테이너를 시험 수명 동안 실행하여 실제 데이터베이스와 메시지 브로커 및 외부 서비스를 이용한 통합 시험을 구성한다. JMH는 JVM의 준비와 JIT 최적화 및 측정 오차를 고려하는 마이크로벤치마크 도구다.

정적 분석과 코드 품질

컴파일러의 타입 검사만으로 모든 오류와 유지 보수 문제를 발견할 수는 없다. Java 생태계에서는 정적 분석과 스타일 검사 및 코드 커버리지 도구를 빌드와 CI에 연결한다.

대표적인 도구는 다음과 같다.

  • Error Prone
  • SpotBugs
  • Checkstyle
  • PMD
  • SonarQube와 SonarLint
  • NullAway
  • Checker Framework
  • JaCoCo

이 도구들은 다음과 같은 문제를 검사할 수 있다.

  • 널 참조 가능성
  • 잘못된 동등성 비교
  • 자원 반환 누락
  • 동시성 오류 가능성
  • 사용되지 않는 코드
  • 위험한 API 사용
  • 코딩 규칙 위반
  • 시험이 실행한 코드 범위
  • 중복 코드와 복잡도
  • 보안 취약 패턴

정적 분석 결과는 IDE에서 즉시 표시하거나 빌드 실패 조건과 코드 리뷰 검사로 사용할 수 있다. 모든 경고를 무조건 오류로 다루기보다 프로젝트 특성에 맞는 규칙과 예외 정책을 관리해야 한다.

버전 관리

현대 Java 프로젝트는 대부분 다른 언어 프로젝트와 마찬가지로 Git을 사용한다. Git은 각 개발자가 전체 저장소 기록을 가지는 분산 버전 관리 시스템이며, 로컬 브랜치와 병합 및 오프라인 커밋을 지원한다.[84]

git switch -c feature/new-api
git add .
git commit -m "Add new API"
git push origin feature/new-api

Git 자체는 저장소 형식과 버전 관리 기능을 제공한다. 실제 협업에서는 다음과 같은 호스팅 및 검토 서비스를 결합한다.

  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps
  • Gerrit
  • 자체 호스팅 Git 서버

호스팅 서비스는 저장소뿐 아니라 이슈와 코드 리뷰, CI/CD, 릴리스, 패키지, 보안 검사와 프로젝트 관리 기능을 함께 제공한다.

브랜치와 코드 리뷰

Git 기반 협업에서는 변경 사항을 별도 브랜치에서 개발하고 Pull Request 또는 Merge Request로 제출하는 흐름이 널리 사용된다.

작업 브랜치 생성
→ 코드와 시험 작성
→ 원격 저장소에 푸시
→ 변경 요청 생성
→ 자동 빌드와 시험
→ 동료 코드 리뷰
→ 수정과 재검증
→ 기본 브랜치에 병합

리뷰에서는 다음 항목을 확인한다.

  • 기능 요구 사항 충족
  • API와 객체 설계
  • 오류와 경계 조건
  • 동시성과 자원 관리
  • 시험 범위
  • 하위 호환성
  • 성능과 보안 영향
  • 문서와 이름의 명확성

코드 리뷰는 변경이 기본 브랜치에 포함되기 전에 설계와 구현을 여러 개발자가 공유하는 지식 전달 과정이기도 하다.

Gerrit

Gerrit Code Review는 Git 위에 구축된 웹 기반 코드 리뷰 시스템이다. 개발자가 변경 커밋을 Gerrit의 검토 영역에 업로드하고, 승인된 변경만 대상 브랜치에 제출하는 구조를 사용한다.[85]

Gerrit에서는 수정한 커밋을 새로운 패치 세트로 다시 올릴 수 있고, 변경 단위로 점수와 승인 권한을 적용한다. Android Open Source Project와 OpenJDK를 비롯한 대규모 프로젝트와 기업에서 사용되어 왔다.

Pull Request 중심 서비스보다 커밋 단위의 사전 병합 심사와 세밀한 승인 규칙에 강점이 있다. 반면 일반적인 GitHub·GitLab 사용자에게는 변경 식별자와 패치 세트, 제출 규칙이 낯설 수 있다.

CVS와 Subversion

Java의 초기 기업 및 오픈 소스 개발에서는 CVS가 널리 사용되었다. CVS는 중앙 서버의 저장소에 여러 개발자가 접근하는 구조였으며, 파일 단위 버전 관리와 브랜치 및 병합을 제공했다.

그러나 디렉터리 이동과 원자적 커밋, 병합 추적 및 오프라인 작업 등의 제약이 있었다. 오늘날 새 Java 프로젝트에서 CVS를 선택하는 경우는 드물며, 주로 오래된 저장소의 유지와 역사 자료 접근에서 볼 수 있다.

Apache Subversion은 CVS의 여러 한계를 개선하기 위해 개발된 중앙 집중식 버전 관리 시스템이다. 디렉터리와 메타데이터를 버전 관리하고 하나의 변경을 원자적으로 커밋할 수 있다.[86]

svn checkout <repository>
svn update
svn commit

2000년대 Java 기업 프로젝트에서는 Eclipse와 Subversion 플러그인, Maven·Ant 및 Jenkins 계열 도구를 결합한 협업 환경이 널리 사용되었다.

Subversion은 현재도 유지되는 활성 프로젝트이며 중앙 통제와 대형 바이너리, 단순한 권한 구조를 선호하는 조직에서 사용된다. 다만 분산 브랜치와 Pull Request 중심 협업이 확산되면서 신규 오픈 소스 및 일반 소프트웨어 프로젝트의 중심은 Git으로 이동했다.

이슈 추적과 프로젝트 관리

코드 변경과 별도로 버그와 기능 요청, 릴리스 계획 및 업무 상태를 관리하기 위해 이슈 추적기를 사용한다.

대표적인 도구는 다음과 같다.

  • Jira
  • GitHub Issues
  • GitLab Issues
  • YouTrack
  • Azure Boards
  • Bugzilla
  • Redmine
  • Trac

과거 Java 오픈 소스와 Eclipse 생태계에서는 Bugzilla가 널리 사용되었고, 기업 환경에서는 Jira가 강한 위치를 차지했다. 현재는 코드 호스팅 서비스의 이슈와 프로젝트 보드가 코드 리뷰 및 CI와 직접 연결되면서 별도의 이슈 추적기를 구축하지 않는 프로젝트도 많다.

독립 이슈 추적기는 복잡한 승인 절차와 조직 간 업무 흐름, 고객 지원 및 여러 저장소를 아우르는 계획 관리에 계속 사용된다.

Eclipse Mylyn

Eclipse Mylyn은 Eclipse 안에서 이슈와 작업 문맥을 관리하기 위한 도구다. Bugzilla와 Jira 등의 작업을 IDE에 연결하고, 현재 작업과 관련된 파일과 편집 기록을 중심으로 화면을 구성했다.

Mylyn은 이슈 추적기와 IDE를 직접 결합하는 초기의 작업 중심 개발 환경을 대표했다. 개발자는 브라우저와 IDE를 오가지 않고 작업 내용을 조회하고 수정할 수 있었다.

이후 GitHub와 GitLab 및 JetBrains 계열 IDE가 코드 리뷰와 이슈, 브랜치 기능을 직접 통합하고 웹 기반 협업이 중심이 되면서 Mylyn의 상대적 영향력은 감소했다. 다만 프로젝트와 일부 커넥터는 계속 유지되고 있으며, Eclipse와 기존 이슈 추적기를 강하게 결합한 환경에서는 여전히 사용할 수 있다.

지속적 통합

지속적 통합은 저장소 변경을 자동으로 빌드하고 시험하여 통합 오류를 빠르게 발견하는 방식이다.

Java 프로젝트의 일반적인 CI 절차는 다음과 같다.

소스 체크아웃
→ 지정된 JDK 설치
→ 의존성 복원
→ 컴파일
→ 단위 시험
→ 정적 분석
→ 통합 시험
→ 패키징
→ 아티팩트 게시
→ 배포

대표적인 CI/CD 도구와 서비스는 다음과 같다.

  • Jenkins
  • GitHub Actions
  • GitLab CI/CD
  • TeamCity
  • Bamboo
  • CircleCI
  • Azure Pipelines
  • Buildkite
  • Tekton
  • Argo Workflows와 Argo CD

CI 환경에서는 개발자 PC에 우연히 설치된 도구에 의존하지 않도록 JDK 버전과 빌드 도구, 운영체제 이미지 및 외부 서비스를 명시해야 한다.

Jenkins와 Hudson

Jenkins는 빌드와 시험, 배포 작업을 자동화하는 오픈 소스 자동화 서버다. 독립 서버와 분산 에이전트 및 많은 플러그인을 통해 Maven과 Gradle, Git, 컨테이너 및 여러 배포 환경을 연결한다.[87]

Jenkins는 처음에 Sun Microsystems에서 Hudson이라는 Java 기반 CI 서버로 개발되었다. Oracle의 Sun 인수 이후 프로젝트 거버넌스 갈등으로 주요 공동체가 2011년 Jenkins라는 이름으로 분기했다.

이후 Jenkins가 더 큰 사용자와 플러그인 생태계를 형성했으며, Hudson의 독립적인 영향력은 점차 줄었다. Hudson은 과거 Java 지속적 통합 환경에서 중요한 역할을 했지만 현대 협업에서는 Jenkins가 그 계보를 사실상 이어받은 것으로 평가된다.

Jenkins Pipeline은 빌드 절차를 저장소의 Jenkinsfile에 코드로 저장할 수 있게 한다.

pipeline {
    agent any

    stages {
        stage("Build") {
            steps {
                sh "./gradlew build"
            }
        }
    }
}

Jenkins는 자체 서버 운영과 플러그인 관리가 필요하지만, 복잡한 사내 네트워크와 다양한 빌드 장비 및 기존 시스템을 연결하는 데 강점이 있다.

호스팅 서비스 기반 CI

GitHub Actions와 GitLab CI/CD 같은 서비스는 코드 저장소와 변경 요청에 직접 연결된 자동화 환경을 제공한다.

name: Java CI

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: "25"

      - run: ./gradlew build

변경 요청마다 자동 시험과 정적 분석 및 패키징 결과를 표시할 수 있으며, 승인 규칙과 병합 조건에 연결할 수 있다.

서비스 기반 CI는 별도 서버를 직접 관리하지 않고 빠르게 시작할 수 있다. 반면 실행 시간과 네트워크, 비밀 정보, 전용 하드웨어 및 사내 시스템 접근에는 서비스 정책과 비용 및 보안 조건이 적용된다.

협업 도구의 변화

Java 협업 환경은 시대에 따라 중심 도구가 변화해 왔다.

시기대표적인 구성
1990년대 후반JDK 명령행 도구, make, CVS, 메일링 리스트
2000년대 초반Eclipse·NetBeans, Ant, CVS·Subversion, Bugzilla
2000년대 후반Eclipse·IntelliJ IDEA, Maven, Subversion, Hudson·Jenkins, Jira
2010년대Git, GitHub·Bitbucket, Maven·Gradle, Jenkins, Pull Request
2020년대 이후GitHub·GitLab, 호스팅형 CI, 컨테이너, 원격 개발, 공급망 보안과 자동 리뷰

이 표는 모든 조직이 같은 시점에 도구를 교체했다는 뜻은 아니다. 대규모 기업과 장수 프로젝트는 오래된 저장소와 빌드 시스템을 수십 년 동안 유지할 수 있으며, 현대 IDE와 CI가 기존 Ant 또는 Subversion 프로젝트를 계속 지원하기도 한다.

CVS와 Hudson은 현재 새 프로젝트에서 거의 중심 도구로 선택되지 않는다. Subversion과 Ant, Bugzilla 및 Mylyn은 여전히 유지되거나 사용되지만, 일반적인 신규 Java 개발에서는 Git과 Maven·Gradle 및 코드 호스팅 서비스의 통합 기능이 더 중심적인 위치를 차지한다.

개발 환경의 재현성

팀 개발에서는 IDE에서 실행되는 것만으로 충분하지 않고, 깨끗한 환경에서 같은 결과를 재현할 수 있어야 한다.

재현 가능한 Java 프로젝트는 일반적으로 다음 정보를 저장소에 포함한다.

  • Maven pom.xml 또는 Gradle 빌드 스크립트
  • Maven Wrapper 또는 Gradle Wrapper
  • 필요한 Java 릴리스와 도구체인 설정
  • 의존성 버전
  • 시험과 정적 분석 설정
  • 코드 형식 규칙
  • CI 구성
  • 컨테이너 또는 개발 환경 정의
  • 데이터베이스 마이그레이션
  • 실행과 배포 설명

Gradle Wrapper는 프로젝트가 지정한 Gradle 버전을 사용하게 하고, Maven Wrapper는 지정한 Maven 환경을 실행할 수 있게 한다.

./gradlew build
./mvnw verify

IDE는 이러한 빌드 파일을 읽어 프로젝트를 구성하고, CI도 같은 명령을 실행한다. 이 방식은 특정 개발자의 IDE 설정에만 존재하는 클래스 경로와 컴파일 옵션을 줄인다.

통합 개발 생태계

현대 Java 프로젝트의 개발 흐름은 다음 요소가 느슨하게 결합된 구조다.

  • JDK는 컴파일러와 런타임 및 기본 도구를 제공한다.
  • IDE와 편집기는 코드 작성과 탐색, 리팩터링 및 디버깅을 지원한다.
  • Maven과 Gradle은 프로젝트와 의존성 및 빌드를 정의한다.
  • Maven 저장소는 라이브러리와 플러그인을 배포한다.
  • JUnit 계열 도구는 자동 시험을 수행한다.
  • 정적 분석기는 잠재적 결함과 품질 문제를 검사한다.
  • Git은 소스와 변경 기록을 관리한다.
  • 코드 호스팅 서비스와 Gerrit은 변경 검토를 지원한다.
  • 이슈 추적기는 작업과 결함 및 릴리스를 관리한다.
  • CI/CD 시스템은 빌드와 시험 및 배포를 자동화한다.
  • 진단 도구는 운영 중인 JVM의 상태와 성능을 분석한다.

이 생태계의 장점은 각 계층의 도구를 프로젝트 요구에 맞게 교체할 수 있다는 점이다. IntelliJ IDEA와 Eclipse 사용자가 같은 Gradle 프로젝트에서 협업할 수 있고, 로컬 환경과 Jenkins 또는 GitHub Actions가 동일한 시험 명령을 실행할 수 있다.

반면 IDE 플러그인과 빌드 플러그인, JDK 및 프레임워크의 호환 버전을 함께 관리해야 하며, 오래된 기업 시스템에서는 여러 세대의 도구가 한 프로젝트 안에 공존하기도 한다. Java 개발 환경은 언어와 JVM의 장기적인 하위 호환성을 바탕으로 발전했지만, 실제 협업의 안정성은 빌드와 의존성 및 자동화 구성을 얼마나 명확히 관리하는지에 크게 좌우된다.

플랫폼 구성과 활용 분야

Java는 하나의 프로그래밍 언어인 동시에 언어로 작성된 프로그램을 실행하기 위한 가상 머신, 표준 API, 개발 도구와 배포 형식을 포함하는 플랫폼이다. Java 프로그램은 일반적으로 Java 소스 코드를 클래스 파일로 컴파일한 뒤, 대상 운영체제와 프로세서에 맞게 구현된 JVM에서 실행한다.

Java 플랫폼의 핵심 구성은 다음과 같다.

  • Java 언어와 컴파일러
  • JVM과 클래스 파일 형식
  • Java SE 표준 라이브러리
  • JDK 개발 및 진단 도구
  • 운영체제별 JDK 구현체와 배포판
  • 서버·모바일·임베디드 환경을 위한 확장 플랫폼
  • 외부 프레임워크와 라이브러리 생태계

Java SE API는 범용 컴퓨팅을 위한 핵심 Java 플랫폼을 정의하며, java.* 모듈에 포함된 표준 API로 구성된다. JDK는 Java SE 구현에 컴파일러와 패키징, 디버깅 및 진단 도구를 추가한 개발 환경이다.[88]

Java SE

Java Platform, Standard Edition은 Java 플랫폼의 공통 기반이다. 언어와 JVM, 핵심 클래스 라이브러리 및 표준 모듈을 정의하며 서버와 데스크톱, 명령행 프로그램, 개발 도구와 각종 프레임워크의 실행 기반으로 사용된다.

Java SE에는 다음과 같은 기능이 포함된다.

  • 객체와 문자열 및 기본 타입 지원
  • 컬렉션과 스트림
  • 파일 및 네트워크 입출력
  • 동시성과 비동기 처리
  • 날짜와 시간
  • 보안과 암호화
  • 데이터베이스 연결
  • XML 처리
  • AWT와 Swing
  • 리플렉션과 클래스 로딩
  • 모듈 시스템
  • 관리와 계측 API

Java SE는 특정 애플리케이션 유형을 강제하지 않는다. 같은 Java SE 기반에서 서버 프로그램과 명령행 도구, 그래픽 애플리케이션, 빌드 시스템 및 개발 도구를 만들 수 있다.

JDK와 실행 환경

JDK는 Java 애플리케이션을 개발하고 실행하기 위한 전체 도구 집합이다. JVM과 표준 라이브러리뿐 아니라 javac, jar, javadoc, jlink, jpackage, jcmd 등의 도구를 포함한다.

과거에는 일반 사용자의 프로그램 실행만을 위한 별도 JRE 배포가 널리 제공되었다. 현대 Java에서는 JDK 전체를 설치하거나, 애플리케이션에 필요한 모듈만 포함한 사용자 정의 실행 이미지를 jlink로 생성하는 방식이 사용된다.

애플리케이션은 JDK가 설치된 환경에 의존할 수도 있고, 전용 런타임과 함께 배포될 수도 있다.

Jakarta EE

Jakarta EE는 Java SE 위에 기업용 서버 애플리케이션을 위한 표준 API와 실행 환경을 추가한다. 과거에는 Java EE와 J2EE라는 이름으로 개발되었으며, 현재는 Eclipse Foundation의 Jakarta EE 프로젝트에서 관리한다.

Jakarta EE는 보안과 확장성, 신뢰성이 필요한 웹 서비스와 업무 시스템, 배치 작업 및 분산 애플리케이션을 위한 공통 서비스를 제공한다.[89]

대표적인 기능은 다음과 같다.

  • HTTP 요청과 Servlet
  • REST API
  • WebSocket
  • 의존성 주입
  • 데이터 검증
  • 관계형 데이터 영속성
  • 트랜잭션
  • 메시징
  • 보안
  • 관리형 동시성
  • 배치 처리
  • JSON 처리
  • 서버 측 사용자 인터페이스

Jakarta EE는 기능 범위에 따라 Core Profile과 Web Profile, 전체 Platform으로 나뉜다. 애플리케이션은 필요한 사양만 사용하거나 여러 사양을 통합한 Jakarta EE 서버에 배포할 수 있다.

대표적인 구현체와 서버에는 다음과 같은 제품이 있다.

  • Eclipse GlassFish
  • WildFly
  • Open Liberty
  • Payara
  • Apache TomEE
  • Oracle WebLogic Server

Jakarta EE는 API와 동작을 사양으로 정의하고 여러 공급자가 구현할 수 있도록 하므로, 특정 공급자 제품에만 종속되지 않는 서버 애플리케이션을 개발할 수 있다.

Spring 생태계

Java 서버 개발에서는 표준 플랫폼과 함께 Spring Framework와 Spring Boot가 널리 사용된다. Spring은 의존성 주입과 웹 개발, 데이터 접근, 보안, 메시징과 트랜잭션을 위한 통합 프로그래밍 모델을 제공한다.

Spring Boot는 자동 구성과 내장 서버, 실행 가능한 JAR 및 운영 기능을 제공하여 독립적으로 실행되는 서버 애플리케이션을 구성한다.

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(
            Application.class,
            args
        );
    }
}

Spring은 Jakarta EE 전체 서버에 반드시 배포해야 하는 플랫폼이 아니라 Java SE 위에서 필요한 서버 구성 요소를 선택하여 사용할 수 있는 프레임워크 생태계다. 일부 Spring API는 Jakarta Servlet과 Jakarta Persistence 같은 Jakarta 사양을 함께 사용하기도 한다.

Spring 생태계는 웹 서비스와 업무 애플리케이션, 마이크로서비스, 데이터 처리와 클라우드 통합 등 여러 서버 분야에서 사용된다.[90]

MicroProfile

MicroProfile은 기업용 Java를 마이크로서비스와 클라우드 환경에 맞게 최적화하기 위한 사양 집합이다. 여러 Jakarta EE 기술을 기반으로 설정과 장애 허용, 상태 검사, OpenAPI, JWT 인증 및 관측성 기능을 추가한다.

주요 사양은 다음과 같다.

  • Config
  • Fault Tolerance
  • Health
  • Metrics 및 Telemetry
  • OpenAPI
  • REST Client
  • JWT Authentication

MicroProfile은 여러 구현체에서 공통으로 사용할 수 있는 마이크로서비스 API를 제공하는 것을 목표로 한다.[91]

서버와 업무 시스템

Java가 가장 널리 사용되는 분야 중 하나는 장기간 실행되는 서버와 기업용 업무 시스템이다.

대표적인 활용 사례는 다음과 같다.

  • 전자 상거래 서버
  • 금융 거래와 결제 시스템
  • 은행 업무 시스템
  • 사내 자원 관리
  • 고객 관계 관리
  • 정부와 공공기관 시스템
  • 통신 사업자 플랫폼
  • 예약과 물류 시스템
  • 인증과 권한 관리
  • REST 및 GraphQL API
  • 메시지 기반 분산 시스템

Java는 강한 정적 타입과 광범위한 라이브러리, 장기적인 하위 호환성 및 성숙한 관측 도구를 제공한다. JVM은 장시간 실행되는 프로그램에서 프로파일 정보를 축적해 JIT 최적화를 수행할 수 있어 지속적인 처리량이 필요한 서버에 적합하다.

서버 프로그램은 일반적으로 다음 구성 요소를 결합한다.

Java 또는 JVM 언어
→ Spring 또는 Jakarta EE
→ 데이터베이스와 메시지 브로커
→ JVM
→ 운영체제 또는 컨테이너

웹 애플리케이션

Java는 서버 측 웹 페이지와 API, 실시간 통신 서비스를 개발하는 데 사용된다.

전통적인 Java 웹 애플리케이션은 Servlet 컨테이너나 애플리케이션 서버에 WAR 파일을 배포했다.

애플리케이션 코드
+ 라이브러리와 웹 자원
→ WAR 파일
→ Servlet 컨테이너

현대적인 Java 웹 서비스는 내장 HTTP 서버와 필요한 라이브러리를 실행 가능한 JAR에 포함하는 경우가 많다.

애플리케이션
+ 내장 서버
+ 의존성
→ 실행 가능한 JAR
→ java -jar application.jar

REST API와 서버 측 렌더링, WebSocket, 인증, 데이터베이스 접근 및 메시징 기능은 Spring과 Jakarta EE, Quarkus, Micronaut, Helidon 등의 프레임워크를 통해 구성할 수 있다.

마이크로서비스

Java는 하나의 대형 애플리케이션을 여러 독립 서비스로 나누는 마이크로서비스 아키텍처에도 사용된다.

각 서비스는 일반적으로 다음 요소를 가진다.

  • 독립적인 빌드와 배포
  • HTTP 또는 메시지 기반 인터페이스
  • 자체 구성과 상태 검사
  • 로그와 메트릭 및 분산 추적
  • 장애 허용과 시간 제한
  • 서비스별 데이터 관리
  • 컨테이너 이미지

Spring Boot와 Quarkus, Micronaut, Helidon 및 MicroProfile 구현체는 작은 실행 이미지와 빠른 시작, 클라우드 환경 통합을 지원한다.

마이크로서비스가 반드시 Java에 적합하거나 항상 단일 애플리케이션보다 우수한 것은 아니다. 서비스 간 통신과 배포 및 관측 복잡성이 증가하므로 조직과 시스템 규모에 맞게 선택한다.

클라우드와 컨테이너

Java 애플리케이션은 가상 머신과 물리 서버뿐 아니라 Docker 호환 컨테이너와 Kubernetes 환경에서도 실행된다.

FROM eclipse-temurin:25-jre

COPY application.jar /application.jar

ENTRYPOINT [
    "java",
    "-jar",
    "/application.jar"
]

현대 JVM은 컨테이너의 CPU와 메모리 제한을 인식하며, 애플리케이션은 환경 변수와 외부 구성 및 비밀 정보 관리 시스템을 통해 실행 환경을 설정할 수 있다.

클라우드 환경에서는 다음 특성이 중요하다.

  • 시작 시간
  • 메모리 사용량
  • 수평 확장
  • 상태 검사
  • 정상 종료
  • 관측 가능성
  • 컨테이너 크기
  • 요청당 동시성

GraalVM Native Image나 애플리케이션별 JDK 실행 이미지는 시작 시간과 배포 크기를 줄이는 선택지로 사용될 수 있다.

데스크톱 애플리케이션

Java SE는 데스크톱 사용자 인터페이스를 위한 AWT와 Swing을 포함한다.

AWT는 운영체제의 그래픽 환경과 입력, 창 및 기본 사용자 인터페이스 구성 요소를 연결한다. Swing은 AWT 위에서 동작하는 Java 기반 위젯 도구 모음이다.

SwingUtilities.invokeLater(() -> {
    JFrame frame =
        new JFrame("Java Application");

    frame.add(
        new JLabel("Hello, Java")
    );

    frame.setDefaultCloseOperation(
        JFrame.EXIT_ON_CLOSE
    );

    frame.pack();
    frame.setVisible(true);
});

Java 데스크톱 애플리케이션은 다음 영역에서 사용되어 왔다.

  • 통합 개발 환경
  • 데이터베이스 및 관리 도구
  • 과학·공학 프로그램
  • 금융 거래 단말
  • 기업 내부 업무 프로그램
  • 네트워크 관리 도구
  • 교육용 프로그램

IntelliJ IDEA와 Eclipse, NetBeans, JMeter, Burp Suite 등 여러 개발 및 관리 도구가 Java 또는 JVM 기반 데스크톱 기술을 사용한다.

JavaFX

JavaFX는 현대적인 Java 데스크톱 및 그래픽 애플리케이션을 위한 별도 오픈 소스 플랫폼이다. 사용자 인터페이스 구성 요소와 CSS 스타일, 2D·3D 그래픽, 미디어 및 웹 콘텐츠 통합을 제공한다.

JavaFX는 초기에는 JDK에 포함되었지만 현재는 Java SE 표준 모듈과 분리되어 OpenJFX 프로젝트에서 별도로 배포된다.

JavaFX 애플리케이션은 Maven과 Gradle 의존성으로 플랫폼별 모듈을 포함하거나 전용 실행 이미지와 설치 패키지로 배포할 수 있다.

명령행 도구와 자동화

Java는 서버와 그래픽 프로그램뿐 아니라 명령행 도구와 빌드 자동화 프로그램에도 사용된다.

대표적인 Java 기반 도구는 다음과 같다.

  • Maven
  • Gradle
  • Ant
  • Jenkins
  • SonarQube
  • Elasticsearch 관리 도구
  • 데이터베이스 마이그레이션 도구
  • 정적 분석기
  • 코드 생성기
  • IDE와 언어 서버

Java는 표준 파일과 네트워크 API, 크로스 플랫폼 실행 환경과 풍부한 라이브러리를 제공하므로 여러 운영체제에서 동일한 개발 도구를 배포하기에 적합하다.

명령행 프로그램은 실행 가능한 JAR이나 전용 런타임 이미지, 네이티브 이미지로 배포할 수 있다.

Android

Java는 Android 애플리케이션 개발의 초기 주요 언어였으며, 현재도 Kotlin과 함께 Android 소스 코드에 사용할 수 있다. Android SDK의 많은 API는 Java 클래스와 인터페이스 형태로 제공되며 Java와 Kotlin 코드가 같은 프로젝트 안에서 상호 운용할 수 있다.

그러나 Android 기기에서 표준 Java SE JVM이 직접 실행되는 것은 아니다. Android 빌드에서는 JDK가 Gradle과 컴파일 도구를 실행하는 데 사용되지만, 애플리케이션 코드는 Android 형식으로 변환되어 기기에서 Android Runtime을 통해 실행된다.[92]

Android의 실행 구조는 대략 다음과 같다.

Java 또는 Kotlin 소스
→ JVM 클래스 파일
→ DEX 형식 변환
→ APK 또는 App Bundle
→ Android Runtime

Android는 Java 언어의 여러 기능과 Java API의 일부를 제공하지만, Java SE 전체 API와 동일한 플랫폼은 아니다. 파일 시스템과 사용자 인터페이스, 애플리케이션 생명 주기, 프로세스와 권한은 Android 고유 API를 따른다.

Android 5.0부터 ART가 기본 런타임이며, 이전 Android 버전에서는 Dalvik이 사용되었다.[93]

Android 신규 개발에서는 Kotlin이 우선적으로 권장되지만 기존 Java 코드와 라이브러리, 대규모 애플리케이션 및 교육 자료가 계속 사용되고 있다.

빅데이터와 분산 처리

Java와 JVM은 대규모 데이터 저장과 분산 처리 생태계에서 중요한 실행 기반으로 사용되어 왔다.

대표적인 Java 또는 JVM 기반 프로젝트에는 다음과 같은 것들이 있다.

  • Apache Hadoop
  • Apache Kafka
  • Apache Flink
  • Apache Cassandra
  • Apache HBase
  • Apache Lucene
  • Elasticsearch
  • Apache Solr
  • Apache ZooKeeper

이러한 시스템은 네트워크와 동시성, 디스크 입출력 및 장시간 실행 성능이 중요하다. Java의 성숙한 서버 런타임과 관리 도구, 여러 운영체제와 아키텍처 지원이 분산 인프라 개발에 활용된다.

사용자는 Java로 직접 애플리케이션을 작성하지 않더라도 JVM 기반 데이터베이스와 검색 엔진, 메시지 브로커를 운영할 수 있다.

메시징과 이벤트 처리

Java는 메시지 브로커와 이벤트 기반 시스템의 생산자와 소비자, 스트림 처리 애플리케이션을 개발하는 데 사용된다.

주요 활용은 다음과 같다.

  • 주문과 결제 이벤트
  • 로그와 메트릭 수집
  • 실시간 데이터 처리
  • 시스템 간 비동기 통신
  • 작업 큐
  • 변경 데이터 캡처
  • 사물인터넷 이벤트 수집

Kafka와 RabbitMQ, ActiveMQ Artemis 및 클라우드 메시징 서비스는 Java 클라이언트와 서버 프레임워크를 제공한다.

비동기 메시징은 서비스 사이의 직접적인 시간 의존성을 줄일 수 있지만, 메시지 중복과 순서, 재처리, 실패 저장소 및 최종 일관성을 함께 설계해야 한다.

금융과 거래 시스템

Java는 은행과 증권, 결제, 보험 및 거래 처리 시스템에서 널리 사용되어 왔다.

이 분야에서는 다음 특성이 중요하다.

  • 정밀한 숫자 처리
  • 트랜잭션과 데이터 일관성
  • 장기적인 유지 보수
  • 안정적인 동시성
  • 감사와 보안
  • 기존 시스템과의 통합
  • 예측 가능한 처리량
  • 운영 중 진단

BigDecimal은 십진수 값을 정확히 표현해야 하는 금융 계산에 사용할 수 있으며, JDBC와 트랜잭션 API 및 메시징 시스템은 데이터 처리 기반을 제공한다.

BigDecimal price =
    new BigDecimal("19.99");

BigDecimal quantity =
    new BigDecimal("3");

BigDecimal total =
    price.multiply(quantity);

실제 금융 시스템에서는 단순한 언어 선택보다 도메인 규칙과 정산, 오류 복구, 규제 및 보안 설계가 더 큰 비중을 가진다.

과학·공학과 데이터 분석

Java는 과학 계산 전용 언어만큼 중심적인 위치를 차지하지는 않지만, 다음 분야에서 사용된다.

  • 수치 계산 라이브러리
  • 통계와 데이터 분석
  • 지리 정보 시스템
  • 생명정보학
  • 시뮬레이션
  • 이미지 처리
  • 연구 데이터 관리
  • 실험 장비 제어

Java의 크로스 플랫폼 GUI와 병렬 처리, 네트워크 및 대용량 서버 환경을 결합하여 연구용 데스크톱 프로그램과 데이터 처리 서버를 만들 수 있다.

JVM 생태계의 Scala와 Kotlin 및 Clojure 라이브러리와 함께 사용하는 경우도 있다.

인공지능과 머신러닝

Java는 Python처럼 인공지능 연구와 모델 학습의 주류 언어는 아니지만, 기존 Java 시스템에 머신러닝 기능을 통합하거나 모델을 서버 환경에서 배포하는 데 사용할 수 있다.

활용 방식은 다음과 같다.

  • 학습된 모델의 추론 서비스
  • 추천 시스템
  • 검색과 문서 분류
  • 이상 탐지
  • 자연어 처리
  • 대규모 데이터 파이프라인
  • Java 서버에 AI API 통합
  • ONNX와 외부 모델 런타임 연동

Java 기반 라이브러리와 딥러닝 프레임워크를 직접 사용할 수도 있고, Python 기반 학습 서비스와 네트워크 API로 연동할 수도 있다.

게임과 멀티미디어

Java는 게임 개발의 주류 AAA 엔진 언어는 아니지만 데스크톱과 모바일, 교육용 게임 및 서버 분야에서 사용되어 왔다.

대표적인 활용은 다음과 같다.

  • 2D 게임
  • 크로스 플랫폼 데스크톱 게임
  • Android 게임
  • 게임 서버
  • 게임 개발 도구
  • 모딩과 플러그인
  • 교육용 게임 엔진

Minecraft: Java Edition은 Java 기반으로 개발된 대표적인 상용 게임이며, Java 생태계의 모딩과 서버 플러그인 환경을 형성했다.

LWJGL과 libGDX 같은 라이브러리는 OpenGL과 오디오, 입력 및 여러 플랫폼을 Java에서 사용할 수 있게 한다. Java 게임은 JVM 시작과 가비지 컬렉션, 네이티브 그래픽 API 연동 및 배포 크기를 고려해야 한다.

임베디드 시스템

Java는 산업 장비와 네트워크 장치, 텔레비전과 셋톱박스, 프린터 및 게이트웨이 같은 임베디드 환경에도 사용되어 왔다.

비교적 자원이 충분한 Linux 기반 장치에서는 일반 Java SE JDK를 사용할 수 있다. 제한된 장치에서는 Java ME나 제조사별 Java 실행 환경이 사용될 수 있다.

Java ME는 메모리와 화면, 전력 및 처리 능력이 제한된 장치를 위해 구성과 프로파일 및 선택적 API를 조합하는 플랫폼이다.[94]

Java ME는 다음 계층으로 구성된다.

  • 구성: JVM 기능과 가장 기본적인 라이브러리
  • 프로파일: 특정 장치 범주에 필요한 API
  • 선택적 패키지: 통신과 미디어 등 특정 기능의 API

과거 Java ME는 기능 전화와 PDA를 포함한 모바일 장치에서 널리 사용되었다. 스마트폰 시장이 Android와 iOS 중심으로 이동한 이후 일반 모바일 애플리케이션 플랫폼으로서의 영향력은 크게 줄었지만, 일부 임베디드와 산업용 장치 및 장기 지원 시스템에서는 관련 기술이 남아 있다.

Java Card

Java Card는 스마트카드와 보안 요소처럼 메모리와 처리 능력이 매우 제한된 장치에서 Java 기술을 사용하기 위한 플랫폼이다.

주요 활용 분야는 다음과 같다.

  • 금융 카드
  • 가입자 식별 모듈
  • 전자 신분증
  • 출입 통제 카드
  • 보안 토큰
  • 내장 보안 요소

Java Card는 일반 Java SE 프로그램을 그대로 실행하는 환경이 아니다. 제한된 가상 머신과 API를 사용하며, 보안 격리와 장기간 배포 및 장치 제약에 맞추어 설계된다.

교육

Java는 객체 지향 프로그래밍과 자료구조, 알고리즘 및 소프트웨어 공학 교육에 널리 사용된다.

교육에서 활용되는 이유는 다음과 같다.

  • 명시적인 정적 타입
  • 가비지 컬렉션
  • 클래스와 인터페이스
  • 방대한 표준 라이브러리
  • 여러 운영체제에서 같은 개발 환경
  • 단위 시험과 IDE 지원
  • 서버와 Android 등 실제 활용 분야
  • 비교적 안정적인 언어 변화

Java 25에 도입된 간결한 소스 파일과 인스턴스 main 메서드는 초보자가 클래스 선언과 정적 메서드 형식을 모두 이해하기 전에 작은 프로그램을 작성할 수 있게 한다.

void main() {
    IO.println("Hello, Java");
}

프로그램이 커지면 같은 언어 안에서 클래스와 패키지, 모듈 및 빌드 도구를 단계적으로 도입할 수 있다.

레거시 시스템

Java의 장기적인 하위 호환성과 기업용 채택으로 인해 오래된 Java 버전과 프레임워크를 사용하는 시스템도 많이 존재한다.

대표적인 레거시 구성은 다음과 같다.

  • Java 6 또는 Java 8 기반 애플리케이션
  • 오래된 Java EE 서버
  • EJB 중심 시스템
  • JSP와 서버 측 화면
  • Ant 빌드
  • 오래된 Spring 버전
  • RMI 기반 분산 시스템
  • Swing 사내 프로그램
  • 애플릿에서 이전된 전용 클라이언트

레거시 Java 시스템은 기능을 계속 수행할 수 있지만 보안 업데이트 중단과 지원되지 않는 운영체제, 오래된 암호화 규칙 및 라이브러리 취약점 문제가 발생할 수 있다.

현대화 방식은 다음과 같다.

  • 지원되는 LTS JDK로 이전
  • 오래된 API와 내부 JDK API 제거
  • 자동 시험 구축
  • Maven이나 Gradle 빌드로 전환
  • 애플리케이션 서버 갱신
  • Java EE에서 Jakarta EE 네임스페이스로 이전
  • 모놀리스를 유지한 채 내부 구조 개선
  • 필요한 부분만 서비스로 분리
  • 컨테이너와 자동 배포 도입

모든 시스템을 마이크로서비스나 다른 언어로 전면 재작성하는 것이 유일한 현대화 방법은 아니다. 기존 동작을 시험으로 보존하면서 런타임과 의존성을 단계적으로 갱신할 수 있다.

현재의 중심 활용 분야

Java의 활용 범위는 초기 웹 애플릿과 기능 전화 애플리케이션에서 크게 변화했다.

오늘날 Java의 중심 활용 분야는 다음과 같다.

  • 기업용 백엔드와 업무 시스템
  • 웹 API와 서버 애플리케이션
  • 금융과 거래 처리
  • 클라우드와 마이크로서비스
  • 데이터 플랫폼과 메시징 시스템
  • Android의 기존 및 혼합 언어 프로젝트
  • 개발·빌드·관리 도구
  • JVM 기반 인프라 제품
  • 일부 데스크톱과 임베디드 시스템
  • 교육과 장기 유지 보수 시스템

웹 브라우저 애플릿과 Java Web Start, 기능 전화용 Java ME 애플리케이션은 과거 Java 확산에 중요한 역할을 했지만 현대 Java의 중심 사용 분야에서는 벗어났다.

Java 플랫폼은 하나의 실행 형식과 표준 API를 여러 운영체제에 제공하면서, 서버와 클라우드, 데스크톱, 모바일 및 임베디드 환경에 서로 다른 실행 계층을 적용해 왔다. Java SE는 공통 기반을 제공하고, Jakarta EE와 Spring 및 MicroProfile은 서버 환경을 확장하며, Android와 Java ME 및 Java Card는 각 장치 환경에 맞는 별도 플랫폼 모델을 사용한다.

다른 프로그래밍 언어와의 관계

Java는 기존 언어와 단절된 형태로 만들어진 언어가 아니다. 표면적인 문법은 C (프로그래밍 언어)C++에 익숙한 개발자가 쉽게 접근할 수 있도록 설계되었고, 객체 모델과 실행 환경은 Smalltalk, Objective-C, Eiffel과 Cedar/Mesa 등 여러 언어 및 시스템의 영향을 받았다.

이후 Java는 자체 언어 기능뿐 아니라 JVM과 클래스 파일, 가비지 컬렉션, 표준 라이브러리를 포함하는 플랫폼으로 발전했다. 그 결과 Java와 문법적으로 경쟁하는 언어뿐 아니라 Java 라이브러리와 JVM을 공유하는 Kotlin, Scala, Groovy, Clojure 등의 언어가 하나의 생태계를 이루게 되었다.

C와 C++

Java의 문법은 C와 C++의 중괄호, 세미콜론, 연산자, 조건문과 반복문 형식을 상당 부분 계승한다.

for (int index = 0; index < values.length; index++) {
    if (values[index] > 0) {
        total += values[index];
    }
}

이러한 형식은 C와 C++ 개발자가 Java의 기본 제어문과 표현식을 비교적 쉽게 이해할 수 있게 했다. Java 언어 명세도 Java가 C와 C++에 관련되어 있지만 여러 기능을 제거하고 다른 언어의 아이디어를 포함하여 다르게 구성되었다고 설명한다.[95]

Java의 초기 개발팀은 Green 프로젝트에서 C++를 사용하려 했으나, 당시 C++의 복잡성과 플랫폼 차이 및 메모리 관리 문제를 해결하기 위해 새로운 언어와 실행 환경을 만들었다. Java의 초기 설계는 C++뿐 아니라 Eiffel, Smalltalk, Objective-C와 Cedar/Mesa 등의 아이디어를 참고했다.[96]

Java는 C와 C++에서 다음과 같은 기능을 제거하거나 제한했다.

  • 포인터 산술
  • 일반적인 주소 연산
  • 수동 객체 해제
  • 매크로 전처리기
  • 헤더 파일
  • 사용자 정의 연산자 오버로딩
  • 클래스의 다중 구현 상속
  • 독립적인 구조체와 공용체
  • 전역 함수와 전역 변수
  • 임의의 암시적 숫자·논리 변환
  • 결정되지 않은 정수 타입 크기

Java에서 객체 참조는 내부적으로 메모리 위치와 연결될 수 있지만, 프로그램이 참조 값을 정수 주소처럼 계산하거나 임의의 메모리에 접근할 수는 없다.

User user = new User();

// user++;              // 허용되지 않음
// int address = user;  // 허용되지 않음

Java의 배열은 길이를 보존하는 객체이며 접근 시 경계가 검사된다.

int[] values = new int[10];

// values[10] = 1;  // ArrayIndexOutOfBoundsException

C와 C++에서는 배열과 포인터 및 수동 메모리 관리가 저수준 제어와 성능 최적화를 가능하게 한다. Java는 이러한 직접 제어를 줄이는 대신 가비지 컬렉션과 배열 경계 검사, 클래스 파일 검증 및 표준화된 기본 타입 크기를 제공한다.

C++의 템플릿은 일반적으로 타입별 코드를 컴파일 시점에 생성할 수 있지만, Java 제네릭은 주로 타입 소거를 사용한다.

List<String> names = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();

실행 시점에 두 객체는 모두 같은 ArrayList 구현 클래스를 사용한다. 반면 C++의 std::vector<std::string>std::vector<int>는 서로 다른 템플릿 인스턴스가 될 수 있다.

Java와 C++는 모두 클래스와 캡슐화, 상속, 가상 메서드에 기반한 객체 지향 프로그래밍을 지원하지만, 실행 방식에는 큰 차이가 있다.

C++
소스 코드
→ 대상 플랫폼의 기계어
→ 운영체제에서 직접 실행

Java
소스 코드
→ JVM 클래스 파일
→ JVM의 해석 또는 JIT 컴파일
→ 대상 플랫폼에서 실행

현대 C++도 LLVM과 GCC 등의 중간 표현과 런타임 라이브러리를 사용하지만, 일반적인 배포 결과는 특정 운영체제와 CPU를 대상으로 하는 네이티브 실행 파일이다. Java 클래스 파일은 JVM 명세에 맞는 중간 형식이며 JVM 구현체가 각 플랫폼에서 실행한다.

Smalltalk

Java와 Smalltalk는 모두 객체에 동작을 결합하고 메서드 호출을 통해 객체와 상호 작용하는 클래스 기반 객체 지향 언어다. Java 초기 문서는 Smalltalk를 Java 객체 모델에 영향을 준 주요 언어 중 하나로 명시한다.[97]

그러나 두 언어의 객체 모델은 동일하지 않다.

Smalltalk에서는 정수와 조건값, 클래스 자체를 포함하여 거의 모든 요소를 객체와 메시지 전송으로 다룬다. Java는 객체와 별도로 int, boolean, double 등의 기본 타입을 제공한다.

int primitive = 10;
Integer object = 10;

Java의 메서드 선택과 타입 검사는 주로 정적 타입 체계를 기반으로 한다. Smalltalk는 실행 중 객체가 메시지에 응답하는지를 중심으로 하는 동적 타입 모델을 사용한다.

Smalltalk는 실행 중 클래스와 메서드를 수정하고 즉시 실행 상태에 반영하는 이미지 기반 라이브 프로그래밍 환경으로도 발전했다. Java는 소스 코드와 클래스 파일, 클래스 로더 및 JVM 실행을 분리하며, 일반적인 개발 흐름은 편집·컴파일·실행 단계로 구성된다.

Java는 Smalltalk의 객체 지향 개념을 일부 받아들이면서도 C 계열 문법과 정적 타입, 기본 타입 및 독립적인 클래스 파일 배포 모델을 결합했다.

Objective-C

Objective-C는 C에 Smalltalk식 메시지 전송과 객체 모델을 추가한 언어다. Java 초기 설계 자료는 Objective-C를 Java의 객체 지향 설계에 영향을 준 언어 가운데 하나로 언급한다.[98]

Objective-C의 메서드 호출은 전통적으로 대괄호를 사용한 메시지 전송 형태로 작성한다.

[object performAction:value];

Java는 같은 개념을 점 표기 메서드 호출로 표현한다.

object.performAction(value);

Objective-C는 C의 포인터와 메모리 표현, 전처리기 및 네이티브 ABI를 유지한다. 현대 Objective-C는 자동 참조 계수를 사용할 수 있지만, Java의 추적형 가비지 컬렉션과는 수명 관리 방식이 다르다.

Java는 Objective-C와 마찬가지로 객체에 대한 동적 메서드 선택을 지원하지만, Java의 일반 메서드 호출은 클래스 파일과 JVM의 타입 규칙 안에서 검증된다.

C#

C#은 Java와 가장 자주 비교되는 언어 가운데 하나다. 두 언어 모두 C와 C++에 익숙한 개발자를 위한 중괄호 기반 정적 타입 언어이며, 관리형 실행 환경과 자동 메모리 관리 및 방대한 표준 라이브러리를 사용한다.

C#은 Microsoft가 .NET Framework와 함께 개발했으며, 일반적인 C# 구현은 Common Language Infrastructure와 .NET 런타임을 사용한다.[99]

두 플랫폼의 기본 구조는 다음과 같이 비교할 수 있다.

JavaC#과 .NET
Java 소스C# 소스
JVM 클래스 파일CIL과 .NET 어셈블리
JVMCLR 또는 호환 런타임
Java SE API.NET Base Class Library
JAR.NET 어셈블리
Java 애너테이션C# 특성
JVM 제네릭과 타입 소거CLI 제네릭과 실행 시점 타입 정보

Java와 C#은 모두 클래스와 인터페이스, 예외, 가비지 컬렉션, 리플렉션, 제네릭, 람다, 비동기 API와 패턴 매칭을 제공한다. 그러나 기능을 표현하는 방식과 실행 모델에는 차이가 있다.

C#은 값 타입인 struct, 속성, 이벤트, 델리게이트, 연산자 오버로딩, LINQ, asyncawait, 사용자 정의 값 타입 및 제한적인 안전하지 않은 코드를 언어에 직접 포함한다.

public string Name { get; set; }

Java는 전통적으로 메서드로 접근자를 표현한다.

private String name;

public String getName() {
    return name;
}

public void setName(String name) {
    this.name = name;
}

Java의 람다는 함수형 인터페이스를 대상으로 변환된다.

Predicate<String> valid =
    text -> !text.isBlank();

C#은 델리게이트 타입을 함수 호출의 명시적인 런타임 표현으로 제공한다.

C#의 제네릭 타입 인수는 일반적으로 CLI 메타데이터와 실행 시점에 유지된다. Java 제네릭은 하위 호환성을 위해 주로 타입 소거를 사용한다.

// value instanceof List<String>
// 구체적인 제네릭 인수를 일반적으로 검사할  없음

두 언어는 서로 다른 플랫폼과 표준 라이브러리를 사용하므로 클래스 파일이나 어셈블리를 직접 공유하지 않는다. 네트워크 프로토콜과 데이터 형식, 네이티브 인터페이스 또는 별도의 상호 운용 계층을 통해 시스템을 연결할 수 있다.

Kotlin

Kotlin은 Java와 긴밀한 상호 운용을 목표로 설계된 정적 타입 언어다. Kotlin/JVM은 JVM 클래스 파일을 생성하며 Java 클래스와 표준 라이브러리 및 기존 프레임워크를 직접 사용할 수 있다.

Kotlin 공식 문서는 대부분의 Java 코드를 Kotlin에서 자연스럽게 사용할 수 있고, Kotlin 코드도 Java에서 사용할 수 있도록 설계되었다고 설명한다.[100]

같은 프로젝트에서 두 언어를 함께 컴파일할 수 있다.

public class JavaService {
    public String loadName() {
        return "Java";
    }
}
val service = JavaService()
println(service.loadName())

Kotlin은 Java의 클래스와 인터페이스, 애너테이션 및 제네릭을 JVM 표현에 맞게 연결하면서 다음과 같은 언어 기능을 추가한다.

  • 타입 체계에 포함된 널 가능성
  • 데이터 클래스
  • 확장 함수
  • 프로퍼티
  • 코루틴
  • 스마트 캐스트
  • 선언 위치 변성
  • 최상위 함수
  • 연산자 관례
  • 식 중심 제어문
  • 컴패니언 객체

Java 참조 타입은 널 가능성이 명시되어 있지 않으므로 Kotlin은 Java에서 가져온 타입을 플랫폼 타입으로 처리한다. 플랫폼 타입은 Kotlin의 엄격한 nullable·non-null 구분을 완전히 적용하기 어렵게 한다.[101]

val text = javaService.loadName()

Java의 getter와 setter 규약은 Kotlin에서 프로퍼티 문법으로 보일 수 있다.

user.name = "Luna"
println(user.name)

이는 JVM에 새로운 프로퍼티 필드 형식이 생긴다는 뜻이 아니라, Java의 getName()setName()을 Kotlin 컴파일러가 프로퍼티 문법으로 연결하는 것이다.

Kotlin 코드도 Java에서 호출할 수 있다. Kotlin 프로퍼티는 일반적으로 Java getter와 setter, 필드 등의 형태로 컴파일된다.[102]

Kotlin은 기존 Java 프로젝트에 일부 파일부터 단계적으로 도입할 수 있으므로 Java를 전면 교체하지 않고 함께 사용하는 경우가 많다. 특히 Android에서는 Kotlin이 신규 개발의 중심 언어가 되었지만 Java 소스 및 Java 기반 Android API와 계속 상호 운용한다.

Scala

Scala는 객체 지향 프로그래밍과 함수형 프로그래밍을 결합한 정적 타입 언어다. Scala의 주된 실행 플랫폼은 JVM이며, Java와 같은 별도 컴파일과 동적 클래스 로딩 모델을 사용한다.[103]

Scala는 Java 클래스와 인터페이스를 직접 사용하고 구현할 수 있다.

import java.util.ArrayList

val names = new ArrayList[String]()
names.add("Java")

Java 코드에서도 공개된 Scala 클래스를 사용할 수 있지만, Scala에만 존재하는 개념은 Java에서 사용하기 쉬운 형태로 조정해야 할 수 있다.

두 언어 사이에서 차이가 두드러지는 영역은 다음과 같다.

  • Scala의 표현식 중심 문법
  • 패턴 매칭과 대수적 데이터 표현
  • 고차 함수와 함수 타입
  • 불변 컬렉션 중심 API
  • 트레이트
  • 암시적 매개변수와 given
  • 더 복잡한 타입 추론과 타입 추상화
  • Java의 검사 예외 규칙 부재

Java와 Scala는 JVM 객체와 Java 표준 라이브러리를 공유하지만 컬렉션과 Optional·Option 등 표준 라이브러리 추상화는 서로 다르다. Scala는 Java 컬렉션과 Scala 컬렉션 사이의 변환 API를 제공한다.[104]

Scala는 Java보다 언어 차원에서 함수형 추상화와 타입 시스템을 강하게 확장했다. 반면 Java는 기능을 비교적 점진적으로 추가하면서 기존 Java 소스와 바이너리 호환성 및 단순한 객체 모델을 중시한다.

Groovy

Groovy는 JVM에서 실행되는 동적·다중 패러다임 언어다. Java와 유사한 문법을 기반으로 하면서 Python, Ruby와 Smalltalk의 영향을 받은 동적 기능과 간결한 문법, 메타프로그래밍 및 DSL 작성을 제공한다.

Groovy는 Java 클래스와 라이브러리를 직접 사용하며 Java 바이트코드로 컴파일된다.[105]

def names = new java.util.ArrayList<String>()
names.add("Java")
println names

Groovy는 타입을 명시할 수도 있고 def를 이용하여 동적으로 처리할 수도 있다.

def value = "Java"
println value.toUpperCase()

필요한 경우 정적 타입 검사와 정적 컴파일을 사용할 수 있다.

Groovy는 다음 분야에서 Java와 함께 사용되어 왔다.

  • Gradle의 Groovy DSL
  • Jenkins Pipeline
  • 빌드와 자동화 스크립트
  • 시험 코드
  • Grails 웹 프레임워크
  • 애플리케이션 내장 스크립트
  • 설정 및 DSL

Java 애플리케이션은 GroovyShell, GroovyClassLoader와 스크립트 엔진 등을 이용해 실행 중 Groovy 코드를 불러올 수 있다.[106]

Groovy의 메서드 선택은 동적 실행에서 실제 인수 타입을 기준으로 이루어질 수 있다. Java의 오버로드 선택은 기본적으로 컴파일 시점의 선언 타입에 따라 결정된다. 따라서 문법이 비슷한 코드라도 일부 실행 의미는 다를 수 있다.

Clojure

Clojure는 JVM에서 실행되는 Lisp 계열 함수형 언어다. Java의 클래스 기반 문법과 달리 괄호로 이루어진 S-표현식과 불변 영속 자료구조, 함수 및 매크로 중심의 프로그래밍 모델을 사용한다.

(defn greet [name]
  (str "Hello, " name))

Clojure는 Java 클래스와 필드, 메서드 및 생성자를 직접 호출하는 상호 운용 문법을 제공한다.[107]

(import java.util.ArrayList)

(def names (ArrayList.))
(.add names "Java")
(.size names)

Clojure는 Java 인터페이스를 구현하거나 클래스를 확장하는 객체도 생성할 수 있다. Java 코드에서는 clojure.java.api.Clojureclojure.lang.IFn을 통해 Clojure 함수를 찾고 호출할 수 있다.

두 언어의 중심 모델에는 다음과 같은 차이가 있다.

JavaClojure
클래스와 메서드 중심함수와 데이터 중심
변경 가능한 객체를 일반적으로 허용불변 영속 자료구조 중심
정적 타입 검사동적 타입
문장과 표현식의 혼합대부분 표현식
명시적인 클래스 선언함수와 프로토콜 및 동적 타입 생성
Java 문법 매크로 없음언어 수준 매크로 지원

Clojure는 JVM과 Java 라이브러리를 이용하면서도 Java의 객체 지향 문법을 따르지 않는 대표적인 JVM 언어다. 이는 JVM이 Java 언어만을 위한 실행 환경이 아니라 여러 언어 모델을 수용할 수 있는 플랫폼임을 보여 준다.

JVM 언어와 Java 생태계

Kotlin과 Scala, Groovy, Clojure는 서로 다른 문법과 타입 체계 및 프로그래밍 패러다임을 사용하지만 JVM 클래스 파일과 Java 객체 모델을 통해 Java 생태계와 연결된다.

공유할 수 있는 기반은 다음과 같다.

  • JVM
  • 클래스 파일
  • Java 표준 라이브러리
  • Java로 작성된 외부 라이브러리
  • Maven 저장소
  • Maven과 Gradle
  • JVM 디버거와 프로파일러
  • 가비지 컬렉터
  • Java 에이전트
  • 서버 프레임워크
  • JNI와 네이티브 라이브러리

그러나 같은 JVM을 사용한다고 해서 모든 언어 기능이 서로 완전히 동일하게 보이는 것은 아니다.

차이가 발생할 수 있는 부분은 다음과 같다.

  • 널 가능성 표현
  • 함수 타입과 람다 변환
  • 프로퍼티와 필드 표현
  • 기본 매개변수
  • 이름 있는 매개변수
  • 검사 예외
  • 제네릭 변성과 와일드카드
  • 특수 문자와 연산자 이름
  • 컬렉션 타입
  • 코루틴과 비동기 모델
  • 언어별 메타데이터
  • 기본 인자와 확장 메서드의 Java 노출

공개 라이브러리를 여러 JVM 언어에서 사용하도록 설계할 때는 Java에서 자연스럽게 호출할 수 있는 API 형태를 기준으로 삼는 경우가 많다. Java 클래스와 인터페이스 및 메서드 서명은 JVM 생태계에서 가장 널리 공유되는 상호 운용 표면이기 때문이다.

JavaScript와 이름의 관계

JavaScript는 이름과 일부 C 계열 문법을 제외하면 Java와 직접적인 언어 계통이나 실행 플랫폼을 공유하지 않는다.

JavaScript는 동적 타입과 프로토타입 기반 객체 모델을 사용하며, 일반적으로 웹 브라우저와 Node.js 등의 JavaScript 엔진에서 실행된다. Java는 정적 타입과 클래스 기반 객체 모델을 사용하며 JVM 클래스 파일로 컴파일된다.

function add(left, right) {
    return left + right;
}
static int add(int left, int right) {
    return left + right;
}

JavaScript라는 이름은 Java가 웹과 애플릿을 통해 주목받던 1995년 Netscape와 Sun의 협력 및 마케팅 배경에서 사용되었지만, JavaScript는 Java의 축소판이나 스크립트 버전이 아니다.

두 언어는 웹 시스템에서 서로 다른 계층을 담당하는 경우가 많다.

Java
→ 서버와 API

JavaScript 또는 TypeScript
→ 웹 브라우저 사용자 인터페이스

Java와 JavaScript는 HTTP와 JSON, WebSocket 등의 프로토콜을 통해 통신하며, GraalVM과 일부 스크립트 엔진을 통해 하나의 프로세스에서 상호 작용할 수도 있다.

Java에 대한 후속 언어의 대응

Java 이후에 등장한 여러 언어는 Java의 장점과 생태계를 유지하면서 불편하다고 평가된 부분을 다른 방식으로 해결하려 했다.

Kotlin은 Java와의 직접적인 상호 운용을 유지하면서 널 안전성과 간결한 데이터 클래스, 확장 함수 및 코루틴을 추가했다. Kotlin 공식 비교 문서는 널 가능성 타입과 함수 타입, 선언 위치 변성, 검사 예외 제거 등을 Java와의 주요 차이로 설명한다.[108]

Scala는 JVM과 Java 라이브러리를 유지하면서 함수형 프로그래밍과 표현식 중심 문법, 패턴 매칭 및 더 높은 수준의 타입 추상화를 제공했다.

Groovy는 Java 개발자가 쉽게 접근할 수 있는 문법을 유지하면서 동적 타입과 스크립팅 및 DSL을 강조했다.

Clojure는 Java 생태계를 사용할 수 있게 하면서 클래스 중심 설계와 다른 Lisp 및 불변 데이터 중심 모델을 제공했다.

이러한 언어들은 Java를 단순히 대체하기보다 기존 Java 코드와 라이브러리를 활용하면서 프로젝트의 일부 또는 새로운 계층에 도입되는 경우가 많다.

Java의 위치

Java는 C와 C++의 익숙한 문법, Smalltalk와 Objective-C 등의 객체 지향 개념, 관리되는 JVM 실행 환경을 결합하여 형성되었다. 이후 C#과 같은 관리형 플랫폼 언어와 비교되는 기준점이 되었으며, JVM 위에 등장한 여러 언어가 Java의 클래스와 라이브러리 생태계를 공유하게 했다.

Java 자체도 다른 언어에서 발전한 개념을 점진적으로 받아들였다.

  • 제네릭
  • 람다 표현식
  • 함수형 스트림 연산
  • 지역 변수 타입 추론
  • 레코드
  • 봉인 타입
  • 패턴 매칭
  • 표현식 형태의 switch

다만 새로운 기능은 기존 클래스 파일과 라이브러리, 소스 코드와의 호환성을 고려하여 Java의 정적 타입 및 클래스 기반 모델 안에 통합된다.

Java와 다른 언어의 관계는 단순한 문법 유사성으로만 설명하기 어렵다. C와 C++는 설계와 문법의 출발점이고, Smalltalk와 Objective-C 등은 객체 모델에 영향을 주었으며, C#은 별도의 관리형 플랫폼에서 Java와 유사한 문제 영역을 다룬다. Kotlin과 Scala, Groovy, Clojure는 JVM과 Java 라이브러리를 직접 공유하면서 서로 다른 언어 모델을 제공한다. 이 관계망은 Java가 하나의 언어인 동시에 여러 언어가 사용하는 실행 플랫폼과 소프트웨어 생태계로 발전했음을 보여 준다.

영향

Java는 1990년대 중반 이후 프로그래밍 언어의 설계뿐 아니라 프로그램 배포 방식과 가상 머신, 기업용 소프트웨어 및 개발 도구 생태계에 넓은 영향을 주었다. Java 이전에도 가상 머신과 중간 코드, 가비지 컬렉션 및 객체 지향 언어가 존재했지만, Java는 이러한 요소를 하나의 대중적인 범용 플랫폼으로 결합했다.

초기 Java의 핵심 목표는 특정 하드웨어와 운영체제에 종속되지 않는 클래스 파일을 배포하고, 각 플랫폼의 JVM이 이를 실행하도록 하는 것이었다. Java 컴파일러가 생성하는 바이트코드는 하드웨어와 운영체제에 독립적인 형식이며, 기본 타입의 크기와 산술 동작도 플랫폼 사이에서 일관되도록 정의되었다.[109]

가상 머신 기반 플랫폼의 확산

Java는 소스 언어와 실행 플랫폼을 분리하는 모델을 대중화했다.

소스 언어
→ 중간 코드
→ 검증과 동적 로딩
→ 가상 머신
→ 실행 중 네이티브 코드 최적화

이 구조에서는 프로그램을 각 CPU와 운영체제용 기계어로 직접 배포하는 대신, 표준화된 중간 형식과 실행 환경을 배포 계약으로 사용한다. JVM은 클래스 로딩과 바이트코드 검증, 가비지 컬렉션, 스레드와 JIT 컴파일을 하나의 관리형 실행 환경에 결합했다.

이후 등장하거나 성장한 여러 플랫폼도 다음과 같은 유사한 구조를 채택했다.

  • .NET과 Common Language Runtime
  • WebAssembly 실행 환경
  • Android Runtime
  • 여러 언어를 지원하는 관리형 런타임
  • JIT 컴파일 기반 동적 언어 구현체

이들 플랫폼이 Java를 그대로 복제한 것은 아니지만, 이식 가능한 중간 코드와 관리되는 메모리, 실행 시점 검증과 최적화라는 조합이 범용 소프트웨어 개발에서 실용적이라는 점을 보여 주었다.

JVM 언어 생태계

JVM은 Java만을 실행하는 가상 머신에서 여러 언어가 공유하는 실행 플랫폼으로 확장되었다.

대표적인 JVM 언어는 다음과 같다.

이들 언어는 서로 다른 문법과 타입 체계 및 프로그래밍 패러다임을 제공하면서도 JVM과 Java 표준 라이브러리, Java로 작성된 프레임워크와 Maven 저장소를 사용할 수 있다.

invokedynamic 명령은 메서드 호출의 연결 방식을 실행 시점에 구성할 수 있도록 JVM을 확장했다. 이 기능은 Java의 람다 표현식뿐 아니라 동적 언어와 다른 JVM 언어의 호출 모델을 효율적으로 구현하는 데 활용되었다.[110]

Java 클래스와 인터페이스 및 메서드 서명은 JVM 언어 사이에서 가장 널리 공유되는 상호 운용 형식이 되었다. 새로운 JVM 언어는 독자적인 표준 라이브러리를 제공하더라도 Java API로 노출되는 경계를 함께 고려하는 경우가 많다.

관리형 언어 설계

Java는 포인터 산술과 수동 객체 해제를 제거하고 배열 경계 검사, 가비지 컬렉션과 타입 검사를 기본 실행 환경에 포함했다. 이는 모든 오류를 방지하지는 않지만 임의 주소 접근과 잘못된 메모리 해제, 객체 수명 이후의 일반적인 접근과 같은 오류 범주를 언어의 일반 코드에서 제거했다.

Java 초기 설계 문서는 포인터 산술 대신 실제 배열과 문자열을 사용하고, 배열 인덱스를 검사하며 정수를 임의의 객체 참조로 변환하지 못하게 한 점을 견고성의 핵심으로 설명했다.[111]

이러한 방향은 이후의 관리형 범용 언어와 플랫폼에 영향을 주었다.

  • C#과 .NET
  • Kotlin
  • Scala
  • Dart
  • Android 애플리케이션 실행 환경
  • 일부 현대적인 시스템 언어의 안전성 설계

Java가 모든 안전한 언어 기능의 최초 발명자는 아니지만, 자동 메모리 관리와 정적 타입, 클래스 기반 객체 지향 및 플랫폼 중립 실행을 대규모 상용 개발에 적용한 대표적인 사례가 되었다.

기업용 소프트웨어

Java는 기업용 서버 소프트웨어의 구조에도 큰 영향을 주었다. Java EE와 이후의 Jakarta EE는 데이터베이스와 트랜잭션, 메시징, 웹 요청, 보안 및 분산 애플리케이션을 위한 공급자 독립적인 API를 정의했다.

그 결과 기업용 애플리케이션은 특정 운영체제나 서버 제품의 고유 API보다 Java 인터페이스와 표준 배포 형식을 중심으로 개발될 수 있었다. 여러 애플리케이션 서버 공급자가 같은 사양을 구현하면서 서버 제품 사이의 경쟁과 이동 가능성도 형성되었다.

Spring Framework는 Java EE의 무거운 컨테이너 중심 모델에 대한 대안으로 의존성 주입과 일반 Java 객체 중심의 프로그래밍 모델을 확산시켰다. 이후 Spring Boot는 내장 서버와 자동 구성 및 실행 가능한 JAR을 중심으로 Java 서버 배포 방식을 바꾸었다.

Java 기업 생태계에서 발전하거나 널리 확산된 개념은 다음과 같다.

  • 애플리케이션 서버
  • 의존성 주입
  • 객체 관계 매핑
  • 선언적 트랜잭션
  • 애너테이션 기반 구성
  • Servlet 기반 웹 서버
  • 실행 가능한 서버 JAR
  • 관리형 메시징
  • 표준 데이터베이스 드라이버
  • 애플리케이션 관측과 관리 API

오픈 소스 서버와 데이터 인프라

Java는 오픈 소스 서버와 분산 데이터 시스템의 주요 구현 언어로 사용되었다.

대표적인 프로젝트는 다음과 같다.

  • Apache Hadoop
  • Apache Kafka
  • Apache Cassandra
  • Apache Lucene
  • Elasticsearch
  • Apache Solr
  • Apache Flink
  • Apache ZooKeeper
  • Jenkins
  • Gradle

이 프로젝트들은 Java 애플리케이션 개발에 사용될 뿐 아니라 다른 언어로 작성된 시스템에서도 기반 인프라로 운영된다. Java를 직접 사용하지 않는 개발자도 JVM 기반 메시지 브로커와 검색 엔진, 데이터 처리 시스템을 이용할 수 있다.

JVM의 장시간 실행 성능과 동시성, 네트워크 및 파일 API, 진단 도구와 여러 운영체제 지원은 이러한 서버 소프트웨어의 배포 기반을 제공했다.

빌드와 의존성 관리

Java 생태계는 현대적인 패키지 및 의존성 관리 방식에도 영향을 주었다. Maven은 라이브러리를 다음과 같은 좌표로 식별하고 중앙 저장소에서 가져오는 모델을 널리 확산시켰다.

group
artifact
version

Maven Central과 POM 기반 전이 의존성은 Java와 JVM 생태계의 공통 배포 기반이 되었다. 이후 Gradle과 Kotlin, Scala 및 여러 JVM 도구도 Maven 저장소 형식을 공유했다.

빌드 설정을 저장소에 포함하고 CI에서 동일한 명령으로 재현하는 방식은 Java 프로젝트에서 오랫동안 일반화되었으며, 다른 언어의 패키지 관리자와 빌드 시스템에서도 유사한 모델이 확산되었다.

IDE와 리팩터링

Java의 정적 타입과 명시적인 클래스 및 메서드 구조는 IDE가 코드의 의미를 분석하고 안전한 리팩터링과 탐색을 제공하기에 적합했다.

Eclipse와 IntelliJ IDEA, NetBeans는 다음 기능을 일반적인 개발 경험으로 확산시켰다.

  • 의미 기반 코드 완성
  • 메서드와 타입 탐색
  • 이름 변경 리팩터링
  • 메서드와 클래스 추출
  • 사용 위치 검색
  • 자동 임포트
  • 즉시 컴파일 오류 표시
  • 디버거와 시험 실행 통합
  • 버전 관리와 코드 리뷰 통합

이러한 기능은 이후 여러 언어의 IDE와 언어 서버에서 기본적으로 기대되는 기능이 되었다. Java 도구 생태계는 단순한 문법 강조 편집기에서 프로젝트 전체의 타입과 호출 관계를 이해하는 개발 환경으로의 전환에 기여했다.

API와 바이너리 호환성

Java는 공개된 클래스와 인터페이스가 이미 컴파일된 외부 프로그램과 연결될 수 있다는 점을 고려하여 언어 명세에 바이너리 호환성 규칙을 포함한다. 명세는 기존 바이너리가 다시 컴파일되지 않더라도 연결 오류 없이 동작할 수 있는 변경과 호환성을 깨는 변경을 구분한다.[112]

이러한 호환성 중시는 오래된 Java 라이브러리와 애플리케이션이 새로운 JVM에서도 실행될 수 있는 기반이 되었다. 동시에 공개 API를 한 번 배포하면 이후 변경이 어려워지는 제약도 만들었다.

Java 생태계는 다음과 같은 API 관리 관행을 발전시켰다.

  • 의미적 버전 관리
  • API와 구현 패키지 분리
  • 폐기 예정 API 표시
  • 하위 호환성을 유지하는 기본 메서드
  • 바이너리 호환성 검사 도구
  • 장기 지원 릴리스
  • 이전 릴리스를 대상으로 하는 컴파일

정기 릴리스 모델

Java는 과거 여러 해 간격으로 대규모 버전을 공개했지만, Java 10 이후에는 6개월 주기의 시간 기반 기능 릴리스 모델을 사용한다. OpenJDK의 JDK 프로젝트는 정해진 시점마다 기능 릴리스를 제공하며, 준비되지 않은 기능은 다음 릴리스로 이동시킨다.[113]

프리뷰 기능과 인큐베이터 모듈은 완전히 확정되지 않은 기능을 실제 사용자에게 제공하고 피드백을 수집하는 수단이 되었다.

이 방식은 다음과 같은 영향을 주었다.

  • 언어 기능을 더 작은 단계로 도입
  • 여러 릴리스에 걸친 기능 시험
  • 대규모 버전 지연 감소
  • 도구와 라이브러리의 정기적인 호환성 대응
  • LTS와 비LTS 릴리스의 역할 분리

Java의 릴리스 모델은 언어와 플랫폼이 장기적인 안정성을 유지하면서도 지속적으로 발전할 수 있는 방법의 한 사례가 되었다.

Android

Java는 Android의 초기 애플리케이션 언어와 API 설계에 큰 영향을 주었다. Android는 Java 문법과 Java 형태의 클래스 라이브러리를 채택했고, 오랫동안 Java가 Android 개발의 중심 언어로 사용되었다.

Android는 표준 JVM이 아닌 Dalvik과 이후 ART를 사용하지만, Java 소스와 클래스 파일 도구, Java 기반 Gradle 빌드 생태계를 활용했다. 현재 Kotlin이 Android 신규 개발의 중심 언어로 자리 잡았어도 많은 Android API와 기존 애플리케이션은 Java와의 상호 운용성을 유지한다.

Java는 이를 통해 데스크톱과 서버를 넘어 대규모 모바일 개발자 생태계에도 영향을 미쳤다.

교육

Java는 대학과 학교의 프로그래밍 및 객체 지향 교육에서 널리 사용되었다.

교육 언어로 사용된 배경은 다음과 같다.

  • C 계열과 유사한 명시적인 문법
  • 정적 타입 검사
  • 수동 메모리 해제 불필요
  • 클래스와 인터페이스
  • 표준화된 실행 환경
  • 풍부한 자료구조와 입출력 API
  • IDE와 디버거
  • 서버와 Android 등 실제 활용 분야

Java의 장황한 클래스 구조와 public static void main 형식은 초보자에게 부담으로 지적되기도 했다. Java 25의 간결한 소스 파일과 인스턴스 main 메서드는 작은 교육용 프로그램에서 이러한 초기 문법 부담을 줄이는 방향으로 도입되었다.

현재의 영향

Java의 영향은 특정 문법 기능 하나보다 언어와 가상 머신, 라이브러리, 도구와 배포 생태계를 하나의 장기 플랫폼으로 운영한 방식에서 크게 나타난다.

Java는 다음 요소를 범용 소프트웨어 개발의 일반적인 선택지로 정착시키는 데 기여했다.

  • 관리되는 메모리
  • 이식 가능한 중간 코드
  • JIT 컴파일
  • 클래스 파일 검증
  • 동적 클래스 로딩
  • 표준화된 대형 클래스 라이브러리
  • 기업용 서버 프레임워크
  • 중앙 의존성 저장소
  • IDE 기반 리팩터링
  • 바이너리 호환성
  • 공급자가 여러 개인 실행 환경
  • 하나의 VM을 공유하는 다중 언어 생태계

Java는 웹 애플릿이라는 초기 중심 분야에서는 물러났지만, JVM과 서버·데이터 인프라 및 개발 도구 생태계를 통해 현대 소프트웨어 산업에 계속 영향을 미치고 있다.

장점과 한계

Java의 장점과 한계는 서로 독립적이지 않다. 이식성과 안전성을 위한 JVM 추상화는 추가적인 런타임과 자원 사용을 요구하고, 강한 하위 호환성은 오래된 코드의 수명을 늘리는 동시에 언어와 표준 라이브러리의 단순화를 어렵게 한다.

장점

이식 가능한 실행 환경

Java 클래스 파일은 특정 CPU의 기계어가 아니라 JVM 명세에 정의된 중간 형식이다. 호환되는 JVM과 필요한 라이브러리가 제공되면 같은 클래스 파일을 여러 운영체제와 프로세서에서 실행할 수 있다.

Java는 기본 정수 타입의 크기와 부동소수점 표현을 플랫폼에 관계없이 정의한다. 이는 C와 C++에서 나타날 수 있는 구현별 기본 타입 차이를 줄인다.[114]

다만 파일 경로와 로케일, 네이티브 라이브러리 및 운영체제 API까지 자동으로 같아지는 것은 아니다.

자동 메모리 관리

가비지 컬렉션은 일반 객체를 개발자가 직접 해제하지 않게 한다. 이로 인해 이중 해제와 일반적인 사용 후 해제 및 임의 포인터 연산과 관련된 오류를 줄일 수 있다.

JVM은 서로 다른 사용 환경을 위한 여러 가비지 컬렉터를 제공하며, 처리량과 짧은 일시 정지 및 대용량 힙과 같은 목표에 맞추어 선택할 수 있다.

그러나 파일과 소켓 및 네이티브 메모리는 여전히 명시적인 자원 관리가 필요하다.

정적 타입과 실행 시점 검사

Java 컴파일러는 대입과 메서드 호출, 타입 변환, 접근 제어와 검사 예외를 컴파일 시점에 확인한다. JVM은 클래스 파일을 로딩할 때 구조와 바이트코드의 타입 흐름을 검증한다.

초기 Java 설계는 바이트코드 검증을 통해 피연산자 스택과 명령 인수 타입, 필드 접근의 유효성을 실행 전에 확인하는 구조를 도입했다.[115]

정적 타입 정보는 IDE의 코드 완성과 리팩터링 및 대규모 코드 탐색에도 활용된다.

성숙한 JVM 성능

JVM은 실행 중 수집한 실제 호출과 타입 및 분기 정보를 이용하여 자주 실행되는 코드를 최적화한다. 장시간 실행되는 서버 애플리케이션에서는 메서드 인라인화와 탈출 분석, 가상 호출 최적화 등의 효과를 얻을 수 있다.

Java는 단순한 바이트코드 인터프리터에 머물지 않고 고성능 JIT 컴파일과 여러 가비지 컬렉터 및 프로파일 기반 최적화를 제공한다.

대규모 표준 라이브러리

Java SE는 컬렉션과 입출력, 네트워크, 동시성, 날짜와 시간, 암호화, 데이터베이스 및 XML 등 넓은 범위의 API를 포함한다.

외부 라이브러리 없이도 기본적인 서버와 명령행, 파일 처리와 네트워크 프로그램을 만들 수 있으며, 표준 API는 서로 다른 JDK 배포판에서 공통 계약을 제공한다.

방대한 생태계

Java는 서버와 데이터 처리, 빌드, 시험, 보안 및 운영을 위한 방대한 라이브러리와 프레임워크를 보유한다.

대표적인 기반은 다음과 같다.

  • Spring
  • Jakarta EE
  • Maven
  • Gradle
  • JUnit
  • Hibernate
  • Netty
  • Apache Kafka
  • Elasticsearch
  • Jenkins

Maven Central을 비롯한 저장소와 정형화된 의존성 관리 방식은 외부 라이브러리를 쉽게 공유하게 한다.

개발 도구

IntelliJ IDEA와 Eclipse, NetBeans 및 여러 언어 서버는 Java의 정적 구조를 이용해 고급 코드 분석을 제공한다.

JDK 자체도 컴파일과 문서화, 패키징, 모듈 분석, 디버깅과 성능 진단을 위한 도구를 포함한다. Java Flight Recorder와 JDK Mission Control은 운영 환경에서 JVM과 애플리케이션의 동작을 분석할 수 있게 한다.

동시성과 서버 처리

Java는 언어 수준의 스레드와 모니터, 메모리 모델 및 java.util.concurrent를 제공한다. 실행자와 동시성 컬렉션, 원자 변수, 비동기 계산과 가상 스레드를 하나의 표준 플랫폼에서 사용할 수 있다.

가상 스레드는 많은 블로킹 I/O 작업을 순차적인 코드 구조로 표현하면서 플랫폼 스레드 수를 과도하게 늘리지 않게 한다.

하위 호환성

Java는 오래된 클래스 파일과 라이브러리의 실행을 중요한 목표로 다룬다. Java 언어 명세는 공개 API의 변경이 기존 바이너리와 호환되는지를 구체적으로 정의한다.[116]

이러한 호환성은 기업과 공공 시스템처럼 수명이 긴 소프트웨어의 유지에 유리하다.

공급자 선택

OpenJDK를 중심으로 여러 기업과 프로젝트가 JDK 배포판을 제공한다. 사용자는 운영체제 지원과 업데이트 기간, 기술 지원 및 JVM 특성에 따라 배포판을 선택할 수 있다.

HotSpot 외에도 OpenJ9와 GraalVM, AriaJDK 같은 다른 JVM 및 JDK 구현 방향이 존재한다.

여러 JVM 언어와의 상호 운용

Java 클래스와 라이브러리는 Kotlin과 Scala, Groovy 및 Clojure 등 다른 JVM 언어에서도 사용할 수 있다. 프로젝트는 기존 Java 생태계를 유지하면서 일부 구성 요소에 다른 언어를 도입할 수 있다.

한계

런타임과 메모리 비용

Java 프로그램은 일반적으로 JVM과 클래스 라이브러리를 필요로 한다. JVM 자체와 JIT 코드, 클래스 메타데이터, 스레드 스택 및 가비지 컬렉터 자료구조가 추가 메모리를 사용한다.

작은 명령행 도구와 제한된 메모리 장치에서는 네이티브 언어로 작성된 단일 실행 파일보다 시작과 메모리 비용이 크게 느껴질 수 있다.

jlink와 GraalVM Native Image는 일부 배포 비용을 줄일 수 있지만 일반적인 JVM 실행과 다른 제약과 빌드 절차를 가진다.

시작 시간과 성능 준비

JIT 컴파일은 실행 정보를 이용하여 높은 장기 성능을 제공하지만, 프로그램 시작 직후에는 클래스 로딩과 검증, 프로파일 수집 및 컴파일이 필요하다.

짧게 실행되고 곧 종료되는 프로그램이나 요청마다 새로운 프로세스를 생성하는 환경에서는 준비 비용이 전체 실행 시간에서 큰 비중을 차지할 수 있다.

가비지 컬렉션의 비결정성

가비지 컬렉션은 객체 메모리 해제를 자동화하지만 정확한 회수 시점을 프로그램이 직접 결정할 수 없다. 수집기의 동시 처리에도 불구하고 일부 stop-the-world 일시 정지가 발생할 수 있다.

낮은 지연 시간이 엄격하게 요구되는 실시간 제어와 일부 거래 및 오디오 처리에서는 GC 설정과 객체 할당 패턴을 세심하게 관리해야 한다.

Java의 일반적인 GC는 엄격한 최악 실행 시간 보장을 제공하는 실시간 메모리 관리와 동일하지 않다.

저수준 제어의 제한

Java는 임의 포인터 산술과 직접적인 객체 메모리 배치를 제공하지 않는다. 이러한 제한은 안전성과 이식성을 높이지만 다음 분야에서는 제약이 될 수 있다.

  • 운영체제 커널
  • 장치 드라이버
  • 부트 로더
  • 매우 작은 임베디드 펌웨어
  • 직접적인 SIMD와 특수 명령 제어
  • 메모리 레이아웃이 ABI와 정확히 일치해야 하는 코드

JNI와 Foreign Function and Memory API를 통해 네이티브 코드와 메모리에 접근할 수 있지만, 이 경계에서는 Java의 일반적인 안전성과 이식성이 일부 줄어든다.

언어의 장황함

Java는 명시적인 클래스와 타입, 접근자 및 예외 처리를 중시해 왔다. 최신 Java는 레코드와 타입 추론, 람다 및 패턴 매칭으로 반복 문법을 줄였지만, Kotlin과 Python 및 C# 등에 비해 같은 기능에 더 많은 코드가 필요한 경우가 있다.

특히 과거 Java 코드에서는 다음과 같은 반복이 흔하다.

  • 단순 데이터 클래스의 생성자와 접근자
  • equals, hashCode, toString
  • 검사 예외 전달
  • 익명 클래스
  • 빌더와 설정 객체
  • 널 검사

레코드와 람다 및 현대 프레임워크는 이를 줄일 수 있지만 오래된 코드와 API에서는 장황한 구조가 계속 남아 있다.

널 안전성 부족

일반적인 Java 참조 타입은 타입 자체에서 null 가능성을 구분하지 않는다.

String name = null;

컴파일러는 String과 널 불가능한 String을 별개의 타입으로 다루지 않으므로 NullPointerException은 Java 애플리케이션에서 흔한 오류가 될 수 있다.

애너테이션과 정적 분석 도구, Optional과 명시적 검사로 위험을 줄일 수 있지만 Kotlin과 같은 언어의 내장 널 가능성 타입과 동일하지는 않다.

기본 타입과 객체의 분리

Java는 성능과 호환성을 위해 기본 타입과 참조 타입을 구분한다. 이 때문에 제네릭 타입 인수에 int를 직접 사용할 수 없고 Integer로 박싱해야 한다.

List<Integer> values =
    List.of(1, 2, 3);

박싱은 추가 객체와 널 가능성, 참조 동일성 문제 및 성능 비용을 만들 수 있다. Project Valhalla는 이러한 차이를 줄이는 방향의 기능을 연구하지만 현재 일반 Java 객체 모델에서는 구분이 남아 있다.

제네릭 타입 소거

Java 제네릭은 주로 타입 소거를 사용하므로 실행 시점에 구체적인 타입 인수를 완전하게 확인할 수 없다.

// value instanceof List<String>

이 구조는 기존 Java 코드와 바이너리 호환성을 유지하는 데 기여했지만 다음 제약을 만든다.

  • 기본 타입을 타입 인수로 사용하지 못함
  • 제네릭 배열 생성의 제한
  • 실행 시점 타입 인수 검사 제한
  • 비검사 캐스트와 경고
  • 브리지 메서드
  • 리플렉션에서의 복잡한 타입 정보 처리

검사 예외

검사 예외는 호출자가 일부 실패 가능성을 무시하지 못하도록 한다. 그러나 여러 계층을 통과하는 API와 함수형 프로그래밍 및 비동기 코드에서는 예외 선언과 변환이 반복될 수 있다.

라이브러리가 지나치게 넓은 검사 예외를 노출하면 호출 코드가 실제로 복구할 수 없는 오류까지 형식적으로 처리해야 할 수 있다.

Kotlin과 Scala 등의 JVM 언어는 Java 예외 객체를 사용할 수 있지만 검사 예외의 컴파일 강제는 채택하지 않았다.

클래스 단일 상속

Java 클래스는 하나의 직접 상위 클래스만 가질 수 있다. 이는 다중 구현 상속의 복잡성을 피하지만 서로 다른 클래스 구현을 조합하기 어렵게 할 수 있다.

인터페이스 기본 메서드와 위임 및 컴포지션을 사용할 수 있으나, 기존 클래스의 상태와 구현을 여러 경로에서 직접 재사용하는 것은 지원하지 않는다.

모듈과 클래스 로더의 복잡성

JVM의 동적 클래스 로딩은 플러그인과 서버 격리 및 도구 개발에 강력하지만 다음과 같은 복잡성을 만들 수 있다.

  • 같은 이름의 클래스가 로더마다 다른 타입이 됨
  • 클래스 로더 메모리 누수
  • 서비스와 리플렉션 접근 문제
  • 모듈 경로와 클래스 경로의 혼합
  • 프레임워크의 동적 프록시와 캡슐화 충돌

Java Platform Module System은 의존성과 캡슐화를 강화했지만, 기존 클래스 경로 기반 생태계와 함께 사용할 때 설정이 복잡해질 수 있다.

의존성 생태계의 복잡성

Java의 라이브러리 생태계는 방대하지만 그만큼 의존성 충돌과 취약점 및 버전 관리 문제가 발생할 수 있다.

대표적인 문제는 다음과 같다.

  • 같은 라이브러리의 여러 버전
  • 전이 의존성 충돌
  • 오래된 프레임워크의 지원 종료
  • 패키지 이름 변경
  • 로깅 구현 중복
  • 대형 의존성 그래프
  • 취약한 간접 의존성
  • 빌드 플러그인과 JDK 버전 호환성

Maven과 Gradle은 의존성 해결 기능을 제공하지만 어떤 버전을 선택해야 하는지와 업그레이드 시 호환성 문제까지 자동으로 해결하지는 않는다.

하위 호환성의 부담

Java의 강한 호환성은 장점이지만 오래된 API와 설계 결정을 오랫동안 유지하게 한다.

예를 들어 오래된 날짜와 시간 API, 객체 직렬화, finalization 및 일부 보안·데스크톱 API는 더 나은 대안이 생긴 뒤에도 기존 프로그램 때문에 즉시 제거하기 어렵다.

정기 릴리스와 폐기 예정 정책은 점진적 개선을 가능하게 하지만, 언어와 표준 라이브러리 전체를 단순하게 다시 설계하는 데에는 제약이 된다.

플랫폼마다 완전히 같지는 않음

Java는 높은 이식성을 제공하지만 실제 프로그램이 모든 환경에서 완전히 같은 결과를 낸다는 의미는 아니다.

차이가 발생할 수 있는 요소는 다음과 같다.

  • 기본 문자 인코딩
  • 파일 시스템 대소문자 구분
  • 경로와 권한
  • 로케일과 시간대 데이터
  • 글꼴과 그래픽 환경
  • 네트워크 설정
  • 네이티브 라이브러리
  • 운영체제별 프로세스 동작
  • JDK 배포판의 지원 기능

이식성은 표준 API와 명세 안에서 개발할수록 높아지며, 플랫폼 고유 기능을 사용할수록 별도 대응이 필요하다.

데스크톱과 클라이언트 분야의 축소

Java는 한때 웹 애플릿과 Java Web Start 및 데스크톱 애플리케이션을 주요 활용 분야로 삼았다. 브라우저 플러그인 지원이 종료되고 웹 사용자 인터페이스가 JavaScript 중심으로 이동하면서 클라이언트 측 Java의 영향력은 크게 줄었다.

Swing과 JavaFX는 현재도 사용할 수 있지만 일반 소비자용 데스크톱 애플리케이션의 주류 선택은 아니다. Java의 중심 활용 분야는 서버와 기업 시스템 및 데이터 인프라로 이동했다.

Android에서의 위치 변화

Java는 Android의 초기 중심 언어였지만, 현재 신규 Android 개발에서는 Kotlin이 우선적으로 권장된다.

기존 Java 코드와 라이브러리는 계속 중요한 비중을 가지지만 Android의 최신 API와 교육 자료 및 선언적 UI 생태계는 Kotlin 중심으로 발전하고 있다. 또한 Android는 Java SE 전체 플랫폼과 표준 JVM을 사용하는 환경이 아니다.

종합

Java는 이식성과 안정성, 장기 호환성 및 성숙한 도구와 생태계가 중요한 서버와 기업용 시스템에 강점을 가진다. 장시간 실행되는 애플리케이션에서는 JVM의 프로파일 기반 최적화와 관리 도구도 장점이 된다.

반면 작은 네이티브 실행 파일과 즉각적인 시작, 직접적인 하드웨어 제어, 엄격한 실시간 응답이나 언어 차원의 널 안전성이 핵심인 환경에서는 다른 언어와 실행 모델이 더 적합할 수 있다.

Java의 적합성은 단순한 실행 속도 비교보다 다음 조건을 함께 고려하여 판단해야 한다.

  • 애플리케이션의 예상 수명
  • 사용 가능한 라이브러리와 프레임워크
  • 개발 팀의 경험
  • 배포 대상과 자원 제한
  • 시작 시간과 장기 처리량
  • 지연 시간 요구
  • 네이티브 코드와의 연동
  • 하위 호환성
  • 운영과 진단 도구
  • 다른 JVM 언어와의 상호 운용

관련 문서

  1. Oracle: Java SE Specifications
  2. OpenJDK: JDK 25
  3. Oracle: Java SE Support Roadmap
  4. Oracle: A Brief History of Java
  5. Oracle: The Java Language Environment — Beginnings of the Java Language Project
  6. Oracle: JDK Release Timeline
  7. Oracle: JDK Release Timeline
  8. OpenJDK Developers’ Guide: Introduction
  9. Oracle: Oracle and Sun
  10. Oracle: Java Programming Language Enhancements in Java SE 8
  11. OpenJDK: JDK Project
  12. OpenJDK: Java 11 General Availability
  13. OpenJDK: JDK Project
  14. Java Language Specification: Packages and Modules
  15. Oracle: javac — Java Programming Language Compiler
  16. Java Virtual Machine Specification: The class File Format
  17. Java Virtual Machine Specification: Run-Time Data Areas
  18. Java Virtual Machine Specification: Creation and Loading
  19. Java Virtual Machine Specification: Linking
  20. OpenJDK: HotSpot Compilation Policy
  21. Java Language Specification: Types, Values, and Variables
  22. Java Language Specification: Classes
  23. Java Language Specification: Interfaces
  24. Java Language Specification: Conversions and Contexts
  25. OpenJDK JEP 530: Primitive Types in Patterns, instanceof, and switch
  26. Java Language Specification: Type Erasure
  27. OpenJDK JEP 395: Records
  28. OpenJDK JEP 409: Sealed Classes
  29. OpenJDK JEP 440: Record Patterns
  30. OpenJDK JEP 441: Pattern Matching for switch
  31. OpenJDK JEP 512: Compact Source Files and Instance Main Methods
  32. Java Language Specification: Lexical Structure
  33. Java Language Specification: Expressions
  34. OpenJDK JEP 511: Module Import Declarations
  35. Java Language Specification: Annotation Interfaces
  36. Java SE 26 API: AnnotatedElement
  37. Java Language Specification: Exceptions
  38. OpenJDK JEP 512: Compact Source Files and Instance Main Methods
  39. Java SE 26 API: AutoCloseable
  40. Java Virtual Machine Specification: Run-Time Data Areas
  41. Java Virtual Machine Specification: The Heap
  42. Java Virtual Machine Specification: The Method Area
  43. Oracle JDK 26: The java Command
  44. Java SE 26 API: java.lang.ref Package
  45. Java SE 26 API: Cleaner
  46. OpenJDK JEP 421: Deprecate Finalization for Removal
  47. OpenJDK JEP 454: Foreign Function & Memory API
  48. Java Language Specification: Threads and Locks
  49. Java Language Specification: Happens-before Order
  50. Java SE 26 API: ExecutorService
  51. OpenJDK JEP 444: Virtual Threads
  52. Java SE 26 API: Executors
  53. OpenJDK JEP 491: Synchronize Virtual Threads without Pinning
  54. OpenJDK JEP 506: Scoped Values
  55. OpenJDK JEP 525: Structured Concurrency (Sixth Preview)
  56. Oracle: Java SE 26 & JDK 26 API Overview
  57. Oracle: java.base Module
  58. Oracle: java.net.http Module
  59. Oracle: java.desktop Module
  60. OpenJDK JEP 261: Module System
  61. Oracle: java.se Module
  62. OpenJDK JEP 282: jlink — The Java Linker
  63. OpenJDK: JDK Project
  64. OpenJDK: HotSpot Group
  65. Eclipse Foundation: Eclipse OpenJ9
  66. IBM: Semeru Runtimes
  67. GraalVM: Graal Compiler
  68. GraalVM: Native Image
  69. GitHub: wavefnd/aria
  70. Oracle: JDK 25 Release Notes — Differences Between Oracle JDK and OpenJDK
  71. Eclipse Adoptium: Eclipse Temurin
  72. BellSoft: Liberica JDK
  73. Oracle: JDK 26 Tool Specifications
  74. Oracle: javac Command
  75. JetBrains: Maven Support in IntelliJ IDEA
  76. JetBrains: Gradle Support in IntelliJ IDEA
  77. Eclipse Foundation: Eclipse IDE for Java Developers
  78. Apache NetBeans: About
  79. Visual Studio Code: Java Extensions
  80. Apache Maven: Build Lifecycle
  81. Gradle: Toolchains for JVM Projects
  82. Apache Ant: Introduction
  83. Gradle: Maven Publish Plugin
  84. Git: Documentation
  85. Gerrit: Product Overview
  86. Apache Subversion: Features
  87. Jenkins: User Documentation
  88. Oracle: Java SE 26 & JDK 26 API Overview
  89. Jakarta EE Tutorial: Overview
  90. Spring: Why Spring
  91. MicroProfile: Project Mission
  92. Android Developers: Java Versions in Android Builds
  93. Android Developers: Platform Architecture
  94. Oracle: Java ME Embedded Getting Started
  95. Java Language Specification: Introduction
  96. Oracle: The Java Language Environment — Beginnings of the Java Language Project
  97. Oracle: The Java Language Environment — Object-Oriented
  98. Oracle: The Java Language Environment — Object-Oriented
  99. Microsoft: C# Language Specification — Introduction
  100. Kotlin Documentation: Calling Java from Kotlin
  101. Kotlin Documentation: Java Interoperability — Null Safety and Platform Types
  102. Kotlin Documentation: Calling Kotlin from Java
  103. Scala Documentation: Tour of Scala
  104. Scala Documentation: Interacting with Java
  105. Apache Groovy: Language Documentation
  106. Apache Groovy: Integrating Groovy in a Java Application
  107. Clojure Reference: Java Interop
  108. Kotlin Documentation: Comparison to Java
  109. Oracle: The Java Language Environment — Architecture Neutral, Portable, and Robust
  110. OpenJDK JSR 292: Supporting Dynamically Typed Languages on the Java Platform
  111. Oracle: The Java Language Environment — Architecture Neutral, Portable, and Robust
  112. Java Language Specification: Binary Compatibility
  113. OpenJDK: JDK Project
  114. Oracle: The Java Language Environment — Architecture Neutral, Portable, and Robust
  115. Oracle: The Java Language Environment — Security in Java
  116. Java Language Specification: Binary Compatibility