성능 팁

이 문서에서는 동시에 구현되는 경우 전체적인 앱 성능을 개선할 수 있는 마이크로 최적화를 주로 다루지만, 이러한 변경으로 인해 성능이 극적으로 향상될 가능성은 거의 없습니다. 올바른 알고리즘 및 데이터 구조를 선택하는 것이 항상 먼저여야 하지만 이는 이 문서에서 다루는 내용은 아닙니다. 이 문서의 팁을 일반적인 코드 효율성을 위해 습관화할 수 있는 일반적인 코딩 방식으로 사용해야 합니다.

효율적인 코드를 작성하기 위한 두 가지 기본 규칙이 있습니다.

  • 할 필요가 없는 것은 하지 않습니다.
  • 가능하다면 메모리를 할당하지 않습니다.

Android 앱을 마이크로 최적화할 때 가장 까다로운 문제 중 하나는 앱이 여러 유형의 하드웨어에서 실행된다는 것입니다. 서로 다른 VM 버전이 서로 다른 속도로 실행되는 서로 다른 프로세서에서 실행됩니다. 일반적으로 'X 기기는 Y 기기보다 빠르거나 느린 F 요소'라고 간단히 말할 수 없으며, 한 기기의 결과를 다른 기기로 확장할 수도 없습니다. 특히 에뮬레이터에서 측정한 결과로는 어떤 기기의 성능에 대해 거의 알 수가 없습니다. JIT가 있는 기기와 없는 기기 간에도 큰 차이가 있습니다. JIT가 있는 기기를 위한 최적의 코드와 JIT가 없는 기기를 위한 최적의 코드가 항상 같지는 않습니다.

앱이 다양한 기기에서 제대로 작동하려면 코드가 모든 레벨에서 효율적이어야 하며 성능을 적극적으로 최적화해야 합니다.

불필요한 개체 생성 방지

개체 생성에는 항상 비용이 듭니다. 임시 개체에 대한 스레드별 할당 풀이 있는 세대 간 가비지 컬렉터는 할당 비용을 낮출 수 있지만, 메모리를 할당하는 것은 메모리를 할당하지 않는 것보다 항상 비용이 더 듭니다.

앱에 더 많은 개체를 할당하면 주기적인 가비지 컬렉션이 강제로 실행되어 사용자 환경에서 일시적 중단이 거의 발생하지 않습니다. Android 2.3에 도입된 동시 가비지 컬렉터는 도움이 되지만, 불필요한 작업은 항상 피해야 합니다.

따라서 필요하지 않은 개체 인스턴스는 생성하지 않아야 합니다. 다음은 도움이 될 수 있는 몇 가지 예입니다.

  • 문자열을 반환하는 메서드가 있고 그 결과가 어떤 식으로든 항상 StringBuffer에 추가되는 경우, 수명이 짧은 임시 개체를 만드는 대신 함수가 직접 추가를 수행하도록 서명과 구현을 변경합니다.
  • 입력 데이터 집합에서 문자열을 추출할 때 복사본을 만드는 대신 원본 데이터의 하위 문자열을 반환하도록 시도합니다. 새 String 개체를 만들게 되지만, char[]을 데이터와 공유하게 됩니다. (원래 입력의 일부만 사용하는 경우, 이 방식을 사용하면 메모리에 모든 것을 보관하게 됩니다.)

좀 더 과감한 아이디어는, 다차원 배열을 병렬의 단일 1차원 배열로 분할하는 것입니다.

  • int의 배열이 Integer 개체의 배열보다 훨씬 낫지만, 이는 또한 int의 두 병렬 배열이 (int,int) 개체의 배열보다 훨씬 더 효율적이라는 사실로 일반화됩니다. 기본 유형의 조합에도 동일한 이론이 적용됩니다.
  • (Foo,Bar) 개체의 튜플을 저장하는 컨테이너를 구현해야 하는 경우, 두 개의 병렬 Foo[]Bar[] 배열이 맞춤형 (Foo,Bar) 개체의 단일 배열보다 일반적으로 훨씬 낫다는 사실을 기억하세요. (물론, 다른 코드가 액세스할 API를 설계하는 경우는 예외입니다. 이 경우에는 일반적으로 양호한 API 설계를 얻기 위해 약간의 속도 저하를 감수하는 것이 좋습니다. 그러나 자체 내부 코드에서는 최대한 효율성을 얻을 수 있도록 시도해야 합니다.)

일반적으로 말하면, 가능한 한 단기 임시 개체를 만들지 않는 것이 좋습니다. 생성되는 개체가 적을수록 사용자 환경에 직접적인 영향을 미치는 가비지 컬렉션의 빈도가 낮아집니다.

virtual보다는 static 사용

개체의 필드에 액세스할 필요가 없는 경우 메서드를 static으로 만드는 것이 좋습니다. 그러면 호출이 약 15%~20% 빨라집니다. 이 방식이 좋은 또 다른 이유는, 메서드 호출이 개체 상태를 변경할 수 없음을 메서드 서명에서 알 수 있기 때문입니다.

상수에 static final 사용

클래스 상단에서 다음을 선언하는 것을 고려해 보세요.

    static int intVal = 42;
    static String strVal = "Hello, world!";
    

컴파일러는 클래스가 처음 사용될 때 실행되는 <clinit>라는 클래스 이니셜라이저 메서드를 생성합니다. 이 메서드는 값 42를 intVal에 저장하고, strVal에 대한 클래스 파일 문자열 상수 테이블에서 참조를 추출합니다. 나중에 이러한 값을 참조할 때 필드 조회를 통해 액세스됩니다.

'final' 키워드로 상황을 개선할 수 있습니다.

    static final int intVal = 42;
    static final String strVal = "Hello, world!";
    

상수가 dex 파일에서 static 필드 이니셜라이저로 들어가므로 클래스에는 더 이상 <clinit> 메서드가 필요하지 않습니다. intVal을 참조하는 코드는 정수 값 42를 직접 사용하며, strVal에 액세스할 때 필드 조회 대신 비교적 저렴한 '문자열 상수' 명령이 사용됩니다.

참고: 이 최적화는 기본 유형 및 String 상수에만 적용되고, 임의의 참조 유형에는 적용되지 않습니다. 가능한 경우 항상 static final 상수를 선언하는 것이 좋습니다.

향상된 for 루프 구문 사용

Iterable 인터페이스를 구현하는 컬렉션 및 배열에는 향상된 for 루프('for-each' 루프라고도 함)를 사용할 수 있습니다. 컬렉션과 함께 hasNext()next()에 대한 인터페이스를 호출하기 위해 반복자가 할당됩니다. ArrayList를 사용하면 수동 작성되어 계산된 루프가 약 3배 더 빠르지만(JIT 사용 여부와 관계없음), 다른 컬렉션의 경우 향상된 for 루프 구문은 명시적 반복자를 사용하는 경우와 정확히 동일합니다.

배열을 반복하기 위한 여러 가지 다른 방법이 있습니다.

    static class Foo {
        int splat;
    }

    Foo[] array = ...

    public void zero() {
        int sum = 0;
        for (int i = 0; i < array.length; ++i) {
            sum += array[i].splat;
        }
    }

    public void one() {
        int sum = 0;
        Foo[] localArray = array;
        int len = localArray.length;

        for (int i = 0; i < len; ++i) {
            sum += localArray[i].splat;
        }
    }

    public void two() {
        int sum = 0;
        for (Foo a : array) {
            sum += a.splat;
        }
    }
    

JIT는 루프를 반복할 때마다 배열 길이를 한 번 가져오는 비용을 최적화할 수 없으므로 zero()가 가장 느립니다.

좀 더 속도가 빠른 one()은 조회를 피하면서 모든 것을 로컬 변수로 가져옵니다. 성능 이점을 가져오는 것은 배열 길이뿐입니다.

two()는 JIT가 없는 기기에서 가장 빠르고 JIT가 있는 기기에서는 one()과 차이가 없으며, 자바 프로그래밍 언어 버전 1.5에 도입된 향상된 for 루프 구문을 사용합니다.

따라서 기본적으로 향상된 for 루프를 사용해야 하지만, 성능이 중요한 ArrayList 반복에는 수동 작성되어 계산된 루프를 고려해야 합니다.

팁: 조슈아 블로치의 이펙티브 자바, 항목 46도 참조하세요.

비공개 내부 클래스의 비공개 액세스 대신 패키지 고려

다음 클래스 정의를 고려하세요.

    public class Foo {
        private class Inner {
            void stuff() {
                Foo.this.doStuff(Foo.this.mValue);
            }
        }

        private int mValue;

        public void run() {
            Inner in = new Inner();
            mValue = 27;
            in.stuff();
        }

        private void doStuff(int value) {
            System.out.println("Value is " + value);
        }
    }

여기서 중요한 것은 외부 클래스의 비공개 메서드 및 비공개 인스턴스 필드에 직접 액세스하는 비공개 내부 클래스(Foo$Inner)를 정의한다는 것입니다. 이는 합법적이며, 코드는 예상대로 'Value is 27'을 인쇄합니다.

자바 언어는 내부 클래스가 외부 클래스의 비공개 멤버에 액세스하는 것을 허용하더라도 FooFoo$Inner는 다른 클래스이므로 VM이 Foo$Inner에서 Foo의 비공개 멤버에 직접 액세스하는 것을 불법으로 간주한다는 것이 문제입니다. 이 격차를 메우기 위해 컴파일러는 몇 가지 합성 메서드를 생성합니다.

    /*package*/ static int Foo.access$100(Foo foo) {
        return foo.mValue;
    }
    /*package*/ static void Foo.access$200(Foo foo, int value) {
        foo.doStuff(value);
    }

내부 클래스 코드는 mValue 필드에 액세스해야 하거나 외부 클래스에서 doStuff() 메서드를 호출해야 할 때마다 이러한 static 메서드를 호출합니다. 즉, 위의 코드는 접근자 메서드를 통해 멤버 필드에 액세스하는 경우로 귀결됩니다. 앞에서 접근자가 직접 필드 액세스보다 얼마나 더 느린지에 대해 이야기했으므로, 이는 '보이지 않는' 성능 히트로 귀결되는 특정 언어 관용구의 예입니다.

성능 핫스팟에서 이러한 코드를 사용하는 경우 내부 클래스가 액세스하는 필드 및 메서드가 비공개 액세스가 아닌 패키지 액세스를 이용하도록 선언함으로써 오버헤드를 피할 수 있습니다. 아쉽게도 이는 동일한 패키지의 다른 클래스가 필드에 직접 액세스할 수 있음을 의미하므로, 공개 API에서는 이 방법을 사용할 수 없습니다.

부동 소수점 사용 피하기

일반적으로 부동 소수점은 Android 구동 기기에서 정수보다 약 2배 느립니다.

속도 측면에서 볼 때, 현대적인 하드웨어에서는 floatdouble 간에 차이가 없습니다. 공간 측면에서는 double이 2배 더 큽니다. 데스크톱 컴퓨터에서 공간이 문제가 되지 않는다고 가정하면, float보다 double을 사용하는 것이 좋습니다.

또한 정수의 경우에도 일부 프로세서에는 하드웨어 곱하기는 있지만 하드웨어 나누기는 없습니다. 이러한 경우 정수 나누기 및 모듈러스 연산이 소프트웨어에서 수행됩니다. 이는 해시 테이블을 설계하거나 대량의 계산을 실행하는 경우 고려해야 할 사항입니다.

라이브러리를 알고 사용하기

코드를 직접 작성하는 것보다 라이브러리 코드를 사용하는 것이 좋은 일반적인 이유가 있지만 무엇보다 시스템은 라이브러리 메서드의 호출을 직접 코딩된 어셈블러로 자유롭게 대체할 수 있다는 점에 유의해야 합니다. 이것이 JIT가 동등한 자바에 대해 생성할 수 있는 최상의 코드보다 더 나을 수 있기 때문입니다. 일반적인 예로는 String.indexOf() 및 관련 API가 있는데, Dalvik은 이를 인라인 내장 함수로 교체합니다. 마찬가지로 System.arraycopy() 메서드는 Nexus One에서 JIT를 사용하여 직접 코딩한 루프보다 약 9배 빠릅니다.

팁: 조슈아 블로치의 이펙티브 자바, 항목 47도 참조하세요.

네이티브 메서드를 신중하게 사용

Android NDK를 사용하여 네이티브 코드로 앱을 개발하는 것이 자바 언어로 프로그래밍하는 것보다 반드시 효율적이지는 않습니다. 우선 자바 네이티브 전환과 관련된 비용이 있으며, JIT는 이러한 경계를 넘어 최적화를 수행할 수 없습니다. 네이티브 리소스(네이티브 힙, 파일 설명자 등에 대한 메모리)를 할당하는 경우, 이러한 리소스를 적시에 수집하도록 조정하는 것이 훨씬 더 어려울 수 있습니다. 또한 실행하고자 하는 각 아키텍처에 대해 코드를 컴파일해야 합니다(JIT로 이에 의존하기보다). 동일한 아키텍처로 간주하는 여러 버전을 컴파일해야 할 수도 있습니다. G1에서 ARM 프로세서용으로 컴파일된 네이티브 코드는 Nexus One에서 ARM을 최대한 활용할 수 없으며, Nexus One에서 ARM용으로 컴파일된 코드는 G1의 ARM에서 실행되지 않습니다.

네이티브 코드는 자바 언어로 작성된 Android 앱의 '속도 향상'이 아니라 Android로 이식하려는 기존의 네이티브 코드베이스를 가지고 있을 때 주로 유용합니다.

네이티브 코드를 사용해야 하는 경우 JNI 팁을 읽어야 합니다.

팁: 조슈아 블로치의 이펙티브 자바, 항목 54도 참조하세요.

성능 미스테리

JIT가 없는 기기에서는 인터페이스보다는 정확한 유형의 변수를 통해 메서드를 호출하는 것이 좀 더 효율적입니다. (따라서 예를 들어 둘 모두 맵은 HashMap이었어도 Map map보다는 HashMap map에서 메서드를 호출하는 것이 더 저렴했습니다.) 실제로는 2배 느린 것이 아니라 6% 정도 느렸습니다. 또한 JIT를 사용하면 둘을 효과적으로 구분할 수 없습니다.

JIT가 없는 기기에서 필드 액세스를 캐시하면 필드에 반복적으로 액세스하는 것보다 약 20% 더 빠릅니다. JIT를 사용하면 필드 액세스 비용이 로컬 액세스 비용과 거의 같기 때문에, 코드가 더 읽기 쉽다고 느껴지지 않는 한 이는 가치 있는 최적화가 아닙니다. (이는 final, static 및 static final 필드에서도 마찬가지입니다.)

항상 측정

최적화를 시작하기 전에 해결해야 할 문제가 있는지 확인해야 하며, 기존 성능을 정확히 측정할 수 있는지도 확인해야 합니다. 그렇게 하지 않으면 시도하는 대안의 이점을 측정할 수 없습니다.

Traceview가 프로파일링에 유용할 수도 있지만, 현재 JIT를 사용 중지한다는 점에 유의해야 합니다. 이 경우 JIT가 다시 가져올 수 있는 코드에 시간 속성을 잘못 할당할 수 있습니다. Traceview 없이 실행할 때 결과 코드가 실제로 더 빨리 실행되도록 하려면 Traceview 데이터에서 제안하는 변경 사항을 수행한 이후가 특히 중요합니다.

앱의 프로파일링과 디버깅에 관해 도움이 필요하면 다음 문서를 참조하세요.