-
Thread(5) synchronized를 이용한 스레드 동기화java/Thread 2021. 6. 8. 22:04
스레드의 작업 내용이 짧으면 우리가 원하는 결과가 나타나지 않을 가능성이 생기게 된다.
일단 이 문제가 다른 결과를 보이는 것은 뒤에 가서 설명하고 먼저 스레드의 동기화 필요성에 대해서 설명한다.
스레드의 동기화
한 스레드가 진행중인 작업을 다른 스레드가 간섭하지 못하게 하는것
동기화의 필요성
스레드간에는 공유하는 자원이 있기 때문에 이 공유자원들에 대하여 여러 스레드에서 동시에 작업하면 문제가 발생할 수 있다. 그렇게 떄문에 스레드의 동기화가 필요하다.
두 스레드에서 하나의 Counter객체를 공유해서 사용해서 발생하는 문제를 봐보자.
하나의 Counter 클래스는 count값과 count값을 1증가시키는 increase메서드가 있다.
Counter sharedCounter= new Counter(); 에 대하여
두 스레드가 이 Counter객체를 공유하여 각각 Counter의 count를 10만번 증가시켜 총 20만번 증가시킨다.
두 스레드가 작업을 모두 끝날때까지 main스레드를 대기시킨후 count값을 확인한다.
각 스레드의 작업이 완전히 끝났는지 스레드 작업 종료시 println을 꼭 남겨주자. main스레드에서 각 스레드가 작업이 종료될때 count값을 잘 확인하기 위해 sleep값을 적절히 조절해야 한다. (각 스레드가 작업 중에 main스레드에서 count값을 확인하면 우리가 원하는 결과가 나타나지 않기 때문에)
public class Problem { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a=new Thread(new MyRunnable(sharedCounter)); Thread b=new Thread(new MyRunnable(sharedCounter)); a.start(); b.start(); try { Thread.sleep(2000); //위의 두 스레드가 끝나기 위해 잠시 main스레드 정지 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); //main스레드에서 Counter객체의 count값 확인 } } class Counter { private int count = 0; public void increase() { //1증가시키는 메서드 count++; } public int getCount() { //count를 확인하는 메서드 return count; } } class MyRunnable implements Runnable { Counter counter; MyRunnable(Counter counter) { this.counter = counter; } public void run() { //스레드 작업 내용- Counter의 increase메서드를 만번 호출하여 count값을 십만 증가시킴 System.out.println(Thread.currentThread().getName()+" 시작"); for (int i = 1; i <= 100000; i++) { counter.increase(); } System.out.println(Thread.currentThread().getName()+" 종료"); } }
결과는 20만이 될까?
실행 시켜본 결과 200000에 미치지 못하는 값들이 계속 출력되었다.
(20만이 계속 잘 찍힌다면 백만번으로 바꿔놓고 확인해보자)
왜 이러한 일이 발생할까??
count++의 경우
1. 메모리에서 count값을 read -> 2. 읽은 값을 토대로 count값을 1증가 -> 3. increase한 값을 메모리에 write
이 일련의 1~3의 작업이 일어난다.
스레드A가 작업중이라면 1~3의 작업이 모두 끝나고 다른 스레드B에서 count값을 read하면 상관없지만 타이밍이 안좋게 스레드A의 1~3작업중 중간 과정에서 cpu제어권이 스레드B로 넘어가면 스레드B가 값을 읽어버려 이상한 결과가 나타나는 것이다.
마찬가지로 스레드B가 작업중 1~3의 중간과정에 스레드A로 제어권이 넘어가서 count값을 읽게되면 똑같은일이 벌어진다.
ex) count가 0일때 스레드A가 count값을 읽어 증가시키는데 증가된 값을 메모리에 쓰기전에 스레드B가 프로세서 제어권을 얻어 count값을 읽는다. 그렇다면 스레드가 A가 1값을 write하기 전에 스레드 B가 0이라는 값을 읽어 1을 증가시키면 count는 1이 된다. 스레드 A도 0에서 하나를 증가시켰으므로 count는 1이 된다. 우리가 기대한 2가 아닌 것이다.
다시 한번 정리하면 count++실행도중 다른 스레드에게 제어권이 넘어가면서 이때 값의 오류가 발생하는 것이다.
블로그 처음에 언급했던 것처럼 스레드의 작업 내용이 짧으면 어떻게 될까?
각 스레드에서 10만이 아닌 100을 증가시키는 코드로 바꾸고 실행시켜보자.
for문을 십만에서 100번 반복으로만 변경시킨 코드
public class Problem { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a=new Thread(new MyRunnable(sharedCounter)); Thread b=new Thread(new MyRunnable(sharedCounter)); a.start(); b.start(); try { Thread.sleep(2000); //위의 두 스레드가 끝나기 위해 잠시 main스레드 정지 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); //main스레드에서 Counter객체의 count값 확인 } } class Counter { private int count = 0; public void increase() { //1증가시키는 메서드 count++; } public int getCount() { //count를 확인하는 메서드 return count; } } class MyRunnable implements Runnable { Counter counter; MyRunnable(Counter counter) { this.counter = counter; } public void run() { //스레드 작업 내용- Counter의 increase메서드를 만번 호출하여 count값을 1000증가 System.out.println(Thread.currentThread().getName()+" 시작"); for (int i = 1; i <= 100; i++) { //100번증가로 변경 counter.increase(); } System.out.println(Thread.currentThread().getName()+" 종료"); } }
결과
Thread-1 시작
Thread-0 시작
Thread-1 종료
Thread-0 종료
200일단 공유 객체에 동시에 접근했는데 한번도 엇갈린적 없이 200이 제대로 찍힌다. 왜 그럴까??
cpu는 스레드에게 정해진 시간동안 제어권을 주고 다른 스레드에게 제어권을 넘긴다.
스레드의 작업 내용이 100번 반복으로 매우 짧아서 스레드a가 count를 100번 카운팅을 이미 한 상태에서 스레드b로 제어권이 넘어가 count를 100번 카운팅한다. 그래서 실제로는 count변수에 스레드의 동시 접근이 이루어지지 않아 동기화 문제가 발생하지 않았다.
반복문의 i를 조금씩 증가시켜 보면(한번 증가 시킬때마다 한 5번씩 실행시켜보자) 오차가 없다가 오차가 조금씩 발생하다 i가 매우 커지면 그만큼 오차도 커지게 될 것이다.
컴퓨터 환경마다 다르다. 내 컴퓨터의 경우
i를 1000으로 바꿔 여러번 실행한 결과
1980, 2000, 1833, 2000, 1985, 1963
i를 10000으로 바꿔 여러번 실행한 결과
18758, 16558, 14952, 18949, 19621
i를 100000(10만)으로 바꿔 여러번 실행한 결과
159585, 182282, 141646, 167444, 194855, 153455
i를 100만으로 바꿔 여러번 실행한 결과
1949573, 1960203, 1075405, 1927927
synchronized 키워드는 스레드를 동기화 시키는 간단하고 편리한 방법이다.
동기화를 설명하기 전에 여러 용어를 살펴보자.
임계영역
스레드간에서 공유 자원을 사용하게 되는 코드부분을 임계영역이라고 부른다.
이 임계영역 부분이 존재하게 되면 스레드를 동기화 해야할 필요성이 있다.
lock과 synchronized키워드
모든 객체는 lock을 가지고 있다. (Object의 wait, notify, notifyAll메서드들이 이 lock의 획득, 반납과 관계가 있다)
wait, notify, notifyAll에 대해서는 다음 글에서 다뤄보겠다.
synchronized를 사용하는 법은 일단 두가지로 synchronized메서드, synchronized블록을 이용한다.
또한 synchronized를 instance메서드에 사용하는 것과 static메서드에 사용하는 것에 차이가 있다.
이 차이는 뒤에서 설명하겠다.
먼저 설명할 것은 한 스레드에서 synchronized메서드나 synchronized블록 진입시 스레드가 객체의 lock을 획득한다.
synchronized 메서드 종료시, synchronized블록의 마지막을 빠져나올 때 객체의 lock을 반납한다. 정도만 이해하고 다음 내용을 봐보자.
스레드가 동기화된 메서드, 동기화 블록 진입시 객체의 lock을 획득하고 빠져나올시 객체의 lock을 반납한다.
synchronized메서드
스레드가 메서드에 진입하면 this객체에 해당하는 객체의 lock을 얻는다. 메서드가 종료될때 lock을 반납한다.
lock의 획득과 반납- 메서드의 시작과 종료
class MyClass { public synchronized void method() { // 스레드가 메서드 진입시 this-> MyClass객체에 대한 lock을 얻는다. //임계 영역 } }
위의 예제에서 Counter객체의 increase메서드에 두 스레드가 동시에 접근하여 문제가 발생하였다.
increase메서드를 synchronized키워드를 사용하여 synchronized메서드로 만들어서 문제를 해결해보자.
public class Solution1 { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a=new Thread(new MyRunnable(sharedCounter)); Thread b=new Thread(new MyRunnable(sharedCounter)); a.start(); b.start(); try { Thread.sleep(2000); //위의 두 스레드가 끝나기 위해 잠시 main스레드 정지 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); //main스레드에서 Counter객체의 count값 확인 } } class Counter { private int count = 0; public synchronized void increase() { //1증가시키는 메서드 count++; } public int getCount() { //count를 확인하는 메서드 return count; } } class MyRunnable implements Runnable { Counter counter; MyRunnable(Counter counter) { this.counter = counter; } public void run() { //스레드 작업 내용- Counter의 increase메서드를 만번 호출하여 count값을 1000증가 System.out.println(Thread.currentThread().getName()+" 시작"); for (int i = 1; i <= 10000000; i++) { //1000만번 카운팅 counter.increase(); } System.out.println(Thread.currentThread().getName()+" 종료"); } }
스레드 작업 내용이 짧으면 동기화 문제가 발생하지 않을 가능성이 있으므로
각 스레드의 작업내용을 충분히 길게하여 1000만번 카운팅으로 변경하였다.
결과
Thread-1 시작
Thread-0 시작
Thread-1 종료
Thread-0 종료
20000000몇번을 실행시켜봐도 이상한 값이 나오지않고 잘나온다.
위의 결과에 대한 설명이다.
synchronized메서드를 동기화된 메서드라고 지칭하겠다.
스레드a 에서 동기화된 메서드가 호출될때 this객체의 lock을 획득한다. 스레드a에서 함수가 종료될때마다 this객체의 lock을 반납한다. 제어권이 a에게 계속있다면 a의 메서드 호출마다 lock을 얻었다가 반납하는 반복과정을 하게 되겠다.
그러나 스레드a가 동기화된 메서드의 count++을 수행하기 시작할때 cpu제어권이 스레드b로 넘어가서 스레드b가 같은 동기화된 메서드를 호출한다면
스레드 b는 메서드에 설정된 this객체의 lock을 가질수 없기 때문에 (이미 스레드a가 가져가서 반납하지 않음)
메서드 앞에서 block상태로 있다가 스레드a가 count++를 수행하고 this객체의 lock을 반납하면 그제서야 스레드b가 increase메서드를 수행한다.
스레드A와 스레드B가 synchronized메서드에는 동시에 작업을 할 수는 없어 문제가 발생하지 않게된다.
(멤버의 접근 제한자는 private로 설정되어야 한다. 그렇지 않으면 외부에서 접근이 가능하여 아무리 동기화를 시켜도 이 값의 변경을 막을 길이 없다.) -> 추가적으로 공부해야하지만 이점에 대해서는 뒤에 가서 조금 건드려보겠다.
위의 예제에서 일반 메서드를 synchronized로 바꿔 synchronized메서드로 변경시켜봤다.
그렇다면 객체내에 여러 synchronized메서드가 존재할 때 각 스레드에서 서로 다른 synchronized메서드를 동시에 호출할 때 동시에 수행될수 있을까?
결과를 먼저 말하면 한 스레드에서 synchronized메서드가 수행되는 중에 다른 스레드에서 다른 synchronized메서드는 수행될 수 없고 메서드 앞에서 대기상태로 있는다.
밑의 코드를 보자.
package threadTest; public class Synchronized1 { public static void main(String[] args) { Counter sharedCounter = new Counter(); new Thread(new MyRunnable1(sharedCounter)).start(); //synchronized 메서드 실행 try { Thread.sleep(12); // 인위적으로 두 스레드의 시작시간을 엇갈리게 한다. } catch (Exception e) { // TODO: handle exception } new Thread(new MyRunnable2(sharedCounter)).start(); //다른 synchronized 메서드 실행 try { Thread.sleep(2000); // 위의 두 스레드가 끝나기 위해 잠시 main스레드 정지 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 System.out.println("main 종료"); } } class Counter { private int count = 0; public synchronized void syncMethod() { System.out.println("synchronized 메서드 시작"); for(long l=0;l<10000000000L;l++) { } System.out.println("synchronized 메서드 종료"); } public synchronized void syncIncrease() { // count++; } public int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { counter.syncMethod(); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { for (int i = 1; i <= 10000; i++) { counter.syncIncrease(); } } }
결과
Thread-0시작
synchronized 메서드 시작
Thread-1시작
0
synchronized 메서드 종료
Thread-0종료
Thread-1종료
10000syncMethod의 for문 작업이 int로 하기에는 너무 빨리 끝나서 long으로 변경하여 충분히 긴 작업으로 바꿔주었다.
두 스레드를 거의 동시에 시작시키되 syncMethod메서드를 먼저 시작되도록 보장하기 위해 main스레드 sleep(12)를 넣어주었다.
두 스레드 시작후 2초 후에 count값을 확인한 결과 0으로 카운팅하는 메서드는 한번도 실행되지 않음을 확인 할 수 있다. println에는 남기지 못했지만 먼저 실행된 synchronized 메서드가 종료되고나서야 count를 증가시키는 메서드가 만번 연달아 실행될 것이다.
컴퓨터 환경에 따라 다르기 때문에 syncMethod중간에 count를 확인하고 모든 스레드가 끝날때 count값을 확인 할 수 있도록 sleep과 반복문 변수를 잘 조절하자.
다음예제는 스레드에서 synchronized메서드 수행시 this객체의 lock을 얻었을 때
일반 메서드의 수행은 어떻게 될 것 일까이다.
혼자 공부하면서 lock객체를 얻으면 this객체를 잠가버리는 것으로 생각했지만 이것이 아니였고 단순히 lock을 사용할 객체를 지정하는 것이다. lock은 synchronized메서드나 synchronized블록에만 해당되는 것으로 일반 메서드나 synchronized블록이 없는 것에 대해서는 적용되지 않는다.
이 부분에 대해서도 애를 많이 먹었다....객체의 lock은 synchronized가 적용된 메서드나 synchronized블록에만 해당되므로 일반 메서드는 언제든지 어느 스레드에 의해서 실행될 수 있다.
lock의 개념은 synchronized키워드가 적용된 놈에 대해서만 적용된다. 스레드가 객체의 lock을 획득하는 것은 단지 얻고 반납하는 것이지 lock을 얻으면 객체가 잠기는 것이 아니다.
귀찮으니까 바로 위의 예제의 카운팅을 하는 동기화 메서드를 그냥 synchronized를 없애 일반 메서드로 변경시켜 확인해보면 된다.
public class SeveralSyncMethod { public static void main(String[] args) { Counter sharedCounter = new Counter(); new Thread(new MyRunnable1(sharedCounter)).start(); //스레드a- synchronized 메서드 실행 try { Thread.sleep(12); // 앞의 스레드가 먼저 실행되도록 보장시킴 } catch (Exception e) { // TODO: handle exception } new Thread(new MyRunnable2(sharedCounter)).start(); //스레드b- 다른 synchronized 메서드 실행 try { Thread.sleep(2000); //스레드a 작업중에 count값을 출력시키도록 sleep값 조절 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 try { Thread.sleep(4000); //스레듬a,b둘다 종료될 때 count값을 확인하도록 sleep값 조절 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 } } class Counter { private int count = 0; public synchronized void syncMethod() { System.out.println("synchronized 메서드 시작"); for (long l = 0; l < 10000000000L; l++) { } System.out.println("synchronized 메서드 종료"); } public void syncIncrease() { // count++; } public int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { System.out.println(Thread.currentThread().getName()+ "시작"); counter.syncMethod(); System.out.println(Thread.currentThread().getName()+ "종료"); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { System.out.println(Thread.currentThread().getName()+ "시작"); for (int i = 1; i <= 10000; i++) { counter.syncIncrease(); } System.out.println(Thread.currentThread().getName()+ "종료"); } }
결과
Thread-0시작
synchronized 메서드 시작
Thread-1시작
Thread-1종료
10000 //synchronized 메서드 수행중 다른 스레드에서 일반 메서드가 대기상태가 아니라 수행중임(여기서는 만번 다카운트했다 이미)
synchronized 메서드 종료
Thread-0종료
10000
지금까지의 내용 정리
synchronized메서드가 호출될 때 스레드가 lock을 얻는것은 this객체의 lock만 얻는 것이지 ,this 객체 전체가 잠긴다는 것이 아니다.
스레드가 한 인스턴스의 synchronized 메서드에 진입시 this객체의 lock을 얻는다.
synchronized메서드 수행중 다른 스레드에 제어권이 넘어가 다른 스레드가 어떠한 synchronized메서드를 호출해도 this객체의 lock이 없어 수행되지 않고 먼저 스레드가 this객체의 lock을 반납할때 수행된다.
일반 메서드는 이 객체의 lock과 관련이 전혀없다. 언제든 어떠한 스레드에 의해서도 이 인스턴스의 일반 메서드는 호출될 수 있다.
synchronized블록에서 설명하겠지만 lock을 얻고 반납하는 것은 JVM이 이 객체의 멤버를 못쓰게 자동으로 관리시켜주는 것이 아니다. 개발자가 단순히 어떠한 기능에 따라 기능에 맞는 lock객체를 만들어 어떠한 lock을 선택할 것인가 정하는 것이다. synchronized메서드는 사용되는 lock이 this객체의 lock으로 고정된다.
synchronized블록
일단 synchronized메서드와 동일한 synchronized블록을 만들어주었다.
매개변수에는 스레드가 메서드 진입,종료시-> 획득하거나, 반납하는 객체의 lock을 지정하는 매개변수가 있다.
synchronized메서드의 시작시 lock획득 종료시 lock반납이 일어났다. 그리고 this객체의 lock을 사용하는 것이 고정되었다.
synchronized블록은 블록시작시 매개변수에 지정한 객체의 lock을 획득하고 , 블록종료시 lock반납이 일어난다.
class MyClass { public void method() { synchronized(this) { //임계영역 } } }
이 synchronized블록은 method(){ 와 synchronized(this){ 의 사이에 아무런 코드가 없으면 synchronized메서드와 완전히 같다.
synchronized블록의 장점
synchronized블록은 임계영역을 줄일 수 있고, 메서드 진입시 획득할 객체의 lock을 지정할 수 있다.
class MyClass { public void method() { // 여러 스레드가 실행 가능한 영역 synchronized (지정객체) { // 임계 영역 축소(공유 객체와 관련된 코드) } } }
메서드 내에서 동기화 시켜야할 부분과 동기화 안시켜도되는 부분을 나눠 임계영역을 줄일 수 있다. 임계영역이 넓을수록 성능이 떨어진다.
synchronized블록에서 매개변수를 this로 설정하면
자동으로 this객체를 사용하는 synchronized메서드와 같은 결과를 보였다.
그렇다면 synchronized블록의 매개변수 argument를 this말고 다른 객체를 설정하는 것에 대해 알아보자.
객체 내에서 lock으로 사용할 Object 객체를 만들어주었다.
객체 내에서 lock으로 사용할 객체를 만들면 보안면에서 좋다고 한다. 또한 final로 설정하여야 한다고 써있다.
그래서 어쩌라고?? 생각할 것이다.
synchronized블록 진입시 Object객체의 lock을 사용하면 synchronized(this)블록과 무슨 차이가 있을까?
public class SeperatedLockObject { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a = new Thread(new MyRunnable1(sharedCounter)); Thread b = new Thread(new MyRunnable2(sharedCounter)); a.start(); b.start(); try { Thread.sleep(4000); // 두 스레드 종료될때까지 충분한 시간동안 main스레드 대기 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 System.out.println("main 종료"); } } class Counter { private int count = 0; private final Object obj1 = new Object(); private final Object obj2 = new Object(); public void increase1() { synchronized (obj1) { count++; } } public void increase2() { synchronized (obj2) { count++; } } public int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { System.out.println("obj1으로 설정된 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase1(); } System.out.println("obj1으로 설정된 것 끝"); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { System.out.println("obj22로 설정된 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase2(); } System.out.println("obj2로 설정된 것 끝"); } }
결과
obj1으로 설정된 것 시작
obj2로 설정된 것 시작
obj1으로 설정된 것 끝
obj2로 설정된 것 끝
1577674
main 종료Counter객체에 lock으로 사용할 객체 두개를 만들어서 두개의 increase1, increase2 메서드 내부에 synchronized블럭으로 각각의 객체로 lock을 사용하였다. 두 동기화 블록의 동작을 보면
특정 스레드가 increase1메서드 내부의 동기화블록에 진입할 때 obj1객체의 lock을 사용하고(얻고)
특정 스레드가 increase2메서드 내부의 동기화블록에 진입할 때 obj2객체의 lock을 사용한다.(얻는다)
obj1로 설정된 동기화 블록은 obj1으로 설정된 다른 obj1 동기화 블록들만 대기 시킨다.
-> 한 인스턴스에서 몇개의 스레드가 여러개의 obj1동기화 블록에 접근하든 동시에 하나의 obj1동기화 블록만 수행된다.
obj2로 설정된 동기화 블록은 obj2로 설정된 다른 obj2동기화 블록들만 대기 시킨다.
-> 한 인스턴스에서 몇개의 스레드가 여러개의 obj2동기화 블록에 접근하든 동시에 하나의 obj2동기화 블록만 수행된다.
결과적으로 이 코드의 두개의 메서드의 내부 동기화 블록에 스레드가 블록진입시 얻고 반납하는 lock객체가 달라서
동시에 접근 가능하기 때문에 동기화 문제가 발생한다.
그렇기 때문에 200만의 count값이 제대로 나오지 않는다.
혼자서 공부할떄 lock은 객체를 잠그는 개념이다라고 생각해서 this객체로 lock을 사용하면 this객체 전체가 잠긴다. 그러므로 obj1으로 설정된 동기화 블록도 잠긴다. 반면 obj1으로 lock을 사용하면 ocj1이외에는 모두 사용가능하다. 이렇게 생각했다. lock의 잠근다는 것이 나를 이토록 헷갈리게 하였다. 이것이 아니다!!!!
두 동기화 블록 모두 공통의 객체를 lock으로 사용하도록 바꾸면 카운팅이 잘 나올 것이다.
두 동기화 블록 모두 obj1으로 설정하던지 obj2로 설정하든지 둘중하나로 바꾼다.
public class SeperatedLockObject { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a = new Thread(new MyRunnable1(sharedCounter)); Thread b = new Thread(new MyRunnable2(sharedCounter)); a.start(); b.start(); try { Thread.sleep(4000); // 두 스레드 종료될때까지 충분한 시간동안 main스레드 대기 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 System.out.println("main 종료"); } } class Counter { private int count = 0; private final Object obj1 = new Object(); private final Object obj2 = new Object(); public void increase1() { synchronized (obj1) { //synchronized(obj2) count++; } } public void increase2() { synchronized (obj1) { //synchronized(obj2) count++; } } public int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { System.out.println("obj1으로 설정된 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase1(); } System.out.println("obj1으로 설정된 것 끝"); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { System.out.println("obj1로 설정된 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase2(); } System.out.println("obj1로 설정된 것 끝"); } }
결과
obj1으로 설정된 것 시작
obj1로 설정된 것 시작
obj1으로 설정된 것 끝
obj1로 설정된 것 끝
2000000
main 종료(두 동기화 블록에서 사용할 lock객체를 obj2로 바꿔도 동일한 결과)
synchronized메서드와 내부 객체를 lock으로 사용하는 synchronized블록이 함께 있을때 확인하는 예제
public class SeperatedLockObject { public static void main(String[] args) { Counter sharedCounter = new Counter(); Thread a = new Thread(new MyRunnable1(sharedCounter)); Thread b = new Thread(new MyRunnable2(sharedCounter)); a.start(); b.start(); try { Thread.sleep(4000); // 두 스레드 종료될때까지 충분한 시간동안 main스레드 대기 } catch (Exception e) { e.printStackTrace(); } System.out.println(sharedCounter.getCount()); // main스레드에서 Counter객체의 count값 확인 System.out.println("main 종료"); } } class Counter { private int count = 0; private final Object obj1 = new Object(); private final Object obj2 = new Object(); public synchronized void increase1() { //메서드 진입시 스레드가 this객체의 lock을 사용 count++; } public void increase2() { synchronized (obj2) { //메서드 내부의 synchronized블록 진입시 스레드가 obj2객체의 lock을 사용 count++; } } public int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { System.out.println("this객체의 lock을 사용하는 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase1(); } System.out.println("this객체의 lock을 사용하는 것 끝"); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { System.out.println("obj2객체의 lock을 사용하는 것 시작"); for (int i = 1; i <= 1000000; i++) { counter.increase2(); } System.out.println("obj2객체의 lock을 사용하는 것 종료"); } }
결과
this객체의 lock을 사용하는 것 시작
obj2객체의 lock을 사용하는 것 시작
this객체의 lock을 사용하는 것 끝
obj2객체의 lock을 사용하는 것 종료
1762354
main 종료바로 위의 전의 예제와 같은 결과다. this랑 obj2랑 사용하는 lock이 달라서 동기화가 안된다.
메서드는 this객체, 동기화 블록은 obj1객체를 사용하므로 스레드가 메서드나 동기화 블록에 접근할때 서로 다른 lock을 사용하고 있다. 동기화 문제가 발생한다.
내가 공부하면서 가장 헷갈렸던 것이 lock의 개념이다. 객체마다 lock을 가지고 있고 이 lock이 스레드가 메서드나 동기화 블록에 진입할때 지정된 객체를 lock(잠근다)하여 멤버를 못쓰게 만든다 라고 생각하면 안된다. 그저 lock을 획득하고 반납하는 것을 통해 개발자가 알아서 용도에 맞게 lock을 사용하는 것이다. 그렇기 때문에 멤버를 private로 선언해야하는 추가적인 이유까지 이해가 될 것이다.
아직도 내부 객체를 만들어 lock을 사용하는 것에 대해 이해가 안되는가? 그렇다면 이렇게 봐보자.
class Service { private int count = 0; private final Object networkLock = new Object(); //네트워크 기능과 관련된 자물쇠 private final Object writeLock = new Object(); //메모리에 쓰는 자물쇠 public void increase() { synchronized (writeLock) { count++; } } public void decrease() { synchronized (writeLock) { count--; } } public void networkMethod1() { synchronized (networkLock) { //네트워크 작업1 } } public void networkMethod2() { synchronized (networkLock) { //네트워크 작업2 } } }
증가 감소 카운팅 관련 메서드들은 writeLock을 사용하도록 하고
네트워크 관련 메서드들은 networkLock을 사용하도록 한다.
이런식으로 기능별로 lock을 정하여 더욱 편리하게 쓸 수 있다. synchronized메서드는 lock을 this하나만 쓸 수 있고
위의 increase(), decrease(), networkMethod1() ,networkMethod2()를 모두 synchronized메서드로 만들면 동시에 하나밖에 수행될 수 없다. 그렇기 때문에 synchronized블록의 lock객체 지정가능성 때문에 더 나은 코드를 만들 수 있는 것이고 이쪽으로 선택을 할 수 밖에 없다.
지금까지 instance메서드에 synchronized메서드와 synchronized블록 설정하는 것을 알아보았다.
객체의 내부에 lock을 두개의 lock obj1, obj2를 만들었다고 하자.
한 객체 인스턴스에서
몇개의 스레드가 obj1으로 지정된 동기화 블록에 동시에 접근한다고 하여도 가장 먼저 lock을 가져간 스레드가 실행하고 있는 obj1블록만 실행이 된다.
obj2로 지정된 동기화 블록중 동시에 하나만이 실행 가능하다.
sync메서드(동기화 메서드는 this객체의 lock이랬다) 혹은 this로 설정된 동기화 블록중 하나만이 동시에 실행 가능하다.
인스턴스메서드에 동기화 블록을 적용하면 두개의 서로 다른 인스턴스에서 각각의 synchronized메서드는 동시에 호출이 될 수 있다. 하나의 인스턴스에 대하여 동기화시키는 것이 synchronized를 써야하는 이유니까.
그러나
static메서드에 동기화 블록을 사용하면 어떻게 될까??
static동기화 메서드는 인스턴스 단위로 동기화를 시키지 않고 클래스 단위로 동기화를 시킨다.
모든 인스턴스에 대해서 static동기화 메서드는 여러 스레드에의해 동시에 수행되지 않는다. 클래스 메서드나 클래스 변수들은 static영역에 존재하므로 여러 인스턴스들과 관계없이 클래스에 대해서 공통된 값,기능을 가지기 떄문에 static 동기화 메서드는 여러 인스턴스에 상관없이(개별객체인지 공유객체인지) 클래스 동기화가 이루어진다고 생각하면 쉬울 것이다.
앞서서 synchronized의 개념을 자세하게 다뤘기 때문에 static동기화는 대충 한다....힘들다......
public class StaticSynchronized { public static void main(String[] args) { // Counter객체를 공유시키지 않기 위해 두개를 만듦 Counter seperatedCounter1 = new Counter(); Counter seperatedCounter2 = new Counter(); Thread a = new Thread(new MyRunnable1(seperatedCounter1)); Thread b = new Thread(new MyRunnable2(seperatedCounter2)); a.start(); b.start(); try { Thread.sleep(4000); // 두 스레드 종료될때까지 충분한 시간동안 main스레드 대기 } catch (Exception e) { e.printStackTrace(); } System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 System.out.println("main 종료"); } } class Counter { private static int count = 0; public static synchronized void increase1() { count++; } public static synchronized void increase2() { count++; } public static int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { for (int i = 1; i <= 1000000; i++) { Counter.increase1(); } } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { for (int i = 1; i <= 1000000; i++) { Counter.increase2(); } } }
결과
2000000
main 종료Counter의 static메서드는 인스턴스와 관계없이 클래스와 관계가 있는 것이다. static메서드에 동기화를 시켰으므로 Counter.class에 동기화를 맞춘다. 쉬운 설명을 위하여 별개의 인스턴스를 사용하게 하도록 하였지 두 스레드가 별개의 Counter인스턴스를 사용하여도 이는 아무런 연관이 없는것이다.
다음 예제 설명생략
public class StaticSynchronized { public static void main(String[] args) { // Counter객체를 공유시키지 않기 위해 여러개를 만듦 Counter seperatedCounter1 = new Counter(); Counter seperatedCounter2 = new Counter(); Thread a = new Thread(new MyRunnable1(seperatedCounter1)); Thread b = new Thread(new MyRunnable2(seperatedCounter2)); a.start(); b.start(); try { Thread.sleep(2000); System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 } catch (Exception e) { e.printStackTrace(); } try { Thread.sleep(2000); System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 } catch (Exception e) { e.printStackTrace(); } } } class Counter { private static int count = 0; public static synchronized void syncMethod() { System.out.println("static syncMethod()시작"); System.out.println("메서드 안에서 스레드가 Counter.class락을 가지고 정지상태"); try { Thread.sleep(3000); } catch (Exception e) { // TODO: handle exception } System.out.println("static syncMethod()종료"); } public static synchronized void increase() { count++; } public static int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { Counter counter; MyRunnable1(Counter counter) { this.counter = counter; } public void run() { Counter.syncMethod(); } } class MyRunnable2 implements Runnable { Counter counter; MyRunnable2(Counter counter) { this.counter = counter; } public void run() { for (int i = 1; i <= 1000000; i++) { Counter.increase(); } } }
결과
static syncMethod()시작
메서드 안에서 스레드가 Counter.class락을 가지고 정지상태
0
static syncMethod()종료
1000000
static메서드의 동기화는 인스턴스 레벨로 이루어지지 않고 Class레벨로 이루어지기 때문에 위의 예제코드의 인스턴스를 만들지 않아도 된다. static메서드가 호출될시에도 클래스 로딩이 이루어지기 때문에 다음과 같이 변경시켜서 테스트를 해도 된다.
public class StaticSynchronized { public static void main(String[] args) { Thread a = new Thread(new MyRunnable1()); Thread b = new Thread(new MyRunnable2()); a.start(); b.start(); try { Thread.sleep(3000); System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 } catch (Exception e) { e.printStackTrace(); } } } class Counter { private static int count = 0; public static void staticIncrease() { //synchronized count++; } public static void staticDecrease() { //synchronized count--; } public static int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticIncrease(); } } } class MyRunnable2 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticDecrease(); } } }
0값이 출력이 되지 않는다.
static메서드를 동기화 시켜 0값이 출력되게 만들어 보자.
public class StaticSynchronized { public static void main(String[] args) { Thread a = new Thread(new MyRunnable1()); Thread b = new Thread(new MyRunnable2()); a.start(); b.start(); try { Thread.sleep(3000); System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 } catch (Exception e) { e.printStackTrace(); } } } class Counter { private static int count = 0; public static synchronized void staticIncrease() { count++; } public static synchronized void staticDecrease() { count--; } public static int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticIncrease(); } } } class MyRunnable2 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticDecrease(); } } }
static synchronized메서드는 클래스명.class에 대한 lock을 사용한다. 밑의 synchronized블록으로 변경이 가능하다. static 메서드(){ synchronized(클래스명.class){ } }
동일하게 변경된 synchronized블록
public class StaticSynchronized { public static void main(String[] args) { Thread a = new Thread(new MyRunnable1()); Thread b = new Thread(new MyRunnable2()); a.start(); b.start(); try { Thread.sleep(3000); System.out.println(Counter.getCount()); // main스레드에서 Counter객체의 count값 확인 } catch (Exception e) { e.printStackTrace(); } } } class Counter { private static int count = 0; public static void staticIncrease() { synchronized (Counter.class) { count++; } } public static void staticDecrease() { synchronized (Counter.class) { count--; } } public static int getCount() { // count를 확인하는 메서드 return count; } } class MyRunnable1 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticIncrease(); } } } class MyRunnable2 implements Runnable { public void run() { for (int i = 1; i <= 1000000; i++) { Counter.staticDecrease(); } } }
synchronized메서드와 synchronized블록의 차이
인스턴스 메서드에 적용하는가 스태틱 메서드에 적용될때의 차이
synchronized메서드, 블록에 스레드가 접근시 lock이 어떻게 적용되는지를 알아보았다
'java > Thread' 카테고리의 다른 글
[Thread] InterruptedException과 인터럽트 상태 (0) 2022.06.12 [Thread] Daemon Thread (0) 2021.05.27 [Thread] Thread, ThreadGroup (0) 2021.05.27