스레드 그룹(thread group)은 원래 applet을 격리시켜 보안문제 해결할 목적으로 만들어졌으나 성공하지 못했다.

그러면 이런 스레드 그룹은 왜 남아있는가? 아예 쓸곳이 없는가? 그렇지는 않다. 스레드 기본연산을 여러 스레드에 동시에 적용할 수 있도록 하는 기능을 가지고 있다. 하지만 대부분이 deprecated 되었다.

결론을 이야기하자면 이미 다 페기가 되어버린 기능이다. 그렇기 때문에 신경쓸 것 없이 사용하지 말아야한다.

 

 

출처 : 조슈아  블로크, 『 Effective Java 2/E』, 이병준 옮김, 인사이트(2014.9.1)

실행해야 할 스레드가 많을 경우 어떠한 스레드를 얼마나 오랫동안 실행할지 결정은 스레드 스케줄러가 진행한다.

운영체제마다 스레드 스케줄러는 다르기 때문에 아무리 운영체제에서 효율적으로 진행한다고 하더라고 이에 의존하여 프로그램을 제작해서는 안된다.

정확하고 좋은 스레드 프로그램은 의존하는것이 아니라 실행가능한 스레드의 개수가 프로세수 개수보다 넘지 않도록 제작하는 것이다.

그렇게되면 스레드 스케줄러가 순차적으로 스레드를 실행시켜줄뿐 정책에 신경쓰지않는다.

그렇다면 실행중인 스레드의 개수를 최대한 줄일 수 있는 방법은 무엇일까?

바로 사용하지 않는 스레드는 실행하지 않고 정지하거나 종료해야한다. 그래서 바로 직전에 공부했던 스레드 풀을 사용하여 적절하게 스레드를 관리하면 좋은 프로그램을 만들 수 있다.

그렇다면 오래 반환하지 않고 잘못된 스레드 방식으로 개발하는 코드를 알아보자.

public class FailLatch {
	private int count;
	public FailLatch (int count) {
		if (count < 0) {
			throw new IllegalArgumentException(count + " < 0");
		}
		this.count = count;
	}
	
	public void await() {
		while (true) {
			synchronized (this) {
				if (count == 0) {
					System.out.println("the end");
					return;
				}
					
			}
		}
	}
	
	public synchronized void countDown() {
		if (count != 0) {
			count--;
		}
	}

}

 위의 코드를 보면 count가 0일 때까지 스레드가 놀고있어야 하는 아주 좋지 않은 프로그램이 된다. 만약 그럼 저상태에서 대기상태에서 Thread.yield를 사용한다면 조금 나아지려나?

그렇지 않다. 왜냐하면 이는 일부 JVM에서는 성능이 향상되는 것처럼 보일 수 있으나, 무조건 좋아지지 않는다. 그렇기 때문에 병렬적으로 실행 가능한 스레드 수를 애초에 줄이는것이 중요하다.

결론을 이야기하자면 프로그램의 정확성을 스레드 스케줄러에 의존하지말고 쓰레드 수를 제한하고 일부 쓰레드가 무조건 낭비되구 있는것을 방지하자. 단 Thread.yield 등과 같이 임시방편으로 무엇을 해결하려 하지 말고 근본적 문제를 해결하라.

 

출처 : 조슈아  블로크, 『 Effective Java 2/E』, 이병준 옮김, 인사이트(2014.9.1)

 

Lazy initialization(초기화 지연)은 필드 초기화를 실제로 그 값이 쓰일 때까지 미루는 것이다.


대부분 초기화 지연의 이유는 초기화의 비용이 증가하고 사용빈도가 특별한 경우에 사용하는 필드에 대해서 그렇게 적용한다.


만약 그렇지 않은 경우에도 초기화 지연을 사용하면 어떨까?

이럴 경우 클래스를 초기화하고 객체를 생성하는 비용은 줄이지만 필드 사용 비용은 증가시킨다.


그럼 동기화가 필요한 다중 스레드 환경에서는 초기화 지연은 어떻게 구현해야할까? 생각만 해도 어렵다. 


몇가지 방법을 살펴보자.

우선 초기화 지연을 사용하지 않고 진행하는 일반적인 초기화는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
 
public class TestClass {
    // 일반적인 초기화 기법
    // 클래스가 처음 로드될 때 바로 초기화
    private final int val = getValue();
    
    private int getValue() {
        return 0;
    }
}
 
cs


이 상태에서 동기화를 적용시키면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
 
public class TestClass {
    // 일반적인 초기화 기법
    // 클래스가 처음 로드될 때 바로 초기화
    private int val;
    
    synchronized int getValue() {
        return 0;
    }
}
 
cs


그렇다면 정적 필드의 초기화를 지연시키고 싶다면 어떻게 해야할까?

이럴때는 Lazy initialization holder class 숙어를 적용하면 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestClass {
    
    private static class ValueHolder {
        static final int value = getValue(); 
    }
    
    static int getData() {
        return ValueHolder.value;
    }
    
    private static int getValue() {
        return 0;
    }
}
 
cs


처음 getData()가 호출되는 순간에 getValue()가 호출되면서 초기화가 진행된다. 이럴경우 synchronized를 붙혀주지 않아도된다. 왜냐하면 최신 VM은 클래스를 초기화하기위한 필드 접근은 동기화를 진행한다.


그리고 필드를 검사를 진행할 때 무조건 적으로 동기화 하는 것보다 다음과 같이 이중검사를 사용하는 것이 좋다.


이중검사란 무엇인가? 락을 먼저 걸고 확인하면 무조건 락을 걸어야해서 비용이 증가하지만, 먼저 락없이 한번 확인하고 진행하기 때문에 부담이 줄어든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String getField() {
        String result = field;
        // 첫 번재 검사 (락 없이 진행)
        if (null == result) {
            // 두 번재 검사  (락을 사용하여 진행)
            synchronized(this) {
                result = field;
                if (result == null) {
                    field = result = getData();
                }
            }
        }
        return result;
    }
    
    String getData() {
        return "dbs";
    }
cs


위 코드에서 result를 사용하여 field값을 대입한 이유는 field 값을 한번만 읽게 하였기에 동기화 사용시에도 비용이 감소된다. 저수준 병렬 프로그래밍에서 25%가량 향상된것을 볼 수있다.


만약 여러번 초기화 되어도 크게 무리가 없는 필드에 경우에는 락을 거는 로직을 빼고 단일 검사만 진행해도 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
String getField() {
    String result = field;
    // 첫 번재 검사 (락 없이 진행)
    if (null == result) {
        field = result = getData();
    }
    return result;
}
    
String getData() {
    return "dbs";
}
cs



결론은 필요없는 필드는 초기화 지연을 사용하지 말자. 비용이 커진다. 만약 비용이 큰 초기화 방법을 적절하게 변경하고 싶은경우 위에 소개한 방식대로 초기화를 진행하라.

클래스를 사용할 때 클래스의 객체와 정적 메서드가 병렬적으로 이용되었을 때, 어떠한 부작용이 있을 수 있는지 안전한지에 대한 정보가 없으면 추후에 큰 문제를 야기할 수있다.


JavaDoc에서 synchronized 키워드를 통해 병렬설 지원 여부를 확인할 수있다고 알고 있으나 실상 그렇지 않다.


왜냐하면 Javadoc이 만드는 문서에는 Javadoc이 들어가지 않는다. 왜냐하면 synchronized 키워드는 메서드의 구현 상세에 해당하는 정보이며, 공개 API의 일부가 아니기 때문이다.


그렇기 때문에 synchronized 키워드를 통해 판단해서는 안되고 병렬적으로 사용해도 되는지의 여부는 문서에 남겨져 있어야 한다.


출처 : 조슈아 블로크, 『 Effective Java 2/E』, 이병준 옮김, 인사이트(2014.9.1), 

멀티 쓰레드 환경에서 wait와 notify를 사용할 경우에 주의가 필요하다. 하지만 그것을 효율적으로 사용하기에는 많은 어려움이 따른다.


그렇기 위해서 wait와 notify를 정확하게 사용하기 위해서는 high-level util(고수준 유틸리티)을 사용해야 한다.

이런 고수준 유틸리티들은 Executor, Concurrent Colloection, Synchronizer를 통해 사용할 수 있다.

그중 Conncurrent Collenction (병행 컬렉션)에 대해 알아보자. 대부분에 컬렉션 Map, List, Queue등은 병행 컬렉션을 제공한다. 



병행성 컬렉션

대표적으로 Map에서 제공하는 ConcurrentMap이다. 그중 putIfAbsent(key, value) 메서드가 대표적이다. 이 메서드는 키에 해당하는 값이 없을 경우에만 주어진 값을 넣고, 해당 키에 저장되어 있었던 기존의 값을 반환한다. 

속도도 일반 Map과 큰 차이가 없다. 그렇기 때문에 병행성 처리가 가능한 Map을 사용하도록 하자.



Wait와 notify, notifyAll 주의사항 및 사용법

wait를 통해 특정 스레드가 대기를 하고자 할 때는 다음과 같이 표준적 숙어를 사용해야 한다. (일반적으로 만들어진 규칙)

1
2
3
4
5
6
// wait 메서드를 사용하는 표준적 숙어
synchronized (obj) {
  while (<조건이 만족 되지 않을경우 순환문 실행>)
     obj.wait();
 
}
cs

왜냐하면 조건이 만족되지 않았는데 wait가 호출되어 스레드가 대기하게 되고 잘못된 코드로 인해 notify가 호출된 뒤에 wait가 호출된다거나 하는 경우에 영원히 깨어나지 못하는 생존오류가 발생할 수 있기 때문이다.


그래서 다른 스레드를 깨울때 notify 메서드를 사용하지만 notifyAll 메서드를 사용해서 일어나지 못한 다른 스레드를 깨워주는 것이 좋다. (notify 보다 무조건 notifyAll을 사용하라!)

하지만 그럴경우에 깨어날 필요가 없는 쓰레드를 깨우지만 프로그램의 정확성에는 영향을 끼치지 않는다. 왜냐면 위에 코드에서 while문에 보면 알겠지만 만약 조건이 만족이 되지 않으면 본인이 알아서 다시 wait 상태로 돌아가기 때문이다.


-> wait 사용시 숙어를 사용하고 notify대신 notifyAll을 사용하여 일어나지 못한 스레드를 깨워줘라.



시간 체크 메서드

마지막으로 병행성 처리에서 시간은 굉장히 예민하기 때문에 시간값이 필요로 할때는 System.currentTimeMillis 대신 System.nanoTime을 사용해야 한다.


두 개의 메소드를 비교한 내용을 보자.

  • currentTimeMillis()
    • 날짜와 관련한 시각 계산을 위해 사용(ms 단위)
    • 시스템 시간을 참조(외부데이터)
    • 일반적인 업데이트 간격은 인터럽트의 타이머(10microsec)에 따름
    • GetSystemTimeAsFileTime 메서드로 구현되어 있으며, 운영체제가 관리하는 시간값을 가져옴
  • nanoTime()
    • 기준 시점에서 경과 시간을 측정하는데 사용(ns 단위)
    • 시스템 시간과 무관
    • QueryPerformanceCounter/QueryPerformanceFrequency API로 구현되어 있음.

그렇기 때문에 정확한 시간 계산을 위해서 nanoTime()을 사용해야 한다.




정리하자면 병행 콜렉션과 같은 고수준 유틸리티를 사용하면 wait, notify를 직접적으로 사용할 이유가 많이 줄어들고 안정적으로 코딩할 수 있다. 


출처 : 조슈아 블로크, 『 Effective Java 2/E』, 이병준 옮김, 인사이트(2014.9.1), 규칙69

  1. Favicon of https://wedul.site BlogIcon 위들 wedul 2018.06.13 11:08 신고

    http://wedul.tistory.com/m/15 참고

+ Recent posts