JAVA

java for, for-each, forEach 성능 비교, 장단점, 특징 정리

@xftg77g 2022. 10. 26. 00:23

for-loop vs for-each (향상된 for문)

// for-loop
for(int i = 0; i < size; i++) {}

// for-each
for(var i : list) {}
for-loop    
장점 인덱스 조작 가능 ArrayList, Array 순회 시 비교적 빠르다
단점 Out of range 발생 가능 LinkedList 순회 시 매우 느림
for-each    
장점 Out of range 발생 여지 없음 LinkedList 순회 시 준수한 성능
단점 인덱스 조작 불가능  

 

for-each(향상된 for문) vs forEach method

// for-each
for(var i : list) {}

// forEach
collection.forEach(i -> {
	//
});

사실 forEach 메서드는 내부적으로 for-each(향상된 for문)을 사용합니다. 아래는 컬렉션에서 Iterable 인터페이스를 상속받아 사용하는 forEach 메서드입니다.

// Iterable interface

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

즉 for-each 메서드 내부 동작을 정의하는 Consumer를 구현 객체나 익명 구현 객체 또는 람다로 다루는 함수형 프로그래밍이 가능한 것이지, 근본적인 차이는 없다고 볼 수 있습니다.

하지만 사용 시 주의해야 하는 점들이 있습니다.

  • non-final 변수를 사용할 수 없다. 
    • 함수적 인터페이스를 익명 구현 객체를 통해 구현한다면 이 객체 내부에서 사용하는 외부 환경(lexical environment)을 객체로 복사합니다. 그런데 복사되는 것 중에 non-final 외부 지역 변수가 있다면 멀티 스레드 환경에서 동시성 문제로 인해 의도치 않은 문제가 발생할 수 있기 때문에 사용이 제한됩니다. 만약 지역 변수가 effectively final (유사 final - 변경 가능성이 없는)이라면 컴파일러가 알아서 final을 붙여주지만 이에 의존하는 것은 좋지 않은 방식이라고 생각합니다. 
    • 그런데 reference type을 이용하면 이를 해결할 수 있습니다. reference type 내부 값을 메서드를 통해 변경하는 것은 허용하기 때문입니다. 다만 멀티 스레드 환경이라면 동기화에 신경 써줘야 하는 것은 당연합니다.
    • 그래서 atomic 패키지의 AtomicInteger나 AtomicLong 사용을 추천하는 것입니다. 우선 reference type이고, 멀티 스레드 환경에서도 동기화가 보장되는 CAS 알고리즘을 내부적으로 이용하기 때문에 동시성 문제를 해결할 수 있습니다.
  • checked exception을 핸들링 하기 불편하다.
    • Try-Catch로 예외 처리를 할 수 있지만 코드가 매끄럽지 않고, forEach 내부에서 예외가 처리된다는 단점(?)이 있습니다.
  • 흐름제어가 어렵다.
    • for-each loop는 break 등을 이용하여 쉽게 흐름 제어가 가능하지만 forEach 메서드의 경우 그렇지 않습니다. 파일을 읽거나 순회를 종료해야 하는 경우 불편할 수 있습니다.
  • 디버깅이 어렵다.

그 외에도 제가 참고한 답변에서는 더 많은 단점을 이야기하고 있는데, 솔직히 와닿지 않는 부분들도 있어서 어느 정도 필터링을 한 후 작성하였습니다. non-final에 대한 이야기는 제가 살을 덧붙였기 때문에 원문과 다를 수 있습니다. 원문이 궁금하신 분은 아래 링크를 참조하시면 좋을 것 같습니다.

https://stackoverflow.com/questions/16635398/java-8-iterable-foreach-vs-foreach-loop

 

Java 8 Iterable.forEach() vs foreach loop

Which of the following is better practice in Java 8? Java 8: joins.forEach(join -> mIrc.join(mSession, join)); Java 7: for (String join : joins) { mIrc.join(mSession, join); } I have lo...

stackoverflow.com


그리고 어떤 분이 벤치마크를 진행해두신 기록이 있길래 이 또한 첨부하도록 하겠습니다. 벤치마크 결과를 살펴보면 분명 차이가 있으나 왜 그런지에 대해서는 따로 분석해두지는 않았습니다. 저는 거의 차이가 없을 거라고 생각했는데, 어째서인지 조금 차이가 나는 것으로 보입니다.

https://medium.com/@KosteRico/for-each-loop-vs-foreach-method-in-java-11-which-one-is-faster- 8a5120c0c8c3

 

For-each loop vs “forEach” method in Java 11. Which one is faster?

Full benchmarks for LinkedList, ArrayList, Stack etc.

medium.com

 

Collection forEach vs Stream forEach

public void test(List<Integer> nums) {
    nums.forEach(System.out::println);
    nums.stream().forEach(System.out::println);
}

표면적인 차이
바로 알 수 있는 차이점은 Stream forEach를 사용하기 위해서 stream 객체의 생성이 필요하다는 것입니다. 단순히 순회가 목적이라면 굳이 stream 객체를 생성하면서 forEach를 실행할 필요는 없을 것 같습니다.

 

만약 순회하다가 중간 처리가 필요하다면 Stream의 다양한 연산 도구(filter, map 등)을 이용하고, 그렇지 않다면 Collection의 forEach나 향상된 for문 중에서 선택하는 것이 좋을 것 같습니다.

 

실질적인 차이

Stream을 이용하는 것이 어떤 영향을 끼칠까요? 그에 관하여 너무 잘 정리된 글이 있어서 링크부터 첨부하겠습니다. 

 

https://jypthemiracle.medium.com/java-stream-api%EB%8A%94-%EC%99%9C-for-loop%EB%B3%B4%EB%8B%A4-%EB%8A%90%EB%A6%B4%EA%B9%8C-50dec4b9974b

 

Java Stream API는 왜 for-loop보다 느릴까?

The Korean Commentary on ‘The Performance Model of Streams in Java 8" by Angelika Langer

jypthemiracle.medium.com

링크에 포스팅을 쭉 읽어보시면 밴치마킹을 기반으로 여러 가지 측면에서 결과를 분석하고 있습니다. 일단 Stream이 느린 이유는 상대적으로 다른 순회 방식들에 비해 최적화가 많이 되지 않았기 때문입니다. for-loop의 경우 30년 정도 최적화가 진행되었다고 하니.. 차이가 날 수밖에 없습니다.

 

그런데 순회 비용이 크거나, 부하가 큰 작업을 수행할 때 for-loop와 순차 스트림의 속도 차이가 거의 없는 것을 확인할 수 있습니다. 즉 순회 비용계산 비용 둘 중 어느 하나가 극단적으로 커진다면, 두 방식의 속도 차이는 체감할 수 없을 정도로 줄어드는 것을 확인할 수 있습니다.

 

그리고 병렬 스트림에 대해서도 잠깐 다루는데요, 병렬 스트림의 경우 데이터가 충분히 크고, split 하기 쉬우며(fork-join framework를 이용하기 때문) 연산이 stateless 한 경우에 효과적인 것을 확인할 수 있습니다. 



동시성 문제
Collection의 forEach를 수행하는 중간에 remove 또는 add 등의 컬렉션 조작 메서드를 수행하면 문제가 발생할 수 있습니다. 아래는 ArrayList의 remove 메서드입니다. remove 내부 흐름을 살펴보시면 fastRemove를 호출하시는 것을 볼 수 있습니다.

public E remove(int index) {
    Objects.checkIndex(index, size);
    final Object[] es = elementData;

    @SuppressWarnings("unchecked") E oldValue = (E) es[index];
    fastRemove(es, index);

    return oldValue;
}

// fastRemove

private void fastRemove(Object[] es, int i) {
    modCount++;
    final int newSize;
    if ((newSize = size - 1) > i)
        System.arraycopy(es, i + 1, es, i, newSize - i);
    es[size = newSize] = null;
}

fastRemove는 내부적으로 modCount를 증가시키는데, 이 modCount는 iterator을 이용할 때 동기화가 보장되도록 설계되어 있습니다. 왠 갑자기 iterator냐 하실 수 있습니다. 위에서 살펴보았지만 Collection의 forEach는 내부적으로 향상된 for문을 이용하고, 향상된 for문은 내부적으로 iterator가 사용됩니다.

즉 Collection의 forEach는 내부적으로 다음과 같은 의미로 해석할 수 있습니다.

nums.forEach(System.out::println);

// 변환

Iterator<Integer> itr = nums.iterator();

while(itr.hasNext()) {
    System.out.println(itr.next());
}

그럼 예시로 ArrayList의 iterator()를 살펴보겠습니다. 내부적으로 Itr 객체를 생성하는데, 이때 expectedModCount에 modCount의 값을 할당하고 있습니다.

public Iterator<E> iterator() {
    return new Itr();
}

/**
 * An optimized version of AbstractList.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; // <<--------------------------------여기요 여기!

    // prevent creating a synthetic constructor
    Itr() {}
    
    
    ...

그리고 iterator의 next()를 살펴보겠습니다. 내부적으로 checkForComodification()을 호출하여 modCount와 expectedModCount의 동기화 여부를 확인하고 있습니다.

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];
}

// checkForComodification()

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

즉, modCount와 expectedModCount가 다르다면 ConcurrentModificationException이 발생하는 것을 알 수 있습니다.

따라서 컬렉션의 forEach를 수행하다가 remove나 add 등의 컬렉션을 조작하는 메서드를 이용한다면, modCount가 증가되어 ConcurrentModificationException이 발생할 수 있습니다.

컬렉션의 데이터 일관성을 보장하기 위해 이런 식으로 설계된 것으로 보이며, 컬렉션의 환경이 변하면서 발생할 수 있는 비정상적인 동작을 방지하기 위함으로 보입니다. 따라서 Collection의 forEach 내부에서 컬렉션을 조작하는 행위는 피해야겠습니다.

불변 컬렉션을 사용해서 참사를 방지하자
혹시나 모를 이런 참사를 막기 위해 불변 컬렉션을 이용하는 것이 좋을 것 같습니다. Java에서는 UnmodifiableCollection을 이용하여 컬렉션 조작이 불가능하도록 할 수 있습니다. 일반 ArrayList에서는 그래도 set 정도는 문제가 없었는데, UnmodifiableCollection을 사용하면 set도 사용할 수 없습니다.

불변 컬렉션을 조작하고 싶다면?
이땐 stream의 중간 연산을 통해 새로운 컬렉션을 생성하는 것이 좋다고 생각합니다.

Ref

https://m.blog.naver.com/tmondev/220393974518

 

List 순회 중 만난 ConcurrentModificationException과 컬렉션 불변성

Java로 웹 서비스를 개발하다 보면 여러 가지 Exception을 만나게 된다. 특히 NullPointerException는...

blog.naver.com

https://tecoble.techcourse.co.kr/post/2020-09-30-collection-stream-for-each/