>  기사  >  Java  >  Java 기초의 휘발성 적용 사례 분석

Java 기초의 휘발성 적용 사례 분석

WBOY
WBOY앞으로
2023-05-28 11:23:391273검색

Java 기초의 휘발성 적용 사례 분석

Q: 휘발성에 대한 이해를 공유해 주세요.
답변: Volatile은 Java 가상 머신에서 제공하는 경량 동기화 메커니즘입니다.
1) 가시성 보장
2) 원자성을 보장하지 않음
3) 명령 재정렬을 금지합니다

방금 완료했습니다. Java의 기본을 배우면서 누군가가 휘발성이 무엇인지 묻는다면? 어떤 기능이 있다면 매우 혼란스러우실 거라 생각합니다...
답변을 읽고 나서 전혀 이해하지 못하실 수도 있는데, 동기화 메커니즘이 무엇인가요? 가시성이란 무엇입니까? 원자성이란 무엇입니까? 명령어 재정렬이란 무엇입니까?

1. Volatile은 가시성을 보장합니다

1.1 JMM 모델이 무엇인가요?

가시성이 무엇인지 이해하려면 먼저 JMM을 이해해야 합니다.

JMM(Java Memory Model) 자체는 추상적인 개념이며 실제로 존재하지 않습니다. 일련의 규칙이나 사양을 설명하며, 이 사양 세트를 통해 프로그램의 다양한 변수에 대한 액세스 방법이 결정됩니다. JMM의 동기화 규정:
1) 스레드가 잠금 해제되기 전에 공유 변수의 값이 주 메모리로 다시 새로 고쳐져야 합니다.
2) 스레드가 잠기기 전에 주 메모리의 최신 값을 읽어야 합니다.
3) 잠금 해제는 동일한 잠금입니다.

JVM 실행 프로그램의 엔터티는 스레드이므로 각 스레드가 생성될 때 JMM은 작업 메모리(어떤 곳에서는 스택 공간이라고 함)를 생성합니다. 작업 메모리는 각 스레드 영역의 개인 데이터입니다.

Java 메모리 모델은 모든 변수가 메인 메모리에 저장된다고 규정합니다. 메인 메모리는 모든 스레드가 접근할 수 있는 공유 메모리 영역입니다.

그러나 스레드의 변수 작업(읽기, 할당 등)은 작업 메모리에서 수행되어야 합니다. 먼저, 주 메모리에서 작업 메모리로 변수를 복사하고 작업을 수행한 다음 다시 주 메모리에 써야 합니다.

위의 JMM 소개를 읽은 후에도 여전히 장점에 대해 혼란스러울 수 있습니다. 티켓 판매 시스템을 예로 들어 보겠습니다.

1) 아래와 같이 현재 백엔드에는 티켓이 1장만 남아 있습니다. 티켓 판매 시스템의 메인 메모리로 읽어왔습니다: ticketNum=1.
2) 현재 네트워크에는 티켓을 구매하는 사용자가 여러 명 있으므로 동시에 티켓 구매 서비스를 수행하는 스레드가 여러 개 있습니다. 현재 티켓 수를 읽은 스레드가 3개 있다고 가정합니다. 이번에는 ticketNum=1 , 그러면 티켓을 구매하겠습니다.
3) 스레드 1이 먼저 CPU 리소스를 점유하고, 티켓을 먼저 구매하고, 자체 작업 메모리에서 ticketNum 값을 0으로 변경한다고 가정합니다(ticketNum=0). 그런 다음 이를 다시 주 메모리에 씁니다.

이때 스레드 1의 사용자는 이미 티켓을 구매했기 때문에 스레드 2와 스레드 3은 현재 티켓을 계속 구매할 수 없어야 하므로 시스템은 스레드 2와 스레드 3에 ticketNum이 있음을 알려야 합니다. 이제 0과 같습니다: ticketNum =0. 이런 알림 동작이 있다면 가시성이 있는 것으로 이해하시면 됩니다.

Java 기초의 휘발성 적용 사례 분석

위의 JMM 소개와 예시를 통해 간략하게 요약해 볼 수 있습니다.

JMM 메모리 모델의 가시성은 여러 스레드가 주 메모리의 리소스에 액세스할 때 스레드가 자체 작업 메모리의 리소스를 수정하고 이를 다시 주 메모리에 쓰는 경우 JMM 메모리 모델이 다른 스레드에 이를 알려야 함을 의미합니다. 스레드는 최신 리소스의 가시성을 보장하기 위해 최신 리소스를 다시 얻습니다.

1.2. 휘발성 보장 가시성 코드 검증

1.1절에서 기본적으로 가시성의 정의를 이해했으며 이제 코드를 사용하여 정의를 검증할 수 있습니다. 휘발성을 사용하면 실제로 가시성을 보장할 수 있다는 것이 실습을 통해 입증되었습니다.

1.2.1.가시성 없는 코드 검증

먼저 휘발성을 사용하지 않으면 가시성이 없는지 확인합니다.

package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{
    int number = 0;

    public void add10() {
        this.number += 10;
    }}public class VolatileVisibilityDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动一个线程修改myData的number,将number的值加10
        new Thread(
                () -> {
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 正在执行");
                    try{
                        TimeUnit.SECONDS.sleep(3);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    myData.add10();
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 更新后,number的值为" + myData.number);
                }
        ).start();

        // 看一下主线程能否保持可见性
        while (myData.number == 0) {
            // 当上面的线程将number加10后,如果有可见性的话,那么就会跳出循环;
            // 如果没有可见性的话,就会一直在循环里执行
        }

        System.out.println("具有可见性!");
    }}

실행 결과는 아래와 같습니다. 스레드 0이 숫자 값을 10으로 변경했지만 이때 숫자가 표시되지 않고 시스템이 적극적으로 알리지 않기 때문에 메인 스레드가 여전히 루프에 있음을 알 수 있습니다. .
Java 기초의 휘발성 적용 사례 분석

1.2.1, 휘발성 보증 가시성 검증

아래와 같이 위 코드의 7번째 줄에 변수 번호에 휘발성을 추가한 후 다시 테스트합니다. 이때 JMM이 적극적으로 알림을 보내므로 메인 스레드가 성공적으로 루프를 종료했습니다. 메인 스레드의 값이 업데이트되었습니다. Number는 더 이상 0이 아닙니다.
Java 기초의 휘발성 적용 사례 분석

2. Volatile은 원자성을 보장하지 않습니다

2.1 원자성이란 무엇인가요?

위에서 언급한 가시성을 이해했다면, 원자성이 무엇인지 알아볼까요?

원자(Atomic)는 쪼개지거나 방해받지 않고 온전함을 유지하는 특성을 말합니다. 즉, 스레드가 작업을 수행하는 동안 어떤 요인으로도 중단될 수 없습니다. 동시에 성공하거나 동시에 실패합니다.

아직은 좀 추상적이지만 예를 들어보겠습니다.

아래와 같이 원자성 테스트를 위한 클래스인 TestPragma가 생성됩니다. 컴파일된 코드는 add 메소드의 n 증가가 세 가지 명령어를 통해 완료되는 것을 보여줍니다.

因此可能存在线程1正在执行第1个指令,紧接着线程2也正在执行第1个指令,这样当线程1和线程2都执行完3个指令之后,很容易理解,此时n的值只加了1,而实际是有2个线程加了2次,因此这种情况就是不保证原子性。
Java 기초의 휘발성 적용 사례 분석

2.2 不保证原子性的代码验证

在2.1中已经进行了举例,可能存在2个线程执行n++的操作,但是最终n的值却只加了1的情况,接下来对这种情况再用代码进行演示下。

首先给MyData类添加一个add方法

package com.koping.test;class MyData {
    volatile int number = 0;

    public void add() {
        number++;
    }}

然后创建测试原子性的类:TestPragmaDemo。验证number的值是否为20000,需要测试通过20个线程分别对其加1000次后的结果。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);

    }}

运行结果如下图,最终number的值仅为18410。
可以看到即使加了volatile,依然不保证有原子性。
Java 기초의 휘발성 적용 사례 분석

2.3 volatile不保证原子性的解决方法

上面介绍并证明了volatile不保证原子性,那如果希望保证原子性,怎么办呢?以下提供了2种方法

2.3.1 方法1:使用synchronized

方法1是在add方法上添加synchronized,这样每次只有1个线程能执行add方法。

结果如下图,最终确实可以使number的值为20000,保证了原子性。

但在实际业务逻辑方法中,很少只有一个类似于number++的单行代码,通常会包含其他n行代码逻辑。现在为了保证number的值是20000,就把整个方法都加锁了(其实另外那n行代码,完全可以由多线程同时执行的)。所以就优点杀鸡用牛刀,高射炮打蚊子,小题大做了。

package com.koping.test;class MyData {
    volatile int number = 0;

    public synchronized void add() {
      // 在n++上面可能还有n行代码进行逻辑处理
        number++;
    }}

Java 기초의 휘발성 적용 사례 분석

2.3.2 方法1:使用JUC包下的AtomicInteger

给MyData新曾一个原子整型类型的变量num,初始值为0。

package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData {
    volatile int number = 0;

    volatile AtomicInteger num = new AtomicInteger();

    public void add() {
        // 在n++上面可能还有n行代码进行逻辑处理
        number++;
        num.getAndIncrement();
    }}

让num也同步加20000次。可以将原句重写为:使用原子整型num可以确保原子性,如下图所示:在执行number++时不会发生竞态条件。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);
        System.out.println("num值加了20000次,此时number的实际值是:" + myData.num);

    }}

Java 기초의 휘발성 적용 사례 분석

3、volatile禁止指令重排

3.1 什么是指令重排?

在第2节中理解了什么是原子性,现在要理解下什么是指令重排?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排:
源代码–>编译器优化重排–>指令并行重排–>内存系统重排–>最终执行指令

处理器在进行重排时,必须要考虑指令之间的数据依赖性。

单线程环境中,可以确保最终执行结果和代码顺序执行的结果一致。

但是多线程环境中,线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测

看了上面的文字性表达,然后看一个很简单的例子。
比如下面的mySort方法,在系统指令重排后,可能存在以下3种语句的执行情况:
1)1234
2)2134
3)1324
以上这3种重排结果,对最后程序的结果都不会有影响,也考虑了指令之间的数据依赖性。

public void mySort() {
    int x = 1;  // 语句1
    int y = 2;  // 语句2
    x = x + 3;  // 语句3
    y = x * x;  // 语句4}

3.2 单线程单例模式

看完指令重排的简单介绍后,然后来看下单例模式的代码。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试
        System.out.println("单线程的情况测试开始");
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println("单线程的情况测试结束\n");
    }}

首先是在单线程情况下进行测试,结果如下图。可以看到,构造方法只执行了一次,是没有问题的。
Java 기초의 휘발성 적용 사례 분석

3.3 多线程单例模式

接下来在多线程情况下进行测试,代码如下。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }

        // DCL(Double Check Lock双端检索机制)//        if (instance == null) {//            synchronized (SingletonDemo.class) {//                if (instance == null) {//                    instance = new SingletonDemo();//                }//            }//        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多线程情况下的运行结果如下图。可以看到,多线程情况下,出现了构造方法执行了2次的情况。
Java 기초의 휘발성 적용 사례 분석

3.4 多线程单例模式改进:DCL

在3.3中的多线程单里模式下,构造方法执行了两次,因此需要进行改进,这里使用双端检锁机制:Double Check Lock, DCL。即加锁之前和之后都进行检查。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {//        if (instance == null) {//            instance = new SingletonDemo();//        }

        // DCL(Double Check Lock双端检锁机制)
        if (instance == null) {  // a行
            synchronized (SingletonDemo.class) {
                if (instance == null) {  // b行
                    instance = new SingletonDemo();  // c行
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多次运行后,可以看到,在多线程情况下,此时构造方法也只执行1次了。
Java 기초의 휘발성 적용 사례 분석

3.5 멀티스레드 싱글톤 모드 개선, DCL 버전의 문제

3.4의 DCL 버전의 싱글톤 모드는 여전히 100% 정확하지 않다는 점에 유의해야 합니다! ! !

싱글톤 모드의 3.4DCL 버전이 100% 정확하지 않은 이유를 이해하지 못하시나요?
3.1의 명령 재배열에 대한 간단한 이해를 마친 후 왜 갑자기 멀티 스레드 싱글톤 모드에 대해 이야기해야 하는지 이해가 안 되시나요?

싱글톤 모드의 3.4DCL 버전은 명령 재배치로 인해 문제가 발생할 수 있기 때문에 이 문제가 발생할 가능성은 천만분의 1일 수 있지만 코드는 여전히 100% 정확하지 않습니다. 100% 정확성을 보장하려면 휘발성 키워드를 추가해야 합니다. 휘발성을 추가하면 명령 재배치가 금지될 수 있습니다.

다음으로 3.4DCL 버전의 싱글톤 모드가 100% 정확하지 않은 이유를 분석해 보겠습니다.

View 인스턴스 = new SingletonDemo(); 컴파일된 명령어는 다음 세 단계로 나눌 수 있습니다.
1) 객체 메모리 공간 할당: memory = 할당()
2) 객체 초기화: 인스턴스(메모리); ) 할당된 메모리 주소를 가리키도록 인스턴스를 설정합니다. 인스턴스 = 메모리;

2단계와 3단계 사이에 데이터 종속성이 없으므로 132단계가 실행될 수 있습니다.

예를 들어 스레드 1은 13단계를 실행했지만 2단계는 실행하지 않았습니다. 이때, 인스턴스!=null이지만 개체가 아직 초기화되지 않았습니다.
이때 스레드 2가 CPU를 점유하면 해당 인스턴스를 찾습니다. !=null이고 직접 사용하기 위해 돌아오면 인스턴스가 비어 있고 예외가 발생한다는 것을 알 수 있습니다.

명령어 재배열로 인해 발생할 수 있는 문제이므로 프로그램이 100% 올바른지 확인하려면 명령어 재배열을 금지하는 휘발성을 추가해야 합니다.

3.6 명령 재배열을 금지하는 휘발성 보증의 원칙

3.1에서 실행 재배치의 의미를 간략하게 소개한 후, 3.2~3.5를 통해 싱글톤 모드를 사용하여 멀티 스레드 상황에서 휘발성을 사용해야 하는 이유를 설명했습니다. . 프로그램 예외를 발생시키는 명령어 재배열이 있을 수 있기 때문입니다.

다음으로 명령어 재정렬이 금지되도록 휘발성의 원칙을 도입하겠습니다.

먼저 메모리 장벽이라고도 알려진 메모리 장벽이라는 개념을 이해해야 합니다.

1) 특정 작업의 실행 순서를 보장합니다.
2) 특정 변수의 메모리 가시성을 보장합니다.

컴파일러와 프로세서 모두 명령어 재배치를 수행할 수 있습니다. 명령어 사이에 메모리 배리어가 삽입되면 이 메모리 배리어 명령어를 사용하여 명령어를 재정렬할 수 없음을 컴파일러와 CPU에 알립니다. 즉, 메모리 배리어를 삽입하면 메모리 배리어 전후의 명령어가 다시 정렬되는 것이 금지됩니다. -스케줄링 요구 최적화

를 실행했습니다. 메모리 배리어의 또 다른 기능은 다양한 CPU의 캐시 데이터를 강제로 플러시하여 CPU의 모든 스레드가 이러한 데이터의 최신 버전을 읽을 수 있도록 하는 것입니다

.

위 내용은 Java 기초의 휘발성 적용 사례 분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 yisu.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제