Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

csct3434

[자바 성능 튜닝 이야기] 08. synchronized 본문

개발 서적/자바 성능 튜닝 이야기

[자바 성능 튜닝 이야기] 08. synchronized

csct3434 2024. 5. 6. 05:29

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

Thread 클래스와 Runnable 인터페이스

  • 스레드의 구현은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법 두 가지가 있다.
  • 기본적으로 Thread 클래스는 Runnable 인터페이스를 구현한 것이기 때문에 어느 것을 사용해도 거의 차이가 없다.
  • 자바는 다중 상속을 허용하지 않기 때문에, Thread 외에 상속받을 클래스가 존재한다면 Runnable 인터페이스를 구현해야 한다.
public class RunnableImpl implements Runnable {

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

public class ThreadExtends extends Thread {

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

public class RunThreads {

    public static void main(String[] args) {
        ThreadExtends threadExtends = new ThreadExtends();
        threadExtends.start();
        
        RunnableImpl runnableImpl = new RunnableImpl();
        new Thread(runnableImpl).start();
    }
}
  • 쓰레드를 실행하기 위해서는 Thread 클래스의 start() 메서드를 호출하면 된다.
  • Runnable 인터페이스를 구현한 경우도 마찬가지로, 해당 구현체를 Thread 클래스의 생성자로 전달하여 객체를 생성한 후 start() 메서드를 호출해야 한다. 그렇지 않고 그냥 run() 메서드를 호출하면 새로운 스레드가 생성되지 않는다.

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

  • 현재 진행 중인 스레드를 대기하도록 하기 위해서는 sleep(), wait(), join() 세 가지 메서드를 사용하는 방법이 있다.
  • 이 세가지 메서드 모두 예외를 던지도록 되어 있어 사용할 때는 반드시 예외 처리를 해주어야 한다.

sleep()

java.lang.Thread.sleep(long millis);
java.lang.Thread.sleep(long millis, int nanos);
  • static 메서드로, 명시된 시간 만큼 현재 스레드를 대기시킨다.

wait()

java.lang.Object.wait();
java.lang.Object.wait(long timeoutMillis);
java.lang.Object.wait(long timeoutMillis, int nanos);
  • Object 클래스의 메서드로, 명시된 시간 만큼 대상 스레드를 대기시킨다.
  • 매개변수가 지정되지 않으면 notify() 혹은 notifyAll() 메서드가 호출될 때까지 대기한다.
  • wait(), notify(), notifyAll() 메서드는 객체의 동기화와 관련된 메서드로, 모니터 락을 관리하며 스레드 간의 통신에 사용된다.

join()

java.lang.Thread.join();
java.lang.Thread.join(long millis);
java.lang.Thread.join(long millis, int nanos);
  • 명시된 시간 만큼 대상 스레드가 종료될 때까지 대기한다.
  • 매개변수가 지정되지 않으면 대상 스레드가 종료될 때 까지 계속 대기한다.

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

interrupt()

  • sleep(), wait(), join() 메서드를 멈추는 데 사용되는 메서드이다.
  • interrupt() 메서드가 호출되면 중지된 스레드에는 InterruptedException 체크 예외가 발생한다.
  • 인터럽트가 발생했는지 확인하려면 interrupted() 메서드를 호출하거나 isInterrupted() 메서드를 호출하면 된다.
    • java.lang.Thread.interrupted() 
      • 현재 쓰레드의 인터럽트 발생 여부를 반환하는 static 메서드로, 해당 메서드가 호출되면 쓰레드의 인터럽트 상태는 초기화된다.
      • 따라서, interrupted() 메서드를 호출한 직후 이를 재호출하면 false가 반환된다.
    • java.lang.Thread.isInterrupted()
      • Thread 클래스의 멤버 메서드로, 현재 쓰레드의 인터럽트 여부를 반환한다.

notify(), notifyAll()

  • 둘 모두 wait() 메서드를 멈추기 위해 호출되는 메서드이다.
  • notify() : 객체의 모니터와 관련 있는 단일 스레드를 깨운다.
  • notifyAll() : 객체의 모니터와 관련 있는 모든 스레드를 깨운다.

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

interrupt() 스레드를 호출하여 특정 메서드를 중단하려 할 때, 그 결과는 보장되지 않는다.

interrupt() 메서드의 Javadoc을 살펴보면, 위와 같은 설명이 적혀있다.

wait(), join(), sleep() 메서드를 통해 block된 스레드의 경우 interrupt() 메서드를 통해 InterruptedException이 발생하지만, 그렇지 않은 경우 해당 예외가 발생하지 않고 인터럽트 상태만 true로 세팅된다는 것이다. 즉, interrupt() 메서드는 단순하게 스레드를 중지시키는 방식으로 동작하는 것이 아니라, block 상태에 있는 특정 스레드를 중지시키는 메서드이다.

이로 인해, block되지 않은 스레드를 대상으로 interrupt() 메서드를 호출하면 인터럽트 상태만 변경되고 스레드는 중단되지 않는다.

public class InfiniteThread extends Thread {

    @Override
    public void run() {
        int value = Integer.MIN_VALUE;
        while(true) {
            value++;
            if (value == Integer.MAX_VALUE) {
                value = Integer.MIN_VALUE;
                System.out.println("MAX_VALUE reached!");
            }
        }
    }
}

public class RunThreads {

    public static void main(String[] args) throws InterruptedException {
        Thread infiniteThread = new InfiniteThread();

        infiniteThread.start();
        Thread.sleep(2000);

        System.out.println("isInterrupted=" + infiniteThread.isInterrupted());
        infiniteThread.interrupt();
        System.out.println("isInterrupted=" + infiniteThread.isInterrupted());
    }
}
----- 출력 결과 -----
MAX_VALUE reached!
MAX_VALUE reached!
isInterrupted=false
isInterrupted=true
MAX_VALUE reached!
MAX_VALUE reached!
...
  • infiniteThread를 대상으로 interrupt() 메서드를 호출한 결과, 인터럽트 상태만 true로 변경되었을 뿐 쓰레드는 중단되지 않고 계속해서 실행되는 모습을 볼 수 있었다.
public class InfiniteThread extends Thread {

    @Override
    public void run() {
        int value = Integer.MIN_VALUE;
        while(true) {
            value++;
            if (value == Integer.MAX_VALUE) {
                value = Integer.MIN_VALUE;
                System.out.println("MAX_VALUE reached!");
            }

            try {
                Thread.sleep(0, 1);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}
----- 출력 결과 -----
isInterrupted=false
isInterrupted=true

Process finished with exit code 0
  • InfiniteThread를 while 루프가 수행될 때 1 나노초 만큼 대기했다가 수행되도록 수정했다.
  • sleep() 메서드 호출로 인해 스레드가 block 되기 때문에, interrupt() 메서드가 호출되면 스레드가 바로 멈추는 것을 볼 수 있다.

synchronized

  • synchronized를 제대로 이해하지 못한 채로 이를 남용하면 성능에 악영향을 미칠 수 있다
public synchronized void sampleMethod() {
	...
}

private Object obj = new Object();
public void sampleBlock() {
    synchronized(obj) {
    	...
    }
}
  • synchronized는 메서드와 블록 단위로 동기화 하는데 사용될 수 있다. 하지만 생성자의 식별자로는 사용할 수 없다.
  • 그럼 언제 동기화를 사용해야 할까? 아래의 경우가 아니라면 동기화를 할 필요가 별로 없다. 
    • 하나의 객체를 여러 스레드에서 동시에 사용할 경우
    • 하나의 static 객체를 여러 스레드에서 동시에 사용할 경우

synchronized 성능 비교

public class Contribution {
    private int amount = 0;

    public void donate() {
        amount++;
    }
    public int getTotal() {
        return amount;
    }
}

public class Contributor extends Thread {
    private final Contribution contribution;
    private final String name;

    public Contributor(Contribution contribution, String name) {
        this.contribution = contribution;
        this.name = name;
    }
    public void run() {
        for (int loop = 0; loop < 1000; loop++) {
            contribution.donate();
        }
        System.out.format("[%s] total = %d\n", name, contribution.getTotal());
    }
}


public class ContributeTest {

    public static void main(String[] args) {
        Contribution contribution = new Contribution();
        Contributor[] contributors = new Contributor[10];
        
        for (int i = 0; i < 10; i++) {
            contributors[i] = new Contributor(contribution, "Contributor" + i);
        }

        for (int loop = 0; loop < 10; loop++) {
            contributors[loop].start();
        }
    }

}
  • 여러 기부자(Contributor)가 특정 기부 단체(Contribution)에 기부금을 내는 상황을 구현한 코드이다.

 

Contribution의 donate() 메서드에 synchronized를 적용하기 전과 후를 비교하면 다음과 같다.

대상 synchronized 적용 전 synchronized 적용 후
안정성 X O
총 기부금 (total) 9707 10000
평균 응답 속도 1.3 ms 10.1 ms
  • synchronized 적용 전 : 동시성 문제가 발생하여 총 기부금이 예상한 값과 다르게 나왔다.
  • synchronized 적용 후 : 동시성 문제는 발생하지 않았지만, synchronized 적용 전보다 약 7배의 느린 응답 시간을 보였다.
  • 그러므로 반드시 필요한 부분에만 synchronized를 적용해야 성능 저하를 줄일 수 있을 것이다.

synchronized static

public class Contribution {
    // private int amount = 0;
    private static int amount = 0;

    public synchronized void donate() {
        amount++;
    }
    public int getTotal() {
        return amount;
    }
}

// Contributor: 동일


public class ContributeTest {

    public static void main(String[] args) {
        // Contribution contribution = new Contribution();
        Contributor[] contributors = new Contributor[10];
        
        for (int i = 0; i < 10; i++) {
            Contribution contribution = new Contribution();
            contributors[i] = new Contributor(contribution, "Contributor" + i);
        }

        for (int loop = 0; loop < 10; loop++) {
            contributors[loop].start();
        }
    }

}
  • Contribution의 amount가 static으로 선언되고 Contributor간에 Contribution 객체를 공유하지 않는 경우, donate() 메서드에 synchronized를 적용해도 동기화가 이루어지지 않는다.
  • non-static synchronized 메서드의 경우, 해당 메서드가 호출된 객체의 모니터 락을 이용하여 동시에 한 스레드만이 해당 메서드를 실행할 수 있도록 보장한다.
  • 위 코드를 보면, 각 Contributor마다 서로 다른 Contribution 객체를 할당받고 있다. 그러므로 여러 쓰레드가 동시에 donate() 메서드를 호출해도, 모든 쓰레드가 각자만의 Contribution 객체를 통해 모니터 락을 획득하여 동기화가 이루어지지 않는 것이다.
public synchronized static void donate() {
    amount++;
}
  • static synchronized 메서드의 경우, 클래스 수준의 모니터 락을 이용하여 스레드간에 동기화를 수행한다.
  • 따라서 클래스 변수인 amount에 대한 동기화를 적용하려면, 객체 레벨이 아닌 클래스 레벨의 모니터 락을 통해 동기화가 이루어지도록 donate() 메서드를 synchronized 'static'으로 선언해 주어야 한다.