본문 바로가기

IT/메모장

자바 성능 튜닝 이야기

1. 메서드 수행

2. 메서드가 운영체제의 커널에 파일을 읽으라고 요청함

3. 커널이 파일을 읽어 자신의 커널에 있는 버퍼에 복사(DMA에서 수행)

4. JVM으로 해당 데이터 전달

5. JVM에서 스트림 관리 클래스로 데이터를 처리

 

NIO에서 새로 추가된 개념

1. 버퍼

2. 채널

3. 문자열의 엔코더/디코더 제공

4. Perl 스타일의 정규 표현식에 기초한 패턴 매칭 방법 제공

5. 파일을 잠그거나 메모리 매핑이 가능한 파일 인터페이스 제공

6. 서버를 위한 복합적인 Non-blocking IO 제공

 

Java 7 부터 NIO2 가 도입됨

 


로그

1. System.out.println 사용금지

2. 직접 로그툴을 만들어 사용하는 것 금지

3. Logger 사용

4. Exception 처리를 위한 부분을 제외하고는 로그를 사용하지 말 것

 

개선율 = (튜닝 전 응답 속도 - 튜닝 후 응답 속도) * 100 / 튜닝 후 응답 속도

 

System.out.println에서 String 을 이어 붙이는 것보다 format 을 쓰는 것이 성능이 더 안 좋음.

1. String 이어 붙이는 경우

new StringBuilder(String.valueOf(a)).append(" ").append(b).append(" ").append(c).toString();

2. Format 사용

String.format("%s %s %d, new Object[] {a, b, Long.valueOf(c)});

 

디버그용으로 사용 시에는 Format 방식 권장, 더 편리하고 소스의 가독성도 높아지기 때문이다. 다만 운영 시에는 디버그용 로그를 제거할 경우를 가정하고 권하는 것

 

예외처리 시 e.printStackTrace()는 여러 쓰레드에서 콘솔에 로그를 프린트 시 데이터가 섞여 알아보기 힘들며, 서버의 성능에도 많은 부하를 준다. 그래도 예외를 출력해주는 데이터가 필요한 경우 아래와 같이 처리하는 방법이 있다.

try {
	// ...
} catch(Exception e) {
	StackTraceElement[] ste = e.getStackTrace();
    String className = ste[0].getClassName();
    String methodName = ste[0].getMethodName();
    int lineNumber = ste[0].getLineNumber();
    String fileName = ste[0].getFileName();
    logger.severe("Exception : " + e.getMessage());
    logger.severe(className + "." + methodName + " " + fileName + " " + lineNumber + "line");
}

JSP 라이프 사이클

1. JSP URL 호출

2. 페이지 번역

3. JSP 페이지 컴파일

4. 클래스 로드

5. 인스턴스 생성

6. jspInit 메서드 호출

7. _jspService 메서드 호출

8. jspDestroy 메서드 호출

 

JSP 페이지가 이미 컴파일 되어있고, 클래스가 로드외어 있고, JSP 파일이 변경되지 않았다면 2~4 프로세스는 생략된다. 서버의 종류에 따라 서버가 기동될 때 컴파일을 미리 수행하는 Pre-compile 옵션이 있다. 이 옵션을 선택하면 서버에 최신 버전을 반영한 이후에 처음 호출되었을 때 응답 시간이 느린 현상을 방지할 수 있다. 하지만 개발 시에 옵션을 켜 놓으면 서버를 기동할 때마다 컴파일을 수행하기에 시간이 오래 걸려 개발 생산성이 떨어진다.

 

Servlet의 라이프 사이클 (204페이지 그림 참고)

1. Servlet 객체가 자동으로 생성되고 초기화 되거나, 사용자가 해당 Servlet을 처음으로 호출했을 때 생성되고 초기화 된다.

2. 그 다음에 계속 사용가능 상태로 대기한다. (예외가 발생할 경우 사용 불가능 상태로 빠졌다가 다시 사용 가능 상태로 변환되기도 한다.)

3. 해당 서블릿이 더 이상 필요 없을 때는 파기 상태로 넘어간 후 JVM에서 제거된다.

* 서블릿은 JVM에서 여러 객체로 생성되지 않는다. 이에 여러 스레드에서 해당 서블릿의 service() 메서드를 호출하여 공유한다.

 

JSP 의 호출(include) 방식

1. 정적방식

페이지 번역 및 컴파일 단계에서 필요한 JSP를 읽어 메인 JS의 자바 소스 및 클래스에 포함시키는 방식

2. 동적방식

페이지가 호출될 때마다 지정된 페이지를 불러들여 수행

 

동적방식이 정적방식에 비해 약 30배 정도 느리게 나타난다.

그렇기에 정적방식을 되도록 쓰되, 오류 발생 가능성이 있는 곳에는 동적방식을 쓰도록하자.

 

Spring의 가장 큰 특징은 POJO로 많은 어플리케이션을 개발할 수 있다는 것이다.(JSP, Servlet는 POJO가 아니다.)

POJO, DI, AOP, PSA

PSA : 사용하는 기술이 바뀌더라도 비즈니스 로직의 변화가 없도록 도와주는 것(해당 라이브러리의 내부에서 변경작업을 하는 것)

 

스프링이 내부 매커니즘에서 사용하는 캐시를 조심해서 써야한다. 

@PostMapping("/member/{id}")

public String hello(@PathVariable int id) {

return "redirect:/member/" + id;

}

이미 찾은 뷰 객체를 캐싱해두면 다음에도 동일한 문자열이 반환되었을 때 빠르게 뷰 객체를 찾을 수 있다.

스프링에서 제공하는 ViewResolver 중에 자주 사용되는 InternalResourceViewResolver에는 그러한 캐싱 기능이 내장되어 있다.

만약 매번 다른 문자열이 생성될 가능성이 높고, 상당히 많은 수의 키 값으로 캐시 값이 생성될 여지가 있는 상황에서는 문자열을 반환하는게 메모리에 치명적일 수 있다. 이런 상황에서는 뷰 객체 자체를 반환하는 방법이 메모리 릭을 방지하는데 도움이 된다.

 

public class SampleController {

@RequestMapping("/member/{id}")

public View hello(@PathVariable int id) {
return new RedirectView("/member/"+id);

}

}


DB 사용 시 주의 점

1. DB Connection을 할 경우에는 반드시 공통 유틸을 만들어 사용

2. 모듈별 Datasource를 사용하여 리소스가 부족한 현상이 발생하지 않도록 할 것

3. 반드시 Connection, Statement 관련 객체, ResultSet을 Close할 것

4. 페이지 처리를 위해서 ResultSet객체.last() 메서드를 사용하지 말 것


XML 파싱이 JSON보다 느리나, Serialize / DeSerialize를 할 경우 JSON이 더 느림


웹 기반의 시스템에서 성능에 영향을 줄만한 세팅은 다음과 같다.

1. 웹 서버 세팅

2. WAS 서버 세팅

3. DB 서버 세팅

4. 장비 세팅

 

아파치 웹 서버의 설정을 바꾸는 방법은 conf 디레터리의 httpd.conf 파일을 수정하는 것이다.

TreadsPerChild 을 수정하면 서버에 따른 사용자의 요청을 얼마나 처리할 수 있는지를 정할 수 있다.

MaxRequestPerChild는 최대 요청 개수를 지정하는 부분이다. 가급적 0에 놓고 사용하자.

좀더 세밀하게 하고 싶은 경우 httpd.conf의 Include conf/extra/httpd-mpm.conf를 주석해제하자.

그리고 httpd-mpm.conf 를 수정해보자.

1. StartServers : 서버를 띄울 때 프로세스의 개수를 지정한다.

2. MaxClients : 최대 처리 가능한 클라이언트 수를 지정한다.

3. MinSpareThreads : 최소 여유 스레드 수를 지정한다.

4. MaxSpareThreads : 최대 여유 스레드 수를 지정한다.

5. ThreadsPerChild : 프로세스당 스레드 수를 지정한다.

 

만약 프로세스가 2개이고 스레드가 25개이면 최대 50개의 요청을 처리할 수 있다. 또한 최대 여유스레드가 75개면 최대 사용가능 클라이언트 수는 150이 된다(75 * 2)

만약 초당 150명의 요청을 받고 있다고 가정해보다, 자바는 GC를 할 때 JVM 자체가 멈춘다. 만약 이 GC가 2초 걸리면 어떻게 될까? 아파치 웹 서버에 총 300명의 요청이 기다릴 것이다. 이 경우 Tomcat에서는 AJP Connector라는 웹 서버와 WAS 사이의 커넥터에 설정한 backlog라는 값의 영향을 받는다. 만약 이 값을 설정하지 않으면 기본값은 100이다. 따라서 100개를 넘는 요청들은 503(Service Unavaliable)를 받게된다. 이 상황을 해결하기 위해서는 다음과 같은 조치를 취해보자.

1. 서버를 늘린다.

2. 서버를 튜닝한다.

3. GC를 튜닝한다.

4. 각종 옵션값을 튜닝한다.

 

모든 웹 서버를 설정할 때 또 한 가지 중요한 값이 있다. 바로 KeepAlive 설정이다.

KeepAlive 기능이 켜져 있지 않으면, 매번 HTTP 연결을 맺었다 끊었다 하는 작업을 반복한다. 

만약 해당 기능을 사용한다면 연결을 하기 위한 대기 시간이 짧아지기 때문에 사용자가 느끼는 응답 속도도 엄청나게 빨라진다.

 

* 사용자의 접근이 많은 사이트에서는 이미지나 CSS같은 정적 파일을 웹 서버에서 처리하지 않고 CDN을 이용한다. 즉 별도의 URL에서 해당 컨텐츠들을 내려받도록 설정하고, 동적인 컨텐츠를 WAS에서 처리하도록 해놓으면, Web-WAS 서버의 부담도 줄어들게 된다(단점  비쌈)

 

만약 사용자가 너무 많아 접속이 잘 안될 경우, 이 설정을 5초 정도로 짧게 주어 서버의 리소스를 보다 효율적으로 사용할 수 있다.

 

WAS에서 설정해야 하는 값이 많다. 그 중 가장 성능에 많은 영향을 주는 DB Connection Pool과 스레드 개수에 대해 알아보자.

대부분 WAS에서 두 설정 값의 개본 개수가 10~20개 정도다. DB Connection Pool은 보통 40~50개로 지정하며, 스레드 개수는 이보다 10개 정도 더 지정한다. 이렇게 지정하는 이유는 스레드 개수가 DB Connection Pool의 개수보다 적으면 적은 수만큼의 연결은 필요 없기 때문이다. 스레드의 개수가 DB 연결 개수보다 많은 이유는 모든 어플리케이션이나 화면이 DB에 접속하는 것은 아니기 때문이다.

 

DB의 CPU 사용량이 100% 도달 시 비용 소모가 큰 쿼리를 찾아서 튜닝해야한다.

WAS의 CPU 사용량이 100%에 도달 시 WAS 어플리케이션을 튜닝해야한다.

 


Sun에서는 자바의 성능을 개선하기 위해 Just In Time(JIT) 컴파일러를 만들었고, 이를 HotSpot로 지었다.

JIT컴파일러는 프로그램의 성능에 영향을 주는 지점에 대해 지속적으로 분석한다.

분석된 지점은 부하를 최소화하고, 높은 성능을 내기 위한 최적화의 대상이 된다.

HotSpot VM은 세 가지 주요 컴포넌트로 되어 있다.

- VM(Virtual Machine) 런타임

- JIT 컴파일러

- 메모리 관리자

HotSpot VM 아키텍처

컴파일이란 작업은 상위 레벨의 언어로 만들어진 코드가 기계에 의존적인 코드로 변환되는 것을 의미한다.

JIT는 모든 코드를 컴파일할 만큼 시간적 여유가 많지 않다. 그러므로, 모든 코드는 초기에 인터프리터에 의해 시작되고, 해당 코드가 충분히 많이 사용될 경우에 컴파일할 대상이 된다. HotSpot VM에서 이 작업은 각 메서드에 있는 카운터를 통해 통제되며, 메서드에는 두 개의 카운터가 존재한다.

 

- 수행 카운터(Invocation Counter) : 메서드를 시작할 때마다 증가

- 백엣지 카운터(BackEdge Counter) : 높은 바이트 코드 인덱스에서 낮은 인덱스로 컨트롤 흐름이 변경될 때마다 증가

 

백엣지 카운터는 메서드가 루프가 존재하는지 확인핼 때 사용되며, 수행 카운터보다 컴파일 우선순위가 높다.

이 카운터들이 인터프리터에서 증가되며, 한계치에 도달했을 경우 인터프리터는 JLT에 컴파일을 요청한다.

여기서 수행 카운터에서 사용하는 한계치는 CompileThreshold이며, 백엣지 카운터에서 한계치는 다음의 공식으로 계산한다.

- CompileThreshold * OnStackReplacePercentage / 100

 

컴파일이 요청되면 큐에 쌓여 컴파일러 스레드에서 컴파일을 수행한다. 이 때 인터프리터는 컴파일이 완료되는 것을 기다리지 않고 수행 카운터를 리셋하여 메서드 수행을 계속한다.

 

IBM JVM의 JIT 컴파일 방식은 5가지로 나뉜다.

- 인라이닝(Inlining) : 메서드가 단순할 때 적용되는 방식이며, 호출된 메서드가 단순할 경우 그 내용이 호출한 메서드의 코드에 포함시킨다. 자주 호출되는 메서드의 성능이 향상되는 장점이 있다.

- 지역 최적화(Local optimizations) : 작은 단위의 코드를 분석하고 개선하는 작업을 수행한다.

- 조건 구문 최적화(Control flow optimizations) : 메서드 내의 조건 구문을 최적화하고, 효율성을 위해 코드의 수행 경로를 변경한다.

- 글로벌 최적화(Global optimizations) : 메서드 전체를 최적화하는 방식이다. 매우 비싼 비용의 방식이며, 컴파일 시간이 많이 소요되는 단점이 있지만, 성능 개선이 많이 될 수도 있다.

- 네이티브 코드 최적화(Native Code Generation) : 플랫폼 아키텍처에 의존적인 방식이다.

 

컴파일된 코드는 코드 캐시(Code cache)라고 하는 JVM 프로세스 영역에 저장된다.

 

HelloWorld 클래스를 실행하면 JVM에서 수행되는 단계(더 자세한건 310p 참고)

1. Java 명령어 줄에 있는 옵션 파싱

2. Java 힙 크기 할당 및 JIT 컴파일러 타입 지정

3. ClassPath와 LD_LIBRARY_PATH 같은 환경변수 지정

4. 자바의 Main Class 확인(지정되지 않았으면, Jar 파일의 manifest 파일에서 Main 클래스를 확인한다.)

5. JNI의 표준 API인 JNI_CreateJavaVM를 사용하여 새로 생성한 non-primordial이라는 스레드에서 HotSpotVM을 생성한다.

6. HotSpot VM이 생성되고 초기화되면, Main클래스의 main() 메서드의 속성 정보를 읽는다.

7. CallStaticVoidMethod에서 네이티브 인터페이스를 불러 HotSpot VM에 있는 main() 메서드가 수행된다. 이 때 자바 실행 시 Main 클래스 뒤에 있는 값들이 전달된다.

 

** Spring 수행단계도 정리하기

 


GC

자바에서 사용하는 메모리 영역

1. PC 레지스터

2. JVM 스택 

3. Heap

4. 메서드 영역

5. Runtime 상수(Constant) 풀

6. 네이티브 메서드 스택

 

이 영역 중에서 GC가 발생하는 부분은 힙 영역이다.

클래스 인스턴스, 배열이 이 메모리에 쌓인다. 공유(shared) 메모리로도 불리며, 여러 스레드에서 공유하는 데이터들이 저장되는 메모리다.

 

Non-Heap 영역

- 메서드 영역 : 모든 JVM스레드에서 공유한다. 이 영역에 런타임 상수풀이 저장된다

    - 런타임 상수 풀 : 자바의 클래스 파일에는 constant_pool이라는 정보가 포함되어 있다.

    - 메서드 데이터, 메서드와 생성자 코드

- JVM 스택 : 스레드가 시작할 때 JVM 스택이 생성된다. 이 스택에는 메서드가 호출되는 정보인 프레임이 저장된다. 그리고 지역변수와 임시 결과, 메서드 수행과 리턴에 관련된 정보들도 포함된다.

- 네이티브 메서드 스택 : 자바 코드가 아닌 다른 언어로 된 코드들이 실행될 때의 스택 정보를 관리한다.

- PC 레지스터 : 자바의 스레드들은 각자의 pc 레지스터를 갖는다. 네이티브한 코드를 제외한 모든 자바 코드들이 수행될 때 JVM의 인스트럭션 주소를 pc 레지스터에 보관한다.

 

GC의 역할

- 메모리 할당

- 사용중인 메모리 인식

- 사용하지 않는 메모리 인식

 

Young 영역 : Eden / Survior 1 / Survior 2 (1,2 우선순위 아님 단지 2개의 영역이 따로 존재할 뿐임)

Old 영역 : 메모리 영역

 

메모리에 객체가 생성되면, Eden영역에 객체가 지정된다.

Eden 영역에 데이터가 꽉차면, 이 영역에 있던 객체가 어디론가 옮겨지거나 삭제되어야 하는데, 옮겨지는 위치는 Survior 영역이다. 

Survior 영역은 2개 중 1개는 반드시 비어 있어야 한다. 그 비어 있는 영역에 Eden 영역에 있던 객체 중 GC후에 살아남은 객체들이 이동한다. 1개의 Survior 영역이 차면, GC가 되면서 Eden의 객체와 Survior 객체가 비어있는 Survior객체로 이동한다. 이런 작업이 반복되다고 이는 Old 영역으로 이동한다. (Young 영역에서 Old 영역으로 바로 이동하는 경우는 객체의 크기가 아주 큰 경우)

 

GC의 종류

- 마이너 GC : Young 영역에서 발생하는 GC

- 메이저 GC : Old 영역에서 발생하는 GC

 

GC의 방식

1. Serial Collector : Young 영역과 Old 영역이 연속적으로(시리얼적으로) 처리되며, 하나의 CPU를 사용한다. 이 처리를 수행할 때를 Stop-the-world라 하는데, 콜렉션이 수행될 때 어플리케이션의 수행이 정지된다.

2. Parallel Collector(병렬 콜렉터) : 스루풋 콜렉터(throughput collector)로도 알려진 방식으로, 이 방식의 목표는 다른 CPI가 대기 상태로 남아 있는 것을 최소화하는 것이다. 많은 CPU를 사용하기에 GC의 부하를 줄이고 처리량을 증가시킬 수 있다.

3. Parallel Compacting Collector : 병렬 콜렉터와 다른 점은 Old 영역에서 GC에 새로운 알고리즘을 사용한다는 것이다.

 - 표시 단계 : 살아 있는 객체를 식별하여 표시

 - 종합 단계 : 이전에 GC를 수행하여 컴팩션된 영역에 살아 있는 객체의 위치를 조사하는 단계

 - 컴팩션 단계 : 컴팩션을 수행하는 단계. 수행 이후에는 컴팩션된 영역과 비어있는 영역으로 나뉜다.

4. Concurrent Mark-Sweep Collector(CMS 콜렉터) : 로우 레이턴시 콜렉터(Low-Latency Collector)로도 불리며, 힙 메모리 영역의 크기가 클 때 적합하다. Young 영역에 대한 GC는 병렬 콜렉터와 동일하나, Old 영역의 GC는 다음단계를 거친다.

 - 초기 표시 단계 : 매우 짧은 대기 시간으로 살아 있는 객체를 찾는 단계

 - 컨커런트 표시 단계 : 서버 수행과 동시에 살아 있는 객체에 표시를 해 놓는 단계

 - 재표시 단계 : 컨커런트 표시 단계에서 표시하는 동안 변경된 객체에 대해 다시 표시하는 단계

 - 컨커런트 스윕 단계 : 표시되어 있는 쓰레기를 정리하는 단계

5. Garbage First Collector(GI 콜렉터) : 어려움;; CMS GC와 비슷하며, 각 단계마다 모두 Stop the world가 발생한다.

 

GC 튜닝의 목적

- Old 영역으로 넘어가는 객체의 수 최소화

- Full GC시간 줄이기