Java 스레드 동기화
두 개 이상의 스레드가 리소스를 공유해야 하는 경우 특정 순간에 하나의 스레드만 리소스를 점유하는지 확인하는 방법이 필요합니다. 이를 달성하는 프로세스를 동기화라고 합니다. 보시다시피 Java는 이에 대한 고유한 언어 수준 지원을 제공합니다.
동기화의 핵심은 모니터(세마포어라고도 함) 개념입니다. 모니터는 상호 배타적인 잠긴 객체, 즉 뮤텍스입니다. 주어진 시간에 하나의 스레드만 모니터를 얻을 수 있습니다. 스레드에 잠금이 필요한 경우 모니터에 들어가야 합니다. 잠긴 튜브에 들어가려는 다른 모든 스레드는 첫 번째 스레드가 튜브에서 나올 때까지 멈춰야 합니다. 이러한 다른 스레드를 대기 모니터라고 합니다. 모니터를 소유한 스레드는 원할 경우 동일한 모니터에 다시 들어갈 수 있습니다.
C나 C++ 등 다른 언어에서 동기화를 사용해보신 분들이라면 사용법이 좀 이상하다는 걸 아실 겁니다. 이는 많은 언어가 동기화 자체를 지원하지 않기 때문입니다. 대조적으로, 동기화된 스레드의 경우 프로그램은 운영 체제 소스 언어를 사용해야 합니다. 다행스럽게도 Java는 언어 요소를 통해 동기화를 구현하므로 동기화와 관련된 복잡성이 대부분 제거됩니다.
두 가지 방법으로 코드를 동기화할 수 있습니다. 두 방법 모두 동기화 키워드의 사용을 포함합니다. 두 가지 방법은 아래에 설명되어 있습니다.
동기화 방법 사용
Java의 동기화는 모든 개체에 해당하는 암시적 모니터가 있으므로 간단합니다. 객체의 모니터에 진입한다는 것은 동기화된 키워드에 의해 수정된 메소드를 호출하는 것입니다. 스레드가 동기화된 메서드 내에 있으면 해당 메서드(또는 다른 동기화된 메서드)를 호출하려고 시도하는 동일한 인스턴스의 다른 모든 스레드는 기다려야 합니다. 모니터를 종료하고 개체 제어를 다른 대기 스레드에 넘겨주기 위해 모니터를 소유한 스레드는 단순히 동기화된 메서드에서 반환됩니다.
동기화의 필요성을 이해하기 위해 동기화를 사용해야 하지만 그렇지 않은 간단한 예부터 시작해 보겠습니다. 다음 프로그램에는 세 가지 간단한 클래스가 있습니다. 첫 번째는 간단한 call() 메소드를 갖는 Callme입니다. call() 메소드에는 msg라는 문자열 매개변수가 있습니다. 이 메소드는 대괄호 안에 메시지 문자열을 인쇄하려고 시도합니다. 흥미로운 점은 call()을 호출하여 왼쪽 대괄호와 msg 문자열을 인쇄한 후 Thread.sleep(1000)이 호출되어 현재 스레드를 1초 동안 일시 중지한다는 것입니다.
다음 클래스인 Caller의 생성자는 각각 target과 msg에 저장된 Callme와 String의 인스턴스를 참조합니다. 또한 생성자는 객체의 run() 메서드를 호출하는 새 스레드를 생성합니다. 스레드가 즉시 시작됩니다. Caller 클래스의 run() 메소드는 msg 문자열 매개변수를 통해 Callme 인스턴스 대상의 call() 메소드를 호출합니다. 마지막으로 Synch 클래스는 Callme의 간단한 인스턴스와 서로 다른 메시지 문자열을 사용하는 Caller의 세 인스턴스를 만드는 것으로 시작됩니다.
동일한 Callme 인스턴스가 각 Caller 인스턴스에 전달됩니다.
// This program is not synchronized. class Callme { void call(String msg) { System.out.print("[" + msg); try { Thread.sleep(1000); } catch(InterruptedException e) { System.out.println("Interrupted"); } System.out.println("]"); } } class Caller implements Runnable { String msg; Callme target; Thread t; public Caller(Callme targ, String s) { target = targ; msg = s; t = new Thread(this); t.start(); } public void run() { target.call(msg); } } class Synch { public static void main(String args[]) { Callme target = new Callme(); Caller ob1 = new Caller(target, "Hello"); Caller ob2 = new Caller(target, "Synchronized"); Caller ob3 = new Caller(target, "World"); // wait for threads to end try { ob1.t.join(); ob2.t.join(); ob3.t.join(); } catch(InterruptedException e) { System.out.println("Interrupted"); } } }
이 프로그램의 출력은 다음과 같습니다.
Hello[Synchronized[World] ] ]
이 예에서 call() 메서드는 sleep()을 호출하여 실행이 다른 스레드로 전환되도록 허용합니다. 결과는 세 가지 메시지 문자열이 혼합된 출력입니다. 이 프로그램에는 세 개의 스레드가 동일한 개체의 동일한 메서드를 동시에 호출하는 것을 방지하는 메서드가 없습니다. 세 개의 스레드가 메서드를 완료하기 위해 경쟁하기 때문에 이는 경쟁 조건입니다. 예제에서는 이 효과를 반복 가능하고 명확하게 만들기 위해 sleep( )을 사용합니다. 대부분의 경우 경합은 컨텍스트 전환이 언제 발생할지 확신할 수 없기 때문에 더 복잡하고 예측할 수 없습니다. 이로 인해 프로그램이 제대로 실행되는 경우도 있고 실패하는 경우도 있습니다.
위 예시의 목적을 달성하기 위해서는 call()을 지속적으로 사용할 수 있는 권한이 있어야 합니다. 즉, 특정 순간에는 그것을 지배할 수 있는 하나의 스레드에만 제한되어야 합니다. 이렇게 하려면 call() 정의 앞에 다음과 같이 동기화된 키워드만 추가하면 됩니다.
class Callme { synchronized void call(String msg) { ...
이렇게 하면 call 사용이 방지됩니다. ( ) 다른 스레드가 call()을 입력할 때. call() 앞에 동기화가 추가된 후 프로그램 출력은 다음과 같습니다.
[Hello] [Synchronized] [World]
멀티 스레드 상황에서는 언제든지 하나 이상의 메소드 객체의 내부 상태를 조작하는 메소드는 상태 경쟁을 방지하기 위해 동기화 키워드를 사용해야 합니다. 스레드가 인스턴스의 동기화된 메서드에 들어가면 다른 스레드는 동일한 인스턴스의 동기화된 메서드에 들어갈 수 없다는 점을 기억하십시오. 그러나 인스턴스의 다른 비동기 메서드는 계속 호출될 수 있습니다.
동기화문
생성된 클래스 내부에 동기화 메소드를 생성하는 것은 간단하고 효과적인 동기화 방법이지만, 항상 작동하는 것은 아닙니다. 그 이유에 대해 생각해 보시기 바랍니다. 다중 스레드 액세스용으로 설계되지 않은 클래스 객체에 대한 동기화된 액세스를 얻으려고 한다고 가정합니다. 즉, 클래스가 동기화 메서드를 사용하지 않습니다. 게다가 해당 클래스는 귀하가 만든 것이 아니라 제3자가 만든 것이므로 해당 클래스의 소스 코드를 얻을 수 없습니다. 이런 방식으로 관련 메소드 앞에 동기화 수정자를 추가할 수 없습니다. 이 클래스의 객체를 어떻게 동기화할 수 있나요? 다행히도 해결책은 간단합니다. 이 클래스에 의해 정의된 메서드에 대한 호출을 동기화된 블록 내에 넣기만 하면 됩니다.
동기문의 일반적인 형태는 다음과 같습니다.
synchronized(object) { // statements to be synchronized }
其中,object是被同步对象的引用。如果你想要同步的只是一个语句,那么不需要花括号。一个同步块确保对object成员方法的调用仅在当前线程成功进入object管程后发生。
下面是前面程序的修改版本,在run( )方法内用了同步块:
// This program uses a synchronized block. class Callme { void call(String msg) { System.out.print("[" + msg); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println("Interrupted"); } System.out.println("]"); } } class Caller implements Runnable { String msg; Callme target; Thread t; public Caller(Callme targ, String s) { target = targ; msg = s; t = new Thread(this); t.start(); } // synchronize calls to call() public void run() { synchronized(target) { // synchronized block target.call(msg); } } } class Synch1 { public static void main(String args[]) { Callme target = new Callme(); Caller ob1 = new Caller(target, "Hello"); Caller ob2 = new Caller(target, "Synchronized"); Caller ob3 = new Caller(target, "World"); // wait for threads to end try { ob1.t.join(); ob2.t.join(); ob3.t.join(); } catch(InterruptedException e) { System.out.println("Interrupted"); } } }
这里,call( )方法没有被synchronized修饰。而synchronized是在Caller类的run( )方法中声明的。这可以得到上例中同样正确的结果,因为每个线程运行前都等待先前的一个线程结束。
Java线程间通信
多线程通过把任务分成离散的和合乎逻辑的单元代替了事件循环程序。线程还有第二优点:它远离了轮询。轮询通常由重复监测条件的循环实现。一旦条件成立,就要采取适当的行动。这浪费了CPU时间。举例来说,考虑经典的序列问题,当一个线程正在产生数据而另一个程序正在消费它。为使问题变得更有趣,假设数据产生器必须等待消费者完成工作才能产生新的数据。在轮询系统,消费者在等待生产者产生数据时会浪费很多CPU周期。一旦生产者完成工作,它将启动轮询,浪费更多的CPU时间等待消费者的工作结束,如此下去。很明显,这种情形不受欢迎。
为避免轮询,Java包含了通过wait( ),notify( )和notifyAll( )方法实现的一个进程间通信机制。这些方法在对象中是用final方法实现的,所以所有的类都含有它们。这三个方法仅在synchronized方法中才能被调用。尽管这些方法从计算机科学远景方向上来说具有概念的高度先进性,实际中用起来是很简单的:
wait( ) 告知被调用的线程放弃管程进入睡眠直到其他线程进入相同管程并且调用notify( )。
notify( ) 恢复相同对象中第一个调用 wait( ) 的线程。
notifyAll( ) 恢复相同对象中所有调用 wait( ) 的线程。具有最高优先级的线程最先运行。
这些方法在Object中被声明,如下所示:
final void wait( ) throws InterruptedException final void notify( ) final void notifyAll( )
wait( )存在的另外的形式允许你定义等待时间。
下面的例子程序错误的实行了一个简单生产者/消费者的问题。它由四个类组成:Q,设法获得同步的序列;Producer,产生排队的线程对象;Consumer,消费序列的线程对象;以及PC,创建单个Q,Producer,和Consumer的小类。
// An incorrect implementation of a producer and consumer. class Q { int n; synchronized int get() { System.out.println("Got: " + n); return n; } synchronized void put(int n) { this.n = n; System.out.println("Put: " + n); } } class Producer implements Runnable { Q q; Producer(Q q) { this.q = q; new Thread(this, "Producer").start(); } public void run() { int i = 0; while(true) { q.put(i++); } } } class Consumer implements Runnable { Q q; Consumer(Q q) { this.q = q; new Thread(this, "Consumer").start(); } public void run() { while(true) { q.get(); } } } class PC { public static void main(String args[]) { Q q = new Q(); new Producer(q); new Consumer(q); System.out.println("Press Control-C to stop."); } }
尽管Q类中的put( )和get( )方法是同步的,没有东西阻止生产者超越消费者,也没有东西阻止消费者消费同样的序列两次。这样,你就得到下面的错误输出(输出将随处理器速度和装载的任务而改变):
Put: 1 Got: 1 Got: 1 Got: 1 Got: 1 Got: 1 Put: 2 Put: 3 Put: 4 Put: 5 Put: 6 Put: 7 Got: 7
生产者生成1后,消费者依次获得同样的1五次。生产者在继续生成2到7,消费者没有机会获得它们。
用Java正确的编写该程序是用wait( )和notify( )来对两个方向进行标志,如下所示:
// A correct implementation of a producer and consumer. class Q { int n; boolean valueSet = false; synchronized int get() { if(!valueSet) try { wait(); } catch(InterruptedException e) { System.out.println("InterruptedException caught"); } System.out.println("Got: " + n); valueSet = false; notify(); return n; } synchronized void put(int n) { if(valueSet) try { wait(); } catch(InterruptedException e) { System.out.println("InterruptedException caught"); } this.n = n; valueSet = true; System.out.println("Put: " + n); notify(); } } class Producer implements Runnable { Q q; Producer(Q q) { this.q = q; new Thread(this, "Producer").start(); } public void run() { int i = 0; while(true) { q.put(i++); } } } class Consumer implements Runnable { Q q; Consumer(Q q) { this.q = q; new Thread(this, "Consumer").start(); } public void run() { while(true) { q.get(); } } } class PCFixed { public static void main(String args[]) { Q q = new Q(); new Producer(q); new Consumer(q); System.out.println("Press Control-C to stop."); } }
内部get( ), wait( )被调用。这使执行挂起直到Producer 告知数据已经预备好。这时,内部get( ) 被恢复执行。获取数据后,get( )调用notify( )。这告诉Producer可以向序列中输入更多数据。在put( )内,wait( )挂起执行直到Consumer取走了序列中的项目。当执行再继续,下一个数据项目被放入序列,notify( )被调用,这通知Consumer它应该移走该数据。
下面是该程序的输出,它清楚的显示了同步行为:
Put: 1 Got: 1 Put: 2 Got: 2 Put: 3 Got: 3 Put: 4 Got: 4 Put: 5 Got: 5
更多深入解析Java的线程同步以及线程间通信相关文章请关注PHP中文网!