Intrinsic Lock

  • 자바는 멀티스레드 환경에서 동기화를 지원하기 위해 가장 기초적인 장치인 ’고유 락(Intrinsic Lock)’을 지원한다. 개발자는 synchronized 키워드를 이용해서 특정 객체의 고유락을 사용해 여러 스레드를 동기화 시킬 수 있다.
  • Synchronized 블록은 Intrinsic Lock을 이용해서, Thread의 접근을 제어한다.

Java의 synchronized

  • 동일한 객체에 대해 synchronized 블록을 사용하는 두 스레드는 한 번에 하나의 스레드만 내부로 들어갈 수 있고, 이 것이 자바가 제공하는 가장 기본적인 ‘상호배제(Mutual Exclusion)’ 장치이다.
  • synchronized에는 4가지의 사용법이 있다.
    • synchronized method
    • synchronized block
    • static synchronized method
    • static synchronized block
      • 4가지 방식의 차이인 lock이 적용되는 범위를 알아보자

1. synchronized method

  • synchronized method는 클래스의 인스턴스에 대하여 lock을 건다.
  • 하나의 인스턴스에 대하여 2개의 thread가 경합하는 상황
public class Main {
    public static void main(String[] args) {
        A a = new A();
        Thread thread1 = new Thread(()->{
            a.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a.run("t2");
        });
        thread1.start();
        thread2.start();
    }
}

public class A {
    public synchronized void run(String name){
        System.out.println(name + "lock");
        try{
            Thread.sleep(1000);
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(name + "unlock");
    }
}
  • 순서대로 lock을 획득하고 반납함
    • 실행 결과
      • t1lock t1unlock t2lock t2unlock
  • 각각의 인스턴스를 만들고 실행 해보자
public class Main {
    public static void main(String[] args) {
        A a = new A();
        A a1 = new A();
        Thread thread1 = new Thread(()->{
            a.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a1.run("t2");
        });
        thread1.start();
        thread2.start();
    }
}
  • lock을 공유하지 않기 때문에 동기화가 발생하지 않음
    • 실행 결과
      • t1lock t2lock t1unlock t2unlock
  • 결과
    • synchronized method는 인스턴스에 대하여 lock을 건다.
    • 인스턴스에 대해서 lock을 건다라는 표현이 인스턴스 접근 자체가 lock이 걸리는 걸까?? 확인해보자
public class Main {
    public static void main(String[] args) throws InterruptedException {
        A a = new A();
        Thread thread1 = new Thread(()->{
            a.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a.print("t2");
        });
        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}

public class A {
    public void print(String name){
        System.out.println(name + " hi");
    }
    public synchronized void run(String name){
        System.out.println(name + "lock");
        try{
            Thread.sleep(1000);
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(name + "unlock");
    }
}
  • synchronized가 적용되지 않은 print() 메서드를 추가하고 lock이 걸린 중간에 호출 해보았더니 run() 메서드의 lock과 상관없이 정상적으로 호출됨
    • 실행 결과
      • t1lock t2 hi t1unlock
  • print() 메서드도 sychronized가 적용되어있다면?
    • 동기화 발생
    • 실행 결과
      • t1lock t1unlock t2 hi
  • 결과
    • synchronized 메서드는 인스턴스 단위로 lock을 건다.
    • 인스턴스에 lock을 거는 synchronized 키워드는 synchronized가 적용된 메서드끼리 일괄적으로 lock을 공유한다.

2. static synchronized method

  • static이 포함된 synchronized method방식은 우리가 일반적으로 생각하는 static 성질을 갖는다. 인스턴스가 아닌 클래스 단위로 lock이 발생한다.
public class Main {
    public static void main(String[] args) throws InterruptedException {
        A a1 = new A();
        A a2 = new A();
        Thread thread1 = new Thread(()->{
            a1.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a2.run("t2");
        });
        thread1.start();
        thread2.start();
    }
}
public class A {
    public static synchronized void run(String name){
        System.out.println(name + "lock");
        try{
            Thread.sleep(1000);
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(name + "unlock");
    }
}
  • 실행 결과
    • t1lock t1unlock t2lock t2unlock
  • 다른 인스턴스 이지만 클래스 단위로 lock이 발생했다.
  • 만약 static synchronized method와 synchronized method가 섞여있다면 어떨까?
public class Main {
    public static void main(String[] args) throws InterruptedException {
        A a1 = new A();
        A a2 = new A();
        Thread thread1 = new Thread(()->{
            a1.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a2.print("t2");
        });
        thread1.start();
        thread2.start();
    }
}
public class A {
    public synchronized void print(String name){
        System.out.println(name + " hi");
    }
    public static synchronized void run(String name){
        System.out.println(name + "lock");
        try{
            Thread.sleep(1000);
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(name + "unlock");
    }
}
  • 실행 결과
    • t1lock t2 hi t1unlock
  • 인스턴스 단위의 lock과 클래스 단위의 lock은 공유되지 않았다.
  • static synchronized method를 정리해보면
    • 클래스 단위로 lock을 걸지만
    • 인스턴스 단위의 synchronized method와 lock을 공유하지 않는다.

3. synchronized block

  • synchronized block은 인스턴스의 block단위로 lock을 건다. 이 때, lock 객체를 지정해줘야 한다.
public class Main {
    public static void main(String[] args) throws InterruptedException {
        A a1 = new A();
        Thread thread1 = new Thread(()->{
            a1.run("t1");
        });
        Thread thread2 = new Thread(()->{
            a1.run("t2");
        });
        thread1.start();
        thread2.start();
    }
}
public class A {
    public void run(String name) {
        synchronized (this) {
            System.out.println(name + "lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + "unlock");
        }
    }
}
  • 실행 결과
    • t1lock t1unlock t2lock t2unlock
  • block의 인자로 this를 주었다.
    • this는 해당 인스턴스를 의미하고 위 코드에서 method 전체가 block으로 감싸져 있으므로 메서드 선언부에 synchronized 키워드를 붙인 것과 똑같이 동작한다.
    • 하지만 여러 로직이 섞여 있는 사이 부분만 lock을 걸 수 있다. lock은 synchronized block에 진입할 때 획득하고 빠져나오면서 반납하므로 block으로 범위를 지정하는 것이 효율적이다.
    • synchronized block도 method와 동일하게 인스턴스에 대해서 적용된다.
  • 지금까지는 block에 인자로 this를 사용해서 Synchronized를 메서드 선언부에 붙인 것과 별반 다를 것 없이 사용함
    • 이제는 block에 자원을 명시하고 사용
public class Main {
    public static void main(String[] args) throws InterruptedException {
        A a = new A();
        Thread thread1 = new Thread(() -> {
            a.run("thread1");
        });

        Thread thread2 = new Thread(() -> {
            a.run("thread2");
        });

        Thread thread3 = new Thread(() -> {
            a.print("자원 B와 상관 없는 thread3");
        });

        thread1.start();
        thread2.start();
        thread3.start();
    }
}
public class A {
    B b = new B();
    public void run(String name) {
        synchronized (b){
            System.out.println(name + " lock");
            b.run();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " unlock");
        }
    }

    public synchronized void print(String name) {
        System.out.println(name + " hello");
    }
}
public class B extends Thread{
    @Override
    public synchronized void run() {
        System.out.println("B lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("B unlock");
    }
}
  • 실행 결과
    • thread1 lock 자원 B와 상관 없는 thread3 hello B lock B unlock thread1 unlock thread2 lock B lock B unlock thread2 unlock
  • 매우 복잡한 상황이다. lock 객체를 A클래스가 아닌 B클래스의 인스턴스로 사용하고 있다.
  • thread1, thread2는 b를 사용하는 method를 호출하고 있지만
  • thread3은 b와 상관없는 method이기 때문에 b의 lock과 상관없이 출력되었다.
  • 즉 인스턴스 단위 lock과 B를 block한 lock은 공유되지 않고 별도로 관리되는 것을 확인할 수 있다.
  • 이번에는 block에 인스턴스가 아니라 class를 명시해보았다.
public class Main {
    public static void main(String[] args) {
        A a = new A();
        Thread thread1 = new Thread(() -> {
            a.run("thread1");
        });

        Thread thread2 = new Thread(() -> {
            a.run("thread2");
        });

        thread1.start();
        thread2.start();
    }
}
public class A {
    B b = new B();

    public void run(String name) {
        synchronized (B.class){
            System.out.println(name + " lock");
            b.run();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " unlock");
        }
    }

    public synchronized void print(String name) {
        System.out.println(name + " hello");
    }
}
public class B extends Thread{
    @Override
    public synchronized void run() {
        System.out.println("B lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("B unlock");
    }
}
  • 실행 결과
    • thread1 lock B lock B unlock thread1 unlock thread2 lock B lock B unlock thread2 unlock
  • block의 인자로 인스턴스가 아닌 .class를 주었다.
  • 출력 결과를 보면 lock을 공유하고 있는 것을 확인할 수 있다.
  • block에는 객체를 넘기면 인스턴스 단위로 lock을 걸고, .class형식으로 넘기면 클래스 단위의 lock을 건다.

4. static synchronized block

  • static method안에 synchronized block을 지정할 수 있다. static의 특성 상 this같이 현재 객체를 가르키는 표현을 사용할 수 없다. static synchronized method 방식과 차이는 lock 객체를 지정하고 block으로 범위를 한정지을 수 있다는 점이다. 이외에 클래스 단위로 lock을 공유한다는 점은 같다.
public class Main {

    public static void main(String[] args) {
        A a1 = new A();
        A a2 = new A();
        Thread thread1 = new Thread(() -> {
            a1.run("thread1");
        });

        Thread thread2 = new Thread(() -> {
            a2.run("thread2");
        });

        thread1.start();
        thread2.start();
    }
}
public class A {

    public static void run(String name) {
        synchronized (A.class){
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " unlock");
        }
    }
}
  • 실행 결과
    • thread1 lock thread1 unlock thread2 lock thread2 unlock

동기화 순서

  • synchronized를 통해 lock을 물고 있을 때 여러 개의 스레드가 접근 요청을 한다고 가정하면
  • 첫 진입 이후에 동기화 순서가 보장되지는 않는다.

정리

  • synchronized method
    • 인스턴스 단위로 lock이 걸림
    • 메서드의 시작→종료 까지 동기화 발생
    • 동일한 인스턴스 내 synchronized 키워드가 적용된 메서드끼리 lock을 공유
  • synchronized block
    • 인스턴스 단위로 lock이 걸림
    • this를 명시하면 synchronized method와 동일하게 동작하면서 synchronized method와 lock을 공유
    • 특정 객체를 명시하면 해당 객체에만 특정 lock을 걸면서 해당 객체에 lock을 거는 block끼리만 lock을 공유
    • .class 형식 명시하면 해당 클래스에만 특정 lock을 걸면서 해당 클래스에 lock을 거는 block끼리만 lock을 공유
  • static synchronized method
    • 클래스 단위로 lock이 걸림
    • 메서드의 시작→종료 까지 동기화 발생
    • static synchronized와 synchronized가 혼용되어있을 때 각자의 lock으로 관리
  • static synchronized block
    • 클래스 단위 lock로 lock이 걸림.
    • block의 인자로 정적 인스턴스나 클래스만 사용
  • synchornized는 Thread의 동기화 순서를 보장하지 않는다.

Atomic

  • 래퍼 클래스의 일종으로 참조 자료형과 기본 자료형 두 종류 변수에 모두 적용 가능
  • 사용시 내부적으로 CAS 알고리즘을 사용해 lock 없이 동기화 처리가 가능

 

https://beststar-1.tistory.com/21#synchronized%EC%99%80_Lock%EC%9D%98_%EC%B0%A8%EC%9D%B4%EC%A0%90_-_%EA%B3%B5%EC%A0%95%EC%84%B1(Fairness)

  • 멀티 스레드 환경, 멀티 코어 환경에서 각 cpu는 메인 메모리에서 변수값을 참조하는 게 아닌 각 cpu의 캐시 영역에서 메모리 값을 참조하게 된다.
  • 이때, 메모리에 저장된 값과 cpu 캐시에 저장된 값이 다른 경우가 존재
    • 이를 가시성 문제라고 한다.
      • 그래서 사용되는 것이 CAS 알고리즘
  • 현재 스레드에서 저장된 값과 메인 메모리에 저장된 값을 비교하여 일치하는 경우 새로운 값으로 교체, 일치하지 않는다면 실패하고 재시도를 한다. 이렇게 처리되면 cpu 캐시에서 잘못된 값을 참조하는 가시성 문제가 해결된다.

Volatile

  • 자바 변수를 메인 메모리에 저장하겠다고 명시하는 키워드이다.
  • 매번 변수의 값을 읽을 때 마다 CPU 캐시에 저장된 값이 아니라 메인 메모리에서 읽는 것이며, 또한 변수의 값을 쓸 때 마다 메인 메모리에 작성하는 것이다.
    • 이 또한 가시성 문제를 해결하는 방법으로 볼 수 있다.

락(lock)

  • 모든 객체에는 lock이 하나씩 있는데 이 lock을 가지고 있는 스레드만 해당 객체의 임계 영역 코드와 관련된 작업을 할 수 있다.
  • 그렇지만 여러 스레드가 경쟁 상태에 있을 때 스레드가 진입권한을 획득할지 순서를 보장하진 않는다. 이를 암시적(Implicit)락 이라고 한다.(Intrinsic Lock) 이라고도 부른다.
  • Lock 클래스는 lock() 메서드와 unlock() 메서드를 호출함으로써 어떤 스레드가 먼저 락을 획득하게 될지 순서를 지정할 수 있다. 이를 명시적(explicit)락 이라고 한다.( Reentrant Lock)

💡 경쟁 상태

  • 공유하는 자원이 있는데 공유하는 자원에 접근하는 여러 스레드 중 어떤 것이 먼저 접근하냐에 따라 결과가 달라질 수 있는 경우가 있다. 이를 경쟁상태에 의해 발생되었다고 하기도 한다.

synchronized와 Lock의 차이점 - 공정성(Fairness)

  • synchronized와 Lock을 구분 짓는 키워드는 공정성(Fairness)이다.
  • 공정성이란 모든 스레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것을 의미한다.
  • 공정한 방법에선 큐 안에서 스레드들이 무조건 순서를 지켜가며 락을 확보한다.
  • 불공정한 방법에선 만약 특정 스레드에 락이 필요한 순간 release가 발생하면 대기열을 건너뛰는 새치기 같은 일이 벌어지게 된다.

💡 기아 상태

  • 다른 스레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 스레드가 존재하는 상황을 말하며, 이 기아 상태를 해결하기 위해 공정성이 필요하다.
  • synchronized는 공정성을 지원하지 않아서 후순위인 스레드의 실행이 안될 수 있는 반면에
  • ReentrantLock은 생성자의 boolean 인자를 통해 공정/불공정을 설정할 수 있다.

ReentrantLock

  • 가장 일반적인 락이며 재진입이 가능한 락이다.
  • 'Reentrant(재진입할 수 있는)'이라는 단어가 앞에 붙은 이유는
  • 특정 조건에서 락을 풀고 나중에 다시 락을 얻고 임계 영역으로 들어와서 작업을 수행할 수 있기 때문이다.

ReentrantReadWriteLock

  • 읽기에는 공유적이고, 쓰기에는 배타적인 락이다.
  • ReentrantReadWriteLock은 이름에서 알 수 있듯 읽기를 위한 락과 쓰기를 위한 락을 제공한다.
  • ReentrantLock은 배타적인 락이라서 무조건 락이 있어야만 임계 영역의 코드를 수행할 수 있으나,
  • ReentrantReadWriteLock은 읽기 락이 걸려있으면 다른 스레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다.
  • 읽기는 내용을 변경하지 않음으로 동시에 여러 스레드가 읽어도 문제 되지 않는다.
    • 그래서 읽기 락이 걸린 상태에서 쓰기 락은 허용되지 않는다.

StampedLock

  • ReentrantReadWriteLock에 '낙관적 읽기 락(Optimistic Reading Lock)'을 추가한 것이다.
  • StampedLock은 Java 8부터 추가되었으며, 다른 락과 달리 Lock 클래스를 구현하지 않았다.
  • 일반적으론 읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하는데,
  • 낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다.
    • 그래서 낙관적 읽기에 실패하면 읽기 락을 얻어서 다시 읽어와야 한다.
  • 무조건 읽기 락을 걸지 않고 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 락을 거는 것이다.
  • StampedLock은 락을 걸거나 해지할 때 '스탬프(long 타입의 정수 값)'를 사용한다.
  • 스탬프에 해당하는 값이 둘다 long형의 숫자고 사전 연산에서 받아놓은 값이 이후 연산에서 비교 해봤을 때 변경되었다면 이후 연산은 실패가 된다.

'Computer science > JAVA' 카테고리의 다른 글

GC  (0) 2023.03.16
JVM  (0) 2023.03.16
Thread  (1) 2023.01.02
Casting  (0) 2023.01.01
Object Class  (0) 2023.01.01

+ Recent posts