- Published on
Optimizing Java Chap11
- Authors
- Name
- ywj9811
들어가며
이번 장에서는 코드 설계를 할 때 어떤 부분을 마음에 새기고 진행해야 하는가에 대해서 이야기를 해볼 것이다.
예를 들면 데이터를 어플리케이션에 어떻게 저장할 것인지는 굉장히 중요한 문제로, 이러한 부분에서 어떤 자료 구조는 어떻게 사용되는지를 알아야 할 것인데 먼저 컬렉션부터 알아보도록 하자.
컬렉션 최적화
우선, 대부분의 프로그래밍 언어 라이브러리는 최소한 두 가지 컨테이너를 제공한다.
- 순차 컨테이너 : 수치 인덱스로 표기한 특정 위치에 객체를 저장한다.
- 연관 컨테이너 : 객체 자체를 이용해 컬렉션 내부에 저장할 위치를 결정한다.
컨테이너에서 메소드가 정확히 작동하려면 저장할 객체가 호환성과 동등성 개념을 지니고 있어야 한다.
코어 자바 컬렉션 API에서는 모든 객체가 반드시 hashCode()
와 equals()
메소드를 구현해야 한다고 한다.
컬렉션 API는 타입별로 해당 컨테이너가 준수해야 할 작업을 구체적으로 명시한 인터페이스 모음이다.
이렇게 JDK에는 인터페이스 외에도 다양한 컬렉션 구현체가 들어있다. 설계 요건에 알맞은 구현체를 선택하는 일이 관건이며 그 선택의 결과가 성능에 전체적인 영향을 끼칠 수 있다는 사실 또한 인지해야 한다.
List 최적화
자바에서는 ArrayList와 LinkedList 두가지 기본 형태로 나타낸다.
ArrayList
이는 고정 크기 배열에 기반한 리스트이다.
이는 배열이 꽉 차면 더 큰 배열을 새로 할당한 다음 기존 값을 복사하는데, 따라서 성능에 민감한 프로그래머는 크기 조정 작업 비용과 유연성(앞으로 얼마나 더 커질지 미리 알 필요가 없는 것)을 잘 저울질 해야 한다.
우선, ArrayList는 처음에 빈 배열로 시작하고 원소가 처음 추가될 때 용량 10인 기반 배열을 할당한다.
만약 초기 용량값을 생성자에게 전달하면 이렇게 크기 조정 작업을 안해도 된다.
또한 ensureCapacity()
메소드를 이용해 ArrayList 용량을 늘려도 크기 조정 작업을 건너뛸 수 있다.
@Benchmark
public List<String> properlySizedArrayList() {
List<String> list = new ArrayList<>(1_000_000);
for(int i=0; i < 1_000_000; i++) {
list.add(item);
}
return list;
}
@Benchmark
public List<String> resizingArrayList() {
List<String> list = new ArrayList<>();
for(int i=0; i < 1_000_000; i++) {
list.add(item);
}
return list;
}
위 코드를 수행하면 properlySizedArrayList()
가 원소 추가 작업을 초당 약 100회 더 처리할 수 있다.
아무래도 크기를 정하고 시작하는게 성능이 더 좋다.
LinkedList
이는 동적으로 증가하는 리스트로 이중 연결 리스트로 구현되어 있어서 리스트에 덧붙이는 작업은 항상 O(1)이다.
원소가 리스트에 더해질 때마다 노드가 생성되고 이 노드를 이전 원소가 바라본다.
ArrayList VS LinkedList
그렇다면 이 둘중에 무엇을 사용할 것인가? 이에 대한 대답은 데이터 접근/수정 패턴에 다라 다르다.
리스트 끝에 원소를 삽입하는 작업은 ArrayList, LinkedList 모두 일정한 시간이 소요된다. (물론 ArrayList의 크기 조정 작업을 고려하지 않았을 때)
하지만 ArrayList는 특정 인덱스에 원소를 추가하려면 다른 원소들을 모두 한칸씩 우측으로 이동시켜야 한다.
반면 LinkedList는 삽입 지점을 찾기 위해 노드 레퍼런스를 쭉 따라가는 수고는 있지만, 삽입 작업은 노드를 하나 생성한 다음 두 레퍼런스를 세팅하면 끝이다.
원소 삭제 또한 LinkedList는 많아야 레퍼런스 2개만 수정하면 되므로 매우 저렴하다. ArrayList는 삭제 원소 오른쪽의 원소를 모두 왼쪽으로 이동시켜야 한다.
하지만 주로 랜덤 액세스하는 경우라면 ArrayList가 정답이다. 모든 원소를 O(1) 시간만에 가져올 수 있기 때문이다.
LinkedList의 경우 처음부터 인덱스 카운트만큼 원소를 방문해야 한다.
마무리로, LinkedList의 고유 기능이 꼭 필요한 경우가 아니라면, 특히 랜덤 액세스가 필요한 알고리즘을 구사할 때는 ArrayList를 권장한다.
그리고 ArrayList의 경우 가급적이면 미리 크기를 지정하여 중간에 다시 조정하는 일이 없도록 하는 것이 좋다.
Map 최적화
Hashmap
여러 면에서 고전 기초 컴퓨터 과학책에 나오는 해시 테이블이라고 볼 수 있는 자바 HashMap에는 요즘 환경에 맞춰 몇가지 기능이 추가된 것이다.
동작을 살펴보면 우선 처음에는 버킷 엔트리를 리스트에 저장한다. 그리고 값을 찾으려면 해시값을 계산하고 equals()
로 리스트에서 해당 키를 찾는다.
키를 해시하고 동등성을 기준으로 리스트에서 값을 찾는 메커니즘을 사용하는데 이를 통해 키 중복은 허용되지 않는다.
그리고, HashMap의 생성자를 보면 initialCapacity와 loadFactor라는 매개변수를 받는데, 이는 HashMap의 성능에 가장 큰 영향을 끼친다.
initialCapacity는 ArrayList의 경우와 마찬가지로 생성될 버킷 개수(디폴트 16)를 지정하며, loadFactor는 최대 버킷 개수의 어느정도 저장이 되었을 때(디폴트 0.75) 최대 버킷 개수를 2배로 늘릴지 정하는 것이다.
예를 들면 100이라는 버킷이 최대 개수일 때 loadFactor가 0.75라면 75만큼 저장되면 200개로 개수를 늘리는 것이다.
즉, 최대 원소 개수를 loadFactor로 나눈 값을 initialCapacity로 설정하면 재해시가 발생하지 않는다.
트리화 또한 성능에 영향을 주는 요인이다.
이는 비교적 최근에 HashMap 내부에 구현된 기술로, 하나의 버킷에 설정한 개수만큼 키/값이 쌓이게 되면 이를 TreeNode로 바꿔준다.
이렇게 하면 접근이 빨라지는 장점이 있지만, 리스트 노드보다 크기가 약 2배 커지기 때문에 처음부터 TreeNode로 생성하지는 않는다.
LinkedHashMap
LinkedHashMap는 HashMap의 서브 클래스로 이중 연결 리스트를 사용해 원소의 삽입 순서를 관리한다.
LinkedHashMap의 기본 관리 모드는 삽입 순서이지만, 액세스 순서 모드로 바꿀 수 있다.
이는 순서가 중요한 코드에서 많이 쓰이지만 TreeMap처럼 비용이 많이 들지 않는다는 장점이 있다.
하지만 Map을 사용할 때 보통 삽입/접근 순서가 중요하지 않기 때문에 LinkedHashMap을 사용해야 하는 일은 드물다.
TreeMap
TreeMap은 레드-블랙 트리를 구현한 Map이다.
레드-블랙 트리는 기본 이진 트리 구조에 메타데이터를 부가해서 트리 균형이 한쪽으로 치우치는 현상을 방지한 트리이다.
이는 다양한 키가 필요할 때 아주 유용하며 서브맵에 신속히 접근할 수 있으며 처음부터 어느 지점까지, 어느 지점부터 끝까지 데이터를 분할하는 용도로 사용된다.
TreeMap이 제공하는 get() put() containsKey() remove()
메소드는 log(n)의 작업 성능을 보장한다.
보통은 HashMap을 사용하는 것이 보편적이지만 람다 혹은 스트림과 같이 Map 일부를 처리해야 하는 경우에는 TreeMap을 사용하는 것이 바람직 할 수 있다.
Set 최적화
자바에는 세 종류의 Set이 있으며 성능에 관해서 고려해야 할 사항은 Map과 비슷하다.
- 실제로 HashSet은 HashMap으로(LinkedHashset은 LinkedHashMap으로) 구현되어 있음
HashSet의 생성자중 파라미터를 받지 않는 경우 HashMap을 사용하며 키가 원소 E, 값이 PRESENT라는 더미 객체로 구성된다.
HashSet의 생성자중 protected 생성자는 LinkedHashMap 객체와 initialCapacity, loadFactor를 받는데 이로써 삽입 순서를 유지하는 LinkedHashMap 로직을 사용할 수 있다.
HashSet의 삽입/삭제, contains 작업은 복잡도가 O(1)이고 LinkedHashSet으로 사용하지 않는 한 원소 순서는 유지되지 않는다. 그리고 순회 비용은 initialCapacity, loadFactor에 따라 달라진다.
또한 TreeSet 은 TreeMap을 활용한다.
TreeSet은 Comparator에 정의된 순서대로 정렬된 키 순서를 유지하므로 TreeSet에 더 알맞게 범위 기반 작업 및 순회 작업을 수행할 수 있다. 이것의 삽입/삭제 복잡도는 log(n)이며 원소 순서는 유지된다.
도메인 객체
도메인 객체란 어플리케이션에 유의미한 비즈니스 컨셉트를 나타낸 코드로 예를 들어 전자 상거래 시스템이라면, Order, OrderItem, DeliverySchedule 등이 도메인 객체가 되는 것이다.
또한 도메인 객체는 대부분 타입 간에 연관되어 있다. (Ex. 하나의 Order에는 여러 OrderItem 인스턴스가 매핑)
우선, 자바 힙에 관한 기본적인 팩트를 확인해보자.
- 가장 흔히 할당되는 자료 구조는 스트링, char 배열, byte 배열, 자바 컬렉션 타입의 인스턴스이다.
- jmap에서 누수되는 데이터는 비정상적으로 비대한 데이터셋으로 나타난다.
이를 통해 알 수 있는 것은 메모리 점유량과 인스턴스 개수 모두 보통 코어 JDK에 있는 자료 구조가 상위권을 형성해야 한다는 것이다.
만약, 도메인 객체가 jmap 결과의 상위 30위 정도 안에 든다면 이는 메모리 누수가 발생한 신호라고 볼 수 있다.
메모리 누수를 일으키는 도메인 객체의 또 다른 특징은 ‘전체 세대 효과’로 특정 타입의 객체가 응당 수집되어야 할 시점에 수집되지 않을 경우 결국 여러 차례 수집 사이클을 꿋꿋이 견뎌내고 테뉴어드 세대까지 살아남는 것이다.
이럴 경우 일단 도메인 객체에 대응되는 데이터셋의 크기를 살피고 그 수치가 괜찮은 것인지, 그리고 작업 세트에 존재하는 도메인 객체 수가 예상 범위 내에 들어있는지 확인해야 한다.
사실 도메인 객체는 비즈니스 관심사를 가장 분명하게, 자연스럽게 나타낸 객체라서 메모리 누수에 더 취약하다.
따라서 도메인 객체의 도메인을 인식하고 그에 알맞는 크기의 작업 세트가 배정되도록 하자.
종료화 안하기
자바 finalize()
메소드는 자동으로 리소스를 관리하려고 만든 장치로 이것의 기본 유스케이스는 매우 간단하다. 바로, 어떤 객체가 생성되면 리소스를 소유하고 그 소유권은 객체가 살아 있는 동안 지속되다가 죽음을 맞이하면 소유권을 내어주는 것이다.
하지만, 자바의 경우 C, C++과는 메모리 관리 개념이 근본부터 다르기 때문에 종료화를 구현한 코드는 치명적인 결함을 지닐 수밖에 없다.
따라서 일반 어플리케이션 코드에 종료화를 사용하지 말라고 오라클은 지속적으로 권고하였으며, 자바 9 이후로는 deprecated 됐다.
try-with-resources
이는 자바 7부터 언어 자체에 추가된 방식으로 try-catch-final을 통해 close()
를 하지 않고
public void readFirst(File file) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(File))) {
String FirstLine = reader.readLine();
System.out.println(FirstLine);
}
}
이런식으로 작성하게 되면 reader.close()
를 까먹고 작성하지 않더라도 자동으로 호출되게 된다.
이는 Best Practice로 알려져 있는 방식이다.
만약 리소스 객체 및 범위를 다룰 때는 이러한 방식을 try-with-resources를 사용하면 된다.
하지만, 이는 상당히 큰 바이트코드로 변환되므로 JIT 컴파일러가 인라이닝하고 메소드를 컴파일 하는 과정에 좋지 않는 영향을 끼칠 가능성이 있다.
물론, 정말 문제가 있다는 사실이 분명하게 된다면 그때 리팩토링을 진행하도록 하자.
그리고 종료화가 사장되어 없어지던 말던 절대로 finalize()
메소드를 오버라이드 해서 클래스를 작성하지 말도록 해야한다.
메소드 핸들
이전에 다룬 invokedynamic 명령어는 자바 7에서 선보인 주요 기능으로 이 명령어 덕분에 호출부에서 실행할 메소드를 아주 유연하게 결정할 수 있게 되었다.
이때 핵심 포인트는 invokedynamic 호출부가 실제로 어느 메소드를 호출할지 런타임 전까지 결정되지 않는다는 점이다.
대신에 호출부가 인터프리터에 이르면 특수한 보조 메소드(부트스트랩 메소드 - BSM)가 호출되고, BSM은 호출부에서 호출됐어야 하는 실제 메소드를 가리키는 객체를 반환한다.
이 객체를 호출 대상이라고 하며 호출부 내부에 ‘가미됐다’ 라고 표현한다.
여기서 핵심은 바로 메소드 핸들로 메소드 핸들은 invokedynamic 호출부에 의해 호출되는 메소드를 나타내는 객체이다.
이는 리플랙션과 어느 정도 개념이 비슷하지만, 리플랙션은 자체 한계 때문에 invokedynamic과 함께 사용하기 불편하다.
그래서 자바 7부터 일부 클래스, 패키지(java.lang.invoke.MethodHandle)가 추가되어 실행 간으한 메소드의 레퍼런스를 직접 반영할 수 있게 되었다.
하부 메소드를 실행할 수 있는 다양한 메소드가 내장되어 있으며 그중 invoke()
를 가장 많이 쓰지만, 주요 invoker를 조금 변형한 메소드와 일부 헬퍼가 추가 되었다.
리플랙션으로는 할 수 없는 몇가지 신기능이 추가 되었는데 어떤 신기능이 있고 그것이 왜 중요한지 알아보자.
우선 아래는 메소드를 재귀 호출하는 단순한 코드이다.
Method m = ...
Object receiver = ...
Object o = m.invoke(receiver, new Object(), new Object());
이는 하나의 객체 인수와 리플렉션 호출에 전달할 가변 인수들을 받지만, 결국 Object를 반환하기에 컴파일 타임에는 이 메소드가 어떻게 호출될지 전혀 가늠할 수 없다.
즉, 수신자와 Method 객체가 안맞거나 매개변수 목록에 조금이라도 오류가 생기면 런타임에 실패할 수 있다.
이번에는 메소드 핸들을 사용한 아래의 코드를 살펴보자.
MethodType mt = MethodType.methodType(int.class);
MethodHandles.Lookup l = MethodHandles.lookup();
MethodHandle mh = l.FindVirtual(String.class, "hashCode", mt);
String receiver = "b";
int ret = (int) mh.invoke(receiver);
System.out.println(ret);
우선 두 부분으로 나누어서 호출하고 있는데, 먼저 메소드 핸들을 룩업하고 그 다음에 호출한다.
참고로, 실제 시스템에서는 이 두 부분이 시점 또는 코드 위치 측면에서 멀리 분리되어 있을 수 있다.
(메소드 핸들은 안정된 불변 객체라서 나중에 쓸 목적으로 보관, 캐시하기 쉽다.)
위의 코드에서 살펴보면 룩업 하는 과정에서 액세스 가능한 메소드를 모두 바라볼 수 있는 컨텍스트 객체가 생성되는데 이로써 FindVirtual()
메소드를 이용해 그 지점에서 보이는 모든 메소드의 핸들을 가져올 수 있다.
만약 룩업 컨텍스트로 안보이는 메소드에 액세스 하려고 하면 IllegalAccessException 이 발생한다.
이외에도 리플렉션이 처음 나왔을 때부터 문제였던 액세스 제어 이슈를 바로잡을 수 있다.