在并发编程中,各种锁起着至关重要的作用,但是什么情景下使用什么锁,就需要好好考虑一下,如使用不当,轻则程序运行效率低,重则发生意想不到的灾难,下面,就来分析一下Java中的各种锁。
偏向/轻量级/重量级锁
Java SE1.6 为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在 Java SE1.6 里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
升级过程
当只有一个线程去竞争锁的时候,不需要阻塞,也不需要自旋,因为只有一个线程在竞争,只要去判断该偏向锁中的ThreadID是否为当前线程即可。如果是就执行同步代码,不是就尝试使用CAS修改ThreadID,修改成功执行同步代码,不成功就将偏向锁升级成轻量锁。
获取轻量锁的过程与偏向锁不同,竞争锁的线程首先需要拷贝对象头中的Mark Word到帧栈的锁记录中。拷贝成功后使用CAS操作尝试将对象的Mark Word更新为指向当前线程的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁。如果更新失败,那么意味着有多个线程在竞争。 当竞争线程尝试占用轻量级锁(自旋)失败多次之后轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒它。
重量级锁的加锁、解锁过程和轻量级锁差不多,区别是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,不使用自旋锁,所以重量级锁适合用在同步块执行时间长的情况下。
优缺点
公平/非公平锁
如果一个线程组里,能保证每个线程都能拿到锁,那么这个锁就是公平锁。相反,如果保证不了每个线程都能拿到锁,也就是存在有线程饿死,那么这个锁就是非公平锁。
公平锁:当一个线程请求锁,会先去查看锁维护的队列中有无等待线程,如果没有,并且锁当时没有被占有,那么此线程占有锁,如果有,则进入队尾等待,遵循FIFO原则。
非公平锁:线程请求锁的时候,首先会尝试占有锁,如果占有失败,才会进入等待队列,进入队列的线程同样遵循FIFO原则,同公平锁。
这里就涉及到了两个锁的区别,当占有锁的线程释放锁的同时,没有新的线程请求锁,公平锁和非公平锁没什么区别;如果释放锁的同时有新线程请求锁,而此时处于队首的线程还没被唤醒(线程上下文切换需要开销),则最后请求锁的线程会优先占有锁。
为什么要这样设计呢
这里涉及到了一个名词,上下文切换,由于上下文切换的开销,非公平锁的效率要高于公平锁,因为非公平锁减少了线程挂起的几率,能更充分的利用cpu的时间片,尽量的减少cpu空闲的状态时间。
线程上下文切换:CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换。
非公平锁效率既然高于公平锁,我们平时用的锁也都是非公平锁,那么,是不是公平锁就没有用了?答案显然是否定的。上面说过,非公平锁存在线程饿死的情况,当线程持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁,在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现,但是用公平锁会给业务增强很多的可控制性。
可重入/不可重入锁
若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。 ——《维基百科》
通俗来说,当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞(不可重入锁)。
对比代码来看两种锁的区别
不可重入锁:
使用锁:
当methodA方法被执行时,首先会获取锁,接下来执行methodB方法,B方法中,同样需要获取锁,此时由于锁被B方法的调用方A占用,无法释放,B也无法获取锁,造成死锁。
可重入锁:
同样调用使用锁的方法,当methodA加锁之后,调用methodB方法,由于是同一线程,不满足等待条件(因为是同一线程),则可以占有锁,计数器+1。我们平时用的锁,如synchronized,ReentrantLock等都属于可重入锁,上面示例公平/非公平锁的示例源码中也有重入锁的概念。
独占/共享锁
独占锁:顾名思义就是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程能读数据又能修改数据。
共享锁:共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独占锁与共享锁都是通过 AQS 来实现的,通过实现不同的方法,来实现独占或者共享。
AQS:AbustactQueuedSynchronizer,抽象队列同步器,Java的底层同步工具类,用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态。AQS的主要作用是为Java中的并发同步组件提供统一的底层支持。
乐观/悲观锁
乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。公平锁和非公平锁所加的均为悲观锁,synchronized和ReentrantLock等也均为悲观锁。
CAS:CompareAndSwap,比较并交换,是一种无锁算法,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization),涉及到三个值,需要读写的内存值 V,比较值A,新值B,当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量;但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行重试,反而降低了性能,所以一般多写的场景下用悲观锁就比较合适。
读写锁
ReadWriteLock管理一组锁,一个是只读锁,一个是写锁。读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占锁。
读写锁比互斥锁允许对于共享数据更大程度的并发。因为每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock适用于读多写少的并发情况。
该读写锁的实现原理是:将同步变量state按照高16位和低16位进行拆分,高16位表示读锁,低16位表示写锁。
写锁的获取与释放
1.获取同步状态,并从中分离出低16位的写锁状态;
2.如果同步状态不为0,说明存在读锁或写锁;
3.如果存在读锁(c !=0 && w == 0),则不能获取写锁(保证写对读的可见性);
4.如果当前线程不是上次获取写锁的线程,则不能获取写锁(写锁为独占锁);
5.如果以上判断均通过,则在低16位写锁同步状态上利用CAS进行修改;
6.将当前线程设置为写锁的获取线程。
释放写锁的过程跟释放独占锁类似,不断将计数器减一,直到等于0,释放写锁成功。
读锁的获取与释放
1.获取当前同步状态;
2.计算高16位读锁状态+1后的值;
3.如果第二步的值大于能够获取到的读锁的最大值,则抛出异常;
4.如果存在写锁并且当前线程不是写锁的获取者,则获取读锁失败;
5.如果上述判断都通过,则利用CAS重新设置读锁的同步状态。
读锁的释放步骤与写锁类似,即不断的释放读锁状态,直到为0时,表示没有线程获取读锁。
自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换(上下文切换)进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致争用锁的线程在最大等待时间内获取不到锁,这时争用线程会停止自旋进入阻塞状态。
优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的消耗(这些操作会导致线程发生两次上下文切换)。
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下要关闭自旋锁。
自旋时间阈值
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能,太短又不能发挥自旋锁的优势。因此自旋的周期选择格外重要。
阈值策略
1.如果平均负载小于CPUs则一直自旋;
2.如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞;
3.如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞;
4.如果CPU处于节电模式则停止自旋;
5.自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)。