모종닷컴

[JVM] 메모리 누수 현상 재현하고 이를 모니터링해보자. 본문

Programming/JAVA

[JVM] 메모리 누수 현상 재현하고 이를 모니터링해보자.

모종 2023. 5. 21. 20:38
반응형

개요

이번에 Out Of Memory( = OOM) 현상을 파악하기 위해 로컬에서 재현을 위해 JVM 몇 가지 옵션들을 설정했습니다. 혹시라도 누군가에게 도움이 될 수도 있을 것 같아 간단하게 적어봅니다.

먼저 Intellij Idea와 Visual VM을 사용하고 있는데 관련 설정이 필요한 분들은 이 포스트에서 확인하시고 설치해주시면 됩니다.

VisualVM에 플러그인 추가

먼저 VisualVm은 기본적으로 JVM 메모리의 힙 영역 메모리를 보여주기는 합니다.

다만 우리가 아는 Survival 영역, Eden 영역, Old Generation 영역 등이 상세히 보여주진 않고 이 합산된 값들만 보여주고 있습니다. 그래서 조금 보기가 힘든데 각 영역을 상세히 보여줄 수 있는 플러그인이 있어 먼저 이를 설치하는 게 좋습니다.

아래와 같이 상단의 Tools 메뉴에서 plugins를 클릭하여 설정에 들어가줍니다.

이후에 아래 사진과 같이 Available Plugins 탭에 들어가서 Visual GC라는 플러그인을 설치해 주세요.

설치가 완료되고 나면 애플리케이션 모니터링 시 사진과 같이 "Visual GC"라는 탭이 생겼을 겁니다. 해당 탭에 들어가서 화면을 보면 각 영역의 메모리가 자세하게 보입니다.

메모리 누수를 일으키는 코드 작성해서 테스트해 보기

다음은 메모리 누수를 일으키는 코드를 하나 작성하였습니다. 

private val log = KotlinLogging.logger {  }

@RestController
class MemoryLeakTestController {
    private val leakyList = mutableListOf<String>()

    @GetMapping("/memory-leak")
    fun createLeak() {
        log.info { "start insert" }
        repeat(100_000) {
            leakyList.add("This Object is not Garbage Collector target")
        }
        log.info { "end insert. current list size is ${leakyList.size}" }
    }
}
 

위 엔드포인트의 경우 요청이 들어왔을 경우 leakyList에 문자열을 10만 개씩 생성하고 삽입합니다.  애플리케이션이 계속 참조를 하고 있을 예정이기 때문에 해당 엔드포인트를 계속해서 요청할 경우 Old Generation 영역까지 가게 될 테고, Old Gen 영역이 꽉 차서 major gc가 발생하더라도 해제되지 않고 결국엔 OOM이 발생합니다. 

애플리케이션을 바로 실행해 보겠습니다. Intellij IDEA에서 애플리케이션을 실행할 때 VisualVM 아이콘이 있는 걸로 실행해 주세요.

실행하고 나서 좌측에서 현재 실행 중인 애플리케이션을 선택한 후 우측에서 아까 위에서 설치한 "Visual GC" 탭으로 이동해 주세요.

여기서 메모리 할당을 보면 엄청나게 크게(?) 메모리가 할당이 되었는데요. 메모리 누수 현상을 보려면 이 메모리만큼을 쌓아야 할 테죠.

일단 엔드포인트를 10번 호출해서 총 100만 개 문자열을 쌓아보도록 하죠.

100만 개의 문자열을 쌓고나니 Eden Space 그래프에서 뚝 떨어지는 구간을 확인할 수 있는데 이는 minor gc가 동작했기 때문일겁니다. minor gc가 동작했음에도 100만개의 문자열은 여전히 참조 중이기에 Old Gen 영역으로 이동이 된 모습입니다.

다시 엔드포인트를 650번 정도 호출해 보겠습니다. 저는 호출을 python으로 작성해서 해보겠습니다. 스크립트는 아래와 같습니다.

import requests


if __name__ == '__main__':
    for i in range(0, 650):
        print(f"try {i}")
        response = requests.request("GET", "http://localhost:9000/memory-leak", headers={}, data={}, files=[])
        print(response)

이렇게 여러 번 호출해 봤더니 메모리는 아래와 같이 그래프가 보입니다. 

조금만 더 하면 Out Of Memory를 볼 수 있을 것 같은데요. 여기서 한 가지 의문점이 듭니다. "이렇게 많은 메모리를 계속해서 내가 채워줘야 하는 건가? "

JVM 메모리 사이즈 조정

JVM 메모리 사이즈를 조정해 보겠습니다. 애플리케이션에 1GB 정도 메모리만 할당하고 Young 영역을 100MB 정도만 할당하면 호출 몇 번에 빠르게 메모리 누수 현상을 재현할 수 있을 것 같습니다.

intellij IDEA에 JVM 옵션을 추가해 보겠습니다. 추가할 옵션은 "-Xmn", "-Xmx"입니다. -Xmn은 Eden 영역의 크기를 나타냅니다. 우리는 100MB만 할당할 것이므로 -Xmn100m 를 옵션으로 추가해 주면 됩니다. -Xmx는 애플리케이션 힙 영역의 크기를 나타냅니다. 이는 곧 Eden+ Old 영역의 메모리를 합친 값과 같습니다. 따라서 이 값을 1GB로 할당하면 Old 영역은 900MB를 할당받게 될 겁니다. 1GB를 할당하기 위해 -Xmx1000m으로 설정합니다.

이 값들은 Intellij IDEA의 실행 설정에 넣어줄 수 있습니다. 아래 화면처럼 Edit Configurations에 들어갑니다. 그리고 Modify options에서 Add VM Option을 선택해 줍니다. 후에 사진과 같이 "-Xmn100m -Xmx1000m" 옵션을 넣어주면 됩니다.

이제 다시 애플리케이션을 restart 시키고 visual vm의 visual gc 탭으로 이동해 봅니다. 그러면 아래 사진과 같이 메모리 할당 사이즈가 달라졌음을 볼 수 있습니다. 

힙 영역 사이즈를 줄이면 빠르게 메모리 누수를 확인할 수 있다는 것을 알았으니 이제 귀찮은 작업을 하지 말고 코드를 아래와 같이 수정해 보도록 하겠습니다.

private val log = KotlinLogging.logger {  }

@RestController
class MemoryLeakTestController {
    private val leakyList = arrayListOf<ByteArray>()

    @GetMapping("/memory-leak")
    fun createLeak() {
        log.info { "start insert" }
        while (true) {
            // 1MB 객체를 계속 추가
            leakyList.add(ByteArray(1024 * 1024).also {
                it[0] = 't'.code.toByte()
                it[1] = 'e'.code.toByte()
                it[2] = 's'.code.toByte()
                it[3] = 't'.code.toByte()
            })
        }
    }
}

이제 해당 엔드포인트를 한번 요청해 놓으면 max size까지 쭉 찰 것 같습니다. 요청을 날려봤을 때 순식간에 메모리가 쭉 차버렸고, 애플리케이션에서도 "java.lang.OutOfMemoryError: Java heap space"가 발생했음을 볼 수 있었습니다. 

Heap Dump 떠보기

이렇게 메모리가 가득 찬 상태에서 힙 덤프를 한번 떠보겠습니다. Monitor 탭에 들어가서 우측에 있는 Heap Dump 버튼을 눌러줍니다. 그러면 사진과 같이 "[heapdump] 덤프 시간 "라는 탭이 하나 만들어질 텐데 들어가 줍니다.

그러면 아래 사진과 같이 처음에 Summary 영역이 보일 텐데 이를 Object로 바꾸어줍니다.

아래화면처럼 현재 객체 중에 size를 가장 많이 차지하고 있는 순으로 정렬을 한번 시켜주면 byte[] 배열이 현재 메모리의 97퍼센트 정도를 차지하고 있음을 볼 수 있습니다.

좀 더 상세히 보기 위해 byte[] 이름 옆에 있는 화살표를 클릭해서 열어주면 아래 사진처럼 저희 생성한 'test         ' 배열이 보임을 알 수 있습니다.

운영에서는 어떻게?

로컬에서는 OOM 일어났을 때 저희가 VisualVM의 HeapDump 버튼을 클릭해서 힙덤프를 떴는데 운영에서는 그럼 어떻게 해야 할까요? 이는 JVM에 Out Of Memory 에러가 일어났을 때 자동으로 힙덤프를 만들어주는 옵션을 넣어서 해결해 줄 수 있습니다.

다음과 같이 JVM 옵션을 추가적으로 넣어줍니다. "-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/monny/dump" 여기서 덤프 경로의 경우 원하시는 경로로 설정해 주시면 됩니다. 그러고 나서 애플리케이션을 재실행시킨 후 똑같이 OOM을 내보도록 하죠. 그러면 애플리케이션 로그에 아래와 같은 로그가 보이고 해당 경로에 덤프파일이 생성됩니다.

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /Users/monny/dump/java_pid56649.hprof ...
Heap dump file created [1011935153 bytes in 0.355 secs]

해당 파일을 찾아서 드래그 앤 드롭으로 VisualVM에 옮겨주면 사진처럼 덤프 파일을 볼 수 있습니다.

반응형

'Programming > JAVA' 카테고리의 다른 글

CompletableFuture 예외 핸들링 3가지 방법  (0) 2022.09.24
final, finally, finalize  (0) 2018.11.20
Comparison with Lambda  (0) 2018.10.29
자바 8 - Map  (0) 2018.10.19
캡슐화, 추상화, 인터페이스  (0) 2018.10.10