[Java] Multi thread 동기화 기능

Welcome to Seoyun Dev Log·2023년 4월 11일
0

JAVA

목록 보기
4/12

thread (스레드)란?

프로세스를 구성하는 작업의 한 단위
프로세스 내부에 있는 여러 스레드는 서로 같은 프로세스 내부에 존재하기 때문에 같은 자원을 공유하여 사용할 수 있으며 같은 자원을 공유할 수 있기 때문에 동시에 여러가지 일을 같은 자원을 두고 수행할 수 있고 이는 병렬성의 향상으로 이어진다

스레드의 상위 단위인 프로세스에 대해서 이해가 필요하다.

  • 프로그램이 실제 실행되어 메모리나 CPU와 같은 자원을 할당받으면 이를 프로세스라 부른다
  • 하나의 프로세스는 여러 스레드가 작동할 수 있다
  • 독자적인 메모리를 할당받아서 서로 다른 프로세스끼리는 일반적으로 서로의 메모리 영역을 침범하지 못한다

스레드 생성 방법


싱글스레드 vs 멀티스레드

  • 싱글스레드 프로세스: 프로세스 내에서 단 하나의 스레드만 작업
  • 멀티스레드 프로세스: 여러 스레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향 준다

멀티스레드의 장점

  • 프로세스 내의 자원을 공유하여 병렬성 향상에 기여

멀티스레드 동시성 이슈

동기화 처리를 하지 않아서 종료되지 않는 문제 발생

public class StopThread {  
        private static boolean stopRequested;  

        public static void main(String[] args) throws InterruptedException {  
            Thread backgroundThread = new Thread(() -> {  
                int i = 0;  
                while (!stopRequested) {  
                    i++;  
                }  
            });  
            backgroundThread.start();  
			//1초뒤에 공유변수 stopRequested 를 main 스레드가 true 로 변경
            TimeUnit.SECONDS.sleep(1);  
            stopRequested = true;  
        }  
    }

자바 언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다.

⭐️ JVM은 데이터를 4byte(=32bit)단위로 처리하기 때문에, int와 int보다 작은 타입들은 한 번에 읽고 쓰는 것이 가능합니다. 즉, 단 하나의 명령어로 읽거나 쓰기가 가능하다는 뜻입니다.
하나의 명령어는 더 이상 나눌 수 없는 최소의 작업단위이므로, 작업의 중간에 다른 스레드가 끼어들 틈이 없습니다. 다만, 크기가 8byte인 long과 double 타입의 변수는 하나의 명령어로 값을 읽거나 쓸수 없기 때문에, 변수의 값을 읽는 과정에 다른 스레드가 끼어들 여지가 있습니다.

따라서 boolean 필드를 읽고 쓰는 작업 자체로는 원자적이어야 하는데 문제가 발생한다

  • 문제 원인

    자바 언어 명세를 보면 스레드가 필드를 읽을 때 '수정이 완벽히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다고 되어 있습니다.
    이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문입니다.

스레드가 변수를 읽을 때 Mainㅇ Memory에 바로 접근하지 않고 cache를 거쳐 데이터가 있는지 확인한다. 이때 존재하면 cache에서 읽어오는 것
따라서 실제 변수의 값이 변화해도 해당 스레드는 그 전에 읽었던 캐시에서 읽기 때문에 변경된 사항을 볼 수 없다.

위 코드에서 main 스레드는 stopRequested의 값을 변경시켰지만 backgroundThread는 본인 스레드 CPU Cache에 있는 값을 바라보기 때문에 불일치 문제가 발생합니다.

이러한 문제를 동시성 프로그래밍에서의 가시성(Visibility) 문제라고 한다.

해결 코드는 아래 thread-safe의 암묵적 Lock 참고


thread-safe

멀티 스레드 환경에서 동시성 문제, 데드락과 같은 여러 가지 문제점을 해결해야 멀티스레드 환경에서 문제없는 프로그램을 제작할 수 있습니다
(여러 스레드가 작동하는 환경에서도 문제 없이 동작하는 것을 스레드 안전하다고 말할 수 있다)

: 한 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 스레드의 동기화(synchronization)라고 한다.

📌동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다

암묵적 Lock

synchronized 키워드를 사용하여 메서드 Lock, 변수(클래스) Lock을 건다
이는 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.
⭐️기본 타입의 변수(int,long)일 경우엔 lock을 걸 수 없으니 주의

위 종료되지 않는 문제를 synchronized로 해결해보았다
synchronized를 통해서 캐시와 메모리간의 동기화가 이루어지기 때문에 값의 불일치 문제 해소

1) 메서드 Lock

class Count {
    private int count;
    public synchronized int view() {return count++;}
}

2) 변수 Lock

class Count {
    private Integer count = 0;
    public int view() {
        synchronized (this.count) {
            return count++;
        }
    }
}

3) Object level lock / Class level lock

  • object level: 객체의 참조변수가 들어가는 경우
// Object Level locking
public class ObjectLevelLockExample {
   public void objectLevelLockMethod() {
      synchronized (this) {
         //...
      }
   }
}
  • class level: Class 클래스(.class)가 들어가는 경우
// Class Level locking
public class ClassLevelLockExample {
   public void classLevelLockMethod() {
      synchronized (ClassLevelLockExample.class) {
         //...
      }
   }
}

3-2) object Lock vs class Lock 차이점

Object level lockClass level lock
staticnon - staic 데이터를 thread safe하게 만들 때 사용static 데이터를 thread safe하게 만들기 위해서 사용
범위클래스의 모든 인스턴스가 각자의 lock을 가질 수 있다.클래스 하나당 하나의 lock만 존재한다.

인스턴스가 2개인 상황일 때

  • object level: 클래스의 모든 인스턴스가 각자의 lock을 가질 수 있다
public class hello {

    public static void main(String[] args) {
        A a1 = new A(); // 인스턴스 두개
        A a2 = new A();

        Thread thread1 = new Thread( () -> {
            a1.run("thread-1");
        });
        Thread thread2 = new Thread( () -> {
            a2.run("thread-2");
        });

        thread1.start();
        thread2.start();
    }

}
public class A {
    public synchronized void run(String name) {
        System.out.println(name + "락 걸기");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(name + "락 해제");
    }

    public synchronized void print(String name) { // 🔴 print()메서드도 synchronized 적용
        System.out.println(name + "hi");
    }
}
  • class level: 클래스 하나당 하나의 lock만 존재
public class hello {

    public static void main(String[] args) throws InterruptedException {
        A a1 = new A();
        A a2 = new A();

        Thread thread1 = new Thread( () -> {
            a1.run("thread-1 ");
        });
        Thread thread2 = new Thread( () -> {
            a2.print("thread-2 ");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }

}
public class A {
    public static synchronized void run(String name) { // static
        System.out.println(name + "락 걸기");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(name + "락 해제");
    }

    public synchronized void print(String name) { // non-static
        System.out.println(name + "hi");
    }
}

명시적 Lock

synchronized 키워드 없이 명시적으로 ReentrantLock을 사용하는 Lock
lock 객체를 생성하여 lock() 메서드를 호출 시점과 unlock() 메서드 호출 시점 사이의 행위에 대해서 Lock 행위를 적용할 수 있다

static int cnt = 0;
static Lock lock = new ReentrantLock();

public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        new Thread() {
            @Override
            public void run() {
                for (int j = 0; j < 10000; j++) {
                    lock.lock();
                    System.out.println(add());
                    lock.unlock();
                }
            }
        }.start();
    }
}

public static int add(){
    return ++cnt;
}

동기화 문제 해결

  • 동기화에 대한 문제를 피하는 가장 좋은 방법은 가변 데이터를 공유하지 않는 것이며 가변 데이터는 단일 스레드에서만 사용하는 것이 좋다.
  • 가변 데이터를 단일 스레드에서만 사용한다면 문서에 남겨 유지보수 정책에서도 지켜지는것이 중요하다.
  • 멀티 스레드 환경에서 한 스레드가 데이터를 수정한 후에 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.
  • 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 메서드 모두에 반드시 synchronized 키워드를 붙이거나 가변 데이터에 atomic 변수를 사용한다.
  • 배타적 실행 (한번에 한스레드) 동작이 필요없고, 스레드 간 최신데이터만 읽는 거로도 충분하면 가변 변수에 volatile 키워드만으로도 동기화가 가능하다.
  • 조회와 저장을 하나의 행위로 묶어 반드시 조회와 저장의 트랜잭션이 끝난 뒤에 다른 스레드가 조회와 저장을 하게 하면 동시성 이슈를 제어 할 수 있습니다. (Lock)

질문의 답변

하나의 쓰기 스레드와 여러 읽기 스레드가 존재할 때 사용되어야 하는 Java의 동기화 기능은 무엇이고 어떻게 동작하게 되는지 설명해주세요.

Java에서 동시에 여러 스레드가 동시에 접근하는 공유 자원에 대한 접근을 동기화하려면, synchronized 키워드를 사용할 수 있습니다

하나의 쓰기 스레드와 여러 읽기 스레드가 공유하는 리스트가 있다고 가정해보겠습니다

이 경우 쓰기 스레드는 리스트에 항목을 추가하거나 삭제할 때 리스트의 상태가 일관성 있게 유지되어야 합니다. 읽기 스레드들도 리스트를 동시에 읽을 수 있기 때문에, 리스트의 상태가 일관성 없이 변할 수 있습니다. 이 문제를 해결하기 위해서는 synchronized 키워드를 사용하여 쓰기 작업이 진행되는 동안 리스트에 대한 모든 읽기 작업이 차단되도록 만들어야 합니다.

synchronized 키워드는 메서드나 블록에 적용할 수 있으며, 하나의 스레드가 해당 메서드나 블록을 실행하는 동안 다른 스레드는 해당 메서드나 블록에 접근할 수 없게 됩니다. 이를 통해 공유 자원에 대한 동시 접근을 제한하고, 데이터 무결성을 유지할 수 있습니다.

public class SharedResource {
   private int count;

   public synchronized void increment() {
      count++;
   }

   public synchronized int getCount() {
      return count;
   }
}

public class MyThread implements Runnable {
   private SharedResource resource;

   public MyThread(SharedResource resource) {
      this.resource = resource;
   }

   @Override
   public void run() {
      resource.increment();
   }
}

public class Main {
   public static void main(String[] args) throws InterruptedException {
      SharedResource resource = new SharedResource();

      // Create multiple threads that access the shared resource
      Thread t1 = new Thread(new MyThread(resource));
      Thread t2 = new Thread(new MyThread(resource));
      Thread t3 = new Thread(new MyThread(resource));

      // Start the threads
      t1.start();
      t2.start();
      t3.start();

      // Wait for the threads to finish
      t1.join();
      t2.join();
      t3.join();

      // Print the final count value
      System.out.println("Count: " + resource.getCount());
   }
}

위 코드에서 SharedResource 클래스는 공유 자원인 count 필드를 가지고 있으며, increment 메서드와 getCount 메서드는 모두 synchronized 키워드로 선언되어 있습니다. 따라서 increment 메서드나 getCount 메서드가 실행되는 동안 다른 스레드는 해당 메서드에 접근할 수 없게 됩니다.

MyThread 클래스는 SharedResource 객체를 생성자로 받아 run 메서드에서 increment 메서드를 호출하는 역할을 합니다. Main 클래스에서는 MyThread 객체를 생성하여 각각의 스레드를 생성하고 실행시킨 뒤, 최종적으로 getCount 메서드를 호출하여 공유 자원의 최종 상태를 출력합니다.

이와 같이 synchronized 키워드를 사용하여 동기화를 구현하면, 여러 개의 스레드가 공유 자원에 안전하게 접근할 수 있습니다.

불변 객체

불변 객체는 객체의 상태가 생성 이후에 변경되지 않으며, 스레드 간 안전하게 공유될 수 있습니다. Java에서는 불변 객체를 생성하기 위해 다음과 같은 방법을 사용할 수 있습니다.

  • 모든 필드를 final로 선언하여 초기화합니다.
  • 생성자에서 모든 필드를 초기화합니다.
  • 필드의 getter 메서드만 제공하고, setter 메서드를 제공하지 않습니다.
  • 내부 상태를 변경하는 메서드를 제공하지 않습니다.

이와 같은 방법을 사용하여 불변 객체로 구현된 SharedResource 클래스는 다음과 같습니다.

public final class SharedResource {
   private final int count;

   public SharedResource(int count) {
      this.count = count;
   }

   public int getCount() {
      return count;
   }
}

public class Main {
   public static void main(String[] args) {
      SharedResource resource = new SharedResource(0);

      // Create multiple threads that access the shared resource
      Thread t1 = new Thread(() -> {
         SharedResource r = resource;
         resource = new SharedResource(r.getCount() + 1);
      });
      Thread t2 = new Thread(() -> {
         SharedResource r = resource;
         resource = new SharedResource(r.getCount() + 1);
      });
      Thread t3 = new Thread(() -> {
         SharedResource r = resource;
         resource = new SharedResource(r.getCount() + 1);
      });

      // Start the threads
      t1.start();
      t2.start();
      t3.start();

      // Wait for the threads to finish
      try {
         t1.join();
         t2.join();
         t3.join();
      } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         return;
      }

      // Print the final count value
      System.out.println("Count: " + resource.getCount());
   }
}

위 코드에서 SharedResource 클래스는 모든 필드를 final로 선언하고, 생성자에서 모든 필드를 초기화합니다. 또한, getCount 메서드만 제공하고, increment 메서드와 같이 내부 상태를 변경하는 메서드를 제공하지 않습니다.

Main 클래스에서는 각각의 스레드가 SharedResource 객체의 상태를 변경하는 람다식을 생성하여 실행합니다. 이때, resource 변수는 final로 선언되어 있으므로, 실제로는 resource 변수를 참조하는 새로운 변수를 생성하여 객체를 변경합니다. 이를 통해 불변 객체로 구현된 SharedResource 클래스가 스레드 간 안전하게 공유될 수 있습니다.


참고

profile
하루 일지 보단 행동 고찰 과정에 대한 개발 블로그

0개의 댓글