랩터
[Java] 스레드 본문
어떤 애플리케이션이 실행되면 운영체제가 해당 애플리케이션에 메모리를 할당해 주며 애플리케이션이 실행되는데, 이처럼 실행 중인 애플리케이션을 프로세스라고 합니다. 그리고, 프로세스 내에서 실행되는 소스 코드의 실행 흐름을 스레드라고 합니다.
단 하나의 스레드를 가지는 프로세스를 싱글 스레드 프로세스, 여러 개의 스레드를 가지는 프로세스를 멀티 스레드 프로세스라고 합니다. 어떤 프로세스가 멀티 스레드로 동작한다는 것은 해당 애플리케이션이 동시 작업을 할 수 있다는 것을 의미합니다. 즉, 여러 코드를 각 스레드에 분배하여 동시에 실행시킬 수 있는 것이죠.
지금까지 우리가 봐왔던 코드들은 싱글 스레드코드입니다. 이번에는 멀티스레드를 활용할 수 있게 스레드를 생성하고 실행하는 배워보겠습니다.
학습 목표
- 스레드가 무엇인지 설명할 수 있다.
- 싱글 스레드와 멀티 스레드의 차이를 설명할 수 있다.
- 스레드를 생성하는 두 가지 방법을 활용할 수 있다.
- 스레드를 실행시킬 수 있다.
- 스레드를 동기화할 수 있다.
- 스레드의 상태를 이해하고, 제어할 수 있다.
스레드란?
프로세스(Process)와 스레드(Thread)
프로세스는 실행 중인 애플리케이션을 의미합니다. 프로세스는 데이터, 컴퓨터 자원, 스레드로 구성되는데, 스레드는 데이터와 애플리케이션이 확보한 자원을 활용하여 소스 코드를 실행합니다. 즉, 스레드는 하나의 코드 실행 흐름이라고 볼 수 있습니다.
메인 스레드(Main thread)
자바 애플리케이션을 실행하면 가장 먼저 실행되는 메서드는 main 메서드이며, 메인 스레드가 main 메서드를 실행시켜 줍니다. 메인 스레드는 메인메서드의 코드를 처음부터 끝까지 차례대로 실행시키며, 코드의 끝을 만나거나 return문을 만나면 실행을 종료합니다. 메인 스레드에서 또 다른 스레드를 생성하여 실행시킨다면 해당 애플리케이션은 멀티 스레드로 동작하게 됩니다.
멀티 스레드(Multi-Thread)
하나의 프로세스는 여러개의 스레드를 가질 수 있으며, 이를 멀티 스레드 프로세스라 합니다. 여러 개의 스레드가 동시에 작업을 수행할 수 있음을 의미하며, 이를 멀티 스레딩이라고 합니다.
멀티 스레딩은 하나의 애플리케이션 내에서 여러 작업을 동시에 수행하는 멀티 태스킹을 구현하는 데에 핵심적인 역할을 수행합니다.
스레드의 생성과 실행
메인 스레드 외의 별도의 작업 스레드를 생성하고 실행하는 방법을 배워봅시다.
작업 스레드 생성과 실행
메인 스레드 외에 별도의 작업 스레드를 활용한다는 것은, 다시 말해 작업 스레드가 수행할 코드를 작성하고, 작업 스레드를 생성하여 실행시키는 것을 의미합니다.
자바코드는 클래스 안에 작성됩니다. 스레드가 수행할 코드도 클래스 내부에 작성해줘야 하며, run()이라는 메서드 내에 스레드가 처리할 작업을 작성해야합니다.
run()메서드는 Runnable인터페이스와 Thread클래스에 정의되어 있습니다. 작업 스레드를 생성하고 실행하는 방법은 다음 두가지입니다.
- 첫 번째 방법
- Runnable인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
- 두 번째 방법
- Thread클래스를 상속받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
1. Runnable 인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
먼저 Runnable에 대해 알아보겠습니다.
Runnable인터페이스는 자바에서 멀티스레딩을 구현할 때 사용하는 인터페이스입니다.
Runnable 인터페이스의 개요
Runnable 인터페이스는 단 하나의 run메드를 가지고 있습니다. 이 메서드는 스레드가 실행될 때 작업을 정의하는 곳입니다.
public interface Runnable {
void run();
}
Runnable 인터페이스 사용 방법
- Runnable 인터페이스 구현: 먼저 Runnalbe인터페이스를 구현하는 클래스를 만듭니다. 여기서 run()메서드를 오버라이드하여 스레드가 실행할 작업을 정의합니다.
public class MyRunnable implements Runnable {
@Override
public void run() {
// 여기에서 스레드가 수행할 작업을 작성합니다.
System.out.println("스레드가 실행 중입니다!");
}
}
2. 스레드 생성 및 실행: Runnable 인터페이스를 구현한 객체를 생성하고, 이를 Thread 클래스의 생성자에 전달합니다. 그 다음, start() 메서드를 호출하여 스레드를 실행합니다.
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}
}
위 코드에서는 MyRunnable 객체가 생성되고, 이 객체를 기반으로 스레드가 만들어집니다. thread.start()를 호출하면 스레드가 실행되며, run() 메서드의 내용이 출력됩니다.
왜 Runnable을 사용하는가?
Runnable 인터페이스를 사용하는 주요 이유는 자바에서 멀티스레딩을 구현할 때 좀 더 유연하게 작업을 분리할 수 있기 때문입니다.
- 자바는 다중 상속을 지원하지 않기 때문에: 클래스가 이미 다른 클래스를 상속받고 있다면, Thread 클래스를 상속받을 수 없습니다. 하지만 Runnable 인터페이스를 구현하면 여러 클래스에서 멀티스레딩을 쉽게 추가할 수 있습니다.
- 작업과 스레드의 분리: Runnable을 사용하면 작업과 스레드의 관리를 분리할 수 있습니다. 이는 코드의 재사용성을 높이고, 유지보수를 쉽게 만듭니다.
Runnable예제 코드
package Thread;
public class ThreadExample1 {
public static void main(String[] args) {
// Runnable인터페이스를 구현한 객체 생성
Runnable task1 = new ThreadTask1();
// Runnable구현 객체를 인자로 전달하면서 Thread 클래스를 인스턴스화 하여 스레드를 생성
Thread thread1 = new Thread(task1);
// 위의 두줄을 아래와 같이 한줄로 축약 가능
// Thread thread1 = new Thread(new ThreadTask1());
//작업 스레드를 실행시켜, run() 내부의 코드를 처리하도록 합니다.
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("#");
}
}
}
출력 결과는 실행 시 마다 다를 수 있습니다,.
@@@@@@@########@@@@@@@@@@@@@@@@@#@@@@@@@@@@###########@@@@@@@@@@@@##########@@@@@@@###########@@@@@@@@@########@@@@@@@#####@@@@@@@@@#############################@@@@@@@###########@@@@@@@@######@@@@@@@
@는 메인스레드의 main메서드 반복문 코드 실행에 의해 출력되었습니다.
#는 작업 스레드의 run()메서드 반복문 코드 실행에 의해 출력되었습니다.
@와 #는 섞여 있습니다. 즉, 메인 스레드와 작업 스레드가 동시에 병렬로 실행되면서 각각 main 메서드와 run() 메서드의 코드를 실행시켰기 때문에 두 가지 문자가 섞여서 출력된 것입니다.
Runnable 인터페이스는 자바에서 멀티스레딩을 구현할 때 매우 유용한 도구입니다. 이를 통해 스레드가 실행할 작업을 정의하고, 작업과 스레드를 분리하여 좀 더 유연하고 재사용 가능한 코드를 작성할 수 있습니다. 이렇게 구현된 코드는 다른 클래스와의 호환성을 높이며, 멀티스레딩의 장점을 극대화할 수 있습니다.
2. Thread 클래스를 상속받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
위에서 작성한 것과 같이 Thread 클래스를 상속받는 하위 클래스를 만들어줍니다. Thread 클래스에는 run() 메서드가 정의되어 있으며, 따라서 run() 메서드를 오버라이딩해주어야 합니다.
package Thread;
public class ThreadExample2 {
public static void main(String[] args) {
//Thread 클래스를 상속받은 클래스를 인스턴스화하여 스레드를 생성
ThreadTask2 thread2 = new ThreadTask2();
//작업 스레드를 실행시켜, run()내부의 코드를 처리하도록 합니다
thread2.start();
//반복문 추가
for (int i=0;i<100; i++){
System.out.print("@");
}
}
}
// Thread클래스를 상속받는 클래스 작성
class ThreadTask2 extends Thread{
//run() 메서드 바디에 스레드가 수행할 작업 내용 작성
public void run(){
for (int i=0; i<100; i++){
System.out.print("#");
}
}
}
두 번째 방법을 사용한 코드를 실행해 보면 첫 번째 방법을 사용한 것과 유사한 결과를 얻을 수 있습니다. 두가지 방법 모두 작업 스레드를 만들고,run()메서드에 작성된 코드를 처리하는 동일한 내부 동작을 수행합니다.
익명 객체를 사용하여 스레드 생성하고 실행하기
꼭 클래스를 따로 정의하지 않고도 익명 객체를 활용하여 스레드를 생성하고 실행시킬 수 있습니다.
Runnable 익명 구현 객체를 활용한 스레드 생성 및 실행
package Thread;
public class ThreadExample1 {
public static void main(String[] args) {
//익명 Runnable 구현 객체를 활용하여 스레드 생성
Thread thread1 = new Thread(new Runnable() {
@Override
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("@");
}
}
}
Thread 익명 하위 객체를 활용한 스레드 생성 및 실행
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("@");
}
}
}
스레드의 이름
메인스레드는 main이라는 이름을 가지며 그 외에 추가로 생성한 스레드는 Thread-n이라는 이름을 가집니다.
스레드의 이름 조회하기
스레드의 이름은 스레드의 참조값.getName()으로 조회할수 있습니다.
package Thread;
public class ThreadExample3 {
public static void main(String[] args) {
Thread thread3 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Get Thread Name");
}
});
thread3.start();
System.out.println("thread3.getName() = " + thread3.getName());
}
}
//출력값
"C:\Program Files\Zulu\zulu-21\bin\java.exe" --enable-preview "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\lib\idea_rt.jar=65073:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\bin" -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -classpath C:\lifesheep_kdt\java\Collection\out\production\Collection Thread.ThreadExample3
Get Thread Name
thread3.getName() = Thread-0
Process finished with exit code 0
스레드의 이름 설정하기
스레드의 이름은 스레드의 참조값.setName()으로 설정할 수 있습니다.
package Thread;
public class ThreadExample4 {
public static void main(String[] args) {
Thread thread4 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Set And Get Thread Name");
}
});
thread4.start();
System.out.println("thread4.getName() = " + thread4.getName());
thread4.setName("Code");
System.out.println("thread4.getName() = " + thread4.getName());
}
}
//출력값
"C:\Program Files\Zulu\zulu-21\bin\java.exe" --enable-preview "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\lib\idea_rt.jar=65105:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\bin" -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -classpath C:\lifesheep_kdt\java\Collection\out\production\Collection Thread.ThreadExample4
Set And Get Thread Name
thread4.getName() = Thread-0
thread4.getName() = Code
Process finished with exit code 0
스레드 인스턴스의 주소값 얻기
실행중인 스레드의 주소값을 사용하는 상황이 발생한다면 Thread클래스의 정적메서드인 currentThread()를 사용하면 됩니다.
package Thread;
public class ThreadExample1 {
public static void main(String[] args) {
//익명 Runnable 구현 객체를 활용하여 스레드 생성
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
thread1.start();
System.out.println(Thread.currentThread().getName());
}
}
//출력값
"C:\Program Files\Zulu\zulu-21\bin\java.exe" --enable-preview "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\lib\idea_rt.jar=65131:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2023.3.6\bin" -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -classpath C:\lifesheep_kdt\java\Collection\out\production\Collection Thread.ThreadExample1
main
Thread-0
Process finished with exit code 0
스레드 동기화란?
멀티 스레드 프로세스는 두 스레드가 같은 데이터를 공유하게 되어 문제가 발생할 수 있습니다.
package Thread;
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 300₩ By 김코딩. Balance : 300
Withdraw 300₩ By 박자바. Balance : 0
위 예제 코드는 두 개의 작업 스레드를 생성하여 1,000원의 잔액을 가진 계좌로부터 100~300원을 인출하고, 인출금과 잔액을 출력하는 예제입니다.
위 코드를 실행하면 두 개의 작업 스레드가 생성되며, 이 작업 스레드는 Account 객체를 공유하게 됩니다.
해당 코드는 잔액이 제대로 출력되지 못하고 있고, 음수의 잔액도 발생합니다.
이러한 상황이 발생하지 않게 하는것을 스레드 동기화라고 합니다.
스레드 동기화 적용하여 위의 코드 예제를 개선하기에 앞서, 먼저 임계영역과 락(Lock)에 대한 이해가 필요합니다.
임계 영역(Critical section)과 락(Lock)
임계 영역은 오로지 하나의 스레드만 코드를 실행할 수 있는 코드 영역을 의미하며, 락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미합니다.
임계 영역으로 설정된 객체가 다른 스레드에 의해 작업이 이루어지고 있지 않을 때, 임의의 스레드 A는 해당 객체에 대한 락을 획득하여 임계 영역 내의 코드를 실행할 수 있습니다. withdraw()메서드를 통해 두 스레드가 동시에 실행하지 못하게 할수 있습니다. 특정코드 구간을 임계영역으로 설정할때는 synchronized라는 키워드를 사용합니다.
1. 메서드 전체를 임계 영역으로 지정하기
아래와 같이 메서드의 반환 타입 좌측에 synchronized 키워드를 작성하면 메서드 전체를 임계 영역으로 설정할 수 있습니다. 이렇게 메서드 전체를 임계 영역으로 지정하면 메서드가 호출되었을 때, 메서드를 실행할 스레드는 메서드가 포함된 객체의 락을 얻습니다.
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;
}
}
}
Thread Pool
스레드의 무분별한 증가를 방지하려면 스레드풀을 사용해야합니다. 스레드풀은 작업 처리에 사용되는 스레드의 수를 정해 놓습니다. 큐에 들어오는 작업이 들어오면 스레드풀 안에 스레드가 하나씩 맡아서 처리합니다. 작업 처리가 끝난 스레드는 작업 큐의 새로운 작업을 처리합니다.
스레드풀 생성
ExecutorService(스레드 풀) 구현객체는 Executors 클래스 메서드로 생성할 수 있습니다.
메서드 초기 스레드 수 코어 스레드 수 최대 스레드 수
newCachedThreadPool() | 0 | 0 | Integer.MAX_VALUE |
newFixedThreadPool(int num) | 0 | num | num |
스레드풀 종료
스레드풀은 main 스레드가 종료되어도 작업을 처리하기 위해 계속 실행 상태로 남아있습니다. 애플리케이션을 종료하기 위해서는 스레드풀을 종료해야 합니다.
메서드 리턴타입 설명
shutdown() | void | 작업 큐에 남아있는 모든 작업을 처리한 뒤 종료 |
shutdownNow() | List | 작업 큐에 남아있는 작업과 상관없이 종료, 처리 못한 작업(Runnable) 목록을 리턴 |
awaitTermination(long timeout, TimeUnit unit) | boolean | shotdown() 메소드 호출 후, 모든 작업 처리를 timeout 시간안에 처리하면 true, 처리 못하면 작업 스레드들을 interrupt()하고 false 리턴 |
'공부 > JAVA' 카테고리의 다른 글
[Java] 스트림 연습문제 (0) | 2024.07.10 |
---|---|
[Java] 자바 가상 머신(Java Virtual Machine) (0) | 2024.07.10 |
[Java] 파일 입출력 (0) | 2024.07.09 |
[Java] 스트림 - Optional Class (0) | 2024.07.09 |
[Java] 스트림 - 스트림의 최종 연산 (0) | 2024.07.08 |