Java 21의 주요 특징
오라클이 최신 LTS버전인 Java21을 출시하였습니다.
미국 라스베이거스에서 개최한 연례 컨퍼런스 2023년 9월 19일(현지)
"오라클 클라우드월드2023"에서
자바 21 LTS(Long-Term Supprot) 버전을 출시한다고 발표하였습니다.
1. SequencedCollection
자바 21 이전의 코드에서 List에서 첫 번째와 마지막 원소를 구하기 위해서는 아래와 같이 할 수 있습니다.
List<String> list = new ArrayList<>();
String first = list.get(0);
String last = list.get(list.size() -1);
자바 21 이후부터는 정해진 순서의 원소를 조회할 수 있는 컬렉션을 표현하는 새로운 인터페이스를 도입합니다.
이를 통해 첫 번째, 마지막 원소 접근 기능과 역순 조회 기능에 대하여 컬렉션에서 일관된 API를 제공할 수 있게 되었습니다.
public interface SequencedCollection<E> extends Collection<E> {
void addFrist(E e); // 컬렉션이 시작 부분에 요소를 삽입합니다.
void addLast(E e); // 컬렉션의 끝에 요소를 삽입합니다.
E getFirst(); // 컬렉션의 시작 부분의 요소를 조회합니다.
E getLast(); // 컬렉션의 끝 요소를 조회합니다.
E removeFirst(); // 첫 번째 요소를 반환하고 제거합니다.
E removeLast(); // 마지막 요소를 반환하고 제거합니다.
SequencedCollection<E> reversed(); // 컬렉션에 대한 뷰를 역순으로 반환합니다.
}
List에서 첫 번째, 마지막 원소 접근
(List<E>는 SequencedCollection<E>의 하위 타입)
List<String> col = new ArrayList<>();
col.add("2");
col.addFirst("1");
col.addLast("3");
// 결과 : [1, 2, 3]
col.removeFirst();
col.removeLast();
// 결과 : [2]
SequencedSet
SqeuencedSet은 SequencedCollection을 상속합니다.
-> reversed() 메서드의 리턴 타입만 SequencedSet으로 변경
Set은 SequencedCollection과 같지만, 한 가지 다른 점은 reversed() 메소드를 재정의 하였습니다.
SequencedCollection의 경우 reversed의 반환 타입이 SequencedCollection이지만,
SequencedSet의 경우 반환 타입이 SequencedSet으로 다릅니다.
구현 클래스 : LinkedHashSet, TreeSet
-> TreeSet은 addFirst(), addLast()는 지원하지 않습니다.
LinkedHashSet
SequencedSet<String> set = new LinkedHashSet<>();
set.add("2");
set.addFirst("3");
set.addLast("1");
set.getFirst(); // "3"
set.getLasst(); // "1"
TreeSet
TreeSet<String> set = new TreeSet<>();
set.add("2");
set.add("3");
set.add("1");
// set.addFirst("3"); // 미지원
// set.addLast("1"); // 미지원
set.first(); // "1"
set.getFirst(); // "1"
set.last(); // "3"
set.getLast(); // "3"
TreeSet의 경우 add()메소드를 수행한 순서가 아니라, 값의 내림차순 또는 오름차순 등의 정렬 순서에 따라서 보관하기 때문에
addFirst(), addLast()는 지원하지 않습니다. (호출할 경우 Exception 발생)
SequencedMap
구현 클래스 : LinkedHashMap, TreeMap
public interface SequencedMap<K, V> extends extends Map<K, V> {
SequencedMap<K, V> reversed();
Map.Entry<K, V> firstEntry();
Map.Entry<K, V> lastEntry();
Map.Entry<K, V> pollFirstEntry();
Map.Entry<K, V> pollLastEntry();
V putFirst(K k, V v); // TreeMap의 경우 미지원
V putLast(K k, V v); // TreeMap의 경우 미지원
SequencedSet<K> sequencedKeySet();
SequencedCollection<V> sequencedValueds();
SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
}
// 키값을 기준
reversed()
순서만 반대로 제공하는 콜렉션에 대한 뷰입니다.
새로운 콜렉션을 생성하지 않습니다.
List<String> list = new ArrayList<>();
list.add("2");
list.addFirst("1");
list.addLast("3");
list.addLast("4"); // [1, 2, 3, 4]
List<String> reversed = list.reversed(); // [4, 3, 2, 1]
reversed.removeLast(); // list에도 영향을 주고 있습니다.
list.removeLast();
System.out.println(list); // [2, 3]
System.out.println(reversed); // [3, 2]
정리
자바 21에서 SequencedCollection이 추가되었고,
첫 번째, 마지막 원소 접근을 명시적으로 할 수 있게 되었습니다.
(인덱스를 사용하는 번거로움 해소)
2. recod 패턴
Java 16에서는 instanceof에 패턴 변수 사용 가능해졌습니다.
if(obj instanceof Name n) { // n 패턴 변수
// n.first(), n.last() -> n을 코드블럭 내에서 변수처럼 사용 가능
}
Java 21부터는 record패턴이 추가되어
recode의 구성 요소까지 변수로 사용이 가능해졌습니다.
record Name(String first, String last) {
}
---
if (obj instanceof Name(String f, String l) {
System.out.println(f);
}
record Member(Name name, Name nick) {
}
record Name(String first, String last) {
}
---
if (obj instanceof Member(Name name, Name(String n1, String n2))) {
System.out.println(n2);
}
record Pair<T1, T2>(T1 first, T2 second) {
}
---
if (obj instanceof Pair(String s, Integer i)) {
System.out.println("Pair(String, Integer)");
} else if (obj instanceof Pair(Integer i, String s)) {
System.out.println("Pair(Integer, String)");
}
3. Switch 패턴
switch에 null처리가 가능해졌습니다.
// Java 21 이전
if (v == null) {
return "기본";
}
return switch(v) {
case 1 -> "최상";
case 5 -> "최하";
default -> "기본";
}
// Java 21
return switch(v) {
case null -> "기본";
case 1 -> "최상";
case 5 -> "최하";
default -> "기본";
}
switch에 패턴매칭 기능도 추가되었습니다.
// Java 21 이전
if (obj instanceof Integer i) {
System.out.println("Integer");
} else if (obj instanceof String s) {
System.out.println("Stringn");
} else if (obj instanceof Pair) {
System.out.println("Pair");
} else {
System.out.println("other");
}
// Java 21
switch(obj) {
case null -> System.out.println("null");
case Integer i -> System.out.println("Integer");
case String s -> System.out.println("String");
case Pair(String s, Integer i) ->
System.out.println("Pair(String:%s, Integer:%d)\n", s, i)
default -> System.out.println("other");
}
Java 21부터는 switch 패턴에 조건도 추가할 수 있게 되었습니다. (패턴 + when(guard))
when(guard) 사용 예시 1
String desc = switch(o) {
case null -> "null";
case Integer i -> "Integer";
case String s when s.length() > 10 -> "long String";
case String s when s.length() < 3 -> "short String";
case String s -> "middle String";
case Pair(String s, Integer i) -> "Pair(String:%s, Integer:%d)".formatted(s, i);
default -> "other";
}
같은 타입 패턴이면 조건 붙은 case가 앞에 위치해야 합니다.
whend(guard) 사용 예시 2
String desc;
if (mark == 0) {
desc = "빵점";
} else if (mark == 100) {
desc = "만점";
} else if (mark >= 90) {
desc = "우수";
} else if (mark >= 80) {
desc = "잘함";
} else {
desc = "노력";
}
---
String desc = switch (mark) {
case 0 -> "빵점";
case 100 -> "만점";
case Integer i when i >= 90 -> "우수";
case Integer i when i >= 80 -> "잘함";
default -> "노력";
};
같은 동작을 하는 코드이지만, switch로 조건을 주며 깔끔하게 바꿀 수 있게 되었습니다.
switch : 식(expression)과 패턴
Object o = ...;
String desc = switch (o) {
case Integer i -> "Integer";
case String s -> "String";
}
// 컴파일 에러: the switch expression does not cover all possible input values
화살표를 이용하는 개선된 switch 문에서는 모든 경우를 다루지 않을 경우 위와 같이 컴파일 에러가 발생하게 됩니다.
이 경우 아래의 코드처럼 default를 넣거나 모든 경우의 수를 다루어야 합니다.
Object o = ...;
String desc = switch (o) {
case Integer i -> "Integer";
case String s -> "String";
default -> "Any";
}
switch : 문(statement)과 패턴
패턴 라벨은 fall through에서 사용하지 못합니다.
Object o = ...;
switch (o) {
case Integer i :
System.out.println("Integer: " + i);
case String s :
System.out.println("String: " + s);
default :
System.out.println("other");
};
// 컴파일 에러 : illegal fall-through to a pattern
에러가 나지 않기 위해서는 아래와 같이 break가 필요합니다.
switch (o) {
case Integer i :
System.out.println("Integer: " + i);
break;
case String s :
System.out.println("String: " + s);
break;
default :
System.out.println("other");
};
또는 아래와 같이 case가 한 개 이면서 default일 경우도 fall-through가 가능합니다.
switch (o) {
case Integer i :
System.out.println("Integer: " + i);
default :
System.out.println("other");
}
정리
record 패턴 - switch에서 사용 가능해졌습니다.
switch null case - null 비교를 위한 if가 필요 없어져서 코드가 간결해졌습니다.
switch에 타입 패턴 매칭이 들어가서 더욱 개선되었습니다.
(when을 추가할 수 있어서 if-else 블록 대신 switch로 대체하여 가독성 향상)
4. 가상 스레드(Virtual thread)
Java의 역사적으로 가장 혁신적인 변경점이라는 평가도 들려옵니다.
서버 애플리케이션을 확장할 때, 스레드는 종족 병목현상(bottleneck)이 발생합니다.
스레드의 수는 제한이 되어 있으며, 데이터베이스 쿼리 또는
원격 호출의 응답과 같은 이벤트를 기다려야 하거나 잠금(lock)에 의해 차단될 때가 많기 때문입니다.
CompletableFuture(자바에서 비동기(Asynchronus) 프로그래밍을 가능하게 해주는 인터페이스) 또는
Reactive(반응형) 프레임워크는 코드를 읽기 어렵고 유지보수가 어렵다는 단점이 있습니다.
이를 해결하기 위해 몇 년 동안 똑똑한 개발자들이 "Project Loom"라는 프로젝트에서 더 나은 솔루션을 연구해 왔습니다.
Java 19에서 가상 스레드가 미리보기(preview) 기능으로 도입되었습니다.
Java 21에서 "Enhancement Proposal 444"를 통해 마무리되어 정식으로 프로덕션에서 사용할 수 있게 되었습니다.
병목현상(bottlnneck)
전체 시스템의 성능이나 용량이 하나의 구성요소로 인해 제한을 받는 현상
- 위키 백과 -
가상 쓰레드가 가지게 되는 이점
가상 스레드(Virtual Thread)는 반응형(Reactive)코드와 달리 익숙하고 친숙한
"순차적인 쓰레드 당 요청(sequential thread-per-request)" 스타일로 프로그래밍할 수 있게 됩니다.
순차적인 코드는 읽고 쓰기가 쉬우며, 디버거를 사용하여 프로그램의 흐름을 단계별로 추적할 수 있습니다.
Java 21에서 새롭게 도입된 가상 스레드는 기존 코드스타일을 유지하면서도 많은 쓰레드를 효율적으로 관리할 수 있게됩니다.
가상 쓰레드 코드 예
Thread virtual =
Thread.ofVirtual()
.name("virtual")
.start(() -> {
callMethod();
}
);
virtual.join();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
...
executor.submit(() -> someCode());
...
}
가상 쓰레드 스케쥴링
기존 Java 쓰레드 모델은 Native Thread로, Java의 유저 쓰레드를 만들면 Java Native Interface(JNI)를 통해
커널 영역을 호출하여 OS가 커널 쓰레드를 생성하고 매핑하여 작업을 수행하는 형태였습니다.
Java의 쓰레드는 I/O, interrupt, sleep과 같은 상황에서 block/waiting 상태가 되는데,
이때 다른 스레드가 커널 스레드를 점유하여 작업을 수행하는 것을 "컨텍스트 스위치"라고 합니다.
스레드는 프로세스의 공통영역을 제외하고 만들어지기 때문에, 프로세스에 비해 크기가 작아서 생성 비용이 적고,
컨텍스트 스위칭 비용이 저렴했기 때문에 주목받아왔습니다.
그러나, 요청량이 급격하게 증가하는 서버 환경에서는 갈수록 더 많은 스레드 수를 요구하게 되며,
메모리가 제한된 환경에서는 생성할 수 있는 스레드 수에 한계가 있었고, 쓰레드가 많아지면서 컨텍스트 스위칭 비용도
기하급수적으로 늘어나게 되었습니다.
이를 극복하기 위해서는 더 많은 요청 처리량과 컨텍스트 스위칭 비용을 줄여야 했는데,
드디어 경량 쓰레드 모델인 Virtual Thread가 등장하였습니다.
Virtual Thread는 플랫폼 스레드와 가상 스레드로 나뉩니다.
플랫폼 스레드 위에서 Virtual Thread가 번갈아 가며 실행되는 형태로 동작합니다.
이는 커널 스레드와 유저 스레드가 매핑되는 형태와 비슷합니다.
하지만, Virtual Threadsms 컨텍스트 스위칭 비용이 저렴하다는 특징이 있습니다.
스레드는 기본적으로 최대 2MB의 스택메모리를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 큽니다.
또한 생성을 위해선 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않습니다.
하지만 Virtual Threadsms JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고,
메모리 크기가 일반 스레드의 1%에 불과하기 때문에 컨텍스트 비용이 적습니다.
VirtualThread의 더욱 자세한 동작과 성능 비교는
"우아한 기술블로그"에 자세히 다루고 있습니다.
주의점
Pinnned
가상 스레드가 캐리어 쓰레드에 고정되는 것
ex)
synchronized 블록에서 IO블로킹
- 가상 쓰레드 블로킹이 끝날 때까지 플랫폼 스레드도 같이 블로킹
발생하는 경우
synchronized 블록/메서드에서 사용 중에 발생
-> Lock을 사용해서 회피하라고 합니다.
synchronized 블록은 최소화(초기화 코드 등)
자바 외부의 다른 언어로 작성된 네이티브 메서드 또는 foreing 함수를 사용할 경우
정리
I/O 블로킹 발생 -> 캐리어 스레드가 다른 가상 쓰레드 실행
=> 플랫폼(OS) 스레드를 블로킹 하지 않음
=> I/O 처리가 가능해지면 가상 쓰레드를 이어서 실행
Thread | VirtualThread | |
Stack 사이즈 | ~2MB | ~10KB |
생성시간 | ~1ms | ~1µs |
컨텍스트 스위칭 | ~100µs | ~10µs |
요약하자면
Java21부터는 가상스레드를 통해 코드 수정 없이 성능(처리량)을 늘릴 수 있습니다.
*synchronized 블록에서는 I/O를 하면 안 됨. (Java21 기준)
*CPU를 주로 사용하는 작업에는 효과 없음
* 아직까지는 MySQL, MariaDB 등에서 사용할 경우 Pinned가 발생할 수 있습니다.
가상스레드를 사용하면 코드는 동기방식이지만, 동작은 비동기처럼 수행할 수 있는 이점을 얻을 수 있게 됩니다.
참고 자료 : HappyCoders, 최범균(유튜브), 우아한 기술블로그
'자바 탐구' 카테고리의 다른 글
자바) Stream API - 3) 스트림(Stream) (0) | 2024.06.09 |
---|---|
자바) Stream API - 2) 함수형 인터페이스(Functional Interface) (2) | 2024.06.09 |
자바) Stream API - 1) 람다 표현식(Lambda Expression) (0) | 2024.06.09 |
스프링) 스프링이란? (0) | 2024.05.14 |
JPA) N + 1 문제 (0) | 2023.08.01 |