알고리즘 문제를 풀면서 각종 해설에서 StringTokenizer를 사용한다. 그래서 당연히 String.split() 은 속도를 생각하면 사용하면 안되는 메소드로 생각하고 있었다. 그런데 생각보다 그렇게 단순히 결정짓고 넘어갈 문제가 아니었다.
결론 :
그때그때 다르다.
StringTokenizer는 레거시코드다. (deprecated 되지는 않았다)
String.split() 은 비교적 일정한 속도를 내지만 StringTokenizer는 경우에 따라 편차가 크다.
즉 상황에 맞춰서 써라. ( 자바 버전을 업그레이드 했을 때 문제 생길지는 미지수 jdk17까지는 사용할 수 있는듯 )
속도 차이가 왜 생기나? 🤷♂️
String.split()은 정규표현식을 사용해 문자열을 나누고 이는 속도가 느린 원인이 된다.
실제로 알고리즘 문제를 풀다보면 속도차이가 꽤 생기는 경우 발생
하지만 속도가 꽤나 균일하다
StringTokenizer는 구분자(delimeter)와 문자열을 전부 다 비교한다.
StringTokenizer는 ‘구분자가 유니코드, hasMoreTokens나 nextToken 호출’ 시에도 문자열과 구분자 전체를 비교하기 때문에 효율이 좋지 못하다.
즉 위에서 말한 조건이 만족되는 경우가 많을수록 속도가 급속도로 느려진다.
예를들어… 아스키코드에 존재하지 않는 “뷁” 같은 문자를 구분자로 사용하며 그 수가 많고, 여러 문자를 나누어 hasMoreTokens()나 nextToken() 을 반복해서 사용한다면 StringTokenizer를 사용하는건 좋은 선택이 아니다.
실험 결과 비교하기
첫번째 실험 : 구분자를 “,” 사용 / hasMoreTokens() 사용 안함 / nextToken() 한번 사용
결과 :
splits ====== time : 271, clientIp : 192.168.1.1
tokenizer ====== time : 80, clientIp : 192.168.1.1
[출처] [StringTokenizer VS String.split] 누가 더 빠른가|작성자 평범한개발자
두번째 실험 : 구분자를 아스키코드가 아닌 문자 사용 / hasMoreTokens(), nextToken() 반복 사용
splits ====== time : 257, clientIp : 127.0.0.3 tokenizer ====== time : 220, clientIp : 127.0.0.3 [출처] [StringTokenizer VS String.split] 누가 더 빠른가|작성자 평범한개발자
// 위 링크에서 코드 발췌 @Test public void getClientIpSplitTest() { String clientIp = null; long start = System.currentTimeMillis(); for(int i = 0; i < 1000000; i++) { String xForwardedFor="192.168.1.1뛟127.0.0.1뛟127.0.0.2뛟127.0.0.3"; String[] splits = xForwardedFor.split("뛟"); if(splits.length < 1) clientIp = ""; else { for(String str : splits) { clientIp = str; } } } long end = System.currentTimeMillis(); log.info("splits ====== time : {}, clientIp : {}", end-start, clientIp); } @Test public void getClientIpStringTokenizerTest() { String clientIp = null; long start = System.currentTimeMillis(); for(int i = 0; i < 1000000; i++) { String xForwardedFor="192.168.1.1뛟127.0.0.1뛟127.0.0.2뛟127.0.0.3"; StringTokenizer tokenizer = new StringTokenizer(xForwardedFor, "뛟"); while(tokenizer.hasMoreTokens()) { clientIp = tokenizer.nextToken(); } if(clientIp == null) clientIp = ""; } long end = System.currentTimeMillis(); log.info("tokenizer ====== time : {}, clientIp : {}", end-start, clientIp); } [출처] [StringTokenizer VS String.split] 누가 더 빠른가|작성자 평범한개발자
세번째 실험 : 두번째 실험에서 구분자 “하나” 추가 / hasMoreTokens(), nextToken() 반복 사용
결과 : 역전 당했다. 효율이 정말 좋지 않아보인다.
"192.168.1.1뛟127.0.0.1뛟127.0.0.2뛟127.0.0.3뛟127.0.0.4";
splits ====== time : 270, clientIp : 127.0.0.4
tokenizer ====== time : 319, clientIp : 127.0.0.4
[출처] [StringTokenizer VS String.split] 누가 더 빠른가|작성자 평범한개발자
Scanner는 동기화 미지원, BufferedReader는 동기화 지원 → 멀티스레드 환경에서 안전하다.
단점
BufferedReader는 문자열만 입력받기 때문에 이후 Integer.parseInt() 같은 후처리가 필요함. Scanner는 nextInt() 와 같이 원하는 자료형으로 쉽게 처리가능.
IOException 예외 처리 필요 : try / catch 혹은 throw 사용
공백 단위로 데이터를 처리하려면 StringTokenizer 혹은 String.split() 사용
Scanner와의 속도 비교
정수를 한 줄씩 읽고 합치기 1000만번 반복
입력 방식 수행시간(초)
java.util.Scanner
6.068
java.io.BufferedReader
0.934
사용법
BufferedReader br = new BufferedReader(new InputStreamReader(System.in);
String s = br.readLine();
// 형변환 예시
int i = Integer.parseInt(s);
// 공백 단위 데이터 처리 예시 1
// 인자1: 문자열, 인자2: 구분자(옵션)
StringTokenizer st = new StringTokenizer(br.readLine(), " ");
Stirng word = null;
while(st.hasMoreTokens()){
word = st.nextToken();
}
// 공백 단위 데이터 처리 예시 2
String arr[] = br.readLine().split(" ");
BufferedWriter
보통 사용하는 System.out.println() 대신 사용
적은 양을 출력할 땐 System.out.println() 이 편리하지만 많은 양을 출력할 땐 이 방법은 속도가 느리다. 그래서 BufferedWriter 를 사용
개행 문자 출력
BufferedWriter는 개행을 자동으로 해주지 않는다.
BufferedWriter.newLine() 이나 BufferedWriter.write(”\n”) 사용
flush() / close()
버퍼를 사용하기 때문에 더 이상 사용하지 않으면 flush()로 스트림을 비우고, close() 로 출력스트림을 닫아줘야한다.
한번 출력 후 다른 것도 출력하려면 flush() 로 비우기
사용법
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
String str = "hello";
// void write(char[] buf, int offset, int length)
// 버퍼 offset부터 length 크기만큼 write
bw.write(str + "\\n"); // 혹은 bw.newLine()
bw.flush();
bw.close();
하지만, 직접 실험해본 결과 속도에서 너무나도 큰 차이가 난다. 물론 10만번으로 많은 반복을 거쳤지만 그래도 무시할만한 차이는 아니다.
oracle 의 java 문서에서 확인한 결과
실제로 문자열 합치는 기능이 append 메소드를 사용한다고 한다. 그런데 왜 이렇게 많은 차이가 발생하는걸까?
The Java language provides special support for the string concatenation operator ( + ), and for conversion of other objects to strings. String concatenation is implemented through the StringBuilder(or StringBuffer) class and its append method. String conversions are implemented through the method toString, defined by Object and inherited by all classes in Java. For additional information on string concatenation and conversion, see Gosling, Joy, and Steele, The Java Language Specification.
네, **String**의 + 연산이 내부적으로 **StringBuilder**를 항상 사용하는 것은 아닙니다.
자바 컴파일러는 + 연산자로 문자열을 연결하는 코드를 컴파일할 때, 효율적인 문자열 연결을 위해 내부적으로 **StringBuilder**를 사용하는 방식으로 변환합니다. 이를 "컴파일러가 자동으로 **StringBuilder**를 사용한다"고 생각할 수 있습니다.
따라서 컴파일러는 다음과 같은 코드:
String str = "Hello";
str = str + " World";
다음과 같이 컴파일됩니다:
String str = "Hello";
str = new StringBuilder().append(str).append(" World").toString();
이렇게 **StringBuilder**를 사용하여 문자열 연결이 수행됩니다. 이러한 변환은 컴파일 과정에서 발생하며, 개발자가 직접 **StringBuilder**를 사용하는 것과는 다른 방식입니다.
하지만, + 연산자로 문자열을 반복적으로 연결하는 경우에는 컴파일러가 최적화를 수행하지 못하고 내부적으로 **StringBuilder**를 사용하지 않습니다. 반복적인 + 연산은 매 연산마다 새로운 **StringBuilder**를 생성하고 문자열을 누적하는 비용이 발생하므로, 성능에 영향을 줄 수 있습니다. 이 경우에는 명시적으로 **StringBuilder**를 사용하는 것이 더 효율적입니다.
따라서, + 연산자를 사용하여 문자열을 연결할 때는 개발자가 컴파일러의 동작을 고려할 필요가 없으며, 코드를 가독성 좋게 작성하는 것에 집중하는 것이 좋습니다. 컴파일러는 최적화를 수행하여 효율적인 문자열 연결을 처리할 수 있습니다.
gpt 답변을 완전히 신뢰할 수 있는지는 모르겠지만 저 말이 맞다면 StringBuilder를 사용하긴 하지만 매 연산마다 새로운 인스턴스를 생성하는 건 여전하다. 이 부분에서 오버헤드가 발생하는지도 모르겠다.
추측 : 아무래도 String 클래스가 불변 속성이다보니 StringBuilder를 사용하더라도 매번 새로운 인스턴스를 만들어 적용할 수 밖에 없는 듯하다. 위 gpt 답변에서 보이듯이 StringBuilder.append().toString()으로 결국 String 클래스로 변환해서 값을 대입해주기 때문이다. 따라서 기존 문자열 대신 새로운 문자열을 넣어주기 위해 매번 새로운 인스턴스를 생성하는 수 밖에 없어보인다.
실험 후 결론 : 속도가 중요한 상황에서 반복적인 문자열 concatenation 이 필요하다면, String의 “+” 보다는 StringBuilder.append() 를 사용하자. 그리고 반복이 끝난 후 toString() 으로 한번에 변환해서 사용하자.
23.09.20 : 시간이 지나서 전에 썼던 글을 다시 보았더니 수정할게 있다. 추측부분에서 새로운 문자열을 할당하기 위해 매번 새로운 인스턴스를 생성한다고 했지만 틀렸다. 내가 for 문에서 hellohello = hellohello + hello; 를 계속 반복하는 바람에 변수에 새로운 String 인스턴스를 10만번 반복해서 할당하는 꼴이되었다. 그러니 오버헤드가 클 수 밖에 없었다. 만약 hellohello = hellohello + hello + bonjour + nihao + annyeong; 처럼 반복문 없이 단순히 + 연산자를 여러번 사용하는 경우라면 내부에서 StringBuilder로 한번에 처리하기때문에 변수에 새 인스턴스를 할당하는 동작은 한 번만 일어난다. 이럴 땐 StringBuilder나 별 차이가 없다. (물론 chatgpt가 한 답변이 옳은 경우라면… 말이다) 하지만 위에서 실험한 것 처럼 for문 같은 반복문안에서 + 연산자 및 인스턴스 할당을 사용할 거라면 StringBuilder를 직접 사용하는게 훨씬 나아보인다.