public class SharedData {
private int value;
public void increase() {
value += 1;
}
public void print() {
System.out.println(value);
}
}
main 함수에서 10개의 TestRunnable 객체를 생성해서 스레드 별로 각각 increase()를 100번씩 호출하는 코드를 작성.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
final SharedData mySharedData = new SharedData(); // shared resource
for (int i = 0; i < 10; i++) {
new Thread(new TestRunnable(mySharedData)).start();
}
}
}
class TestRunnable implements Runnable {
private final SharedData mySharedData;
public TestRunnable(SharedData mySharedData) {
this.mySharedData = mySharedData;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
mySharedData.increase();
}
mySharedData.print();
}
}
TestData 객체를 공유하는 10개의 스레드가 run()블록에 정의된 작업을 시분할 방식으로 번갈아가면서 실행.
결과가 매번 달라지고 보장받지 못한다. 100씩 증가를 원했다면 실패.
Lock인스턴스를 사용해 동시성 문제 해결 가능.
스레드들이 공유할 Lock 인스턴스를 만들고, 동기화가 필요한 실행문의 앞 뒤로 lock(), unlock()을 호출하면 된다.
이 때, lock()을 걸어놨다면 unlock()도 빼먹지 말고 반드시 호출해야한다.
임계 영역 블록의 실행이 끝나더라도 unlock() 호출 되기 전까지는 스레드는 잠금 상태가 계속 유지된다.
어떤 예외가 발생하더라도 반드시 unlock()이 호출되도록 try-catch-finally 형태를 사용하면 좋다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
final SharedData mySharedData = new SharedData(); // shared resource
final Lock lock = new ReentrantLock(); // lock instance
for (int i = 0; i < 10; i++) {
new Thread(new TestRunnable(mySharedData, lock)).start();
}
}
}
class TestRunnable implements Runnable {
private final SharedData mySharedData;
private final Lock lock;
public TestRunnable(SharedData mySharedData, Lock lock) {
this.mySharedData = mySharedData;
this.lock = lock;
}
@Override
public void run() {
lock.lock();
try {
for (int i = 0; i < 100; i++) {
mySharedData.increase();
}
mySharedData.print();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
위와 같이 run()메소드에 lock을 걸고 finally에서 해제해준다.
ReentrantLock 객체를 이용해서 Runnable 을 넘겨준다.
위의 예제의 경우, 충분히 synchronized로 처리 가능하지만 굳이 lock으로 처리를 하였다.
그리고 앞서 말했듯 lock은 synchronized를 보다 더 우아하게 쓴다고 하였따.
둘을 구분 짓는 키워드는 fairness(공정성) 이라고 한다.
공정성?
모든 스레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것
공정한 방법에선 큐 안에서 스레드들이 무조건 순서를 지켜가며 lock을 확보한다.
불공정한 방법에선 특정 스레드에 lock이 필요한 순간 release가 발생하면 대기열을 건너뛰는 새치기가 발생한다.
다른 스레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 스레드가 존재하는 상황을 기아상태(starvation) 이라고 한다. 이를 해결하기 위해선 공정성이 필요하다.
synchronized는 공정성을 제공하지 않는다.
반면, ReentrantLock은 생성자의 인자를 통해 공정/불공정 설정을 할 수 있다.
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
공정한 lock의 경우, 오래 기다린 스레드에게 lock을 제공한다.
락을 요청하는 시간 간격이 긴 경우가 아니라면, 스레드를 공정하게 관리하는 겁소다 불공정하게 관리할 때 성능이 더 좋다.
그래서 일반적으로는 불공정하게 사용된다.
그 외의 차이점
synchronized는 블록 구조를 사용하기에 하나의 메서드 안에서 임계 영역의 시작과 끝이 있어야 한다. Lock은 lock(), unlock()으로 시작과 끝을 명시하기에 임계 영역을 여러 메소드에 나눠 작성할 수 있다.
synchronized는 동기화가 필요한 블럭을 synchronized{}로 감싸서 락을 건다. 여러 스레드가 경쟁 상태에 있을 때 어떤 스레드가 진입권을 획득할 지 순서 보장을 못한다. 이는 암시적인 락 이다.
Lock은 lock()-unlock() 메소드를 호출함으로써 어떤 스레드가 먼저 락을 획득할 지 순서를 지정할 수 있다. 이를 명시적인 lock이라고 한다.
Lock은 인스턴스에 1개 이상의 Condition을 지정할 수 있다. lockInterruptibly(), tryLock() 같은 편리한 제어 메서드를 사용할 수 있고, lock 획득을 기다리고 있는 스레드의 목록을 간편하게 확인 할 수 있다.
synchronized는 간결한 코드로 임계 영역을 지정할 수 있다. 그리고 개발자의 실수로 lock을 해제하지 않아 문제가 생길 가능성이 없다. Lock을 사용할 경우, synchronized와 다르게 import 구문과 try-finally구문이 추가됨으로 코드가 더 늘어난다는 단점이 있다.