자바 스레드를 이해하기 위해 같이 알아야 할 개념은 프로세스입니다. 먼저 프로세스와 스레드의 개념에 대해 간략히 설명하고 넘어가겠습니다.
프로세스는 self-contained 실행환경입니다. 각각 자신만의 메모리 공간을 가지고 있습니다. 흔히 프로세스를 하나의 프로그램, 크롬을 예시로 들면 실행된 크롬 앱으로 예시를 드는데, 완전히 맞다고 할 수는 없습니다. 하나의 어플리케이션 역시 여러 프로세스의 동작으로 이루어질 수 있습니다. 대부분의 JVM은 하나의 프로세스만 가지는 Single-Process로 운영됩니다. 하지만 JVM 안에서도 여러 프로세스를 실행시키고, 프로세스 간 데이터를 주고받을 수 있는데 이는 IPC(Inter-Process Communication)를 거쳐야 합니다.
출처: 시나공 정보처리기사 2017년 필기
프로세스는 하나 이상의 스레드로 구성되어 있습니다. 반대로 말하면 스레드는 프로세스 내에서 동작합니다. 스레드는 프로세스 내에서 프로세스의 메모리와 I/O로 가져온 파일 등 프로세스의 자원을 공유하여 작업을 진행합니다. 때문에 데이터를 주고받을 때 IPC가 필요했던 프로세스와 달리 스레드는 같은 메모리 공간을 공유하기 때문에 바로바로 주고받을 수 있습니다.
Java에서 스레드는 main thread로부터 시작하여 점차 필요한 작업이 생길때마다 스레드를 추가하여 진행하는 구조로 확장됩니다. 하나의 프로세스 내에서 스레드 간 동작은 독립적으로 진행되는데, 좋은(?) 단어로 병렬처리라는 키워드를 떠올릴 수 있습니다.
병렬처리의 장점은 한번에 여러 작업을 동시에 처리하기 때문에 속도가 매우 빠르다는 것과, 여러 시스템 자원을 한번에 사용할 수 있기 때문에 효율적이라는 점입니다. 하지만 여러 쓰레드가 동시에 작업을 진행하다보면 하나의 자원에 대해 동시에 접근하는 경우가 필연적으로 생길 것이고, Synchronization 문제와 Deadlock 문제가 발생하게 됩니다.
동기화 문제는 정말 간단한 예시로 이해할 수 있습니다.
class Hello {
public int x = 0;
public void hello() {
x++;
/* A */
System.out.println(x);
}
}
x라는 전역변수는 Hello 클래스 내부에서 모두가 공유하는 변수입니다. 두 스레드 ThreadA, ThreadB가 모두 hello()라는 메소드를 실행하려고 한다고 가정합시다.
1. ThreadA가 hello() 메소드를 먼저 실행한다.
2. ThreadA가 x++;
라인을 실행하여 현재 x == 1이다.
3. ThreadB가 실행된다. hello() 메소드를 실행한다.
4. ThreadB가 x++;
라인을 실행하여 x == 2로 변경된다.
5. ThreadA가 다음 라인인 System.out.println(x);
을 실행한다.
위의 과정으로 진행되었을 때 어떤 값이 출력이 될까요? 바로 2가 출력됩니다. 이처럼 서로 다른 스레드가 각자의 작업을 위해 공통된 자원에 접근할 때, 데이터의 일관성이 유지되지 않는 것입니다. 이러한 상황을 Race Condition이라고 합니다.
Race Condition은 비단 Java 뿐만이 아니라 운영체제 위에서 동작하는 concurrent한 프로세스나 스레드의 동작에서 모두 고려해야 하는 문제입니다. 이를 해결하기 위해서는 mutual exclusion, synchronizing process등을 진행하면 되는데, 이 글의 범위에서 너무 벗어나는 내용이기 때문에 여기서는 더 다루진 않겠습니다.
OS에서는 mutex lock, semaphore로 해결할 수 있습니다.
더 자세한 내용은 Race Condition in Java - javatpoint을 참고하시면 좋을 것 같습니다.
교착 상태를 설명하는 아래 그림을 한번쯤 접해보셨을거라 생각합니다.
출처: Nara Bagi, medium.com
Deadlock은 서로 다른 스레드가 자신의 자원은 소유하고 있으면서, 다른 스레드가 그들의 자원들 놓기를 기다리는 상태입니다. 둘 다 양보하지 않기 때문에 모두 실행되지 못하는 상황, 위의 그림처럼 모두가 전진하려 하기에 모두가 가지 못하게 된 상황을 Deadlock이라고 합니다.
Deadlock이 성립될 조건은 4가지입니다. 4가지 중 1개만 해당되는 것이 아닌, 모두 해당되는 경우에만 deadlock상태가 일어날 수 있습니다.
1. Mutual Exclusion: 하나의 자원에 대해서는 하나의 프로세스(혹은 스레드)만 접근이 가능하다. 다른 프로세스가 해당 자원을 사용하고 싶다면 대기해야 한다.
2. Hold and Wait: 하나 이상의 자원을 점유하고 있으면서, 다른 프로세스(스레드)의 자원을 사용하기 위해 대기하는 프로세스(스레드)가 존재한다.
3. No Preemption: 할당된 자원은 작업이 끝날 때까지 다른 프로세스(스레드)가 뺏어갈 수 없다.
4. Circular Wait: A는 B의 자원을, B는 C의 자원을, C는 A의 자원을 기다리고 있는 형태와 같이 순환적으로 점유된 자원을 요구하는 형태이다.
교착상태의 해결방법은 간단(?)한데, 위의 4가지 case 중 하나라도 없앤다면 해결할 수 있습니다. 또한 작업 완료 후 교착상태인지 다시 check하여 처리하여 deadlock을 피하는 방법 등 여러 방법이 있는데, 이 부분 역시 너무 범위에 벗어나기 때문에 생략하겠습니다.
Java 1.0 공식문서가 Sun에서 더이상 지원하기 않기 때문에 어렵게 찾은 MIT 아카이브에서 가져온 내용을 기반으로 작성하였습니다.
http://web.mit.edu/java_v1.0.2/www/javadoc/packages.html
Java에서 스레드는 태초부터 존재했습니다. 사실 스레드라는 개념 자체가 Java에서 나온것이 아닌 운영체제에서 나온 개념이기에 뒤늦게 나온 Java에 있다는 것은 어찌보면 당연하기도 합니다.
Java에서 스레드의 생명주기를 아래와 같이 정의합니다.
This simply means that while executing within a program, each thread has a beginning, a sequence, a point of execution occurring at any time during runtime of the thread and of course, an ending.
Thread는 Java.lang.Thread 클래스입니다. Java 1.0의 스레드는 단일 프로그램 내에서 concurrent한 스레드의 동작을 지원했습니다.
Thread의 생성은 start()
명령을 통해 가능합니다.
class PrimeThread extends Thread {
public void run() {
// compute primes...
}
}
PrimeThread p = new PrimeThread();
p.start();
...
또는 Runnable 인터페이스를 상속받아서 생성할 수도 있습니다.
class Primes implements Runnable {
public void run() {
// compute primes...
}
}
Primes p = new Primes();
new Thread(p).start();
...
Runnable Interface
스레드가 실행되었을 때 원하는 동작이 있을 때만 run() 메서드를 오버라이딩하여 사용하는 것을 권장한다고 합니다.
This interface is designed to provide a common protocol for Objects that wish to execute code while they are active.
스레드는 생성 시에 부모 thread의 우선순위와 부모가 daemon 스레드라면 daemon flag까지 상속받습니다. 또한 synchronized
키워드를 통해 race condition문제도 해결할 수 있습니다. 하지만 개발자가 수동으로 이러한 키워드와 로직을 통해 race condition과 deadlock 문제를 해결해야 한다는 불편함도 있었습니다.
ThreadGroup이 추가됩니다. ThreadGroup은 스레드를 그룹화하여 여러 작업을 처리할 수 있고, 트리구조로 이루어져 있습니다. 한번에 스레드 그룹에 인터럽트를 걸수도 있고, 대기, 실행도 한번에 진행할 수 있습니다. 여러 스레드 deadlock을 일으킬 수 있는 메서드들이 deprecated되었습니다.
Java 5에서 Concurrent 패키지가 등장합니다. java.util.concurrent
, java.util.concurrent.atomic
, java.util.concurrent.locks
의 3개의 패키지로 이전에 수동으로 관리해야했던 스레드의 race condition과 deadlock을 이제는 concurrent 패키지 내부에서 자동적으로 관리할 수 있도록 변경되었습니다. 이 내용에 대해서는 Concurrent Wiki에서 자세히 다루고, 덕분에 스레드 관리가 매우 편해졌다고 하고 넘어가겠습니다.
ForkJoinPool이 추가되면서 하나의 작업을 divide-and-conquer 방식으로 처리할 수 있게 되었습니다. 스레드 풀에 있는 여러 스레드에 대해서 쉬고 있는 스레드가 job queue에서 계속 subtask를 가져가며 처리하고, 처리결과를 합해 반환합니다.
함수형 인터페이스의 등장으로 Runnable 인터페이스를 매개변수가 없고 output도 없는 인터페이스로 간주하기 시작했고, 이로써 Functional Interface
로써 자리잡게 되었습니다. 스레드 생성 시에도 좀 더 간결한 코드로 실행할 수 있게 되었습니다.
Reactive Stream을 지원하기위한 Flow 클래스가 추가되었습니다. 이때부터 스레드의 asynchronous, non-blocking 처리가 극도로 효율적으로 변화합니다.
이미지 출처: Openjdk 공식 위키
Java가 탄생했을때부터 Java 1.1버전까지 개발자들의 목표는 스레드의 완전한 독립이였습니다. JVM의 목표 자체가 OS 독립적인 자바 실행환경을 만드는 것인데, 자바 스레드를 커널 스레드로 만들겠다는 것 자체가 모순적이였을 겁니다.
따라서 초창기에 Green Project를 진행했습니다.(공식 문서 상의 공식 용어는 아닙니다) JVM에서 스레드의 스케줄링을 관리하고, 스레드는 OS에 의해서가 아닌 VM에 의해서 관리됩니다. 이 스레드들은 Application Level에서 구현되고, user space에서 관리됩니다.
출처: Operating Systems MCQs
하지만 user-level에서 멀티스레드라고 해서 운영체제의 스케줄러는 이를 인식하지 못하고, green 스레드들을 매핑된 하나의 kernel thread로 보게 됩니다.(Many to One 관계) 따라서 물리적으로 Multi-Core CPU지만, 정작 프로세스를 실행할 때 하나의 스레드만 동작하는 Single-Thread 방식이 됩니다. 또한 JVM에서 yield 방식의 스케줄링을 택했기 때문에 user-mode thread가 kernel thread보다 좋다는 이유였던 빠른 속도가 overhead로 인해 실제로는 더 느렸습니다. 마지막으로 스레드를 OS에 독립적으로 만든다면 JVM의 구현체에서 별도로 구현해야 하는데, 이에 대한 유지보수 비용도 컸습니다.
이러한 이유로 Java 1.2에 걸쳐 Java 1.3부터는 Green Thread를 더이상 지원하지 않고, OS의 Kernel Thread와 1대1로 매핑되는 Native Thread 방식만 지원하게 되었습니다.
Native Thread로 Java의 최신버전까지 지원하고 있는데, 스레드를 User-Mode에서 동작하도록 설계하는 것이 Oracle의 숙원 사업으로 남아있었던 것 같습니다. 오라클 공식 홈페이지의 블로그를 보면, Project Loom은 "과거의 green threads를 다시 가져왔다"라는 표현을 직접적으로 사용할 정도로 user-mode thread에 진심(?)임을 볼 수 있습니다.
실제로 Native Thread역시 몇가지 문제가 존재했습니다. 특히 과거에는 크게 문제가 되지 않았지만, 현재에 와서 큰 이슈가 되었는데 아래와 같습니다.
Project Loom에서는 Fiber라는 클래스가 새로 등장합니다. 기존의 Thread 클래스와 동일한 역할을 한다고 보면 됩니다. 하지만 차이는, user-mode에서 동작한다는 점입니다.
//이후 내용 추가