본문 바로가기

Java/Java IO+NIO

멀티 스레드 병렬 프로그래밍을 하기 전 반드시 읽어야할 것들 - Java 객체 편(객체 동기화, 클래스의 쓰레드 안정성)

반응형

JAVA 멀티 스레드 환경에서 "객체"를 다루기 전 알아야 할 것들

제목은 거창하지만 내용이 빈약할 수 있음을 미리 알립니다...

java.util.concurrent 패키지 내용을 정리하려고 하다가 기본 지식이 부족하다 생각하여 JAVA 병렬 프로그래밍이라는 책을 읽고 멀티 스레드 프로그래밍 환경에서 "Thread-safe"하게 만드는 기본 지식을 정리한 것입니다.

+ 스레드에 대한 어느정도 지식, 경험이 있는 분들이 보기 좋습니다. (초급 개발자 정도? 중급이상은 볼 필요가 없을 겁니다...)


스레드 안정성(Thread-safe)

: 여러 스레드가 어떤 변수나 함수 또는 클래스 객체에 접근할 때 계속해서 개발자가 의도한대로 정확하게 동작하하다는 것로 정의한다. 호출하는(사용하는) 쪽에서 특별한 동기화 코드 없이도 정확하게 동작하는 것이다.

멀티 스레드의 궁극적인 목표가 스레드 안정성을 가지면서 성능은 최대한 뽑아낼 수 있게 하는 것이다.

그 중에서 가장 기초가 되는 변수!(객체!) 를 thread-safe하게 만들기 위해 생각해야할 것들을 아래에 나열했다.


- 단일 연산

: 어떤 스레드A가 작업 A'를 실행 중일 때 다른 스레드B가 하는 작업 B'를 완전히 수행되었거나 전혀 수행되지 않는 두 가지 상태로만 파악된다면 A작업 입장에서 작업 B는 단일 연산이다.

즉, 외부에서 어떤 연산을 봤을 때, 완료 또는 실패로만 결과가 나오면 단일 연산이라는 것이다.

ex) a = b++; 를 봤을 때 동기화가 안되었다면 b를 증가하는 연산(b++)과 a에 대입하는 연산(a=...)이 두 개가 있으므로 단일 연산으로 볼 수 없다. (해당 연산이 동기화가 되었다면 외부에서 성공과 실패로 나뉘므로 단일 연산이다)

* 객체보단 함수(?)편에서 다뤄야 할 내용같긴 하다..


- 상태 관리를 위해 스레드에 안전한 객체를 사용하자

java.util.concurrent.atomic 패키지 안에 있는 객체들을 사용하면 도움이 된다.

atomic클래스 안에 있는 클래스들은 CAS(compare-and-swap)방식을 사용해서 스레드에 안전하다.

CAS방식은 자신이 읽었던 변수의 값을 기억하고 있다가 변경을 완료하기 직전에 읽었던 변수의 값이 그대로인지 확인하고 아니라면 실행을 무산시키는 방식이다.

이 방식은 CPU에 의해 직접 지원되는 부분이라 안심하고 사용할 수 있다.

ex) AtomicIntegerArray,길이가 100인 배열의 값을 읽어서 변경하고 있는데 다른 스레드가 와서 변경하는 경우

ex2) AtomicLong 사용하기 Long타입은 64bit중 32bit씩 변경하는 연산이 들어가서 2번 연산이 된다고 한다. (정확한지는 아직 모름.. 추측상 64bit cpu에서는 1번 연산하지 않을까함..) 따라서 long 타입으로 timestamp를 찍을 때 멀티 스레드 환경에서 동기화가 안되어있다면 변경될 가능성이 있다.


- 암묵적인 락(lock)

Mutexes, mutual exclusion lock, synchronized는 한 번에 한 스레드만 특정 락을 소유할 수 있다.

락으로 보호되고 있다는 사실은 @Guarded 같은 애노테이션을 써서 표시하면 유지보수에 도움될 수 있다.

또한 값을 쓸 때(setter)만 동기화 해야 한다는 생각은 버려야 한다.

최대한 캡슐화를 열심히 하고 변수에 접근하는 모든 메소드에서 해당 변수에 동기화 처리를 해줘야한다.


- synchronized 블록을 너무 잘게 쪼개는 것도 안 좋다.


- 오래 걸리는 연산, 네트워크, IO작업은 웬만하면 Lock을 걸지 말아야 한다. (성능 문제가 심각해질 수 있음)


- stale data(=최신 값이 아닌 과거 데이터) 주의하기

아까 AtomicLong과 마찬가지로.. long이나 double형의 64bit 값에는 메모리에 쓰거나 읽을 때 두 번의 연산이 일어나기 때문에 volatile 키워드를 써줘야 한다.

참고로 volatile 키워드로 선언된 변수 값을 바꾸면 다른 스레드에서 항상 최신 값을 읽어갈 수 있다.

단, 작은 부분이라도 가시성을 추론해봐야할 때는 사용하면 안된다.

보통 중요 이벤트가 발생했다는 정보를 정확하게 전달하고자 할 때 사용하는 것이 효과적이다.


- 클래스 생성 메소드에서 this 변수가 외부에 노출되지 않도록 해야한다.

보통 생성 메소드에서 쓰레드를 새로 만들어서 시작시키는 코드를 만들면 그런 일이 발생한다.

그 경우 쓰레드가 this를 마음대로 접근할 수 있는 상황을 만드니 조심해야한다.

쓰레드 생성까지는 문제가 없지만 동시에 시작시키는 일까지 하는 것을 주의해야 한다.

생성 메소드에서 이벤트 리스너를 등록하거나 꼭 스레드를 시작시켜야 한다면 팩토리 메소드 생성자를 통해 진행하는 것이 좋다.


- 객체가 불변이라는 것과 참조가 불변이라는 것을 반드시 구분해서 사용해야 한다.

private을 쓰듯 나중에 굳이 변경될 일이 없는 변수들은 final 키워드를 사용하는 것이 좋다.

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
public class Point {
    private int x;
    private int y;
    public int getX() {
        return x;
    }
    public void setX(int x) {
        this.x = x;
    }
    public int getY() {
        return y;
    }
    public void setY(int y) {
        this.y = y;
    }
    
    public Point(int x, int y) {
        super();
        this.x = x;
        this.y = y;
    }
    public static void main(String[] args) {
        final Point p = new Point(1,2);
        System.out.println(p.getX() + "," + p.getY()); //1,2
        p.setX(2);
        p.setY(4);
        System.out.println(p.getX() + "," + p.getY());//2,4
        p = null//compile error
    }
}


예제에서 볼 수 있듯 Point객체는 불변하게(final) 되어있지만 내부에 있는 변수들은 변경될 수 있다. 엄연히 다른 것이다.


- 객체를 공유해 사용하고자 할 때 원칙

1. 스레드 내부에 존재하면서 해당 스레드에서만 사용한다.

2. 읽기 전용 객체를 공유한다. 즉, 불변 객체를 이용한다.

3. 스레드에 안전한 객체를 공유한다. 객체 내부적으로 필수적인 동기화 기능이 이미 구현되어 있는 객체가 이에 해당한다.

4. 특정 객체에 동기화 방법을 적용해두면 지정한 락(Lock)을 획득하기 전에는 사용할 수 없다.


- 객체 지향에 맞게 객체가 갖고 있는 정보를 객체 내부에만 잘 두면 객체 단위로 스레드 안정성을 확보하기만 하면 다른 것을 고려하지 않아도 되기 때문에 좋다.

+ 객체 상태를 보관하는 변수가 무엇이 있는지 확인한다.

+ 객체 상태를 보관하는 변수가 어떤 종류인지, 어떤 범위를 갖는지를 확인한다.

+ 객체 내부의 값을 동시에 사용하려고할 때 그 과정을 관리할 수 있는 정책을 확인한다.


- 어떤 동작을 실행하기 전에 특정한 조건을 만족할 때까지 기다리도록 프로그래밍하고자 한다면 wait, notify 대신 세마포어나 블록킹 큐와 같은 라이브러리를 쓰는 것이 안전하다.


- 동기화 정책 문서화하기 (사람끼리 동기화)


- 동기화된 컬렉션 사용하기?

Vector나 hashtable같은 컬렉션을 사용한다.

주의할 점은 반드시 컬렉션에서 제공되는 메소드로 추가 또는 제거등의 기능을 사용해야 한다.


- 반복문 전체에 동기화를 거는 방법은 지양해야한다.

반복문 전체에 동기화를 걸어버리면 컬렉션 안에 값들이 얼마나 들어있을지 모르고 그 안에서 다른 lock이 걸리면 데드락이 걸리는 최악의 상황도 생긴다. (iter.next()로 반복하는 중간에 값이 변경될 수 있음 concurrentModificationException)

이럴 때는 clone()메소드로 사본을 만들어서 사용하면 그 스레드에 한정되어 있으므로 문제를 해결할 수 있다.

물론 clone()할 때는 동기화 해야한다.


- 더 나아진 병렬 컬렉션 사용하기

동기화된 컬렉션의 사용은 동시성에 손해가 컸다. (반드시 1개의 쓰레드만 사용하게 되어있었으니...)

여기서 말하는 병렬 컬렉션은 ConcurrentHashMap과 같은 것들이다. (java.util.concurrent패키지에 있는 클래스)

put-if-absent, replace, conditional remove등의 연산을 사용한다.

병렬 컬렉션은 락스트라이핑이라는 굉장히 세밀한 동기화 방법을 사용해서 여러 스레드에서 공유하는 상태에 더 잘 대응할 수 있다.

참고로 ConcurrentHashMap같은 것은 ConcurrentModificationException을 만들지 않는다.

대신 병렬 컬렉션을 쓰면 size나 isEmpty()메소드 같은 것들은 의미가 퇴색된다. (정확한 값이 아니기 때문)

결과를 추정할 수 있는 정도로 쓰면 된다.

물론 치명적이지 않으니 걱정할 정도는 아니다.

ConcurrentHashMap의 단점이 없을까? 아니다 Map을 독점적으로 사용해야 하는 경우가 있을 때는 사용하면 피곤해진다.


- blockingQueue를 사용하면 생산자 - 소비자 구조에서 생산자가 엄청나게 생산하는 것을 방지할 수 있다. (생산자가 큐 사이즈이상으로 생산하려고 할 때 lock을 걸어주기 때문.)


- 동기화 클래스 사용하기

+ Latch

Latch는 1회용이다. CountDownLatch의 경우 Latch는 lock을 걸 count를 만들고 코드에서 latch를 만나는 부분에 들어오는 모든 쓰레드들을 못 지나가게 막다가 지정한 count가 0이되면 막혀있던 스레드들을 풀어준다.다. 대신 한 번 열리면 계속 열려있게된다. 다시 안막음!


+ FutureTask

FutureTask도 래치와 비슷하다.

Callable 인터페이스를 구현하도록 되어있다. 시작 전 대기, 시작됨, 종료 3가지 상태로 구분할 수 있고 한번 종료상태가 되면 더 이상 상태가 바뀌는 일은 없다.

(java.util.concurrent패키지를 정리하며 CompletableFuture 부분에서 다시 설명할 기회가 있을 것 같다.

Future.get()메소드는 FutureTask의 작업이 종료되면 그 결과를 즉시 알려준다.

보통 FutureTask의 경우 실제 결과가 필요한 시점보다 훨씬 이전에 시간이 많이 필요한 작업을 미리 해두는 용도로 사용한다.


+ 세마포어

특정 자원이나 특정 연산을 동시에 사용하거나 호출할 수 있는 스레드의 수를 제한할 때 사용한다.

남은 퍼밋(Permit)이 없는 경우 acquire()메소드를 사용하면 퍼밋이 생기거나 인터럽트가 걸리거나 타임아웃이 걸리지 전까지 대기한다.

release()메소드는 확보했던 퍼밋을 다시 세마포어에게 돌려주는 메소드다.


+ barrier

latch와 유사하게 barrier가 있는 코드에 접근하는 쓰레드들을 해당 count만큼 막고 있다가 풀어준다. 대신 1회성이 아니라 다시 지정한 개수의 barrier를 만들어서 또 스레드들을 막는다.

latch는 이벤트를 기다리는 동기화 클래스고 barrier는 다른 스레드를 기다리는 동기화 클래스다.

모든 쓰레드가 배리어 위치에 이르러야 진행이 가능하다.


+ Exchanger

Exchanger는 두 개의 쓰레드가 연결되는 barrier다.

barrier 포인트에 도달하면 양쪽의 스레드가 서로 갖고 있던 값을 교환한다.

양쪽 스레드가 서로 대칭되는 작업을 수행할 때 유용하다.


* 요약

객체의 상태를 나타내는 변수가 바뀔 수 있음을 인지한다.

병렬성과 관련된 모든 문제점은 "변수"에 접근하려는 시도에서 나온다는 것을 인지한다.

변경 가능한 값이 아닌 변수는 모두 final을 쓰는 것을 권장하고

변경 가능한 부분은 lock을 걸어서 동기화 시킨다.

동기화가 필요없는 부분은 버린다.


틀린 부분이 있으면 지적바랍니다. 꼭이요.. (저를 포함한 모두에게 도움이 됩니다!)


출처 :

JAVA 병렬 프로그래밍 - 책

http://aroundck.tistory.com/

반응형