티스토리 뷰

https://medium.com/javarevisited/6-tips-for-resolving-common-java-performance-problems-b88f42dc6118

6 Common Java performance problems

1. Memory leaks

자바는 가비지 컬렉터를 통해 자동 메모리 관리를 제공함에도 불구하고, 성능 문제에 대한 보장을 하지 않습니다. 가비지 컬렉터는 더 이상 사용되지 않는 메모리를 자동으로 식별하고 회수하지만, 자동 메모리 관리에만 의존하는 것은 성능 문제에서 완벽한 방법은 아닙니다.

자바의 가비지 컬렉터는 더 이상 사용되지 않는 메모리를 자동으로 식별하고 회수하여 언어의 강력한 메모리 관리 시스템의 필수적인 기능 입니다.

그러나 이러한 고급 메커니즘을 사용하더라도, 심지어 가장 숙련된 프로그래머들도 자바 메모리 누수를 우연히 발생시킬 수 있습니다. 메모리 누수는 객체가 의도치 않게 메모리에 유지되어 가비지 컬렉터가 관련 메모리를 회수하지 못하게 하는 것으로, 시간이 지남에 따라 메모리 소비가 증가하고 응용 프로그램의 성능이 저하될 수 있습니다.

메모리 누수는 겹치는 증상 때문에 감지하고 해결하기 어려울 수 있습니다. 우리의 경우, 가장 명백한 증상 중 하나인 OutOfMemoryError 힙 오류가 있었으며, 시간이 지남에 따라 성능이 저하되었습니다.

자바에서 메모리 누수를 일으킬 수 있는 여러 가지 문제가 있습니다. 우리의 첫 번째 접근 방식은 이것이 정상적인 메모리 고갈(디자인의 부적절한 결과로 인한)인지 누수인지를 분석하여 메모리 부족 오류 메시지를 조사하는 것이었습니다.

우리는 먼저, static 필드, 컬렉션 및 애플리케이션의 전체 수명 동안 중요한 메모리를 차단할 수 있는 정적으로 선언된 큰 객체와 같은 가장 가능성이 높은 원인들을 확인하는 것으로 시작했습니다.

예를 들어, 아래의 코드 예제에서는 리스트를 초기화할 때 static 키워드를 제거하면 메모리 사용량이 급격하게 감소합니다.


public class StaticFieldsMemoryTestExample {
    public static List<Double> list = new ArrayList<>();
    public void addToList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }
    public static void main(String[] args) {
        new StaticFieldsDemo().addToList();
    }
}

우리가 취한 다른 조치에는 메모리를 차단할 수 있는 열린 리소스나 연결을 확인하는 것이 포함되었습니다. 이에 따라 가비지 컬렉터의 도달 범위 밖에 있습니다. HashMaps와 HashSets에서 equals() 및 hashCode() 메서드의 잘못된 구현을 수정하여 적절한 equals() 및 hashCode() 메서드를 작성했습니다. 다음은 equals() 및 hashCode() 메서드를 올바르게 구현하는 예시입니다.

public class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

Memory leaks를 방지하는 tips

  • 코드가 파일 핸들, 데이터베이스 연결 또는 네트워크 소켓과 같은 외부 리소스를 사용하는 경우, 해당 리소스가 더 이상 필요하지 않을 때 명시적으로 해제하십시오.

  • VisualVM이나 YourKit과 같은 메모리 프로파일링 도구를 사용하여 응용 프로그램에서 잠재적인 메모리 누수를 분석하고 식별하십시오.

  • 싱글톤을 사용할 때, 싱글톤이 실제로 필요할 때까지 불필요한 리소스 할당을 피하기 위해 이른 초기화(eager loading) 대신 지연 로딩(lazy loading)을 사용하십시오.

  • 코드가 파일 핸들, 데이터베이스 연결 또는 네트워크 소켓과 같은 외부 리소스를 사용하는 경우, 해당 리소스가 더 이상 필요하지 않을 때 명시적으로 해제하십시오.

2. Thread deadlocks

자바는 멀티쓰레드 언어입니다. 이것은 자바를 특히 복수의 작업을 동시에 처리하는 기업 애플리케이션을 개발하기에 적합한 언어로 만드는 기능 중 하나입니다.

멀티쓰레드라는 이름에서 알 수 있듯이 여러 쓰레드가 관련되며 각각이 가장 작은 실행 단위입니다. 쓰레드는 독립적이며 다른 쓰레드에게 영향을 미치지 않는 별도의 실행 경로를 가집니다.

그러나 쓰레드가 동시에 동일한 리소스(락)에 액세스하려고 시도하는 경우 어떻게 될까요? 이것이 데드락이 발생하는 경우입니다. 실시간 금융 데이터 처리 시스템에서 협업하면서 이를 경험했습니다. 이 프로젝트에서는 외부 API에서 데이터를 가져 오고 복잡한 계산을 수행하며 공유 인메모리 데이터베이스를 업데이트하는 여러 쓰레드가 있었습니다.

이 도구의 사용이 증가함에 따라 우리는 가끔씩 멈춤 현상이 발생하는 보고서를 받게 되었습니다. 쓰레드 덤프를 분석하면 일부 쓰레드가 락에 대한 원형 종속성을 형성하여 대기 상태에 갇혀 있는 것을 알 수 있었습니다.

이 예에서는 두 개의 쓰레드(thread1 및 thread2)가 서로 다른 순서로 두 개의 락(lock1 및 lock2)을 획득하려고 시도합니다. 이는 원형 대기를 도입하여 데드락의 가능성을 높입니다.

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock 1");
                // Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock 1 and lock 2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock 2");
                // Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("Thread 2: Holding lock 2 and lock 1");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

이 문제를 해결하기 위해 쓰레드가 항상 일관된 순서로 락을 획득하도록 코드를 리팩토링할 수 있습니다. 이를 위해 락의 전역 순서를 도입하고 모든 쓰레드가 동일한 순서를 따르도록 할 수 있습니다.

public class DeadlockSolution {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock 1");
                // Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock 2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 2: Holding lock 1");
                // Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 2: Holding lock 2");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

Thread deadlocks를 방지하는 tips

  • 락 순서 지정: 모든 쓰레드가 락을 획득할 때 동일한 순서를 따르도록하여 원형 대기를 방지합니다.
  • 락 타임아웃 구현: 쓰레드가 특정 시간 내에 락을 획득할 수 없는 경우, 모든 획득한 락을 해제하고 재시도합니다.
  • 중첩 락 회피: 다른 락이 이미 보유된 상태에서 중요한 섹션 내에서 락을 획득하는 것을 피합니다. 중첩 락은 데드락의 위험을 증가시킵니다.

3. Excessive garbage collection

자바의 가비지 컬렉션은 메모리를 관리해주는 주요한 기능입니다. 더 이상 필요하지 않은 객체를 자동으로 정리하여 개발자들의 삶을 훨씬 쉽게 만들어줍니다. 이 자동 가비지 컬렉션은 개발자들에게 편의를 제공하지만, 애플리케이션 성능에 영향을 미칠 수 있는 가비지 컬렉션에 할당된 CPU 사이클의 비용이 발생할 수 있습니다.

전형적인 메모리 부족 오류 외에도, 가끔씩 애플리케이션이 멈추거나 느려지거나 애플리케이션 충돌이 발생할 수 있습니다. 또한 클라우드를 사용하는 경우 가비지 컬렉션 프로세스를 최적화하면 컴퓨팅 비용을 크게 절약할 수 있습니다. Uber라는 회사가 그러한 사례입니다. 이 회사는 고도로 효과적이고 낮은 위험, 대규모, 반자동화된 Go 가비지 컬렉션 튜닝 메커니즘을 사용하여 30개의 중요 서비스 전체에서 70,000개의 코어를 절약할 수 있었습니다.

Excessive garbage collection을 방지하는 tips

  • 로그 분석 및 튜닝: 완전한 가비지 컬렉션 주기나 긴 일시 정지 시간과 같은 패턴을 식별합니다.
  • 다양한 가비지 컬렉션 알고리즘을 평가하고 전환합니다. JDK 알고리즘인 Serial, Parallel, G1, Z GC 등을 고려합니다.
  • 애플리케이션의 작업 부하와 성능 특성을 기반으로 알고리즘을 선택합니다. 더 적합한 GC 알고리즘으로 전환하면 CPU 소비를 줄일 수 있습니다.
  • 과도한 객체 생성을 줄이기 위해 코드를 최적화합니다. HeapHero 또는 YourKit과 같은 메모리 프로파일링 도구를 사용하여 과도한 객체를 생성하는 부분을 식별합니다. 객체 풀링을 구현하여 객체를 재사용하고 할당 오버헤드를 줄입니다.
  • 가비지 컬렉션 중 CPU 소비에 영향을 미치도록 힙 크기를 수정합니다. 컬렉션 주기의 빈도를 줄이기 위해 힙 크기를 증가시키거나, 메모리 풋프린트가 낮은 애플리케이션의 경우 힙 크기를 감소시킵니다.
  • 클라우드에서 실행 중인 경우 작업 부하를 여러 인스턴스에 분산합니다. 리소스를 더 잘 활용하고 개별 인스턴스에 가중 부하를 줄이기 위해 컨테이너 인스턴스나 EC2 인스턴스의 수를 늘릴 수 있습니다.

4. Bloated Libraries and Dependencies

Maven과 Gradle과 같은 빌드 도구는 자바 프로젝트에서 종속성을 관리하는 방식을 혁신적으로 바꿔 놓았습니다. 이러한 도구들은 외부 라이브러리를 포함하는 간소화된 방법을 제공하고 프로젝트 구성 프로세스를 단순화합니다. 그러나 이러한 편의성에는 Bloated 된 라이브러리와 종속성의 위험이 따릅니다.

실제로 2021년에 발표된 전체 연구에서는 애플리케이션의 컴파일된 코드에 포함된 종속성 중에 실제로 애플리케이션을 빌드하고 실행하는 데 필요하지 않은 것들이 있음을 밝혔습니다.

소프트웨어 프로젝트는 버그 수정, 새로운 기능 및 새로운 종속성 등의 결과로 빠르게 성장하는 경향이 있습니다. 때로는 프로젝트가 비례치 않게 커져 개발자로서 효과적으로 유지하기 어려울 수 있습니다. 이로 인해 보안 취약점과 추가적인 성능 오버헤드도 발생할 수 있습니다.

이러한 상황에 처한 경우, 사용되지 않는 종속성 및 라이브러리를 제거하는 방법을 찾는 것이 좋습니다.

자바 생태계 내에서는 종속성을 관리하기 위한 여러 도구들이 있습니다. 가장 일반적인 도구 중 몇 가지는 Maven 종속성 플러그인과 Gradle 종속성 분석 플러그인 등이 있습니다. 이 도구들은 사용되지 않는 종속성과 사용된 전이적 종속성(직접 선언하려는 것) 및 잘못된 구성(API vs implementation vs compileOnly 등)을 감지하는 데 꽤 효과적입니다.

또한 Sonarqube와 JArchitect와 같은 다른 도구들도 활용할 수 있습니다. Intellij와 같은 일부 최신 IDE도 꽤 괜찮은 종속성 분석 기능을 제공합니다.

Bloated Libraries and Dependencies를 방지하는 tips

  • 종속성 검사: 사용되지 않거나 오래된 라이브러리를 식별하기 위해 정기적인 검사를 수행하십시오. Maven Dependency Plugin이나 Gradle의 dependencyInsight와 같은 도구를 사용하여 종속성 분석을 지원할 수 있습니다.

  • 버전 관리: 종속성을 최신 상태로 유지하십시오. 버전 관리 시스템을 활용하여 종속성의 변경 사항을 추적하고 업데이트를 체계적으로 관리합니다.

  • 종속성 범위: 종속성 범위(compile, runtime, test 등)를 효과적으로 활용하십시오. 최종 아티팩트의 크기를 줄이기 위해 컴파일 범위에 있는 종속성의 수를 최소화합니다.

5. Inefficient Code

비효율적인 코드

어떤 개발자도 의도적으로 비효율적이거나 최적화되지 않은 코드를 작성하려 하지 않습니다. 그러나 최선의 의도에도 불구하고, 다양한 이유로 인해 비효율적인 코드가 제품에 반영될 수 있습니다. 이는 프로젝트의 시간 제약, 기술의 제한된 이해, 또는 개발자들이 최적화보다는 기능을 우선시해야 하는 변화하는 요구사항에서 비롯될 수 있습니다.

비효율적인 예시

String result = "";
for (int i = 0; i < 1000; i++) {
   result += "example";
}

개선된 예시

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
   sb.append("example");
}
String result = sb.toString();

비효율적인 코드는 증가된 메모리 사용, 더 긴 응답 시간, 전체 시스템 효율성 감소로 이어질 수 있습니다. 궁극적으로 비효율적인 코드는 사용자 경험을 저하시키고 운영 비용을 증가시키며 증가된 부하를 처리하기 위한 애플리케이션 확장을 제한할 수 있습니다.

비효율적인 코드를 제거하는 것은 비효율성을 나타내는 패턴을 식별하는 것으로 시작합니다. 항상 주의를 기울여야 할 패턴 중 일부는 적절한 종료 조건이 없는 중첩된 루프, 불필요한 객체 생성과 인스턴스화, 과도한 동기화, 비효율적인 데이터베이스 쿼리 등이 있습니다.

Inefficient Code를 방지하는 tips

  • 코드를 리팩토링하고 모듈화하여 불필요한 중복을 피합니다.
  • I/O 작업 최적화: 주요 쓰레드를 차단하지 않도록 비동기 또는 병렬 I/O 작업을 사용합니다.
  • 특히 코드의 성능이 중요한 섹션에서 불필요한 객체 생성을 피합니다. 가능한 경우 객체를 재사용하고 객체 풀링 기법을 사용하는 것을 고려합니다.
  • 문자열을 빌드할 때 + 연산자로 문자열을 연결하는 대신 StringBuilder를 사용합니다. 이렇게 하면 불필요한 객체 생성을 피할 수 있습니다.
  • 효율적인 알고리즘과 데이터 구조를 사용합니다. 작업에 적합한 알고리즘과 데이터 구조를 선택하여 최적의 성능을 보장합니다.

6. Concurrency problems

쓰레드 동시성 문제는 여러 쓰레드가 공유 리소스에 동시에 접근할 때 발생하며, 종종 예상치 못한 동작으로 이어집니다.

코드 작성에 오랜 시간을 보낸다면, 개발 주기 중에 나타나는 문제로 인한 좌절을 경험했을 것입니다. 이러한 문제를 효과적으로 식별하고 해결하는 것은 실제로 매우 어려운 일입니다.

정확한 현실 성능을 파악하지 못하면, 이러한 문제들은 애플리케이션을 계속해서 괴롭힐 수 있습니다. 특히 복잡한 분산 시스템을 다룰 때 이러한 고통은 더욱 현실적입니다. 적절한 통찰력 없이는 정보화된 설계 결정을 내리거나 코드 변경의 영향을 평가하기가 어려울 수 있습니다.

Concurrency problems를 방지하는 tips

  • 원자 변수 사용: java.util.concurrent.atomic 패키지는 AtomicInteger 및 AtomicLong과 같은 클래스를 제공하여 명시적 동기화 없이도 원자적 작업을 가능하게 합니다.
  • 변경 가능한 객체 공유 피하기: 가능한 경우 클래스를 변경할 수 없도록 설계하여 동기화 필요성을 없애고 쓰레드 안전성을 보장합니다.
  • Lock 경합 최소화: Lock 경합을 최소화하기 위해 미세한 잠금 또는 lock striping과 같은 기술을 사용하여 동일한 Lock에 대한 경쟁을 줄입니다.
  • synchronized 키워드 활용: synchronized 블록이나 메서드를 생성하여 한 번에 하나의 쓰레드만 synchronized 코드 블록에 액세스할 수 있도록 합니다.
  • 쓰레드 안전한 데이터 구조 사용: Java의 java.util.concurrent 패키지는 ConcurrentHashMap, CopyOnWriteArrayList 및 BlockingQueue와 같은 쓰레드 안전한 데이터 구조를 제공하여 추가 동기화 없이도 동시 액세스를 처리할 수 있습니다.
공지사항