반응형

LCA 알고리즘이란,

트리 상의 정점 U 와 V 가 주어질 때 U 와 V 에서 가장 가까운 공통 조상을 찾는 알고리즘이다.

 

LCA 그래프 예시 (사실 많은 블로그에서 루트 노드는 1로 설정되어 있음)

 

위 LCA 그래프 예시에서 24 와 26 의 LCA (최초 조상) 은 5 이다.

 

어떻게 조상을 찾는 것일까?

조상을 찾는 방법을 step-by-step 으로 표현한다면 다음과 같다.

 

0 . U 노드와 V 노드를 정한다. (깊이가 더 큰 노드를 V로 정한다.)

1 . U 노드의 깊이와 V 노드의 깊이가 같아질 때까지 깊이가 더 깊은 노드(V)를 자신의 조상으로 바꾸어 준다.

2 . U 노드와 V 노드의 조상이 같아질 때까지 자신의 조상으로 바꾸어 준다.

 

1 번 과정, 2 번 과정 모두 한 단계씩 조상을 올리게 되면 O(N) (깊이를 N이라 두었을 때) 의 복잡도를 가지게 된다.

조금 더 빠르게 트리를 탐색하기 위해 $2^{x}$ 번째에 해당하는 부모만 기억하고 중학교 때 배운 아래 지수법칙

$2^{x+1}=2^{x}*2$ 수식을 활용한다. 수식에서 * 2 부분을 만족하려면 조상노드를 한 번 더 그 크기만큼 올라가야 한다. 

 

따라서,

u 노드의 $2^{x}$ 번째에 해당하는 부모를 parent[u][x] 라고 할 때, parent[u][x] = parent[parent[u][x-1]][x-1] 라는 점화식이 성립한다. 점화식이 만들어졌으니 Bottom-Up DP 로 parent 테이블을 만들어주면 된다.

 

총 루틴 정리

알고리즘의 중요 부분은 모두 나왔다. 총 루틴을 정리해보자.

 

1 . 그래프의 깊이를 구한다.

이 LCA 에서 사용하는 트리는 이진 트리이므로 높이는 log2(N) + 1이다. 여기서 N은 총 그래프 노드의 개수.

(int)Math.ceil(Math.log(n)/Math.log(2)) +1;

 

2 . DFS 로 순회하면서 depth 배열과 parent[][0] 배열을 초기화한다.

 

3 . parent[x][y] 배열을 셋팅한다.

parent[x][y] = parent[parent[x][y-1]][y-1] 점화식이 성립하고 미래 값을 먼저 구하지 않아도 되므로 Bottom-up DP 로 구할 수 있다.

 

4 . LCA(a, b) 에서 b 가 더 깊도록 a 와 b 를 서로 바꾼다.

 

5 . b 깊이가 a 깊이와 같도록 부모 배열을 활용해 자신의 조상으로 바꾼다.

 

6 . 현재 부모가 같다면 리턴.

 

7 . 조상이 같을 때까지 조상을 거슬러 올라가기. 부모가 같다면 그 조상을 리턴.

 

자바 코드

자바코드는 다음과 같다.

public static class LCA {
    private int nodeCount;
    private List<Integer>[] adjacencyList;
    private int treeHigh;
    private int[] depth;
    private boolean[] memoization;
    private int[][] parent;

    public LCA(int nodeCount, List<Integer>[] adjacencyList) {
        this.nodeCount = nodeCount;
        this.adjacencyList = adjacencyList;
        this.memoization = new boolean[nodeCount+1];
        this.depth = new int[nodeCount+1];
        getTreeHigh();
        this.parent = new int[nodeCount+1][this.treeHigh];
    }

    private void getTreeHigh() {
        this.treeHigh = (int)Math.ceil(Math.log(nodeCount)/Math.log(2)) + 1;
    }
    private void init(int current, int treeHigh) {
        memoization[current] = true;
        depth[current] = treeHigh;
        for (int next : adjacencyList[current]) {
            // 메모이제이션
            if(memoization[next]) continue;
            init(next, treeHigh+1);
            parent[next][0] = current;
        }
    }
    private void makeParentArray() {
        for(int i=1; i<treeHigh; i++) {
            for(int j=1; j<nodeCount+1; j++) {
                parent[j][i] = parent[parent[j][i-1]][i-1];
            }
        }
    }
    public int run(int a, int b) {
        init(1,1);
        makeParentArray();
        // b 가 더 깊도록 설정
        if (depth[a] > depth[b]) {
            int temp = a;
            a = b;
            b = temp;
        }
        // 깊이가 동일하도록 b 를 옮김
        for (int i = treeHigh-1; i>=0; i--) {
            if ((1<<i) <= depth[b] - depth[a]) {
                b = parent[b][i];
            }
        }

        if(a==b) return a;

        for(int i=treeHigh-1; i>=0; i--) {
            if (parent[a][i] != parent[b][i]) {
                a = parent[a][i];
                b = parent[b][i];
            }
        }
        return parent[a][0];
    }
}

 

반응형

'알고리즘 > 개념' 카테고리의 다른 글

[알고리즘] Segment Tree  (0) 2022.08.29
[알고리즘] BFS & DFS  (0) 2022.07.12
[알고리즘] 투 포인터 알고리즘 (Two Pointer)  (0) 2022.06.29
[알고리즘] DP (Dynamic Programming)  (0) 2021.05.10
반응형

ITEM 12 "toString 을 항상 재정의하라"

 

이번 장에서는 Object 의 toString 을 항상 재정의하라고 권고하고 있다.

equals 와 hashCode 규약처럼 중요하진 않지만, toString 을 잘 재정의한 클래스는 사용하기 편하고 디버깅하기 쉽다.

기본 toString 메서드는 클래스_이름@16진수로_표시한_해시코드 만 반환하는데 이는 사람이 읽기 어려울 뿐 아니라 쓸모 없는 메세지만 로그에 남을 것이다.

 

가급적 그 객체가 가진 주요 정보 모두를 반환하는게 좋다. 객체가 크거나 문자열로 표현하기에 적합하지 않다면 요약 정보로 표현해도 된다.

 

또 반환값의 포맷을 결정하고 문서화하면 좋다. 포맷을 명시하면 표준적이고 명확하며 사람이 읽을 수 있게 된다. 추가로 toString 포맷으로 생성할 수 있는 생성자나 정적 팩토리 메서드를 같이 정의하면 금상첨화다.

 

BigInteger String 을 매개변수로 받는 생성자

 

BigInteger 클래스에서 제공하는 toString 반환 포맷으로 생성자를 호출할 수 있게 구성되어 있는 것을 볼 수 있다.

 

하지만, 한 번 포맷을 명시하면 평생 그 포맷에 얽매이게 되어 다음 릴리스에서 포맷을 바꾸면 이를 사용하던 코드들 모두 변경해야 되는 단점이 있다. 이 반대 개념으로 포맷을 명시하지 않는다면 포맷에 얽매이지 않고 프로그래밍할 수 있다. 향후 릴리스에서 추가로 정보를 넣거나 포맷을 개선할 수 있는 유연함을 가지게 된다. 어떠한 방법이던 포맷을 명시하든 아니든 의도는 분명히 알 수 있도록 주석은 달아두자.

 

책에서는 포맷 명시 여부와 상관없이 toString 반환 값에 포함된 정보를 얻어올 수 있는 API 를 제공해야 한다고 하는데 나는 이 API 가 getter 메서드를 말하는 것 같았다. 프로그래머가 직접 파싱해서 정보를 얻어오는 폐해를 피하고자 단순히 필드 값들을 제공해주면 되니깐 말이다.

 

public class EffectiveJavaTest {
    @Test
    public void 전화번호_테스트() {
        PhoneNumber testPhone = new PhoneNumber("010", "1234", "5678");
        System.out.println(testPhone);
        PhoneNumber testPhone2 = PhoneNumber.createByString(testPhone.toString());
        System.out.println(testPhone2);
    }

    @Builder
    @Getter
    public static class PhoneNumber {
        protected String firstNumber;
        protected String secondNumber;
        protected String thirdNumber;

        private static final Pattern phoneNumberPattern = Pattern.compile("^\\d{3}-\\d{4}-\\d{4}$");
        public PhoneNumber(String firstNumber, String secondNumber, String thirdNumber) {
            this.firstNumber = firstNumber;
            this.secondNumber = secondNumber;
            this.thirdNumber = thirdNumber;
        }

        /*
         * toString 메서드는 전화번호의 문자열 표현을 반환한다.
         * 010-xxxx-yyyy 형태의 11글자로 구성된다.
         * 전화번호의 각 부분 값이 너무 작아서 자릿수를 채울 수 없다면,
         * 앞에서부터 0을 채워나간다. 예컨데 중간 번호가 123이라면, 0123 으로 표기한다.
         * */
        @Override
        public String toString() {
            StringBuffer sb = new StringBuffer();
            sb.append(firstNumber);
            sb.append("-");
            sb.append(secondNumber);
            sb.append("-");
            sb.append(thirdNumber);
            return sb.toString();
        }

        public static PhoneNumber createByString(String phoneNumber) {
            if(!phoneNumberPattern.matcher(phoneNumber).find()) {
                throw new IllegalArgumentException(phoneNumber + " cannot be parsed");
            }

            String[] numbers = phoneNumber.split("-");
            return PhoneNumber.builder()
                    .firstNumber(numbers[0])
                    .secondNumber(numbers[1])
                    .thirdNumber(numbers[2])
                    .build();
        }
    }
}

 

책에서 권장하는 방법들을 모두 적용해 보았다.

 

"toString 을 항상 재정의하고 문서화하자

toString 반환 값으로 생성할 수 있는 생성자도 마련하자"

반응형
반응형

ITEM 11 "equals를 재정의하려거든 hashCode도 재정의하라"

 

본 글을 보기 전에 아래 순서대로 HashTable 에 대한 정의를 살펴보는 것을 추천한다.

HashTable 의 구현 방식들과 좋은 해쉬 함수를 작성하는 방법에 대해 잘 설명되어 있다. Effective Java 에서 다소 설명이 부족한 부분들은 아래 블로그에서 일부 차용해 작성하려고 한다.

 

https://bcho.tistory.com/1072
https://d2.naver.com/helloworld/831311

 

자바 최고 조상 Object 에는 equals 메서드 뿐만 아니라 hashCode 메서드도 기본적으로 정의되어 있다. HashMap 이나 HashSet 같은 Collection 에서 Object 의 hashCode 메서드를 활용하기 때문이다. 그래서 (Item 10) Object 를 재정의하였다면 hashCode 도 함께 재정의해야 한다. 아래는 Object 명세서에서 정의되어 있는 HashCode 가 지켜야 하는 규약들이다.

 

"equals 비교에 사용되는 정보가 변경되지 않았다면, hashCode 는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다."

"equals 가 두 객체를 같다고 판단했다면, 두 객체의 hashCode 는 동일한 값을 반환해야 한다."

"equals 가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode 는 서로 다른 값을 반환할 필요는 없다. 다만, 다른 값을 반환해야 해시테이블의 성능이 좋아진다."

 

hashCode 재정의를 안 하거나 잘못 했을 때, 두 번째 조항을 위반한다. equals 에서 논리적으로 같다고 판단하더라도 Object 의 hashCode 기본 메서드는 이 둘을 전혀 다르게 판단할 수도 있다.

 

public class HashTest {
    
    @Test
    public void hashcode_재정의하지_않음() {
        HashMap<PhoneNumber, String> map = new HashMap<>();
        PhoneNumber test1 = new PhoneNumber(010, 1234, 5678);
        PhoneNumber test2 = new PhoneNumber(010, 1234, 5678);
        System.out.println("첫 번째 hashcode : " + test1.hashCode());
        System.out.println("두 번째 hashcode : " + test2.hashCode());
        map.put(test1, "골드에그");
        assertNotEquals(map.get(test2), "골드에그");
    }

    @Test
    public void hashcode_재정의() {
        HashMap<AddedHashCodePhoneNumber, String> map = new HashMap<>();
        AddedHashCodePhoneNumber test1 = new AddedHashCodePhoneNumber(010, 1234, 5678);
        AddedHashCodePhoneNumber test2 = new AddedHashCodePhoneNumber(010, 1234, 5678);
        System.out.println("첫 번째 hashcode : " + test1.hashCode());
        System.out.println("두 번째 hashcode : " + test2.hashCode());
        map.put(test1, "골드에그");
        assertEquals(map.get(test2), "골드에그");
    }

    public static class PhoneNumber {
        protected int firstNumber;
        protected int secondNumber;
        protected int thirdNumber;

        public PhoneNumber(int firstNumber, int secondNumber, int thirdNumber) {
            this.firstNumber = firstNumber;
            this.secondNumber = secondNumber;
            this.thirdNumber = thirdNumber;
        }

        @Override
        public boolean equals(Object o) {
            if (o == this)
                return true;
            if (!(o instanceof PhoneNumber)) {
                return false;
            }
            PhoneNumber p = (PhoneNumber) o;
            return this.firstNumber == p.firstNumber &&
                    this.secondNumber == p.secondNumber &&
                    this.thirdNumber == p.thirdNumber;
        }
    }
    public static class AddedHashCodePhoneNumber extends PhoneNumber {

        public AddedHashCodePhoneNumber(int firstNumber, int secondNumber, int thirdNumber) {
            super(firstNumber, secondNumber, thirdNumber);
        }

        @Override
        public int hashCode() {
            int c = 31;
            int hashcode = Integer.hashCode(firstNumber);
            hashcode = c * hashcode + Integer.hashCode(secondNumber);
            hashcode = c * hashcode + Integer.hashCode(thirdNumber);
            return hashcode;
        }
    }
}

 

책에 있는 예제로 직접 테스트 해보면, 재정의 하지 않았을 때 hashCode 값이 같지 않아 get 메서드로 PhoneNumber 객체를 못 꺼내오는 것을 확인할 수 있다. 2번 규약과 같이 hashCode 가 같아야만 동치성 비교를 하기 때문이다.

 

그렇다고 아래와 같이 모두 같은 상수로 반환하면 HashTable 의 성능이 O(1) -> O(N) 으로 성능이 떨어지게 된다.

물론 3번 정의에 위반되지는 않지만, 아래와 같이 설계하면 42 라는 키에 모든 아이템들이 순차적으로 쌓일 것이다. 배열이나 링크드 리스트에서 아이템을 검색하는 것과 다를 바가 없게 된다. 

 

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

 

어떻게 해야 여러 키들로 균등 분배하는 해시함수를 만들 수 있을지 고민해봐야 한다. 

(위 네이버 링크를 참고해보면, 자바 진영에서도 더 성능이 좋은 해시함수를 만들기 위해 노력한 흔적들이 보인다.)

먼저 해시 테이블이 어떤 구성으로 되어 있는지 살펴보자.

 

해시 테이블은 입력에 대한 해시코드를 만들고 그 해시코드를 버킷의 갯수만큼 나누어 분배한다. 아무리 나눈다고 하지만 해시함수가 완전함수가 아니라면 결과가 중복이 되어 충돌이 날 수 밖에 없다. 이런 충돌을 피하고자 크게 Separate Chaining 방식과 Open Address 방식으로 해결한다.

 

Open Addressing

 

Open Addressing 방식은 index 에 대한 충돌이 일어날 경우, 추가적으로 메모리를 할당하지 않고 비어있는 버킷을 사용한다. Separate Chaining 방식보다 메모리를 덜 사용하고 캐시된 데이터를 빠르게 꺼낼 수 있다는 장점이 있지만 데이터의 크기가 커지면 자연스레 캐시미스가 나서 성능이 점점 떨어진다. (자세한 사항은 1번 참조 블로그를 확인하자.)

 

Separate chaining

 

Separate chaining 방식은 동일한 인덱스로 인해 충돌나면 그 index 가 가리키고 있는 linked list 에 노드를 추가하여 값을 추가한다. (역시 자세한 사항은 1번 참조 블로그를 확인하자.)

 

두 방식 모두 버킷의 크기가 넉넉해야 되고, 잘 분배되어야 해시 성능이 좋아진다는 것을 직감적으로 알 수 있다.

책에서 3번째 규약을 잘 지키기 위해 좋은 해시 함수들을 다음과 같이 제안하고 있다.

 

좋은 해시 함수 만들기

(* 여기서 소개하는 핵심필드들은 equals 메서드에서 사용했던 필드들이다.)

1 . 결과 값 int 변수를 선언하고 첫 번째 핵심필드에 대한 hashCode 로 초기화한다.

2 . 기본타입 필드라면, Type.hashCode 를 실행한다. (Type 은 기본타입의 Boxing 클래스)

3 . 참조타입이라면 참조타입에 대한 hashCode 를 호출한다. 값이 null 이라면 0 을 더한다.

4 . 필드가 배열이라면 핵심 원소를 2번과 3번처럼 각각 계산한다. 배열의 모든 원소가 핵심필드라면 Arrays.hashCode 를 호출한다.

5 . 각각의 핵심 필드를 해시코드로 표현하여 소수와 곱해 재귀적으로 결과값을 더한다.

 

위 HashTest 코드에 오버라이딩된 hashCode 메서드가 이 방법을 통해 만든 것이다.

 

소수를 곱하는 이유는 책에서 관습적으로 해왔고 정확한 사유는 모르겠다고 하지만, 내 생각엔 소수가 자기 자신과 1 만이 약수를 가지고 있는 형태라 의미를 잃지 않는 값이라는 점을 활용한 것으로 보였다. 소수들 중에 31 이 2^5 - 1 로 계산이 쉬운 숫자라 활용된 것으로 보인다.

 

hashCode 를 재정의할 때 주의할 점은 다음과 같다.

1 . 성능을 높인다고 hashCode 를 계산할 때 핵심 필드를 생략해서는 안 된다.

2 . hashCode 생성 규칙을 API 사용자에게 공표하지 말자. 해시 성능을 더 높일 수 있는 좋은 방안이 있는데 공표를 하게 되면 그 hashCode 를 믿고 작성한 코드들 때문에 다음 릴리즈 때 성능을 개선할 수 없게 된다.

 

"equals 를 재정의하였다면 hashCode 도 재정의하자.

좋은 해시 함수 만들기 방법을 통해 적절히 분배하여 성능을 높이자"

반응형
반응형

ITEM 10 "equals는 일반 규약을 지켜 재정의하라"

 

자바 클래스의 최고 조상! Object 에서는 equals 메서드를 기본적으로 가지고 있다.

보통, 다른 오브젝트들과의 동치성을 확인하기 위해 equals 메서드를 호출하여 확인하는데 제대로 정의하지 않으면 잘못된 결과를 초래할 수 있다. 기본적으로 Object 에서는 자기 자신과만 같음을 보장한다.

 

Object 의 equals 메서드

 

아예 재정의하지 않거나 아래 열거한 상황 중 하나라도 해당하면 재정의하지 않는 것을 권장하고 있다.

1 . 각 인스턴스가 본질적으로 고유하다. 값 자체가 아니라 동작 등을 표현하는 클래스 등이 대표적인 예이다.

2 . 논리적인 동치성을 검사할 일이 없다.

3 . 상위 클래스에서 재정의한 equals 가 하위 클래스에도 알맞은 경우이다.

4 . 클래스가 private 이거나 package-private 인 경우 equals 메서드를 호출할 일이 없다.

(굳이 바깥에서 호출될 일이 없는 클래스에 equlas 를 재정의할 필요는 없다. 오버라이딩해서 AssertionError 호출)

5 . 싱글톤이나 Enum 은 같은 인스턴스가 둘 이상 만들어지지 않는다.

 

1, 3, 4, 5번의 경우 굳이 필요하지 않는 경우이고 2번에서 equals 를 재정의해야 되는 이유를 알 수 있다.

두 객체가 물리적으로 같은 경우(객체 식별성) 말고 논리적으로 같은지(논리적 동치성) 확인해야 하는데 상위 클래스의 equals 가 논리적 동치성을 비교하도록 재정의하지 않을 때 재정의해야 한다. 주로 값 클래스들이 여기에 해당한다.

 

이제 equals 메서드를 안전하게 재정의하는 방법을 살펴보자.

equals 메서드를 재정의하려면, 반드시 아래 일반 5개 규약을 따라야 한다. Object 명세서에서 확인할 수 있다.

(사실 아래 규칙들은 동치관계를 만족하기 위한 조건들이다. "모든 원소가 같은 류에 속한 원소와도 교환할 수 있어야 된다." 라는 조건을 만족시키기 위한 수학적 특성들이랄까...)

참고로, 아래에서 사용하는 x,y,z 인수들은 모두 null 이 아니어야 한다.

 

반사성

x.equals(x) == true

대칭성

x.equals(y) ==  true 이면 y.equals(x) == true

추이성

x.equals(y) == true 이고 y.equals(z) == true 이면 x.equals(z) == true

일관성

x.equals(y) 에 대한 값은 항상 같은 값이어야 한다.

NULL 아님

x.equals(null) == false

 

반사성의 경우에는 어기기가 쉽지 않다. 안심해도 된다. 이 요건을 어긴 클래스의 인스턴스가 컬렉션에 추가되었는데 contains 메서드로 확인해보니 방금 넣은 인스턴스가 없다고 하는 경우이다. 이런 경우를 만들기도 어렵다.

 

대칭성은 자칫하면 어길 가능성이 있다. 대소문자를 구별하지 않는 문자열을 위한 클래스가 있다고 가정해보자.

 

// 선언부
public static class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString() {
        this.s = Objects.requireNonNull(s);
    }

    @Override
    public boolean equals(Object obj) {
        if(obj instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
        if(obj instanceof String)
            return s.equalsIgnoreCase((String) obj);
        return false;
    }
}

// 사용부
CaseInsensitiveString cis = new CaseInsensitiveString("Phone");
String s = "phone";

 

String 객체와도 값에 대한 동치성을 제공하기 위해 if(obj instanceof String) 구문을 추가했다. 대소문자가 다른 문자열에 대해 cis.equals(s) 는 true 를 반환하지만, s.equals(cis) 는 false 를 반환할 것이다. String 은 CaseInsensitiveString 클래스의 존재여부 조차 모르기 때문이다. 따라서 대칭성을 위반한다. 이 오브젝트를 컬레션에 넣어두고 contains 메서드를 호출했다고 가정해보자. JDK 의 구현체에 따라 각기 다 다른 결과값을 도출한다. 애초에 다른 객체에 대한 equals 를 정의하면 안 된다는 것을 보여준다.

 

추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면 첫 번째 객체와 세 번째 객체가 같다는 뜻이다. 어기기 쉽지 않아 보이지만 상속으로 하위 클래스를 구현할 때 하위 필드 값을 고려하여 동치성을 체크하다가 문제가 발생한다.

 

// 선언부
public static class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object obj) {
        if(!(obj instanceof Point))
            return false;
        Point p = (Point) obj;
        return p.x == x && p.y == y;
    }
}

public static class ColorPoint extends Point {
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Point))
            return false;
        // obj 가 Point 이면 색상을 무시하고 비교
        if (!(obj instanceof ColorPoint))
            return obj.equals(this);
        // obj 가 ColorPoint 이면 색상까지 비교
        return super.equals(obj) && ((ColorPoint) o).color == color;
    }
}

// 사용부
ColorPoint p1 = new ColorPoint(1, 1, Color.RED);
Point p2 = new Point(1, 1);
ColorPoint p3 = new ColorPoint(1, 1, Color.BLUE);

 

Point 를 상속받은 ColorPoint 클래스가 동치성을 확인하고자 비교 대상이 Point 객체일 때와 ColorPoint 객체일 때를 나누어 비교하였다. ColorPoint 객체의 경우 색상까지 비교하는데 이 부분이 추이성을 위반한다.

p1.equals(p2) 와 p2.equals(p3) 는 true 를 반환하는데 p1.equals(p3) 가 false 를 반환한다. 추가 필드를 빼고 비교할 수 있게 고려해주었기 때문에 문제가 발생한 것이다. 또 이 방식은 Point 의 새로운 자식 클래스를 비교할 때 무한 재귀에 빠져 스택 오버플로우 에러를 발생시킬 수 있다고 한다. (obj 가 자기 자신이 아닐 때 상대편의 equals 를 호출)

 

public static class Parent {}
public static class Child extends Parent {}
public static void main(String[] args) {
    Parent parent = new Parent();
    Child child = new Child();
    if (parent instanceof Child) {
        System.out.println("parent is child");
    } else if (child instanceof Parent) {
        // 아래 문구만 출력 가능
        System.out.println("child is Parent");
    }
}

 

이 문제점을 해결하기 위해서 equals 세계와 객체지향 세계는 다르다는 점을 확실히 알아야 한다.

객체 지향 세계에서는 자식 클래스가 부모 클래스로 간주할 수 있지만(같을 수 있지만), equals 세계는 중요 필드가 모두 같아야 한다. 다시 말해, 객체 지향적 추상화의 이점을 포기해야 한다는 말이다. 그렇다고 같은 구현체의 클래스만 비교할 수도 없다. (책에서는 .getClass() 메서드롤 통해 확인)

 

객체 지향 원칙 중 리스코프 치환 원칙이라는 중요한 원칙이 있다. 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하며, 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 동작해야 한다는 말이다. 다시 표현하면, 하위 클래스일지라도 정의상 상위 클래스이니까 어디서든 상위 클래스로써 활용되어야 한다는 말이다.

 

뒤에서 이야기하겠지만, 이를 해결하기 위해 상위 클래스를 신경쓰지 말고 자신의 클래스의 주요 필드만 체크하면 된다.

 

일관성은 equals 의 판단에 신뢰할 수 없는 자원이 끼어들면 안 된다는 것이다.

책에서는 java.net.URL의 equals 가 주어진 URL 과 매핑된 호스트의 IP 주소를 이용해 비교한다고 써있지만, 직접 정의를 살펴보니 최신 버전의 자바에서는 바뀐 것으로 보인다. 만약 IP 주소가 equals 로직에 사용되었다면, 계속해서 변하는 IP 특성 상 항상 같다는 보장이 없다.

 

마지막 'NULL 아님' 원리는 모든 객체가 null 이 아니어야 한다는 뜻이다. null 을 비교하여 true 를 반환하기는 어려울 것 같지만, 실수로 NullPointerException 이 발생할 수도 있기 때문에 주의가 필요하다. 많은 클래스가 if (object == null) 구문을 삽입해 false 를 리턴하게 작성하지만, 굳이 그럴 필요성이 없다고 주장하고 있다. 어차피 동치성을 검사하려면 건네받은 객체를 형변환해야 되고, instanceof 구문이 null 일 경우에 자동으로 false 를 반환하기 때문이다.

 

지금까지 내용을 종합해서 equals 메서드를 정의하는 정석트리를 소개하고 있다.

equals 메서드 재정의하는 방법은?

 

1. == 연산자를 활용해 입력된 객체가 자기 자신의 참조인지 확인하고 자기 자신이라면 true 를 반환한다.

단순 성능 최적화용이다. 자기 자신이 들어왔으면 비싼 객체를 확인할 필요도 없이 바로 true 를 반환하면 되니까.

 

2. instanceof 연산자로 입력이 올바른 타입인지 확인하고 아니라면 false 를 반환한다. 

 

3. 입력을 올바른 타입으로 형변환한다.

 

4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다. 모든 필드가 일치해야 true 를 반환한다.

다를 가능성이 크거나 비교하는 비용이 싼 필드부터 먼저 비교한다. float 과 double 를 제외한 기본 필드는 == 연산자로 비교한다. 참조 타입 필드는 equals 메서드로 비교한다.

 

@Override
public boolean equals(Object obj) {
    if (obj == this) return true;
    if (!(obj instanceof Test)) return false;
    Test test = (Test) obj;
    return test.importantValue1 == importantValue1 &&
            test.importantValue2 == importantValue2;
}

 

재정의 방법의 정석트리를 적용하면 위 코드와 같다.

참고로, 이러한 수고를 덜기 위해 구글에서 만든 AutoValue 라는 프레임워크도 있다고 한다.

반응형
반응형

ITEM 9 "try-with-resource 를 사용하라"

 

 

이 ITEM 을 확인하기 전에 위 백기선님의 Effective Java 해설 영상을 보는 것을 추천한다.

 

자바 라이브러리에는 InputStream, OutputStream, java.sql.Connection 과 같이 정리해야 되는 리소스들이 많다.

전통적으로 try-finally 를 사용해서 많이 예외처리를 해왔는데 아래와 같은 문제점들이 있어 Java 7 부터 제공하는 try-with-resource 구문을 사용하는 것을 권장하고 있다.

 

전통적인 try-finally 문제점

 

public class MyResource implements AutoCloseable {
    public void doSomething() {
        System.out.println("Do something");
        throw new FirstError();
    }

    @Override
    public void close() throws Exception {
        System.out.println("Close my Resource");
        throw new SecondError();
    }
}

public class FirstError extends RuntimeException { }
public class SecondError extends RuntimeException { }

public class Main {
    public static void main(String[] args) {
        MyResource myResource = new MyResource();
        try {
            myResource.doSomething();
        } finally {
            myResource.close();
        }
    }
}

 

위 코드처럼 try-finally 를 사용할 경우, 첫 번째 에러는 알 수가 없다. doSomething 에서 첫 번째 에러가 발생한 후 finally 구문으로 넘어와 close 에서 두 번째 에러를 발생하는데 두 번째 에러가 첫 번째 에러를 삼켜 버린다. 보통 우리는 문제 해결을 위해 첫 번째 에러가 어디서 발생하였는지 알고 싶다.

 

public class Main {
    public static void main(String[] args) {
        MyResource myResource = new MyResource();
        try {
            myResource.doSomething();
            MyResource myResource2 = null;
            try {
                myResource2 = new MyResource();
                myResource2.doSomething();
            } finally {
                if (myResource2 != null) {
                    myResource2.close();
                }
            }
        } finally {
            myResource.close();
        }
    }
}

 

심지어 Resource 를 하나 더 생성해서 무언가를 수행하면 try-finally 구문이 nested 된 형식으로 계속해서 중첩되는 것을 볼 수 있다. 가독성도 떨어지고 실수할 가능성도 많아진다. 차라리 자바 7부터 제공하는 try-with-resource 를 사용하자.

 

public class Main {
    public static void main(String[] args) {
        try(MyResource myResource = new MyResource();
            MyResource myResource2 = new MyResource()) {
            myResource.doSomething();
            myResource2.doSomething();
        } catch (Throwable e) {
            Throwable[] suppExe = e.getSuppressed();
            System.out.println("Suppressed Exception Array" + " length = " + suppExe.length);
            for (int i = 0; i < suppExe.length; i++) {
                System.out.println("Suppressed Exceptions:");
                System.out.println(suppExe[i]);
            }
        }
    }
}
//Do something
//Close my Resource
//Close my Resource
//Suppressed Exception Array length = 2
//Suppressed Exceptions:
//org.example.SecondError
//Suppressed Exceptions:
//org.example.SecondError

 

첫 번째 에러가 발생하고 두 번째 에러가 발생했을 때 두 번째 에러를 Suppressed 로 쌓아두고 첫 번째 에러를 먼저 보여준다. 또한 catch 구문을 통해 Exception 을 받아 처리할 수도 있으며 여기서 getSuppressed API 를 통해 에러 로그들을 소스코드 단에서 직접 핸들링할 수도 있다.

 

"try-with-resources 구문은 가독성도 훌륭할 뿐 아니라 디버깅하기도 쉽다."

반응형
반응형

ITEM 8 "finalizer 와 cleaner 사용을 피하라"

 

 

 

이 ITEM 을 확인하기 전에 위 백기선님의 Effective Java 해설 영상을 보는 것을 추천한다.

처음에 글을 읽자마자 이해가 안 되었는데 영상을 통해 이해 되는 부분이 많았다. 무조건 시청하는 것을 추천한다.

아래 동영상은 finalizer 를 오버라이딩 했을 때 부모 객체의 필드를 접근할 수 있는 공격 예제이다. 단점 4에서 설명한다.

 

자바는 두 가지 객체 소멸자를 제공한다. finalizer 와 cleaner.

finalizer 와 cleaner 는 해당 객체가 JVM 에서 Garbage Collection 을 해야 할 대상이 될 때 호출되는 메서드이며, 모두 GC 로 인해 자원이 회수가 되어야만 소멸을 할 수 있다.

 

자바 9에서는 finalizer 를 deprecated API 로 지정하고 cleaner 를 사용 대안으로 제시하고 있지만, 둘 다 엄청난 단점들을 가지고 있어 두 소멸자를 의지하여 코딩하면 안 된다. C++ 에서 destructor(소멸자) 와 같은 개념으로 오해하기 쉽지만 전혀 다른 개념이다. 정말 자원 반납을 하고 싶다면 다음 장에서 언급할 try-with-resource 나 try-finally 를 통해 해야한다.

 

단점 1

finalizer 와 cleaner 가 언제 실행이 될지 알 수 없다. 스코프를 벗어난 지역변수라던가, 값이 할당되지 않은(null 인) 변수들이 GC 의 대상이 된다는 것은 얼핏 알고 있지만 GC 의 대상이 될 뿐 언제 GC 가 회수해 가는지 누구도 알지 못 한다. 전적으로 GC 알고리즘에 달려 있으며, 구현체마다 천차만별이다. 자원 해제하는데 시간이 얼마나 걸릴지 모른다는 것은 타이밍이 중요한 작업에서는 hell(지옥)이다.

 

finalizer 스레드는 다른 애플리케이션 스레드보다 우선 순위가 낮아서 실행될 기회를 제대로 얻지 못 할 수도 있다. 한편, cleaner 는 백그라운드에서 실행하며 자신을 수행할 스레드를 제어할 수 있다는 면이 있지만 GC 통제하에 있다 보니 즉각 수행되리라는 보장이 없다.

 

즉시 실행되지 않을 뿐 아니라 아예 실행하지 않을 수도 있다. 자바 언어 명세서에서도 수행 여부조차 보장하지 않는 것을 볼 수 있다. 따라서, Finalizer나 Cleaner로 저장소 상태를 변경하는 일을 하지 말아야 된다. 데이터베이스 같은 자원의 락을 그것들로 반환하는 작업을 한다면 전체 분산 시스템이 멈춰 버릴 수도 있다.

 

System.gc 나 System.runFinalization 메서드에 속지 말아야 한다. 실행될 가능성을 높여줄 수는 있으나 보장하지 않는다.

사실 실행을 보장하기 위해 System.runFinalizersOnExit 와 Runtime.runFinalizersOnExit 메서드를 제공하려고 시도했지만 심각한 결함 때문에 수십 년간 지탄받아 왔다.

 

단점 2

finalizer 동작 중 발생한 예외는 무시된다. 처리할 작업이 있다고 하더라도 바로 종료된다. 종료될 때 마무리가 덜 된 상태로 남을 수 있고 다른 스레드가 훼손된 객체를 사용한다면 어떻게 동작할지 예측이 불가하다. 스택 추적 내역을 확인할 수도 없다.

 

단점 3

심각한 성능문제가 있다. AutoCloseable 객체를 생성하고 try-with-resource 로 자신을 닫게 했다면 12ns 가 걸리지만 finalizer 를 사용하면 550ns 가 걸렸다고 한다. cleaner 도 마찬가지이다. 

 

단점 4

원래 객체 생성이 안 되는데 상속 받아 하위 클래스에서 객체를 생성하고 finalize 를 오버라이딩해 상위 클래스의 기능을 사용할 수 있다. (finalize attack) finalize 는 객체가 소멸될 때 GC 에 의해 발생하므로 강제로 exception 을 발생시켜 해제함과 동시에 GC 가 회수해 가야 된다는 조건이 있긴 하다. 객체 생성을 막기 위해 생성자에서 예외를 던졌는데 finalize 를 통해 우회한 것이다. 사실 하위 클래스를 만들 권한이 있다면, 이미 심각한 수준의 보안 문제를 야기한 것이라고 생각하지만 Trigger 과정에서 공격의 범위가 넓어지는 것이니 보안 문제라고 볼 수 있다.

 

public class Account {
    private String accountName;
    public Account(String accountName) {
        this.accountName = accountName;
        if (this.accountName.equals("러시아")) {
            throw new IllegalArgumentException("돌아가");
        }
    }
    public void transfer(int amount, String to) {
        System.out.printf("transfer %d from %s to %s.", amount, this.accountName, to);
    }
}
public class BrokenAccount extends Account {
    public BrokenAccount(String name) {
        super(name);
    }
    @Override
    protected void finalize() throws Throwable {
        this.transfer(10000, "gold-egg");
    }
}

class AccountTest {
    @Test
    void 오_마이_갓() {
        Account account = null;
        try {
            account = new BrokenAccount("러시아");
        } catch (Exception exception) {
            System.out.println("No?");
        }
        System.gc();
        Thread.sleep(3000L);
    }
}

 

정말 finalize 를 사용해야 한다면 위와 같은 보안 문제를 막기 위해 final 로 선언하자. 

 

그렇다면, 어떻게 자원을 해제해야 하는 것일까?

그저 자원을 해제할 클래스에 AutoCloseable 을 구현해주고 클라이언트에서 close 메서드를 호출하면 된다.

 

public class Resource implements AutoCloseable {
	private boolean closed;
    @Override
    public void close() throws RuntimeException {
        if (this.closed) {
        	throw new IllegalStateException();
        }
        closed = true;
        System.out.println("close");
    }
    public void hello() {
        System.out.println("hello");
    }
    // 안정망
    @override
    protected final void finalize() throws Throwable {
    	if(!this.closed) close();
    }
}

public class Runner {
    public static void main(String[] args) {
        Resource resource1 = null;
        // 첫 번째 방법. try-catch
        try{
            resource1 = new Resource();
            resource1.hello();
        } finally {
            if(resource1 != null) {
                resource1.close();
            }
        }

        // 두번째 방법. try-with-resource
        // scope 를 벗어나면 자동으로 close 메서드 호출
        try(Resource resource2 = new Resource()) {
            resource2.hello();
        }
    }
}

 

그럼 Cleaner 와 Finalizer 는 언제 필요할까?

첫 번째. 안전망 역할.

클라이언트가 실수로 close 메서드를 호출하지 않아 자원 반납을 안 할 수 있다. cleaner 와 finalizer 가 즉시 호출되지는 않지만 늦게라도 자원 회수를 해주는 것이 안전하기 때문에 사용된다. 바로 위 코드에서 finalize 를 오버라이딩해 close 를 삽입하여 안전망을 설치하는 것을 볼 수 있다.

 

두 번째. 네이티브 피어와 연결된 객체를 해제할 때 사용.

네이티브 오브젝트는 순수 자바코드로만 프로그래밍되지 않은 오브젝트를 말한다. 간혹 다른 언어나 어셈블리어를 통해 기능을 구현할 때가 있는데 그 기능들의 메서드를 통해 위임받아 기능을 수행하는 자바 오브젝트를 네이티브 피어라고 한다. 이 네티이브 피어는 가비지 컬렉터에서 그 존재를 알지 못하기 때문에 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. 명시적으로 close 메서드를 사용하는 것이 best 이나 cleaner 와 finalizer 로도 자원 회수가 가능하다.

권장은 역시 close 메서드!

 

cleaner 사용 방법

위에서 finalizer 와 관련된 코드를 살펴보았지만 cleaner 사용방법은 확인하지 않았다.

AutoClosable 구현체 내부에 Cleaner 와 Cleanable 를 생성 후 생성자에서 해당 객체와 청소할 객체를 연결시켜주면 끝이다. 주의할 점은 청소할 객체에 AutoClosable 구현 객체를 주입하면 안 된다. 순환참조가 발생한다. 

 

public class Resource implements AutoCloseable {
    private boolean closed;
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;
    private final ResourceCleaner resourceCleaner;

    public Resource(){
        this.resourceCleaner = new ResourceCleaner();
        this.cleanable = CLEANER.register(this, resourceCleaner);
    }
    private static class ResourceCleaner implements Runnable {
        // 절대로 Resource 객체를 이 클래스에 주입하면 안 된다.

        @Override
        public void run() {
            System.out.println("Clean");
        }
    }
    @Override
    public void close() throws RuntimeException {
        if (this.closed) {
            throw new IllegalStateException();
        }
        closed = True;
        cleanable.clean();
    }
}
반응형
반응형

ITEM 7 "다 쓴 객체 참조를 해제하라"

 

 

이 ITEM 을 확인하기 전에 위 백기선님의 Effective Java 해설 영상을 보는 것을 추천한다.

열심히 문어체로 정리하고 작성하지만, 구어체를 따라 올 전달력은 없는 것 같다. 열심히 예를 들어 설명해주셔서 덕분에 이해가 잘 됐다.

 

자바에는 GC (Garbage Collector) 가 있기 때문에 메모리 관리에 대해 신경을 쓰지 않아도 될 것이라고 생각하기 쉽지만, 사실 그렇지 않다. GC 가 언제 메모리를 회수해 가는지 정책(?) 을 잘 알아야 메모리 누수 문제에 해방될 수 있다.

 

public class Stack {

    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        this.ensureCapacity();
        this.elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        return this.elements[--size]; // 여기가 문제!
    }

    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보한다.
     * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
     */
    private void ensureCapacity() {
        if (this.elements.length == size) {
            this.elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

 

위 스택을 구현한 코드는 메모리 누수 문제가 있다.

스택에 아이템을 계속 쌓았다가 다시 빼냈다고 가정해보자. 스택이 차지하는 메모리가 줄어들 것이라고 예상이 되지만, 줄어들지 않는다. pop 메서드에서 this.element 를 리턴하고 있는데 배열의 인덱스만 줄어들었을 뿐 배열의 크기는 조정이 된 것이 없기 때문에 계속해서 메모리만 할당이 되고 해제가 되지 않는 것이다.

 

그렇다고 해도 GC 가 주기적으로 검사하여 해제를 할 수 있을 것 같은데 무엇이 문제일까?

GC 는 scope 중심으로 scope 가 끝나는 지점에 더 이상 사용이 되지 않는 변수들을 해제한다. 하지만 위와 같이 직접 메모리를 관리하는 경우, 명시적으로 null 을 삽입해주어야 해제 대상이 된다. 위 스택 코드를 보면 element 배열에 있는 값을 그대로 두고 인덱스만 조정하기 때문에 값은 그대로 있게 되고 해제 대상이 되지 않는 것을 볼 수 있다. (객체들의 다 쓴 레퍼런스를 그대로 가지고 있다라고 표현한다.)

 

public Object pop() {
    if (size == 0) {
        throw new EmptyStackException();
    }

    Object value = this.elements[--size];
    this.elements[size] = null;
    return value;
}

 

위와 같이 스택에서 꺼낼 때 그 자리를 null 로 설정해주면 GC 가 발생할 때 레퍼런스가 정리된다. 실수로 null 처리한 참조를 사용하여 NullPointerException 이 발생할 수 있지만, null 이 처리되지 않고 인식하지 못 한 상태에서 값이 사용되는 것보단 낫다. 프로그래밍 에러는 언제든지 빨리 포착하는 것이 유익하다.

 

하지만, 모든 필요 없는 객체를 null 로 만들 필요는 없다. 그 레퍼런스를 가리키는 변수를 스코프 안에서만 사용한다면 문제가 없다. ("변수를 가능한 가장 최소의 scope 안에서 처리하라" 라는 규칙만 지킨다면 자연스럽게 해결할 수 있다.)

보통은 위와 같은 규칙을 지키면 문제가 없고, 스택 코드처럼 메모리를 직접 관리하는 코드만 메모리 누수에 주의하면 된다.

 

캐시 역시 메모리 누수를 일으키는 주범이다. 재사용할 목적으로 객체의 레퍼런스를 캐시에 넣어두지만, 언제 이 캐시가 유효한지 정확히 정의하기 어렵기 때문에 객체를 다 쓴 뒤에도 비우는 것을 잊기 쉽다. 외부에서 키를 참조하는 동안만 엔트리가 살아 있다고 가정하면 WeakHashMap 을 사용하는 것도 나쁘지 않다. 캐시의 키에 대한 레퍼런스가 캐시 밖에 서 필요 없어지만 자동으로 제거되는 원리이다.

 

그런데 보통은 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 사용한다. 백그라운드 스레드를 통해 쓰지 않는 엔트리를 청소하거나 캐시에 새 엔트리를 추가할 때 엔트리를 검사하여 청소하는 방법이 있다. LinkedHashMap 은 removeEldestEntry 메서드가 후자의 방식으로 처리한다.

 

리스너와 콜백도 메모리 누수를 일으킬 수 있다. 콜백을 등록하기만 하고 명확히 해지하지 않는다면 계속 쌓이기만 할 것이다. 이럴 때 콜백을 약한 참조로 저장하면 GC 가 수거할 수 있다. WeakHashMap 에 키로 저장하는 방법도 한 예이다.

 

Weak 레퍼런스에 대한 자세한 내용은 아래 링크를 참조하자.

https://web.archive.org/web/20061130103858/http://weblogs.java.net/blog/enicholas/archive/2006/05/understanding_w.html

 

"다 쓴 객체 참조를 살려두면 GC 는 그 객체 뿐만 아니라 그 객체를 참조하는 모든 객체를 회수해가지 못 한다.

GC 의 편리성에 속아 성능을 잃지 말자."

반응형
반응형

ITEM 6 "불필요한 객체 생성을 피하라"

 

 

이 ITEM 을 확인하기 전에 위 백기선님의 Effective Java 해설 영상을 보는 것을 추천한다.

열심히 문어체로 정리하고 작성하지만, 구어체를 따라 올 전달력은 없는 것 같다. 열심히 예를 들어 설명해주셔서 덕분에 이해가 잘 됐다.

 

객체를 새로 만드는 대신 하나를 재사용하는 것이 적절할 때가 있다. 불변 객체의 경우 언제든 재사용할 수 있고 가변 객체라고 하더라도 사용 중에 변경이 되지 않을 것임을 안다면 재사용할 수 있다. 재사용할 수 있음에도 불구하고 매번 같은 기능의 객체를 생성하는 것은 불필요하며 성능에 제약을 가져다 준다. 불필요한 객체를 생성하는 예를 살펴보자.

 

new String() 을 사용하지 말자.

자바의 문자열 String 을 new 로 생성하면 매번 새로운 객체를 만들게 된다.

가급적 String s = "gold-egg"; 와 같이 String 객체를 생성하는 것이 올바르다. 문자열이 같다면 리터럴 자체를 재사용한다.

 

String s1 = new String("gold-egg");
String s2 = new String("gold-egg");
System.out.println(s1 == s2); // false
System.out.println(s1.equals(s2)); // true

String s3 = "gold-egg";
String s4 = "gold-egg";
System.out.println(s3 == s4); // true
System.out.println(s3.equals(s4)); // true

 

static 팩토리 메서드를 사용하자.

Item 1 에서 살펴보았듯이 static 팩토리 메서드는 매번 새로운 객체를 만들지 않고 캐싱해 둔 객체를 리턴할 수 있다.

Boolean(String) 대신 Boolean.valueOf(String) 처럼 static 팩토리 메서드를 사용하자. 생성자는 매번 새로운 객체를 생성하기 때문에 위와 같이 불변객체라면 팩토리 메서드를 통해 캐싱된 객체를 사용하는 것이 바람직하다.

 

비싼 객체라면 재사용할 수 있는지 고려해야 한다.

객체 생성 시, 메모리나 시간이 오래 걸리는 객체를 비싼 객체라고 표현한다. 이런 비싼 객체가 반복해서 필요하다면 성능 문제가 발생할 수 있다. 캐싱하여 재사용할 수 있는지 검토해야 한다.

 

static boolean isRomanNumeral(String s) {
	return s.matches("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

 

여기 문자열이 로마 숫자인지 검증하는 정규표현식 코드가 있다. String.matches 메서드를 통해 정규표현식이 매치되는지 확인하는데 내부적으로 정규표현식용 Pattern 인스턴스가 만들어진다. matches 메서드 인자 값이 Pattern 인스턴스 재료이다. 그런데 잘 보면 매치가 일어난 다음에 scope 가 끝나버려 바로 GC 의 대상이 되는 것을 볼 수 있다.

 

Pattern 인스턴스는 정규표현식을 표현하기 위해 내부적으로 FSM 유한 상태 기계를 만드는데 이 비용이 비싸다. Pattern 인스턴스가 불변이라면 클래스 초기화 과정에 직접 생성해 캐싱해두고, 필요할 때마다 재사용한다면 성능을 향상시킬 수 있다.

 

public class RomanNumber {

    private static final Pattern ROMAN = Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

    static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }

}

 

개선 후 조금 더 빨라지고 코드도 더 명확해진 것을 볼 수 있다. 어떤 문자열을 매칭하는 것인지 몰랐지만 Pattern 을 바깥으로 끄집어 내면서 ROMAN 이라는 문자열을 통해 무엇을 비교하는지 명확해진 것이다. 그러나 만약 초기화 된 후 isRomanNumeral 메서드를 사용하지 않는다면 쓸데없이 객체를 생성한 꼴이 된다는 점도 알아야 한다. 처음 호출 할 때 초기화가 될 수 있도록 지연 초기화라는 방법을 생각할 수 있지만 코드는 더 복잡해지고 성능은 개선되지 않는 경우가 많아 비추천한다.

 

반대로 같은 객체라고 생각을 하지 못 하고 객체를 공유하여 사용해서 잘못된 side-effect 가 발생할 수도 있다.

 

public static void main(String[] args) {
    Map<Integer, String> apt = new HashMap<>();
    apt.put(101, "gold-egg");
    apt.put(102, "silver-egg");

    Set<Integer> keySet1 = apt.keySet();
    Set<Integer> keySet2 = apt.keySet();

    System.out.println(keySet1 == keySet2); // true

    keySet1.remove(101);
    System.out.println(keySet2.size()); // 1
    System.out.println(apt.size()); // 1
}

 

불변 객체라면 안전하게 재사용할 수 있지만 어댑터 패턴으로 생성된 뷰 객체들은 원본 객체들을 수정할 수 있기 때문에 주의가 필요하다. Map 인터페이스의 KeySet 메서드는 Map 뒤에 있는 Set 인터페이스의 뷰를 제공한다. key 들만 따로 모아놓은 Set 인터페이스인데 호출할 때마다 새로운 객체가 생성되는 것이 아니라 같은 객체를 리턴하게 된다.

 

리턴 받은 Set 객체를 변경하면 그 뒤에 있는 Map 객체도 변경하게 될 수 있다.

 

오토박싱은 불필요한 객체를 생성한다.

오토박싱과 오토 언박싱은 프리미티브 타입과 레퍼런스 타입을 섞어 사용할 때 자동으로 상호 변환해주는 기술이다.

Int <-> Integer, boolean <-> Boolean 등... 

 

public class AutoBoxingExample {

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        Long sum = 0l;
        for (long i = 0 ; i <= Integer.MAX_VALUE ; i++) {
            sum += i;
        }
        System.out.println(sum);
        System.out.println(System.currentTimeMillis() - start);
    }
}

 

위 예제에서 sum 변수의 타입을 실수해서 Long(대문자) 로 만들었기 때문에 불필요한 Long 객체가 2의 31제곱만큼 만들어지게 된다. i 와 sum 둘 다 long 타입이었으면 별도의 객체를 만들지 않아도 되었을텐데 레퍼런스 타입 때문에 불필요한 객체가 많이 생기게 되는 것이다. 오토박싱은 프리미티브 타입과 레퍼런스 타입의 경계가 안 보여주지만 그렇다고 그 경계가 없어지진 않는다.

 

"그렇다고 객체 생성이 항상 비싸며 가급적 피해야 한다는 오해를 해서는 안 된다.

방어적인 복사를 해야 하는 경우에도 객체를 재사용하면 심각한 버그와 보안 문제가 발생할 수 있다.

불필요한 객체 생성을 피해야 한다는 점을 리마인드해야 한다."

반응형

+ Recent posts