Java 쓰레드 동기화 : Thread Synchronization

이번글은 Java 쓰레드 동기화에 대해 알아보겠습니다.
프로그램에서 두 개 이상의 쓰레드를 사용할 때 둘 이상의 쓰레드가 동시에 리소스에 액세스하려고 할 수 있습니다. 예를 들어 한 쓰레드는 파일에서 데이터를 읽으려고 시도하고 다른 쓰레드는 동일한 파일의 데이터를 변경하려고 할 수 있습니다. 이 경우 데이터가 일치하지 않을 수 있습니다. 이상적인 상황은 한 쓰레드가 작업을 완전히 완료하고 다른 쓰레드가 다음에 실행되도록 허용하는 것입니다. 공유 리소스는 한 번에 하나의 쓰레드에서만 사용되도록 해야 합니다. 이 작업은 동기화라는 프로세스를 통해 수행됩니다.

프로그래머는 메서드 단위 블럭 단위로 synchronized 키워드를 사용하여 Lock 을 걸어서 쓰레드간의 침범을 막고 동기화를 할 수 있습니다.
C# 에서는 lock, Monitor
C++ 에서는 크리티컬 섹션 등의 개념과 동일 합니다.

Java synchronized 키워드

Java에서 동기화 객체인 synchronized 키워드 블록을 동기화 합니다. 동일한 객체에 동기화된 블록은 동시에 한 번에 하나의 스레드만 실행할 수 있습니다. 동기화된 블록으로 들어가려는 다른 스레드는 동기화된 블록 내의 스레드가 블록을 빠져나올 때까지 차단됩니다.

synchronized 키워드는 네 가지 다른 유형의 블록을 표시하는 데 사용될 수 있습니다:

  • 인스턴스 메서드 동기화 (Synchronized Instance methods)
  • 정적 메서드 동기화 ( Synchronized Static Methods )
  • 인스턴스 메서드 내의 코드 블록 동기화(Synchronized Blocks in Instance Methods)
  • 정적 메서드 내의 코드 블록 동기화( Synchronized Blocks in Static Methods )

이러한 블록들은 서로 다른 객체에 대해 동기화됩니다. 동기화된 블록의 유형은 상황에 따라 다릅니다.
그에 맞는 상황을 아래 예제로 풀어 보겠습니다.

인스턴스 메서드 동기화 (Synchronized Instance methods)

다음은 동기화된 인스턴스 메서드입니다:

public class Main {

  public static void main(String[] args) {
    SyncClass a = new SyncClass();
    Thread thread1 = new Thread(() -> {
      a.run("thread1");
    });

    Thread thread2 = new Thread(() -> {
      a.run("thread2");
    });

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

public class SyncClass {

  public synchronized void run(String name) {
      long nano = System.currentTimeMillis();

    System.out.println(
      new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
      name + " lock");
    try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }

    nano = System.currentTimeMillis();
    System.out.println(
        new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
        name + " unlock");
  }
}

출력문은 다음과 같습니다.

13:59:01.840 thread1 lock
13:59:02.877 thread1 unlock
13:59:02.877 thread2 lock
13:59:03.880 thread2 unlock

SyncClass 클래스의 run 메서드 선언에서 synchronized 키워드의 사용을 주목하세요. 이는 Java에게 해당 메서드가 동기화되었음을 알려줍니다.
결과를 보시면 아시겠지만 두 쓰레드 안에서 동시에 같은 메소드에 진입하지 않았습니다.

다음 항목을 유심히 보아야 합니다.

SyncClass a = new SyncClass();
Thread thread1 = new Thread(() -> { a.run("thread1"); });
Thread thread2 = new Thread(() -> { a.run("thread2"); });

두 쓰레드는 하나의 인스턴스 즉 a 라는 인스턴스를 같이 사용 하였고 run 메서드는 Lock 이 걸려 동시 진입이 안됨을 알 수 있습니다.

그런데 만약 다음 처럼 두개의 인스턴스 a, b 라는 인스턴스를 각자 다른 쓰레드에서 실행 하면 어떻게 될까요?

SyncClass a = new SyncClass();
SyncClass b = new SyncClass();
Thread thread1 = new Thread(() -> { a.run("thread1"); });
Thread thread2 = new Thread(() -> { b.run("thread2"); });

결과는 아래와 같습니다.

14:08:10.768 thread1 lock
14:08:10.768 thread2 lock
14:08:11.838 thread1 unlock
14:08:11.838 thread2 unlock

시간을 보시면 두개의 쓰레드가 동시에 같은 메소드에 진입하고 동시에 진출하는 결과를 볼 수 있습니다.
이제 여러분들은 이 각자 다른 인스턴스의 동기화의 함정에서 빨리 벗어 나셔야 합니다.

정적 메서드 동기화 ( Synchronized Static Methods )

static 키워드가 붙으면 변수에 붙는 사용법과 유사합니다. 정적으로 동기화가 진행 됩니다. 위의 메서드 동기화 예제 2번째 것으로 조금 수정후 다시 보여드립니다.

public class Main {

  public static void main(String[] args) {
    SyncClass a = new SyncClass();
    SyncClass b = new SyncClass();

    Thread thread1 = new Thread(() -> {
      a.run("thread1");
    });

    Thread thread2 = new Thread(() -> {
      b.run("thread2");
    });

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

public class SyncClass {

  public static synchronized void run(String name) {
      long nano = System.currentTimeMillis();

    System.out.println(
      new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
      name + " lock");
    try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }

    nano = System.currentTimeMillis();
    System.out.println(
        new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
        name + " unlock");
  }
}

출력은 다음과 같습니다.

14:22:32.922 thread1 lock
14:22:33.960 thread1 unlock
14:22:33.960 thread2 lock
14:22:34.973 thread2 unlock

다음 함수 명앞에 붙은 static 키워드를 눈여겨 보셔야 합니다.
public static synchronized void run(String name)

static 하나만 붙였는데 어떤가요. 2개의 인스턴스로 구성된 2개의 쓰레드에서 동시 접근 하였음에도 run 메서드는 동시에 접근하지 못하고 있습니다.
여러 인스턴스임에도 static 을 붙여서 run 메서드를 완벽하게 동기화 시키고 있습니다.
static 변수 와 그 느낌이 같습니다. static 변수를 이해 하셨다면 이것도 어렵지 않게 이해 하셨을겁니다.

인스턴스 메서드 내의 코드 블록 동기화(Synchronized Blocks in Instance Methods)

public class Main {

  public static void main(String[] args) {
      SyncClass a = new SyncClass();

      Thread thread1 = new Thread(() -> {
          a.run("thread1");
      });

      Thread thread2 = new Thread(() -> {
          a.run("thread2");
      });

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

public class SyncClass {

  public void run(String name) {
    long nano = System.currentTimeMillis();

    System.out.println(
      new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
      name + " lock");

    synchronized(this){
      try {
        for(int i=0;i<2;i++){
          Thread.sleep(100);
          System.out.println(name + " " + i + " wait");           
        }
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }

    nano = System.currentTimeMillis();
    System.out.println(
        new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
        name + " unlock");
  }
}

출력 문은 다음과 같습니다.

14:44:47.495 thread2 lock
14:44:47.495 thread1 lock
thread2 0 wait
thread2 1 wait
14:44:47.758 thread2 unlock
thread1 0 wait
thread1 1 wait
14:44:47.975 thread1 unlock

자 결과물을 보시면 코드 블럭 동기화가 이해가 좀 가실 것입니다.
run 메서드에는 두 쓰레드가 동시 진입합니다. 그러나 synchronized(this) {} 이 선언된 블럭안에서는 동기화가 이루어집니다.
이 synchronized 블럭 안에서는 두 쓰레드가 동시 진입하지 못하고 한 쓰레드가 종료되어야만 다른 쓰레드가 진입됨을 알 수 있습니다.

정적 메서드 내의 코드 블록 동기화( Synchronized Blocks in Static Methods )

static 메서드 동기화와 마찬가지로 Synchronized 블럭을 정적으로 동기화 시킬 수 있습니다.
그러나 사용법은 좀 다릅니다. static 을 붙이는 것이 아니라 다음처럼 사용합니다.

synchronized(SyncClass.class){ }

전체 코드를 보시면 다음과 같습니다.

public class Main {

  public static void main(String[] args) {
      SyncClass a = new SyncClass();
      SyncClass b = new SyncClass();

      Thread thread1 = new Thread(() -> {
          a.run("thread1");
      });

      Thread thread2 = new Thread(() -> {
          b.run("thread2");
      });

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

public class SyncClass {

  public void run(String name) {
    long nano = System.currentTimeMillis();

    System.out.println(
      new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
      name + " lock");

    synchronized(SyncClass.class){
      try {
        for(int i=0;i<2;i++){
          Thread.sleep(100);
          System.out.println(name + " " + i + " wait");           
        }
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }

    nano = System.currentTimeMillis();
    System.out.println(
        new SimpleDateFormat("HH:mm:ss.SSS ").format(nano) + 
        name + " unlock");
  }
}

출력문은 아래와 같습니다.

14:55:07.990 thread2 lock
14:55:07.990 thread1 lock
thread2 0 wait
thread2 1 wait
14:55:08.243 thread2 unlock
thread1 0 wait
thread1 1 wait
14:55:08.464 thread1 unlock

이처럼 여러 인스턴스임에도 코드 블럭이 동기화 되고 있음을 알 수 있습니다.

동기화 메소드 작동 예시

아래 예제는 synchronized 메소드의 작동 방식을 보여줍니다.

class One {
    synchronized void display(int num) {
        System.out.print("" + num);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            System.out.println("Interrupted");
        }
        System.out.println(" Done");
    }
}

class Two implements Runnable {
    int number;
    One objOne;
    Thread objTh;

    public Two(One one_num, int num) {
        objOne = one_num;
        number = num;
        objTh = new Thread(this);
        objTh.start();
    }

    public void run() {
        objOne.display(number);
    }
}

class SynchMethod {
    public static void main(String args[]) {
        One objOne = new One();
        int digit = 10;
        Two objSynch1 = new Two(objOne, digit++);
        Two objSynch2 = new Two(objOne, digit++);
        Two objSynch3 = new Two(objOne, digit++);
        try {
            objSynch1.objTh.join();
            objSynch2.objTh.join();
            objSynch3.objTh.join();
        } catch (InterruptedException e) {
            System.out.println("Interrupted");
        }
    }
}

출력문은 다음과 같습니다.

10 Done
11 Done
12 Done

위 코드에서 One 클래스에는 display()라는 메소드가 하나 있습니다. 이 메소드는 int 타입의 매개변수를 받아 ‘done’ 접미사와 함께 출력합니다. Thread.sleep(1000) 메소드는 display() 메소드 호출 이후 현재 스레드를 일정 시간동안 멈추도록 합니다.

Two 클래스의 생성자는 One 클래스의 객체를 참조하는 objTh와 정수형 변수를 받습니다. 여기서 새로운 스레드도 생성됩니다. 이 스레드는 objTh 객체의 run() 메소드를 호출합니다.

메인 클래스인 SynchMethod는 One 클래스를 objOne으로 인스턴스화하고 Two 클래스의 객체를 세 개 생성합니다. 각 Two 클래스 객체에는 동일한 objOne 객체가 전달됩니다. join() 메소드는 호출된 스레드가 종료될 때까지 호출한 스레드를 대기시킵니다.

결론

이번글에서는 Java 의 쓰레드 동기화 그중 synchronized 키워드에 대해서 알아보았습니다.
동기화를 통해 쓰레드에서 안전하게 데이터를 처리 할 수 있습니다.

Leave a Comment