[Java] Thread - 1 개요

Hyeonsu Bang·2021년 12월 11일
0

Java Basic

목록 보기
8/11
post-thumbnail

1. 프로세스, 스레드 Process & Thread


  • Process:
    메모리를 할당 받아 애플리케이션을 수행하는 것, 또는 그 애플리케이션이다.

  • Thread:
    프로세스 내에서 한 가지 작업을 수행하기 위한 코드의 실행 흐름을 뜻한다. 코드들이 실처럼 이어져 있다고 해서 붙여진 이름이다.

프로세스 간의 작업은 독립적이다. 따라서 한 프로세스가 다른 프로세스의 작업에 영향을 주지 않는다. 간단한 예로 크롬과 사파리를 같이 켜놨을 때 크롬이 오류가 생겨 꺼지더라도 사파리는 문제없이 계속 작동한다. 반면 스레드는 한 프로세스 안에서 발생하기 때문에 하나의 스레드에서 오류가 발생하면 프로세스 자체가 종료될 수 있으므로 다른 스레드에도 영향을 미치게 된다. 크롬을 쓰는데 한 탭에서 에러가 발생하면 크롬 자체가 꺼지는 것과 같다.


메인 스레드


모든 자바 애플리케이션은 메인 스레드에서 main()을 실행하면서 시작된다. 기본적으로 main() 이 실행되면 순차적으로 코드를 수행하고, 코드 수행이 끝나면 실행이 종료된다.


멀티 스레드 작업 환경을 만들면 모든 스레드가 실행 완료될 때까지 프로세스가 종료되지 않는다. 아래는 멀티 스레드를 간략히 도식화한 것이다.





2. 작업 스레드 생성과 실행


멀티 스레드 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 작업별로 스레드를 생성해야 한다.

스레드를 생성하는 방법은 크게 두 가지이다.

  1. java.lang.Thread를 직접 생성
  2. Thread 를 상속하는 서브 클래스를 통해 생성

차례대로 알아보도록 하자.



스레드 생성: Thread 클래스로부터 직접 생성

Thread 클래스를 생성할 때 Runnable를 매개값으로 갖는 생성자를 호출하여 생성하는 방법이다.


Thread thread = new Thread(Runnable target);

Runnable 은 추상 메서드로 run()을 하나 가지고 있다. 따라서 Runnable을 구현하는 클래스에서 오버라이딩하여 스레드에서 작업할 코드를 써주면 된다.


public class Task implements Runnable{

	@Override
	public void run(){

		//..실행할 코드
	}
}


그리고 Runnable을 구현한 클래스를 Thread 생성자의 인자로 넣어주어서 객체를 생성하면된다.

Ruunable task = new Task();
Thread thread = new Thread(task);


익명 구현 객체를 작성하면 구현 클래스를 따로 작성할 필요 없이 코드를 좀 더 간결하게 짤 수 있다.

Thread thread = new Thread(Runnable() {

	public void run(){
    	// .. code
	}
    
}

작업 스레드는 생성되는 즉시 실행되는 것이 아니라 Threadstart() 를 호출해야 실행된다. 작성한Runnablerun()을 호출하면 될 것 같지만, 그러면 스레드가 실행되지 않는다. 두 메서드의 차이점은 아래에서 좀 더 자세히 다뤄본다.



0.5초 주기로 비프음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정하고 코드로 구현해보면 아래와 같다.

public class PrintExample {
    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        
        for(int i=0 ; i<5 ; i++){
            toolkit.beep();
            try{ Thread.sleep(500);} catch (Exception e){}
        }

        for(int i=0 ;i<5; i++){
            System.out.println("ding");
            try{ Thread.sleep(500);} catch (Exception e){}
        }
    }
}

위 코드에서는 메인 스레드에서만 작업이 실행되고 있다. 따라서 위 코드를 실행하면 0.5초마다 beep()이 5번 수행되고, 이어서 ding이 0.5초 간격으로 5번 출력되는 결과가 나올 것이다. 병렬처리를 위해 작업 스레드를 추가해서 처리해보자. 먼저 Runnable을 구현하고 있는 클래스를 하나 만든다.

public class BeepTask implements Runnable {

    @Override
    public void run() {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for(int i=0 ; i<5 ; i++){
            toolkit.beep();
            System.out.println("beep");
            try{ Thread.sleep(500);} catch (Exception e){}
        }
    }
    
}
public class PrintExample2 {

    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        Thread beepThread = new Thread(beepTask);
        beepThread.start(); // 작업 스레드 시작

        for(int i=0 ;i<5; i++){
            System.out.println("ding");
            try{ Thread.sleep(500);} catch (Exception e){}
        }
    }
}
ding
beep
ding
beep
ding
beep
ding
beep
ding
beep

동시에 작업이 일어나고 있는 것을 확인할 수 있다. 정확한 같은 시간이라기보다 병렬적으로 작업이 수행된다는 것이 좀 더 정확하겠다. 스레드를 실행하더라도 반드시 같은 시점에 동작하지는 않기 때문이다.



BeepTask 와 같은 클래스를 만들지 않고 익명 객체 또는 람다식을 이용해서도 스레드를 작성할 수 있다.

public class PrintExample2 {

    public static void main(String[] args) {
//        BeepTask beepTask = new BeepTask();
//        Thread beepThread = new Thread(beepTask);
//        beepThread.start();

        // anonymous object 이용
        Thread beepThread = new Thread(new Runnable(){
            @Override
            public void run(){
                Toolkit toolkit = Toolkit.getDefaultToolkit();
                for(int i=0 ; i<5 ; i++){
                    toolkit.beep();
                    System.out.println("beep");
                    try{ Thread.sleep(500);} catch (Exception e){}
                }
            }
        });

	// lambda
        Thread beepThread = new Thread(()->{
            Toolkit toolkit = Toolkit.getDefaultToolkit();
                for(int i=0 ; i<5 ; i++){
                    toolkit.beep();
                    System.out.println("beep");
                    try{ Thread.sleep(500);} catch (Exception e){}
                }
        });
        
        beepThread.start();

        for(int i=0 ;i<5; i++){
            System.out.println("ding");
            try{ Thread.sleep(500);} catch (Exception e){}
        }
    }
}


스레드 생성: Thread 서브 클래스로부터 생성


ThreadRunnable을 구현하고 있다. 따라서 Thread를 상속하는 클래스를 만들면 Runnable.run()을 재정의할 수 있고, Thread.start()도 사용할 수 있다.




아래는 Thread를 상속하는 서브 클래스로 스레드를 작성하는 예시이다. 생성자를 통해 인자를 받으면 객체의 필드와 비교해서 메세지를 출력하는 스레드이다.

public class WorkThread extends Thread {

    private final int comparison = 80;
    private int number;

    public WorkThread (int number){
        this.number = number;
    }

    @Override
    public void run(){
        String msg = "your num is bigger";
        if(number>comparison){
            System.out.println(msg);
        }

    }
}

public class ThreadExam {

    public static void main(String[] args) {
        WorkThread workThread = new WorkThread(90);
        workThread.start();
    }
}

이 역시 익명 객체로 만들 수 있다.

public class ThreadExam {

    public static void main(String[] args) {
//        WorkThread workThread = new WorkThread(90);
//        workThread.start();

        Thread thread = new Thread(){

          public void run(){
              for(int i=0 ; i<5 ; i++){
                  System.out.println("beep");
                  try{ Thread.sleep(500);} catch (Exception e){}
              }
          }

        };
        thread.start();

        for(int i=0 ;i<5; i++){
            System.out.println("ding");
            try{ Thread.sleep(500);} catch (Exception e){}
        }
    }
}


`Thread`를 직접 작성하거나 서브 클래스를 이용하거나 하는 방법을 봤는데, 익명 구현 객체를 만드는 것이 가장 깔끔하기는 하다. 다만 객체의 기능을 명확하게 분리하기 위해서 따로 작성하는 것이 좋을 듯하다.


스레드 이름

스레드는 식별을 목적으로 이름을 가지고 있다. main()은 main 스레드의 이름이고, 개발자가 직접 생성한 스레드는 Thread-n이라는 이름이 설정된다. ThreadsetName(), getName() 통해 이름을 변경하거나 확인할 수 있다. 그런데 두 메서드는 인스턴스 메서드이므로 Thread 객체의 참조가 필요하다. 스레드의 참조를 알 수 없을 때 static method인 Thread.currentThread()을 이용하면 현재 코드를 수행하고 있는 스레드의 인스턴스를 얻을 수 있다.



3. 스레드 우선순위(Concurrency, Parallelism)

멀티 스레드는 동시 또는 병렬적으로 실행된다. 동시성은 싱글 코어에서 멀티 스레드를 실행할 때 각 스레드를 번갈아가며 수행해나가는 것을 말한다 (앞서 잠깐 언급했듯 동시라는 것이 정확히 같은 시간에 일어난다는 것과는 조금 다르다). 동시성은 각 스레드를 조금씩 번갈아가며 빠르게 수행한다. 병렬성은 멀티 코어 상황에서 코어마다 스레드를 담당하여 작업을 한 번에 처리하는 것을 말한다.





작동하는 스레드의 개수가 cpu보다 많을 경우 스레드를 어떤 순서로 동시적으로 실행할 것인지를 정해야 하는데, 이를 스레드 스케줄링이라고 한다. 이 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가면서 해당 스레드의 run()을 조금씩 실행한다.


스레드 스케줄링: priority vs. round robin

자바에는 스케줄링에 두 가지 개념이 있다.

  • Priority

    우선순위를 정해 우선순위가 높을수록 실행 상태를 더 많이 가지도록 한다. 1부터 10까지 개발자가 부여할 수 있기 때문에 개발자가 스레드를 제어할 수 있다. Thread.setPriority()를 통해서 우선순위를 설정할 수 있으며, 설정하지 않았을 때 스레드는 default로 5의 우선순위를 가진다. 값을 설정할 때에는 Thread에 제공되어 있는 상수를 이용해도 된다.

    Thread.setPriority(Thread.MAX_PRIORITY); // 10
    Thread.setPriority(Thread.NORM_PRIORITY); // 5
    Thread.setPriority(Thread.MIN_PRIORITY); // 1

  • Round-Robin scheduling

    순환할당방식은 시간 할당량(time slice)을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다른 스레드를 실행하는 방식이다. 이는 JVM에 의해 정해지므로 코드로 제어할 수는 없다.


4. 동기화 메소드와 동기화 블록

다음은 멀티 스레드에서 중요한 기능인 개념인 메서드와 동기화 블럭에 대해 설명한다. 먼저 이 개념들을 왜 사용해야 하는지에 대해서 알아보자.



공유 객체를 사용할 때 주의점

싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 여러 스레드가 객체를 공유해서 작업해야 하는 경우가 있다. 이 경우, 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있다.

User1이 Calculator라는 객체의 a 필드를 100이라고 저장한다고 하자. 그런데 User1가 스레드를 끝내기 전에 User2가 해당 객체의 a 필드를 다시 50으로 저장해버렸다. User1가 작업을 끝내고 a 필드를 출력하면 100이 정상적으로 출력될까? 아래 코드는 위 내용을 표현하고 있다.

package org.java.chap12.multi_threads;

public class Calc {

    private int memory;

    public int getMemory(){
        return this.memory;
    }

    public void setMemory(int memory){
        this.memory = memory;
        try{
            Thread.sleep(2000);
        } catch (Exception e){}
        System.out.println(Thread.currentThread().getName() + " :"+this.memory);
    }
}
package org.java.chap12.multi_threads;

public class User1 extends Thread {

    private Calc calc;

    public void setCalc(Calc calc){
        setName("User1");
        this.calc = calc;
    }

    public void run(){
        calc.setMemory(100);
    }
}
package org.java.chap12.multi_threads;

public class User2 extends Thread{

    private Calc calc;

    public void setCalc(Calc calc){
        setName("User2");
        this.calc = calc;
    }

    public void run(){
        calc.setMemory(50);
    }
}
package org.java.chap12.multi_threads;

public class SharedObject {

    public static void main(String[] args) {
        Calc calc = new Calc();
        User1 user1 = new User1();
        user1.setCalc(calc);
        user1.start();

        User2 user2 = new User2();
        user2.setCalc(calc);
        user2.start();

    }
}
50
50

위 코드에서는 user1과 user2라는 스레드가 있고 두 스레드 모두 Calc 객체를 공유 참조하고 있다. user1이 먼저 실행되어서 Calc 객체의 memory 필드의 값을 100으로 설정했다. 그런데 user2 스레드가 시작되고 값을 50으로 설정하자 두 스레드 모두에서 memory 값이 50으로 변경되었다.


동기화 메서드 및 동기화 블록

위와 같이 멀티 스레드 상황에서 객체를 공유할 때, 한 스레드가 작업 중인 내용을 다른 스레드가 변경할 수 없게 하려면 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야 한다.

멀티 스레드에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역(critical section)이라고 한다. 자바는 임계 영역을 지정하기 위해 synchronized 메서드와 동기화 블록을 제공한다. 스레드가 객체 내부의 동기화 메서드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 한다.

메서드 선언에 synchronized 키워드를 붙이면 해당 메서드 또는 코드 블록에 들어가는 스레드는 잠금이 걸린다. 해당 키워드는 인스턴스, 정적 메서드 모두에 사용될 수 있다.


public synchronized void method(){
	//.. 단 하나의 스레드만 실행됨
}


동기화 메서드는 메서드 전체가 임계 영역이므로 해당 메서드를 실행하면 스레드에 잠금이 걸리고, 메서드가 종료될 때 잠금이 풀린다. 메서드 전체가 아니라 메서드 일부에만 임계 영역을 지정하고 싶으면 동기화 블록을 만들면 된다.

public void method(){

	// 여러 스레드가 실행 가능

	synchronized(공유 객체){
		// 하나의 스레드만 실행
	}

	// 여러 스레드 실행 가능
}



그림에서 보는 것처럼 동기화가 걸려있는 블럭이나 메서드는 한 스레드가 사용 중이면 다른 스레드에서 실행할 수 없다. 하지만 일반 메서드는 같이 실행이 가능하다.

이를 이전 예제에 적용해보면 아래와 같다.


public class Calc {

    private int memory;

    public int getMemory(){
        return this.memory;
    }

    public synchronized void setMemory(int memory){
        this.memory = memory;
        try{
            Thread.sleep(2000);
        } catch (Exception e){}
        System.out.println(Thread.currentThread().getName() + " :"+this.memory);
    }
}

각 스레드에서 값을 입력하는 메서드였던 setMemory()에 동기화를 적용했다. 이제 수행 결과를 보자.

public class SharedObject {

    public static void main(String[] args) {
        Calc calc = new Calc();
        User1 user1 = new User1();
        user1.setCalc(calc);
        user1.start();

        User2 user2 = new User2();
        user2.setCalc(calc);
        user2.start();

    }
}
User1 :100
User2 :50

스레드 user1이 값을 입력하는 동안에는 user2스레드에서 setMemory()를 실행하지 않으므로 user1.setMemory()가 정상적으로 100을 출력할 때까지 대기하게 된다. 따라서 user1이 작업을 끝날때까지 안전하게 calc 객체를 사용할 수 있게 된다.



다음은 동기화 메서드가 아닌 동기화 블럭을 통해 동기화를 구현한 것이다.

public void setMemory(int memory){

        synchronized (this){
            this.memory = memory;
//        try{
//            Thread.sleep(500);
//        } catch (Exception e){}
            System.out.println(Thread.currentThread().getName() + " :"+this.memory);
        }

    }

동기화 블럭을 이용하면 해당 블럭에만 동기화를 걸고 나머지 메서드 블럭에는 공동으로 실행할 수 있는 코드를 적어줄 수 있다.




source:

github

github2

reference:

「이것이 자바다」, 신용권

profile
chop chop. mish mash. 재밌게 개발하고 있습니다.

0개의 댓글