자바 성능 튜닝 - Thread, synchronized By starseat 2024-05-08 18:31:15 java/spring Post Tags [자바 성능 튜닝 이야기](https://product.kyobobook.co.kr/detail/S000001032977) 책을 읽고 정리한 내용 입니다. --- 여러개의 스레드를 동작시킬때 synchronized 를 자주 사용한다. 하지만 synchronized를 쓴다고 무조건 안정적인 것은 아니며, 성능에 영향을 미치는 부분도 있다. # 자바에서의 스레드(Thread) 자바에서 스레드의 구현은 **Thread** 클래스를 상속받는 방법과 **Runnable** 인터페이스를 구현하는 방법 두 가지가 있다. 기본적으로 **Thread** 클래스는 **Runnable** 인터페이스를 구현한 것이기 때문에 어느 것을 사용해도 거의 차이가 없다. 대신 **Runnable** 인터페이스를 구현하면 원하는 기능을 추가할 수 있다. 이는 장점이 될 수도 있지만 해당 클래스를 수행할 때 별도의 스레드 객체를 생성해야 한다는 점은 단점이 될 수도 있다. 또한 자바는 다중 상속을 인정하지 않아 스레드를 사용해야 할 때 이미 상속받은 클래스가 존재한다면 **Runnable** 인터페이스로 구현해야 한다. ## 스레드 구현 예제 - Runnable ```java public class RunnableImpl implements Runnable { public void run() { System.out.println("This is RunnableImpl."); } } ``` - Thread ```java public class ThreadExtends extends Thread { public void run() { System.out.println("This is ThreadExtends."); } } ``` - 실행 ```java public class RunThreads { public static void main(String[] args) { RunnableImpl ri = new RunnableImpl(); ThreadExtends te = new ThreadExtends(); new Thread(ri).start(); // Thread 생성자를 이용하여 Runnable 실행 te.start(); } } ``` ## 스레드 대기 - sleep(), wait(), join() `sleep()`, `wait()`, `join()` 세가지 메서드를 사용하여 실행중인 스레드를 대기하도록 할 수 있다. 이 세가지 메서드들은 모두 예외를 던지도록 되어 있어 사용할 때는 반드시 예외 처리를 해야 한다. ### sleep() 명시된 시간만큼 해당 스레드를 대기시킨다. - **sleep(long millis)** - 명시된 ms 만큼 해당 스레드가 대기한다. - **static 메서드**이기 때문에 반드시 스레드 객체를 통하지 않아도 사용할 수 있다. - **sleep(long millis, int nanos)** - 명시된 ms + 면시된 nano 시간만큼 해당 스레드가 대기한다. - 여기서 나노 시간은 0~999999 까지 사용할 수 있다. - 이 메서드도 static 메서드 이다. ### wait() 모든 클래스의 부모 클래스인 **Object** 클래스에 선언되어 있으므로 어떤 클래스에서도 사용할 수 있다. 명시된 시간만큼 해당 스레드를 대기시키지만, **만약 아무런 매개변수를 지정하지 않으면 nofify() 메서드 또는 notifyAll() 메서드가 호출될 때까지 대기** 한다. 대기 시간은 sleep() 메서드와 동일하다. ### join() 명시된 시간만큼 해당 스레드가 죽기를 기다린다. 만약 매개변수를 지정하지 않으면 죽을 때까지 계속 대기한다. ## 스레드 대기 멈춰! - interrupt(), notify(), notifyAll() ### interrupt() **interrupt()** 는 앞의 sleep(), wait(), join() 메서드를 '모두' 멈출 수 있는 유일한 메서드이다. interrupt() 메서드가 호출되면 중지된 스레드에는 **InterruptedException** 이 발생한다. 제대로 수행되었는지 확인하려면 `interrupted()` 메서드나 `isInterrupted()` 메서드를 호출하면 된다. `interrupted()` 메서드는 스레드의 상태를 변경시키지만, `isInterrupted()` 메서드는 단지 스레드의 상태만을 반환한다. 추가로 `ㅑisAlive()` 메서드가 있는데 이는 해당 스레드가 살아 있는지 확인 하는 메서드이다. ### notify(), notifyAll() **notify()** 와 **notifyAll()** 은 wait() 메서드를 멈추기 위한 용도이다. 이 두 메서드는 **Object** 클래스에 정의되어 있는데, wait() 메서드가 호출된 후 대기 상태로 바뀐 쓰레드를 깨운다. **notify()** 메서드는 단일 스레드를 꺠우며, **notifyAll()** 메서드는 모든 스레드를 깨운다. ### 예제 ```java public class Sleep extends Thread { public void run() { try { Thread.sleep(1000 * 10); // 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 count = 0; while(count < 5) { // 1초식 대기하며 스레드가 죽기를 기다림 s.join(1000); // 1초 대기 count++; System.out.format("%d second waited.\n", count); } if(s.isAlive()) { // 스레드 살아있는지 확인 s.interrupt(); // 살아있으면 스레드 죽임 } } catch(Exception e) { e.printStackTrace(); } } } ``` ## interrupt() 메서드는 절대적인 것이 아니다 interrupt() 메서드를 호출하여 특정 메서드를 중지시키려고 해도 항상 멈추지는 않는다. **interrutp() 메서드는 해당 스레드가 'block' 상태가 되거나 특정 상태에서만 작동하기 때문이다.** - 관련 내용: [Class Thread](https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html) 의 **interrupt** 부분 ### 예제 스레드를 실행하고 2초 후에 interrupt() 메서드를 호출하는 예제이다. - InfinitThread ```java 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!"); } } } } ``` - InterruptSampe ```java public class InterruptSample { public static void main(String[] args) throws Exception { InfinitThread infinit = new InfinitThread(); infinit.start(); Thread.sleep(2000); System.out.println("isInterrupted (before interrupt()) = " + infinit.isInterrupted()); infinit.interrupt(); System.out.println("isInterrupted (after interrupt()) = " + infinit.isInterrupted()); } } ``` 여기서 InterruptSample 를 수행하면 interrupt() 메서드를 호출하였을때 이 스레드는 멈추지 않는다. **'해당 스레드가 block된 상태에서만 작동된다.'** 라고 하였듯이 interrupt() 는 대기 상태일때만 해당 스레드를 중단시키기 때문이다. ### 예제 2 - 안전장치 - flag - InfinitThread - flag 변경 메서드 추가 ```java 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!"); } } } // flag 변경 메서드 추가 public void setFlag(boolean flag) { this.flag = flag; } } ``` - InterruptSampe ```java public class InterruptSample { public static void main(String[] args) throws Exception { InfinitThread infinit = new InfinitThread(); infinit.start(); Thread.sleep(2000); System.out.println("isInterrupted (before interrupt()) = " + infinit.isInterrupted()); infinit.interrupt(); System.out.println("isInterrupted (after interrupt()) = " + infinit.isInterrupted()); infinit.setFlag(false); // flag 변경 } } ``` InterruptSample 를 시작하고 2초 후에 interrupt() 메서드가 호출된다. **flag 값이 false 가 되기 때문에 바로 멈추게 된다.** ### 예제 3 - 다른 안전장치 - sleep() 추가 - InfinitThread ```java 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!"); } // sleep() 추가 try { Thread.sleep(0, 1); } catch(Exception e) { break; } } } } ``` InterruptSampe 는 동일하다. 위 소스를 보면 `Thread.sleep(0, 1);` 부분이 추가 되었는데, 이는 while 루프가 수행될 때 1 나노초 만큼 대기했다가 수행하게 한다. 성능 저하는 발생하지만, interrupt() 메서드가 호출되면 이 스레드는 바로 멈추게 된다. # synchronized > synchronize 의 사전적 의미: (v) 동시에 일어나다. 동시에 진행하다. synchronized 는 하나의 객체에 여러 객체가 동시에 접근하여 처리하는 상황이 발생할 때 사용한다. 하나의 객체에 여러 요청이 동시에 발생하면 원하는 처리를 하지 못하고 이상한 결과가 나올 수 있다. 그래서 synchronized 를 사용하여 동기화를 하는 것이다. synchronized 를 사용하면 한번에 한개의 처리만 하게 된다. synchronized 는 메서드와 블록에 사용 할 수 있다. ```java public synchronized void sampleMethod() { // ... } provate Object obj = new Object(); public void sampleBlock() { synchronized(obj) { // ... } } ``` 위 예제코드처럼 **메서드와 블록부분에 `synchronized` 라는 식별사를 써서 사용** 할 수 있다. 메서드를 동기화 하려면 메서드 선언부에 식별자를 붙이면 되고, 메서드 내의 특정 부분에 사용하려면 해당 블록에만 선언해서 사용하면 된다. 그렇담 동기화는 언제 사용해야 될까? - 하나의 객체를 여러 스레드에서 동시에 사용할 경우 - static 으로 선언한 객체를 여러 스레드에서 동시에 사용할 경우 이렇게 두가지 이다. (이 두가지가 아니면 동기화를 사용할 필요가 없다.) ## 동기화 사용 - 동일 객체 접근 시 이해를 빠르게 하기 위해 상황을 만들었다. 기부금을 내는 상황을 가정하면 기부금을 내는 사람(Contributor)과 기부금을 받는 단체(Contribution) 가 있을 것이다. 기부자를 스레드로 구현하며, 기부자의 이름과 정보가 있어야 할 것이다. 기부금을 받는 단체는 기부금을 받을 창구로 donate()라는 메서드를 제공한다. ### 기부자 및 기부 단체 예제 소스 - 기부금을 받는 단체(Contribution) 의 예제 소스 ```java public class Contribution { private int amount = 0; public void donate() { amount++; } public int getTotal() { return amount; } } ``` - 기부금을 내는 사람(Contributor) 의 에제 소스 ```java public class Contributor extends Thread { private Contribution contribution; private String name; public Contributor(Contribution contribution, String name) { this.contribution = contribution; this.name = name; } public void run() { for(int i=0; i<1000; i++) { contribution.donate(); } System.out.format("%s total=%d\n", name, contribution.getTotal()); } } ``` 위 소스를 보면 1인당 1원씩 1000번 기부하고, 기부가 완료되면 현재까지 쌓인 기부금을 출력하도록 되어 있다. ### 기부 실행 ```java public class ContributeTest { public static void main(String[] args) { Contributor[] crs = new Contributor[10]; // 기부자 10명 Contribution group = new Contribution(); // 기부 단체는 1개 // 기부자와 기부 단체 초기화 for(int i=0; i<10; i++) { crs[i] = new Contributor(group, " Contributor" + i); } // 기부 실행 for(int i=0; i<10; i++) { crs[i].start(); } } } ``` 10명의 기부자가 1개의 기부 단체에 기부하는 상황이다. 얼핏 보면 마지막 수행이 되는 기부금의 총 합은 10,000 원이 되어야 하지만 결과는 그렇지 않다. - 실행 결과 ```text Contributor0 total=1000 Contributor8 total=9707 Contributor9 total=8653 Contributor7 total=7135 Contributor4 total=6874 Contributor5 total=5578 Contributor6 total=5424 Contributor2 total=3964 Contributor3 total=3112 Contributor1 total=2000 ``` 수행 할때마다 위 결과는 다를 것이다. 위 결과를 봤을때는 최대값이 10,000 이 아닌 9,707이다. 이렇게 되는 이유는 10개의 Contributor 객체에서 하나의 Contribution 객체의 donate() 메서드를 동시에 접근할 수 있도록 되어있기 때문이다. 예제와 같은 오류를 수정하기 위해서는 **donate() 메서드에 synchronized를 써서 동기화**를 해야 한다. ```java public synchronized void donate() { amount++; } ``` 이렇게 동기화 식별자를 추가하면 ContributeTest 클래스를 몇번이고 실행해도 최종값은 10,000 이 될 것이다. ### 기부 예제의 소요 시간 각가 기부 단체에 동기화 적용, 미적용 건을 추가하여 테스트 진행할 시의 결과이다. | 케이스 | 각각 단체에 기부동기화 X | 각각 단체에 기부동기화 O | 동일 단체에 기부동기화 X | 동일 단체에 기부동기화 O | | -- | -- | -- | -- | -- | | 케이스 번호 | 1 | 2 | 3 | 4 | | 안정성 | O | O | X | O | | 평균 응답 속도 | 1.3 ms | 2.2 ms | 1.3 ms | 10.1 ms | 동기화를 사용하면 약간의 성능 영향이 있음을 볼 수 있다. 그러므로 반드시 필요한 부분에만 동기화를 사용해야 된다. ## 동기화 사용 - static 위 예제에서 donate() 의 동기화가 아닌 static 변수에 동기화를 사용할 수도 있다. ```java public class Contribution { private static int amount = 0; // static 적용 ``` 이 상태로 실행하면 amount 가 객체의 변수가 아닌 클래스의 변수가 될 뿐이라 원하는 결과를 얻을 수 없다. 그렇다면 다음과 같이 donate() 에도 동기화를 설정하면 어떨까? ```java public class Contribution { private static int amount = 0; // static 적용 public synchronized void donate() { // 동기화 적용 amount++; } ``` 희한하게도 원하는 결과값이 나오지 않는다. 이렇게 하면 단체에 대한 동기화는 되겠지만 amount 에 대한 동기화는 되지 않는다. (다시 한번더 언급하지만 amount 가 클래스의 변수이지 객체의 변수가 아니기 때문이다.) 그래서 다음과 같이 수정하여야 한다. ```java public class Contribution { private static int amount = 0; // static 적용 public static synchronized void donate() { // static 및 동기화 적용 amount++; } ``` 이렇게 해야 donate() 가 클래스 메소드가 되어 클래스 변수인 amount 를 동기화 처리를 할 수 있게 된다. # 동기화를 위해 자바에서 제공하는 것들 jdk 5.0 에 추가된 java.util.concurrent 패키지에 대해서 간단히 언급한다. 자세한 내용은 [https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html](https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html) 또는 구글링 해서 참조하면 된다. - **Lock** - 실행 중인 스레드를 간단한 방법으로 정지시켰다가 실행시킨다. - 상호 참조로 인해 발생하는 데드락을 피할 수 있다. - **Executors** - 스레드를 더 효율적으로 관리할 수 있는 클래스들을 제공한다. - 스레드 풀도 제공하므로 필요에 따라 유용하게 사용할 수 있다. - **Concurrent** - 콜렉션의 클래스들을 제공한다. - **Atomit** 변수 - 동기화가 되어 있는 변수를 제공한다. - 이 변수를 사용하면 synchronized 식별자를 메서드에 지정할 필요 없이 사용할 수 있다. # JVM 내에서 sycnronization 동작 방법 자바의 **HotSpot VM** 은 '자바 모니터(java monitor)'를 제공함으로써 스레드를 '상호 배제 프로토콜(mutual exclusion protocol)'에 참여할 수 있도록 돕는다. 자바 모니터는 잠긴 상태(lock)나 풀림(unlock) 중 하나이며, 동일한 모니터에 진입한 여러 스레드들 중에서 한 시점에는 단 하나의 스레드만 모니터를 가질 수 있다. 즉 모니터를 가진 스레드만 모니터에 의해서 보호되는 영역에 들어가서 작업을 할 수 있다. (여기서 보호되는 영역이란 synchronized로 감싸진 블록들을 의미한다.) 모니터를 보유한 스레드가 보호 영역에서의 작업을 마치면 모니터는 다른 대기중인 스레드에게 넘어간다. JDK 7 부터는 **-XX:+UseBiasedLocking** 라는 옵션을 통해 biased locking 이라는 기능을 제공한다. 그 전까지는 대부분의 객체들이 하나의 스레드에 의해서 잠기게 되었지만 이 옵션을 키면 스레드가 자기 자신을 향하여 bias 된다. 즉, 이 상태가 되면 스레드는 많은 비용이 드는 인스트럭션 재배열 작업을 통해서 잠김과 풀림 작업을 수행할 수 있게 된다. 이 작업들은 진보된 적응 스피닝(adaptive spinning) 기술을 사용하여 처리량을 개선시킬 수 있다고 한다. 결과적으로 동기화 성능이 보다 빨라졌다. HotSpot VM에서 대부분의 동기화 작업은 fast-path 코드 작업을 통해서 진행한다. 만약 여러 스레드가 경합을 일으키는 상황이 발생하면 이 fast-path 코드는 slow-path 코드 상태로 변환한다. 참고로 slow-path 구현은 C++ 코드로 되어 있으며, fast-path 코드는 JIT compoiler 에서 제공하는 장비에 의존적은 코드로 작성되어 있다. Previous Post 자바 성능 튜닝 - 클래스 정보 Next Post 자바 성능 튜닝 - IO 에서 발생하는 병목 현상