요약

재3자가 확장할 수 없는 클래스라면 가능한한 직렬화 프록시 패턴을 사용하자

  • 직렬화 프록시 패턴 사용 불가능한 경우
    • 클라이언트가 클래스 확장할 수 있는 경우
    • 객체 그래프에 순환이 있는 클래스

직렬화 프록시 패턴을 사용하면 쉽게 중요한 불변식을 안정적으로 직렬화 할 수 있을 것이다.

 

1. 직렬화 프록시 패턴(Serialization Proxy Pattern)

Serializable을 구현하기로 했다면, 일반적인 인스턴스 생성 방법인 생성자 이외의 방법으로 인스턴스를 생성할 수 있게 된다. 이는 버그와 보안 문제가 커질 가능성이 있다. 따라서 직렬화로 인해 발생하는 버그/보안 문제를 직렬화 프록시 패턴으로 해결할 수 있다.

 

1-1. 직렬화 프록시 패턴 구현하기

바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다. 바깥 클래스와 직렬화 프록시 모두 implements Serializable 해야 한다.

  • 직렬화 프록시의 생성자 특징
    • 단 1개여야 한다
    • 바깥 클래스를 매개변수로 받아야 한다
    • 인수로 넘어온 인스턴스의 데이터를 복사하는 형태
    • 일관성 검사나 방어적 복사가 필요없다

 

1-2. 직렬화 프록시 패턴 예시

  • 직렬화 프록시 - SerializationProxy 클래스
public class SerializationProxy implements Serializable {
    private static final long serialVersionUID  = 234209470239740923L; // 아무 값
    private final Date start;
    private final Date end;

    public SerializationProxy(Period period) {
        this.start = period.start;
        this.end = period.end;
    }
    
    // Period.SerializationProxy 용 메서드 - readResolve()
    private Object readResolve() {
        return new Period(start, end);
    }
}
  • readResolve()
    • 바깥 클래스(Period)와 논리적으로 동일한 인스턴스를 반환하는 메서드
    • 직렬화 프록시 → 바깥 클래스 인스턴스 변환하는 역할이다
    • 생성자를 이용하지 않고도 역직렬화된 인스턴스 생성 기능을 제공한다
  • 바깥 클래스 - Period 클래스 
    • writeReplace() : 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 반환하는 메서드이다. 이로 인해 직렬화 시스템은 바깥 클래스의 직렬화된 인스턴스 생성이 불가능하다
    • readObject() : writeReplace 덕분에 바깥 클래스의 직렬화 인스턴스를 생성해낼 수 없지만, 바깥 클래스에 접근하려는 공격이 있을 수 있다. 이런 공격을 readObject()가 막아준다.
public class Period implements Serializable {
    // 접근자 어떻게 해야하나?
    final Date start;
    final Date end;

    public Period(Date start, Date end) {
        this.start = start;
        this.end = end;
    }

    // 직렬화 프록시 패턴용 메서드1
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    // 직렬화 프록시 패턴용 메서드2
    private void readObject(ObjectInputStream stream) throws InvalidObjectException {
        throw new InvalidObjectException("프록시가 필요합니다.");
    }
}

 

1-3. 직렬화 프록시 패턴 장점

  1. (방어적 복사와의 공통점) 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단
  2. 직렬화 프록시는 final 필드로 선언 가능하기에 바깥 클래스를 진정한 불변으로 만들 수도 있다
  3. 필드의 직렬화 공격 걱정이 없다
  4. 역직렬화 할 때 유효성 검사 수행하지 않아도 됨
  5. 직렬화 인스턴스 클래스 ≠ 역직렬화 인스턴스 인 경우에도 정상 작동한다 (ex. EnumSet)

 

1-4. EnumSet - 5번 예시

이 클래스는 public 생성자 없이 정적 팩터리들만 제공한다. 이 클래스는 열거 타입의 원소 개수에 따라 다른 클래스를 사용하는데, 원소 개수가 64개 이하면 RegularEnumSet을, 65개 이상이면 JumboSet을 사용한다.

원소 64개짜리 열거 타입을 가진 EnumSet을 직렬화한 후(RegularEnumSet), 원소 5개를 추가해 69개의 원소를 가진 EnumSet(JumboSet)을 역직렬화 한다면 어떻게 될까?

  • 처음 직렬화된 인스턴스는 RegularEnumSet 인스턴스이다
  • 역직렬화는 JumboEnumSet 인스턴스가 반환된다
  • 이는 EnumSet이 직렬화 프록시 패턴을 사용했기 때문이다

 

2. 직렬화 프록시 패턴의 한계

  1. 클라이언트가 멋대로 확장할 수 있는 클래스에는 직렬화 프록시 패턴을 적용할 수 없다.
  2. 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다. 이런 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려고 하면, ClassCastException이 발생할 것이다. 직렬화 프록시만 가졌을 뿐, 실제 객체는 아직 만들어진 것이 아니기 때문이다.
  3. 직렬화 프록시 패턴이 강력한 안정감을 주지만, 성능이 떨어질 수 있다.

요약

불변식을 지키기 위해 인스턴스 수를 통제(싱글턴 구현)해야 한다면, 열거 타입 사용을 권장한다.

직렬화 + 인스턴스 수 통제가 모두 필요하다면 readResolve() + 모든 참조 타입 인스턴스 필드 transient 선언을 하도록 하자.

  1. 열거타입
  2. readResolve() + 모든 인스턴스 필드 transient 선언

 

1. 싱글턴 패턴 + Serializable

싱글턴 패턴을 구현한 경우, implements Serializable로 직렬화를 구현했다면 이는 더이상 싱글턴처럼 동작하지 않는다.

커스텀 직렬화하고, readObject()를 제공하더라도 더이상 싱글톤으로 작동하지 않는다. 왜냐하면 클래스 초기화 시 생성한 인스턴스가 아닌 다른 인스턴스를 반환하기 때문이다.

 

 2. 해결책 - readResolve()

readResolve()를 사용하면 위의 경우에도 싱글턴 속성을 유지할 수 있다.

  • readResolve() psuedo code
readResolve(역직렬화 후 새로 생성된 객체) {
	return 직렬화한 객체 참조;
}

 

  • readResolve() - 예시
// 인스턴스 통제를 위한 readResolve - readObject()로 인한 문제 해결 가능성 존재
private Object readResolve(매개변수) {
  // 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다. 
  return INSTANCE;
}
  • 이 readResolve()는 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환한다. 이때 Elvis의 인스턴스의 모든 필드를 transient로 선언해야 한다.

 

2-1. transient 사용

readResolve()를 인스턴스 통제 목적으로 사용한다면, 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다. 그렇지 않으면 역직렬화된 객체의 참조 공격의 가능성이 있다.

 

2-2. 객체 참조 공격

역직렬화할 인스턴스에 non-transient 필드가 존재한다고 가정해보자. 이 경우에는 필드 내용이 역직렬화 된 후에 readResolve()가 실행된다. 이렇게 필드 내용이 역직렬화되는 시점& readResolve()가 실행되기 전 에 잘 조작된 스트림을 사용해 역직렬화된 인스턴스를 훔쳐올 수 있다.

 

  • 공격 원리
[도둑 클래스]
- 필드(impersonator) : 훔칠 예정인 싱글턴 직렬화 참조를 저장하는 역할

[싱글턴 클래스]
- non-transient 불변 필드(INSTANCE) : 도둑의 인스턴스 참조가 저장됨

* 싱글턴이 도둑 클래스의 참조를 포함하므로 싱글턴이 역직렬화될 때 도둑의 readResolve()가 먼저 호출됨(Elvis의 readResolve()가 아닌)
* 도둑의 readResolve()가 수행될 때,
  도둑의 인스턴스 필드에는 역직렬화 중인 싱글턴의 readResolve()가 수행되기 전의 싱글턴 참조가 담긴다

 

  • steal 시점
(1) 역직렬화 (2) ——————> (3) readResolve() 실행(싱글턴 객체 반환)

(2)번 단계에서 instance steal이 발생할 수 있다.

 

  • 잘못된 싱글턴
    • transient가 아닌 참조 필드를 가지고 있다
    • Elvis가 역직렬화 될 때, INSTANCE에 담긴 도둑 클래스 참조로 인해 Elvis의 readResolve()가 아닌, ElvisStealer의 readResolve()가 실행된다
    public class Elvis implements Serializable {
        // transient가 아닌 참조 필드
        // transient가 아니기에 이후 도둑의 인스턴스 참조가 담길 예정
        public static final Elvis INSTANCE = new Elvis();
        private Elvis() {}
        
        private String[] favoriteSongs = { "Hound Dog", "HeartBreak Hotel" };
        
        public void printFavorites() {
            System.out.println(Arrays.toString(favoriteSongs));
        }
        
        private Object readResolve() {
            return INSTANCE;
        }
    }
    

 

  • 도둑 클래스
public class ElvisStealer implements Serializable {
    static Elvis impersonator; // 훔칠 Elvis 인스턴스의 참조를 저장할 필드
    private Elvis payload; // 기존의 싱글턴 Elvis를 받음
    
    // .. ElvisStealer 생성자 
    ElvisStealer(Elvis elvis) {
        this.payload = payload;
    }
    
    private Object readResolve() {
      // resolve 되기 전의 Elvis 인스턴스의 참조를 저장한다.
      impersonator = payload;
      
      // favoriteSongs 필드에 맞는 타입의 객체를 반환한다.
      return new String[] {"A Fool Such as I"};
    }
    
    private static final long serialVersinUID = 0;
}
    

 

  • 아래 코드를 실행하면 서로 다른 2개의 Elvis 인스턴스를 생성한다
public class ElvisImpersonator {
    // 진짜 Elvis 인스턴스로는 만들어질 수 없는 바이트 스트림
    private static final byte[] serializedForm = {
            (byte)0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05,
            0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte) 0xe6,
            (byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x86
            // .. 
    };

    public static void main(String[] args) {
        // ElvisStealer.impersonator 를 초기화한 다음
        // 진짜 Elvis(즉, Elvis.INSTANCE)를 반환한다.
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;
        
        elvis.printFavorites();
        impersonator.printFavorites();
    }
}

 

  • 결과
    • Elvis는 싱글턴 패턴으로 구현했음에도 두 개의 인스턴스가 생성된 것을 확인할 수 있다.
    • 따라서, 싱글턴인 경우에도 implements Serializable을 한다면 싱글턴이 깨질 수 있다
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

→ 싱글턴 + Serializable + readResolve()를 설정했더라도, non-transient 필드가 있다면 싱글턴이 깨진다.

 

2-3. 해결방법

위처럼 싱글턴 패턴인데도 직렬화 때문에 2개의 인스턴스가 생성되는 해결법으로는 2가지가 있는데, 다음과 같다.

  1. 인스턴스 필드 transient 선언 (transient favoriteSongs)
    1. 이 방법은 readResolve()를 사용해 순간적으로 만들어진 역직렬화된 인스턴스에 접근하지 못하는 로직을 구현해야하고, 이는 깨지기 쉽고 신경을 많이 써야해서 권장하지 않는다.
  2. Elvis를 원소 하나짜리 열거 타입으로 변경
    1. 저자는 이 방법을 더 권장!
    2. 직렬화 가능 인스턴스 통제 클래스 + 열거 타입으로 구현하면 선언한 상수 객체만 존재함을 자바가 보장한다

 

3. 열거 타입 싱글턴

열거 타입으로 싱글턴은 구현한 코드는 다음과 같다.

public enum EnumElvis {
    INSTANCE;

    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    
    public void printFavorite() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

열거 타입으로 싱글턴을 구현할 수 있지만, 모든 경우에 적용되는 것은 아니다. 만약 컴파일 타임에 어떤 인스턴스가 있는지 알기 어렵다면, 열거타입을 적용하기 어렵다. 이때는 readResolve로 직렬화한 인스턴스를 통제하면 된다.

 

4. readResolve() 접근성

  • final 클래스
    • private readResolve()
  • non-final 클래스
    • private readResolve() : 하위 클래스에서 사용 불가능
    • package-private readResolve() : 같은 패키지, 하위 클래스에서만 사용 가능
    • protected or public readResolve() : 재정의하지 않은 모든 하위 클래스에서 사용 가능하다
      • 다만, 재정의하지 않은 하위클래스를 역직렬화할 경우 상위 인스턴스 생성시 ClassCastException이 발생할 수 있다

요약

클래스 직렬화 형태를 기본으로 할지, 커스텀으로 할지 신중하게 결정해라.

기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때(논리적 구조 = 물리적 구조)만 사용하고, 그렇지 않으면 커스텀 직렬화 형태를 고안하라.

직렬화 형태에 포함된 필드도 릴리즈 후 1) 마음대로 제거할 수 없고 2) 추후 직렬화 호환성 유지를 위해 코드의 복잡성과 성능에 부정적인 영향을 미친다. 따라서 직렬화 필드도 신중하게 선택하도록 하자.

 

기본 직렬화

객체의 물리적 표현과 논리적 내용이 같은 경우에 기본 직렬화 형태를 사용해도 된다. 기본 직렬화 형태가 적합하다고 결정했다고 하더라도, 불변식 보장과 보안을 위해 readObject 메서드 제공을 권장한다.

기본 직렬화 사용 가능 예시

public class Name implements Serializable {
    /**
     * 성. null이 아니어야 함
     * @serial
     */
    private final String lastName;

    /**
     * 이름. null이 아니어야 함
     * @serial
     */
    private final String firstName;

    /**
     * 중간이름. 중간이름이 없다면 null
     * @serial
     */
    private final String middleName;

	...
}

성명은 논리적으로 이름, 성, 중간이름이라는 3개의 문자열로 구성되며, 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영했기에 기본 직렬화를 사용해도 무방하다.

  • private 필드임에도 주석이 포함된 이유
    • 위 코드의 lastName, firstName, middleName 필드는 클래스 직렬화 형태에 포함되는 공개 API에 포함되고, 공개 API는 모두 문서화 해야하기 때문이다
  • @serial 태그
    • 주석에 있는 이 태그는 private 필드의 설명을 API 문서에 포함하라고 자바독에 알려주는 역할을 한다
    • @serial 태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다

 

기본 직렬화 형태에 적합하지 않은 예시 - StringList

public class StringList {
    private int size = 0;
    private Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }

    // ...
}
  • 논리적 : 일련의 문자열을 표현
  • 물리적 : 문자열들을 이중 연결 리스트로 연결
  • 이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리를 철두철미하게 기록한다

 

발생 가능한 문제 4가지

이 코드처럼 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화를 형태를 사용시 발생하는 문제는 다음과 같다.

  1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다
    1. private 클래스인 StringList.Entry가 공개 API가 되는데, 다음 릴리즈에서 내부 표현 방식을 바꾸더라도 StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다.
    2. 즉, 연결 리스트를 더는 사용하지 않더라도 관련 코드를 절대 제거할 수 없고, 과거 버전에 종속된 코드를 작성할 수밖에 없게 된다.
  2. 너무 많은 공간을 차지할 수 있다
    1. 위의 직렬화 형태는 내부 구현 정보인 연결 리스트의 모든 엔트리와 연결 정보까지 포함했기에 직렬화와는 관련없는 정보를 저장하는데 저장 공간을 사용하게 된다.
  3. 시간이 너무 많이 걸릴 수 있다
    1. 직렬화 로직은 그래프를 직접 순회하며 객체 그래프의 위상에 관한 정보를 얻는다. 그렇기에 많은 시간이 들 수 있다.
  4. 스택 오버플로우를 일으킬 수 있다
    1. 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로우를 일으킬 수 있다.

StringList 보완

위의 문제를 해결하기 위한 방법으로 다음과 같은 방식을 사용하면 된다.

  • 리스트가 포함한 문자열의 개수를 적고
  • 그 뒤로 문자열들을 나열한다

→ StringList의 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다.

public class StringList implements Serializable {
    // 모든 필드가 transient 되었다. 
    private transient int size = 0;
    private transient Entry head = null;
    private int num = 0;

    // 이제는 직렬화되지 않는다.
    private static class Entry {
        String data;
        Entry next;
        Entry previous;
    }

    public final void add(String s) {}

    /**
     * 이 {@code StringList} 인스턴스를 직렬화한다.
     *
     * @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후 ({@code int}),
     * 이어서 모든 원소를(각각은 {@code String}) 순서대로 기록한다.
     */
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject(); // not transient 필드 직렬화
        s.writeInt(size); // 리스트 크기 s에 write

        // 모든 원소를 올바른 순서로 기록한다.
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();

        // 모든 원소를 읽어 이 리스트에 삽입한다.
        for (int i = 0; i < numElements; i++) {
            add((String) s.readObject());
        }
    }
    // ...
}
  • transient(일시적) 한정자 : 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시
  • readObject, writeObject : 직렬화 형태를 처리
  • writeObject 주석
    • private 메서드는 직렬화 형태에 포함되는 공개 API에 속하며, 공개 API는 모두 문서화 해야한다.
  • @SerialData 태그
    • 자바독 유틸리티에게 이 내용을 직렬화 형태 페이지에 추가하도록 요청하는 역할

transient 한정자

기본 직렬화를 수용하든, 하지 않든 defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 따라서 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략하고, transient 사용이 가능하다면 다 transient를 사용하도록 하자.

  • transient 사용 예시
    • 다른 필드에서 유도되는 필드(ex. 캐시된 해시값)
    • JVM 실행시 마다 값이 달라지는 필드(ex. 네이티브 자료구조를 가리키는 long 필드)
    • 커스텀 직렬화 형태 사용시, 위의 StringList처럼 대부분 혹은 모든 인스턴스 필드를 transient로 선언해야 한다.
  • 기본 직렬화에서 transient 필드 사용시
    • 해당 필드를 역직렬화 할 때, 기본값으로 초기화된다.
      • 객체 참조 필드 : null
      • 숫자 기본 타입 필드(int, long) : 0
      • boolean : false
    • 기본값으로 초기화하면 안되는 경우
      1. readObject()에서 defaultReadObject를 호출
      2. 해당 필드를 원하는 값으로 복원 or 해당 값을 처음 사용할 때 초기화

defaultWriteObject, defaultReadObject

StringList의 모든 필드가 transient라고 해도, writeObject와 readObject는 각각 가장 먼저 defaultWriteObject, defaultReadObject를 호출한다. 이 호출은 무조건 선행되어야 하는 작업이다(직렬화 명세에서 요구하는 내용이다). 그 이유는 향후 릴리즈에서 transient가 아닌 인스턴스 필드가 추가되더라도, 상위/하위 모두 호환될 수 있기 때문이다.

 

 

해시테이블

위의 StringList의 기본 직렬화 형태는 유연성과 성능이 떨어질 뿐이지 객체를 직렬화한 후 역직렬화할 경우 원래 객체의 불변식까지 포함해 제대로 복원해낸다는 것을 보장할 수 있다. 하지만 그 불변식이 세부 구현에 따라 달라지는 객체에서는 이 정확성마저 깨질 수 있는데, 그 예시가 해시테이블이다.

해시테이블은 물리적으로는 키-값 엔트리들을 담은 해시 버킷을 차례로 나열한 형태이다. 어떤 엔트리를 어떤 버킷에 담을지는 키에서 구한 해시코드가 결정하는데, 해시코드 계산 방식은 구현에 따라 달라질 수 있다.

→ 해시테이블에 기본 직렬화를 사용하면 심각한 버그로 이어질 수 있다.

 

 

직렬버전 UID

어떤 직렬화 형태를 택하든, 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.

  • 이점
    • 직렬버전 UID가 일으키는 잠재적 호환성 문제가 사라짐
    • 약간의 성능 향상
      • 런타임에 UID를 생성하느라 복잡한 연산을 수행하기 때문
  • 선언
  • private static final long serialVersionUID = <무작위로 고른 long 값>;
  • 직렬버전 UID 후보값
    • 임의의 long 값(클래스 일련번호를 생성해주는 serialver 유틸리티, 아무값)
    • 꼭 고유값이 아니어도 된다

만약 직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구 버전에서 사용한 자동 생성값을 그대로 사용해야 한다.

기존 버전 클래스와의 호환성을 끊고 싶다면, 단순히 직렬 버전 UID의 값을 변경해주면 된다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때, InvalidClassException이 던져질 것이다.

→ 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.

요약

프로그램의 성능을 운영체제의 스레드 스케줄러에 의존하면 특정 운영체제의 의존도가 높아지기에 견고성과 이식성이 떨어지는 프로그램을 만들 가능성이 높다.

따라서 되도록이면 스레드 스케줄러에 독립적인 프로그램을 개발하도록 하자!

 

OS에 덜 의존적인 프로그램 작성하는 법

 

1. 견고하고 빠릿하고 이식성 좋은 프로그램

  1. 실행 가능한 스레드의 평균 개수프로세서 수보다 지나치게 많아지지 않도록 하자
    1. 스레드 스케줄러가 고민할 거리가 줄어들기 때문
  2. 실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.
    1. 이런 구조라면 스레드 스케줄링 정책이 아주 상이한 시스템에서도 동작이 크게 달라지지 않기 때문
    2. 전체 스레드, 실행 가능한 스레드, 대기 중인 스레드를 구분해야 한다.

 

2. 실행 가능한 스레드 수 적게 유지하는 기법

  1. 스레드가 작업 완료 후 다음 작업까지는 대기해야한다
    1. 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안된다
  2. 스레드는 절대 바쁜 대기(busy waiting) 상태가 되면 안된다
    1. 공유 객체의 상태가 바뀔 때까지 쉬지 않고 검사해서는 안된다는 의미
    2. 스레드가 필요도 없이 실행 가능한 상태인 시스템의 예시
    3. 바쁜 대기의 취약점
      1. 스레드 스케줄러의 변덕에 취약
      2. 프로세서에 큰 부담을 제공해 유용한 작업이 실행될 기회를 박탈
    4. 바쁜 대기 예시
      1. 스레드 1000개를 만들어 자바의 CountDownLatch와 비교할 때 약 10배의 성능 차이가 발생했다.
public class SlowCountDownLatch {
    private int count;

    public SlowCountDownLatch(int count) {
        if (count < 0) {
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }

    public void await() {
        while (true) {
            synchronized (this) {
                if (count == 0) {
                    return;
                }
            }
        }
    }

    public synchronized void countDown() {
        if (count != 0) {
            count--;
        }
    }
}

 

3. Thread.yield 사용을 지양하자

특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield는 사용하지 말자.

  • Thread.yield 메서드
    • 양보 : 현재 실행 대기 중인 동등한 우선순위 이상의 다른 스레드에게 실행기회를 제공한다
    • 즉, 실행 중인 스레드를 **실행 대기 상태(Runnable)로 변경**하는 메서드
    • 스레드 실행되었는데 스레드의 실행이 잠시동안 무의미한 경우가 발생하는데, 이때 CPU 자원의 낭비가 발생할 수 있다
  • 이유
    • 문제를 개선할 수는 있지만, 이식성 면에서 좋은 선택이 아니다
    • 테스트를 할 수단도 없다
OS1 
우선순위 B(1) > A(2)

thread A 우선순위 2-> 실행
thread B 우선순위 1(더 높은 우선순위)-> 나 급해. 
문제 해결 : thread A -> (yield) -> thread B 실행 but 급한 불만 끈 상태, 본질적인 문제 해결 x
thread A 실행된 이유 o. 실행이 완료가 되어야 하는데, 갑자기 대기 상태.

OS2
우선순위 A > B -> 의도대로 동작하지 않음!
의도 : B > A
  •  대안
    • 애플리케이션 구조를 바꿔 동시에 실행 가능한 스레드 수가 적어지도록 조치하자

 

4. 스레드 우선순위 사용을 지양하자

스레드 몇 개의 우선순위를 조율해서 애플리케이션의 반응 속도를 높이는 것도 타당할 수 있지만, 이것이 적합한 상황은 드물고 이식성이 떨어진다.

이미 잘 동작하는 프로그램의 서비스 품질 향상을 위해 드물게 사용될 수는 있지만, 간신히 동작하는 프로그램을 고치는 용도로 사용해서는 절대 안된다.

+ Recent posts