본문 바로가기
프로그래밍/JAVA

초보 자바 프로그래밍(58) - Thread 동기화

by 머니테크리더 2023. 10. 4.
반응형

초보 자바 프로그래밍(58) - Thread 동기화
초보 자바 프로그래밍(58) - Thread 동기화

 

🔖 INDEX

     

     

    Java에서 스레드 간의 동기화는 여러 스레드가 동시에 동일한 자원에 접근할 때 발생하는 데이터 무결성 문제를 방지하기 위해 사용됩니다. 여기서 주의해야 할 가장 중요한 개념은 '공유된 자원'에 대한 동시 수정을 방지하는 것입니다.

     

    synchronized

    Synchronized는 Java에서 객체의 임계 영역(critical section)에 대한 동시 접근을 제한하기 위한 동기화 키워드입니다. 이 키워드를 사용하면 한 번에 하나의 스레드만 해당 코드 블록이나 메서드에 접근할 수 있으므로 다른 스레드들은 잠금이 해제될 때까지 대기해야 합니다.

     

    사용 방법

    메서드에 synchronized 사용: 전체 메서드를 동기화합니다.

    public synchronized void synchronizedMethod() {
        // 동기화된 코드
    }

     

    특정 객체에 대한 동기화 블록

    public void method() {
        synchronized (someObject) {
            // someObject에 대한 동기화된 코드
        }
    }

     

    예제

    아래는 생산자-소비자(Producer-Consumer) 문제, 두 개의 스레드 그룹(생산자와 소비자)이 공유 자원에 대한 동시 액세스를 시도할 때 발생하는 문제를 설명합니다. 아래 예제에서는 List를 공유 버퍼로 사용하며, 생산자는 버퍼에 아이템을 추가하고 소비자는 아이템을 제거합니다.

    import java.util.LinkedList;
    import java.util.List;
    
    public class ProducerConsumerExample {
    
        public static class SharedBuffer {
            private final List<Integer> buffer = new LinkedList<>();
            private final int MAX_SIZE = 5;
    
            public synchronized void produce(int value) throws InterruptedException {
                while (buffer.size() == MAX_SIZE) {
                    // 버퍼가 가득 찼으면 대기
                    wait();
                }
                buffer.add(value);
                System.out.println("Produced: " + value + " | Buffer size: " + buffer.size());
                notifyAll();  // 소비자에게 아이템을 추가했음을 알림
            }
    
            public synchronized int consume() throws InterruptedException {
                while (buffer.isEmpty()) {
                    // 버퍼가 비어있으면 대기
                    wait();
                }
                int value = buffer.remove(0);
                System.out.println("Consumed: " + value + " | Buffer size: " + buffer.size());
                notifyAll();  // 생산자에게 아이템을 제거했음을 알림
                return value;
            }
        }
    
        public static void main(String[] args) {
            SharedBuffer buffer = new SharedBuffer();
    
            // 생산자 스레드
            Thread producerThread = new Thread(() -> {
                try {
                    for (int i = 0; i < 10; i++) {
                        buffer.produce(i);
                        Thread.sleep(100);  // 생산 속도 조절을 위해 잠시 대기
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
    
            // 소비자 스레드
            Thread consumerThread = new Thread(() -> {
                try {
                    for (int i = 0; i < 10; i++) {
                        buffer.consume();
                        Thread.sleep(150);  // 소비 속도 조절을 위해 잠시 대기
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
    
            producerThread.start();
            consumerThread.start();
        }
    }

    이 예제에서 생산자와 소비자는 동기화된 produce 및 consume 메소드를 통해 공유 버퍼에 대한 액세스를 시도합니다. 버퍼가 가득 찼을 때 생산자는 대기하고, 버퍼가 비었을 때 소비자는 대기합니다. synchronized 키워드는 동시에 여러 스레드가 메소드에 액세스하지 못하도록 합니다, 따라서 한 번에 하나의 스레드만 produce 또는 consume 메소드를 실행할 수 있습니다.

     

     

    volatile

    Java에서 volatile은 변수를 선언할 때 사용되는 키워드입니다. 이 키워드는 변수에 대한 읽기와 쓰기가 항상 메인 메모리에서 직접 수행된다는 것을 보장합니다. 이렇게 함으로써 여러 스레드에서 해당 변수를 동시에 읽고 쓸 때 발생할 수 있는 비동기화 문제를 방지합니다.

    volatile의 주요 사용 사례는 하나의 스레드에 의해 쓰이고 다른 스레드에 의해 읽혀지는 변수를 위한 것입니다.

     

    왜 필요한가?

    Java에서 스레드는 CPU의 로컬 캐시를 사용하여 변수를 읽고 쓸 수 있습니다. 만약 변수가 volatile로 선언되지 않았다면, 스레드는 메인 메모리보다 로컬 캐시에서 더 빠른 읽기/쓰기를 위해 로컬 캐시에 있는 값을 사용할 수 있습니다. 이는 여러 스레드가 동일한 변수를 동시에 읽고 쓰는 경우에 문제가 될 수 있습니다.

    volatile 키워드를 사용하면 변수의 모든 읽기와 쓰기가 직접 메인 메모리에서 수행되기 때문에 변수의 최신 값을 항상 얻을 수 있습니다.

     

    예제

    아래는 volatile을 사용하는 간단한 예제로, 이 예제는 백그라운드 스레드에서 실행되는 작업을 주 스레드에서 안전하게 중지시키는 방법을 보여줍니다.

    public class VolatileExample {
    
        private static volatile boolean running = true;
    
        public static void main(String[] args) {
            // 백그라운드 스레드
            Thread taskThread = new Thread(() -> {
                while (running) {
                    // 여기에서 복잡한 작업 수행...
                    System.out.println("Task is running...");
                    try {
                        Thread.sleep(1000);  // 일종의 작업 시뮬레이션
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
    
            taskThread.start();  // 스레드 시작
    
            try {
                Thread.sleep(5000);  // 메인 스레드에서 5초 동안 대기
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            running = false;  // 작업 스레드 중지
            System.out.println("Task has been stopped from the main thread.");
        }
    }

    이 예제에서는 running 변수를 volatile로 선언하여 다른 스레드에서 변수의 값을 변경할 때 해당 변경이 모든 스레드에 즉시 보이도록 합니다. 이렇게 함으로써 메인 스레드에서 running 변수의 값을 false로 변경하면 백그라운드 스레드의 while 루프도 즉시 중지됩니다.

     

     

    ReentrantLock

    ReentrantLock은 Java의 java.util.concurrent.locks 패키지에 있는 클래스로서, 동기화 블록 (synchronized 키워드)의 대안으로 사용됩니다. 이 클래스는 더 유연한 동시성 제어를 가능하게 해주며, 기다리는 스레드들에게 순서를 부여하거나 타임아웃을 사용하여 특정 시간 동안만 잠금을 기다리게 하는 등의 고급 기능을 제공합니다.

     

    특징

    • 재진입 가능: 한 스레드가 이미 잠금을 보유하고 있는 경우, 그 스레드는 여러 번 잠금을 획득할 수 있습니다.
    • 타임아웃: 스레드는 특정 시간 동안만 잠금을 기다릴 수 있습니다.
    • 직접 인터럽트: 대기 중인 스레드는 다른 스레드에 의해 인터럽트될 수 있습니다.
    • 정의된 순서: 기다리는 스레드들에게 순서를 부여하여 잠금을 획득하는 순서를 정의할 수 있습니다.

     

    예제

    import java.util.concurrent.locks.ReentrantLock;
    
    class Counter {
        private final ReentrantLock lock = new ReentrantLock();
        private int count = 0;
    
        public void increment() {
            lock.lock();
            try {
                count++;
                System.out.println("Count after increment: " + count);
            } finally {
                lock.unlock();
            }
        }
    
        public void decrement() {
            lock.lock();
            try {
                count--;
                System.out.println("Count after decrement: " + count);
            } finally {
                lock.unlock();
            }
        }
    }
    
    public class ReentrantLockDemo {
        public static void main(String[] args) {
            Counter counter = new Counter();
    
            Thread thread1 = new Thread(() -> {
                for (int i = 0; i < 5; i++) {
                    counter.increment();
                }
            });
    
            Thread thread2 = new Thread(() -> {
                for (int i = 0; i < 5; i++) {
                    counter.decrement();
                }
            });
    
            thread1.start();
            thread2.start();
        }
    }

    위의 예제에서 ReentrantLock을 사용하여 increment 및 decrement 메서드를 동기화합니다. 두 메서드는 동시에 동일한 자원 (count 변수)에 접근하지 않도록 잠금을 사용하여 동기화됩니다.

     

    ReentrantLock을 사용할 때는 항상 잠금을 얻기 위해 lock() 메서드를 호출하고, 작업이 완료되면 unlock() 메서드를 호출하여 잠금을 해제해야 합니다. 가능한 한 try-finally 블록 내에서 unlock() 메서드를 호출하는 것이 좋습니다. 이렇게 하면 예외가 발생하더라도 잠금이 항상 해제됩니다.

     

     

    Semaphore

    Semaphore는 Java의 java.util.concurrent 패키지에 있는 동시성 제어를 위한 클래스입니다. 세마포어는 주로 제한된 자원을 여러 스레드가 공유하도록 허용할 때 사용됩니다. 예를 들어, 특정한 동시 스레드 수를 제한하거나 임계 영역에 동시에 들어갈 수 있는 스레드 수를 제한할 때 사용합니다.

     

    특징

    • 허용량: 세마포어는 초기 허용량으로 생성되며, 이 허용량은 한 번에 자원에 액세스 할 수 있는 스레드 수를 나타냅니다.
    • 획득 및 해제: 스레드는 acquire() 메서드를 사용하여 세마포어를 획득하고, release() 메서드를 사용하여 해제합니다.
    • 블로킹 및 타임아웃: 스레드는 세마포어가 사용 가능할 때까지 블록될 수 있으며, 선택적으로 타임아웃과 함께 획득을 시도할 수 있습니다.

     

    예제

    다음 예제에서는 세마포어를 사용하여 동시에 실행될 수 있는 스레드의 수를 제한합니다.

    import java.util.concurrent.Semaphore;
    
    class PoolResource {
        private final Semaphore semaphore;
    
        public PoolResource(int permits) {
            semaphore = new Semaphore(permits);
        }
    
        public void useResource() {
            try {
                semaphore.acquire();
                System.out.println(Thread.currentThread().getName() + " is using the resource.");
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println(Thread.currentThread().getName() + " has released the resource.");
                semaphore.release();
            }
        }
    }
    
    public class SemaphoreExample {
        public static void main(String[] args) {
            PoolResource poolResource = new PoolResource(3); // Only 3 threads can use the resource at once
    
            for (int i = 0; i < 10; i++) {
                new Thread(() -> poolResource.useResource()).start();
            }
        }
    }

    위의 예제에서 PoolResource 클래스는 제한된 허용량 (3개의 스레드)으로 세마포어를 사용하여 동시 접근을 제어합니다. 따라서 한 번에 3개의 스레드만이 자원을 사용할 수 있으며, 다른 스레드는 자원이 해제될 때까지 대기합니다.

     

    CountDownLatch와 CyclicBarrier

    CountDownLatch

    CountDownLatch는 스레드가 다른 스레드가 하나 이상의 작업을 완료할 때까지 기다리게 하기 위해 사용되는 동시성 유틸리티입니다. 이는 주로 한 스레드가 다른 스레드의 여러 작업이 완료되길 기다리는 시나리오에서 유용합니다.

     

    특징

    • 초기 카운트가 제공되며, 이 카운트는 countDown() 메서드 호출에 의해 감소합니다.
    • await() 메서드는 카운트가 0이 될 때까지 현재 스레드를 차단합니다.

     

    예제

    import java.util.concurrent.CountDownLatch;
    
    public class CountDownLatchExample {
        private static final int NUMBER_OF_TASKS = 5;
        private static final CountDownLatch latch = new CountDownLatch(NUMBER_OF_TASKS);
    
        public static void main(String[] args) throws InterruptedException {
            for (int i = 0; i < NUMBER_OF_TASKS; i++) {
                new Thread(new Task()).start();
            }
            latch.await(); // Main thread will wait until all tasks are finished
            System.out.println("All tasks are finished!");
        }
    
        static class Task implements Runnable {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + " finished.");
                latch.countDown(); // Decrease the count
            }
        }
    }

     

    CyclicBarrier

    CyclicBarrier는 여러 스레드가 서로 기다리도록 하여 공통의 장벽에 도달할 때까지 모든 스레드가 차단되게 만드는 동시성 유틸리티입니다. CountDownLatch와는 다르게, CyclicBarrier는 재사용 가능하며, 모든 스레드가 장벽에 도달하면 옵션으로 주어진 동작을 실행할 수 있습니다.

     

    특징

    • 스레드는 await() 메서드를 사용하여 장벽에 도달합니다.
    • 모든 스레드가 await()을 호출하면 장벽은 깨지며 스레드가 계속 실행됩니다.

     

    예제

    import java.util.concurrent.BrokenBarrierException;
    import java.util.concurrent.CyclicBarrier;
    
    public class CyclicBarrierExample {
        private static final int NUMBER_OF_TASKS = 5;
        private static final CyclicBarrier barrier = new CyclicBarrier(NUMBER_OF_TASKS, () -> {
            // This action will be executed once when all threads reach the barrier
            System.out.println("All tasks reached the barrier!");
        });
    
        public static void main(String[] args) {
            for (int i = 0; i < NUMBER_OF_TASKS; i++) {
                new Thread(new Task()).start();
            }
        }
    
        static class Task implements Runnable {
            @Override
            public void run() {
                try {
                    System.out.println(Thread.currentThread().getName() + " is waiting at the barrier.");
                    barrier.await(); // Waiting for all tasks to reach the barrier
                    System.out.println(Thread.currentThread().getName() + " continued.");
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    이 예제에서 CyclicBarrier는 모든 스레드가 장벽에 도달할 때까지 각 스레드를 차단합니다. 모든 스레드가 장벽에 도달하면 메시지가 출력되고 스레드는 계속 실행됩니다.

     

    댓글