Java 并发锁

Java 中的锁

阻塞锁、可重入锁、读写锁、互斥锁、悲观锁、乐观锁、公平锁、偏向锁、对象锁、线程锁、锁粗化、锁消除、轻量级锁、重量级锁、信号量、独享锁、共享锁、分段锁

一、常见的锁

synchronized 和 Lock

synchronized 是一个: 非公平、悲观、独享、互斥、可重入的轻量级锁,原生语义上实现的锁
以下是锁是在JUC 包,在API层面上的实现
ReentrantLock 是默认非公平但可以实现公平的、悲观的、独享、互斥、可重入、重量级锁
ReentrantReadWriteLock 它是一个默认非公平但可以实现公平的,悲观、写独、读共享、可重入、重量级锁

1.公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序不按申请顺序获取,有可能后申请的线程,先获得锁。对于ReentrantLock ,通过构造函数指定锁是否公平锁,

默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于 Synchronized 而言,也是一种非公平锁,由于并不像ReentrantLock 是通过AQS 来实现线程调度,所以没有任何办法变为公平锁。

2.乐观锁/悲观锁

悲观锁是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。别人想拿数据除非获得锁,比如 synchronized 就是悲观锁

乐观锁:顾名思义就是很乐观,每次拿数据的时候都认为别人不会修改数据,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有更新此数据,可以使用版本号等机制。

乐观锁使用于多读的应用类型,这样可以提高吞吐量。比如在 Java 中 jcu.atomic 包下的原子类就是使用了乐观锁的一种方式CAS 实现的。

疑问:那乐观锁基本就和不上锁有何区别?CAS? 

3.独享锁/共享锁

独享锁是指锁只能一次只能被一个线程锁持有。共享锁是指该锁可被多个线程持有。 如 Java ReentrantLock 而言,是独享锁,但是对于 Lock 的另一种实现 ReentrantReadWriteLock 其读锁是共享

锁,写锁是独享锁。读锁的共享保证并发读的高效,写的过程是互斥的。独享锁于共享锁也是通过AQS 来实现的,通过不同的方法,来实现独享或者共享。对于synchronized 是独享锁。

疑问: AQS ?

4.互斥锁/读写锁

独享锁/共享锁是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁具体的实现 ReentrantLock ,synchronized 读写锁的实现:ReentrantReadWriteLock

5.可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在内层方法会自动获取锁。

二、synchronized

1.作用

  • 原子性:让线程互斥的访问同步代码
  • 可见性:保证共享变量的修改能够及时可见
  • 有序性:解决重排序问题

2.使用

  • 修饰普通方法:监视器锁monitor 是对象实例 this
  • 修饰静态方法:监视器锁monitor 对象 Class 实例。因为 Class数据存在永久代,因此静态方法锁相当于类的一个全局锁
  • 修饰代码块: 监视器monitor 是括号起来的对象实例
public class Test {
public synchronized void test(){ }
public synchronized static void test1(){ }
public void test2(){ synchronized (Test.class) { } } }

3.实现

同步方法: JVM 使用  acc_synchronized 标识来实现。同步代码块使用 monitorenter 和 monitorexit 指令实现同步。

同步代码块:每个对象都会与一个 monitor 关联,当 monitor 被占用时,就会处于被锁定状态,当线程执行到 monitorenter 指令时,就会去尝试获取对应的 monitor 。步骤入下

  • 每个 monitor 维护着一个记录着拥有次数的计数器。未被拥有的 monitor 的该计数器未0,当一个线程获得monitor 后,该计数器变为1
  • 当同一个线程再次获得该 monitor 时,计数器再次自增
  • 如果其他线程已经占有了 monitor,则该线程进入阻塞状态,直到monitor 计数器变为0,再次尝试获取 monitor
  • 当一个线程释放 monitor (执行指令monitorexit )时候,计数器自减,当计数器为0 时,monitor 被释放,其他线程可以获得monitor

同步方法:同步方法是隐式的,一个同步方法会在运行时常量池中的 method_info 结构体中存放 acc_synchronized 标识符。当一个线程访问方法时,会去检测是否存在 acc_synchronized 标识。

如果存在,则先要获得对应的 monitor 锁,然后执行方法。当方法执行结束(正常return 或者 抛出异常)都会释放对应的 monitor 锁。如果此时有其他的线程想要访问这个方法时,会因为得不到 

monitor 锁而阻塞。

三、锁优化

 jdk 1.6 实现各种锁优化,如适应性自旋,锁消除,锁粗化,轻量级锁,偏向锁等,这些技术都是为了线程之间更高效的共享数据以及解决竞争问题

1.自旋锁与适应性自旋

在许多应用上,共享数据的锁状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得(java 线程是映射在内核之上的,线程的挂起和恢复会极大的影响开销)。如果物理机器上有

一个以上的处理器,能让2个线程并行执行,可以让后面的请求的线程 “稍等一下”,但是不放弃处理器的执行时间,看看持有锁的线程是否很快就就放锁。为了让线程等待,我们需要让线程执行一个

忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待不能代替阻塞,且不说对处理器的要求,自旋等待本身虽然避免了线程切换的开销,但它要占用处理器的时间,因此如果锁被占用的时间很短,自旋的效果会很好,反之则是性能上的浪费。

因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋默认的次数是 10 次

在jdk 1.6 中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果

在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能会成功,就会允许自旋等待相对较长的时间。另外如果很少成功获得过,那么在

以后要获得这个锁时将可以忽略自旋的过程,避免浪费处理器资源。

2.锁消除

 锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

StringBuffer 的 append 方法用了 synchronized 关键字,它是线程安全的。如果在线程方法的局部变量中使用 StringBuffer ,由于不同线程调用方法时都会创建不同的对象(在当前线程的虚拟机栈中

创建栈帧),不会出现线程安全是问题,所以append() 没必要加锁,会进行锁消除。

3.锁粗化

如果系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。因此可以把多次加锁的请求

合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的损耗。

4.轻量级锁

轻量级锁并不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥变量产生的性能消耗。

对象头

HotSpot 虚拟机的对象头分为两部分信息。第一部分用于存储对象自身的运行时数据,如哈希码(HashCode) 、GC分代年龄(Generational GC age) 等,官方称 “Mark Word " . 它是实现轻量级锁

和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针。如果是数组对象,会有额外的部分存储数组的长度。

 

 

 轻量级锁:在代码进入同步的时候,如果此同步对象没有被锁定(锁标志位为“01“ 状态),虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录的空间,

用于存储锁对象目前的 Mark Word 的拷贝。

 

 

 然后,虚拟机将使用CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志将转变

为 ”00“ ,即标识此对象处于轻量级锁定的状态。

如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧。如果是说明当前线程已经拥有该对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,(自旋失败后)那轻量级锁就不再有效,要膨胀为重量级镇,锁标志的状态值变为 ”10“ ,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻寒状态。
它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mak Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的Displaced Mark Word 替换回来,如果替换成功整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

5.偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都清除掉,连CAS操作都不做了。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
假设当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”, 即偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外一个线程去尝试获取这个锁时,偏向模式就宜告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续同步操作同轻量级锁那样执行。

6.在 synchronized 锁流程如下

检测 Mark Word 里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁。
如果不是,则使用CAS将当前线程的 ID 替换 Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
如果自旋成功则依然处于轻量级状态。如果自旋失败,则升级为重量级锁。

 

 7.几种锁的对比

 四、Lock 锁

 1.Lock 与 synchronized 的不同

  • Lock 是一个接口,而 synchronized 是 Java 关键字,synchronized 是内置的语言实现(虚拟机级别),lock 是通过代码实现的(API)级别
  • synchronized 发生异常,会自动释放线程占有的锁。而 Lock 在发生异常时,如果没有主动 unlock() 释放,则很可能造成死锁现象,因此使用Lock 时,需要在 finally 块中释放锁
  • Lock 可以让等待锁的线程响应中断,线程可以中断干别的事物,而synchronized 不行,会一直等待下去

2.主要是实现类

ReentrantLock

此类中有3个内部类,分别是 Sync 抽象同步器,NonfairSync 非公平锁同步器、FairSync 公平同步器

abstract static class Sync extends AbstractQueuedSynchronizer {...}
static final class NonfairSync extends Sync{...}
static final class FairSync extends Sync {...}

Reentrant.lock() 方法的调用过程

默认非公平

 

 

 公平锁加锁过程

首先公平锁对应的是 ReentrantLock 内部静态类 FairSync

1.加锁时会先从 lock 方法中获取锁,调用 AQS 中的 acquire() 方法

final void lock() {
            acquire(1);
        }

2.acquire() 方法调用了 tryAcquire()  【FairSync 实现】方法

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

3.tryAcquire() 方法通过 getState() 获取当前同步状态,如果 state 为 0,则通过 CAS 设置该状态值,state 初始值为1,设置锁的拥有者为当前线程,tryAcquire返回true,否则返回false。如果同一个线程在获取了锁之后,再次去获取了同一个锁,状态值就加 1,释放一次锁状态值就减一,这就是可重入锁。只有线程 A 把此锁全部释放了,状态值减到0,其他线程才有机会获取锁。

4.如果获取锁失败,也就是 tryAcquire 返回 false,则调用的 addWaiter(Node mode) 方法把该线程包装成一个 node 节点入同步队列(FIFO),即尝试通过 CAS 把该节点追加到队尾,如果修改失败,意味着有并发,同步器通过进入 enq 以死循环的方式来保证节点的正确添加,只有通过 CAS 将节点设置成为尾节点之后,当前线程才能从该方法中返回,否则当前线程不断的尝试设置。加入队列时,先去判断这个队列是不是已经初始化了,没有初始化,则先初始化,生成一个空的头节点,然后才是线程节点。

5.加入了同步队列的线程,通过acquireQueued方法把已经追加到队列的线程节点进行阻塞,但阻塞前又通过 tryAccquire 重试是否能获得锁,如果重试成功能则无需阻塞)

6.头节点在释放同步状态的时候,会调用unlock(),而unlock会调用release(),release() 会调用 tryRelease 方法尝试释放当前线程持有的锁(同步状态),成功的话调用unparkSuccessor() 唤醒后继线程,并返回true,否则直接返回false

注意:队列中的节点在被唤醒之前都处于阻塞状态。当一个线程节点被唤醒然后取得了锁,对应节点会从队列中删除

非公平锁加锁过程

1.加锁时会先从 lock 方法中去获取锁,不同的是,它的 lock 方法是先直接 CAS 设置 state 变量,如果设置成功,表明加锁成功。设置失败,再调用 acquire 方法将线程置于队列尾部排队。也是去获取锁调用 acquire() 方法,acquire 方法内部同样调用了 tryAcquire() 方法,nonfairTryAcquire() 方法比公平锁的 tryAcquire 的if判断少了一个 !hasQueuedPredecessors()

hasQueuedPredecessors():判断是否有其他线程比当前线程在同步队列中等待的时间更长。有的话,返回 true,否则返回 false,进入队列中会有一个队列可能会有多个正在等待的获取锁的线程节点,可能有Head(头结点)、Node1、Node2、Node3、Tail(尾节点),如果此时Node2节点想要去获取锁,在公平锁中他就会先去判断整个队列中是不是还有比我等待时间更长的节点,如果有,就让他先获取锁,如果没有,那我就获取锁(这里就体会到了公平性)
其他步骤和公平锁一致

非公平锁的机制:如果新来了一个线程,试图访问一个同步资源,只需要确认当前没有其他线程持有这个同步状态,即可获取到。
公平锁的机制:既需要确认当前没有其他线程持有这个同步状态,而且要确认同步器的FIFO队列为空,或者队列不为空但是自己是队列中头结点指向的下一个节点。

这个区别很重要,因为线程在阻塞和非阻塞之间切换时需要比较长的时间,如果刚好线程A释放了资源,A会去唤醒下一个排着队的Node节点,当这个唤醒操作还没完成的时候,这时又来了一个线程B,线程B发现当前没人持有这个资源,于是自己就迅速拿到了这个资源,充分利用了线程A去唤醒B的这一段时间,这就是公平锁和非公平锁之间的差异,这里也体现了非公平锁性能较高的地方

ReentrantReadWriteLock

ReentrantReadWriteLock 是 Lock 的另一种实现方式,我们已经知道了 ReentrantLock 是一个排他锁,同一时间只允许一个线程访问,而 ReentrantReadWriteLock 允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。相对于排他锁,提高了并发性。在实际应用中,大部分情况下对共享数据(如缓存)的访问都是读操作远多于写操作,这时ReentrantReadWriteLock能够提供比排他锁更好的并发性和吞吐量。

  1. ReentrantReadWriteLock支持锁的降级。
  2. 读锁不支持Condition,会抛出UnsupportedOperationException异常,写锁支持Condition

锁降级/升级

同一个线程中,在没有释放读锁的情况下,就去申请写锁,这属于锁升级,ReentrantReadWriteLock是不支持的

同一个线程中,在没有释放写锁的情况下,就去申请读锁,这属于锁降级,ReentrantReadWriteLock是支持的

锁降级中读锁获取的意义:

主要是为了保证数据的可见性,如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程(T)获取了写锁并修改了数据,那么当前线程是无法感知线程 T 的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程 T 才能获取写锁进行数据更新。

CountDownLatch(共享锁)

CountDownLatch是一个计数器闭锁,通过它可以完成类似于阻塞当前线程的功能,即:一个线程或多个线程一直等待,直到其他线程执行的操作完成。CountDownLatch用一个给定的计数器来初始化,该计数器的操作是原子操作,即同时只能有一个线程去操作该计数器。调用该类 await 方法的线程会一直处于阻塞状态,直到其他线程调用countDown方法使当前计数器的值变为零,每次调用countDown计数器的值减1。当计数器值减至零时,所有因调用await()方法而处于等待状态的线程就会继续往下执行。这种现象只会出现一次,因为计数器不能被重置,如果业务上需要一个可以重置计数次数的版本,可以考虑使用CycliBarrier。

CountDownLatch主要有两个方法:countDown() 和 await()。countDown() 方法用于使计数器减一,其一般是执行任务的线程调用,await()方法则使调用该方法的线程处于等待状态,其一般是主线程调用。这里需要注意的是,countDown()方法并没有规定一个线程只能调用一次,当同一个线程调用多次countDown()方法时,每次都会使计数器减一;另外,await()方法也并没有规定只能有一个线程执行该方法,如果多个线程同时执行await()方法,那么这几个线程都将处于等待状态,并且以共享模式享有同一个锁。

实现原理

CountDownLatch 是基于 AbstractQueuedSynchronizer 实现的,在AbstractQueuedSynchronizer 中维护了一个 volatile 类型的整数 state,volatile 可以保证多线程环境下该变量的修改对每个线程都可见,并且由于该属性为整型,因而对该变量的修改也是原子的。创建一个 CountDownLatch 对象时,所传入的整数 n 就会赋值给 state 属性,当 countDown() 方法调用时,该线程就会尝试对 state 减一,而调用await() 方法时,当前线程就会判断 state 属性是否为 0,如果为 0,则继续往下执行,如果不为0,则使当前线程进入等待状态,直到某个线程将 state 属性置为 0,其就会唤醒在await()方法中等待的线程。

CyclicBarrier

CyclicBarrier 也是一个同步辅助类,它允许一组线程相互等待,直到到达某个公共屏障点(common barrier point)。通过它可以完成多个线程之间相互等待,只有当每个线程都准备就绪后,才能各自继续往下执行后面的操作。

CountDownLatch主要是实现了1个或N个线程需要等待其他线程完成某项操作之后才能继续往下执行操作,描述的是1个线程或N个线程等待其他线程的关系。CyclicBarrier主要是实现了多个线程之间相互等待,直到所有的线程都满足了条件之后各自才能继续执行后续的操作,描述的多个线程内部相互等待的关系。
CountDownLatch是一次性的,而CyclicBarrier则可以被重置而重复使用。

Semaphore

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。比如控制用户的访问量,同一时刻只允许1000个用户同时使用系统,如果超过1000个并发,则需要等待。
Semaphore与CountDownLatch相似,不同的地方在于Semaphore的值被获取到后是可以释放的,并不像CountDownLatch那样一直减到底。它也被更多地用来限制流量,类似阀门的 功能。如果限定某些资源最多有N个线程可以访问,那么超过N个主不允许再有线程来访问,同时当现有线程结束后,就会释放,然后允许新的线程进来。有点类似于锁的lock与 unlock过程。相对来说他也有两个主要的方法:
用于获取权限的acquire(),其底层实现与CountDownLatch.countdown()类似;
用于释放权限的release(),其底层实现与acquire()是一个互逆的过程。

 参考:https://blog.csdn.net/qq_41573234/article/details/99702344

posted @ 2019-08-21 11:54  byebai95  阅读(333)  评论(0编辑  收藏  举报