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 없이 동기화 처리가 가능
- 멀티 스레드 환경, 멀티 코어 환경에서 각 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 |