多线程特性解读

原子性

java的原子性就和数据库事物的原子性差不多,线程的一个或者多个操作要么全部执行,而且执行过程不会被打断,要么全部都不执行.

JMM只是保证了基本的原子性,但类似i++ 之类的操作,看似是原子操作,其实里面涉及到

  • 获取i的值
  • 自增
  • 再赋值给i

这三步操作,所以想要实现i++ 这样的原子操作就需要用到synchronize 或者lock进行加锁处理

example :

i = 0;       //1
j = i ;      //2
i++;         //3
i = j + 1;   //4

什么四个操作,哪些是原子性操作,哪些不是。

1在Java中,对基本数据类型的变量和赋值操作都是原子性操作; 
2中包含了两个操作:读取i,将i值赋值给j 
3中包含了三个操作:读取i值、i + 1 、将+1结果赋值给i; 
4中同三一样

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的

要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。volatile是无法保证复合操作的原子性。

所谓原子性操作是指当执行一系列操作时候,这些操作那么全部被执行,那么全部不被执行,不存在只执行其中一部分的情况。

在设计计数器时候一般都是先读取当前值,然后+1,然后更新,这个过程是读 -> 改 -> 写的过程,如果不能保证这个过程是原子性,那么就会出现线程安全问题。如下代码是线程不安全的,因为不能保证 ++value 是原子性操作。

    public class ThreadNotSafeCount {

        private  Long value;

        public Long getCount() {
            return value;
        }

        public void inc() {
            ++value;
        }
    }

通过使用 Javap -c 查看汇编代码如下:

 public void inc();
    Code:
       0: aload_0       
       1: dup           
       2: getfield      #2                  // Field value:J
       5: lconst_1      
       6: ladd          
       7: putfield      #2                  // Field value:J
      10: return        

可知简单的 ++value有 2,5,6,7 组成,其中2是获取当前 value 的值并放入栈顶,5是把常量1放入栈顶,6是把当前栈顶中2个值相加并把结果放入栈顶,7则是把栈顶结果赋值会 value 变量,可知 Java 中简单的一句 ++value 转换为汇编后就不具有原子性了。

那么如何才能保证多个操作完成原子性呢,最简单的是使用 Synchronized 进行同步,修改代码如下:

    public class ThreadSafeCount {

        private  Long value;

        public synchronized Long getCount() {
            return value;
        }

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

使用 Synchronized 的确可以实现线程安全,即实现内存可见性和同步,但是 Synchronized 是独占锁,同时只有一个线程可以调用 getCount 方法,其他没有获取内部锁的线程会被阻塞掉;而这里 getCount 方法只是读操作,多个线程同时调用不会存在线程安全问题,但是加了关键字 Synchronized 后同时就只能有一个线程可以调用了,这显然大大降低了并发性。

也许你会问既然是只读操作那么为何不去掉 getCount 方法上的 Synchronized 关键字呢?其实是不能去掉的,别忘了这里要靠 Synchronized 的内存语义来实现 value 的内存可见性。

那么有没有更好的实现呢?答案是肯定的,下面会讲到的内部使用非阻塞 CAS 算法实现的原子性操作类 AtomicLong 就是不错选择。

如果是基础类的自增操作可以使用AtomicInteger 这样的原子类来实现(其本质是利用了CPU级别的CAS指令来完成的)

其中用的最多的方法就是 incrementAndGet() 以原子的方式自增

  public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

通过源码分析可知,incrementAndGet()和 getAndIncrement ()都调用了 Unsafe 类中的 getAndAddInt() 方法,区别是:

① 前者,先+1,再返回

② 后者,先返回,再 +1

可见性

现代计算机中,由于 CPU 直接从主内存中读取数据的效率不高,所以都会对应的 CPU 高速缓存,先将主内存中的数据读取到缓存中,线程修改数据之后首先更新到缓存,之后才会更新到主内存。如果此时还没有将数据更新到主内存其他的线程此时来读取就是修改之前的数据。

volatile 关键字就是用于保证内存可见性,当线程A更新了 volatile 修饰的变量时,它会立即刷新到主线程,并且将其余缓存中该变量的值清空,导致其余线程只能去主内存读取最新值。

使用 volatile 关键词修饰的变量每次读取都会得到最新的数据,不管哪个线程对这个变量的修改都会立即刷新到主内存。

synchronized和加锁也能能保证可见性,实现原理就是在释放锁之前其余线程是访问不到这个共享变量的。但是和 volatile 相比开销较大。

volatile保证可见性

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太重,因为它会引起线程上下文的切换开销,对于解决内存可见性问题,Java 还提供了一种弱形式的同步,也就是使用了 volatile 关键字。

一旦一个变量被 volatile 修饰了,当线程获取这个变量值的时候会首先清空线程工作内存中该变量的值,然后从主内存获取该变量的值;当线程写入被 volatile 修饰的变量的值的时候,首先会把修改后的值写入工作内存,然后会刷新到主内存。这就保证了对一个变量的更新对其它线程马上可见。

下面看一个使用 volatile 关键字解决内存不可见性的一个例子,如下代码的共享变量 value 是线程不安全的,因为它没有进行适当同步措施。

    public class ThreadNotSafeInteger {

        private int value;

        public int get() {
            return value;
        }

        public void set(int value) {
            this.value = value;
        }
    }

首先看下使用 synchronized 关键字进行同步方式如下:

    public class ThreadSafeInteger {

        private int value;

        public synchronized int get() {
            return value;
        }

        public synchronized  void set(int value) {
            this.value = value;
        }
    }

然后看下使用 volatile 进行同步如下:

    public class ThreadSafeInteger {

        private volatile int value;

        public int get() {
            return value;
        }

        public void set(int value) {
            this.value = value;
        }
    }

这里使用 synchronized 和使用 volatile 是等价的,都解决了共享变量 value 的内存不可见性问题;但是前者是独占锁,同时只能有一个线程调用 get() 方法,其它调用线程会被阻塞;并且会存在线程上下文切换和线程重新调度的开销;而后者是非阻塞算法,不会造成线程上下文切换的开销。

这里使用 synchronized 和使用 volatile 是等价的,但是并不是所有情况下都是等价的,这是因为 volatile 虽然提供了可见性保证,但是并没有保证操作的原子性。

那么一般什么时候才使用 volatile 关键字修饰变量呢?

  • 当写入变量值时候不依赖变量的当前值。因为如果依赖当前值则是获取 -> 计算 -> 写入操作,而这三步操作不是原子性的,而 volatile 不保证原子性。
  • 读写变量值时候没有进行加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile。

另外变量被声明为 volatile 还可以避免重排序的发生,这个后面会讲到。

有序性

以下这段代码:

int a = 100 ; //1
int b = 200 ; //2
int c = a + b ; //3

正常情况下的执行顺序应该是 1>>2>>3。但是有时 JVM 为了提高整体的效率会进行指令重排导致执行的顺序可能是 2>>1>>3。但是 JVM 也不能是什么都进行重排,是在保证最终结果和代码顺序执行结果一致的情况下才可能进行重排。

重排在单线程中不会出现问题,但在多线程中会出现数据不一致的问题。

Java 中可以使用 volatile 来保证顺序性,synchronized 和 lock 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的。

除了通过 volatile 关键字显式的保证顺序之外, JVM 还通过 happen-before 原则来隐式的保证顺序性。

其中有一条就是适用于 volatile 关键字的,针对于 volatile 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。

扩展: 深入理解Volatile关键字

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太重,因为它会引起线程上下文的切换开销,对于解决内存可见性问题,Java 还提供了一种弱形式的同步,也就是使用了 volatile 关键字。

一旦一个变量被 volatile 修饰了,当线程获取这个变量值的时候会首先清空线程工作内存中该变量的值,然后从主内存获取该变量的值;当线程写入被 volatile 修饰的变量的值的时候,首先会把修改后的值写入工作内存,然后会刷新到主内存。这就保证了对一个变量的更新对其它线程马上可见。

下面看一个使用 volatile 关键字解决内存不可见性的一个例子,如下代码的共享变量 value 是线程不安全的,因为它没有进行适当同步措施。

    public class ThreadNotSafeInteger {

        private int value;

        public int get() {
            return value;
        }

        public void set(int value) {
            this.value = value;
        }
    }

首先看下使用 synchronized 关键字进行同步方式如下:

    public class ThreadSafeInteger {

        private int value;

        public synchronized int get() {
            return value;
        }

        public synchronized  void set(int value) {
            this.value = value;
        }
    }

然后看下使用 volatile 进行同步如下:

    public class ThreadSafeInteger {

        private volatile int value;

        public int get() {
            return value;
        }

        public void set(int value) {
            this.value = value;
        }
    }

这里使用 synchronized 和使用 volatile 是等价的,都解决了共享变量 value 的内存不可见性问题;但是前者是独占锁,同时只能有一个线程调用 get() 方法,其它调用线程会被阻塞;并且会存在线程上下文切换和线程重新调度的开销;而后者是非阻塞算法,不会造成线程上下文切换的开销。

这里使用 synchronized 和使用 volatile 是等价的,但是并不是所有情况下都是等价的,这是因为 volatile 虽然提供了可见性保证,但是并没有保证操作的原子性。

那么一般什么时候才使用 volatile 关键字修饰变量呢?

  • 当写入变量值时候不依赖变量的当前值。因为如果依赖当前值则是获取 -> 计算 -> 写入操作,而这三步操作不是原子性的,而 volatile 不保证原子性。
  • 读写变量值时候没有进行加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile。

另外变量被声明为 volatile 还可以避免重排序的发生,这个后面会讲到。

volatile通常被比喻成轻量级的synchronized ,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

volatile 的应用

双重检查锁的单例模式

可以用 volatile 实现一个双重检查锁的单例模式:

    public class Singleton {
        private static volatile Singleton singleton;

        private Singleton() {
        }

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

    }

这里的 volatile 关键字主要是为了防止指令重排。 如果不用 volatilesingleton = new Singleton();,这段代码其实是分为三步:

  • 分配内存空间。(1)
  • 初始化对象。(2)
  • singleton 对象指向分配的内存地址。(3)

加上 volatile 是为了让以上的三步操作顺序执行,反之有可能第三步在第二步之前被执行就有可能导致某个线程拿到的单例对象还没有初始化,以致于使用报错。

控制停止线程的标记
    private volatile boolean flag ;
    private void run(){
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (flag) {
                    doSomeThing();
                }
            }
        });
    }

    private void stop(){
        flag = false ;
    }

这里如果没有用 volatile 来修饰 flag ,就有可能其中一个线程调用了 stop()方法修改了 flag 的值并不会立即刷新到主内存中,导致这个循环并不会立即停止。

这里主要利用的是 volatile 的内存可见性。

总结一下:

  • volatile 关键字只能保证可见性,顺序性(禁止指令重排序),不能保证原子性

在以下两个场景中可以使用volatile来代替synchronized

1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。

2、变量不需要与其他状态变量共同参与不变约束。

除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包

我们来看一下volatile和原子性的例子:

public class Test {
    public volatile int inc = 0;

    public 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);
    }
}

以上代码比较简单,就是创建10个线程,然后分别执行1000次i++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。这其实就是volatile无法满足原子性的原因。

为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc在多个线程之间的可见性。但是无法inc++的原子性。

volatile 原理解析

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。

但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

总结与思考

我们介绍过了volatile关键字和synchronized关键字。现在我们知道,synchronized可以保证原子性、有序性和可见性。而volatile却只能保证有序性和可见性。

思考题: 双重校验锁实现的单例,已经使用了synchronized,为什么还需要volatile


  转载请注明: Hi 高虎 多线程特性解读

  目录