본문 바로가기
Java/Java

Thread 클래스(with synchronized)

by oneny 2023. 7. 22.

Thread

 

우리가 JVM을 실행시키면 자바 프로세스(Java Process)가 시작한다. 이 프로세스에는 여러 개의 스레드가 수행되는데 반대로 여러 프로세스가 공유하는 하나의 스레드가 수행되는 일은 절대 없다. 어떤 프로세스든 간에 스레드가 하나 이상 수행된다.

  • 프로세스(Process): 운영체제로부터 시스템 자원을 할당받아 메모리에 올라와 실행되고 있는 프로그램의 인스턴스(독립적인 개체)
  • 스레드(Thread): 프로세스가 할당받은 자원을 이용하는 실행의 단위

이 때, Java Thread는 일반 스레드와 거의 차이가 없으며, JVM이 운영체제의 역할을 한다. 즉, Java Thread는 JVM에 의해 스케되는 실행 단위 코드 블록이라고 할 수 있고, 스레드 스케줄링 및 스레드와 관련된 많은 정보들을 전적으로 JVM이 관리한다. 개발자는 Java Thread로 작동할 스레드 코드를 작성하고, 스레드 코드가 생명을 가지고 실행을 시작하도록 JVM에 요청하는 일만 하면 된다.

 

왜 스레드를 만들었을까?

프로세스가 하나 시작하려면 많은 자원이 필요하다. 만약 하나의 작업을 동시에 수행하려고 할 때 여러 개의 프로세스를 띄워서 실행하면 각각 메모리를 할당해야 한다. JVM은 기본적으로 아무런 옵션없이 실행하면 OS마다 다르지만, 적어도 32MB ~ 64MB의 물리 메모리를 점유한다. 그에 반해서, 스레드를 하나 추가하려면 1MB 이내의 메모리를 점유한다. 그래서 쓰레드를 경량 프로세스(lightweight process)라고도 부른다.

이러한 이유 외에도 스레드가 프로세스에 비해 컨텍스트 스위칭 비용이 적게 든다는 등의 장점이 있는데 이에 대해서는 나중에 제대로 공부해보자.

 

Runnable 인터페이스와 Thread 클래스

스레드를 생성하는 방법에는 2가지가 있다.

public class RunThreads {

  public static void main(String[] args) {
    RunnableSample runnable = new RunnableSample();
    new Thread(runnable).start();

    ThreadSample thread = new ThreadSample();
    thread.start();

    System.out.println("RunThreads.main() method is ended");
  }
}

class RunnableSample implements Runnable {

  @Override
  public void run() {
    System.out.println("This is RunnableSample's run() method");
  }
}

class ThreadSample extends Thread {

  @Override
  public void run() {
    System.out.println("This is ThreadSample's run() method");
  }
}
  • Runnable 인터페이스를 사용하는 방법
  • Thread 클래스를 사용하는 방법(Thread 클래스는 Runnable 인터페이스를 구현한 클래스)

 

Thread 클래스의 주요 메서드

리턴 타입 메서드 이름 및 매개 변수 설명
static void sleep(long millis) 매개 변수로 넘어온 시간(1/1,000초)만큼 대기한다.
static void sleep(long millis, int nanos) millis(1/1,000초)+nanos(1/1,000,000,000초)만큼 대기한다.
void run() 구현해야 하는 메서드
long getId() JVM에서 자동으로 생성해준 스레드의 고유 id를 리턴한다.
String getName() 스레드의 이름을 리턴한다.
void setName(String name) 스레드의 이름을 지정한다.
int getPriority() 스레드의 우선순위를 리턴한다
void setPriority(int newPriority) 스레드의 우선순위를 지정한다.
boolean isDaemon() 스레드가 데몬인지 확인한다.
void setDaemon(boolean on) 스레드를 데몬으로 설정할지 아닌지를 설정한다.
StackTraceElement[] getStackTrace() 스레드의 스택 정보를 확인한다.
Thread.State getState() 스레드의 상태를 확인한다.
ThreadGroup getThreadGroup() 스레드의 그룹을 확인한다.
Thread.State getState() 스레드의 상태를 리턴한다.
void join() 수행중인 스레드가 중지할 때까지 대기한다.
void join(long millis) millis(1/1,000초)만큼 대기한다.
void join(long millis, int nanos) millis(1/1,000초)+nanos(1/1,000,000,000초)만큼 대기한다.
void interrupt() 수행중인 스레드에 중지 요청을 한다.
void checkAccess() 현재 수행중인 스레드가 해당 스레드를 수정할 수 있는 권한이 있는지를 확인한다. 만약 권한이 없다면 SecurityException이라는 예외를 발생시킨다.
boolean isAlive() 스레드가 살아있는지를 확인한다. 해당 스레드의 run() 메서드가 종료되었는지 안되었는지를 확인하는 것이다.
boolean isInterrupted() run() 메서드가 정상적으로 종료되지 않고, interrupt() 메서드의 호출을 통해서 종료되었는지를 확인하는데 사용한다.
static boolean interrupted() 현재 스레드가 중지되었는지를 확인한다.
static int activeCount() 현재 스레드가 속한 스레드 그룹의 스레드 중 살아있는 스레드의 개수를 리턴한다.
static Thread currentThread() 현재 수행중인 스레드의 객체를 리턴한다.
static void dumpStack() 콘솔 창에 현재 스레드의 스택 정보를 출력한다.

Thread에 있는 static 메서드는 대부분 해당 스레드를 위해서 존재하는 것이 아니라, JVM에 있는 스레드를 관리하기 위한 용도로 사용되는데 그 예외가 있다. 그 예외 중 하나가 sleep() 메서드이다.

 

데몬 스레드가 아닌 사용자 스레드는 JVM이 해당 스레드가 끝날 때까지 기다리지만, 데몬 스레드는 수행하고 있든, 수행하고 있지 않든 상관없이 JVM이 끝날 수 있다. 단, 해당 스레드가 시작하기(state() 메서드 호출) 전에 데몬 스레드로 지정할 수 있고 시작한 다음에는 데몬으로 지정할 수 없다. 데몬 스레드는 모니터링하는 스레드를 별도로 띄워 모니터링하다가, 주요 스레드가 종료되면 관련된 모니터링 스레드가 종료되어야 프로세스가 종료될 수 있다. 그런데, 모니터링 스레드를 데몬 스레드로 만들지 않으면 프로세스를 종료할 수 없게 된다. 따라서 이런 부가적인 작업을 수행하는 스레드를 선언할 때는 데몬 스레드를 만든다.

 

interrupt() 메서드 같은 경우에는 InterruptedException을 발생시키면서 중단시킨다. 이 interrupt() 메서드는 sleep()과 join() 메서드와 같이 대기 상태를 만드는 메서드가 호출되었을 때 또는 Object 클래스의 wait()에서 interrupt() 메서드를 호출할 수 있지만, 스레드가 시작하기 전이나, 종료된 상태에 interrupt() 메서드를 호출해도 예외나 에러 없이 그냥 다음 문장을 넘어간다.

 

스레드의 Thread.State

자바의 Thread 클래스에는 State라는 enum 클래스가 있다. 이 클래스에 선언되어 있는 상수들의 목록은 다음과 같다.

  • NEW: 스레드 객체는 생성되었지만, 아직 시작되지 않은 상태
  • RUNNABLE: 스레드가 실행중인 상태
  • BLOCKED: 스레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태
  • WAITING: 스레드가 대기중인 상태
  • TIMED_WAITING: 특정 시간만큼 스레드가 대기중인 상태
  • TERMINATED: 스레드가 종료된 상태

 

Object 클래스에 선언된 스레드와 관련있는 메서드

리턴 타입 메서드 이름 및 매개 변수 설명
void  wait() 다른 스레드가 Object 객체에 대한 notify() 메서드나 notifyAll() 메서드를 호출할 때까지 현재 스레드가 대기하고 있도록 한다.
void wait(long timeout) wait() 메서드와 동일한 기능을 제공하며, 매개 변수에 지정한 시간(1/1,000초)만큼만 대기한다. 즉, 매개 변수 시간을 넘어 섰을 때에는 현재 스레드가 다시 깨어난다.
void wait(long timeout, int nanos) 위와 동일하며 지정할 수 있는 시간은 timeout(1/1,000초) + nanos(1,000,000,000초)이다.
void notify() Object 객체의 모니터에 대기하고 있는 단일 스레드를 깨운다.
void notifyAll() Object 객체의 모니터에 대기하고 있는 모든 스레드를 깨운다.

 

synchronized

여러 스레드가 한 객체에 선언된 메서드에 접근하여 데이터를 처리하려고 할 때 동시에 연산을 수행하여 값이 꼬이는 경우가 발생할 수 있다. 이 때, synchronized를 사용하면 동일한 객체의 메서드에 2개의 스레드가 접근하든, 100개의 스레드가 접근하든 간에 한 순간에 하나의 스레드만 이 메서드를 수행하게 된다. 단, 메서드에서 인스턴스 변수를 수정하려고 할 때에만 이런 문제가 발생하지 매개변수나 메서드 내부에서 사용하는 지역변수만 다루는 메서드는 전혀 synchronized로 선언할 필요가 없다

synchronized는 두 가지 방법으로 사용할 수 있다.

  • 메서드 자체를 synchronized로 선언하는 방법(synchronized methods)
  • 다른 하나는 메서드 내의 특정 문장만 synchronized로 감싸는 방법(synchronized statements)

위 방법을 통해 특정 스레드의 연산결과가 메인 메모리에 써질때까지 다른 스레드들은 진행하지 않고 대기하도록 만든다.

 

synchronized 적용 전

public class ModifyAmountThread extends Thread {

  private Calculate calculate;
  private boolean addFlag;

  public ModifyAmountThread(Calculate calculate, boolean addFlag) {
    this.calculate = calculate;
    this.addFlag = addFlag;
  }

  public void run() {
    for (int i = 0; i < 10_000; i++) {
      if (addFlag) {
        calculate.plus(1);
      } else {
        calculate.minus(1);
      }
    }
  }
}

class Calculate {

  private int amount;

  public Calculate() {
    amount = 0;
  }

  public void plus(int value) {
    amount += value;
  }

  public void minus(int value) {
    amount -= value;
  }

  public int getAmount() {
    return amount;
  }
}

다음과 같이 Calculate 클래스를 만들고 Calculate 객체를 받아서 10_000번의 루프를 돌면서 addFlag가 true면 1을 더하고, addFlag가 false면 1을 빼는 연산을 수행하는 스레드를 만들었다.

 

class ModifyAmountThreadTest {

  @Test
  void testModifyAmountThread() throws InterruptedException {
    Calculate calculate = new Calculate();
    ModifyAmountThread thread1 = new ModifyAmountThread(calculate, true);
    ModifyAmountThread thread2 = new ModifyAmountThread(calculate, true);

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    assertThat(calculate.getAmount()).isEqualTo(20_000);
  }
}

그리고 테스트 코드를 작성해서 실행해보니 두 개의 스레드가 모두 작업을 마치면 20000이 될 거라 예상할 수 있지만 그와 달리 20000에 한참 못미치는 값이 된 것을 확인할 수 있다. 왜 이러한 일이 발생한지에 대해서는 아래 블로그를 참고하자.

 

스핀락(spinlock), 뮤텍스(mutex), 세마포(semaphore) (feat. 동기화(synchronization))

동기화(Synchronization) 하나의 객체를 두 개의 스레드가 접근하면 어떻게 될까? 왜 동기화가 중요한지에 대해서 살펴보고, 동기화를 하지 않으면 어떤 문제가 발생하는지에 대해서 살펴보자. 상한

oneny.tistory.com

 

synchronized 적용

Object lock = new Object();

public void plus(int value) {
  synchronized (lock) {
    amount += value;
  }
}

public synchronized void minus(int value) {
  synchronized (lock) {
    amount -= value;
  }
}

synchronized를 블록으로 메서드 내 특정부분만 감싸는 방법으로 synchronized를 적용했다. 두 번째 방법을 선택한 이유는 30줄짜리 메서드가 있다고 가정할 때 나머지 29줄의 처리는 필요가 없는데 대기시간만 발생하게 만든다. 따라서 30줄짜리 전체에 syncrhonized를 거는 것 보다는 부분만 처리할 수 있도록 만들었다.

syncrhonized(this) 이후에 있는 중괄호 내에 있는 연산만 동시에 여러 스레드에서 처리하지 않겠다는 의미이다. 그리고 synchronized를 사용할 때에는 하나의 객체를 사용하여 블록 내의 문장을 하나의 스레드만 수행할 수 있다. 여기서 사용한 lock이라는 객체가 스레드들을 하나씩 처리할 수 있도록 해주는 문지기라고 할 수 있다.

 

synchronized를 적용하여 다시 테스트 코드를 돌려보면 amount는 20000이 된 것을 확인할 수 있다.

 

lock 객체 분리하여 사용하자

private int amount;
private int interest;

private Object amountLock = new Object();
private Object interestLock = new Object();

public Calculate() {
	amount = 0;
	interest = 0;
}

public void plus(int value) {
	synchronized (amountLock) {
		amount += value;
	}
}

public void addInterest(int value) {
	synchronized (interestLock) {
		interest += value;
	}
}

만약 Calculate 클래스에 amount라는 변수 외에 interest라는 변수가 있고, 그 interest 변수를 처리할 때에도 여러 스레드에서 접근하면 안되는 경우가 발생할 수가 있다. 이럴 때 만약 lock이라는 하나의 잡급용 객체만을 사용하면 amount라는 변수를 처리할 때, interest라는 변수를 처리하려는 부분도 처리를 못하게 된다. 따라서, 두 개의 별도의 lock 객체를 사용하면 보다 효율적으로 동기화를 할 수 있다.

 

synchronized 동작방식의 문제점

synchronized를 사용하면 하나의 스레드만 임계 영역(critical section)에 작업을 수행하게 만들어 원자성과 가시성을 보장할 수 있지만, 나머지 스레드는 대기하게 만들어 성능저하를 일으킬 수 있다. 그리고 임계 영역에 들어갈 때 락을 획득하고 들어가기 때문에 데드락이라는 문제가 발생할 수도 있다.

 

참고: 데드락
둘 이상의 프로세스/스레드들이 아무것도 진행하지 않는 상태로 서로 영원히 대기하는 상황을 말한다.

 

 

 

출처

[자바의 신] 25장.쓰레드는 개발자라면 알아두는 것이 좋아요

[10분 테코톡] 알렉스, 열음의 멀티스레드와 동기화 In Java

[OS] 프로세스와 스레드의 차이