본문 바로가기
  • GDG on campus Ewha Tech Blog
3-2기 스터디/이펙티브 자바 독서

2주차 - 2,3장(item 7-12)

by eeMovie 2022. 5. 10.

>GDSC 이펙티브 자바 스터디 3주차 기록입니다.
>2장과 3장, 아이템 7~12를 읽고 스터디원들이 정리한 글입니다.
>아이템 7,8(배수현) 아이템 9,10 (이혜빈) 아이템 11,12 (정수진)

[아이템 7] 다 쓴 객체 참조를 해제하라

객체 참조를 제때에 미리 해제하지 않으면 메모리 누수가 나기 쉽다.

이러한 문제는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있으니 미리 예방법을 익혀두는 것이 좋다.

  1. 자기 메모리를 직접 관리하는 클래스에서 쓰지 않는 객체는 null처리하기
public class Stack{
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack(){
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    public void push(Object e){
        ensureCapacity();
        elements[size++] = e;
    }
    public Object pop(){
        if(size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

    private void ensureCapacity(){
        if(elements.length == size)
            elements = Arrays.copyOf(elements,2*size+1);
    }
}

위 코드의 Stack 클래스는 ensureCapacity 메서드를 통해 elements 메모리의 크기를 자신이 직접 관리한다.

이 코드에서 메모리 누수가 일어날 수 있는 지점은 pop 메서드인데, elements 배열의 index를 size 변수로 포인터 위치만 앞으로 옮겨갈 뿐, 실질적으로 pop되어서 더 이상 쓰이지 않는 Object 객체는 여전히 elements 배열에 살아 남아있다.

elements 배열을 활성 영역이라고 하는데, 더 이상 쓰지 않는 객체가 활성 영역에 남아있는 경우 가비지 컬렉터가 해당 객체 뿐만 아니라 해당 객체가 참조하는 객체들 모두를 회수해가지 못하는 문제가 생긴다. 이는 잠재적으로 성능 저하의 문제를 초래한다.

따라서 위와 같은 경우 pop을 해서 더 이상 참조할 일이 없는 객체는 null처리를 해주어야 한다. 아래 코드와 같이 말이다.

public Object pop(){
    if(size == 0) throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 다 쓴 참조 해제
    return result;
}

이 코드는 메모리 누수 문제 말고도 더 이상 쓰이지 않는 객체를 참조하려고 하면 NullpointException이 나서 오류 수정에 더욱 용이하다는 장점이 있다.

  1. WeakHashMap 사용하기

일반적인 HashMap의 경우 일단 Map 안에 Key 와 Value 가 put되면 사용여부와 관계 없이 해당 내용은 삭제되지 않는다. WeakHashMap은 Key 객체가 null이 되면 강제 Garbage Collection 이후에 해당 key와 value의 값을 map에서 제거한다.

public class WeakHashMapTest {
    public static void main(String[] args) {
        WeakHashMap<Integer, String> map = new WeakHashMap<>();
        Integer key1 = 1000;
        Integer key2 = 2000;
        map.put(key1, "test a");
        map.put(key2, "test b");
        key1 = null;
        System.gc();  //강제 Garbage Collection
        map.entrySet().stream().forEach(el -> System.out.println(el));
    }
}

[아이템 8] finalizer와 cleaner 사용을 피하라

자바 언어 명세는 finalizer와 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 못한다. 따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다.

finalizer와 cleaner는 가비지 컬렉터의 효율을 떨어뜨리기 때문에 심각한 성능 문제도 동반한다.

AutoCloseable

try-with-resources 형태는 아래 코드와 같이 try()의 괄호 안에서 try 문이 끝날 때 종료(close)할 객체를 선언해주는 형태이다. 이 패턴의 장점은 의도한 코드대로 진행이 다 되고 난 후 객체를 해제하고 싶을 때 복잡한 예외처리 없이 코드를 짤 수 있다는 것이다.

다만 try-with-resources 형태에서 자동으로 close()메서드가 동작하기 위해서는 해당 인스턴스가 Autocloseable을 구현하고 있어야 한다.

public class AutoCloseableExample {
    public static void main(String[] args) {
        try (CustomResource customResource = new CustomResource()){
            customResource.doSomething();
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}

public class CustomResource implements AutoCloseable {

    public void doSomething(){
        System.out.println("Do something ...");
    }

    @Override
    public void close() throws Exception {
        System.out.println("CustomResource Closed!");
    }
}

굳이 cleaner와 finalizer을 사용하는 경우?

  1. AutoCloseable 에서 close 메서드가 동작하지 않을 상황을 대비한 안전망
  2. 그러나 값어치가 있는지 잘 따져봐야 한다고 하는 걸로 보아 딱히 추천하지는 않는 것 같다.
  3. 네이티브 객체 회수 할 때
  4. 네이티브 객체는 자바 객체가 아니니 가비지 컬렉터가 그 존재를 모르기 때문에 cleaner와 finalizer가 회수해줘야 한다고 한다. 그러나 이 경우에도 위험 부담은 여전히 존재하기 때문에 네이티브 객체가 심각한 자원을 가지고 있어서 반드시 회수해줘야 하는 경우에만 사용하기

[아이템 9] try -finally 보다는 try -with -resources 를 사용하라

꼭 회수해야 하는 자원을 다룰 때는 try-with-resources를 사용하자

자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어진다.

try- with- resources의 핵심은 AutoCloseable 인터페이스 구현이다. 닫아야 하는 자원을 뜻하는 클래스를 작성한다면 AutoCloseable을 반드시 구현해야 한다.

static void copy(String src, String dst) throws IOException{
    try (InputStream in = new FileInputStream(src);
        OutputStream out = new FileOutputStream(dst)){
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while((n == in.read(buf)) <= 0)
            out.write(buf, 0,n);
}
}

여기서 catch 문을 사용해서 try에서 발생할 수 있는 예외를 잡아준다면 close 호출에서 발생하는 예외와 try 에서 실행할 코드에서 발생하는 예외를 둘 다 잡을 수 있다는 장점이 있다.

[아이템 10] equals는 일반 규약을 지켜 재정의하라

equals는 단순히 객체를 비교하는 때 뿐만 아니라 contains 메서드가 작동할 때도 작동하므로 주의해주어야 한다.

재정의하지 않는 것이 좋은 상황

  1. 각 인스턴스가 본질적으로 고유한 경우 : 값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스
  2. 인스턴스의 논리적 동치성을 검사할 일이 없는 경우 : 애초에 논리적으로 같은지 여부를 판단할 필요가 없는 인스턴스의 클래스는 재정의할 필요가 없음
  3. 상위 클래스에서 재정의한 equals를 하위 클래스에서 사용하면 되는 경우: 굳이 하위 클래스에서 또 재정의하지 않는다.
  4. 클래스가 private 이거나 package private 이고 equals 메서드를 호출할 일이 없을 때
@Override public boolean equals(Object o){
    throw new AssertionError(); 
}

아예 예외 처리를 해서 equals 메서드 호출을 막아버린다.

equals를 재정의해야 하는 상황

객체 식별성(물리적으로 같은지)이 아닌 논리적 동치성(갖고 있는 값이 같은지) 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다.

주로 값 클래스들이 여기에 해당한다. IntegerString같이 특정 값을 표현하는 클래스들을 말한다. 이 두 인스턴스를 equals로 비교할 때는 객체가 같은 객체인지를 보고자 하는 것이 아니라 객체가 담고 있는 내용이 같은지를 판단하고 싶을 때이다.

값 클래스여도 같은 인스턴스가 여러개 만들어지지 않도록 정적 팩터리 메서드를 사용하는 클래스거나 Enum 인 경우 재정의할 필요가 없다.

equals 규약 지키기

equals 규약을 지키지 않으면 그 객체를 사용하는 다른 객체들이 어떻게 반응할 지 알 수 없다.

  1. 대칭성

대칭성의 핵심은 두 객체가 서로에 대한 동치 여부에 똑같이 답해야 한다는 것이다. 다시 말해 객체 A와 객체 B가 있다고 할 때, A.equals(B)B.equals(A)는 모두 같은 값을 내야 한다.

이를 어기는 예시 코드는 아래와 같다.

public final class CaseInsensitiveString{
    private final String s;

    public CaseInsensitiveString(String s){
        this.s = Objects.requireNonNull(s);
    }
 //대칭성 위배
    @Override public boolean equals(Object o){
        if(o instance of CaseInsensitiveString) return s.equalsIgnoreCase{
        ((CaseInsensitiveString) o).s);
        if(o instance of String) // 한방향으로만 작동
        return s.equalsIgnoreCase((String) o);
        return false;
}
}

위 코드는 caseInsensitiveString.equals(string)은 true를 반환하지만 string.equals(caseInsensitiveString)은 false를 반환하게 된다.

CaseInsensitiveString 클래스의 equals는 string을 같은 값으로 인식하도록 재정의 되었지만 String 클래스의 equals는 그렇지 않기 때문이다.

따라서 이 경우 다른 타입의 클래스끼리 equals로 비교하는 것은 웬만하면 시도하지 않는 것이 좋다. 특히 String과 같은 사용자 정의 클래스가 아닌 클래스는 다른 타입과 equals로 값을 비교하는 것이 불가능하다.

@Override public boolean equals(Object o){
    return o instanceof CaseInsensitiveString && 
    ((CaseInsensitiveString)o)s.equals.IgnoreCase(s);
}

위의 코드처럼 equals는 CaseInsensitiveString 클래스끼리만 비교하도록 설계하는 것이 좋다.

  1. 추이성

추이성은 A=B이고 B=C 이면 C=A 가 성립해야 한다는 뜻이다. 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황에서 추이성을 어기기 쉽다.

public 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 o){
    if(!(o instanceof Point) return false;
    Point p = (Point) o;
    return p.x == x && p.y == y;
}
}
public class ColorPoint extends Point{
    private final Color color;

    public ColorPoint(int x, int y, Color color){
        super(x,y);
        this.color = color;
    }
}

여기서 만약 ColorPoint 의 eqauls 메서드를 재정의 안하고 그대로 둔다면 ColorPoint의 color 는 무시한 채 비교하게 될 것이다.

그래서 ColorPoint 인 경우는 color까지 비교하도록 equals를 짠다면 아래와 같아질 것이다.

@Override public boolean eqauls(Object o){
    if(!(o instanceof ColorPoint)) return false;
        return super.eqauls(o) && ((ColorPoint)o).color == color;
}

이런 경우 point.equals(colorpoint) 는 true, colorpoint.equals(point)는 false를 반환하는 대칭성 위배 문제가 생긴다.

ColorPoint가 equals에서 Point인 경우 color를 무시하게 코드를 짤 경우 추이성이 깨진다.

@Override public boolean equals(Object o){
    if(!(o instanceof Point)) return false;
    if(!(o instanceof ColorPoint)) return o.eqauls(this);
    return super.equals(o)&&((ColorPoint)o).color == color;
}

같은 위치의 세 점 A, B,C 중 A는 그냥 Point고 B와 C의 색이 다르다면 A = B이고 A=C이지만 B≠C이기 때문이다.

해결 방법은 없다, 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재X
무조건 같은 클래스끼리만 equals로 비교해야 함

이 모든 규약을 지키며 상위 클래스 컬렉션의 contain메서드에서 하위클래스도 유효하려면?

하위 클래스는 상위 클래스를 상속하는 대신 final 필드로 상위 클래스의 객체를 가진다.

또 상위 클래스 필드만 리턴해주는 View 메서드를 구현한다.

public class ColorPoint{
    private final Point point;
    private final Color color;

    public ColorPoint(int x, int y, Color color){
        point = new Point(x,y);
        this.color = Objects.requireNonNull(color);
    }

public Point asPoint(){ return point;}

@Override public boolean equals(Object o){
    if(!(o instanceof ColorPoint)) return false;
    ColorPoint cp = (ColorPoint)o;
    return cp.point.equals(point)&&cp.color.equals(color);
}
  1. 일관성

equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다 : 예컨대 java.net.URLequals메서드는 호스트 IP에 따라 값이 달라지기 때문에 쓰지 않는 것이 좋다.

⭐⭐Equals 메서드 재정의하는 방법 ⭐⭐ (사실 이거만 봐도 될 듯)

  1. ==연산자를 활용해서 입력이 자기 자신의 참조인지 확인한다.
  2. 성능 향상의 효과를 기대할 수 있다.
  3. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
     if(!(o instanceof ColorPoint)) return false;
  4. 그렇지 않다면 false를 반환한다. equals 재정의 규약에 따라 반드시 같은 타입(클래스)의 인스턴스만 비교하도록 한다.
  5. 입력을 올바른 타입으로 형변환한다.
     ColorPoint cp = (ColorPoint)o;
  6. 앞서 instanceof를 했기 때문에 여기서 오류가 날 가능성은 없다.
  7. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
     return cp.point.equals(point)&&cp.color.equals(color);
  8. 비교해야 하는 필드를 하나씩 모두 검사하라는 이야기이다.

인스턴스 비교 주의사항

floatdouble을 제외한 기본 타입 필드는 ==연산자로 비교한다.

참조 타입 필드는 각각의 equals메서드로 비교한다.

floatdouble필드는 각각 정적 메서드인 Float.compare(float,float) , Double.compare(double,double)로 비교한다.

배열 비교의 경우 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드를 사용한다.

간혹 null 값을 정상 값으로 취급하는 참조 타입 필드도 있는데, 이 경우에는 Objects.equals(Object, Object)로 비교해서 NullPointerException을 예방해야 한다.

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 하기 때문에 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교한다.

추가적인 주의사항

  • equals를 재정의할 때는 hashCode도 반드시 재정의하기
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 않기 : @Override 어노테이션을 일관되게 사용하기 위함이다.

**@AutoValue 사용하기**

https://dahye-jeong.gitbook.io/java/java/advanced/2020-02-02-autovalue

AutoValue는 불변 객체 클래스를 편리하게 생성해주는 프레임워크로 위의 equals 메서드도 손쉽게 만들어낸다. 사용은 아래와 같이 한다.

@AutoValue
public abstract class Product {
    public abstract String name();
    public abstract BigDecimal price();

    @AutoValue.Builder
    public abstract static class Builder{
        public abstract Builder name(String name);
        public abstract Builder price(BigDecimal price);
        public abstract Product build();
    }

    public static Product.Builder builder(){
        return new AutoValue_Product.Builder();
    }
}

위 코드를 컴파일하면 자동으로 AutoValue_Product 클래스가 생성된다.

import java.math.BigDecimal;

final class AutoValue_Product extends Product {
    private final String name;
    private final BigDecimal price;

    private AutoValue_Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    public String name() {
        return this.name;
    }

    public BigDecimal price() {
        return this.price;
    }

    public String toString() {
        return "Product{name=" + this.name + ", price=" + this.price + "}";
    }

    public boolean equals(Object o) {
        if (o == this) {
            return true;
        } else if (!(o instanceof Product)) {
            return false;
        } else {
            Product that = (Product)o;
            return this.name.equals(that.name()) && this.price.equals(that.price());
        }
    }

    public int hashCode() {
        int h = 1;
        int h = h * 1000003;
        h ^= this.name.hashCode();
        h *= 1000003;
        h ^= this.price.hashCode();
        return h;
    }

    static final class Builder extends ch3.dahye.item11.Product.Builder {
        private String name;
        private BigDecimal price;

        Builder() {
        }

        public ch3.dahye.item11.Product.Builder name(String name) {
            if (name == null) {
                throw new NullPointerException("Null name");
            } else {
                this.name = name;
                return this;
            }
        }

        public ch3.dahye.item11.Product.Builder price(BigDecimal price) {
            if (price == null) {
                throw new NullPointerException("Null price");
            } else {
                this.price = price;
                return this;
            }
        }

        public Product build() {
            String missing = "";
            if (this.name == null) {
                missing = missing + " name";
            }

            if (this.price == null) {
                missing = missing + " price";
            }

            if (!missing.isEmpty()) {
                throw new IllegalStateException("Missing required properties:" + missing);
            } else {
                return new AutoValue_Product(this.name, this.price);
            }
        }
    }
}

코드에서 보다시피 AutoValue 는 필드와 클래스를 final로 제한하기 때문에 불변객체를 생성할 때만 사용해야 한다. @AutoValue가 붙는 클래스는 추상클래스로 작성해야 한다.

[아이템 11] equals를 재정의하려거든 hashCode도 재정의하라

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.

논리적으로 같은 객체는 같은 해시코드를 반환해야 한다. 하지만 Object의 기본 hashCode 메서드는 이 둘이 전혀 다르다고 판단하여 규약과 달리 서로 다른 값을 반환한다.

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707,867,5309),"제니"); 

//이후 
m.get(new PhoneNumber(707,867,5309)) // null 반환 

위 코드와 같이 논리적으로 같은 객체이지만 물리적 객체가 다르면 hashCode가 다르기 때문에 key 값으로서의 역할을 못하게 된다. Hashmap 입장에서는 그저 다른 객체 두 개일 뿐인 것이다.

이런 문제를 해결하기 위해 HashCode 메서드를 수정해야 한다.

HashCode 작성하는 요령

  1. int 변수 result를 선언한 후 값을 c로 초기화한다. 이때 c는 해당 객체의 첫번째 핵심 필드를 단계 2.a 방식으로 계산한 해시코드다. (이때 핵심 필드란 equals 메서드에서 비교할 필드를 말한다.)
  2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
    1. 해당 필드의 해시코드 c를 계산한다.
      1. 기본 타입 필드라면 Type.hashCode(f)를 수행한다.
      2. 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals 를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 더 복잡해질 것 같으면 이 필드의 표준형을 만들어 그 표준형의 hashCode를 호출한다. 필드의 값이 null 이면 0을 사용한다.
      3. 필드가 배열이라면 핵심 원소 각각을 별도의 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음 단계 2.b 방식으로 갱신한다. 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    2. 단계 2.a 에서 계산한 해시코드로 c를 result로 갱신한다. 코드는 다음과 같다.
    3. result = 31 * result + c;
  3. result를 반환한다.
@Override public int hashCode(){
//PhoneNumber의 filde는 short 타입의 areaCode, prefix, lineNum
     int result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        return result;
}

성능에 민감하지 않다면 코드가 더 깔끔한 hash를 사용할 수도 있음

@Override public int hashCode(){
    return Objects.hash(lineNum, prefix, areaCode);
}

클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야 한다. 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.

캐싱은 아예 해당 인스턴스의 필드로 hashCode를 인스턴스가 갖고 있게 하는 방식으로 구현한다.

private int hashCode; 

@Override public int hashCode(){
    int result = hashCode;
    if( result = 0){ // 해시코드 생성 안됨
      result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        hashCode = result;
    }
    return result;
}

성능을 높인답시고 핵심 필드를 해시코드 계산에서 생략하면 안된다

AutoValue를 사용하면 equals 뿐만 아니라 hashCode 메서드까지 자동생성된다.

릴리즈할 때는 hashCode 메서드를 클라이언트에게 공표하지 말아야 한다.

[아이템 12] toString을 항상 재정의하라

equalshashCode 규약만큼 대단히 중요하진 않지만, toString 을 잘 두현한 클래스는 사용하기 편하고 클래스를 사용한 시스템 디버깅에 용이하다.

실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다. 하지만 정보의 양이 방대한 경우에는 중요한 요약 정보를 담아야 한다.

주석으로 toString의 포맷에 대해 명시를 해줄 수 있는데 포맷이 향후 릴리즈에서 바뀐다면 많은 어려움을 겪는다는 단점도 있다.

포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공해야 한다.

AutoValue에서 toString 메서드도 오버라이드 해서 재정의해주고 있다.

이 포스트는 이펙티브 자바 서적을 참고하여 작성하였습니다.

댓글