Java의 변수는 로컬 변수와 클래스 변수라는 두 가지 범주로 나뉩니다. 지역 변수는 run 메서드에 정의된 변수와 같이 메서드 내에 정의된 변수를 나타냅니다. 이러한 변수의 경우 스레드 간 공유에 문제가 없습니다. 따라서 데이터 동기화가 필요하지 않습니다. 클래스 변수는 클래스에 정의된 변수이며 해당 범위는 클래스 전체입니다. 이러한 변수는 여러 스레드에서 공유될 수 있습니다. 따라서 이러한 변수에 대해 데이터 동기화를 수행해야 합니다.
데이터 동기화는 하나의 스레드만 동시에 동기화된 클래스 변수에 액세스할 수 있음을 의미합니다. 현재 스레드가 이러한 변수에 액세스한 후에는 다른 스레드가 계속해서 해당 변수에 액세스할 수 있습니다. 여기서 언급된 액세스는 쓰기 작업을 통한 액세스를 의미합니다. 클래스 변수에 액세스하는 모든 스레드가 읽기 작업인 경우 일반적으로 데이터 동기화가 필요하지 않습니다. 그렇다면 공유 클래스 변수에 대해 데이터 동기화가 수행되지 않으면 어떻게 될까요? 먼저 다음 코드에서 어떤 일이 발생하는지 살펴보겠습니다.
다음과 같이 코드 코드를 복사합니다.
패키지 테스트;
공개 클래스 MyThread는 Thread를 확장합니다.
{
공개 정적 int n = 0;
공개 무효 실행()
{
int m = n;
생산하다();
m++;
n = m;
}
public static void main(String[] args)에서 예외가 발생했습니다.
{
MyThread myThread = 새로운 MyThread();
스레드 스레드[] = 새 스레드[100];
for (int i = 0; i < thread.length; i++)
스레드[i] = new Thread(myThread);
for (int i = 0; i < thread.length; i++)
스레드[i].start();
for (int i = 0; i < thread.length; i++)
스레드[i].join();
System.out.println("n = " + MyThread.n);
}
}
위 코드를 실행하여 가능한 결과는 다음과 같습니다.
다음과 같이 코드 코드를 복사합니다.
n=59
이 결과를 보면 많은 독자들이 놀랄 것이다. 이 프로그램은 분명히 100개의 스레드를 시작한 다음 각 스레드는 정적 변수 n을 1씩 증가시킵니다. 마지막으로 Join 메소드를 사용하여 100개의 스레드를 모두 실행시킨 후 n 값을 출력합니다. 일반적으로 결과는 n = 100이어야 합니다. 그러나 결과는 100 미만입니다.
사실 이번 결과의 주범은 우리가 자주 언급하는 '더티 데이터'다. run 메소드의 Yield() 문은 "더티 데이터"의 시작자입니다(Yield 문을 추가하지 않으면 "더티 데이터"도 생성될 수 있지만 그다지 명확하지는 않습니다. 100을 더 큰 숫자로 변경해야만 "더티 데이터"를 생성하는 경우 이 예에서 Yield를 호출하는 것은 "더티 데이터"의 효과를 증폭시키기 위한 것입니다. 항복 메서드의 기능은 스레드를 일시 중지하는 것입니다. 즉, 항복 메서드를 호출하는 스레드가 일시적으로 CPU 리소스를 포기하게 하여 CPU가 다른 스레드를 실행할 수 있는 기회를 제공하는 것입니다. 이 프로그램이 "더티 데이터"를 생성하는 방법을 설명하기 위해 thread1과 thread2라는 두 개의 스레드만 생성된다고 가정해 보겠습니다. thread1의 시작 메소드가 먼저 호출되므로 일반적으로 thread1의 실행 메소드가 먼저 실행됩니다. thread1의 run 메소드가 첫 번째 라인(int m = n;)까지 실행되면 n 값이 m에 할당됩니다. 두 번째 줄의 항복 메서드가 실행되면 thread1은 일시적으로 실행을 중지합니다. thread1이 일시 중지되면 thread2는 CPU 리소스를 얻은 후 실행을 시작합니다(thread2는 이전에 준비 상태에 있었습니다). m = n;), thread1이 Yield로 실행될 때 n은 여전히 0이므로 thread2에서 m이 얻은 값도 0입니다. 이로 인해 thread1과 thread2의 m 값이 모두 0이 됩니다. Yield 메소드를 실행한 후 모두 0부터 시작하여 1을 더합니다. 따라서 누가 먼저 실행하든 n의 최종 값은 1이 되지만 이 n에는 각각 thread1과 thread2에 의해 값이 할당됩니다. n++만 있으면 "더티 데이터"가 생성됩니까? 대답은 '예'입니다. 그러면 n++는 단지 명령문일 뿐인데, 실행 중에 CPU를 다른 스레드에 어떻게 넘겨줄 수 있을까요? 사실 이는 단지 표면적인 현상일 뿐이며, Java 컴파일러에 의해 n++가 중간 언어(바이트코드라고도 함)로 컴파일된 후에는 언어가 아닙니다. 다음 Java 코드가 어떤 Java 중간 언어로 컴파일되는지 살펴보겠습니다.
다음과 같이 코드 코드를 복사합니다.
공개 무효 실행()
{
n++;
}
컴파일된 중간 언어 코드
다음과 같이 코드 코드를 복사합니다.
공개 무효 실행()
{
aload_0
멍청이
겟필드
iconst_1
iadd
퍼트필드
반품
}
run 메소드에는 n++ 문만 있는 것을 볼 수 있는데, 컴파일 후에는 7개의 중간 언어 문이 있습니다. 이 명령문의 기능이 무엇인지 알 필요는 없습니다. 005, 007 및 008행의 명령문만 살펴보십시오. 005행은 getfield입니다. 영어 의미에 따르면 우리는 특정 값을 얻고자 한다는 것을 알고 있습니다. 여기에 n이 하나만 있기 때문에 우리가 n의 값을 얻고자 한다는 것은 의심의 여지가 없습니다. 007행의 iadd는 얻은 n 값에 1을 더하는 것임을 추측하는 것은 어렵지 않습니다. 008번째 줄에 있는 putfield의 의미를 짐작하셨을 것 같습니다. 이는 클래스 변수 n에 다시 1을 더한 후 n을 업데이트하는 역할을 담당합니다. n++를 실행할 때 n에 1을 더하는 것만으로도 충분합니다. 왜 그렇게 많은 수고가 필요한가요? 실제로 이는 Java 메모리 모델 문제와 관련이 있습니다.
Java의 메모리 모델은 주 저장 영역과 작업 저장 영역으로 구분됩니다. 주요 저장 영역은 모든 인스턴스를 Java로 저장합니다. 즉, new를 사용하여 객체를 만든 후 해당 객체와 해당 객체의 내부 메서드, 변수 등이 이 영역에 저장되고 MyThread 클래스의 n이 이 영역에 저장됩니다. 메인 스토리지는 모든 스레드에서 공유될 수 있습니다. 작업 저장 영역은 앞서 이야기한 스레드 스택입니다. 이 영역에는 run 메서드에서 정의한 변수와 run 메서드에서 호출하는 메서드, 즉 메서드 변수가 저장됩니다. 스레드가 주 저장 영역의 변수를 수정하려는 경우 이러한 변수를 직접 수정하지 않고 현재 스레드의 작업 저장 영역에 복사하여 수정 후 해당 값으로 변수 값을 덮어씁니다. 주 저장 영역에 있는 변수 값입니다.
Java의 메모리 모델을 이해하고 나면 n++가 원자 연산이 아닌 이유를 이해하는 것은 어렵지 않습니다. 복사하고, 1을 추가하고, 덮어쓰는 과정을 거쳐야 합니다. 이 프로세스는 MyThread 클래스에서 시뮬레이션된 프로세스와 유사합니다. 짐작할 수 있듯이 getfield가 실행될 때 어떤 이유로 thread1이 중단되면 MyThread 클래스의 실행 결과와 유사한 상황이 발생합니다. 이 문제를 완전히 해결하려면 n을 동기화하는 몇 가지 방법을 사용해야 합니다. 즉, 하나의 스레드만이 동시에 n을 작동할 수 있으며, 이를 n에 대한 원자 작동이라고도 합니다.