Post

자바 언어의 성능 향상 기법

11. 자바 언어의 성능 향상 기법

개발자가 성능 튜닝할 때 고려해야 할 부분??

네트워크 연결, I/O, DB 등의 애플리케이션 외부 요인 다음으로 병목을 일으킬 공산이 가장 큰 부분이 바로 코드 설계

성능에 민간함 개발자는 반드시 마음에 새겨두어야 할 코드의 기본 원칙

데이터를 애플리케이션에 어떻게 저장할지

어떤 알고리즘이 가장 효울적일지

도메인 객체를 애플리케이션 내부에서 어떻게 사용하는가

11.1 컬렉션 최적화

대부분의 프로그래밍 넌어 라이브러리는 최소한 두 가지 컨테이너를 제공

  • 순차 컨테이너(sequential container): 수치 인덱스로 표기한 특정 위치게 객체를 저장
  • 연관 컨테이너(associative container): 객체 자체를 이용해 컬렉션 내부에 저장할 위치를 결정

컨테이너에서 메서드가 정확히 작동하려면 저장할 객체가 호환성(compatibility)과 동등성(equality) 개념을 지니고 있어야 함

코어 자바 컬렉션 API에서는 모든 객체가 반드시 hashCode() 및 equals() 메서드를 구현해야 한다고 표현

참조형 필드는 힙에 레퍼런스로 저장됨

객체를 가리키는 레퍼런스가 저장됨

자바는 메모리 서브시스템이 알아서 가비지 수집을 해주는 대신, 저수준의 메모리 제어를 포기할 수 밖에 없음

컬렉션 API는 타입별로 해당 컨테이너가 준수해야 할 작업을 구체적으로 명시한 인터페이스 모음

11.2 List 최적화

11.2.1 ArrayList

고정 크기 배열에 기반한 리스트

배킹 배열의 최대 크기만큼 원소를 추가할 수 있고 이 배열이 꽉 차면 더 큰 배열을 새로 할당한 다음 기존 값을 복사

성능에 민감한 프로그래머는 크기 조정 작업 비용과 유연성(리스트가 앞으로 얼마나 더 커질지 미리 알 필요 없는 것)을 잘 저울질해야 함

ArrayList는 처음에 빈 배열로 시작하고 처음 원소가 추가될 때 용량 10인 기반 배열을 할당

초기 용량값을 생성자에 전달하면 이렇게 크기 조정을 안 해도 됨

크기 조정은 성능 비용을 유발하는 작업이므로 용량은 가급적 미리 설정하는 게 좋음

11.2.2 LinkedList

동적으로 증가하는 리스트

이중 연결 리스트로 구현되어 있어서 리스트에 덧붙이는 작업은 항상 O(1)

11.2.3 ArrayList vs LinkedList

둘 중 어느 것을 쓸지는 데이터 접근/수정 패턴에 따라 다름

ArrayList의 특정 인덱스에 원소를 추가하려면 이동시켜야함

반면, LinkedList는 삽입 지점을 찾기 위해 노드 레퍼런스를 죽 따라가는 수고는 있지만, 삽입 작업은 노드를 하나 생성한 다음 두 레퍼런스를 세팅하면 간단히 끝남

원소 삭제도 비슷함

리스트를 주로 랜덤 액세스 하는 경우라면 ArrayList가 정답

LinkedList의 고유 기능이 꼭 필요한 경우가 아니라면, 특히 랜덤 액세스가 필요한 알고리즘을 구사할 때에는 ArrayList를 권장

컬렉션 헬퍼 클래스 중 synchronizedList()라는 메서드가 있는데, 사실 이 메서드는 모든 리스트 메서드 호출을 동기화 블록으로 감싸는 장식자(decorator)

11.3 Map 최적화

매핑이란 키와 연관된 값 사이의 관계르 ㄹ뜻함

키/값 모두 반드시 참조형이어야 함

11.3.1 HashMap

처음에 버킷 엔트리를 리스트에 저장

값을 찾으려면 키 해시값을 계산하고 equals() 메서드로 리스트에서 해당 키를 찾음

키를 해시하고 동등성(equality)을 기준으로 리스트에서 값을 찾는 메커니즘이므로 키 중복은 허용되지 않음

HashMap이 키 해시값을 계산할 때 상위 비트를 무조건 반영하도록 설계(해시 충돌을 막기 위한 것)

이렇게 안 하면 인덱스 계산 시 상위 비트가 누락될 수 있기 때문에 문제가 됨

무엇보다 입력 데이터에 미세한 변화가 생겨도 해시 함수의 출력 데이터는 아주 크게 요동칠 수 있음

HashMap 생성자에 전달하는 initialCapacity와 loadFactor 두 매개변수는 HashMap의 성능에 가장 큰 영향을 미침

HashMap 용량은 현재 생성된 버킷 개수(디폴트 값은 16)를, loadFactor는 버킷 용량을 자동 증가(2배) 시키는 한계치(기본값 0.75)

용량을 2배 늘리고 저장된 데이터를 다시 배치한 다음, 해시를 다음 계산하는 과정을 재해시(rehash)

initialCapacity가 정확하면 테이블이 커져도 자동 재해시할 일은 없다,

loadFactor를 조정해도 되지만 0.75(디폴트값) 정도면 공간과 접근 시간의 균형이 대략 맞음

최대 원소 개수를 loadFactor로 나눈 값을 initialCapacity로 설정하면 재해시가 발생하지 않음

HashMap의 get(), put() 작업은 일정 시간이 소요되지만 순회를 하면 비용이 증가할 수 있음

트리화(treeify)도 성능에 영향을 주는 또 다른 요인

버킷이 금세 채워지면 버킷 원소를 LinkedList로 구현하면 원소 하나를 찾으러 리스트를 훑어보는 작업도 크기가 커질수록 평균 비용이 더 든다

최신 HashMap에는 하나의 버킷에 TREEIFY_THRESHOLD에 설정한 개수만큼 키/값 쌍이 모이면 버킷을 TreeNode로 바꿔버림

그럼, 아예 처음부터 바꾼다면??

TreeNode는 리스트 노드보다 약 2배 더 커서 그만큼 공간을 더 차지함

LinkedHashMap

LinkedHashMap은 HashMap의 서브클래스로, 이중 연결 리스트를 사용해 원소의 삽입 순서를 관리

LinkedHashMap의 기본 관리 모드는 삽입 순서(insertion order)이지만, 액세스 순서로 바꿀 수 있다.

LinkedHashMap은 순서가 중요한 코드에서 많이 쓰이지만 TreeMap처럼 비용이 많이 들지 않음

11.3.2 TreeMap

TreeMap은 레드-블랙 트리(red-black tree)를 구현한 Map

레드-블랙 트리는 기본 이진 트리 구조에 메타데이터를 부가해서 트리 균형이 한쪽으로 치우치는 현상을 방지한 트리

TreeMap은 다양한 키가 필요할 때 아주 유용하며, 서브맵에 신속히 접근 가능

또 처음부터 어느 지점까지, 또는 어느 지점부터 끝까지 데이터를 분할하는 용도로 쓰임

TreeMap이 제공하는 get(), put(), containsKey(), remove() 메서드는 log(n) 작업 성능을 보장

11.3.3 MultiMap은 없어요

자바는 MultiMap(하나의 키에 여러 값을 묶은 맵) 구현체를 제공하지 않음

MultiMap을 쓸 일이 드물고 대부분 Map<K, List>형태로도 충분히 구현 가능하여 제공하지 않음

오픈 소스는 있음(대표적으로 Guava)

11.4 Set 최적화

자바에는 세 종류 Set가 있고 성능에 관해서 고려해야 할 사항은 Map과 비슷

Set는 중복 값을 허용하지 않음. Map의 키 원소와 똑같다

HashSet의 add() 메서드가 내부적으로 사용하는 HashMap은 키가 원소 E, 값이 PRESENT라는 더미 객체로 구성됨

PRESENT는 처음 한번 만들어 참조하는 객체라서 오버헤드는 무시할 정도

HashSet의 삽입/삭제, contains작업은 복잡도가 O(1)이고 원소 순서는 유지하지 않으며, 순회 비용은 initialCapacity, loadFactor에 따라 다름

TreeSet 역시 TreeMap을 활용

TreeSet은 Comparator에 정의한 순서대로 정렬된 키 순서를 유지하므로 TreeSet에 더 알맞게 범위 기반 작업 및 순회 잡업 가능

TreeSet의 삽입/삭제 복잡도는 log(n)이며 원소 순서는 유지됨

11.5 도메인 객체

도메인 객체는 애플리케이션의 유의미한 비즈니스 컨셉트를 나타낸 코드

도메인 객체는 대부분 타입 간에 연관되어 있다

도메인 객체는 애플리케이션에서 일차적인 비즈니스 관심사를 나타내고 어느 정도 유일한 상태값을 지니고 있기 때문에 메모리 누수 같은 버그를 찾는 과정에서 쉽게 눈에 띔

자바 힙에 관한 기본적인 팩트

  • 가장 흔히 할당되는 자료 구조는 스트링, char 배열, byte 배열, 자바 컬렉션 타입의 인스턴스
  • jmap에서 누수되는 데이터는 비정상적으로 비대한 데이터셋

즉, 메모리 점유량과 인스턴스 개수 모두 보통 코어 JDK에 있는 자료구조가 상위권을 형성하는 게 보통

그런데 도메인 객체가 jmap 결과치의 상위 30위 정도 안에 든다면 꼭 그렇다고 단정 지을 순 없지만 메모리 누수가 발생한 신호

메모리 누수를 일으키는 도메인 객체의 또 다른 특징은 ‘전체 세대(all generations)’효과

특정 타입의 객체가 수집돼야 할 시점에 수집되지 않을 경우, 결국 여러 차례 수집 사이클을 꿋꿋이 견뎌내도 별의별 세대 카운트 값을 지는 채 테뉴어드 세대까지 살아남음

신속하게 대처하려면, 일단 도메인 객체에 대응되는 데이터셋의 크기를 살피고 그 수치가 온당한지, 그리고 작업 세트(working set, 어느 시점에 특정 프로세스의 필요에 의해 물리적으로 할당된 메모리의 페이지 그룹)에 존재하는 도메인 객체 수가 예상 범위 내에 들어 있는지 확인해야 함

한편, 단명 도메인 객체 역시 부유 가비지 문제를 일으키는 또 다른 원인이 될 가능성이 농후함

누수를 일으키는 도메인 객체는 종종 GC 마킹 시간을 증가시키는 주범으로 밝혀짐, 근본적인 이유는 하나의 단명 객체가 긴 전체 객체 체인에 걸쳐 살아남기 때문

많은 도메인 객체는 ‘탄광 속의 카나리아(붕괴 조짐을 미리 알려주듯이 위기 상황을 조기에 예고해주는 역할)’

도메인 객체의 도메인을 인식하고 그에 알맞은 크기와 작업 세트가 배정되도록 해야 함

11.6 종료화 안 하기

자바 finalize() 메서드는 자동으로 리소스를 관리하려고 만든 장치

객체를 해체할 때 자동으로 리소스를 해제/정리하는 해체기 메서드(destructor method)가 있다

어떤 객체가 생성되면 이 객체는 리소스를 소유하고 그 소유권은 객체가 살아 있는 한 지속됨

객체가 죽음을 맞이하면 리소스 소유권을 자동으로 내어줌

11.6.1 무용담: 정리하는 걸 깜빡하다

close() 함수가 에러시 제대로 호출이 안 됨

-> 응답이 느려짐

-> 리소스 고갈

-> 다른 프로세스에 영향

11.6.2 왜 종료화로 문제를 해결하지 않을까?

finalize() 메서드는 오버라이드해서 특정 로직을 부여 가능

어떤 객체가 더 이상 자신을 참조하지 않는다고 가비지 수집기가 판단하면 그 객체에 있는 finalize() 메서드를 호출함 서브 클래스는 finalize() 메서드를 오버라이드해서 시스템 리소스를 처분하는 등의 기타 정리 작업을 수행

실제로는 JVM 가비지 수집기가 특정 객체의 사망 사실을 분명히 알리는 서브시스템 역할을 함

다만, finalize() 메서드를 지원하는 타입으로 생성된 객체 중 오버라이드한 객체는 가비지 수집기가 특별하게 처리함

종료화 가능한 개별 객체는 java.lang.Object 생성자 바디에서 성공 반환되는 시점에 해당 객체를 등록하는 식으로 JVM에 구현됨

가비지 수집 중 즉시 회수되지 않고 종료화 대상으로 등록된 객체는 다음과 같이 수명을 연장함

  1. 종료화가 가능한 객체는 큐로 이동
  2. 애플리케이션 스레드 재시작 후, 별도의 종료화 스레드가 큐를 비우고 각 객체마다 finalize() 메서드를 실행
  3. finalize()가 종료되면 객체는 다음 사이클에 진짜 수집될 준비를 마침

종료화할 객체는 모두 GC 마킹을 해서 도달 불가능한 객체로 인식시키고, 종료화한 다음엔 반드시 GC를 재실행해서 데이터를 다시 수집해야 함

즉, 종료화 가능한 객체는 적어도 한 번의 GC 사이클은 더 보존됨

이는 테뉴어드 세대 객체의 경우 상당히 긴 시간이 될 수도 있음

finalize()는 다른 문제점도 있다

종료화 스레드 실행 도중 메서드에서 예외가 발생한다면?

그냥 무시됨

종료화에 블로킹 작업이 있을지 모르니 JVM이 스레드를 하나 더 만들어 finalize() 메서드를 실행해야 함

따라서 새 스레드를 생성/실행하는 오버헤드는 감수해야 함

JVM은 대부분의 필요한 작업을 처리하는 애플리케이션 스레드와 함께 별도의 스레드를 만들어 종료화를 수행

1
2
3
static void register(Object Finalizee) {
	new Finalizer(Finalizee);
}

종료화 구현체는 FinalReference 클래스에 크게 의존

이 클래스의 슈퍼 클래스가 런타임이 특별한 경우로 인식하는 java.lang.ref.Reference 클래스

종료화를 구현한 코드는 치명적인 결함을 지닐 수밖에 없음

자바의 메모리 관리 서브시스템은 할당할 가용 메모리가 부족하면 그떄그때 반사적으로 가비지 수집기 실행

가비지 수집이 언제 일어날지는 아무도 모르는 까닭에 객체가 수집될 때에만 실행되는 finalize() 메서드도 언제 실행될지 알 길이 없다

가비지 수집은 딱 정해진 시간에 실행되는 법이 없으므로 종료화를 통해 자동으로 리소르를 관리한다는 것 자체가 어불성성

리소스 해제와 객체 수명을 엮는 장치가 따로 없으니 항상 리소스가 고갈될 위험에 노출돼 있는 셈.

종료화는 의도했던 목적과는 잘 맞지 않음

그래서 오라클은 종료화를 사용하지 말라고 개발자들에게 권고함

11.6.3 try-with-resources

자바 7부터 언어 자체에 추가된 try-with-resources 생성자를 이용하면 try 키워드 다음의 괄호 안에 리소스(AutoClosable 인터페이스를 구현한 객체만 가능)를 지정해서 생성할 수 있다

이로써 try 블록이 끝나는 지점에 개발자가 close() 메서드 호출을 깜빡 잊고 빠뜨려도 자동으로 호출됨

try-with-resources는 강력히 추천하는 베스트 프랙티스

종료하는 런타임 내부 깊숙한 곳에 있는 어셈블리 코드에 기반해 객체를 미리 등록하고 특별한 GC 작업을 수행

그런 다음, 가비지 수집기를 이용해 레퍼런스 큐와 별도의 전용 종료화 스레드를 동원해 정리 작업

반면, try-with-resources는 순수한 컴파일 타임 기능

컴파일하면 평범한 바이트코드로 바뀌는 다른 런타임 조직과는 무관한 일종의 간편 구문

단, try-with-resources는 상당히 큰 바이트코드로 변환되므로 JIT 컴파일러가 인라이닝하고 메서드를 컴파일하는 과정에 좋지 않은 영향을 끼칠 가능성이 있다

종료화는 GC에 의존하고 GC는 그 자체로 불확정적인 프로세스라서 리소스 관리를 비롯한 대부분의 경우에 원래의 의도와는 맞지 않음

즉, 종료화는 의존하는 객체는 언제 리스소가 해제될지 아무런 보장이 없음

절대로 filnalize()를 오버라이드해서 클래스를 작성 X

11.7 메서드 핸들

invokedynamic 호출부가 실제로 어느 메서드를 호출할지 런타임 전까지 결정되지 않음

대신, 호출부가 인터프리터에 이르면 특수한 보조 메서드(부트스트랩 메서드(bootstrap method, BSM)가 호출되고, 이 BSM은 호출부에서 호출됐어야 할 실제 메서드를 가리키는 객체를 반환

이 객체를 호출 대상이라고 하며, 호출부 내부에 가미됐다(laced into)고 표현

여기서 핵심은 메서드 핸들(method handle)

메서드 핸들은 invokedynamic 호출부에 의해 호출되는 메서드를 나타낸 객체

그래서 자바 7부터 일부 클래스, 패키지가 추가돼서 실행 가능한 메서드의 레퍼런스를 직접 반영할 수 있게 됨

리플렉션 호출처럼 메서드 핸들의 하부 메서드의 시그니처는 자유롭기 때문에 메서드 핸들에 있는 인보커 메서드는 최대한 융통성 있게 관대한 시그니처를 지니고 있어야 함

ex. 메서드 핸들 이전

1
2
3
Method m = ...
Object receiver = ...
Object o = m.invoke(receiver, new Object(), new Object());

하나의 객체 인수화 리플렉션 호출에 전달할 가변 인수들을 죽 받음

컴파일 타임에는 이 메서드가 어떻게 호출될지 젼허 가늠할 길이 없음

ex. 메서드 핸들 이후

1
2
3
4
5
6
7
MethodType mt = MethodType.methodtype(int.class);
MethodHandles.lookup l = MethodHandles.lookup();
MethodHandle mh = l.FindVirtual(String.class, "hashCode", mt);

String reciver = "b";
int ret = (int) mh.invoke(receiver);
System.out.println(ret);

메서드 핸들을 룩업하고 그다음에 호출

실제 시스템에서는 이 두 부분이 시점 또는 코드 위치 측면에서 멀찍이 분리되어 있을수도 있음

메서드 핸들은 안정된 불변 객체라서 나중에 쓸 목적으로 보관, 캐시하기가 쉬움

메서드 핸들을 룩업하는 부분이 판박이 코드처럼 보이지만, 리플렉션이 처음 나왔을 때부터 문제였던 액세스 제어 이슈를 바로잡을 수 있음

클래스가 처음 로딩되면 VM은 바이트코드를 전수 검사

이 과정에서 액세스 권한이 없는 메서드를 클래스가 악의적으로 호출하려고 시도하는지 검사

한번 로딩된 클래스는 성능 문제가 있어 두번 다시 검사 X

메서드 핸들 API는 룩업 컨텍스트(lookup context)라는 방식으로 접근

MethodHandles.lookup() 메서드를 호출해 컨텍스트 객체를 생성하는데, 이 메서드가 반환한 불변 객체에는 컨텍스트 객체를 생성한 지점에서 액세스 가능한 메서드 및 필드를 기록한 상태 정보가 있다

따라서 컨텍스트 객체는 바로 사용해도 되고 저장했다가 나중에 써도됨

lookup()을 정적 호출한 지점에서 액세스 가능한 메서드를 모두 바라볼 수 있는 컨텍스트 객체가 생성됨

이로써 FindVirtual() 메서드를 이용해 그 지점에서 보이는 모든 메서드의 핸들을 가져올 수 있음

만약 룩업 컨텍스트로 안 보이는 메서드에 액세스하려고 하면 IllegalAccessException이 발생

리플렉션과 달리 프로그래머가 이러한 액세스 체크 로직을 없애거나 해제하는 건 불가능

invoke() 호출이 모든 인수를 두루 받아들이는 만능 호출 대신, 런타임에 호출돼야 할 메서드의 예상 시그니처를 기술

대부분의 개발자에게 메서드 핸들이란, 코어 리플렉션과 기능은 비슷하나 최대한 정적 타입을 안전하게 지키는, 요즘의 방식으로 구현한 리플렉션

참조

  1. Optimizing Java(자바 최적화)
This post is licensed under CC BY 4.0 by the author.