Chapter 1. 스레드(Thread)
1. 프로세스(Process)와 스레드(Thread)
프로세스는 실행 중인 애플리케이션을 의미한다.
애플리케이션을 실행하면 운영체제로부터 실행에 필요한 만큼의 메모리를 할당 받아 프로세스가 된다.
프로세스는 데이터, 컴퓨터 자원, 스레드로 구성되는데 스레드는 데이터와 애플리케이션이 확보한 자원을 활용하여 소스 코드를 실행한다.
즉, 스레드는 하나의 코드 실행 흐름이라고 볼 수 있다.
메인 스레드(Main thread)
메인 스레드는 main 메서드의 코드를 처음부터 끝까지 순차적으로 실행시키며, 코드의 끝을 만나거나 return 문을 만나면 실행을 종료한다.
메인 스레드에서 또 다른 스레드를 생성하여 실행시킨다면 해당 애플리케이션은 멀티 스레드로 동작하게 된다.
멀티 스레드(Multi-Thread)
여러 개의 스레드를 가진다는 것은 여러 스레드가 동시에 작업을 수행할 수 있다는 것을 의미하며 이를 멀티 스레딩이라고 한다.
멀티 스레딩은 하나의 애플리케이션 내에서 여러 작업을 동시에 수행하는 멀티 태스킹을 구현하는 데에 핵심적인 역할을 수행한다.
예를 들어, 메신저 프로그램을 사용할 때 상대방에게 보낼 사진을 업로드하는 동시에 메세지를 주고 받을 수 있다.
2. 스레드의 생성과 실행
스레드가 수행할 코드는 클래스 내부에 작성해주어야 하며, run() 메서드 내에 스레드가 처리할 작업을 작성하도록 규정되어 있다.
run() 메서드는 Runnable 인터페이스와 Thread 클래스에 정의되어져 있다.
따라서, 작업 스레드를 생성하고 실행하는 방법은 두 가지가 있다.
- Runnable 인터페이스를 구현한 객체에서 run()을 구현하는 것
- Thread 클래스를 상속 받은 하위 클래스에서 run()을 구현하는 것
- Runnable 인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
public class ThreadExample1 {
public static void main(String[] args) {
Runnable task1 = new ThreadTask1();
Thread thread1 = new Thread(task1);
thread1.start();
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
class ThreadTask1 implements Runnable {
public void run() {
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
}
//결과
@@@@@@@@@@@######@@@@@############################
@#########@@@@@@@@@@@@@@@@############@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@##@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@###########################################
- Thread 클래스를 상속 받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
public class ThreadExample2 {
public static void main(String[] args) {
ThreadTask2 thread2 = new ThreadTask2();
thread2.start();
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
class ThreadTask2 extends Thread {
public void run() {
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
}
두 가지 방법 모두 작업 스레드를 만들고, run() 메서드에 작성된 코드를 처리하는 동일한 내부 동작을 수행한다.
이렇게 클래스를 따로 정의하지 않고도 익명 객체를 활용하여 스레드를 생성하고 실행시킬 수 있다.
public class ThreadExample1 {
public static void main(String[] args) {
// 익명 Runnable 구현 객체를 활용하여 스레드 생성
Thread thread1 = new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
});
thread1.start();
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
public class ThreadExample2 {
public static void main(String[] args) {
// 익명 Thread 하위 객체를 활용한 스레드 생성
Thread thread2 = new Thread() {
public void run() {
for (int i = 0; i < 100; i++) {
System.out.print("#");
}
}
};
thread2.start();
for (int i = 0; i < 100; i++) {
System.out.print("@");
}
}
}
3. 스레드의 이름
메인 스레드는 “main”이라는 이름을 가지며, 그 외에 추가적으로 생성한 스레드는 기본적으로 “Thread-n”이라는 이름을 가진다.
스레드의 이름 조회
스레드의_참조값.getName()
스레드의 이름 설정
스레드의_참조값.setName()
실행 중인 스레드의 주소값 얻기
Thread.currentThread()
4. 스레드의 동기화
멀티 스레드 프로세스는 두 스레드가 동일한 데이터를 공유하기 때문에 문제가 발생할 수 있다.
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);
// 인출 결과 확인
// 만약, withraw가 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
1000원의 잔액이 있는 계좌에서 100~300원을 인출하고, 인출금과 잔액을 출력하는 코드이다.
두 개의 작업 스레드가 Account 객체를 공유하게 된다.
결과적으로 김코딩은 100원을 인출했는데 잔액이 600원이라는 잘못된 결과가 출력되고 있다.
또한 withdraw()에서 잔액이 인출하고자 하는 금액보다 많은 경우에만 인출이 가능하도록 코드를 작성했지만 음수의 잔액이 발생하는 것을 확인할 수 있다.
이러한 상황이 발생하지 않게 하는 것을 스레드 동기화라고 한다.
임계 영역(Critical section)과 락(Lock)
임계 영역은 오로지 하나의 스레드만 코드를 실행할 수 있는 코드 영역을 의미하며, 락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미한다.
즉, 임계 영역으로 설정된 객체가 다른 스레드에 의해 작업이 이루어지고 있지 않을 때 임의의 스레드 A는 해당 객체에 대한 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다. 이 때 스레드 A가 임계 영역 내의 코드를 실행 중일 때는 다른 스레드들은 락이 없으므로 임계 영역 내의 코드를 실행할 수 없다. 스레드 A가 임계 영역 내의 코드를 모두 실행하면 락을 반납하고, 다른 스레드들 중 하나가 락을 획득하여 임계 영역 내의 코드를 실행한다.
임계 영역으로 설정할 때에는 synchronized 키워드를 사용하면 된다.
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;
}
**public synchronized boolean withdraw(int money) {
if (balance >= money) {
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) {
int money = (int)(Math.random() * 3 + 1) * 100;
boolean denied = !account.withdraw(money);
System.out.println(String.format("Withdraw %d₩ By %s. Balance : %d %s",
money, Thread.currentThread().getName(), account.getBalance(), denied ? "-> DENIED" : "")
);
}
}
}
//결과
Withdraw 100₩ By 김코딩. Balance : 900
Withdraw 100₩ By 박자바. Balance : 800
Withdraw 200₩ By 김코딩. Balance : 600
Withdraw 300₩ By 박자바. Balance : 300
Withdraw 300₩ By 김코딩. Balance : 0
Withdraw 100₩ By 박자바. Balance : 0 -> DENIED
이렇게 임계 영역을 설정함으로서 올바른 값이 출력되는 것을 볼 수 있다.
5. 스레드의 상태와 실행 제어
스레드를 생성한 후에 실행시키기 위해 start() 메서드를 사용했는데 이 때 사용한 start()는 스레드를 실행시키는 메서드가 아니다.
정확히 말하면 start()는 스레드의 상태를 실행 대기 상태로 만들어주는 메서드이며, 어떤 스레드가 start()에 의해 실행 대기 상태가 되면 운영체제가 적절한 때에 스레드를 실행시켜준다.
스레드 실행 제어 메서드
- sleep(long milliSecond) : milliSecond 동안 스레드를 잠시 멈춘다.
sleep()에 의해 일시 정지된 스레드는 milliSecond 만큼의 시간이 경과했거나 interrupt()를 호출한 경우 실행 대기 상태로 복귀한다.
이 때, interrupt()를 호출하여 실행 대기 상태로 복귀시키려면 반드시 try … catch 문을 사용해서 예외 처리를 해야한다.
try { Thread.sleep(1000); } catch (Exception error) {}
- interrupt() : 일시 중지 상태인 스레드를 실행 대기 상태로 복귀시킨다.
다른 스레드에서 멈춰_있는_스레드.interrupt()를 호출하면 sleep(), wait(), join() 메서드에서 예외가 발생되며 그에 따라 일시 정지가 풀리게 된다.
- yield() : 다른 스레드에게 실행을 양보한다.
운영 체제의 스케줄러에 의해 3초를 할당 받은 스레드 A가 1초 동안 작업을 수행하다가 yield() 를 호출하면 남은 실행 시간 2초는 실행 대기열 상 우선순위가 높은 다른 스레드에게 양보된다.
public void run() {
while (true) {
if (example) {
...
}
else Thread.yield();
}
}
- join() : 다른 스레드의 작업이 끝날 때까지 기다린다.
특정 스레드가 작업하는 동안 자신을 일시 중지 상태로 만드는 상태 제어 메서드이다.
sleep()과 유사하지만 sleep()은 Thread 클래스의 static 메서드이고, join() 은 특정 스레드에 대해 동작하는 인스턴스 메서드라는 차이점이 있다.
public class ThreadExample {
public static void main(String[] args) {
SumThread sumThread = new SumThread();
sumThread.setTo(10);
sumThread.start();
// 메인 스레드가 sumThread의 작업이 끝날 때까지 기다립니다.
try { sumThread.join(); } catch (Exception e) {}
System.out.println(String.format("1부터 %d까지의 합 : %d", sumThread.getTo(), sumThread.getSum()));
}
}
- wait(), notify() : 스레드 간 협업에 사용된다.
두 스레드가 교대로 작업을 처리해야 할 때 사용하는 상태 제어 메서드이다.
스레드 A와 스레드 B가 공유 객체를 두고 협업하는 상황이라고 할 때 아래의 플로우로 진행이 된다.
스레드 A가 공유 객체에 자신의 작업을 완료하고 notify() 호출한다.
→ 스래드 B가 실행 대기 상태가 되며 곧 실행이 된다.
→ 스레드 A는 wait()을 호출하여 자기 자신을 일시정지 상태로 만든다.
이 과정을 반복하며 협업이 진행된다.
public class ThreadExample5 {
public static void main(String[] args) {
WorkObject sharedObject = new WorkObject();
ThreadA threadA = new ThreadA(sharedObject);
ThreadB threadB = new ThreadB(sharedObject);
threadA.start();
threadB.start();
}
}
class WorkObject {
public synchronized void methodA() {
System.out.println("ThreadA의 methodA Working");
notify();
try { wait(); } catch(Exception e) {}
}
public synchronized void methodB() {
System.out.println("ThreadB의 methodB Working");
notify();
try { wait(); } catch(Exception e) {}
}
}
class ThreadA extends Thread {
private WorkObject workObject;
public ThreadA(WorkObject workObject) {
this.workObject = workObject;
}
public void run() {
for(int i = 0; i < 10; i++) {
workObject.methodA();
}
}
}
class ThreadB extends Thread {
private WorkObject workObject;
public ThreadB(WorkObject workObject) {
this.workObject = workObject;
}
public void run() {
for(int i = 0; i < 10; i++) {
workObject.methodB();
}
}
}
//결과
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
ThreadA의 methodA Working
ThreadB의 methodB Working
Chapter 2. 자바 가상 머신(JVM)
1. JVM이란?
JVM(Java Virtual Machine)은 자바 프로그램을 실행시키는 도구이다. 즉, JVM은 자바로 작성한 소스 코드를 해석해 실행하는 별도의 프로그램이다.
프로그램이 실행되기 위해서는 CPU, 메모리, 각종 입출력 장치 등과 같은 컴퓨터 자원을 프로그램이 할당받아야 한다.
프로그램이 자신이 필요한 컴퓨터 자원을 운영체제에게 주문하면, 운영체제는 가용한 자원을 확인한 다음 프로그램이 실행되는 데에 필요한 컴퓨터 자원을 프로그램에게 할당해준다.
이 때, 프로그램이 운영체제에 필요한 컴퓨터 자원을 요청하는 방식이 운영체제마다 다르다.
하지만 자바는 JVM을 매개해서 운영체제와 소통한다. 즉, JVM이 자바 프로그램과 운영체제 사이에서 일종의 통역가 역할을 하는 것이다.
운영체제에 맞는 JVM이 개발되어져 있으며 JVM은 자바 소스 코드를 운영체제에 맞게 변환해 실행시켜준다.
2. JVM의 내부 구조
자바로 소스 코드를 작성하고 실행하는 과정은 다음과 같다.
- 컴파일러가 실행되면서 컴파일이 진행된다.
- 컴파일의 결과로 .java 확장자의 자바 코드가 .class 확장자를 가진 바이트 코드 파일로 변환된다.
- JVM은 운영체제로부터 소스 코드 실행에 필요한 메모리를 할당받는다(Runtime Data Area).
- 클래스 로더(Class Loader)가 바이트 코드 파일을 JVM 내부로 불러들여 런타임 데이터 영역에 적재시킨다.
- 실행 엔진이 런타임 데이터 영역에 적재된 바이트 코드를 실행시킨다. 이 때 두 가지 방식이 있는데, 인터프리터를 통해 코드를 한줄씩 기계어로 번역하고 실행시키는 것과 JIT Compiler를 통해 바이트 코드 전체를 기계어로 번역하고 실행시키는 것이다.
기본적으로 인터프리터를 통해 한줄씩 실행시키다가 특정 바이트 코드가 자주 실행되면 해당 바이트 코드를 JIT Compiler를 통해 실행시킨다.
3. JVM 메모리 구조 (Stack, Heap)
- Stack
스택은 일종의 자료구조이다.
스택은 LIFO(Last in First Out) 즉, 마지막에 들어간 데이터가 마지막에 가장 먼저 나온다.
메서드가 호출되면 그 메서드를 위한 공간인 Method Frame이 생성되는데 메서드 내부에서 사용하는 다양한 값들이 임시로 저장된다.
이런 Method Frame이 Stack에 호출되는 순서대로 쌓이게 되는데 Method의 동작이 완료되면 역순으로 제거된다.
- Heap
JVM에는 단 하나의 Heap 영역이 존재하고 JVM이 작동되면 이 영역은 자동으로 생성된다.
Heap 영역에는 객체나 인스턴스 변수, 배열이 저장된다.
Person person = new Person();
new Person()이 실행되면 Heap 영역에 인스턴스가 생성되며, 인스턴스가 생성된 위치의 주소값을 person에게 할당해준다.
이 때, person이 Stack 영역에 선언된 변수이다.
4. Garbage Collection
자바에는 가비지 컬렉션이라는 메모리를 자동으로 관리하는 프로세스가 포함되어 있다.
가비지 컬렉션은 프로그램에서 더 이상 사용하지 않는 객체를 찾아 삭제하거나 제거하여 메모리를 확보하는 것을 의미한다.
Person person = new Person();
person.setName("김코딩");
person = null;
// 가비지 발생
person = new Person();
person.setName("박해커");
person에 null이 할당되면서 person이 가리키던 인스턴스와 참조변수 person 간의 연결이 끊어졌다.
이처럼 아무도 인스턴스를 참조하고 있지 않다면 더 이상 메모리에 person이 가리키던 인스턴스가 존재해야 할 이유가 없다.
이런 경우 가비지 컬렉터는 메모리에서 점유를 해제하며 메모리 공간을 확보한다.
Garbage Collection의 동작 방식
객체가 얼마나 살아있냐에 따라 Heap 영역 안에서 Young, Old 영역으로 나뉜다.
Young 영역에서는 새롭게 생성된 객체가 할당되는 곳이고 많은 객체가 생성되고 사라짐을 반복한다.
이 영역에서 활동하는 가비지 컬렉터를 Minor GC라고 부른다.
Old 영역에서는 Young 영역에서 상태를 유지하고 살아남은 객체들이 복사되는 곳으로 보통 Young 영역보다 크게 할당되고 크기가 큰 만큼 가비지는 적게 발생한다.
이 영역에서 활동하는 가비지 컬렉터를 Major GC라고 부른다.
Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어 있기 때문에 세부적인 동작 방식은 다르지만 기본적으로 아래 2가지 단게를 따른다.
- Stop The World 가비지 컬렉션을 실행시키기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. 가비지 컬렉션이 실행될 때 가비지 컬렉션을 실행하는 스레드를 제외한 모든 스레드들의 작업은 중단되고, 가비지 정리가 완료되면 재개된다.
- Mark and Sweep Mark는 사용되는 메모리와 사용하지 않는 메모리를 식별하는 작업을 의미하며, Sweep은 Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업을 의미한다.
정리하자면 Stop The World를 통해 모든 작업이 중단되면 사용되지 않는 메모리를 식별해서(Mark) 제거(Sweep)하는 과정을 진행하는 것이다.
'CodeStates_BE_44 > TIL' 카테고리의 다른 글
[네트워크] HTTP (0) | 2023.03.27 |
---|---|
[네트워크] 웹 애플리케이션 작동 원리 (0) | 2023.03.24 |
Day 17. [Java] 파일 입출력 (0) | 2023.03.09 |
Day 16. [Java] 애너테이션, 람다, 스트림 (0) | 2023.03.08 |
Day 15. [Java] 컬렉션 (1) | 2023.03.07 |