개발 블로그
article thumbnail
Published 2023. 4. 12. 14:52
스레드 동기화(synchronized) Java

이번에는 Java에서 스레드 동기화를 할 때 사용하는 synchronized 에 대해서 알아보려 합니다.

 

 

동시성 문제와 동기화

 

자바에서 여러 스레드가 공유 자원에 동시에 접근하게 되면 의도와 다르게 잘못된 결과가 나올 수 있는데 이를 동시성 문제라고 하고 이를 해결하는 방법을 동기화라고 합니다.

 

여러 스레드가 공유 자원에 동시에 접근할 경우 어떤 일이 발생하는지 예제를 통해 살펴보겠습니다.

class Temp {
    public void methodA() {
        System.out.println(Thread.currentThread.getName() + " lock this method");
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread.getName() + " unlock this method");
    }
}

public class Main {
    public static void main(String[] args) {
        Temp temp = new Temp();
        
        Runnable task = () -> {
            temp.methodA();
        };
        
        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

methodA() 는 단순하게 ~~ lock this method 와  ~~ unlock this method 라는 문자열을 출력해주는 메서드입니다.

우리가 원하는 것은 lock -> unlock -> lock -> unlock 순으로 결과가 나오는 것입니다.

 

이 메서드에 동기화 처리를 하지 않았을 때는 실행 결과가 어떻게 될까요?

 

동기화 안 했을 때의 결과

우리가 원하는 대로 결과가 나오게 하고 싶다면 thread1이 methodA() 를 다 완료하고 종료시킬 때까지 다른 스레드는 접근할 수 없도록 해야합니다. 이렇게 하면 동기화가 되는 것이며 Java 에서는 'synchronized'를 통해서 가능합니다.

 

synchronized 로 선언된 영역은 '임계 영역'(통제가 필요한 영역)이라고 불리며 이 영역에 접근하려면 스레드에서 lock(접근 권한) 을 가지고 있어야 합니다. 그리고 하나의 임계 영역에 대한 lock 은 한 개밖에 존재하지 않기에 임의의 스레드가 lock 을 가지고 임계 영역에 들어가 있다면 다른 스레드들은 해당 스레드가 lock 을 반납해줄 때까지 기다려야 하는 것입니다.

 

특정 스레드가 lock 을 소유하는 것은 임계 영역에 들어가려 할 때 이루어집니다.(단, 다른 스레드가 lock 을 아직 반납하지 않은 상태라면 대기하게 됩니다)

특정 스레드가 lock 을 반납하는 것은 임계 영역을 탈출할 때 이루어집니다.

 

 

Synchronized

Java 에서 synchronized 를 이용해 동기화하는 방법에는 2가지가 있습니다.

 

- synchronized method

- synchronized block

 

그리고 인스턴스 멤버를 임계 영역으로 설정하면 인스턴스 단위로 lock 을 공유하고, 클래스 멤버(static)를 임계 영역으로 설정하면 클래스 단위로 lock 을 공유하게 됩니다. 이는 아래에서 예시를 통해 보다 자세히 설명하겠습니다.

 

synchronized method

synchronized method는 메서드 전체 영역을 임계 영역으로 설정하는 방법입니다.

public synchronized methodA() {
    앞선 코드와 동일
}

 

Temp 클래스의 methodA() 를 synchronized method 로 만들고 다시 실행을 해보면 메서드 자체가 임계 영역으로 설정되어 우리가 원했던 대로 lock -> unlock -> lock -> unlock 의 결과를 얻을 수 있습니다.

 

앞서 인스턴스 멤버를 임계 영역으로 설정하면 인스턴스 단위로 lock 을 공유한다고 했는데 두 개의 인스턴스를 만들고 두 개의 스레드에서 각각의 인스턴스가 가진 methodA() 를 호출하도록 해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Temp temp1 = new Temp();
        Temp temp2 = new Temp();
        
        Runnable task1 = () -> {
            temp1.methodA();
        };
        
        Runnable task2 = () -> {
            temp2.methodA();
        };
        
        Thread thread1 = new Thread(task1, "thread1");
        Thread thread2 = new Thread(task2, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

lock 이 인스턴스 단위로 공유되고 두 개의 스레드는 서로 다른 인스턴스를 통해 methodA()를 호출하고 있기 때문에

lock -> lock -> unlock -> unlock 의 결과가 나올 것입니다.

 

그렇다면 하나의 인스턴스에 두 개의 synchronized method 를 만들고 접근을 시도하면 어떻게 될까요?

 

인스턴스 멤버를 임계영역으로 설정하면 인스턴스 단위로 lock 을 공유한다는 관점에서 보면 이 lock 은 해당 인스턴스 내의 모든 임계영역에 접근 가능한 유일한 lock 입니다.

 

따라서 서로 다른 두 개의 synchronized method 라고 하더라도 여기에 접근하기 위한 lock 은 유일무이 하기에 하나의 스레드가 먼저 작업을 마치고 lock 을 반환한 후에야 다른 스레드가 작업을 할 수 있을 거라고 예상이 가능합니다.

 

실제로 코드를 작성해서 실행해보면

public synchronized methodB() {
    System.out.println("different synchronized method");
}
public class Main {
    public static void main(String[] args) {
        Temp temp = new Temp();
        
        Runnable task1 = () -> {
            temp.methodA();
        };
        
        Runnable task2 = () -> {
            temp.methodB();
        };
        
        Thread thread1 = new Thread(task1, "thread1");
        Thread thread2 = new Thread(task2, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

두 개의 서로 다른 synchronized method 이지만 이 영역들에 접근하는 lock 이 동일하고 유일하기 때문에 thread1 이 작업을 다 끝마친 다음에야 thread2 가 methodB()를 호출하여 결과를 출력할 수 있는 것입니다.

 

methodB()는 임계영역으로 설정하지 않는다면 methodB()의 출력 결과가 1번째 아니면 2번째 줄에 나오겠죠?

 

 

synchronized block

앞선 방법이 메서드 영역 전체를 임계 영역으로 설정하는 방법이었다면 synchronized block 은 { } 처리한 부분만 임계영역으로 설정하는 방법입니다.

 

synchronized method 방법은 lock이 설정되는 객체가 반드시 해당 메서드를 구현해놓은 클래스로 만든 객체였다면synchronized block 을 이용하면 lock 을 설정할 객체를 지정해줄 수도 있습니다.

 

다만 이 글에서는 lock 을 설정할 객체는 this 로 해서 { } 처리된 부분만 임계영역으로 설정되는 내용에 대해서 다루겠습니다.

 

class Temp {
    public void methodA() {
    	System.out.println("아직 임계영역 전입니다.");
        try {
            Thread.sleep(1000);
        } catch (InterrupteException e) {
            e.printStackTrace();
        }
        synchronized (this) {
            System.out.println(Thread.currentThread().getName() + " lock this block");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread.getName() + " unlock this block");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Temp temp = new Temp();
        
        Runnable task = () -> {
            temp.methodA();
        };
        
        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

 

임계영역으로 설정된 블록 { } 전 까지는 thread1과 thread2 가 모두 동시 접근이 가능합니다.

 

두 스레드가 모두 '아직 임계영역 전입니다.'를 출력하고 둘 중 임의의 스레드가 임계 영역에 진입하게 되면 그 후로는

lock -> unlock -> lock -> unlock의 결과가 나오게 됩니다.

 

지금 우리가 설정한 블록은 인스턴스 메서드 안에 위치하고 있기 때문에 역시나 인스턴스 단위로 lock 을 공유하게 됩니다.

 

 

static synchronized

지금까지 다뤘던 내용들은 모두 인스턴스 메서드에 대해서 임계 영역을 설정하는 것이었습니다.

그리고 인스턴스 메서드나 그 내부 일부 블록을 임계 영역으로 설정하면 인스턴스 단위로 lock 을 공유한다고 했습니다.

 

static 메서드 또는 static 메서드 내부 일부 블록을 임계 영역으로 설정하면 클래스 단위로 lock 을 공유하게 됩니다.

 

즉 서로 다른 인스턴스를 만들고, 두 스레드에서 이 인스턴스들을 통해 static synchronized 된 영역에 접근하려고 하면 임의의 스레드가 lock 을 획득해서 작업을 하고 lock 을 반납하기 전까지 다른 스레드는 기다려야 하는 것입니다.

(static 이 아닌 곳에서는 인스턴스 두 개 만들면 굳이 다른 스레드가 기다리지 않았습니다.)

 

class Temp {
    public static synchronized void methodA() {
        System.out.println(Thread.currentThread.getName() + " lock this method");
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread.getName() + " unlock this method");
    }
}

public class Main {
    public static void main(String[] args) {
        Temp temp1 = new Temp();
        Temp tmpe2 = new Temp();
        
        Runnable task1 = () -> {
            temp1.methodA();
        };
        
        Runnable task2 = () -> {
            temp2.methodA();
        };
        
        Thread thread1 = new Thread(task1, "thread1");
        Thread thread2 = new Thread(task2, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

 

정리하자면 static synchronized 는 해당 임계 영역에 대해서 클래스 단위로 lock 을 공유하므로 여러 개의 서로 다른 인스턴스를 이용하여 여러 스레드에서 접근하려고 하더라도 반드시 다른 스레드가 lock 을 반납하기를 기다려야 하고,

 

non static synchronized 는 해당 임계 영역에 대해서 인스턴스 단위로 lock 을 공유하므로 여러 개의 서로 다른 인스턴스를 이용하여 여러 스레드에서 접근 시 각자 lock 을 획득하기에 다른 스레드를 기다릴 필요가 없는 것입니다.

 

 

다만 주의할 점은 static synchronized 가 클래스 단위로 lock 을 공유한다는 것이 클래스 내부의 모든 임계 영역에 대해서 하나의 lock 이 존재하는 것은 아니라는 점입니다.

 

클래스 내부에 static 으로 설정된 임계 영역에 대해서 클래스 단위로 lock 이 하나 존재하는 것입니다.

 

앞선 코드에 non static synchronized 메서드를 추가해서 동작시켜보면 쉽게 이해가 가능합니다.

public synchronized void methodB() {
    System.out.println("different synchronized method");
}
public class Main {
    public static void main(String[] args) {
        Temp temp = new Temp();
        
        Runnable task1 = () -> {
            temp.methodA();
        };
        
        Runnable task2 = () -> {
            temp.methodB();
        };
        
        Thread thread1 = new Thread(task1, "thread1");
        Thread thread2 = new Thread(task2, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

thread1 이 static synchronized 영역에 대한 lock 을 획득했음에도 thread2 가 non static synchronized 영역에 대한 lock 을 얻어 different synchronized method 가 출력된 것을 볼 수 있습니다.

 

즉, static 임계 영역에 대한 lock 과 non static 임계 영역에 대한 lock 은 서로 별개인 것입니다.

'Java' 카테고리의 다른 글

산술 변환  (0) 2023.09.19
비트 연산자(<<, >>, &, |, ^, ~)  (0) 2023.09.19
상속  (0) 2023.04.07
접근제어자와 Getter & Setter  (0) 2023.04.06
클래스, 객체, 인스턴스  (0) 2023.04.06
profile

개발 블로그

@하얀.손

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!