1、锁类型
1.1、乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
1.2、悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。
java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。
1.3、自旋锁
如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。这是一种空间换时间的策略
1.3.1、自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
1.4、可重入锁(递归锁)
在 JAVA 环境下 ReentrantLock 和 synchronized 都是 可重入锁
2、Synchronized 同步锁
synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。新的版本引入"无锁化"->"“轻量级锁”->“偏向锁”->“重量级”
2.1、作用范围
- 作用于方法时,锁住的是对象的实例(this);
- 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
- synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
2.2、核心组件
- Wait Set:哪些调用 wait 方法被阻塞的线程被放置在这里;
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
- Owner:当前已经获取到所资源的线程被称为 Owner;
- !Owner:当前释放锁的线程。
2.3、synchronized 实现
- 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 内核函数实现的)。
- Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时,等待的线程会先
尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是
不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁
资源。
- 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加
上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的
- synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线
程加锁消耗的时间比有用操作消耗的时间更多。
- Java1.6,synchronized 进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向
锁等,效率有了本质上的提高。在之后推出的 Java1.7 与 1.8 中,均对该关键字的实现机理做
了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。
-
锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;
-
JDK 1.6 中默认是开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLocking 来禁用偏向锁。
3、ReentrantLock
ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完
成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等
避免多线程死锁的方法。
3.1、非公平锁
加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待
- 非公平锁性能比公平锁高 5~10 倍,因为公平锁需要在多核的情况下维护一个队列
- Java 中的 synchronized 是非公平锁,ReentrantLock 默认的 lock()方法采用的是非
JVM 按随机、就近原则分配锁的机制则称为不公平锁,ReentrantLock 在构造函数中提供了
是否公平锁的初始化方式,默认为非公平锁。非公平锁实际执行的效率要远远超出公平锁,除非
程序有特殊需要,否则最常用非公平锁的分配机制。
3.2、公平锁
加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得
公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁,
ReentrantLock 在构造函数中提供了是否公平锁的初始化方式来定义公平锁。
3.3、与synchronized的区别
- ReentrantLock 通过方法 lock()与 unlock()来进行加锁与解锁操作,与 synchronized 会
被 JVM 自动解锁机制不同,ReentrantLock 加锁后需要手动进行解锁。为了避免程序出
现异常而无法正常解锁的情况,使用 ReentrantLock 必须在 finally 控制块中进行解锁操
作。
- ReentrantLock 相比 synchronized 的优势是可中断、公平锁、多个锁。这种情况下需要
使用 ReentrantLock
3.4、tryLock和 lock 和 lockInterruptibly 的区别
- tryLock 能获得锁就返回 true,不能就立即返回 false,tryLock(long timeout,TimeUnit
unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false
-
lock 能获得锁就返回 true,不能的话一直等待获得锁
-
lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程,
lock 不会抛出异常,而 lockInterruptibly 会抛出异常。
4、 ReadWriteLock 读写锁
为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如
果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写
锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。
- 读锁
如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁
- 写锁
如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁!Java 中 读 写 锁 有 个 接 口 java.util.concurrent.locks.ReadWriteLock , 也 有 具 体 的 实 现ReentrantReadWriteLock。
4.1、共享锁和独占锁
- 独占锁
独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock 就是以独占方式实现的互斥锁。
独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线
程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
-
共享锁
多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。
- AQS 的内部类 Node 定义了两个常量 SHARED 和 EXCLUSIVE,他们分别标识 AQS 队列中等
待线程的锁获取模式。
- java 的并发包中提供了 ReadWriteLock,读-写锁。它允许一个资源可以被多个读操作访问,
或者被一个 写操作访问,但两者不能同时进行。
4.2、锁分离
最常见的锁分离就是读写锁 ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能,读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如LinkedBlockingQueue 从头部取出,从尾部放数据
5、AbstractQueuedSynchronizer(AQS)
AbstractQueuedSynchronizer类如其名,抽象队列同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它。它维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)
ReadWriteLock 和 ReentrantLock 两者的原理实际上都是通过 CAS 原则实现的。不同的是ReentrantLock直接依赖state字段实现上锁。CAS本身则是通过本身内存所在地址的偏移量和原值进行比较实现的原子化操作。
ReadWriteLock 则是将state字段区分高位和低位,state的高16位作为写的锁判断,低16位作为读的锁判断
5.1、CAS
CAS(Compare And Swap/Set)比较并交换,CAS 算法的过程是这样:它包含 3 个参CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。
CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时
使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂
起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,
CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
-
ABA 问题
CAS 会导致“ABA 问题”。CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少。