2023. 3. 23. 15:00ㆍ개발
예전에 사둔 책인 자바 성능을 결정짓는 코딩 습관과 튜닝이야기(저자 이상민)를 보고 있는데 사람들이 항상 말하는 String에 대한 사용과 그것을 잘못 사용 했을 때 발생하는 이슈 사항들에 대해 말 하고 있었다.
(항상 말씀드리지만 저에게 뭐 떨어지는거 없습니다;;)
예를들어 기본적으로 String의 경우 이런식으로 동작하게 된다.
...
//초기화 변수 선언
String a = "";
a = "SELECT *"
a += " FROM TABLE_NAME"
a += " WHERE 1=1"
...
String의 경우 불변객체이고 겉에서 보기에는 기존값 + 추가 String값으로 더해가면서 잘 사용하는듯 보인다.
하지만 내부적으로 동작할 때는 String Pool에서 확인 후 동일한 값이 없다면 새로 메모리에 할당하여 사용하기 때문에 메모리를 불필요하게 잡아먹게 된다.
String의 기본 동작에 대해서 궁금한 경우 자세하게 설명해둔 블로그가 많다. 해당 블로그들을 참고하거나 구글에 검색해 보면 도움이 될 듯 하다.
https://starkying.tistory.com/entry/what-is-java-string-pool
더 궁금한점의 경우 요즘은 Chat gpt가 잘 설명해주기도 한다..!
질문
자바에서 아래와 같은 형태로 String을 사용하면 안되는 이유에 대해서 String constant Pool 영역과 함께 자세히 설명해줘.
String a = "";
a = "SELECT *";
a += " FROM TABLE_NAME";
a += " WHERE 1=1";
gpt답변
Java에서 문자열 리터럴은 문자열 상수 풀이라는 메모리 영역에서 유지됩니다. 새 문자열 리터럴이 생성되면 JVM은 먼저 풀에 이미 존재하는지 확인하고 존재하는 경우 기존 문자열 개체에 대한 참조를 반환합니다. 즉, 동일한 문자열 리터럴을 여러 번 재사용할 수 있으므로 메모리가 절약되고 성능이 향상됩니다.
그러나 제공된 코드 스니펫에서 String 개체 "a"는 빈 문자열 리터럴로 초기화된 다음 폐기되고 세 개의 다른 String 리터럴을 연결하여 만든 새 String 개체로 대체됩니다. 새로운 String 객체가 생성될 때마다 String 상수 풀에 추가되어 잠재적으로 불필요한 메모리 할당을 유발하고 프로그램 속도를 저하시킵니다.
더 나은 방법은 StringBuilder 개체를 사용하는 것입니다. 이 개체를 사용하면 여러 String 개체를 만들지 않고도 단일 개체에 여러 문자열을 추가할 수 있습니다. 예를 들어:
StringBuilder sb = new StringBuilder();
sb.append("SELECT *");
sb.append(" FROM TABLE_NAME");
sb.append(" WHERE 1=1");
문자열 쿼리 = sb.toString();
StringBuilder를 사용하면 불필요한 메모리 할당 및 풀 추가를 피할 수 있을 뿐만 아니라 여러 문자열을 연결할 때 더 효율적입니다.
결국 특정 문자를 선언하게되면 동일한 문자가 존재한다면.. 해당 메모리 영역의 주소 값을 전달하여 괜찮지만, 합쳐서 새로운 문자로 만들게 된다면 새로운 값을 메모리에 띄워 추가적인 영역을 사용하게 되고 만약 저런케이스가 많다면..?(4천줄이라면..? 몇만줄이라면..?) 불필요한 메모리 사용을 하게된다.
예시로 아래와 같이 테스트코드를 작성해보았다.
@Test
@DisplayName("String 속도 테스트")
void StringTest() {
final String value = "abcdef";
for (int i = 0; i < 10; i++) {
String a = new String();
StringBuilder b = new StringBuilder();
StringBuffer c = new StringBuffer();
StopWatch timea = new StopWatch("aStopWatch");
StopWatch timeb = new StopWatch("bStopWatch");
StopWatch timec = new StopWatch("cStopWatch");
timea.start();
for (int j = 0; j < 10000; j++) {
a += value;
}
timea.stop();
System.out.println(timea.prettyPrint());;
timeb.start();
for (int j = 0; j < 10000; j++) {
b.append(value);
}
timeb.stop();
System.out.println(timeb.prettyPrint());;
timec.start();
for (int j = 0; j < 10000; j++) {
c.append(c);
}
timec.stop();
System.out.println(timec.prettyPrint());;
}
}
특정 문자열을 만들어두고 a의 경우 += 형태로 b의 경우 builder, c의 경우 buffer를 사용하여서 문자를 합쳤다. 모두 동일한 역활을 하고 동일한 결과를 반환하겠지만 속도를 확인하기 위해 각각의 로직을 StopWatch를 사용하여 찍어보았다… 결과는..?
총 열번의 결과들 사이에서 첫번째 결과만 봐도 차이가 확연하게 발생하게 된다.
StopWatch 'aStopWatch': running time = 69988800 ns
---------------------------------------------
ns % Task name
---------------------------------------------
069988800 100%
StopWatch 'bStopWatch': running time = 975600 ns
---------------------------------------------
ns % Task name
---------------------------------------------
000975600 100%
StopWatch 'cStopWatch': running time = 1612500 ns
---------------------------------------------
ns % Task name
---------------------------------------------
001612500 100%
여기서!
쓰레드 세이프(Thread Safe)란?
멀티 쓰레드 프로그래밍에서, 어떤 공유 자원에 여러 쓰레드가 동시에 접근해도, 프로그램 실행에 문제가 없는 상태를 의미한다.
그리고 항상 String을 말하게 되면 StringBuilder와 StringBuffer가 단골 질문으로 나오게 된다.
그렇다고 간단한걸 작성 할때도 String을 그대로 사용하는건 항상 나쁘다고 StringBuffer나 StringBuilder를 사용하는건..좀 그렇다.
보통의 경우에는 아래와 같이 정리하는 경우가 많다.
- String은 짧은 문자열을 더하는 경우 사용한다.
- StringBuffer는 스레드 안전한 프로그램에서 사용하거나, 개발중인 부분이 스레드 안전과 관련하여 잘 모를 경우 사용한다.
- StringBuilder의 경우 스레드 안전 여부과 상관없는 경우 사용한다.
그렇다면 1번의 경우 불필요한 메모리 사용에 대해 개발 편의를 위해 버리는 것 일까..?
1번과 같이 사용하더라도 내부적으로 자바 버전에 따라 컴파일 된 파일을 살펴보면 좀 다르다…
String value = "abcdef";
String a = "";
a += value;
a += value;
a += value;
a += value;
a += value;
a += value;
위와 같은 코드가 있다고 할때 컴파일 결과 나오는 class파일을 열어본다면 아래와 같이 변한다.
String value = "abcdef";
String a = "";
a = a + value;
a = a + value;
a = a + value;
a = a + value;
a = a + value;
a.makeConcatWithConstants<invokedynamic>(a, value);
그냥 더하기만 하는게 아니라 a.makeConcatWithConstants 라는 걸 하고 있다…!
최적화가 이뤄지고 있는 것이다.
버전별 차이에 대해서 설명한 블로그 글도 여기저기서 찾아 볼 수 있다.
해당 메서드에 대한 설명은 여기있다.
https://www.baeldung.com/java-string-concatenation-invoke-dynamic
StringBuffer의 경우 동시접근에 있어서 안전하고(Thread Safe) StringBuilder의 경우에는 안전하지 못하다는 것이다. 왜 그럴까..?
어떠한 점 때문에 그렇게 판단하는 것일까..?
StringBuffer 클래스에서 사용하는 append 메서드를 들어가서 살펴보면 다음과 같다.(synchronized)
synchronized StringBuffer append(AbstractStringBuilder asb) {
toStringCache = null;
super.append(asb);
return this;
}
sysnchronized를 사용하여 스레드에서 동시에 접속 할 시 동기화를 지원하고 있다.(참고로 append뿐 아니라 모든 메서드에 선언되어 있는 듯 하다.)
그렇다면 StringBuilder의 경우에는..? 해당 클래스내부에서 아무리 찾아봐도 synchronized가 적용된 곳이 없다..!
synchronized의 경우 선언이 되기만 한다면 성능상의 영향을 줄 수 있기때문에 아무래도 속도차이가 나게 된다…!
여담..!
우리가 자주 사용하고 간편하게 로그를 남길 때 사용했던 그 클래스..!
System.out.println의 경우에는 어떨까?
현업에서 로그를 남길때 사용하지 말고! Logger나 @Slf4j 를 사용하여 로그를 작성하게 되는 경우가 많다. 왜..? 그런걸까..!
내부 로직상에서 살펴 보았을 때 synchronized가 선언되어 있다….!!!
public void println(String x) {
if (getClass() == PrintStream.class) {
writeln(String.valueOf(x));
} else {
synchronized (this) {
print(x);
newLine();
}
}
}
기타 상세한 설명의 경우
여기서 설명을 잘 해두신것 같다.
https://hudi.blog/do-not-use-system-out-println-for-logging/
'개발' 카테고리의 다른 글
템플릿 메서드 패턴, 전략패턴(스프링 핵심원리 고급편) (0) | 2023.04.25 |
---|---|
세션에 있는 로그인정보 쉽게 사용하기(Spring, ArgumentResolver) (0) | 2023.03.29 |
스프링부트 모니터링 하는법(Actuator, prometheus, grafana) (1) | 2023.03.20 |
vm옵션값에 따라 Bean 등록하는법(스프링, 스프링부트)(@Conditional) (1) | 2023.03.19 |
스프링과 스프링 부트 차이 (1) | 2023.03.17 |