[JAVA] 쓰레드 - 6.Thread-safe

유알·2023년 2월 24일
1

[JAVA]

목록 보기
13/13

멀티 쓰레드 프로그래밍에서는 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭할 수 있다.

특히 많이 쓰는 Spring Boot의 경우 Bean 을 싱글톤으로 가지고 있고, tomcat 이 쓰레드 풀을 만들어 요청을 할당하기 때문에 설계할 때 Thread-safe 하게 설계하는 것은 필수이다.
만일 그렇지 않다면, 트래픽이 적을 때나 개인적인 테스트를 돌릴 때는 문제가 발생하지 않다가, 트래픽이 몰리게 되면 아무도 예상할 수 없는 결과로 이어지게 된다.

이를 방지하는 방법을 알아보자.

1. synchronized 사용하기

쓰레드의 동기화 = 한 쓰레드가 작업중인 작업을 다른 쓰레드가 간섭하지 못하게 막는 것

동기화 하려면 간섭받지 않아야 할 문장을 임계영역으로 묶으면 된다.
이렇게 되면 한번에 한쓰레드만 이 영역에 접근할 수 있으며 다른 쓰레드는 BLOCKED 상태가 된다.

  1. 메서드에 지정 (메서드 전체가 지정됨)
public synchronized void calculate(){
	//...
}
  1. 특정영역을 지정
public void calculate(){
  synchronized(객체의 참조변수){ //참조변수는 흔히 this 로 사용한다.
      //...
  }
}

synchronized 를 사용하게 되면, 매우 간편하지만 한번에 한개의 쓰레드만 접근이 가능하기 때문에 성능에 큰 영향을 끼치게 되며, 멀티 쓰레드의 장점을 잃게 된다.
따라서 임계영역을 최소화 하는 것이 좋다.

wait() , notify(), notifyAll()

이때 동기화의 효율을 높이기 위해 이 세가지 메서드가 등장한다.
이 세 메서드는 Object 객체에 정의되어 있다.

아래와 같은 상황을 보자.
두 메서드 모두 synchronized 처리가 되어있기 때문에 한 쓰레드가 작업 중이면 다른 쓰레드는 대기 할 수 밖에 없다.

하지만 아래와 같이 while문을 돌고 있을 경우 출금을 하려는 모든 쓰레드가 계속 대기를 해야하고 이는 대참사가 날 것이다.

class Account{

	int balance = 1000;
    
	public synchronized void withdraw (int money) {
		while (balance < money) {
			try {
            	wait()
            } catch (InterruptedException e) {}
		}
		balance -= money;
	} // withdraw
    
	public synchronized void deposit (int money) {
		balance += money;
		notify() : // 통지 - 대기중인 쓰레드 중 하나에게 알림.
	}
}

하지만 wait()을 사용하게 되면 이 작업은 별도의 대기실에 들어가게 되고, lock을 반납하게 된다.
그러다가 어떤 메서드에서 notify()를 호출하게 되면 이 대기실 중 한명에게 lock을 다시 주고 작업을 진행시킨다.

이 코드에서는 만약 lock을 받더라도 잔고가 부족하다면, 다시 wait()하게 된다.

notifyAll()은 메서드 이름에서 알 수 있듯이 대기중인 모든 쓰레드를 깨운다.


2. ReentrantLock


3. 지역변수 사용 (JVM stack영역 활용)

전역변수를 사용하게 되면 JVM의 heap 영역에 의해 처리되게 된다.
반면 지역변수나 파라미터로 전달받은 변수들은 JVM의 stack 영역에서 처리된다.
알다싶이 JVM stack 영역은 쓰레드간 공유되지 않는다.

따라서 static 이나 전역변수 없이 지역변수나 파라미터로 변하는(수정가능한) 값들을 처리하게 되면 thead-safe 하게 처리할 수 있다.

chatGpt가 힌트를 주었는데, 지역변수라도 전역변수나 static 변수를 사용한다면 thread-safe하지 않을 수 있다고 한다.

class MyBean{
    String name; //멀티 쓰레드 환경에서 문제 발생의 원흉!!

    public void setName(String name){ 
        this.name = name; //이름이 바뀐다면 다른 쓰레드에도 영향을 받게 된다.
        ...
    }
}
class MyBean{
    public void setName(String name){ 
        name ... //지역 변수로써 사용하게 되면 영향이 없다.
    }
}

출처 :https://siyoon210.tistory.com/140


4. ThreadLocal 사용

지역변수로의 전환의 경우 가능만하다면 매우 간단하게 구현이 가능하며 코드도 복잡해지지 않는다.
하지만 한 쓰레드내에서 공유되는 전역변수를 사용하고 싶을 때에는 곤란해진다.
반복되는 변수를 모든 메서드의 파라미터로 넘겨줄 수는 없으니 말이다.

이럴때 사용되는 것이 ThreadLocal이다.
ThreadLocal을 사용하면 값이 Thread 별로 저장이 되어 다른 쓰레드에 영향을 주지 않으면서 같은 쓰레드 내에서 공유할 수 있게 만든다.

// 현재 쓰레드와 관련된 로컬 변수를 하나 생성한다.
ThreadLocal<UserInfo> local = new ThreadLocal<UserInfo>();

// 로컬 변수에 값 할당
local.set(currentUser);

// 이후 실행되는 코드는 쓰레드 로컬 변수 값을 사용
UserInfo userInfo = local.get();

// 값을 지운다
local.remove();

주의해야할 점은 사용 후 public void remove()를 호출하여 현재 쓰레드의 저장된 값을 지워줘야한다는 것이다.
특히 이것은 쓰레드풀을 이용할 때 중요하고, 만일 지워주지 않으면 메모리 누수가 발생한다.

동작원리

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

ThreadLocal 클래스는 set() 메소드를 수행하게 되면 실제 getMap() 메소드로 실제 Thread 클래스에 있는 변수에
ThreadLocalMap 타입의 값을 할당한다.

ThreadLocalMap 클래스는 ThreadLocal 의 내부 클래스로 초기에는 16개의 크기를 가지는 Entry 배열로 Thread에 저장할 값을 담는 공간이다.


5. 불변객체 사용하기

불변객체로 만들게 되면, Thread-safe가 된다.
불변객체란 할당 이후 변경될 수 없는 객체로 대표적으로 String 이 있다.

String str = "hello";
str = "123";

이런 경우 값이 바뀌는 것이 아닌 "123"이라는 새로운 String 이 생성되어 변수에 저장된다.

불변객체를 만드는 방법은 멤버변수에 private final을 붙이고 setter를 제공하지 않는 방법이 기본이다.
하지만 이 방법은 primitive 타입일 때만 유효하다.

참조형 변수나 배열, 리스트 같은 변수를 멤버 변수로 저장하게 되면, getter를 통해 오브젝트를 받아서 그 내부에 있는 setter 등으로 값을 변경할 수 있기 때문에 완전한 불변객체라고 볼수 없다.

https://velog.io/@conatuseus/Java-Immutable-Object%EB%B6%88%EB%B3%80%EA%B0%9D%EC%B2%B4
이 글에서 불변객체에 대해 자세히 나와있다. 하지만 한가지 내용이 부실해서 내용을 추가하자면,

가변객체를 멤버변수로 갖으려면 어떻게 해야할까? 예시로 Date라는 객체를 예로 들어보자

public final class ImmutableClass
{
	// 이 둘은 불변객체이다.
    private final Integer immutableField1;
    private final String immutableField2;
 
    //Date 객체는 가변 객체이다.
    private final Date mutableField;
 
    //private로 된 생성자
    private ImmutableClass(Integer fld1, String fld2, Date date)
    {
        this.immutableField1 = fld1;
        this.immutableField2 = fld2;
        this.mutableField = new Date(date.getTime()); //받을 때 새로운 객체를 만들어서 배정한다.
    }
 
    //펙토리 메서드
    public static ImmutableClass createNewInstance(Integer fld1, String fld2, Date date)
    {
        return new ImmutableClass(fld1, fld2, date);
    }
 
    //setter를 제공하지 않는다.
 
    //...
 	
    //제공한 객체를 통해 값을 변경하지 못하도록 방어적 복사본을 생성하여 준다.
    public Date getMutableField() {
        return new Date(mutableField.getTime());
    }
}

이런식으로 하면 가변객체로 불변객체를 만들어 낼 수 있다.
핵심은

  • private final 처리
  • 생성자에서 복사본을 만들어 멤버변수로 전달
  • setter 미제공
  • getter를 통해 가변객체를 전달할 때는 복사본을 만들어 전달

6. Thread-safe Collection

Thread-safe 한 Collection 을 사용하는 방법이 있다.
이들은 보통 메서드의 일부를 synchronized 처리를 한다던가 volatile 을 활용하는 방식으로 thread-safe 하게 구현되어 있다.

동기화된(synchronized) 컬렉션

  • Vector, Hashtable, Collections.synchronizedXXX()로 생성된 컬렉션들

  • Since JDK 1.2

  • 문제점: Thread Safe하나, 두개 이상의 연산을 묶어서 처리해야 할 때 외부에서 동기화 처리를 해줘야 한다. (Iteration, put-if-absent, replace, condition-remove 등)

병렬(concurrent) 컬렉션

  • List: CopyOnWriteArrayList

  • Map: ConcurrentMap, ConcurrentHashMap

  • Set: CopyOnWriteArraySet

  • SortedMap: ConcurrentSkipListMap (Since Java 6)

  • SortedSet: ConcurrentSkipListSet (Since Java 6)

  • Queue 계열:ConcurrentLinkedQueue

  • Since Java 5

  • 특이사항: Concurrent(병렬/동시성)이란 단어에서 알 수 있듯이 Synchronized 컬렉션과 달리 여러 스레드가 동시에 컬렉션에 접근할 수 있다. ConcurrentHashMap의 경우, lock striping 이라 부르는 세밀한 동기화 기법을 사용하기 때문에 가능하다. 구현 소스를 보면 16개의 락 객체를 배열로 두고 전체 Hash 범위를 1/16로 나누어 락을 담당한다. 최대 16개의 스레드가 경쟁없이 동시에 맵 데이터를 사용할 수 있게 한다. (p.350)

반대로 단점도 있는데, clear()와 같이 전체 데이터를 독점적으로 사용해야할 경우, 단일 락을 사용할 때보다 동기화 시키기도 어렵고 자원도 많이 소모하게 된다. 또한, size(), isEmpty()같은 연산이 최신값을 반환하지 못할 수도 있다. 하지만 내부 상태를 정확하게 알려주지 못한다는 단점이 그다지 문제되는 경우는 거의 없다.

출처 : http://deepblue28.tistory.com/entry/Java-SynchronizedCollections-vs-ConcurrentCollections

7. volatile 지시자

이는 약간 번외로써 끼워 넣자면

private volatile int days;

이런식으로 쓰는 것인데, 이는 이 변수를 cpu에 캐시된 값이 아닌 Main Memory에서 참조할 수 있도록 보장해주는 키워드 이다.

컴퓨터는 빠른 연산을 위해 메모리의 값을 cpu의 캐시로 잠시 저장하고 빠르게 연산을 지정하는데,
멀티쓰레드 프로그램에서 만약 읽어드리는 메서드값을 수정하는 메서드를 서로 다른 쓰레드에서 사용하게 되면, 읽어드리는 값이 cpu에 캐시된 이전의 값을 읽어드리고, main memory에는 값을 수정하는 메서드가 접근하여 값이 달라질 수 있다.

이를 방지하기 위해 값을 항상 main 메모리에서 참조하도록 보장하는 키워드 이다.
이는 당연히 성능 저하를 발생시킨다.

이 키워드를 항상 사용하는 것은 옳지 않다. 이 키워드는 같은 변수의 값에 대해 동시에 수정과 읽기가 발생할 수 있는 변수에 대해 사용하는 것이 적합하다.

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글