实现多线程同步,java提供了多种方式,比如Synchronized、Lock、Volatile关键字等。但是他们的区别是什么呢,至今对这些东西我还是很懵懂的,所以是时候要对这些东西做一些了结了,不然每次看过就忘
volatile通常被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile关键字的作用:保证了变量的可见性(visibility)。被volatile关键字修饰的变量,如果值发生了变更,其他线程立马可见,避免出现脏读的现象。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
变量可见性
其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的 值对于其他线程是可以立即获取的。 Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该 线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进 行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。 volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存, 被其修饰的变量在每次是用之前都从主内存刷新。 因此,可以使用volatile来保证多线程操作时变量的可见性。
禁止重排序
有序性即程序执行的顺序按照代码的先后顺序执行。 除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行, 比如load->add->save 有可能被优化成load->save->add 。 这就是可能存在有序性问题。 而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。 普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果, 而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。 volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。 被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是: load、add、save。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一 种比sychronized关键字更轻量级的同步机制。volatile适合这种场景:一个变量被多个线程共享, 线程直接给这个变量赋值。
当对非volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU, 每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache中。 而声明变量是volatile的,JVM保证了每次读变量都从内存中读,跳过CPU cache 这一步。
首先我们理解下内存模型相关的概念:
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。 由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度 很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候 对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度,因此在CPU里面就有了高速缓存。 也就是说,程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算 时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存 当中。举个简单的例子,比如下面的这段代码: i = i + 1; 当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行 加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。 这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核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就是利用通过在总线加LOCK#锁的方式和通过缓存一致性协议实现的:
对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令, 将这个缓存中的变量回写到系统主存中。 但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下, 为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议: 缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了, 当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态, 当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。 所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。 而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。 这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
Synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
学习Java的小伙伴都知道synchronized关键字是解决并发问题常用解决方案,常用的有以下三种使用方式:
修饰代码块,即同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象。 修饰普通方法,即同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象。 修饰静态方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象。
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的
理解Java对象头与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下: 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度, 这部分内存按4字节对齐。 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为 了字节对齐,这点了解即可。 Java头对象:它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言, synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头 (如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)
同步代码块的实现使用的是monitorenter 和 monitorexit 指令: monitorenter指令指向同步代码块的开始位置, monitorexit指令则指明同步代码块的结束位置, 当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权, 当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1, 取锁成功。 如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的 值也会加 1。 倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完 毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持 有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。 为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产 生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。 从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
synchronized修饰的方法并没有monitorenter指令和monitorexit指令, 取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法, JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。 这便是synchronized锁在同步代码块和同步方法上实现的基本原理。
从不同的角度来划分的话,synchronized是独占锁、非公平锁、悲观锁、可重入锁、不可中断锁。
这里重点介绍下synchronized的可重入和不可中断性。
synchronized的可重入性:
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态, 但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功, 在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法 的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求 该对象锁,是允许的,这就是synchronized的可重入性。 通俗点说就是:线程获取锁进入一个synchronized方法/代码块,又调用了一个synchronized方法/代码块, 在进入第二个synchronized方法/代码块时,不需要先释放进入第一个synchronized时获取的锁, 也不需要再次争抢锁,而是直接进入。所以可重入锁也叫递归锁。 synchronized的可重入性不需要两个synchronized方法是同一个方法、也不需要是同一个类中的方法, 仅仅要求是同一个线程。
不可中断:
一旦锁被某个线程获得了,别的线程想要获得锁,只能选择等待或者阻塞,直到那个线程释放了这个锁。 如果那个线程永远不释放,其他线程只能永远等下去。
等待唤醒机制与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时, 必须处于synchronized代码块或者synchronized方法中, 否则就会抛出IllegalMonitorStateException异常, 这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象, 也就是说notify/notifyAll和wait方法依赖于monitor对象, 在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针), 而synchronized关键字可以获取 monitor , 这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块 或者synchronized方法调用的原因。
在Java多线程中可以使用synchronized隐式锁实现线程之间同步互斥,Java5中提供了Lock类(显示锁)也可以实现线程间的同步,而且在使用上更加方便。
synchronized是java内置的关键字,它提供了一种独占的加锁方式。synchronized的获取和释放锁有JVM实现,用户不需要显式的释放锁,非常方便,然而synchronized也有一定的局限性,例如:
1、当线程尝试获取锁的时候,如果获取不到锁就会一直阻塞。
2、如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁会一直等待。
JDK1.5之后发布的concurrent包,提供了Lock接口,用来提供更多扩展的加锁功能。Lock弥补了synchronized的局限性,提供了更加细粒度的加锁功能。
首先回忆下Lock提供的方法:
//尝试获取锁,获取成功则返回,否则阻塞当前线程 void lock(); //尝试获取锁,线程在成功获取锁之前被中断,则放弃获取锁,抛出异常 void lockInterruptibly() throws InterruptedException; //尝试获取锁,获取锁成功则返回true,否则返回false boolean tryLock(); //尝试获取锁,若在规定时间内获取到锁,则返回true,否则返回false,未获取锁之前被中断,则抛出异常 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //释放锁 void unlock(); //返回当前锁的条件变量,通过条件变量可以实现类似notify和wait的功能,一个锁可以有多个条件变量 Condition newCondition();
Lock有三个实现类,一个是ReentrantLock,另两个是ReentrantReadWriteLock类中的两个静态内部类ReadLock和WriteLock。
ReentrantLock ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,可重入。
CAS
Compare and Swap,比较并交换。CAS有3个操作数:内存值V、预期值A、要修改的新值B。 当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。该操作是一个原子操作, 被广泛的应用在Java的底层实现中。在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调 用CPU底层指令实现
AQS
AQS使用一个FIFO的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”, 它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus
ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后CAS再次尝试获取锁。
ReentrantLock 分为公平锁和非公平锁,默认一般使用非公平锁,它的效率和吞吐量都比公平锁高的多可以通过构造方法来指定具体类型:
非公平锁:如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取; 公平锁:如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾, 由队首的线程获取到锁。
两者的共同点:
1. 都是用来协调多线程对共享对象、变量的访问 2. 都是可重入锁,同一线程可以多次获得同一个锁 3. 都保证了可见性和互斥性
两者的不同点:
1. ReentrantLock显示的获得、释放锁,synchronized隐式获得释放锁 2. ReentrantLock可响应中断、可轮回,synchronized是不可以响应中断的处理锁的 ReentrantLock提供了更高的灵活性 3. ReentrantLock 是 API 级别的,synchronized 是 JVM 级别的 4. ReentrantLock可以实现公平锁 5. ReentrantLock通过Condition可以绑定多个条件 6. 底层实现不一样,synchronized是同步阻塞,使用的是悲观并发策略, lock是同步非阻塞,采用的是乐观并发策略 7. Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现。 8. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生; 而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象, 因此使用Lock时需要在finally块中释放锁。 9. Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时, 等待的线程会一直等待下去,不能够响应中断。10. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。Lock可以提高多个线程进行读操作的效率,既就是实现读写锁等。