스레드(Thread)
프로세스는 실행 중인 애플리케이션을 의미한다. 즉, 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 만큼의 메모리를 할당 받아 프로세스가 된다. 프로세스는 데이터, 컴퓨터 자원, 그리고 스레드로 구성되는데, 스레드는 데이터와 애플리케이션이 확보한 자원을 활용하여 소스 코드를 실행한다. 즉, 스레드는 하나의 코드 실행 흐름이라고 볼 수 있다.
단 하나의 스레드를 가지는 프로세스를 싱글 스레드 프로세스, 여러 개의 스레드를 가지는 프로세스를 멀티 스레드 프로세스라고 한다.
메인 스레드(Main thread)
자바 애플리케이션을 실행하면 가장 먼저 실행되는 메서드는 main 메서드이며, 메인 스레드가 main 메서드를 실행시켜준다. 메인 스레드는 main 메서드의 코드를 처음부터 끝까지 순차적으로 실행시키며, 코드의 끝을 만나거나 return문을 만나면 실행을 종료한다.
만약, 어떤 자바 애플리케이션의 소스 코드가 싱글 스레드로 작성되었다면, 그 애플리케이션이 실행되어 프로세스가 될 때 오로지 메인 스레드만 가지는 싱글 스레드 프로세스가 될 것이다.

멀티 스레드(Multi-Thread)
멀티 스레드 프로세스는 하나의 프로세스는 여러 개의 스레드를 가진것이다. 여러 개의 스레드를 가진다는 것은 여러 스레드가 동시에 작업을 수행할 수 있음을 의미하며, 이를 멀티 스레딩이라고 한다.
멀티 스레딩은 하나의 애플리케이션 내에서 여러 작업을 동시에 수행하는 멀티 태스킹을 구현하는 데에 핵심적인 역할을 수행한다.
예를 들어 카카오톡에서 상대에게 사진을 전송하면서 동시에 메시지를 주고받을 수 있다. 이처럼 여러 가지 작업을 동시에 수행하려면, 작업을 동시에 실행해줄 스레드가 추가적으로 필요하다.

작업 스레드 생성과 실행
메인 스레드 외에 별도의 작업 스레드를 활용한다는 것은, 다시 말해 작업 스레드가 수행할 코드를 작성하고, 작업 스레드를 생성하여 실행시키는 것을 의미한다.
스레드가 수행할 코드는 클래스 내부에 작성해주어야 하며, run()이라는 메서드 내에 스레드가 처리할 작업을 작성하도록 규정되어져 있다.
run() 메서드는 Runnable 인터페이스와 Thread 클래스에 정의되어져 있습니다. 따라서, 작업 스레드를 생성하고 실행하는 방법은 두 가지이다.
1. Runnable 인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
public class ThreadExample1 {
public static void main(String[] args) {
Runnable task1 = new ThreadTask1(); // Runnable 인터페이스를 구현한 객체 생성
Thread thread1 = new Thread(task1); // Runnable 구현 객체를 인자로 전달하면서 Thread 클래스를 인스턴스화하여 스레드를 생성
// 위의 두 줄을 아래와 같이 한 줄로 축약할 수도 있습니다.
// Thread thread1 = new Thread(new ThreadTask1());
// 작업 스레드를 실행시켜, run() 내부의 코드를 처리하도록 합니다.
thread1.start();
// 반복문 추가
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
// Runnable 인터페이스를 구현하는 클래스
class ThreadTask1 implements Runnable {
public void run() { // run() 메서드 바디에 스레드가 수행할 작업 내용 작성
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
}
@@@@@@@@@@@######@@@@@############################
@#########@@@@@@@@@@@@@@@@############@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@##@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@###########################################
2. Thread 클래스를 상속받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
public class ThreadExample2 {
public static void main(String[] args) {
Thread thread2 = new ThreadTask2(); // Thread 클래스를 상속받은 클래스를 인스턴스화하여 스레드를 생성
// 작업 스레드를 실행시켜, run() 내부의 코드를 처리하도록 합니다.
thread2.start();
// 반복문 추가
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
// Thread 클래스를 상속받는 클래스 작성
class ThreadTask2 extends Thread {
public void run() { // run() 메서드 바디에 스레드가 수행할 작업 내용 작성
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
}
스레드의 이름
메인 스레드는 “main”이라는 이름을 가지며, 그 외에 추가적으로 생성한 스레드는 기본적으로 “Thread-n”이라는 이름을 가진다.
스레드의 이름 조회하기
스레드의 이름은 아래와 같이 스레드의_참조값.getName()으로 조회할 수 있다.
public class ThreadExample3 {
public static void main(String[] args) {
Thread thread3 = new Thread(new Runnable() {
public void run() {
System.out.println("Get Thread Name");
}
});
thread3.start();
System.out.println("thread3.getName() = " + thread3.getName());
}
}
스레드의 이름 설정하기
스레드의 이름은 스레드의_참조값.setName()으로 설정할 수 있다.
public class ThreadExample4 {
public static void main(String[] args) {
Thread thread4 = new Thread(new Runnable() {
public void run() {
System.out.println("Set And Get Thread Name");
}
});
thread4.start();
System.out.println("thread4.getName() = " + thread4.getName());
thread4.setName("Sangwonee");
System.out.println("thread4.getName() = " + thread4.getName());
}
}
스레드 인스턴스의 주소값 얻기
실행 중인 스레드의 주소값을 사용해야 하는 상황이 발생한다면 Thread 클래스의 정적 메서드인 currentThread()를 사용하면 된다.
public class ThreadExample1 {
public static void main(String[] args) {
Thread thread1 = new Thread(new Runnable() {
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
thread1.start();
System.out.println(Thread.currentThread().getName());
}
}
스레드의 동기화
프로세스는 스레드가 운영 체제로부터 자원을 할당받아 소스 코드를 실행하여 데이터를 처리한다. 이 때, 싱글 스레드 프로세스는 데이터에 단 하나의 스레드만 접근하므로, 문제될 사항이 없다. 하지만 멀티 스레드 프로세스의 경우, 두 스레드가 동일한 데이터를 공유하게 되어 문제가 발생할 수 있다.
public class ThreadExample3 {
public static void main(String[] args) {
Runnable threadTask3 = new ThreadTask3();
Thread thread3_1 = new Thread(threadTask3);
Thread thread3_2 = new Thread(threadTask3);
thread3_1.setName("상원");
thread3_2.setName("먕주");
thread3_1.start();
thread3_2.start();
}
}
class Account {
// 잔액을 나타내는 변수
private int balance = 1000;
public int getBalance() {
return balance;
}
// 인출 성공 시 true, 실패 시 false 반환
public boolean withdraw(int money) {
// 인출 가능 여부 판단 : 잔액이 인출하고자 하는 금액보다 같거나 많아야 합니다.
if (balance >= money) {
// if문의 실행부에 진입하자마자 해당 스레드를 일시 정지 시키고,
// 다른 스레드에게 제어권을 강제로 넘깁니다.
// 일부러 문제 상황을 발생시키기 위해 추가한 코드입니다.
try { Thread.sleep(1000); } catch (Exception error) {}
// 잔액에서 인출금을 깎아 새로운 잔액을 기록합니다.
balance -= money;
return true;
}
return false;
}
}
class ThreadTask3 implements Runnable {
Account account = new Account();
public void run() {
while (account.getBalance() > 0) {
// 100 ~ 300원의 인출금을 랜덤으로 정합니다.
int money = (int)(Math.random() * 3 + 1) * 100;
// withdraw를 실행시키는 동시에 인출 성공 여부를 변수에 할당합니다.
boolean denied = !account.withdraw(money);
// 인출 결과 확인
// 만약, withdraw가 false를 리턴하였다면, 즉 인출에 실패했다면,
// 해당 내역에 -> DENIED를 출력합니다.
System.out.println(String.format("Withdraw %d₩ By %s. Balance : %d %s",
money, Thread.currentThread().getName(), account.getBalance(), denied ? "-> DENIED" : "")
);
}
}
}
Withdraw 100₩ By 상원. Balance : 600
Withdraw 300₩ By 먕주. Balance : 600
Withdraw 200₩ By 상원. Balance : 400
Withdraw 200₩ By 먕주. Balance : 200
Withdraw 200₩ By 상원. Balance : -100
Withdraw 100₩ By 먕주. Balance : -100
Process finished with exit code 0
두 스레드 간에 객체가 공유되기 때문에 오류가 발생할 수 있고, 왜 오류가 생겼는지 추측하기가 어렵습니다. 이러한 상황이 발생하지 않게 하는 것을 바로 스레드 동기화라고 합니다.
임계 영역(Critical section)과 락(Lock)
임계 영역은 오로지 하나의 스레드만 코드를 실행할 수 있는 코드 영역을 의미하며, 락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미합니다.
임계 영역으로 설정된 객체가 다른 스레드에 의해 작업이 이루어지고 있지 않을 때, 임의의 스레드 A 는 해당 객체에 대한 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.
이 때, 스레드 A가 임계 영역 내의 코드를 실행 중일 때에는 다른 스레드들은 락이 없으므로 이 객체의 임계 영역 내의 코드를 실행할 수 없다.
잠시 뒤 스레드 A가 임계 영역 내의 코드를 모두 실행하면 락을 반납합니다. 이 때부터는 다른 스레드들 중 하나가 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.
위 예제에서 오류가 발생하지 않게 하기위해서는 두 스레드가 동시에 실행하면 안 되는 영역을 설정하는 것이다. withraw() 메서드를 두 스레드가 동시에 실행하지 못하게 해야한다. 즉, withdraw() 메서드를 임계 영역으로 설정하는것이다.
특정 코드 구간을 임계 영역으로 설정할 때에는 synchronized라는 키워드를 사용한다. synchronized 키워드는 두 가지 방법으로 사용할 수 있다.
1. 메서드 전체를 임계 영역으로 지정하기
메서드의 반환 타입 좌측에 synchronized 키워드를 작성하면 메서드 전체를 임계 영역으로 설정할 수 있다.
메서드 전체를 임계 영역으로 지정하면 메서드가 호출되었을 때, 메서드를 실행할 스레드는 메서드가 포함된 객체의 락을 얻는다.
withdraw()가 호출되면, withdraw()를 실행하는 스레드는 withdraw()가 포함된 객체의 락을 얻으며, 해당 스레드가 락을 반납하기 이전에 다른 스레드는 해당 메서드의 코드를 실행하지 못하게 된다.
class Account {
...
public synchronized boolean withdraw(int money) {
if (balance >= money) {
try { Thread.sleep(1000); } catch (Exception error) {}
balance -= money;
return true;
}
return false;
}
}
2. 특정한 영역을 임계 영역으로 지정하기
특정 영역을 임계 영역으로 지정하려면 아래와 같이 synchronized 키워드와 함께 소괄호(()) 안에 해당 영역이 포함된 객체의 참조를 넣고, 중괄호({})로 블럭을 열어, 블럭 내에 코드를 작성하면 된다.
임계 영역으로 설정한 블럭의 코드로 코드 실행 흐름이 진입 할 때, 해당 코드를 실행하고 있는 스레드가 this에 해당하는 객체의 락을 얻고, 배타적으로 임계 영역 내의 코드를 실행한다.
class Account {
...
public boolean withdraw(int money) {
synchronized (this) {
if (balance >= money) {
try { Thread.sleep(1000); } catch (Exception error) {}
balance -= money;
return true;
}
return false;
}
}
}
스레드의 상태와 실행 제어
스레드에는 상태라는 것이 존재하며, 스레드의 상태를 바꿀 수 있는 메서드가 존재한다.

start() : 스레드의 상태를 실행 대기 상태로 만들어주는 메서드
어떤 스레드가 start()에 의해 실행 대기 상태가 되면 운영체제가 적절한 때에 스레드를 실행시켜준다.
sleep(long milliSecond) : milliSecond 동안 스레드를 잠시 멈춘다.
static void sleep(long milliSecond)
sleep()은 스레드의 실행을 잠시 멈출 때 사용한다. 인자로 얼마나 스레드를 멈출 것인지 설정할 수 있으나, 항상 지정한 시간 만큼 정확히 스레드가 중지되는 것은 아니며 약간의 오차가 있다.
sleep()은 Thread의 클래스 메서드이다. 따라서 sleep()을 호출할 때에는 Thread.sleep(1000);과 같이 클래스를 통해서 호출하는 것이 권장된다.
sleep()을 호출하면 스레드의 상태가 실행 상태에서 일시 정지(TIMED_WAITING) 상태로 전환된다.
sleep()에 의해 일시 정지된 스레드는 다음의 경우에 실행 대기 상태로 복귀한다.
1. 인자로 전달한 시간 만큼의 시간이 경과한 경우
2. interrupt()를 호출한 경우
interrupt()를 호출하여 스레드를 실행 대기 상태로 복귀시키고자 한다면 반드시 try … catch 문을 사용해서 예외 처리를 해주어야 한다.
interrupt()가 호출되면 기본적으로 예외가 발생하기 때문이다.
따라서 sleep()을 사용할 때에는 아래처럼 try … catch 문으로 sleep()을 감싸주어야 한다.
try { Thread.sleep(1000); } catch (Exception error) {}
interrupt() : 일시 중지 상태인 스레드를 실행 대기 상태로 복귀시킨다.
void interrupt()
interrupt()는 sleep(), wait(), join()에 의해 일시 정지 상태에 있는 스레드들을 실행 대기 상태로 복귀시킨다.
sleep(), wait(), join()에 의해 일시 정지된 스레드들의 코드 흐름은 각각 sleep(), wait(), join()에 멈춰있다.
멈춰있는 스레드가 아닌 다른 스레드에서 멈춰 있는 스레드.interrupt()를 호출하면, 기존에 호출되어 스레드를 멈추게 했던 sleep(), wait(), join() 메서드에서 예외가 발생되며, 그에 따라 일시 정지가 풀리게 된다.
yield() : 다른 스레드에게 실행을 양보한다.
static void yield()
yield()는 다른 스레드에게 자신의 실행 시간을 양보한다. 예를 들어, 운영 체제의 스케줄러에 의해 3초를 할당 받은 스레드 A가 1초 동안 작업을 수행하다가 yield()를 호출하면 남은 실행 시간 2초는 다음 스레드에게 양보된다.
아래코드와같이 example의 값이 false라면 스레드는 while문의 반복이 불필요함에도 계속해서 반복시킨다.
어떤 스레드가 yield()를 호출하면 example의 값이 false일 때에 무의미한 while문의 반복을 멈추고 실행 대기 상태로 바뀌며, 자신에게 남은 실행 시간을 실행 대기열 상 우선순위가 높은 다른 스레드에게 양보한다.
public void run() {
while (true) {
if (example) {
...
}
else Thread.yield();
}
}
join() : 다른 스레드의 작업이 끝날 때까지 기다린다.
void join()
void join(long milliSecond)
join()은 특정 스레드가 작업하는 동안에 자신을 일시 중지 상태로 만드는 상태 제어 메서드다.
인자로 시간을 밀리초 단위로 전달할 수 있으며, 전달한 인자만큼의 시간이 경과하거나, interrupt()가 호출되거나, join() 호출 시 지정했던 다른 스레드가 모든 작업을 마치면 다시 실행 대기 상태로 복귀한다.
join()과 sleep()은 유사하지만 차이가 있다. sleep()은 Thread 클래스의 static 메서드다. 반면, join()은 특정 스레드에 대해 동작하는 인스턴스 메서드다.
wait(), notify() : 스레드 간 협업에 사용된다.
스레드를 활용하다보면, 두 스레드가 교대로 작업을 처리해야할 때가 있다. 이 때 사용할 수 있는 상태 제어 메서드가 바로 wait()과 notify() 다.
스레드A와 스레드B가 공유 객체를 두고 협업하는 상황을 가정해보자.
먼저, 스레드A가 공유 객체에 자신의 작업을 완료한다. 이 때, 스레드B와 교대하기 위해 notify()를 호출한다. notify()가 호출되면 스레드B가 실행 대기 상태가 되며, 곧 실행된다. 이어서 스레드A는 wait()을 호출하며 자기 자신을 일시 정지 상태로 만든다.
이후 스레드B가 작업을 완료하면 notify()를 호출하여 작업을 중단하고 있던 스레드A를 다시 실행 대기 상태로 복귀시킨 후, wait()을 호출하여 자기 자신의 상태를 일시 정지 상태로 전환한다.
이와 같은 과정이 반복되면서, 두 스레드는 공유 객체에 대해 서로 배타적으로 접근하면서도 효과적으로 협업할 수 있다.
JVM이란?
JVM(Java Virtual Machine)은 자바 프로그램을 실행시키는 도구다. 즉, JVM은 자바로 작성한 소스 코드를 해석해 실행하는 별도의 프로그램이다.

자바는 운영체제로부터 독립적이다. 이러한 자바의 독립성은 JVM을 통해 구현된다.
먼저, 프로그램이 실행되기 위해서는 CPU, 메모리, 각종 입출력 장치 등과 같은 컴퓨터 자원을 프로그램이 할당받아야 한다.
프로그램이 자신이 필요한 컴퓨터 자원을 운영체제에게 주문하면, 운영체제는 가용한 자원을 확인한 다음, 프로그램이 실행되는 데에 필요한 컴퓨터 자원을 프로그램에게 할당해준다.
이 때, 프로그램이 운영체제에게 필요한 컴퓨터 자원을 요청하는 방식이 운영체제마다 다르다.
하지만 자바는 JVM을 매개해서 운영체제와 소통합니다. 즉, JVM이 자바 프로그램과 운영체제 사이에서 일종의 통역가 역할을 수행한다.
JVM은 Windows용 JVM, Mac OS용 JVM, Linux용 JVM이 따로 존재한다. 이처럼 운영체제에 맞게 JVM이 개발되어져 있으며, JVM은 자바 소스 코드를 운영 체제에 맞게 변환해 실행시켜준다. 이것이 자바가 운영체제로부터 독립적으로 동작할 수 있는 이유다.
JVM 구조

자바로 소스 코드를 작성하고 실행하면, 먼저 컴파일러가 실행되면서 컴파일이 진행된다. 컴파일의 결과로 .java 확장자를 가졌던 자바 소스 코드가 .class 확장자를 가진 바이트 코드 파일로 변환된다.
이후, JVM은 운영 체제로부터 소스 코드 실행에 필요한 메모리를 할당받는다. 그것이 바로 위 그림 상의 런타임 데이터 영역(Rumtime Data Area)이다.
그 다음에는 클래스 로더(Class Loader)가 바이트 코드 파일을 JVM 내부로 불러들여 런타임 데이터 영역에 적재시킨다. 즉, 자바 소스 코드를 메모리에 로드시키는것이다.
로드가 완료되면 이제 실행 엔진(Execution Engine)이 런타임 데이터 영역에 적재된 바이트 코드를 실행시킨다.
이 때, 실행 엔진은 두 가지 방식으로 바이트 코드를 실행시킨다.
- 인터프리터(Interpreter)를 통해 코드를 한 줄씩 기계어로 번역하고 실행시키기
- JIT Compiler(Just-In-Time Compiler)를 통해 바이트 코드 전체를 기계어로 번역하고 실행시키기
실행 엔진은 기본적으로 1번의 방법을 통해 바이트 코드를 실행시키다가, 특정 바이트 코드가 자주 실행되면 해당 바이트 코드를 JIT Compiler를 통해 실행시킨다.
즉, 중복적으로 어떤 바이트 코드가 등장할 때, 인터프리터는 매 번 해당 바이트 코드를 해석하고 실행하지만, JIT 컴파일러가 동작하면 한 번에 바이트 코드를 해석하고 실행시킨다.
JVM 메모리 구조

VM에 Java 프로그램이 로드되어 실행될 때 특정 값 및 바이트코드, 객체, 변수등과 같은 데이터들이 메모리에 저장되어야 한다.
런타임 데이터 영역이 바로 이러한 정보를 담는 메모리 영역이며, 크게 5가지 영역으로 구분되어 있다.
Stack 영역
스택은 일종의 자료구조이다. 스택은 LIFO(Last In First Out)로 마지막에 들어간 데이터가 가장 먼저 나오는 자료구조이다.
쉽게 생각해 프링글스과자 통에 감자칩을 쌓고 먹는거라 생각하면 된다.
JVM안에서 Stack은 메서드가 호출되면 그 메서드를 위한 공간인 Method Frame이 생성된다. 메서드 내부에서 사용하는 다양한 값들이 있는데 참조변수, 매개변수, 지역변수, 리턴값 및 연산시 일어나는 값들이 임시로 저장된다.
이런 Method Frame이 Stack에 호출되는 순서대로 쌓이게 되는데, Method의 동작이 완료되면 역순으로 제거된다.

Heap 영역
JVM이 작동되면 단 하나의 Heap 영역이 자동 생성된다.
Person person = new Person();
인스턴트를 생성하는 예시 코드이다. 예시에서 new Person()이 실행되면 Heap 영역에 인스턴스가 생성되며, 인스턴스가 생성된 위치의 주소값을 person에게 할당해주는데, 이 person은 Stack 영역에 선언된 변수이다.
즉, 우리가 객체를 다룬다는 것은 Stack 영역에 저장되어 있는 참조 변수를 통해 Heap 영역에 존재하는 객체를 다룬다는 의미이다.
정리하자면, Heap 영역은 실제 객체의 값이 저장되는 공간이다.

Garbage Collection
자바에서는 가비지 컬렉션이라는 메모리를 자동으로 관리하는 프로세스가 포함되어 있다. 프로그램에서 더 이상 사용하지 않는 객체를 찾아 삭제하거나 제거하여 메모리를 확보하는 것을 의미한다.
JVM의 Heap 영역은 객체는 대부분 일회성이며, 메모리에 남아 있는 기간이 대부분 짧다는 전제로 설계되어 있다.
그렇기 때문에 객체가 얼마나 살아있냐에 따라서 Heap 영역 안에서도 영역을 나누게 되는데 Young, Old영역으로 나뉜다.

Young 영역에서는 새롭게 생성된 객체가 할당되는 곳이고 여기에는 많은 객체가 생성되었다 사라지는 것을 반복한다.
이 영역에서 활동하는 가비지 컬렉터를 Minor GC라고 부른다.
Old 영역에서는 Young영역에서 상태를 유지하고 살아남은 객체들이 복사되는 곳으로 보통 Young 영역보다 크게 할당되고 크기가 큰 만큼 가비지는 적게 발생한다.
이 영역에서 활동하는 가비지 컬렉터를 Major GC라고 부른다.
기본적으로 가비지 컬렉션이 실행될때는 다음의 2가지 단계를 따른다.
1. Stop The World가비지 컬렉션이 실행될때 가비지 컬렉션을 실행하는 스레드를 제외한 모든 스레드들의 작업은 중단되고, 가비지 정리가 완료되면 재개된다.
Stop The World는 가비지 컬렉션을 실행시키기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다.
2. Mark and Sweep
Mark는 사용되는 메모리와 사용하지 않는 메모리를 식별하는 작업을 의미하며, Sweep은 Mark단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업을 의미한다.
Stop The World를 통해 모든 작업이 중단되면, 가비지 컬렉션이 모든 변수와 객체를 탐색해서 각각 어떤 객체를 참고하고 있는지 확인후, 사용되고 있는 메모리를 식별해서(Mark) 사용되지 않는 메모리는 제거(Sweep)하는 과정을 진행한다.
'Java' 카테고리의 다른 글
| 쓰레드와 JVM (0) | 2024.06.23 |
|---|---|
| 객체 지향 설계의 SOLID 원칙 (0) | 2024.06.20 |
| java 람다, 스트림 (0) | 2022.09.15 |
| Java컬렉션 (0) | 2022.09.14 |
| Java기초 사칙연산 계산기 만들기 (0) | 2022.09.09 |