volatile 与 线程安全

服务端编程的3大性能杀手:1、大量线程导致的线程切换开销。2、锁。3、非必要的内存拷贝。所以说锁在多线程编程中的地位是很重要的。我们找工作的时候,经常会谈到多线程,貌似多线程挺难的,很值得技术人员拿出来说说。那么我们讲来讲去,多线程到底难在什么地方?会不会是因为编程语言在多线程编程方面的基础设施没有理清楚,导致新手不能正确和安全的编写多线程程序?我们知道多线程编程中有三个核心概念,分别是可见性、原子性、顺序性。所以我们讨论多线程问题,特别是线程安全的问题的时候,来围绕这三个概念来展开,下面我们看下这三个概念。

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略,这个问题是很多开发人员忽略或者理解错误的一点。下面举个一种典型的使用场景,就是变量用于停止线程的例子来说明一下。在这种实现方式下,即使其它线程通过调用stop()方法将isRunning设置为false, 因为cache缓存的原因, 循环也不一定会立即结束。

复制代码

//成员变量
boolean shouldShutdown=false;


//在线程1
public void run () {
    while(!shouldShutdown) {
      someOperation();
    }
}

//在线程2
public void stop () {
  shouldShutdown = true;
}

复制代码

那么如何解决这个问题?第一、Java提供了volatile关键字来保证可见性。volatile变量具有 synchronized 的可见性特性,但是不具备原子特性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。所以成员变量 volatile boolean isRunning= false;可以解决这个问题。第二、使用java提供的锁。锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。在这里我们只用到可见性,而锁可以确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。
问:既然锁可保证原子性也可保证可见性,为何还需要volatile?
答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。


原子性

是指一个操作多含多条指令,要么全部执行,要么全部都不执行。这一点,跟数据库事务的原子性概念差不多。举个例子就是银行转账就需要操作的原子性。因为原子操作在一步之内就完成而且不能被中断,所以原子操作在多线程环境中是线程安全的,无需考虑同步的问题。在java中,下列操作是原子操作:

  • all assignments of primitive types except for long and double
  • all assignments of references
  • all operations of java.concurrent.Atomic* classes
  • all assignments to volatile longs and doubles

问题来了,为什么long型赋值不是原子操作呢?例如:long foo = 65465498L;事实上java会分两步写入这个long变量,先写32位,再写后32位,这样就线程不安全了。

另一个就是基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作,虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,所以不是线程安全的。

那么Java如何保证原子性的哪?
第一、对于基础类型变量自增(i++,i--)操作,Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger等方法请自行学习使用。
第二、对于其它业务逻辑,如银行转账,使用java里面内置锁(synchronize)和Lock(ReentrantLock)等。对于锁的详细用法,以后再讨论。
第三、对于long和double的赋值,使用关键字volatile,如:private volatile long foo; 这里引出一个问题, 为什么volatile能替代简单的锁, 却不能保证原子性?
注:CAS,Compare & Set,或是 Compare & Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。

顺序性

顺序性指的是,程序执行的顺序按照代码的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。所以多线程操作,就带来了一些顺序性的问题。Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式的保证顺序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。以下面这段代码为例

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

服务端编程的3大性能杀手:1、大量线程导致的线程切换开销。2、锁。3、非必要的内存拷贝。所以说锁在多线程编程中的地位是很重要的。我们找工作的时候,经常会谈到多线程,貌似多线程挺难的,很值得技术人员拿出来说说。那么我们讲来讲去,多线程到底难在什么地方?会不会是因为编程语言在多线程编程方面的基础设施没有理清楚,导致新手不能正确和安全的编写多线程程序?我们知道多线程编程中有三个核心概念,分别是可见性、原子性、顺序性。所以我们讨论多线程问题,特别是线程安全的问题的时候,来围绕这三个概念来展开,下面我们看下这三个概念。

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略,这个问题是很多开发人员忽略或者理解错误的一点。下面举个一种典型的使用场景,就是变量用于停止线程的例子来说明一下。在这种实现方式下,即使其它线程通过调用stop()方法将isRunning设置为false, 因为cache缓存的原因, 循环也不一定会立即结束。

复制代码

//成员变量
boolean shouldShutdown=false;


//在线程1
public void run () {
    while(!shouldShutdown) {
      someOperation();
    }
}

//在线程2
public void stop () {
  shouldShutdown = true;
}

复制代码

那么如何解决这个问题?第一、Java提供了volatile关键字来保证可见性。volatile变量具有 synchronized 的可见性特性,但是不具备原子特性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。所以成员变量 volatile boolean isRunning= false;可以解决这个问题。第二、使用java提供的锁。锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。在这里我们只用到可见性,而锁可以确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。
问:既然锁可保证原子性也可保证可见性,为何还需要volatile?
答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。


原子性

是指一个操作多含多条指令,要么全部执行,要么全部都不执行。这一点,跟数据库事务的原子性概念差不多。举个例子就是银行转账就需要操作的原子性。因为原子操作在一步之内就完成而且不能被中断,所以原子操作在多线程环境中是线程安全的,无需考虑同步的问题。在java中,下列操作是原子操作:

  • all assignments of primitive types except for long and double
  • all assignments of references
  • all operations of java.concurrent.Atomic* classes
  • all assignments to volatile longs and doubles

问题来了,为什么long型赋值不是原子操作呢?例如:long foo = 65465498L;事实上java会分两步写入这个long变量,先写32位,再写后32位,这样就线程不安全了。

另一个就是基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作,虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,所以不是线程安全的。

那么Java如何保证原子性的哪?
第一、对于基础类型变量自增(i++,i--)操作,Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger等方法请自行学习使用。
第二、对于其它业务逻辑,如银行转账,使用java里面内置锁(synchronize)和Lock(ReentrantLock)等。对于锁的详细用法,以后再讨论。
第三、对于long和double的赋值,使用关键字volatile,如:private volatile long foo; 这里引出一个问题, 为什么volatile能替代简单的锁, 却不能保证原子性?
注:CAS,Compare & Set,或是 Compare & Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。

顺序性

顺序性指的是,程序执行的顺序按照代码的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。所以多线程操作,就带来了一些顺序性的问题。Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式的保证顺序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。以下面这段代码为例

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

展开阅读全文

没有更多推荐了,返回首页