고래씌

[JAVA] 15. 스레드(Thread) 본문

JAVA/JAVA 기초

[JAVA] 15. 스레드(Thread)

고래씌 2023. 10. 27. 12:29

1. 스레드(Thread)

▶ 프로그램

- 어떤 작업(프로세스)을 실행할 수 있는 파일(cpu로부터 메모리를 할당받지 못한 상태)

 

▶ 프로세스(Process)

- 간단한 의미로 실행 중인 프로그램.

- 프로세스는 프로그램이 실행될 때마다 개별적으로 생성.

- 하나의 프로세스는 프로그램을 수행함에 있어 필요한 데이터와 메모리 등의 할당 받은 자원, 그리고 하나 이상의 스레드로 구성됨.

- 현재 동작하고 있는 프로그램(작업을 위해 cpu로부터 메모리를 할당 받음)

 

 

▶ 스레드(Thread)

- 프로세스 내에서 할당된 자원을 이용해서 실제 작업을 수행하는 작업단위. 모든 프로세스는 하나 이상의 스레드를 가지며 각각 독립적인 작업단위를 가짐

- 한 개의 프로세스 내에서 실제로 주어진 자원과 메모리로 작업을 수행하는 최소단위

- 모든 프로세스에는 한개 이상의 스레드가 존재하여 작업을 수행

 

ex)

- 바닥청소(나, 1시간) + 빨래(세탁기, 2시간) + 설거지(나, 30분) + 화장실 청소(나, 30분)

- 2시간 걸림 → 무조건 있어야하는 중심이 되는 스레드(나) => 메인스레드

- 메인스레드가 실행하는 서브스레드(세탁기)

- 추가로 필요한 스레드 = 서브 스레드 = main에서 생성해서 실행한다.

 

- 하나의 프로세스를 안에서 다양한 작업을 담당하는 최소 실행 단위를 스레드라고 한다.
ex) 크롬 브라우저(=프로세스)에서 벨로그 작성하기(=스레드1) & 유튜브로 음악 듣기(=스레드2)

 

▶멀티스레드

- 한개의 프로그램을 실행하고 그 내부적으로 여러 작업을 처리하는 것

- 멀티 스레드는 CPU의 최대 활용을 위해 프로그램의 둘 이상을 동시에 실행하는 기술

- 멀티 스레드의 경우, 스레드 간의 자원을 공유하고 자원의 생성과 관리의 중복성을 최소화하여 수행 능력을 향상시킨다.

- 멀티스레드의 장점

① 자원을 효율적으로 사용

② 사용자 입장에서 일처리가 빠르게 보인다.

③ 작업이 분리되어서 코드가 간결해짐

 

 

=> 모든 자바어플리케이션 Main Thread가 Main 메소드를 실행하면서 시작됨.

 

 

 


2. 스레드 생성방법

1) 스레드 생성방법 ① - Thread 클래스를 상속받는 방법


- Thread클래스 상속 후 run()메소드 오버라이딩

- main에 Thread1객체 생성후 start()메소드 호출
 start() 호출시 해당 스레드 내부의 run메소드가 실행이 됨(Thread1 클래스 내부의 run메소드)
 즉, 스레드를 새롭게 가동시키려면 start()를 실행해야하고, 해당 스레드의 작업방식은  run 메소드 내부의 기술해야함

 

- getName() : 스레드의 이름 반환(스레드 이름 미지정시 Thread-0, Thread-1...순으로 이름 자동부여)

 

 

■ Thread 클래스를 상속받은 Thread1 클래스

package com.kh.chap01_thread.thread;


public class Thread1 extends Thread{
	
	@Override
	public void run() { // 실행하고자 하는 코드를 작성
		
		for(int i = 1; i<=100; i++) {
			System.out.println(getName()+"["+i+"]");
			// getName() : 스레드의 이름 반환(스레드 이름 미지정시 Thread-0, Thread-1...순으로 이름 자동부여
		}
	}
}

 

 

■ main 메소드

package com.kh.chap01_thread.run;

import com.kh.chap01_thread.Thread2;
import com.kh.chap01_thread.thread.Thread1;

public class ThreadRun {

	public static void main(String[] args) {
		
		// 항상 앞 라인의 명령문이 다 끝난 후에 코드가 실행됨
		for(int i = 1; i<= 100; i++) {
			System.out.println("작업 1 ["+i+"]");
		}
		for(int i = 1; i<= 100; i++) {
			System.out.println("작업 2 ["+i+"]");
		}
		
        
		// 작업 2를 수행하게 하기 위해서 새로운 스레드를 생성
		// 스레드 생성방법 1 : Thread클래스 상속받기

		Thread th1 = new Thread1();
		
		// 스레드의 이름지정
		th1.setName("스레드1");
		
		// 스레드 시작시 start를 써야함
		th1.start();
		
	}

}

...

...

=> 순차적으로 스레드가 동작하는 것을 확인

 

 

 

 

2) 스레드 생성방법 ② - Runnable인터페이스를 구현하는 방법

 

▶ Runnable 인터페이스를 구현
- Runnable 인터페이스의 run()메소드 오버라이딩

 

 

■ Runnable 인터페이스를 구현한 Thread2 클래스

package com.kh.chap01_thread;

public class Thread2 implements Runnable{
	
	@Override
	public void run() {
		for(int i=1; i<= 100; i++) {
			System.out.println(Thread.currentThread().getName()+"["+i+"]");

		}
	}
}

☞ currentThread()  : 현재 실행중인 스레드 객체를 가져옴

 

 

main 메소드 (Thread 생성방법 ①번)

package com.kh.chap01_thread.run;

import com.kh.chap01_thread.Thread2;
import com.kh.chap01_thread.thread.Thread1;

public class ThreadRun {

	public static void main(String[] args) {
		
		for(int i = 1; i<= 100; i++) {
			System.out.println("작업 1 ["+i+"]");
		}
		for(int i = 1; i<= 100; i++) {
			System.out.println("작업 2 ["+i+"]");
		}
		

		Thread th1 = new Thread1();
		
		// 스레드의 이름지정
		th1.setName("스레드1");
		
		// 스레드 시작시 start를 써야함
		th1.start();

 

■ main 메소드 (Thread 생성방법 ②번)

		// 스레드 생성방법 2: Runnable인터페이스를 구현하는 방법
		Thread th2 = new Thread(new Thread2(),"스레드2");
		// Runnable 인터페이스를 구현한 구현클래스를 업캐스팅원리에 의해서 넣어줄수 있음
		
		th2.setName("스레드2");
		
		th2.start();

	}

}

...

 

 

☞ Thread th2 = new Thread(new Thread2(),"스레드2");

Runnable 인터페이스를 구현한 구현클래스를 업캐스팅원리에 의해서 넣어줄수 있음

☞ 스레드 자체가 여러개 만들어졌기 때문에 1번 스레드 실행하고 2번 스레드 실행하고...섞이게 된 것임

 

 

 

 

3) 스레드 생성방법 ③ - 익명 인터페이스 (1회용 스레드가 필요할 때)

 

■  main 메소드 (Thread 생성방법 ①, ②번)

package com.kh.chap01_thread.run;

import com.kh.chap01_thread.Thread2;
import com.kh.chap01_thread.thread.Thread1;

public class ThreadRun {

	public static void main(String[] args) {



		// 스레드 생성방법 1 : Thread클래스 상속받기

		Thread th1 = new Thread1();
		

		th1.setName("스레드1");
		

		th1.start();
		
		
		// 스레드 생성방법 2: Runnable인터페이스를 구현하는 방법
		Thread th2 = new Thread(new Thread2(),"스레드2");

		
		th2.setName("스레드2");
		
		th2.start();  // 스레드 자체가 여러개 만들어졌기 때문에 1번 스레드 실행하고 2번 스레드 실행하고...섞이게 된 것임

 

 

■ main 메소드 (Thread 생성방법 ③번)

		Thread th3 = new Thread() {
			@Override
			public void run() {
				for(int i=1; i<=100; i++) {
					System.out.println(getName()+"["+i+"]");
				}
			}
		
		};
		
		th3.start();
		
		System.out.println("메인스레드 종료");
	}
}

...

 

☞ System.out.println("메인스레드 종료");

맨아래에 없음. 스레드 가동시키자마자 꺼진것을 볼수 있음

=> 서브스레드가 존재하면 메인스레드가 종료돼도 프로그램은 종료되지 않음

 

 

 


 

▶ 스레드의 특징


1) 메인 스레드가 종료되더라도 실행 중인 스레드가 하나라도 있다면 프로세스는 종료되지 않는다.
     메인스레드 종료시 서브스레드도 종료시키게하려면, 메인과 서브스레드 간에 주종관계를 설정하면 됨


2) 매번 결과가 다르게 보임.
    각 스레드의 실행시점은 스케줄러라는 것이 따로 결정해주기 때문에


3) 멀티스레드 환경에서는 모든 작업이 동시에 일어나는 것처럼 보이지만 사실 "한순간에 하나의 스레드만 실행"되고 있음
(a스레드 실행 → a스레드 멈춤 → b스레드 실행 → b스레드 멈춤 → c스레드 실행 → c스레드 멈춤 ...)
이 과정들이 순식간에 발생하기 때문에 우리 눈에는 동시에 스레드가 작동하고 있는 것처럼 보임

 

Q. 왜 스레드는 이런방식으로 작업을 처리하는가?
    ① 하드웨어적인 한계(옛날 문제)
    ② 논리적 효율(요즘에는 이것때문에 사용)
     => 만약 용량이 엄청나게 큰 파일을 다운로드 받는다고 할 때 파일을 다운로드 받는동안 아무작업도 못한다면?
     => 그것보다는 기타 다른 작업도 실행하면서 동시에 다운로드도 받게해주는게 작업효율이 좋을 것이기 때문에 더 빠른 반응성을 가져다줌

 


▶ 컴퓨터의 특징


 1) 프로그램을 실행시키기 위해서는 프로그램을 실행시킬수 있는 자원들을 CPU로부터 할당받아야 함

☞ 따라서 스레드는 프로그램을 실행하기 위해 CPU자원을 할당받았다가, 이 자원을 스케줄러에게 강탈당함.

그렇기 때문에 명령어들이 연속적으로 수행되지 못하고, 어느부분까지 명령을 수행했는지 스레드별로  개별적으로 기억할 필요가 생김

이 명령어들을 기억하는 저장공간이 Registrer이고 스레드별로 개별적인 Register를 가지고 있음

개별적인 실행흐름을 보장하기 위해 스렐드는 메모리 영역 중 statck을 각자 할당 받음

 

 


3. 스레드 스케줄링

 

1) 우선순위 변경 전


- 각 스레드는 기본값으로 5의 우선순위를 가지고 있다.
- 메모리가 모자랄때 우선순위를 파악해서 어떤 작업을 먼저 실행시킬지 정할 수 있다.
- 스레드의 우선순위를 정하지 않으면 스케줄러가 임의로 수행시킨다.

- 스레드는 CPU를 할당받았다가 스케줄러에 의해 다시 선점당한다.
- 그렇기 때문에 명령어의 실행이 연속적이지 못하고 어느부분까지 수행했는지 기억할 필요가 있다.

 

 

■ Runnable 인터페이스를 구현한 Car 클래스

package com.kh.chap02_scheduling.scheduling;

public class Car implements Runnable{
	
	@Override
	public void run() {
		System.out.println(Thread.currentThread().getName());
		
		for(int i=0; i<20; i++) {
			System.out.println("Car is driving");
			
			try {
				Thread.sleep(100);   // 스레드를 0.1초만큼 지연시키는 메소드
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

 

 

Runnable 인터페이스를 구현한 Tank 클래스

package com.kh.chap02_scheduling.scheduling;

public class Tank implements Runnable{
	
	@Override
	public void run() {
		System.out.println(Thread.currentThread().getName());
		
		for(int i=0; i<20; i++) {
			System.out.println("Tank is shooting...");
			
			try {
				Thread.sleep(100);   // 스레드를 0.1초만큼 지연시키는 메소드
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			
		}
	}

}

 

 

 

 Runnable 인터페이스를 구현한 Plane 클래스

package com.kh.chap02_scheduling.scheduling;

public class Plane implements Runnable{
	
	@Override
	public void run() {
		System.out.println(Thread.currentThread().getName());
		
		for(int i=0; i<20; i++) {
			System.out.println("Plane flight...");
			
			try {
				Thread.sleep(100);   // 스레드를 0.1초만큼 지연시키는 메소드
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			
		}
	}

}

 

 

■ main 메소드

package com.kh.chap02_scheduling.run;

import com.kh.chap02_scheduling.scheduling.*;

public class ScheduleRun {

	public static void main(String[] args) {
		
        Thread t1 = new Thread(new Car(), "Car");
        Thread t2 = new Thread(new Plane(), "Plane");
        Thread t3 = new Thread(new Tank(), "Tank");


        System.out.println("----- 우선순위 변경 전 -----");
        System.out.println(Thread.currentThread().getName()+"의 우선순위 : " + Thread.currentThread().getPriority());
        System.out.println(t1.getName()+"의 우선순위 "+ t1.getPriority());
        System.out.println(t2.getName()+"의 우선순위 "+ t2.getPriority());
        System.out.println(t3.getName()+"의 우선순위 "+ t3.getPriority());

        t1.start();
        t2.start();
        t3.start();


    }

}

...

 

☞ 무작위로 실행되는 것을 확인

 

 

 

 

2) 우선순위 변경 후

- setPrioity() : 우선순위를 지정해주는 메소드

- Thread.MAX_PRIORITY  => 10

- Thread.MIN_PRIORITY => 1

//		setPriority() : 우선순위를 지정해주는 메소드
		t1.setPriority(Thread.MAX_PRIORITY); // 10
		t3.setPriority(Thread.MIN_PRIORITY); // 1
		
		System.out.println("----- 우선순위 변경 후 -----");
		System.out.println(Thread.currentThread().getName()+"의 우선순위 : " + Thread.currentThread().getPriority());
		System.out.println(t1.getName()+"의 우선순위 "+ t1.getPriority());
		System.out.println(t2.getName()+"의 우선순위 "+ t2.getPriority());
		System.out.println(t3.getName()+"의 우선순위 "+ t3.getPriority());


		t1.start();
		t2.start();
		t3.start();
		
		System.out.println("메인 스레드 종료");

	}

}

...

...

 

☞ 변경 결과 Car 메소드가 항상 먼저 시작하고 끝나고, 그다음 Plane이 시작하고 끝나는 것을 볼수있지만, 
☞ 가끔 순서가 바뀔때가 있음 → 우리가 스케줄러의 순서까지 건들 수 없으므로 어쩔 수 없다.

 

 

 

3) 주종관계 설정

 

메인 스레드가 종료되더라도 실행 중인 스레드가 하나라도 있으면 프로세스는 종료되지 않음.
이에 대한 해결책으로 스레드를 생성(start()) 하기전에 스레드간의 주종관계를 설정하면 된다.

 ▶ 주종관계 설정
 main 스레드가 종료시 다른 스레드도 종료시키기 위해서는 setDaemon함수를 사용.
 주의점으로는 스레드 중 한개라도 setDaemon을 설정했다면 모든 스레드에 setDaemon을 해주어야 정상작동한다.

 

    	t1.setDaemon(true);
        t2.setDaemon(true);
        t3.setDaemon(true);  
        // 3개 다 setDaemon을 설정해주어야 main 메소드가 종료되는 시점에 다른 스레드도 함께 종료된 것을 볼 수 있음

        t1.start();
        t2.start();
        t3.start();

        System.out.println("메인 스레드 종료");

 

☞ main 메소드가 종료되는 시점에 다른 스레드도 함께 종료된 것을 볼 수 있음

 

 

 

하지만!!!!!

멀티스레드를 잘 사용하면 프로그램적으로 좋은 성능을 낼 수 있지만,

멀티스레드 환경에서 반드시 고려해야할 점인 스레드간 동기화라는 문제는 꼭 해결해야한다.

 


 

4. Synchronized

- 예를 들어 스레드간 서로 공유하고 수정할 수 있는 data가 있는데 스레드간 동기화가 되지 않은 상태에서 

멀티스레드 프로그램을 돌리면, data의 안정성과 신뢰성을 보장할 수 없다.

 

- 따라서 data thread-safe  하기 위해 자바에서는 synchronized 키워드를 제공해 스레드간 동기화를 시켜 data thread-safe 가능하게 함. 

 

 

▶ Synchronized란?

: 한번에 한개의 스레드만 프로세스 공유 자원(객체)에 접근할 수 있도록 락(Lock)을 걸어 다른 스레드가 진행 중인 작업에 간섭하지 못하도록 하는 것

 

- 되도록 안쓰는게 좋지만, atm같은 중요한 것은 반드시 동기화하는 작업처리를 해야함!
- 멀티스레드 프로그램에서 스레드간의 공유자원에 대한 처리를 말함
- 공유자원의 사용순서를 정하여, 한번에 한개의 스레드만 공유자원에 접근하여 사용할 수 있도록 제어

 

 

 

 

▶ 스레드의 문제점

- Heap 메모리 안에서 공유하면서 쓸 때 문제점이 있음. 만약 읽기만 할거라면 아무 문제가 없다. 하지만.....

- Heap 내부에 있는 자원에 int count 에 값을 추가하려고 한다? => 문제 발생

이 문제를 해결하기 위해서 "synchronized" 를 사용!

 

 


 

1) 동기화 처리 전

 

■ ATM 클래스

ATM에서 100~500 랜덤 함수를 돌려 Account 클래스에 있는 withdraw 메소드를 실행시켜 돈을 빼는 코드

package com.kh.chap03_sync.sync;

public class Atm implements Runnable{
	
	private Account acc;
	
	public Atm() {
		
	}
	
	public Atm(Account acc) {
		this.acc = acc;
	}
	
	@Override
	public void run() {
		
		while(acc.getBalance() > 0) {
			
			// 100, 200, 300, 400, 500
			int money = (int)(Math.random() * 5 + 1) * 100;
			acc.withdraw(money);
			
			try {
				Thread.sleep(500); // 0.5 초단위로 실행
			} catch (InterruptedException e) {
				e.printStackTrace();
			} 
		}
		
		System.out.println(Thread.currentThread().getName()+"종료");
	}
}

 

 

■ Account 클래스

통장에 10000원 저장해놓고 현재의 스레드를 가져옴

withdraw 메소드에는 출금 및 출금시도를 출력하는 메소드

package com.kh.chap03_sync.sync;


// 계좌 
// 2개의 ATM기에 동시 접속할 예정
public class Account {
	
	private int balance = 10000;
	
	public int getBalance() {
		return balance;
	}
	
    public void withdraw(int money) {

        String threadName = Thread.currentThread().getName();

        System.out.println("현재 계좌에 남아있는 잔액? " + balance);

        if(money <= balance) {
            balance -= money;
            System.out.printf("[%s] %d원 출금 >> 잔액 : %d원\n", threadName, money, balance);
        }else {
            System.out.printf("[%s] %d원 출금시도 >>> 현재 잔액 %d\n", threadName, money, balance);
        }

    }

}

 

■ main 메소드

package com.kh.chap03_sync.run;

import com.kh.chap03_sync.sync.Account;
import com.kh.chap03_sync.sync.Atm;

public class Run {

	public static void main(String[] args) {

		Account acc = new Account();
		
		Thread atm1 = new Thread(new Atm(acc), "atm1");
		Thread atm2 = new Thread(new Atm(acc), "atm2");
		
		atm1.start();
		atm2.start();
		
		
		System.out.println("메인 스레드 종료");
		
	}

}

...

  두 스레드가 시작하면 잔액이 0  때까지  스레드가 경쟁하며 출금시킬 것을 확인

여기서 멀티스레드의 문제점이 발견되는데, balance(잔액) thread-safe 되지 않았기 때문에 atm1 스레드가 잔액을 삭감하기 전에 atm2가 balance(잔액)에 접근해 삭감을 해버리고 다시 atm1 이 sleep()에서 깨어나 balance(잔액) 을 삭감해버리기 때문에 잔액이 맞지 않게 됨!!!

 

 

 

☞ 하나의 공유자원에 동시에 여러개의 스레드가 달라붙어서 특정자원에 접근하는 상황을 "경쟁상태"라고 부름.
이를 방지하기 위해 하나의 공유자원에 하나의 스레드만 접근가능하도록 통제를 해야하는데, 통제가 필요한 영역을 임계영역이라고 부름.

임계영역을 통제하기 위하여 synchronized 키워드를 추가한다.

 

 


 

2) 동기화 처리 후

 

▶ synchronized(격리공간)
- 동기화 메소드, 동기화 블럭에 사용되는 키워드
- 동기화 메소드는 메소드 선언에 synchronized 키워드를 붙이고, 인스턴스 및 정적메소드에서도 사용이 가능함
- 동기화 메소드는 스레드가 메소드를 실행하면 메소드 전체에 즉시 락을 걸고 메소드가 종료되면 락이 풀린다.
- 메소드 전체가 아니라 일부 내용에만 락을 걸고 싶다면 동기화 블럭을 이용하면 됨.
- 락이 걸려있다면 락은 해소할 수 있는 키를 가진 스레드만 접근이 가능해짐

 

 

■ synchronized 를 추가한 Account클래스의 withdraw메소드

package com.kh.chap03_sync.sync;


// 계좌 
// 2개의 ATM기에 동시 접속할 예정
public class Account {
	
	private int balance = 10000;
	
	public int getBalance() {
		return balance;
	}
	
    public synchronized void withdraw(int money) {



        String threadName = Thread.currentThread().getName();


        System.out.println("현재 계좌에 남아있는 잔액? " + balance);

        if(money <= balance) {
            balance -= money;
            System.out.printf("[%s] %d원 출금 >> 잔액 : %d원\n", threadName, money, balance);
        }else {
            System.out.printf("[%s] %d원 출금시도 >>> 현재 잔액 %d\n", threadName, money, balance);
        }

    }

}

☞ public synchronized void withdraw(int money) { }

메소드 전체에 synchronized 넣어 락을 걸고 이 락을 풀수있는 키를 가지고 있으면 접근이 가능해지지만 키를 가지고 있지 않다면 무시하고 넘어감

 

 

 

■ 메소드 전체에 동기화를 안걸고 일부 부분만 동기화를 하고싶다면

    public void withdraw(int money) {

		String threadName = Thread.currentThread().getName();
		
		synchronized(this) { // 일부 부분만 동기화를 하고 싶다면
			
			System.out.println("현재 계좌에 남아있는 잔액? " + balance);
			
			if(money <= balance) {
				balance -= money;
				System.out.printf("[%s] %d원 출금 >> 잔액 : %d원\n", threadName, money, balance);
			}else {
				System.out.printf("[%s] %d원 출금시도 >>> 현재 잔액 %d\n", threadName, money, balance);
			}
		}	
	}

...

☞ synchronized 키워드를 사용함으로써 balance 공유데이터에 대한 락을 시켰기 때문에 데이터나 메서드 점유하고 있는 스레드가 온전히 자신의 작업을 마칠 수 있는 것을 볼 수 있음