휘발성의 특성
공유 변수를 휘발성으로 선언하면 이 변수의 읽기/쓰기가 매우 특별해집니다. 휘발성의 특성을 이해하는 좋은 방법은 휘발성 변수에 대한 개별 읽기/쓰기를 동일한 모니터 잠금을 사용하여 이러한 개별 읽기/쓰기 작업을 동기화하는 것으로 생각하는 것입니다. 구체적인 예를 통해 살펴보겠습니다.
class VolatileFeaturesExample { volatile long vl = 0L; //使用volatile声明64位的long型变量 public void set(long l) { vl = l; //单个volatile变量的写 } public void getAndIncrement () { vl++; //复合(多个)volatile变量的读/写 } public long get() { return vl; //单个volatile变量的读 } }
위 프로그램의 세 가지 메서드를 각각 호출하는 여러 스레드가 있다고 가정합니다. 이 프로그램은 의미상 다음 프로그램과 동일합니다.
class VolatileFeaturesExample { long vl = 0L; // 64位的long型普通变量 public synchronized void set(long l) { //对单个的普通 变量的写用同一个监视器同步 vl = l; } public void getAndIncrement () { //普通方法调用 long temp = get(); //调用已同步的读方法 temp += 1L; //普通写操作 set(temp); //调用已同步的写方法 } public synchronized long get() { //对单个的普通变量的读用同一个监视器同步 return vl; } }
위의 샘플 프로그램에서 볼 수 있듯이 휘발성 변수에 대한 단일 읽기/쓰기 작업은 일반 변수에 대한 읽기/쓰기 작업과 동일한 모니터 잠금을 사용합니다. , 그들 사이의 실행 효과는 동일합니다.
모니터 잠금의 사전 발생 규칙은 모니터를 해제하고 모니터를 획득하는 두 스레드 간의 메모리 가시성을 보장합니다. 즉, 휘발성 변수의 읽기는 항상 (모든 스레드) 최종 쓰기를 볼 수 있음을 의미합니다. 이 휘발성 변수에.
모니터 잠금의 의미에 따라 임계 섹션 코드의 실행이 원자성으로 결정됩니다. 이는 64비트 long 및 double 변수의 경우에도 휘발성 변수인 한 변수에 대한 읽기 및 쓰기는 원자적으로 이루어짐을 의미합니다. 휘발성 작업이 여러 개 있거나 휘발성++과 같은 복합 작업이 있는 경우 이러한 작업은 전체적으로 원자적이지 않습니다.
간단히 말하면 휘발성 변수 자체에는 다음과 같은 속성이 있습니다.
가시성. 휘발성 변수를 읽으면 항상 휘발성 변수에 대한 마지막 쓰기(모든 스레드에 의한)가 표시됩니다.
원자성: 단일 휘발성 변수를 읽고 쓰는 것은 원자적이지만 휘발성++와 같은 복합 연산은 원자적이지 않습니다.
휘발성 쓰기-읽기에 의한 관계 설정 전 발생
위는 휘발성 변수 자체의 특성에 대한 것입니다. 프로그래머에게 휘발성은 휘발성 자체보다 스레드의 메모리 가시성에 더 큰 영향을 미칩니다. 기능이 더 중요하며 더 많은 관심이 필요합니다.
JSR-133부터 휘발성 변수의 쓰기 읽기를 통해 스레드 간 통신이 가능해졌습니다.
메모리 의미론의 관점에서 볼 때 휘발성 및 모니터 잠금은 동일한 효과를 갖습니다. 휘발성 쓰기 및 모니터 해제는 동일한 메모리 의미론을 가지며 휘발성 읽기 및 모니터 획득은 동일한 메모리 의미론을 갖습니다.
휘발성 변수를 사용하는 다음 샘플 코드를 참조하세요.
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 …… } } }
스레드 A가writer() 메서드를 실행한 후 스레드 B가 reader() 메서드를 실행한다고 가정합니다. 발생 전 규칙에 따르면 이 과정에서 설정된 발생 전 관계는 두 가지 범주로 나눌 수 있습니다.
프로그램 순서 규칙에 따르면 1은 2보다 먼저 발생하고 3은 4보다 먼저 발생합니다.
휘발성 규칙에 따르면 2는 3보다 먼저 발생합니다.
이전에 발생한다는 전이성 규칙에 따르면 1은 4보다 먼저 발생합니다.
위의 발생 이전 관계를 그래픽으로 표현하면 다음과 같습니다.
위 그림에서 각 화살표로 연결된 두 노드는 발생 이전을 나타냅니다. 관계. 검은색 화살표는 프로그램 순서 규칙을 나타내고, 주황색 화살표는 변동성 규칙을 나타내며, 파란색 화살표는 이러한 규칙을 결합하여 제공되는 사전 발생 보장을 나타냅니다.
스레드 A가 휘발성 변수를 쓴 후 스레드 B는 동일한 휘발성 변수를 읽습니다. 휘발성 변수를 쓰기 전에 스레드 A에 표시되는 모든 공유 변수는 스레드 B가 동일한 휘발성 변수를 읽은 직후 스레드 B에 표시됩니다.
휘발성 쓰기-읽기의 메모리 의미
휘발성 쓰기의 메모리 의미는 다음과 같습니다.
휘발성 변수를 쓸 때 JMM은 휘발성 변수에 해당하는 로컬 메모리의 공유 변수를 새로 고칩니다. 스레드를 주 메모리로 보냅니다.
위의 예제 프로그램 VolatileExample을 예로 들면, 스레드 A가 먼저writer() 메서드를 실행한 다음 스레드 B가 reader() 메서드를 실행한다고 가정합니다. 처음에는 로컬 메모리에 플래그와 a가 있습니다. 두 스레드 모두 초기 상태입니다. 다음 그림은 스레드 A가 휘발성 쓰기를 수행한 후 공유 변수의 상태를 개략적으로 나타낸 것입니다.
위 그림과 같이 스레드 A가 플래그 변수를 쓴 후, 스레드 A의 로컬 메모리는 A가 업데이트한 두 공유 변수의 값이 메인 메모리로 플러시됩니다. 이때 로컬 메모리 A와 메인 메모리의 공유변수 값은 일치한다.
휘발성 읽기의 메모리 의미는 다음과 같습니다.
휘발성 변수를 읽을 때 JMM은 스레드에 해당하는 로컬 메모리를 무효화합니다. 스레드는 다음으로 주 메모리에서 공유 변수를 읽습니다.
다음은 스레드 B가 동일한 휘발성 변수를 읽은 후 공유 변수의 상태에 대한 개략도입니다.
위 그림과 같이 플래그 변수를 읽은 후 로컬 메모리 B가 무효화되었습니다. 이 시점에서 스레드 B는 주 메모리에서 공유 변수를 읽어야 합니다. 스레드 B의 읽기 작업으로 인해 로컬 메모리 B와 메인 메모리의 공유 변수 값이 일치하게 됩니다.
휘발성 쓰기와 휘발성 읽기의 두 단계를 결합하면 읽기 스레드 B가 휘발성 변수를 읽은 후 쓰기 스레드 A가 휘발성 변수를 쓰기 전에 표시되는 모든 공유 변수의 값이 즉시 표시됩니다. 스레드 B를 읽는 중입니다.
다음은 휘발성 쓰기 및 휘발성 읽기의 메모리 의미를 요약한 것입니다.
스레드 A는 본질적으로 휘발성 변수를 다음에 읽을 스레드에 메시지를 보냅니다. .(공유 변수 수정) 메시지.
스레드 B는 휘발성 변수를 읽습니다. 본질적으로 스레드 B는 이전 스레드에서 보낸 메시지를 받습니다(공유 변수는 이 휘발성 변수를 쓰기 전에 수정되었습니다).
스레드 A는 휘발성 변수를 쓴 다음 스레드 B는 휘발성 변수를 읽습니다. 이 프로세스는 본질적으로 스레드 A가 메인 메모리를 통해 스레드 B에 메시지를 보내는 것입니다.
휘발성 메모리 의미 구현
다음으로 JMM이 휘발성 쓰기/읽기 메모리 의미를 어떻게 구현하는지 살펴보겠습니다.
앞서 재정렬은 컴파일러 재정렬과 프로세서 재정렬로 나누어진다고 말씀드렸습니다. 휘발성 메모리 의미 체계를 달성하기 위해 JMM은 이 두 가지 유형의 재정렬 유형을 각각 제한합니다. 다음은 컴파일러를 위해 JMM이 공식화한 휘발성 재정렬 규칙의 표입니다.
재정렬 가능 여부
두 번째 작업
첫 번째 작업
일반 읽기/쓰기
휘발성 읽기
휘발성 쓰기
일반 읽기/쓰기
NO
휘발성 읽기
NO
NO
NO
휘발성 쓰기
NO
NO
예 예 , 세 번째 행의 마지막 셀의 의미는 다음과 같습니다. 프로그램 시퀀스에서 첫 번째 작업이 일반 변수의 읽기 또는 쓰기이고 두 번째 작업이 휘발성 쓰기인 경우 컴파일러는 이 두 작업의 순서를 변경할 수 없습니다. 운영.
위 표에서 다음을 확인할 수 있습니다.
두 번째 작업이 휘발성 쓰기인 경우 첫 번째 작업이 무엇이든 순서를 변경할 수 없습니다. 이 규칙은 휘발성 쓰기 이전의 작업이 컴파일러에 의해 휘발성 쓰기 이후로 재정렬되지 않도록 보장합니다.
첫 번째 작업이 휘발성 읽기인 경우 두 번째 작업이 무엇이든 순서를 변경할 수 없습니다. 이 규칙은 휘발성 읽기 이후의 작업이 휘발성 읽기보다 우선하도록 컴파일러에 의해 재정렬되지 않도록 보장합니다.
첫 번째 작업이 휘발성 쓰기이고 두 번째 작업이 휘발성 읽기인 경우 재정렬을 수행할 수 없습니다.
휘발성 메모리 의미 체계를 달성하기 위해 컴파일러는 바이트 코드를 생성할 때 명령어 시퀀스에 메모리 장벽을 삽입하여 특정 유형의 프로세서 재정렬을 금지합니다. 삽입된 장벽의 총 개수를 최소화하는 최적의 배열을 컴파일러가 찾는 것은 거의 불가능하므로 JMM은 보수적인 전략을 채택합니다. 다음은 보수적인 전략에 기반한 JMM 메모리 배리어 삽입 전략입니다.
각 휘발성 쓰기 작업 앞에 StoreStore 배리어를 삽입합니다.
각 휘발성 쓰기 작업 후에 StoreLoad 장벽을 삽입하세요.
각 휘발성 읽기 작업 후에 LoadLoad 장벽을 삽입합니다.
각 휘발성 읽기 작업 후에 LoadStore 장벽을 삽입하세요.
위의 메모리 장벽 삽입 전략은 매우 보수적이지만 모든 프로세서 플랫폼의 모든 프로그램에서 올바른 휘발성 메모리 의미 체계를 얻을 수 있도록 보장할 수 있습니다.
다음은 보존적 전략에 따라 휘발성 쓰기가 메모리 배리어에 삽입된 후 생성된 명령어 시퀀스의 개략도입니다.
The StoreStore Barrier in 위 그림을 통해 휘발성 쓰기가 이전에는 모든 프로세서에서 이미 볼 수 있었던 일반적인 쓰기임을 확인할 수 있습니다. 이는 StoreStore 장벽이 위의 모든 일반 쓰기가 휘발성 쓰기 전에 기본 메모리로 플러시되도록 보장하기 때문입니다.
여기서 더 흥미로운 점은 휘발성 쓰기 뒤에 있는 StoreLoad 장벽입니다. 이 장벽의 목적은 휘발성 쓰기가 후속 휘발성 읽기/쓰기 작업으로 인해 재정렬되는 것을 방지하는 것입니다. 왜냐하면 컴파일러는 휘발성 쓰기 후에 StoreLoad 장벽을 삽입해야 하는지 여부를 정확하게 결정할 수 없는 경우가 많기 때문입니다(예: 휘발성 쓰기 후에 메서드가 즉시 반환됨). 휘발성 메모리 의미 체계가 올바르게 구현되도록 하기 위해 JMM은 여기서 보수적인 전략을 채택합니다. 즉, 각 휘발성 쓰기 후 또는 각 휘발성 읽기 앞에 StoreLoad 장벽을 삽입하는 것입니다. 전반적인 실행 효율성의 관점에서 JMM은 각 휘발성 쓰기 후에 StoreLoad 장벽을 삽입하기로 결정했습니다. 휘발성 쓰기-읽기 메모리 의미론의 일반적인 사용 패턴은 하나의 쓰기 스레드가 휘발성 변수를 쓰고, 여러 읽기 스레드가 동일한 휘발성 변수를 읽는 것이기 때문입니다. 읽기 스레드 수가 쓰기 스레드 수를 크게 초과하는 경우 휘발성 쓰기 후에 StoreLoad 장벽을 삽입하도록 선택하면 실행 효율성이 크게 향상됩니다. 여기에서 우리는 JMM 구현의 특징을 볼 수 있습니다: 먼저 정확성을 보장하고 실행 효율성을 추구합니다.
다음은 보존적 전략에 따라 휘발성 읽기가 메모리 장벽에 삽입된 후 생성된 명령 시퀀스의 개략도입니다.
上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; //第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; //普通写 v1 = i + 1; // 第一个volatile写 v2 = j * 2; //第二个 volatile写 } … //其他方法 }
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。
上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:
前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。
以上就是Java内存模型深度解析:volatile的内容,更多相关内容请关注PHP中文网(www.php.cn)!