for loop 

우리가 흔히 알고 있는 반복문은 for과 while이 대표적인데

해당 조건이 만족할때까지 반복을 수행하는 작업을 해준다.

for은 아주 일반적인 반복 구조로

for(초기화; 테스트 표현식; 표현식 업데이트){

루프 내부의 코드

}

위 코드에 적힌 테스트 표현식의 조건이 false가 될때까지 반복되는 구조이다.

특정 인덱스를 조작하거나 표현할때 쓰이는 기본적인 방식이다.

 

        int size = list.size();
        for (int i = 0; i < size; i++) {
            list.add(i);
        }

여기서 테스트 표현식은 대부분 array의 length거나 list의 size가 되는 경우가 많은데

그럴 경우에는 반복문을 시작하기 전에 size를 미리 지정하여 불필요한 list.size()의 메소드를

반복하는 횟수 만큼 줄일 수 있고 가독성이 좋아질 수 있으니 반복 호출 없이 미리 size를 변수에 담고 그 변수를

테스트 표현식에 쓸 수 있도록 하자.

size를 미리 지정하여 나타나는 효과는 밑의 코드 사진에서 확인 할 수 있다.

 

그리고 중첩 반복문이 되어야 하는 상황에는 최대한 반복 루프가 적은 회수를 외부 중첩으로 사용해야

효율적이라고 한다.

 

for each loop

foreach는 향상된 for문이라고도 불린다. 

루프 카운트가 필요하지 않고 컬렉션 또는 배열의 모든 요소를 처리해야 할 때 사용된다.

인덱스를 쓰지 않기에 ArrayIndexOutOfBoundsException 걱정이 전혀 필요 없고

명백한 정확성을 보장받는다.

Iterable 인터페이스를 구현한 객체만 이 for each loop를 사용할 수 있다.

임의 특정 요소를 관리하는데 사용하기엔 어렵다. ( 해당 인덱스 요소 제거 등 )

for(데이터 유형 항목: 컬렉션){

각 루프 내부의 코드

}

 

여기서 for과 for each를 사용하며 드는 다들 한번쯤은 해봤을 공감대가 있을텐데

        (1) Test test;
        for (int i = 0; i < 1000000; i++) {
            (1) test = new Test();
            (2) Test test = new Test();
        }

for문안에 객체를 불가피하게 생성하여야 할 때

(1)번의 방법이 올바른가 (2)번의 방법이 올바른가에 대한 고민이 있을 수도 있는데

우리가 객체지향 자바에 대해 처음부터 공부할 때 배웠듯

껍데기가 어디있던 결국 구현체를 생성하는 new가 중요한것이기에 메모리 관점으로 똑같다고 보면 되고

이왕이면 반복문이 종료되면서 스코프도 종료되는 (2)번의 방법이 옳다고 볼 수 있다.

 

for loop vs for each loop

Effective Java에 따르면 for each를 사용할 수 없는 3가지 상황이 있다고 한다.

 

1. 컬렉션을 순회하면서 선택된 원소를 제거해야하는 경우 반복자의 remove메소드를 호출해야하는 상황

2. 리스트나 배열을 순회하며 그 원소의 값 일부 혹은 전체를 교체하려고 하여 그에 대한 인덱스가 필요한 경우

3. 여러 컬렉션을 병렬로 순회하려면 각각의 반복자와 인덱스 변수를 사용해 엄격하고 명시적으로 사용해야함.

이 3가지 상황에 하나라도 속하면 for loop를 사용하라고 한다

 

성능 저하도 없다고 나와있는데 모든 경우에 그런가 테스트를 해보았는데.

정말 자주 쓰이는 컬렉션인 ArrayList로 테스트를 해보았다.

        for (int i = 0; i < 1000000; i++) {
            list.add(i);
        }

1,000,000개의 데이터를 가진 List로 계산을 돌려보았다.

        // for loop
        startTime = System.nanoTime();
        
        for (int j = 0; j < list.size(); j++) {
            int a = list.get(j);
        }
        
        endTime = System.nanoTime();
        runningTime = endTime - startTime;
        
        System.out.println("for loop : " + runningTime / 1000000.0 + " ms");




        // for each loop
        startTime2 = System.nanoTime();
        
        for (int i : list) {
            int a = i;
        }
        
        endTime2 = System.nanoTime();
        runningTime2 = endTime2 - startTime2;
        
        System.out.println("for each loop : " + runningTime2  / 1000000.0 + " ms");





        // Size 미리 계산 후 for loop
        startTime3 = System.nanoTime();
        
        int size = list.size();
        
        for (int j = 0; j < size; j++) {
            int a = list.get(j);
        }
        
        endTime3 = System.nanoTime();
        runningTime3 = endTime3 - startTime3;
        
        System.out.println("size 미리 계산 후 for loop: " + runningTime3 / 1000000.0 + " ms");

위 코드로 각각 값을 할당해주는 테스트를 JVM을 기준으로 시간을 찍는 nanoTime을 사용하여 10번정도 돌려본 결과

10번중 7번이 for loop가 빠른 결과가 나타났다.

 

결국 for loop가 효율이 조금 더 괜찮다는건데

for each는 내부 메서드들이 호출되기에 비용이 있기에 그렇다.


그런데 문제가 생겼다. 데이터를 10,000,000개로 지정한 뒤 진행한 결과는 조금 달랐다.

5번정도 실행을 해보니 foreach가 더 빠르게 나오는 경우가 많은데 

700만개부터 소요 시간이 비슷해지는 것 같다.

 

이에 대한 이유는 시간복잡도에 대한 이해가 필요하다.

ArrayList get()메서드의 시간복잡도는 O(1)인데 

O(1)은 N이 증가해도 속도가 일정하다는 의미이다.

만약 ArrayLIst에서 get()의 속도가 1초라고 생각한다면

for loop의 루프가 1000만번 돈다면 1초 * 1000만초가 되어버린다.

이렇게 반복 횟수가 많아지는 어느순간 foreach가 어떠한 내부적 동작의 이점으로

이겨버린게 아닐까 하는 생각인데 foreach가 이기는 이유에 대해 다시 공부하고 여기다 적겠다. 


ArrayList에 for loop를 사용하면 배열과 마찬가지로 연속 메모리 블록이 할당되므로 

시간복잡도가 O(1)이 되어 굉장히 좋지만 LinkedList가 되어버리면 연속 메모리 블록이 할당되지 않아

임의 엑세스가 불가능하므로 필요한 엑세스에 도달 할 때 까지

LinkedList를 순회해야하므로 최악의 경우 O(n)까지 시간복잡도가 증가한다고 한다.

반복자 vs Foreach in Java - GeeksforGeeks

 

foreach or iterator은 임의 엑세스가 없는 LinkedList같은 경우는 for each가 for loop보단 빠르지만

ArrayList나 배열 같은 

순서가 있고 임의 엑세스를 허용하는 컬렉션에서는 for loop보다 빠르다고 할 수 없다.

LinkedList에서 for each가 유리한 이유는 for each를 타고 들어가다보면 Iterator에서

Itr라는 클래스를 반환해주는데 이 클래스안의 변수인 cursor가 

 

LinkedList는 처음부터 그 앞전 요소들을 다 지나쳐 해당 인덱스 까지 가야하기에 O(N)의

시간 복잡도를 가지게 된다. (LinkedList의 get()메소드 내부에 반복문이 숨어있다.)

 

즉 ArrayList 또는 배열을 사용할때는 for loop 를 사용하고

LinkedList가 될 경우에는 foreach문을 사용하는게 성능적인 관점에서는 맞는 판단이라는데 

(성능적인 면에서 RandomAccess Interface를 구현된 클래스 = for loop, 그렇지 않은 클래스 = for each)

이것 조차 상황에 따라 달라질 것 같다.

 

만약 지금 이 컬렉션들을 사용하여 해결하고자 하는 것이 알고리즘 해결이라고 하면 

성능을 중요시 하니 전통 for loop를 사용하는게 맞을 수도 있다.

 

근데 지금 해결하고자 하는 것이 프로젝트이고 객체지향 관점으로 List를 선언한뒤

LinkedLIst, ArrayList  둘 중 어떤 구현체가 들어올지 모르는 상황 또는 

성능에 크게 구애받지 않는 상황에서는 유연하고 명료하고 버그까지 줄일 수 있는 

for each를 쓰는게 맞는 판단인 것 같다.

 

 

Stream? forEach?

우선 forEach는 스트림의 각 요소에 대한 작업을 수행해주는 메서드이고

보통 print와 같이 결과를 출력할 때 사용하는 최종 연산에 해당된다.

Stream을 이용하면 가독성이 좋고 중복코드들을 확실히 줄일 수 있는 대안이 된다고 생각한다.

(if문 난사와 for문을 최소화하기 때문에)

 

근데 Stream은 아주 신중하게 써야한다고 한다.

AngelikaLanger.com - 컨퍼런스 비디오 - GeeCon 2015 - Java 8 스트림의 성능 모델 - 안젤리카 랑거 - 안젤리카 랑거 교육 / 컨설팅

에 따른 타입에 따른 for loop와 Stream의 성능차이는

Primitive Type int array 일 경우 약 15배 정도 차이로 for loop가 우세하고

Wrapper Type ArrayList 일 경우  약 1.27배 정도 차이로 for loop가 우세하다.

 

Wrapper Type의 경우에 Stream과 for loop의 차이가 왜 조금밖에 나지 않느냐의 이유는

우선 for loop는 오랜 시간 컴파일러에 최적화 되어있고 Stream은 그렇지 않다는 기본 바탕이 깔려있고

Wrapper Type으로 사용할 시 Stack 영역이 아닌 Heap영역에 저장되어 있기에 간접참조를 해야하므로

for loop의 최적화 이점이 사라진다고 한다. 그래서 큰 차이가 나지 않는다고 한다.

 

그래서 Stream은 어떨 때 사용하냐

내부적인 함수 계산이 복잡한 그런 상황(함수의 시간복잡도가 큰 상황)에 Stream을 사용하면 

시간 성능적으로도 for-loop와 비교했을 때 큰 차이도 없고 가독성까지 챙길 수 있다.

그리고 굳이 forEach를 사용하지 않더라도 Stream에서 제공하는 map이나 filter로 충분히 해결할 수 있는 상황이 

나온다고 한다. 상황마다 사용을 신중히 고민해보자.

 

Stream을 사용하는 상황이 왔을때 forEach를 사용하는 경우가 있다.

Effective Java에선 forEach는 최종 연산 메서드 중에 기능이 가장 덜 스트림 다워서 

계산 결과를 보고하는 형식으로만 쓰고 이걸 이용해서 무슨 계산을 하면 안된다고 한다.

꼭 주의하자.

 

결론은 Stream은 Collection, Wrapper를 사용하는 경우에 크기가 충분히 크고 그에 사용하고자 하는 함수의 

시간복잡도가 충분한 상황에서 사용을 고려해봐야 한다. 

그런데도 성능이 좋다고 무조건 for-loop만 고집하는것도 아니라고 한다. 성능이 중요한 프로젝트 

가독성 및 유지보수가 중요한 프로젝트 등이 있을 것이고

위의 주의사항들을 충분히 고민하여 Stream을 사용할 것인지 for each를 사용 할 지 for loop를 사용 할 지

선택하면 될 것 같다.

 
 
Stream을 사용하는 좋다는 경우를 훌륭한 블로그에서 펌했다..
 
원소들의 시퀀스를 일관되게 변환한다.
원소들의 시퀀스를 필터링한다.
원소들의 시퀀스를 하나의 연산을 사용해 결합한다.(ex: 덧셈, 연결, 최솟값 구하기, ...)
원소들의 시퀀스를 컬렉션에 모은다
원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

 

 

Java Stream API는 왜 for-loop보다 느릴까? | by Sigrid Jin | Medium

Java Lambda Expression과 성능 (brunch.co.kr)

스트림 (자바 플랫폼 SE 8) (oracle.com)

6. 람다와 스트림 (oopy.io)

자바의 List는 배열을 넘어서서 동적으로 데이터들을 순서가 있고 중복이 허용되게 하며

1차원적으로 저장할 수 있게 하는 Collection이다.

public interface List<E> extends Collection<E> {
    // Query Operations

    /**
     * Returns the number of elements in this list.  If this list contains
     * more than {@code Integer.MAX_VALUE} elements, returns
     * {@code Integer.MAX_VALUE}.
     *
     * @return the number of elements in this list
     */
    int size();
... 생략

List는 자바의 Collection Interface를 구현한 "인터페이스"이며

ArrayList, LinkedList, Stack, Vector로 인스턴스화를 할 수 있다.

 

이 포스팅에선 List를 인스턴스화하여 선언할 수 있는 몇 가지의 방법과 그에 대한 특징에 대해 알아볼 것 이다.

 

new를 이용한 ArrayList 선언

new를 이용하여 ArrayList를 선언하게 되면 동적으로 List의 크기를 관리할 수 있다는 장점이 있다.

new ArrayList<T>(n); 에서 n 부분에 아무것도 적지 않으면 기본 생성자로 선언하게 되면

10개의 용량을 가지는 리스트가 생성되고 ( size가 0인 ArrayList는 기본 설정값인 10으로 capacity가 초기화 됨)

n에 숫자를 적게 되면 그 숫자 만큼의 용량이 초기 용량 값으로 생성된다.

 

앞서 말한 List의 크기를 동적으로 관리할 수 있는 것이 장점이자 단점이 되는데

큰 단점을 말하자면 매개변수n 부분에 적은 초기 용량값을 초과하는 객체가 들어오면

ArrayList는 자동으로 리사이징 되어 ArrayList가 차지하는 메모리 용량이 1.5배 늘어나게 된다.

이때 지금까지의 크기 비율로 매번 증가하는 용량의 값이 다른데 이것을 자꾸 늘리다 보면 사용하는

메모리의 낭비가 심해 단점이 될 수 있고 공간 확장에 드는 행위 때문에 작업 처리 시간이 증가하게 된다.

이러한 상황을 피하기 위해 사용할 List의 용량을 미리 예상하여 정하거나

아래의 메소드를 활용하여 효과적인 List 메모리 관리를 할 수 있다.

 

ArrayList의 용량을 관리하는 방법으로 ArrayList에 내장된 trimToSize 메서드가 있는데

    /**
     * Trims the capacity of this {@code ArrayList} instance to be the
     * list's current size.  An application can use this operation to minimize
     * the storage of an {@code ArrayList} instance.
     */
    public void trimToSize() {
        modCount++;
        if (size < elementData.length) {
            elementData = (size == 0)
              ? EMPTY_ELEMENTDATA
              : Arrays.copyOf(elementData, size);
        }
    }

이 메서드는 ArrayList의 용량을 현재의 List Size(현재의 리스트 크기)만큼 동일하게 변경한다.

즉 이 메서드로 내부에 할당되지 않은 공간을 현재의 크기 만큼 잘라버려 메모리를 아낄 수 있다.

 

또한 ensureCapacity(int minCapacity) 라는 메서드로 작업이 진행되는 도중

ArrayList의 최소 용량의 값을 원하는 값으로 늘려 ArrayList의 자동 1.5배 리사이징을 방지하는 작업을 할 수 있다.

 

 

Immutable한 List를 만들기 위한 List.of() ? Arrays.asList() ? Collections.unmodifiableList() ? Collections.singletonList() ?

먼저 List.of()는 Java 8 이후에 업데이트 된 메소드 이다.

Arrays.asList()가 있는데 뭐하러 쓰는가에 대해 궁금증이 생기면서 부터 정리하게 된 내용이다.

먼저 결론부터 말하자면 완전히 Immutable한 리스트를 만들기 위해 사용해야하는 메소드는 List.of()이다.

List.of()는 매개변수로 받은 Element들을 Immutable하게 관리해주는 ImmutableCollections 객체로 만들어

새로운 Element들을 CUD하려고 하면 UnsupportedOperationException을 발생시킨다.

추가로 List.of()의 E에 null을 넣을시 NullPointException이 일어난다. null을 허용하지 않는다.

 

그럼 Arrays.asList()도 Immutable한 List를 만들 수 있지 않냐라고 했을때 불변하지 않다고 말할 수 있다.

이를 알기 위해 Arrays.asList의 구조부터 살펴보자.

public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

위 코드는 Arrays.asList의 내장 함수 구현체 이다. ArrayList<>(a);를 반환하도록 되어있는데 

여기서 주의해야 할 점은 이 ArrayList는 java.util 패키지의 ArrayList가 아닌 그 일부만 구현 된 Arrays 내부 클래스이다.

확인해보면 add() 메소드 remove등이 없다.

그런데 이 ArrayList는 add도 remove도 없는데 왜 불변이 아니냐

        @Override
        public E set(int index, E element) {
            E oldValue = a[index];
            a[index] = element;
            return oldValue;
        }

        @Override
        public void sort(Comparator<? super E> c) {
            Arrays.sort(a, c);
        }

        @Override
        public void replaceAll(UnaryOperator<E> operator) {
            Objects.requireNonNull(operator);
            E[] a = this.a;
            for (int i = 0; i < a.length; i++) {
                a[i] = operator.apply(a[i]);
            }
        }

Arrays.asList를 호출 했을 때 반환되는 Arrays의 내부 클래스인 ArrayList에 구현 되어 있는 위 메서드 때문에

List의 Element 값들의 위치가 변경되거나 값이 변경 될 수 있기에 불변하지 않다 라고 말할 수 있다.

 

그래서 위의 asList를 사용하고 난 뒤에는 Collections.unmodifiableList()의 매개변수로

List를 주어 immutable 하도록 만들어 준다고 하는데 이 조차도 이 메소드는 원본 List를 참조하고 있기 때문에

원본 List의 값이 변경되면 해당 메서드를 사용하여 불변하게 만들어준 List의 값도 바뀌게 된다.

이러면 불변하다고 말을 할 수 없다.

List usedAsList = Arrays.asList(1,2,3,4,5);
List usedUnmodifiableList = Collections.unmodifiableList(usedAsList);

System.out.println(usedAsList);
System.out.println(usedUnmodifiableList);

[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]

의 결과 값을 도출하는데 위의 usedAsList의 값이 바뀌게 되면  똑같이 usedUnmodifiableList의 리스트 값도

원본을 참조하여 바뀌게 된다. 따라서 불변히라고 말을 할 수 없다.

 

강력한 불변을 보장해주는 메서드는 List.of()이다.

하지만 Element 인자의 개수가 1개인 상황에 쓸 수 있는 불변을 제공하는 List가 있는데

이게 바로 Collections.singletonList()이다. 이는 배열 변환 과정이 거의 없고 주소값과 속성에 대해 불변을

보장해준다. 이는 1개인 상황에서 메모리 절약에 효과적이라고 한다.  또한 singletonList()는 null에 대한 지원을 

하므로 상황에 따라 잘 쓰면 될 것 같다. 

 

  • ✔️ 메서드가 지원됨을 의미합니다
  • ❌ 이 메서드를 호출하면 UnsupportedOperationException
  • ❗️ 메서드의 인수가 변형을 일으키지 않는 경우에만 메서드가 지원됨을 의미합니다.

사진 출처 : Singleton List Showdown: Collections::singletonList Vs. List::of - DZone Java 

+ Recent posts