동시에 하나의 데이터에 여러명의 사용자가 접근하는 경우, 문제가 생길 여지가 있습니다. 이러한 문제의 대표적인 예시가 싱글톤에서 상태를 가지고 있는 경우입니다.

하나의 싱글톤 인스턴스를 동시에 여러 사용자가 상태를 변경하게 되면, 정합성이 맞지 않는 경우가 존재합니다. 다음 예시를 살펴보겠습니다.

class Wallet {

    public static final Wallet INSTANCE = new Wallet();

    private long bill;

    private Wallet() {
        this.bill = 0;
    }

    public void putInto(long bill) {
        this.bill += bill;
    }
}

두명의 사용자가 동시에 각각 putInto 메소드를 실행시킨다고 가정해 봅시다. 결과적으로 bill 필드의 값은 두 사용자의 putInto 메소드로 인한 변화중 하나만 적용되게 될 것입니다. 왜냐하면 따로 동기화 처리가 되어있지 않기 때문에 메소드를 실행시키는 시점엔 같은 필드값을 읽어들이기 때문입니다.

 

동시성 문제

 

테스트

그렇다면, 이러한 테스트는 어떻게 해볼 수 있을까요? 두개의 쓰레드를 동시에 putInto() 메소드를 실행하도록 하면 될 것 같습니다. 코드는 다음과 같습니다.

@DisplayName("동시성 테스트")
@Test
void 동시성_테스트() throws InterruptedException {
    final Wallet wallet = Wallet.INSTANCE;

    Thread threadPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드1 종료");
    });

    Thread threadAnotherPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드2 종료");
    });

    threadPutInto.start();
    threadAnotherPutInto.start();

    assertThat(wallet.getBill()).isEqualTo(3000);
}

예상과는 다르게, 3000원이 나오지 않고 0원이 나오는 문제가 발생했습니다. 무엇이 문제였을까요? 테스트에서 assertThat을 호출하는 시점이 쓰레드의 작업이 모두 끝난 시점이 아닐수도 있기 때문입니다. 따라서 쓰레드의 작업이 모두 끝난 다음에 테스트를 하도록 바꿔주어야 합니다.

 

그렇다면 이러한 문제는 어떻게 해결할 수 있을까요? 이 때 사용하는 것이 CountdownLatch입니다. CountdownLatch의 countDown() 메서드와 await() 메서드를 사용하여 위의 문제를 간단하게 해결할 수 있습니다.

 

CountdownLatch

생성자에 원하는 count를 명시하고, countDown() 메서드로 count를 하나씩 감소시킵니다. await() 메서드는 CountdownLatch가 0이 되기만을 기다리는 메소드입니다. 따라서 각각의 쓰레드에서 countdownLatch를 하나씩 감소시키고, 테스트 쓰레드(Test worker)에서는 countdownLatch가 0이 되기를 기다리도록 작성하면 될 것 같습니다.

 

그럼 CountdownLatch를 사용하여 원하는 결과가 나오도록 테스트를 수정해 보겠습니다.

@DisplayName("동시성 테스트")
@Test
void 동시성_테스트() throws InterruptedException {
    final Wallet wallet = Wallet.INSTANCE;
    final CountDownLatch countDownLatch = new CountDownLatch(2);
    System.out.println(Thread.currentThread().getName());
    Thread threadPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드1 종료");
        countDownLatch.countDown();
    });

    Thread threadAnotherPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드2 종료");
        countDownLatch.countDown();
    });

    threadPutInto.start();
    threadAnotherPutInto.start();

    countDownLatch.await();
    assertThat(wallet.getBill()).isEqualTo(3000);
}

음.. 예기치 못한 결과가 발생했네요..ㅠ 문제없이 순차적으로 진행되어 버렸습니다. 아무래도 너무 메서드 자체가 간단해서 순차적으로 실행된 것 같습니다. putInto 메서드에 어느정도 딜레이를 줘 보겠습니다.

 

public void putInto(long bill) {
    long current = this.bill;

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    this.bill = current + bill;
}

 

다시 테스트를 돌려보니, 예상한 대로 동시성 문제가 발생하는 것을 볼 수 있습니다.

 

마치며

동시성 테스트를 작성할 때는 위와 같이 여러개의 쓰레드를 사용하고, CountdownLatch를 사용하면 원하는 대로 쉽게 테스트를 작성할 수 있습니다.

간단한 메소드에 대한 동시성 테스트는 생각보다 어렵지만, (메소드 실행 시간을 거의 동일하게 맞추어 주어도 순차적으로 실행되어 버립니다) 트랜잭션 단위의 메소드에 대한 동시성 테스트는 앞의 예제보다는 훨씬 잘 될 것이라고 생각합니다.

'Language > Java' 카테고리의 다른 글

Java의 동시성 제어  (0) 2022.11.10
Collection 얕게 알아보기 (1)  (0) 2022.11.08
Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02

Synchronized

Synchronization is built around an internal entity known as the intrinsic lock  or monitor lock (The API specification often refers to this entity simply as a "monitor.")

https://docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html

 

Intrinsic Locks and Synchronization (The Java™ Tutorials > Essential Java Classes > Concurrency)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

https://www.baeldung.com/cs/monitor

  • Synchronized를 사용하여 동기화를 하는 경우 Monitor 방식으로 한다고 합니다.
  • 그렇다면 모니터 방식은 대체 무엇일까요?

모니터

  • 스레드가 상호 배제와 협력을 가질 수 있도록 하는 동기화 메커니즘입니다.
  • 상호 배제란 하나의 스레드만 Lock을 사용하여 특정 시점에 메서드를 실행 시킬 수 있는걸 의미합니다.
  • 협력이란 wait-set을 사용하여 특정 조건이 충족될 때까지 스레드를 대기시킵니다.

Java에서의 Monitor 구현

자바에서는 모든 객체가 Monitor가 될 수 있습니다. 즉, 모든 객체가 고유 잠금을 가지고 있습니다.

  • JVM에서 모든 객체와 클래스는 논리적으로 모니터와 연결되며 모니터의 상호 배제 기능을 구현하기 위해 잠금(Mutex)이 각 개체 및 클래스에 연결됩니다.
  • java.lang.Object에 notify 메소드와 wait 메소드가 존재합니다.

Lock을 얻기 위해서 여러 작업이 요청하는 경우 JVM의 스케쥴러가 하나의 작업을 선택합니다.

  • 이 때, 우선순위에 따라서 결정하며, 우선순위가 같은 경우 FIFO 방식으로 가져옵니다.

스레드를 일시 중지하는 wait() 메서드와 객체를 기다리고 있는 다른 스레드를 깨우기 위한 notify()메서드를 사용하여 구현합니다.

  • wait() : 쓰레드가 일시 중단되고 대기 세트에 추가되어 다른 쓰레드가 동일한 객체에 대해 notify()나 notifyAll()을 호출할 때까지 대기합니다.
  • notify() : 대기 세트에서 임의의 쓰레드가 깨어나도록 통지합니다. 어떤 쓰레드를 깨울 것인지 선택하는건 비결정적이며 JVM에 따라 다릅니다.
  • notifyAll() : 대기 세트에서 기다리고 있는 모든 쓰레드를 깨웁니다.

 

Volatile

  • 공유 필드의 최신 값을 보도록 설정합니다. CPU 캐시 등의 이유로 공유 필드의 최신 값을 읽지 못하는 문제가 발생할 수도 있습니다.

https://nesoy.github.io/articles/2018-06/Java-volatile

https://www.baeldung.com/java-volatile

 

Guide to the Volatile Keyword in Java | Baeldung

Learn about the Java volatile keyword and its capabilities.

www.baeldung.com

 

Atomic 클래스

CAS(Compare-and-Swap)을 활용하여 데이터 무결성을 보장합니다. CAS는 세개의 피연산자에서 작동합니다. 하나는 작동할 메모리 위치, 하나는 변수의 기존 기대값, 마지막 하나는 설정해야 하는 새 값입니다.

 

즉, CAS는 메모리 위치에 있는 값을 새 값으로 업데이트하는 방법입니다. 단, 이전 값이 변수의 기존 기대값과 같아야 합니다.

 

이번 예시에서는 AtomicInteger의 accumulateAndGet() 메서드 구조를 통해 어떻게 동시성을 유지하는지 확인해 보겠습니다.

public final int accumulateAndGet(int x,
                                      IntBinaryOperator accumulatorFunction) {
    int prev = get(), next = 0;
    for (boolean haveNext = false;;) {
        if (!haveNext)
            next = accumulatorFunction.applyAsInt(prev, x);
        if (weakCompareAndSetVolatile(prev, next))
            return next;
        haveNext = (prev == (prev = get()))
  1. weakCompareAndSetVolatile(expected, newValue) 메서드는 현재 값이 매개변수로 전달된 expectedValue와 같은 경우 AtomicReference에 대한 값을 원자적으로 newValue로 설정하는데 사용됩니다. 즉, 현재 값이 prev인 경우에만 next를 리턴하게 됩니다.
  2. haveNext는 기존 값이 변경되었는지의 여부입니다. 
    1. (prev == (prev = get())) 에서 기존 값과 실제 저장되어 있는 데이터를 비교하며 실제 저장되어 있는 데이터로 prev를 갱신합니다.
    2. haveNext가 False인 경우가 실제 저장된 데이터와 현재 데이터가 다른 것이므로, accumulatorFunction.applyAsInt 메서드의 결과값을 새롭게 next에 넣어줍니다.
  3. 결국 최신 값으로 prev가 설정이 되고 나서야 weakCompareAndSetVolatile 메서드에 의해 next로 값이 설정되고 리턴됩니다.

 

https://www.baeldung.com/java-atomic-variables

 

'Language > Java' 카테고리의 다른 글

동시성 테스트와 CountdownLatch  (0) 2022.12.08
Collection 얕게 알아보기 (1)  (0) 2022.11.08
Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02

전체 Collections의 구조는 다음과 같습니다.

 

List

  • 순서가 있는 데이터를 처리하기 위한 자료구조입니다.
  • 인덱스로 위치를 지정하여 값을 처리할 수 있습니다.
  • 중복을 허용한다는 성질이 있습니다.

그 전에, 자바엔 Vector라는 자료구조가 리스트에 있는데, 왜 사용되지 않는걸까요?

Vector의 문제점

  • Vector의 문제점은 모든 작업이 Synchronized 되어있다는 점입니다.
  • Vector 인스턴스가 동기화되는 것이 아니라 작업이 동기화되는 것이기 때문에 동시성을 보장하지 않습니다.
  • 한 쓰레드가 Vector을 for문으로 반복하고, 다른 쓰레드가 Vector의 데이터를 수정한다면 ConcurrentModificationException 예외가 발생합니다.
  • 즉, 서로 다른 두 작업이 동시에 수행될 수 있는 문제가 있습니다. 동시에 수행될 수 있다면 차라리 ArrayList를 쓰거나 Collections.synchronizedList() 메서드로 감싸주는 것이 더 좋습니다.
    • Collections.synchronizedList()는 각 메소드에서 mutex로 동기화를 처리하여 한 메서드가 실행중일 때 다른 메서드가 접근할 수 없습니다.

1. ArrayList

  • 데이터를 배열에 저장합니다.
  • 리스트가 꽉 차게 되면 배열을 늘려줍니다.
    • 배열의 크기는 이전 배열 크기의 1.5배로 늘어납니다.
    • oldCapacity >> 1 ⇒ oldCapacity * 0.5
    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity <= 0) {
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }
    
  • 따로 동시성이 처리되지는 않습니다.

2. LinkedList

  • List 뿐만 아니라 Queue를 구현합니다.
  • add() 메서드 호출시 마지막 노드에 추가 노드를 삽입한다.
    • 노드의 경우는, 양방향 노드입니다. prev, next를 저장합니다.
    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    
    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }
    

get() 메서드 호출시 ArrayList의 경우는 배열의 index로 접근하지만, LinkedList의 경우는 첫번째에서부터 n번 이동하여 데이터를 가져옵니다. 이로 인해 조회 속도가 ArrayList에 비해선 느릴 수 있습니다.

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

참고로, LinkedList는 동시성에 대해선 따로 처리되지 않습니다.

 

Set

  • 중복이 없는 집합 객체를 만들 때 유용합니다.
  • 순서가 없어서 인덱스로 위치를 지정하여 값을 처리할 수 없습니다.

1. HashSet

  • 내부적으로 HashMap을 가지고 있습니다.
  • 데이터를 추가할 때 HashMap의 put() 메서드를 사용합니다.
    • PRESENT는 빈 Object입니다. (더미 값)
    private transient HashMap<E,Object> map;
    
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    
  • 값을 가지고 있는지 유무 체크 또한 Map의 메서드를 사용합니다.
  • public boolean contains(Object o) { return map.containsKey(o); }

2. TreeSet

  • 내부적으로 NavigableMap을 가지고 있습니다. 기본으로 사용하는 구현체는 TreeMap입니다.
  • 데이터를 추가하거나 데이터에 값을 가지고 있는지 유무는 HashSet과 마찬가지로 Map의 메서드를 사용합니다.
  • TreeMap의 성질로 인해 데이터가 정렬되어 있습니다. 이로 인해 compareTo 메서드를 잘 재정의 해두어야 합니다.
    • Key를 기준으로 오름차순, 내림차순으로 정렬된 Map입니다.
  • NavigableMap

3. LinkedHashSet

  • 값이 들어간 순서대로 출력 가능합니다.

 

Map

  • 키-값 형태의 데이터를 저장합니다.

1. HashMap

해시를 기준으로 키-값을 저장합니다. 데이터 저장 로직인 putVal() 메서드의 코드를 하나하나 분석해 보겠습니다.

Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

초기 테이블 설정이 되어있지 않은 경우 테이블을 설정합니다. resize() 메서드는 테이블 크기를 초기화하거나 2배로 재조정하며 초기 테이블이 설정되어 있지 않은 경우 입력된 초기 크기로 설정하거나 초기값을 16으로 설정합니다.

if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    Node<K,V> e; K k;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        e = p;
    else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
        for (int binCount = 0; ; ++binCount) {
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                break;
            }
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                break;
            p = e;
        }
    }

기존에 동일한 해시값으로 데이터가 들어오지 않았다면 newNode() 메서드로 새로운 노드를 만들어서 넣어줍니다. 존재한다면 두 분기점이 존재합니다. equals()와 hashcode가 모두 같은 경우는 같은 데이터가 들어온 것으로 간주합니다. 타입이 TreeNode인 경우는 테이블이 아닌 트리구조인 형태이며, TreeNode가 아닌 경우, 체이닝으로 되어있으므로 다음 링크로 이동해 나가면서 같은 데이터가 있는지 찾고, 없다면 맨 뒤에 데이터를 넣어줍니다.

 

단, 여기서 TREEIFY_THRESHOLD - 1 이상인 경우 테이블 구조에서 트리구조로 변경합니다. TREEIFY_THRESHOLD는 8이므로, 동일한 해쉬값인 데이터가 8개 들어오는 순간 트리구조로 변환할 것 같지만, 그렇지 않습니다.

final void treeifyBin(Node<K,V>[] tab, int hash) {
	  int n, index; Node<K,V> e;
	  if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
	      resize();
	  else if ((e = tab[index = (n - 1) & hash]) != null) {
	      TreeNode<K,V> hd = null, tl = null;
	      do {
	          TreeNode<K,V> p = replacementTreeNode(e, null);
	          if (tl == null)
	              hd = p;
	          else {
	              p.prev = tl;
	              tl.next = p;
	          }
	          tl = p;
	      } while ((e = e.next) != null);
	      if ((tab[index] = hd) != null)
	          hd.treeify(tab);
	  }
}

사실상, 테이블의 크기가 MIN_TREEIFY_CAPACITY(64) 아래인 경우에는 resize() 메서드를 호출하여 테이블의 크기를 늘려주기만 하고, 기존에 있는 데이터를 다시 테이블에 넣어주기만 합니다. 크기가 64 이상인 경우에만 트리구조로 변경시켜 줍니다. 왜 이런 구조가 되었을까요?

 

왜냐하면 트리를 사용하는건 최대한 뒤로 미루어야 하기 때문입니다. 해시 테이블의 경우는 이론상 O(1)의 시간이 걸리나, 트리를 사용한 탐색의 경우 O(log n)의 시간이 걸리기 때문입니다. 일반적으로 작은 테이블 크기의 경우 확장을 통해 해시 충돌을 해결할 가능성이 높습니다.

 

그렇다면 왜 트리를 사용하게 될까요? 이 부분은 개인적인 생각입니다. 해시 충돌이 잦은 경우를 생각해 봅시다. 충분하게 테이블 크기를 늘렸음에도 불구하고 해시 충돌이 매우 잦게 일어나게 되면 테이블에 데이터가 균일하게 들어가지 않고, 하나의 버킷에 데이터가 몰리는 현상이 일어나게 됩니다. 이러한 경우 결국 해시 테이블을 사용했지만 검색 속도가 O(n)에 가깝게 될겁니다. 따라서 트리를 사용하게 되면 O(log n)의 시간이 걸리니 오히려 트리가 더 빠르게 동작할 수도 있을 것 같습니다.

if (e != null) { // existing mapping for key
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
}
++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

마지막으로, 이미 존재한 경우에 대해서 처리하고 저장된 데이터의 수가 임계치보다 커진다면 크기를 더 확장시켜 줍니다.

 

2. TreeMap

  • 정렬된 키값 순으로 데이터가 조회됩니다.
  • Red-Black Tree를 사용합니다. 적절한 위치에 데이터를 삽입한 다음 Red-Black 후처리를 진행합니다. Red-Black Tree에 대해선 추후에 다시 다룰 예정입니다.
private void fixAfterInsertion(Entry<K,V>x) {
    x.color = RED;

    while (x != null && x != root && x.parent.color == RED) {
        if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
            Entry<K,V> y = rightOf(parentOf(parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
x= parentOf(parentOf(x));
            } else {
                if (x== rightOf(parentOf(x))) {
x= parentOf(x);
                    rotateLeft(x);
                }
                setColor(parentOf(x), BLACK);
                setColor(parentOf(parentOf(x)), RED);
                rotateRight(parentOf(parentOf(x)));
            }
        } else {
            Entry<K,V> y = leftOf(parentOf(parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
x= parentOf(parentOf(x));
            } else {
                if (x== leftOf(parentOf(x))) {
x= parentOf(x);
                    rotateRight(x);
                }
                setColor(parentOf(x), BLACK);
                setColor(parentOf(parentOf(x)), RED);
                rotateLeft(parentOf(parentOf(x)));
            }
        }
    }
    root.color = BLACK;
}
  • 자세한건 Red-Black Tree를 검색해봅시다.

 

3. LinkedHashMap

  • 값이 들어간 순서대로 데이터가 저장됩니다.
  • 내부에 Head, Tail을 따로 두어 Map이 변경될때마다 같이 조절하여 저장 순서를 유지합니다. 구현 로직은 HashMap의 메서드들을확장해서 사용합니다.

put 메서드 기준으로 살펴보겠습니다. HashMap의 putVal메서드에서 사용하는 newNode 메서드를 재정의해서 사용합니다.

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
		LinkedHashMap.Entry<K,V> p =
		    new LinkedHashMap.Entry<>(hash, key, value, e);
		linkNodeLast(p);
		return p;
}

private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

 

linkNodeLast(p)라는 메서드가 살짝 낀걸 볼 수 있습니다. 이 친구는 무엇을 하는 역할일까요? Tail의 링크에 p를 연결시켜주고 Tail을 p로 설정해주는걸 알 수 있습니다. 따라서 Head에서 시작해서 링크를 따라 Tail로 이동하면 데이터를 넣은 순서대로 조회가 가능합니다.

다음은 LinkedkeySet의 forEach 메서드의 구현 코드입니다.

public final void forEach(Consumer<? super K> action) {
    if (action == null)
        throw new NullPointerException();
    int mc = modCount;
    for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
        action.accept(e.key);
    if (modCount != mc)
        throw new ConcurrentModificationException();
}

 

실제로 head에서 시작해서 null이 될 때 까지(tail의 after은 null입니다.) 이동하면서 action을 실행시켜 주는걸 볼 수 있습니다.

 

참고

 

Collections Framework Overview

Collections Framework Overview Introduction The Java platform includes a collections framework. A collection is an object that represents a group of objects (such as the classic Vector class). A collections framework is a unified architecture for represent

docs.oracle.com

 

'Language > Java' 카테고리의 다른 글

동시성 테스트와 CountdownLatch  (0) 2022.12.08
Java의 동시성 제어  (0) 2022.11.10
Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02

2022년 10월 31일 월요일, 저는 백엔드 이프와 함께 자바를 학습하고 있었습니다. 그러던 중 이프가 흥미로운 주제를 던졌습니다. Final 키워드가 실제로 성능이 향상된다는 점이었습니다.

처음 들었을 때는, 말도 안되는 소리라고 생각했지만.. 학습해 보니 성능이 향상될 수도 있다는 점을 알게 되었습니다.

 

우선, Final 키워드를 붙인 코드입니다.

@Test
public void sum1() {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
        final String a = "a";
        final String b = "b";

        String c = a + b;
    }
    long end = System.currentTimeMillis();

    System.out.println(end - start);
}

 

다음으로, Final 키워드를 붙이지 않은 코드입니다. 동일한 코드를 사용하며 정말 final 키워드만 빼두었습니다.

@Test
void test2() {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
        String a = "a";
        String b = "b";

        String c = a + b;
    }
    long end = System.currentTimeMillis();

    System.out.println(end - start);
}

생각보다 위 코드의 경우는 성능 차이가 나타나는 것을 볼 수 있습니다. 그렇다면 왜 이런 결과가 나타났을까요? 컴파일 시 최적화가 일어나기 때문입니다. 실제로 생성된 바이트코드를 살펴보겠습니다.

 

우선 Final 키워드를 사용한 코드의 바이트코드를 살펴보겠습니다.

예상은 + 연산을 해줄 것이라고 생각했지만, 실제 결과는 따로 연산을 수행하는 것이 아닌 “a”와 “b”가 더해진 결과인 “ab”를 곧바로 넣어줍니다.

 

다음으로, Final 키워드를 사용하지 않은 코드의 바이트코드를 살펴보겠습니다.

Final 키워드를 사용하지 않으니 우리가 생각한 결과인 a와 b를 불러오고, a와 b를 makeConcatWithConstants 메서드를 통해 더해주는 모습을 볼 수 있었습니다.

 

결론

즉, 결론적으로 모든 경우에서 무조건 성능이 좋아진다고 보기는 어렵지만, 위와 같은 경우 Final 키워드를 사용하니 컴파일러가 최적화 해주는 모습을 볼 수 있었습니다.

 

Reference


https://stackoverflow.com/questions/4279420/does-use-of-final-keyword-in-java-improve-the-performance

'Language > Java' 카테고리의 다른 글

Java의 동시성 제어  (0) 2022.11.10
Collection 얕게 알아보기 (1)  (0) 2022.11.08
JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02
[ Junit ] 랜덤 데이터 테스트  (0) 2022.02.10

참고 자료

https://www.geeksforgeeks.org/jvm-works-jvm-architecture/

 

How JVM Works - JVM Architecture? - GeeksforGeeks

A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.

www.geeksforgeeks.org

https://tecoble.techcourse.co.kr/post/2021-07-15-jvm-classloader/

 

JVM에 관하여 - Part 2, ClassLoader

Java 로 작성한 코드는 어떻게 돌아가는 걸까? 해당 물음에 답을 찾기 위한 JVM 시리즈 2편, JVM 의 구성 요소 중 ClassLoader 에 관한 글입니다. 이번 글에서는 ClassLoader…

tecoble.techcourse.co.kr

 

JVM이란 Java 응용 프로그램을 실행하는 런타임 엔진 역할을 합니다. JVM에서 실제로 자바 코드상에 존재하는 main() 메서드를 호출하며, JRE(Java Runtime Environment)의 일부입니다.

Java 어플리케이션은 한 시스템에서 Java 코드를 작성하더라도 변경 없이 다른 Java를 지원하는 시스템에서 실행할 수 있는 성질이 있는데, JVM을 사용하기 때문입니다.

JVM의 동작 과정

Java 파일을 컴파일할 때, .java 파일에 있는 동일한 클래스 이름을 가진 클래스 파일이 컴파일러에 의해 생성됩니다. class 파일은 실행할 때 다양한 단계를 거치는데, 이 단계들이 바로 전체 JVM의 동작 과정입니다.

 

전체 JVM 구조

 

Class Loader


주로 세가지 역할을 담당합니다.

  1. Loading
  2. Linking
  3. Initialization

Loading

  • ClassLoader가 필요한 클래스들을 불러와서 적재시키는 과정입니다.

Initialization

초기화 단계에서 모든 정적 변수는 코드 및 정적 블록(있는 경우)에 정의된 값으로 할당됩니다. 클래스 내부에서 위에서 아래로, 클래스 계층에서 부모에서 자식으로 실행됩니다. 일반적으로 세개의 클래스 로더가 존재합니다.

클래스 불러오는 과정

클래스를 불러올 때, Bootstrap Class Loader에서 찾을 수 없다면 Extension ClassLoader, Extension ClassLoader에서 찾을수 없으면 Application ClassLoader을 찾아 보고, 최종적으로 없다면 ClassNotFoundException이 발생합니다.

Class Loader의 종류

  1. Bootstrap Class loader
    • JAVA_HOME/jre/lib 디렉토리에 존재하는 핵심 자바 API 클래스를 로드합니다.
  2. Extension Class loader
    • 확장 디렉토리(JAVA_HOME/jre/lib/ext) 또는 기타 디렉토리(java.ext.dirs 시스템 속성에 의해 지정)에 있는 클래스들을 로드합니다.
    • ExtClassLoader 클래스에 의해 구현됩니다.
  3. System/Application class loader
    • Extension Class loader의 자식입니다.
    • 애플리케이션 클래스 경로에서 클래스를 불러오는 역할을 합니다. 즉, 개발자들이 작성한 클래스 파일이 불러와 집니다.
    • 내부적으로 java.class.path에 매핑된 환경 변수를 사용하며, AppClassLoader 클래스에 의해 구현됩니다.

 

Linking


Linking 과정에서는 verification(검증), preparation(준비), (optionally) resolution(해결)을 수행합니다.

verification(검증)

  • 클래스 파일(.class)의 정확성을 보장합니다.
    • 파일이 올바른 형식으로 지정되고 유효한 컴파일러에 의해 생성되었는지 여부를 확인합니다.
    • 실패하는 경우 예외(java.lang.VerifyError)이 발생합니다. ByteCodeVerifier에 의해 작업이 수행되며, 완료되면 클래스 파일을 컴파일 할 준비가 되어있는 상태입니다.

preparation(준비)

JVM에서 클래스 정적 변수(Static)에 대한 메모리를 할당하고, 메모리를 기본 값으로 초기화합니다.

resolution(분석)

  • 타입의 Symbolic Referrence를 direct references로 바꾸는 작업입니다.
  • 대표적인 예시로는 new나 instaceof가 있습니다.

Initialization

  • Linking 이후, 클래스 파일의 코드를 읽는 과정입니다.
  • 자바 코드에서의 class와 interface 값들을 지정한 값으로 초기화하거나 초기화 메서드를 실행시켜 줍니다.

 

JVM 메모리

  1. 메서드 영역
    • 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메서드에 대한 정보, Static 변수, 메서드의 바이트 코드 등을 보관
    • JVM당 하나의 메서드 영역만 존재하며, 공유 자원입니다.
    • Java 8부터는 정적 변수가 힙 영역에 저장
    • 런타임 상수 풀(Runtime Constant Pool)이 존재합니다. 
      • 각 클래스와 인터페이스의 상수 뿐 아니라 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블
      • 어떤 메서드나 필드를 참조할 때, JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조
  1. 힙 영역
    • 모든 객체의 정보 저장
    • JVM당 하나의 힙 영역이 있습니다.
    • 공유 리소스입니다.
  2. 스택 영역
    • 모든 스레드에 대해 JVM은 하나의 런타임 스택을 생성합니다. 스택 내의 모든 블록을 메서드 호출을 저장하는 활성화 레코드 또는 스택 프레임이라고 합니다.
    • 해당 메서드의 모든 지역 변수는 해당 프레임에 저장
    • 스레드가 종료된 후 런타임 스택은 JVM에 의해 파괴
    • 공유 리소스가 아닙니다.
  3. PC 레지스터
    • 스레드의 현재 실행 명령의 주소를 저장합니다.
    • 각 스레드에는 별도의 PC 레지스터가 존재합니다.
  4. 네이티브 메서드 스택
    • 모든 스레드에 대해 별도의 네이티브 스택이 생성됩니다.
    • 기본 메서드 정보를 저장합니다.

 

실행 엔진

실행 엔진은 .class 파일을 실행합니다. 바이트 코드를 한 줄씩 읽고, 다양한 메모리 영역에 있는 데이터와 정보를 사용하고 명령을 실행합니다. 컴파일러를 통해 바이트 코드로 변경되고, JIT 컴파일러를 거쳐 Native Code가 됩니다.

  1. Interpreter
    • 바이트코드 명령어를 한 줄씩 해석하여 실행합니다.
    • 하나의 메서드를 여러번 호출할 때 매번 해석이 필요하다는 단점이 있습니다.
  2. Just-In-Time Compiler(JIT)
    • 인터프리터의 효율성을 높이기 위해 사용
    • 전체 바이트 코드를 컴파일하여 네이티브 코드로 변경하여 인터프리터가 반복되는 메서드 호출을 볼 때마다 JIT에서 해당 부분에 대한 직접 네이티브 코드를 제공하므로 재해석이 필요하지 않아 효율 향상이 됩니다.
  3. Garbage Collector
    • 참조되지 않은 객체를 삭제합니다.

 

자바 네이티브 인터페이스(JNI)

Native Method Libraries와 연동하여 실행에 필요한 Native Library를 제공하는 인터페이스입니다. JVM이 C/C++ 라이브러리를 호출하고 하드웨어에 특정한 C/C++ 라이브러리에 의해 호출될 수 있도록 합니다.

네이티브 메소드 라이브러리

  • Execution Engine에 필요한 Native Libraries의 모음입니다.

 

참고

https://steady-snail.tistory.com/67

 

[JAVA] JVM 동작원리 및 기본개념

JAVA라는 언어를 통해 코딩을 하고 있는 사람으로서 JAVA의 간단한 탄생배경 그리고 JAVA의 시작과 끝이라고 할 수 있는 JVM을 한 번 짚고넘어가려고 해요 우선 JAVA의 탄생배경을 좀 알고가면 이해하

steady-snail.tistory.com

https://d2.naver.com/helloworld/1230

 

'Language > Java' 카테고리의 다른 글

Collection 얕게 알아보기 (1)  (0) 2022.11.08
Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
lambda 예외 핸들링  (0) 2022.10.02
[ Junit ] 랜덤 데이터 테스트  (0) 2022.02.10
[ Junit ] 테스트 작성  (0) 2022.02.10

jdbc 템플릿을 직접 구현해보는 미션을 하다가 쿼리를 실행하는 모든 메서드에 try-with-resource가 있는 문제점이 발견되었습니다. 중복을 처리하기 위해 처음에는 try-with-resource 부분을 Function 함수형 인터페이스를 사용하여서 처리하도록 구현 해 보았습니다.

 

이 때, 한가지 문제점이 도출되었습니다.

Checked Exception이 발생한 모습
Try-Catch 블록을 모두 써주어야 한다.

Function 함수형 인터페이스에는 throws가 정의되어 있지 않기 때문에, throws로 예외를 상위로 넘겨줄 수 없고, try-catch로 예외를 모두 핸들링해주어야 하는 문제점이 발생했습니다. 물론 try-catch 블록으로 해결할 수 있겠지만, 그렇다면 doExecute 메서드를 사용하는 모든 메서드에서 try-catch 블록을 써주어야 할 테니 코드가 좀 지저분해 질 거 같다고 생각이 들었습니다.

 

해결하고자 구글링을 조금 해보고 찾아보니 간단하게 사용자 정의 함수형 인터페이스를 사용하면 된다고 합니다. 사용자 정의 함수형 인터페이스의 메서드에 throws를 붙여주면 됩니다. 

스프링을 참고하면서 만들다 보니, 실제 클래스명을 그대로 사용했습니다.

 

사용자 정의 함수형 인터페이스를 만들어 주었다면, 이를 활용하도록 코드를 변경해 봅시다.

 

더이상 빨간줄이 나타나지 않는 모습을 볼 수 있습니다. try-catch 블록을 따로 써줄 필요 없어 코드가 더욱 깔끔해졌네요.

'Language > Java' 카테고리의 다른 글

Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
JVM의 동작원리  (0) 2022.10.22
[ Junit ] 랜덤 데이터 테스트  (0) 2022.02.10
[ Junit ] 테스트 작성  (0) 2022.02.10
[ Java ] 쉽게 최대공약수 구하기  (0) 2022.02.03

우아한 테크코스 미션을 진행하던 도중, TDD로 개발을 하는데 랜덤 데이터를 테스트해야 하는 경우가 생겼다. 이 때, 랜덤 데이터를 뽑는걸 어떻게 테스트해야 할까 고민하다가 좀 검색하고 찾아보니 랜덤 데이터를 사용하는 클래스의 생성자에 랜덤 데이터를 뽑는 인터페이스를 받도록 구현하고 테스트 클래스에서는 구현 객체를 특정 정수를 리턴하도록 구현하였다. 그 결과 어느정도 만족스럽게 된 것 같다.

 

RandomUtil 인터페이스 생성


우선, 랜덤값을 발생시키는 generate 메소드를 가지는 RandomUtil 인터페이스를 만들어 둔다.

public interface RandomUtil {
    public int generate();
}

 

클래스 생성자에 RandomUtil 받도록 구현


Example 클래스에서 RandomUtil의 구현 객체에 의존하도록 생성자에 RandomUtil을 추가해 준다.

 

public class Example {
    RandomUtil randomUtil;

    public Example(RandomUtil randomUtil) {
        this.randomUtil = randomUtil;
    }

    public int generate() {
        return randomUtil.generate();
    }
}

 

테스트 케이스 작성


위와 같이 생성자에서 RandomUtil을 추가해 주면, 넘겨준 RandomUtil을 조정해주는 방식을 통해 원하는대로 테스트를 진행할 수 있다. 따라서, Random의 결과를 조정해 줄 수 있으므로, 각각의 결과에 따라 테스트 케이스를 작성하는 방식으로 진행하였다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Random;

import static org.junit.jupiter.api.Assertions.*;

class ExampleTest {
    @Test
    @DisplayName("generate의 결과로 1이 나옴")
    void generate_1() {
        Example example = new Example(() -> 1);
        assertEquals(example.generate(), 1);
    }

    @Test
    @DisplayName("generate의 결과로 5가 나옴")
    void generate_5() {
        Example example = new Example(() -> 5);
        assertEquals(example.generate(), 5);
    }

    @Test
    @DisplayName("generate의 결과로 난수가 나옴")
    void generate_Random() {
        Example example = new Example(() -> {
            Random random = new Random();
            return random.nextInt(100);
        });
        int randomData = example.generate();
        assertTrue(0 <= randomData && randomData < 100);
    }
}

'Language > Java' 카테고리의 다른 글

JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02
[ Junit ] 테스트 작성  (0) 2022.02.10
[ Java ] 쉽게 최대공약수 구하기  (0) 2022.02.03
[ Java ] 문자열 알파벳순 정렬하기  (0) 2022.01.12

이번 우아한테크코스 시간에는 테스트 작성 기법과 TDD에 관하여서 학습 및 실습을 하였다. 실제로 페어와 TDD로 의식적으로 개발을 하려다 보니, 테스트 케이스를 어떻게 작성해야 할 지 고민을 많이 하게 되는 것 같고, 무엇보다 junit을 능숙하게 사용하지 못하다 보니 턱턱 막히는 경우가 많아 따로 정리해보려고 한다ㅎㅎ


Dependency


처음에는 왜 자동완성이 안될까 고민했었는데 Dependency 설정을 하지 않아서 문제가 있었음을 알 수 있었다..ㅋㅋ

testImplementation 'org.assertj:assertj-core:3.22.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'

 

애노테이션(Annotation)


Junit을 사용하면 애노테이션을 활용하여 테스트 케이스를 구현할 수 있다.

  1.  @Test
    • 테스트를 진행 할 메소드
    • AssertEquals 등 Assert 클래스의 static 메소드 활용
  2. @BeforeEach
    • 각각의 테스트(@Test)를 진행하기 전에 실행 할 메소드 
  3. @AfterEach
    • 각각의 테스트(@Test)를 진행한 후에 실행 할 메소드
  4. @BeforeAll
    • 모든 테스트를 실행하기 전에 단 한번만 실행되는 메소드
    • static으로 선언
  5. @AfterAll
    • 모든 테스트를 실행한 후에 단 한번만 실행되는 메소드
    • static으로 선언

BeforeAll과 AfterAll이 static으로 선언되는 이유는 static으로 선언해 두어야 각 테스트를 실행하기 전에 실행되어지기 때문이라고 한다.


예시


  • BeforeEach 애노테이션을 통해 테스트 진행 전, numbers에 값을 설정해 준다.
    • 이 때, Set 자료구조의 특성상 중복된 1은 제거된다.
  • Test 애노테이션에서는 실제 테스트가 진행된다.
    • BeforeEach의 결과로 Set 자료구조에는 [1, 2, 3]이 들어가 있다.
    • 따라서, size()는 3이어야 하므로 이를 assertEquals 메소드를 통해 테스트를 진행한다.
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

private Set<Integer> numbers;

@BeforeEach
void setUp() {
    numbers = new HashSet<>();
    numbers.add(1);
    numbers.add(1);
    numbers.add(2);
    numbers.add(3);
}

@Test
void setSizeTest() {
    assertEquals(numbers.size(), 3);
}

 

 

최대공약수를 구할 때, 여지껏 두개의 수를 입력받아서 나머지와 나눗셈 연산을 통해 구하는 방법을 사용했지만 최근에는 파이썬에서는 Math.gcd라는 함수가 있어 잘 사용했지만, Java에서는 Math 클래스에 gcd 함수가 없더라.. 내장함수를 쓰면 편하니까 비슷한 함수가 있나 찾아보니 BigInteger라는 클래스에 gcd라는 함수가 있었다!

 

BigInteger 클래스를 사용해서 최대공약수를 구하려면 다음과 같은 단계를 거쳐 최대공약수를 구할 수 있다.

  1. 2개의 숫자(int형)를 BigInteger 클래스로 변환한다.
    • 이 때, BigInteger의 valueOf 함수를 사용해서 변환해 준다.
  2. BigInteger 클래스의 gcd 함수를 사용하여 최대공약수를 구한다.
  3. 구한 gcd 값을 다시 int형으로 변환한다.
    • 이 때, BigInteger 클래스의 intValue() 메서드를 사용해서 변환해 준다.

 


예시


BigInteger a1 = BigInteger.valueOf(5);
BigInteger a2 = BigInteger.valueOf(10);
int gcd = a1.gcd(a2).intValue();

'Language > Java' 카테고리의 다른 글

[ Junit ] 랜덤 데이터 테스트  (0) 2022.02.10
[ Junit ] 테스트 작성  (0) 2022.02.10
[ Java ] 문자열 알파벳순 정렬하기  (0) 2022.01.12
[알고리즘] Java로 구현한 Union-find  (0) 2021.07.11
[알고리즘] HashMap  (0) 2021.07.11

전체적인 과정을 살펴보면 각각의 문자열을 한자리씩 끊어서 char 타입의 배열로 만들어준 다음, char 타입의 배열을 정렬하는 방식으로 진행하는 것이다..

 

이 과정을 스트림을 사용하여 구현하기 위해서는, 다음과 같은 순서로 진행한다.

  1. 문자열을 chars() 메소드를 사용하여 Intstream으로 바꿔준다.
  2. Intstream을 박싱하여 IntegerStream으로 바꾸어준다.
  3. sorted 메서드를 사용하여 정렬된 스트림을 얻는다. 이 때, Comparators.reverseOrder를 인자로 넘겨주면 내림차순으로 정렬된다.
  4. 이후 정렬된 스트림을 int[] 타입의 배열로 얻어준 다음, Char형으로 바꿔주면서 한자리씩 추가해주는 방식으로 정렬된 문자열을 얻을 수 있다.

 

- char형 타입을 스트림으로 다룰때는 잘 되지 않는 점이 많으니 intstream으로 간주하고 int 배열로 처리하는것이 훨씬 편하다. 맨 마지막에 (char)만 붙여주면 아스키 코드가 문자화 되니까ㅎㅎ

 

import java.util.Comparator;

class Solution {
    public String solution(String s) {
        String answer = "";

        int[] temp = s.chars()
                .boxed()
                .sorted(Comparator.reverseOrder())
                .mapToInt(i -> i)
                .toArray();
        for (int ch : temp) {
            answer += (char)ch;
        }
        return answer;
    }
}

+ Recent posts