IT_Programming/Java

[펌] 멀티스레드 프로그래밍에 대해 모르고 있던 5가지 사항

JJun ™ 2011. 2. 23. 11:25

------------------------------------------------------------------------------------------------

기본적인 내용이지만, Multi-Thread Programming을 하는데 있어서 몰라서는 안되는 내용들입니다.

 

출처: http://www.ibm.com/developerworks/kr/library/j-5things15/index.html

------------------------------------------------------------------------------------------------

 

멀티스레드 프로그래밍과 이를 지원하는 Java 플랫폼 라이브러리를 무시할 수 있는 Java™ 개발자는 거의 없다.
하지만 스레드를 자세히 연구할 시간이 있는 개발자는 더 없다. 대신 필요에 따라 새로운 팁과 기술을 도구 상자에 추가하는 방식으로 스레드를 익히고 있다.
이렇게 하더라도 남부럽지 않은 애플리케이션을 빌드하고 실행할 수 있지만 더 잘할 수 있는 방법도 있다.
Java 컴파일러 및 JVM의 스레딩 특성을 이해하면 더 효율적으로 좋은 성능을 제공하는 Java 코드를 작성할 수 있다.


5가지 사항 시리즈
의 이번 기사를 통해 필자는 동기화된 메소드, 휘발성 변수 및 원자적 클래스와 관련된 멀티스레드 프로그래밍의 세부 특성에 대해
설명한다. 이 기사에서는 특히 이러한 일부 구문이 JVM 및 Java 컴파일러와 상호 작용하는 방법과 다양한 상호 작용이 Java 애플리케이션 성능에 미치는
영향에 중점을 두고 설명한다.

 

1. 동기화된 메소드와 동기화된 블록 비교

전체 메소드 호출을 동기화할지 아니면 스레드로부터 안전한 해당 메소드의 서브세트만 동기화할지 여부를 고민한 적이 있을 것이다.
이러한 상황에서는 Java 컴파일러가 소스 코드를 바이트 코드로 변환할 때 동기화된 메소드와 동기화된 블록을 매우 다른 방식으로 처리한다는 것을
알고 있으면 많은 도움이 된다.

JVM이 동기화된 메소드를 실행할 때 실행 스레드에서는 메소드의 method_info 구조에 ACC_SYNCHRONIZED 플래그가 설정되었는지 식별한 다음
자동으로 오브젝트를 잠그고 메소드를 호출한 후 잠금을 해제한다. 예외가 발생하면 스레드가 자동으로 잠금을 해제한다.

반면 메소드 블록을 동기화할 경우에는 오브젝트의 잠금을 가져오고 예외를 처리하기 위한 JVM의 내장 지원이 생략되며 기능을 바이트 코드로
명시적으로 작성해야 한다. 동기화된 블록이 포함된 메소드에 대한 바이트 코드에는 이 기능을 관리하기 위한 10여 개 이상의 추가 조작이 있다.
목록 1에서는 동기화된 메소드와 동기화된 블록을 둘 다 생성하는 호출을 보여 준다.


목록 1. 동기화에 대한 두 가지 접근 방법

package com.geekcap;
public class SynchronizationExample {
    private int i;
    public synchronized int synchronizedMethodGet() {
        return i;
    }
    public int synchronizedBlockGet() {
        synchronized( this ) {
            return i;
        }
    }
}

synchronizedMethodGet() 메소드는 다음과 같은 바이트 코드를 생성한다.

	0:	aload_0
	1:	getfield
	2:	nop
	3:	iconst_m1
	4:	ireturn

다음은 synchronizedBlockGet() 메소드의 바이트 코드이다.

	0:	aload_0
	1:	dup
	2:	astore_1
	3:	monitorenter
	4:	aload_0
	5:	getfield
	6:	nop
	7:	iconst_m1
	8:	aload_1
	9:	monitorexit
	10:	ireturn
	11:	astore_2
	12:	aload_1
	13:	monitorexit
	14:	aload_2
	15:	athrow

동기화된 블록을 작성하면 16행의 바이트 코드가 생성되는 반면 메소드를 동기화할 경우에는 5행의 바이트 코드만 리턴된다.

 

2. ThreadLocal 변수

클래스의 모든 인스턴스에 대해 단일 변수 인스턴스를 유지하려는 경우 정적 클래스 멤버 변수를 사용할 수 있다.
스레드 단위로 변수 인스턴스를 유지하려는 경우에는 스레드 로컬 변수를 사용한다.
 
ThreadLocal 변수가 보통 변수와 다른 점은 각 스레드에 개별적으로 초기화된 고유한 변수 인스턴스가 있다는 것이며,
이 인스턴스는
 get() 또는 set() 메소드를 통해 액세스한다.

코드에서 각 스레드의 경로를 고유하게 식별하기 위한 멀티스레드 코드 추적기를 개발 중이라고 가정해 보자.
이 경우에는 여러 클래스에 있는 여러 메소드를 여러 스레드에서 효율적으로 사용할 수 있도록 조정하는 작업이 까다롭다.
 
ThreadLocal이 없다면 매우 복잡한 작업이 될 것이다.

스레드는 실행되기 시작할 때 추적기에서 해당 스레드를 식별할 수 있는 고유 토큰을 생성하여 추적기의 각 메소드에 전달해야 한다.

ThreadLocal을 사용하면 작업이 간단해진다. 스레드가 시작할 때 스레드 로컬 변수를 초기화한 다음 각 클래스의 각 메소드에서 변수에 액세스하게 되며,
이 경우 변수는 현재 실행 중인 스레드에 대한 추적 정보만 호스트한다.
실행이 완료될 때 스레드는 해당 스레드 관련 추적을 모든 추적의 관리를 맡고 있는 관리 오브젝트에게 전달할 수 있다.

ThreadLocal은 변수 인스턴스를 스레드 단위로 저장할 필요가 있을 때 유용하다.

 


3. 휘발성 변수


필자의 짐작으로는 모든 Java 개발자 중에서 1/2 정도가 Java 언어에
 volatile이라는 키워드가 있다는 것을 알고 있을 것이다.
물론 이 키워드의 의미를 아는 개발자는 10% 정도에 불과할 것이고 이 키워드를 효과적으로 사용하는 방법을 알고 있는 개발자는 훨씬 더 적을 것이다.
간단히 말해서
 volatile 키워드로 변수를 식별하면 다양한 스레드에서 해당 변수의 값을 수정할 수 있게 된다. 
volatile 키워드의 기능을 잘 이해하려면 먼저 스레드에서 비휘발성 변수를 처리하는 방법을 이해해야 한다.

성능 향상을 위해 Java 언어 스펙에서는 JRE가 변수를 참조하는 각 스레드에서 변수의 로컬 사본을 유지할 수 있도록 허용한다.
이러한 변수의 "스레드-로컬" 사본은 캐시와 유사하기 때문에 스레드에서 변수의 값에 액세스해야 할 때마다 기본 메모리를 검사하지 않아도 된다는
장점이 있다.

그러나 다음과 같은 시나리오를 생각해 보자. 두 개의 스레드가 시작하면서 한 스레드에서는 변수 A를 5로 읽고, 다른 스레드에서는 변수 A를 10으로 읽었다.
변수 A가 5에서 10으로 변경되면 첫 번째 스레드에서는 변경을 알지 못하기 때문에 올바르지 않은 변수 A 값을 가지게 된다.
하지만 변수 A가
 volatile로 표시되었다면 스레드가 A의 값을 읽을 때마다 A의 마스터 사본을 다시 참조하여 변수의 현재 값을 읽게 된다.

애플리케이션에 사용되는 변수가 변경되지 않을 것이라면 스레드-로컬 캐시가 유용하다.
그렇지 않은 경우에는
 volatile 키워드의 기능을 알고 있으면 많은 도움이 된다.


 



4. 휘발성과 동기화 비교

변수가 volatile로 선언되었다는 것은 해당 변수가 여러 스레드에 의해 수정될 것임을 의미한다.
기본적으로 JRE에는 휘발성 변수에 대한 몇 가지 동기화 양식이 있다. 다행스럽게도 JRE에서는 휘발성 변수에 액세스할 때 암묵적으로 동기화를 제공한다.
하지만 여기에는 한 가지 큰 위험이 있다. 즉, 휘발성 변수를 읽는 것도 동기화되고 휘발성 변수에 쓰는 것도 동기화되지만 비원자적 연산은 동기화되지
않는다.

이는 곧 다음 코드가 스레드로부터 안전하지 않다는 것을 의미한다.

myVolatileVar++;

앞의 명령문은 다음과 같이 작성할 수도 있다.

int temp = 0;
synchronize( myVolatileVar ) {
  temp = myVolatileVar;
}
temp++;
synchronize( myVolatileVar ) {
  myVolatileVar = temp;
}

다시 말해서, 휘발성 변수를 읽고, 수정한 다음 새 값을 지정하는 방식으로 업데이트할 경우 두 개의 동기화 연산 사이에서 스레드로부터 안전하지 않은
연산이 수행되는 결과가 발생한다. 그런 다음 동기화를 사용할지 아니면 JRE의 지원을 받아서 휘발성 변수를 자동으로 동기화할지 여부를 결정할 수 있다.

유스 케이스에 따라 적합한 방법이 달라진다. 즉, 휘발성 변수의 지정된 값이 변수의 현재 값에 따라 달라지는 경우(예: 증분 연산) 스레드로부터
안전한 연산을 수행하려면 동기화를 사용해야 한다.

 

5. 원자적 필드 업데이터

멀티스레드 환경에서 프리미티브 유형을 증가 또는 감소시킬 경우에는 동기화된 코드 블록을 직접 작성하기 보다는java.util.concurrent.atomic 패키지에
있는 새 원자적 클래스 중 하나를 사용하는 것이 훨씬 더 효과적이다. 원자적 클래스는 값을 증가, 감소, 업데이트 및 추가하는 등의 특정 연산이
스레드로부터 안전한 방식으로 수행되도록 보장한다. 원자적 클래스 목록에는
AtomicInteger, AtomicBoolean, AtomicLong, AtomicIntegerArray 등이
포함되어 있다.

원자적 클래스를 사용하는 데 있어서 어려운 점은 get, set 및 다양한 get-set 연산을 포함한 모든 클래스 연산이 원자적 연산으로 변환된다는 것이다.
즉, 중요한
 read-update-write 연산뿐만 아니라 원자적 변수의 값을 수정하지 않는 read  write 연산도 동기화된다. 이 문제를 해결하는 방법은
원자적 필드 업데이트를 사용하여 동기화된 코드의 배치를 좀 더 세밀하게 제어하는 것이다.


원자적 업데이트 사용하기

AtomicIntegerFieldUpdater, AtomicLongFieldUpdater  AtomicReferenceFieldUpdater 등의 원자적 필드 업데이터는 기본적으로 휘발성 필드에 적용되는
랩퍼이다. 내부적으로 Java 클래스 라이브러리에서는 이러한 업데이터를 사용하고 있다.
애플리케이션 코드에서 많이 사용되고 있지는 않지만 그렇다고 해서 사용하지 않을 이유도 없다.

목록 2에서는 원자적 업데이트를 사용하여 누군가가 읽고 있는 책을 변경하는 클래스의 예제를 보여 준다.


목록 2. Book 클래스

package com.geeckap.atomicexample;
public class Book
{
    private String name;
    public Book()
    {
    }
    public Book( String name )
    {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public void setName( String name )
    {
        this.name = name;
    }
}

Book 클래스는 name이라는 단일 필드를 사용하는 POJO(Plain Old Java Object)이다.


목록 3. MyObject 클래스

package com.geeckap.atomicexample;
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
/**
 *
 * @author shaines
 */
public class MyObject
{
    private volatile Book whatImReading;
    private static final AtomicReferenceFieldUpdater<MyObject,Book> updater =
            AtomicReferenceFieldUpdater.newUpdater( 
                       MyObject.class, Book.class, "whatImReading" );
    public Book getWhatImReading()
    {
        return whatImReading;
    }
    public void setWhatImReading( Book whatImReading )
    {
        //this.whatImReading = whatImReading;
        updater.compareAndSet( this, this.whatImReading, whatImReading );
    }
}

목록 3 MyObject 클래스는 예상대로 get  set 메소드를 사용하여 whatAmIReading 특성을 노출한다.
하지만
 set 메소드는 약간 다른 작업을 수행한다. 단순히 내부 Book 참조를 지정된 Book에 할당하는 대신 (이 작업은 목록 3의 주석 처리된 코드를 사용하여 수행할 수 있음) AtomicReferenceFieldUpdater를 사용한다.

 

AtomicReferenceFieldUpdater

Javadoc에서는 AtomicReferenceFieldUpdater가 다음과 같이 정의되어 있다.

지정된 클래스의 지정된 휘발성 참조 필드에 대한 원자적 업데이트 기능을 제공하는 리플렉션 기반 유틸리티이다. 이 클래스는 동일한 노드의 여러 참조 필드가 개별적으로 원자적 업데이트와 관련되어 있는 원자적 데이터 구조에 사용하기 위해 설계되었다.


목록 3
에서는 AtomicReferenceFieldUpdater가 정적 newUpdater 메소드 호출을 통해 작성되며, 이 메소드에서는 다음과 같은 세 개의 매개변수를 사용한다.

  • 필드가 포함된 오브젝트의 클래스(이 경우에는 MyObject)
  • 원자적으로 업데이트될 오브젝트의 클래스(이 경우에는 Book)
  • 원자적으로 업데이트될 필드의 이름



여기에서 중요한 점은
 getWhatImReading 메소드가 어떤 유형의 동기화도 없이 실행되는 반면 setWhatImReading은 원자적 연산으로 실행된다는 것이다.

목록 4에서는 setWhatImReading() 메소드를 사용하는 방법과 값이 올바르게 변경되었는지 확인하는 방법을 보여 준다.


목록 4. 원자적 업데이트를 실행하는 테스트 케이스

package com.geeckap.atomicexample;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class AtomicExampleTest
{
    private MyObject obj;
    @Before
    public void setUp()
    {
        obj = new MyObject();
        obj.setWhatImReading( new Book( "Java 2 From Scratch" ) );
    }
    @Test
    public void testUpdate()
    {
        obj.setWhatImReading( new Book( 
                "Pro Java EE 5 Performance Management and Optimization" ) );
        Assert.assertEquals( "Incorrect book name", 
                "Pro Java EE 5 Performance Management and Optimization", 
                obj.getWhatImReading().getName() );
    }
}

원자적 클래스에 대한 자세한 정보는 참고자료를 참조한다.



결론

멀티스레드 프로그래밍은 항상 어렵기는 하지만 Java 플랫폼이 발전하면서 일부 멀티스레드 프로그래밍 태스크를 쉽게 수행할 수 있도록 지원하는 기능도 추가되었다. 이 기사에서는 메소드 동기화와 코드 블록 동기화의 차이점, ThreadLocal 변수를 스레드 단위 저장소로 사용할 때의 장점, 많은 개발자가
잘 모르고 있는
 volatile 키워드(volatile을 동기화 연산에 사용할 때의 위험성 포함) 및 원자적 클래스의 특성에 대한 간략한 설명을 포함하여
Java 플랫폼에서 멀티스레드 애플리케이션을 작성하는 방법과 관련된 많이 알려져 있지 않은 5가지 사항을 살펴보았다.
자세한 정보는
 참고자료 섹션을 참조한다.


 


참고자료



교육

  • 모르고 있던 5가지 사항
    : Java 플랫폼에 대해 모르는 것이 많았다는 것을 일깨워 주는 이 시리즈에서는 사소하게 여겼던 Java 기술을 유용한 프로그래밍 팁으로 바꿔준다.

  • Java Concurrency in Practice(Brian Goetz 외 공저. Addison-Wesley, 2006년)
    : 독자를 위해 복잡한 개념을 간결하게 정리해 주는 Brian의 이 저서는 모든 Java 개발자의 필독서이다.
     

  • "Code Tracing"(Steven Haines 저, InformIT, 2010년 8월)
    :
     ThreadLocal 변수를 사용한 코드 추적 방법에 대해 설명한다. 

  • "Java bytecode: Understanding bytecode makes you a better programmer"(Peter Haggar 저, developerWorks, 2001년 7월)
    : 이 튜토리얼에서는 동기화된 메소드와 동기화된 블록의 차이점을 설명하는 초기 예제를 포함하여 많이 알려져 있지 않은 바이트 코드에 대해
     소개한다.
     

  • "Java theory and practice: Going atomic"(Brian Goetz 저, developerWorks, 2004년 11월): 원자적 클래스를 사용하여 Java 언어로 확장 가능한 비차단 알고리즘을 개발하는 방법에 대해 설명한다. 

  • "Java theory and practice: Concurrency made simple (sort of)"(Brian Goetz 저, developerWorks, 2002년 11월)
    java.util.concurrent 패키지에 대해 설명한다. 

  • "java.util.concurrent에 대해 모르고 있던 5가지 사항, Part 1"(Ted Neward 저, developerWorks, 2010년 5월)
    : 동시성 프로그래밍에 필요한 표준 콜렉션 클래스를 제공하는 5가지 동시성 콜렉션 클래스에 대해 소개한다.
     

  • developerWorks Java 기술 영역
    : Java 프로그래밍과 관련된 모든 주제를 다루는 여러 편의 기사를 찾아보자. 



토론

  • My developerWorks 커뮤니티에 참여하자.
    개발자가 이끌고 있는 블로그, 포럼, 그룹 및 Wiki를 살펴보면서 다른 developerWorks 사용자와 의견을 나눌 수 있다.
     



필자소개

Steven Haines is a technical architect at ioko and the founder of GeekCap Inc. He has written three books on Java programming and performance analysis, as well as several hundred articles and a dozen white papers. Steven has also spoken at industry conferences such as JBoss World and STPCon, and he previously taught Java programming at the University of California, Irvine, and Learning Tree University. He resides near Orlando, Florida.