//并发产生的底层原理,从三个维度进行说明:
//1.内存维度,cpu内存有主存,缓存,寄存器,一般我们操作数据在寄存器上操作是最快的,但是直接在寄存器上操作的话,会造成,每个线程自己的寄存器上面,自己保有一个数字,不与其他线程相通
//2.cpu维度,当我们多线程操作数据的时候,会进行cpu时间片轮换,造成相同代码段同一时间会有多条线程同时执行
//3.代码的指令重排序,cpu执行我们的代码的时候会对我们的代码进行重排序,所以造成代码的顺序会有偏差,如下所示:
Volatile
Volatile关键字用于确保共享数据的可见性与有序性,但是并不能保证方法的原子性,在程序中对Volatile关键字使用得当的话,它比synchronized的使用和执行成本会更低,因为他不会引起线程的上下文切换和调度。
先讲一下重排序,重排序是什么?
我们所编写的程序会经过编译器编译,然后写入内存中。在执行时,CPU会从内存中读取并执行,在这里,编译器与CPU为了提高程序执行时的效率,会对代码的执行顺序进行优化,但代码输出的结果并不会改变,所以从宏观上我们认为程序是按照我们的思路来运行的,这里有三种重排序:
1.编译器重排序:在不改变代码语义的情况下,对重新对代码执行顺序进行排序。
2.CPU重排序:我们的代码在CPU处理时,会被编译成各种指令,若不存在数据依赖性,处理器在执行代码语句时,可以对其生成的指令进行重排序。
3.缓存的重排序:在CPU对缓存进行读/写时,加载与存储的操作是存在乱序的。
所以在单线程运行情况下的,重排序是提升程序的执行效率,这些重排序对程序运行是无害的,而在多线程运行情况下,线程交替执行则会出问题。先看一个比较常见的例子:
class Counter{
public static int count = 0;
public static boolean flag = false;
public void inc(){
count++; //-------操作1
flag = true; //-------操作2
}
public int getCount(){
if(falg){
flag = false; //-------操作3
return count; //-------操作4
}
return 0;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
正常情况下是调用inc()后执行操作1与操作2,然后再调用getCount()执行操作3与操作4,但是再多线程情况下,如果对这段代码进行了重排序,很有可能会出现如下结果:
1.线程A调用了inc(),代码被重排序后执行顺序为先将flag置为ture,再对count进行自增。
2.而此时线程B调用了getCount(),此时线程A只运行了flag置为ture,还没有执行到对count自增,这时线程B就会返回非预期值。
在我们使用Volatile关键字时,JMM会向CPU指令中插入特定的指令来确保共享数据可见性与有序性。
1.内存屏障用于保障有序性
内存屏障是一组同步指令集,使得CPU与编译器在对加入内存屏障之前的所有读写操作都执行后才可以开始执行此点之后的操作。它用于保障程序的有序执行,被插入内存屏障指令的代码,会对其实际代码执行顺序进行限制,有以下四种内存屏障:
根据JSR-133 CookBook中的描述,我们可以从下表中得出结论:
1.如果第二个操作是Volatile写,那么无论第一个操作是什么类型的操作,都不能改变代码的执行顺序。它用于保障Volatile写之前的操作不会被重排序到其后执行。
2.如果第一个操作是Volatile读,那么无论第二个操作是什么类型的操作,都不能改变代码的执行顺序。它用于保障Volatile读之后的操作不会被冲排序到其前面执行。
为了达到上述规则,编译器在生成字节码时,会在指令中插入内存屏障来保证Volatile数据前后代码的执行顺序。
读:
1.在对一个Volatile读操作之后添加一个LoadLoad指令。(用来确保当前读操作之后的读操作都不会被重排序)
2.对于一个Volatile读操作之后添加一个LoadStore指令。(用来确保当前读操作于后续写操作之前进行)
写:
1.在对一个Volatile写操作之前添加一个StoreStore指令。(用来确保当前写操作之前的写操作不会被重排序到其后面执行)
2.在一个Volatile写操作之后添加一个StoreLoad指令。(用来确保当前写操作于之后读操作之前执行)
2.可见性
当共享变量被Volatile关键字修饰后,对Volatile变量进行读写都使JVM在变量前插入LOCK前缀指令,若不涉及缓存一致性协议,LOCK前缀指令会锁住总线,使其他CPU暂时无法通过总线访问内存,作用如下:
1.对被Volatile关键字修饰的变量进行写操作,Lock指令会直接将工作内存中的变量刷新至主存中。
1.对于被Volatile关键字修饰的变量进行读操作,Lock指令会令工作内存中的该变量失效,直接从主存中读取。
这里需要注意的是Volatile关键字只能修饰单个变量,它无法保障代码块的原子性,如a++这样的操作并不能保障它的原子性,因为它是由做个指令集组成。
volitatile的使用情景:
1.状态标记
2.double check
synchronized
synchronized关键字用于解决在并发编程时的有序性、原子性、可见性。相比于Volatile关键字,synchronized锁能够控制的范围更大,使用synchronized关键字修饰方法或代码块时,能够确保在同一时刻最多只有一个线程能够执行该代码。当synchronized修饰方法时,它锁住的是对象的实例synchronized(this),当作用在对象实例时,它锁住的是代码块。
synchronized实现原理:
我们对于synchronized的理解也许只在于其互斥的特性,认为线程在执行加上synchronized关键字的代码,需要执行该代码块或方法的线程就会竞争该代码块或方法的互斥锁。若竞争成功则线程该代码块的执行权,而未竞争到该锁的线程只能被阻塞,等到持有锁的线程执行代码块或方法执行完毕后释放锁,其他线程才能继续竞争,而这仅仅synchronized关键字中重量锁的特性。其实synchronized在经过不断优化后,其锁的特性为偏向锁-》轻量锁-》重量锁三种,偏量锁和轻量锁在某种意义上能够减少重量锁带来的开销,但是他们都不能替代重量锁,下面就让我们来看看这三种锁的原理。
重量锁
重量锁故名思议就是需要消耗大量系统资源的锁,因为很重嘛。当多线程执行到具有synchronized关键字的代码时,会进行锁竞争,竞争失败的线程会进入一个阻塞队列,而获得锁的线程会获取代码的执行权。
具体重量锁的原理可以参考:http://blog.csdn.net/chen77716/article/details/6618779
而synchronized锁是一种非公平锁,当线程竞争失败时会阻塞,我们知道线程从运行状态切换到阻塞状态是依赖于操作系统从用户态切换到内核态来执行的,这种切换会消耗大量的系统资源(因为用户态与内核态都有各自专用的内存空间,专用的寄存器租等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作)。如果该方法是一个高频操作时,这将会消耗很多CPU处理时间。所以为了避免线程阻塞带来的消耗,引入了轻量锁。
轻量锁
轻量锁是为了避免在没有竞争的情况下重量锁所带来的开销,一旦该对象有多个线程竞争,轻量锁就会升级为重量锁,所以轻量锁和偏向锁并不能在多线程竞争情况下代替重量锁!!!只能在无锁竞争的条件下减缓重量锁的开销。在了解轻量锁前,我们先了解一下CAS操作与mark word标记,他们是实现轻量锁的基础。
CAS
CAS英文名(compare and swap)也就是比较交换,java语言在代码层面对其进行了封装,实际上是它是通过调用jni来实现的,它本质上是调用了cpu的指令集。在JUC中大量的使用到了CAS操作,它作为一种乐观锁,使用它就能实现所谓的无锁交换。
在CAS操作中,一个变量有三个状态值,一个是内存值V,一个是旧的预期值A,还有一个是要替换新值的B。对应到JMM中,V表示为主存中的值,而旧的预期值A为我们工作内存中的值,而B为操作后的新值,若V与B相等就说明该变量没有被其他线程修改,那么将变量替换为B值,不相等则不进行交换。所以使用CAS操作的开销相对于线程竞争过程中的阻塞唤醒引起的上下文切换来说小了很多(竞争情况下,操作队列,线程挂起,上下文切换)。
MARK WORD
我们知道java的Class文件是对java程序二进制文件格式的定义,java编译器将Class文件编译成字节码在jvm中运行,在堆内存中的对象都含有各自的对象头用于确定obj在运行时的状态,而Mark Word正是一个长为32bit的对象头,是用来标记同步线程的,具体信息如下图所示:
这里先解释HashCode 与state两个变量的值,轻量锁与重量锁在HashCode中存入的值为指向占有锁的线程的栈中的存储该线程所占有锁的信息的地址(有点绕口,其实就是存了一个地址,下面会详细说),state表示当前对象所处的状态,下面我们来看一下轻量锁如何来实现锁机制,这里分两种情况。
- 该对象没有被其他线程锁定
因为轻量锁是由偏向锁升级而来,通过判断tag是否为1与锁标志位是否为01来得知对象是否有没有被其他线程占用,若没有被其他线程占用,jvm会在当前线程的栈中创建一个lock record空间,将当前需要被锁定的对象的mark word的拷贝副本存入到lock record中,然后尝试使用CAS操作将mark Word中的betifields字段中的值更新为指向lock record空间的地址,若CAS操作成功,则将state更新为00,表示轻量锁添加成功,当前线程拥有对该对象的执行权。
2.出现锁竞争或对象已经被其他线程占用
若有两个线程同时对未被锁定的对象上轻量锁时,会有一个线程竞争失败,此时竞争失败标志是CAS操作失败,则该线程会自旋一段时间,若还是CAS操作失败,则该轻量锁会升级为重量锁,竞争失败的线程进入阻塞状态,state标志置为10,且mark down中重量锁指针会被修改。当占有该轻量锁的线程释放锁时,竞争失败的锁会被唤醒,重新竞争锁。
2.unlock
解锁过程也是将lock record中存储的mark down副本与object头中mark down进行CAS操作,若两者相等,则说明没有其他线程竞争该轻量锁,释放成功。如果失败,则当前轻量锁存在竞争,则锁会升级为重量锁。
从上述轻量锁实现过程我们可以看到轻量锁是使用CAS操作来代替重量锁的互斥操作,在语言层面上实现了同步操作,这样能够节约许多系统开销,但是需要注意的是,这都是在无锁竞争的前提条件下,因为轻量锁并不能代替重量锁。
偏向锁
偏向锁是在JVM1.6中引入了,主要也是为了解决在没有竞争情况下锁性能的问题,通过上述轻量锁的讲解,我们了解到轻量锁是通过CAS操作来避免重量锁的阻塞开销。但是我们知道CAS操作也是需要通过本地调用来实现,归根到底还是通过CPU指令集的实现,JVM只是封装了该指令调用。所以CAS操作会产生一定的副作用。因为CPU通过总线来实现对内存中数据的读写,而多核CPU在将自身cache内存中的数据刷新至主存中时,会引触发“缓存一致性协议”,就是说CPU1对主存中的值进行了改变,“缓存一致性协议”会通知CPU2、CPU3自身cache中该值已经失效,需要重新读取。若在轻量锁中每次进入操作,若CAS操作很频繁的话,会给总线带来巨大的开销,而偏向锁就是为了避免这个开销产生的。
若线程在没有竞争的情况下去获取某一对象的锁,会通过CAS操作将自身的Thread ID 存入Mark Word中,如果CAS操作成功,则表示该线程拥有该对象的执行权,而偏向锁是具有可重入性的,偏向吗,就是偏袒第一次占有该对象锁的线程,当该线程再次竞争该对象的锁时,只需要对比较Mark Word中的Thread ID 与自身的Thread ID 是否相同,相同则表明没有其他线程竞争,可以继续使用;如果这时有线程来竞争,则该线程在执行完代码块后,偏向锁会升级为轻量锁。这里需要注意的是,偏向锁只有再有竞争时才会撤销,若没有竞争,则一直是第一次获得偏向锁的线程持有。我们可以看到偏向锁的出现更加降低了线程初次获取锁的开销。