Java는 왜! 포인터를 쓰지 않는가

이은엽·2023년 7월 5일
0

포인터가 뭔데?

C나 C++을 공부하는 사람들을 친구로써 두다보면 많이 어려워하고 포기하는 부분인 포인터라는 개념을 확인할 수 있다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int size;
    printf("Enter the size of the array: ");
    scanf("%d", &size);

    // 동적으로 정수 배열 할당
    int* arr = (int*)malloc(size * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed.\n");
        return 1;
    }

    // 배열에 값 입력 받기
    printf("Enter the elements of the array:\n");
    for (int i = 0; i < size; i++) {
        scanf("%d", &arr[i]);
    }

    // 배열 요소 출력
    printf("Array elements: ");
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 동적 할당 해제
    free(arr);

    return 0;
}

위의 코드에서 int* arr은 int형 포인터로서 동적으로 할당된 정수 배열을 가리킵니다. malloc() 함수를 사용하여 메모리를 동적으로 할당하고, free() 함수를 사용하여 할당된 메모리를 해제합니다.

#include <iostream>

class MyClass {
private:
    int data;

public:
    MyClass(int value) : data(value) {}

    void printData() {
        std::cout << "Data: " << data << std::endl;
    }
};

int main() {
    // 동적으로 MyClass 객체 생성
    MyClass* myObj = new MyClass(42);

    // 포인터를 사용하여 객체 멤버에 접근
    myObj->printData();

    // 동적 할당 해제
    delete myObj;

    return 0;
}

위의 코드에서 MyClass* myObj는 MyClass 타입의 포인터로서 동적으로 생성된 객체를 가리킨다.
그 후 new 키워드를 사용하여 객체를 동적으로 생성하고, -> 연산자를 사용하여 포인터를 통해 객체의 멤버에 접근한다.

여기서 포인터는 변수의 메모리 주소를 가리키는 변수라고 할 수 있다.

즉, 변수의 데이터 내용이 아닌 위치를 가리키는 것이다.

그렇기 때문에 포인터를 사용하게 된다면 변수의 주소를 직접 조작하고, 그 주소에 접근하여 데이터를 읽거나 변경할 수 있게 된다.

포인터와 참조의 차이에 대해서 간단하게 알아볼 수 있는데
포인터와 참조는 둘다 주소를 통해서 원본 데이터에 접근을 하는 공통적인 특성을 가지고 있다.

차이점은 위에서 말햇듯이 포인터는 직접적인 주소를 접근이 가능하기 때문에 메모리를 직접적으로 컨트롤할 수 있게 된다.

하지만 참조는 직접적으로 메모리에 접근을 할 수 없기 때문에 메모리를 직접 컨트롤할 수 없다.

중요한 점은 참조는 메모리에 직접적으로는 접근이 불가능하지만 내부적으로는 이미 접근하고 있다는 것을 알길 바란다.

여기서 포인터는 직접적인 주소를 접근하기 때문에 주소 값이 잘못 변경되면 오류가 발생하게 된다.

여기서 JAVA는 포인터의 유연성과 성능보단 안정성을 고려하면서 포인터라는 개념을 개발자에게는 직접 제공하지 않는 것이다.

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.print("Enter the size of the array: ");
        int size = scanner.nextInt();

        // 동적으로 정수 배열 할당
        int[] arr = new int[size];

        // 배열에 값 입력 받기
        System.out.println("Enter the elements of the array:");
        for (int i = 0; i < size; i++) {
            arr[i] = scanner.nextInt();
        }

        // 배열 요소 출력
        System.out.print("Array elements: ");
        for (int i = 0; i < size; i++) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();
    }
}
class MyClass {
    private int data;

    public MyClass(int value) {
        this.data = value;
    }

    public void printData() {
        System.out.println("Data: " + data);
    }
}

public class Main {
    public static void main(String[] args) {
        // 동적으로 MyClass 객체 생성
        MyClass myObj = new MyClass(42);

        // 객체 멤버에 접근
        myObj.printData();
    }
}

JAVA Garbage Collector

여기서 Java가 직접적으로 주소를 접근할 필요가 없는 이유는 JVM에는 직접 메모리를 관리해주는 Garbage Collector(GC)가 존재하게 된다.

Garbage Collector는 Java 프로그램이 실행될 때 필요하지 않은 메모리를 감지하고 해제하여 프로그래머가 직접 메모리 관리를 하지 않게 해준다.


이때 5를 담고 있던 heap 영역은 Unreachable Object가 되는데 이를 제거해 메모리를 관리하는게 가비지 컬렉터가 되는 것이다.

Garbage Collector의 제거 과정

이 과정에서 가비지 컬렉터는 Mark라는 과정을 시행하게 되는데
스택에 할당된 모든 변수들의 연결된 주소값인 Reachable Object를 탐색하며 어떤 힙 영역이랑 연결되는지 Mark하게 된다.

이와 같은 진행을 한 후 Sweep을 통해 Mark가 되지 않은 객체들을 제거하게 된다.

제거하는 과정은 다음과 같다.

  1. Eden에서는 매번 new 생성자를 통해 새롭게 생기는 객체가 생기게 된다.

  2. Eden이 꽉차게 된다면 Minor GC가 일어나게 된다.

  3. 그러면 Eden에 Unreachable Object만 남기고 survivor1에 넘기게 된다. (이 과정에서 age라는 값을 1더하게 된다.)

  4. 그 후 survivor1이 꽉차면 survivor2에 넘기게 된다. (이 과정에서 age라는 값을 1더하게 된다.)

  5. survivor1과 survivor2를 왔다갔다하면서 꽉차면 Minor GC가 일어나 Unreachabel Object만 남기고 서로 넘기게 되는 것이다. (이 과정에서 넘어갈 때마다 age라는 값을 1더하게 된다.)

  6. 객체의 age가 일정 값 이상이 된다면 Old로 주로 사용되는 것이라는 생각을 하게 되어 Old로 이동하게 된다.

  7. Old에서도 꽉차게 된다면 Major GC가 일어나게 되며 Mark,Sweep이 이뤄지게 된다.

이와 같은 방식으로 java에서는 메모리 관리를 직접 해주기 때문에 포인터와 같은 방식을 사용하지 않고도 메모리에 직접 접근하지 않아도 효율적으로 메모리 관리를 할 수 있는 것이다.

이와 같이 java는 직접 메모리를 처리함으로써 개발자가 메모리에 직접적인 접근을 하는 것을 막아 코드의 안정성을 매우 높게 올렸다고 볼 수 있다.

Garbage Collector의 단점

하지만 직접적인 메모리 관리를 하지 않기 때문에
1. 성능 영향: GC 작업은 프로그램 실행 중에 발생하며, GC 실행 시간에 따라 프로그램의 일시 중단이 발생할 수 있습니다. 이로 인해 애플리케이션의 응답성이 저하될 수 있습니다.

  1. 예측 어려움: GC의 작동 방식과 타이밍은 JVM에 의해 제어되므로 개발자는 명확한 제어를 할 수 없습니다. 따라서 GC가 언제 실행될지 예측하기 어려울 수 있습니다. 이는 실시간 시스템이나 지연 시간이 중요한 애플리케이션에서 문제가 될 수 있습니다.

  2. 자원 사용: GC는 CPU 및 메모리 자원을 사용합니다. GC 작업 중에는 메인 애플리케이션 스레드와 동일한 자원을 공유하므로, GC 작업이 많아지면 애플리케이션의 성능에 영향을 줄 수 있습니다.

  3. 메모리 누수 가능성: GC가 자동으로 메모리를 관리하므로 메모리 누수가 발생할 수 있습니다. 예를 들어, 객체에 대한 참조가 제대로 해제되지 않으면 해당 객체는 GC의 수거 대상이 되지 않고 계속해서 메모리를 점유하게 될 수 있습니다.

  4. 객체 소멸의 타이밍 제어 어려움: GC는 객체가 더 이상 사용되지 않는지 판단하고 소멸시킵니다. 따라서 객체의 소멸 시점을 개발자가 직접 제어하기 어렵습니다. 이는 파일 핸들이나 네트워크 연결과 같이 명시적인 리소스 해제가 필요한 경우에 문제가 될 수 있습니다.

  5. 세밀한 메모리 관리 제한: GC를 사용하면 개발자가 메모리 할당 및 해제를 세밀하게 제어할 수 없습니다. 따라서 특정 애플리케이션에서 메모리 사용에 대한 최적화를 수행해야 하는 경우, GC를 사용하는 것보다는 명시적인 메모리 관리가 필요할 수 있습니다.

다음과 같은 단점은 생길 수 있게 된다.

참조 : https://sorjfkrh5078.tistory.com/77?category=1007499
https://sorjfkrh5078.tistory.com/278

0개의 댓글