[Java] 동기화
- 쓰레드의 메모리 접근방식
다음과 같이 존재한다고 가정해보겠습니다.
class IncreaseThread extends Thread{
int num = 0;
public IncreaseThread(int num){
this.num = num;
}
public void run(){
num +=1;
}
}
public class ThreadAccessMemory{
public static void main(String[] args){
int num = 9;
IncreaseThread threadA = new Thread(num);
IncreaseThread threadB = new Thread(num);
threadA.start();
threadB.start();
}
IncereaseThread : 변수 num을 증가시키는 쓰레드
위 코드를 실행하면 어떤 값이 나올까요? 아마 예상가능한 값은 11일 것 입니다. 하지만 11이 아닌 다른 값이 나올 수 있습니다. 그 이유는 쓰레드의 메모리 접근 방식에 있습니다. 아래 그림을 보시죠!

왼쪽에서 오른쪽으로 진행되는 순서입니다.
- threadA가 변수 num을 가져옵니다. num 값 현재 9입니다.
- threadA에서는 9를 1증가시키는 사이, threadB가 num에서 값 9을 가져옵니다.
- threadA에서 결과값 10값을 반환하게되고 num값은 10이 됩니다. 그 사이 ThreadB는 가져온 값 9를 10으로 증가시킵니다.
- threadB가 결과값 10를 num에 대입합니다.
원래 예상한대로 나오지 않는이유가 바로 위와 같습니다. 즉 num에 한 번에 한 쓰레드씩 접근하지 않고 두 개이상의 쓰레드가 접근하기 때문에 예상한 값 11이 나오지 않는 것이죠. 그럼 어떻게 해야할까요? 바로 변수 num에 한 번에 한 개의 쓰레드가 접근하게 되면, 쓰레드의 일이 끝날 때까지 다른 쓰레드가 접근하지 못하게해야 합니다. 그것이 바로 동기화입니다. 즉 동기화를 하게되면 아래 그림과 같이 됩니다.(동기화 처리라고 합니다. 보통 동기화 처리가 되면 Thread-safe하다라고 말은 합니다.)

- 쓰레드 동기화 방법!
- synchronized 기반의 동기화!
synchronized의 사용법은 간단합니다. 바로 메소드 선언할 때 붙여주기만 하면됩니다.
class Calculator{
int count = 0;
public synchronized int add(int n1, int n2){
count++;
return n1 +n2;
}
public synchronized int minus(int n1, int n2){
count++;
return n1 - n2;
}
}
위 처럼 synchronized를 선언해주면, 메소드가 동기화 처리가 된 것입니다. 이제 count의 값은 add와 minus 메소드가 호출된 수를 정확히 표기할 수 있습니다. 즉, 한 번에 한 개의 쓰레드만 접근이 한 번에 한 개만 가능합니다. 또한 add 메소드가 호출될 때, 다른 쓰레드가 minus 메소드 호출하는 것을 막을 수 있습니다. 이 반대의 경우도 마찬가지 입니다. 동기화 메소드에 의해서 메소드가 동기화되는 것이지만, 실질적인 동기화의 주체는 인스턴스이기 때문에 동기화되는 영역은 인스턴스 전체로 확장되기 때문입니다.
좀 더 쉽게 설명하자면, 동기화처리를 하나의 자물쇠라고 생각해봅시다. 인스턴스는 동기화처리로 인해 자물쇠로 잠긴 집이 되고, 쓰레드는 이 집을 방문하려는 손님이 됩니다. 그럼 인스턴스는 손님에게 열쇠(열쇠는 하나만 존재한다.)를 주고, 손님에게 설명합니다. "이 집은 한 번에 한 명의 손님만 이용하실 수 있습니다. 사용하실 때 열쇠로 문을 잠가주시고 볼 일이 끝나시면 열쇠를 저에게 다시 반납해주세요" 그럼 손님(쓰레드)은 집 문을 잠그고 자신의 볼 일(메소드 호출 등)을 끝마친 뒤에야 열쇠를 집에게 반납하는 것입니다.
흠...그럼 프로그램은 빠르게 작업을 해야하는데, 한 번의 한 쓰레드만 작업을 하게되면 너무 느리지 않을까..?
쓰레드 A가 add 메소드만을 필요로하고 쓰레드 B가 minus 메소드만을 필요로 한다면, 두 개가 동시에 호출 되면 더 좋은게 아닐까..? 맞습니다. 당연히 맞는 말입니다. 이 또한 방법이 있습니다.
- 동기화 블록
public class IHaveTwoNum {
Object key1 = new Object(); // 동기화 대상 num1의 열쇠
Object key2 = new Object();// 동기화 대상 num2의 열쇠
int num1 = 0;
int num2 = 0;
public void addOneNum1() {
synchronized(key1){
num1 +=1;
}
}
public void addTwoNum1() {
synchronized(key1){
num1 +=2;
}
}
public void addOneNum2() {
synchronized(key2){
num2 +=1;
}
}
public void addTwoNum2() {
synchronized(key2){
num2 +=2;
}
}
}
위 코드처럼 sychronized의 동기화 블록을 이용해 동기화처리 대상을 동기화 할 수 있습니다. 이렇게 되면 addOne(Two)Num1, addOne(Two)Num2의 동기화 대상이 다르기 때문에, 서로 같은 순간에 호출이되도 문제가 없습니다. 따라서 각각의 잠금장치를 열 수 있는 key를 만들어 각자 접근할 수 있게 한 것입니다.(단, addOneNum1, addTwoNum1는 서로 동기화 대상이 같기 때문에 같은 열쇠를 써야합니다. num2와 관련된 메소드들도 마찬가지 입니다.)
추가적으로, 동기화처리 해야하는 코드의 길이가 짧다면, 메소드를 전체를 동기화처리하는 것 보다 해당 블록만 동기화 처리하는 것이 처리속도가 훨씬 빨라집니다.
- 동기화 실행순서 컨트롤 method
- wait : 쓰레드를 잠시 대기시킵니다.
- notify : 대기하는 쓰레드에게 실행시킵니다.
- notifyAll : 대기하는 모든 쓰레드들은 실행시킵니다.
- 해당 메소드들은 Object 클래스에 정의된 메소드들 이므로 어디서나 사용할 수 있습니다.
class NewsPaper{
String todayNews;
boolean isTodayNews = false;
public void setTodayNews(String news) {
todayNews = news; //뉴스가 들어왔다.
isTodayNews = true; //뉴스가 들어옴을 표시!
synchronized(this){
notifyAll(); // 뉴스가 들어왔으므로 대기하고 있는 모든 쓰레드를 실행시킨다
}
}
public String getTodayNews() {
if(isTodayNews == false) { //오늘의 뉴스가 없다면,
try {
synchronized(this) {
wait(); //글을 가져가려고하는 쓰레드를 대기시킨다.
}
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
return todayNews;
}
}
위 코드를 쉽게 설명하자면, 앵커(쓰레드)가 방송을 위해 뉴스를 가져갈려고 할려는 상황입니다. 근데(isTodaynews =false) 뉴스가 없다면, 잠시 대기(wait)하고 있고, 뉴스가 들어오면(isTdaynews =true) 기자가 앵커에게 "뉴스가 들어왔으니 방송을 진행해(notifyAll)"라고 알립니다. 이처럼 메소드를 실행순서를 동기화시킬 수 있습니다.
- synchronized 키워드 대체 가능한 ReentrantLock
동기화 하는 방법은 synchronized말고 다른 방법인 ReentrantLock입니다.
(java.util.concurret.lock.ReentrantLock 패키지를 import해야합니다!!)
import java.util.concurrent.locks.ReentrantLock;
class IHaveTwoNum{
int num1 = 0;
public void addOneNum1() {
key1.lock(); // 쓰레드가 들어오면서 lock을 겁니다.
try {
num1 +=1;
}
finally
{
key1.unlock(); //쓰레드가 볼일 본뒤 lock 해체합니다.
}
}
}
class AddThread extends Thread{
IHaveTwoNum nums;
public AddThread(IHaveTwoNum n) {
nums = n;
}
public void run() {
nums.addOneNum1();
}
}
lock() 메소드를 통해 동기화 대상을 자신만 쓸 수 있게 자물쇠로 잠그고 , 사용을 완료했다면 다른 쓰레드가 쓸 수 있게 unlock() 메소드를 통해 자물쇠를 풉니다. (단, unlock 메소드는 어떤한 경우에도 반드시 실행되어야하기 때문에 try~finally문으로 사용하는 것을 추천드립니다.) synchronized의 {}가 lock, unlock으로 대체되었다고 생각하시면 편하실 겁니다.
- ReentrantLock 실행순서 컨트롤 메소드
- await : 쓰레드를 대기시킨다.
- signal : 대기중인 하나의 쓰레드를 실행시킨다.
- signalAll : 대기중인 모든 쓰레드를 실행시킨다.
- 각각이 synchronized의 wait, notify, notifyAll 대응 됩니다.
좀 다른 사항이 있다면,
import java.util.concurrent.locks.ReentrantLock; //ReentrantLock이 포함되어이있는 패키지
import java.util.concurrent.locks.Condition; // signal, await 등의 메소드가 포함되어있는 패키지
두 패키지를 import 하는 것과 ReentrantLock의 인스턴스를 통해 await, signal 등의 메소드를 사용해야합니다.
사용법은 다음과 같습니다. 위와 비슷한 내용의 코드로 이 또한 문자열이 없으면, 대기하고 문자열이 생성되면 쓰레드를
호출해 문장을 가져가게 하는 것입니다. 반대로는 문자열이 아직 남아있다면 새로운 문자열은 가지고 온 쓰레드를 대기시키고, 문자열을 가져가는 쓰레드를 호출시켜 가져가게 한뒤 문자열을 저장하게 하는 것입니다.
import java.util.concurrent.locks.ReentrantLock;
import java.util.Scanner;
import java.util.concurrent.locks.Condition;
class StringComm{
String newString;
boolean isNewString = false;
private final ReentrantLock entLock = new ReentrantLock();
private final Condition readCond = entLock.newCondition();
private final Condition writeCond = entLock.newCondition();
// 문장을 읽을 때 사용하는 key readCond , 문자을 저장할 때 사용한 key writeCond를 entLock 대상으로 Condition 인스턴스 생성
public void setNewsString(String news) { //새로운 문자열을 저장하기위한 메소드
entLock.lock();//다른 인스턴스를 접근하지 못하게 함
try {
if(isNewString == true) { // 기존의 문장이 아직 존재한다면
writeCond.await(); // writeCond를 기존의 문장을 가져갈 때까지 대기시킨다.
}
newString = news; //새로운 문장을 저장
isNewString = true; //새로운 문자열을 저장하면 안됨을 표시
readCond.signal();//새로운 문자가 오는 것을 기달리는 쓰레드를 호출
}catch(InterruptedException e) {
e.printStackTrace();
}
finally {
entLock.unlock(); // 다른 인스턴스가 접근가능하게 함
}
}
public String getNewsString() { //새로운 문자열을 가져가기위한 메소드
String retStr = null;
entLock.lock(); //다른 인스턴스를 접근하지 못하게 함
try {
if(isNewString == false) // 저장되어있는 문자열이 없다면
readCond.await(); // 읽어오는 쓰레들 잠시 대기
retStr = newString; // 새로이 문자열을 저장
isNewString = false; // 새로운 문자열 저장되도 상관 없음을 표시
writeCond.signal();//혹시 문자열을 쓰기위해 기달리고 있는 쓰레드를 호출
}catch(InterruptedException e) {
e.printStackTrace();
}
finally {
entLock.unlock(); //// 다른 인스턴스가 접근가능하게 함
}
return retStr;
}
}
class StringWriter extends Thread{
StringComm comm;
public StringWriter(StringComm comm) {
this.comm = comm;
}
public void run() {
Scanner scan = new Scanner(System.in);
String writeStr;
System.out.println("문장을 적어주세요!");
writeStr = scan.nextLine(); //새로운 문자열을 입력받음
comm.setNewsString(writeStr); // setNewsString 메소드를 통해 문자열을 저장
}
}
class StringReader extends Thread{
StringComm comm;
public StringReader(StringComm comm) {
this.comm = comm;
}
public void run() {
System.out.println(comm.getNewsString());
}
}
public class ConditionSyncStringReadWrite {
public static void main(String[] args) {
// TODO Auto-generated method stub
StringComm comm = new StringComm();
StringWriter wStr = new StringWriter(comm);
StringReader rStr = new StringReader(comm);
System.out.println("입출력 쓰레드 실력-^3^-");
wStr.start();
rStr.start();
}
}