Java 的锁 – 锁的分类、设计与优化
-
在当线程操作数据时,数据是一致的(没有其他线程操作该数据)。
-
锁机制:用来保证
在多线程并发情况下数据的一致性
。 -
锁的作用点:
操作一个对象或者调用一个方法前加锁,这样当其他线程也对该对象和方法进行访问时就需要获得锁,如果该锁被其他线程持有,那么该线程则进入阻塞队列等待获得锁
。 -
不同的锁,其线程等待机制是不尽相同的。
锁的某种分类
乐观锁与悲观锁
- 乐观锁和悲观锁是以
是否用乐观态度看待共享数据写冲突问题
,为分类点进行分类。
乐观锁
-
对于这个加了乐观锁的共享数据,认为数据读写中
读多写少
,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁
。 -
但是,
是在更新的时候会判断一下在此期间别人有没有去更新这个数据
。 -
更新比较采取
在写时先读出当前版本号
,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。 -
java 中的乐观锁基本都是
通过 CAS 操作实现
的。 -
CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败
。 -
在 Java 语言中
java.util.concurrent.atomic 包下的原子类就是使用 CAS 乐观锁实现的
。
CAS
-
全称:
Compare and swap,意思是:比较并交换,是一种轻量级锁
。 -
线程在读取数据是不进行加锁,在准备修改数据时,先去查询原值,操作的时候比较原值是否被修改;若未被其他线程修改则写入数据;若已经被修改,就要重新执行读取流程。
CAS 的缺点
-
循环时间长,开销大,如果 CAS 失败,会一直尝试,如果 CAS 一直不成功,会给 CPU 带来很大的开销。
-
只能保证一个共享变量的原子操作。
-
出现 ABA 问题(只关心共享变量的起始值和结束值,而不关心过程中共享变量是否被其他线程改动过)。
- ABA 问题:某线程读取数据 A,将其改为数据 B,但是其执行时间较长;在此时间内,数据被修改过,但数据重新改为了数据 A,那么该线程继续并正确运行。
悲观锁
-
对于这个加了乐观锁的共享数据,认为数据读写中
写多读少
,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁
。 -
别的线程想读写这个数据就会
一直阻塞,直到拿到锁
。 -
悲观锁的典范:
synchronized 和 ReentrantLock
。
两种锁的使用场景比较
-
悲观锁和乐观锁没有孰优孰劣,有其各自适应的场景。
-
乐观锁适用于写比较少(冲突比较小)的场景
,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。 -
悲观锁适用于写多读少的场景,即冲突比较严重,线程间竞争激励
。
独占锁与共享锁
- 独占锁与共享锁是以
一个锁一次是否可以被多个线程所持有
,为分类点进行分类。
独占锁
-
独占锁是指
该锁一次只能被一个线程所持有
。 -
也可以称为:排他锁。
-
独占锁模式下,
每次只能有一个线程能持有锁
,该线程即能读数据又能修改数据
。 -
独占锁是一种
悲观保守的加锁策略
,它避免了读、读冲突。 -
但,某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了只读线程的并发,因为读操作并不会影响数据的一致性。
-
ReentrantLock
就是以独占方式实现的互斥锁
。 -
独占锁的典范还有:
-
JDK 中的 synchronized。
-
java.util.concurrent(JUC)包中 Lock 的实现类。
-
互斥锁
-
互斥锁是
独占锁的一种常规实现
,是指某一资源同时只允许一个访问者对其进行访问
。 -
具有
唯一性
和排它性
。
共享锁
-
共享锁是指
该锁一次可以被多个线程(只读线程)共享
。 -
共享锁模式下,
允许多个线程同时获取锁
,并发访问资源,这些线程只能读数据
。 -
共享锁是一种
乐观锁
,放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源
。 -
但是,
该锁一次只可以被一个写线程持有
。 -
共享锁的典范:
ReadWriteLock
。- java 的并发包中提供了 ReadWriteLock(读、写锁)。它允许一个资源
可以被多个读操作访问
,或者被一个写操作访问
,但两者不能同时进行
。
- java 的并发包中提供了 ReadWriteLock(读、写锁)。它允许一个资源
读写锁
-
读写锁是
共享锁的一种具体实现
。 -
读写锁管理一组锁,
一个是只读的锁
,一个是写锁
。 -
读锁在没有写锁的时候,可以被多个线程同时持有
。 -
写锁在没有读锁的时候,可以被一个线程持有。因为写锁是独占的
。 -
同时,
写锁的优先级要高于读锁
,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。 -
ReentrantReadWriteLock
一个读写锁的实现类,实现了 ReadWriteLock 接口
。
公平锁与非公平锁
- 公平锁与非公平锁是以
锁的分配机制是否公平(先申请先得到)
,为分类点进行分类。
公平锁(Fair)
-
公平锁模式下,锁的分配机制是公平的,
先对锁提出获取请求的线程会先被分配到锁
。 -
ReentrantLock
是一个公平锁,同时也是一个非公平锁。new ReentrantLock(true);
获取到的对象是一个公平锁
。
非公平锁(NonFair)
-
非公平锁模式下,锁的分配机制是不公平的,
先对锁提出获取请求的线程不一定会先被分配到锁
。 -
线程在获取非公平锁时,会
先尝试插队(线程发起请求后就直接尝试获取锁,先不进入线程阻塞队列),插队失败后在排队(进入线程阻塞队列等待锁)
。 -
锁的分配机制是
随机分配、或就近原则分配
。 -
synchronized 是非公平锁
。 -
同时,ReentrantLock 对象默认创建的也是非公平锁,
new ReentrantLock();、new ReentrantLock(false);
获取到的对象是一个非公平锁
。
可重入锁
-
可重入锁,指的是
同一线程中 外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响
。 -
同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。
-
也叫做:递归锁。
-
在 Java 语言中 ReentrantLock 和 synchronized 都是 可重入锁
。 -
可重入锁的好处之一:
可一定程度避免死锁
。
自旋锁
-
一般情况下,线程在请求锁后,如果现在没有获取到(需要竞争锁),那么线程就会做内核态和用户态之间的切换进入阻塞挂起状态。
-
自旋锁是:如果持有锁的线程
能在很短时间内释放锁资源
,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁
。 -
线程自旋是需要消耗 CPU 的,此时 CPU 在做无用功。线程可不能一直占用 CPU 自旋做无用功。
-
自旋锁可以
避免用户线程和内核的切换的消耗
。将用户线程和内核的切换的消耗
转变成CPU 做无用功的消耗
。 -
在 JDK 1.5 及之前,自旋时间是固定的,人工输入。
-
在 JDK 1.6,引入了适应性自旋锁,自旋时间不再是固定的,
而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间
。- 同时,如果虚拟机认为这次自旋很有可能成功,那就会将自旋时间延长、或增加自旋次数;如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。
-
在 JDK 1.7 及之后,在 1.6 的基础上,去除自旋锁开启参数,改由 JVM 控制自旋锁的开启和次数。
锁的设计、升级与优化
锁的设计和升级
分段锁
-
分段锁是
一种锁的设计,并不是具体的一种锁
。 -
设计目的是:
将锁的粒度进一步细化
,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。 -
在 Java 语言中 CurrentHashMap 底层就用了分段锁,使用 Segment,就可以进行并发使用了。
锁的升级
-
为了
提升性能
、减少获得锁和释放锁所带来的消耗
,JDK 1.6 引入了 4 种锁的状态。 -
无锁
、偏向锁
、轻量级锁
和重量级锁
,它会随着多线程的竞争情况越来越激烈逐渐升级。 -
锁状态升级方向:
无锁
->偏向锁
->轻量级锁
->重量级锁
。 -
锁状态升级是单向的,不会出现降级。
-
synchronized 关键字内部实现原理就是锁升级的过程
。
无锁状态
- 无锁状态其实就是乐观锁,认为线程对数据读写中
读多写少
,遇到并发写的可能性低
,不会上锁,只是在更新的时候会判断一下在此期间别人有没有去更新这个数据
。
重量级锁
-
依赖于操作系统 Mutex Lock 来实现
的锁我们称之为重量级锁
。 -
而操作系统实现线程之间的切换这就
需要从用户态转换到核心态
,这个成本非常高,状态之间的转换需要相对比较长的时间。 -
JDK1.6 以后,
为了减少获得锁和释放锁所带来的性能消耗,提高性能
,引入了轻量级锁
和偏向锁
。 -
注意,
轻量级锁
和偏向锁
不是用来替换重量级锁
的。 -
重量级锁
是互斥锁
。
轻量级锁
-
目的是:
在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗
。 -
适用情况:
线程交替执行同步块的情况
。 -
轻量级锁认为
虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁
。 -
如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
-
轻量级锁
是自旋锁
。
偏向锁
-
目的是:
在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令
。 -
如果在运行过程中,
只有一个线程访问加锁的资源,不存在多线程竞争的情况
,那么线程是不需要重复获取锁的。 -
此时,就会给该线程添加一个偏向锁。
-
偏向锁可以在
只有一个线程执行同步块时进一步提高性能
。 -
偏向锁的实现是
通过控制对象 Mark Word 的标志位来实现
的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致
,如果一致直接进入。 -
适用情况:
锁不仅不存在多线程竞争,而且总是由同一线程多次获得
。 -
一旦出现多线程竞争的情况就必须撤销偏向锁
,故,偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗。
锁的优化
减少锁持有时间
- 只在
有线程安全要求的程序上加锁
,没有线程安全要求的程序不加锁。
减少锁粒度
-
将大对象(这个对象可能会被很多线程访问),拆成小对象
,大大增加并行度,降低锁竞争。 -
降低了锁的竞争,偏向锁,轻量级锁成功率才会提高
。 -
最最典型的减小锁粒度的案例就是:ConcurrentHashMap。
锁分离
-
根据功能进行分离成
读锁和写锁
,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能。 -
相应的,读写分离思想可以延伸,
只要操作互不影响,锁就可以分离
。- 比如:LinkedBlockingQueue 从头部取出,从尾部放数据。
-
最常见的锁分离就是:读写锁(ReadWriteLock)。
锁粗化
-
锁粗化就是:
将多个同步块的数量减少,并将单个同步块的作用范围扩大
。 -
本质上就是
将多次上锁、解锁的请求合并为一次同步请求
。 -
通常情况下,
为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁
。 -
但是,
如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化
。 -
在这种情况下,锁粗化 就是很好的选择。
锁消除
-
锁消除是指:
虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除
。 -
锁消除是编译器级别的事情
。 -
在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。
-
同样的,一些没有竞争的线程就不需要添加锁了。
总结
-
在第一部分中,锁的分类关系
不是 非此即彼、二者必其一
。 -
只是从
看待共享数据写冲突问题的态度
、锁能否被多个线程持有
、线程获取锁的是否公平
等等来看待这个锁。 -
如:
synchronized
是悲观锁
、独占锁
、非公平锁
、可重用锁
。 -
如:
ReentrantLock
是悲观锁
、独占锁
、互斥锁
、公平锁
、非公平锁
、可重用锁
。 -
如:
ReadWriteLock
是共享锁
、读写锁
。