Thread类——Java多线程详解

多线程基本概念

一、进程与线程

    进程指一个正在运行的运行程序,每个进程开始的时候,CPU都会开辟一块内存空间,一个进程可以产生多条线程,程序运行结束,进程结束。

    线程可以理解为进程的运行路径,执行流程,线程总是属于某个进程,线程没有自己的虚拟空间,线程之间可以共享同一进程的资源。

     window支持多进程操作,在运行某一程序的时候可以使用其他程序,这时CPU会在多个进程之间不断快速切换,在暂停某一进程时,会记录下该进程的运行位置和状态,当执行权再次切换到该进程时,会从暂停点再次运行。每一个进程都有独立的内存空间,该进程内的多个线程共享资源,这节省了内存空间,提高了运行效率。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈)

一个线程包含以下内容:

  •  一个指向当前被执行指令的指令指针;
  • 一个栈;
  • 一个寄存器值的集合,定义了一部分描述正在执行线程的处理器状态的值
  • 一个私有的数据区。

 在Java中,每次程序运行至少启动2个线程:一个是main线程,一个是垃圾收集(gc)线程。因为每当使用java命令执行一个类的时候,实际上都会启动一个JVM,每一个JVM实际上就是在操作系统中启动了一个进程。

二、java中的多线程

1、实现多线程

      在jdk1.5之前,java提供了两种实现多线程的方法,继承Thread类,和实现Runnable接口。jdk1.5之后提供了实现Callable<T>接口来实现多线程。

2、三种实现方式之间的区别

  •  Thread类底层实现了Runnable 接口,并且实现了public void run()方法;
  • 通过Runnable接口来实现多线程可以避免java单继承的局限;
  • Runnable 接口实现的多线程可以更好的体现多线程之间的资源共享;
  • Thread类和Runnable的多线程中其启动入口run()方法是无返回值的方法,而通过Callable<T>接口实现的多线程通过其启动入口call()方法,是返回值类型为T的方法。

3、线程的状态

  •  准备状态:实现了多线程,也可以理解为线程已经被new了出来
  • 就绪状态:线程被new出来之后不是立刻被运行,它要等待start()方法的调用,就绪状态是调用了start()方法,线程可以去抢占CPU资源了。
  • 运行状态:线程抢占了CPU资源,具备了执行权(CPU资源)和执行资格(锁),正在被执行。
  • 临时堵塞状态:线程sleep、wait或者被I/O堵塞,失去了CPU执行权;但不一定失去执行资格(锁)等线程被唤醒之后并不是立即获得执行权,从而转变到就绪状态,准备去抢占资源;
  • 死亡状态:线程执行结束,释放执行权和执行资格。

4、线程的同步

 在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

1.原子性

  原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  一个很经典的例子就是银行账户转账问题:

  比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

  所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  同样地反映到并发编程中会出现什么结果呢?

  举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

1

i = 9;

   假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

  那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

2.可见性

  可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  举个简单的例子,看下面这段代码:

1

2

3

4

5

6

//线程1执行的代码

int i = 0;

i = 10;

 

//线程2执行的代码

j = i;

   假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

  此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

  这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

3.有序性

  有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

1

2

3

4

int i = 0;              

boolean flag = false;

i = 1;                //语句1  

flag = true;          //语句2

   上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

  下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

  但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

1

2

3

4

int a = 10;    //语句1

int r = 2;    //语句2

a = a + 3;    //语句3

r = a*a;     //语句4

   这段代码有4个语句,那么可能的一个执行顺序是:

  

  

  那么可不可能是这个执行顺序呢: 语句2   语句1    语句4   语句3

  不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

  虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

1

2

3

4

5

6

7

8

9

//线程1:

context = loadContext();   //语句1

inited = true;             //语句2

 

//线程2:

while(!inited ){

  sleep()

}

doSomethingwithconfig(context);

   上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

   从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

  也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

synchronized关键字

在JDK1.5之前。Java主要靠synchronized这个关键字保证同步,已解决多线程下的线程不安全问题,但是这会导致锁的发生,会引发一些个性能问题。

锁主要存在一下问题

(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

(2)一个线程持有锁会导致其它所有需要此锁的线程挂起。

(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

锁分两种,一个叫悲观锁,一种称之为乐观锁。Synchronized就是悲观锁的一种,也称之为独占锁,加了synchronized关键字的代码基本上就只能以单线程的形式去执行了,它会导致其他需要该资源的线程挂起,直到前面的线程执行完毕释放所资源。而另外一种乐观锁是一种更高效的机制,它的原理就是每次不加锁去执行某项操作,如果发生冲突则失败并重试,直到成功为止,其实本质上不算锁,所以很多地方也称之为自旋。

乐观锁用到的主要机制就是CAS。Compare And Swap。

CAS有三个操作数,内存数据v,旧的预期数据A,要修改的数据B。每次进行数据更新时,当且仅当预期值A和内存中的数据V相同时,才将内存中的数据修改为B,否则什么也不做。

使用这种机制编写的算法也叫非阻塞算法,标准定义为一个线程的失败或者挂起不影响其他线程的失败或者挂起的算法。

现在的CPU提供了特殊的指令来自动更新共享数据,而且能检测到其他数据的干扰,因此可以通过compareAndSet来提到锁定。前面我们提到的一些原子类其实就是用的这个原理,如AtomicInteger。

利用CPU的CAS机制,同时借助JNI来完成Java的非阻塞算法。基本上Java中的原子类都是使用类似的机制来保证数据的原子操作的。

尽管CAS机制使得我们可以不依赖同步,不影响和挂起其他线程实现原子性操作,能大大提升运行时的性能,但是会导致一个ABA的问题。如线程一和线程二都取出了主存中的数据为A,这时候线程二将数据修改为B,然后又修改为A,此时线程一再次去进行compareAndSet的时候仍然能够匹配成功,而实际对的数据已经发生了变更了,只不过发生了2次变化将对应的值修改为原始的数据了,并不代表实际数据没有发生变化。这时候前面提到的原子操作AtomicStampedReference/AtomicMarkableReference就很有用了。这允许一对变化的元素进行原子操作。


Lock锁

JDK1.5之前只能通过synchronized关键字来实现同步,这个前面我们已经提到是属于独占锁,性能并不高,因此JDK1.5之后开始借助JNI实现了一套高效的锁实现!

Synchronized是基于JVM来保证数据同步的,而Lock则是在硬件层面,依赖特殊的CPU指令实现数据同步的,那究竟是如何来实现的呢?我们一一看来!

一、synchronized的实现方案

synchronized比较简单,语义也比较明确,尽管Lock推出后性能有较大提升,但是基于其使用简单,语义清晰明了,使用还是比较广泛的,其应用层的含义是把任意一个非NULL的对象当作锁。当synchronized作用于方法时,锁住的是对象的实例(this),当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带,因此静态方法锁相当于类的一个全局锁,当synchronized作用于一个对象实例时,锁住的是对应的代码块。在Sun的HotSpot JVM实现中,其实synchronized锁还有一个名字:对象监视器。

当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中。

1、  Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中

2、  Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中

3、  Wait Set:哪些调用wait方法被阻塞的线程被放置在这里

4、  OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck

5、  Owner:当前已经获取到所资源的线程被称为Owner

6、  !Owner:当前释放锁的线程

下图展示了他们之前的关系

 ContentionList并不是真正意义上的一个队列。仅仅是一个虚拟队列,它只有Node以及对应的Next指针构成,并没有Queue的数据结构。每次新加入Node会在队头进行,通过CAS改变第一个节点为新增节点,同时新增阶段的next指向后续节点,而取数据都在队列尾部进行。

 

 JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交个OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。该线程被阻塞后则进入内核调度状态,会导致系统在用户和内核之间进行来回切换,严重影响锁的性能。为了缓解上述性能问题,JVM引入了自旋锁。原理非常简单,如果Owner线程能在很短时间内释放锁资源,那么哪些等待竞争锁的线程可以稍微等一等(自旋)而不是立即阻塞,当Owner线程释放锁后可立即获取锁,进而避免用户线程和内核的切换。但是Owner可能执行的时间会超过设定的阈值,争用线程在一定时间内还是获取不到锁,这是争用线程会停止自旋进入阻塞状态。基本思路就是先自旋等待一段时间看能否成功获取,如果不成功再执行阻塞,尽可能的减少阻塞的可能性,这对于占用锁时间比较短的代码块来说性能能大幅度的提升!

但是有个头大的问题,何为自旋?其实就是执行几个空方法,稍微等一等,也许是一段时间的循环,也许是几行空的汇编指令,其目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!

JVM对于自旋周期的选择,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化

1、  如果平均负载小于CPUs则一直自旋

2、  如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞

3、  如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞

4、  如果CPU处于节电模式则停止自旋

5、  自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)

6、  自旋时会适当放弃线程优先级之间的差异

Synchronized在线程进入ContentionList时,等待的线程就通过自旋先获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

在JVM6以后还引入了一种偏向锁,主要用于解决无竞争下面锁的性能问题。我们首先来看没有这个会有什么样子的问题。

现在基本上所有的锁都是可重入的,即已经获取锁的线程可以多次锁定/解锁监视对象,但是按照之前JVM的设计,每次加锁解锁都采用CAS操作,而CAS会引发本地延迟(下面会讲原因),因此偏向锁希望线程一旦获取到监视对象后,之后让监视对象偏向这个锁,进而避免多次CAS操作,说白了就是设置了一个变量,发现是这个线程过来的就避免再走加锁解锁流程。

那CAS为什么会引发本地延迟呢?这要从多核处(SMP)理架构说起(前面有提到过--JVM内存模型),下图基本上表明了多核处理的架构

多核CPU会共享一条系统总线,靠总线和主存通讯,但是每个CPU又有自己的一级缓存,而CAS是一条原子指令,其作用是让CPU比较,如果相同则进行数据更新,而这些是基于硬件实现的(JVM只是封装了硬件的汇编调用,AtomicInteger其实是通过调用这些封装后的接口实现的)。多核运算时,由于线程切换,很有可能第二次取值是在另外一核CPU上执行的。假设Core1和Core2把对应的某个值加载到自己的一级缓存时,某个时刻,core1更新了这个数据并通过总线通知主存,此时core2的一级缓存中的数据就失效了,他需要从主存中重新加载一次到一级缓存中,大家通过总线通讯被称之为一致性流量,总线的通讯能力有限,当缓存一致性流量过大时,总线会成为瓶颈,而当Core1和Core2的数据再次一致时,被称为缓存一致性!

而CAS要保证数据的一致性,恰好会引发比较多的一致性流量,如果有很多线程共享一个对象,当某个线程成功执行一次CAS时会引发总线风暴,这就是本地延迟,而偏向锁就是为了消除CAS,降低Cache一致性流量!

当然并不是所有的CAS都会引发总线风暴,这和Cache一致性协议有关系的。但是偏向锁的引入却带来了另外一个问题,在很多线程竞争使用中,如果一个线程持有偏向锁,另外一个线程想争用偏向对象,拥有者想释放这个偏向锁,释放会带来额外的性能开销,但是总体来说偏向锁带来的好处还是大于CAS的代价的。

二、Lock的实现

与synchronized不同的是,Lock书纯Java实现的,与底层的JVM无关。在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖java.util.concurrent.AbstractQueuedSynchronizer类(简称AQS),实现思路都大同小异,因此我们以ReentrantLock作为讲解切入点。

分析之前我们先来花点时间看下AQS。AQS是我们后面将要提到的CountDownLatch/FutureTask/ReentrantLock/RenntrantReadWriteLock/Semaphore的基础,因此AQS也是Lock和Excutor实现的基础。它的基本思想就是一个同步器,支持获取锁和释放锁两个操作。

获取锁:首先判断当前状态是否允许获取锁,如果是就获取锁,否则就阻塞操作或者获取失败,也就是说如果是独占锁就可能阻塞,如果是共享锁就可能失败。另外如果是阻塞线程,那么线程就需要进入阻塞队列。当状态位允许获取锁时就修改状态,并且如果进了队列就从队列中移除。

复制代码

while(synchronization state does not allow acquire){

    enqueue current thread if not already queued;

    possibly block current thread;

}

dequeue current thread if it was queued;

复制代码

释放锁:这个过程就是修改状态位,如果有线程因为状态位阻塞的话,就唤醒队列中的一个或者更多线程。

update synchronization state;

if(state may permit a blocked thread to acquire)

    unlock one or more queued threads;

要支持上面两个操作就必须有下面的条件

1、  状态位必须是原子操作的

2、  阻塞和唤醒线程

3、  一个有序的队列,用于支持锁的公平性

怎么样才能满足这几个条件呢?

1、  原子操作状态位,前面我们已经提到了,实际JDK中也是通过一个32bit的整数位进行CAS操作来实现的。

2、  阻塞和唤醒,JDK1.5之前的API中并没有阻塞一个线程,然后在将来的某个时刻唤醒它(wait/notify是基于synchronized下才生效的,在这里不算),JDK5之后利用JNI在LockSupport 这个类中实现了相关的特性!

3、  有序队列:在AQS中采用CLH队列来解决队列的有序问题。

我们来看下ReentrantLock的调用过程

经过源码分析,我们看到ReentrantLock把所有的Lock都委托给Sync类进行处理,该类继承自AQS,其类关系图如下

其中Sync又有两个final static的子类NonfairSync和FairSync用于支持非公平锁和公平锁。我们先来挑一个看下对应Reentrant.lock()的调用过程(默认为非公平锁)

这些模版很难让我们直观的看到整个调用过程,但是通过上面的过程图和AbstractQueuedSynchronizer的注释可以看出,AbstractQueuedSynchronizer抽象了大多数Lock的功能,而只把tryAcquire(int)委托给子类进行多态实现。tryAcquire用于判断对应线程事都能够获取锁,无论成功与否,AbstractQueuedSynchronizer都将处理后面的流程。

简单来讲,AQS会把所有请求锁的线程组成一个CLH的队列,当一个线程执行完毕释放锁(Lock.unlock())的时候,AQS会激活其后继节点,正在执行的线程不在队列当中,而那些等待的线程全部处于阻塞状态,经过源码分析,我们可以清楚的看到最终是通过LockSupport.park()实现的,而底层是调用sun.misc.Unsafe.park()本地方法,再进一步,HotSpot在Linux中中通过调用pthread_mutex_lock函数把线程交给系统内核进行阻塞。其运行示意图如下

与synchronized相同的是,这个也是一个虚拟队列,并不存在真正的队列示例,仅存在节点之前的前后关系。(注:原生的CLH队列用于自旋锁,JUC将其改造为阻塞锁)。和synchronized还有一点相同的是,就是当获取锁失败的时候,不是立即进行阻塞,而是先自旋一段时间看是否能获取锁,这对那些已经在阻塞队列里面的线程显然不公平(非公平锁的实现,公平锁通过有序队列强制线程顺序进行),但会极大的提升吞吐量。如果自旋还是获取失败了,则创建一个节点加入队列尾部,加入方法仍采用CAS操作,并发对队尾CAS操作有可能会发生失败,AQS是采用自旋循环的方法,知道CAS成功!下面我们来看下锁的实现细节!

锁的实现依赖与lock()方法,Lock()方法首先是调用acquire(int)方法,不管是公平锁还是非公平锁

public final void acquire(int arg) {
         if (!tryAcquire(arg) &&
             acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
             selfInterrupt();
     }

Acquire()方法默认首先调用tryAcquire(int)方法,而此时公平锁和不公平锁的实现就不一样了。

1、Sync.NonfairSync.TryAcquire(非公平锁)

nonfairTryAcquire方法是lock方法间接调用的第一个方法,每次调用都会首先调用这个方法,我们来看下对应的实现代码:

复制代码

final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

复制代码

该方法首先会判断当前线程的状态,如果c==0 说明没有线程正在竞争锁。(反过来,如果c!=0则说明已经有其他线程已经拥有了锁)。如果c==0,则通过CAS将状态设置为acquires(独占锁的acquires为1),后续每次重入该锁都会+1,每次unlock都会-1,当数据为0时则释放锁资源。其中精妙的部分在于:并发访问时,有可能多个线程同时检测到c为0,此时执行compareAndSetState(0, acquires))设置,可以预见,如果当前线程CAS成功,则其他线程都不会再成功,也就默认当前线程获取了锁,直接作为running线程,很显然这个线程并没有进入等待队列。如果c!=0,首先判断获取锁的线程是不是当前线程,如果是当前线程,则表明为锁重入,继续+1,修改state的状态,此时并没有锁竞争,也非CAS,因此这段代码也非常漂亮的实现了偏向锁。

2、Sync.FairSync.TryAcquire(公平锁)

我们直接来看代码

protected final boolean tryAcquire(int acquires) {
         final Thread current = Thread.currentThread();
         int c = getState();
         if (c == 0) {
             if (isFirst(current) &&
                 compareAndSetState(0, acquires)) {
                 setExclusiveOwnerThread(current);
                 return true;
             }
         }
         else if (current == getExclusiveOwnerThread()) {
             int nextc = c + acquires;
             if (nextc < 0)
                 throw new Error("Maximum lock count exceeded");
             setState(nextc);
             return true;
         }
         return false;
     }

和明细我们可以看出,公平锁就比不公平锁多了一个判断头结点的方法,就是采用此方法来保证锁的公平性。

3、AbstractQueuedSynchronizer.addWaiter

tryAcquire失败就意味着入队列了。此时AQS的队列中节点Node就开始发挥作用了。一般情况下AQS支持独占锁和共享锁,而独占锁在Node中就意味着条件(Condition)队列为空。在java.util.concurrent.locks.AbstractQueuedSynchronizer.Node中有两个常量

static final Node EXCLUSIVE = null; //独占节点模式
 
static final Node SHARED = new Node(); //共享节点模式

addWaiter(mode)中的mode就是节点模式,也就是共享锁还是独占锁模式。添加的节点是当前线程。(注:ReentrantLock是独占锁模式),我们来看下对应的实现代码:

复制代码

private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

复制代码

这块代码并不复杂,如果当前队尾存在元素(tail!=null),则通过CAS添加当前线程到队尾,如果队尾为空或者CAS失败,则通过enq方法设置tail。我们来看下enq的代码

复制代码

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                Node h = new Node(); // Dummy header
                h.next = node;
                node.prev = h;
                if (compareAndSetHead(h)) {
                    tail = node;
                    return h;
                }
            }
            else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

复制代码

该方法就是循环调用CAS,即使有高并发的场景,无限循环将会最终成功把当前线程追加到队尾(或设置队头)。总而言之,addWaiter的目的就是通过CAS把当前现在追加到队尾,并返回包装后的Node实例。

把线程要包装为Node对象的主要原因,除了用Node构造供虚拟队列外,还用Node包装了各种线程状态,这些状态被精心设计为一些数字值:

1)、  SIGNAL(-1) :线程的后继线程正/已被阻塞,当该线程release或cancel时要重新这个后继线程(unpark)

2)、  CANCELLED(1):因为超时或中断,该线程已经被取消

3)、  CONDITION(-2):表明该线程被处于条件队列,就是因为调用了Condition.await而被阻塞

4)、  PROPAGATE(-3):传播共享锁

5)、  0:0代表无状态

3、AbstractQueuedSynchronizer.acquireQueued(进行阻塞)

acquireQueued的主要作用是把已经追加到队列的线程节点(addWaiter方法返回值)进行阻塞,但阻塞前又通过tryAccquire重试是否能获得锁,如果重试成功能则无需阻塞,直接返回。下面我们来看以下它对应的源码信息

复制代码

final boolean acquireQueued(final Node node, int arg) {
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } catch (RuntimeException ex) {
            cancelAcquire(node);
            throw ex;
        }
    }

复制代码

仔细看看这个方法是个无限循环,感觉如果p == head && tryAcquire(arg)条件不满足循环将永远无法结束,当然不会出现死循环,奥秘在于parkAndCheckInterrupt会把当前线程挂起,从而阻塞住线程的调用栈。我们来看下他的实现方法:

private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

如前面所述,LockSupport.park最终把线程交给系统(Linux)内核进行阻塞。当然也不是马上把请求不到锁的线程进行阻塞,还要检查该线程的状态,比如如果该线程处于Cancel状态则没有必要,具体的检查在shouldParkAfterFailedAcquire中:

复制代码

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
        do {
       node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE. Indicate that we
             * need a signal, but don't park yet. Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

复制代码

检查原则如下:

1、如果前继节点的waitStatus为signal,则说明前面的节点都还灭有获取到锁,此时当前线程需要阻塞,直接返回true

2、如果前继节点waitStatus>0,说明前继节点已经被取消,则重新设置当前节点的前继节点,返回false,之后无限循环直到第一步状态返回true,导致线程阻塞

3、如果前继节点waitStatus小于0而且不等于-1(signal),则通过CAS设置前继节点额外isignal,并返回false,之后无限循环直到步骤1返回true,线程阻塞。

请求锁不成功的线程会被挂起在acquireQueued方法的第12行,12行以后的代码必须等线程被解锁锁才能执行,假如被阻塞的线程得到解锁,则执行第13行,即设置interrupted = true,之后又进入无限循环。

从无限循环的代码可以看出,并不是得到解锁的线程一定能获得锁,必须在第6行中调用tryAccquire重新竞争,非公平锁中有可能被新加入的线程获取到,从而导致刚刚被唤醒的线程再次阻塞;公平锁通过判断当前节点是否是头结点来保证锁的公平性。上面的代码我们还可以看到,因为每次第一个被解锁的是头结点,因此一般p==head的判断都会成功。解锁相对比较简单,主要体现在AbstractQueuedSynchronizer.release和Sync.tryRelease方法中:

复制代码

 public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
}

复制代码

复制代码

protected final boolean tryRelease(int releases) {
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);
        return free;
    }

复制代码

这个逻辑也比较简单:

1.判断持有锁的线程是否是当前线程,如果不是就抛出IllegalMonitorStateExeception(),因为一个线程是不能释放另一个线程持有的锁(否则锁就失去了意义)。否则进行2。

2.将AQS状态位减少要释放的次数(对于独占锁而言总是1),如果剩余的状态位0(也就是没有线程持有锁),那么当前线程就是最后一个持有锁的线程,清空AQS持有锁的独占线程。进行3。

3.将剩余的状态位写回AQS,如果没有线程持有锁就返回true,否则就是false。

从上面我们可以知道,这里c==0决定了是否完全释放了锁。由于ReentrantLock是可重入锁,因此同一个线程可能多重持有锁,那么当且仅当最后一个持有锁的线程释放锁是才能将AQS中持有锁的独占线程清空,这样接下来的操作才需要唤醒下一个需要锁的AQS节点(Node),否则就只是减少锁持有的计数器,并不能改变其他操作。

当tryRelease操作成功后(也就是完全释放了锁),release操作才能检查是否需要唤醒下一个继任节点。这里的前提是AQS队列的头结点需要锁(waitStatus!=0),如果头结点需要锁,就开始检测下一个继任节点是否需要锁操作。

上文说道acquireQueued操作完成后(拿到了锁),会将当前持有锁的节点设为头结点,所以一旦头结点释放锁,那么就需要寻找头结点的下一个需要锁的继任节点,并唤醒它。我们来看下对应的实现代码:

复制代码

private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling. It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
 
        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }

复制代码

对比对应的代码我们可以看出,一旦头结点的后继结点被唤醒,那么后继结点就尝试去获取锁,如果获取成功就将头结点设置为自身,并将头结点的前任节点清空。

复制代码

final boolean acquireQueued(final Node node, int arg) {
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } catch (RuntimeException ex) {
            cancelAcquire(node);
            throw ex;
        }
    }

复制代码

对比lock,unlock是相当比较简单的,主要是释放需要响应的资源,并唤醒AQS队列中有效的后继结点,这样就试图以请求的顺序获取锁资源了。

对比公平锁和不公平锁,其实就是在获取锁的时候有区别,释放锁的时候都是一样的。非公平锁总是尝试看当前有没有线程持有锁,如果没有则使用现有的线程去抢占锁资源,但是一旦抢占失败,也就和公平锁一样,进入阻塞队列老老实实排队去了,也就是说公平锁和非公平锁只有在进入AQS的CLH队列之前有区别,后面都是按照队列的顺序请求锁资源的。

由锁衍生的下一个对象是条件变量,这个对象的存在很大程度上是为了解决Object.wait/notify/notifyAll难以使用的问题。

条件(也称为条件队列 或条件变量)为线程提供了一个含义,以便在某个状态条件现在可能为 true 的另一个线程通知它之前,一直挂起该线程(即让其“等待”)。因为访问此共享状态信息发生在不同的线程中,所以它必须受保护,因此要将某种形式的锁与该条件相关联。等待提供一个条件的主要属性是:以原子方式 释放相关的锁,并挂起当前线程,就像 Object.wait 做的那样。

上述API说明表明条件变量需要与锁绑定,而且多个Condition需要绑定到同一锁上。前面的Lock中提到,获取一个条件变量的方法是Lock.newCondition()。

Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set(wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。await对应于Object.wait,signal对应于Object.notify,signalAll对应于Object.notifyAll。特别说明的是Condition的接口改变名称就是为了避免与Object中的wait/notify/notifyAll的语义和使用上混淆,因为Condition同样有wait/notify/notifyAll方法。

每一个Lock可以有任意数据的Condition对象,Condition是与Lock绑定的,所以就有Lock的公平性特性:如果是公平锁,线程为按照FIFO的顺序从Condition.await中释放,如果是非公平锁,那么后续的锁竞争就不保证FIFO顺序了。

三、volatile关键字

1、java内存模型

在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。

  Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

  举个简单的例子:在java中,执行下面这个语句:

1

i  = 10;

   执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。

  那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

1.原子性

  在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

  上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:

  请分析以下哪些操作是原子性操作:

1

2

3

4

x = 10;         //语句1

y = x;         //语句2

x++;           //语句3

x = x + 1;     //语句4

   咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

  语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

  同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

   所以上面4个语句只有语句1的操作具备原子性。

  也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

  不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。

  从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

  对于可见性,Java提供了volatile关键字来保证可见性。

  当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

  而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

  在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

  在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

  另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

  下面就来具体介绍下happens-before原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

  这8条原则摘自《深入理解Java虚拟机》。

  这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

  下面我们来解释一下前4条规则:

  对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

  第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

  第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

  第四条规则实际上就是体现happens-before原则具备传递性。

1.volatile关键字的两层语义

  一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  2)禁止进行指令重排序。

  先看一段代码,假如线程1先执行,线程2后执行:

1

2

3

4

5

6

7

8

//线程1

boolean stop = false;

while(!stop){

    doSomething();

}

 

//线程2

stop = true;

   这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

  下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

  那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

  但是用volatile修饰之后就变得不一样了:

  第一:使用volatile关键字会强制将修改的值立即写入主存;

  第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

  第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

  那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

  那么线程1读取到的就是最新的正确的值。

2.volatile保证原子性吗?

  从上面知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗?

  下面看一个例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

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

    }

}

   大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

  可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

  这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

  在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  假如某个时刻变量inc的值为10,

  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  那么两个线程分别进行了一次自增操作后,inc只增加了1。

  解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

  根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

 

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

3.volatile能保证有序性吗?

  在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

  volatile关键字禁止指令重排序有两层意思:

  1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

  可能上面说的比较绕,举个简单的例子:

1

2

3

4

5

6

7

8

//x、y为非volatile变量

//flag为volatile变量

 

x = 2;        //语句1

y = 0;        //语句2

flag = true;  //语句3

x = 4;         //语句4

y = -1;       //语句5

   由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

  并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

 

4.volatile的原理和实现机制

  前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

  下面这段话摘自《深入理解Java虚拟机》:

  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

 synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  1)对变量的写操作不依赖于当前值

  2)该变量没有包含在具有其他变量的不变式中

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

本文参考多篇文章,部分为原创!!!

 

 

  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值