ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [2024_동계_모각코] 3회차(01/17)
    [CNU] Mogakco 2024. 2. 18. 23:08

    3회차 목표

    Synchronized

    Java Stream

    Garbage Collection


     

    1. Synchronized 키워드

    멀티스레드 환경에서 공유 데이터에 대한 동기화를 제공하는 방법 중 하나.

    멀티스레드 환경에서 여러 스레드가 공유 데이터에 접근할 때, 동시에 데이터를 수정하면 예기치 않은 결과가 발생할 수 있음.

    이런 문제를 해결하기 위해 스레드 간의 동기화가 필요하며, 이를 위해 synchronized 키워드를 사용한다.

    synchronized 키워드를 사용하면 두 가지 주요 목적을 달성할 수 있음.

     

    메소드 동기화: 메소드 선언부에 synchronized 키워드를 추가하여 해당 메소드의 모든 코드 블록에 대한 동기화를 제공. 이렇게 하면 여러 스레드가 해당 메소드를 호출할 때 동시에 접근하지 않고, 순차적으로 실행될 수 있다.

    public synchronized void synchronizedMethod() {
        // Critical section
        // 이 메소드 내의 코드는 한 번에 하나의 스레드만 실행.
    }
    

     

    블록 동기화: 특정 코드 블록에 대해 synchronized 키워드를 사용하여 해당 블록에 대한 동기화를 제공할 수도 있다. 이 방법은 메소드 전체가 아닌 특정 부분만 동기화 해야 할 때 유용.

    public void someMethod() {
        // Non-critical section
        // 동기화가 필요하지 않은 코드
    
        synchronized (this) {
            // Critical section
            // 동기화가 필요한 코드
        }
    
        // Non-critical section
        // 동기화가 필요하지 않은 코드
    }
    

    정리하자면,

    synchronized 키워드를 사용하면 잠재적으로 발생할 수 있는 경쟁 상태나 데이터 불일치 문제를 막을 수 있지만, 락을 얻고 해제하는 overhead 가 있으므로 필요한 부분만 동기화하고 과도하게 사용하지 않는 것이 중요.

     

    또한 자바 5부터는 java.util.concurrent 패키지에 있는 더 효율적인 동기화 기능들을 사용하는 것이 권장된다고 함.

    예를 들어 ReentrantLock과 ReadWriteLock 등을 활용하여 세밀한 컨트롤이 가능한 동기화를 구현할 수 있음.

     

    키워드 사용 목적

    1. 스레드 안전성을 보장:

    synchronized를 사용하면 여러 스레드 간의 데이터 경합(데이터 불일치) 문제를 방지할 수 있다.

    이를 통해 스레드 안전성을 보장하여 동시에 여러 스레드가 접근해도 데이터의 일관성을 유지할 수 있음.

    이어서 단점)

    2. 동기화 오버헤드 발생:

    락을 획득하고 해제하는 과정에 오버헤드가 발생, 이로 인해 성능 저하가 발생할 수 있음.

    특히, 여러 스레드가 동시에 접근하는 경우 락을 얻기 위해 대기해야 하므로 처리 속도가 저하될 수 있다.

    **대안 존재: synchronized가 블록 또는 메소드에 대한 기본적인 동기화 메커니즘으로 유용하지만, 자바 5부터는 java.util.concurrent 패키지에서 더 효율적인 동시성 유틸리티들을 제공.

    ReentrantLock, ReadWriteLock, ConcurrentHashMap 등을 활용하면 더 세밀한 동기화가 가능하고 성능도 향상될 수 있음.

     

    ReentrantLock:

    ReentrantLock은 synchronized와 비슷한 동기화 메커니즘을 제공하는데, 더 세밀한 컨트롤이 가능. ReentrantLock은 락을 획득하고 해제하는데 명시적인 방법(lock 객체 생성)을 사용하며, 락을 획득할 수 없을 때 대기하는 기능도 제공함.

    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    Lock lock = new ReentrantLock();
    
    public void someMethod() {
        lock.lock();
        try {
            // 동기화가 필요한 코드
        } finally {
            lock.unlock();
        }
    }
    

     

    ReadWriteLock:

    ReadWriteLock은 읽기와 쓰기 동작에 대한 동기화를 구분하여 제공.

    읽기 연산은 여러 스레드가 동시에 수행할 수 있지만, 쓰기 연산은 단 하나의 스레드만 허용한다.

    따라서 읽기 연산이 빈번하고 쓰기 연산이 상대적으로 적은 경우에 ReadWriteLock을 사용하면 성능을 향상시킬 수 있다.

    import java.util.concurrent.locks.ReadWriteLock;
    import java.util.concurrent.locks.ReentrantReadWriteLock;
    
    ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    
    public void readMethod() {
        readWriteLock.readLock().lock();
        try {
            // 읽기 동기화가 필요한 코드
        } finally {
            readWriteLock.readLock().unlock();
        }
    }
    
    public void writeMethod() {
        readWriteLock.writeLock().lock();
        try {
            // 쓰기 동기화가 필요한 코드
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }
    

     

    Semaphore:

    Semaphore은 특정 리소스에 대한 접근을 제한하는데 사용되는 동기화 기법입.

    Semaphore은 정해진 개수의 허용 스레드 수를 가지고 있으며, 허용 스레드가 더 이상 없는 경우 대기하도록 하여 주로 리소스 풀 관리 등에 사용된다고 함.

    import java.util.concurrent.Semaphore;
    
    Semaphore semaphore = new Semaphore(5); // 5개의 허용 스레드 수
    
    public void someMethod() {
        try {
            semaphore.acquire(); // 락 획득, 허용 스레드가 없는 경우 대기
            // 동기화가 필요한 코드
        } catch (InterruptedException e) {
            // 예외 처리
        } finally {
            semaphore.release(); // 락 해제
        }
    }
    

     

    CountDownLatch:

    CountDownLatch는 특정 스레드가 특정 작업을 완료하기 전에 다른 스레드가 기다리도록 할 때 사용된다.

    특정 횟수의 작업 완료 시까지 대기하다가 모든 작업이 완료되면 스레드가 실행되도록 함.

    import java.util.concurrent.CountDownLatch;
    
    CountDownLatch latch = new CountDownLatch(3); // 3개의 작업 완료 대기
    
    public void someMethod() {
        try {
            // 동기화가 필요한 코드
        } finally {
            latch.countDown(); // 작업이 완료될 때마다 카운트 다운
        }
    }
    
    // 메인 스레드에서 작업 완료 대기
    try {
        latch.await();
        // 모든 작업이 완료되면 이 부분이 실행됨
    } catch (InterruptedException e) {
        // 예외 처리
    }
    

     

    정리하자면,

    synchronized는 단순한 동기화에 유용하며, 더 세밀한 동기화가 필요한 경우에는 대안을 찾아보는 것이 좋다.

    Thread Local은 자바에서 스레드 간에 데이터를 공유하지 않고, 각 스레드 별로 독립적으로 유지하고자 할 때 사용하는 기능.

    즉, 동일한 데이터를 여러 스레드가 공유하지 않고 스레드마다 독립적으로 가지도록 하는 메커니즘이다.

    스레드 간에 공유되는 전역 변수나 인스턴스 변수를 사용하면 스레드 안전성을 위해 위와 같은 방법으로 동기화를 해야하고, 동기화를 하면 성능 저하가 발생할 수 있다.

    이런 상황에서 Thread Local을 사용하면 각 스레드 별로 자신만의 독립적인 변수를 사용할 수 있으며, 스레드 안전성을 보장하면서 동기화 오버헤드를 피할 수 있다.

     

     

    ThreadLocal 객체 생성:

    ThreadLocal<String> threadLocalVariable = new ThreadLocal<>();
    

     

     

    데이터 설정 (각 스레드에서 독립적으로 값을 설정):

    threadLocalVariable.set("Value for 7번 문제");
    

     

    데이터 조회 (각 스레드에서 독립적으로 값을 조회):

    String value = threadLocalVariable.get();
    

     

     

    데이터 삭제 (데이터 사용이 끝난 후, 필요에 따라 데이터를 삭제):

    threadLocalVariable.remove();
    

     

    예를 들어, 웹 애플리케이션에서 사용자 인증 정보를 Thread Local을 이용해 저장하면, 각각의 요청을 처리하는 스레드가 독립적으로 사용자 인증 정보를 가지고 처리할 수 있음.

    이렇게 되면 스레드 간의 정보 누수나 인증 정보가 다른 스레드와 공유되는 상황을 방지할 수 있다.

     

    주의할 점으로는 사용 후에 데이터를 명시적으로 삭제해야 함.

    또한, 과도한 사용은 메모리 누수로 이어질 수 있으므로, 적절하게 사용하는 것이 중요.


    2. Java Stream

    자바 8에서 소개된 기능으로, 컬렉션(List, Map …)의 요소들을 처리하고 조작하는 기능을 제공하는 API.

    Stream은 데이터의 흐름을 나타내며, 간결한 함수형 프로그래밍 스타일을 지원하여 컬렉션 처리를 더욱 쉽고 간결하게 만들어준다.

    기존의 컬렉션 처리 방식은 주로 반복문을 사용하는 것이었는데, Stream은 이를 대체하고 데이터의 흐름을 다루는데 적합한 선언적인 방식으로 작성할 수 있도록 해준다.

    → 코드를 더욱 간결하고 가독성 있게 만들 수 있다.

     

    Stream의 특징과 동작 방식

    1. 중간 연산과 최종 연산:중간 연산은 Stream을 반환하며, 연속적으로 체인을 이루어 연산을 수행.
    2. 최종 연산은 Stream을 닫고, 실제 연산이 수행되는 단계.
    3. Stream은 중간 연산과 최종 연산으로 구분된다.
    4. 지연 실행:최종 연산이 호출될 때까지 중간 연산은 실제로 수행되지 않는다. 이를 통해 불필요한 연산을 피할 수 있음.
    5. 지연 실행(lazy evaluation)을 지원.
    6. 병렬 처리:데이터가 충분히 크고 병렬 처리가 가능한 경우, Stream API를 사용하여 멀티코어 프로세서의 성능을 최대한 활용할 수 있다.
    7. Stream은 병렬 처리를 자동으로 지원함.

    병렬 처리의 특징

    병렬 처리는 멀티코어 프로세서를 활용하여 작업을 여러 개의 스레드로 나누어 동시에 처리하는 것을 의미한다.

    이로 인해 대용량 데이터를 더 빠르게 처리할 수 있음.

    Stream은 병렬 처리를 사용하기 위해 parallel() 메서드를 제공.

    parallel() 메서드를 호출하면 스트림의 데이터 처리가 병렬적으로 수행되고 성능이 향상된다.

     

    import java.util.Arrays;
    import java.util.List;
    
    public class ParallelStreamExample {
        public static void main(String[] args) {
            List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    
            // 일반 Stream 사용
            int sumOfSquares = numbers.stream()
                    .map(n -> n * n) // 각 요소를 제곱
                    .reduce(0, Integer::sum); // 합계 구하기
    
            System.out.println("일반 스트림 사용: " + sumOfSquares);
    // -------------------------------------------------------------------------------------------------------------------------------------------------------------
    
            // 병렬 Stream 사용
            int sumOfSquaresParallel = numbers.parallelStream()
                    .map(n -> n * n) // 각 요소를 제곱 (병렬 처리)
                    .reduce(0, Integer::sum); // 합계 구하기
    
            System.out.println("병렬 스트림 사용: " + sumOfSquaresParallel);
        }
    }
    

     병렬 처리를 사용할 때의 주의점

    1. 데이터가 적다면 오히려 병렬 처리 오버헤드 때문에 성능을 저하시킬 수 있으므로, 대용량 데이터에 사용하는 것이 적합.
    2. 병렬 처리를 수행할 때 스레드 안전성을 유지해야 함.
    3. 즉, 공유 자원에 접근할 때 적절한 동기화를 고려해야 한다.
    4. 병렬 처리는 각 요소의 순서에 영향을 주지 않으므로, 순서가 중요한 작업에는 주의해야 함.

    Stream의 일반적인 사용 예시 

    import java.util.Arrays;
    import java.util.List;
    
    public class StreamExample {
        public static void main(String[] args) {
            List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    
            int sumOfEvenNumbers = numbers.stream()
                    .filter(n -> n % 2 == 0)  // 중간 연산: 짝수만 필터링
                    .mapToInt(Integer::intValue)  // 중간 연산: Integer를 int로 변환
                    .sum();  // 최종 연산: 합을 계산
    
            System.out.println("Sum of even numbers: " + sumOfEvenNumbers);
        }
    }
    

    코드 설명) numbers 리스트 → Stream으로 변환한 후 → 중간 연산인 filter와 mapToInt를 이용해 필요한 처리를 수행하고 → 최종 연산인 sum()을 호출하여 짝수의 합을 계산.

     

    일반적으로,

    Stream은 코드를 간결하고 가독성 있게 만들어주지만, 반복문에 비해 일부 상황에서는 성능이 더 낮다.

     

    1. Stream API를 사용하면 중간 연산과 최종 연산에 대해 많은 객체 생성과 메서드 호출이 발생.
    2. 지연 실행:(실제로 호출되는 시점에서 중간연산을 진행하기 때문에 더 이득일 것 같았지만) 만약, 반복되는 코드로 인해 여러 번 같은 연산이 호출되는 상황이라면 반복문보다 성능이 떨어질 수 있다.
    3. 중간 연산이 최종 연산이 호출될 때까지 실제로 수행되지 않음.
    4. 병렬 처리 오버헤드:하지만 대부분의 경우 병렬 처리가 필요한 대용량 데이터를 다루는 상황에서는 Stream의 성능이 훨씬 우수.
    5. 병렬 처리를 위한 추가적인 오버헤드가 발생할 수 있음.

    3. Garbage Collection (GC)

    자바의 Garbage Collection (GC)는 자동 메모리 관리 기법 중 하나로, 개발자가 명시적으로 메모리를 할당하거나 해제하는 대신,

    JVM(Java Virtual Machine)이 자동으로 더 이상 사용되지 않는 객체를 감지하고 자동으로 해당 메모리를 회수. 이를 통해 개발자는 메모리 관리에 대한 부담을 덜 수 있으며, 메모리 누수와 같은 일반적인 프로그래밍 실수를 최소화할 수 있다.

     

    GC의 주요 기능과 특징

    • 참조 카운팅(Reference Counting)하지만 자바는 실제로 이 기법을 사용하지 않는다. 왜냐하면 순환 참조 같은 문제로 인해 객체가 실제로 참조되지 않지만 참조 카운트가 0이 되지 않는 상황이 발생할 수 있기 때문.
    • 이는 객체가 참조되는 횟수를 계산하는 기법으로, 참조되는 횟수가 0이 되면 해당 객체는 더 이상 사용되지 않는 것으로 간주하고 메모리를 해제한다.
    • Reachability (도달 가능성)이는 어떤 객체가 루트(root)에서부터 접근 가능한지를 판단하여 살아있는 객체와 쓸모 없어진 객체를 구분한다.
    • 루트는 주로 스레드의 스택 프레임, 정적(static) 변수, JNI(Java Native Interface) 등을 포함한다.
    • 자바의 GC는 도달 가능성(reachability) 기반으로 동작한다.
    • Mark and Sweep (마킹 & 스윕 알고리즘)
    • 가장 일반적인 GC 알고리즘으로, 루트에서부터 접근 가능한 객체들을 모두 마킹(mark)한 다음, 마킹되지 않은 객체들은 쓸모 없는 것으로 판단하고 메모리를 해제(sweep) 하는 방식이다.
    • Generational GC (세대 기반 알고리즘) → (수업 시간에 쌤이 잠깐 알려주신 알고리즘)새로운 객체들을 Eden 영역에 할당하고, 오래된 객체들을 더 오래 살아남은 영역(Old 영역)에 할당하는 방식.
    • 대부분의 객체는 금방 쓸모 없어지기 때문에 새로운 객체들이 많이 수집되는 반면, 오래된 객체들은 잘 유지된다.
    • 대부분의 자바 GC는 세대(generation) 기반의 알고리즘을 사용.
    • Stop-the-world (일시적 중지)이러한 중지 시간을 최소화하기 위해 GC 알고리즘들은 계속 개선되고 있다.
    • 일반적인 GC 실행 도중에는 GC를 위해 JVM의 모든 스레드가 일시적으로 중지되는 것을 뜻함.

     

    Java에서 finalize() 메서드는 객체가 GC에 의해 수집되기 전에 자동으로 호출되는 메서드이다.

    이 메서드를 오버라이딩하여 객체가 소멸되기 전에 정리 작업을 수행할 수는 있는데, finalize() 메서드를 수동으로 호출하는 것은 일반적으로 문제가 될 수 있고 권장되지 않는다.

    왜?

    1. finalize() 메서드를 수동으로 호출한다는 것은 명시적으로 GC를 실행하려는 시도를 의미하는데,그렇기 때문에 System.gc() 또는 Runtime.getRuntime().gc()와 같은 메서드를 사용해서 GC를 요청해도 실제로 GC가 실행될지는 보장되지 않는다.
    2. Java에서는 공식적으로 개발자가 직접 GC를 호출하는 것을 지원하지 않는다. (GC는 JVM에 의해 자동으로 관리되는 것)
    3. JVM 의 GC 알고리즘 방해 가능성
    4. JVM은 자체적으로 적절한 시기에 GC를 수행하고, 일반적으로는 적절한 메모리 압력이나 상황이 발생할 때에만 GC를 실행하는데, 수동적 GC 호출은 JVM의 GC 알고리즘을 방해하고 성능 저하를 초래할 수 있다.
    5. finalize()의 불확실성대신 try-with-resources 문을 사용하여 자원을 명시적으로 정리하거나 AutoCloseable 인터페이스를 구현하여 자원을 관리하는 것이 더 안전하고 예측 가능한 대체 방법이다.
    6. finalize() 메서드의 호출 시점은 보장되지 않기 때문에 GC에 의해 수동으로 호출하는 객체가 아직 수집되지 않았을 수도 있다.
    7. 성능 저하: finalize() 메서드를 오버라이딩하고 수동 호출하면서 객체를 GC의 관리 대상에서 제외하도록 처리하려고 할 수 있다. 이러한 접근 방식은 오히려 메모리 누수를 유발할 수 있으며, 더 많은 메모리 사용과 GC의 빈번한 실행으로 성능 저하를 초래

     


    3회차 회고록

    3회차는 저학년 때 수강한 컴퓨터프로그래밍 과목이 생각나는 시간이었다. 가장 기억에 남는 건 synchronized 키워드인데, 멀티스레드 환경에서 공유 자원에 대한 동시 접근을 제어하는 방법의 중요성을 이해할 수 있었다. 메소드와 블록 수준에서 동기화를 어떻게 적용할 수 있는지 직접 보고, 동기화가 성능에 어떤 영향을 미칠 수 있는지 배울 수 있어서 좋았다. Stream 세션에서는 데이터 컬렉션을 더욱 효율적으로 처리하는 방법을 배울 수 있어서 좋았고, GC는 메모리 누수와 같은 문제를 방지하고 효율적인 애플리케이션을 개발하는 방법을 배울 수 있어서 좋았다.

     

    댓글

Designed by Tistory.