스레드는 프로세스 내에서 코드 실행의 기본 단위로, 동시에 여러 작업을 수행할 수 있습니다. 프로세스는 자신만의 주소 공간, 파일 핸들, 자원 등을 가지고 있지만, 스레드는 프로세스의 자원을 공유하여 실행됩니다.
따라서 스레드는 프로세스 내에서 동시에 여러 작업을 처리하고 서로 협력하여 작업을 완료할 수 있습니다.
스레드는 병렬성(Parallelism)을 제공하여 작업의 처리 속도를 향상시키고, 동시성(Concurrency)을 통해 여러 작업을 동시에 처리할 수 있습니다. 스레드를 사용하면 여러 작업을 동시에 수행하거나 하나의 작업을 여러 스레드로 분할하여 병렬로 처리할 수 있습니다.
스레드는 일반적으로 독립적인 실행 경로를 가지고 있으며, 각 스레드는 자체적인 프로그램 카운터(PC), 스택, 레지스터 등을 가지고 있습니다. 스레드는 동시에 실행되기 때문에 경쟁 상태와 같은 동시성 문제에 유의해야 하며, 적절한 동기화 메커니즘을 사용하여 데이터의 일관성과 안정성을 보장해야 합니다.
자바에서는 스레드를 사용하기 위해 Thread 클래스를 제공하고 있습니다. 또한 Runnable 인터페이스를 구현하는 방법을 통해 스레드를 생성할 수도 있습니다. 스레드를 생성하고 실행하면 자바 가상 머신(JVM)은 해당 스레드를 스케줄링하여 실행시킵니다.
스레드를 적절히 활용하면 병렬 처리, 응답성 향상, 멀티태스킹 등 다양한 이점을 얻을 수 있습니다. 그러나 스레드를 잘못 사용하면 동기화 문제, 경쟁 조건, 교착상태와 같은 문제가 발생할 수 있으므로 주의가 필요합니다.
자바에서 이러한 동기화 메커니즘을 지원하기 위해 다음과 같은 기능을 제공합니다.
synchronized 키워드 : 자바에서 가장 기본적인 동기화 메커니즘으로, synchronized 키워드를 사용하여 특정 코드 블록 또는 메서드를 임계 영역(critical section)으로 지정할 수 있습니다. synchronized 키워드를 사용하면 해당 영역에는 오직 하나의 스레드만 진입할 수 있습니다.
객체 잠금(Object Locking) : 모든 자바 객체는 잠금(lock)을 가질 수 있습니다. synchronized 키워드를 사용하여 객체에 대한 잠금을 설정하고 해제할 수 있습니다. 이를 통해 여러 스레드가 동일한 객체를 안전하게 공유할 수 있습니다.
wait(), notify(), notifyAll() 메서드 : Object 클래스에서 제공하는 메서드로, 스레드 간의 통신과 상태 제어를 위해 사용됩니다. wait() 메서드는 스레드를 일시적으로 대기 상태로 전환하고, notify() 또는 notifyAll() 메서드를 호출하여 대기 중인 스레드를 깨워 실행 상태로 전환할 수 있습니다.
Lock 및 Condition 인터페이스 : 자바 5부터 도입된 java.util.concurrent 패키지에는 Lock 인터페이스와 Condition 인터페이스가 있습니다. Lock 인터페이스는 synchronized 키워드와 유사한 기능을 제공하며, Condition 인터페이스는 wait(), notify(), notifyAll() 메서드와 유사한 기능을 제공합니다. 이러한 인터페이스를 활용하여 더 세밀한 스레드 동기화를 구현할 수 있습니다.
뮤텍스는 자바의 표준 라이브러리에서 직접적으로 제공되지는 않지만, Lock 및 Condition 인터페이스를 사용하여 뮤텍스의 동작을 구현할 수 있습니다.
또한, java.util.concurrent 패키지에서는 CountDownLatch, CyclicBarrier, Phaser 등의 동기화 도구를 제공하여 다양한 동기화 시나리오를 구현할 수 있도록 도와줍니다.
예를 들어 스트리밍 오디오 애플리케이션은 동시에 네트워크에서 디지털 오디오를 읽고, 압축을 풀고, 재생을 관리하고, 디스플레이를 업데이트해야 합니다. 워드 프로세서도 아무리 바쁘게 텍스트를 재포맷하거나 디스플레이를 업데이트하더라도 키보드와 마우스 이벤트에 항상 응답할 준비가 되어 있어야 합니다. 이러한 작업을 수행할 수 있는 소프트웨어를 동시 소프트웨어라고 합니다.
Java 플랫폼은 처음부터 Java 프로그래밍 언어 및 Java 클래스 라이브러리의 기본 동시성(concurrency) 지원과 함께 동시 프로그래밍을 지원하도록 설계되었습니다.
버전 5.0부터 Java 플랫폼에는 높은 수준의 동시성 API도 포함되었습니다. 이 학습에서는 플랫폼의 기본 동시성 지원을 소개하고 java.util.concurrent 패키지의 일부 고급 API를 요약합니다.
동시(concurrent) 프로그래밍에는 두 가지 기본 실행 단위인 processes와 threads가 있습니다. Java 프로그래밍 언어에서 동시 프로그래밍은 대부분 스레드와 관련이 있습니다. 그러나 프로세스도 중요합니다
컴퓨터 시스템에는 일반적으로 많은 활성 프로세스와 스레드가 있습니다. 이는 단일 실행 코어만 있는 시스템에서도 마찬가지이므로 주어진 순간에 실제로 실행되는 스레드는 하나만 있습니다. 단일 코어의 처리 시간은 타임 슬라이싱(time slicing)이라는 OS 기능을 통해 프로세스와 스레드 간에 공유됩니다.
컴퓨터 시스템에 다중 프로세서 또는 다중 실행 코어가 있는 프로세서가 있는 것이 점점 일반화되고 있습니다. 이것은 프로세스와 스레드의 동시 실행을 위한 시스템의 용량을 크게 향상시킵니다. 그러나 동시성은 다중 프로세서나 실행 코어가 없는 단순한 시스템에서도 가능합니다.
프로세스는 종종 프로그램이나 응용 프로그램과 동의어로 간주됩니다. 그러나 사용자가 단일 응용 프로그램으로 보는 것은 실제로 협력 프로세스 집합일 수 있습니다. 프로세스 간 통신을 용이하게 하기 위해 대부분의 운영 체제는 pipes 및 sockets과 같은 IPC(Inter Process Communication) 리소스를 지원합니다. IPC는 동일한 시스템의 프로세스 간 통신뿐만 아니라 다른 시스템의 프로세스에도 사용됩니다.
대부분의 JVM(Java Virtual Machine) 구현은 단일 프로세스로 실행됩니다. Java 응용 프로그램은 ProcessBuilder 객체를 사용하여 추가 프로세스를 만들 수 있습니다. 다중 프로세스 응용 프로그램은 이 단원의 범위를 벗어납니다.
스레드는 프로세스 내에 존재합니다. 모든 프로세스에는 적어도 하나가 있습니다. 스레드는 메모리 및 open 파일을 포함하여 프로세스의 리소스를 공유합니다. 이것은 효율적이지만 잠재적으로 문제가 있는 communication을 만듭니다.
Multithreaded execution은 Java 플랫폼의 필수 기능입니다. 모든 애플리케이션에는 적어도 하나의 스레드가 있습니다. 메모리 관리 및 signal 처리와 같은 작업을 수행하는 "시스템" 스레드를 포함한다면 여러 개입니다. 그러나 애플리케이션 프로그래머의 관점에서 보면 메인 스레드라고 하는 단 하나의 스레드로 시작합니다. 이 스레드에는 다음 섹션에서 설명하는 것처럼 추가 스레드를 생성하는 기능이 있습니다.
스레드 생성과 관리를 직접적으로 제어하려면, 애플리케이션이 비동기(asynchronous) 작업을 시작해야 할 때마다 Thread를 인스턴스화하면 됩니다.
애플리케이션의 스레드 관리를 애플리케이션의 나머지로부터 추상화하기 위해, 애플리케이션의 작업을 executor에 전달하세요.
이 섹션에서는 Thread 객체의 사용에 대해 설명합니다. Executors는 다른 high-level concurrency objects와 논의됩니다.
다음 두 가지 방법이 있습니다.
Runnable 객체를 제공합니다. Runnable 인터페이스는 스레드에서 실행되는 코드를 포함하기 위한 단일 메서드인 run을 정의합니다.
public class HelloRunnable implements Runnable {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
(new Thread(new HelloRunnable())).start();
}
}
public class HelloThread extends Thread {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
(new HelloThread()).start();
}
}
두 예제 모두 새 스레드를 시작하기 위해 Thread.start를 호출합니다.
어떤 관용구를 사용해야 할까요?
첫 번째 관용구는 Runnable 객체를 사용하므로 더 일반적입니다. Runnable 객체는 Thread 이외의 클래스를 상속할 수 있습니다. 두 번째 관용구는 간단한 애플리케이션에서 사용하기 쉽지만, 작업 클래스가 Thread의 하위 클래스여야 한다는 제한이 있습니다. 이 강의는 첫 번째 접근 방식에 초점을 맞추고 있으며, Runnable 작업을 실행하는 Thread 객체와 분리합니다. 이 접근 방식은 더 유연하며, 나중에 다룰 고수준 스레드 관리 API에도 적용할 수 있습니다.
Thread 클래스는 스레드 관리에 유용한 여러 메서드를 정의합니다. 여기에는 메서드를 호출하는 스레드에 대한 정보를 제공하거나 상태에 영향을 주는 정적 메서드가 포함됩니다. 다른 메서드는 스레드 및 스레드 객체 관리와 관련된 다른 스레드에서 호출됩니다. 다음 섹션에서 이러한 방법 중 일부를 살펴보겠습니다.
두 가지 오버로딩된 버전의 sleep 메서드가 제공됩니다. 하나는 밀리초 단위로 sleep 시간을 지정하고, 다른 하나는 나노초 단위로 sleep 시간을 지정합니다. 그러나 이러한 sleep 시간은 운영 체제에서 제공하는 기능에 의해 정확하게 보장되지 않을 수 있습니다. 또한, 나중에 다룰 섹션에서 볼 수 있듯이, 인터럽트에 의해 sleep 기간이 중단될 수도 있습니다. 어떤 경우에도 sleep을 호출하여 스레드가 정확히 지정된 시간 동안 일시 중지될 것이라고 가정해서는 안 됩니다.
SleepMessages 예제는 4초 간격으로 메시지를 출력하기 위해 sleep을 사용합니다.
public class SleepMessages {
public static void main(String args[])
throws InterruptedException {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
for (int i = 0;
i < importantInfo.length;
i++) {
//Pause for 4 seconds
Thread.sleep(4000);
//Print a message
System.out.println(importantInfo[i]);
}
}
}
주목해야 할 점은 main 메서드가 InterruptedException을 선언한다는 것입니다. 이는 sleep이 활성화된 동안 다른 스레드가 현재 스레드를 인터럽트할 때 sleep이 throw하는 예외입니다.
이 어플리케이션에서는 인터럽트를 발생시킬 다른 스레드를 정의하지 않았으므로 InterruptedException을 catch하지 않습니다.
스레드는 인터럽트될 스레드에 대한 Thread 객체의 interrupt 메서드를 호출하여 인터럽트를 보냅니다. 인터럽트 메커니즘이 올바르게 작동하려면 인터럽트된 스레드가 자체 인터럽트를 지원해야 합니다.
예를 들어, SleepMessages 예제의 중앙 메시지 루프가 스레드의 Runnable 객체의 run 메서드 내에 있다고 가정해봅시다. 그렇다면 인터럽트를 지원하도록 다음과 같이 수정될 수 있습니다.
for (int i = 0; i < importantInfo.length; i++) {
// Pause for 4 seconds
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
// We've been interrupted: no more messages.
return;
}
// Print a message
System.out.println(importantInfo[i]);
}
sleep와 같이 InterruptedException을 throw하는 많은 메서드들은, 인터럽트를 받았을 때 현재 작업을 취소하고 즉시 리턴하기 위해 설계되었습니다.
만약 스레드가 InterruptedException을 throw하는 메서드를 호출하지 않고 오랜 시간을 보내는 경우, 주기적으로 Thread.interrupted를 호출해야 합니다. Thread.interrupted는 인터럽트가 수신되었다면 true를 반환합니다.
예를 들어
for (int i = 0; i < inputs.length; i++) {
heavyCrunch(inputs[i]);
if (Thread.interrupted()) {
// We've been interrupted: no more crunching.
return;
}
}
이 간단한 예제에서는 코드가 단순히 인터럽트를 테스트하고, 인터럽트가 수신되었다면 스레드를 종료합니다. 더 복잡한 애플리케이션에서는 InterruptedException을 throw하는 것이 더 의미가 있을 수 있습니다:
if (Thread.interrupted()) {
throw new InterruptedException();
}
이는 인터럽트 처리 코드를 catch 절에서 중앙 집중화할 수 있도록 해줍니다.
관례적으로, InterruptedException을 throw하고 메서드를 종료하는 경우, 해당 메서드는 인터럽트 상태를 clear 하게 됩니다. 그러나 다른 스레드가 interrupt를 호출하여 인터럽트 상태가 다시 set될 수 있다는 점을 항상 염두에 두어야 합니다.
join 메서드는 한 스레드가 다른 스레드의 완료를 기다리도록 허용합니다.
t가 현재 실행 중인 스레드를 가진 Thread 객체인 경우,
t.join();
causes the current thread to pause execution until t's thread terminates. Overloads of join allow the programmer to specify a waiting period. However, as with sleep, join is dependent on the OS for timing, so you should not assume that join will wait exactly as long as you specify.
현재 스레드를 일시 중지시켜 t의 스레드가 종료될 때까지 기다리게 합니다. join의 오버로드를 사용하면 프로그래머가 대기 기간을 지정할 수 있습니다. 그러나 sleep과 마찬가지로 join은 타이밍에 대해 운영 체제에 의존하므로 join이 정확히 지정한 시간만큼 기다릴 것이라고 가정해서는 안 됩니다.
sleep와 마찬가지로, join도 InterruptedException을 발생시켜 인터럽트에 응답합니다.
MessageLoop 스레드는 일련의 메시지를 출력합니다. 만약 모든 메시지를 출력하기 전에 인터럽트가 발생하면, MessageLoop 스레드는 메시지를 출력하고 종료합니다.
public class SimpleThreads {
// Display a message, preceded by
// the name of the current thread
static void threadMessage(String message) {
String threadName =
Thread.currentThread().getName();
System.out.format("%s: %s%n",
threadName,
message);
}
private static class MessageLoop
implements Runnable {
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
try {
for (int i = 0;
i < importantInfo.length;
i++) {
// Pause for 4 seconds
Thread.sleep(4000);
// Print a message
threadMessage(importantInfo[i]);
}
} catch (InterruptedException e) {
threadMessage("I wasn't done!");
}
}
}
public static void main(String args[])
throws InterruptedException {
// Delay, in milliseconds before
// we interrupt MessageLoop
// thread (default one hour).
long patience = 1000 * 60 * 60;
// If command line argument
// present, gives patience
// in seconds.
if (args.length > 0) {
try {
patience = Long.parseLong(args[0]) * 1000;
} catch (NumberFormatException e) {
System.err.println("Argument must be an integer.");
System.exit(1);
}
}
threadMessage("Starting MessageLoop thread");
long startTime = System.currentTimeMillis();
Thread t = new Thread(new MessageLoop());
t.start();
threadMessage("Waiting for MessageLoop thread to finish");
// loop until MessageLoop
// thread exits
while (t.isAlive()) {
threadMessage("Still waiting...");
// Wait maximum of 1 second
// for MessageLoop thread
// to finish.
t.join(1000);
if (((System.currentTimeMillis() - startTime) > patience)
&& t.isAlive()) {
threadMessage("Tired of waiting!");
t.interrupt();
// Shouldn't be long now
// -- wait indefinitely
t.join();
}
}
threadMessage("Finally!");
}
}
Threads는 주로 필드 및 객체 참조 필드에 대한 액세스를 공유함으로써 통신합니다. 이러한 형태의 통신은 매우 효율적이지만, 스레드간 간섭(thread interference)과 메모리 일관성 오류(memory consistency errors)라는 두 가지 종류의 오류가 발생할 수 있습니다. 이러한 오류를 방지하기 위해 필요한 도구는 동기화(synchronization)입니다.
그러나 동기화는 스레드 경합(thread contention)을 발생시킬 수 있습니다. 스레드 경합은 두 개 이상의 스레드가 동시에 동일한 리소스에 액세스하려고 할 때 발생하며, 이는 자바 런타임이 하나 이상의 스레드를 더 느리게 실행하거나 실행을 일시 중단할 수 있습니다. 스레드 경합의 형태로는 굶주림(starvation)과 라이브락(livelock)이 있습니다. 자세한 내용은 Liveness 섹션을 참조하십시오.
This section covers the following topics:
Thread Interference 는 여러 스레드가 공유 데이터에 접근할 때 오류가 발생하는 방식을 설명합니다.
Memory Consistency Errors 는 공유 메모리의 일관성이 없는 뷰로 인해 발생하는 오류를 설명합니다.
Synchronized Methods 는 스레드 간 간섭(thread interference)과 메모리 일관성 오류(memory consistency errors)를 효과적으로 방지할 수 있는 간단한 관용구를 설명합니다.
Implicit Locks and Synchronization 는 보다 일반적인 동기화 관용구를 설명하며, 동기화가 암묵적인 잠금(implicit locks)에 기반한다고 설명합니다.
Atomic Access 는 다른 스레드에 의해 간섭받을 수 없는 작업의 일반적인 개념에 대해 설명합니다..
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
Counter는 increment를 호출할 때마다 c에 1을 더하고, decrement를 호출할 때마다 c에서 1을 뺄 수 있도록 설계되었습니다.
그러나 Counter 객체가 여러 스레드에서 참조된다면, 스레드 간의 간섭으로 인해 이러한 동작이 예상대로 이루어지지 않을 수 있습니다.
간섭은 서로 다른 스레드에서 실행되지만 동일한 데이터에 작용하는 두 개의 작업이 교차(interleave)하는 경우 발생합니다.
이는 두 작업이 여러 단계로 구성되어 있고, 단계의 순서가 겹치는 것을 의미합니다.
Counter의 인스턴스에 대한 작업이 교차(interleave)할 수 있는 것처럼 보이지 않을 수 있습니다. 왜냐하면 c에 대한 두 작업 모두 단일하고 간단한 문장이기 때문입니다.
그러나 심지어 간단한 문장도 가상 머신에 의해 여러 단계로 변환될 수 있습니다. 가상 머신이 수행하는 구체적인 단계를 살펴볼 필요는 없습니다. 중요한 점은 단일 표현식인 c++도 세 단계로 분해될 수 있다는 것입니다.
표현식 c--도 동일한 방식으로 분해될 수 있지만, 두 번째 단계에서 감소(increment) 대신 감소(decrement)가 일어납니다.
Thread A가 increment를 호출하는 동안 Thread B가 decrement를 호출한다고 가정해봅시다. c의 초기 값이 0이라면, 이들 교차 작업은 다음과 같은 시퀀스를 따를 수 있습니다:
Thread A's result is lost, overwritten by Thread B. This particular
Thread A의 결과는 Thread B에 의해 덮어씌워지므로 손실됩니다. 이러한 특정한 교차 작업은 하나의 가능성에 불과합니다. 다른 상황에서는 Thread B의 결과가 손실될 수 있거나, 오류가 전혀 발생하지 않을 수도 있습니다. 예측할 수 없기 때문에, 스레드 간 간섭 버그는 감지하고 수정하기 어려울 수 있습니다.
메모리 일관성 오류는 서로 다른 스레드가 동일한 데이터로 간주되어야 할 것에 대해 일관되지 않은 보기를 가질 때 발생합니다. 메모리 일관성 오류의 원인은 복잡하며 이 튜토리얼의 범위를 벗어납니다. 다행히도, 프로그래머는 이러한 원인에 대해 상세한 이해가 필요하지 않습니다. 필요한 것은 이러한 오류를 피하기 위한 전략입니다.
메모리 일관성 오류를 방지하는 핵심은 happens-before(발생 전) 관계를 이해하는 것입니다. 이 관계는 단순히 하나의 특정 statement에 의한 메모리 쓰기가 다른 특정 statement에서 볼 수 있음을 보장합니다. 이를 확인하려면 다음 예를 고려하십시오. 간단한 int 필드가 정의되고 초기화되었다고 가정합니다.
int counter = 0;
counter 필드는 두 개의 스레드 A와 B 사이에서 공유됩니다. 스레드 A가 counter를 증가시킨다고 가정해봅시다:
counter++;
그런 다음, 잠시 후에 스레드 B가 counter를 print합니다:
System.out.println(counter);
If the two statements had been executed in the same thread, it would be safe to assume that the value printed out would be "1". But if the two statements are executed in separate threads, the value printed out might well be "0", because there's no guarantee that thread A's change to counter will be visible to thread B — unless the programmer has established a happens-before relationship between these two statements.
위 두 개의 statements이 동일한 스레드에서 실행된 경우, 출력된 값이 "1"이라고 가정하는 것이 안전합니다. 그러나 두 statements이 별도의 스레드에서 실행되는 경우 스레드 A의 카운터 변경 사항이 스레드 B에 표시된다는 보장이 없기 때문에 출력되는 값은 "0"일 수 있습니다.
There are several actions that create happens-before relationships. One of them is synchronization, as we will see in the following sections.
이때, 프로그래머가 이 두 문장 사이에 happens-before 관계를 설정하지 않은 한, 값의 가시성을 보장할 수 없습니다.
We've already seen two actions that create happens-before relationships.
이미 두 가지 happens-before 관계를 생성하는 동작을 보았습니다.
한 statement가 Thread.start를 호출할 때, 그 statement과 happens-before 관계가 있는 모든 statement은 새로운 스레드에 의해 실행되는 모든 문장과도 happens-before 관계가 있습니다. 새로운 스레드를 생성하는 코드의 효과는 새로운 스레드에게도 보이게 됩니다.
한 스레드가 종료되고 다른 스레드에서 Thread.join이 반환되면, 종료된 스레드에 의해 실행된 모든 statement은 성공적인 join 이후에 실행되는 모든 statement과 happens-before 관계가 있습니다. 종료된 스레드의 코드 효과는 이제 join을 수행한 스레드에게도 보이게 됩니다.
For a list of actions that create happens-before relationships, refer to the Summary page of the java.util.concurrent package..
Java 프로그래밍 언어는 두 가지 기본 synchronization 관용구를 제공합니다: synchronized methods와 synchronized statements입니다. 이 중 더 복잡한 synchronized statements은 다음 섹션에서 설명됩니다. 이 섹션은 synchronized methods에 대한 내용입니다.
메서드를 동기화하기 위해서는 해당 메서드의 선언에 단순히 synchronized 키워드를 추가하면 됩니다.
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
count가 SynchronizedCounter의 인스턴스라면, 이러한 메서드를 동기화하면 두 가지 효과가 있습니다:
첫째로, 동일한 객체에 대해 synchronized methods의 두 호출이 교차(interleave)하는 것은 불가능합니다. 하나의 스레드가 객체의 synchronized method를 실행하는 동안, 해당 객체에 대해 synchronized methods을 호출하는 다른 모든 스레드는 첫 번째 스레드가 해당 객체 작업을 완료할 때까지 블록(Block)되어 (실행이 중단되어) 대기하게 됩니다.
둘째로, synchronized method가 종료되면 자동으로 동일한 객체에 대한 후속 synchronized method의 호출과 happens-before 관계를 설정합니다. 이를 통해 객체의 상태 변경이 모든 스레드에게 보이도록 보장됩니다.
생성자는 동기화될 수 없습니다. 생성자에 synchronized 키워드를 사용하는 것은 구문 오류입니다. 생성자를 동기화하는 것은 의미가 없습니다. 왜냐하면 객체가 생성되는 동안 해당 객체에 대한 액세스 권한은 해당 객체를 생성하는 스레드에게만 있어야하기 때문입니다.
스레드 간에 공유할 객체를 구성할 때 개체에 대한 참조가 조기에 "유출"되지 않도록 매우 주의하십시오. 예를 들어 클래스의 모든 인스턴스를 포함하는 instances라고 하는 List을 유지하려고 한다고 가정합니다. 생성자에 다음 줄을 추가하고 싶을 수도 있습니다.
instances.add(this);
하지만 그렇게 되면 객체의 생성이 완료되기 전에 다른 스레드가 instances를 통해 객체에 액세스할 수 있게 됩니다.
Synchronized methods는 스레드간 간섭과 메모리 일관성 오류를 방지하기 위한 간단한 전략을 제공합니다. 객체가 여러 스레드에게 보이는(표시되는) 경우, 해당 객체의 변수에 대한 모든 읽기 또는 쓰기 작업은 동기화된 메서드를 통해 수행됩니다. (중요한 예외: 객체가 생성된 후 수정할 수 없는 final 필드는 객체가 생성된 후에는 동기화되지 않은 메서드를 통해 안전하게 읽을 수 있습니다) 이 전략은 효과적이지만, 활성성(liveness)과 관련하여 나중에 이 강의에서 더 자세히 살펴볼 수 있는 문제가 발생할 수 있습니다.
동기화는 고유 락(intrinsic lock) 또는 모니터 잠금(monitor lock)으로 알려진 내부 엔터티를 중심으로 구축됩니다. (API 사양에서는 종종 이 엔터티를 단순히 "모니터"라고 합니다.) 고유 락은 동기화의 두 가지 측면에서 모두 역할을 합니다. 즉, 객체 상태에 대한 배타적 액세스를 적용하고 가시성에 필수적인 사전 발생(happens-before) 관계를 설정하는 것입니다.
동기화의 양쪽 측면이란, 동기화의 두 가지 주요 목적을 의미합니다.
첫째로, 고유 락은 객체의 상태에 대한 독점적인 액세스를 보장합니다. 동기화된 메서드나 동기화된 블록에 진입한 스레드는 해당 객체의 고유 락을 획득하고, 다른 스레드들은 동시에 동기화된 메서드나 동기화된 블록을 실행할 수 없습니다. 이를 통해 여러 스레드가 동시에 객체의 상태를 수정하거나 일관성 없는 동작을 수행하는 것을 방지합니다.
둘째로, 고유 락은 가시성에 필요한 happens-before 관계를 설정합니다. 스레드가 동기화된 메서드나 동기화된 블록을 통해 고유 락을 획득하면, 그 스레드는 이전에 획득한 고유 락을 보유한 다른 스레드의 작업 결과를 볼 수 있습니다. 이를 통해 스레드 사이의 작업 순서와 상태의 일관성을 보장합니다.
즉, 고유 락은 동기화의 양쪽 측면, 즉 상호 배제와 가시성을 위한 핵심 역할을 수행합니다.
모든 객체에는 관련 고유 락이 있습니다. 규칙에 따라 객체의 필드에 대한 배타적이고 일관된 액세스가 필요한 스레드는 액세스하기 전에 객체의 고유 락을 획득한 다음 작업이 완료되면 고유 락을 해제해야 합니다. 스레드는 락을 획득한 시간과 락을 해제한 시간 사이에 고유 락을 소유한다고 합니다. 스레드가 고유 락을 소유하는 한 다른 스레드는 동일한 락을 획득할 수 없습니다. 다른 스레드는 락을 획득하려고 시도할 때 차단됩니다.
스레드가 고유 락을 해제하면 해당 작업과 동일한 락의 후속 획득 간에 사전 발생 관계가 설정됩니다.
정적 메서드는 객체가 아닌 클래스와 연결되기 때문에 동기화된 정적 메서드가 호출되면 어떤 일이 발생하는지 궁금할 수 있습니다. 이 경우 스레드는 클래스와 연결된 클래스 객체에 대한 고유 락을 획득합니다. 따라서 클래스의 정적 필드에 대한 액세스는 클래스의 모든 인스턴스에 대한 락과는 다른 에 의해 제어됩니다.
동기화된 코드를 만드는 또 다른 방법은 synchronized statements을 사용하는 것입니다. synchronized methods와 달리 동기화된 문은 고유 락을 제공하는 체를 지정해야 합니다.
public void addName(String name) {
synchronized(this) { // this lock을 잡기위핸 매개체
lastName = name;
nameCount++;
}
nameList.add(name);
}
이 예에서 addName 메소드는 lastName 및 nameCount에 대한 변경 사항을 동기화해야 하지만 다른 객체의 메소드 호출 동기화를 피해야 합니다. (동기화된 코드에서 다른 개체의 메서드를 호출하면 Liveness 섹션에 설명된 문제가 발생할 수 있습니다.) synchronized statements이 없으면 nameList.add를 호출하기 위한 목적으로만 동기화되지 않은 별도의 메서드가 있어야 합니다.
Synchronized statements은 세분화된 동기화로 동시성을 개선하는 데에도 유용합니다. 예를 들어 MsLunch 클래스에 함께 사용되지 않는 두 개의 인스턴스 필드 c1과 c2가 있다고 가정합니다.
이러한 필드의 모든 업데이트는 동기화되어야 하지만 c1 업데이트가 c2 업데이트와 인터리브되는 것을 방지할 이유가 없으며 이렇게 하면 불필요한 차단이 생성되어 동시성이 감소합니다. 동기화된 메서드를 사용하거나 이와 관련된 락을 사용하는 대신 락을 제공하기 위해 두 개의 객체를 만듭니다.
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++; // lock을 얻기위한 매개체
}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
이 관용구는 매우 주의해서 사용하세요. 영향을 받는 필드의 액세스를 인터리브하는 것이 실제로 안전한지 절대적으로 확신해야 합니다.
스레드는 다른 스레드가 소유한 락을 획득할 수 없습니다. 그러나 스레드는 이미 소유하고 있는 락을 획득할 수 있습니다. 스레드가 동일한 락을 두 번 이상 획득하도록 허용하면 reentrant synchronization가 활성화됩니다. 이는 동기화된 코드가 직접 또는 간접적으로 동기화된 코드도 포함하는 메서드를 호출하고 두 코드 세트 모두 동일한 락을 사용하는 상황을 설명합니다. 재진입 동기화가 없으면 동기화된 코드는 스레드 자체가 차단되는 것을 방지하기 위해 많은 추가 예방 조치를 취해야 합니다.
프로그래밍에서 atomic action은 한 번에 효과적으로 발생하는 동작입니다. atomic action은 중간에 멈출 수 없습니다. 완전히 일어나거나 전혀 일어나지 않습니다. 작업이 완료될 때까지 atomic action의 부작용이 표시되지 않습니다.
우리는 이미 C++와 같은 increment expression이 atomic action을 설명하지 않는다는 것을 보았습니다. 매우 간단한 표현식이라도 다른 작업으로 분해할 수 있는 복잡한 작업을 정의할 수 있습니다. 그러나 원자성으로 지정할 수 있는 작업이 있습니다.
Reads 및 writes는 참조 변수 및 대부분의 기본 변수(long 및 double을 제외한 모든 유형)에 대해 원자적입니다.
Reads 및 writes는 volatile 으로 선언된 모든 변수(long 및 double 변수 포함)에 대해 atomic 입니다.
Atomic actions은 인터리브할 수 없으므로 스레드 간섭에 대한 두려움 없이 사용할 수 있습니다. 그러나 메모리 일관성 오류가 여전히 발생할 수 있기 때문에 원자적 작업을 동기화해야 하는 모든 필요성이 제거되지는 않습니다. volatile 변수를 사용하면 메모리 일관성 오류의 위험이 줄어듭니다.
volatile 변수에 대한 모든 쓰기는 동일한 변수의 후속 읽기와 이전 발생(happens-before) 관계를 설정하기 때문입니다. 즉, volatile 변수에 대한 변경 사항은 항상 다른 스레드에서 볼 수 있습니다. 또한 스레드가 volatile 변수를 읽을 때 volatile에 대한 최신 변경 사항뿐만 아니라 변경을 이끈 코드의 부작용도 볼 수 있음을 의미합니다.
간단한 원자 변수 액세스를 사용하는 것이 동기화된 코드를 통해 이러한 변수에 액세스하는 것보다 더 효율적이지만 메모리 일관성 오류를 방지하려면 프로그래머가 더 많은 주의를 기울여야 합니다. 추가 노력이 가치가 있는지 여부는 응용 프로그램의 크기와 복잡성에 따라 다릅니다.
java.util.concurrent 패키지의 일부 클래스는 동기화에 의존하지 않는 원자성 메소드를 제공합니다. High Level Concurrency Objects 섹션에서 이에 대해 설명합니다.