TIL

  • ArrayList
    1. 특징
    2. Array vs ArrayList
      • 크기/제네릭/초기화/데이터타입/반복문/다차원
      • Array 크기를 변경하는 방법
      • ArrayList 크기가 동적으로 작동하는 방법
    3. ArrayList를 순회하는 방법

 


 

ArrayList

 

 

 

특징

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, Serializable {}
  • 연속적인 데이터의 리스트(메모리 공간에 데이터가 연속적으로 들어있어야 하며, 중간에 빈 공간이 있어서는 안 된다.)
  • ArrayList 클래스는 내부적으로 Object[] 배열을 이용하여 데이터를 저장한다.
  • 배열을 이용해서 만든 리스트이기 때문에 인덱스 기반으로 동작한다. 이 인덱스 값을 사용하여 요소를 add, get, remove 할 수 있다.
  • 단방향 포인터 구조로 자료에 대한 순차적인 접근이 쉬워 조회가 빠르다.
  • 그러나 데이터를 리스트 중간에 삽입/삭제 할 경우, 중간에 빈 공간이 생기지 않도록 데이터들의 위치를 앞뒤로 이동시키기 때문에 데이터 중간 삽입/삭제 동작은 느리다.
  • 데이터량에 따라 capacity가 동적으로 늘어나거나 줄어든다.
  • 그러나 배열 공간이 꽉 찰 때마다 new 배열을 생성하고 기존 배열의 데이터를 copy하는 방식으로 capacity를 늘리기 때문에 이 과정에서 지연이 발생된다.
  • 동기화를 제공하지 않는다. 즉, 여러 스레드가 동시에 접근할 수 있다. synchronized 키워드를 사용하여 동기화하거나 다중 스레딩을 위해 CopyOnWriteArrayList를 사용할 수 있다.
  • ArrayList의 default size는 10이다.

 

 

 

Array vs ArrayList

 

크기

Array는 고정된 길이를 갖고있으며 객체를 생성한 후, 길이를 변경할 수 없다. 즉, 동적으로 길이를 확장할 수 없다. 현재 크기보다 더 많은 요소를 추가하려고 하면 ArrayIndexOutOfBoundException이 발생할 것이다.

ArrayList는 동적으로 확장 가능한 배열이다. default size는 10이고, 요소를 추가하거나 제거하면 자동으로 커지고 줄어든다.

 

 

generic

Array는 제네릭을 사용할 수 없으며 런타임 시점에 서로 다른 타입의 요소를 가지고 있을 수 있다. 서로 다른 타입의 요소를 배열에 추가하면 컴파일러는 런타임 시점에 ArrayStoreException을 던진다. 그러나 이러한 오류는 컴파일 타임에 발생하는 것이 좋다.

ArrayList는 배열과 달리 제네릭을 지원한다. 제네릭을 사용해서 JVM은 컴파일을 할 때, type safety를 보장한다. 따라서 ArrayList는 서로 다른 타입의 데이터가 저장되는 것을 제한해준다.

public static void main(String[] args) {

    Object[] arr = new Integer[5];
    arr[0] = 1;
    arr[1] = "f-lab"; // ArrayStoreException

}

 

 

초기화

Array는 특정 값으로 배열의 크기를 초기화 시켜야 한다. 따라서 Array의 크기를 명시적으로 지정하는 것은 필수다.

ArrayList는 default size 10으로 자동 초기화 된다. 따라서 반드시 초기화할 필요는 없다.

 

 

data type

Array는 primitive와 reference 타입 모두 요소로 가질 수 있다.

ArrayList는 오직 reference 타입만 가질 수 있다.

ArrayList<Integer> list = new ArrayList<Integer>(); // Integer 타입 요소만 가질 수 있다.
list.add(1); // int 타입 요소 추가

// JVM converts the int to Integer type.
list.add(new Integer(1));

 

 

반복문

Array는 요소 순회를 위해 for, forEach를 사용할 수 있다.

ArrayList는 요소 순회를 위해 for, forEach, Iterator, List Iterator를 사용할 수 있다.

 

 

다차원

Array는 다차원으로 만들 수 있지만, ArrayList는 항상 1차원 배열만 만들 수 있다.

 

 

 

Array 크기를 변경하는 방법

public static void main(String[] args) {

    int[] oldArr = {0, 1, 2, 3};
    System.out.println(Arrays.toString(oldArr)); // [0, 1, 2, 3]

    // copyOf(복사한 기존 배열, 리턴하고자 하는 배열의 길이)
    int[] newArr = Arrays.copyOf(oldArr, 10);
    System.out.println(Arrays.toString(newArr)); // [0, 1, 2, 3, 0, 0, 0, 0, 0, 0]

}

Array의 크기를 확장/축소 하기위해서는 Arrays.copyOf() 메소드를 사용해서 직접 크기를 확장해주어야 한다.

 

 

 

ArrayList 크기가 동적으로 작동하는 방법

ArrayList<String> arrayList = new ArrayList<>();

ArrayList의 default size는 10이다. JVM은 default 생성자로 ArrayList 객체를 생성할 때, heap 영역에 default size만큼 생성한다. 그러나 ArrayList는 고정 길이가 없는 배열과 달리 확장 가능한 배열이다. ArrayList에 요소를 추가하거나 제거할 때마다 크기가 동적으로 증가한다.

 

 

ArrayList<Integer> list = new ArrayList<Integer>(15);

ArrayList 클래스는 크기를 지정할 수 있는 생성자를 제공한다. 해당 생성자를 이용해 객체를 생성하고 요소를 추가하면 ArrayList 크기는 자동으로 증가한다.

ArrayList의 크기는 load factor(부하율)와 현재 용량에 따라 자동으로 증가한다. load factor는 ArrayList의 용량을 언제 늘릴 지 결정하는 척도며 ArrayList의 default load factor는 0.75f이다. ArrayList는 임계값을 넘으면 용량을 늘린다. 임계값을 계산하는 방법은 이와 같다.

 

Threshold = (current capacity) * (load factor)

 

예를 들어, ArrayList가 10개의 요소를 가지고 있고 load factor가 0.75f라면 임계값은 다음과 같다.

 

Threshold = 10 * 0.75 = 7

 

ArrayList는 데이터를 추가할 때마다 ArrayList에 있는 요소의 수와 임계값이 같은지 확인한다. 현재 요소의 크기가 임계값보다 큰 경우, JVM은 새 ArrayList를 만들고 이전 ArrayList를 새 ArrayList에 복사한다.

10개의 요소를 넣을 수 있는 ArrayList가 있다. 7번째 공간까지 요소를 추가한 후(임계값이 7), 크기가 20인 새 ArrayList를 만들고 요소들을 복사한다.

ArrayList의 크기가 임계값에 도달할 때마다 새 용량의 ArrayList 객체를 만들고 이전 ArrayList 객체에 있던 모든 요소를 복사한다. 이 프로세스는 유연하지만 공간과 시간이 많이 소요된다. 따라서 예상되는 크기를 초기에 세팅한 후, 사용하는 것이 좋다.

 

 

private Object[] grow(int minCapacity) {
    return elementData = Arrays.copyOf(elementData, newCapacity(minCapacity));
}

ArrayList 클래스 내부를 살펴보면 grow() 메소드가 있는데 이는 ArrayList의 capacity를 늘려주는 메소드이다. Array와 마찬가지로 Arrays.copyOf() 메소드를 사용하는 것을 볼 수 있다.

 

 

 

ArrayList를 순회하는 방법

Java에서는 for 루프와 iterator를 모두 사용하여 ArrayList를 순회할 수 있지만 두 접근 방식의 성능은 ArrayList의 크기에 따라 다르다. for 루프는 간편하게 사용할 수 있고 인덱스를 통해 요소에 쉽게 접근할 수 있다. 그러나 ArrayList의 크기가 큰 경우, for 루프를 사용하면 성능이 저하될 수 있다. 왜냐하면 for 루프가 size() 메소드를 사용해서 매번 ArrayList 크기를 확인하기 때문이다.

반면 iterator는 for 루프보다 더 효율적인 방법을 제공한다. size() 메소드 호출을 피하고 ArrayList의 요소에 접근하기 위해 hasNext()next() 메소드를 사용한다.

public static void main(String[] args) {

    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 10_000_000; i++) {
        list.add(i);
    }

    // for loop
    long startTime1 = System.nanoTime();
    for (int i = 0; i < list.size(); i++) {
        list.get(i);
    }
    long endTime1 = System.nanoTime();
    System.out.println("For loop execution time: " + (endTime1 - startTime1) + " nanoseconds.");

    // iterator
    Iterator<Integer> iterator = list.iterator();
    long startTime2 = System.nanoTime();
    while (iterator.hasNext()) {
        iterator.next();
    }
    long endTime2 = System.nanoTime();
    System.out.println("Iterator execution time: " + (endTime2 - startTime2) + " nanoseconds.");

}
For loop execution time: 86467800 nanoseconds.
Iterator execution time: 58686500 nanoseconds.

위 코드를 실행하면 for 루프의 실행 시간이 iterator 실행 시간보다 훨씬 길다. for 루프는 반복할 때마다 소괄호 내 조건식 중 size() 메서드를 호출해야 하기 때문에 ArrayList의 크기가 증가함에 따라 비용이 더 많이 든다. 반대로 iterator는 size() 메서드를 호출할 필요가 없고 hasNext() 메서드를 사용하여 요소가 더 있는지 확인만 하면 된다.

 

 

 

📚 참고
https://javagoal.com/arraylist-in-java/