neptune에 multi-row selection 기능(like, between) 을 구현하고 테스트 하는 중에 계속 GC로 인해 10 ~ 30초 정도 시스템이 멎는 증상이 발생하였습니다. 지난번부터 계속 GC가 문제를 일으키고 있는데 UseConcMarkSweepGC 옵션으로 해결된 것 같은 문제가 다시 재현되었습니다.
일주일 삽질 끝에 발견한 것은 테스트 하는 장비의 환경이 바뀌어서 물리적인 메모리가 작아졌기 때문이었습니다. 메모리가 모자라서 GC가 오래 걸렸다라기 보다는 메모리는 여유 있는데 메모리 swap이 발생하면서 GC 도는 시간이 오래 걸린 증상이었습니다. GC 로그를 보면
GC 시간 7초 정에 user 시간은 0.43인 것을 볼 수 있습니다. 나머지 시간의 swap 메모리를 로딩하거나 디스크로 내리는데 걸린 wait 시간으로 볼 수 있습니다. 프로그램을 잘못 만든것이 아닌가 하고 열심히 튜닝했었는데 결국 원인은 외부에 있었네요... 일주일 삽질의 결과입니다. ㅋ
여러 프로세스를 수행하고 각 프로세스가 사용하는 실제적인 메모리의 총합이 장비가 가지고 있는 물리적인 메모리보다 크게 설정하게 되면 swap 영역을 사용하게 되겠죠. swap과 GC가 동시에 수행되면 GC쪽에 hang이 걸리고 JVM위에서 수행되는 프로세스의 모든 쓰레드가 hang 되는 상황이 발생합니다.
굳이 이렇게 나누는 것은 REST한 서비스 구성을 자세히 보면 특정 패턴이 보이고 이 패턴을 이용하여 프로그램을 쉽고 표준적으로 할 수 있기 때문입니다. 표준적으로 할 수 있다는 것은 프레임워크나 경량의 컨테이너가 출현할 가능성이 높고 이런 프레임워크를 통해 아주 쉽게 개발할 수 있게 됩니다. 최근 자바 진영에서는 JSR 311에 JAX-RS: The Java API for RESTful Web Services라는 형태로 표준화 하고 있습니다. 이것을 지원하는 프레임워크도 일부 나오고 있습니다. 단순한 개념인 것 같지만 그 개념의 이론을 만들고 이론에 따르는 시스템들이 개발되어 나오고 다시 이것들이 솔루션으로 만들어져 나오는 이런 선순환 구조를 통해 소프트웨어 산업이 계속 발전하고 성장하는 것 같습니다.
vm 디스크 확장 cd /var/lib/xen/images/vlinux1 #use dd to create a 1 GB file dd if=/dev/zero of=Tempfile bs=1024 count=1000000 #append this file to virtual image file (in this case is hda) cat Tempfile >> hda resize2fs -f hda
분산/병렬 프로그램 만들다 보니 전혀 재현 불가능한 에러도 발생합니다. 오늘 발생한 에러는 거의 재현하기 불가능할 것 같습니다.
코드는 다음과 같습니다.
class Test { TreeSet<String> values = new TreeSet<String>();
//이부분이 Multi client로부터 접속되는 코드 public void exec() { values.add(...); ... //다양한 조건을 이용하여 valus add, remove List<String> temp = new ArrayList<String>(); temp.addAll(values); } }
원래는 Test하는 코드의 exec를 호출하는 것은 앞단에서 하나의 클라이언트만 접속하게 되어 있었는데 이 부분에 약간 잘못이 있어 동시에 접속되는 상황이 발생한 것 같습니다. 예외는 다음과 같이 발생했습니다.
java.lang.NegativeArraySizeException at java.util.AbstractCollection.toArray(AbstractCollection.java:119) at java.util.ArrayList.addAll(ArrayList.java:472)
AbstractCollection 소스를 보니 TreeSet의 size()에서 반환되는 값이 0 보다 작은 경우에만 이런 예외가 발생하게 되어 있습니다.
어제 포스팅한 자바 메모리 관련해서 계속 프로파일링 도중에 다음과 같은 아주 심플한 코드들 JProfiler를 이용하여 프로파일링을 해 봤습니다.
길이 10의 byte[]를 10,000개 만들 경우 실제 데이터의 메모리는 100,000 byte가 됩니다. 하지만 프로파일러에는 234KB가 10,0001개 생성되었고 234KB 할당 되어 있다고 나옵니다.
List<byte[]> values = new ArrayList<byte[]>();
byte[] aaa = new byte[10];
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String a = "1"; int size = 0; for(int i = 1; i < 1000000; i++) { values.add("1234567890".getBytes()); size += 10;
if(i % 10000 == 0) { System.gc(); long free = Runtime.getRuntime().freeMemory(); long total = Runtime.getRuntime().totalMemory(); System.out.println(i + ">" + size + ">" + free + ">" + total + ">" + (total-free)); reader.readLine(); } }
이것과 관련해서 검색해보니까 Java에서는 32bit JVM의 경우 하나의 byte[]를 저장하는데 12byte의 추가 메모리가 필요하다고 합니다(http://forum.java.sun.com/thread.jspa?threadID=5216017&messageID=9879177). 그러면 100,000byte + 120,000byte = 220,000 byte로 프로파일링된 결과가 많이 비슷해진 것을 알 수 있습니다. 오버헤드가 너무 심하다는 생각입니다. byte[]에 아주 작은 값이 많이 쌓이는 경우라면 실제 생성한 데이터의 크기와 메모리 할당되는 크기가 너무 차이가 나서 개발자가 예상하지 못한 곳에서 OutOfMemory가 발생할 가능성이 많습니다. 제 경우도 이런 노가다 분석을 시작한 이유가 이것 때문입니다. 실제 데이터는 100MB 정도 넣었는데 메모리는 거의 400MB 이상을 사용하고 있었던 거죠... 난감하네요. 해결해야 하는데
그 역시 내부데이터에 byte[]가 대부분인지라 ENTRY당 12byte의 오버헤드를 감안하면 비슷한 수치가 나오네요.
그런데 그보다도 실제 문제는, 메모리가 3G로 충분함에도 700M의 SPACE를 더 사용해버리니 GC가 더욱 자주일어나며 FGC시간이 3~4초대로 늘어나 엔진이 잠시 멈추는 현상이 발견된다는 것입니다. 따라서 전체적인 THROUGHPUT이 70%에도 못미친다는.... 물론 다른 모듈의 메모리 사용정책이 방만해서 일어난 현상이라고는 하지만 요즘 저도 자바가 미오지고 있음당...ㅋ.ㅋ JASO님 혹시 이문제 해결하셨으면 저도 좀 알려주세용..ㅋ
오늘 코드 테스트 중 우연히 발견한 내용입니다. List에 추가된 모든 데이터의 대략 100MB도 안되는데에도 불고하고 OutOfMemory Error가 발생하였습니다. 물론 -Xmx, -Xms 옵션은 모두 256m으로 설정했습니다. 그래서 테스트 코드를 몇개 만들어서 비교해봤습니다.
String str = "0123456789";
List<String> datas = new ArrayList<String>(100);
long realSize = 0;
for (int i = 0; i < 100000000; i++) { String data = "0123456789" + str; datas.add(data); realSize += data.getBytes().length; if (i % 10000 == 0) { System.out.println("dataCount=" + i + ",dataSize=" + realSize + ",free=" + Runtime.getRuntime().freeMemory() + ",total=" + Runtime.getRuntime().totalMemory()); } }
for (int i = 0; i < 100000000; i++) { String data = new String("01234567890123456789"); datas.add(data); realSize += data.getBytes().length; if (i % 10000 == 0) { System.out.println("dataCount=" + i + ",dataSize=" + realSize + ",free=" + Runtime.getRuntime().freeMemory() + ",total=" + Runtime.getRuntime().totalMemory()); } } dataCount=8640000,dataSize=172800020,free=9725240,total=266403840
위의 코드와 아래코드의 구분은 String에 대한 + 처리 부분만 차이가 납니다. 이 코드는 String에 대한 테스트 코드는 아닙니다. 일반적으로 프로그램 내에서 위에 있는 코드와 같이 생성된 객체를 List에 add하는 경우가 많아서 비교하기 쉽게 만들어 본 것입니다. 아래 코드의 경우 약 864만개의 Object를 추가할 수 있는데 이것을 메모리로 계산해보면 데이터가 172M, 860만개를 가리키기 위한 레퍼런스(1객체당 4byte) 30M 정도 소요되어 200M 정도가 데이터 저장에 사용되었습니다. 그리고 free영역으로 10M 정도 사용되었습니다. 나머지 60M는 어디에서 사용되었는지가 첫번째 이슈입니다. 일단 추측해볼 수 있는 것은 ArrayList의 경우 자신의 용량보다 더 많은 레코드에 대해 add 요청이 들어오면 확장을 하게 되는데 이 확장되어 미리 확보한 영역 때문이라고 예측을 해볼 수 있습니다. 그래서 두번째 코드에서 List를 String[] 배열로 변경하여 다시 수행해 보면 다음과 같은 결과가 나타납니다. "dataCount=9410000,dataSize=188200020,free=56504,total=266403840"
1000만개 레퍼런스에 40M, 데이터 180M -> 220M 이정도면 봐줄만 합니다. 나머지 40M는 다른 용도로 사용하겠죠....
두번째 이슈는 첫번째 코드에서는 300만개 정도밖에 저장할 수 없다는 것입니다. for loop내에서 발생하는 String 연산 처리에서 생성된 객체들은 GC 수행시 모두 해제 되기 때문에 이론적으로는 두번째 코드와 동일해야 할 것 같은데 다르게 나타나고 있습니다.
저도 이놈의 out of memory 때문에 여러가지로 고민을 해봤는데 자바의 GC가 영 험블하더군요...^^
어쨌든 직접적인 원인은 임시 객체를 저장하는 메모리 영역인 eden이 꽉차서 발생하는 문제인데...
제가 그나마 찾은 대안은 VM에 옵션에 -XX:+UseParallelGC 를 추가하는 것 정도...이 옵션을 추가하면 eden 영역 GC가 기본 옵션보다 훨씬 빨리 제거됩니다. 하드웨어의 프로세서 갯수가 많을수록 효과가 큼...
불만인 것은 'out of memory가 발생하기 전에 알아서 eden영역을 정리해주면 좋잖아~' 인데...이게 프로그래머의 예상대로 동작하지 않는다는 점이 안타까울뿐이죠...^^;
이럴땐 그저 C++의 소멸자가 그리울 뿐...
embedded db는 oracle, mysql 처럼 네트워크 기반으로 데이터 서비스를 제공하지 않고 DB를 사용하는 프로그램이 수행되는 장비의 로컬 파일시스템 또는 메모리를 이용하는 DB를 말한다. 자바로 만든 embedded db 오픈소스 중 derby, berkeley db, hsqldb를 가지고 몇가지 테스트를 해 보았다. derby, hsqldb는 네트워크 기반, 임베디드 기반 모두 지원하지만 여기서는 임베디드 기반만 테스트 하였다. berkeley db(이후 bdb)는 db 라기 보다는 hash 형태만 제공하는 아주 간단한 db이다. 1 row = 1000byte, 100,000 rows 데이터를 저장하는 테스트를 수행하였다.
100,000 insert 한 다음 commit(bdb의 경우 sync) 하는 경우
bdb(14초) << hsqldb(42초) < derby(53초)
bdb가 다른 db에 비해 상상 할 수 없을 정도의 속도로 저장되었다. hsqldb의 경우 옵션에 따라 성능 차이가 많이 나타난다. hsqldb는 server 타입, file 타입, memory 타입 세가지 형태로 사용할 수 있다. 테이블을 생성할 때 create memory table ...형태로 만들면 memory 기반 테이블이 되고 create cached table로 만들면 file 기반 테이블이 된다. file 기반 테이블은 일정 크기 동안은 메모리에 저장시키고(물론 change log 정보는 파일에 기록) 메모리가 일정 수준 이상 도달하면 디스크로 저장하는 하이브리드 방식을 채택하고 있다. 여기서 문제점은 디스크로 저장할 때 기존 파일에 추가하는 것이 아니라 기존 파일 + 메모리 내용이 되는 새로운 파일을 만든다는 것이다. 데이터 크기가 큰 경우 데이터 파일을 생성하는데 너무 많은 시간이 소요되고 이 시간 동안 insert 작업을 할 수 없게 된다.
1건씩 commit 하는 경우 hsqldb(42초) < derby(158초) << bdb(답이 없음, 수십분 이상)
위 성능으로 보면 hsqldb가 commit, rollback을 지원하지 않는 것 처럼 보이지만 실제 테스트 해보면 rollback도 잘 지원한다. 건당 commit 하는 경우와 전체 commit 하는 경우가 동일한 속도를 보장하고 있는데 다른 side effect는 없는지 확인해봐야 겠다. 어쨋든 여러가지를 고려해보면 안정적인 성능을 발휘하는 derby가 그나마 가장 좋기는 한데 원하는 수준의 속도가 나오지 않는다.
bdb를 어떻게 테스트 하셨는지 궁금하군요.
제가 예전에 4.1.x 버젼 (C API)를 3년 정도 개발 한 적이 있는데요.
1row 하나 insert 하는데 수십분이라니요?
개발상의 ISAM 형식의 원시적인 data management 기능만 제공하기에 개발상에 어려운 점은 많이 있으나 탁월한 성능을 나타냅니다.
물론 지금은 오라클에 인수된 상태이구요.
AOP(Aspect Oriented Program)을 위해 Proxy를 이용하여 특정 클래스의 method하는 경우 InvocationHandler를 implements 한다. 이때 invoke() 메소드 내에서 synchronized 처리에 주의해야 한다. 다음 코드의 경우
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
synchronized(monitor) {
try {
return method.invoke(targetObject, args);
} catch (Throwable e) {
e.printStackTrace(System.out);
throw e;
}
}
}
}
이 경우 일반적인 경우에는 잘 작동하지만 호출하는 메소드 내에서 다시 Proxy 객체의 메소드를 호출하는 경우 Handler 내부적으로는 recursive하게 call되어 두번째 메소드가 수행되는 시점에서는 monitor에 대한 lock을 가져오지 못해 무한대기 상황이 발생한다. 따라서 method.invoke(targetObject, args); 이부분 수행할 때에는 lock에 대한 처리가 없어야 한다.
님이 말씀하신대로 동일 thread에 대해서는 reentrant가 되네요... 제가 사용한 코드 상황을 보니 여러 Thread가 하나의 클래스에 있는 메소드를 호출하는데 이 클래스가 Proxy를 이용하여 생성된 클래스 입니다. 따라서 위와 같은 코드로 구성하면 원본 클래스에서는 동기화 작업을 잘 구성하였지만 InvocationHandler에 대해 동기화 처리를 하기 위해 lock을 걸 경우에는 조금 고려해서 걸어야 한다는 취지로 작성했습니다. *^^* 좋은 지적 감사합니다.
Java의 File 클래스에 있는 기능 중 renameTo 메소드는 잘못 사용할 가능성이 많은 메소드이다. Windows와 Linux에서 다르게 동작할 수 있으며 파일 시스템의 디스크 구성에 따라 다르게 작동할 수 있기 때문이다. 예를 들어 windows에서 C 드라이브에 있는 파일을 D 드라이브로 renameTo 메소드를 이용할 경우 rename 되지 않는다. 이유는 windows의 경우 논리적으로 다른 드라이버에서의 rename은 rename이 아니라 copy & delete old 이기 때문이다. 이런 로직을 renameTo 처리해 주면 좋겠지만 그렇지 않다. 또 다른 문제는 서브 디렉토리에 대한 문제이다. 디렉토리를 renameTo할 경우 해당 디렉토리 아래에 파일 또는 서브 디렉토리가 존재하는 경우에도 정상적으로 작동하지 않을 가능성이 크다. 따라서 rename의 경우 별도로 메소드를 만들어서 사용하는 것이 환경적인 문제로 발생하는 오동작을 피할 수 있다. 어제 개발 도중에 이런 문제로 인해 인터넷을 검색해 봤지만 쓸만한 코드를 찾지 못해 직접 만들었다.
종종 들러서 좋은정보 잘 보는 1人입니다 ^^
apache commons-io에 있는 FileUtils 에서 원하시는 기능을 찾을 수 있을것 같습니다.
moveXXX 류의 method 들이 renameTo 시도후 실패하면 copy & delete 하는 방식으로 구현되어 있습니다.