Java Volatile Atomic关键字

简介: Java Volatile Atomic关键字

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

  也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

1

i = i + 1;

  当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

  这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

  比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

  可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。

  最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

  也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

  为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

  所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。



对于非volatile变量,JVM并不保证会从主存中读取数据到CPU缓存,或者将CPU缓存中的数据写到主存中。这会引起一些问题,在后面的章节中,我会来解释这些问题。

试想一下,如果有两个以上的线程访问一个共享对象,这个共享对象包含一个counter变量,下面是代码示例:

1 publicclassSharedObject {
2
3     publicintcounter = 0;
4
5 }

如果只有线程1修改了(自增)counter变量,而线程1和线程2两个线程都会在某些时刻读取counter变量。

如果counter变量没有声明成volatile,则counter的值不保证会从CPU缓存写回到主存中。也就是说,CPU缓存和主存中的counter变量值并不一致,如下图所示:


这就是“可见性”问题,线程看不到变量最新的值,因为其他线程还没有将变量值从CPU缓存写回到主存。一个线程中的修改对另外的线程是不可见的。

volatile可见性保证

Java的volatile关键字就是设计用来解决变量可见性问题。将counter变量声明为volatile,则在写入counter变量时,也会同时将变量值写入到主存中。同样的,在读取counter变量值时,也会直接从主存中读取。

下面的代码演示了如果将counter声明为volatile:

1 publicclassSharedObject {
2
3     publicvolatileintcounter = 0;
4
5 }

将一个变量声明为volatile,可以保证变量写入时对其他线程的可见。

在上面的场景中,一个线程(T1)修改了counter,另一个线程(T2)读取了counter(但没有修改它),将counter变量声明为volatile,就能保证写入counter变量后,对T2是可见的。

然而,如果T1和T2都修改了counter的值,只是将counter声明为volatile还远远不够,后面会有更多的说明。

完整的volatile可见性保证

实际上,volatile的可见性保证并不是只对于volatile变量本身那么简单。可见性保证遵循以下规则:

  • 如果线程A写入一个volatile变量,线程B随后读取了同样的volatile变量,则线程A在写入volatile变量之前的所有可见的变量值,在线程B读取volatile变量后也同样是可见的。
  • 如果线程A读取一个volatile变量,那么线程A中所有可见的变量也会同样从主存重新读取。

下面用一段代码来示例说明:

    public class MyClass {    
        private int years;    
        private int months    
        private volatile int days;    
        public void update(int years, int months, int days){    
            this.years  = years;    
            this.months = months;    
            this.days   = days;    
        }    
    }    


    update()方法写入3个变量,其中只有days变量是volatile。

    完整的volatile可见性保证意味着,在写入days变量时,线程中所有可见变量也会写入到主存。也就是说,写入days变量时,years和months也会同时被写入到主存。

    下面的代码读取了years、months、days变量:

    public class MyClass {
        private int years;
        private int months
        private volatile int days;
        public int totalDays() {
            int total = this.days;
            total += months * 30;
            total += years * 365;
            return total;
        }
        public void update(int years, int months, int days){
            this.years  = years;
            this.months = months;
            this.days   = days;
        }
    }


    请注意totalDays()方法开始读取days变量值到total变量。在读取days变量值时,months和years的值也会同时从主存读取。因此,按上面所示的顺序读取时,可以保证读取到days、months、years变量的最新值。

    译者注:可以将对volatile变量的读写理解为一个触发刷新的操作,写入volatile变量时,线程中的所有变量也都会触发写入主存。而读取volatile变量时,也同样会触发线程中所有变量从主存中重新读取。因此,应当尽量将volatile的写入操作放在最后,而将volatile的读取放在最前,这样就能连带将其他变量也进行刷新。上面的例子中,update()方法对days的赋值就是放在years、months之后,就是保证years、months也能将最新的值写入到主存,如果是放在两个变量之前,则days会写入主存,而years、months则不会。反过来,totalDays()方法则将days的读取放在最前面,就是为了能同时触发刷新years、months变量值,如果是放后面,则years、months就可能还是从CPU缓存中读取值,而不是从主存中获取最新值。

    指令重排问题

    出于性能考虑,JVM和CPU是允许对程序中的指令进行重排的,只要保证(重排后的)指令语义一致即可。如下代码为例:

    1 inta = 1;
    2 intb = 2;
    3
    4 a++;
    5 b++;

    这些指令可以按以下顺序重排,而不改变程序的语义:

    1 inta = 1;
    2 a++;
    3
    4 intb = 2;
    5 b++;

    然而,指令重排面临的一个问题就是对volatile变量的处理。还是以前面提到的MyClass类来说明:

    01 publicclassMyClass {


    03     privateintmonths
    04     privatevolatileintdays;
    05
    06
    07     publicvoidupdate(intyears, intmonths, intdays){
    08         this.years  = years;
    09         this.months = months;
    10         this.days   = days;
    11     }
    12 }

    一旦update()变量写了days值,则years、months的最新值也会写入到主存。但是,如果JVM重排了指令,比如按以下方式重排:

    1 publicvoidupdate(intyears, intmonths, intdays){
    2     this.days   = days;
    3     this.months = months;
    4     this.years  = years;
    5 }

    在days被修改时,months、years的值也会写入到主存,但这时进行写入,months、years并不是新的值(译者注:即在months、years被赋新值之前,就触发了这两个变量值写入主存的操作,自然这两个变量在主存中的值就不是新值)。新的值自然对其他线程是不可见的。指令重排导致了程序语义的改变。

    Java对此有一个解决方法,我们会在下一章节中说明。

    Java volatile Happens-Before保证

    为了解决指令重排的问题,Java的volatile关键字在可见性之外,又提供了happends-before保证。happens-before原则如下:

    • 如果有读写操作发生在写入volatile变量之前,读写其他变量的指令不能重排到写入volatile变量之后。写入一个volatile变量之前的读写操作,对volatile变量是有happens-before保证的。注意,如果是写入volatile之后,有读写其他变量的操作,那么这些操作指令是有可能被重排到写入volatile操作指令之前的。但反之则不成立。即可以把位于写入volatile操作指令之后的其他指令移到写入volatile操作指令之前,而不能把位于写入volatile操作指令之前的其他指令移到写入volatile操作指令之后。
    • 如果有读写操作发生在读取volatile变量之后,读写其他变量的指令不能重排到读取volatile变量之前。注意,如果是读取volatile之前,有读取其他变量的操作,那么这些操作指令是有可能被重排到读取volatile操作指令之后的。但反之则不成立。即可以把位于读取volatile操作指令之前的指令移到读取volatile操作指令之后,而不能把位于读取volatile操作指令之后的指令移到读取volatile操作指令之前。

    以上的happens-before原则为volatile关键字的可见性提供了强制保证。

    译者注:这两个原则读起来有些拗口(当然翻译也不足够好),其实就是不管JVM怎么去禁止/允许某些情况下的指令重排,最终就是保证“完整的volatile可见性保证”的那种效果,所以,只要理解了“完整的volatile可见性保证”的效果就足够了。

    volatile并不总是可行的

    虽然volatile关键字能保证volatile变量的所有读取都是直接从主存读取,所有写入都是直接写入到主存中,但在一些情形下,仅仅是将变量声明为volatile还是远远不够的。

    就像前面示例所说的,线程1写入共享变量counter的值,将counter声明为volatile已经足够保证线程2总是能获取到最新的值。

    事实上,多个线程都能写入共享的volatile变量,主存中也能存储正确的变量值,然而这有一个前提,变量新值的写入不能依赖于变量的旧值。换句话说,就是一个线程写入一个共享volatile变量值时,不需要先读取变量值,然后以此来计算出新的值。

    如果线程需要先读取一个volatile变量的值,以此来计算出一个新的值,那么volatile变量就不足够保证正确的可见性。(线程间)读写volatile变量的时间间隔很短,这将导致一个竞态条件,多个线程同时读取了volatile变量相同的值,然后以此计算出了新的值,这时各个线程往主存中写回值,则会互相覆盖。

    多个线程对counter变量进行自增操作就是这样的情形,下面的章节会详细说明这种情形。

    设想一下,如果线程1将共享变量counter的值0读取到它的CPU缓存,然后自增为1,而还没有将新值写回到主存。线程2这时从主存中读取的counter值依然是0,依然放到它自身的CPU缓存中,然后同样将counter值自增为1,同样也还没有将新值写回到主存。如下图所示:


    从实际的情况来看,线程1和线程2现在就是不同步的。共享变量counter正确的值应该是2,但各个线程中CPU缓存的值都是1,而主存中的值依然是0。这是很混乱的。即使线程最终将共享变量counter的值写回到主存,那值也明显是错的。

    何时使用volatile

    正如我前面所说,如果两个线程同时读写一个共享变量,仅仅使用volatile关键字是不够的。你应该使用 synchronized 来保证读写变量是原子的。(一个线程)读写volatile变量时,不会阻塞(其他)线程进行读写。你必须在关键的地方使用synchronized关键字来解决这个问题。

    除了synchronized方法,你还可以使用java.util.concurrent包提供的许多原子数据类型来解决这个问题。比如,AtomicLong或AtomicReference,或是其他的类。

    如果只有一个线程对volatile进行读写,而其他线程只是读取变量,这时,对于只是读取变量的线程来说,volatile就已经可以保证读取到的是变量的最新值。如果没有把变量声明为volatile,这就无法保证。

    volatile关键字对32位和64位的变量都有效。

    volatile的性能考量

    读写volatile变量会导致变量从主存读写。从主存读写比从CPU缓存读写更加“昂贵”。访问一个volatile变量同样会禁止指令重排,而指令重排是一种提升性能的技术。因此,你应当只在需要保证变量可见性的情况下,才使用volatile变量。

    当需要保证原子性时,又该怎么用呢

    对于Java中的运算操作,例如自增或自减,若没有进行额外的同步操作,在多线程环境下就是线程不安全的。num++解析为num=num+1,明显,这个操作不具备原子性,多线程并发共享这个变量时必然会出现问题。测试代码如下:

    public class AtomicIntegerTest {
        private static final int THREADS_CONUT = 20;
        public static int count = 0;
        public static void increase() {
            count++;
        }
        public static void main(String[] args) {
            Thread[] threads = new Thread[THREADS_CONUT];
            for (int i = 0; i < THREADS_CONUT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 1000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
            while (Thread.activeCount() > 1) {
                Thread.yield();
            }
            System.out.println(count);
        }
    }

    这里运行了20个线程,每个线程对count变量进行1000此自增操作,如果上面这段代码能够正常并发的话,最后的结果应该是20000才对,但实际结果却发现每次运行的结果都不相同,都是一个小于20000的数字。这是为什么呢?


    要是换成volatile修饰count变量呢?

    顺带说下volatile关键字很重要的两个特性:


    1、保证变量在线程间可见,对volatile变量所有的写操作都能立即反应到其他线程中,换句话说,volatile变量在各个线程中是一致的(得益于java内存模型—"先行发生原则");


    2、禁止指令的重排序优化;


    那么换成volatile修饰count变量后,会有什么效果呢?试一试:

    public class AtomicIntegerTest {
        private static final int THREADS_CONUT = 20;
        public static volatile int count = 0;
        public static void increase() {
            count++;
        }
        public static void main(String[] args) {
            Thread[] threads = new Thread[THREADS_CONUT];
            for (int i = 0; i < THREADS_CONUT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 1000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
            while (Thread.activeCount() > 1) {
                Thread.yield();
            }
            System.out.println(count);
        }
    }

    结果似乎又失望了,测试结果和上面的一致,每次都是输出小于20000的数字。这又是为什么么?上面的论据是正确的,也就是上面标红的内容,但是这个论据并不能得出"基于volatile变量的运算在并发下是安全的"这个结论,因为核心点在于java里的运算(比如自增)并不是原子性的。


    用了AtomicInteger类后会变成什么样子呢?

    把上面的代码改造成AtomicInteger原子类型,先看看效果


    import java.util.concurrent.atomic.AtomicInteger;
    public class AtomicIntegerTest {
        private static final int THREADS_CONUT = 20;
        public static AtomicInteger count = new AtomicInteger(0);
        public static void increase() {
            count.incrementAndGet();
        }
        public static void main(String[] args) {
            Thread[] threads = new Thread[THREADS_CONUT];
            for (int i = 0; i < THREADS_CONUT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 1000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
            while (Thread.activeCount() > 1) {
                Thread.yield();
            }
            System.out.println(count);
        }
    }


    结果每次都输出20000,程序输出了正确的结果,这都归功于AtomicInteger.incrementAndGet()方法的原子性。


    非阻塞同步

    同步:多线程并发访问共享数据时,保证共享数据在同一时刻只被一个或一些线程使用。


    我们知道,阻塞同步和非阻塞同步都是实现线程安全的两个保障手段,非阻塞同步对于阻塞同步而言主要解决了阻塞同步中线程阻塞和唤醒带来的性能问题,那什么叫做非阻塞同步呢?在并发环境下,某个线程对共享变量先进行操作,如果没有其他线程争用共享数据那操作就成功;如果存在数据的争用冲突,那就才去补偿措施,比如不断的重试机制,直到成功为止,因为这种乐观的并发策略不需要把线程挂起,也就把这种同步操作称为非阻塞同步(操作和冲突检测具备原子性)。在硬件指令集的发展驱动下,使得 "操作和冲突检测" 这种看起来需要多次操作的行为只需要一条处理器指令便可以完成,这些指令中就包括非常著名的CAS指令(Compare-And-Swap比较并交换)。

    所以再返回来看AtomicInteger.incrementAndGet()方法,它的时间也比较简单


      /**
         * Atomically increments by one the current value.
         *
         * @return the updated value
         */
        public final int incrementAndGet() {
            for (;;) {
                int current = get();
                int next = current + 1;
                if (compareAndSet(current, next))
                    return next;
            }
        }

    incrementAndGet()方法在一个无限循环体内,不断尝试将一个比当前值大1的新值赋给自己,如果失败则说明在执行"获取-设置"操作的时已经被其它线程修改过了,于是便再次进入循环下一次操作,直到成功为止。这个便是AtomicInteger原子性的"诀窍"了,继续进源码看它的compareAndSet方法:

     /**
         * Atomically sets the value to the given updated value
         * if the current value {@code ==} the expected value.
         *
         * @param expect the expected value
         * @param update the new value
         * @return true if successful. False return indicates that
         * the actual value was not equal to the expected value.
         */
        public final boolean compareAndSet(int expect, int update) {
            return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
        }

    可以看到,compareAndSet()调用的就是Unsafe.compareAndSwapInt()方法,即Unsafe类的CAS操作。


    原子类相比于普通的锁,粒度更细、效率更高(除了高度竞争的情况下)

    如果对于上面的示例代码中使用了thread.yield()之类的方法不清晰的,可以直接看下面的代码压测:

    public class AtomicIntegerTest implements Runnable {
        static AtomicInteger atomicInteger = new AtomicInteger(0);
        static int commonInteger = 0;
        public void addAtomicInteger() {
            atomicInteger.getAndIncrement();
        }
        public void addCommonInteger() {
            commonInteger++;
        }
        @Override
        public void run() {
            //可以调大10000看效果更明显
            for (int i = 0; i < 10000; i++) {
                addAtomicInteger();
                addCommonInteger();
            }
        }
        public static void main(String[] args) throws InterruptedException {
            AtomicIntegerTest atomicIntegerTest = new AtomicIntegerTest();
            Thread thread1 = new Thread(atomicIntegerTest);
            Thread thread2 = new Thread(atomicIntegerTest);
            thread1.start();
            thread2.start();
            //join()方法是为了让main主线程等待thread1、thread2两个子线程执行完毕
            thread1.join();
            thread2.join();
            System.out.println("AtomicInteger add result = " + atomicInteger.get());
            System.out.println("CommonInteger add result = " + commonInteger);
        }
    }

    原子类一览图参考如下:

    在高度并发竞争情形下,AtomicLong每次进行add都需要flush和refresh(这一块涉及到java内存模型中的工作内存和主内存的,所有变量操作只能在工作内存中进行,然后写回主内存,其它线程再次读取新值),每次add()都需要同步,在高并发时会有比较多冲突,比较耗时导致效率低;而LongAdder中每个线程会维护自己的一个计数器,在最后执行LongAdder.sum()方法时候才需要同步,把所有计数器全部加起来,不需要flush和refresh操作。

    目录
    相关文章
    |
    22天前
    |
    设计模式 安全 Java
    Java并发编程实战:使用synchronized关键字实现线程安全
    【4月更文挑战第6天】Java中的`synchronized`关键字用于处理多线程并发,确保共享资源的线程安全。它可以修饰方法或代码块,实现互斥访问。当用于方法时,锁定对象实例或类对象;用于代码块时,锁定指定对象。过度使用可能导致性能问题,应注意避免锁持有时间过长、死锁,并考虑使用`java.util.concurrent`包中的高级工具。正确理解和使用`synchronized`是编写线程安全程序的关键。
    |
    1天前
    |
    存储 安全 Java
    聊聊Java关键字synchronized(下)
    聊聊Java关键字synchronized(下)
    5 0
    |
    1天前
    |
    监控 安全 Java
    聊聊Java关键字synchronized(上)
    聊聊Java关键字synchronized
    5 0
    |
    4天前
    |
    安全 Java 编译器
    是时候来唠一唠synchronized关键字了,Java多线程的必问考点!
    本文简要介绍了Java中的`synchronized`关键字,它是用于保证多线程环境下的同步,解决原子性、可见性和顺序性问题。从JDK1.6开始,synchronized进行了优化,性能得到提升,现在仍可在项目中使用。synchronized有三种用法:修饰实例方法、静态方法和代码块。文章还讨论了synchronized修饰代码块的锁对象、静态与非静态方法调用的互斥性,以及构造方法不能被同步修饰。此外,通过反汇编展示了`synchronized`在方法和代码块上的底层实现,涉及ObjectMonitor和monitorenter/monitorexit指令。
    17 0
    |
    4天前
    |
    Java
    两千字讲明白java中instanceof关键字的使用!
    两千字讲明白java中instanceof关键字的使用!
    12 0
    |
    4天前
    |
    Java 开发者
    Java基础知识整理,注释、关键字、运算符
    在日常的工作中,总会遇到很多大段的代码,逻辑复杂,看得人云山雾绕,这时候若能言简意赅的加上注释,会让阅读者豁然开朗,这就是注释的魅力!
    37 11
    |
    9天前
    |
    安全 Java 开发者
    Java并发编程:深入理解Synchronized关键字
    【4月更文挑战第19天】 在Java多线程编程中,为了确保数据的一致性和线程安全,我们经常需要使用到同步机制。其中,`synchronized`关键字是最为常见的一种方式,它能够保证在同一时刻只有一个线程可以访问某个对象的特定代码段。本文将深入探讨`synchronized`关键字的原理、用法以及性能影响,并通过具体示例来展示如何在Java程序中有效地应用这一技术。
    |
    11天前
    |
    存储 缓存 安全
    Java并发基础之互斥同步、非阻塞同步、指令重排与volatile
    在Java中,多线程编程常常涉及到共享数据的访问,这时候就需要考虑线程安全问题。Java提供了多种机制来实现线程安全,其中包括互斥同步(Mutex Synchronization)、非阻塞同步(Non-blocking Synchronization)、以及volatile关键字等。 互斥同步(Mutex Synchronization) 互斥同步是一种基本的同步手段,它要求在任何时刻,只有一个线程可以执行某个方法或某个代码块,其他线程必须等待。Java中的synchronized关键字就是实现互斥同步的常用手段。当一个线程进入一个synchronized方法或代码块时,它需要先获得锁,如果
    24 0
    |
    13天前
    |
    Java
    Java关键字(1)
    Java关键字(1)