Develop/JAVA

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다. 동기화의 기능자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.그러나 사실 동기화의 기능은 총 2가지이다. a. 배타적 실행위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가..

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

728x90

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다.

 

동기화의 기능

자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.

그러나 사실 동기화의 기능은 총 2가지이다.

 

a. 배타적 실행

위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가 보지 못하게 막는 용도를 말한다.

이때 락에 대한 개념이 나온다. 락을 건 메서드에서 객체의 상태를 확인하고 필요하면 수정하도록 작성했을 때, 한 쓰레드에서 해당 메서드를 사용하게 되면 객체에 락이 걸리게 되고, 해당 객체는 다른 쓰레드에서 동시에 접근이 불가능하다.

즉 배타적 실행은 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시키는 것이다.

 

b. 쓰레드 사이의 안정적 통신

나는 이 a번만 이전에 알고있었는데, 동기화의 중요한 기능이 하나 더 있다.

동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
동기화덕분에 한 스레드에서 락의 보호하에 수행된 수정사항을 다른 쓰레드에서 최종 결과를 볼 수 있다.

자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다. 여러 쓰레드가 primitive 타입의 변수를 동기화 없이 수정하더라도, 각 스레드에서는 정상적으로 그 값을 온전하게 (연산중간에 끼어들지 않고 온전히) 읽어온다

 

원자적 연산

위에서 읽고 쓰는 동작이 원자적이라 했는데, 원자적 연산은 중단이 불가능한 연산을 이야기한다
여러 자바의 연산은 바이트코드로 이루어져 있는데, 하나의 연산을 수행하기 위해  바이트코드가 수행될 때 중간에 다른 쓰레드가 끼어들어서 연산의 결과가 올바르지 않게 변한다면 해당 연산은 원자적 연산이 아니다.

원자적이지 않은 동작의 예시로는 a++(증가 연산자)이 있다. cleancode책의 동시성 부록에서는 아래와 같은 설명이 나온다

lastId값이 42였다고 가정하자. 다음은 getNextId 메서드의 바이트 코드다.
예를 들어 첫째 스레드가 ALOAD 0, DUP, GETFIELD lastId까지 실행한 후 중단 되었다고 가정하자.
둘째 스레드가 끼어들어 모든 명령을 실행했다. 즉, lastId를 하나 증가해 43을 얻어갔다.
이제 첫째 스레드가 중단했던 실행을 재개한다.
첫째 스레드가 GETFIELD lastId를 실행한 당시 lastId 값은 42였다. 그래서 피연산자 스택에도 42가 들어있었다. 여기에 1을 더해 43을 얻은 후 결과를 저장한다.
첫째 스레드가 반환하는 값 역시 43이다. 둘째 스레드가 증가한 값은 잃어 버린다.
둘째 쓰레드가 첫째 스레드를 중단한 후 다시 실행된 첫째 스레드가 둘째스레드의 작업을 덮어썼기 때문이다.

즉, 여기서의 문제는 연산을 수행할 때 JVM에서 사용하는 프레임, 지연변수, 피연산자 스택에 저장하는 과정에서 원자적 연산이 아닌경우, 연산 중간 과정이 덮어씌워져 올바르지 않은 값을 갖는다는 것이다.

좀더 쉽게는 두개의 쓰레드에서 ++ 연산을 했으니 +2가 되어야하는데, ++ 연산이 원자적이지 않아 +1만 되었다는 것이다.

 

원자적 데이터에서의 동기화

위의 원자성에 대한 이야기를 들으면 원자적 데이터를 읽고 쓸 때는 (할당 연산은 원자적이다) 동기화를 하지 않아도 괜찮다고 생각 할 수 있다. (중단이 불가능하기 때문에!)

하지만 원자적 데이터라도 동기화가 필요하다

Java언어에서 스레드가 (원자적 데이터 값을 가지더라도) 필드를 읽을 때 '수정이 완전히 반영된' 값을 얻는다고 보장하지 않는다.
즉 A 쓰레드에서 필드를 수정했더라도, B 쓰레드에서 수정된 필드를 반드시 볼 수 있는 것은 아니라는 것이다.

따라서 한 쓰레드에서 수정이된 필드값을 다른 쓰레드에서 '잘 읽기' 위해서라도 동기화의 안정적인 통신이 필요하다
이는 자바 메모리 모델 때문이다.

 

동기화의 관점에서의 자바의 메모리 모델

동기화를 하지 않으면 스레드가 변수를 읽어올 때 각 쓰레드가 변수를 cached한 영역에서 읽어오게 된다. 그래서 한 쓰레드로 인해 해당 변수가 값이 변화해도, 다른 쓰레드에서는 이전에 읽었던 cached된 변수의 값을 읽기 때문에 변경된 사항을 볼 수 없다.

따라서 각 쓰레드에서 변경한 값을 값을 통신하기 위해 동기화가 필요하며. 이때 통신을 위한 동기화를 사용하기 위해서는 volatile 한정자를 사용하는 방법이 있다. (Synchronized는 배타적수행, 안정적 통신을 모두수행하는 것이고, volatile은 안정적 통신만을 수행한다고 생각하면 편하다)

즉, 여러 스레드가 공유하는 변수값을 읽어오기 위해서 volatile 키워드를 붙이면 그 변수를 읽어올때 각 쓰레드의 cached한 영역이 아닌 메인 메모리에서 직접 읽어오기 때문에 안정적인 통신을 보장할 수 있다.

 

공식문서에 있는 자바 메모리 모델에 대한 설명

volatile 변수의 경우에는 inter-thread action에 해당하여, synchronized된 경우의 자바 메모리 모델 reordering 규칙이 적용된다. (Reordering은 다른 쓰레드의 변수값을 읽어오기 위한 작업으로, 한 쓰레드의 변경사항이 다른 쓰레드에 표시될 수 있게 하기 위한 작업이라 생각하자.)
이 규칙은 volatile 변수가 쓰기가 일어날 경우에는, 항상 임의의 읽기 쓰레드에 의해서 동기화가 되도록 reordering되는 것을 의미하며, reordering이 된다는 것은 다른 쓰레드에서 변수를 읽을 때 최신 변경사항을 읽을 수 있다는 것이다.

좀더 자세한 내용을 보고싶다면 더보기 클릭

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

위 글에서는 자바의 메모리 모델에 대해 설명하며, 동기화 되지 않은 프로그램이 어떻게 놀라운 결과를 나타내는지를 보여주고 있다.

자바의 메모리 모델은 한 실행에 대해서 각각의 읽기를 검사하고, 특정 규칙에 따라 읽기가 쓰기를 옵저빙하면서 쓰기가 유효한지를 확인하여 작동한다.

격리된 각 쓰레드의 동작은 표시되는 값이 메모리 모델에 의해 결정되는 경우를 제외하고, 각 쓰레드의 의미에 따라 제어되는 방식으로 작동한다. (intra-thread semantics). 

즉, 격리된 각 쓰레드의 동작이 메모리 모델에 의해 결정되는 경우는 멀티쓰레드에서 표시되는 값을 기반으로 이해해야하는 경우를 이야기 한다. 

intra-thread semantics : 싱글 쓰레드에서 스레드 안에서만 표시되는 값을 기반으로 스레드 동작을 예측 가능하다
inter-thread action : 한 스레드에서 수행되어 다른 스레드에의해 직접 감지되거나 영향을 받는 작업

 

자바의 메모리 모델의 reordering

왼쪽 : reordering되기 전 / 오른쪽 : reordering된 후

왼쪽의 경우에 r2 == 2, r1 == 1이 되는것이 불가능해 보인다. 하지만, 컴파일러는 스레드의 실행에 영향을 미치지 않는 경우 두 스레드에서 명령어를 다시 정렬할 수 있다 (오른쪽 사진처럼 정렬). d

불가능한 이유 : 1번이 먼저오면 4번에 의한 쓰기 결과를 볼 수없고, 3번이 먼저오면 2번에 의한 쓰기 결과를 볼 수 없기 때문이다.

하지만 오른쪽의 경우에는 동기화가 되지 않았다.

  • 한개의 thread에서는 쓰기를 하고있고
  • 같은 변수값을 다른 쓰레드에서 읽고있고
  • 쓰기와 읽기는 동기화에 의해 정렬되지 않았다 : 동기화에 의한 정렬에 대한 설명은 17.4.4에 있다.

 

동기화의 경우에 자바 메모모리 모델의 reordering

volatile 변수에 대한 쓰기는 임의의 쓰레드에 의해 해당 변수를 subsequent read(동기화 순서에 의해 정의된 read)를 하여 동기화된다. -> volatile 변수의 변경 사항은 항상 다른 쓰레드에 표시된다.

 

long과 double

위에서 "자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다." 라고 언급했다. 그렇다면 long과 double은 왜 변수를 읽고 쓰는 동작이 원자적이지 않을까?

JVM 비트수와 관련이 있다. 자바 메모리 모델에 의하면 32비트 메모리에 값을 할당하는 연산은 중단이 불가능하다. (원자적이다)
그렇지만 long과 double의 경우에는 64비트의 메모리 공간을 갖고있기 때문이다.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

해당 글을 읽어보면, 3가지로 요약이 된다.

  • volatile이 설정되지 않은 long, doulbe에 대한 쓰기는 두번에 이루어진다
    => 먼저 첫번째 32비트를 쓰고 다음 쓰기에서 두번째 32비트를 쓴다.
  • volatile이 설정된 long, double이라면 항상 원자적이다
  • 프로그래머는 shared 되는 62bit 값은 volatile이나 synchronize 로 선언하는게 좋다. complication을 피하기 위해!

즉 첫 비트 32비트 값을 할당한 직후에, 즉 둘째 32비트를 할당하기 직전에 다른 쓰레드가 끼어들어 두 32비트 값중 하나를 변경할 수 있기 때문에 long, double은 원자적 연산이 될 수 없다.

하지만 volatile을 사용을 한다는 것은 여러 쓰레드에서 하나의 변수가 같은 값을 읽도록 보장하는 것이기 때문에, 메모리를 2번 접근을 하더라도 같은 값을 읽도록하는. 변수에 접근하는 연산을 원자적으로 수행하게 보장한다는 것이다.

in which case the Java memory model ensures that all threads see a consistent value for the variable

따라서 long, double 변수를 원자적으로 사용하고 싶다면 volatile로 선언하는게 좋다.

I came across this while reading Effective Java and looking into the racy single-check idiom.

 

Functions of Synchronization

If you've done any thread programming in Java, you've probably encountered the synchronized keyword a few times. In synchronization, synchronized lets you safely modify a shared resource when multiple threads access it simultaneously, so it's easy to think that's all synchronization does. In other words, it ensures that only one thread at a time can execute a synchronized block or method.

But in fact, synchronization has two functions in total.

 

a. Mutual Exclusion

As mentioned above, this refers to preventing other threads from seeing a shared object while one thread is modifying it and its state is inconsistent — only the thread currently using the object can access it.

This is where the concept of a lock comes in. When you write a method that acquires a lock to check and potentially modify an object's state, once a thread enters that method, the object becomes locked, and other threads cannot access it simultaneously.

In short, mutual exclusion is about transitioning an object from one consistent state to another consistent state.

 

b. Reliable Communication Between Threads

I previously only knew about point (a), but there's another important function of synchronization.

Without synchronization, changes made by one thread may not be visible to other threads.
Thanks to synchronization, modifications performed under the protection of a lock in one thread can be seen as the final result by other threads.

In the Java language, reading and writing variables is atomic for all types except long and double. Even if multiple threads modify a primitive variable without synchronization, each thread will read the value correctly and completely (without being interrupted mid-operation).

 

Atomic Operations

I mentioned above that read and write operations are atomic. An atomic operation is one that cannot be interrupted.
Many Java operations are composed of bytecode instructions, and if another thread can intervene during the bytecode execution of an operation and cause incorrect results, then that operation is not atomic.

A classic example of a non-atomic operation is a++ (the increment operator). The concurrency appendix of the Clean Code book explains it like this:

Assume lastId had a value of 42. Here is the bytecode for the getNextId method.
For example, suppose the first thread executes up to ALOAD 0, DUP, GETFIELD lastId and then gets interrupted.
The second thread cuts in and executes all the instructions — it increments lastId and gets 43.
Now the first thread resumes execution from where it was interrupted.
When the first thread executed GETFIELD lastId, the value of lastId was 42. So 42 was on the operand stack. It adds 1 to get 43 and stores the result.
The first thread also returns 43. The value incremented by the second thread is lost.
This happened because the second thread interrupted the first thread, and when the first thread resumed, it overwrote the second thread's work.

The problem here is that when performing operations, if the operation is not atomic during the process of storing values in the JVM's frame, local variables, and operand stack, intermediate results can get overwritten, leading to incorrect values.

To put it more simply: two threads each performed a ++ operation, so the result should have been +2, but since the ++ operation is not atomic, only +1 was applied.

 

Synchronization with Atomic Data

After hearing about atomicity above, you might think that you don't need synchronization when reading and writing atomic data (since assignment operations are atomic). (Because they can't be interrupted!)

However, even with atomic data, synchronization is necessary.

The Java language does not guarantee that when a thread reads a field (even if the data is atomic), it will get a 'fully updated' value.
In other words, even if thread A modifies a field, thread B is not guaranteed to see the modified value.

Therefore, reliable communication through synchronization is needed even just to ensure that a field value modified by one thread is 'properly read' by another thread.
This is due to the Java Memory Model.

 

Java Memory Model from a Synchronization Perspective

Without synchronization, when a thread reads a variable, it reads from its own cached area. So even if one thread changes the variable's value, other threads still read the previously cached value and cannot see the change.

Therefore, synchronization is needed for threads to communicate changed values. One way to achieve communication-only synchronization is by using the volatile modifier. (Think of it this way: synchronized provides both mutual exclusion and reliable communication, while volatile only provides reliable communication.)

In other words, when you add the volatile keyword to a shared variable, reading that variable goes directly to main memory instead of each thread's cached area, which guarantees reliable communication.

 

Java Memory Model Explained in the Official Documentation

Volatile variables are considered inter-thread actions, so the Java Memory Model's reordering rules for synchronized cases apply. (Think of reordering as the mechanism for reading variable values from other threads — it's what makes changes in one thread visible to other threads.)
This rule means that writes to volatile variables are always reordered so that they are synchronized by any reading thread through subsequent reads (as defined by synchronization order). Being reordered means that other threads can read the latest changes when they access the variable.

Click "Show More" for more details

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

The article above explains Java's memory model and demonstrates how unsynchronized programs can produce surprising results.

Java's memory model works by examining each read in an execution and checking whether the write being observed by the read is valid according to specific rules.

The behavior of each isolated thread operates in a manner controlled by that thread's semantics, except when the values it sees are determined by the memory model (intra-thread semantics). 

In other words, when the behavior of an isolated thread is determined by the memory model, it needs to be understood based on values visible in a multithreaded context. 

intra-thread semantics: In a single thread, the thread's behavior is predictable based on values visible only within that thread.
inter-thread action: An action performed by one thread that can be directly detected or affected by another thread.

 

Reordering in the Java Memory Model with Synchronization

Left: Before reordering / Right: After reordering

In the left case, it seems impossible for r2 == 2 and r1 == 1. However, the compiler can reorder instructions in both threads if it doesn't affect each thread's execution (as shown in the right image).

Why it seems impossible: If instruction 1 comes first, it can't see the write result from instruction 4. If instruction 3 comes first, it can't see the write result from instruction 2.

But in the right case, there is no synchronization.

  • One thread is writing
  • Another thread is reading the same variable
  • The writes and reads are not ordered by synchronization: the explanation of synchronization ordering is in section 17.4.4.

 

Reordering in the Java Memory Model with Synchronization

A write to a volatile variable is synchronized with any subsequent read (a read defined by synchronization order) of that variable by any thread. → Changes to a volatile variable are always visible to other threads.

 

long and double

I mentioned above that "in the Java language, reading and writing variables is atomic for all types except long and double." So why aren't read and write operations atomic for long and double?

It's related to the JVM's bit width. According to the Java memory model, assigning a value to 32-bit memory is an uninterruptible operation (i.e., atomic).
However, long and double occupy 64-bit memory space.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

Reading through that article, it can be summarized in three points:

  • Writes to non-volatile long and double are done in two steps.
    => The first 32 bits are written first, then the second 32 bits are written next.
  • If long or double is declared volatile, the operation is always atomic.
  • Programmers should declare shared 64-bit values as volatile or synchronized to avoid complications.

In other words, right after assigning the first 32 bits — just before assigning the second 32 bits — another thread can cut in and modify one of the two 32-bit values. That's why long and double cannot be atomic operations.

However, using volatile guarantees that multiple threads read the same value from a single variable. So even though memory is accessed twice, it ensures the same value is read — meaning the variable access operation is guaranteed to be performed atomically.

in which case the Java memory model ensures that all threads see a consistent value for the variable

Therefore, if you want to use long or double variables atomically, it's best to declare them as volatile.

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] Chapter6: 열거 타입과 애너테이션

item 34. int 상수 대신 열거 타입을 사용하라 열거 타입은 확실히 정수 상수보다 뛰어나다. 더 알기쉽고 강력하다 대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다 하나의 메서드가 상수별로 다르게 동작해야할 때에는 switch문 대신 상수별 메서드 구현을 사용하자 열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자. Link : jyami.tistory.com/102 [Effective Java] item 34. int 상수 대신 열거 타입을 사용하라 열거타입 (enum) : 일정 개수의 상수 값을 정의한 다음 그외의 값은 허용하지 않는 타입 정수 열거 패턴 (int enum patt..

[Effective Java] Chapter6: 열거 타입과 애너테이션

728x90

item 34. int 상수 대신 열거 타입을 사용하라

  • 열거 타입은 확실히 정수 상수보다 뛰어나다. 더 알기쉽고 강력하다
  • 대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다
  • 하나의 메서드가 상수별로 다르게 동작해야할 때에는 switch문 대신 상수별 메서드 구현을 사용하자
  • 열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자.
  • Link : jyami.tistory.com/102
 

[Effective Java] item 34. int 상수 대신 열거 타입을 사용하라

열거타입 (enum) : 일정 개수의 상수 값을 정의한 다음 그외의 값은 허용하지 않는 타입 정수 열거 패턴 (int enum pattern) : 이전까지 사용하던 패턴 1. 정수 열거 패턴 (int enum pattern)의 단점 public stati..

jyami.tistory.com

 

item 35. ordinal 메서드 대신 인스턴스 필드를 사용하라

  • ordinal 메서드는 EnumSet과 EnumMap과 같이 열거 타입 기반의 범용 자료 구조에 쓸 목적으로 설계 되었다. 이 경우가 아니면 사용하지 말자
  • 열거 타입 상수에 연결 된 값을 ordinal 메서드로 얻지 말고 인스턴스 필드에 저장하라.
  • Link : https://jyami.tistory.com/103
 

[Effective Java] item 35. ordinal 메서드 대신 인스턴스 필드를 사용하라

1. ordinal 메서드를 잘못쓸 때 ordinal 메서드 : 해당 상수가 그 열거 타입에서 몇 번째 위치인지 반환하는 메서드 상수 선언 순서를 바꾸면 오동작한다. 이미 사용중인 정수와 값이 같은 상수는 추��

jyami.tistory.com

 

item 36. 비트 필드 대신 EnumSet을 사용하라

  • 열거할 수 있는 타입을 한데 모아 집합 형태로 사용한다고 해도 비트 필드를 사용할 이유는 없다
  • EnumSet 클래스가 비트 필드 수준의 명료함과 성능을 제공하고 아이템 34에서 설명한 열거 타입의 장점까지 선사한다
  • EnumSet의 유일한 단점은 (자바 11까지는 아직) 불변 EnumSet을 만들 수 없다는 것이다.
  • 향후 릴리즈에서 수정될 때 까지는 (명확성과 성능이 조금 희생되지만) Collections.unmodifiableSet으로 EnumSet을 감싸 사용할 수 있다.
  • Link : https://jyami.tistory.com/104
 

[Effective Java] item 36. 비트 필드 대신 EnumSet을 사용하라

1. 비트 필드란? 비트 필드 : 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있는 집합 public class Text{ public static final int STYLE_BOLD = 1 << 0; // 1 public static final int STYLE_ITALIC..

jyami.tistory.com

 

 

item 37. ordinal 인덱싱 대신 EnumMap을 사용하라

  • 배열의 인덱스를 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니 EnumMap을 사용하라
  • 다차원 관계는 EnumMap<..., EnumMap<...>> 으로 표현하라
  • 애플리케이션 프로그래머는 Enum.ordinal을 (왠만해서는) 사용하지 말아야한다 (아이템 35)는 일반원칙의 특수한 사례다
  • Link : https://jyami.tistory.com/105
 

[Effective Java] item 37. ordinal 인덱싱 대신 EnumMap을 사용하라

1. 올바르지 않은 방법 : ordinal()을 배열 인덱스로 사용 Set [] plant ByLisfeCycle = (Set []) new Set[Plant.LifeCycle.values().length]; for(int i =0; i < plantsByLifeCycle.length; i++) plantsByLifeCyc..

jyami.tistory.com

 

item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

  • 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다.
  • 이렇게 하면 클라이언트는 이 인터페이스를 구현해 자신만의 열거 타입(혹은 다른 타입)을 만들 수 있다.
  • API가 (기본 열거 타입을 직접 명시하지 않고) 인터페이스 기반으로 작성되었다면 기본 열거 타입의 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거 타입의 인스턴스로 대체해 사용 할 수 있다.
  • Link : https://jyami.tistory.com/106
 

[Effective Java] item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

1. 열거타입 확장은 하지 말자 열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴(typesafe enum pattern)보다 우수하다. 단점 : 타입 안전 열거 패턴은 확장할 수 있으나 열거 타입은 그렇지 못하다

jyami.tistory.com

 

Item 39. 명명패턴보다 애너테이션을 사용하라

  • 애너테이션으로 할 수 있는 일을 명명패턴으로 처리할 이유는 없다.
  • 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야한다.
  • 마커애너테이션을 이용하여 애너테이션에 관심있는 프로그램에 추가 정보를 제공한다.
  • 매개변수를 받는 애너테이션 타입, 반복가능한 애너테이션 등을 사용하여 소스 코드에 추가 정보를 제공할 수 있는 도구를 제공하자.
  • Link : https://jyami.tistory.com/107
 

[Effective Java] item 39. 명명 패턴보다 애너테이션을 사용하라

1. 명명 패턴의 단점 ex) junit3 : 테스트 메서드의 시작을 test로 시작하게 하였다. 오타가 나면 안된다. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다. 메서드가 아닌 클래스 명을 Tes

jyami.tistory.com

 

item 40. @Override 애너테이션을 일관되게 사용하라

  • 재정의한 모든 메서드에 @Override 애너테이션을 의식적으로 달면 실수했을 때 컴파일러가 바로 알려준다.
  • 예외는 하나다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우엔 이 애너테이션을 달지 않아도 된다. (단다고 해서 해롭진 않다.)
  • Link : https://jyami.tistory.com/108
 

[Effective Java] item 40. @Override 애너테이션을 일관되게 사용하라

1. @Override를 사용했을 때 장점 @Override : 메서드 선언에만 달 수 있다. 상위 타입의 메서드를 재정의했음을 뜻한다. Overriding을 Overloading로 잘못 작성할 수 있는 오류를 방지 할 수 있다. 잘못 작성 �

jyami.tistory.com

 

item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

  • 마커 인터페이스와 마커 애너테이션은 각자의 쓰임이 있다.
  • 새로 추가하는 메서드 없이 단지 타입 정의가 목적이라면 마커 인터페이스를 선택하자
  • 클래스나 인터페이스의 프로그램요소에 마킹해야하거나 애너테이션을 적극 활용하는 프레임워크의 일부로 그 마커를 편입시키고자 한다면 마커 애너테이션이 올바른 선택이다.
  • 적용 대상이 ElementType.TYPE인 마커 애너테이션을 작성하고 있다면, 잠시 여유를 갖고 정말 애너테이션으로 구현하는게 옳은지, 혹은 마커 인터페이스가 낫지는 않을지 곰곰히 생각해보자
  • Link : https://jyami.tistory.com/109
 

[Effective Java] item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

1. 마커 인터페이스 (marker interface) 마커 인터페이스(marker interface) : 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 것 실제로 아무런 메서드도 담

jyami.tistory.com

 

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

1. 마커 인터페이스 (marker interface) 마커 인터페이스(marker interface) : 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 것 실제로 아무런 메서드도 담고있지 않다 : Serializable을 구현한 클래스의 인스턴스는 ObjectOutputStream 을 통해 쓸 수 있다고, 즉 직렬화 할 수 있다고 알려준다. ObjectOutputStream의 writeObject0 메서드안에는 Serializable의 인스턴스인지 확인하는 검증 로직이 들어가있으며, 마킹이 안되어있다고 판단되는 경우에 NotSerializableException이 발생한다. 2. 마커 인터페이스의 장점 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구..

[Effective Java] item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

728x90

1. 마커 인터페이스 (marker interface)

마커 인터페이스(marker interface) : 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 것

item41-1

실제로 아무런 메서드도 담고있지 않다 : Serializable을 구현한 클래스의 인스턴스는 ObjectOutputStream 을 통해 쓸 수 있다고, 즉 직렬화 할 수 있다고 알려준다.

item41-2

ObjectOutputStream의 writeObject0 메서드안에는 Serializable의 인스턴스인지 확인하는 검증 로직이 들어가있으며, 마킹이 안되어있다고 판단되는 경우에 NotSerializableException이 발생한다.

 

2. 마커 인터페이스의 장점

  • 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
    인터페이스가 어엿한 타입이므로 런타임에야 발견될 오류를 컴파일타임에 잡을 수 있다
    ex ) 애너테이션 : 직렬화 할 수 없는 객체를 넘겨도 런타임에야 발견 가능함
  • 적용 대상을 더 정밀하게 지정할 수 있다 : 특정 인터페이스를 구현한 클래스에만 적용하게!
    @Target(ElementType.TYPE) : 모든 타입에 달 수 있다. (클래스, 인터페이스, 열거타입, 애너테이션)

Set 인터페이스 : 일종의 마커 인터페이스

  • Set은 Collection의 하위 타입에만 적용할 수 있다.
  • Collection이 정의한 메서드 외에는 새로 추가한 것이 없다.
  • 특정 인터페이스의 하위타입에만 적용할 수 있으며, 아무 규약에도 손대지 않은 마커 인터페이스는 충분히 있음직 하다
    • 객체의 특정 부분을 불변식으로 규정
    • 그 타입의 인스턴스는 다른 클래스의 특정 메서드가 처리할 수 있다는 사실을 명시하는 용도로 사용

 

3. 마커 애너테이션의 장점

  • 거대한 애너테이션 시스템의 자원을 받는다.
  • 애너테이션을 적극 활용하는 프레임워크에서는 애너테이션을 사용하는게 일관성에 좋다.

 

4. 사용해야할 때

1. 클래스와 인터페이스 외의 프로그램 요소 (모듈, 패키지, 필드, 지역변수)
이 요소들에 마킹해야할 때 애너테이션을 쓸 수 밖에 없다.

2. 클래스와 인터페이스 요소
"이 마킹이 된 객체를 매개변수로 받는 매서드를 작성할 일이 있을 때" : 인터페이스
마커 인터페이스를 메서드의 매개변수 타입으로 사용하여 컴파일타임에 오류를 잡을 수 있다. > 아닐 때 : 애너테이션

3. 애너테이션을 활발히 활용하는 프레임워크
마커 애너테이션을 사용하는 편이 좋다.

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 40. @Override 애너테이션을 일관되게 사용하라

1. @Override를 사용했을 때 장점 @Override : 메서드 선언에만 달 수 있다. 상위 타입의 메서드를 재정의했음을 뜻한다. Overriding을 Overloading로 잘못 작성할 수 있는 오류를 방지 할 수 있다. 잘못 작성 했을 경우, 컴파일시 컴파일러가 잘못된 부분을 명확히 알려준다. 대부분의 IDE에서 의도한 재정의를 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자 컴파일 오류의 보완재 역할이다. @Override를 다는 습관을 들이자! 시그니처가 올바른지 재차 확신할 수 있다. 2. @Override를 작성하지 않아도 되는 예외 경우 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때 (이때 밖에 없다.) 구체 클래스인데 구현하지 않은 메..

[Effective Java] item 40. @Override 애너테이션을 일관되게 사용하라

728x90

1. @Override를 사용했을 때 장점

@Override : 메서드 선언에만 달 수 있다. 상위 타입의 메서드를 재정의했음을 뜻한다.

  • Overriding을 Overloading로 잘못 작성할 수 있는 오류를 방지 할 수 있다.
  • 잘못 작성 했을 경우, 컴파일시 컴파일러가 잘못된 부분을 명확히 알려준다.
  • 대부분의 IDE에서 의도한 재정의를
  • 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자

컴파일 오류의 보완재 역할이다.

@Override를 다는 습관을 들이자! 시그니처가 올바른지 재차 확신할 수 있다.

 

2. @Override를 작성하지 않아도 되는 예외 경우

구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때 (이때 밖에 없다.)

  • 구체 클래스인데 구현하지 않은 메서드가 있다면 컴파일러가 알려주기 때문이다.

그러나 재정의 메서드 모두에 @Override를 일괄로 붙여두어도 상관없다.

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 39. 명명 패턴보다 애너테이션을 사용하라

1. 명명 패턴의 단점 ex) junit3 : 테스트 메서드의 시작을 test로 시작하게 하였다. 오타가 나면 안된다. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다. 메서드가 아닌 클래스 명을 TestSafetyMechanisms으로 지었을 때 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다. 예외를 던져야 성공하는 테스트 : 방법이 없다. 2. 마커(marker) 애너테이션 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Test { } 메타 애너테이션 (meta-annotation) : 애너테이션 선언에 다는 애너테이션 @Retention(RetentionPolicy.RUNTIME) :..

[Effective Java] item 39. 명명 패턴보다 애너테이션을 사용하라

728x90

1. 명명 패턴의 단점

ex) junit3 : 테스트 메서드의 시작을 test로 시작하게 하였다.

  • 오타가 나면 안된다.
  • 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
    메서드가 아닌 클래스 명을 TestSafetyMechanisms으로 지었을 때
  • 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
    예외를 던져야 성공하는 테스트 : 방법이 없다.

 

2. 마커(marker) 애너테이션

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

메타 애너테이션 (meta-annotation) : 애너테이션 선언에 다는 애너테이션

@Retention(RetentionPolicy.RUNTIME) : @Test가 런타임에도 유지되어야한다 - 보존 정책

@Target(ElementType.METHOD) : @Test가 메서드 선언에서만 사용되어야 한다 - 적용 대상

마커 애너테이션 : 아무 매개변수 없이 단순히 대상에 마킹을 한다.

  • 대상 코드의 의미는 그대로 둔채 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다.
  • 실제 클래스에 영향은 주지 않지만, 애너테이션에 관심있는 프로그램에 추가 정보를 제공한다.
import java.lang.reflect.*;

public class RunTests { // 코드 39-3 마커 애너테이션을 처리하는 프로그램
  public static void main(String[] args) throws Exception {
    int tests = 0;
    int passed = 0;
    Class<?> testClass = Class.forName(args[0]);
    for (Method m : testClass.getDeclaredMethods()) {
      if (m.isAnnotationPresent(Test.class)) {
        tests++;
        try {
          m.invoke(null);
          passed++;
        } catch (InvocationTargetException wrappedExc) {
          Throwable exc = wrappedExc.getCause();
          System.out.println(m + " 실패: " + exc);
        } catch (Exception exc) {
          System.out.println("잘못 사용한 @Test: " + m);
        }
      }
    }
    System.out.printf("성공: %d, 실패: %d%n",
                      passed, tests - passed);
  }
}

리플렉션을 사용하여, @Test 애너테이션이 달린 메서드 찾기, 원래 예외에 담긴 실패 정보 추출하여 출력한다.

 

3. 매개 변수를 가진 애너테이션

예외를 던져야하는 테스트 애너테이션 만들기

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
  Class<? extends Throwable> value();
}

애터네이션의 필드 : 애터네이션에서 사용하는 옵션값을 의미한다 @Test(value = "RuntimeException.class")

Class<? extends Throwable> : Throwable을 확장한 클래스의 Class 객체 - 모든 예외와 오류 타입을 수용함

if (m.isAnnotationPresent(ExceptionTest.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (InvocationTargetException wrappedEx) {
    Throwable exc = wrappedEx.getCause();
    Class<? extends Throwable> excType =
      m.getAnnotation(ExceptionTest.class).value();
    if (excType.isInstance(exc)) {
      passed++;
    } else {
      System.out.printf(
        "테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n",
        m, excType.getName(), exc);
    }
  } catch (Exception exc) {
    System.out.println("잘못 사용한 @ExceptionTest: " + m);
  }
}

위의 메서드를 위와 같이 수정한다.

애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는데 사용한다.

  • 테스트 프로그램이 문제 없이 컴파일 되면 애터네이션 매개변수가 가리키는 예외가 올바른 타입일것.
  • 컴파일타임에는 존재했으나 런타임에는 존재하지 않는 경우 : TypeNotPresentException

 

4. 배열 매개변수를 받는 애너테이션 타입

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value();
}
배열 매개변수 애너테이션의 사용
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
if (m.isAnnotationPresent(ExceptionTest.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (Throwable wrappedExc) {
    Throwable exc = wrappedExc.getCause();
    int oldPassed = passed;
    Class<? extends Throwable>[] excTypes =
      m.getAnnotation(ExceptionTest.class).value();
    for (Class<? extends Throwable> excType : excTypes) {
      if (excType.isInstance(exc)) {
        passed++;
        break;
      }
    }
    if (passed == oldPassed)
      System.out.printf("테스트 %s 실패: %s %n", m, exc);
  }
}

테스트 러너 수정 : for문을 추가하여 throwable 배열을 검증한다.

 

5. 반복 가능한 애너테이션

  • 자바 8부터 가능하다
  • 배열 매개변수 사용 대신 애너테이션에 @Repeatable메타 애너테이션을 단다.

@Repeatable : 하나의 프로그램 요소에 여러개의 애너테이션을 달 수 있다.

  • @Repeatable을 단 애너테이션을 반환하는 '컨테이션 애너테이션' 을 하나 더 정의한다
  • @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다
  • 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야한다
  • 컨테이너 애너테이션 타입에 적절한 @Retention@Target을 명시해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
  Class<? extends Throwable> value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
  ExceptionTest[] value();
}

적용 방법

@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {

@Repeatable 처리시 주의점

  • 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다.
  • isAnnotationPresent : 반복 가능 애너테이션이 달렸는지 검사 (false : 컨테이너 애너테이션)
  • getAnnotationsByType : 반복 가능 애너테이션과 컨테이너 애너테이션을 모두 가져옴
  • 따로따로 확인하여 코딩한다
if (m.isAnnotationPresent(ExceptionTest.class)
    || m.isAnnotationPresent(ExceptionTestContainer.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (Throwable wrappedExc) {
    Throwable exc = wrappedExc.getCause();
    int oldPassed = passed;
    ExceptionTest[] excTests =
      m.getAnnotationsByType(ExceptionTest.class);
    for (ExceptionTest excTest : excTests) {
      if (excTest.value().isInstance(exc)) {
        passed++;
        break;
      }
    }
    if (passed == oldPassed)
      System.out.printf("테스트 %s 실패: %s %n", m, exc);
  }
}
  • 애너테이션을 여러번 달아 코드 가독성을 높였다.
  • 애너테이션을 선언하고 처리하는 부분에서 코드양이 늘어난다.
  • 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명시하자

 

6. 결론

애너테이션이 명명패턴보다 낫다

다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입을 제공하자

애너테이션으로 할 수 있는 일을 명명패턴으로 처리할 이유는 없다.

자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용하자

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

1. 열거타입 확장은 하지 말자 열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴(typesafe enum pattern)보다 우수하다. 단점 : 타입 안전 열거 패턴은 확장할 수 있으나 열거 타입은 그렇지 못하다 타입 안전 열거 패턴의 예시 public class DSymbolType{ private final String type; private DSymbolType(String type){ this.type = type; } public String toString(){ return type; } public static final DSymbolType Terminal = new DSymbolType("Terminal"); public static final DSymbolType Process ..

[Effective Java] item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

728x90

1. 열거타입 확장은 하지 말자

열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴(typesafe enum pattern)보다 우수하다.

단점 : 타입 안전 열거 패턴은 확장할 수 있으나 열거 타입은 그렇지 못하다

타입 안전 열거 패턴의 예시

public class DSymbolType{
  private final String type;
  private DSymbolType(String type){
    this.type = type;
  }
  public String toString(){
    return type;
  }
  public static final DSymbolType Terminal = new DSymbolType("Terminal");
  public static final DSymbolType Process = new DSymbolType("Process");
  public static final DSymbolType Decision = new DSymbolType("Decision");
}

열거 타입을 확장하는 건 좋지 않다.

  • 확장 타입의 원소는 기반 타입의 원소로 취급, 그러나 반대는 아니다 (이상함)
  • 기반 타입과 확장 타입의 원소 모두 순회할 방법도 마땅치 않다.
  • 확장성을 높이려면 고려할 요소가 늘어나 설계와 구현이 복잡하다.

어울리는 쓰임 : 연산 코드 (opcode)

 

2. 열거 타입 확장

아이디어 : 열거 타입이 임의의 인터페이스를 구현할 수 있다.

public interface Operation {
    double apply(double x, double y);
}
public enum BasicOperation implements Operation {
    PLUS("+") {
        public double apply(double x, double y) { return x + y; }
    },
    MINUS("-") {
        public double apply(double x, double y) { return x - y; }
    },
    TIMES("*") {
        public double apply(double x, double y) { return x * y; }
    },
    DIVIDE("/") {
        public double apply(double x, double y) { return x / y; }
    };

    private final String symbol;

    BasicOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override public String toString() {
        return symbol;
    }
}
public enum ExtendedOperation implements Operation {
    EXP("^") {
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        public double apply(double x, double y) {
            return x % y;
        }
    };
    private final String symbol;
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
    @Override public String toString() {
        return symbol;
    }
}

새로 작성한 연산은 기존 연산을 쓰던 곳이면 어디든 사용이 가능하다.

Operation 인터페이스를 사용하도록 작성되있기만 하면된다

// 열거 타입의 Class 객체를 이용해 확장된 열거 타입의 모든 원소를 사용하는 예
private static <T extends Enum<T> & Operation> void test(
  Class<T> opEnumType, double x, double y) {
  for (Operation op : opEnumType.getEnumConstants())
    System.out.printf("%f %s %f = %f%n",
                      x, op, y, op.apply(x, y));
}

<T extends Enum<T> & Operation> : Class 객체가 열거 타입인 동시에 Operation의 하위 타입이어야 한다.

// 컬렉션 인스턴스를 이용해 확장된 열거 타입의 모든 원소를 사용하는 예 (235쪽)
private static void test(Collection<? extends Operation> opSet,
                         double x, double y) {
  for (Operation op : opSet)
    System.out.printf("%f %s %f = %f%n",
                      x, op, y, op.apply(x, y));
}

확장 가능한 열거 타입 흉내내는 방식의 문제점

열거 타입끼리 구현을 상속할 수 없다.

  • 디폴트 구현을 이용해 인터페이스에 추가하는 방법을 사용할 수 없다.
  • 연산 기호를 저장하고 찾는 로직이 BasicOperation ,ExtendedOperation 모두에 들어가야한다.

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 37. ordinal 인덱싱 대신 EnumMap을 사용하라

1. 올바르지 않은 방법 : ordinal()을 배열 인덱스로 사용 Set[] plant ByLisfeCycle = (Set[]) new Set[Plant.LifeCycle.values().length]; for(int i =0; i p.lifeCycl..

[Effective Java] item 37. ordinal 인덱싱 대신 EnumMap을 사용하라

728x90

1. 올바르지 않은 방법 : ordinal()을 배열 인덱스로 사용

Set<Plant>[] plant ByLisfeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for(int i =0; i < plantsByLifeCycle.length; i++)
  plantsByLifeCycle[i] = new HashSet<>();
for(Plant p : garden){
  plantsByLifeCycle[p.lifeCycler.ordinal()].add(p);
}
for(int i =0; i<plantsByLifeCycle.length;i++)}{
  System.out.println("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

문제점

  • 배열은 제네릭과 호환되지 않아 비검사 형변환을 수행해야한다. (컴파일이 깔끔하지 않다)
  • 정수 값을 정확히 사용해야한다 : 잘못하면 ArrayIndexOutOfBoundException

 

2. EnumMap : 열거 타입을 키로 사용

Set<Plant>[] plantsByLifeCycleArr =
  (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycleArr.length; i++)
  plantsByLifeCycleArr[i] = new HashSet<>();
for (Plant p : garden)
  plantsByLifeCycleArr[p.lifeCycle.ordinal()].add(p);
// 결과 출력
for (int i = 0; i < plantsByLifeCycleArr.length; i++) {
  System.out.printf("%s: %s%n",
                    Plant.LifeCycle.values()[i], plantsByLifeCycleArr[i]);
}

열거 타입을 키로 사용하도록 설계한 Map 구현체이다.

  • 안전하지 않은 형변환 사용하지 않는다.
  • 배열 인덱스 계산 과정 오류 가능성도 없다.
  • 내부에서 배열을 사용해서 ordinal 배열만큼 성능이 나온다 : 낭비 되는 공간과 시간이 거의 없어 명확하고 안전하고 유지보수하기 좋다.

EnumMap의 생성자가 받는 키타입의 Class 객체는 한정적 타입 토큰으로 런타입 제네릭 타입정보를 제공한다.

item37-1

 

3. EnumMap을 stream과 함께 사용할 때

스트림을 사용할 때 고유한 맵 구현체를 사용하기 때문에 EnumMap을 사용할 때 얻는 공간과 성능 이점이 사라진다는 문제가 있다.

System.out.println(Arrays.stream(garden)
                   .collect(groupingBy(p -> p.lifeCycle))); // 고유한 맵 구현체
System.out.println(Arrays.stream(garden)
                  .collect(groupingBy(p -> p.lifeCycle, // 원하는 맵 구현체 명시
                                     () -> new EnumMap<>(LifeCycle.class), toSet())));

stream을 사용하면 enumMap 만 사용했을 때와 생성되는 맵의 개수가 다르다.

// 코드 37-2 EnumMap을 사용해 데이터와 열거 타입을 매핑한다.
System.out.println("using EnumMap");
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
  new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
  plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
  plantsByLifeCycle.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle);

System.out.println("using stream");
// 코드 37-3 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다!
Map<LifeCycle, List<Plant>> collect = Arrays.stream(garden)
  .collect(groupingBy(p -> p.lifeCycle));
System.out.println(collect);

System.out.println("using stream enumMap");
// 코드 37-4 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다.
EnumMap<LifeCycle, Set<Plant>> collect1 = Arrays.stream(garden)
  .collect(groupingBy(p -> p.lifeCycle,
                      () -> new EnumMap<>(LifeCycle.class), toSet()));
System.out.println(collect1);

 

item37-2

EnumMap을 이용해 put한 코드드에서는 PERENNIAL이 없어도 생성된다. 반면 stream 을 이용한 코드에서는 2개만 생성된다.

댓글

Comments

Dev Book Review/Effective Java

[Effective Java] item 36. 비트 필드 대신 EnumSet을 사용하라

1. 비트 필드란? 비트 필드 : 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있는 집합 public class Text{ public static final int STYLE_BOLD = 1

[Effective Java] item 36. 비트 필드 대신 EnumSet을 사용하라

728x90

1. 비트 필드란?

비트 필드 : 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있는 집합

public class Text{
  public static final int STYLE_BOLD = 1 << 0; // 1
  public static final int STYLE_ITALIC = 1 << 1; // 2
  public static final int STYLE_UNDERLINE = 1 << 2; // 4
  public static final int STYLE_STRIKETHROUGH = 1 << 3; // 5

  // 매개변수 styles는 0개 이상의 STYLE_ 상수를 비트별 OR 한 값
  public void applyStyle(int styles){...}
}

비트 필드의 문제점

  • 정수 열거 상수의 단점을 그대로 지님
  • 비트 필드 값이 그대로 출력되면 단순 정수 열거 상수 출력보다 해석이 어렵다
  • 비트 필드 하나에 녹아있는 모든 원소 순회도 까다롭다
  • 최대 몇 비트가 필요한지 API 작성시 미리 예측해야한다.

 

2. 비트 필드의 해결 : EnumSet

열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현한다

내부에서 비트 벡터로 구현되어있어 성능도 비트 필드에 비견된다.

item36-1

EnumSet javadoc 내용

  • enum type을 구현할 때 특화된다.
  • Enum set들은 bit vector들로 표현된다 : 극도로 시간과 공간 복잡도에서 효율적이다 (bit flag)
  • 인자가 enum set이라면 containsAll, retainAll 같은 대형 연산에서도 매우 빠르다.
import java.util.*;

// 코드 36-2 EnumSet - 비트 필드를 대체하는 현대적 기법 (224쪽)
public class Text {
    public enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}

    // 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다.
    public void applyStyles(Set<Style> styles) {
        System.out.printf("Applying styles %s to text%n",
                Objects.requireNonNull(styles));
    }

    // 사용 예
    public static void main(String[] args) {
        Text text = new Text();
        text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
    }
}

EnumSet을 건네리라 짐작되는 상황이라도 이왕이면 인터페이스로 받는게 좋은 습관이다

댓글

Comments