线程二(锁)
java中,一把锁可能同时占有多个标准,符合多种分类
1. 偏向锁/轻量级锁/重量级锁
这三种锁特指synchronized锁的状态,通过在对象头中的mark word表明锁的状态
偏向锁
它的思想是如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只要打个标记就行了
一个对象在被初始化后,如果还没有任何线程来获取它的锁时,它就是可偏向的,当有第一个线程来访问它尝试获取锁的时候,它就记录下来这个线程,如果后面尝试获取锁的线程正是这个偏向锁的拥有者,就可以直接获取锁,开销很小。
轻量级锁(CAS)
JVM 的开发者发现在很多情况下,synchronized 中的代码块是被多个线程交替执行的,也就是说,并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决。
这种情况下,重量级锁是没必要的。轻量级锁指当锁原来是偏向锁的时候,被另一个线程所访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的方式尝试获取锁,不会阻塞。
重量级锁(阻塞)
这种锁利用操作系统的同步机制实现,所以开销比较大。当多个线程直接有实际竞争,并且锁竞争时间比较长的时候,
此时偏向锁和轻量级锁都不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态
是否存在竞争
竞争时间断(任务执行时间短),少数竞争
多个线程竞争,时间长
2. 悲观锁,乐观锁
- 悲观锁:
它认为如果不锁住这个资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失。 - 乐观锁:
乐观锁比较乐观,认为自己在操作资源的时候不会有其他线程来干扰,所以并不会锁住被操作对象,不会不让别的线程来接触它,同时,为了确保数据正确性,在更新之前,会去对比在我修改数据期间,数据有没有被其他线程修改过:如果没被修改过,就说明真的只有我自己在操作,那我就可以正常的修改数据;如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内修改过数据,那说明我迟了一步,所以我会放弃这次修改,并选择报错、重试等策略。。
乐观锁的实现一般都是利用 CAS 算法实现的
(1)案例:
a. 悲观锁:synchronized 关键字和 Lock 接口
lock() 等方法就是执行加锁,而 unlock() 方法是执行解锁。处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁这就是非常典型的悲观锁思想。
b. 乐观锁:原子类(CAS)
乐观锁的典型案例就是原子类,例如 AtomicInteger 在更新数据时,就使用了乐观锁的思想,多个线程可以同时操作同一个原子变量。
c. 数据库
同时有悲观锁,乐观锁思想。
悲观锁 :MySQL 选择 select for update 语句,在提交之前不允许第三方来修改该数据
乐观锁:我们可以利用一个版本 version 字段在数据库中实现乐观锁。
在获取及修改数据时都不需要加锁,但是我们在获取完数据并计算完毕,准备更新数据时,会检查版本号和获取数据时的版本号是否一致,如果一致就直接更新,如果不一致,说明计算期间已经有其他线程修改过这个数据了,那我就可以选择重新获取数据,重新计算,然后再次尝试更新数据。
(2)使用场景
- 悲观锁:适合用于`并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗。
- 乐观锁:适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。
3. synchronized背后的’monitor锁’
每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,
线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
(1)理解:
synchronized 修饰方法的代码:
public synchronized void method(){
System.out.println("李");
}
下面这种等价形式的伪代码:
public void method(){
this.intrinsicLock.lock();
try {
System.out.println("李");
} finally {
this.intrinsicLock.unlock();
}
}
进入方法后,立即添加内置锁,并且用try代码块把方法保护起来,最后finally释放这把锁,
这里的intrinsicLock
就是monitor锁
(2)syn 方法和 syn 代码块的细节是不一样
cd 找到路径
javac 编译
javap -verbose 得到反汇编内容
a. 同步代码块的实现
public void synBlock(){
synchronized (this){
System.out.println("李");
}
}
对应的反汇编内容:关键信息:
public void synBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #4 // String 李
9: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令,第3、13、19行指令分别对应的是 monitorenter 和 monitorexit
里有一个 monitorenter,却有两个 monitorexit 指令的原因是
,JVM 要保证每个 monitorenter 必须有与之对应的 monitorexit,monitorenter 指令被插入到同步代码块的开始位置,而 monitorexit 需要插入到方法正常结束处和异常处两个地方,这样就可以保证抛异常的情况下也能释放锁
monitorenter理解 :加锁
,线程获取所有权:
- monitor 计数0,获取monitor ,monitor 计数变1
- 已经拥有了这个monitor ,重新进入,累加计数
- 其他线程拥有monitor ,阻塞,等待monitor 为0,在其尝试
monitorexit 理解 :释放锁
,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0,
作用:减一,知道减为0为止
b. 同步方法
这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。
public synchronized void synMethod(){
System.out.println("李");
}
public synchronized void synMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String 李
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 13: 0
line 14: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/lang/demosuo/monitor/Demo2;
}
被 synchronized 修饰的方法会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。
4. synchronized 和 Lock 孰优孰劣,如何选择?
(1)相同点
- 保护资源线程安全
- 保证可见性(
happens-before
针对 synchronized 的一个原则) - synchronized 和 ReentrantLock 都拥有可重入的特点。
(2)不同点
锁加在谁身上,用法区别
synchronized 可以自定义monitor锁对象。(加解锁是隐式的)
Lock接口必须用Lock锁对象来加锁和解锁。(显式的加锁,解锁,抛异常时也能保证释放锁)加解锁顺序不同:
对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁:
lock1.lock();
lock2.lock();
...
lock1.unlock();
lock2.unlock();
但是 synchronized 无法做到,synchronized 解锁的顺序和加锁的顺序必须完全相反
synchronized 锁不够灵活
synchronized 锁在被占有后,只能阻塞。(如果持有锁的线程很有才释放锁,等待时间长)
Lock 类等锁的过程中,通过提供非阻塞尝试来获取锁( tryLock() ),尝试获取可被中断的锁( lockInterruptibly()) ,以及尝试获取可以超时( tryLock(long, TimeUnit) )。synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制
例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized 做不到。原理区别
synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。
Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的。是否可以设置公平/非公平
ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置。性能区别
在 Java 5 以及之前,synchronized 的性能比较低,但是到了 Java 6 以后,发生了变化,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。
- 可重入性:从名字上理解,ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程没进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
- 锁的实现:Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。
- 性能的区别:在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。
- 功能区别 :便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
- 锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized
ReenTrantLock独有能力 :
- ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
- ReenTrantLock提供了一个 Condition(条件) 类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
- ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
- ReenTrantLock实现原理:ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
- 什么情况下使用ReenTrantLock :当需要实现ReenTrantLock的三个独有功能时。
(3)如何选择
在 Java 并发编程实战和 Java 核心技术里都认为:
- 如果能不用最好既不使用 Lock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
- 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全。
- 如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock。
5. Lock 有哪几个常用方法?分别有什么用?
(1). lock(),不能中断
Lock lock = ...;
lock.lock();
try{
//获取到了被本锁保护的资源,处理任务
//捕获异常
}finally{
lock.unlock(); //释放锁
}
(2). tryLock()
因为该方法会立即返回,即便在拿不到锁时也不会一直等待,所以通常情况下,我们用 if 语句判断 tryLock() 的返回结果,根据是否获取到锁来执行不同的业务逻辑,典型使用方法如下。
Lock lock = ...;
if(lock.tryLock()) {
try{
//处理任务
}finally{
lock.unlock(); //释放锁
}
}else {
//如果不能获取锁,则做其他事情
}
不产生死锁:
public void tryLock(Lock lock1, Lock lock2) throws InterruptedException {
while (true) {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
System.out.println("获取到了两把锁,完成业务逻辑");
return;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
} else {
Thread.sleep(new Random().nextInt(1000));
}
}
}
如果代码中**我们不用 tryLock() 方法,那么便可能会产生死锁,**比如有两个线程同时调用这个方法,传入的 lock1 和 lock2 恰好是相反的,那么如果第一个线程获取了 lock1 的同时,第二个线程获取了 lock2,它们接下来便会尝试获取对方持有的那把锁,但是又获取不到,于是便会陷入死锁,
(3). tryLock(long time, TimeUnit unit) 可以响应中断
这个方法解决了 lock() 方法容易发生死锁的问题
tryLock(long time, TimeUnit unit) 方法会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false;如果一开始就获取锁或者等待期间内获取到锁,则返回 true。
这个方法解决了 lock() 方法容易发生死锁的问题,
(4). lockInterruptibly() ,可以响应中断
我们可以把这个方法理解为超时时间是无穷长的 tryLock(long time, TimeUnit unit),因为 tryLock(long time, TimeUnit unit) 和 lockInterruptibly() 都能响应中断,只不过 lockInterruptibly() 永远不会超时。
public void lockInterruptibly() {
try {
lock.lockInterruptibly();
try {
System.out.println("操作资源");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
6. 公平锁和非公平锁,为什么要“非公平”?
(1)定义
- 公平锁指的是按照线程请求的顺序,来分配锁;
- 而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。但需要注意这里的非公平并不是指完全的随机,不是说线程可以任意插队,而是仅仅“在合适的时机”插队。
那么什么时候是合适的时机呢?假设当前线程在请求获取锁的时候,恰巧前一个持有锁的线程释放了这把锁,那么当前申请锁的线程就可以不顾已经等待的线程而选择立刻插队。但是如果当前线程请求的时候,前一个线程并没有在那一时刻释放锁,那么当前线程还是一样会进入等待队列。
假设线程 A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态,那么当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这把锁,那么根据非公平的策略,会把这把锁给线程 C,这是因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完任务释放了这把锁。相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,如果在锁代码中执行的内容不多的话,线程 C 就可以很快完成任务,并且在线程 B 被完全唤醒之前,就把这个锁交出去,这样是一个双赢的局面,对于线程 C 而言,不需要等待提高了它的效率,而对于线程 B 而言,它获得锁的时间并没有推迟,因为等它被唤醒的时候,线程 C 早就释放锁了,因为线程 C 的执行速度相比于线程 B 的唤醒速度,是很快的,所以 Java 设计者设计非公平锁,是为了提高整体的运行效率。
默认非公平
public ReentrantLock() {
sync = new NonfairSync();
}
可以初始化设定
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
(2)对比
(3)源码分析
- ReentrantLock
public class ReentrantLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = 7373984872572414699L;
/** Synchronizer providing all implementation mechanics */
private final Sync sync;
- 内部类:
Sync 有公平锁 FairSync 和非公平锁 NonfairSync两个子类:
abstract static class Sync extends AbstractQueuedSynchronizer {}
NonfairSync
static final class NonfairSync extends Sync { }
FairSync
static final class FairSync extends Sync { }
公平锁的锁获取源码如下:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
非公平锁的锁获取源码如下:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
通过对比,公平锁与非公平锁的 lock() 方法唯一的区别:公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors() 为 false,
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires))
这个方法就是判断在等待队列中是否已经有线程在排队了。这也就是公平锁和非公平锁的核心区别,
- 如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;
- 对于非公平锁而言,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。
但是对于tryLock(),不遵守设定的公平原则
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
// 表明了是不公平的,和锁本身是否是公平锁无关。
公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,所以存在后申请却先获得锁的情况,但由此也提高了整体的效率。
7. 读写锁 ReadWriteLock 获取锁有哪些规则?
(1)规则
排它锁----共享锁,
要么是一个或多个线程同时有读锁,要么是一个线程有写锁,
但是两者不会同时出现。也可以总结为:读读共享、其他都互斥
(写写互斥、读写互斥、写读互斥)。
(2)使用
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
readWriteLock.writeLock().lock();
readWriteLock.writeLock().unlock();
readWriteLock.readLock().lock();
readWriteLock.readLock().unlock();
// 返回的是内部类对象属性
源码:
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
// 对应的writerLock,readerLock
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
(3)场景
ReadWriteLock 适用于读多写少的情况,
合理使用可以进一步提高并发效率。(更加细粒度的控制
)
8. 读锁应该插队吗?什么是读写锁的升降级?
(1)插队
ReentrantLock,如果锁被设置为非公平,那么它是可以在前面线程释放锁的瞬间进行插队的,而不需要进行排队。在读写锁这里,策略也是这样的吗?
ReentrantReadWriteLock 可以设置为公平或者非公平:
/** 执行所有同步机制 */
final Sync sync;
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。
- 公平锁对于这两个方法的实现:
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
队列中有等待,就入队列
- 非公平锁的实现
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
// 写入,直接false, 入队列
final boolean writerShouldBlock() {
return false; // writers can always barge
}
// 读取,判断队首是写入?
final boolean readerShouldBlock() {
// 判断队首 是否 是写入
return apparentlyFirstQueuedIsExclusive();
}
}
所以我们可以看出,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的
,目的是避免“饥饿”。
如果允许读锁插队,那么由于读锁可以同时被多个线程持有,所以可能造成源源不断的后面的线程一直插队成功,导致读锁一直不能完全释放,从而导致写锁一直等待,为了防止“饥饿”,在等待队列的头结点是尝试获取写锁的线程的时候,
不允许读锁插队
。
写锁可以随时插队
,因为写锁并不容易插队成功,写锁只有在当前没有任何其他线程持有读锁和写锁的时候,才能插队成功,同时写锁一旦插队失败就会进入等待队列,所以很难造成“饥饿”的情况,允许写锁插队是为了提高效率。
(2)读写锁的降级(ReentrantReadWriteLock不支持升级)
模拟的是读取缓存的过程,当有效时直接读取,无效时先更新在读取
public class CachedData {
Object data; // 模拟更新缓存的新缓存
volatile boolean cacheValid; // 代表缓存的有效性,true:有效; false:无效
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock(); // 先用读锁判断是否有效
if (!cacheValid) {
//在获取写锁之前,必须首先释放读锁。
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
//这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。(情况比较少,必须有)
if (!cacheValid) {
data = new Object(); // 更新缓存
cacheValid = true; // 改变标志位
}
//在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
rwl.readLock().lock();
} finally {
//释放了写锁,但是依然持有读锁
rwl.writeLock().unlock();
}
}
try {
System.out.println(data); // 读取缓存
} finally {
//释放读锁
rwl.readLock().unlock();
}
}
}
如果只用写锁:效率低下(无论缓存是否失效,先独占资源)
public class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public void processCachedData(){
rwl.writeLock().lock(); // 加写锁
if (!cacheValid){
// 缓存失效,重新写入
data =new Object();
cacheValid = true;
}
try {
System.out.println(data);
}finally {
// 释放写锁
rwl.writeLock().unlock();
}
}
}
ReentrantReadWriteLock升级可能会造成死锁
假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。
但是读写锁的升级
并不是不可能
的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。
9. 什么是自旋锁?自旋的好处和后果是什么呢?
(1)自旋
自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞。
(2)流程比较
非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。
(3)好处
自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。
(4)AtomicLong 的实现:自旋
(5)自实现:可重入自旋
public class ReentrantSpinLock {
// 里面存Thread,初始为null
private AtomicReference<Thread> reference = new AtomicReference<>();
private int count = 0; // 重入次数
public void lock(){
Thread t = Thread.currentThread();
if (t == reference.get()){
++count;
return;
}
while (!reference.compareAndSet(null,t)) {
System.out.println(Thread.currentThread().getName()+"处于自旋");
}
}
public void unlock(){
Thread t = Thread.currentThread();
// 只有持有所得线程才可以解锁
if (t == reference.get()){
if (count > 0){
--count;
}else {
// 此处不需要自旋,因为没有竞争(只有自己持有)
reference.set(null);
}
}
}
public static void main(String[] args) {
ReentrantSpinLock spinLock = new ReentrantSpinLock();
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName()+"开始尝试获取自旋锁");
spinLock.lock();
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"获取到自旋锁");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
spinLock.unlock();
spinLock.unlock();
System.out.println(Thread.currentThread().getName()+"释放自旋锁");
}
};
Thread a = new Thread(runnable, "A");
Thread b = new Thread(runnable, "B");
a.start();
b.start();
}
}
A开始尝试获取自旋锁
B开始尝试获取自旋锁
A获取到自旋锁
B处于自旋
B处于自旋
......
B处于自旋
A释放自旋锁
B获取到自旋锁
B释放自旋锁
(6)缺点:
它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,
虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失
。
(7)适用场景
- 自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。
- 如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。
10. JVM 对锁的优化
相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。有了这些优化措施后,synchronized 锁的性能得到了大幅提高,
(1)自适应的自旋锁
- 之前的自旋是判断后,立即再次判断
- 在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题,会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
- 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
(2)锁消除
经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
(3)锁粗化
public void lockCoarsening() {
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
}
把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁
这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。
for (int i = 0; i < 1000; i++) {
synchronized (this) {
//do something
}
}
锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。
(4)偏向锁/轻量级锁/重量级锁
这三种锁是特指 synchronized 锁的状态的,通过在对象头中的 mark word 来表明锁的状态。
从无锁到偏向锁,再到轻量级锁,最后到重量级锁。
1. 偏向锁性能最好,避免了 CAS 操作。
2. 而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。
3. 重量级锁则会把获取不到锁的线程阻塞,性能最差。
JVM 默认会优先使用偏向锁,如果有必要的话才逐步升级,这大幅提高了锁的性能。