Programing
1장 Java Strings - Java Performance and Scalability
원문 : http://www.mobilejava.co.kr/bbs/temp/lecture/j2me/perf1.html
※이 문서는 『Java Performance and Scalability Volume1』(by Dov Bulka)라는 책의 1장:Java Strings 를 읽고 나름대로 정리한 것입니다.
자바에서 String과 StringBuffer 객체는 참으로 자주 사용된다. 웹환경이라면 더욱 말할 것도없다. 그러므로 좀 더 성능이 좋은 웹 응용프로그램을 만들기 위해서는 이 두가지 객체의 내면에 숨겨져 있는 성능 문제를 이해하는 것이 중요하다.
다음의 코드가 있을 경우 String p = a + b ; //a,b are String objects 자바 컴파일러는 다음과 같이 컴파일 한다. String p = (new StringBuffer()).append(a).append(b).toSting(); 여기서 우리는 벌써 두개의 새로운 객체를 발견할 수 있다. - StringBuffer - toString()에 의한 String 그렇지만 이게 다는 아니다. 각각의 String과 StringBuffer는 private으로 문자 배열을 가지고있다. 그러나 성능을 위해서 이 문자 배열은 공유되므로 결과적으로 새로 생성된 객체는 ‘3’개가 된다. 자바에서 객체를 새로 생성하는 것은 객체 생성 자체가 비싼 작업이라는 점, 그리고 추후 가비지컬렉터가 더 많은 일을 해야 한다는 점에서 성능에 별로 좋지 않은 영향을 미친다. 단순한 문자의 추가가 비효율적인 이유는 String 객체는 불변(immutable)이기 때문이다. 그러므로 “a”라는 문자열을 수정해서 “ab”라는 문자열로 바꿀 수는 없고 “ab”라는 새로운 객체를 생성해서 “a”를 치환해야만 한다. 이와는 달리 StringBuffer 객체는 문자열을 변경할 수 있다. 문자열을 계속적으로 추가해야만 한다면 StringBuffer를 사용하는 것이 훨씬 효율적이다. 다음의 예제를 보자.
두 결과를 살펴보면 StringBuffer가 확연하게 빠르다는 것을 알 수 있다. s.append(“a”)는StringBuffer의 내부버퍼에 “a”를 추가할뿐 새로운 객체를 생성하지 않기 때문이다. (새로운 객체를 생성할 때는 단지 버퍼가 다 차서 확장해야 할 때 뿐이다.) String의 경우 s += “a” 는 다음과 같이 변환된다. s = (new StringBuffer()).append(s).append(“a”).toString(); 이는 몇개의 새로운 객체들을 매번 루프가 돌때마다 생성해야 한다는 것을 의미하므로 엄청난 성능의저하를 가져온다.
다른 경우를 살펴보자. for(int i=0 ; i<100000; i++){ String s = “Hello ” + “John ” + “Parker”; } 위의 경우도 역시 많은 문자열끼리의 연결이 생길 것이라고 생각할 수 있지만 실제로는 그렇지 않다.컴파일러는 __temp = “Hello John Parker” 라는 String 객체를 생성한 후 아래와 같이 변환해버린다. for(int i=0 ; i<100000; i++){ String s = __temp; } 결국 String s = “Hello ” + “John ” + “Parker”; 나 String s = “Hello John Parker”; 나 private static final String s1 = “Hello ”; private static final String s2 = “John ”; private static final String s3 = “Parker”; String s = s1 + s2 + s3 ; 는 모두 같은 성능을 가지게 된다. 조심해야 할 것은 for(int i=0 ; i<100000; i++){ String s = “Hello ” + “John ” + “Parker”; } 는 1ms 도 안걸리는 반면 for(int i=0 ; i<100000; i++){ String s = “Hello ”; s += “John “; s += “Parker”; } 는 1,370ms가 걸린다는 점이다. 그러므로 String 객체에 대해서는 새로운 객체가 생성되는 경우와생성되지 않는 두가지 상황을 확실히 구별할 필요가 있다.
자바 프로그램은 ‘객체의 개수’에 따라 성능이 많이 좌우된다. 앞절에서 본 String 객체에 대한예제는 프로그램에서 객체의 개수를 주시하지 않으면 성능에 어떤 영향을 미치는가를 말해주고 있다. String 객체는 불변이기 때문에 어떠한 방법으로도 이 객체를 변경하고자 한다면 새로운 객체를 생성할 수 밖에 없게 된다. 문자 하나만 덧붙이고자 해도 새로운 String 객체를 생성해야만 하는 것이다.
자바는 자동으로 메모리 관리를 해줌으로써 프로그래머가 좀더 쉽게 프로그래밍을 할 수 있도록 하였다. 그러나 프로그래머가 직접 메모리를 관리하지 않음으로써 성능문제에 있어서는 약간의 희생이따르지 않을 수 없게 되었다. 그러므로 프로그래머는 이러한 특성을 잘 인식해야 한다. 예제를 보자.당신이 파일에 String 객체를 쓰고자 한다면 다음과 같이 작성할 것이다. printWriter.print(x+y); 혹은 printWriter.print(x); printWriter.print(x); 이렇게 할지도 모른다. 직관적으로 후자가 덜 효율적이라고 생각할지 모른다. 그러나 실제로는 그렇지 않다.
(1)의 경우 “one”+i 라는 작업을 하기위해 매번 String, StringBuffer, char[] 객체를 생성해야만 한다. (2)의 경우 print라는 작업을 두번이나 수행했지만 불필요한 객체들을 생성하지 않았으므로 성능면에서 훨씬 유리하게 된다. 이러한 점들로 미루어 불필요하게 많은 객체들을 생성하는것은 성능면에 있어서 매우 안좋다는 것을 알 수 있다. 우리는 자바 프로그래머로써 자신의 코드에서프로그램이 다루는 객체의 개수를 주의깊게 살펴봐야 한다. 다른 예제를 보자. (예제 3) s = a; s += b; s += c; 이러한 코드는 객체 생성에 있어서 매우 비경제적이다. s += b 는 s = s + b 와 같고 이는 다음과 같이 변환된다. s = (new StringBuffer()).append(s).append(b).toString(); 이미 알다시피 이 코드는 StringBuffer, String, char[] 객체를 생성시킨다. 그러므로 예제3은 추가적으로 6개의 객체를 더 생성시키며, 이렇게 생성된 객체들은 아무 쓸모 없이garbage collector에 의해 처리되기만을 기다려야 한다. 이러한 문제의 대안으로는 다음과 같이사용하는 것이다. (예제 4) s = a + b + c; 이는 s = (new StringBuffer()).append(a).append(b).append(c).toString(); 으로 변환되므로 단지 3개의 객체만이 생성될 것이다. 100,000의 루프를 돌려본 결과 예제3은 1,550ms가 걸리며 예제 4는 800ms로 약 1/2 밖에 걸리지 않는다.
또다른 예제를 보자. (예제 4) ucA = a.toUpperCase(); ucB = b.toUpperCase(); boolean bool = ucA.equals(ucB); 자바는 C나 C++과 달리 String을 변화시킬 수 없으므로 대문자로 변환하기 위해서는 반드시 새로운객체를 생성해야만 한다. ucA나 ucB 객체를 불필요하게 생성시키지 않기 위해서는 boolean bool = a.equalIgnoreCase(b); 를 사용하면 된다. 두가지를 백만번 정도 반복시켜보면 후자는 전자에 비해 1/20 의 시간밖에 걸리지 않는다.
자바에서 String의 값을 비교하는데는 대소문자를 구별하는 equals() 메소드와 대소문자를 구별하지 않는 equalsIgnoreCase()라는 메소드가 있다. 예제 1,2,3은 서로 다른 형식으로 문자열을 비교해봄으로써 자바에서 문자열을 비교할 때의 성능에 대해서 얘기하고자 한다. 예제1은 완전히 동일한 두개의 문자열을 비교하고, 예제2는 문자열 길이는 같으나 마지막 글자만 대소문자가 다른 문자열을 비교하며 예제3은 길이가 서로 다른 문자열을 비교해본다. (예제 1) 수행시간 : 140 ms String s = “H”+”elloWorld”; String p = “HelloWorl”+”d”; for(int i=0 ; i<1000000; i++){ s.equals(p); } (예제 2) 수행시간 : 1,700 ms String s = “HelloWorld”; String p = “HelloWorlD”; for(int i=0 ; i<1000000; i++){ s.equals(p); } (예제 3) 수행시간 : 650 ms String s = “HelloWorld”; String p = “HelloWorldl”; for(int i=0 ; i<1000000; i++){ s.equals(p); } 예제1의 경우 두 문자열 s,p는 “HelloWorld”라는 문자열 상수를 담고 있다. 이런 경우 자바에서는 하나의 “HelloWorld”라는 객체만 생성하고 두 개의 변수가 하나의 같은 주소를 가리키게끔 한다. String 에서는 equals() 메소드를 수행하기 전에 주소값을 먼저 체크하므로, 이 경우 문자열값을 비교할 필요도 없이 두개의 주소값이 일치하므로 실행이 상대적으로 빠를 수 밖에 없다. 예제2는 마지막 문자만이 대소문자가 다르므로 처음부터 끝가지 다 비교해야만 한다. 그래서 가장오래 걸리게 되었다. 예제3은 두개의 문자열이 길이가 서로 다르므로 예제2보다 훨씬 빠르다. 1의 경우처럼 equals()메소드는 문자열의 값들을 비교하기 전에 길이를 먼저 비교하기 때문이다.
대소문자를 무시하고 비교하면 어떻게 될까? 한번의 변환작업을 더 거쳐야 하므로 더 느리다고 충분히 예상할 수 있다. 예제4는 길이는 같지만 대소문자가 다른 문자열을 비교하고 예제5는 길이가 다른 문자열을 비교한다. (예제 4) 수행시간 : 7,000 ms String s = “HelloWorld”; String p = “HelloWorlD”; for(int i=0 ; i<1000000; i++){ s.equalsIgnoreCase(p); } (예제 5) 수행시간 : 220 ms String s = “HelloWorld”; String p = “HelloWorldl”; for(int i=0 ; i<1000000; i++){ s.equalsIgnoreCase(p); }
예제4는 5가지 중에서 최악의 결과를 나타낸다. 이는 마지막 문자 하나만 다르므로 전체 문자를 변환한 후에 처음부터 끝까지 다 비교해야 하기 때문이다. 예제5는 대소문자를 구별하지 않고 비교를 해야 하지만 길이가 다르므로 비교를 하기도 전에 실행이완료되어 빠른 속도를 내고 있다. 한가지 특이한 점은 비슷한 경우인 예제3과 예제5의 수행속도가 확연히 차이가 난다는 점이다. 이는내부적으로 equals()보다 equalsIgnoreCase()가 문자열의 길이를 먼저 체크하기 때문이지만 JDK버전에 따라 다르므로 이를 고려해서 실행해봐야 한다.
String 클래스에서 getBytes() 메소드는 계산량이 가장 많은 메소드이다. 코드에서 단 한번만이라도 getBytes()를 호출해보면 그것이 성능에 많은 영향을 미친다는 것을 알 수 있다. 이 메소드는 char 배열을 byte 배열로 바꿔주는 메소드인데 각각의 유니코드 캐릭터는 하나나 둘 또는 심지어 3개의 바이트로 변환이 되며 이를 위한 판단 작업도 뒤따라야 하므로 비싼 작업일 수 밖에 없다. 그러나 ASCII 문자의 경우는 문제가 간단해진다. 각각의 ASCII 문자는 2byte 유니코드에서 한 byte를잘라버리고 남은 1byte만을 변환하면 된다. 다양한 문자 인코딩 형식에 맞춰 변환시키기 위해서는좀 더 복잡하고 계산량이 많은 작업을 할 수 밖에 없다. 그러나 ASCII는 인코딩 방식에 상관없이1byte만 변환하면 되므로 그만큼 계산량이 많이 줄어들게 된다.
예제1은 getBytes()로 실행하는 예제이고 예제2는 ASCII를 변환시키는 예제이다.
(예제1) 실행시간 : 17,000 ms String s = “HelloWorld”; for(//1000000번){ byte[] b = s.getBytes(); } (예제2) 실행시간 : 2,500 ms public static byte[] asciiGetBytes(String buf){ int size = buf.length(); int i ; byte[] bytebuf = new byte[size] for(i=0; i< size; i++){ bytebuf[i] = (byte)buf.charAt(i); } return bytebuf; } //메인 함수 String s = “HelloWorld”; for(//1000000번){ byte[] b = asciiGetBytes(s); } 같은 이유로 byte에서 문자열로 변환시키는 것도 역시 계산량이 많은 작업이며 ASCII로 변환하는작업이 훨씬 더 간단하다.
StringTokenizer 클래스는 자바에서 있어서 프로그래머가 문자열을 파싱할때 간편하게 사용할 수있는 강력하면서도 유연한 클래스이다. 그러나 강력하고 유연하다는 말은 고성능을 뜻하지는 않는다.고성능의 코드는 주로 특수화된 코드이므로 단순화된 가정에 초점을 맞춰 작성된다. 일반적인 목적의코드를 작성하기 위해서는 많은 제반 사항들을 고려해야 하므로 가정을 너무 많이 단순화시킬 수가없다. JDK는 많은 자바 응용프로그램을 만족시켜야 하므로 일반적인 형태로 작성이 된다. 그러나 여러분의 코드는 꼭 그렇지만은 않다. 그러므로 JDK 클래스에서 제공하는 일반적인 형태의 클래스들이언제나 필요한 것은 아니고 현재 요구에 맞는 특화된 방법들이 필요할 수도 있게 된다. 예를 들어 당신이 작성한 코드를 살펴보다가 당신은 StringTokenizer가 CPU를 많이 잡아먹고있다는 것을 발견하였다. 이러한 경우 StringTokenizer를 계속 사용하기 보다는 indexOf()와substring()을 사용한 StringBuffer로 바꿔보는 것도 한번 생각해 볼만하다. 예제1은 StringTokenizer를 사용하는 경우이다. (예제1) String sub = null; StringTokenizer st = new StringTokenizer(s,”,”); try{ while((sub = (String)st.nextToken()) != null){ //...어쩌구 저쩌구 } }catch(NoSuchElementException e){}
예제2는 특별한 경우를 가정하여 직접 만든 것이다. (예제2) String sub = null; int i = 0; int j = s.indexOf(“,”); while (j >= 0){ sub = s.substring(i,j); //... 어쩌구 저쩌구 i = j+1; //skip over comma we already found j = s.indexOf(“,”,i); //find next comma } sub = s.substring(i) //Don’t forget the last substring
“a,b,c,def,g”를 가지고 예제1을 십만번 정도 반복해보면 3,100ms 가 소요된다. 그러나 예제2는 900ms가 소요된다. 예제2가 더 빠른 이유는 단순화된 가정을 바탕으로 만들어서 별로 강력하지 않기 때문이다. 우리가만든 것은 구분자가 문자 1개일 경우만 가능하며 “a,b,,,c,,d” 처럼 구분자가 연속해서 들어오는경우는 처리할 수 없다. 물론 StringTokenizer는 이러한 경우를 다 처리할 수 있다. 그리고 내부적으로 다음번 토큰을 구하기 우리가 작성한 것은 단 한번 indexOf()를 호출한다. 그러나 실제StringTokenizer는 그것이 구분자에 속하는지를 확인하기 위해 각 문자마다 indexOf()를 호출한다. 강력한 만큼 좀 더 느릴 수 밖에 없다. StringTokenizer 자체는 실제로는 매우 빠르며 잘 작동한다. 그러나 성능이라는 관점에서 본다면이 클래스는 성능이 저하될 만큼 너무 강력하다.
String 클래스에는 어떤 문자열이 주어진 문자열로 시작하는지를 묻는 startsWith()라는 메소드가 있다. 근데 종종 저자는 사람들이 단지 어떤 문자열이 주어진 하나의 문자로 시작하는지 묻는 일에 다음과 같은 코드를 쓰는 것을 많이 본다. if (s.startsWith(“a”)) {...} 물론 이 코드는 잘 작동한다. 그러나 성능관점에서 본다면 이러한 코드는 String API를 잘못 사용하고 있는 것이다. startsWith()라는 메소드는 단지 처음 한글자만을 체크하기 위한 것이 아니다.내부구현에는 주어진 문자열과 다른 문자열을 비교하기 위한 준비작업으로 적지않은 계산을 한다. 그러므로 단지 한글자만을 비교한다면 이러한 준비작업은 시간낭비로 만들어 버리고 만다. 이 경우charAt() 메소드를 쓰는 것이 낫다. if (‘a’ == s.charAt(0)){...} 두가지 경우를 각각 루프를 돌려보면 전자의 경우가 약 2배정도 느리다는 것을 알 수 있다.
StringBuffer 객체를 정의할 때 StringBuffer sb = new StringBuffer(); 이렇게 정의를 한다면 디폴트로 ‘16’개의 문자 배열을 가진 객체를 생성해준다. 이 객체에 문자열을계속 추가를 한다면 배열의 크기가 16을 넘을 수가 있다. 이때 StringBuffer는 기존보다 2배의 크기를 가지는 새로운 배열을 만들고 예전의 것을 새 배열에 복사한 후 이전 배열은 버려 버린다. 자주언급했다시피 불필요한 객체 생성은 성능에 좋지 않은 영향을 미치므로 StringBuffer 객체를 생성할 때 충분한 초기 버퍼값을 예약함으로써 객체가 불필요하게 확장되는 것을 막을 수 있다. StringBuffer sb = new StringBuffer(1024); 위와 같이 작성한다면 초기 용량이 1,024인 객체를 생성한 것이다. 초기 사이즈를 불필요하게 너무크게 잡으면 메모리가 낭비되며 내부적으로 문자배열을 초기하는데 시간을 낭비하게 되므로 주의해야한다. 이러한 최적화는 실제로 그 효과를 별로 느낄 수 없을 만큼 사소한 것이지만, 사소한 것이라도 모이면 큰 영향을 미칠 수도 있다.
▒ 대부분의 자바 컴파일러는 컴파일시에 (1)을 (2)로 만든다. String s = “a” + “b”; (1) String s = “ab”; (2) 위와 같은 경우에는 runtime시 concatenation은 일어나지 않는다. ▒ (3)을 (4)로 대치하는 것은 전혀 최적화가 아니다. String s = x + y (3) String s = (new StringBuffer()).append(x).append(y).toString(); (4) 이는 컴파일러가 자동으로 하는 것이며 결과적으로 생기는 s의 길이를 정확히 알 때만 좀더 빠르게만들 수 있다. String s = (new StringBuffer(32)).append(x).append(y).toString(); ▒ (5)가 (6)보다 빠르다. String s= “a” + “b” + “c” (5) String s= “a”; (6) s += “b”; s += “c”; ▒ 두 문자열을 비교할 때 길이가 서로 다르다면 비교하는 속도는 훨씬 빠르다. ▒ 두개의 문자열 객체가 길이가 같다면 equalsIgnoreCase()는 equals()보다 비싼 작업이다. ▒ String 클래스의 getBytes() 메소드는 굉장히 비싼 작업이다. ▒ 일반적이며 강력한 클래스들은 특수화된 클래스보다 성능면에서는 떨어진다. 그러므로 성능이 아주중요한 곳에서는 문자열 파서를 직접 작성해서 StringTokenizer보다 성능을 높일 수 있다. ▒ (7)은 (8)보다 빠르다. (‘a’ == s.charAt(0)) (7) s.startsWith(“a”); (8) ▒ StringBuffer의 defualt capacity는 16이다. 들어가는 문자열이 이보다 크다고 생각되면 생성시에 충분한 공간을 예약해 놓은 것이 좋다. |
'Programing'의 다른글
- 현재글1장 Java Strings - Java Performance and Scalability