본문 바로가기

개발 공부

[자바] 참조 타입 - 2

앞에서 참조 타입을 정확히 알기 위해 JVM의 메모리 구조를 살펴 보았습니다. 이제는 진짜 참조 타입에 대해서 알아보도록 하지요! 

기본 타입 변수는 스택 영역에 직접 값을 가지고 있지만, 참조 타입 변수는 값이 아니라 힙 영역이나 메소드 영역의 객체 주소를 가지고 있습니다. 그래서 참조 타입의 변수가 생성되면 일단 스택 영역에서 생성되지만, 실제 값은 힙 영역에서 생성이 됩니다. 스택 영역이 가지고 있는 값은 힙 영역의 주소가 저장되는 것이지요.

 

참조 변수의 '= =', '! =' 연산

'==', '!=' 연산은 변수의 값이 같은지 확인할 때 사용됩니다. 그렇기 때문에 기본 타입의 경우에서만 비교 연산으로써 사용할 수 있습니다. 왜냐하면 참조 타입의 경우 변수에 진짜 값이 저장되어 있는 것이 아니라, 주소 값을 가지고 있기 때문입니다. 참조 타입을 '==', '!=' 연산으로 비교하게 된다면 주소 값을 비교하게 되는 것이겠지요. 동일한 주소 값을 갖고 있다는 것은 동일한 객체를 참조한다는 의미가 됩니다. 

null과 NullPointerException

참조 타입 변수는 힙 영역의 객체를 참조하지 않는다는 의미로 null 값을 가질 수가 있습니다. null 값도 초기값으로 사용될 수 있으며, null로 초기화된 참조 변수는 스택 영역에 생성됩니다. 그래서 null의 경우에는 위에서의 '==', '!=' 비교 연산을 사용할 수가 있습니다. 

우리가 자바 프로그램을 실행하는 중에 여러 오류, 즉 예외상황(Exception)을 만날 수가 있습니다. 그 중에서 null과 관련된 가장 많이 발생하는 예외 중 하나로 NullPointerException이 있습니다. 이 예외는 참조 타입 변수를 잘못 사용하면 발생합니다. 참조 타입 변수가 null을 가지고 있을 때 참조 타입 변수는 사용할 수 없습니다. 

int[] array = null;
array[0] = 10; 

이 경우에 NullPointerException이 발생합니다. array 변수가 참조하는 배열 객체가 없기 때문입니다.

 

String 

자바는 String이라는 참조 타입 변수에 문자열을 저장합니다. String 변수를 초기화해봅시다. 

String str;

String 변수에 문자열을 저장하려면 큰 따옴표로 감싸면 됩니다. 이렇게 되면 문자열 리터럴을 대입하게 되는 것입니다. 

str = "문자열";

물론 선언과 동시에 초기화할 수도 있습니다.

String str = "문자열";

String은 참조 타입 변수이기 때문에 이 변수에 문자열을 저장한다는 말은 틀린 표현입니다. 문자열은 String 객체로 생성되고 변수는 String 객체를 참조합니다. 그래서 위 코드의 str이라는 변수는 스택 영역에 주소 값을 저장하게 되고, 그 주소 값은 힙 영역에 있는 String 객체인 "문자열"을 가리키고 있는 것입니다. 

자바에서는 문자열 리터럴이 동일하다면 해당 객체를 공유하도록 되어 있습니다. 

String puppy = "두리";
String dog = "두리";

 

이 경우에는 스택영역에 저장된 puppy와 dog의 값이 같은 주소값을 가리키고 있습니다. 하지만 new 연산자를 이용해서 String 객체를 생성하는 경우에는 어떻게 될까요? new 연산자는 힙 영역에 새로운 객체를 만들 때 사용하는 연산자로 객체 생성 연산자라고 합니다. 정답! 이 경우에는 서로 다른 String 객체를 참조합니다. 

String puppy = new String("두리");
String dog = new String("두리");

코드로 직접 확인해볼 수 있습니다.

String puppy = new String("두리");
String dog = new String("두리");
System.out.println(System.identityHashCode(puppy)); // 1846274136
System.out.println(Integer.toHexString(puppy.hashCode())); // 168f5c
System.out.println(System.identityHashCode(dog)); // 1639705018
System.out.println(Integer.toHexString(dog.hashCode())); // 168f5c 

앗, 뭔가 이상한 점을 발견하셨나요? System.identityHashCode()와 .hashCode()의 결과값이 다르네요. 두 api는 모두 객체가 메모리에서 갖는 해쉬 주소를 출력합니다. 하지만 System.identityHashCode()는 OS(System)에서 가지는 해쉬값이고, .hashCode()는 자바 애플리케이션에서 가지는 해쉬값을 의미합니다. 그래서 참조 변수의 경우 hashCode()를 통해 객체의 값을 비교할 수 있게 되는 거지요.

String 객체는 문자열만을 비교할 때에 equals() 메소드를 사용할 수 있습니다. equals() 메소드의 매개타입은 Object이며 비교 연산자인 ==과 동일한 결과를 리턴합니다. 이 메소드는 두 객체를 비교해서 논리적으로 동등하면 true를 리턴, 그렇지 않으면 false를 리턴합니다. 논리적으로 동등하다는 것은 같은 객체이건 다른 객체이건 상관없이 객체가 저장하고 있는 데이터가 동일함을 의미합니다. 하지만 참조 객체인 String의 경우 번지 값이 아닌 문자열 값을 비교할 수 있었을까요? 그것은 String 클래스에서는 equals() 메소드를 재정의(=오버라이딩)해서 번지 비교가 아닌 문자열 비교로 변경했기 때문입니다. 

hashCode()에 대해 더 자세히 알아볼까요? Object의 hashCode() 메소드는 객체의 메모리 번지를 이용해 해시코드를 만들어 리턴하기 때문에 객체마다 다른 값을 가지고 있습니다. 논리적 동등 비교 시에 hashCode()를 오버라이딩할 필요가 있습니다. 예를 들어 hashCode()에서 객체의 데이터값을 리턴하는 방식으로 오버라이딩을 하고, equals()를 객체만을 비교하도록 오버라이딩하면 원하는 값을 얻을 수 있습니다. 

아래는 int의 변수를 기준으로 논리적 동등을 비교하는 코드입니다.

class Member {
    Integer id;

    Member(Integer id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object anOjb) {
        System.out.println(anOjb.hashCode() + " " + this.hashCode());
        if (anOjb instanceof Member) {
            Member obj = (Member)anOjb;
            if (this.id == obj.id) {
                return true;
            }
        }
        return false;
    }

    @Override
    public int hashCode() {
        return id;
    }
}

아래는 참조형의 변수를 기준으로 논리적 동등을 비교하는 코드입니다. 

class Member {
    String id;

    Member(String id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object anOjb) {
        System.out.println(anOjb.hashCode() + " " + this.hashCode());
        if (anOjb instanceof Member) {
            Member obj = (Member)anOjb;
            if (this.id == obj.id) {
                return true;
            }
        }
        return false;
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

 

참조 타입은 모두 초기값으로 null을 대입할 수 있습니다. null은 String 변수가 참조하는 String의 객체가 없다는 뜻으로, String 객체자체를 참조하지 않게 됩니다. 예를 들어 아래와 같은 상황이 있다고 합시다. 

String str = "문자열";
str = null;

변수 str은 String 객체를 참조하다가 null을 대입하는 순간 객체를 참조하지 않게 됩니다. 그렇게 된다면 Heap 영역에 있던 데이터 "문자열"은 쓰레기 객체가 되어 Garbage Collector가 구동되어 메모리에서 자동 제거합니다. 

 

배열

배열은 같은 타입의 데이터를 연속된 공간에 나열시키고 각 데이터에 인덱스를 부여해놓은 자료구조입니다. 이 인덱스는 각 항목의 데이터를 읽거나 저장하는 데에 사용됩니다. 배열은 같은 타입의 데이터만 가능하며, 선언과 동시에 데이터 타입과 크기가 결정됩니다. 그래서 크기를 변경해야 한다면 배열을 복사해야 합니다. 또한, 배열 변수를 이미 선언하게 된다면 중괄호를 이용한 배열 생성은 허용되지 않습니다. 

String[] people = {"심지", "두리", "코리"} // 얘는 됨
String[] people;
people = {"심지", "두리", "코리"} // 얘는 안 됨!

배열은 중괄호로 생성하지 않고 new 연산자를 이용해 선언할 수 있습니다. 그렇게 되면 해당 배열은 초기값을 갖습니다. 기본타입의 정수형은 0, 실수형은 0.0, 논리형은 false를 갖고 참조 타입의 경우 null을 갖습니다.

 

(+ 잠깐만 ! 

우리가 프로그램 실행을 위해 main() 메소드가 필요합니다.하지만 main() 메소드의 매개값이 왜 String[] args 일까요? 

public class Main {
    public static void main(String[] args) {
        
    }
}

우리가 커맨드 라인으로 자바 프로그램을 프로그램을 실행하면, JVM은 길이가 0인 String 배열을 먼저 생성하고 main() 메소드를 호출할 때 매개값으로 전달합니다. 그래서 IDE로 실행을 하면 args에는 별 다른 결과가 나오지 않을 것입니다. 만약 아래처럼 실행한다면 어떨까요? 

$ java 클래스 문자열1 문자열2 ... 문자열n

이런 경우에 args의 값은 아래와 같겠죠!

String[] args = {"문자열1", "문자열2" ... "문자열n"}

잠깐만 끝!)

 

다차원 배열의 경우 어떤 식으로 선언이 될까요? 

int[][] arr = new int[2][3]

이 경우에 2x3 배열이 만들어집니다. 독특하게도 다차원 배열의 경우 행과 열 부분이 힙 영역에서 각자 다른 곳에 저장되어 있습니다. 그러니까, 행 부분인 new int[2]가 한 부분, new int[3]인 부분 두 개가 한 부분을 차지해 총 3개의 배열 객체를 생성한다는 것이지요. 그래서 아래와 같은 선언이 가능합니다. 

int[][] arr = new int[2][];
arr[0] = new int[5];
arr[1] = new int[3];

이 경우의 배열을 출력하면 아래와 같은 결과가 나옵니다. 열마다 다른 크기의 배열을 선언해줄 수 있습니다. 

0 0 0 0 0 
0 0 0 

Process finished with exit code  

 

배열을 복사하는 방법을 알아봅시다. 일일이 복사할 수도 있지만, System.arraycopy()라는 메소드를 이용할 수도 있습니다. 

System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length)

src 매개값은 원본 배열이고, srcPos는 원본 배열에서 복사할 항목의 시작 인덱스입니다.
dest는 매개값의 새 배열이고, destPos는 새 배열에서 붙여넣을 시작 인덱스입니다. 
마지막 length는 복사할 개수를 의미합니다. 

System.arratcopy(origin, 0, copy, 0, origin.length)

이 코드의 의미는 "원본 배열 origin의 0부터 복사 배열 copy의 0부터 복사하는데, origin.length 개만큼 복사하라!"라는 의미인 것이지요.

참조 타입 배열의 경우 배열 복사가 되면 복사되는 값이 객체의 번지가 됩니다. 새 배열의 항목은 이전 배열의 항목이 참조하는 객체와 동일한다. 이러한 방법을 얇은 복사(Shallow Copy)라고 합니다. 반대로 깊은 복사(Deep Copy)는 참조하는 객체도 별도로 생성하는 것을 의미합니다.