Java 스레드 성능 vs. 가상 스레드 vs. Kotlin 코루틴
원문 (아래 리스트 순으로 번역되어 있음. Translated by Google Gemini) :
- https://blog.behzadian.info/2024-05-03/Java-Thread-Performance-vs.-Virtual-Threads
- https://blog.behzadian.info/2024-05-06/Java-Virtual-Threads-Performance-vs.-Kotlin-Coroutines
- https://blog.behzadian.info/2024-05-26/Java-Thread-Performance-vs.-Virtual-Threads-Part-2
Java 스레드 vs. 가상 스레드 Pt.1#
면접 중에 한 면접관이 저에게 Java 스레드와 가상 스레드의 성능 차이에 대해 물었습니다. 저는 가상 스레드가 실제로는 JVM이 처리하는 경량 스레드이기 때문에 더 빠를 것이라고 답했지만, 둘 사이의 정확한 성능 차이가 궁금했습니다. 그래서 가상 스레드의 성능 향상을 확인하기 위해 간단한 벤치마크를 수행했습니다.
이것은 매우 간단한 테스트입니다. 저는 매우 많은 수의 스레드와 가상 스레드를 생성하며, 각각이 완료되는 데 5초가 걸리도록 했습니다. 그런 다음 앱 실행 시간을 계산합니다. 이 벤치마크에 사용한 Java 코드는 다음과 같습니다:
package info.behzadian.thread;
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws InterruptedException {
int numThreads = 100000;
CountDownLatch latch = new CountDownLatch(numThreads);
long start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
startThreads(latch);
}
latch.await(); // 모든 스레드가 끝날 때까지 대기
long end = System.currentTimeMillis();
System.out.println("Time taken with regular threads: " + (end - start) + "ms");
latch = new CountDownLatch(numThreads); // 래치 재설정
start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
startVirtualThreads(latch);
}
latch.await(); // 모든 가상 스레드가 끝날 때까지 대기
end = System.currentTimeMillis();
System.out.println("Time taken with virtual threads: " + (end - start) + "ms");
}
private static void startThreads(CountDownLatch latch) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown(); // 래치의 카운트 감소
}
}
}).start();
}
private static void startVirtualThreads(CountDownLatch latch) {
Thread.startVirtualThread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown(); // 래치의 카운트 감소
}
}
});
}
}
첫 번째 실행에서는 100,000개의 스레드 대 가상 스레드로 시작했으며 결과는 다음과 같습니다:
Time taken with regular threads: 54639ms
Time taken with virtual threads: 9477ms
보시다시피, 가상 스레드가 훨씬 빠릅니다. 일반 스레드 실행 시간의 약 17%를 차지합니다.
그런 다음, 200,000개의 스레드로 벤치마크를 시도했으며 결과는 다음과 같습니다:
Time taken with regular threads: 98614ms
Time taken with virtual threads: 12260ms
보시다시피, 스레드 수를 늘리면 가상 스레드가 훨씬 더 효과적으로 작동합니다! 일반 스레드 실행 시간의 약 12.4%를 차지합니다. 저는 16GB RAM이 장착된 Windows 11 노트북에서 Java 버전 21.0.2를 사용하고 있었습니다.
Java 스레드 vs. 가상 스레드 Pt.2#
이전 게시물(여기 및 여기)에서 Java의 스레드와 Java의 가상 스레드 및 Kotlin의 코루틴 간의 매우 간단한 성능 벤치마크를 수행했습니다. 제 친구 Hossein은 더 나은 통찰력을 얻기 위해 메모리와 성능 사용량이 많은 작업으로 벤치마크를 수행해달라고 요청했습니다. 여기서는 벤치마크 테스트를 변경하여 각 작업 내에서 몇 초 동안 잠자기만 하는 대신, 이번에는 훨씬 더 높은 메모리와 CPU 집약적인 작업을 수행하려고 합니다.
첫 번째 라운드#
첫 번째 라운드에서는 1,000,000개의 정수 배열을 복사한 다음 배열을 반복하여 특정 값을 찾는 코드로 변경했습니다. 다음은 작업 실행 가능 코드입니다.
Runnable task = new Runnable() {
@Override
public void run() {
try {
boolean found = false;
int[] numbersCopy = Arrays.copyOf(numbers, numbers.length);
for (int i = 0; i < numbersCopy.length; i++) {
if (numbersCopy[i] == 1000000) {
found = true;
}
}
} finally {
latch.countDown(); // 래치 카운트 감소
}
}
};
다음은 10,000개 스레드의 결과입니다.
일반 스레드 소요 시간: 5059ms
가상 스레드 소요 시간: 2478ms
가상 스레드가 더 나은 성능을 보입니다. 약 두 배 더 빠릅니다. 이제 50,000개 스레드로 시도하겠습니다.
일반 스레드 소요 시간: 17092ms
가상 스레드 소요 시간: 12289ms
가상 스레드의 성능은 스레드보다 여전히 좋지만 이전 실행보다는 떨어집니다. 100,000개 스레드로 테스트하여 결과를 확인해 보겠습니다.
일반 스레드 소요 시간: 34063ms
가상 스레드 소요 시간: 24385ms
가상 스레드가 여전히 더 나은 성능을 보입니다! 200,000개 스레드의 경우:
일반 스레드 소요 시간: 65082ms
가상 스레드 소요 시간: 48078ms
400,000개 스레드의 경우:
일반 스레드 소요 시간: 128880ms
가상 스레드 소요 시간: 95860ms
그리고 1,000,000개 스레드의 경우:
일반 스레드 소요 시간: 351020ms
가상 스레드 소요 시간: 253580ms
다음은 벤치마크 차트입니다.
그리고 다음은 전체 소스 코드입니다.
package info.behzadian;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws InterruptedException {
Main main = new Main();
main.startTest();
}
CountDownLatch latch;
private int[] numbers;
private void startTest() throws InterruptedException {
int numThreads = 1_000_000;
latch = new CountDownLatch(numThreads);
numbers = createRandomNumbersArray();
long start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
new Thread(task).start();
}
latch.await(); // 모든 스레드가 완료될 때까지 대기
long end = System.currentTimeMillis();
System.out.println("Time taken with regular threads: " + (end - start) + "ms");
latch = new CountDownLatch(numThreads); // 래치 초기화
start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
Thread.startVirtualThread(task);
}
latch.await(); // 모든 가상 스레드가 완료될 때까지 대기
end = System.currentTimeMillis();
System.out.println("Time taken with virtual threads: " + (end - start) + "ms");
}
Runnable task = new Runnable() {
@Override
public void run() {
try {
boolean found = false;
int[] numbersCopy = Arrays.copyOf(numbers, numbers.length);
for (int i = 0; i < numbersCopy.length; i++) {
if (numbersCopy[i] == 1000000) {
found = true;
}
}
} finally {
latch.countDown(); // 래치 카운트 감소
}
}
};
private static int[] createRandomNumbersArray() {
int[] numbers = new int;
Random random = new Random();
for (int i = 0; i < numbers.length; i++) {
numbers[i] = 1 + random.nextInt(Integer.MAX_VALUE - 1);
}
return numbers;
}
}
두 번째 라운드#
이제 CPU 집약적인 작업에서 스레드와 가상 스레드 간의 성능 비교를 확인하려고 합니다. 크기 1,000의 무작위로 생성된 long 값 배열을 생성한 다음 각 실행에서 이 배열을 반복하여 숫자가 회문수인지 확인합니다.
다음은 새로운 작업 실행 가능 코드입니다.
Runnable task = new Runnable() {
@Override
public void run() {
try {
int palindromeCount = 0;
for (long num : numbers) {
if (isPalindrome(num)) {
palindromeCount++;
}
}
} finally {
latch.countDown(); // 래치 카운트 감소
}
}
};
그리고 다음은 long 숫자가 회문수인지 확인하는 함수입니다.
public boolean isPalindrome(long num) {
long reversed = 0, remainder, original = num;
while (num != 0) {
remainder = num % 10;
reversed = reversed * 10 + remainder;
num /= 10;
}
return original == reversed;
}
다음은 10,000개 스레드의 결과입니다.
일반 스레드 소요 시간: 1003ms
가상 스레드 소요 시간: 124ms
이제 50,000개 스레드로 시도하겠습니다.
일반 스레드 소요 시간: 3655ms
가상 스레드 소요 시간: 418ms
100,000개 스레드로 테스트하여 결과를 확인해 보겠습니다.
일반 스레드 소요 시간: 6560ms
가상 스레드 소요 시간: 805ms
200,000개 스레드의 경우:
일반 스레드 소요 시간: 14899ms
가상 스레드 소요 시간: 1891ms
400,000개 스레드의 경우:
일반 스레드 소요 시간: 26038ms
가상 스레드 소요 시간: 3242ms
그리고 1,000,000개 스레드의 경우:
일반 스레드 소요 시간: 65596ms
가상 스레드 소요 시간: 9821ms
다음은 벤치마크 차트입니다.
CPU 처리가 필요한 작업의 경우 가상 스레드가 스레드보다 더 나은 성능을 보인다는 것이 매우 명확합니다. 그리고 다음은 전체 소스 코드입니다.
package info.behzadian;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws InterruptedException {
Main main = new Main();
main.startTest();
}
CountDownLatch latch;
long[] numbers;
private void startTest() throws InterruptedException {
int numThreads = 10_000;
latch = new CountDownLatch(numThreads);
numbers = createRandomLongArray();
long start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
new Thread(task).start();
}
latch.await(); // 모든 스레드가 완료될 때까지 대기
long end = System.currentTimeMillis();
System.out.println("Time taken with regular threads: " + (end - start) + "ms");
latch = new CountDownLatch(numThreads); // 래치 초기화
start = System.currentTimeMillis();
for(int i =0; i < numThreads; i++) {
Thread.startVirtualThread(task);
}
latch.await(); // 모든 가상 스레드가 완료될 때까지 대기
end = System.currentTimeMillis();
System.out.println("Time taken with virtual threads: " + (end - start) + "ms");
}
Runnable task = new Runnable() {
@Override
public void run() {
try {
int palindromeCount = 0;
for (long num : numbers) {
if (isPalindrome(num)) {
palindromeCount++;
}
}
} finally {
latch.countDown(); // 래치 카운트 감소
}
}
};
private long[] createRandomLongArray() {
long[] numbers = new long[1_000];
Random random = new Random();
for (int i = 0; i < numbers.length; i++) {
numbers[i] = 1 + random.nextLong(Long.MAX_VALUE - 1);
}
return numbers;
}
public boolean isPalindrome(long num) {
long reversed = 0, remainder, original = num;
while (num != 0) {
remainder = num % 10;
reversed = reversed * 10 + remainder;
num /= 10;
}
return original == reversed;
}
}
자바 가상 스레드 성능 대 코틀린 코루틴#
이전 게시물에서 저는 자바 스레드와 자바 가상 스레드의 성능을 비교하기 위한 간단한 벤치마크를 수행했습니다. 이 게시물에서는 자바 가상 스레드를 코틀린 코루틴과 비교하고자 합니다.
이것은 매우 간단한 테스트입니다. 매우 많은 수의 코루틴을 생성하고, 각 코루틴은 5초가 걸려 완료됩니다. 그런 다음 앱 실행 시간을 계산합니다.
다음은 이 벤치마크에 사용한 코틀린 코드입니다:
package info.behzadian
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val jobs = List(100000) {
launch {
delay(5000L)
}
}
jobs.forEach { it.join() }
val endTime = System.currentTimeMillis()
println("Total running time: ${endTime - startTime} ms")
}
첫 번째 실행에서 100,000개의 코루틴으로 시작했으며 결과는 다음과 같습니다:
총 실행 시간: 5302 ms
이 결과를 자바 가상 스레드(9477ms)와 비교하면 훨씬 빠르다는 것을 알 수 있습니다! 다음으로 200,000개의 코루틴으로 시도했으며 결과는 다음과 같습니다:
총 실행 시간: 5415 ms
놀랍습니다! 실행 중인 코루틴 수를 두 배로 늘렸는데도 100,000개의 코루틴에 대한 오버헤드는 단 100ms에 불과합니다! 이것을 자바 가상 스레드(12260ms)와 비교하면 의심할 여지 없이 승자는 코틀린 코루틴이라는 것을 알 수 있습니다!
저는 16GB RAM을 탑재한 Windows 11 노트북에서 Kotlin 버전 1.9.23과 Coroutines 버전 1.8.0을 사용했습니다.