자바 synchronized

soluinoon·2023년 8월 17일
0
post-thumbnail

🎼 멀티 쓰레드란?
프로세스 내부에서 실행 단위인 쓰레드를 여러개로 나눠 여러 개의 작업을 동시에 처리하는 것

자바의 멀티 쓰레드

synchronized에 대해 공부하기 전에 간단하게 쓰레드에 대해 알아보겠습니다.

자바에서는 쓰레드를 Thread 클래스를 직접 인스턴스화해서 사용하거나, 상속해서 하위 클래스를 만들어 생성할 수도 있습니다.

Thread 클래스에 들어가보면 사진처럼 Runnable 객체를 받거나 다양한 인자를 받을 수 있는 여러개의 생성자가 오버로딩 되어있습니다.

Name만 받는 생성자는 targetnull인 것에 주목해주세요.

모든 생성자는 this를 통해 사진의 생성자를 호출합니다.


이제 쓰레드를 start() 해주면 쓰레드가 시작됩니다.
여기서 start0()는 네이티브 메서드로, 쓰레드를 스케쥴러에 포함시키고 Runnable로 대기하다 수행 차례가 되면 run()으로 시작합니다.


앞서 생성자로 targetnull이라면

class Task implements Runnable{
    private int number;

    public Task(int number) {
        this.number = number;
    }

    @Override
    public void run() {
        System.out.println(number + "번 쓰레드 입니다!!");
    }
}

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread();
        Thread threadWithRunnable = new Thread(new Task(1));
        Thread threadWithName = new Thread("쓰레드 입니다.");

        System.out.println(thread.getName()); // Thread-0
        System.out.println(threadWithRunnable.getName()); // Thread-1
        System.out.println(threadWithName.getName()); // 쓰레드 입니다.

        thread.run();
        threadWithRunnable.run(); // 1번 쓰레드 입니다!!
        threadWithName.run();
    }
}

---------------------
Thread-0
Thread-1
쓰레드 입니다.
1번 쓰레드 입니다!!

다음의 코드를 실행시켜봅니다.
thread는 이름, Runnable 객체를 주지 않았기 때문에, getName()에서 자동으로 할당된 이름인 'Thread-'접미사에 생성 번호인 0이 붙어서 출력됐고, run()Runnablenull이기 때문에 아무것도 실행되지 않았습니다.

threadWithRunnableRunnable을 상속한 Task라는 클래스를 인자로 받기 때문에 Task안에 있는 run이 실행됐습니다.

synchronized는 왜 쓰일까?

위에서 공부한 쓰레드를 생각해보며 다음의 코드를 확인해봅시다.

public class Singleton {
    private static Singleton singleton;
    int number = 0;
    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }

    public void increase(String thread) {
        int temp = number;
        System.out.println(thread + ": before = " + temp);
        temp = temp + 1;
        this.number = temp;
        System.out.println(thread + ": after = " + number);
    }

    public int getNumber() {
        return number;
    }
}

간단한 싱글톤 입니다. increase() 호출시 number를 1 증가시킵니다.

class Task implements Runnable{
    private String number; // 몇번 쓰레드인지
    private Singleton singleton;
    
    public Task(String number) {
        this.number = number;
        this.singleton = Singleton.getInstance();
    }


    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            singleton.increase(number);
        }
    }
}

그리고 TaskSingleton을 멤버로 갖고 있습니다. 당연히 싱글톤이니 모든 Task는 같은 Singleton을 다루겠죠?
실행시 increase()를 1000번 호출합니다.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Singleton singleton = Singleton.getInstance();
        Thread threadWithRunnable1 = new Thread(new Task("t1"));
        Thread threadWithRunnable2 = new Thread(new Task("t2"));
        Thread threadWithRunnable3 = new Thread(new Task("t3"));

        threadWithRunnable1.start();
        threadWithRunnable2.start();
        threadWithRunnable3.start();

        sleep(1000);

        System.out.println(singleton.getNumber());
    }
}

그 다음 메인에서 쓰레드를 3개 만들고 실행시킵니다. 예상은 각각의 쓰레드마다 increase를 1000번 호출하므로, 모든 과정이 끝나면 3000이 되야합니다.

하지만 결과는 실행마다 달라지지만, 3000이 나오지 않습니다.



실제로 734라는 중복된 값이 확인됩니다. 무슨 이유일까요?

Race Condition


그 이유는 그림과 같습니다. 특정 쓰레드가 increase()의 결과를 반영하기 전에 number의 값을 다른 쓰레드에서 읽었기 때문입니다.

우리는 이처럼 공유자원의 접근 순서에 따라 문제가 발생하는 것을 Race Condition이라 합니다.

해결

원자적 연산으로 해결할 수도 있지만, 다른 방법을 생각해봅시다.
단순하게 동시에 하나의 쓰레드만 함수를 사용할 수 있도록 하면 됩니다.
increasesynchronized를 추가해봅시다.

public synchronized void increase(String thread) {
    int temp = number;
    System.out.println(thread + ": before = " + temp);
    temp = temp + 1;
    this.number = temp;
}


저희가 의도한 3000이 정상적으로 출력됩니다.

synchronized

synchronized는 여러 쓰레드의 동시접근을 막는 키워드 입니다.
메서드나 코드 블럭으로 사용될 수 있습니다.

같은 객체 내에서만 동기화를 보장한다

위의 예제에서 TaskSingleton, 즉 같은 객체를 공유하면서 사용했고, 같은 객체의 synchronized 메서드를 사용했습니다.
하지만 increase()synchronized를 제거하고 Task에서 synchronized를 붙이면 어떻게 될까요?

class Task implements Runnable {

    private String number;
    private Singleton singleton;
    
    public Task(String number) {
        this.number = number;
        this.singleton = Singleton.getInstance();
    }


    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            increase();
        }
    }
	
    private synchronized void increase() {
        singleton.increase(number);
    }




같은 숫자가 반복됐고, 3000이 아닌 1000에서 끝났습니다.
이것으로 저희는 다른 객체에서 사용하는 synchronized는 동기화를 보장해주지 않는다는 것을 알 수 있습니다.

더 자세한 Lock 살펴보기

syncronized는 lock을 걸어서 메서드나 코드 블럭을 동시에 접근하는 것을 막습니다.
간단한 예제를 살펴보겠습니다.

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();

        Thread thread1 = new Thread(() -> {
            task.run(1);
        });

        Thread thread2 = new Thread(() -> {
            task.run(2);
        });

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

class Task {

    public synchronized void run(Integer number) {
        System.out.println(number + " lock....");
        System.out.println("do process in " + number);
        System.out.println(number + " unlock....");
    }

    public void interrupt(Integer number) {
        System.out.println(number + " interrupt!!");
    }
}

간단하게 쓰레드 마다 lock, unlock을 표현해봤습니다.


당연히 같은 인스턴스를 사용하고, synchronized 메서드 이므로, 동시에 하나만 실행됩니다.
lock을 공유합니다.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Task task1 = new Task(); //
        Task task2 = new Task(); // 테스크를 다른 인스턴스로 설정

        Thread thread1 = new Thread(() -> {
            task1.run(1);
        });

        Thread thread2 = new Thread(() -> {
            task2.run(2);
        });

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

이번엔 다른 인스턴스로 설정해보겠습니다.
쓰레드마다 각각의 다른 Task 인스턴스를 주고 실행시켰습니다.

다른 인스턴스 이므로, lock을 공유하지 않고 각자 수행합니다. 여기까진 위에서 다뤘던 내용입니다.

lock은 인스턴스 단위로 잠긴다?

전 'lock은 인스턴스 단위로 잠긴다'는 말 때문에 syncronized가 사용된 인스턴스의 모든 메서드는 lock이 잠긴다는 오해를 했었는데요,
syncronized와 아닌 메서드를 섞어서 사용하는 예제를 통해 설명드리겠습니다.

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();

        Thread thread1 = new Thread(() -> {
            task.run(1);
        });

        Thread thread2 = new Thread(() -> {
            task.interrupt(2);
        });

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

class Task {

    public synchronized void run(Integer number) {
        System.out.println(number + " lock....");
        System.out.println("do process in " + number);
        System.out.println(number + " unlock....");
    }

    public void interrupt(Integer number) {
        System.out.println(number + " interrupt!!");
        System.out.println("print.... in " + number);
        System.out.println(number + " interrupt end");
    }
}

쓰레드 1은 정상적인 run을 하고, 쓰레드 2는 그걸 가로채서 inturrupt 한다는 의미로 작성해봤습니다.
runsynchronized, interrupt은 일반 메서드인데, run()을 실행하면 인스턴스에 lock이 걸려서 다른 쓰레드에서 interrupt에도 접근할 수 없는줄 알았습니다.

하지만 제 생각과는 달리 인스턴스에 lock이 걸린다는 것은 인스턴스에 있는 syncronized 전체가 lock을 공유한다는 의미였습니다. 다음 예제를 통해 확인해보겠습니다.

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

        Thread thread1 = new Thread(() -> {
            task.run(1);
            task.interrupt(1);

        });

        Thread thread2 = new Thread(() -> {
            task.run(2);
            task.interrupt(2);
        });

        Thread thread3 = new Thread(() -> {
            task.run(3);
            task.interrupt(3);
        });
        
        Thread thread4 = new Thread(() -> {
            task.run(4);
            task.interrupt(4);
        });
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

class Task {

    public synchronized void run(Integer number) {
        System.out.println(number + " lock....");
        System.out.println("do process in " + number);
        System.out.println(number + " unlock....");
    }

    public synchronized void interrupt(Integer number) {
        System.out.println(number + " interrupt!!");
        System.out.println("print.... in " + number);
        System.out.println(number + " interrupt end");
    }
}


인스턴스 단위로 lock이 걸린다는 의미는 사진과 같습니다.

  • 어떤 쓰레드의 run()이 실행되며 lock이 잠김
  • 끝나고 unlock되면 어떤 쓰레드의 run() 또는 interrupt() (run을 수행했다면 interrupt) 가 실행되며 lock이 잠김
  • 또 unlock 되면 어떤 쓰레드의 run() 또는 *interrupt() ... 끝날 때 까지

즉, 인스턴스 안에 synchronized가 붙은 메서드끼리 lock을 공유하며 하나의 작업이 끝나면 또 다른 작업이 lock을 잠그며 들어가는 방식입니다.
그래서 사진에서도 무조건 lock과 unlock, interrupt와 interrupt end가 올바른 쌍을 이루며 나타납니다.

synchronized는 순서를 보장하지 않기 때문에 1->2->3->4로 실행했어도 무작위로 lock을 가져갑니다.

block으로 제어하기

synchronized를 코드 블록으로도 만들 수 있습니다.

class Task {

    public Task() {
    }

    public void run(Integer number) {
        System.out.println("method call : " + number);
        
        synchronized (this) { // 블록
            System.out.println(number + " lock....");
            System.out.println("do process in " + number);
            System.out.println(number + " unlock....");
        }
    }
    
}

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

        Thread thread1 = new Thread(() -> {
            task.run(1);

        });

        Thread thread2 = new Thread(() -> {
            task.run(2);
        });

        Thread thread3 = new Thread(() -> {
            task.run(3);
        });

        Thread thread4 = new Thread(() -> {
            task.run(4);
        });
        
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

이 때, 괄호 안에 무엇(객체의 참조)을 기준으로 락을 공유할지 설정할 수 있습니다.

당연히 하나의 Task 인스턴스를 사용하니까 문제가 없습니다.
만약 여러 개의 Task를 사용하고, 클래스 기준으로 사용하고 싶다면?

class Task {

    public Task() {
    }

    public void run(Integer number) {
        System.out.println("method call : " + number);

        synchronized (Task.class) {
            System.out.println(number + " lock....");
            System.out.println("do process in " + number);
            System.out.println(number + " unlock....");
        }
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Task task1 = new Task();
        Task task2 = new Task();
        Task task3 = new Task();
        Task task4 = new Task();

        Thread thread1 = new Thread(() -> {
            task1.run(1);
        });

        Thread thread2 = new Thread(() -> {
            task2.run(2);
        });

        Thread thread3 = new Thread(() -> {
            task3.run(3);
        });

        Thread thread4 = new Thread(() -> {
            task4.run(4);
        });

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

쓰레드마다 다른 Task를 할당했습니다.
이번엔 synchronized 블록에 Task.class로 모든 Task 클래스에 대해 동기화를 진행했습니다.


모두 다른 Task라도 동기화가 잘 진행되고 있습니다.

마치며

동기화는 공부하면 할수록 어려운 것 같습니다.
감사합니다 👋

Reference

https://jgrammer.tistory.com/entry/Java-%ED%98%BC%EB%8F%99%EB%90%98%EB%8A%94-synchronized-%EB%8F%99%EA%B8%B0%ED%99%94-%EC%A0%95%EB%A6%AC

profile
수박개 입니다.

2개의 댓글

comment-user-thumbnail
2023년 8월 17일

좋은 글 감사합니다.

1개의 답글