<추천글>[JAVA] 개발하면서 알아야할 기본사항1

2021. 8. 4. 10:06[ 백엔드 개발 ]/[ Java,Kotlin ]

1. Java의 Call by Reference

자바는 기본적으로, 객체에 대해서는 call-by-reference이지만, 8개의 primitive type에 대해서는 call-by-value가 적용된다고 생각하면 편합니다.

[예제1]

public class HelloWorld { 
	public static void main(String[] args) { 
    	HelloWorld b = new HelloWorld(); 
        int a = 100; b.doSomething(a); // 100이라는 숫자 자체가 넘어간다. 
        System.out.println(a); // 100이 출력된다. 
    } 
    
    private void doSomething(int a) { 
    	a *= 2; 
    } 
}

위 예제1에서 변수 a를 초기화하면 메모리의 4byte공간에 100이라는 값이 써지고 doSomething메소드를 호출하면 4byte의 100이라는 값 자체가 전달됩니다.

[예제2]

class Int {
    int a = 100;
}

public class HelloWorld {
    public static void main(String[] args) {
        HelloWorld b = new HelloWorld();
        Int a = new Int();
        b.doSomething(a); // a객체의 레퍼런스가 넘어간다 
        System.out.println(a.a); // 200이 출력된다.
    }

    private void doSomething(Int b) {
        b.a *= 2;
    }
}

[예제2]와 같이 a에 새로운 '객체'를 만들면, a에는 객체의 reference(주소 값이라 생각하기)가 저장됩니다. 즉, 메모리 상에서, heap 어딘가에 Int 객체가 할당되어 있고, a라는 변수 값은 메모리의 stack에 할당되어 있으며 Int 객체의 메모리상 주소값을 저장하고 있다고 이해하면 될 것 같습니다.

[예제2] 에서와 같이 argument로 객체를 넘기는 경우, 객체의 레퍼런스가 보내집니다. 이때 a를 인자로 보낼 때, doSomething에서는 새로운 stack frame이 할당되고 그 공간에 받은 인자 a의 값(reference 값)을 저장합니다. (call stack 참고) 따라서 main메소드와 doSomething 메소드가 하나의 객체에 대해 같이 참조하는 형태가 되므로 200이 출력됩니다.

cf) int a = 100; 과 Integer a = 100; 의 차이점은?

 

2. String - Constant Pool

Java에서 String은 특별하게 취급됩니다. 같은 내용을 갖는 String에 대해서는 Constant Pool에 한번만 저장될 것입니다. (마치 set처럼)

[예제3]

public class HelloWorld {
    public static void main(String[] args) {
        String a = "Hello world";
        String b = "Hello world";
        // reference비교이지만 같은 String 값은 constant pool에 하나만 둔다.
        // String에 변화가 생길때 constant pool에 새로운 String을 생성함
        System.out.println(a == b);
    }
}

위와 같이 같은 '내용'을 저장하는 String 객체에 대해서는 메모리 상에 한 개만 저장해 둘 겁니다. 따라서 레퍼런스 값을 비교하는 == 연산(object에 대해서만 한정.)결과에 대해서도 true를 반환합니다.

cf. string constant pool은 heap의 어딘가에 저장되어 있다.

constant pool에 대해 알고 있다면, 보다 효율적인 코드를 작성할 수 있을 것 같습니다.

[예제4]

public class HelloWorld {
    public static void main(String[] args) {
        String a = "";
        for (int i = 0; i < 10; i++) { // constant pool에 String 10개가 생성됨(굉장히 비효율적) 
            a += i;
        }
        System.out.println(a);
    }
}

위 [예제4]와 같이 코드를 작성하면, 메모리에 String이 1개가 아닌 10개가 만들어 집니다.
왜냐하면 String은 기본적으로 immutable하기 때문에 한번 결정된 값은 변경할 수 없습니다.

[예제4]의 for문에서 시간순서대로 생성되는 String을 보면 다음과 같습니다.

 

i가 0일 때, "0" 이 만들어지고,

i가 1일 때, "01" 이 만들어지고, (Constant Poll => "0", "01")

i가 2일 때, "012" 이 만들어지고, (Constant Poll => "0", "01", "012")

....

i가 9일 때, "012....89" 이 만들어집니다. (Constant Poll => "0", "01", "012", ..., "012...89")


1개의 String변수를 선언했지만 메모리 공간에는 총 10개의 String이 만들어집니다.

cf. 이전에 만들어져 있던 String들은 메모리의 힙(heap) 영역에 가비지로 남아있게 되고, 추후에 가비지 컬렉터에 의해 제거됩니다. (Sysytem.gc() 를 통해 GC를 호출할 수 있다)

따라서, 메모리를 효율적으로 사용하려면 문자열에 대한 연산을 많이 사용하지 않는 것이 바람직해 보입니다.

 

만약 문자열에 대한 연산이 많이 필요할때에는 StringBuffer를 사용합시다!

[예제5]

public class HelloWorld {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
        System.out.println(sb);
    }
}

위와 같이 StringBuffer를 사용하면 immutable한 String을 mutable하게 변경할 수 있습니다. 기본적으로 String에 문자열을 바로 할당하면 string constant pool에 저장되지만 StringBuffer를 선언하면 constant pool이 아닌 heap에 저장됩니다. 따라서 한 번 생성된 String의 값을 계속해서 바꿀 수 있습니다. 따라서 문자열에 대해 변경하는 연산이 많은 경우에는 String보다 StringBuffer를 사용하는 것이 더욱 바람직해 보입니다. StringBuffer와 StringBuilder에 대해서는 아래에서 설명합니다.

cf. String str1 = "abc", String str2 = new String("abc") 의 차이) : https://jh-labs.tistory.com/15

 

3. Object class

java에서 모든 객체는 extends Object를 명시하지 않아도 java.lang package의 Object class를 상속합니다. 따라서 모든 객체에 대해서 Object class의 method들을 호출할 수 있습니다. 주로 toString(), clone(), equals(), hashCode() 메소드들을 override해서 사용합니다.

1) toString()
객체를 출력할때 객체가 담고 있는 내용을 출력하기 위해 사용됩니다. 해당 class에 toString()을 override하고 레퍼런스.toString() 을 출력하면 됩니다. 보통 reference 이름만 사용했을 경우 .toString()이 생략된 것과 같습니다.

[예제6]

double[] values = {1.0, 1.1, 1.2}; 
System.out.println(Arrays.toString(values));


2) equals()
equals() 메소드를 override 하는 이유는 물리적으로 다른 메모리에 위치하는 객체여도 논리적으로 동일함을 구현하기 위함입니다. 즉, 객체 레퍼런스 외에 다른 값들을 대상으로 서로다른 두 객체간 비교를 위해 사용합니다.

[예제7]

public class User {
    int id;
    String name;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof User) {
            return this.getId() == ((User) obj).getId(); // 값 비교(int이므로) 
        } else {
            return false;
        }
    }

    public static void main(String[] args) {
        User user1 = new User(1001, "홍길동");
        User user2 = new User(1001, "홍길동");
        System.out.println(user1.equals(user2)); // true 
    }
}

equals()를 override해서 id의 값을 비교하는 함수로 재정의 했습니다.

cf. equals()를 override하지 않으면 기본적으로 Object class의 equals()는 == 비교(reference비교)를 합니다.


3) hashcode()
Object class의 hashcode()는 기본적으로 인스턴스가 저장된 가상머신의 주소를 10진수 값으로 반환해 줍니다. 즉, 인스턴스마다 메모리를 할당 받기 때문에, 인스턴스마다 고유한 hashcode값을 가질 것입니다. 위 [예제6]에서 두 객체의 hashcode를 비교하면 서로 다른 값이 나올 것입니다.

 

cf. https://jh-labs.tistory.com/173

만약 String의 경우 같은 내용을 갖는 서로 다른 변수로 선언한다면, 같은 hashcode() 값을 갖을 것입니다. String constant pool에 같은 내용의 String일 경우 하나만 존재하기 때문입니다. (new String()은 예외)

따라서 hashcode() 메소드를 override는 하는 것은 서로 다른 객체의 '동등성'을 부여하기 위함입니다. 예를 들어, 서로 다른 Student class가 같은 studentId 값을 갖을 경우, 서로 다른 두 객체에 hashcode를 서로 갖게 만들어(Student class에 override) 동등성을 부여할 수 있습니다.

4) clone()
객체가 저장된 메모리 공간 자체를 복제합니다. private 변수까지 복제할 수 있으므로 '정보은닉'에 위배 될 수 있습니다. 따라서 Cloneable 인터페이스를 implements한 객체에 대해서만 clone()을 사용할 수 있습니다.

 

 

 

4. String vs StringBuffer vs StringBuilder

String의 immutable한 성질때문에 String에 대한 연산이 많아질 수록 성능상 문제가 발생할 수 있습니다.

 

[왜 성능상 문제가 발생하나?]

1. 자바6 이하 버전
string constant pool이 JVM이 관리하는 메모리의 perm(Permenent) 영역에 위치했고, perm 영역은 고정된 사이즈를 갖기(사이즈를 늘릴 순 있지만 런타임 중에는 늘릴 수 없습니다. 때문에 OutOfMemoryException 예외가 발생할 가능성이 있었습니다.

2. 자바 7이후 버전
자바7 버전부터는 String constant pool의 위치를 heap 영역으로 변경함에따라 런타임시 String constant pool의 사이즈가 변동될 수 있고, 추가적으로 string constant pool에 위치한 문자열이 GC(가비지 컬렉터) 대상이 될 수 있게 되어 메모리 누수 현상을 어느정도 피하게 되었다고합니다.

[추가로 알게된 것]
1. JVM의 메모리는 크게 Method Area, Heap Area, Stack Area, Native Method 영역으로 나뉩니다. 가비지 컬렉터에서는 힙 메모리를 다루게 됩니다.perm 영역도 heap영역 중 일부입니다. 하지만 자바 8버전부터 Native영역으로 분류되었습니다.

2. perm 영역도 heap영역의 일부였기 때문에 GC대상은 맞지만, 클래스가 언로드 될때만 드물게 GC가 발생된다고 합니다.(perm 영역에 저장되는 객체는 주로 생존주기가 긴 객체이므로)


이를 보완하고자 String에 mutable한 성질을 부여한 'StringBuffer' 및 'StringBuilder'가 등장했습니다.

 

[어떻게 보완했는가?]

String은 immutable하기 때문에 String에 대한 연산을 할 때마다, heap영역에 있는 String constant pool에 String 객체를 새로 만들어 줘야 합니다.(기존에 있다면 만들지 않음)

하지만 StringBuffer나 StringBuilder의 경우 mutable한 성격때문에 String과 달리, 문자열에 대해 연산을 하더라도 새로운 객체를 만들지 않고 기존 객체에 값을 변경하는 방식으로 문자열 처리가 진행되며 마지막에 String constant pool에 저장됩니다.

그래서 StringBuffer나 StringBuilder를 사용하면 String constant pool에 문자열 객체를 계속적으로 생성하지 않기 때문에 메모리와 시간을 절약할 수 있습니다.

따라서 문자열 객체를 계속 만드는 방식이 아닌, 하나의 객체에 대해 문자열 값을 수정하는 방식으로 String의 문제점을 보완했다고 말할 수 있습니다.

 


StringBuffer와 StringBuilder의 가장 큰 차이점은 '쓰레드 간 동기화의 유무'(synchronization)입니다.

 

동기화(synchronization)란 critical section(여기서는 StringBuffer에 담긴 문자열)에 하나의 쓰레드만 접근(Mutual Exclusion)할 수 있도록 해서 race condition을 방지하는 것입니다.

 

- StringBuffer : 쓰레드 간 동기화를 지원해 multi-thread 환경에서 안전하게 사용할 수 있음.(+ String)
- StringBuilder : 동기화를 지원하지 않기 때문에 multi-thread 환경에서는 사용하지 않는 것이 좋음.

만약, Single-thread 환경에서 개발한다면 동기화를 지원하지 않는 StringBuilder를 사용하는 것이 성능 상 더 효율적이라고 생각합니다.

 

<정리>
- String : 문자열에 대한 연산이 적고 multi-thread 환경일 경우.
- StringBuffer : 문자열에 대한 연산이 많고 multi-thread 환경일 경우.
- StringBuilder : 문자열에 대한 연산이 많고 single-thread 환경일 경우, 또는 동기화를 고려할 필요가 없는 경우.

 

 

 

 

cf. 자바에서 쓰레드 생성하기 : https://wakestand.tistory.com/93

cf. String, StrungBuffer, StringBuilder 성능테스트 : https://madplay.github.io/post/difference-between-string-stringbuilder-and-stringbuffer-in-java