>  기사  >  Java  >  루트에서 Java 휘발성 키워드 구현의 샘플 코드 분석(그림)

루트에서 Java 휘발성 키워드 구현의 샘플 코드 분석(그림)

黄舟
黄舟원래의
2017-03-22 10:47:231303검색

1. 분석개요

  1. 메모리 모델 관련 개념

  2. 동시 프로그래밍의 세 가지 개념

  3. Java 메모리 모델

  4. 휘발성키워드 심층 분석

  5. 휘발성 키워드 사용 시나리오

메모리 모델 관련 개념

캐시 일관성 문제. 이러한 유형의 변수를 일반적으로 공유 변수라고 합니다.

즉, 변수가 여러 CPU에 캐시되는 경우(일반적으로) 멀티스레드에서만 발생합니다. 프로그래밍), 캐시 불일치 문제가 있을 수 있습니다.

캐시 불일치 문제를 해결하려면 일반적으로 두 가지 해결 방법이 있습니다.

  • 버스에 LOCK#을 추가하는 방법

  • 캐시 일관성 프로토콜을 통해

이 두 가지 방법은 하드웨어 수준에서 제공되는 방법

위의 방법 1은 잠금 기간 동안 다른 CPU가 메모리에 액세스할 수 없어 비효율성을 초래하므로 문제가 발생합니다.

가장 유명한 캐시 일관성 프로토콜은 Intel의 MESI 프로토콜입니다. 각 캐시에 사용되는 공유 변수는 일관됩니다. 핵심 아이디어는 CPU가 데이터를 쓸 때 작동 변수가 공유 변수인 것으로 확인되면 다른 CPU에도 해당 변수의 복사본이 있다는 것입니다. CPU는 변수의 캐시 라인을 무효화하도록 신호를 보냅니다. 따라서 다른 CPU가 이 변수를 읽어야 할 때 자신의 캐시에 변수를 캐싱하는 캐시 라인이 유효하지 않다는 것을 알게 됩니다. 메모리에서 다시 읽기

3. 동시 프로그래밍의 세 가지 개념

동시 프로그래밍에서는 일반적으로 원자성 문제, 가시성 문제, 순서 문제

3.1 원자성

원자성: 하나의 작업 또는 여러 작업이 모두 실행되고 실행되는 프로세스입니다. 어떤 요인에도 중단되지 않습니다.

3.2 가시성

가시성은 여러 스레드가 동일한 변수에 액세스할 때 한 스레드가 변수 값을 수정하면 다른 스레드가 수정된 값을 즉시 볼 수 있음을 의미합니다.

3.3 질서성

순서성: 즉, 프로그램 실행 순서는 코드 순서로 보면 명령문 1이 명령문 2보다 먼저 실행됩니다. 따라서 JVM이 실제로 이를 실행할 때 코드에서는 명령문 1이 명령문 2보다 먼저 실행되도록 보장합니까? 반드시 그런 것은 아닙니다. 여기서 정렬(명령어 재정렬)이 발생할 수 있는 이유는 무엇입니까?

다음은 명령어 재정렬이 무엇인지 설명합니다. 일반적으로 프로그램 작업의 효율성을 높이기 위해 프로세서는 입력 코드를 최적화할 수 있습니다. 이는 프로그램의 각 명령문의 실행 순서를 보장하지 않습니다. 코드와 동일합니다. 순서는 일관되지만 프로그램의 최종 실행 결과는 코드의 순차적 실행 결과와 일치합니다.

명령어 재정렬은 단일 스레드 실행에는 영향을 미치지 않지만 스레드 동시 실행의 정확성에는 영향을 미칩니다.

즉, 동시성 프로그램이 올바르게 실행되기 위해서는 원자성, 가시성, 질서가 보장되어야 합니다. 그 중 하나가 보장되지 않는 한 프로그램이 잘못 실행될 수 있습니다.

4. Java 메모리 모델

Java Virtual Machine 사양에서는 다양한 보호를 위해 Java 메모리 모델(Java Memory

Model

, JMM)을 정의하려는 시도가 있습니다. 다양한 플랫폼에서 Java 프로그램에 대한 일관된 메모리 액세스 효과를 달성하기 위한 시스템 메모리 액세스 차이점입니다. 그렇다면 Java 메모리 모델은 무엇을 규정합니까? 이는 프로그램의 변수에 대한 액세스 규칙을 정의합니다. 더 넓은 범위에서는 프로그램 실행 순서를 정의합니다. 더 나은 실행 성능을 얻기 위해 Java 메모리 모델은 실행 엔진이 명령 실행 속도를 향상시키기 위해 프로세서의 레지스터나 캐시를 사용하는 것을 제한하지 않으며 컴파일러가 명령을 재정렬하는 것을 제한하지도 않습니다. 즉, Java 메모리 모델에는 캐시 일관성 문제와 명령어 재정렬 문제도 있습니다.

Java 메모리 모델에서는 모든 변수가 메인 메모리(앞서 언급한 물리적 메모리와 유사)에 저장되고 각 스레드에는 자체 작업 메모리(이전 캐시와 유사)가 있다고 규정합니다. 스레드에 의한 변수에 대한 모든 작업은 작업 메모리에서 수행되어야 하며 주 메모리에서 직접 작업할 수 없습니다. 그리고 각 스레드는 다른 스레드의 작업 메모리에 액세스할 수 없습니다.

4.1 원자성

Java에서 기본

데이터 유형

의 변수를 읽고 할당하는 작업은 원자 작업입니다. 즉, 이러한 작업은 중단되거나 실행될 수 없습니다. 또는 실행되지 않았습니다.

다음 연산 중 원자 연산이 무엇인지 분석해 보세요.

x = 10 //Statement 1
  1. y = x; //문장 2
  2. x++; //문장 3

  3. x = x + 1

사실 문 1만 원자 연산이고, 나머지 세 문은 원자 연산이 아닙니다.

즉, 단순한 읽기와 할당(그리고 변수에 숫자를 할당해야 하며, 변수 간의 상호 할당은 원자적 연산이 아님)만이 원자적 연산입니다.

위에서 볼 수 있듯이 Java 메모리 모델은 기본 읽기 및 할당이 원자성 작업인 것만 보장합니다. 더 넓은 범위의 작업에 대한 원자성을 달성하려면 동기화 및 잠금을 통해 이를 달성할 수 있습니다. .

4.2 가시성

가시성을 위해 Java는 가시성을 보장하기 위해 휘발성 키워드를 제공합니다.

공유 변수가 휘발성으로 수정되면 수정된 값이 즉시 메인 메모리에 업데이트됩니다. 다른 스레드가 이를 읽어야 할 경우 메모리에서 새 값을 읽습니다.

일반 공유 변수는 가시성을 보장할 수 없습니다. 일반 공유 변수가 수정된 후, 다른 스레드가 이를 읽을 때 메모리가 여전히 원래의 이전 값에 기록될 수 있는지 알 수 없기 때문입니다. 이므로 가시성이 보장되지 않습니다.

또한, 동기화 및 잠금을 통해 가시성도 보장할 수 있습니다. 동기화 및 잠금은 하나의 스레드만 동시에 잠금을 획득한 후 동기화 코드를 실행하도록 보장할 수 있으며, 변수의 수정은 다음과 같습니다. 잠금을 해제하기 전에 메인 메모리로 플러시됩니다. 따라서 가시성이 보장됩니다.

4.3 질서

Java 메모리 모델에서는 컴파일러와 프로세서가 명령어를 재정렬할 수 있지만 재정렬 프로세스는 단일 스레드 프로그램의 실행에는 영향을 미치지 않지만 정확성에는 영향을 미칩니다. 멀티스레드 동시 실행.

Java에서는 휘발성 키워드를 사용하여 특정 "질서성"을 보장할 수 있습니다(명령어 재정렬을 금지할 수 있음). 또한 동기화 및 잠금을 통해 질서를 보장할 수 있습니다. 당연히 동기화 및 잠금은 하나의 스레드가 매 순간 동기화 코드를 실행하도록 보장합니다. 이는 스레드가 동기화 코드를 순차적으로 실행하도록 하는 것과 동일하므로 자연스럽게 질서가 보장됩니다.

또한 Java 메모리 모델에는 선천적인 "질서성", 즉 아무런 수단 없이도 보장될 수 있는 질서성이 있습니다. 이를 흔히 사전 발생 원칙이라고 합니다. 두 작업의 실행 순서를 사전 발생 원칙에서 추론할 수 없는 경우 순서가 보장되지 않으며 가상 머신이 마음대로 순서를 변경할 수 있습니다.

다음은 사전 발생 원칙에 대한 자세한 소개입니다.

  1. 프로그램 순서 규칙: 스레드 내에서는 코드 순서에 따라 앞에 쓰는 작업입니다.

  2. 이후 작성된 작업 이전에 발생 잠금 규칙: 동일한 잠금 이후 동일한 잠금 작업 이전에 잠금 해제 작업이 발생

  3. 휘발성 변수 규칙: 변수에 대한 쓰기 작업이 이 변수에 대한 후속 읽기 작업보다 먼저 발생합니다.

  4. 전파 규칙: 작업 A가 작업 B보다 먼저 발생하고 작업 B가 발생하는 경우 C 작업 이전에 A 작업이 C 작업보다 먼저 발생한다고 결론을 내릴 수 있습니다

  5. 스레드 시작 규칙: Thread 객체의 start() 메서드가 여기에서 먼저 발생합니다. 스레드의 모든 작업

  6. 스레드 중단 규칙: 중단된 스레드의 코드가 인터럽트 이벤트 발생을 감지하면 스레드 Interrupt() 메서드 호출이 먼저 발생합니다.

  7. 스레드 종료 규칙: 스레드의 모든 작업은 스레드가 종료될 때 먼저 발생합니다. Thread.join() 메서드와 Thread.isAlive()의 반환 값을 통해 스레드가 종료되었음을 감지할 수 있습니다.

  8. 객체 종료 규칙: 객체 초기화는 finalize() 메서드 시작 부분에서 먼저 발생합니다.

이 8가지 규칙 중 처음 4가지 규칙은 더 중요하므로 마지막 4가지 규칙은 분명합니다.

처음 4가지 규칙을 설명하겠습니다.

  1. 프로그램 순서 규칙의 경우 프로그램 코드 조각의 실행은 단일 스레드에서 실행되는 것처럼 보입니다. 주문하다. 이 규칙에서는 "앞에 작성된 작업이 뒤에 작성된 작업보다 먼저 발생합니다"라고 언급하지만 이는 프로그램이 실행되는 것처럼 보이는 순서가 코드의 순서임을 의미합니다. 순서가 변경된 프로그램 코드를 변경합니다. 재정렬이 수행되더라도 최종 실행 결과는 프로그램의 순차적 실행 결과와 일치합니다. 이는 데이터 종속성이 없는 명령만 재정렬합니다. 따라서 단일 스레드에서는 프로그램 실행이 순서대로 실행되는 것처럼 보이므로 이를 이해해야 합니다. 실제로 이 규칙은 단일 스레드에서 프로그램 실행 결과의 정확성을 보장하는 데 사용되지만, 다중 스레드에서 프로그램 실행의 정확성을 보장할 수는 없습니다.

  2. 두 번째 규칙도 이해하기 쉽습니다. 즉, 단일 스레드에서든 다중 스레드에서든 동일한 잠금이 잠금 상태에 있으면 잠금을 해제해야 합니다. 먼저 해제 작업을 수행한 후 나중에 잠금 작업을 계속할 수 있습니다.

  3. 세 번째 규칙은 더 중요한 규칙이며 다음 글에서 중점적으로 다룰 것입니다. 직관적인 설명은 스레드가 변수를 먼저 쓴 다음 스레드가 이를 읽으면 쓰기 작업이 읽기 작업보다 먼저 발생한다는 것입니다.

  4. 네 번째 규칙은 실제로 사전 발생 원칙의 전이적 특성을 반영합니다.

5. 휘발성 키워드 심층 분석

5.1 휘발성 키워드의 2단계 의미

일단 공유변수(멤버변수) 클래스의 클래스 정적 멤버 변수)가 휘발성으로 수정된 후 두 가지 수준의 의미를 갖습니다.

  1. 는 이 변수에 대해 서로 다른 스레드, 즉 하나의 스레드가 작동할 때 가시성을 보장합니다. 특정 변수 값을 수정하면 새 값이 다른 스레드에 즉시 표시됩니다.

  2. 명령어 재정렬은 금지됩니다.

가시성에 관해 먼저 코드를 살펴보겠습니다. 스레드 1이 먼저 실행되고 스레드 2가 나중에 실행되는 경우:

//线程1
boolean stop = false;
while(!stop){
doSomething();
}

//线程2
stop = true;

이 코드는 매우 일반적입니다. 코드 조각에는 많은 사람들이 스레드를 중단할 때 이 표시 방법을 사용할 수 있습니다. 하지만 실제로 이 코드가 완전히 올바르게 실행될까요? 즉, 스레드가 중단됩니까? 반드시 그런 것은 아니지만 대부분의 경우 이 코드는 스레드를 중단할 수 있지만 스레드를 중단할 수 없게 만들 수도 있습니다(이런 가능성은 매우 적지만 일단 이런 일이 발생하면 무한 루프가 발생합니다).

다음은 이 코드로 인해 스레드가 중단되지 않는 이유를 설명합니다. 앞에서 설명했듯이 각 스레드는 실행 중에 자체 작업 메모리를 가지므로 스레드 1이 실행 중일 때 중지 변수의 값을 복사하여 자체 작업 메모리에 넣습니다.

그런 다음 스레드 2가 중지 변수의 값을 변경했지만 이를 주 메모리에 쓸 시간이 되기 전에 스레드 2는 다른 작업을 수행하도록 전환하고 스레드 1은 중지 변수의 값을 알지 못합니다. 스레드 2에 의해 변수가 지정되므로 주기가 계속됩니다.

그러나 휘발성 수정을 사용한 후에는 달라집니다.

  • 첫 번째: 휘발성 키워드를 사용하면 수정된 값이 즉시 메인 메모리에 기록됩니다. >

  • 둘째: 휘발성 키워드를 사용하면 스레드 2가 수정을 할 때 스레드 1의 작업 메모리에 있는 캐시 변수 stop의 캐시 라인이 유효하지 않게 됩니다. 하드웨어 계층에 반영됩니다. 즉, CPU의 L1 또는 L2 캐시에 있는 해당 캐시 라인이 유효하지 않습니다.)

  • 셋째: 캐시 변수의 캐시 라인이 작동을 멈추기 때문입니다. 스레드 1의 메모리가 유효하지 않아 스레드 1이 이를 다시 읽습니다. stop 변수의 값은 주 메모리에서 읽혀집니다.

그런 다음 스레드 2가 중지 값을 수정하는 경우(물론 여기에는 스레드 2의 작업 메모리에 있는 값을 수정하고 수정된 값을 메모리에 쓰는 2개의 작업이 포함됩니다) , 스레드 1의 작업 메모리에서 캐시 변수 중지의 캐시 라인이 유효하지 않게 됩니다. 그런 다음 스레드 1이 이를 읽을 때 캐시 라인이 유효하지 않음을 발견하고 캐시에 해당하는 주 메모리 주소를 기다립니다. 업데이트할 라인을 선택한 다음 해당 주 메모리에서 읽어옵니다.

그럼 스레드 1이 읽는 것이 가장 최근의 정확한 값입니다.

5.2 휘발성이 원자성을 보장하나요?

휘발성은 원자성을 보장하지 않습니다. 아래 예를 살펴보겠습니다.

아아아아

모두들 이 프로그램의 결과물에 대해 생각하시나요? 어쩌면 어떤 친구들은 그것이 10,000이라고 생각할 수도 있습니다. 그런데 실제로 실행해 보면 매번 결과가 일관되지 않고, 항상 10,000 미만의 숫자임을 알 수 있습니다.

여기서 오해가 있습니다. 휘발성 키워드는 가시성을 보장할 수 있지만 위 프로그램의 오류는 원자성을 보장하지 못한다는 것입니다. 가시성은 매번 최신 값을 읽는 것만 보장할 수 있지만 휘발성은 변수 작업의 원자성을 보장할 수 없습니다.

앞서 언급했듯이 자동 증가 작업은 원자성이 아닙니다. 여기에는 변수의 원래 값을 읽고 1을 더하고 작업 메모리에 쓰는 작업이 포함됩니다. 즉, 자동 증가 연산의 세 가지 하위 연산이 별도로 실행될 수 있으며 이로 인해 다음과 같은 상황이 발생할 수 있습니다.

특정 순간에 변수 inc의 값이 10인 경우.

스레드 1은 변수에 대해 자동 증가 작업을 수행합니다. 스레드 1은 먼저 변수 inc의 원래 값을 읽은 다음 스레드 1이 차단됩니다.

스레드 2는 자동 증가 작업을 수행합니다. 스레드 2는 변수 inc의 원래 값도 읽습니다. 스레드 1은 변수 inc만 읽고 변수를 수정하지 않으므로 스레드 2의 작업 메모리에 있는 캐시된 변수 inc의 캐시 라인은 변경되지 않습니다. 따라서 스레드 2는 직접 주 메모리로 이동하여 inc 값을 읽고 inc 값이 10임을 확인한 다음 1을 더하고 11을 작업 메모리에 쓴 다음 마지막으로 주 메모리에 씁니다. .

그런 다음 스레드 1은 1을 더합니다. inc 값을 읽었으므로 이때 스레드 1의 작업 메모리에 있는 inc 값은 여전히 ​​10이므로 스레드 1은 inc에 1을 더합니다. 마지막 inc의 값은 11이고 11이 작업 메모리에 기록되고 마지막으로 주 메모리에 기록됩니다.

그런 다음 두 스레드가 각각 자동 증가 작업을 수행한 후 inc는 1씩만 증가합니다.

이렇게 설명하니 궁금한 친구들이 있을 수도 있겠네요. 아니요. 변수를 휘발성 변수로 수정하면 캐시 라인이 무효화된다는 보장은 없나요? 그러면 다른 스레드가 새 값을 읽습니다. 예, 맞습니다. 이는 위의 발생 전 규칙에 있는 휘발성 변수 규칙이지만 스레드 1이 변수를 읽고 차단된 후에는 inc 값이 수정되지 않는다는 점에 유의해야 합니다. 그런 다음 휘발성은 스레드 2가 메모리에서 변수 inc의 값을 읽는 것을 보장할 수 있지만 스레드 1은 이를 수정하지 않았으므로 스레드 2는 수정된 값을 전혀 볼 수 없습니다.

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

把上面的代码改成以下任何一种都可以达到效果:

采用synchronized:

public class Test {
    public  int inc = 0;

    public synchronized void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

采用Lock:

public class Test {
    public  int inc = 0;
    Lock lock = new ReentrantLock();

    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

采用AtomicInteger:

public class Test {
    public  AtomicInteger inc = new AtomicInteger();

    public  void increase() {
        inc.getAndIncrement();
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

5.3 volatile能保证有序性吗?

volatile能在一定程度上保证有序性。

volatile关键字禁止指令重排序有两层意思:

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

举个例子:

//x、y为非volatile变量
//flag为volatile变量

x = 2;         //语句1
y = 0;         //语句2
flag = true;   //语句3
x = 4;         //语句4
y = -1;        //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

5.4 volatile的原理和实现机制

这里探讨一下volatile到底如何保证可见性和禁止指令重排序的。

下面这段话摘自《深入理解Java虚拟机》:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2. 它会强制将对缓存的修改操作立即写入主存;

  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

6、使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  1. 对变量的写操作不依赖于当前值(比如++操作,上面有例子)

  2. 该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

下面列举几个Java中使用volatile的几个场景。

状态标记量

volatile boolean flag = false;

while(!flag){
    doSomething();
}

public void setFlag() {
    flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

double check

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

至于为何需要这么写请参考:


위 내용은 루트에서 Java 휘발성 키워드 구현의 샘플 코드 분석(그림)의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.