본문으로 바로가기

Java의 동기화 Synchronized 개념 정리#1

category 개발이야기/Java 2017. 11. 19. 00:23
반응형

Java의 동기화 -Synchronized 키워드의 사용

Java를 프로그래밍 하다면 multi-thread로 인하여 동기화를 제어해야하는 경우가 생깁니다.
그래서 흔히 Synchronized 키워드를 사용하는데요
그냥 multi-thread로 동시접근되는것을 막는다! 라는 개념보다는 여러 case를 살펴보면서 좀더 디테일하게 보려 합니다.

Thread는 class의 멤버변수의 자원에 접근할 수 있습니다.
이건 멤버변수가 Heap 메모리를 사용하기 때문에 가능한 부분인데 여하튼 여러 Thread가 공유자원에 접근하는 경우 동기화를 해 줘야 할 필요가 있습니다.
사실 그밖에 동기화 해줘야 하는 이유들이야 많습니다.

추가적으로 synchronized 키워드 이외에 volatile을 사용할수 있고, Atomic 클래스를 이용할 수 도 있습니다만, 이번에는 Synchronized keyword만 중심적으로 테스트 해 보겠습니다.


Synchronized 함수의 사용

Synchronized는 두가지 형태로 사용이 가능합니다.

1. synchronized 함수를 만들어 사용한다.
2. synchronized block을 사용한다.

먼저 synchronized의 함수에 대한 기본적인 이해부터 해보도록 하시죠!

public class BasicSynchronization {
	private String mMessage;

	public static void main(String[] agrs) {
		BasicSynchronization temp = new BasicSynchronization();

		System.out.println("Test start!");
		new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				temp.callMe("Thread1");
			}
		}).start();

		new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				temp.callMe("Thread2");
			}
		}).start();
		System.out.println("Test end!");

	}

	public synchronized void callMe(String whoCallMe) {
		mMessage = whoCallMe;

		try {
			long sleep = (long) (Math.random() * 100);
			Thread.sleep(sleep);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		if (!mMessage.equals(whoCallMe)) {
			System.out.println(whoCallMe + " | " + mMessage);
		}
	}
}
두개의 thread를 만들어 synchronized callMe()함수를 부르고 있습니다.

callMe() 함수는 값을 받아서 아래와 같은 작업을 합니다.

1. parameter 값을 멤버변수에 저장하고

2. 랜덤하게 sleep한 후

3. 멤버변수와 parameter와 값이 같지 않으면 로그를 찍습니다.


위 함수에서는 절대 로그가 찍히지 않습니다.

단 synchronized 함수를 제거하면 로그가 주르르륵 찍히는걸 볼 수 있습니다.

만약에 아래와 같이 Main 함수를 수정했다면 어떻게 될까요?

public class BasicSynchronization {
	private String mMessage;

	public static void main(String[] agrs) {
		BasicSynchronization temp1 = new BasicSynchronization();
		BasicSynchronization temp2 = new BasicSynchronization();

		System.out.println("Test start!");
		new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				temp1.callMe("Thread1");
			}
		}).start();

		new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				temp2.callMe("Thread2");
			}
		}).start();
		System.out.println("Test end!");

	}

	public void callMe(String whoCallMe) {
		mMessage = whoCallMe;

		try {
			long sleep = (long) (Math.random() * 100);
			Thread.sleep(sleep);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		if (!mMessage.equals(whoCallMe)) {
			System.out.println(whoCallMe + " | " + mMessage);
		}
	}
}


두개의 객체를 만들어서 각각 thread 에서 불렀습니다.

그리고 callMe() 함수는 더이상 synchronized 함수가 아닙니다.

이런 경우 thread는 호출하고 있는 각 객체가 다르기 때문에 callMe() 함수에서 로그를 찍지 않습니다.


즉! 함수에 synchronized를 걸면 그 함수가 포함된 해당 객체(this)에 lock을 거는것과 같습니다.

으잉? 아직 개념이 헷깔린가요?

그럼 좀더 명확한 이해를 위해 아래 예제를 한번 더 보도록 하겠습니다.

public class MyHero {
	private String mHero;
	
	public static void main(String[] agrs) {
		MyHero tmain = new MyHero();
		System.out.println("Test start!");
		new Thread(() -> {
			for (int i = 0; i<1000000; i++) {tmain.batman();}			
		}).start();
		
		new Thread(() -> {
			for (int i = 0; i<1000000; i++) {tmain.superman();}
		}).start();
		System.out.println("Test end!");
		
	}
	
	public synchronized void batman() {
		mHero= "batman";
try { long sleep = (long) (Math.random()*100); Thread.sleep(sleep); if ("batman".equals(mHero) == false) { System.out.println("synchronization broken"); } } catch (InterruptedException e) { e.printStackTrace(); } } public synchronized void superman() { mHero = "superman";
try { long sleep = (long) (Math.random()*100); Thread.sleep(sleep); if ("superman".equals(mHero) == false) { System.out.println("synchronization broken"); } } catch (InterruptedException e) { e.printStackTrace(); } } }

1. synchronized 함수가 두개인 MyHero 클래스의 객체를 생성합니다.

2. 두개의 thread가 객체의 sychronzied 함수인 batman() 과 superman()을 각각 백만번 호출 합니다.

3. batman(), superman() 함수는 mHero란 멤버변수에 각각 다른 값을 세팅하고, 랜덤하게 sleep한 후에 값이 변했는지 체크합니다.

4. 값이 변했다면 "synchronization broken" 메시지를 찍습니다.


결과는 예상 되시나요?

위에서 synchronized 함수는 객체에 lock을 건다고 얘기했습니다.

따라서 해당 로그는 절대 찍히지 않습니다.

두개의 thread가 각기 다른 함수를 synchronized 함수를 호출하지만 객체에 lock이 걸리기 때문에 동시에 호출할 수가 없는거죠.


정리하자면 synchronized 함수는 자신이 포함된 객체에 lock을 겁니다.

따라서 동기화 문제를 해결하는데 가장 간단하고 확실하면서 무식한 방법입니다.

여기서 무식하다 함은 synchronized로 인하여 객체에 포함된 다른 모든 synchronized의 접근 까지 lock이 걸리기 때문입니다.

그래서 synchronized block이 존재합니다.^^

이제 블록 사용법을 보시죠~


Synchronized block의 사용

Synchronized 함수는 위에 언급한대로 두가지 문제점이 있습니다.

1. synchronized 함수가 lock이 걸린다.

2. synchronized 함수를 포함한 객체(this)에 lock이 걸린다.

먼저 첫번째 방법을 해결하는건 간단합니다.

public class SyncBlock1 {
	public ArrayList<Integer> mList = new ArrayList<>();	
	
	public static void main(String[] agrs) throws InterruptedException {
		SyncBlock1 syncblock1 = new SyncBlock1();
		System.out.println("Test start!");
		Thread t1 = new Thread(() -> {
			for (int i = 0; i<10000; i++) {syncblock1.add(i);}			
		});
		
		Thread t2 = new Thread(() -> {
			for (int i = 0; i<10000; i++) {syncblock1.add(i);}
		});
		
		t1.start();
		t2.start();
		
		t1.join();		
		t2.join();
		
		System.out.println(syncblock1.mList.size());
		System.out.println("Test end!");
		
	}
	
	public void add(int val) {
		/*
		 * Code for synchronization is not needed
		 * 
		 */
		synchronized(this) {
			if (mList.contains(val) == false) {
				mList.add(val);				
			}			
		}
	}
}

1. SyncBlock1 객체를 하나 만듭니다.

2. SyncBlock1 객체에는 add() 함수가 있고, 내부에 동기화가 필요한 부분에만 synchronized(this) 블록으로 처리합니다.

3. 두개의 thread로 동시에 생성한 SyncBlock1 객체의 add()함수를 호출합니다.


결과로 mList의 size()는 몇인지 아시겠죠?

size는 for문이 수행된 10000개 입니다.

예제에서는 "Code for synchronization is not needed"로 주석으로 표시해 놓은곳에 동기화가 필요없는 다른 코드를 넣으면 됩니다.

따라서 함수 단위가 아니라 필요한 부분에만 block으로 동기화처리를 해줄 수 있습니다.


사실 위 예제는 synchronized 블럭에 this를 사용했습니다.

즉 주석 부분에 다른 코드를 넣지 않는다면 아래와 같이 함수로 동기화 하는것과 다르지 않습니다.

public synchronized void add() {
	//Do something
}

public void add() {
	synchronized(this) {
		//Do something
	}
}

위 두개의 코드는 같은 코드 입니다.


Synchronized block을 사용한 서로 다른 lock

Synchronized block에 인자값은 lock을 걸 대상입니다.
따라서 아래와 같이 (this)로 표기하면 해당 객체 안에 있는 모든 synchronized block에 lock 걸립니다.

쉽게 얘기해서 여러 쓰레드가 들어와서 각기 다른 synchronized 부분을 호출해도 아래 코드는 this 라는 자기 자신의 lock을 사용하기 때문에 다들 기다려야 하는거죠.

좀더 자세한 설명은 코드와 함께 아래에 설명하겠습니다.

public class SyncBlock2 {
	private HashMap<String, String> mMap1 = new HashMap<>();
	private HashMap<String, String> mMap2 = new HashMap<>();
	
	public static void main(String[] agrs) {
		SyncBlock2 syncblock2 = new SyncBlock2();
		System.out.println("Test start!");
		new Thread(() -> {
			for (int i = 0; i<10000; i++) {
				syncblock2.put1("A","B");
				syncblock2.get2("C");
				}			
		}).start();
		
		new Thread(() -> {
			for (int i = 0; i<10000; i++) {
				syncblock2.put2("C","D");
				syncblock2.get1("A");
				}
		}).start();
		System.out.println("Test end!");
		
	}
	
	public void put1(String key, String value) {		
		synchronized(this) {
			mMap1.put(key, value);
		}
	}
	
	public String get1(String key) {
		synchronized(this) {
			return mMap1.get(key);
		}
	}
	
	public void put2(String key, String value) {		
		synchronized(this) {
			mMap2.put(key, value);
		}		
	}
	
	public String get2(String key) {
		synchronized(this) {
			return mMap2.get(key);
		}
	}
}

1. 일반 함수 put1(), get1(), put2(), get2()를 갖는 SyncBlock1객체를 생성합니다.

2. 객체는 두개의 hashmap을 갖습니다.

3. 네개의 함수 내부에 동기화가 필요한 부분을 synchronized(this) {...}로 묶습니다.

4. 두개의 쓰레드로 서로 번갈아 가면서 네개의 함수를 만번 호출합니다.


이 경우 네개의 메서드 안의 synchronized(this) 블럭으로 감싸진 부분은 동시에 불리지 않습니다.

어떤 쓰레드든지 synchronized(this) block에 들어가는 순간 자원을 선점하고 lock을 걸어 둡니다.

단 lock의 주체가 this이기 때문에 this로 걸려있는 동기화 block은 해당 lock이 풀릴때까지 대기해야 합니다.


하지만 lock이 필요한건 같은 hashmap을 동시에 접근하는 경우라고 한다면 put1()과 get1(), put2()와 get2()이 각각 lock을 사용하면 좋을것 같습니다.

따라서 아래와 같이 수정하면 훨씬 효율적인 코드가 됩니다.

public class SyncBlock2 {
	private HashMap<String, String> mMap1 = new HashMap<>();
	private HashMap<String, String> mMap2 = new HashMap<>();
	
	private final Object object1 = new Object();
	private final Object object2 = new Object();
	
	public static void main(String[] agrs) {
		SyncBlock2 syncblock2 = new SyncBlock2();
		System.out.println("Test start!");
		new Thread(() -> {
			for (int i = 0; i<10000; i++) {
				syncblock2.put1("A","B");
				syncblock2.get2("C");
				}			
		}).start();
		
		new Thread(() -> {
			for (int i = 0; i<10000; i++) {
				syncblock2.put2("C","D");
				syncblock2.get1("A");
				}
		}).start();
		System.out.println("Test end!");
		
	}
	
	public void put1(String key, String value) {		
		synchronized(object1) {
			mMap1.put(key, value);
		}
	}
	
	public String get1(String key) {
		synchronized(object1) {
			return mMap1.get(key);
		}
	}
	
	public void put2(String key, String value) {		
		synchronized(object2) {
			mMap2.put(key, value);
		}		
	}
	
	public String get2(String key) {
		synchronized(object2) {
			return mMap2.get(key);
		}
	}
}


this가 아닌 object1과 object2 객체를 만들어 this가 아닌 동시에 lock 걸려야 하는 부분을 따로 지정해 줄 수 있습니다.


이제 위에서 this에 lock을 설정하는게 왜 무식하지만 간단하면서 확실하다고 했는지 이해가 가시는지요? ^^


그렇다면 함수가 static이라면 어떻게 동작할까요?

아니면 객체가 singleton이라면??

다음 포스팅에 이어서 진행하겠습니다.

https://tourspace.tistory.com/55?category=788398

반응형