devnoong.log
article thumbnail
Published 2022. 7. 27. 16:50
[JAVA] Stack 과 Heap에 대해서 JAVA
728x90

JVM 메모리 영역

 

https://devnoong.tistory.com/5

 

[JAVA] JVM 구조

JVM 이란? JavaVirtualMachine의 줄임말로 OS에 종속되지 않고 CPU가 JAVA를 인식 및 실행 할 수 있게 도와주는 가상 장치를 뜻한다. 자바 소스 코드로 작성된 자바 파일은 Javac라는 Java 컴파일러에 의해 JVM

devnoong.tistory.com

JVM구조에 대해서 이전 시리즈로 글을 올린 적이 있었다.

 

JVM의 자세한 구조는 위의 링크를 참조하면 되고 Stack과 Heap에 대해 자세하게 알아보기 위해 기록한다.

 

간략하게 JVM에 할당된 메모리 영역을 보면 아래와 같다.

 

 

Code 영역

실행할 프로그램의 코드가 저장되는 영역으로 ClassLoader에 의해 배치된 코드들을 뜻한다.

CPU는 코드 영역에 저장된 명령어를 하나씩 가져가서 실행 엔진에 의해 처리하게 된다.
데이터 영역

프로그램의 전역 변수와 정적(static) 변수가 저장되는 영역으로 모든 쓰레드가 공유하고 있는 MethodArea에 해당하는 영역이다.

데이터 영역은 프로그램의 시작과 함께 할당되며, 프로그램이 종료되면 소멸한다.

Stack 영역

기본 자료형 (int, double, byte, long, boolean 등)에 해당되는 지역변수 (매개 변수 및 블럭문 내 변수 포함)의 데이터의 값이 저장되는 공간이다.

② Heap 영역에 생성된 Object 타입의 데이터의 참조값이 할당된다.

③ 메소드 지역 변수 외에도 if문 , 반복문, 예외처리를 위한 try문등도 포함된다.

 

각각의 Thread마다 스택이 존재하며 공유하지 않는다.

 

해당 메소드가 호출될 때 메모리에 할당되고 종료되면 메모리가 해제되어 함수 내부에서 선언한 모든 지역변수들은 스택에서 pop 되어 사라진다.

 

스택은 후입 선출(LIFO, Last-In First-Out) 방식에 따라 동작하므로, 가장 늦게 저장된 데이터가 가장 먼저 인출된다.

 

크기가 정해져 있어 컴파일시 크기가 결정된다.

 

stackOverFlowError란?

지정한 스택 메모리 사이즈보다 더 많은 스택 메모리를 사용하게 되어 에러가 발생하는 상황을 일컫는다.

 

즉 스택 포인터가 스택의 경계를 넘어갈때 발생한다.

BufferOverFlowError 와의 차이점

버퍼 오버 플로우는 보통 데이터를 저장하는 과정에서 그 데이터를 저장할 메모리 위치가 유효한지를 검사하지 않아 발생한다.

버퍼(Buffer) 는 데이터가 저장될 수 있는 가용 메모리 공간으로 단순히 메인 메모리만이 아닌 다른 하드웨어에서 사용하는 임시 저장 공간을 뜻한다.

오버플로우는 데이터가 지정된 크기의 공간보다 커서 해당 메모리 공간을 벗어 나는 경우를 말한다.

 

StackOverflowError 발생 종류

① 재귀(Recursive)함수

 

반복과 재귀의 가장 큰 차이는, 재귀는 스스로를 호출한다는 점에 있다. 때문에 코드의 길이는 짧다.

 

그러나 분석을 해보면, 호출이 상당히 많아지면 시간 복잡도가 지수 단위(Exponential)로 증가할 수 있다.

 

그러므로, 재귀의 사용은 짧은 코드라는 점에서 이점이지만, 반복보다 높은 시간 복잡도를 가진다.

 

하지만 이는 대량의 스택이 쌓이다보면 자칫 프로그램 에러를 발생하는 경우가 발생한다.

 

public static void main(String[] args) {
		long before =  System.currentTimeMillis(); //시작시간
        // 수행
        int n = 10000000;
		int result = calculateFibo(n); 
		long after = System.currentTimeMillis(); //종료시간
		System.out.println(result);
		System.out.println("걸린시간 : "+(after-before)/1000+"ms");
	}
	
	public static int calculateFibo(int number) {
		if(number==1 || number==2) {
			return 1;
		}
		return  calculateFibo(number - 2) + calculateFibo(number - 1);
	}

 

 

예제로 피보나치 수열을 이용하여 10000000개를 호출하니까 위 사진과 같이 StackOverFlow가 발생하였다.

 

이를 해결하기 위해서는 꼬리 재귀 방식이 존재하지만 ,

 

JAVAC에서는 꼬리재귀를 지원하지 않기때문에 꼬리재귀 방식을 사용해도 일반재귀 사용법이랑 동일하다.

 

자세한 내용은 추후에 재귀와 꼬리재귀편으로 작성하고, 추가적으로 재귀함수 속도를 측정하고자 한다.

 

숫자를 줄여서 50으로 설정 해놓고 수행했을때는 24초가 소요되었다.

 

 

수행시간 속도를 줄이기 위해 아래는 메모이제이션 방식으로 변환해보왔다.

 

static int[] menoization;
	public static void main(String[] args) {
		long before =  System.currentTimeMillis(); //시작시간
        // 수행
        int n = 50;
        menoization = new int[n+1];
		int result = calculateFibo(n); 
		long after = System.currentTimeMillis(); //종료시간
		System.out.println(result);
		System.out.println("걸린시간 : "+(after-before)/1000+"s");
	}
	
	public static int calculateFibo(int number) {
		if(menoization[number] > 0) return menoization[number];
		if(number==1 || number==2) {
			return menoization[number] =1;
		}
		return  menoization[number] = calculateFibo(number - 2) + calculateFibo(number - 1);
	}

 

메모이제이션 방법으로 적용했을때 수행시간이 0초로 줄어든것을 확인 할 수 있다.

 

메모제이션방식이나 일반 재귀방식이나 스택이 동일 개수로 생성되는것은 똑같지만 계산할 필요없이 배열의 값을 이용하기때문에 수행시간면에서 차이를 보이는 것 같았다.

 

고로, 메모제이션 방식을 사용했다고 해서 재귀함수에서 발생한 stackOverFlow가 발생하지 않는것은 아니다.

 

② 상호 참조

상호 참조란 두 클래스간에 생성을 위임하면서 체이닝을 이루게 되면 발생하게 된다.

 

public static void main(String[] args) {
		ClassOne one = new ClassOne();
	}
	
	public static class ClassOne {
	    private int oneValue;
	    private ClassTwo clsTwoInstance = null;

	    public ClassOne() {
	        oneValue = 0;
	        clsTwoInstance = new ClassTwo();
	    }
	}

	public static class ClassTwo {
	    private int twoValue;
	    private ClassOne clsOneInstance = null;

	    public ClassTwo() {
	        twoValue = 10;
	        clsOneInstance = new ClassOne();
	    }
	}

 

 

main함수에서 classOne을 생성하고 있다.

classOne은 생성자 생성하면서 classTwo 호출하고 classTwo도 생성자를 생성하면서 classOne 호출하고 있다.

 

 

 

그 결과, 위 사진과 같이 stackOverFlow가 발생하였다.

 

이를 해결하기 위해서는 상호간의 생성 관계를 만들지 않으면 된다.

 

또는 클래스내에서 인스턴스를 직접 생성하기보다는 주입을 통해서 인스턴스를 생성하면 된다고한다.

 

이에 관련된 내용도 추후에 이펙티브 자바에 대한 내용으로 정리할 예정이다.

 

③ 본인 참조

상호 참조와 원인은 비슷하다.
단순히 본인 클래스 내에서 본인을 생성하면서 무한으로 생성되는 이슈이다.

 

public static void main(String[] args) {
		ClassOne one = new ClassOne();
	}
	
	public static class ClassOne {
	    private int oneValue;

	    public ClassOne() {
	        oneValue = 0;
	        ClassOne newClass = new ClassOne();
	    }
	}

 

 

main함수에서 classOne을 생성하고 있다.
classOne은 생성자를 생성하면서 본인을 호출하고 있다.

 

 

 

그 결과, 위 사진과 같이 stackOverFlow가 발생하였다.
해결방법 역시 상호 참조 방식과 동일하다.

 

Heap영역

① 모든 자바 클래스의 인스턴스(instance)와 배열(array)이 할당되는 곳으로,  런타임 데이터(Object타입의 데이터)를 저장하는 영역이다.

② 참조 자료형 메모리이다.

 

모든 Thread 공유하는 공간이다.

JVM이 시작될 때 생성되어 런타임시 크기가 결정되고 크기가 커졌다 작아졌다 한다.
힙 영역의 크기를 임의로 설정하고 싶을 경우 -Xms VM option으로 설정 하면 된다.

 

힙 참조 값은 스택 메모리 영역에서 가지고 있고 해당 객체를 통해서만 힙 메모리에 있는 인스터스들을 핸들링 할 수 있고,
스택 POP되어 스택 힙 참조값이 사라져도 Heap안에 있는 Object 데이터들은 유지된다.

 

public static void main(String[] args) {
		print();
	}
	
	public static void print() {
		String str = "test";
		System.out.println(str);
	}

 

위 예제 코드에서 print함수가 시작된 후 종료되면,
str 객체 참조값은 pop되었지만, str 객체의 데이터 "test"는 heap에 유지되고 unreachable객체로 인식된다.

 

 

반대로 스택 힙 참조값을 핸들링하면서 새로운 Heap Object 생성될수 있다.

 

자바에서 Wrapper class (Integer, Character, Byte, Boolean, Long, Double, Float, Short ) 클래스는 모두 불변객체(Immutable) 이다.

 

불변객체는 heap 에 있는 같은 오브젝트를 레퍼런스 하고 있는 경우라도, 새로운 연산이 적용되는 순간 기존객체는 유지하고 새로운 오브젝트가 heap 에 새롭게 할당된다.

 

public static void main(String[] args) {
		Integer i = 1; 
		i =i+1;
		System.out.println(i);
	}

 

위 예제 코드에서 i의 데이터타입은 Integer로 Wrapper클래스에 해당된다.
똑같은 변수를 가리키고 있지만, 래퍼클래스는 불변객체이므로 연산을 통해 값이 변경되었기때문에 새로운 오브젝트가 생성되고 i에 할당된다.

 

기존객체는 unreachable 객체로 바뀐다.

 

 

 

추가적으로 String관련 객체도 불변객체(immutable)다.

 

String 객체를 생성하는 방법에는 두가지 방법이 존재한다.

 

① 문자열 리터럴을 이용한 방법과 ② new String("문자열")을 이용한 방법이 존재한다.

 

https://dololak.tistory.com/718

 

① 문자열 리터럴을 이용하여 생성하는 경우는 String Pool에 저장되는데 그 위치는 자바 버전에 따라 다르게 저장된다.

 

 

         JAVA 6이하

 

         JVM Heap내부의 Permanent Generation이라는 위치에 저장한다.
         JAVA8부터는 Permanent Generation부분이 삭제되고 Metaspace로 대체 되었다.

 

https://code-factory.tistory.com/48

 

        JAVA 7이상

 

        다른 일반 객체들과 마찬가지고 Perm영역이 아닌 Heap에 String Pool을 생성한다.

 

 

② new String을 이용해서 생성하면 Heap영역의 Yong Generation , Old Generation에 생성된다.

 

이러한 String 객체도 불변객체이기때문에 문자열값이 변경되면 새로운 String 객체 생성되어 할당 된다.

 

그렇기때문에 String의 값이 자주 바뀌는 값으로 할당 된다고 하면, StringBuilder 객체를 이용해서 사용하는 것이 효율적이다.

 


 

JVM Heap에 대해 자세히 알기 위해서는 GC(가비지 컬렉션)를 알아야한다.

 

  • GC(Garbage Collection)

      GC는 메모리 관리 기법 중 하나로, 동적으로 할당했던 메모리 영역 중 필요 없게 된 영역을 해제한다.

 

     여기서 동적으로 할당했던 메모리 영역은 프로그램 런타임에 사용되는 Heap 영역 메모리를 뜻하고,

     필요 없게 된 영역은 어떤 변수도 가리키지 않게 된 영역(unreachable 객체)을 의미한다.

 

     JVM은 Mark And Sweep 방식으로 작동한다.

 

Mark And Sweep 루트에서부터 해당 객체에 접근 가능한지, 아닌지를 해제의 기준으로 삼는다.

 

 루트부터 그래프 순회를 통해 연결된 객체들을 찾아내고 (Mark)

 

 연결이 끊어진 객체들은 지우는 방식이다. (Sweep)

 

 Sweep 이후에는 분산되어 있던 메모리를 정리하여 메모리 파편화를 막는다. (Compaction)

 

다만, Mark And Sweep에서 Compaction은 필수는 아니다.

 

루트로부터 연결된 객체는 Reachable, 연결되지 않았다면 Unreachable이라고 부른다.

장점

Mark And Sweep 방식을 사용하면, 루트로부터 연결이 끊긴 순환 참조되는 객체들도 지울 수 있다.

 

단점

객체의 reference count가 0이 되면 지워버리는 reference counting 방식과는 달리, Mark And Sweep 의도적으로 특정 순간에 GC를 실행시켜야 한다.

 

즉 어느 순간에는 실행 중인 어플리케이션이 GC에게 컴퓨터 리소스들을 내줘야 한다.

 

따라서 어플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 것이 꽤나 어려운 최적화 작업이라고 합니다.

 


이러한 Mark And Sweep 방식을 이용해 JVM GC는 필요 없는 동적 메모리 영역을 자동 해제한다.

 

JVM에서 Root Space는 Heap 영역 메모리에 대한 참조를 들고 있을 수 있는 영역을 뜻한다.

 

  1. Stack의 로컬 변수
  2. Method Area의 Static 변수
  3. Native Method Stack의 JNI 참조

 

 

이러한 Root Space를 이용하여 Mark And Sweep 방식으로 동적 메모리 영역을 관리를 진행하나, 무작정 GC를 실행시켜 메모리 관리를 할 수는 없다.

 

▶ GC 실행의 타이밍

JVM의 Heap은 크게 두 영역  Young Generation ,  Old Generation으로 나뉘어진다.

 

  • Yong Generation

        Young Generation에서 발생하는 GC는 minor gc라고 한다.

 

         Young Generation은 또다시 세가지로 나뉜다.

         Eden / Survival 0 / Survival 1 영역으로 나뉜다.

 

        Eden 새롭게 생성된 객체들이 할당되는 영역이고,

        Survival 영역은 minor gc로 부터 살아남은 객체들이 존재하는 영역이다.

        Survival 영역에는 특별한 규칙이 존재하는대 survival 0 혹은 Survival 1 둘 중 하나는 꼭 비어 있어야 한다.

 

       minor gc의 실행 타이밍은 바로 Eden 영역이 꽉 찼을 때이다.
       아래의 그림과 같은 상황 (회색 네모는 메모리에 할당된 객체)
       

     

 

                  minor gc가 발생하고 난 뒤 Reachable이라 판단된 객체들은 Survival 0 영역 으로 옮겨진다.

 

                     

           Minor gc에서 살아남은 객체는 Age bit의 값이 1씩 증가한다.

           이후 Eden 영역이 꽉 차게 되면 Survival 0 혹은 Survival 1 이동하면서 위의 과정을 반복하게 된다.

 

           JVM GC에서는 일정 수준의 Age bit를 넘어가면 오래도록 참조될 객체구나 라고 판단하고,

           해당 객체를 Old Generation에 넘겨준다. (Promotion)

 

 

  • Old Generation

       Old Generation에서 발생하는 GC는 major gc라고 한다.

 

      Old Generation은 Young Generation 보다 크게 할당되며,

      크기가 큰 만큼 Young Generation 보다 GC가 적게 발생하지만 더 많은 시간이 소요 된다.

 

      이때 GC를 실행하는 스레드를 제외한 모든 스레드는 작업을 멈추게 되고 이를 'Stop-the-World' 라 한다.

 

      Minor GC보다 더 오래 걸리고 이 작업이 너무 잦으면 프로그램 성능에 문제가 될 수 있다.

 

  즉, Young Generation은 주기적으로 GC가 발생하고, 상대적으로 오래 사용되는 객체는 Old Generation에서 관리한다.

 

          Young Generation은 한번에 모든 영역을 비우기때문에 해당되는 나머지 연속되는 다른 여유 공간이 생긴다. 

                                                               

                                                                    메모리 파편화를 방지한다.

 

OutOfMemoryError : Java heap space 란?

 

Heap이 가득차 있고 JVM이 새 객체에 메모리를 할당할 수 없을 때 에러가 발생하는 상황을 일컫는다.

 

실제로 사용되지 않는 객체의 reference를 프로그램에서 잡고 있어 GC에 의해 처리되지 않고 프로그램 내에서도 접근하여 사용될 수 없는 객체로서 메모리를 점유하게 된다.

 

즉 힙 메모리에 새 객체를 위한 공간이 남아 있지 않으면 발생한다.

 

OutOfMemoryError : Java heap space 원인

① Heap 사이즈가 작은 경우

 

Heap 최대 크기 Application 메모리 요구량에 비해 작게 설정된 경우이다.

 

-Xms200m
-Xmx200m 으로 작게 설정 해놓고 무한 객체를 생성하는 코드를 아래와 같이 실행 해 보왔다.

 

        int i=0;
		List<Object> list = new ArrayList<>();
		while(true)
        {
			list.add(new Object());
			System.out.println((i+1)+"개째 생성중...."+list.get(i).toString());
            i++;
        }

 

 

수 없이 생성 하다가 결국 새로운 객체를 생성 할 공간이 부족해 Object 생성을 실패하였다.

 

금방 heap space over가 발생 할 줄 알았는데 생각 보다 오래 걸려서 의외였다.

그만큼 GC가 자동으로 관리해주기때문에 바로 발생 하지 않았던 것 같다.

 

무튼

 

→ Memory Leak이 발생하지 않았음에도 OOME 발생시 Heap 크기 부족 의심 가능
 -Xmx Size 옵션으로 Java Heap 최대 크기 증가해야한다.

 

 

② Memory Leak이 발생한 경우

 

  • Application 에서는 사용되지 않지만 Object 참조 관계가 복잡할때 실수로 참조되지 않은 객체를 지속적으로 참조 상태 유지하여 GC에 의한 메모리 해제 불가로 발생한다.
  • JDK 버그 또는 WAS 버그에 의해 라이브러리에서 발생하는 로직 오류로 인해 발생한다.

③ finalize 메소드에 의한 Collection 지연

 

Class에 finalize 메소드가 정의된 경우 해당 Class Type의 Object는 GC발생시 즉각 Collection 되지않는다.

 

 Finalization Queue에 들어간 후 Finalizer에 의해 정리
▷ Finalizer는 Object의 finalize 메소드 실행 후 메모리 정리 작업 수행

▷ Finalizer의 작업시간이 길어지는 경우, 객체가 오랫동안 메모리 점유 → OOME 발생

 

finalize 메소드 사용 비권장한다고 나와있다.

 

Stack과 Heap의 장단점

Stack

  • 매우 빠른 액세스
  • 변수를 명시적으로 할당 해제할 필요가 없다
  • 공간은 CPU에 의해 효율적으로 관리되고 메모리는 단편화되지 않는다.
  • 지역 변수만 해당된다
  • 스택 크기 제한(OS에 따라 다르다)
  • 변수의 크기를 조정할 수 없다

Heap

  • 변수는 전역적으로 액세스 할 수 있다.
  • 메모리 크기 제한이 없다
  • (상대적으로) 느린 액세스
  • 효율적인 공간 사용을 보장하지 못하면 메모리 블록이 할당된 후 시간이 지남에 따라 메모리가 조각화 되어 해제될 수 있다.
  • 메모리를 관리해야 한다 (변수를 할당하고 해제하는 책임이 있다)
  • 변수는 자바 new를 사용

 


참고

728x90