02. 스레드
자바 언어가 스레드를 강력하게 지원하게 된 이유 중 하나는 자바가 실행되는 기반인 JVM 자체가 하나의 프로세스기 때문이다? IDE 백그라운드에서 입력 내용에 대한 오류를 검사하면서 바로바로 바로잡아 준다. 한글과 같은 문서 프로그램 또한 문법 검사를 백그라운드에서 진행하며 자동 저장 또한 지원한다. 즉, 스레드는 동시에 실행될 수 있는 또 다른 실행흐름을 갖게 한다.
쓰레드 사용의 두가지 방법
Thread 상속을 통한 구현 방법 ( White Box )
Runnable 구현을 통한 구현 방법 ( Black Box )
GoF의 디자인 패턴에서는 객체 구현이 클래스 상속보다 더 나은 방법이다. 라고 정의한다. 디자인 패턴에서는 상속을 통한 재사용을 ( White-Box reuse )라고 하고 구현을 통한 재사용을 ( Black-Box reuse )라고 한다. 왜 상속 보다는 구현을 통한 재사용이 더 나은 선택인가?
상속 - 어떤 객체를 상속하면 private으로 선언되지 않는 모든 변수와 메소드, 생성자가 하위 클래스에 노출된다. 하위 클래스에서 수퍼 클래스의 내부가 보인다는 의미로 디자인 패턴에서는 상속을 통한 재사용을 White-Box Reuse라고 한다. 반면 상속의 장점은 오버라이딩을 통해 수퍼클래스의 구현을 손쉽게 재정의할 수 있다는 것이다. 그럼에도 불구하고 상속을 이용해서 재사용할 때 무엇이 문제이길래?
이에 세가지 이유가 있다.
- 첫째, 수퍼클래스가 하위클래스에 불필요하게 많은 부분이 노출된다는 것이다. 이것은 객체지향의 원칙중 하나인 캡슐화에 위배된다. 또한 하위 클래스가 상위 클래스의 구현에 종속되고 수퍼클래스 구현이 변경되어야 할 경우가 생기면 하위 클래스도 변경해야 하는 문제점을 야기한다.
- 둘째, 컴파일 시점에서 이미 객체 형식이 결정된다는 것이다. A 클래스가 B 클래스의 수퍼클래스다. 라는 식의 정보가 이미 컴파일 시점에서 결정되어버리기 때문에 런타임 시점에서 상속받은 수퍼클래스의 구현을 유연하게 바꿀수 없다.
- 셋째, 시스템이 진화(라이브러리는 시간이 지날수록 새로운 기능이 추가되고 버그 수정을 위해 변경된다) 할수록 상속 관계가 복잡해져서 그 시스템의 상속 트리를 정확하게 이해하고 있지 않으면 시스템의 수정과 확젱에 손댈 수 없는 상황까지 발생할 가능성이 있다.
구현 ( Implements ) 객체 구현은 객체가 다른 객체의 참조자를 얻는 방식으로 런타임 시에 동적으로 이루어진다. 따라서 다른 객체의 참조자를 얻은 후 그 참짜를 이용해서 객체의 기능을 이용하기 떄문에 객체의 인터페이스만을 바라보게 됨으로써 캡슐화가 잘 이루어질 수 있다. 하지만 구현에도 단점은 오용에 따른 단점과 주의해야 할 점이 존재한다.
첫째, 구현은 객체 간의 관계가 수직관계가 아닌 수평 관계가 된다. 따라서 큰 시스템에서 많은 부분에 걸쳐 합성이 사용될 떄 객체나 메소드 명이 명확하지 않으면 코드 가독성이 떨어지고 이해하기 어려워진다. 따라서 구현을 사용할 떄에는 그 용도에 따라 클래스들을 패키지로 적절하게 분리해야 하고 각각의 사용 용도가 명확하게 드러나도록 인터페이스를 잘 설계해야 한다.
스레드의 종료 stop() 메소드는 여러 문제점으로 이 메소드를 사용하지 말 것을 권고하고 있다. 현재 크게 두가지 방법으로 구현할 수 있따.
첫째, while(!stopped) 처럼 flag를 사용하는 것이다. 이 방법에는 몇가지 문제점이 있따. Run() 메소드 안의 특정 로직에서 무한 루프를 돌거나 조건 루프를 도는 시간이 너무 오래 걸리는 작업을 하면 Stopped 플래그를 검사할 수 없다.
둘째, Interrupt() 메소드를 이용한다. interrupt() 메소드는 현재 수행하고 있는 명령을 바로 중지시킨다. 만약 interrupt() 메소드를 호출하는 시점에 Object 클래스의 wait(), wait(long), wait(long, int) 메소드나 Thread 클래스의 join(), join(long), join(long, int), sleep(long), sleep(long, int) 메소드가 호출된 경우에는 InterruptedException을 발생시킨다.
Try{
while(!Thread.currentThread().isInterrupted() { … }
}catch ( InterruptedException e) {
// 예외를 처리한다.
} finally {
// 마무리 해야할 작업을 수행한다.
}
데몬 스레드와 join() 자바에서는 애플리케이션 내부의 모든 스레드가 종료되지 않으면 jvm이 종료되지 않는다. But 데몬 스레드는 백그라운드 작업을 위한 스레드의 하위 개념인데 위 조건에서 예외인 스레드이다.
Join() join은 t.join() 처럼 실행하게 되면 t 스레드의 실행이 종료될때까지 기다리다가 끝이 나면 자신의 나머지 작업을 이어간다.
- 스레드 그룹
- 스레드 우선순위
- 멀티스레드와 동기화
- JVM의 런타임 데이터 영역
PC레지스터 영역
- 현재 스레드가 수행하고 있는 코드의 명령과 주소들을 저장한다.
- JVM 안에서 실행되는 모든 스레드는 각자 자신으 PC ( PROGRAM COUNT ) 레지스터가 있다. 각 스레드들은 특정 객체의 메소드를 호출하고 그 메소드를 실행하는 도중 다른 객체의 특정 메소드를 호출하는 등의 과정이 생기는데, PC 레지스터는 해당 스레드가 어떤 부분을 어떤 명령으로 실행할지에 대해 기록하는 영역이다.
JVM 스택 영역
- 지역 변수, 파라미터, 리턴 값과 지역 객체 레퍼런스를 저장한다. 각각의 스레드들이 자신만의 스택을 만들어서 사용한다.
- JVM 스레드는 private JVM 스택을 갖게된다. JVM 안의 모든 스레드들은 각자 자신만의 고유 스택 영역을 갖는다. 자신만의 고유 영역 스택이기 때문에 다른 스레드가 자신의 스택 영역에 접근할 수 없다. 이 스택 영역에는 지역변수와 지역 객체 레퍼런스, 메소드 파라미터, 메소드 리턴 값 등 어떤 메소드 안에서 사용되어지는 값들이 저장된다.
public class CallByValue {
public static void main(String[] args){
StringBuffer a = new StringBuffer(“AAAA”);
StringBuffer b = new StringBuffer(“BBBB”);
swapMethod(a, b);
}
public static void swapMethod(StringBuffer a, StringBuffer b){
String temp = x.toString();
x.delete(0, x.length());
x.append(y.toString());
y.delete(0, y.length());
y.append(temp);
}
}
자바는 참조 형식의 언어이이기 때문에 어떤 객체를 이용하기 위해 그 객체의 레퍼런스를 사용한다.
힙 영역
- 생성된 객체(Array도 객체임)들을 저장한다. 모든 스레드에 의해서 공유된다.
- 힙은 JVM의 모든 스레드들이 공유하는 데이터 영역이다. 인스턴스화된 모든 객체가 저장되는 곳이다. new 키워드로 어떤 객체를 생성할 떄 이 영역에 해당 객체가 저장되고 그 객체가 더 이상 사용되지 않을떄, 즉 해당 객체의 참조가 모두 끊어졌을때 가비지 컬렉션 목록에 포함되고 그 후 적절한 시점에 JVM 스스로 가비지 컬렉터로 그 객체가 점유하고 있던 메모리를 반환한다.
메소드 영역
- 각 클래스 또는 인터페이스의 런타임 컨스턴트 풀 영역, 메소드, 생성자를 저장한다. 모든 스레드에 의해서 공유된다.
- 메소드 영역은 힙과 같이 JVM의 모든 스레드들이 공유하는 데이터 영역이다.
런타임 컨스턴트 풀 영역
- 각 클래스 또는 인터페이스 클래스 변수, static 변수, 클래스 객체 레퍼런스를 저장한다.
- 런타임 컨스턴트 풀 영역은 각 클래스에 대한 인스턴스 변수와 인스턴스 레퍼런스, 그리고 static 변수와 static 인스턴스 레퍼런스가 저장되는 영역이다.
- 메소드 영역에 의해 할당되고 또한 메소드 영역이 관리하기 떄문에 모든 JVM 스레드들이 공유하게 된다. 네이티브 메소드 스택 영역 - 일명 C 스택으로 불린다. JNI의 네이티브 메소드 호출 시 사용되는 스택 영역이다.
- 네이티브 메소드 스택 영역은 흔히 C 스택이라 불리우는 영역으로 JNI를 사용할 경우 네이티브 메소드에서 사용되는 값을 저장할 때 사용되는 데이터 영역이다.
Lock, Monitor, Synchronized
메소드에 Synchronized를 선언하는 방법과 블록 형태로 사용하는 방법이 있따.
synchronized
키워드를 사용하면 모니터(monitor)가 해당 객체의 락을 검사한다.
모니터는 락의 현재 사용여부를 검사함으로써 각 객체를 보호하는데, 락과 마찬가지로 모니터도 각 객체의 레퍼런스와 연결되어 있따.
어떤 클래스를 new 키워드를 사용해서 인스턴스화 하면 락과 함께 자동으로 생성된다.
synchronized 키워드를 사용한 메소드나 블록에 접근하게 되면 그 synchronized와 연관된 모니터는 해당 객체의 레퍼런스를 검사한ㄷ. 이떄 락이 아직 다른 어떤 스레드에게 사용되어지지 않고 있다면 JVM에게 알려준다. JVM은 monitorenter라는 JVM 내부 명령으로 해당 객체의 락을 요청한 스레드에게 준다. 반대로 락이 어떤 스레드에 의해 사용되고 있다면 락이 반환될때까지 더이상 진행되지 않고 그 스레드는 대기한다. 스레드가 락을 얻은 후에 synchronized 메소드나 블록을 다 마치고 나면 monitorexit라는 JVM 내부 명령을 자동 실행해 해당 스레드가 얻은 객체의 락을 즉시 반환한다.
ThreadLocal - 공유 자원의 특정 데이터만을 접근하는 각각의 스레드가 다른 값을 갖도록 만들어 유지하고 싶을 때도 있다. ThreadLocal은 흔하게 사용되는 클래스는 아니지만 java.nio.charset 패키지의 Charset 클래스와 java.util.loggin 패키지의 LogRecord 클래스에서 그 실제 구현 예를 볼 수 있다.
생성자 - 소비자 패턴 더그 리 (Doing Lea) 가 만든 concurrent util 패키지를 참조하자. 더그 리가 만든 패키지의 예제 코드가 공개되어 있다.
출처 :브라이언 괴츠, 더그 리, 팀 피얼스, 조셉 보우비어, 데이빗 홈즈, 조슈아 블로쉬 공저, 『 멀티코어를 100% 활용하는 자바 병렬 프로그래밍』, 에이콘(2008.7.30), 2장 인용.