할당 프로파일링 시작하기

다른 언어: English Español Português 中文

우리는 종종 코드가 제대로 작동하지 않는 상황에서 자신을 발견하며, 조사를 어디서부터 시작해야할지 전혀 모릅니다.

우리는 그냥 코드를 쳐다보면서 결국은 우리에게 해결책이 나타나게 할 수 없을까요? 그럴 수 있지만, 이 방법은 프로젝트에 대한 깊은 지식과 많은 정신적 노력 없이는 아마 동작하지 않을 것입니다. 더 현명한 접근법은 가지고 있는 도구를 이용하는 것입니다. 그들은 당신을 올바른 방향으로 이끌 수 있습니다.

이 게시물에서는, 우리가 실행 시 문제를 해결하기 위해 메모리 할당을 프로파일링하는 방법을 살펴봅니다.

문제

다음 리포지터리를 복제함으로써 시작합시다: https://github.com/flounder4130/party-parrot.

프로젝트에 포함된 Parrot 실행 구성을 사용하여 애플리케이션을 시작합니다. 앱은 잘 작동하는 것처럼 보입니다: 애니메이션의 색상과 속도를 조정할 수 있습니다. 그러나, 잘못된 상황이 발생하기 전에 오래 걸리지 않습니다.

The parrot animation is frozen

일정 시간동안 작동한 후에, 애니메이션이 어떤 원인에 따라 멈춰버립니다. 프로그램은 때때로 OutOfMemoryError 를 던질 수 있으며, 여기에 포함된 스택 추적은 문제의 원인에 대해 우리에게 아무런 정보를 제공하지 않습니다.

정확히 언제 문제가 나타날지 알 수 있는 믿을 수 있는 방법은 없습니다. 이 애니메이션이 멈춘다는 점에서 흥미로운 것은, 그런 일이 생기면 여전히 UI의 나머지 부분을 사용할 수 있다는 것입니다.

Info icon

이 앱을 실행하는 데에는 Amazon Corretto 11을 사용했습니다. 결과는 다른 JVM에서나 심지어 동일한 JVM에서 다른 구성을 사용하는 경우와 다를 수 있습니다.

디버거

우리에게 버그가 있는 것 같습니다. 디버거를 사용해보려 합시다! 디버가 모드에서 애플리케이션을 시작하고, 애니메이션이 멈출 때까지 기다린 다음, 프로그램 일시 중지를 누릅니다.

디버거의 스레드 뷰에는 버그와 관련이 없는 것처럼 보이는 스택이 표시됩니다. 디버거의 스레드 뷰에는 버그와 관련이 없는 것처럼 보이는 스택이 표시됩니다.

불행히도, 이것은 우리에게 많은 것을 알려주지 않습니다. 왜냐하면 팔레트 파티에 관여하는 모든 스레드들이 대기 상태에 있기 때문입니다. 그들의 스택을 살펴봐도 그 동결이 왜 일어났는지에 대한 단서를 얻지 못합니다. 분명히, 우리는 다른 방법을 시도해야 합니다.

리소스 사용량 모니터링

OutOfMemoryError 를 만나기 때문에, 분석의 좋은 시작점은 CPU 및 메모리 실시간 차트 (CPU and Memory Live Charts)입니다. 그들은 실행 중인 프로세스의 실시간 리소스 사용량을 시각화하는 데 도움이 됩니다. 파티 앱의 차트를 열어 보고, 애니메이션이 멈출 때 우리가 무엇을 발견할 수 있는지 살펴봅시다.

메모리 사용량 차트는 사용된 메모리의 양이 계속해서 증가한 다음, 평탄화되는 것을 보여줍니다. 메모리 사용량 차트는 사용된 메모리의 양이 계속해서 증가한 다음, 평탄화되는 것을 보여줍니다.

실제로, 메모리 사용량이 계속해서 상승한 후 한 도달점에 도달하는 것을 볼 수 있습니다. 이것이 정확히 애니메이션이 멈춘 순간이며, 그 이후로 그것은 영원히 멈춰있는 것처럼 보입니다.

이것은 우리에게 단서를 줍니다. 보통, 메모리 사용량 곡선은 톱니 모양입니다: 새로운 객체가 할당되면 차트가 올라가고, 메모리가 쓰이지 않는 객체를 가비지 컬렉션 한 후에 메모리가 회수되면 주기적으로 내려갑니다. 아래 사진에서는 정상적으로 작동하는 프로그램의 예를 볼 수 있습니다:

사용된 메모리가 계속해서 증가하지만 정기적으로 내려가는 메모리 사용량 차트의 스크린샷 사용된 메모리가 계속해서 증가하지만 정기적으로 내려가는 메모리 사용량 차트의 스크린샷

만약 톱니가 너무 잦으면, 가비지 컬렉터가 메모리를 확보하기 위해 긴밀하게 작동한다는 것을 의미합니다. 평탄화는 그것이 어떤 것도 확보할 수 없다는 것을 의미합니다.

JVM이 가비지 컬렉션을 수행할 수 있는지 테스트하기 위해서는 명령을 통해 명시적으로 요청 할 수 있습니다:

'CPU and Memory Live Charts'의 툴바에 'Perform GC' 버튼 'CPU and Memory Live Charts'의 툴바에 'Perform GC' 버튼

우리의 앱이 도달점에 도달한 후에도, 우리가 수동으로 일부 메모리를 확보하라고 요청하면 메모리 사용량이 줄어들지 않습니다. 이것은 가비지 컬렉션 대상이 되는 객체가 없다는 우리의 가설을 지지합니다.

간단한 해결책은 그냥 더 많은 메모리를 추가하는 것일 것입니다. 이를 위해, -Xmx500m VM 옵션을 실행 구성에 추가하세요.

'Run/Debug Configurations' 대화 상자에 -Xmx500m VM 옵션 추가 'Run/Debug Configurations' 대화 상자에 -Xmx500m VM 옵션 추가
Tip icon

현재 선택된 실행 구성의 설정에 빠르게 접근하려면, ‘Shift’ 키를 누르고 메인 툴바의 실행 구성 이름을 클릭하십시오.

사용 가능한 메모리에 관계없이, 팔레트가 그것을 모두 소진합니다. 다시 말해서, 우리는 같은 현상을 봅니다. 추가 메모리의 유일한 가시적인 효과는 “파티”의 끝을 지연시켰다는 것입니다.

메모리 사용량 차트는 이제 500M의 사용 가능 메모리가 있지만, 앱은 그것을 모두 사용하는 것을 보여줍니다. 메모리 사용량 차트는 이제 500M의 사용 가능 메모리가 있지만, 앱은 그것을 모두 사용하는 것을 보여줍니다.

할당 프로파일링

우리 애플리케이션이 결코 충분한 메모리를 얻지 못하기 때문에, 메모리 누수를 의심하고 그 메모리 사용량을 분석하는 것이 합리적입니다. 이를 위해 우리는 -XX:+HeapDumpOnOutOfMemoryError VM 옵션을 사용하여 메모리 덤프를 수집 할 수 있습니다. 힙을 검사하는 데 이것은 완벽하게 수용 될 수 있지만; 그러나 이 것은 이 객체들을 생성하는데 책임이 있는 코드를 가리키지는 않습니다.

이 정보는 프로파일러 스냅샷에서 얻을 수 있습니다: 이는 물론 객체의 유형에 대한 통계를 제공할 뿐만 아니라, 그들이 만들어진 때에 해당하는 스택 추적도 알려줄 것입니다. 이것은 할당 프로파일링에 대한 조금 비정통적인 사용 사례지만, 그 문제를 파악하는 데 그것을 사용하는 것을 아무 것도 못하게 하지는 않습니다.

인텔리제이 프로파일러와 함께 애플리케이션을 실행합시다. 실행하는 동안, 프로파일러는 주기적으로 스레드 상태를 기록하고 메모리 할당 이벤트에 대한 데이터를 수집합니다. 이 데이터는 사람이 읽을 수 있는 형식으로 집계되며, 애플리케이션이 이러한 객체를 할당할 때 앱이 어떤 일을 하고 있었는지 알려줍니다.

프로파일러를 일정 시간동안 실행한 후에는 보고서를 열어 메모리 할당 (Memory allocations)을 선택합니다:

'Profiler' 도구 창의 오른쪽 상단 메뉴의 'Show'에 'Memory Allocations' 항목 'Profiler' 도구 창의 오른쪽 상단 메뉴의 'Show'에 'Memory Allocations' 항목

수집된 데이터에 대한 여러 가지 뷰가 사용 가능합니다. 이 튜토리얼에서는 플레임 그래프를 사용할 것입니다. 단일 스택과 같은 구조에서 수집된 스택을 집계하며, sample 수에 따라 요소의 너비를 조정합니다. 가장 넓은 요소는 프로파일링 기간 동안 가장 대량으로 할당된 유형을 나타냅니다.

여기에서 중요한 것 중 하나는 많은 할당이 꼭 문제를 나타내지 않는다는 것입니다. 메모리 누수는 오직 할당된 객체가 가비지 컬렉션이 되지 않을 때 발생합니다. 할당 프로파일링은 가비지 컬렉션에 대해 아무것도 말해주지 않지만, 그것은 여전히 이후의 조사를 위한 단서를 줄 수 있습니다.

할당 그래프에서 가장 큰 두 프레임은 int[]와 byte[]입니다. 할당 그래프에서 가장 큰 두 프레임은 int[]와 byte[]입니다.

두 가지 가장 대량의 요소들인 byte[] int[] 가 어디서 나오는지 살펴봅시다. 스택의 상단은 이 배열들이 java.awt.image 패키지의 코드에 의해 이미지 처리하는 동안 생성된다는 것을 알려줍니다. 스택의 하단은 이 모든 것이 별도의 스레드에서 실행됨을 말해줍니다, 이는 실행 서비스에 의해 관리됩니다. 우리는 라이브러리 코드에서 버그를 찾지 않고 있으므로, 그 사이에 있는 프로젝트 코드를 봅시다.

상단에서 하단으로 넘어가며, 우리가 보는 첫 번째 애플리케이션 메소드는 recolor() 이며, 이것은 차례로 updateParrot() 에 의해 호출됩니다. 이 메소드는 이름에서 알 수 있듯이 팔레트가 움직이게 만드는 것입니다. 이것이 어떻게 구현되며, 왜 그렇게 많은 배열이 필요한지 살펴봅시다.

플레임 그래프에서 updateParrot() 메소드를 가리키는 중 플레임 그래프에서 updateParrot() 메소드를 가리키는 중

프레임을 클릭하면 해당 메소드의 소스 코드로 이동합니다:

public void updateParrot() {
    currentParrotIndex = (currentParrotIndex + 1) % parrots.size();
    BufferedImage baseImage = parrots.get(currentParrotIndex);
    State state = new State(baseImage, getHue());
    BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue));
    parrot.setIcon(new ImageIcon(coloredImage));
}

updateParrot() 는 어떤 기본 이미지를 가져가서 그것을 다시 색칠하는 것처럼 보입니다. 추가 작업을 피하기 위해, 구현은 먼저 이미지를 어떤 캐시에서 검색하려고 합니다. 검색의 키는 State 객체이며, 그 생성자는 기본 이미지와 색조를 받습니다:

public State(BufferedImage baseImage, int hue) {
    this.baseImage = baseImage;
    this.hue = hue;
}

데이터 흐름 분석

내장된 정적 분석 도구를 사용하면 State 생성자 호출에 대한 입력 값의 범위를 추적할 수 있습니다. baseImage 생성자 인수를 마우스 오른쪽 버튼으로 클릭한 후, 메뉴에서 분석 | 여기로의 데이터 흐름을 선택합니다.

'여기로의 데이터 흐름 분석' 도구 창에 값의 가능한 원천을 노드로 표시합니다 '여기로의 데이터 흐름 분석' 도구 창에 값의 가능한 원천을 노드로 표시합니다

노드를 확장하고 ImageIO.read(path.toFile()) 에 주목하세요. 이로부터 기본 이미지가 파일 집합에서 나온다는 것을 알 수 있습니다. 이 라인을 더블 클릭하여 근처의 PARROTS_PATH 상수를 보면, 파일의 위치를 발견할 수 있습니다:

public static final String PARROTS_PATH = "src/main/resources";

이 디렉토리로 이동하면 다음을 확인할 수 있습니다:

'프로젝트' 도구 창에서 src/main/java 경로 아래의 10개의 이미지 파일 '프로젝트' 도구 창에서 src/main/java 경로 아래의 10개의 이미지 파일

앵무새의 가능한 위치에 해당하는 10개의 기본 이미지입니다. 그럼 생성자 인수인 hue 에 대해서 어떨까요?

'여기로의 데이터 흐름 분석' 도구 창에 값의 가능한 원천을 노드로 표시합니다 '여기로의 데이터 흐름 분석' 도구 창에 값의 가능한 원천을 노드로 표시합니다

hue 변수를 수정하는 코드를 살펴보면, 시작 값이 50 임을 알 수 있습니다. 그 다음 이는 슬라이더로 설정되거나 updateHue() 메소드에서 자동으로 업데이트됩니다. 어떠한 경우에도 항상 1 에서 100 범위 내에 있습니다.

따라서 우리는 색상의 100가지 변형과 10개의 기본 이미지를 가지고 있으며, 이는 캐시 크기가 1000개 요소를 초과하지 않게 함을 보장해야 합니다. 이가 사실인지 확인 해 봅시다.

조건부 중단점

이제 디버거가 유용할 수 있는 부분입니다. 조건부 중단점을 사용해 캐시의 크기를 확인할 수 있습니다.

Info icon

뜨거운 코드에 조건부 중단점을 설정하면 애플리케이션이 상당히 느려질 수 있습니다.

업데이트 액션에서 중단점을 설정하고, 캐시 크기가 1000개 요소를 초과할 때만 애플리케이션을 일시 중지하도록 조건을 추가합시다.

조건 'cache.size() > 1000'와 함께 나타난 중단점 설정 대화상자 조건 'cache.size() > 1000'와 함께 나타난 중단점 설정 대화상자

이제 앱을 디버그 모드로 실행합니다.

하이라이트된 줄이 중단점이 발동하고 디버거가 애플리케이션을 일시 중지했음을 나타냅니다 하이라이트된 줄이 중단점이 발동하고 디버거가 애플리케이션을 일시 중지했음을 나타냅니다

실제로, 프로그램을 잠시 실행한 후에 이 중단점에서 멈춥니다, 이는 문제가 실제로 캐시에 있다는 것을 의미합니다.

코드 검사

cache 에서 Cmd + B를 누르면 선언부로 이동합니다:

private static final Map<State, BufferedImage> cache = new HashMap<>();

HashMap 의 문서를 확인하면, 이의 구현이 equals() hashcode() 메소드에 의존하며, 키로 사용되는 타입은 이들을 올바르게 오버라이드해야 함을 알 수 있습니다. 이를 확인해봅시다. Cmd + B State 에서 누르면 클래스 정의부로 이동합니다.

class State {
private final BufferedImage baseImage;
private final int hue;

    public State(BufferedImage baseImage, int hue) {
        this.baseImage = baseImage;
        this.hue = hue;
    }

    public BufferedImage getBaseImage() { return baseImage; }

    public int getHue() { return hue; }
}

처벌자를 찾은 것 같습니다: equals() hashcode() 의 구현이 잘못되어 있는 것이 아니라, 그냥 전혀 없습니다!

메소드 오버라이드

equals() hashcode() 를 구현하는 것은 일상적인 작업입니다. 다행히도, 현대 도구는 이들을 우리를 위해 생성할 수 있습니다.

State 클래스 내부에서 Cmd + N을 누르고 equals() 및 hashCode() (equals() and hashCode()) 를 선택합니다. 제안 사항을 받아들이고 다음 (Next)를 클릭하여 케럿 위치에 메소드가 나타나도록 합니다.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    State state = (State) o;
    return hue == state.hue && Objects.equals(baseImage, state.baseImage);
}


@Override
public int hashCode() {
    return Objects.hash(baseImage, hue);
}

수정 사항 확인

애플리케이션을 다시 시작하고 개선 사항이 있는지 확인합시다. 다시 CPU와 메모리 실시간 차트를 사용하면 됩니다:

'CPU와 메모리 실시간 차트'의 그래프가 더 이상 평탄하지 않고 정기적으로 내려갑니다 'CPU와 메모리 실시간 차트'의 그래프가 더 이상 평탄하지 않고 정기적으로 내려갑니다

이는 훨씬 낫습니다!

요약

이 게시물에서는 문제의 일반적인 증상에서 시작하여, 우리의 추론 및 이용 가능한 다양한 도구를 사용하여 검색 범위를 단계적으로 좁히고 문제를 일으키는 코드의 정확한 줄을 찾는 방법을 살펴 보았습니다. 더 중요한 것은, 어떤 경우라도 앵무새 파티가 계속 이루어질 것임을 확인했습니다!

항상처럼, 귀하의 피드백을 기쁘게 받아들이겠습니다! 행복한 프로파일링 되세요!

all posts ->