-
ArrayList는 어떻게 동적으로 크기를 늘릴 수 있을까?One Cookie a day 2023. 10. 9. 16:01
이전에 CS 스터디에서 ArrayList와 LinkedList의 차이에 대해 공부한 적이 있다.
- ArrayList
- 배열과 유사하나 크기를 지정하지 않고 동적으로 값을 삽입하고 삭제할 수 있음
- 초기 용량이 다 차면 더 큰 ArrayList 생성 후 거기다 복사한다. 원소가 많다면 대략적인 용량을 처음에 설정하는 것이 성능상 좋다.
- 조회
- 각 데이터의 index 가지고 있고 index를 통해 해당 데이터에 바로 접근 가능
- 데이터 삽입, 삭제
- 가장 마지막 자리에 데이터를 삽입하거나 삭제한다면 빠른 속도가 가능하나, 중간에 데이터를 삽입하거나 삭제할 경우 빈 칸만큼 뒤의 원소를 앞으로 이동시켜야 한다. (위치를 맞춰줘야 함)
- 따라서 일반적으로 ArrayList 중간에 원소를 삽입하거나 삭제하는 경우가 많다면 비효율적
- 만약 원소를 맨 뒤에 원소부터 쭉 삭제하거나 삽입하게 된다면 오히려 Linked List보다 빠르다.
데이터를 삽입할 때, ArrayList는 공간이 부족할 시 자동으로 공간을 확장해준다. 너무 자주 공간을 할당하거나, 한 번에 너무 많은 공간을 할당해버려서 낭비되는 공간이 많아지지 않도록 내부적으로 구현이 되어있다.
데이터를 추가할 때 어떤 작동방식을 가지고 있어서 이것이 가능한것일지, Java 6, 7, 8 버전을 통해 조사해보았다.
Java 6
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { private transient Object[] elementData; private int size; // ... }
add
- capacity 다 찼는지 확인한 후(ensureCapacity) data 넣기
- checkForComodification
- Iterator<E> 인터페이스 구현한 내부 클래스 Itr에 존재하는 final 메소드
- if (modCount != expectedModCount) throw new ConcurrentModificationException();
- Iterator<E> 인터페이스 구현한 내부 클래스 Itr에 존재하는 final 메소드
/** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return <tt>true</tt> (as specified by {@link Collection#add}) */ public boolean add(E e) { ensureCapacity(size + 1); // Increments modCount!! elementData[size++] = e; return true; } public void add(int index, E e) { rangeCheckForAdd(index); checkForComodification(); parent.add(parentOffset + index, e); this.modCount = parent.modCount; this.size++; } private void rangeCheck(int index) { if (index >= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } private void rangeCheckForAdd(int index) { if (index > size || index < 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); }
ensureCapacity
public void ensureCapacity(int minCapacity) { modCount++; int oldCapacity = elementData.length; if (minCapacity > oldCapacity) { Object oldData[] = elementData; int newCapacity = (oldCapacity * 3)/2 + 1; if (newCapacity < minCapacity) newCapacity = minCapacity; // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } }
- 먼저 처음의 elementData 길이를 oldCapacity에 저장(data 저장된 길이가 아니고 현재의 배열 크기를 의미한다! 이만큼 넣을 수 있다는뜻)
- minCapacity(데이터 하나 넣었을 때의 용량 == 최소 필요 길이) > oldCapacity라면
- 새로운 용량은 (oldCapacity * 3) / 2 + 1로 계산
- 만약 새로운 용량보다도 더 큰 길이가 필요하다면 newCapacity를 minCapcity(필요한 최소 길이)로 바꿔준다.
- new Capacity만큼의 크기의 배열에 기존 데이터를 복사해 넣는다.
- 이때 복사되는 Arrays.copyOf는 shallow Copy
Java 7
public boolean add(E e) { ensureCapacity(size + 1); // Increments modCount!! elementData[size++] = e; return true; } public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacity(size + 1); // Increments modCount!! System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } public boolean addAll(Collection<? extends E> c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacity(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } public boolean addAll(int index, Collection<? extends E> c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacity(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved > 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; }
ensureCapacity
단순히 곱하기 나누기로 계산했던 JDK6에서, 7은 grow 함수 호출을 통해 arrayList의 크기를 늘린다.
grow(int minCapacity)
- oldCapacity : 현재 데이터 담는 배열 크기
- newCapacity : 비트 연산을 통해 구함
- oldCapacity >> 1 : oldCapacity의 비트를 한칸 오른쪽으로 이동, 빈칸은 최상위 부호와 같은 값으로 채워짐
- JDK 6에서는 일반 곱셈으로 구하는데, 위의 비트 연산자는 단순 비트값을 옮겨주기만 하면 되기 때문에 CPU 부하를 줄일 수 잇음
- 만약 MAX_ARRAY_SIZE보다 크다면(MAX_ARRAY_SIZE : Integer.MAX_VALUE - 8이다) hugeCapacity 호출 후newCapacity로 배열 복사
- 이때, minCapacity가 음수 나왔다는 것은 int 범위를 넘어갔다는 의미이므로 OutofMemory 에러 던진다.
public void ensureCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) grow(minCapacity); } private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }
Java 8
기본적으로 7까지는 10이라는 default 크기의 배열이 생겼는데, 8에서는 EMPTY_ELEMENTDATA가 할당이 된다.
private static final Object[] EMPTY_ELEMENTDATA = {}; 로 선언이 되어 있다.
빈 배열에 처음 원소를 넣게되면, DEFAULT_CAPACITY인 10 크기를 가진 배열로 확장된다.
public ArrayList() { super(); this.elementData = EMPTY_ELEMENTDATA; }
add
- ensureCapacityInternal이라는 메소드가 사용되었다.
- 배열에 아무것도 없는 초기 상태라면, minCapacity 따라 default를 넣을지, minCapacity 만큼 할당할 지 결정한다.
- 그 후 ensureExplicitCapacity 호출
- ensureExplicitCapacity
- 앞 버전들에 있는 ensureCapacity와 동일한 흐름으로 간다. (grow 내부에서 비트연산자 사용하는 것, MAX_CAPACITY 등)
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; } public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } private void ensureCapacityInternal(int minCapacity) { if (elementData == EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) grow(minCapacity); }
결론
Java 6 Java 7 Java 8 배열 크기
늘리는 방식( 현재 배열의 크기 * 3 ) / 2 + 1 shift 연산, oldCapacity >> 1 사용되는 함수 - add
- ensureCapacity- add
- ensureCapacity
- grow
- hugeCapacity- add
- ensureCapacityInternal
- ensureExplicitCapacity
- grow
- hugeCapacityAdd 수행 순서 - add
- ensureCapacity
넣을 수 있는지 검사 후, 불가능하다면 배열 크기 늘리고 원본 배열 복사 후 넣는다.- add
- ensureCapacity
넣을 수 있는지 검사
- grow
할당할 수 있는 배열의 최대 크기 넘지 않는지 검사 후 크기 늘려줌
- hugeCapacity
MAX_ARRAY_SIZE 보다 크다면 Integer.MAX_VALUE까지만 늘림, 그 이상은 out of memory- add
- ensureCapacityInternal
최초값 넣는건지 아닌지 검사
- ensureExlicitCapacity
이전 버전의 ensureCapacity와 동일하게 grow, hugeCapacity 호출최초값 크기 10인 배열 빈 배열
(처음 add 호출시에 할당 크기 결정)기타 참조 글
'One Cookie a day' 카테고리의 다른 글
Transaction에 대해서 알아보자 (0) 2023.10.27 싱글톤 디자인 패턴에 대해 (0) 2023.10.16 Java 7 이상에서 ArrayList 크기 계산 시 비트 연산자를 활용하는 이유 (0) 2023.10.10 Java 5부터 등장한 Generic은 하위 버전에 어떻게 호환이 되는걸까? (0) 2023.09.30 Thread Safety에 대해 알아보자 (0) 2023.09.22 - ArrayList