티스토리 뷰

Java

synchronized 와 Double-checked locking

개발자-김씨 2020. 11. 20. 19:15
반응형

Hikari 소스를 까 보던 중 동기화와 관련된 좋은 코드가 있어서 소개합니다.

그전에 일단 DataSource에 대해서 좀 알아보겠습니다.

 

 

DataSource를 사용하면 getConnection메소드를 통해 커넥션을 얻어 옵니다.

public interface DataSource  extends CommonDataSource, Wrapper {

  Connection getConnection() throws SQLException;
  ..

 

보통 DB당 DataSource 객체를 하나만 생성하기 때문에 DataSource는 다수의 스레드에 의해 공유됩니다.

따라서 커넥션을 얻기 위해 getConnection메소드를 호출할 때마다 스레드 간 경합이 발생하게 됩니다.

 

그런데 getConnection메소드에는 커넥션을 얻기 위한 경합만 있는 건 아닙니다.

DataSource의 커넥션풀은 getConnection메소드가 처음 호출되는 시점에 초기화 되도록 되어 있습니다(지연 초기화 전략). 따라서 동시에 getConnection메소드가 호출되더라도 초기화(커넥션풀 생성)는 하나만 생성되게 해야 합니다. (Hikari의 경우 DataSource생성 시점에도 커넥션 풀 초기화가 가능합니다. 관련 글)

 

오늘 살펴볼 내용은 바로 커넥션풀 초기화와 관련된 동기화 처리입니다.

 

 

우선 getConnection메소드를 호출하면 커넥션풀을 초기화하고 생성된 커넥션풀에서 커넥션을 반환하는 코드를 작성해 보겠습니다.

public class SampleDataSource implements DataSource {

    private ConnectionPool pool = null;
		
    @Override        
    public  Connection getConnection() {
        ConnectionPool pool = createConnectionPool();//커넥션풀 획득(createConnectionPool호출)
        return createConnectionPool().getConnection();//커넥션풀에서 커넥션 획득
    }
	
    private synchronized ConnectionPool createConnectionPool() {
        if (this.closed) {
            throw new SQLException("Data source is closed");
        }
        ....
        ....
        if (this.pool == null) {//커넥션풀이 생성되지 않았다면	
             //.. 커넥션풀 초기화 작업
            this.pool = new ConnectionPool(option);	//커넥션풀 생성은 1초가 걸림
        }

        return this.pool;
    }
}

동시에 getConnection이 호출되더라도 커넥션풀이 두 번 생성되지 않도록 커넥션풀 초기화 메소드(createConnectionPool)에 synchronized 키워드를 붙였습니다.

하지만 가급적이면 동기화 블록은 최소한으로 잡는 것이 좋습니다.

 

    public ConnectionPool createConnectionPool() {
        if (this.closed) {
            throw new SQLException("Data source is closed");
        }
        ....
        ....
        synchronized (this) {  <<<<==============================================  여기
            if (this.pool == null) {//커넥션풀이 생성되지 않았다면	
                //.. 커넥션풀 초기화 작업
                this.pool = new ConnectionPool(option);
            }
        }
        
        return this.pool;
    }

실제 커넥션풀 초기화 영역에만 동기화되도록 수정했습니다.

하지만 초기화 여부를 확인하는 if문이 synchronized블록 안에 있기 때문에 커넥션풀이 초기화된 이후에도 매번 synchronized블록으로 진입하게 됩니다. synchronized블록으로 진입하는 건 매우 비싼 작업이므로 오버헤드를 줄이기 위해 커넥션풀이 초기화 된 이후부터는 synchronized블록으로 진입하지 않도록 수정하도록 하겠습니다.

 

    public ConnectionPool createConnectionPool() {
        if (this.closed) {
            throw new SQLException("Data source is closed");
        }
        ....
        ....
        if (this.pool == null) {  <<<====================================  여기
            synchronized (this) {
                if (this.pool == null) {//커넥션풀이 생성되지 않았다면	
                    //.. 커넥션풀 초기화 작업
                    this.pool = new ConnectionPool(option);
                }
            }
        }

        return this.pool;
    }

synchonized블록 진입 전에 커넥션풀이 초기화되었는지 확인하도록 if문을 추가하였습니다.

이제 커넥션풀 초기화가 완료된 이후에는 synchonized블록으로 진입하지 않습니다.

synchonized블록 안에 또 if문이 또 있는 이유는 커넥션풀을 초기화하는 동안 다른 스레드가 getConnection메소드를 호출하면 아직 this.pool == null이기 때문에 synchonized블록으로 진입하기 위해 대기하게 됩니다. 초기화가 끝난 후 다른 스레드가 락을 획득하면 다시 한번 초기화 여부를 확인하도록 해서 중복해서 커넥션풀이 생성되지 않도록 하기 위함입니다.

 

이렇게 동기화 블록(락 획득) 전후로 체크하는 것을 DCL(Double - checked locking)이라고 합니다. 

하지만 예상치 않게 위 코드는 컴파일 단계에서 문제가 생길 수 있습니다.

 

 

컴파일 - 재배치(reordering)

this.pool = new ConnectionPool(option);

원래 위 코드는

 1. ConnectionPool 인스턴스 생성

 2. this.pool변수에 ConnectionPool 주소 값 대입

 

순서대로 처리되어야 하는데 컴파일러 최적화에 따라 코드가 재배치되면서 순서가 바뀝니다.

 

 1. this.pool변수에 ConnectionPool 주소 값 대입

 2. connectionPool 인스턴스 생성

 

따라서 인스턴스가 완전히 생성되지 않은 시점에 this.pool변수에 커넥션풀 주소 값이 대입되게 됩니다. 이 시점에 다른 스레드가 진입하면 pool이 null이 아니기 때문에 커넥션풀이 초기화가 되었다고 간주하고 그다음 단계로 진행하게 됩니다. 하지만 인스턴스가 완전히 생성되지 않았기 때문에 문제가 발생할 수 있습니다.

 

 

volatile 키워드

그런데  위 문제는 자바 1.5이상부터 volatile를 사용하여 해결이 가능합니다.

왜냐하면 자바 1.5부터 volatile키워드가 붙은 변수들은 컴파일 시에 재배치를 하지 않도록 했기 때문입니다.

따라서 재배치를 하지 않도록 volatile를 붙여보도록 하겠습니다.

(일반적인 volatile의 사용목적과 다르게, 재배치-최적화를 막기 위한 목적으로 사용 - volatile에 재한 자세한 내용은 다음 포스팅에서 더 자세히 다루도록 하겠습니다.)

public class SampleDataSource implements DataSource {

    private volatile ConnectionPool pool = null;  <<<<====== volatile 선언
		
    @Override        
    public  Connection getConnection() {
        ConnectionPool pool = createConnectionPool();	//커넥션풀 획득(createConnectionPool호출)
        return createConnectionPool().getConnection();	//커넥션풀에서 커넥션 획득
    }
    
    public ConnectionPool createConnectionPool() {
        if (this.closed) {
            throw new SQLException("Data source is closed");
        }
        ....
        ....
        if (this.pool == null) {
            synchronized (this) {
                if (this.pool == null) {//커넥션풀이 생성되지 않았다면	
                    //.. 커넥션풀 초기화 작업
                    this.pool = new ConnectionPool(option);
                }
            }
        }

        return this.pool;
    }

이제 정상적으로 작동하는 DCL코드가 되었습니다.

 

 

 

자. 그런데 성능 향상을 위해 조금 더 개선할 부분이 있습니다.

volatile변수들은 메인 메모리에서 처리하기 때문에 접근(read/write처리)비용이 비싼 편입니다.

초기화된 이후에도 매번 2번의 읽기가 발생합니다.


    public ConnectionPool createConnectionPool() {

        ....
        if (this.pool == null) {  <<<<=== 여기서 한번
            synchronized (this) {
                if (this.pool == null) {//커넥션풀이 생성되지 않았다면	
                    //.. 커넥션풀 초기화 작업
                    this.pool = new ConnectionPool(option);
                }
            }
        }

        return this.pool;   <<<<<====== 여기서 한번
    }

로컬 변수를 이용하여 volatile가 붙은 this.pool변수를 한 번만 참조하도록 수정해 보겠습니다.

    public ConnectionPool createConnectionPool() {

        ConnectionPool localRef = this.pool; <========= 여기서 한번만 읽도록
        if (localRef == null) {  
            synchronized (this) {
                localRef = this.pool; 
                if (localRef == null) {//커넥션풀이 생성되지 않았다면	
                    //.. 커넥션풀 초기화 작업
                    this.pool = localRef = new ConnectionPool(option);
                }
            }
        }

        return localRef;
    }

이렇게 지역 변수를 이용하여 한 번만 참조하도록 하면 약 40% 정도의 성능 개선 효과가 있다고 합니다.

 

 

마지막으로 위의 내용들이 모두 적용된 HikariDataSource의 getConnection메소드를 보고 마치도록 하겠습니다. 

   private volatile HikariPool pool;
   
   ...
   
   @Override
   public Connection getConnection() throws SQLException
   {
      if (isClosed()) {
         throw new SQLException("HikariDataSource " + this + " has been closed.");
      }

      if (fastPathPool != null) {
         return fastPathPool.getConnection();
      }

      // See http://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java
      HikariPool result = pool;
      if (result == null) { 
         synchronized (this) {
            result = pool;
            if (result == null) {
               validate();
               LOGGER.info("{} - Starting...", getPoolName());
               try {
                  pool = result = new HikariPool(this);
                  this.seal();
               }
               catch (PoolInitializationException pie) {
                  if (pie.getCause() instanceof SQLException) {
                     throw (SQLException) pie.getCause();
                  }
                  else {
                     throw pie;
                  }
               }
               LOGGER.info("{} - Start completed.", getPoolName());
            }
         }
      }

      return result.getConnection();
   }

 

마치며 .. 덧붙이기

이펙티브 3에 지연 초기화에 대한 내용이 있네요.

공감되는 내용이고 위 내용과 관련이 많은 것 같아 덧붙입니다.

 

1. 지연 초기화보다는 일반적인 초기화가 낫다.

HikariDataSource도 getConnection메소드 호출 시점에 커넥션풀을 초기화하는 게 아니라 객체 생성시점에 바로 커넥션풀을 초기화할 수 있도록 생성자(config를 받는..)를 추가로 만들어 두었습니다. 그래서 지연 초기화를 할지 사용자가 선택할 수 있도록 해두었습니다. 

 

 

2. 반복해서 초기화해도 상관없다면 ..

종종 싱글톤 때문에 synchrnoized 사용하는 경우를 볼 수 있는데

커넥션풀처럼 중복되면 문제가 발생하는 경우가 아니라면 굳이 비싼 비용을 지불할 필요는 없을 수도 있습니다.

 

 

참고 : en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java

 

 

 

다음에는 '자바 동기화 - volatile와 synchronized'에 대해서....

 

 

 

반응형

'Java' 카테고리의 다른 글

ClientSocketUtil 개선버전  (0) 2023.04.12
Socket Util 만들어 보기  (0) 2023.04.11
자바 동기화 처리 - volatile 와 synchronized  (1) 2020.11.25
Optional에 대해....  (0) 2020.10.22
ThreadPoolExecutor  (0) 2017.01.09
댓글