8. synchronized는 제대로 알고 써야 한다

de_sj_awa·2021년 9월 2일
0

8. synchronized는 제대로 알고 써야 한다

우리가 개발하는 WAS는 여러 개의 스레드가 동작하도록 되어 있다. 그래서 synchronized를 자주 사용한다. 하지만 synchronized를 쓴다고 무조건 안정적인 것은 아니며, 성능에 영향을 미치는 부분도 있다.

1. 자바에서 스레드는 어떻게 사용하나?

프로세스와 스레드

클래스를 하나 수행시키거나 WAS를 기동하면, 서버에 자바 프로세스가 하나 생성된다. 하나가 생성되는지 여러 개가 생성되는지는 윈도의 자원관리자나 리눅스, 유닉스의 프로세스를 조회해 보면 된다.

하나의 프로세스에는 여러 개의 스레드가 생성된다. 단일 스레드가 생성되어 종료될 수도 있고, 여러 개의 스레드가 생성되어 수행될 수도 있다. 그러므로 프로세스와 스레드의 관계는 1:多의 관계라고 보면 된다. 프로세스와 스레드는 왜 이러한 관계가 만들어질까? 스레드는 다른 말로 Lightweight Process(LWP)라고도 한다. 즉 가벼운 프로세스이고, 프로세스에서 만들어 사용하고 있는 메모리를 공유한다. 그래서 별개의 프로세스가 하나씩 뜨는 것보다는 성능이나 자원 사용에 있어 많은 도움이 된다.

Thread 클래스 상속과 Runnable 인터페이스 구현

스레드의 구현은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법 두 가지가 있다. 기본적으로 Thread 클래스는 Runnable 인터페이스를 구현한 것이기 때문에 어느 것을 사용해도 거의 차이가 없다. 대신 Runnable 인터페이스를 구현하면 원하는 기능을 추가할 수 있다. 이는 장점이 될 수도 있지만, 해당 클래스를 수행할 때 별도의 스레드 객체를 생성해야 한다는 점은 단점이 될 수도 있다. 또한 자바는 다중 상속을 인정하지 않는다. 따라서 스레드를 사용할 때 이미 상속받은 클래스가 존재한다면 Runnable 인터페이스를 구현해야 한다.

다음 클래스는 Runnable 인터페이스를 구현한 클래스이다.

package com.perf.thread.basic;

public class RunnableImpl implements Runnable{

    @Override
    public void run() {
      System.out.println("This is RunnableImpl.");
    }
}

두 번째 클래스는 Thread 클래스를 확장한 경우다.

package com.perf.thread.basic;

public class ThreadExtends extends Thread{
  
    public void run() {
      System.out.println("This is ThreadExtends.");
    }
}

그럼 이 클래스들은 어떻게 실행해야 할까? Thread 클래스를 상속받은 경우에는 start() 메서드를 호출하면 된다. 하지만 Runnable 인터페이스를 구현한 경우네는 Thread 클래스의 Runnable 인터페이스를 매개변수로 받는 생성자를 사용해서 Thread 클래스를 만든 후 start() 메서드를 호출해야 한다. 그렇게 하지 않고, run() 메서드를 호출하면 새로운 스레드가 생성되지 않는다.

package com.perf.thread.basic;

public class RunThreads {
    public static void main(String[] args) {
      RunnableImpl ri = new RunnableImpl();
      ThreadExtends te = new ThreadExtends();
      new Thread(ri).start();
      te.start();
    }
}

실행할 때마다 결과 값이 고정되지 않는다. 스레드를 호출하면서 우선순위를 따로 지정하지 않았으므로 Thread 클래스를 상속받은 결과가 먼저 나올 수도 있고, Runnable 인터페이스를 구현한 결과가 먼저 나올 수도 있다.

sleep(), wait(), join() 메서드

현재 진행 중인 스레드를 대기하도록 하기 위해서는 sleep(), wait(), join() 세 가지 메서드를 사용하는 방법이 있다. wait() 메서드는 모든 클래스의 부모 클래스인 Object 클래스에 선언되어 있으므로 어떤 클래스에서도 사용할 수 있다. 이 세 가지 메서드는 모두 예외를 던지도록 되어 있어 사용할 때는 반드시 예외 처리를 해주어야 한다.

sleep() 메서드는 명시된 시간만큼 해당 스레드를 대기시킨다. 이 메서드는 다음과 같은 두 가지 방법으로 매개변수를 지정해서 사용한다.

  • sleep(long millis) : 명시된 ms만큼 해당 스레드가 대기한다. static 메서드이기 때문에 반드시 스레드 객체를 통하지 않아도 사용할 수 있다.
  • sleep(long millis, int nanos) : 명시된 ms + 명시된 ns 만큼 해당 스레드가 대가한다. 여기서 나노 시간은 0~999999까지 사용할 수 잇다. 이 메서드도 위와 마찬가지로 static 메서드이다.

wait() 메서드도 명시된 시간만큼 해당 스레드를 대기시킨다. sleep() 메서드와 다른 점은 매개변수인데, 만약 아무런 매개변수를 지정하지 않으면 notify() 메서드 혹은 notifyAll() 메서드가 호출될 때까지 대기한다. wait() 메서드가 대기하는 시간을 설정하는 방법은 sleep() 메서드와 동일하다.

join() 메서드는 명시된 시간만큼 해당 스레드가 죽기를 대기한다. 만약 아무런 매개변수를 지정하지 않으면 죽을 때까지 계속 대기한다.

interrupt(), notify(), notifyAll() 메서드

앞서 명시한 세 개의 메서드를 모두 멈출 수 있는 유일한 메서드는 interrupt() 메서드다. interrupt() 메서드가 호출되면 중지된 스레드에는 InterruptedException이 발생한다. 제대로 수행되었는지 확인하려면 interrupted() 메서드를 호출하거나 isInterrupted() 메서드를 호출하면 된다. 두 방법의 차이는 interrupted() 메서드는 스레드의 상태를 변경시키지만, isInterrupted() 메서드는 단지 스레드의 상태만을 리턴한다는 점이다.

추가로 isAlive() 메서드라는 것이 있는데, 이는 해당 스레드가 살아있는지 확인하는 메서드다. 스레드ㅏ가 살아있다면 true를, 그렇지 않으면 false를 리턴한다.

notify() 메서드와 notifyAll() 메서드는 모두 wait() 메서드를 멈추기 위한 메서드다. 이 두 메서드는 Object 클래스에 정의되어 있는데, wait() 메서드가 호출된 후 대기 상태로 바뀐 스레드를 깨운다. notify() 메서드는 객체의 모니터와 관련있는 단일 스레드를 깨우며, notifyAll() 메서드는 객체의 모니터와 관련 있는 모든 스레드를 깨운다.

간단한 예를 통해서 대기 메서드와 중단 메서드의 사용법을 확인하자.

package com.perf.thread.sleep;

public class Sleep extends Thread {

    public void run() {
        try {
          Thread.sleep(10000); // 10초간 대기한 후 종료한다.
        } catch (InterruptedException e) {
            System.out.println("Somebody stopped me T T");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Sleep s = new Sleep();
        s.start();  // 스레드를 시작했다
        try {
            int cnt = 0;
            while (cnt < 5) {
                s.join(1000);   // 1초씩 기다린다
                cnt++;
                System.out.format("%d second waited\n", cnt);
            }
            if (s.isAlive()) {
                s.interrupt();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

run() 메서드를 보자. 스레드가 시작되면 해당 스레드를 10초간 대기시킨다. 만약 InterruptedException이 발생하면 메시지를 표시하고 끝낸다. main() 메서드에서는 스레드를 시작한다. cnt를 증가시키면서 1초씩 해당 스레드가 죽기를 기다린다. 만약 5초 동안 해당 스레드가 죽지 않으면, main() 메서드는 스레드가 살아있는지 한 번 더 확인하고 스레드를 죽인다.

2. interrupt() 메서드는 절대적인 것이 아니다

interrupt() 메서드를 호출하여 특정 메서드를 중지시키려고 할 때 항상 해당 메서드가 멈출까? 정답은 '아니요'다. API에 있는 Thread 클래스의 interrupt 메서드에는 다음과 같은 설명이 있다.

If this thread is blocked in an invocation of the wait(), wait(long), or wait(long, int) methods of the Object class, or of the join(), join(long), join(long, int), sleep(long), or sleep(long, int), methods of this class, then its interrupt status will be cleared and it will receive an InterruptedException.

If this thread is blocked in an I/O operation upon an InterruptibleChannel then the channel will be closed, the thread's interrupt status will be set, and the thread will receive a ClosedByInterruptException.

If this thread is blocked in a Selector then the thread's interrupt status will be set and it will return immediately from the selection operation, possibly with a non-zero value, just as if the selector's wakeup method were invoked.

If none of the previous conditions hold then this thread's interrupt status will be set.

이를 설명하면 interrupt() 메서드는 해당 스레드가 'block' 되거나 특정 상태에서만 작동한다는 말이다. 다음 코드는 스레드를 실행하고 2초 후에 interrupt() 메서드를 호출한다.

package com.perf.thread;

public class InterruptSample {

    public static void main(String[] args) throws Exception {
        InfinitThread infinit=new InfinitThread();
        infinit.start();
        Thread.sleep(2000);
        System.out.println("isInterrupted="+infinit.isInterrupted());
        infinit.interrupt();
        System.out.println("isInterrupted="+infinit.isInterrupted());
//      infinit.setFlag(false);
    }
}

그리고, InfinitThread 클래스는 다음과 같이 되어 있다.

package com.perf.thread;

public class InfinitThread extends Thread {
    int value=Integer.MIN_VALUE;
    private boolean flag=true;
    public void run(){
        while(flag) {
            value++;
            if(value==Integer.MAX_VALUE) {
                value=Integer.MIN_VALUE;
                System.out.println("MAX_VALUE reached !!! ");
            }
        }
    }
//  public void setFlag(boolean flag) {
//      this.flag=flag;
//  }
}

InterruptSample 클래스를 수행하면 어떻게 될까? 정상적으로 interrupt() 메서드를 호출하면 이 메서드가 멈출까?

문제는 이 스레드가 멈추지 않는다는 것이다. interrupt() 메서드는 대기 상태일 때에만 해당 스레드를 중단시키기 때문에 이 스레드는 멈추지 않는다. 따라서 while(true)를 쓰는 것 보다 flag 값을 수정하거나 sleep()을 추가하는 방법을 사용해야 한다.

flag 값 수정하기

다음과 같이 스레드에 flag를 수정할 수 있도록 하고,

package com.perf.thread;

public class InfinitThread extends Thread {
    int value=Integer.MIN_VALUE;
    private boolean flag=true;
    public void run(){
        while(flag) {
            value++;
            if(value==Integer.MAX_VALUE) {
                value=Integer.MIN_VALUE;
                System.out.println("MAX_VALUE reached !!! ");
            }
        }
    }
    public void setFlag(boolean flag) {
        this.flag=flag;
    }
}

그 다음에, 다른 스레드에서 interrupt() 메서드를 호출한 후 flag를 변경하는 방법이다.

package com.perf.thread;

public class InterruptSample {

    public static void main(String[] args) throws Exception {
        InfinitThread infinit=new InfinitThread();
        infinit.start();
        Thread.sleep(2000);
        System.out.println("isInterrupted="+infinit.isInterrupted());
        infinit.interrupt();
        System.out.println("isInterrupted="+infinit.isInterrupted());
        infinit.setFlag(false);
    }
}

이 예제는 시작하고 2초 후에 interrupt() 메서드가 호출되고, flag 값이 false가 되기 때문에 바로 멈춘다.

sleep 추가하기

이번에는 InfinitThread 클래스만 변경하면 된다.

package com.perf.thread;

public class InfinitThread extends Thread {
    int value=Integer.MIN_VALUE;
    private boolean flag=true;
    public void run(){
        while(flag) {
            value++;
            if(value==Integer.MAX_VALUE) {
                value=Integer.MIN_VALUE;
                System.out.println("MAX_VALUE reached !!! ");
            }
            try {
                Thread.sleep(0,1);
            } catch(Exception e) {
                break;
            }
        }
    }
    public void setFlag(boolean flag) {
        this.flag=flag;
    }
}

중간에 Thread.sleep(0,1)이 추가된 것을 볼 수 있다. 이 스레드는 while 루프가 수행될 때 1 나노초 (1/1,000,000,000)만큼 대기했다가 수행된다. 분명히 성능 저하는 발생하지만, interrupt() 메서드가 호출되면 이 스레드는 바로 멈춘다.

3. synchronized를 이해하자

웹 기반의 시스템에서 스레드 관련 부분 중 가장 많이 사용하는 것은 synchronized일 것이다. synchronized는 하나의 객체에 여러 객체가 동시에 접근하여 처리하는 상황이 발생할 때 사용한다.

하나의 객체에 여러 요청이 동시에 달려들면 원하는 처리를 하지도 못하고, 이상한 결과가 나올 수 있다. 그래서 synchronized를 해당 메서드나 블록에서 사용해서 동기화를 하는 것이다. 또한 절대로 생성자의 식별자로는 사용할 수 없다.

public synchronized void sampleMethod() {
    // 중간 생략
}

private Object obj = new Object();
public void sampleBlock() {
    synchronized(obj) {
        // 중간 생략
    }
}

이처럼 간단히 synchronized라는 식별자만 쓰면 동기화할 수 있다. 메서드를 동기화하려면 메서드 선언부에 사용하면 된다. 특정 부분을 동기화하려면 해당 블록에만 선언을 해서 사용하면 된다. 그럼 언제 동기화를 사용해야 할까?

  • 하나의 객체를 여러 스레드에서 동시에 사용할 경우
  • static으로 선언한 객체를 여러 스레드에서 동시에 사용할 경우

4. 동기화는 이렇게 사용한다 - 동일 객체 접근 시

간단한 예를 들어보자. 여러 기부자(Contibutor)가 어떤 기부금을 처리하는 단체(Contribution)에 기부금을 내는 상황을 가정한다. 기부금을 내는 사람은 스레드로 구현되며, 기부금을 내는 사람의 이름 정보가 있어야 한다. 기부금을 받는 단체는 기부금을 donate()라는 메서드를 제공한다. 기부한 전체 기부금을 확인하는 메서드는 getTotal()이다. 먼저 기부금을 받는 단체의 클래스를 구현한 소스를 보자.

package com.perf.thread;

public class Contribution {
    private int amount = 0;
    public void donate() {
        amount++;
    }
    public int getTotal() {
        return amount;
    }
}

기부금은 계속 축적되어야 하므로, amount라는 변수로 선언되어 있다. 그럼 기부금을 내는 사람의 클래스를 구현한 소스를 보자.

package com.perf.thread;

public class Contributor extends Thread {
    private Contribution myContribution;
    private String myName;
    public Contributor(Contribution contribution, String name) {
        myContribution = contribution;
        myName = name;
   }
   public void run() {
       for(int loop=0; loop<1000; loop++) {
           myContribution.donate();
       }
       System.out.format("%s total=%d\n", myName, myContribution.getTotal());
   }

}

소스를 보면, 1인당 1원씩 1,000번 기부하고, 기부가 완료되면 현재까지 쌓인 기부금을 프린트하도록 되어 있다. 다음 예제는 기부를 하도록 하는 코드이다.

package com.perf.thread;

public class ContributeTest {
    public static void main(String[] args) {
        Contributor[] crs = new Contributor[10];
        // 기부자와 기부 단체 초기화
        for(int loop=0; loop<10; loop++) {
            Contribution group = new Contribution();
            crs[loop] = new Contributor(group, " Contributor" + loop);
        }
        // 기부 실행
        for(int loop=0; loop<10; loop++) {
            crs[loop].start();
        }
    }
}

이렇게 수행하면 기부금을 받는 단체인 group 객체를 매번 새로 생성했기 때문에, 10명의 기부자가 10개의 각기 다른 단체에 기부하는 상황이 된다. 수행된 결과를 보면 다음과 같다.

 Contributor0 total=1000
 Contributor9 total=1000
 Contributor8 total=1000
 Contributor6 total=1000
 Contributor7 total=1000
 Contributor5 total=1000
 Contributor2 total=1000
 Contributor1 total=1000
 Contributor4 total=1000
 Contributor3 total=1000

수행할 때 마다 결과는 다르겠지만, 각 기부자가 돈을 낸 각 기부단체에는 1,000원씩 기부되었을 것이다. 그럼 만약 기부 단체가 하나만 있을 경우에는 어떻게 될까? 앞의 ContributeTest 클래스를 다음과 같이 수정하여 매번 기부자를 생성하지 않고, 하나의 그룹을 여러 기부 단체에서 참조하도록 하면 원하는 기능이 구현될 것이다.

// 앞부분 생략
Contributor[] crs = new Contributor[10];
Contribution group = new Contribution();
for(int loop=0; loop<10; loop++) {
    crs[loop] = new Contributor(group, "Contributor" + loop);
}
// 이하 생략

예상대로라면 각 단체에서 돈을 1,000원씩 냈기 때문에, 이떤 기부자가 마지막에 수행이 되든 기부금의 총 합은 10,000원씩 되어야 한다. 실행한 결과를 보자.

 Contributor0 total=1407
 Contributor9 total=8107
 Contributor8 total=8107
 Contributor7 total=7289
 Contributor6 total=6559
 Contributor5 total=5794
 Contributor4 total=5036
 Contributor3 total=3818
 Contributor2 total=3069
 Contributor1 total=2127

이렇게 되는 이유는 10개의 Contributor 객체에서 하나의 Contribution 객체의 donate() 메서드를 동시에 접근할 수 있도록 되어 있기 때문이다.

우리가 만들고자 하는 시스템은 어쩌다 한두 번 참이 되는 시스템이 아니라, 항상 참이 되는 시스템이다. 이 오류를 수정하기 위해서는 다음과 같이 donate() 메서드에 synchronized를 써서 동기화 식별자를 추가해야 한다.

public synchronized void donate() {
    amount++;
}

이렇게 동기화 식별자를 추가하면 ContributeTest 클래스를 100번 실행하는 1,000번 실행하든 최종 값은 10,000이 될 것이다.

 Contributor0 total=1000
 Contributor6 total=10000
 Contributor2 total=9525
 Contributor5 total=9249
 Contributor8 total=9010
 Contributor4 total=8290
 Contributor7 total=8185
 Contributor9 total=6391
 Contributor3 total=6231
 Contributor1 total=4365

그럼 기부 단체에 각각 기부할 때, 하나의 단체에 동기화를 하지 않고 기부할 때, 하나의 단체에 동기화를 하고 기부할 때 세 가지 경우에 얼마나 시간 차이가 발생하는지 확인해 보자. 소요된 시간의 평균의 다음과 같다.

케이스명 각각 단체에 기부 동기화 미사용 동일 단체에 기부 동기화 미사용 동일 단체에 기부 동기화 사용 각각 단체에 기부 동기화 사용
케이스 번호 1 2 3 4
안정성 O X O O
평균 응답 속도 1.3 ms 1.3 ms 10.1 ms 2.2 ms

동일 단체에 기부하고 동기화를 사용하지 않은 경우는 정상적인 결과 값이 넘어오지 않으므로 논의할 필요가 없다. 1번 케이스는 응답 속도가 1.3ms 소요되었고, 3번 케이스는 10ms 소요되었다. 거의 7배 차이가 발생한다.

그럼 만약 1번 케이스에 synchronized를 명시하면 어떻게 될까? 이 경우에는 2.2ms가 소요된다. 필요 없는 부분에 synchronized를 사용하면 약간이지만 성능에 영향을 준다는 의미이다.

또한 대부분의 프로그램에서 동기화를 부여한 메서드는 이렇게 간단하지 않다. 약간의 대기 시간을 주기 위해서 1번과 3번 케이스의 donate() 메서드에 1000ns씩 쉬도록 Thread.sleep(0,1000);을 추가하자. 물론 sleep() 메서드는 try~catch로 묶어 주어야 정상적으로 컴파일이 완료될 것이다. 결과를 비교해보면 다음과 같다.

케이스명 각각 단체에 기부 동기화 미사용 sleep 1000 ns 동일 단체에 기부 동기화 사용 sleep 1000 ns 각각 단체에 기부 동기화 사용 sleep 1000 ns
케이스 번호 1 3 4
평균 응답 속도 1.953초 16.105초 1.954초

대기 시간을 넣으니 응답 시간이 많이 증가하였다. 1번과 4번 케이스는 1.9초로 많은 차이가 발생하지 않는다. 그러나 3번 케이스는 16초나 소요된다. 필요한 부분에 동기화를 했지만, 응답 속도에 너무 많은 차이가 난다. 그러므로 반드시 필요한 부분에는 동기화를 사용해야 이와 같은 성능 저하를 줄일 수 있다.

5. 동기화는 이렇게 사용한다 - static 사용 시

또 한 가지, static을 사용하는 경우에 동기화를 사용한다. 앞서 살펴본 예제에서 amount를 static으로 선언하고 synchronized를 사용하면 어떻게 보자.

package com.perf.thread;

public class ContributionStatic {
    private static int amount = 0;
    public void donate() {
        amount++;
    }
    public int getTotal() {
        return amount;
    }
}

이렇게 static으로 amount를 선언하였다. 각 단체에 기부하는 케이스를 고려해보자.

package com.perf.thread;

public class Contributor extends Thread {
  private ContributionStatic myContribution;
  private String myName;
  public Contributor(ContributionStatic contribution, String name) {
    myContribution = contribution;
    myName = name;
  }
  public void run() {
    for(int loop=0; loop<1000; loop++) {
      myContribution.donate();
    }
    System.out.format("%s total=%d\n", myName, myContribution.getTotal());
  }

}

각 단체에 기부하기 위해서 그룹을 각 기부자별로 부여해 놓았다. 이 메서드를 수행한 결과를 보자.

 Contributor0 total=1986
 Contributor8 total=9507
 Contributor7 total=9236
 Contributor9 total=8559
 Contributor2 total=7511
 Contributor6 total=6986
 Contributor5 total=5986
 Contributor4 total=4986
 Contributor3 total=3986
 Contributor1 total=2986

우리가 원한 결과가 나오지 않는다. 각 단체에 기부하는 케이스라고 하더라도, amount를 static으로 선언하면 객체의 변수가 아닌 클래스의 변수가 된다. 따라서 아무리 여러 단체가 있더라도 하나의 amount에 값을 지정하게 되므로 이렇게 사용해서는 절대 안 된다. 만약 amount가 static이라면, 각 단체에 따로 기부하는 것은 구현이 불가능하다.

그럼 synchronized만 쓰면 해결이 될까? donate() 메서드에 synchronized를 추가하고 실행해보면 다음과 같은 결과를 얻는다.

package com.perf.thread;

public class ContributionStatic {
  private static int amount = 0;
  public synchronized void donate() {
    amount++;
  }
  public int getTotal() {
    return amount;
  }
}
 Contributor2 total=2220
 Contributor9 total=8294
 Contributor8 total=7551
 Contributor7 total=6301
 Contributor5 total=5301
 Contributor6 total=5205
 Contributor3 total=4738
 Contributor1 total=2911
 Contributor4 total=3614
 Contributor0 total=2188

희한하게도 우리가 원하는 결과 값이 나오지 않는다. synchronized는 각각의 객체에 대한 동기화를 하는 것이기 때문에, 이렇게 하면 각각의 단체에 대한 동기화는 되겠지만 amount에 대한 동기화는 되지 않는다. amount는 클래스의 변수이지, 객체의 변수가 아니다. 그래서 다음과 같이 수정하였다.

package com.perf.thread;

public class ContributionStatic {
  private static int amount = 0;
  public static synchronized void donate() {
    amount++;
  }
  public int getTotal() {
    return amount;
  }
}

amount는 클래스 변수이므로 메서드도 클래스 메서드로 참조하도록 static을 추가해 주어야 한다. 이렇게 하고 나서 결과를 보면 다음과 같다.

 Contributor0 total=3142
 Contributor8 total=10000
 Contributor7 total=9999
 Contributor4 total=9309
 Contributor3 total=9146
 Contributor9 total=8860
 Contributor5 total=8668
 Contributor1 total=6181
 Contributor6 total=5629
 Contributor2 total=5476

원하는 대로 결과가 나왔다. 항상 변하는 값에 대하여 static으로 선언하여 사용하면 굉장히 위험하다. synchronized도 꼭 필요할 때만 사용해야 한다.

6. 동기화를 위해서 자바에서 제공하는 것들

스레드 관련 클래스와 메서드, 기법은 여러 가지가 있지만 JDK 5.0부터 추가된 java.util.concurrent 패키지에 대해 알아보자. 이 패키지에는 주요 개념 4가지가 포함되어 있다.

  • Lock : 실행 중인 스레드를 간단한 방법으로 정지시켰다가 실행시킨다. 상호 참조로 인해 발생하는 데드락을 피할 수 있다.
  • Executors : 스레드를 더 효율적으로 관리할 수 있는 클래스들을 제공한다. 스레드 풀도 제공하므로, 필요에 따라 유용하게 사용할 수 있다.
  • Concurrent 콜렉션 : 콜렉션의 클래스들을 제공한다.
  • Atomic 변수 : 동기화가 되어 있는 변수를 제공한다. 이 변수를 사용하면, synchronized 식별자를 메서드에 지정할 필요 없이 사용할 수 있다.

7. JVM 내에서 synchronization은 어떻게 동작할까?

자바의 HotSpot VM은 '자바 모니터(monitor)'를 제공함으로써 스레드들이 '상호 배제 프로토콜(mutual exclusion protocol)'에 참여할 수 있도록 돕는다. 자바 모니터는 잠긴 상태(lock)이나 풀림(unlocked) 중 하나이며, 동일한 모니터에 진입한 여러 스레드들 중에서 한 시점에는 단 하나의 스레드만 모니터를 가질 수 있다. 다시 말하면, 모니터를 가진 스레드만 모니터에 의해서 보호되는 영역에 들어가서 작업을 할 수 있다. 여기서 보호된 영역이란 synchronized로 감싸진 블록들을 의미한다. 모니터를 보유한 스레드가 보호 영역에서의 작업을 마치면, 모니터는 다른 대기중인 스레드에게 넘어간다.

JDK 5부터는 -XX:+UseBiasedLocking이라는 옵션을 통해서 biased locking이라는 기능을 제공한다. 그 전까지는 대부분의 객체들이 하나의 스레드에 의해서 잠기게 되었지만, 이 옵션을 키면 스레드가 자기 자신을 향하여 bias 된다. 즉, 이 상태가 되면 스레드는 많은 비용이 드는 인스트럭션 재배열 작업을 통해서 잠김과 풀림 작업을 수행할 수 있다. 이 작업들은 진보된 적응 스피닝(adaptive spinning) 기술을 사용하여 처리량을 개선시킬 수 있다고 한다.

HotSpot VM에서 대부분의 동기화 작업은 fast-path 코드 작업을 통해서 진행한다. 만약 여러 스레드가 경합을 일으키는 상황이 발생하면 이 fast-path 코드는 slow-path 코드 상태로 변환된다. 참고로 slow-path 코드는 C++ 코드로 되어 있으며, fast-path 코드는 JIT Compiler에서 제공하는 장비에 의존적인 코드로 작성되어 있다.

참고

  • 자바 성능 튜닝 이야기
profile
이것저것 관심많은 개발자.

0개의 댓글