꿈꾸는 개발자 박상호입니다.
Devhoyas
꿈꾸는 개발자 박상호입니다.
전체 방문자
오늘
어제
  • ALL (17)
    • Algorithm (7)
    • Java (2)
    • Go (2)
    • Spring (3)
    • Database (1)
      • MySQL (1)
      • ElasticSearch (0)
    • Http (1)
    • 일상 (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

최근 글

티스토리

hELLO · Designed By 정상우.
꿈꾸는 개발자 박상호입니다.

Devhoyas

Java

[Java] 자바에서의 동기화는 왜 어렵고 까다로울까?

2022. 6. 16. 10:52
"멀티쓰레드 개발은 언어 무관하게 무지 어렵다. 세계 최고 개발자의 할아버지가 와도 어렵다." 
 
 

언어를 배운다는것은 정말 힘든일이라고 생각되는게,  지금 쓰고있는 "자바에서의 동기화는 왜 어렵고 까다로울까?" 를 읽어보면 아시겠지만, 해당 언어에 대한 경험이 많지 않다면 실수하기 쉬운 문제가 도처에 도사리고 있습니다. 

그 이슈를 실전에서 대처하려면 ,실수에 의한 경험도 필요하고, 가끔은 소스의 내부를 철저히 조사해봐야하는 수고를 해야하는데, 언어를 배우는것도 힘든데 저런부분까지 신경쓰려면 고난의 행군은 각오해야할거 같습니다.

 

1. Collections.synchronizedList 이야기 

보통 우리는 Vector 대신해서 ArrayList 를 사용하라는 말을 듣곤합니다.  Vector 는 동기화되어진 함수로 가득차있기때문에 싱글쓰레드 프로그램에서 효율적이지 않다라는거지요. 

따라서 멀티쓰레드 프로그램을 짤때 ArrayList 를 사용할 경우 , 쓰레드문제에 대해서 신경을 써줘야하는데 , 선택할 2가지방법은 ArrayList 의 함수를 사용할때마다 적절한 동기화를 직접처리해주거나 Collections.synchronizedList 과 같은 함수를 사용하는 방법이 있습니다.

 

Collections.synchronizedList 사용법은 다음과 같습니다.

List list = Collections.synchronizedList(new ArrayList());

 

일단 아 저렇게 쓰면 쓰레드문제는 이제 신경안써도 되겠군~ 아싸~~!!! 하는 순간에 뒤통수 제대로 맞는거지요. 저렇게 되면 일단 함수하나를 사용할때는 락이 걸려서 중복호출이 되지 않겠지만, 여러함수가 호출될때 문제가 생길수있습니다.

다음과 같이

final List<String> list = Collections.synchronizedList(new ArrayList<String>());

final int nThreads = 2;

ExecutorService es = Executors.newFixedThreadPool(nThreads);

for (int i = 0; i < nThreads; i++) {

    es.execute(new Runnable() {

        public void run() {

            while(true) {

                try {

                    list.clear();

                    list.add("888");

                    list.remove(0);

                } catch(IndexOutOfBoundsException ioobe) {
                    ioobe.printStackTrace();

                }
            }
        }
    });
}

위의 코드를 실행하면 , Thread A 가 remove(0) 을 하는 순간에 Thread B 가 clear() 를 한다면 ,, 꽝~~
remove 할것이 없는데 remove 를 하려니 문제가 생길수밖에요.  이럴땐 

 synchronized (list) {

    list.clear();

    list.add("888");

    list.remove(0);

}

이렇게 함수들을 묶어서 동기화를 시켜줘야합니다. 
따라서 단지 Collections.synchronizedList  를 쓴다고해서 동기화 문제를 회피할수있지 않다는 얘기입니다.

 

2. Threadsafe Iteration & ConcurrentModificationException 이야기 

컬렉션 객체를 사용할때 우리는 동기화에 대해서 굉장히 불분명할때가 많은데요. 그리고 예상치 못한 문제가 생기기도 합니다. 얼마전에 생긴문제인데요. 

final List<String> list = new ArrayList<String>();

list.add("A");

list.add("B");

list.add("C");

for(String s : list) {

    if(s.equals("A")) {

        list.remove(s);

    }

}

위의 코드를 실행시키면 어떻게 될까요??  
ConcurrentModificationException 예외가 발생합니다. 정확히는 remove 끝내고 다시 위로 올라간후에 list 로 부터 다음것을 할당받는순간에 발생합니다.  (내부에서 next() 함수 호출될때) 

솔직히 저런 코드에서 이런 예외가 발생할수있다는걸 예상하는건  어렵습니다. 너무나 뻔해보이는 코드이고 설마 내부에서 해결해주지 않았겠어 하는 마음때문인데요. 저게 왜 예외가 발생하는지 알려면 소스 내부를 살펴봐야합니다.

내부를 살펴보겠습니다.!!

modCount 를 주의깊게 살펴봐주세요 !!!

 

ArrayList 의 add 함수 입니다.

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    ....
}

modCount 를 하나 증가시키고 있습니다.  위의 소스에서 add 를 3번하니깐 3이 되었겠네요. size 는 3 이 됩니다.

다음은  list.remove(s); 함수를 살펴보겠습니다.

private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}

역시나 modCount 를 하나 증가시키고 있습니다. 이때 modeCount 는 4가 됩니다.  size 는 2로 줄어듭니다.

modCount가 늘어나고 size  가 줄어들기전인 처음 for  문이 시작되는 상황으로 돌아가서   for(String s : list)   부분을  살펴봅시다.

list 에서 s 객체를 가져오기 위해서  처음에는 Iterator 객체를 생성합니다.

이때 Iterator 객체는 list 객체의 값 (modeCount) 를 할당받습니다.

 

public Iterator<E> iterator() {
    return new Itr();
}
private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;

 expectedModCount = modCount;  이렇게  두 값은 3이 됨을 알수있습니다. CURSOR = 0  이 됩니다.

 

다음으로는  hasNext 를 호출하여 순회가능한지 확인합니다.

public boolean hasNext() {
    return cursor != size;
}

size 는 3일것이고 CURSOR = 0  이기때문에 순회가능합니다.

이후에 modCount 가 4 가되었고. 다시 위(for  문 시작) 로 올라가봅시다.

자 대망의 next() 함수입니다. 순회가능하기때문에 해당차례의 객체를 얻기위하여 next() 를 호출합니다.

public E next() {
    checkForComodification();
    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

자 여기 checkForComodification() 함수를 잘보시면 modCount != expectedModCount 다르면 예외가 터집니다.

위에 remove 함수쪽을 다시 올라가서 보시면 remove 하면서 modeCount 는 4가 되었고, size 는 2가 되었는데, Iter 객체의 expectedModCount  는 여전히 3입니다.

무언가 수정이 이루어질때 이 변수는 변하지 않는겁니다. 그래서 예외가 발생하는거지요.

여기서  눈치챈분도 있겠지만 remove 뿐만아니라 add 해도 예외가 발생합니다.

그래서 저런 수정이 있을경우 대신해서 다음과 같이 작성합니다.

for(Iterator<String> iter = list.iterator(); iter.hasNext();) {

    String s = iter.next();

    if(s.equals("Test1")) {

        iter.remove();

    }

}

예외가 발생하지 않습니다.  이유는 다음 코드와 같이

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

iter 객체의 remove 에서는 expectedModCount = modCount; 를 같게끔 변경해주기때문입니다.

 

자 여기까지는 싱글쓰레드 얘기였습니다. 

이제 여기에 두개의 쓰레드가 동시에 컬렉션을 건드린다고 생각해봅시다...끔찍합니다. 그래서 akka 니 무상태 지향 코딩이니 같은것들이 나오는건가 봅니다.

설사 아래와 같이 foreach 를 쓰지않고 iterator 를 쓴다고 해도 멀티쓰레드상에서는 컬렉션의 아이템 수정이 가해지면 헬이됩니다.  여러모로 ConcurrentModificationException  예외는 멀티쓰레드 프로그래밍에서 중요한 예외가 될것입니다. 

 

3. Volatile  실패 이야기 

가끔 ConcurrentModificationException   예외는 의도치 않게 행동을 합니다.

public E next() {
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
      .....
}

위의 소스를 보면 내부 상태가 불일치 할때 ConcurrentModificationException  를 던지기로 되있는데요. 황당하게 어떤때는  예외를 안던지는 경우가 있습니다.  

A의 쓰레드가 next 를 호출하고 있고, B 쓰레드는 modCount 를 수정하고있습니다. 이때 위에 살펴본것과 마찬가지로 예외가 발생해야하는데 예외가 발생하지 않습니다.

B 쓰레드가 수정한 modeCount 가 A 쓰레드의 메모리공간에서는 변경되지 않았기때문인데요. 이때 떠오르는 생각이 , 최신의 변수만을 적용하고 싶을때 변수에 붙히는 키워드가 멀까요? 

그렇습니다. Volatile 인데요. (http://tutorials.jenkov.com/java-concurrency/volatile.html  참고) 
Volatile 이 붙어있는지 확인했더니. 없습니다. 조슈아 블로흐(이펙티브 자바 저자 & 자바 아키텍트) 가 정신을 딴데 팔고 API 를 개발했던것일까요?  그건 아닙니다.  volatile 은 동기화에 그닥 도움을 줄수없습니다. 

updateness (가시성) 는 보장해도 atomicity (원자성) 는 보장하지 않습니다.Volatile 을 붙혀놔도 operator (++) 가 atomic 하지 않습니다.


4. 그래서 어떻게 하면 되냐 OTL 

멀티쓰레드를 짤 때 문제되는 경쟁조건, 메모리가시성,데드락 등을 요리조리 피해서 코딩하는것의 어려움에 봉착되어 있을 때, 우리의 구원자 Dug Lea 가 나타나주셨습니다. akka 라이브러리를 만든사람인데요. 그 분은 

자바에 concurrent collection API 를 추가해 주셨습니다.

 

5. 주요 컬렉션 이야기

이 게시물이 너무 길어지기때문에 간단하게 정리합니다.

CopyOnWriteArrayList 

CopyOnWrite 가 말해주는것처럼 read (select) 시는 아무런 동기화 문제가 없기때문에 놔두고 변경이 일어날경우 객체를 clone 해서 다루자는 전략입니다.

따라서 읽기행위가 많이 일어나는 곳에서 사용하기 좋습니다. 위의 예제에서도 ArrayList 를 이거로 바꾸면 예외가 발생하지 않습니다.

 

BlockingQueue 

보통 생산자 - 소비자 패턴에서 활용되는 큐로 많이 사용된다. 사실 이야기는 이 큐는 멀티쓰레드환경에서 대표할만한 컬렉션이라는 것이다.

전에 Actor / Akka 문서에 말한 큐같은것들이 대부분 이것으로 이루어져있다. 

소비자가 꺼내어 사용할동안 생산자는 멈춰있고, 생산자가 넣을동안 소비자는 멈춰있어야한다.

서로 쟁탈하면 선반은 망가질것이다.

 

ConcurrentHashMap

ConcurrentHashMap은 Map의 일부에만 Lock을 걸기때문에 HashTable과 synchronized Map 보다 효율적인게 특징이다.

 

저작자표시 비영리 변경금지 (새창열림)

'Java' 카테고리의 다른 글

[Java] 람다(Lambda) 함수 내에서의 지역 변수 사용 규약  (0) 2022.05.24
    'Java' 카테고리의 다른 글
    • [Java] 람다(Lambda) 함수 내에서의 지역 변수 사용 규약
    꿈꾸는 개발자 박상호입니다.
    꿈꾸는 개발자 박상호입니다.
    취미를 특기로, 특기를 꿈으로, 꿈을 직업으로!

    티스토리툴바