Welcome! Everything is fine.

[Java/Study] 김영한의 실전 자바 기본 - 스터디 4회차 본문

Java

[Java/Study] 김영한의 실전 자바 기본 - 스터디 4회차

개발곰발 2024. 10. 26.
728x90

 

인프런 강의 <김영한의 실전 자바 - 기본편>을 보고 정리한 내용입니다.

매주 모여 각자 정리한 내용을 기반으로 발표하고 질문 공유하는 스터디입니다.


 

자바 메모리 구조와 static

자바 메모리 구조

메서드 영역

✔️ 메서드 영역 : 프로그램을 실행하는데 필요한 공통 데이터를 관리하는 영역으로, 해당 데이터는 프로그램의 모든 영역에서 공유한다.

스택 영역

✔️  스택 영역 : 실제 프로그램이 실행되는 영역

스택 영역에 대해 공부하기 전, 스택의 개념에 대해 알아야 한다. 스택 자료구조는 익숙하기 때문에 여기서 또 정리하진 않고 내가 이전에 정리한 내용을 아래 더보기에 올려두었다.

아래 코드와 결과는 스택 영역이 사용되는  예시를 나타낸 것이다.

public class JavaMemoryMain1 {
    public static void main(String[] args) { // main() 스택 프레임 생성
        // 자바 메모리 구조 중 스택 영역이 사용되는 경우
        System.out.println("main start");
        method1(10); // method1 스택 프레임 생성
        System.out.println("main end");
    }

    static void method1(int m1) {
        System.out.println("method1 start");
        int cal = m1 * 2;
        method2(cal); // method2 스택 프레임 생성
        System.out.println("method1 end");
    }

    static void method2(int m2) {
        System.out.println("method2 start");
        System.out.println("method2 end");
    }
}
main start
method1 start
method2 start
method2 end
method1 end
main end

 

힙 영역

✔️  힙 영역 : 객체(인스턴스)와 배열이 생성되는 영역으로, 가비지 컬렉션(GC)이 이루어진다.

 

필드는 각자 다 다른 값을 넣어야하기 때문에 메모리를 따로 할당하지만 메서드는 공통된 코드를 공유한다.

 

스택 영역과 힙 영역이 함께 사용되는 경우도 있다.

public class Data {
    private int value;

    public Data(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
public class JavaMemoryMain2 {
    public static void main(String[] args) { // main() 스택 프레임 생성
        // 자바 메모리 구조 중 힙 영역이 사용되는 경우
        System.out.println("main start");
        method1(); // method1 스택 프레임 생성
        System.out.println("main end");
    }

    static void method1() {
        System.out.println("method1 start");
        Data data1 = new Data(10); // 힙 영역에 Data 인스턴스 생성(이때 생성된 참조값은 method1 스택 프레임에 보관)
        method2(data1);
        System.out.println("method1 end");
    }
    // method1이 스택 프레임이 제거되면 지역변수 data1도 함께 제거 -> 이제 x001 참조값을 가진 Data 인스턴스를 참조하는 곳이 없음
    // 이렇게 더이상 사용되지 않는 객체는 메모리만 차지 -> GC(가비지 컬렉션)이 참조가 사라진 인스턴스를 메모리에서 제거함

    static void method2(Data data2) { // method1()과 같은 인스턴스 참조
        System.out.println("method2 start");
        System.out.println("data2.value = " + data2.getValue());
        System.out.println("method2 end");
    }


}

static 변수

static 변수특정 클래스에서 공용으로 사용할 수 있는 변수를 말한다. 즉, static 변수는 클래스의 모든 인스턴스가 동일한 값을 가지며, 클래스가 로드될 때 메모리(메서드 영역)에 한 번만 생성된다. static 변수는 정적 변수 또는 클래스 변수라고도 한다. 

⭐ 용어 정리
멤버 변수(필드)는 static이 붙지 않은 인스턴스 변수와 static이 붙은 클래스 변수로 분류할 수 있다.
- 인스턴스 변수 : 인스턴스를 생성해야 사용 가능, 인스턴스를 새로 만들 때 마다 새로 만들어진다.
- 클래스 변수(=static 변수, 정적 변수) : 클래스에 바로 접근해 사용 가능, 자바 프로그램을 시작할 때 딱 1개가 만들어지며 여러곳에서 공유하는 목적으로 사용된다.

 

강의 예제를 통해 static 변수가 왜 필요한지 이해할 수 있었다. 인스턴스의 개수를 세는 프로그램을 만든다면 어떻게 만들 수 있을까? 다음과 같은 방법들을 사용해볼 수 있다.

  1. 인스턴스 내부 변수에 count 저장
  2. 외부 객체에 count 저장
  3. static 변수 사용해 저장

이 방법들 중 1번은 인스턴스를 생성할 때마다 초기화되어서 count의 개수를 셀 수도 없다. 

public class Data1 {
    public String name;
    public int count;

    public Data1(String name) {
        this.name = name;
        count++;
    }
}
public class DataCountMain1 {
    public static void main(String[] args) {
        Data1 data1 = new Data1("A");
        System.out.println("A count = " + data1.count);

        Data1 data2 = new Data1("B");
        System.out.println("B count = " + data2.count);

        Data1 data3 = new Data1("C");
        System.out.println("C count = " + data3.count);

        // 객체를 생성할 때마다 Data1 인스턴스가 새로 만들어짐(count값 계속 0으로 초기화)
    }
}

 

2번은 정상적으로 count를 셀 수는 있지만 별도의 클래스를 추가로 사용해야 하고, 생성자가 복잡해진다는 문제점이 있다.

public class Counter {
    public int count;
}
public class Data2 {
    public String name;

    public Data2(String name, Counter counter) {
        this.name = name;
        counter.count++;
    }
}
public class DataCountMain2 {
    public static void main(String[] args) {
        Counter counter = new Counter(); // Counter 인스턴스를 공용으로 사용 -> 객체 생성할 때마다 값 증가
        Data2 data1 = new Data2("A", counter);
        System.out.println("A count = " + counter.count);

        Data2 data2 = new Data2("B", counter);
        System.out.println("B count = " + counter.count);

        Data2 data3 = new Data2("C", counter);
        System.out.println("C count = " + counter.count);
    }
}

 

이러한 문제점은 3번처럼 static 키워드를 사용해 해결할 수 있다.

public class Data3 {
    public String name;
    public static int count; // static 키워드 사용 -> 공용 변수 만듦

    public Data3(String name) {
        this.name = name;
        count++;
    }
}

 

static 변수를 사용할 때는 클래스명에 바로 점(.)을 찍어 사용한다.

public class DataCountMain3 {
    public static void main(String[] args) {
        Data3 data1 = new Data3("A");
        System.out.println("A count = " + Data3.count);

        Data3 data2 = new Data3("B");
        System.out.println("B count = " + Data3.count);

        Data3 data3 = new Data3("C");
        System.out.println("C count = " + Data3.count);
    }
}

 

변수와 생명주기
* 클래스 변수 > 인스턴스 변수 > 지역 변수 순으로 생명 주기가 길다!
- 지역 변수(매개변수 포함) : 스택 영역의 스택 프레임 안에 보관되는 변수. 메서드 종료 시 스택 프레임이 제거되면서 그 안의 지역 변수도 함께 제거되므로 생존 주기가 짧다.
- 인스턴스 변수 : 힙 영역의 인스턴스에 있는 멤버 변수. GC(가비지 컬렉션)가 발생하면 제거되므로 보통 지역 변수보다는 생존 주기가 길다.
- 클래스 변수 : 메서드 영역의 static 영역에 보관되는 변수. 해당 클래스가 JMV에 로딩되는 순간 생성되고 JVM이 종료될 때 제거되므로 생명 주기가 가장 길다.

 

static 변수는 클래스와 인스턴스를 통해 접근할 수 있지만, 인스턴스를 통한 접근은 권장되지 않는다. 마치 인스턴스 변수에 접근하는 것으로 오해할 수 있기 때문이다. 딱 보자마자 'static 변수구나!'라는 생각이 들도록 클래스를 통한 접근이 권장된다. 

// 인스턴스를 통한 접근 - 권장X
Data3 data4 = new Data3("D");
System.out.println("D count = " + data4.count);

// 클래스를 통한 접근
System.out.println(Data3.count);

static 메서드

보통 인스턴스를 사용하는 것은 멤버 변수를 사용하기 위한 목적일 때가 많다. 만약 클래스가 멤버 변수가 없고 기능만 제공한다면 굳이 인스턴스를 사용할 필요가 없는 것이다. static 메서드를 사용하면 불필요한 객체 생성 없이 메서드를 사용할 수 있다. static 메서드는 인스턴스 생성 없이 클래스명을 통해 바로 호출할 수 있어 정적 변수처럼 딱 보면 정적 메서드라는 것을 알 수 있다.

 

아래 예시 코드처럼 인스턴스 변수 없이 단순히 기능만 제공하는 메서드인 경우 static을 붙여 더 편리하게 사용할 수 있다.

public class DecoUtil1 {
    public String deco(String str) {
        return "*" + str + "*";
    }
}

 

static이 붙은 정적 메서드는 정적 변수처럼 인스턴스 생성 없이 클래스명을 통해 바로 호출이 가능하다.

public class DecoUtil2 {
    public  static String deco(String str) {
        return "*" + str + "*";
    }
}

 

다음과 같이 static이 붙은 메서드는 클래스명을 통해 바로 불러올 수 있다. static 메서드도 인스턴스를 통한 접근은 가능하지만, 클래스를 통한 접근이 권장된다.

public class DecoMain {
    public static void main(String[] args) {
        String s = "hello Java";
        
        DecoUtil1 utils = new DecoUtil1();
        String deco1 = utils.deco(s);
        System.out.println("before: " + s);
        System.out.println("after: " + deco1);
        
        // 클래스에서 바로 불러오기 가능한 static 메서드
        String deco2 = DecoUtil2.deco(s); 
        System.out.println("before: " + s);
        System.out.println("after: " + deco2);
    }
}

 

매번 클래스명을 적어 접근하는 것이 번거롭다면, 다음과 같이 아예 import를 해서 더 간소화해서 사용할 수 있다.

import static static2.DecoUtil2.deco;
⭐ 용어 정리
- 인스턴스 메서드 : static이 붙지 않은 메서드로, 인스턴스를 생성해야 사용할 수 있다.
- 클래스 메서드(=static 메서드, 정적 메서드) : static이 붙은 메서드로, 인스턴스 생성 없이 메서드를 바로 호출 할 수 있다. 
❔main 메서드에서 static 메서드만 호출할 수 있는 이유
main 메서드 역시 static이기 때문에 객체 생성 없이도 실행된다. main 메서드가 static 메서드이기 때문에 static 메서드만 호출할 수 있기 때문이다.

final

final 키워드는 클래스, 메서드, 변수를 선언할 때 사용된다. final은 뜻 그대로 "끝! 값 변경 불가!"라고 생각하면 기억하기 쉬울 것 같다. 최초 한 번만 할당할 수 있기 때문에 이미 할당한 final 변수에 다시 값을 할당하려고 하면 컴파일 오류가 난다.

public class FinalLocalMain {
    public static void main(String[] args) {
        // final 지역 변수
        final int data1;
        data1 = 10; // 최초 한 번만 할당 가능
//        data1 = 20; // 컴파일 오류

        // final 지역 변수2
        final int data2 = 10;
//        data2 = 20; // 컴파일 오류

        method(10);
    }

    static void method(final int parameter) {
//        parameter = 20; // 컴파일 오류
    }
}

 

필드에서 final을 사용할 때는 생성자로 한 번만 초기화할 수 있다. 물론 그 전에 값이 할당된다면 생성자로 초기화할 수 없다. 필드가 final로 되어있고, 값이 미리 정해져있다면 인스턴스를 생성할 때마다 같은 값을 가진다. 모든 인스턴스가 같은 값을 사용하면 중복이 발생하고, 메모리를 낭비하게 된다. 이 때 static을 함께 사용하여 문제를 해결할 수 있다.

static final를 사용하면 바뀌지 않는 공용변수(상수)를 사용할 수 있다!

 

상수란 다시 말해 변하지 않고 항상 일정한 값을 값는 수를 의미한다. 자바에서 상수의 특징은 다음과 같다.

  • static final 키워드 사용
  • 대문자 사용, 구분은 _(언더스코어) 사용
  • 고정된 값 자체를 사용하는 것이 목적
  • 필드에 직접 접근해 사용
public class Constant {
    // 수학 상수
    public static final double PI = 3.14;
    
    // 시간 상수
    public static final int HOURS_IN_DAY = 24;
    public static final int MINUTES_IN_HOUR = 60;
    public static final int SECONDS_IN_MINUTE = 60;
    
    // 애플리케이션 설정 상수
    public static final int MAX_USERS = 2000;
}

 

final을 참조형 변수에 사용하면 참조값을 변경할 수 없지만, 참조 대상의 값은 변경할 수 있다. 

public class FinalRefMain {
    public static void main(String[] args) {
        final Data data = new Data(); // 참조형
//        data = new Data(); // 컴파일 오류, 새로 담을 수 없음(참조값 변경 불가능)

        // 참조 대상의 값은 변경 가능
        data.value = 10;
        System.out.println(data.value);
        data.value = 20;
        System.out.println(data.value);
    }
}

상속

상속은 이름 그대로 어떤 속성과 기능을 자식이 물려받는 것을 말한다. 객체 지향 프로그래밍의 핵심 요소 중 하나로, 기존 클래스의 필드와 메서드를 새로운 클래스에서 재사용하게 해준다. extends 키워드로 상속을 표현하며, 그 대상은 하나만 선택할 수 있다.

  • 부모 클래스(슈퍼 클래스) : 상속을 통해 자신의 필드와 메서드를 다른 클래스에 제공하는 클래스
  • 자식 클래스(서브 클래스) : 부모 클래스로부터  필드와 메서드를 상속받는 클래스

다음과 같이 Car 라는 부모 클래스를 만들고 자식 클래스인 ElecticCar에서 Car를 상속하여 사용한다. ElecticCar에 move()가 정의되어있지 않더라도 Car에 정의되어 있으므로 정상적으로 출력된다. 이렇게 하면 어떤 자동차를 만들더라도 move() 는 공통적으로 사용할 수 있다.

public class Car {
    public void move() {
        System.out.println("차를 이동합니다.");
    }
}
public class ElectricCar extends Car {
    public void charge() {
        System.out.println("차를 충전합니다.");
    }
}
public class CarMain {
    public static void main(String[] args) {
        ElectricCar electricCar = new ElectricCar();
        electricCar.move();
        electricCar.charge();
    }
}

 

상속 관계를 객체로 생성할 때 메모리 구조는 다음과 같다. 상속 관계를 사용하면 인스턴스를 생성했을 때 자식 클래스뿐만 아니라 부모 클래스까지 생성된다. 호출하는 변수의 타입이 ElectricCar 이므로 내부에 같은 타입인 ElectricCar를 통해 찾으려는 메서드를 찾게 된다. 만약 메서드가 없으면 부모 타입인 Car로 올라가서 찾아 호출하게 된다.

  • 상속 관계의 객체를 생성하면 그 내부에는 부모와 자식이 모두 생성된다.
  • 상속 관계의 객체를 호출할 때, 대상 타입을 정해야 한다. 이때 호출자의 타입을 통해 대상 타입을 찾는다.
  • 현재 타입에서 기능을 찾지 못하면 상위 부모 타입으로 기능을 찾아서 실행한다.

메서드 오버라이딩

부모 타입의 기능을 자식에서 재정의하는 것을 메서드 오버라이딩이라고 하며, @Override 애노테이션을 사용한다. 해당 애노테이션은 필수가 아니지만, 실수로 오버라이딩을 못하는 경우를 방지해주기 때문에 코드의 명확성을 위해 붙여주는 것이 좋다.

public class ElectricCar extends Car {
    @Override
    public void move() {
        System.out.println("전기차를 빠르게 이동합니다."); // 기능을 새로 재정의
    }
    public void charge() {
        System.out.println("차를 충전합니다.");
    }
}

 

상속과 접근 제어

자식 클래스인 Child에서 패키지가 다른 부모 클래스인 Parent에 얼마나 접근할 수 있을까? 이런 경우에는 public, protected 필드나 메서드만 접근할 수 있다. 자식 타입에서 부모 타입의 기능을 호출할 때, 부모 입장에서 보면 외부에서 호출 한 것과 같으므로 접근 제어자가 영향을 준다.

super

super 키워드는 부모클래스에 대한 참조를 의미한다. 부모와 자식의 필드명이나 같거나 메서드가 오버라이딩 된 경우, super 키워드를 사용한다.

public class Parent {
    public String value = "parent";

    public void hello() {
        System.out.println("Parent.hello");
    }
}

 

this는 생략 가능하지만, super를 붙여 부모를 나타낼 수 있다.

public class Child extends Parent {
    public String value = "child";

    @Override
    public void hello() {
        System.out.println("Child.hello");
    }

    public void call() {
        System.out.println("this value = " + this.value); // 자기 자신
        System.out.println("super value = " + super.value); // 부모

        this.hello(); // this 생략 가능
        super.hello();
    }
}

 

상속 관계를 사용하면 자식 클래스의 생성자에서 부모 클래스의 생성자를 반드시 호출해야 한다. 상속 관계에서 부모의 생성자를 호출할 때는 super(...)를 사용하면 된다.

 

먼저 ClassA라는 최상위 클래스를 만들었다.

public class ClassA { // 최상위 부모 클래스
    public ClassA() {
        System.out.println("ClassA 생성자");
    }
}

 

ClassA를 상속받은 ClassB는 원래 부모 생성자를 첫 줄에 적어줘야 하지만, 매개변수가 없는 기본 생성자의 경우 생략이 가능하다.

public class ClassB extends ClassA {
    public ClassB(int a) {
        super(); // 기본 생성자 생략 가능
        System.out.println("ClassB 생성자 a = " + a);
    }

    public ClassB(int a, int b) {
        super(); // 기본 생성자 생략 가능
        System.out.println("ClassB 생성자 a = " + a + " b = " + b);
    }
}

 

ClassB를 상속받은 ClassC는 ClassB의 두 생성자 중 하나의 생성자를 선택하여 호출하면 된다.

public class ClassC extends ClassB {
    public ClassC() {
        super(10, 20);
        System.out.println("ClassC 생성자");
    }
}